Files
MailUI4Agents/docs/reviews/harmony-pages-review.md
JianFeeeee 2530229180 docs(审查): 归档本轮四份代码审查报告
`docs/reviews/` 此前一直是**未跟踪**状态 —— 审查报告只在磁盘上,
不进版本库 ⇒ 换机器、换会话、给别人看时全部拿不到,
而它们正是本轮五个修复(hap 出库 / SSE 写锁 / 换身份清数据 /
鸿蒙门禁三态 / HMS 配额)的**来源**。

  push-and-gui-review.md     推送链 + Electron GUI(HMS 配额那两条)
  electron-gui-review.md     换身份不清数据
  harmony-client-review.md   鸿蒙:门禁 fail-open / MailStore 快照共用 / clear() 零调用方
  harmony-pages-review.md    页面层
  harmony-state-review.md    状态层
  fix-report-2026-09-26.md   上述修复的实施记录

其中 `harmony-client-review.md` §三.1 记的那条值得单独留意:
该报告自己声明「ArkTS 语言规范层面零违规,本文所有问题都是**逻辑缺陷**」——
本次提交的三处鸿蒙改动也只动逻辑(门禁条件、logout 清理),
不碰语法层。
2026-09-28 08:46:02 +08:00

232 lines
28 KiB
Markdown

# HarmonyOS ArkTS client review — `client/harmony/entry/src/main/ets/pages/`
Scope: `MainPage.ets`, `MailDetailPage.ets`, `CalendarPage.ets`, `SettingsPage.ets`, `ComposePage.ets`,
`ContactsTab.ets`, `AdminUsersPage.ets`, `PermissionTab.ets`, `LoginPage.ets`, `InboxPage.ets`,
`SessionsPage.ets`, `PermissionPanel.ets`, `WideSidebar.ets`, `Index.ets`, `NavDestinations.ets`, `NavShared.ets`.
---
### CRITICAL (will crash / data loss / security hole)
**`AdminUsersPage.ets:319` — the admin gate fails OPEN: any `GET /me` failure renders the full admin console to a non-admin.**
```ts
if (this.roleKnown && !this.isAdmin) {
```
`loadRole()` sets `roleKnown = false` on *any* rejection (`:113-116` — network error, expired token, 500, DNS failure). The render condition only shows the "not an admin" wall when `roleKnown === true`. So the fail-open path is: request fails ⇒ `roleKnown === false` ⇒ `else` branch ⇒ full user list, create-user form, edit/delete/password-reset. Worse, `aboutToAppear` fires `loadRole()` and `load()` **un-awaited and in parallel**, so on the very first frame `roleKnown` is `false` and the admin UI is already mounted before the role check resolves. The file's own header at `:100-103` states the intended rule — "不能把'读不到'当成'是管理员'" — and the code does exactly the thing that comment forbids. The client-side gate is cosmetic anyway (the server must enforce it), but this renders a privileged-looking console and lets a non-admin fire `createUser` / `updateUser` / `resetPassword` requests; any of those endpoints that trusts the client's page visibility will 200. Minimum fix: gate on `if (!this.roleKnown)` → show a "checking…" state and return; show the wall only when `roleKnown && !isAdmin`.
No `any`/`unknown`, no destructuring, no regex literals, no `Object.assign`, no `for...in`, no `@ts-ignore`, and no V1/V2 decorator mixing were found in any in-scope file. No member name collides with an ArkUI universal attribute. No `null` is passed as a function argument without narrowing. The ArkTS grammar itself is clean throughout — the defects below are logic, not syntax.
---
### HIGH (real bug, will be hit in production)
**1. `MailDetailPage.ets:1939` — a `mail_type` value that does not exist makes the Enter-key guard dead code.**
```ts
if (this.mailType === 'permission' && this.permissionResult.length === 0) {
```
The only values the server ever emits are `'normal'`, `'permission_request'` (`repo.go:455-461` and `client/electron/src/types/index.ts:110`) and the internal control-plane `'permission_decision'`. `'permission'` appears exactly once in the entire ArkTS client and matches nothing. Consequence: the guard never fires, so the detail page's root `onKeyEvent` always treats Enter as "open reply" — including on a mail that is an *undecided permission request*, where the correct action is answering the panel. The surrounding `MainPage.ets:4263-4274` dispatch then routes Enter to `ReplyIntent.request()` → `openReplyWithMorph()`, so the user gets a reply box over a decision they were supposed to make. Every other permission check in the codebase correctly uses `'permission_request'` (e.g. `PermissionTab.ets:175`, `MailGrouping.ts` `isPendingPermission`).
**2. `MainPage.ets:2759` — the inner `setTimeout` of the topbar carousel is never tracked, so it fires after teardown.**
```ts
this.topTimer = setInterval(() => {
...
setTimeout(() => {
this.topIndex = (this.topIndex + 1) % this.topbarTexts().length;
ui.animateTo({...}, () => { this.topOpacity = 1; });
}, TOPBAR_FADE_MS);
}, TOPBAR_ROTATE_MS);
```
`stopTopbarRotation()` (`:2768-2773`) clears **only** `this.topTimer` (the interval). The `setTimeout` handle is discarded, so on `aboutToDisappear` the pending callback still runs: it writes `this.topIndex` and `this.topOpacity` on a component that is being destroyed, and calls `ui.animateTo` on a stale `UIContext`. Second, independent defect in the same block: there is **no re-entrancy guard**. `TOPBAR_ROTATE_MS` is 5500 and `TOPBAR_FADE_MS` is 260, so under normal conditions timers don't overlap — but any stall longer than one cycle (main-thread jank, a `JSON.parse` of a large inbox payload, app backgrounding) lets the next interval tick schedule a second fade while the first is still pending, and `topIndex` advances twice with a single visible fade, so a quote is silently skipped. Store the timeout handle alongside `topTimer` and clear both in `stopTopbarRotation`; guard the body with a "fade in flight" flag.
**3. `MainPage.ets:2154` — `closeDetail()` is dead code, so `KEY_OPEN_MAIL_ID` survives a system back and permanently misroutes Enter.**
```ts
private closeDetail(): void {
this.currentMailId = '';
AppStorage.setOrCreate<string>(KEY_OPEN_MAIL_ID, '');
}
```
Grep confirms the definition at `:2154` is the only occurrence in the repository — nothing calls it. The key is written by `openMail` (`:2144`) and is read by the root key dispatcher at `:4263-4274`, which decides "am I looking at a mail? → `ReplyIntent`; else → `ComposeIntent`". The only place it gets cleared is the in-app back chevron's `onBack` callback inside `NavDestinations.ets:54-57`. There is **no `onPop` callback registered on either `Navigation`** (verified: `MainPage.ets` and `ContactsTab.ets` only call `.navDestination(...)`, no `onPop` / `onNavBarStateChange`). So hardware back button and the back-swipe gesture pop the stack directly, bypassing that callback. Result: after system-back out of a detail page, `KEY_OPEN_MAIL_ID` still holds the old id, so pressing Enter on the inbox list opens a **reply box for a mail you are no longer viewing** instead of the compose page. `this.currentMailId` has the mirror problem — it is never reset, so the previously-read row keeps its highlight and `groupHasActive()` (`:741`) keeps painting the accent border on a group you have left.
**4. `MainPage.ets:2081-2083` / `:4235-4243` — `KEY_COMM_STACK_DEPTH` is published on push but never on system pop, so Esc is swallowed forever after.**
```ts
private publishStackDepth(): void {
AppStorage.setOrCreate<number>(KEY_COMM_STACK_DEPTH, this.navPathStack.size());
}
```
Call sites are `openMail` (`:2145`), `openComposeWith`, the tab-bar `clear()` (`:1947`) and the `PopIntent` listener (`:1708`). The `PopIntent` listener is the *only* place a decrement can happen — and it only runs when Esc itself is pressed. Push a detail, then dismiss it with the system back button: the real stack is empty, but the published depth is still 1. The next Esc reads `depth > 0`, fires `PopIntent.request()`, `navPathStack.pop()` is a no-op on an empty stack, and the handler `return true`s (`:4239`) — consuming the key. From then on Esc can never be allowed through to the system, so the user cannot exit the app with the keyboard. `publishStackDepth()` must be driven by an actual pop observation (a `NavDestination` `onHidden`, or re-publishing whenever the stack changes), not only from the Esc path.
**5. `ContactsTab.ets:202-221` — `openSession()` has no request token, so a slower earlier tap overwrites a later one.**
```ts
async openSession(c: Contact): Promise<void> {
...
this.openSessionId = c.session_id;
this.openSessionTitle = c.subject.length > 0 ? c.subject : ...;
this.sessionMails = [];
this.sessionLoading = true;
try {
this.sessionMails = await m.sessionMails(c.session_id);
} catch (e) { ... } finally { this.sessionLoading = false; }
}
```
Tapping contact A then quickly contact B leaves two requests in flight. If A's response lands second (slow account, cold connection), the assignment overwrites B's list while the header still reads `openSessionTitle` from B — the panel shows B's name and subject above A's mail list, and `openSessionId` (used by `confirmArchive` at `:181` to decide whether to clear the selection) points at B while the visible content is A's. There is no sequence number, no abort, and no post-await re-check that `c.session_id === this.openSessionId`. `this.sessionLoading` is also cleared by whichever request finishes first, dropping the spinner while the other is still pending.
---
### MEDIUM (correctness or robustness gap)
**6. `MailDetailPage.ets:113` — `autoReadMailId` is declared with a 7-line comment about deduplication but is never read or assigned, so the dedupe it describes does not exist.**
```ts
private autoReadMailId: string = '';
```
Verified: the identifier occurs only on this line. The real protection is `doMarkRead`'s `if (this.status !== 'unread') { return; }` (`:596-598`), and `this.status` is only set to `'read'` **after** the `await this.mailApi.markRead(...)` resolves (`:604-605`). So the guard is not a re-entrancy lock: two overlapping triggers — the retry button at `:1231` (`.onClick(() => { this.loadMail(this.mailId); })`) tapped twice, or `loadMail` racing an SSE-driven reload — both pass the `status !== 'unread'` test before either `markRead` resolves, and fire two `POST` requests. The server treats mark-read as idempotent so the outcome is correct, but the duplicate is real and the field that was written to prevent it is dead. Either set the field and compare against it, or set `this.status = 'read'` optimistically before the await and roll back on failure.
**7. `MailDetailPage.ets:2008-2012` and `:2044-2048` — `string | null` locals compared against `null` guard nothing, because the `@State` they copy is typed `string` and initialised to `''`.**
```ts
const sessionId: string | null = this.sessionId;
if (proposal === null || sessionId === null || this.renameBusy) {
return;
}
```
`this.sessionId` is `@State sessionId: string = ''` — it can never be `null`, so the widened type is fiction and the check is always false. The empty-string case falls straight through and `acceptRename('', alias)` / `dismissRename('')` are issued against a session that does not exist. The narrowing is written as if the field were optional, which is the signature of a missed rename: the `@State` declaration, not the guard, is what should have been `string | undefined` (ArkTS has no `null`, so `''` is the sentinel and the guard must test `.length === 0`). Same shape in `doDismissRename`.
**8. `WideSidebar.ets:97-98` — the comment claims a subscription to SSE status changes that does not exist; the status dot is a one-shot snapshot.**
```ts
aboutToAppear(): void {
this.refreshSseStatus();
}
```
The header comment at `:92` says "read once in `aboutToAppear` **+ subscribe to changes**", and `SseService` provides `addStatusListener` / `removeStatusListener` for exactly this. No `addStatusListener` call exists anywhere in the file. `refreshSseStatus()` is never called again, so the connection indicator is frozen at whatever it read during mount: it shows "connected" through a dropped connection and "disconnected" after a successful reconnect, indefinitely, until the whole app is restarted. The missing `removeStatusListener` would also leak the component if the call were added without the teardown.
**9. `Index.ets:1-38` — the untouched DevEco "Hello World" template ships as a registered route.**
```ts
@Entry
struct Index {
build() {
Column() { Text('Hello World') ... }
}
}
```
`resources/base/profile/main_pages.json` registers `pages/Index` alongside the real pages. It is a reachable-by-router page that renders template placeholder text in a shipping app. Delete it and drop it from `main_pages.json`.
**10. `Index.ets` route table also carries two more orphans.** `pages/InboxPage` is not in `main_pages.json` at all **and** nothing pushes to it — verified by grepping every `pushUrl` in the tree, the only references to `pages/InboxPage` are its own definition. It is therefore fully dead code, but it is a substantial `@Entry` page (~262 lines) with its own `loadInbox()` that duplicates `MainPage.InboxTab`'s fetching and its own unread-counting. `pages/SessionsPage` is reachable only from that dead page (`InboxPage.ets:171`). Both duplicate logic that already exists in `MainPage`, and both are where the `e as ApiError` issue below lives.
**11. `InboxPage.ets` / `SessionsPage.ets` — `e as ApiError` is trusted without narrowing, so a non-`ApiError` rejection throws a second `TypeError` inside the error handler.**
```ts
catch (e) {
const ae = e as ApiError;
this.error = ae.message.length > 0 ? ae.message : '加载失败';
}
```
`ApiClient` throws `ApiError` for HTTP failures, but the same `catch` also receives the rejections of internal helpers — a `JSON.parse` `SyntaxError` inside `Models.normalize()`, a `preferences` store failure, a `TypeError` from an unexpected shape. In every one of those cases `ae.message` is either `undefined` or a non-string, and `.length` on it throws. The result is that the page's own error path crashes instead of showing a message. `AdminUsersPage` shows the correct shape for comparison — `messageOf(e)` (`:161-163`) tests `e instanceof ApiError` before touching `.message`. Both files are dead routes today, which is the only reason this has not surfaced.
**12. `MainPage.ets:38` — an unused import of the built-in detail view signals a half-finished refactor.**
```ts
import { MailDetailView } from './MailDetailPage';
```
`MailDetailView` is referenced nowhere in the file; detail rendering goes through `MailDetailDestination` (`:2181`) from `NavDestinations.ets`. The dead import drags the whole `MailDetailPage` module into `MainPage`'s dependency graph and, more importantly, tells a reader the built-in view is still the path. `NavDestinations.ets` has the mirror issue with `PaneModifier`.
**13. `MainPage.ets:455` — `InboxTab.openCompose()` still uses the old full-page `pushUrl` path while its sibling was migrated to the nav stack.**
```ts
this.getUIContext().getRouter().pushUrl({ url: 'pages/ComposePage', params: params });
```
`CommPage.openCompose()` (`:1839`) delegates to `openComposeWith()` (`:2225`) which uses `navPathStack.pushPath`, so compose opens in the right pane on wide screens. `InboxTab.openCompose()` is the pre-migration version. It is currently **unreachable** — `InboxTab` uses the injected `onOpenCompose` prop (`:2225`), never this method — so the `onOpenCompose` prop declared at `:204` is itself vestigial. It is one stray call site away from regressing the wide-screen layout, and it is the reason the vestigial prop was not removed.
**14. `MailDetailPage.ets:1982-1991` — the conversation-tree sheet flattens the tree into indented strings and keys the `ForEach` by array index.**
```ts
ForEach(this.threadLines, (line, idx) => { ... }, (line, idx) => idx.toString())
```
Two issues. (a) The key is the index, so a re-fetch that changes the node count makes ArkUI treat every row as new — it destroys and rebuilds all rows, and any in-progress scroll offset is lost. (b) The tree is reconstructed purely by `indent += ' '` per `t.depth` (`:693-696`) inside a plain `lines: string[]`; the `ThreadNode`'s identity, `parent_mail_id` and `mail_id` are all discarded at that point. On a long or deeply-replying thread this produces a long run of `Text` rows with no per-row data, and depth is a server-computed integer that is not clamped here — a bad `depth` (or a negative one, which the server does not validate) makes the loop either emit a negative count of spaces or spin. The `ThreadApiResponse` shape is already flat with a `depth` field, so the simplest fix is to keep `ThreadNode[]` and render with a real key on `mail_id`.
**15. `SettingsPage.ets:483-497` — the appearance snapshot is mutated in place before the server `PUT`, and a failed `PUT` never rolls back, so the theme silently reverts on next cold start.**
```ts
const snap: AppearanceSnapshot = store.current(); // live reference, not a copy
snap.theme = theme; // mutates the singleton
```
`AppearanceStore.current()` (`common/AppearanceStore.ets:200-202`) returns `this.snapshot` by reference. The mutation therefore lands on the shared singleton immediately. On failure the handler sets `appearanceStatus = 'local-only'` but never calls `store.saveLocal`, so the persisted copy on disk still holds the old theme while the in-memory one holds the new one — the user sees the new theme until the app restarts, then it is back. `MainPage.applyThemeNow` (`:3024-3048`) does the right thing by calling `store.saveLocal` on both paths; the settings page should mirror that, or clone the snapshot before mutating.
---
### STRESS (plausible under load/concurrency)
**16. `MailStore.ets:446` and `:593` — `loadInbox` and `loadSent` write the same `this.snapshot`, so the two tabs clobber each other's data.**
```ts
async loadInbox(ctx, accountFilter) { const snap = this.snapshot; ... snap.mails = merged; ... }
async loadSent(ctx, accountFilter) { const snap = this.snapshot; ... snap.mails = merged; ... }
```
Both alias the **same** `MailSnapshot` and assign `mails` / `groups` / `loaded` / `unread` / `loading`. `loadSent` sets `snap.unread = 0` and rebuilds `groups` with the sent-folder grouping, so an in-flight `loadSent` that resolves after an `loadInbox` leaves the inbox rendering sent-folder data. `MainPage.ets:1947` mitigates the common case by calling `this.navPathStack.clear()` on tab switch (only one tab is mounted at a time), but nothing serialises the in-flight promise: an `loadSent` started, then a `notifyRemoteChange()` SSE event landing mid-flight, calls `InboxTab.onMailRevChanged` → `loadData()` → `loadInbox()` while `loadSent` is still awaiting. Whichever resolves last wins. Needs a generation counter checked after the await, or separate snapshot objects per view.
**17. `MainPage.ets:2917-2920` — every SSE event type triggers a full multi-account inbox refetch, with no coalescing or debounce.**
```ts
if (event.type === 'permission_decision' || event.type === 'session_update'
|| event.type === 'session_archived') {
MailStore.getInstance().notifyRemoteChange();
}
```
`notifyRemoteChange()` publishes `KEY_MAIL_REV`, and the mounted pane's `onMailRevChanged` calls `loadData()` → `MailStore.loadInbox()` (`:446`), which loops over **every** account and issues one `GET /me/mail/inbox` per account (`:477-487`). A busy session firing twenty `session_update` events in a minute produces twenty full multi-account refetches with no debounce, no in-flight suppression and no "already loading" check — `loadInbox` has no `if (snap.loading) return` guard, so they also queue up rather than collapsing. With three accounts that is up to sixty redundant requests. The fix is a short debounce on the revision handler plus an early-out in `loadInbox` when a fetch for the same filter is already in flight.
**18. `MailStore.ets:216-244` — `markReadLocal` mutates `snapshot.mails` in place and can be silently undone by a concurrent `loadInbox`.**
```ts
markReadLocal(mailId: string): void {
const snap = this.snapshot;
for (let i = 0; i < snap.mails.length; i++) {
const m: MailLike = snap.mails[i];
if (m.mail_id === mailId && m.status === 'unread') { m.status = 'read'; changed = true; }
}
```
This mutates a shared singleton that an in-flight `loadInbox` will wholesale overwrite at `:568-575` when its response lands. Sequence: user opens an unread mail, detail calls `markRead` → `markReadLocal` flips the row locally; the SSE `new_mail` event that the server emits for that same action then fires `notifyRemoteChange` → `loadInbox` → the response may still carry `unread` for that id (the server's own write and the list query are not in one transaction) ⇒ the row flips back to unread in the list while the detail page shows it read. The local-only optimisation has no reconciliation against the refetch. Carrying a set of locally-applied read ids and re-applying them after the load would close it.
**19. `CalendarPage.ets:1312-1322` vs `:1344` — the same event is placed on the grid by one time base and labelled by another.**
```ts
// placement (device-local):
if (new Date(e.event_time).getHours() === hour) { ... }
// label (user-configured offset):
Text(hhmmAtOffset(e.event_time, this.offsetMinutes))
```
`hourEvents(hour)` buckets an event into a row using the **device's** local hour, while `EventRow` prints the same event using the **calendar's** `offsetMinutes`. A user whose calendar is set to a zone other than the device's sees every event drawn in one row and captioned with a time from another. `nowHour()` (`:1328`) also uses device-local `getHours()`, so the "current time" guide line is drawn against device-local hours while every label is offset-aware. Both need to go through the same offset conversion that `hhmmAtOffset` uses.
**20. `CalendarPage.ets:499-520` — `loading` is cleared after the `catch` rather than in a `finally`, so a throw from outside the `try` strands the spinner.**
```ts
this.loading = true;
try { ... } catch { this.error = ...; this.events = []; }
this.loading = false;
this.loadLunar();
```
`this.loadLunar()` at `:520` is a separate call whose own failure mode is unrelated, and the assignments at `:519` are on `@State` — a throw from `errorTextOf` or a listener notification inside the assignment strands `loading === true` with no error message, i.e. a permanent spinner. The sibling mutators `saveEvent` (`:993`), `deleteEvent` (`:1036`) and `toggleStatus` (`:1060`) have the identical shape. `PermissionTab.load` (`:189`) and `AdminUsersPage.load` (`:133`) use `finally` correctly — the calendar page is the outlier.
**21. `ContactsTab.ets:416` — index-based `ForEach` key over a mutable contact list.**
```ts
ForEach(this.contacts, ..., (_c: Contact, idx: number) => idx.toString())
```
Every list in this codebase keys `ForEach` on a stable identity (`m.source_account_id + ':' + m.mail_id` at `MainPage.ets:690`, `user.user_id` at `AdminUsersPage.ets:380`, `g.key` at `:708`). This one keys on position. `confirmArchive` (`:180`) removes an element from the middle of the array via `filter`, which shifts every later index — ArkUI reuses the wrong row components, so the list visibly reorders/mismatches after any archive until a full remount. `c.session_id` is already available and unique here.
**22. `MailDetailPage.ets:812-840` — a fresh `MarkdownController` is constructed on every `build()`.**
```ts
private mdController(): MarkdownController {
const c: MarkdownController = new MarkdownController();
c.setTextColor(...); // ~17 setter calls
...
}
```
Called from `Markdown({ text: this.body, controller: this.mdController() })` at `:1324`, which is inside `build()`. The design is deliberate and the comment at `:802-807` explains why (the controller must be rebuilt so a theme change is picked up, because it is a plain object ArkUI does not observe). But it means every re-render of the detail page — scroll-driven state changes, any `@State` write, the auto-mark-read `status` flip at `:604`, a theme toggle — allocates a controller and makes seventeen setter calls on the main thread. On a long mail body that markdown component also re-parses the full text each time. The theme case can be handled by keeping one controller and re-applying colours from a `@StorageLink`-driven path, and the parse can be pinned to the mail id.
---
### FALSE POSITIVES YOU RULED OUT (brief, so the lead can double check)
- **`MainPage.ets:2782-2804` `loadTopbar()` — un-`catch()`ed promise chain.** `AccountManager.load()` has a total `try/catch` (`AccountManager.ets:49-62`) and never rejects; `TopbarStore.refresh()` (`TopbarStore.ets:110-127`) also catches internally and returns `null`. Neither `.then()` can produce an unhandled rejection. **False positive.**
- **`MainPage.ets:372` `loadSent` setting `unread = 0`.** Looks like a logic inversion but is correct — the sent folder has no unread concept, and `loadInbox` recomputes the badge on the next load.
- **`CalendarPage.ets:335` `Date.UTC(this.year, this.month - 2, 1)`.** `month` is 1-12 so the index can be `-1` (January). That is intentional overflow arithmetic for "start of the month before", and `Date.UTC` normalises it correctly. Not an off-by-one.
- **`MailDetailPage.ets:674-711` `openThread()` reading `resp.nodes` directly.** `ThreadApiResponse` **is** the flat `ThreadPage`; the server has no `thread` wrapper. The comment at `:680-685` records that the earlier guess was corrected against the real shape. The flattening itself is a MEDIUM (#14) but the field access is right.
- **`MailDetailPage.ets:305` `loadMail` calling `doMarkRead()` which reads `this.mailId` rather than the `mailId` parameter.** I traced every assignment of `this.mailId` (`:240`, `:244` only, both in `aboutToAppear`) and confirmed each `MailDetailDestination` instance is created fresh per `pushPath` with its own params, so `this.mailId` is stable for the life of the instance. Not a stale-parameter bug.
- **`CalendarPage.ets:186` `@Prop @Watch('onVisibleChanged') visible` with the self-call at `:302`.** Self-invoking a `@Watch` handler by name is legal and is the documented pattern for handling the initial value; the guard at `:313` makes the `aboutToAppear` call a no-op when hidden. Fine.
- **`.onClick` handler signatures.** Every `.onClick` in all 16 files takes zero parameters (checked by grep across `MainPage`, `CalendarPage`, `ContactsTab`, `AdminUsersPage`, `LoginPage`, `PermissionTab`, `PermissionPanel`, `ComposePage`, `MailDetailPage`, `SettingsPage`) — no `ClickEvent`/`GestureEvent` confusion anywhere.
- **`.fadingEdge(true, {...})`, `.attributeModifier(...)`, `.geometryTransition(...)`, `bindSheet`/`bindMenu` with `$$` two-way binding, `PageTransitionEnter/Exit` in `pageTransition()`.** All genuine API usages, not fabricated modifiers. No `List({gap})`, no `.bgColor()`, no `.textSize()`, no `TextAlign.CENTER` (the codebase uses `TextAlign.Center`), no `Switch(...)` component, no `ScrollEdgeEffect` anywhere in scope.
- **Markdown rendering of remote email bodies (`MailDetailPage.ets:1324`).** I checked for a WebView/`loadUrl`/`innerHTML` path across `pages/` and `common/` — there is none. The body goes through the native `@luvi/lv-markdown-in` component with an explicit controller, so remote HTML is not executed. No XSS via WebView.
- **`ComposePage.ets:229` bare `catch {`.** Intentional and correct: an unbound catch binding is the ArkTS-legal way to swallow, since binding `e` would make it `any` and trip `arkts-no-any-unknown`. Not a missing-`any` violation.
- **`AdminUsersPage.ets:114` keeping `roleKnown = false` on `loadRole` failure.** The *storage* of that state is the documented deliberate choice; the **bug is the render condition at `:319` that consumes it fail-open** (reported as CRITICAL). The state itself is not the defect.
- **`MainPage.ets:2077-2079` comment about `NavPathStack` having no change callback.** Verified accurate: the framework's `Navigation` exposes `onNavBarStateChange` / `onNavigationModeChange`, neither of which reports stack depth. This makes HIGH #4 a genuine design gap rather than an oversight the author could have trivially avoided.
- **`PermissionTab.ets:252-258` `isStale` and the absent "已失效" branch on history rows.** Both match WebUI's `PermissionList.tsx:249-312` exactly, and the server's `AttachPermissionDeadline` genuinely returns early for already-decided rows, so the history branch would indeed be dead. The deliberate omission is correct, not a missing feature.
- **`PermissionTab.ets:38-48` duplicate local `compactMailTime`.** Duplicated with `NavShared.compactMailTime`, but the two are used in different modules and I found no behavioural divergence between them. Drift risk, not a current defect.
- **`InboxTab`/`SentTab` reading the same `MailStore.snapshot` by design.** The singleton is deliberate (`MailStore.ets:147-153` explains why) and the one-tab-at-a-time `if` in `MainPage.build()` keeps them from coexisting in the common case. The defect is only the *concurrent* write race (#16), not the sharing itself.
- **Dead imports in `MainPage.ets` (`ApiError`, `ComposeView`, `Contact`, `DecideResponse`, `budgetLabel`, `budgetState`, `groupMailsBySession`, `partialLoadNotice`, and ~12 others), `CalendarPage.ets` (`weekdayNameOf`), `ComposePage.ets` (`filterIndexes`), `ContactsTab.ets` (`AccountInfo`), `PermissionPanel.ets` (`AmIcon`, `Motion`), `NavDestinations.ets` (`PaneModifier`).** Style noise, not reported as findings. I mention only `MailDetailView` (MEDIUM #12) because it is load-bearing evidence of a half-finished refactor.