两份审查报告(`docs/reviews/electron-gui-review.md` /
`harmony-client-review.md`)里两条**数据隔离**缺陷。
## ① 换身份不清数据 ⇒ 在新账号的界面下显示旧账号的邮件
`setActive` 之后 `api/config` 单例里的 API_BASE 与 bearer 就翻到了新账号,
于是此后每个请求都带**新账号**的凭证。而各 store 里还留着**旧**账号的:
· mailStore.sent / currentMail
· sessionStore.sessions / currentSession / currentSessionMails
· contactStore.contacts / archivedContacts
⇒ 肉眼完全看不出来(不报错、不空屏),而**从旧视图发出的写操作**
(归档 / 转发 / 批准权限)改的是**新账号**。
新增 `src/lib/resetAccountData.ts` —— **一处实现,三个入口都调它**:
① 主动切账号(AccountSwitcher.pick)
② 登出(authStore.logout)
③ 任意接口 401(api/client.ts 的 unauthorized 回调 → markAnonymous)
只在 ① 里清是最容易漏的那种做法:② 和 ③ 各自还会重新泄露一次,
而它们都不在切换账号的代码路径上,grep 也找不到。
**身份变化有三条路径,清空也该有三条。**
★ 只碰**数据** store;`uiStore` 的 reset 仍由 App.tsx 负责
(它还要复位窄屏分栏、写信态那些纯界面状态)。
判据:`test/stores/resetAccountData.test.ts`(4 格)。
## ② 鸿蒙管理台门禁**失败开放**(fail-open)
`AdminUsersPage.ets` 原来的条件是 `roleKnown && !this.isAdmin`,
于是 `roleKnown === false`(loadRole() 失败、**身份还没读到**)
落进 else 分支 ⇒ **把完整管理台整个渲染出来**。
一次网络抖动 = 管理入口对所有人可见。
讽刺的是该文件自己的头注释写的就是正确规则
(「不能把读不到当成是管理员」)—— 代码做的正是这条注释禁止的事。
⇒ 改三态:`!roleKnown` 显示「正在确认身份…」、`!isAdmin` 显示墙、
否则管理台。
服务端 `middleware/user.go` 的 `AdminOnly` 仍在,所以**不是越权**;
但非管理员会看到完整用户列表、建号表单、改密入口 ——
属于客户端信息泄露 + 无意义的失败请求风暴。
184 lines
7.1 KiB
Plaintext
184 lines
7.1 KiB
Plaintext
/**
|
||
* 「打开写信」的**跨组件一次性意图**。
|
||
*
|
||
* 用户 2026-09-21:「接下来做一下 2in1 上的快捷键,比如快捷键打开发信页面」。
|
||
*
|
||
* ## 为什么需要这个中间层
|
||
*
|
||
* 快捷键必须绑在**根**(`MainPage` 的根 `Stack`)—— 官方 `keyboardShortcut`
|
||
* 文档说「即使组件未获焦或是在所在页面未展示,只要已经挂载到**获焦窗口**的
|
||
* 组件树上就会响应」,而"窗口级生效"只有绑在窗口组件树的根上才成立。
|
||
*
|
||
* 但 `openCompose()` 住在 `CommPage` 上(写信要 `CommPage` 自己的 `navPathStack`,
|
||
* 宽屏 Split 时才能在右栏开 —— 见 `MainPage.openComposeWith()` 的注释)。
|
||
* 于是 `MainPage` 的快捷键够不着那个方法。
|
||
*
|
||
* ## 做法:照抄本仓已经验证过的"两半"形状
|
||
*
|
||
* `api/PushService.ets` 处理"点通知要跳转"时踩过同一个坑,解法写在它的注释里:
|
||
* · **第一半**:一个静态格子(`pending`)—— 冷启/尚未挂载时,请求先存着;
|
||
* · **第二半**:一个回调(`listener`)—— 已经挂载时,请求当场送达。
|
||
*
|
||
* 缺任何一半都会漏一种情形:
|
||
* · 只有格子 ⇒ 用户此刻就在通信页、页面早挂载完了,`aboutToAppear` 不重跑
|
||
* ⇒ **按了快捷键什么都没发生**;
|
||
* · 只有回调 ⇒ 用户此刻在日历页,`CommPage` 因 `if (currentIndex === 0)`
|
||
* 尚未挂载 ⇒ 没有任何监听者 ⇒ **同样什么都没发生**。
|
||
*
|
||
* ## 谁负责切到通信页
|
||
*
|
||
* `request()` **不管** UI 导航(那是 `MainPage` 的事:它得先把 `currentIndex`
|
||
* 拨到 0,`CommPage` 才会挂载)。这里只管"把意图存住/交出去"。
|
||
* 分开的好处:`MainPage` 里那两行是**唯一**需要读当前 tab 的地方,
|
||
* 而本类保持无 UI 依赖 —— 可被条件挂载的两侧都安全调用。
|
||
*/
|
||
export class ComposeIntent {
|
||
/**
|
||
* 还没人接手的一次请求。
|
||
*
|
||
* 用静态格子而不是 `AppStorage`:这与 `PushService.pendingRoute` 同一理由 ——
|
||
* 发起方(根组件)和接手方(条件挂载的 `CommPage`)之间只隔一次挂载,
|
||
* 不需要一个全局可观察的状态;而用了 `@StorageLink` 反而要处理
|
||
* "读到之后怎么清"(不清会重放,清了会触发另一轮刷新)。
|
||
*/
|
||
static pending: boolean = false;
|
||
|
||
/** 已经在监听的那一处(同一时刻只可能有一处:`CommPage` 是唯一的写信持有者) */
|
||
private static listener: (() => void) | undefined = undefined;
|
||
|
||
/** `CommPage` 挂载时登记 */
|
||
static setListener(fn: () => void): void {
|
||
ComposeIntent.listener = fn;
|
||
}
|
||
|
||
/** `CommPage` 卸载时摘掉(否则会唤醒一个已销毁的组件) */
|
||
static clearListener(): void {
|
||
ComposeIntent.listener = undefined;
|
||
}
|
||
|
||
/**
|
||
* 提"我要写信"。
|
||
*
|
||
* **有人监听就当场交出去;没人监听就存着等挂载后取走** —— 两半都留着,
|
||
* 因为两种时刻各走一条(用户此刻在通信页 / 在别的页)。
|
||
*/
|
||
static request(): void {
|
||
const fn: (() => void) | undefined = ComposeIntent.listener;
|
||
if (fn !== undefined) {
|
||
fn();
|
||
return;
|
||
}
|
||
ComposeIntent.pending = true;
|
||
}
|
||
|
||
/** 取走待处理的请求(取完即清,避免下次挂载时重放) */
|
||
static consume(): boolean {
|
||
if (!ComposeIntent.pending) {
|
||
return false;
|
||
}
|
||
ComposeIntent.pending = false;
|
||
return true;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 「打开回复」的跨组件一次性意图 —— 与 `ComposeIntent` **完全同形**。
|
||
*
|
||
* ★★ 2026-09-24 新增。用户:「邮件详情页回车打开回复」。
|
||
*
|
||
* ── 为何需要它(同样是因为"够不着")──
|
||
* 回车是在**主页根**上收到的(详情页自己的 `onKeyEvent` 因组件不获焦而从不触发,
|
||
* 实测硬证 —— 详见 `MainPage` 根上那段注释),
|
||
* 而"开回复"那个动作(`openReplyWithMorph`)住在 `MailDetailView` 里。
|
||
* 根组件拿不到它的实例 ⇒ 与 `openCompose()` 当初撞到的是**同一个问题**,
|
||
* 所以用**同一个解法**:一个格子 + 一个回调,两半都留着。
|
||
*
|
||
* ── 为什么不直接在根上 setState 传下去 ──
|
||
* `MailDetailView` 是被 `NavDestination` 挂进去的、参数只传 `mail_id/account_id`
|
||
* 那几个 —— 为这个再加一个 `@Prop showReply` 要改三层(壳→页→回调),
|
||
* 而且"根改一个值"与"详情正在看哪一封"会分叉(换一封时该清的没清)。
|
||
* 意图格子天然是"一次性动作"语义,正好。
|
||
*
|
||
* ★ 与 `ComposeIntent` 分开两个类而不是加个 mode 参数:
|
||
* 两类意图的**接收方完全不同**(一个在 `CommPage`、一个在 `MailDetailView`),
|
||
* 合成一个会让"谁该消费它"变得含糊 —— 而含糊正是漏接收的来源。
|
||
*/
|
||
export class ReplyIntent {
|
||
static pending: boolean = false;
|
||
private static listener: (() => void) | undefined = undefined;
|
||
|
||
/** `MailDetailView` 挂载时登记 */
|
||
static setListener(fn: () => void): void {
|
||
ReplyIntent.listener = fn;
|
||
}
|
||
|
||
/** 卸载时摘掉(否则会唤醒一个已销毁的组件) */
|
||
static clearListener(): void {
|
||
ReplyIntent.listener = undefined;
|
||
}
|
||
|
||
/** 提"我要回复":有人听就当场给,没人听就存着等挂载后取走 */
|
||
static request(): void {
|
||
const fn: (() => void) | undefined = ReplyIntent.listener;
|
||
if (fn !== undefined) {
|
||
fn();
|
||
return;
|
||
}
|
||
ReplyIntent.pending = true;
|
||
}
|
||
|
||
/** 取走待处理的请求(取完即清,避免下次挂载时重放) */
|
||
static consume(): boolean {
|
||
if (!ReplyIntent.pending) {
|
||
return false;
|
||
}
|
||
ReplyIntent.pending = false;
|
||
return true;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 「弹掉通信页导航栈的最上一层」的意图(Esc 用)—— 与另两个同形。
|
||
*
|
||
* ★★ 2026-09-24 新增(用户:「esc返回上一级」)。
|
||
*
|
||
* Esc 在**主页根**上收到(详情/写信自己的 onKeyEvent 因组件不获焦而不触发),
|
||
* 而 `navPathStack` 只有 `CommPage` 持有 ⇒ 同样的"够不着"问题、同样的解法。
|
||
*/
|
||
export class PopIntent {
|
||
static pending: boolean = false;
|
||
private static listener: (() => void) | undefined = undefined;
|
||
|
||
/*
|
||
* ★ 2026-09-26 曾把回调从 `() => void` 改成 `() => boolean`(为了让
|
||
* `UIAbility.onBackPressed` 知道"到底退没退")—— **已随那次一起撤回**,
|
||
* 原因见 `EntryAbility` 里 `onBackPress` 那段注释(模拟器上按一次系统
|
||
* 返回键直接把 UIAbility 销毁了,`harmony-admin` 判据因此挂住)。
|
||
* ⇒ 形状回到 `() => void`。等系统返回键那条**真机验过**之后再说。
|
||
*/
|
||
static setListener(fn: () => void): void {
|
||
PopIntent.listener = fn;
|
||
}
|
||
|
||
static clearListener(): void {
|
||
PopIntent.listener = undefined;
|
||
}
|
||
|
||
static request(): void {
|
||
const fn: (() => void) | undefined = PopIntent.listener;
|
||
if (fn !== undefined) {
|
||
fn();
|
||
return;
|
||
}
|
||
PopIntent.pending = true;
|
||
}
|
||
|
||
static consume(): boolean {
|
||
if (!PopIntent.pending) {
|
||
return false;
|
||
}
|
||
PopIntent.pending = false;
|
||
return true;
|
||
}
|
||
}
|