跨端: 修三个真崩溃/失败 —— @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:
2026-09-21 15:21:47 +08:00
parent 2fe023735e
commit 20fc8a5800
6 changed files with 301 additions and 28 deletions

View File

@ -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 上浮 + 淡入)。
*