# GUI 复查与转交报告(2026-10-03) > **基线**:HEAD `5e312c6`(`main`,工作区干净,923 提交) > **范围**:`client/electron`(React 18 + Vite + Tailwind + Zustand + Electron 主进程) > 与 `client/harmony`(ArkTS / ArkUI,Stage 模型) > **方法**:对现存代码逐条复核,不引用印象。所有 `file:line` 均在 HEAD `5e312c6` 上实测过。 > **上游**:`docs/reviews/` 2026-09-28 那四份报告(`electron-gui-review.md`、 > `harmony-client-review.md`、`harmony-pages-review.md`、`harmony-state-review.md`)的每一条 finding 重新判一次。 --- ## 0. 给接手的 agent:怎么用这份报告 | 你要做的事 | 看哪节 | |---|---| | 只想知道「上一轮报告的东西修了没」 | §1 结算表 | | 要动手修 | §2 的 **H-1 / H-2**(两端各一条 HIGH),§5 有逐条修复草案 | | 想先确认我说的缺陷是不是真的 | §2 每条都带**触发路径**与**代码引证**,可直接复现 | | 想知道哪些**不要动** | §6(一条已实测失败并撤回的修法) | | 想先立判据再修 | §4(每条给了判据建议与"判据该红在哪") | ★ **纪律提醒**(本仓 `client/electron/test/CRITERIA.md` 已写明,这里复述三条最常踩的): 1. 判据要钉**结构与行为**,不要钉字面与邻接 —— 本报告里所有行号都请自行 grep 复核, `DEBTS.json` 已登记「行号引用漂到无关代码」这条债。 2. 每条判据都要**验证它能红,且红在正确位置**(本仓 `criteria-hygiene.test.mjs` 有自检)。 3. **不要**用自动格式化工具。`.pi-lens.json` 与 `biome.jsonc` 都关掉了 formatter/linter, 历史上自动格式化制造过约 7000 行无关 diff。 --- ## 1. 上一轮报告的结算 ### 1.1 Electron(`electron-gui-review.md`,12 条 → 修掉 9 条) | # | 原 finding | 现状 | 证据(HEAD `5e312c6`) | |---|---|---|---| | C1 | 切账号不清 store ⇒ 跨账号邮件泄露 | ✅ 已修 | 新增 `src/lib/resetAccountData.ts`;三个入口都调:`AccountSwitcher.tsx:71`(主动切)、`authStore.ts:86`(登出)、`authStore.ts:115`(任意 401)。判据 `test/stores/resetAccountData.test.ts` | | C2 | `lastUploadedImage` 不分账号 | ✅ 已修 | `appearanceSync.ts:53` 已改成 `new Map()`,`:56` 的 `currentAccountKey()` 逐账号分桶 | | H1 | `PermissionPanel.submit` 吞掉错误 | ✅ 已修 | `MailView.tsx:772` 有 `submitErrorBanner`,`:780` 的 `submit` 末尾 `setSubmitError(err …)` | | H2 | `ForwardBar` 双提交 + `Promise.all` 卡住关窗 | ✅ 已修 | `MailView.tsx:393` `inFlight = useRef(false)` 同步闸门;`:415` 改 `Promise.allSettled` | | H3 | ICS `revokeObjectURL` 在 `click()` 同步之后 | ✅ 已修 | `CalendarView.tsx:292` `setTimeout(() => URL.revokeObjectURL(url), 0)` | | M1 | `CalendarView.load()` 竞态 | ✅ 已修 | `CalendarView.tsx:104` `const loadGen = useRef(0)`、`:107` `const myGen = ++loadGen.current`、`:111/:114/:117` 三处守卫 | | M2 | `ComposePage` `setTimeout` 未清 | ✅ 已修 | `ComposePage.tsx:62` `closeTimer` ref + cleanup | | M3 | `AdminUsersPage` / `KeyPanel` 同形状 | ✅ 已修 | `AdminUsersPage.tsx:97` `noticeTimer` ref(`:90-96` 是说明它的注释)+ cleanup;`KeyPanel.tsx:36` `copiedTimer` ref + `:40` cleanup | | M4 | 审批 chip 缺同步闸门 | ⚠️ **半修** | 见 §2 **E-1** | | M5 | `BackgroundPicker` 预设缩略图被 `--bg-image` 盖住 | ✅ 已修 | `BackgroundPicker.tsx:118-127` 是一段解释「**为什么不能**写 `style={{ backgroundImage: 'var(--bg-image)' }}`」的注释,`:116` 只留 `bg-preset-${p.id}` 类 | | M6 | 附件覆盖上传用陈旧 `items` 闭包 | ⚠️ **半修** | 见 §2 **E-2** | **另确认一条不在旧报告里的修复**:`aeb1f41` 修的 SSE 账号凭证问题 —— `App.tsx:125-133` 的注释记录了症状与根因(依赖只有 `[phase]`,而切号只换 base/token 不改 phase), `:134` `const credentialSig = useCredentialChange();` 取的是 `api/config` 那个唯一发生地。 切号不再拿旧账号的 SSE 连接,`aeb1f41` 提交信息里记的用户报「页面停留不动,新邮件不自动同步」已闭环。 判据:`test/sse-credentials.test.mjs`。 ### 1.2 HarmonyOS(`harmony-pages-review.md` 22 条 → 修掉约 10 条) 已修(抽样核过源码): | 原 finding | 证据 | |---|---| | Enter 守卫的 `mail_type` 写错(`'permission'` ⇒ `'permission_request'`) | `MailDetailPage.ets:1946` 已改 | | 顶栏轮播的 `setTimeout` 未清 | `MainPage.ets:2921-2923` `this.topFadeTimer` 存句柄 + `clearTimeout` | | `mail.cc_list.length` 无守卫(`harmony-state-review.md` #4) | `MailStore.ets:513` 与 `:642` 都是 `(mail.cc_list ?? []).length` | | `MailStore.clear()` 零调用点(`harmony-state-review.md` #2) | `Logout.ets:108` 与 `SettingsPage.ets:605` 都调了;`Logout.ets:92-107` 记了为什么必须放这里(这段流程有**两个入口**) | | 管理台 fail-open | `AdminUsersPage.ets:337` 改三态 `if (!this.roleKnown) …` | | 旧 `MyPage` / `InboxPage` 若干文案 | 已收敛(详见 `git show 18b148e`) | 未修(见 §2 的 H-1 / M-4 / M-5 / L-1): | 原 finding | 编号 | |---|---| | `loadInbox` / `loadSent` 共用 `this.snapshot` | **H-1**(旧报告标 CRITICAL,**从 9/28 至今没人动**) | | `closeDetail()` 是死代码 ⇒ 系统返回后 `KEY_OPEN_MAIL_ID` 残留 | M-1(已登记 `harmony-system-back-key`) | | `ContactsTab.openSession()` 无请求令牌 | M-2 | | 对话树 `ForEach` key 用数组下标 | M-3 | | 每个 SSE 事件全量重拉多账号收件箱 | M-4 | | 每次 `build()` 新建 `MarkdownController` | M-5 | | `loading` 不在 `finally` | M-6 | | `Index.ets` Hello World 注册为路由 + 两个孤儿页 | L-1(已登记 `harmony-dead-pages`) | ### 1.3 我**排除**的疑点(核过,不是问题) **① 鸿蒙日历「格与行时间基准不一致」(旧报告 finding 19)—— 误报,已实测否证。** `CalendarPage.ets:1317` 的 `hourEvents` 用 `d.getHours()`, `:1344` 的 `EventRow` 用 `hhmmAtOffset(e.event_time, this.offsetMinutes)`, 而 `offsetMinutes = deviceOffsetMinutes(now) = -now.getTimezoneOffset()`(`model/Calendar.ts:226`)。 看着像两个基准。实测五个时区,**两者恒等**: ``` Asia/Shanghai: hhmmAtOffset=00:30 getHours=00:30 America/Los_Angeles: hhmmAtOffset=09:30 getHours=09:30 UTC: hhmmAtOffset=16:30 getHours=16:30 Pacific/Kiritimati: hhmmAtOffset=06:30 getHours=06:30 Pacific/Midway: hhmmAtOffset=05:30 getHours=05:30 ``` 原因:`hhmmAtOffset` 内部是 `new Date(ms + offset*60000)` 读 `getUTCHours()`, 而 `-getTimezoneOffset()` 恰好把设备本地偏移补回去 ⇒ 它算的就是本地钟点。 **只有判据进程时区 ≠ 设备时区时才会分叉**,那不是产品缺陷。 `harmony-calendar.test.mjs:288` 那条判据正是为「三个时区都能真跑」写的,方向正确。 ★ 这条是本报告里唯一一个**否证了上游报告**的条目,请不要再去"修"它。 **② 鸿蒙 `MailStore` 的 `bump()` 不发布 AppStorage —— 是设计,不是漏发。** `harmony-state-review.md` #3 断言 `loadInbox` 的 `finally` 走 `bump()`(不自增 AppStorage) 会让窗格"静默错过刷新"。实测 `MailStore.ets:175-178`: ```ts private bump(): void { this.revision += 1; } ``` 而 `:180-196` 的注释把为什么分开写得很清楚:`bump()` 是"我自己改了快照,持有者自己接进 `@State`", `publishChange()` 才是"别的组件可能不知道,要广播"。两者混用会**死循环** (`bump` → 发布 → `onMailRevChanged` → `loadData` → `loadInbox` → `bump` → …)。 **不是缺陷,不要合并这两个函数。** **③ Electron Markdown XSS —— 无。** 三处渲染都是 ``, `react-markdown@^9.0.1` 无 `rehype-raw` ⇒ raw HTML 转义、`defaultUrlTransform` 中和 `javascript:`。 全库 `dangerouslySetInnerHTML` / `innerHTML` / `insertAdjacentHTML` 命中 **0** 次。 鸿蒙侧用 `@luvi/lv-markdown-in` 原生 ArkTS 引擎,不经 WebView。`test/markdown-xss.test.mjs` 钉着这条。 **④ `LEGACY_BACKUP_KEY` 永久残留 —— 有意决策。** `backgroundStore.ts` 注释明确写了 「没有任何代码路径能判定何时安全删除」并列出代价(体积有界、共享机器上是一份别人的外观)。 **⑤ 深色模式对比度 —— 有依据。** `tailwind.config.js` 的 `solid()` / `textColor.white` / `chrome` 三套覆盖都有注释记录**实测对比度数字**(`bg-red-600` 白字 1.6:1、`bg-chrome-700` 白字 1.34:1)。 不是猜的。 --- ## 2. 仍然成立的问题(按严重度) ### H-1 【HIGH · 正确性】鸿蒙 `MailStore` 收件箱与发件箱共用一个快照 **位置**:`client/harmony/entry/src/main/ets/common/MailStore.ets` ``` 447: async loadInbox(ctx, accountFilter) { const snap = this.snapshot; … } // ← 608: async loadSent(ctx, accountFilter) { const snap = this.snapshot; … } // ← 同一个对象 ``` `MailSnapshot`(`:120-140`)有 8 个字段:`mails` / `groups` / `loaded` / `unread` / `notice` / `accountErrors` / `loading` / `error`。**两个方法全部往同一份写**, 而且 `loadSent` 写的时候会把 `unread` 置 0(`:678`)。 **读侧**:`MainPage.ets` 里 `InboxTab`(`:191`)与 `SentTab`(`:1319`)是两个独立 `@Component`, 各自 `@StorageProp('agentmail.mail.revision') @Watch('onMailRevChanged')`(`:230` / `:1376`), 各自 `await store.loadInbox(...)` / `await store.loadSent(...)` 然后 `applyStoreSnapshot(store.snapshot)`(`:358` / `:1410`)。 两个 `applyStoreSnapshot` 都逐字段拷贝(InboxTab 版 `:408-424`),包括 `this.unread = snap.unread`。 **触发路径(三条,都是常规操作)**: 1. **切页签时旧 pane 未回**:`commTab` 分支(`:2357-2371`)同一时刻只 mount 一个 tab, 所以不是"两个 pane 同时在屏"。但用户点页签时旧 pane 的 `loadInbox` 还在飞, 新 mount 的 pane 的 `applyStoreSnapshot` 读到的是旧 pane 写到一半的 `mails`。 2. **SSE 与切页签交错**:`MainPage.ets:3069-3095` 的 `onGlobalSseEvent` 对 `new_mail` / `permission_decision` / `session_update` / `session_archived` **四类事件一律** `notifyRemoteChange()` → 叫醒已 mount 的 pane → 开始 `loadInbox`。 用户此时点开发件箱页签,`loadSent` 与仍在飞的 `loadInbox` 交错写同一个对象。 3. **`loading` 互相关闭**:`:462`(收件箱置 true)/ `:596`(收件箱置 false) 与 `:609` / `:687`(发件箱同)共用一个字段。谁最后完成谁置 false ⇒ 会出现"转圈停了但列表是空的,无 spinner 无报错"。 4. **`unread` 单向污染**:发件箱 pane 没有自己的 `unread` 字段(实测 `SentTab` 的 `@State` 只有 `hoveredMailId` / `groups` / `expandedKeys` / `loading` / `error` / `loaded`), 所以它**不受影响**;但 `loadSent` 把 `snap.unread = 0` 之后, 若收件箱 pane 的 `loadInbox` 还没回来,它 `applyStoreSnapshot` 就会读到 `unread = 0` ⇒ **收件箱未读数被清零**。 ★ 好消息:底栏与侧栏的徽标走的是**另一条路** —— `MainPage.refreshCounts()`(`:1934`)自己遍历账号发 `api.inbox('all', …)`, 不读 `snapshot.unread`。所以**徽标不受污染**,受污染的是收件箱栏内的未读显示。 **为什么至今没修**:旧报告(`harmony-state-review.md` #1、`harmony-pages-review.md` #16) 把它标成 CRITICAL 并描述成 `Navigation` 分栏下"两个 pane 同时在屏"的必然场景。 但 `commTab` 后来改成同一时刻只 mount 一个(`:2357-2371`),**那个"必然"没了**, 于是它从"每次都坏"降级成"切页签/SSE 交错时坏" ⇒ 症状变罕见 ⇒ 没人动。 ★ 这是典型的**判据缺失导致缺陷降级**:`harmony-pages-review.md` 有 22 条, 但没有一条判据钉住"发件箱 pane 拿到的必须是自己那次拉的数据"。 **修复草案(三选一)**: ```ts // 方案 A(最小,推荐先落):拆成两个快照字段 export class MailStore { snapshot: MailSnapshot = new MailSnapshot(); // 收件箱(保持不动,调用点零改) sentSnapshot: MailSnapshot = new MailSnapshot(); // 发件箱(新增) private inboxGen: number = 0; private sentGen: number = 0; async loadInbox(ctx, accountFilter): Promise { const myGen = ++this.inboxGen; const snap = this.snapshot; // ← 只碰收件箱那份 … // 每次 await 之后、每次写 snap 之前:if (this.inboxGen !== myGen) return; } async loadSent(ctx, accountFilter): Promise { const myGen = ++this.sentGen; const snap = this.sentSnapshot; // ← 换一份 … } } ``` - `SentTab.applyStoreSnapshot`(`MainPage.ets:1410`)改一行:`store.sentSnapshot`。 - `SentTab` 的 `@Watch('onMailRevChanged')` 建议改订**独立**的 revision key (否则发件箱的 revision 变化不会叫醒它)。这一步可选,但不做的话 `markReadLocal` 之类走 `publishChange()` 的路径仍会误叫醒收件箱。 - `clear()`(`:716`)要同时清两份。 - generation 守卫**治不了 `loading` 互相关闭**(那是共用字段), 所以**方案 A 的拆字段是必须的,generation 是附加的**。 ← 这点很重要:旧报告推荐的"加 generation 守卫"单独用**并不足以**修好 `loading`。 **验判据**(`harmony-logic.test.mjs`,设备判据,见 §4.1): 「发件箱 tab 加载完成后,收件箱 tab 的 `unread` 不被清零」+「切页签时新 pane 拿到的 `from_name`/`to_name` 列方向属于自己的那一份」。 --- ### H-2 【HIGH · 资源】鸿蒙壁纸:`PixelMap` 泄漏 + 全尺寸解码 + UI 线程阻塞 **位置**:`client/harmony/entry/src/main/ets/common/AppearanceStore.ets:188-197` ```ts async loadWallpaper(api: AppearanceApi): Promise { try { const bytes: ArrayBuffer = await api.fetchImageBytes(); const src: image.ImageSource = image.createImageSource(bytes); this.wallpaper = await src.createPixelMap(); // ← 无 desiredSize,无 release } catch (e) { this.wallpaper = null; } } ``` 三个问题叠在一起: 1. **旧 `PixelMap` 与 `ImageSource` 都从不 `release()`**。 `PixelMap` 是 native 内存,`syncFromServer`(`:143`,`:182` 调 `loadWallpaper`) 每次同步、每次切账号都会重新解码一次,全部留在映射表里等 GC 压力。 `src` 在**成功路径**上也从不释放。 对照 `BackgroundPicker.ets:167-229`:那里 `head`(`:228`)、`passSource`(`:200`)、 `scaled`(`:197`)都在 `finally` 里 `await ... .release()`,而且注释写明了 为什么必须用 `finally` 收口(那条路径有三个出口,逐处 release 必漏一处或重一处)。 **store 里没有对应物。** 2. **无 `desiredSize`** ⇒ 最坏一张 2560×2560 ARGB ≈ **26 MB** 常驻, 而它永远被合成器降采样着全屏画。`BackgroundPicker` 已把上传边长卡在 `MAX_EDGE = 2560`(`ImagePrep.ts:35`),所以来源有界但仍然很大。 `createPixelMap(options)` 支持 `DecodingOptions.desiredSize`, `BackgroundPicker.ets:189-193` 就在用,这里没用。 3. **在 UI 线程内联解码** ⇒ `syncFromServer` 被设置页 `await` 之后才应用主题, 所以每次同步都有一次几百毫秒的可见卡顿,不只启动时。 ★ **加重项**:`MailStore.clear()` 已在登出时调(`Logout.ets:108`), 但 `AppearanceStore.wallpaper` **不在任何清理路径里** —— 它是个静态单例,活过登出(实测全文件 `clear` 命中 0 次,`wallpaper` 命中 `:45`/`:172`/`:192`/`:195`)。 所以"换账号"这条路上,第 1 条的泄漏**必然发生**。 **修复草案**(照 `BackgroundPicker.ets:167-229` 的既有形状,不要另创一套): ```ts async loadWallpaper(api: AppearanceApi, targetW: number, targetH: number): Promise { let src: image.ImageSource | null = null; let next: image.PixelMap | null = null; try { const bytes: ArrayBuffer = await api.fetchImageBytes(); src = image.createImageSource(bytes); const opts: image.DecodingOptions = { desiredSize: { width: targetW, height: targetH } }; next = await src.createPixelMap(opts); const old: image.PixelMap | null = this.wallpaper; this.wallpaper = next; // 先换引用 next = null; // 转移所有权,避免 finally 释放掉新的那个 if (old !== null) { await old.release(); // ← 释放**旧**的,不是新的 } } catch (e) { this.wallpaper = null; } finally { if (next !== null) await next.release(); if (src !== null) await src.release(); } } ``` - `targetW/targetH`:从 `display.getDefaultDisplaySync()` 或窗口尺寸取屏幕像素, 不要写死一个魔数。**判据里要钉住"传了 desiredSize"而不是钉住具体数值** (数值会随屏幕变)。 - 离屏解码(`@Concurrent` / taskpool)**本轮不做** —— 全仓 `grep taskpool|worker|@Concurrent` 命中 0,没有先例,单独引入会扩大风险面。记一笔即可。 **验判据**:`harmony-appearance.test.mjs`(设备判据) 「每次切账号后,壁纸对象被 `release()` 过」—— 真机上不好直接观测 native 内存, 可行形态是**静态判据钉形状**:断言 `loadWallpaper` 里存在 `desiredSize`, 且存在 `finally` 且 `finally` 里有 `release()`(按 §4.2 的"取结构体"写法,不要用固定宽度窗口)。 --- ### M-1 【MEDIUM】鸿蒙系统返回键仍不清理详情状态 —— **不要动** 已登记为 `docs/DEBTS.json` 的 `harmony-system-back-key`(count=2)。 `MainPage.ets:1845-1856` 那段注释把现状与踩坑都写全了: > ★ 注意:这条路**只接 Esc**。系统返回键/手势绕开它走 `Navigation` 自己的 pop > ⇒ `closeDetail()` 与这里重发的层数都不覆盖那条路(这是已知缺口)。 > 2026-09-26 试过在 `EntryAbility` 加 `onBackPressed()` 来补,**实测失败并已撤回** > (模拟器上按一次系统返回键直接销毁 UIAbility)。 实测:`closeDetail()`(`MainPage.ets:2305`)定义完整、**零调用点**。 后果是系统返回 pop 掉 nav 栈后 `KEY_OPEN_MAIL_ID` 仍指向刚看的那封邮件, 下次在根上按 Enter 会跳回一封可能已被归档/删除的邮件; `publishStackDepth`(`:2232`)同样不在 pop 路径上,Esc 会永久被吞。 到期前提已写得准确:**必须真机验证 `Navigation` 的返回事件消费点在哪一层**。 ★ 请不要在没有真机的情况下"顺手修" —— 上一轮就是这么失败的。 --- ### M-2 【MEDIUM】鸿蒙 `ContactsTab.openSession()` 无请求令牌 **位置**:`ContactsTab.ets:202-221` ```ts async openSession(c: Contact): Promise { … this.openSessionId = c.session_id; this.openSessionTitle = …; this.sessionMails = []; this.sessionLoading = true; try { this.sessionMails = await m.sessionMails(c.session_id); // ← 无令牌 } catch (e) { const ae = e as ApiError; // ← 无收窄 ``` 连点两个会话:先发的请求后回 ⇒ 标题已经是 B、`sessionMails` 是 A 的。 **同一类问题 Electron 侧已经修过**(`CalendarView.tsx:104-117` 的 `loadGen` / `myGen`), 只修了一端。 另外 `e as ApiError` 无收窄:非 `ApiError` 的 reject(例如 `abort`)会在错误处理里 再抛一次 `TypeError`,把真正的错误盖掉(旧报告 finding 11,同一形状)。 **修复草案**:照 `CalendarView.tsx:104-117` 的形状加 `private reqGen: number = 0`, `const myGen = ++this.reqGen`,`await` 之后与 `finally` 里各判一次。 `catch` 改成 `e instanceof ApiError ? e.message : String(e)`。 --- ### M-3 【MEDIUM】鸿蒙对话树 `ForEach` key 用数组下标 + 拍平成字符串 **位置**:`MailDetailPage.ets:1990-1999` ```ts List() { ForEach(this.threadLines, (line: string, idx: number) => { ListItem() { Text(line) … } }, (line: string, idx: number) => idx.toString()) } ``` `threadLines` 是把树拍平成"缩进字符串"的数组(`:1985-1989` 的注释说明了做法)。 用数组下标做 key,在数组头部插入/删除时会让**所有后续行**被复用成错误的组件。 分块加载(`limit` + `next_offset`,`MailApi.ets:348`)一旦在中间插入,新旧行身份全错。 **修复草案**:key 用能稳定标识一封邮件的字段(`mail_id`), 或至少用 `(mail_id, depth)` 组合。需要先让 `threadLines` 带上标识而不只是字符串。 --- ### M-4 【MEDIUM】鸿蒙 SSE 事件无合并,每个事件全量重拉多账号收件箱 **位置**:`MainPage.ets:3069-3095` ```ts private onGlobalSseEvent = (event: SseEvent): void => { if (event.type === 'new_mail') { …showToast…; MailStore.getInstance().notifyRemoteChange(); return; } if (event.type === 'permission_decision' || event.type === 'session_update' || event.type === 'session_archived') { MailStore.getInstance().notifyRemoteChange(); // ← 三类事件同一个动作 } }; ``` 四类事件**一律**广播 ⇒ 窗格重拉 ⇒ 多账号时逐账号 `GET /me/mail/inbox` 一次。 一批同时到达的事件就是一批全量拉。 ★ **Electron 侧已经做了这件事**,而且做法正好可以抄: `src/lib/inboxFallbackPoll.ts` 用 `total` 当**廉价探针**(`limit=1` 只取计数), 变了才真拉列表(原理见该文件头部注释「为什么轮询只比 `total`」; 接在 `App.tsx:151-157`,判据 `test/inbox-fallback-poll.test.mjs`)。 **这条优化只在 Electron 侧做了,鸿蒙侧没有对应物。** **修复草案**:给 `notifyRemoteChange` 加去抖(例如 300ms 合并窗), 并在 `loadInbox` 里加 `limit=1` 的探针快路径。 判据建议放在 `harmony-logic.test.mjs`:一批同时到达的事件 ⇒ 探针请求数固定,不随事件数线性增长。 --- ### M-5 【MEDIUM】鸿蒙每次 `build()` 新建 `MarkdownController` **位置**:`MailDetailPage.ets:812` ```ts private mdController(): MarkdownController { const c: MarkdownController = new MarkdownController(); c.setTextColor(Theme.textPrimary); … ``` `build()` 每次重渲染都调它。`build()` 的调用频次由状态决定, **主题切换**(`@StorageProp('agentmail.appearance.revision') @Watch`)会整页重渲染一次。 控制器是纯样式对象,每次新建 = 每次重新解析一遍样式。 **修复草案**:改成 `@State` 私有字段 + 主题变化时才重建(或 `@Provide` 一份全局的)。 ⚠️ 注意 ArkTS 的 `@State` 装饰器对 class 类型的约束,先确认能编译。 --- ### M-6 【MEDIUM】鸿蒙日历 `loading` 不在 `finally` **位置**:`CalendarPage.ets:499-520`(`loadEvents()`) ```ts this.loading = true; // :504 this.error = ''; try { const resp = await a.listEvents(this.rangeFrom(), this.rangeTo()); this.events = resp.events; } catch (e) { this.error = e instanceof ApiError ? … : '日程加载失败'; this.events = []; } this.loading = false; // :517 —— 在 try/catch 之后裸写 /* 事件拉完再拉农历(不 await:农历是附加信息,不该拖慢事件列表的出现) */ this.loadLunar(); ``` `this.loading = true` 与 `this.loading = false` 之间的代码**全部**在 `try` 内, 且 `rangeFrom()` / `rangeTo()` 也是同步调用(`:507` 在 `try` 里), 所以**今天不会真的卡住**。 **修复草案**:`try { … } catch { … } finally { this.loading = false; }`, `loadLunar()` 放在 `finally` 之后(保持"不 await"的既有语义)。 --- ### E-1 【MEDIUM】Electron 审批闸门仍是异步 state,缺同步 ref **位置**:`client/electron/src/components/MailView.tsx:780`(`PermissionPanel` 的 `submit`) ```tsx const submit = async (decision: string, noteText: string) => { setSubmitError(''); setBusy(true); try { await api.decidePermission(mail.mail_id, decision, noteText || undefined); ``` `PermissionPanel` 里的 `@State`(`:730-741` 附近)有 `note` / `busy` / `decided` / `picked` / `staleWarning` / `submitError`,**没有 `useRef`**。 `disabled={busy}` 挡得住渲染后的点击,但 React 提交前的那一帧里 两次快速激活(双击、Enter 连按、鼠标+键盘同时)仍会打出**两条** `decidePermission`。 对审批来说这不是"多点一次"的问题:服务端会**记账两次**,Agent 侧收到的通知也两条。 ★ **同文件的 `ForwardBar` 已经解决了一模一样的问题**(`:393` `inFlight = useRef(false)`, `:400` `if (!to.trim() || inFlight.current) return;`)。照抄那份形状即可。 **修复草案**: ```tsx const inFlight = useRef(false); const submit = async (decision: string, noteText: string) => { if (inFlight.current) return; inFlight.current = true; setSubmitError(''); setBusy(true); try { … } catch { … } finally { inFlight.current = false; setBusy(false); } }; ``` **验判据**:`test/components/PermissionPanel.test.tsx` 补一格 「同一帧内两次激活只发出**一条** `decidePermission`」。 ★ 该文件已存在(`test/components/PermissionPanel.test.tsx`),不是新建。 --- ### E-2 【MEDIUM】Electron 附件连续选取会丢前一次的选择 **位置**:`client/electron/src/components/Attachments.tsx:70-94` ```tsx async function handleFiles(files: FileList | null) { … for (const file of Array.from(files)) { // 逐个上传,可能几秒 setUploading({ name: file.name, pct: 0 }); const r = await api.uploadAttachment(file, pct => setUploading({ name: file.name, pct })); added.push({ … }); } setUploading(null); if (added.length > 0) onChange([...items, ...added]); // ← items 是渲染闭包里的 if (inputRef.current) inputRef.current.value = ''; // ← 上传**完成后**才清 } ``` `uploading` 只加在**按钮**的 `disabled` 上(`:121` `disabled={disabled || uploading !== null}`), `` 本身(`:105-111`)**没有任何闸门**, 而 `inputRef.current.value = ''` 要等**上传全部完成**才执行。 用户在第一个文件还在传的时候再选一次文件 ⇒ 第二次的 `handleFiles` 拿到的是 **旧的 `items` 闭包** ⇒ `onChange([...旧items, ...第二次])` **把第一次的结果整批覆盖掉**。 且因为 input 还没清,同一个文件重复选也不报错 ⇒ 表现就是"选的附件少了",无任何提示。 `onChange` 的签名是 `(next: PendingAttachment[]) => void`(`:61`), 不是函数式更新,所以**改签名会影响所有调用点**(`ComposePage`、`ReplyBar`)。 **修复草案(推荐第一种,不动签名)**: ```tsx const picking = useRef(false); async function handleFiles(files: FileList | null) { if (!files || files.length === 0) return; if (picking.current) return; // ← 同步闸门,挡住上传期间的重入 picking.current = true; if (inputRef.current) inputRef.current.value = ''; // ← 提前清,别等上传完 try { … if (added.length > 0) onChange([...items, ...added]); } finally { picking.current = false; } } ``` ⚠️ 若选"提前清 input"要确认不会影响"上传失败后重选同一个文件"这个既有行为 —— 现在这个行为是靠末尾那句 `value = ''` 提供的。**改之前先读一遍失败路径。** --- ### X-1 【MEDIUM · 安全】Electron 主进程零导航/新窗口拦截 **位置**:`client/electron/electron/main.cjs`(204 行) 实测 `grep will-navigate|setWindowOpenHandler|session.webRequest` **命中 0**。 `webPreferences` 本身是**对的**(`:47-49` `contextIsolation: true` / `nodeIntegration: false` / `sandbox: true`),preload 只暴露窄接口, token 走主进程文件 + `additionalArguments` 而**不落 localStorage** (理由见 `main.cjs:139-148`),`accounts.json` 原子写(tmp + rename)+ `0600` + 解析失败时**不覆盖**(`main.cjs:159-173`)。 **这份安全姿态是本工程最扎实的部分之一,不要动它。** 缺的是最后一道:默认 `loadFile(dist/index.html)` 下, 邮件正文里的链接(`react-markdown` 会渲染 ``)点下去会 **在应用窗口里导航走**。渲染层的 Markdown 纪律很好(无 `dangerouslySetInnerHTML`、无 `rehype-raw`), 所以**目前没有 XSS 面**;但一个 `href="https://…"` 就能把整个应用窗口变成浏览器 —— 窗口标题、preload 注入的 API base/token 全部暴露在陌生站点上。 **修复草案**: ```js const { shell } = require('electron'); const openExternal = (url) => { if (/^https?:\/\//i.test(url)) shell.openExternal(url); // 只放行 http(s) else e.preventDefault(); }; mainWindow.webContents.setWindowOpenHandler(({ url }) => { openExternal(url); return { action: 'deny' }; }); mainWindow.webContents.on('will-navigate', (e, url) => { e.preventDefault(); openExternal(url); }); ``` (`will-navigate` 的 `e` 要在 handler 签名里拿对,草案里的 `preventDefault` 传参需调整。) **另**:`main.cjs` 没有 `requestSingleInstanceLock()` —— 双击图标会起两个进程、两个窗口、两个 SSE 连接、两套 `accounts.json` 写入。 这是 5 行的事,建议同一批改。 **验判据**:`test/packaging.test.mjs` 已存在,可加一格静态断言 「`main.cjs` 含 `setWindowOpenHandler` 且含 `will-navigate`」 —— 静态可机检、适合钉住不退化(按 §4.2 的"取结构体"写法)。 --- ### X-2 【LOW · 加固】`index.html` 无 CSP **位置**:`client/electron/index.html` 全文件没有任何 ``。 当前**无实际漏洞**(无 `dangerouslySetInnerHTML`、无 `eval`、 动态 `src` 只有本地 data URL),但这是**唯一一道会在有人加一行 `dangerouslySetInnerHTML` 之后消失的防线** —— 而 `sandbox: true` + `contextIsolation: true` **不会**替你挡渲染层里的注入 (那两处只挡 node 与跨上下文)。 ⚠️ 首帧防闪屏那段内联脚本(`index.html:31-61`)**要求** `'unsafe-inline'` (它必须同步执行,早于任何外链),所以 CSP 要写成 `script-src 'self' 'unsafe-inline'`,**不要**写成严格 CSP。 `test/theme.test.mjs` 逐字符断言 `:54` 是 `document.documentElement.classList.add('dark');` ——单引号;`index.html:42-43` 的注释已经写了「引号别顺手统一成双引号, 曾经被 prettier 改写过一次,直接让测试变红」。 --- ### L-1 【LOW】鸿蒙孤儿页与 Hello World 路由仍在 **位置**:`client/harmony/entry/src/main/resources/base/profile/main_pages.json` 仍含 `pages/Index`(DevEco 模板 "Hello World",38 行)。 `pages/InboxPage.ets`(262 行)与 `pages/SessionsPage.ets`(170 行)仍在树上: 按路由表现状,`SessionsPage` 被 `InboxPage.ets:171` push, 而 `InboxPage` 自己**不在**路由表里 ⇒ 两者一起不可达。 `InboxPage` 里那份 `loadInbox()` + 未读计数与 `MainPage.InboxTab` 重复。 已登记 `harmony-dead-pages`(count=2,到期前提「下次清理鸿蒙页面时」)。 清理前**先确认**它们不是将来要用的留存实现 —— 旧报告已提醒过,仍然有效。 --- ## 3. 两端共同的空白(不在任何 review 里) ### G-1 无障碍:接近于零 | 指标 | Electron | HarmonyOS | |---|---|---| | 交互元素 | `