跨端: 修三个真崩溃/失败 —— @BuilderParam 丢 this、发送后退错页、漏校验 body
用户 2026-09-21:「点击发送邮件直接闪退,点击授权也直接闪退,所有功能全部不可用」。
三个都是**真 bug**,逐个拿到证据后修的(不是猜的)。
## ① 点「授权」必崩:`@BuilderParam` 把 `this` 换掉了
崩溃日志(`jscrash-…-20260921150832132.log`)给出的栈:
Reason: TypeError
Error message: Cannot read property length of undefined
at anonymous entry (MainPage.ets:1458:23) ← this.requests.length
at … Surface.ets:717:7 ← AppHeader 里 this.trailing()
`MainPage.ets:1458` 是 `if (this.requests.length > 0)`,
而它住在 `PendingTrailing()` 这个 `@Builder` 里 —— **传给 `AppHeader` 的
`@BuilderParam` 之后,它执行时的 `this` 变成了 `AppHeader`**,
而 `AppHeader` 上当然没有 `requests` ⇒ `undefined.length` ⇒ 崩。
★ 这是 ArkUI 的老坑:`@BuilderParam` 是**按值传递一个函数**,
调用方的 `this` 不会跟着过去。全仓**4 处**都踩了(`MainPage` 的
`SentCountTrailing`/`PendingTrailing`、`AdminUsersPage`/`SettingsPage`
的 `HeaderTrailing`)—— 它们各自读 `this.loaded`/`this.load()`。
修法:改成**尾随闭包**(`AppHeader({...}) { this.XxxTrailing() }`),
闭包捕获的是**定义处**的 `this`(本组件的),而不是 AppHeader 的。
★ 为什么判据没抓到:那 4 处此前都只是"静态源码里有这个 builder",
而崩溃只发生在**运行时的 `this` 绑定**上 —— 形态判据看不见绑定。
这一条只能靠设备实测(我这次是靠真机崩溃日志)。
## ② 发送成功后"闪退":其实是退错了页
`ComposePage.doSend()` 成功分支里是**无条件** `router.back()`。
而内嵌时(宽屏右栏 / 窄屏 `Navigation` 覆盖)写信只是 `MainPage` 的一个
**右栏状态** —— `router.back()` 退掉的是**整个 MainPage**,用户看到的就是
"发送之后 App 没了"(报成闪退)。
★ 同一个文件里,顶栏「取消」键(上面几十行)**早就写对了**:
if (this.embedded) { this.onBack(); return; }
this.getUIContext().getRouter().back();
我加 `doSend` 时没照着抄。`MailDetailView.goBack()` 也是这个正确形状 ——
**只有 `doSend` 是那个异类**。已改成与取消键同一判据。
## ③ 发送真的失败:校验漏了 `body`,且没 trim
日志里 `→ POST …/mail/send` 发出去了,但服务端 400。
直接打服务端复现:
curl -d '{"to":"pi@root.new","subject":"t","body":""}'
→ {"error":"Missing to, subject, or body"}
而 WebUI 的 `canSend`(`ComposePage.tsx:132-138`)是**四个条件**:
to.trim() !== '' && subject.trim() !== '' && body.trim() !== '' && …
鸿蒙这边只校验了 `to` 与 `subject` —— **漏了 `body`**。
⇒ 用户在"正文本来就是可选的"观感下不填正文,请求照样发出去、被拒。
同时补 `trim()`:WebUI 发的是 `to.trim()` / `subject.trim()`,
而 `pi@root.new ` 与 `pi@root.new` 在服务端是**两条不同地址**。
## 设备验证(改前 → 改后)
· 点「授权」:崩(进程消失,新增 jscrash) → **进程存活,页面正常渲染,
待决策徽标 "4" 正确显示**(证明 `this.requests` 绑定对了)
· 发送邮件:POST 发出但服务端 400,且"闪退" → **回到收件箱,
发件箱里 `realtest` 已落库**(服务端实测 9 封)
## 另修:候选补全的菜单按 WebUI 补齐四件
用户:「收件人填充能力完全不可用,根本没有与 webui 对齐」。
实测后确认功能是通的(`pi` → `pi@` → 路径 → 会话 → 完整地址,
三段链逐段验过),但**行内渲染漏了 WebUI 的四个要素**(`AddressInput.tsx:186-231`):
① 别名 `font-mono`(地址类文本全仓等宽)
② 标题在**第二行**(原来挤在右边同一行)
③ `source` 三态视觉:`platform` 蓝胶囊 / `new` 灰字 / `mail` 无标
④ `unread > 0` 红徽标(服务端 `SessionCandidate.Unread`,带 `omitempty`)
`AddressSuggestion` 顺带补 `unread` 字段并守住 `omitempty`
(缺键时裸 cast 是 `undefined`,不是类里的 `= 0` —— 与 `title` 同一个坑)。
★ 另外修掉一个我自己写错的参数:`suggestAddress` 原来把 `'?name=…'` 传给
`ApiClient.get(path, query)`,而**问号是那个方法自己加的**
⇒ 会拼成 `??name=`。约定:`query` 只放 `k=v`,不含问号。
This commit is contained in:
@ -97,6 +97,15 @@ export struct ComposeView {
|
||||
*/
|
||||
@State suggestItems: string[] = [];
|
||||
@State suggestTitles: string[] = [];
|
||||
/*
|
||||
* 候选的 `source`(`mail` | `platform` | `new`)与未读数。
|
||||
*
|
||||
* ★ 对齐 WebUI `AddressInput.tsx:200-228`:这两者各有视觉
|
||||
* (`platform` 蓝胶囊 / `new` 灰字 / `unread>0` 红徽标)。
|
||||
* 服务端 `SessionCandidate` 里 `unread` 带 `omitempty` ⇒ 缺键要守。
|
||||
*/
|
||||
@State suggestSources: string[] = [];
|
||||
@State suggestUnreads: number[] = [];
|
||||
@State suggestActive: number = 0;
|
||||
@State suggestOpen: boolean = false;
|
||||
/** 输入防抖(WebUI 是 120ms;打字每字符都打接口会打断输入) */
|
||||
@ -141,22 +150,40 @@ export struct ComposeView {
|
||||
* (**不是** 类里那个 `= ''`)。所以用 `typeof` 守一道再取。
|
||||
*/
|
||||
const titles: string[] = [];
|
||||
const sources: string[] = [];
|
||||
const unreads: number[] = [];
|
||||
for (let i = 0; i < cands.length; i++) {
|
||||
const c = cands[i];
|
||||
const raw: string | undefined = c === undefined ? undefined : c.title;
|
||||
titles.push(typeof raw === 'string' ? raw : '');
|
||||
/*
|
||||
* `source` 与 `unread` 同样带 `omitempty`
|
||||
* (`platform_sessions.go:97/101`)⇒ 缺键时裸 cast 是 `undefined`。
|
||||
* `Unread` 缺键时给 `undefined`,直接比较 `> 0` 在 ArkTS 里类型不过 ⇒ 守一道。
|
||||
*/
|
||||
const src: string | undefined = c === undefined ? undefined : c.source;
|
||||
sources.push(typeof src === 'string' ? src : '');
|
||||
const un: number | undefined = c === undefined ? undefined : c.unread;
|
||||
unreads.push(typeof un === 'number' ? un : 0);
|
||||
}
|
||||
/* 片段:没写 @ 时用 name、写了 @ 用 path、写了 . 用 session(与 queryFor 同层) */
|
||||
const frag: string = parts.hasDot ? parts.session : (parts.hasAt ? parts.path : parts.name);
|
||||
const keep: number[] = filterIndexes(all, titles, frag);
|
||||
const items: string[] = [];
|
||||
const keepTitles: string[] = [];
|
||||
const keepSources: string[] = [];
|
||||
const keepUnreads: number[] = [];
|
||||
for (let i = 0; i < keep.length; i++) {
|
||||
items.push(all[keep[i]]);
|
||||
keepTitles.push(titles[keep[i]] ?? '');
|
||||
const k: number = keep[i];
|
||||
items.push(all[k]);
|
||||
keepTitles.push(titles[k] ?? '');
|
||||
keepSources.push(sources[k] ?? '');
|
||||
keepUnreads.push(unreads[k] ?? 0);
|
||||
}
|
||||
this.suggestItems = items;
|
||||
this.suggestTitles = keepTitles;
|
||||
this.suggestSources = keepSources;
|
||||
this.suggestUnreads = keepUnreads;
|
||||
this.suggestActive = 0;
|
||||
this.suggestOpen = items.length > 0;
|
||||
} catch {
|
||||
@ -333,14 +360,45 @@ export struct ComposeView {
|
||||
if (this.sending) {
|
||||
return;
|
||||
}
|
||||
if (this.to.length === 0) {
|
||||
/*
|
||||
* ★★ 2026-09-21 修(用户:「点击发送邮件直接闪退」)。
|
||||
*
|
||||
* 真正的失败链有**两段**,两段都得修:
|
||||
*
|
||||
* ── ① 校验漏了一项,把服务端会拒的请求发出去了 ──
|
||||
*
|
||||
* WebUI 的 `canSend`(`ComposePage.tsx:132-138`)是**四个条件**:
|
||||
* roundsError === null && to.trim() !== '' && subject.trim() !== '' &&
|
||||
* body.trim() !== '' && aliasError === null && !sending
|
||||
*
|
||||
* 我这里只有 `to` 与 `subject` 两项 —— **漏了 `body`**。
|
||||
* 而服务端 `mail.go` 要求正文非空,实测:
|
||||
* curl -d '{"to":"pi@root.new","subject":"t","body":""}'
|
||||
* → {"error":"Missing to, subject, or body"}
|
||||
*
|
||||
* ⇒ 用户填了收件人和主题、**没填正文**(正文本来就是可选的观感)时,
|
||||
* 请求真的发出去了、服务端 400、弹出"发送失败"。
|
||||
*
|
||||
* ── ② 更糟的是"失败之后退到哪"──
|
||||
*
|
||||
* 那段在下面 `doSend` 的成功分支里(原来是**无条件** `router.back()`,
|
||||
* 而内嵌时那会退掉整个 MainPage)——已一并修。
|
||||
*
|
||||
* ★ 为什么两段要一起修:只修 ①,用户永远看不到 ②;
|
||||
* 只修 ②,"发送失败"仍会在最正常的输入下出现。它们是一条链上的两环。
|
||||
*/
|
||||
if (this.to.trim().length === 0) {
|
||||
this.getUIContext().getPromptAction().showToast({ message: '请填写收件人' });
|
||||
return;
|
||||
}
|
||||
if (this.subject.length === 0) {
|
||||
if (this.subject.trim().length === 0) {
|
||||
this.getUIContext().getPromptAction().showToast({ message: '请填写主题' });
|
||||
return;
|
||||
}
|
||||
if (this.body.trim().length === 0) {
|
||||
this.getUIContext().getPromptAction().showToast({ message: '请填写正文' });
|
||||
return;
|
||||
}
|
||||
const ctx: Context | undefined = this.getUIContext().getHostContext();
|
||||
if (ctx === undefined) {
|
||||
return;
|
||||
@ -354,8 +412,14 @@ export struct ComposeView {
|
||||
this.sending = true;
|
||||
try {
|
||||
const request: SendMailRequest = new SendMailRequest();
|
||||
request.to = this.to;
|
||||
request.subject = this.subject;
|
||||
/*
|
||||
* 逐个 `trim()` —— 对齐 WebUI `ComposePage.tsx:144`:
|
||||
* api.sendMail(to.trim(), subject.trim(), body, …)
|
||||
* 用户常手滑留一个尾空格,而不 trim 时那个地址会**服务端判存在性失败**
|
||||
* (`pi@root.new ` 与 `pi@root.new` 是两条不同地址)。
|
||||
*/
|
||||
request.to = this.to.trim();
|
||||
request.subject = this.subject.trim();
|
||||
request.body = this.body;
|
||||
if (this.replyTo.length > 0) {
|
||||
request.reply_to = this.replyTo;
|
||||
@ -368,7 +432,26 @@ export struct ComposeView {
|
||||
}
|
||||
await mailApi.send(request);
|
||||
this.getUIContext().getPromptAction().showToast({ message: '邮件已发送' });
|
||||
this.getUIContext().getRouter().back();
|
||||
/*
|
||||
* ★★ 2026-09-21 修:这里原来是**无条件** `router.back()`。
|
||||
*
|
||||
* 而内嵌(宽屏右栏 / 窄屏 `Navigation` 覆盖)时我们只是 `MainPage` 的一个
|
||||
* 右栏状态 —— `router.back()` 退掉的是**整个 MainPage**,不是写信这一层。
|
||||
*
|
||||
* 同一个文件里,顶栏那个「取消」键(上面几十行)**早就写对了**:
|
||||
* if (this.embedded) { this.onBack(); return; }
|
||||
* this.getUIContext().getRouter().back();
|
||||
* 我加 `doSend` 时没照着抄,于是**发送成功之后退到了登录页/空白**
|
||||
* (用户报的「点击发送邮件直接闪退」——看着像闪退,其实是退页)。
|
||||
*
|
||||
* ⇒ 改成与取消键**同一个判据**(`embedded` → 弹自己的栈)。
|
||||
* 两处必须一致,否则"取消"和"发送成功"会走两条不同的退路。
|
||||
*/
|
||||
if (this.embedded) {
|
||||
this.onBack();
|
||||
} else {
|
||||
this.getUIContext().getRouter().back();
|
||||
}
|
||||
} catch (e) {
|
||||
const apiError = e as ApiError;
|
||||
this.getUIContext().getPromptAction().showToast({ message: '发送失败: ' + apiError.message });
|
||||
@ -518,15 +601,69 @@ export struct ComposeView {
|
||||
if (this.suggestOpen && this.suggestItems.length > 0) {
|
||||
Column() {
|
||||
ForEach(this.suggestItems, (item: string, idx: number) => {
|
||||
Row() {
|
||||
Text(item).fontSize(13).fontColor(Theme.textPrimary).layoutWeight(1)
|
||||
if (idx < this.suggestTitles.length && this.suggestTitles[idx].length > 0) {
|
||||
Text(this.suggestTitles[idx])
|
||||
.fontSize(12).fontColor(Theme.textSubtleFor())
|
||||
/*
|
||||
* 一行候选的结构逐条对齐 WebUI `AddressInput.tsx:186-231`:
|
||||
*
|
||||
* <button class="px-2.5 py-1.5 {active ? bg-blue-50 : hover:bg-gray-50}">
|
||||
* <div class="flex items-center gap-1.5">
|
||||
* <span class="text-sm font-mono truncate">{别名}</span>
|
||||
* <div class="flex-1" />
|
||||
* {source==='platform' && <span class="bg-blue-100 text-blue-700">平台</span>}
|
||||
* {source==='new' && <span class="text-gray-400">新建会话</span>}
|
||||
* {unread>0 && <span class="bg-red-600 text-white">{unread}</span>}
|
||||
* </div>
|
||||
* {title && source!=='new' && <p class="text-3xs text-gray-400 mt-0.5">{title}</p>}
|
||||
* </button>
|
||||
*
|
||||
* ★ 四个要点我第一版**全漏了**,逐个补:
|
||||
* ① 别名 `font-mono` —— 它是 `name@path.session` 的一段,
|
||||
* 等宽才看得清(WebUI 全仓地址类文本都是 mono);
|
||||
* ② 标题在**第二行**(`mt-0.5` 单独一段),不是挤在右边同一行;
|
||||
* ③ `source` 三种取值各有视觉:`platform` 蓝胶囊 / `new` 灰字 / 空(mail)无标;
|
||||
* ④ `unread>0` 有红徽标(服务端 `SessionCandidate.Unread`,`omitempty`)。
|
||||
*/
|
||||
Column() {
|
||||
Row() {
|
||||
Text(item)
|
||||
.fontSize(13)
|
||||
/* 等宽:对齐 WebUI 的 `font-mono`(地址类文本全仓都是 mono) */
|
||||
.fontFamily('monospace')
|
||||
.fontColor(idx === this.suggestActive ? Theme.accentFor() : Theme.textPrimary)
|
||||
.layoutWeight(1)
|
||||
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
|
||||
if (idx < this.suggestSources.length && this.suggestSources[idx] === 'platform') {
|
||||
Text('平台')
|
||||
.fontSize(10).fontColor(Theme.accentFor())
|
||||
.padding({ left: 4, right: 4, top: 1, bottom: 1 })
|
||||
.borderRadius(4)
|
||||
.backgroundColor(Theme.accentSoftFor(this.isDarkNow))
|
||||
}
|
||||
if (idx < this.suggestSources.length && this.suggestSources[idx] === 'new') {
|
||||
Text('新建会话').fontSize(10).fontColor(Theme.textSubtleFor())
|
||||
}
|
||||
if (idx < this.suggestUnreads.length && this.suggestUnreads[idx] > 0) {
|
||||
Text(this.suggestUnreads[idx].toString())
|
||||
.fontSize(10).fontColor(Theme.accentFg)
|
||||
.padding({ left: 4, right: 4, top: 1, bottom: 1 })
|
||||
.borderRadius(4)
|
||||
.backgroundColor(Theme.danger)
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
/* 标题第二行 —— `source==='new'` 不显示(它没有真标题) */
|
||||
if (idx < this.suggestTitles.length && this.suggestTitles[idx].length > 0
|
||||
&& !(idx < this.suggestSources.length && this.suggestSources[idx] === 'new')) {
|
||||
Text(this.suggestTitles[idx])
|
||||
.fontSize(11).fontColor(Theme.textSubtleFor())
|
||||
.width('100%')
|
||||
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
.margin({ top: 2 })
|
||||
}
|
||||
}
|
||||
.width('100%').height(40).padding({ left: 12, right: 12 })
|
||||
.width('100%')
|
||||
.padding({ left: 10, right: 10, top: 7, bottom: 7 })
|
||||
.backgroundColor(idx === this.suggestActive ? Theme.accentSoftFor(this.isDarkNow) : Color.Transparent)
|
||||
.onClick(() => { this.applySuggestion(item); })
|
||||
}, (item: string, idx: number) => item + '#' + idx.toString())
|
||||
@ -603,7 +740,33 @@ export struct ComposeView {
|
||||
.backgroundColor(Theme.surface)
|
||||
}
|
||||
.width('100%').height('100%')
|
||||
.backgroundColor(Theme.pageBg)
|
||||
/*
|
||||
* ★★ 2026-09-21 修(用户:「写邮件页面和其他多个页面圆角下方还是有白框(直角框)」)。
|
||||
*
|
||||
* 这里原来是 `backgroundColor(Theme.pageBg)` —— 一个**不透明**的页面底。
|
||||
* 而 `ComposeView` 是**装在圆角窗格内部**的(`ComposeDestination` 的
|
||||
* `NavDestination` / `MainPage` 内容列),实测它的边界
|
||||
* `[30,142][978,1955]` 比外壳 `[28,140][980,1957]` **四周各小 2px**
|
||||
* ⇒ 那块不透明的白在四个角上从外壳的圆角里**露出来**,形成直角白边。
|
||||
*
|
||||
* 像素实测(修前):y=1946 时圆角已收窄到 x=45,而 x=30..39 仍是纯白;
|
||||
* y=1952 时 x=30..48 仍是纯白 —— 就是那圈"白框"。
|
||||
*
|
||||
* ── 为什么改成透明而不是把圆角加到这一层 ──
|
||||
*
|
||||
* 对齐 WebUI:它的 `.app-shell > *`(对应我们的**外壳**)才有
|
||||
* `border-radius`,面板内部(`ComposePage` 自己)是 `bg-white` 的**直角块**——
|
||||
* 因为在 WebUI 里那个圆角是**靠 `overflow`/`background-clip` 裁到子节点上的**。
|
||||
* 我们这层的 `borderRadius` 同样**不裁 `backgroundColor`**,
|
||||
* 给这层再加一个圆角只会多一道弧、白边照样在。
|
||||
*
|
||||
* 正确做法是让**外壳那层玻璃**显出来:这一层不铺底
|
||||
* (它不是页面的"背景",只是窗格里的内容)。
|
||||
*
|
||||
* ★ `embedded` 时尤其必须透明:那时我们**就是**右栏面板本身,
|
||||
* 再铺一层不透明底等于把外壳的玻璃盖掉(用户报的「玻璃不透明」同源)。
|
||||
*/
|
||||
.backgroundColor(this.embedded ? Color.Transparent : Theme.pageBg)
|
||||
/*
|
||||
* 整页入场(对齐 WebUI 写信页的 `rise-in`:4vp 上浮 + 淡入)。
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user