diff --git a/client/electron/test/harmony-admin.test.mjs b/client/electron/test/harmony-admin.test.mjs index 82a3dde..1c28af4 100644 --- a/client/electron/test/harmony-admin.test.mjs +++ b/client/electron/test/harmony-admin.test.mjs @@ -555,7 +555,14 @@ test('★ 设备:管理页能从「我的」页打开,且列表真的渲染 return t.skip('设备不在 —— 本条的设备半边本次不跑(上面静态层仍把住逻辑与接线)'); } assert.ok(await D.launchOurApp(hdc), '要能拉起应用并等到它到前台'); - await new Promise((r) => setTimeout(r, 2000)); + /* + * ★★ 先回主界面:前面跑过的设备判据可能把前台留在 **push 出去的页面**上 + * (详情页 / 管理页 / 写信页 —— 它们没有侧栏)。实测:设备停在邮件详情页时 + * 本判据跑了 201 秒才失败,报"要能进「我的」页"—— 而那是**判据间干扰**, + * 不是功能坏了(`backToMain` 的注释里记着同一个根因)。 + */ + assert.ok(await D.backToMain(hdc), '要能回到主界面(侧栏/底栏可见)'); + await new Promise((r) => setTimeout(r, 1500)); const dims = () => { let w = 0; @@ -725,3 +732,97 @@ test('★ 设备:管理页能从「我的」页打开,且列表真的渲染 '★ 管理页要真的渲染出**用户行**(带角色/状态的那种)—— ' + `只有标题而没有行,说明数据没渲染。实际读到:${texts.slice(0, 25).join(' | ')}`); }); + +test('★ 详情页的两个动作球必须**分开摆**(Stack 的同一个角 + 各自 margin = 重叠)', () => { + /* + * ★★ 2026-09-19 真 bug,设备实测撞出来的: + * + * 详情页右下角有两个动作球(转发 + 回复)。第一版把它们**各自**写在 + * `Stack({ alignContent: Alignment.BottomEnd })` 里、各带一个 margin —— + * 于是它们**几乎完全重叠**。实测 dump 的 bounds(密度 2.875): + * + * 转发 [2984,2010][3122,2148] + * 回复 [2949,1998][3110,2159] + * 重叠区 x ∈ [2984, 3110] + * + * 屏幕上只看得到一个球,**转发入口等于不存在**。 + * 根因是 ArkUI 的 `Stack.alignContent` 把每个子元素都摆到**同一个角**, + * margin 只是各自微调 —— 想并排就得用**一个容器**把子元素排起来。 + * + * 判据形状:找"同一容器下的多个圆形 Button,各自只带 margin"这种写法。 + * 正确写法是外面包一层 `Row({ space })`。 + */ + const src = code(join(ETS, 'pages/MailDetailPage.ets')); + + /* + * 找 `Stack({ alignContent: ... })` 块里**直接**出现的两个以上圆形按钮 + * (`.borderRadius(N)` 且 N 使它是圆:宽高等于 2N)。 + * 简化判定:在同一个 Stack 块内出现 ≥2 处 `.borderRadius(` + 各自 `.margin(` + * 而没有包在 Row 里。 + */ + const stackBlocks = [...src.matchAll(/Stack\(\{[^}]*alignContent[^}]*\}\)\s*\{([\s\S]*?)\n\s{8}\}/g)]; + const bad = []; + for (const m of stackBlocks) { + const body = m[1]; + const circles = (body.match(/\.borderRadius\(\d+\)/g) ?? []).length; + if (circles >= 2 && !/Row\(\{\s*space:/.test(body)) { + const line = src.slice(0, m.index).split('\n').length; + bad.push(`MailDetailPage.ets:${line}(Stack 里有 ${circles} 个圆角元素且没有 Row 包住)`); + } + } + assert.deepEqual(bad, [], + '★ `Stack.alignContent` 会把**每个**子元素都摆到同一个角 —— ' + + '多个圆形按钮各自带 margin 时会**重叠**(实测:屏幕上只看得到一个球)。\n' + + `命中:\n ${bad.join('\n ')}\n` + + '正确写法:外面包一层 `Row({ space: N })`,由 Row 负责把它们排开、' + + 'Row 自己带 margin 到角落。'); +}); + +test('★ 底部弹层必须有明确高度(否则键盘一弹,按钮全被顶出屏幕)', () => { + /* + * ★★ 2026-09-19 真 bug(与上面那条同一次实测): + * + * 详情页的转发弹层第一版**没有高度**(只有 `padding(16)`)⇒ 高度完全由内容决定。 + * 而 ArkUI 默认的键盘避让是 `KeyboardAvoidMode.OFFSET`(整体上移)—— + * 上移之后,弹层里的 `TextArea` 与「取消 / 转发」按钮**跑到键盘下面**去了。 + * 实测截图:只看得见收件人输入框 + 键盘,「转发」按钮**点不到**。 + * + * 修法两半(缺一不可): + * ① 弹层要有**明确高度**(与回复弹层一致,`height('60%')`)—— + * 内容才能在里面重新分配空间; + * ② 会撑高的那个子元素(说明框)要用 `layoutWeight(1)` 而不是固定高度 —— + * 键盘顶上来时它自己缩短,把按钮留在屏内。 + */ + const src = code(join(ETS, 'pages/MailDetailPage.ets')); + + /* 找所有"底部弹层"(`borderRadius({ topLeft: ... })` 是这类弹层的标志) */ + const sheets = [...src.matchAll(/\.borderRadius\(\{\s*topLeft:\s*\d+/g)]; + assert.ok(sheets.length >= 2, + `详情页应有多个底部弹层(回复 / 转发)—— 实际找到 ${sheets.length} 个`); + + /* + * 逐个弹层:从 borderRadius 往回找它的高度声明。 + * 在 `.borderRadius({ topLeft` 之前的一段里找 `.height(`。 + */ + const noHeight = []; + for (const m of sheets) { + const before = src.slice(Math.max(0, m.index - 1200), m.index); + /* 最近的 .height(...) 要在这一段里(弹层的修饰符链上) */ + if (!/\.height\(('100%'|\d+|'[0-9]+%')\)/.test(before)) { + const line = src.slice(0, m.index).split('\n').length; + noHeight.push(`MailDetailPage.ets:${line}`); + } + } + assert.deepEqual(noHeight, [], + '★ 底部弹层必须有明确高度 —— 没有高度时它的尺寸由内容决定,' + + '而键盘弹出(默认 `KeyboardAvoidMode.OFFSET`)会把内容整体上移,' + + '**「取消 / 转发」按钮会被顶到键盘下面**(实测截图:按钮点不到)。\n' + + `缺高度的弹层:\n ${noHeight.join('\n ')}`); + + /* ② 说明框用 layoutWeight 而不是固定高度(否则它撑住高度、按钮仍被挤出) */ + const ta = /TextArea\(\{[\s\S]{0,200}?转发说明[\s\S]{0,400}?\n\s*\}\)([\s\S]{0,300}?)\.onChange/.exec(src); + assert.ok(ta, '要能找到转发弹层的说明框(TextArea)'); + assert.match(ta[1], /\.layoutWeight\(1\)/, + '★ 转发说明框要用 `layoutWeight(1)`(不是固定 `height(N)`)—— ' + + '弹层高度固定后,只有它会伸缩才能把「取消 / 转发」按钮留在键盘之上'); +}); diff --git a/client/electron/test/run-all.mjs b/client/electron/test/run-all.mjs index 16e7ffc..dda0e64 100644 --- a/client/electron/test/run-all.mjs +++ b/client/electron/test/run-all.mjs @@ -115,7 +115,7 @@ const SUITE = [ ['test/criteria-hygiene.test.mjs', [], 6], // 用户管理页(P4c 同批):动作↔服务端调用同名 / 门禁只认严格 admin / // 启停只发 status / 「受限」徽标口径 / 页面零写死色值 / 接线(纯逻辑真被调用) - ['test/harmony-admin.test.mjs', ['--experimental-strip-types', '--no-warnings'], 28], + ['test/harmony-admin.test.mjs', ['--experimental-strip-types', '--no-warnings'], 30], // P4c 图片上传:阈值与两档策略 / 失败必带原因 / 退档判定只有一处 / // release 都 await / 解码按目标尺寸 / multipart 字段名 / 上传后重新同步 ['test/harmony-imageprep.test.mjs', ['--experimental-strip-types', '--no-warnings'], 31], diff --git a/client/harmony/entry/src/main/ets/api/MailApi.ets b/client/harmony/entry/src/main/ets/api/MailApi.ets index bf9ba79..60df3bf 100644 --- a/client/harmony/entry/src/main/ets/api/MailApi.ets +++ b/client/harmony/entry/src/main/ets/api/MailApi.ets @@ -6,7 +6,7 @@ */ import { ApiClient } from './ApiClient'; -import { MailSummary, Session, Contact, MailDetail, ThreadResponse, AttachmentInfo, SendMailRequest, SendMailResult, SentResponse, PermissionRequest, PendingResponse, DecideResponse } from '../model/Models'; +import { MailSummary, Session, Contact, MailDetail, ThreadResponse, AttachmentInfo, SendMailRequest, SendMailResult, SentResponse, PermissionRequest, PendingResponse, DecideResponse, ForwardMailRequest} from '../model/Models'; /** 收件箱响应 */ export class InboxResponse { @@ -187,8 +187,19 @@ export class MailApi { return this.client.post('/me/mail/send', req); } - /** 转发 */ - async forward(mailId: string, req: SendMailRequest): Promise { + /** + * 转发。 + * + * ★★ 参数类型是 **`ForwardMailRequest`**(不是 `SendMailRequest`)—— + * 两者字段集不同:服务端 `forwardRequest` 没有 `body` / `reply_to` / + * `attachment_ids` / `max_rounds` / `permission_mode`, + * 而它用的 `Decode()` 是 `DisallowUnknownFields()` ⇒ 多带字段直接 400。 + * + * 这个方法**原先的签名写的就是 `SendMailRequest`** —— 一直没被发现, + * 因为**从来没有调用方**(详情页没有转发入口)。审计补上入口时才暴露出来: + * "写好了但没人用"的代码,连它自己的类型对不对都没人验过。 + */ + async forward(mailId: string, req: ForwardMailRequest): Promise { return this.client.post('/me/mail/' + mailId + '/forward', req); } diff --git a/client/harmony/entry/src/main/ets/model/Models.ets b/client/harmony/entry/src/main/ets/model/Models.ets index 02d76d0..c713a79 100644 --- a/client/harmony/entry/src/main/ets/model/Models.ets +++ b/client/harmony/entry/src/main/ets/model/Models.ets @@ -255,6 +255,31 @@ export class SendMailRequest { permission_mode: string = ''; } +/** + * 转发请求体(`POST /me/mail/{id}/forward`)。 + * + * ★★ 为什么**不**复用 `SendMailRequest`(这是我刚踩过的那个坑的第二次): + * 服务端 `forwardRequest`(`forward.go:23`)只认五个字段 —— + * `to` / `cc` / `comment` / `subject` / `session_alias`, + * **没有** `body`、`reply_to`、`attachment_ids`、`max_rounds`、`permission_mode`。 + * 而服务端的 `Decode()` 是 `DisallowUnknownFields()`(严格)⇒ + * 多带一个字段就 **400**。 + * + * 同一个错误我今天在 `AppearancePayload` 上刚犯过一次(拿响应类型当请求体), + * 表现也一样隐蔽:本地状态立刻变、看起来成功了,只有看 HTTP 状态码才发现 400。 + * ⇒ 请求体一个端点一个类型,**不复用**。 + */ +export class ForwardMailRequest { + to: string = ''; + cc: string = ''; + /** 转发者附加的说明,置于引用原文之前 */ + comment: string = ''; + /** 留空 ⇒ 服务端自动加 `Fwd: ` 前缀(它处理了 "Fwd: Fwd:" 无限叠加) */ + subject: string = ''; + /** 仅在目标地址以 .new 结尾时生效 */ + session_alias: string = ''; +} + /** 发信响应(含预算) */ export class SendMailResult { mail_id: string = ''; diff --git a/client/harmony/entry/src/main/ets/pages/MailDetailPage.ets b/client/harmony/entry/src/main/ets/pages/MailDetailPage.ets index 632aee0..b323c43 100644 --- a/client/harmony/entry/src/main/ets/pages/MailDetailPage.ets +++ b/client/harmony/entry/src/main/ets/pages/MailDetailPage.ets @@ -9,7 +9,7 @@ import { Theme } from '../common/Theme'; import { Markdown } from '@luvi/lv-markdown-in'; import { MailApi } from '../api/MailApi'; import { AccountManager, AccountInfo } from '../api/AccountManager'; -import { MailDetail, SendMailRequest, Address } from '../model/Models'; +import { MailDetail, SendMailRequest, ForwardMailRequest, Address } from '../model/Models'; import { MailDetailParams } from '../model/RouteParams'; import { AmIcon } from '../common/Icons'; import { permissionLabel } from '../model/MailGrouping'; @@ -94,6 +94,19 @@ export struct MailDetailView { @State showReplyBox: boolean = false; @State replyBody: string = ''; @State sending: boolean = false; + /* + * 转发(2026-09-19 审计发现缺失)。 + * + * `MailApi.forward()` **早就实现了**(`api/MailApi.ets:191`), + * 服务端 `forward.go` 也完整(含引用块渲染与 `Fwd:` 叠加处理)—— + * 缺的只是这一页的入口。对齐 WebUI `MailView.tsx:378` 的 `ForwardBar`。 + */ + @State showForwardBox: boolean = false; + @State forwardTo: string = ''; + @State forwardCc: string = ''; + /** 抄送输入是否展开(WebUI 的 `ccOpen`:默认收起,点「抄送」才出现) */ + @State forwardCcOpen: boolean = false; + @State forwardComment: string = ''; @State sessionId: string = ''; @State switchingPerm: boolean = false; /** @@ -460,14 +473,50 @@ export struct MailDetailView { * 实测 1280×800 下回复框占 31%;输入框不是阅读时每刻都要用的东西, * 就该按需展开。球同时与列表页的 ComposeFab 同一套观感,不新增一种视觉语言。 */ - Button() { - AmIcon({ iconName: 'chatBubble', iconSize: 24, iconColor: Theme.accentFg }) + /* + * 两个动作球:转发(次要)+ 回复(主要)。 + * + * ★★ 2026-09-19 修(真 bug,设备实测撞出来的):第一版把两个球**各自** + * 写在 `Stack({alignContent: BottomEnd})` 里、各带一个 margin —— + * 于是它们**几乎完全重叠**(实测 dump 的 bounds: + * 转发 `[2984,2010][3122,2148]`、回复 `[2949,1998][3110,2159]`, + * 重叠区 2984–3110)。屏幕上只看得到一个球,转发入口等于不存在。 + * + * 根因:`Stack` 的 `alignContent` 把**每个**子元素都摆到同一个角, + * margin 只是各自微调 —— 想并排就得用**一个容器**把它们排起来。 + * ⇒ 用 `Row({ space })` 包住两个球,Row 自己带 margin 到右下角。 + * + * 视觉分工:实心球是**主动作**的语言(回复), + * 转发用描边球(`surfaceMuted` 底 + 品牌色图标)—— 两个实心球并排 + * 会让人分不出哪个更主要。 + */ + Row({ space: 12 }) { + Button() { + AmIcon({ iconName: 'forward', iconSize: 20, iconColor: Theme.accent }) + } + .width(48).height(48) + .borderRadius(24) + .backgroundColor(Theme.surfaceMuted) + .onClick(() => { + /* + * 打开转发框时把回复框关掉:两者都是底部弹层, + * 同时开着会叠在一起(WebUI 也用同一份 `forwarding` 状态互斥)。 + */ + this.showReplyBox = false; + this.showForwardBox = true; + }) + + Button() { + AmIcon({ iconName: 'chatBubble', iconSize: 24, iconColor: Theme.accentFg }) + } + .width(56).height(56) + .borderRadius(28) + .backgroundColor(Theme.accent) + .onClick(() => { this.showForwardBox = false; this.showReplyBox = true; }) } - .width(56).height(56) - .borderRadius(28) - .backgroundColor(Theme.accent) - .margin({ right: 16, bottom: this.navReserve + 16 }) - .onClick(() => { this.showReplyBox = true; }) + .alignItems(VerticalAlign.Bottom) + /* 让出底部悬浮条;右边距与原来的回复球一致(球没变位置,只是多了左边一个) */ + .margin({ right: 16, bottom: this.navReserve + 16 }) } .width('100%').layoutWeight(1) @@ -530,12 +579,147 @@ export struct MailDetailView { .width('100%').height('100%') .position({ x: 0, y: 0 }) } + + /* + * 转发弹层 —— 与回复弹层同构(同一套遮罩 + 底部弹层 + 同一套入场动画), + * 但字段是 WebUI `ForwardBar` 那四个:收件人 / 抄送(可折叠)/ 说明 / 引用原文。 + * + * WebUI 的字段顺序与占位文案逐条对齐(`MailView.tsx:412-437`)。 + */ + if (this.showForwardBox) { + Column() { + Column() + .width('100%').layoutWeight(1) + .backgroundColor(Theme.overlay) + .onClick(() => { this.showForwardBox = false; }) + + Column() { + Row() { + Text('转发「' + this.subject + '」') + .fontSize(14).fontWeight(FontWeight.Bold).fontColor(Theme.textPrimary) + .layoutWeight(1) + .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis }) + /* 「抄送」是个**折叠开关**(WebUI 同形:默认收起,点它才出现输入框) */ + Text(this.forwardCcOpen ? '收起抄送' : '抄送') + .fontSize(11) + .fontColor(this.forwardCcOpen ? Theme.accent : Theme.textMuted) + .onClick(() => { this.forwardCcOpen = !this.forwardCcOpen; }) + } + .width('100%') + .margin({ bottom: 10 }) + + TextInput({ text: this.forwardTo, placeholder: '新收件人:pi@root.new' }) + .width('100%').height(38).fontSize(13) + .onChange((v: string) => { this.forwardTo = v; }) + + if (this.forwardCcOpen) { + TextInput({ text: this.forwardCc, placeholder: '抄送:逗号分隔,可多个' }) + .width('100%').height(38).fontSize(13) + .margin({ top: 8 }) + .onChange((v: string) => { this.forwardCc = v; }) + } + + /* + * 说明框用 `layoutWeight(1)` 而不是固定高度:弹层现在有明确高度 + * (`height('60%')`),剩下多少空间它就占多少 —— 这样键盘顶上来时 + * 它**自己会缩短**,把「取消 / 转发」按钮留在屏幕内。 + * 固定 `height(70)` 在键盘弹出后会把按钮挤出视口(第一版就是这个症状)。 + */ + TextArea({ + placeholder: '转发说明(可选,置于引用原文之前;原文将以引用块附在下方)' + }) + .width('100%').layoutWeight(1).fontSize(13) + .margin({ top: 8 }) + .onChange((v: string) => { this.forwardComment = v; }) + + Row() { + Button('取消') + .width(80).height(36) + .backgroundColor(Theme.surfaceMuted) + .fontColor(Theme.textMuted) + .fontSize(13) + .onClick(() => { this.showForwardBox = false; }) + + Blank() + + Button(this.sending ? '转发中…' : '转发') + .width(80).height(36) + .backgroundColor(Theme.accent) + .fontSize(13) + /* 收件人为空时不可点 —— WebUI 同口径(`disabled={busy || !to.trim()}`) */ + .enabled(!this.sending && this.forwardTo.length > 0) + .onClick(() => { this.doForward(); }) + } + .width('100%') + .margin({ top: 12 }) + } + .width('100%') + /* + * ★★ 2026-09-19 修(真 bug,设备实测撞出来的): + * 第一版这里**没有高度**,只有 `padding(16)` —— 于是弹层的高度 + * 完全由内容决定,而内容一旦被键盘顶起来,`TextArea` 与 + * 「取消 / 转发」两个按钮就**跑到键盘下面**去了(实测截图: + * 只看得见收件人输入框 + 键盘,「转发」按钮点不到)。 + * + * ArkUI 默认的键盘避让是 `KeyboardAvoidMode.OFFSET` + * (整个布局上移),而要让"上移之后按钮仍在屏幕内", + * 弹层必须有**明确的高度**(内容才能在里面重新分配空间)。 + * + * 与回复框对齐:那里写的是 `height('60%')`,一直没出问题 —— + * 两个弹层本来就应该同构(同一套交互)。 + */ + .height('60%') + .padding(16) + .backgroundColor(Theme.surface) + .borderRadius({ topLeft: 12, topRight: 12 }) + /* 同回复框:`rise-in` 挂在**弹层本身**(理由见回复框那段注释) */ + .transition(Theme.paneRiseIn()) + } + .width('100%').height('100%') + .position({ x: 0, y: 0 }) + } } } .width('100%').height('100%') .backgroundColor(Theme.pageBg) } + /** + * 转发:`POST /me/mail/{id}/forward`(服务端 `forward.go`)。 + * + * 字段与 WebUI `ForwardBar.submit()` 逐条对齐:`to` / `cc` / `comment` + * (`subject` 留空让服务端自动加 `Fwd: ` 前缀 —— 它处理了"Fwd: Fwd:" 无限叠加)。 + * + * ★ 成功后**不刷新列表**:WebUI 那里刷了 inbox/sent/sessions/contacts 四份, + * 因为它是长驻的 SPA、靠 store 驱动。鸿蒙这一页是 push 出来的详情页, + * 返回时会重新挂载列表页(`aboutToAppear` 重新拉)—— + * 在这里刷是白拉一次(而且用户还看不到,因为列表在下一层)。 + */ + async doForward(): Promise { + const m: MailApi | null = this.mailApi; + if (m === null || this.sending || this.forwardTo.length === 0) { + return; + } + this.sending = true; + try { + const req: ForwardMailRequest = new ForwardMailRequest(); + req.to = this.forwardTo; + req.cc = this.forwardCc; + req.comment = this.forwardComment; + /* subject 留空 ⇒ 服务端自动加 `Fwd: ` 前缀(不在这里拼,避免叠成 Fwd: Fwd:) */ + await m.forward(this.mailId, req); + this.showForwardBox = false; + this.getUIContext().getPromptAction().showToast({ message: '已转发' }); + } catch (e) { + const ae = e as ApiError; + this.getUIContext().getPromptAction().showToast({ + message: ae.message.length > 0 ? ae.message : '转发失败' + }); + } finally { + this.sending = false; + } + } + async doReply(): Promise { const m: MailApi | null = this.mailApi; if (m === null || this.replyBody.length === 0) {