跨端: fix(客户端) 换身份必须清全部账号数据 + 鸿蒙管理台门禁改三态

两份审查报告(`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` 仍在,所以**不是越权**;
但非管理员会看到完整用户列表、建号表单、改密入口 ——
属于客户端信息泄露 + 无意义的失败请求风暴。
This commit is contained in:
2026-09-28 08:27:01 +08:00
parent 044a664cc3
commit 18b148e476
28 changed files with 690 additions and 64 deletions

View File

@ -21,6 +21,7 @@ import { UIContext } from '@kit.ArkUI';
import { ApiClient } from './ApiClient';
import { AuthApi } from './AuthApi';
import { AccountManager } from './AccountManager';
import { MailStore } from '../common/MailStore';
import { SseService } from './SseService';
import { PushService } from './PushService';
@ -88,6 +89,27 @@ export async function performLogout(
hilog.info(0x0001, 'Logout', 'disconnectAll 失败:%{public}s', JSON.stringify(e));
}
/*
* ★ ③′ 清内存里的邮件快照(2026-09-26 接上)。
*
* `MailStore.clear()` 从写下来到今天**一个调用点都没有** —— 于是退出后
* `snapshot` 里还留着上一个账号的邮件、群组与未读数。下一个登录的人
* (同一台设备切账号是常态)会**先看到上一个人的邮件**,而且
* `LoginPage` 的快速登录路径直接 `pushUrl MainPage` ⇒ 那是**第一屏**。
*
* ★ 为什么必须放在这里而不是各调用点自己清:这段流程有**两个入口**
* (宽屏侧栏 / 「我的」页),而 `performLogout` 的既定纪律就是
* "两个入口做同一件事"。复制一份到调用方就会重新分叉。
*
* ★ 放在 ③ 之后:SSE 断开会触发回调,晚一步清更保险(清完之后再有
* 旧数据落地才是问题,见 `MailStore.clear()` 里的 bump)。
*/
try {
MailStore.getInstance().clear();
} catch (e) {
hilog.info(0x0001, 'Logout', 'MailStore.clear 失败:%{public}s', JSON.stringify(e));
}
/*
* ④ `replaceUrl` 而不是 `pushUrl`:退出后不该还能"返回"到已登出的页。
*

View File

@ -149,6 +149,13 @@ 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;
}

View File

@ -496,7 +496,21 @@ export class MailStore {
* MailLike`,而 ArkTS 的 interface 里不能声明 getter
* (编译报 "incorrectly implements interface")。派生放在**填充处**。
*/
mail.cc_count = mail.cc_list.length;
/*
* ★ 2026-09-26 补上 `?? []`(下面那段长注释写的正是这个坑,
* 而**上一行**当时恰恰没兜)。
*
* 当时的理由是「`CCList` 的 tag 没有 `omitempty` ⇒ 96/96 都带
* `cc_list`,所以这行一直安全」—— 那是**当时的事实,不是保证**。
* 服务端任何一次 JSON tag 调整都能把它从 96/96 变成 0/96,而
* `JSON.parse as T` 是**裸转型** ⇒ 缺失字段是 `undefined`
* ⇒ `.length` 抛 ⇒ 整个列表页白屏。
*
* 与 `attach_count` 下方那段同一个道理:模型层可能带
* `omitempty`,所以在**真正读的地方**兜,而不是指望服务端永远给。
* (收件箱与发件箱两处都补了。)
*/
mail.cc_count = (mail.cc_list ?? []).length;
/*
* 附件数(列表行显示回形针 + 数字)。
*
@ -625,7 +639,7 @@ export class MailStore {
const mail: MailSummary = res.mails[j];
mail.source_account_id = acct.id;
mail.source_account_name = acct.displayName;
mail.cc_count = mail.cc_list.length;
mail.cc_count = (mail.cc_list ?? []).length;
/* 附件数 —— 同收件箱那处,必须 `?? []`(`Attachments` 带 omitempty,
94/96 的邮件缺这个 key)。完整理由见收件箱那一处的注释。 */
mail.attach_count = (mail.attachments ?? []).length;

View File

@ -69,7 +69,43 @@ export class Theme {
static readonly textPrimary: Resource = $r('sys.color.ohos_id_color_text_primary');
static readonly textMuted: Resource = $r('sys.color.ohos_id_color_text_secondary');
static readonly textSubtle: Resource = $r('sys.color.ohos_id_color_text_tertiary');
/*
* ⚠️ 系统三级色 `ohos_id_color_text_tertiary` **已不再作为令牌暴露**。
*
* 它在浅色下实测 rgb(153,153,153) 压白底 = 2.85:1(低于 3:1),
* 而本机 `text_secondary` 实测**同值** ⇒ 换到二级色也不解决问题。
* 小字统一走 `textSubtleFor()`(见下面自己拥有的那一档)。
* 保留 `textSubtle` 这个名字只会让人以为"三级色可以直接用",
* 而那正是 66 处小字读不动的来源。
*/
/*
* ──────── 小字可读性:自己拥有的一档「三级文字」(2026-09-26 补)────────
*
* ★★★ 起因是一次**设备实测打回了上一轮的修法**:
*
* 2026-09-19 判据抓到「浅色 `textSubtle` = rgb(153,153,153) 压白底 = 2.85:1」,
* 当时的修法是让 `textSubtleFor()` 两个主题都返回 `textMuted`。
* 那条注释的依据是「二级色本身就是次要文字,语义对得上,且跟随主题」。
*
* ★ 但 2026-09-26 复扫发现**那个前提不成立**:本机 HarmonyOS 6.1.1 上
* `ohos_id_color_text_secondary`(= `textMuted`)实测**也是 rgb(153,153,153)**,
* 与三级色同值 ⇒ 换过去之后**一点没变**,仍是 2.85:1。
* (系统把二级/三级在浅色下压到了同一档;而 WebUI 的 gray-400 是
* `rgb(107,114,128)` = 4.83:1、gray-500 = 6.15:1,是两套适配过的值。)
*
* ⇒ 结论:**"系统语义色"不等于"我们要求的可读性"** —— 这与 `surface`
* 当前景色、`accent` 深色没调亮是**同一类**的三次复发。
* 系统的三级/二级色只保证"层次关系"(比一级淡),不保证 WCAG 下限。
*
* ★ 修法:自己拥有这一档,两个主题各给一个**实测过 ≥3:1** 的值 ——
* 与 WebUI 的 gray-400/gray-500 同量级(两端视觉层次一致),
* 并按纪律登记进 `cross-client-theme` 的 `SELF_OWNED_COLORS`。
* 不用 `textSubtleFor()` 里那套 `isDark ? a : b` 的手写分支 ——
* 令牌化之后调用点只管用 `textSubtleFor()`,形状与 accent 那一族一致。
*/
static readonly textSubtleLight: string = '#6B7280'; // WebUI gray-400,压白底 4.83:1
static readonly textSubtleDark: string = '#8A92A1'; // WebUI .dark gray-400,压深卡片 5.51:1
/*
* ──────── 三级文字在**深色**下的可读性下限(2026-09-19 加)────────
@ -108,24 +144,16 @@ export class Theme {
* ★ 使用入口是 `Theme.textSubtleFor()` —— 与 `accentFor` / `accentSoftFor`
* 同一条纪律:**别在页面里自己写 `isDark ? a : b`**。
*/
static textSubtleFor(dark?: boolean): Resource {
static textSubtleFor(dark?: boolean): ResourceColor {
/*
* ★★ 2026-09-19 **浅色也要换**(第一版只改了深色,漏了另一半)。
* ★★ 2026-09-19 的一版把两个主题都指向 `textMuted`,**已被 2026-09-26 的
* 设备复扫推翻**:本机系统二级色实测与三级色同值(都 rgb(153,153,153)),
* 换过去一点没变。见上面 `textSubtleLight/Dark` 那段注释的完整因果。
*
* 切到浅色主题复扫,立刻抓到 12 处:
* 浅色 `textSubtle` 实测 `rgb(153,153,153)` 压白底 = **2.85:1**
* 而 WebUI 浅色的 gray-400 是 `rgb(107,114,128)` = **4.83:1**,
* gray-500 是 `rgb(90,98,112)` = **6.15:1**。
*
* ⇒ 系统三级色**两个主题下都不够**,不是"只有深色有问题"。
* 同一档位在 WebUI 那边两套主题都做了可读性适配,
* 而系统语义色只管"比二级更淡"这层关系。
*
* 所以两个主题都返回二级色(`textMuted`)。
* ★ 为什么不各写一个手写值:系统二级色本身就是"次要文字",
* 语义对得上,且**跟随主题**(不必再维护两个常量、不必登记)。
* 现在返回自己拥有的那一档(按主题),两个值都实测 ≥3:1。
*/
return Theme.textMuted;
const useDark: boolean = dark !== undefined ? dark : Theme.isDarkNow();
return useDark ? Theme.textSubtleDark : Theme.textSubtleLight;
}
// ─────────────── 遮罩与材质:交给系统 ───────────────
@ -422,7 +450,7 @@ export class Theme {
* 第 12 个**新加的手写色**(它不在名单里,于是 A/B/裸色值三条都碰不到它)。
* 「枚举挡实例,类才挡漂移」—— 这次枚举的是**名字**,所以要有名单。
*
* 登记项(28 个,名字与判据里的 SELF_OWNED_COLORS 逐字一致 —— 逐个列出而不是缩写,
* 登记项(30 个,名字与判据里的 SELF_OWNED_COLORS 逐字一致 —— 逐个列出而不是缩写,
* 这样 grep 一个名字就能找到它的理由):
*
* 玻璃(**白基材 + alpha**,深浅各一档;系统材质没有这一档):
@ -554,6 +582,29 @@ export class Theme {
* ★ 不复用 warnFg/danger:那两个是**文字色**,而状态点是 8px 实心圆,
* 用文字色会发脏、深色底上不够跳(WebUI 也是 text-* 与 bg-* 两套)。
*
* ★★★ 2026-09-26 补登记:小字那一档(设备复扫打回了上一轮的修法)
*
* · textSubtleLight #6B7280 WebUI 浅色 gray-400(`index.css:65`),压白底 4.83:1
* · textSubtleDark #8A92A1 WebUI `.dark` gray-400(`index.css:377`),压深卡片 5.51:1
*
* 起因:`cross-client-theme` 的设备条抓到「浅色小字 = rgb(153,153,153) 压白底
* = 2.85:1」(时间戳、「N 封」那类 10–11px 辅助文字,66 处)。
* 2026-09-19 的修法是让 `textSubtleFor()` 返回 `textMuted`
* (`ohos_id_color_text_secondary`),理由写的是"二级色语义对得上"。
*
* ★★ 但复扫证明**那个前提不成立**:本机 HarmonyOS 6.1.1 上
* `text_secondary` 实测**也是 rgb(153,153,153)**,与三级色同值
* ⇒ 换过去**一点没变**,判据继续红。
*
* 为什么必须自己写:系统二级/三级色只保证「比一级淡」这层**层次关系**,
* 不保证 WCAG 对比度下限;而 WebUI 那边 gray-400 是**两套主题各自适配**的
* 浅/深两个值(不是同一个色翻主题)。同一类坑本文件已犯三次
* (`surface` 当前景色、accent 深色没调亮、这一次),都是
* 「**系统语义 ≠ 我们的可读性要求**」。
*
* 为什么不用 `textSubtleFor()` 里 `isDark ? a : b` 的裸分支:
* 令牌化之后调用点只管用 `textSubtleFor()`,与 accent 那一族同一形状。
*
* 不在这里的手写色只有一种合法去处:`$r('sys.*')`(跟随系统/深色模式)。
*/

View File

@ -196,6 +196,37 @@ export default class EntryAbility extends UIAbility {
});
}
/**
* ~~系统返回键 ⇒ 退掉导航栈最上面一层~~ —— **撤回(2026-09-26)**。
*
* 原本这里加了 `onBackPressed()`,把系统返回键接进 `PopIntent`,想顺手修掉
* 「`KEY_COMM_STACK_DEPTH` 停在旧值 / `KEY_OPEN_MAIL_ID` 不清」两个问题。
*
* ★ **撤回的原因:它没有生效,而且把应用关掉了。**
* 模拟器实测(HarmonyOS 6.1.1):应用在前台时按一次系统返回键 ⇒
* **UIAbility 被销毁**(`HandleAppDied` + `aa dump -a` 里 EntryAbility 消失),
* `harmony-admin` 那条设备判据因此挂住不返回。
* 对照实验:把这三处改动 stash 掉重编重装,同一条判据 **31/31 通过**。
*
* ⇒ 也就是说 `UIAbility.onBackPressed()` 在这条链上**压根没被调用到**
* (`Navigation` 自己先消费了返回事件),而我加的 `PopIntent.request()`
* 改变了 `ComposeIntent`/`PopIntent` 的回调签名 ⇒ 判据侧行为变了。
*
* ★ 为什么**不继续查**:这一步要验的是"系统返回键在 `Navigation` 内部的
* 事件分发顺序",属于框架行为,本机模拟器这一条又验不出真机行为。
* 与其赌一个**会关掉应用**的半成品,不如整块撤回、把问题登记成一条
* 带复现步骤的账(见 `docs/DEBTS.json` 的 `harmony-system-back-key`)。
*
* ★ 影响面(**没有修掉的**,请勿当成已修):
* · 退到列表后 `KEY_COMM_STACK_DEPTH` 仍是旧值;
* · `KEY_OPEN_MAIL_ID` 仍留着上一封 ⇒ 回列表按回车会开"回复";
* · `MainPage.closeDetail()` 仍然是死代码(只被 Esc 那条路调)。
*
* ★ Esc 那条路**不受影响**,它是好的(`PopIntent.setListener` 仍接 Esc)。
*/
// (实现见 `MainPage` 的 PopIntent 监听者;系统返回键这一半未接。)
/**
* 全屏布局 + 读出避让区 —— 消掉上下黑边的**两半**。
*

View File

@ -316,7 +316,35 @@ struct AdminUsersPage {
Column() {
this.Header()
if (this.roleKnown && !this.isAdmin) {
/*
* ★ 门禁**三态**,不是两态(2026-09-26 修)。
*
* 原来的条件是 `roleKnown && !this.isAdmin` ⇒ "显示管理台",
* 于是 `roleKnown === false`(`loadRole()` 失败、身份**还没读到**)
* 落进 else 分支 ⇒ **把管理台整个渲染出来**。
*
* `loadRole()` 的 catch 明确写着"不能把读不到当成不是管理员",
* 但那一条只管住了 `isAdmin`,**没管住渲染** —— 身份未知时
* 界面照样把管理台亮出来。一次网络抖动 = 管理入口对所有人可见。
*
* 服务端 `middleware/user.go` 的 `AdminOnly` 仍然拦着,所以
* **数据不会被读到**;但"列出所有用户/改配额"这套界面本身
* 就是信息泄露面(用户名单、账号名、额度都摆在上面)。
*
* ⇒ 身份未知(`roleKnown === false`)必须**停在大门外**,
* 而不是"先显示再试加载"。
*/
if (!this.roleKnown) {
Column() {
Row() {
AmIcon({ iconName: 'lock', iconSize: 16, iconColor: Theme.textMuted })
Text('正在确认身份…').fontSize(Theme.fontBody).fontColor(Theme.textMuted).margin({ left: 6 })
}
Text('读取账号角色失败,请下拉重试。')
.fontSize(Theme.fontSmall).fontColor(Theme.textSubtleFor()).margin({ top: 6 })
}
.width('100%').layoutWeight(1).justifyContent(FlexAlign.Center)
} else if (!this.isAdmin) {
Column() {
Row() {
AmIcon({ iconName: 'lock', iconSize: 16, iconColor: Theme.textMuted })
@ -464,7 +492,7 @@ struct AdminUsersPage {
*/
if (isRestricted(user)) {
Text(' ').width(4)
this.Chip('受限', Theme.surfaceMuted, Theme.textSubtle)
this.Chip('受限', Theme.surfaceMuted, Theme.textSubtleFor())
}
}
.width('100%')
@ -492,7 +520,7 @@ struct AdminUsersPage {
/*
* 参数类型是 `ResourceColor` 而不是 `string`:底色/文字色可能来自**系统语义色**
* (`Theme.surfaceMuted` / `Theme.textSubtle` 是 `$r('sys.color.*')` ⇒ `Resource`),
* (`Theme.surfaceMuted` / `Theme.textSubtleFor()` 是 `$r('sys.color.*')` ⇒ `Resource`),
* 也可能来自自写色值(`Theme.chipNeutralBg` ⇒ `string`)。写成 `string` 时
* `user.role === 'admin' ? Theme.chipBgFor() : Theme.surfaceMuted` 这种三目
* 就变成 `string | Resource` 而**编译不过**(`ArkTS Compiler Error`,不是警告)。

View File

@ -910,7 +910,7 @@ export struct CalendarPage {
return Theme.accent;
}
if (!cell.inMonth) {
return Theme.textSubtle;
return Theme.textSubtleFor();
}
return Theme.textPrimary;
}
@ -926,7 +926,7 @@ export struct CalendarPage {
}
private statusFg(status: string): ResourceColor {
return status === 'active' ? Theme.textSubtle : Theme.warnFg;
return status === 'active' ? Theme.textSubtleFor() : Theme.warnFg;
}
/** 新建:默认落在**当前选中的那一天** 09:00(从"我在看的那天"出发最省事) */
@ -1837,13 +1837,13 @@ export struct CalendarPage {
*/
Text('导入')
.fontSize(Theme.fontSmall)
.fontColor(this.icsBusy ? Theme.textSubtle : Theme.accentFor())
.fontColor(this.icsBusy ? Theme.textSubtleFor() : Theme.accentFor())
.margin({ left: 8 })
.onClick(() => { this.importIcs(); })
.attributeModifier(PressEffectModifier.of())
Text('导出')
.fontSize(Theme.fontSmall)
.fontColor(this.icsBusy ? Theme.textSubtle : Theme.accentFor())
.fontColor(this.icsBusy ? Theme.textSubtleFor() : Theme.accentFor())
.margin({ left: 8 })
.onClick(() => { this.exportIcs(); })
.attributeModifier(PressEffectModifier.of())

View File

@ -1935,8 +1935,15 @@ export struct MailDetailView {
/*
* 正在看权限决策面板时不抢回车 —— 那里回车的语义是"提交决策"。
* 与 `MailDetailPage` 里 `PermissionPanel` 的分工一致。
*
* ★ 2026-09-26 修:`'permission'` ⇒ `'permission_request'`。
* 服务端只会发 `normal` / `permission_request`
* (`handler/permission.go:286`、`repo/repo.go:442`),本文件另外两处
* (`:1100` / `:1388`)用的也是 `'permission_request'`。
* 这个写错的字面量**恒不成立** ⇒ 权限面板上按回车会**同时**开回复框
* (两个动作抢同一个键),而这正是这段注释要防的事。
*/
if (this.mailType === 'permission' && this.permissionResult.length === 0) {
if (this.mailType === 'permission_request' && this.permissionResult.length === 0) {
return false;
}
this.openReplyWithMorph();

View File

@ -1700,6 +1700,15 @@ struct CommPage {
* 在**根**上收到键、在这里执行 —— 因为 `navPathStack` 只有本组件持有。
* 弹完要重发层数,否则第二下 Esc 会以为还有层(或反之)。
*/
/*
* ★ 注意:这条路**只接 Esc**。系统返回键/手势绕开它走 `Navigation` 自己的
* pop ⇒ `closeDetail()` 与这里重发的层数都不覆盖那条路(这是已知缺口)。
* 2026-09-26 试过在 `EntryAbility` 加 `onBackPressed()` 来补,
* **实测失败并已撤回**(模拟器上按一次系统返回键直接销毁 UIAbility,
* `harmony-admin` 判据挂住;stash 掉重编则是 31/31 通过)。
* 完整复现步骤与未修好的影响面见 `EntryAbility` 里 `onBackPress` 那段注释。
* ⇒ 下面保持原样,**不要**在这里"顺手"加系统返回键的处理。
*/
PopIntent.setListener(() => {
if (this.navPathStack.size() > 0) {
this.navPathStack.pop();
@ -2513,6 +2522,8 @@ struct MainPage {
/** 淡出/淡入用:0 = 不可见、1 = 可见(驱动 opacity,见 TopbarText) */
@State topOpacity: number = 1;
private topTimer: number = 0;
/** 轮播里那次"淡出→换字→淡入"的 `setTimeout` 句柄;停轮播时必须一起清 */
private topFadeTimer: number = 0;
/**
* App 级 SSE 监听(`aboutToAppear` 注册 / `aboutToDisappear` 摧掉)。
@ -2756,8 +2767,24 @@ struct MainPage {
ui.animateTo({ duration: TOPBAR_FADE_MS, curve: Theme.easeOutSoft }, () => {
this.topOpacity = 0;
});
setTimeout(() => {
this.topIndex = (this.topIndex + 1) % this.topbarTexts().length;
/*
* ★ 2026-09-26:这次换字的 `setTimeout` 句柄**存下来**了。
*
* 原来它是个匿名 `setTimeout`,而 `stopTopbarRotation()` 只 `clearInterval`
* 那个 interval ⇒ 停轮播的那一刻,**上一次没跑完的换字回调还会跑**:
* 已经淡出的顶栏文本被换掉再淡入,停在一条"随机"的文案上。
* 反复进出这个页面会攒下一串待触发的回调。
*/
if (this.topFadeTimer !== 0) {
clearTimeout(this.topFadeTimer);
}
this.topFadeTimer = setTimeout(() => {
this.topFadeTimer = 0;
const n: number = this.topbarTexts().length;
if (n <= 0) {
return;
}
this.topIndex = (this.topIndex + 1) % n;
ui.animateTo({ duration: TOPBAR_FADE_MS, curve: Theme.easeOutSoft }, () => {
this.topOpacity = 1;
});
@ -2770,6 +2797,11 @@ struct MainPage {
clearInterval(this.topTimer);
this.topTimer = 0;
}
/* ★ 同一个"停"必须把换字回调一起停(原来漏了,见上面那段) */
if (this.topFadeTimer !== 0) {
clearTimeout(this.topFadeTimer);
this.topFadeTimer = 0;
}
}
/**

View File

@ -156,7 +156,7 @@ struct SessionsPage {
.padding({ left: 4, right: 4, top: 1, bottom: 1 })
Blank()
Text(s.mail_count + ' 封').fontSize(11).fontColor(Theme.textSubtleFor())
Text(' · ' + s.status).fontSize(11).fontColor(s.status === 'active' ? Theme.approveFor() : Theme.textSubtle)
Text(' · ' + s.status).fontSize(11).fontColor(s.status === 'active' ? Theme.approveFor() : Theme.textSubtleFor())
}
.width('100%').margin({ top: 4 })
}

View File

@ -19,6 +19,7 @@ import { LengthMetrics } from '@kit.ArkUI';
import { AmIcon } from '../common/Icons';
import { LIST_FADE_LENGTH } from '../model/NavItems';
import { AccountManager, AccountInfo } from '../api/AccountManager';
import { MailStore } from '../common/MailStore';
import { SseService } from '../api/SseService';
import { AppearanceStore } from '../common/AppearanceStore';
import { AppearanceApi } from '../api/AppearanceApi';
@ -575,6 +576,23 @@ export struct SettingsPane {
client.setToken('');
}
}
/*
* ★ 删掉的是**正在用的**账号时,内存里那份快照要清(2026-09-26)。
*
* 删非当前账号不动它 —— 那些邮件还属于还在的账号。
*
* 不清的后果:删掉 A 之后库里可能还剩 B(`getActiveAccount()` 返回 B),
* 凭证已经翻到 B,而 `MailStore` 的 `snapshot` 里还是 **A 的邮件** ——
* 界面直接显示别人的邮件。这与 `AccountSwitcher` 那边查出来的是
* **同一个根因的两端**(WebUI 切账号不清 store / 鸿蒙删账号不清快照)。
*/
if (accountId === this.activeId) {
try {
MailStore.getInstance().clear();
} catch (e) {
hilog.info(0x0001, 'Settings', 'MailStore.clear 失败:%{public}s', JSON.stringify(e));
}
}
this.refreshList();
this.getUIContext().getPromptAction().showToast({ message: '账号已删除' });
}
@ -1241,7 +1259,7 @@ export struct SettingsPane {
Text(this.keyState(k))
.fontSize(11)
.fontColor(k.status === 'active' ? Theme.approveFg : Theme.textSubtle)
.fontColor(k.status === 'active' ? Theme.approveFg : Theme.textSubtleFor())
.margin({ right: 12 })
Text('吹销')