feat(harmony): P2b+P3 —— 「收件箱」改成「通信」(内部三栏 + 徽标 + 悬浮加号),发件箱与授权栏落地

用户:「收件发件授权改为一个导航项,通过内部导航区分,然后新建作为他们内部的一个悬浮的圆形加号」。
所以这一期不是"再加两个页面",而是对齐信息架构。

## 鸿蒙侧

- 底部第一项 **收件箱 → 通信**(`CommPage`),内部三栏 收件箱 / 发件箱 / 授权;
  页签下划线式(不是浮动白胶囊 —— WebUI 侧用户原话「通信页面的二级页面与其他位置极其割裂」)。
- **徽标**:收件箱红(未读)、授权橙(**待决策**)、发件箱无;0 不显示,>99 写 `99+`。
  合并成一个导航项后,底部看不到"授权有 3 个在等我",这个信息不能丢 —— 它比未读更急。
- **悬浮圆形加号**挂到通信页这一层(三个栏都要能新建);`⚙` 也搬上来(否则切栏就够不到设置)。
- **收件箱不再混权限邮件**(`splitByPermission`),未读按筛后算 ——
  WebUI 实测过"一个会话 17 封权限邮件挤掉另外两个会话"。
- **发件箱**:`GET /me/mail/sent`,与收件箱同构(同一套折叠/行),行上主角是**收件人**;
  空态有说明(主句与 WebUI 逐字一致「暂无邮件」+ 一句"这里放什么")。
- **授权栏**(P3 主体):`GET /permission/pending`(不从收件箱筛)+ `POST /permission/decide`。
  拒绝**可填备注且备注真的送出**;请求**过期**时当场说清「审批不会让那次调用继续」。
- 顺手删掉死代码:收件箱里「写邮件」的 `bindSheet`(`composeVisible` 从未置 true,谁也打不开)。

## 判据(harmony-logic 19 → 28 条)

页签键/顺序从 WebUI 源码抽取比对(`uiStore.ts` 的 `CommTab` + `CommTabs.tsx` 的 `TABS`);
页签状态机(键↔下标往返、脏键/越界/非整数 → 回收件箱);徽标规则(数字来源、0 不显示、
99+ 上限、红/橙与 WebUI 类名对应);分家语义(决策过的不再算待决策、空串与 null 同义);
三栏空态互不相同且主句与 WebUI 一致;接线(三个 pane 真的渲染、加号是圆形且在通信页、
"先分家再折叠"、接口路径与决策体三字段、拒绝传备注、过期分支)。

判据抓到一个**真 bug**:`commTabFromIndex` 只判范围,`1.5` → `COMM_TABS[1.5]` = `undefined`
(表现"点哪都不亮")。已加 `Number.isInteger`。

变异验证(六种):授权徽标看未读 / 权限邮件不分出去 / 类型名拼错 / 决策过仍算待决策 /
收件箱不分家 / 发件箱空态去掉说明 —— 全部判红。

## 排序说明

**日历暂时没有入口**:P6 的内容(网格 + 事件读写 + 滑动翻页)还没做,
先放一个点进去空着的入口比暂时没有更糟 —— 有意排序,记在 §7.15 以免被当成漏做。

## 验证 / 未验

`hvigorw assembleHap` BUILD SUCCESSFUL;`npm test` 退出码 0(10 个判据文件全绿 + vitest 258/258)。
**未验**:页签/徽标/悬浮加号在真机上的观感与点击 —— 需设备或模拟器(模拟器要人在命令行启动)。
This commit is contained in:
2026-09-14 14:13:45 +08:00
parent c6aaf8c468
commit 73f886aea5
7 changed files with 1196 additions and 65 deletions

View File

@ -28,9 +28,11 @@ const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, '..', '..', '..');
const HARMONY_ETS = join(ROOT, 'client/harmony/entry/src/main/ets');
const MODULE_TS = join(HARMONY_ETS, 'model/MailGrouping.ts');
const COMM_TS = join(HARMONY_ETS, 'model/CommTabs.ts');
/** 被测对象:鸿蒙客户端真正引用的那份逻辑(不是复制品) */
const H = await import(pathToFileURL(MODULE_TS).href);
const C = await import(pathToFileURL(COMM_TS).href);
const page = readFileSync(join(HARMONY_ETS, 'pages/MainPage.ets'), 'utf8');
/**
@ -56,6 +58,9 @@ const mail = (over) => Object.assign(
body_preview: '预览',
created_at: '2026-09-14T10:00:00Z',
status: 'read',
mail_type: 'normal',
permission_result: '',
to_name: 'pi',
permission_mode: '',
source_account_id: 'acct-1',
source_account_name: '工作邮箱'
@ -202,7 +207,9 @@ test('卡片上"最新一封是谁发的":人 vs Agent决定人要不要接
test('页面把折叠逻辑真正接上了(不是"逻辑写好了没人用"', () => {
assert.match(pageCode, /import \{[\s\S]*groupMailsBySession[\s\S]*\} from '\.\.\/model\/MailGrouping'/, '页面要 import 折叠逻辑');
assert.match(pageCode, /this\.groups = groupMailsBySession\(mergedMails\)/, '加载后要折叠');
// 加载后要折叠。变量是**筛过权限邮件之后**的那一批(权限邮件归授权栏,
// 收件箱里不该出现它们 —— 见"权限邮件不进收件箱"那条)。
assert.match(pageCode, /this\.groups = groupMailsBySession\(inboxMails\)/, '加载后要折叠(筛过权限邮件的那批)');
assert.match(pageCode, /if \(isFlatGroup\(g\)\)/, '单封的组要平铺渲染(这条就是"点开会多一次点击"的那个分支)');
assert.match(pageCode, /this\.isExpanded\(g\.key\)/, '多封的组要按展开状态渲染');
assert.match(pageCode, /toggleExpanded\(g\.key\)/, '组头要能点开(用户真正会点的那一层)');
@ -211,15 +218,18 @@ test('页面把折叠逻辑真正接上了(不是"逻辑写好了没人用"
// ───────────────────────── 撤掉平级「会话」tabP2a 收尾) ─────────────────────────
test('底部只剩 收件箱 / 联系人 两个平级页签,「会话」不再是入口', () => {
test('底部只剩 通信 / 联系人 两个平级页签,「会话」不再是入口', () => {
/*
* pi 的判断WebUI 里「会话」从来不是一个入口,它是**两处已有视图**(联系人页的卡片视图
* + 收件箱的会话折叠)。鸿蒙原来把它单列成 tab等于把"卡片视图"放错了位置。
* 顺序也按他说的:**先补视图与折叠,再撤 tab** —— 撤早了,
* 往返预算 / status / from_agent 这些只在会话列表里出现的信息就没地方看了。
*/
// 第一项 2026-09-14 从「收件箱」变成「通信」:收件箱/发件箱/授权现在是**通信页内部**的三栏
// (用户:「收件发件授权改为一个导航项,通过内部导航区分」),标签必须跟着改 ——
// 否则底部写着"收件箱"、点进去却有发件箱和授权,比没有更让人困惑。
const tabLabels = [...pageCode.matchAll(/TabBarBuilder\('([^']+)'/g)].map(m => m[1]);
assert.deepEqual(tabLabels, ['收件箱', '联系人'], `平级页签应只剩两个,实际:${tabLabels.join('、')}`);
assert.deepEqual(tabLabels, ['通信', '联系人'], `平级页签应只剩两个,实际:${tabLabels.join('、')}`);
assert.ok(!/struct\s+SessionsTab/.test(pageCode), 'SessionsTab 已经撤了,不该再留在页面里');
assert.ok(!/sessions\(\)/.test(pageCode), '撤了入口就不该再拉 /me/sessions否则是没人看的请求');
});
@ -384,3 +394,164 @@ test('★ 不得再用废弃的全局 promptAction.showToastAPI 18 起废弃
assert.ok(/(?<!get)promptAction\.showToast\(/.test('promptAction.showToast({ message: 1 })'), '自检:认不出旧写法');
assert.ok(!/(?<!get)promptAction\.showToast\(/.test('this.getUIContext().getPromptAction().showToast({})'), '自检:误伤了新写法');
});
// ───────────────────── 通信页:内部页签 / 徽标 / 三栏分家P2b ─────────────────────
const webUiStore = readFileSync(join(ROOT, 'client/electron/src/stores/uiStore.ts'), 'utf8');
const webCommTabs = readFileSync(join(ROOT, 'client/electron/src/components/CommTabs.tsx'), 'utf8');
const webMailList = readFileSync(join(ROOT, 'client/electron/src/components/MailList.tsx'), 'utf8');
const sendCode = readFileSync(join(HARMONY_ETS, 'pages/MainPage.ets'), 'utf8')
.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
test('通信页的页签键与顺序,与 WebUI 的 CommTab / TABS 完全一致', () => {
/*
* 页签键是**状态机的字母表**:两边不一致时,深链/恢复上次页签的行为会悄悄不同
* (用户上次停在"授权",鸿蒙这边认不出这个键,就退回收件箱了)。
* 所以从 WebUI 源码里把键抽出来比,而不是在判据里再抄一遍。
*/
const union = webUiStore.match(/export type CommTab = ([^;]+);/);
assert.ok(union, 'WebUI 要有 CommTab 联合类型');
const webKeys = [...union[1].matchAll(/'([a-z]+)'/g)].map(m => m[1]);
assert.deepEqual(C.COMM_TABS, webKeys, '页签键与顺序必须与 WebUI 一致');
// 标签也要一致(用户看到的就是这两个字)
const tabBlock = webCommTabs.slice(webCommTabs.indexOf('const TABS'), webCommTabs.indexOf('];'));
const webLabels = [...tabBlock.matchAll(/label:\s*'([^']+)'/g)].map(m => m[1]);
assert.deepEqual(C.COMM_TABS.map(C.commTabLabel), webLabels, '页签标签与 WebUI 一致');
assert.deepEqual(webLabels, ['收件箱', '发件箱', '授权']);
});
test('★ 页签状态机:认不出的键与越界下标都落回收件箱(不落在空 pane 上)', () => {
assert.equal(C.COMM_TAB_DEFAULT, 'inbox', '默认页签是收件箱WebUI 的 uiStore 初值也是它)');
// 三个键各自往返:键 ↔ 下标
C.COMM_TABS.forEach((key, i) => {
assert.equal(C.commTabIndex(key), i, `${key} 的下标应是 ${i}`);
assert.equal(C.commTabFromIndex(i), key, `下标 ${i} 应是 ${key}`);
assert.equal(C.normalizeCommTab(key), key, `${key} 是合法键,不该被改写`);
});
// 脏输入:旧数据、拼错的键、名字改过之后的残留
for (const junk of ['', 'inboxx', 'SENT', '会话', 'unknown']) {
assert.equal(C.normalizeCommTab(junk), 'inbox', `认不出的键 ${JSON.stringify(junk)} 应落回收件箱`);
assert.equal(C.commTabIndex(junk), 0, `${junk} 的页签栏下标应是默认页签`);
}
for (const bad of [-1, 3, 99, 1.5]) {
assert.equal(C.commTabFromIndex(bad), 'inbox', `越界下标 ${bad} 应落回收件箱`);
}
});
test('徽标:未读红 / 待决策橙 / 发件箱无,且 99 以上写 99+(与 CommTabs.tsx 同一套规则)', () => {
// 数字来源:收件箱看未读、授权看**待决策**、发件箱没有徽标
assert.equal(C.badgeCount('inbox', 3, 5), 3);
assert.equal(C.badgeCount('permissions', 3, 5), 5, '授权徽标看**待决策**,不是权限邮件总数');
assert.equal(C.badgeCount('sent', 3, 5), 0, '发件箱不该有徽标');
assert.equal(C.badgeCount('不认识', 3, 5), 3, '认不出的键按默认页签(收件箱)算');
// 脏数字(负数/NaN 来源)不该显示成负数
assert.equal(C.badgeCount('inbox', -1, 0), 0);
assert.equal(C.badgeCount('permissions', 0, -3), 0);
// 文字与上限WebUI 写的是 `badge > 99 ? '99+' : badge`
assert.equal(C.badgeText(0), '', '0 不显示徽标');
assert.equal(C.badgeText(-1), '', '负数不显示');
assert.equal(C.badgeText(1), '1');
assert.equal(C.badgeText(99), '99');
assert.equal(C.badgeText(100), '99+');
assert.match(webCommTabs, /badge > 99 \? '99\+' : badge/, 'WebUI 的上限写法变了,这条判据要跟着核');
// 色调:红=要读、橙=有人被卡住。色值来自 Theme页面不自己挑颜色
assert.equal(C.badgeTone('inbox'), 'danger');
assert.equal(C.badgeTone('permissions'), 'warn');
assert.equal(C.badgeTone('sent'), 'none');
assert.match(webCommTabs, /key === 'permissions' \? 'bg-orange-700 text-white' : 'bg-red-600 text-white'/,
'WebUI 的徽标配色变了(授权橙、收件箱红),这条判据要跟着核');
});
test('★ 权限邮件不进收件箱:它进授权栏,且**决策过的**不再算待决策', () => {
const asks = mail({ mail_id: 'perm-1', mail_type: 'permission_request', permission_result: '', status: 'unread', subject: '是否允许删除' });
const settled = mail({ mail_id: 'perm-2', mail_type: 'permission_request', permission_result: 'allow', status: 'unread' });
const letter = mail({ mail_id: 'plain-1', mail_type: 'normal', status: 'unread' });
const split = H.splitByPermission([asks, settled, letter]);
assert.deepEqual(split.normal.map(m => m.mail_id), ['plain-1'], '收件箱只放要读的');
assert.deepEqual(split.permissions.map(m => m.mail_id), ['perm-1', 'perm-2'], '授权栏放全部权限邮件(含已决策的)');
assert.equal(H.isPendingPermission(asks), true, '没有 permission_result = 还在等人点头');
assert.equal(H.isPendingPermission(settled), false, '已决策的不该再喊人');
// 后端用 COALESCE 归一成空串,空串与 null 同义 —— 判据要按"假值"而不是"等于空串"来判
assert.equal(H.isPendingPermission(mail({ mail_type: 'permission_request', permission_result: null })), true);
assert.equal(H.countPendingPermissions([asks, settled, letter]), 1);
// 收件箱未读不能把授权栏的未读算进去(否则头显示"7 未读"、列表里一封都没有)
const heads = [mail({ status: 'unread', mail_type: 'normal' }), mail({ status: 'unread', mail_type: 'permission_request' })];
const normalUnread = H.splitByPermission(heads).normal.filter(m => m.status === 'unread').length;
assert.equal(normalUnread, 1);
});
test('三栏的空态都有说明,且主句与 WebUI 的「暂无邮件」逐字一致', () => {
// WebUI 的空态就是这一句MailList.tsx—— 两边对同一件事说同一句话
assert.match(webMailList, />暂无邮件</, 'WebUI 的空态文案变了,这条判据要跟着核');
for (const tab of C.COMM_TABS) {
assert.equal(C.emptyTitle(tab), '暂无邮件', `${tab} 的主句应与 WebUI 一致`);
assert.ok(C.emptyHint(tab).length >= 6, `${tab} 要有"为什么是空的"那一句(只说暂无邮件说不清)`);
}
// 空态说明要**分得开**:三栏各说各的,否则等于没说明
assert.equal(new Set(C.COMM_TABS.map(C.emptyHint)).size, 3, '三栏的空态说明不该是同一句话');
assert.match(C.emptyHint('sent'), /发出/, '发件箱空态要说清这是"你发出的信"的地方');
assert.match(C.emptyHint('permissions'), /待决策/, '授权空态要说清"没有人被卡住"');
});
test('通信页把三栏真的接上了:内部页签 + 徽标 + 悬浮加号 + 收件箱按权限分家', () => {
// 底部第一项的标签是「通信」而不是「收件箱」(信息架构变了,标签必须跟着变)
const tabLabels = [...sendCode.matchAll(/TabBarBuilder\('([^']+)'/g)].map(m => m[1]);
assert.deepEqual(tabLabels, ['通信', '联系人'], `底部应只剩两项且第一项是通信,实际:${tabLabels.join('、')}`);
assert.match(sendCode, /CommPage\(\)/, '第一项要渲染通信页');
// 内部页签:三栏由 COMM_TABS 驱动,点击切到 normalizeCommTab 的**同一个函数**
assert.match(sendCode, /ForEach\(COMM_TABS, \(key: string\)/, '页签栏要按页签清单渲染');
assert.match(sendCode, /commTabLabel\(key\)/, '页签文字走同一份标签');
assert.match(sendCode, /badgeText\(badgeCount\(key, this\.unreadCount, this\.pendingCount\)\)/, '徽标数字走同一份规则');
assert.match(sendCode, /this\.commTab = normalizeCommTab\(key\)/, '点击要过状态机的归一化,不能直接赋值');
// 三个 pane 都要真的存在(少一个就是空页签)
for (const pane of ['InboxTab()', 'SentTab()', 'PermissionTab()']) {
assert.ok(sendCode.includes(pane), `通信页要渲染 ${pane}`);
}
// 悬浮加号在通信页这一层(三个栏都要能新建),形状是圆形
const commPage = sendCode.slice(sendCode.indexOf('struct CommPage'), sendCode.indexOf('struct ContactsTab'));
assert.match(commPage, /Text\('\+'\)[\s\S]{0,220}?borderRadius\(28\)/, '悬浮加号要是圆形');
assert.match(commPage, /this\.openCompose\(\)/, '加号要真的能进写信页');
// 收件箱那一栏必须**先分家再折叠**(否则权限邮件会混进收件箱,正是这条要防的)
// 注意切到那一行**之后**再截断:原来截到 indexOf 处,正好把要断言的那一行切掉,
// 判据于是报"再折叠筛过的那些"失败 —— 判据自己的切片边界错了(变异测试式的自省)
const gStart = sendCode.indexOf('this.groups = groupMailsBySession');
const inboxLoad = sendCode.slice(sendCode.indexOf('async loadData'), gStart + 60);
assert.match(inboxLoad, /splitByPermission\(mergedMails\)/, '收件箱要先分家');
assert.match(inboxLoad, /groupMailsBySession\(inboxMails\)/, '再折叠筛过的那些');
});
test('发件箱与授权栏走的是与 WebUI 相同的接口(路径、字段、备注都要对)', () => {
const api = readFileSync(join(HARMONY_ETS, 'api/MailApi.ets'), 'utf8')
.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
assert.match(api, /get<SentResponse>\('\/me\/mail\/sent'\)/, '发件箱接口');
assert.match(api, /get<PendingResponse>\('\/permission\/pending'\)/, '待决列表接口(不从收件箱筛)');
assert.match(api, /post<DecideResponse>\('\/permission\/decide'/, '决策接口');
// 决策体三个字段名必须与后端/WebUI 一致
const decide = api.slice(api.indexOf('async decidePermission'), api.indexOf('async decidePermission') + 420);
for (const field of ['mail_id', 'decision', 'note']) {
assert.ok(new RegExp(`payload\\.${field} =`).test(decide), `决策体要带 ${field}(后端按这三个字段解析)`);
}
// 备注必须真的送出:拒绝路径要传 noteTextWebUI 踩过"界面能填、其实没发出去"的坑)
const permTab = sendCode.slice(sendCode.indexOf('struct PermissionTab'));
assert.match(permTab, /this\.decide\(req, 'deny', this\.noteText\)/, '拒绝要把备注送出去');
assert.match(permTab, /this\.noteText = v;/, '备注框要真的收集输入');
// 过期必须当场说清:审批不会让那次调用继续
assert.match(permTab, /resp\.expired/, '过期分支要处理');
assert.match(permTab, /不会让那次调用继续/, '过期时必须说清后果(否则人会以为 Agent 接着跑了)');
});
test('★ 判据自检:把状态机的默认页签改错必须判红', () => {
// 自检方式:直接改源码字符串,确认断言会红(而不是"看起来能红"
const mutated = sendCode.replace("this.commTab = normalizeCommTab(key);", "this.commTab = key;");
assert.ok(!/this\.commTab = normalizeCommTab\(key\)/.test(mutated), '自检:变异没生效');
const defaultMutated = readFileSync(COMM_TS, 'utf8').replace("export const COMM_TAB_DEFAULT: string = 'inbox';", "export const COMM_TAB_DEFAULT: string = 'sent';");
assert.ok(!/COMM_TAB_DEFAULT: string = 'inbox'/.test(defaultMutated), '自检:默认页签变异没生效');
});