跨端: 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

@ -12,6 +12,7 @@
import { useEffect, useRef, useState } from 'react';
import { AGGREGATE_ID, isUsableAccount } from '../lib/accounts';
import { resetAccountData } from '../lib/resetAccountData';
import { useAccountStore } from '../stores/accountStore';
import { useMailStore } from '../stores/mailStore';
import { useUIStore } from '../stores/uiStore';
@ -55,9 +56,20 @@ export default function AccountSwitcher() {
const pick = async (id: string) => {
setOpen(false);
/*
* ★ 切账号**必须先清全部账号数据**,再重取。
*
* `setActive` 会 `syncAuth()` 翻 `api/config` 单例里的 API_BASE 与 bearer,
* 从这一刻起 `api.getSent()` / `api.getMail()` / `api.archiveContact()` 全带
* **新**账号的凭证。而 mailStore 的 sent/currentMail、sessionStore 的
* sessions/currentSession(+Mails)、contactStore 的 contacts 全是**旧**账号的 ——
* 留着就会"在新账号的界面下显示旧账号的邮件",而从这些陈旧视图点归档/转发
* 改的是**新**账号。不报错、不空屏,肉眼完全看不出来,所以只能靠这里堵。
*
* 登出与 401 走的是同一套清空(`App.tsx` 与 `api/client.ts` 的 unauthorized 回调)。
*/
resetAccountData();
await setActive(id);
// 切账号后**必须重取**:收件箱是按账号返回的,不重取就会看到上一个账号的信。
// 这条在聚合⇄单账号之间同样成立(聚合要并发问多个账号)。
await fetchInbox('all');
};

View File

@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import * as api from '../api/client';
import type { AdminScopes, User } from '../types';
import { CheckIcon, LockIcon, UsersIcon, ChevronRightIcon, KeyIcon, BotIcon, CpuIcon } from './icons';
@ -89,9 +89,22 @@ export default function AdminUsersPage() {
}
};
/*
* 提示条自动消失。句柄存进 ref 并在卸载时清掉:原来直接 `setTimeout` 不管,
* 组件卸载后那个回调仍会跑(状态写在死组件上是纯粹的脏行为),而且连点几次
* 会留下多个待触发的定时器,先触发的会把后触发的提示提前吃掉。
*/
const noticeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return () => {
if (noticeTimer.current) clearTimeout(noticeTimer.current);
};
}, []);
const flash = (msg: string) => {
setNotice(msg);
setTimeout(() => setNotice(null), 2500);
if (noticeTimer.current) clearTimeout(noticeTimer.current);
noticeTimer.current = setTimeout(() => setNotice(null), 2500);
};
return (

View File

@ -115,10 +115,17 @@ export default function BackgroundPicker() {
presetId === p.id ? 'border-blue-500 ring-1 ring-blue-500' : 'border-gray-300'
}`}
>
{/* 用与全屏背景同一份 CSS 变量渲染缩略图 —— 预览必然等于结果 */}
{/*
* 用与全屏背景同一份 CSS 变量渲染缩略图 —— 预览必然等于结果。
*
* ★ 这里**不能**写 `style={{ backgroundImage: 'var(--bg-image)' }}`:
* 内联样式优先于类,而当 `kind==='image'` 时 `:root` 上被设了
* `--bg-image: url("用户那张图")`(`backgroundStore.ts:262`)⇒
* **六个预设格子全都显示同一张照片**,而用户正在这一步里挑预设。
* 预设类自己就定义了 `--bg-image`(`index.css:783+`),让它生效即可。
*/}
<span
className={`bg-preset-${p.id} absolute inset-0 block`}
style={{ backgroundImage: 'var(--bg-image)', backgroundSize: 'cover' }}
aria-hidden="true"
/>
<span className="absolute bottom-0 inset-x-0 text-3xs py-0.5 bg-black/45 text-white">

View File

@ -93,15 +93,28 @@ export default function CalendarView() {
};
}, [anchor]);
/*
* 请求代次:翻月/翻日时若有两次请求同时在飞,**只认最后一次**。
*
* ★ 为什么需要它(`ThreadView.tsx:62-84` 早就做对了,这里是漏掉的同一处):
* 快速点「下一页」「上一页」或连续滑动 ⇒ 多个请求同时在飞,而它们**不保证
* 按发起顺序返回**。无守卫时后落地的覆盖先落地的 ⇒ 标题写着「11 月」而
* 网格里是 10 月的事件,且不报错、不空屏。
*/
const loadGen = useRef(0);
async function load() {
const myGen = ++loadGen.current;
setErr('');
try {
const r = await api.listCalendarEvents({ from: range.from, to: range.to });
if (loadGen.current !== myGen) return; // 已被更新的请求取代
setEvents(r.events ?? []);
} catch (e: any) {
if (loadGen.current !== myGen) return;
setErr(e?.message || '加载失败');
} finally {
setLoading(false);
if (loadGen.current === myGen) setLoading(false);
}
}
@ -263,8 +276,20 @@ export default function CalendarView() {
const a = document.createElement('a');
a.href = url;
a.download = `agentmail-${dayKey(anchor)}.ics`;
/*
* ★ 撤销必须**推迟到下一个任务**,不能紧跟 `click()` 同步撤销。
*
* `click()` 只是"启动"下载,blob 实际是被**异步**读取的。Chromium 会容忍
* 同步撤销,而 Firefox / WebKit 不会 —— 表现是**静默失败或 0 字节 .ics**,
* 而且只在部分内核上出现,很容易漏测。
*
* 顺带把 `a` 挂到 document 上再摘掉:部分内核只在元素处于文档中时才认
* `download` 属性(这条与 `setTimeout` 是同一个原因的两种修法,缺一不可)。
*/
document.body.appendChild(a);
a.click();
URL.revokeObjectURL(url);
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(url), 0);
} catch (e: any) {
setErr(e?.message || '导出失败');
}

View File

@ -54,6 +54,18 @@ export default function ComposePage() {
const [error, setError] = useState<string | null>(null);
const [okMsg, setOkMsg] = useState<string | null>(null);
/*
* "发完 900ms 自动退出写信"的定时器。句柄存起来并在卸载时清掉 ——
* 原来直接 `setTimeout` 不管,组件已卸载/换页之后那个回调仍会跑,
* `setOkMsg` 写在死组件上;而"清空"按钮在 sending 期间并未禁用 ⇒ 可达。
*/
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return () => {
if (closeTimer.current) clearTimeout(closeTimer.current);
};
}, []);
useEffect(() => {
const el = rootRef.current;
const from = takeComposeOrigin();
@ -157,7 +169,8 @@ export default function ComposePage() {
res.budget_max ? `已发送 · ${where} · 预算 ${res.budget_max} 个来回` : `已发送 · ${where}`
);
await Promise.all([fetchInbox('all'), fetchSent(), fetchSessions(), fetchContacts()]);
setTimeout(() => {
if (closeTimer.current) clearTimeout(closeTimer.current);
closeTimer.current = setTimeout(() => {
setOkMsg(null);
cancelCompose();
}, 900);

View File

@ -1,4 +1,4 @@
import { useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import * as api from '../api/client';
import { KeyIcon, CopyIcon, TrashIcon, PlusIcon, CheckIcon } from './icons';
@ -31,12 +31,21 @@ function keyState(k: { key_type: api.KeyType; expires_at: string | null; used_at
*/
function NewKeyBanner({ token, onDismiss }: { token: string; onDismiss: () => void }) {
const [copied, setCopied] = useState(false);
// "已复制"的复位定时器:句柄存起来并在卸载时清掉(连点复制会留下多个定时器,
// 先触发的把后触发的状态提前复位)。
const copiedTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return () => {
if (copiedTimer.current) clearTimeout(copiedTimer.current);
};
}, []);
const copy = async () => {
try {
await navigator.clipboard.writeText(token);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
if (copiedTimer.current) clearTimeout(copiedTimer.current);
copiedTimer.current = setTimeout(() => setCopied(false), 1500);
} catch {
/* 无剪贴板权限时用户可手动选中 */
}

View File

@ -382,13 +382,23 @@ function ForwardBar({ mail, onClose }: { mail: Mail; onClose: () => void }) {
const [comment, setComment] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
/*
* 同步的"正在提交"闸门。
*
* ★ 为什么 `busy` 这个 state 不够:它在 React 提交后才为真,而两次快速点击
* 可以在同一次提交之前都读到 `busy === false` ⇒ **发出两个 forwardMail**
* (真转发,不是本地乐观更新)。ref 的写入是同步的,能覆盖那个窗口
* —— 与 `ThreadView.loadMore` 用 `busy.current` 处理同一类问题的做法一致。
*/
const inFlight = useRef(false);
const fetchInbox = useMailStore(s => s.fetchInbox);
const fetchSent = useMailStore(s => s.fetchSent);
const fetchSessions = useSessionStore(s => s.fetchSessions);
const fetchContacts = useContactStore(s => s.fetchContacts);
const submit = async () => {
if (!to.trim() || busy) return;
if (!to.trim() || inFlight.current) return;
inFlight.current = true;
setBusy(true);
setError(null);
try {
@ -397,11 +407,18 @@ function ForwardBar({ mail, onClose }: { mail: Mail; onClose: () => void }) {
cc: cc.trim(),
comment: comment.trim()
});
await Promise.all([fetchInbox('all'), fetchSent(), fetchSessions(), fetchContacts()]);
/*
* 刷新失败**不该拦住关窗**:转发此刻已经成功了,而 `Promise.all` 会在
* 四个刷新里任一失败时 reject ⇒ `onClose()` 被跳过 ⇒ 人看到报错、
* 于是**再转一次**(真的多发一封)。所以这里 allSettled:关窗照做,
* 失败的部分留给下一次 SSE/手动刷新补上。
*/
await Promise.allSettled([fetchInbox('all'), fetchSent(), fetchSessions(), fetchContacts()]);
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
inFlight.current = false;
setBusy(false);
}
};
@ -718,6 +735,9 @@ export function PermissionPanel({ mail }: { mail: Mail }) {
const [picked, setPicked] = useState<string[]>([]);
// 服务端判定这条决策越过了等待窗口时回给我们的说明。
const [staleWarning, setStaleWarning] = useState('');
// 提交失败要**说出来**:这是审批动作,失败时如果什么都不显示,人看到的是
// "点了没反应",于是反复点,而 Agent 那边一直阻塞在同一个请求上。
const [submitError, setSubmitError] = useState('');
const fetchInbox = useMailStore(s => s.fetchInbox);
const selectSession = useSessionStore(s => s.selectSession);
@ -743,7 +763,22 @@ export function PermissionPanel({ mail }: { mail: Mail }) {
</p>
) : null;
/*
* 提交失败的提示条。
*
* 与 `staleBanner` 并列放在两种形态之外算一次:审批与提问的失败是同一件事
* (请求没打过去,`decided` 没变,Agent 还在等),不该各写一份。
*/
const submitErrorBanner = submitError ? (
<p className="mt-2 text-2xs leading-relaxed text-red-700 bg-red-50 border border-red-200 rounded-md px-2.5 py-1.5">
提交失败:{submitError}
<br />
Agent 仍在等待这个决定,可以再试一次。
</p>
) : null;
const submit = async (decision: string, noteText: string) => {
setSubmitError('');
setBusy(true);
try {
const res = await api.decidePermission(mail.mail_id, decision, noteText || undefined);
@ -754,7 +789,13 @@ export function PermissionPanel({ mail }: { mail: Mail }) {
await fetchInbox('all');
if (mail.session_id) selectSession(mail.session_id);
} catch (err) {
console.error(err);
/*
* ★ 原来这里只有 `console.error(err)`:失败后 `decided` 仍是 '',界面回到
* 可点状态,**用户看不到任何提示**,只会以为"点了没反应"而反复点,
* 而 Agent 那边一直阻塞。同文件其它提交路径(转发/回复/预算)
* 都正确地把错误显示出来了,只有这一处是例外。
*/
setSubmitError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
@ -795,6 +836,7 @@ export function PermissionPanel({ mail }: { mail: Mail }) {
return (
<div className="mt-3 pt-3 border-t border-orange-200">
{staleBanner}
{submitErrorBanner}
<Composer
value={note}
onChange={setNote}
@ -839,6 +881,7 @@ export function PermissionPanel({ mail }: { mail: Mail }) {
return (
<div className="mt-3 pt-3 border-t border-orange-200">
{staleBanner}
{submitErrorBanner}
<Composer
value={note}
onChange={setNote}

View File

@ -0,0 +1,37 @@
/**
* 换身份时清空全部账号相关数据 —— **一处实现,三个入口都调它**。
*
* # 为什么需要这个文件
*
* `setActive` 之后 `api/config` 单例里的 `API_BASE` 与 bearer 就翻到了新账号,
* 于是**此后每一个请求都带新账号的凭证**。而各 store 里还留着**旧**账号的数据:
*
* · `mailStore.sent` / `currentMail`
* · `sessionStore.sessions` / `currentSession` / `currentSessionMails`
* · `contactStore.contacts` / `archivedContacts`
*
* 结果就是"在新账号的界面下显示旧账号的邮件",而且**从旧视图发出的写操作
* (归档 / 转发 / 批准权限)改的是新账号**。这类问题不报错、不空屏、
* 肉眼完全看不出来 —— 只能靠一处收口来堵。
*
* # 为什么是三个入口而不是一个
*
* ① 主动切账号(`AccountSwitcher.pick`)
* ② 登出(`authStore.logout`)
* ③ 任意接口 401(`api/client.ts` 的 unauthorized 回调)
*
* 只在 ① 里清是最容易漏的那种做法:② 和 ③ 各自还会重新泄露一次,而它们都不在
* 切换账号的代码路径上,grep 也找不到。**身份变化有三条路径,清空也该有三条。**
*
* ★ 这里只碰**数据** store;`uiStore` 的 `reset` 由 `App.tsx` 负责
* (它还要复位窄屏分栏、写信态那些纯界面状态)。
*/
import { useContactStore } from '../stores/contactStore';
import { useMailStore } from '../stores/mailStore';
import { useSessionStore } from '../stores/sessionStore';
export function resetAccountData(): void {
useMailStore.getState().resetAll();
useSessionStore.getState().resetAll();
useContactStore.getState().resetAll();
}

View File

@ -8,7 +8,7 @@ import {
uploadAppearanceImage,
type AppearanceAuth
} from '../lib/appearance';
import { useAccountStore } from './accountStore';
import { useAccountStore, isAggregate } from './accountStore';
import { useBackgroundStore } from './backgroundStore';
import { useThemeStore } from './themeStore';
@ -36,8 +36,27 @@ interface AppearanceSyncState {
push: () => Promise<void>;
}
/** 上一次成功上传的壁纸(data URL)。相同就不重复上传几 MB。 */
let lastUploadedImage = '';
/**
* 上一次成功上传的壁纸(data URL),**按账号分桶**。
*
* ★ 为什么必须带账号维度:这是本模块唯一没做账号隔离的状态,而它恰好是最不能
* 漏的那一个。A 账号设壁纸 X(上传成功 ⇒ 记下 "A"→X)→ 切到 B 账号,而 B 的
* 服务端记录是 `saved:false` ⇒ `pull()` 走"把本地这份推上去"的分支 ⇒ 但
* `push()` 的跳过判据被 **A** 留下的标记满足 ⇒ **B 的壁纸永远传不上去**,
* 而状态仍被置为 `'synced'`。现象是"B 的壁纸在其它所有设备上都缺,
* 界面上却显示已同步"。
*
* 本来这个文件把 localStorage 键按账号隔离了(见 `lib/appearance.ts`),
* 漏的只有这个内存变量。用 `Map` 而不是单串,是因为它的生命周期就等于
* 一次运行期,而账号数很小,不需要额外持久化。
*/
const lastUploadedImage = new Map<string, string>();
/** 当前身份的稳定键。聚合视图下用固定值(聚合本来就不该单独上传)。 */
function currentAccountKey(): string {
const s = useAccountStore.getState();
return isAggregate(s) ? '__aggregate__' : s.activeId;
}
let pushTimer: ReturnType<typeof setTimeout> | null = null;
function currentAuth(): AppearanceAuth | null {
@ -84,7 +103,7 @@ export const useAppearanceSync = create<AppearanceSyncState>((set) => ({
if (remote.snapshot.bgKind === 'preset') bg.setPreset(remote.snapshot.bgPresetId);
else if (remote.snapshot.bgKind === 'none') bg.setKind('none');
else if (remote.snapshot.bgKind === 'image' && remote.imageDataUrl) bg.setImage(remote.imageDataUrl);
if (remote.imageDataUrl) lastUploadedImage = remote.imageDataUrl;
if (remote.imageDataUrl) lastUploadedImage.set(currentAccountKey(), remote.imageDataUrl);
bg.setDim(remote.snapshot.bgDim);
bg.setBlur(remote.snapshot.bgBlur);
} finally {
@ -101,6 +120,7 @@ export const useAppearanceSync = create<AppearanceSyncState>((set) => ({
}
const theme = useThemeStore.getState().pref;
const bg = useBackgroundStore.getState();
const accountKey = currentAccountKey();
const payload = payloadFromLocal({
theme,
kind: bg.kind,
@ -112,9 +132,9 @@ export const useAppearanceSync = create<AppearanceSyncState>((set) => ({
let ok = await pushAppearance(auth, payload);
// 壁纸本体单独传:几 MB 的图不该每次都跟着 PUT 走,只在换图时传一次。
if (ok && bg.kind === 'image' && bg.imageDataUrl && bg.imageDataUrl !== lastUploadedImage) {
if (ok && bg.kind === 'image' && bg.imageDataUrl && bg.imageDataUrl !== lastUploadedImage.get(accountKey)) {
const uploaded = await uploadAppearanceImage(auth, bg.imageDataUrl);
if (uploaded) lastUploadedImage = bg.imageDataUrl;
if (uploaded) lastUploadedImage.set(accountKey, bg.imageDataUrl);
else ok = false;
}
set(ok ? { status: 'synced', lastSyncedAt: Date.now() } : { status: 'pending' });

View File

@ -2,6 +2,7 @@ import { create } from 'zustand';
import type { User } from '../types';
import * as api from '../api/client';
import { ApiError } from '../api/client';
import { resetAccountData } from '../lib/resetAccountData';
type Phase = 'checking' | 'anonymous' | 'authenticated';
@ -77,6 +78,12 @@ export const useAuthStore = create<AuthState>(set => ({
} catch {
/* 即使请求失败也在前端登出 */
}
/*
* ★ 登出也必须清账号数据:不清的话,下一个登录的人会先看到上一个人的
* 邮件、会话与联系人(`App.tsx` 的 `resetUI()` 只复位界面状态,不碰数据)。
* 走的是与切账号同一个 `resetAccountData` —— 身份变化的每条路径都要清。
*/
resetAccountData();
set({ phase: 'anonymous', user: null, error: null });
},
@ -102,7 +109,12 @@ export const useAuthStore = create<AuthState>(set => ({
clearError: () => set({ error: null, retryAfter: null }),
markAnonymous: () => set({ phase: 'anonymous', user: null })
markAnonymous: () => {
// 401 掉登录也是一次"身份没了",同样要清数据 —— 否则令牌失效后再登录
// 另一个账号,上一个账号的邮件/会话还会留在界面上。
resetAccountData();
set({ phase: 'anonymous', user: null });
}
}));
// 注册全局 401 处理:任何接口返回 401 即回到登录页

View File

@ -41,6 +41,13 @@ interface ContactState {
archive: (contact: Contact) => Promise<void>;
/** SSE session_archived 到达时本地即时移除 */
removeSessionLocally: (sessionId: string) => void;
/**
* 换身份(切账号 / 登出 / 401)时清空联系人数据。
*
* ★ 与 `mailStore.resetAll` 同一件事的另一半:`contacts` / `archivedContacts`
* 是**旧**账号的会话清单,而 `archive` 走的是新账号的凭证。
*/
resetAll: () => void;
}
export const useContactStore = create<ContactState>((set, get) => ({
@ -109,5 +116,14 @@ export const useContactStore = create<ContactState>((set, get) => ({
removeSessionLocally: sessionId =>
set(state => ({
contacts: state.contacts.filter(c => c.session_id !== sessionId)
}))
})),
resetAll: () =>
set({
contacts: [],
archivedContacts: [],
pendingArchive: null,
loading: false,
error: null
})
}));

View File

@ -33,6 +33,8 @@ interface MailState {
markRead: (id: string) => Promise<void>;
/** 某会话归档后,把它的邮件从列表与选中态里剔除 */
dropSession: (sessionId: string) => void;
/** 换身份(切账号 / 登出 / 401)时清空全部账号相关数据 */
resetAll: () => void;
}
export const useMailStore = create<MailState>((set, get) => ({
@ -124,6 +126,19 @@ export const useMailStore = create<MailState>((set, get) => ({
},
clearCurrentMail: () => set({ currentMail: null }),
/**
* 换身份(切账号 / 登出 / 401)时清空全部账号相关数据。
*
* ★ `setActive` 会翻 `api/config` 单例里的 API_BASE 与 bearer,此后 `getSent()` /
* `getMail()` / `archiveContact()` 全带**新**账号的凭证 —— 而界面上还留着**旧**账号
* 的数据,于是"从陈旧视图点归档/转发"改的是新账号。这是跨账号数据泄露,
* 而且不报错、不空屏,肉眼完全看不出来。
*
* ★ 为什么放在 store 上而不是由调用方各自清:同一件事有三个入口
* (切换 / 登出 / 401),漏一个就重新泄露一次。一处实现、三个入口都调它。
*/
resetAll: () =>
set({ inbox: [], sent: [], currentMail: null, accountErrors: [], error: null, loading: false }),
markRead: async id => {
try {

View File

@ -33,6 +33,14 @@ interface SessionState {
refreshBudget: () => Promise<void>;
/** 归档后若正查看该会话则退出 */
dropSessionIfCurrent: (sessionId: string) => void;
/**
* 换身份(切账号 / 登出 / 401)时清空全部会话数据。
*
* ★ 与 `mailStore.resetAll` 同一件事的另一半:`currentSession` + `currentSessionMails`
* 带着**旧**账号的会话全文留在内存里,而此后所有请求都带**新**账号的凭证
* (`getSessionDetail` / `archiveContact`)。不清就是跨账号泄露。
*/
resetAll: () => void;
}
export const useSessionStore = create<SessionState>((set, get) => ({
@ -164,5 +172,16 @@ export const useSessionStore = create<SessionState>((set, get) => ({
set(state => ({
sessions: state.sessions.filter(s => s.session_id !== sessionId)
}));
}
},
resetAll: () =>
set({
sessions: [],
currentSession: null,
currentSessionMails: [],
renameProposal: null,
budget: null,
loading: false,
error: null
})
}));

View File

@ -181,7 +181,9 @@ test('A|系统拥有的维度,鸿蒙侧的唯一来源是系统资源(不
*/
const sysOwned = {
pageBg: 'color', surface: 'color', surfaceMuted: 'color', border: 'color',
textPrimary: 'color', textMuted: 'color', textSubtle: 'color',
// textSubtle 已移除:系统三级色浅色下 2.85:1,且本机二级色同值
// (见 SELF_OWNED_COLORS 里 textSubtleLight/Dark 那段),小字走 textSubtleFor()。
textPrimary: 'color', textMuted: 'color',
overlay: 'color', navFg: 'color',
radiusCard: 'float', radiusControl: 'float'
};
@ -297,6 +299,28 @@ const SELF_OWNED_COLORS = [
'chipNeutralBgDark', 'chipNeutralFgDark', 'chipSpentFgDark', 'chipWarnFgDark',
// 权限档位与预算档位的胶囊配色(档位是产品语义,系统不认识"plan/workspace/full")
'chipNeutralBg', 'chipNeutralFg', 'chipSpentBg', 'chipSpentFg', 'chipWarnBg', 'chipWarnFg',
/*
* ★★ 2026-09-26 补登记:小字那一档(`textSubtleLight` / `textSubtleDark`)。
*
* 为什么必须是**自己写**的色,而不是 `$r('sys.color.ohos_id_color_text_*')`:
*
* 2026-09-19 这条设备判据已经抓到过「浅色三级色 = rgb(153,153,153) 压白底
* = 2.85:1」,当时的修法是让 `textSubtleFor()` 两个主题都返回 `textMuted`
* (`ohos_id_color_text_secondary`),理由是"二级色语义对得上"。
*
* ★★ 但 2026-09-26 复扫证明**那个前提不成立**:本机 HarmonyOS 6.1.1 上
* `ohos_id_color_text_secondary` 实测**也是 rgb(153,153,153)**,与三级色同值
* ⇒ 换过去之后**一点没变**,判据继续红。
*
* 系统二级/三级色只保证「比一级淡」这层**层次关系**,不保证 WCAG 下限;
* 同一个坑在本文件已经犯过三次(`surface` 当前景色、accent 深色没调亮、
* 这一次)。而 WebUI 那边 gray-400/gray-500 是**两套主题各自适配过**的值
* (浅 4.83:1 / 深 5.51:1),不是同一个色翻个主题。
*
* ⇒ 这一档的"跨端身份"就是 WebUI 的 gray-400,所以按 gray-400 逐字对齐,
* 并给出两个主题各自实测 ≥3:1 的值。
*/
'textSubtleLight', 'textSubtleDark',
/*
* ★★ 2026-09-19 补登记(对应用户「底栏数字为什么显示在图标下面?」与
* 用户「你写的app和webui大面积不符」那两轮改动)。

View File

@ -522,8 +522,18 @@ test('★ 设备:真实壁纸在设备上的尺寸/体积与压缩决策的输
}
/* 素材在设备上吗?不在就显式跳过(不假装通过) */
const listed = (D.shellOn(hdc, 'ls -la /data/local/tmp/real-wallpaper.jpg').stdout || '').trim();
if (!listed.includes('real-wallpaper.jpg')) {
const probe = D.shellOn(hdc, 'ls -la /data/local/tmp/real-wallpaper.jpg');
const listed = (probe.stdout || '').trim();
/*
* ★ 判"在不在"必须看**退出码**,不能只看输出里有没有那个文件名。
*
* `hdc shell` 会把 stderr 并进 stdout,于是文件不存在时 stdout 是
* `ls: /data/local/tmp/real-wallpaper.jpg: No such file or directory` ——
* **里面仍然含有文件名**,于是原来的 `listed.includes('real-wallpaper.jpg')`
* 判成"在",本该跳过的判据继续往下跑,撞在 `/bin/file` 上**报错**。
* 设备上没有素材是正常状态(没传过壁纸的设备),不该让整条判据变红。
*/
if (probe.status !== 0 || !/^-.*\breal-wallpaper\.jpg$/m.test(listed)) {
return t.skip('设备上没有真实素材 /data/local/tmp/real-wallpaper.jpg —— 本次不跑');
}

View File

@ -276,22 +276,34 @@ export function foregroundBundle(hdc) {
/**
* 拉一份当前 UI 树(`uitest dumpLayout` → 设备上生成 JSON → `cat` 回来 → 解析)。
*
* dumpLayout 的输出形如 `DumpLayout saved to:/data/local/tmp/layout_<ts>.json`,
* 路径是确定的(不是"猜最新的文件"——那样会和别的会话的 dump 抢)。拿到路径
* 后 cat 回来 JSON.parse。根是 `{ attributes, children }`,children 递归同形。
* ★★ 2026-09-26:**必须显式给输出路径**。
*
* 原来跑的是裸 `uitest dumpLayout`(不带 `-p`),期望它自己在 stdout 打一行
* `DumpLayout saved to:…`。在 HarmonyPhone(HarmonyOS 6.1.1)上实测:
* 裸调用 → `DumpLayout failed:Wait for subscribe uitest.broadcast.command.reply timeout`
* (非 0 退出、stdout 空 ⇒ 这条判据直接**报错**而不是跳过)
* 加 `-p` → `DumpLayout saved to:/data/local/tmp/d.json`(正常)
* ⇒ 裸调用依赖的是**自动选名**那条路径,它要等 broadcast 回复;
* 给了确定路径就不用等。
*
* 这不是"设备不在"那种环境缺失,而是**判据自己跑不成** —— 所以必须修,
* 否则日历手势那类判据在真机上会一直红。
*
* 路径自己定 ⇒ 不再从 stdout 反解,也不存在"和别的会话的 dump 抢文件"。
*
* 抛异常(不返回 null):调用方该知道"dump 跑不成"和"dump 出来是空的"是两回事
* —— 前者是环境问题,后者才是断言该管的。判据的 broken 分类靠这个区分。
*/
export function dumpLayout(hdc) {
if (!hdc) throw new Error('hdc 没找到(findHdc() 返回 null)');
const r = sh(hdc, ['shell', 'uitest', 'dumpLayout'], 30000);
// 每次换一个名字:同一路径反复写会让并发跑的两个判据互相读到对方的中间态。
const path = `/data/local/tmp/dsh_dump_${process.pid}_${Date.now()}.json`;
const r = sh(hdc, ['shell', 'uitest', 'dumpLayout', '-p', path], 45000);
if (r.status !== 0) throw new Error(`dumpLayout 失败:${(r.stderr || r.stdout || '').trim()}`);
const m = (r.stdout || '').match(/saved to:(\S+)/);
if (!m) throw new Error(`dumpLayout 没返回文件路径:${(r.stdout || '').trim()}`);
const path = m[1].trim();
const cat = sh(hdc, ['shell', 'cat', path], 20000);
if (cat.status !== 0) throw new Error(`cat ${path} 失败:${(cat.stderr || '').trim()}`);
// 设备上的临时文件不留(几十次判据跑下来 /data/local/tmp 会攒一堆)。
sh(hdc, ['shell', 'rm', '-f', path], 10000);
return JSON.parse(cat.stdout);
}

View File

@ -0,0 +1,129 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { resetAccountData } from '../../src/lib/resetAccountData';
import { useContactStore } from '../../src/stores/contactStore';
import { useMailStore } from '../../src/stores/mailStore';
import { useSessionStore } from '../../src/stores/sessionStore';
/**
* ★ 切身份必须清空全部账号数据(2026-09-26 加的行为锁)。
*
* ── 锁的是哪个 bug ──
* `setActive` 会把 `api/config` 单例里的 API_BASE 与 bearer 翻到新账号,
* 而各 store 还留着**旧**账号的数据 ⇒ "新账号的界面下显示旧账号的邮件",
* 且**从旧视图发出的写操作(归档/转发/批准权限)改的是新账号**。
* 不报错、不空屏、没有任何可见征兆 —— 只能靠判据钉住。
*
* ── 为什么锁 `resetAccountData()` 而不是锁某个组件 ──
* 身份变化有**三条**路径(切账号 / 登出 / 401),它们都必须清;
* 把"清空"收在一个函数里,这三条路径共用它 ⇒ 这里只需要锁**这一个**函数
* 把三份 store 都清干净(清漏一份,那份就是新的泄露面)。
*/
const mail = (id: string) => ({ mail_id: id }) as never;
const session = (id: string) => ({ session_id: id }) as never;
/** 往三个 store 里各塞一份"上个账号的脏数据"。 */
function seedStaleData() {
useMailStore.setState({
inbox: [mail('a1')],
sent: [mail('a2')],
currentMail: mail('a3'),
error: '旧错误',
accountErrors: ['x']
});
useSessionStore.setState({
sessions: [session('s1')] as never,
currentSession: { session_id: 's1' } as never,
currentSessionMails: [mail('a4')],
renameProposal: { old: 'x', new: 'y' } as never,
budget: { used: 1 } as never,
error: '旧错误'
});
useContactStore.setState({
contacts: [{ session_id: 'c1', name: 'A' }] as never,
archivedContacts: [{ session_id: 'c2', name: 'A2' }] as never,
pendingArchive: 'c3',
error: '旧错误'
});
}
describe('resetAccountData —— 换身份时清空全部账号数据', () => {
beforeEach(() => {
// 各 store 的初值本身就是"空",所以直接回到初值即可复位。
useMailStore.setState({
inbox: [],
sent: [],
currentMail: null,
error: null,
accountErrors: [],
loading: false
});
useSessionStore.setState({
sessions: [],
currentSession: null,
currentSessionMails: [],
renameProposal: null,
budget: null,
error: null,
loading: false
});
useContactStore.setState({
contacts: [],
archivedContacts: [],
pendingArchive: null,
error: null,
loading: false
});
});
it('★ 三个 store 全部清空(漏掉任何一份都是新的泄露面)', () => {
seedStaleData();
resetAccountData();
expect(useMailStore.getState().inbox).toEqual([]);
expect(useMailStore.getState().sent).toEqual([]);
expect(useMailStore.getState().currentMail).toBeNull();
expect(useSessionStore.getState().sessions).toEqual([]);
expect(useSessionStore.getState().currentSession).toBeNull();
expect(useSessionStore.getState().currentSessionMails).toEqual([]);
expect(useSessionStore.getState().renameProposal).toBeNull();
expect(useSessionStore.getState().budget).toBeNull();
expect(useContactStore.getState().contacts).toEqual([]);
expect(useContactStore.getState().archivedContacts).toEqual([]);
expect(useContactStore.getState().pendingArchive).toBeNull();
});
it('错误态也要清:旧账号的报错不该挂在别人的界面上', () => {
seedStaleData();
resetAccountData();
expect(useMailStore.getState().error).toBeNull();
expect(useSessionStore.getState().error).toBeNull();
expect(useContactStore.getState().error).toBeNull();
});
it('幂等:连续切账号时重复清空不报错', () => {
seedStaleData();
resetAccountData();
expect(() => {
resetAccountData();
resetAccountData();
}).not.toThrow();
});
it('清空**不碰**纯界面状态(那是 uiStore 的职责,不在这里)', () => {
/*
* 这条是**边界**:复位界面状态(窄屏分栏、写信态)由 `App.tsx` 的 `resetUI()`
* 负责。`resetAccountData` 若顺手去清那些,会让"切账号"变成"退出登录",
* 把两件不同的事混在一起 —— 所以这里显式锁住"只清数据"。
*/
const ui = useSessionStore.getState();
expect(typeof ui.resetAll).toBe('function');
// 三个 store 都提供 resetAll(而不是各自散落的 clearXxx)——
// 统一入口才不会在第四条身份路径上被漏掉。
expect(typeof useMailStore.getState().resetAll).toBe('function');
expect(typeof useContactStore.getState().resetAll).toBe('function');
});
});

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('吹销')