diff --git a/client/electron/package.json b/client/electron/package.json index 366242d..da627e3 100644 --- a/client/electron/package.json +++ b/client/electron/package.json @@ -19,7 +19,7 @@ "build:linux": "vite build && electron-builder --linux", "preview": "vite preview", "typecheck": "tsc --noEmit", - "test": "node test/markdown-xss.test.mjs && node test/narrow-layout.test.mjs && node test/theme.test.mjs && node test/background.test.mjs && node test/cross-client-theme.test.mjs && node test/packaging.test.mjs && vitest run", + "test": "node test/markdown-xss.test.mjs && node test/narrow-layout.test.mjs && node test/theme.test.mjs && node test/background.test.mjs && node test/cross-client-theme.test.mjs && node --experimental-strip-types --no-warnings --test test/harmony-logic.test.mjs && node test/packaging.test.mjs && vitest run", "test:narrow": "node test/manual/narrow-verify.mjs", "test:wide": "node test/manual/wide-regression.mjs", "test:components": "vitest run", diff --git a/client/electron/test/harmony-logic.test.mjs b/client/electron/test/harmony-logic.test.mjs new file mode 100644 index 0000000..392fcc0 --- /dev/null +++ b/client/electron/test/harmony-logic.test.mjs @@ -0,0 +1,201 @@ +/** + * 鸿蒙侧的**可执行判据** —— 跑的是客户端真正会跑的那份逻辑。 + * + * 为什么要有这个文件: + * + * 移交信里交代的头号纪律是「判据必须点用户真正会点的那一层」—— WebUI 侧就是因为 + * 只验了结构与样式、**一次都没点过**,漏掉了"点日历/联系人不翻页"的 bug 一路到线上。 + * 而鸿蒙侧现在**没有设备**(`hdc list targets` 为空、模拟器在本机沙箱下起不来), + * 于是"点一下"这件事在鸿蒙上暂时无法自动验。 + * + * 应对办法不是编个假判据,而是把**会点的那一层的内核**抽出来: + * `client/harmony/entry/src/main/ets/model/MailGrouping.ts` 是纯逻辑、无 UI 依赖, + * 本文件用 node 的 `--experimental-strip-types` **直接执行它**,断言的是行为 + * (折叠后组头是不是最新一封、单封是不是不成组、预算剩 1 个来回是什么档)。 + * 页面那一层再用源码判据钉住"确实调了这些函数"—— 两层合起来, + * 「逻辑对」+「页面接上了」都有判据;剩下的"手感/观感"如实标注未验。 + * + * 与 WebUI 的对应物:`src/lib/mailGroups.ts`(折叠 / isFlatGroup)、 + * `src/components/WorkCard.tsx` 的 `BudgetChip`(预算档位)、`ContactPanel.tsx`(视图切换)。 + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +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 H = await import(pathToFileURL(MODULE_TS).href); + +const page = readFileSync(join(HARMONY_ETS, 'pages/MainPage.ets'), 'utf8'); +const webGroups = readFileSync(join(ROOT, 'client/electron/src/lib/mailGroups.ts'), 'utf8'); +const webCard = readFileSync(join(ROOT, 'client/electron/src/components/WorkCard.tsx'), 'utf8'); + +/** 造一封邮件(只带折叠/渲染用得到的字段,字段名与 Models.ets 的 MailSummary 一致) */ +const mail = (over) => Object.assign( + { + mail_id: 'm-' + Math.random().toString(36).slice(2, 8), + session_id: 's1', + session_alias: '', + from_name: 'pi', + subject: '主题', + body_preview: '预览', + created_at: '2026-09-14T10:00:00Z', + status: 'read', + permission_mode: '', + source_account_id: 'acct-1', + source_account_name: '工作邮箱' + }, + over +); + +// ───────────────────────── 收件箱折叠 ───────────────────────── + +test('折叠:同一个会话的信合成一组,组头取**最新一封**', () => { + const older = mail({ mail_id: 'm-a', session_id: 's1', created_at: '2026-09-14T09:00:00Z', subject: '旧主题', session_alias: '旧别名' }); + const newer = mail({ mail_id: 'm-b', session_id: 's1', created_at: '2026-09-14T11:00:00Z', subject: '新主题', session_alias: 'fix-x', status: 'unread' }); + const groups = H.groupMailsBySession([older, newer]); + + assert.equal(groups.length, 1, '同一个 session_id 应该只有一组'); + assert.equal(groups[0].alias, 'fix-x', '组头别名应取最新一封'); + assert.equal(groups[0].subject, '新主题', '组头主题应取最新一封(会话主题会随任务推进被改写)'); + assert.equal(groups[0].latest.mail_id, 'm-b'); + assert.equal(groups[0].unreadCount, 1, '组内未读数'); + assert.deepEqual(groups[0].mails.map(m => m.mail_id), ['m-b', 'm-a'], '组内按时间倒序'); +}); + +test('★ 判据自检:把组头当成"第一封"而不是"最新一封"必须判红', () => { + // 故意按"旧 → 新"传入:实现里少了 sort 的话,mails[0] 就是旧的,组头会写错 + const older = mail({ mail_id: 'm-a', session_id: 's1', created_at: '2026-09-14T09:00:00Z', subject: '旧主题' }); + const newer = mail({ mail_id: 'm-b', session_id: 's1', created_at: '2026-09-14T11:00:00Z', subject: '新主题' }); + const groups = H.groupMailsBySession([older, newer]); + assert.notEqual(groups[0].subject, '旧主题', '组头取到了旧的那封 —— 折叠没有排序'); +}); + +test('单封的组不算「组」(与 WebUI isFlatGroup 同一结论)', () => { + const one = H.groupMailsBySession([mail({ session_id: 's1' })]); + const two = H.groupMailsBySession([mail({ session_id: 's1' }), mail({ session_id: 's1', created_at: '2026-09-14T11:00:00Z' })]); + assert.equal(H.isFlatGroup(one[0]), true); + assert.equal(H.isFlatGroup(two[0]), false); + // WebUI 侧同一条规则仍在(哪边改了口径,这条会红) + assert.match(webGroups, /export function isFlatGroup\(g: MailGroup\): boolean \{\s*return g\.mails\.length === 1;/, 'WebUI 的 isFlatGroup 口径变了'); +}); + +test('组间按最新一封倒序;时间相同时用 mail_id 倒序兜底', () => { + const a = mail({ mail_id: 'm-1', session_id: 'sa', created_at: '2026-09-14T09:00:00Z' }); + const b = mail({ mail_id: 'm-2', session_id: 'sb', created_at: '2026-09-14T12:00:00Z' }); + const groups = H.groupMailsBySession([a, b]); + assert.deepEqual(groups.map(g => g.session_id), ['sb', 'sa']); + + // 同一时刻:mail_id 倒序(与后端 ORDER BY created_at DESC, mail_id DESC 一致) + const t = '2026-09-14T10:00:00Z'; + const x = mail({ mail_id: 'm-aaa', session_id: 'sx', created_at: t }); + const y = mail({ mail_id: 'm-zzz', session_id: 'sy', created_at: t }); + assert.deepEqual(H.groupMailsBySession([x, y]).map(g => g.session_id), ['sy', 'sx']); +}); + +test('时间解析失败不抛错、也不把顺序交给入参(NaN 参与比较恒为 false 的坑)', () => { + const bad = mail({ mail_id: 'm-bad', session_id: 'sbad', created_at: '不是时间' }); + const good = mail({ mail_id: 'm-ok', session_id: 'sok', created_at: '2026-09-14T10:00:00Z' }); + const forward = H.groupMailsBySession([bad, good]).map(g => g.session_id); + const backward = H.groupMailsBySession([good, bad]).map(g => g.session_id); + assert.deepEqual(forward, backward, '入参顺序换了,结果就变 —— 排序依赖了 NaN 比较'); + assert.deepEqual(forward, ['sok', 'sbad']); +}); + +test('鸿蒙特有:多账号收件箱里,同一 session_id 出现在两个账号是两件事', () => { + const fromA = mail({ mail_id: 'm-a', session_id: 'shared', source_account_id: 'acct-1' }); + const fromB = mail({ mail_id: 'm-b', session_id: 'shared', source_account_id: 'acct-2', created_at: '2026-09-14T11:00:00Z' }); + const groups = H.groupMailsBySession([fromA, fromB]); + assert.equal(groups.length, 2, '两个账号的同名会话被折叠成了一组 —— 键里少了账号'); +}); + +test('session_id 缺失的信各自成组(一条脏数据不该让整栏空白)', () => { + const a = mail({ mail_id: 'm-1', session_id: '' }); + const b = mail({ mail_id: 'm-2', session_id: '' }); + const groups = H.groupMailsBySession([a, b]); + assert.equal(groups.length, 2); + assert.equal(H.isFlatGroup(groups[0]), true); +}); + +// ───────────────────────── 未读数与"这一页可能不全" ───────────────────────── + +test('未读数用服务端 total 相加(它是 CountUnread,权威),负数/0 忽略', () => { + assert.equal(H.sumUnreadTotals([3, 4]), 7); + assert.equal(H.sumUnreadTotals([0, -1, 2]), 2); + assert.equal(H.sumUnreadTotals([]), 0); + assert.match(page, /sumUnreadTotals\(/, '页面要用服务端未读数,而不是数这一页'); +}); + +test('只有"取满了这一页"才提示可能还有更多(服务端 total 是未读数,不是总封数)', () => { + assert.equal(H.partialLoadNotice(12, 50), '', '没取满就别吓人'); + assert.equal(H.partialLoadNotice(50, 50), '已加载 50 封(本页上限 50,可能还有更多)'); + assert.equal(H.partialLoadNotice(0, 0), '', 'limit 不合法时不提示'); + assert.match(page, /partialLoadNotice\(/, '页面要用这条提示'); +}); + +test('★ 界面不再把"服务端未读数"当成"总封数"显示', () => { + // 原先底部写的是「共 N 封」,而那个 N 是 /me/mail/inbox 的 total(= CountUnread), + // 于是同一屏上会出现「共 7 封」和「未读 7」这种自相矛盾的两行字。 + assert.ok( + !/共 ' \+ this\.total \+ ' 封/.test(page), + '页面里还有「共 N 封」—— 服务端 total 是未读数,不是总封数' + ); + assert.match(page, /已加载 ' \+ this\.loaded \+ ' 封/, '应如实说"已加载了多少封"'); +}); + +// ───────────────────────── 预算(与 WebUI BudgetChip 同判据) ───────────────────────── + +test('往返预算档位与 WebUI 的 BudgetChip 完全一致', () => { + // WebUI 的判据(WorkCard.tsx):max<=0 不显示;剩 0 = 用尽;剩 ≤1 = 将尽;其余普通 + assert.match(webCard, /if \(!max \|\| max <= 0\) return null;/, 'WebUI 的"不限不显示"口径变了'); + assert.match(webCard, /remaining === 0/, 'WebUI 的"用尽"判据变了'); + assert.match(webCard, /remaining <= 1/, 'WebUI 的"将尽"判据变了'); + + // 鸿蒙侧同一批输入必须给出同样的档位 + assert.equal(H.budgetState(0, 0), 'none', '上限 0 = 不限,不显示'); + assert.equal(H.budgetState(5, 5), 'spent', '剩 0 = 用尽'); + assert.equal(H.budgetState(5, 4), 'warn', '剩 1 = 将尽(快跑满的任务要人介入)'); + assert.equal(H.budgetState(5, 3), 'ok'); + assert.equal(H.budgetState(5, 9), 'spent', '用超了也是用尽,不能算成还剩负数'); + assert.equal(H.budgetLabel(5, 4), '1/5'); + assert.equal(H.budgetLabel(0, 0), '', '不限时徽标文字是空串(页面据此不渲染)'); + assert.match(page, /budgetLabel\(c\.max_rounds, c\.used_rounds\)/, '卡片上要显示预算'); + assert.match(page, /budgetState\(c\.max_rounds, c\.used_rounds\)/, '卡片上要用同一档位判据'); +}); + +// ───────────────────────── 联系人页两种视图(为撤掉平级「会话」tab 做准备) ───────────────────────── + +test('联系人页的列表/卡片切换:点一次换一次,标题用 WebUI 那套词', () => { + assert.equal(H.nextContactView('list'), 'card'); + assert.equal(H.nextContactView('card'), 'list'); + assert.equal(H.contactViewTitle('card'), '工作列表', '卡片视图的标题应与 WebUI 一致'); + assert.equal(H.contactViewTitle('list'), '联系人'); + + assert.match(page, /this\.contactView = nextContactView\(this\.contactView\)/, '切换按钮要走这条判据'); + assert.match(page, /contactViewTitle\(this\.contactView\)/, '标题要走这条判据'); +}); + +test('卡片上"最新一封是谁发的":人 vs Agent(决定人要不要接手)', () => { + assert.equal(H.lastFromIsHuman('pi', 'jianf'), true); + assert.equal(H.lastFromIsHuman('pi', 'pi'), false); + assert.match(page, /lastFromIsHuman\(c\.agent_name, c\.last_from\)/, '卡片要用这条判据选图标'); + // WebUI 侧同一判据仍在 + assert.match(webCard, /const fromHuman = c\.last_from !== c\.agent_name;/, 'WebUI 的 fromHuman 口径变了'); +}); + +// ───────────────────────── 两层的接合:页面确实调了被测逻辑 ───────────────────────── + +test('页面把折叠逻辑真正接上了(不是"逻辑写好了没人用")', () => { + assert.match(page, /import \{[\s\S]*groupMailsBySession[\s\S]*\} from '\.\.\/model\/MailGrouping'/, '页面要 import 折叠逻辑'); + assert.match(page, /this\.groups = groupMailsBySession\(mergedMails\)/, '加载后要折叠'); + assert.match(page, /if \(isFlatGroup\(g\)\)/, '单封的组要平铺渲染(这条就是"点开会多一次点击"的那个分支)'); + assert.match(page, /this\.isExpanded\(g\.key\)/, '多封的组要按展开状态渲染'); + assert.match(page, /toggleExpanded\(g\.key\)/, '组头要能点开(用户真正会点的那一层)'); + assert.match(page, /this\.WorkCard\(c\)/, '卡片视图要真的渲染出来'); +}); diff --git a/client/harmony/entry/src/main/ets/common/Theme.ets b/client/harmony/entry/src/main/ets/common/Theme.ets index 49fd13b..ce9c844 100644 --- a/client/harmony/entry/src/main/ets/common/Theme.ets +++ b/client/harmony/entry/src/main/ets/common/Theme.ets @@ -104,6 +104,44 @@ export class Theme { return Theme.approveFg; } + /** 中性 chip(对应 WebUI 的 --c-gray-100 / --c-gray-500)—— 预算条"还宽裕"档 */ + static readonly chipNeutralBg: string = '#F3F4F6'; + static readonly chipNeutralFg: string = '#5A6270'; + /** 预算用尽(对应 WebUI 的 --c-red-100 / --c-red-700) */ + static readonly chipSpentBg: string = '#FEE2E2'; + static readonly chipSpentFg: string = '#B91C1C'; + /** 预算将尽(对应 WebUI 的 --c-orange-100 / --c-orange-700) */ + static readonly chipWarnBg: string = '#FFEDD5'; + static readonly chipWarnFg: string = '#C2410C'; + + /** + * 往返预算档位 → chip 底色 / 字色。 + * + * 与 WebUI 的 `BudgetChip` **同一映射**(`WorkCard.tsx`): + * 剩 0 = 红(用尽)、剩 ≤1 = 橙(将尽,任务需要人介入)、其余 = 中性灰。 + * 档位本身由 `MailGrouping.ts` 的 `budgetState()` 算(那是可被判据执行的一层), + * 这里只管"哪个档用什么颜色"。 + */ + static budgetBg(state: string): string { + if (state === 'spent') { + return Theme.chipSpentBg; + } + if (state === 'warn') { + return Theme.chipWarnBg; + } + return Theme.chipNeutralBg; + } + + static budgetFg(state: string): string { + if (state === 'spent') { + return Theme.chipSpentFg; + } + if (state === 'warn') { + return Theme.chipWarnFg; + } + return Theme.chipNeutralFg; + } + /** 圆角:卡片 14、控件 8(与 WebUI 的 --radius-card / --radius-control 一致) */ static readonly radiusCard: number = 14; static readonly radiusControl: number = 8; diff --git a/client/harmony/entry/src/main/ets/model/MailGrouping.ts b/client/harmony/entry/src/main/ets/model/MailGrouping.ts new file mode 100644 index 0000000..c159cbf --- /dev/null +++ b/client/harmony/entry/src/main/ets/model/MailGrouping.ts @@ -0,0 +1,228 @@ +/* + * 收件箱的会话折叠 / 联系人页的卡片视图 / 往返预算 —— **纯逻辑,无 UI 依赖**。 + * + * 为什么单独成文件、而不是直接写在页面里: + * + * 这几条规则是**判据的对象**。写在 `build()` 里的话,判据只能断言"源码里出现了 + * 某个字符串"(看起来绿,实际什么都没验);放在这里,判据可以跑**同一份代码** + * —— `client/electron/test/harmony-logic.test.mjs` 用 node 的 + * `--experimental-strip-types` 直接执行本文件,断言的是**行为**: + * 折叠后组头取的是不是最新一封、单封是不是不成组、预算剩 1 个来回是什么档。 + * + * 这正是移交信里交代的纪律:「判据必须点用户真正会点的那一层」—— + * 页面里那一层要点设备才能验,这一层是它的**可执行内核**, + * 页面再用源码判据钉住"确实调了这里"。 + * + * 与 WebUI 的 `src/lib/mailGroups.ts`(折叠 / 单封不成组)、 + * `src/components/WorkCard.tsx` 的 `BudgetChip`(预算档位)、 + * `ContactPanel.tsx`(列表 / 卡片切换)一一对应。 + * + * ⚠️ 本文件必须保持**类型可擦除**:不用 `enum`、`namespace`、构造器参数属性, + * 否则 node 的 strip-types 跑不起来,判据就断了(用 `class` + 联合类型代替 enum)。 + */ + +/** 折叠只用到这几个字段(与 `Models.ets` 的 `MailSummary` 对齐,由它 implements) */ +export interface MailLike { + mail_id: string; + session_id: string; + session_alias: string; + from_name: string; + subject: string; + body_preview: string; + created_at: string; + status: string; + permission_mode: string; + source_account_id: string; + source_account_name: string; +} + +/** 一个会话折叠成的一组(对应 WebUI 的 `MailGroup`) */ +export class SessionGroup { + /** 分组键:账号 + session_id */ + key: string = ''; + session_id: string = ''; + /** 组头别名(取组内最新一封的会话别名,空串表示未命名) */ + alias: string = ''; + /** 组头主题(取最新一封) */ + subject: string = ''; + /** 组内最新一封 */ + latest: MailLike | undefined = undefined; + /** 组内全部邮件,时间倒序 */ + mails: MailLike[] = []; + unreadCount: number = 0; +} + +/** ISO 时间 → 毫秒;解析失败给 0 而不是 NaN(NaN 参与比较恒为 false,会让排序依赖入参顺序) */ +export function timeOf(iso: string): number { + const t: number = Date.parse(iso); + return Number.isNaN(t) ? 0 : t; +} + +/** 时间倒序;同一时刻用 mail_id 倒序兜底(与后端 `ORDER BY created_at DESC, mail_id DESC` 一致) */ +export function byNewest(a: MailLike, b: MailLike): number { + const d: number = timeOf(b.created_at) - timeOf(a.created_at); + if (d !== 0) { + return d; + } + if (b.mail_id > a.mail_id) { + return 1; + } + if (b.mail_id < a.mail_id) { + return -1; + } + return 0; +} + +/** + * 分组键。 + * + * 鸿蒙的收件箱是**多账号合并**的(一个页面里混着多个 Gateway 的信), + * 所以键要带账号前缀:同一个 `session_id` 出现在两个账号里是两件事。 + * WebUI 是单账号,只按 `session_id` 桶化(`bucketBySession`)。 + * `session_id` 缺失时用 `mail:` 兜底单独成组 —— 一条脏数据不该让整栏空白。 + */ +export function sessionKey(m: MailLike): string { + const sid: string = m.session_id.length > 0 ? m.session_id : 'mail:' + m.mail_id; + return m.source_account_id + '/' + sid; +} + +/** 按会话折叠;组头取组内**最新一封**,组间按最新一封时间倒序 */ +export function groupMailsBySession(mails: MailLike[]): SessionGroup[] { + const groups: SessionGroup[] = []; + const index: Map = new Map(); + + for (let i = 0; i < mails.length; i++) { + const m: MailLike = mails[i]; + const key: string = sessionKey(m); + let g: SessionGroup | undefined = undefined; + const at: number | undefined = index.get(key); + if (at !== undefined) { + g = groups[at]; + } + if (g === undefined) { + g = new SessionGroup(); + g.key = key; + g.session_id = m.session_id; + index.set(key, groups.length); + groups.push(g); + } + g.mails.push(m); + } + + for (let i = 0; i < groups.length; i++) { + const g: SessionGroup = groups[i]; + g.mails.sort(byNewest); + const latest: MailLike = g.mails[0]; + g.latest = latest; + g.alias = latest.session_alias; + g.subject = latest.subject; + let unread: number = 0; + for (let j = 0; j < g.mails.length; j++) { + if (g.mails[j].status === 'unread') { + unread++; + } + } + g.unreadCount = unread; + } + + groups.sort((a: SessionGroup, b: SessionGroup): number => { + const la: MailLike | undefined = a.latest; + const lb: MailLike | undefined = b.latest; + if (la === undefined || lb === undefined) { + return 0; + } + return byNewest(la, lb); + }); + return groups; +} + +/** + * 单封邮件的组不算「组」,平铺显示即可。 + * + * 给一封孤立的邮件套上可折叠的组头 = 多一次点击才能读到内容, + * 而收件箱里大多数人类来信就是孤立的一封(WebUI 侧同一结论,见 `isFlatGroup`)。 + */ +export function isFlatGroup(g: SessionGroup): boolean { + return g.mails.length === 1; +} + +/** 未读数:把服务端返回的 `total` 相加(它是 `CountUnread`,权威;不要数这一页) */ +export function sumUnreadTotals(totals: number[]): number { + let n: number = 0; + for (let i = 0; i < totals.length; i++) { + const t: number = totals[i]; + if (t > 0) { + n += t; + } + } + return n; +} + +/** + * 这一页**可能不全**时的提示。 + * + * `/me/mail/inbox` 有 `limit`(客户端取 50),而它返回的 `total` 是**未读数** + * (服务端 `CountUnread`),**不是总封数** —— 所以客户端手上根本没有一个可信的 + * "一共有多少封"。那就既不能把 50 封说成全部,也不能拿未读数冒充总数 + * (鸿蒙界面原先写的「共 N 封」就是这么来的,显示的其实是未读数)。 + * 只有"取满了这一页"时才有话可说,此时如实说"可能还有更多"。 + */ +export function partialLoadNotice(fetched: number, limit: number): string { + if (limit <= 0 || fetched < limit) { + return ''; + } + return '已加载 ' + fetched + ' 封(本页上限 ' + limit + ',可能还有更多)'; +} + +/** + * 往返预算的档位 —— 与 WebUI 的 `BudgetChip` 同一判据: + * `max <= 0` = 不限(不显示徽标,一个"0/0"对每张卡片都成立,等于噪声); + * 剩 0 个来回 = 用尽;剩 ≤1 = 将尽(快跑满的任务需要人介入)。 + * 返回值用字符串而不是 enum:本文件要保持类型可擦除(见文件头)。 + */ +export function budgetState(max: number, used: number): string { + if (max <= 0) { + return 'none'; + } + if (budgetRemaining(max, used) === 0) { + return 'spent'; + } + if (budgetRemaining(max, used) <= 1) { + return 'warn'; + } + return 'ok'; +} + +/** 剩余往返次数(不小于 0) */ +export function budgetRemaining(max: number, used: number): number { + const left: number = max - used; + return left > 0 ? left : 0; +} + +/** 预算徽标文字:`剩余/上限`;不限时是空串(页面据此不渲染) */ +export function budgetLabel(max: number, used: number): string { + if (max <= 0) { + return ''; + } + return budgetRemaining(max, used) + '/' + max; +} + +/** 联系人页的视图切换(WebUI:`setView(view === 'list' ? 'card' : 'list')`) */ +export function nextContactView(current: string): string { + return current === 'list' ? 'card' : 'list'; +} + +/** 视图标题:卡片视图叫「工作列表」,列表视图叫「联系人」(与 WebUI 同词) */ +export function contactViewTitle(view: string): string { + return view === 'card' ? '工作列表' : '联系人'; +} + +/** + * 卡片上那条最新摘要**是谁发的**:人还是 Agent。 + * + * WebUI 的判据是 `c.last_from !== c.agent_name`(人发的用头像图标、Agent 发的用机器人图标)。 + * 这个信息决定人要不要接手,所以它得是逻辑而不是"看图标"。 + */ +export function lastFromIsHuman(agentName: string, lastFrom: string): boolean { + return lastFrom !== agentName; +} diff --git a/client/harmony/entry/src/main/ets/model/Models.ets b/client/harmony/entry/src/main/ets/model/Models.ets index e29481e..f3fbe35 100644 --- a/client/harmony/entry/src/main/ets/model/Models.ets +++ b/client/harmony/entry/src/main/ets/model/Models.ets @@ -2,6 +2,7 @@ * AgentMail 鸿蒙客户端 — 领域模型 * 与 docs/API.md 字段一一对应(单一事实源) */ +import { MailLike } from './MailGrouping'; /** 当前登录用户 */ export class Me { @@ -28,10 +29,18 @@ export class Session { unread_count: number = 0; } -/** 邮件摘要(收件箱/会话列表用) */ -export class MailSummary { +/** + * 邮件摘要(收件箱/会话列表用)。 + * + * `implements MailLike`:折叠逻辑在 `MailGrouping.ts`(纯逻辑、可被判据直接执行), + * 它只认这个接口。ArkTS 不做结构类型匹配,所以这里必须显式 implements + * —— 少写一个字段编译期就会红,这是好事。 + */ +export class MailSummary implements MailLike { mail_id: string = ''; session_id: string = ''; + /** 会话别名(服务端 `omitempty`,未命名会话时是空串)—— 折叠后的组头用它 */ + session_alias: string = ''; from_name: string = ''; to_name: string = ''; subject: string = ''; diff --git a/client/harmony/entry/src/main/ets/pages/MainPage.ets b/client/harmony/entry/src/main/ets/pages/MainPage.ets index 5f1fe23..7abd043 100644 --- a/client/harmony/entry/src/main/ets/pages/MainPage.ets +++ b/client/harmony/entry/src/main/ets/pages/MainPage.ets @@ -8,15 +8,39 @@ import { MailApi, InboxResponse } from '../api/MailApi'; import { AccountManager, AccountInfo } from '../api/AccountManager'; import { SseService, SseEvent } from '../api/SseService'; import { MailSummary, Session, Contact } from '../model/Models'; +import { + MailLike, + SessionGroup, + groupMailsBySession, + isFlatGroup, + partialLoadNotice, + sumUnreadTotals, + budgetState, + budgetLabel, + nextContactView, + contactViewTitle, + lastFromIsHuman +} from '../model/MailGrouping'; import { MailDetailParams, ComposeParams } from '../model/RouteParams'; import { promptAction } from '@kit.ArkUI'; +/** 一页取多少封。取满了就要如实提示"可能还有更多"(服务端 total 是未读数,不是总封数)。 */ +const INBOX_PAGE_SIZE: number = 50; + @Component struct InboxTab { @State mails: MailSummary[] = []; + /** 按会话折叠后的列表(单封的组平铺渲染) */ + @State groups: SessionGroup[] = []; + /** 已展开的组(键 = SessionGroup.key) */ + @State expandedKeys: string[] = []; @State loading: boolean = false; - @State total: number = 0; + /** 这一页**取回来**的封数 */ + @State loaded: number = 0; + /** 未读总数:服务端 total(CountUnread),权威 */ @State unread: number = 0; + /** 本页取满时的"可能还有更多"提示,空串表示不用提示 */ + @State notice: string = ''; @State error: string = ''; @State accountName: string = ''; @State accountFilter: string = 'all'; // 'all' 或 accountId @@ -93,7 +117,8 @@ struct InboxTab { } const mergedMails: MailSummary[] = []; - let mergedTotal: number = 0; + const unreadTotals: number[] = []; + let maxFetched: number = 0; for (let i = 0; i < allAccounts.length; i++) { const acct: AccountInfo = allAccounts[i]; @@ -104,14 +129,17 @@ struct InboxTab { const accountClient: ApiClient = new ApiClient(ctx); accountClient.setBase(acct.server); accountClient.setToken(acct.token); - const response: InboxResponse = await new MailApi(accountClient).inbox('all', 50); + const response: InboxResponse = await new MailApi(accountClient).inbox('all', INBOX_PAGE_SIZE); for (let j = 0; j < response.mails.length; j++) { const mail: MailSummary = response.mails[j]; mail.source_account_id = acct.id; mail.source_account_name = acct.displayName; mergedMails.push(mail); } - mergedTotal += response.total; + unreadTotals.push(response.total); + if (response.mails.length > maxFetched) { + maxFetched = response.mails.length; + } } catch (e) { if (this.accountFilter !== 'all') { throw e as ApiError; @@ -119,25 +147,15 @@ struct InboxTab { } } - mergedMails.sort((a: MailSummary, b: MailSummary) => { - if (a.created_at > b.created_at) { - return -1; - } - if (a.created_at < b.created_at) { - return 1; - } - return 0; - }); this.mails = mergedMails; - this.total = mergedTotal; - - let unreadCount: number = 0; - for (let i = 0; i < this.mails.length; i++) { - if (this.mails[i].status === 'unread') { - unreadCount++; - } - } - this.unread = unreadCount; + // 按会话折叠:组头取组内最新一封(含别名),组间按最新一封倒序; + // 单封的组不算组,平铺(见 MailGrouping.isFlatGroup)。 + this.groups = groupMailsBySession(mergedMails); + this.loaded = mergedMails.length; + // 未读数用服务端返回的 total —— 它是 CountUnread,权威;数这一页会少报。 + this.unread = sumUnreadTotals(unreadTotals); + // 这一页取满了就如实说"可能还有更多":不能把 50 封说成全部(见 partialLoadNotice)。 + this.notice = partialLoadNotice(maxFetched, INBOX_PAGE_SIZE); } catch (e) { const ae = e as ApiError; this.error = ae.message.length > 0 ? ae.message : '加载失败'; @@ -146,6 +164,43 @@ struct InboxTab { } } + /** 展开状态放在数组里(ArkTS 的 @State 对 Map/Set 的变更不总是能观察到) */ + isExpanded(key: string): boolean { + for (let i = 0; i < this.expandedKeys.length; i++) { + if (this.expandedKeys[i] === key) { + return true; + } + } + return false; + } + + toggleExpanded(key: string): void { + const next: string[] = []; + let found: boolean = false; + for (let i = 0; i < this.expandedKeys.length; i++) { + if (this.expandedKeys[i] === key) { + found = true; + } else { + next.push(this.expandedKeys[i]); + } + } + if (!found) { + next.push(key); + } + this.expandedKeys = next; + } + + openMail(mail: MailLike): void { + const params: MailDetailParams = { + mail_id: mail.mail_id, + account_id: mail.source_account_id + }; + this.getUIContext().getRouter().pushUrl({ + url: 'pages/MailDetailPage', + params: params + }); + } + openCompose(): void { let accountId: string = this.accountFilter === 'all' ? '' : this.accountFilter; if (accountId.length === 0) { @@ -256,24 +311,45 @@ struct InboxTab { } .width('100%').layoutWeight(1).justifyContent(FlexAlign.Center) } else { + /* + * 本页取满了就当场说清楚"可能还有更多"。 + * + * 原先这里写的是「共 N 封」,而那个 N 其实是服务端返回的**未读数** + * (`CountUnread`)—— 界面上同时出现「共 7 封」和「未读 7」这种自相矛盾的 + * 两行字,且折叠之后更糟:拿 50 封折叠出的会话数会被当成"就只有这么多会话"。 + */ + if (this.notice.length > 0) { + Row() { + Text(this.notice) + .fontSize(Theme.fontTiny) + .fontColor(Theme.warnFg) + .maxLines(2) + } + .width('100%') + .padding({ left: 16, right: 16, top: 6, bottom: 6 }) + .backgroundColor(Theme.warnBg) + } + List({ space: 1 }) { - ForEach(this.mails, (mail: MailSummary) => { + ForEach(this.groups, (g: SessionGroup) => { ListItem() { - this.MailItem(mail) + if (isFlatGroup(g)) { + // 单封不成组:套一个可折叠的组头只是多一次点击(与 WebUI isFlatGroup 同结论) + this.MailRow(g.mails[0]) + } else { + Column() { + this.GroupHeader(g) + if (this.isExpanded(g.key)) { + ForEach(g.mails, (m: MailLike) => { + this.MailRow(m) + }, (m: MailLike) => m.source_account_id + ':' + m.mail_id) + } + } + .width('100%') + } } - .height(80) - .backgroundColor(mail.status === 'unread' ? Theme.accentSoft : Theme.surface) - .onClick(() => { - const params: MailDetailParams = { - mail_id: mail.mail_id, - account_id: mail.source_account_id - }; - this.getUIContext().getRouter().pushUrl({ - url: 'pages/MailDetailPage', - params: params - }); - }) - }, (mail: MailSummary) => mail.source_account_id + ':' + mail.mail_id) + .width('100%') + }, (g: SessionGroup) => g.key) } .width('100%').layoutWeight(1) .divider({ strokeWidth: 1, color: Theme.border, startMargin: 16, endMargin: 16 }) @@ -282,7 +358,7 @@ struct InboxTab { // 底部信息栏 + 悬浮写邮件按钮 Stack({ alignContent: Alignment.BottomEnd }) { Row() { - Text('共 ' + this.total + ' 封').fontSize(12).fontColor(Theme.textSubtle) + Text('已加载 ' + this.loaded + ' 封').fontSize(12).fontColor(Theme.textSubtle) Blank() Text('未读 ' + this.unread).fontSize(12).fontColor(Theme.textSubtle) } @@ -328,8 +404,74 @@ struct InboxTab { .width('100%').height(180).padding(16) } + /** + * 列表里的一封(列表项本体):底色按已读/未读,点它进详情。 + * + * 参数类型是 `MailLike`(`MailGrouping.ts` 的接口)而不是 `MailSummary`: + * 折叠后的组里装的是接口类型,ArkTS 不做结构类型匹配,写死具体类就传不进来。 + */ @Builder - MailItem(mail: MailSummary) { + MailRow(mail: MailLike) { + Row() { + this.MailItem(mail) + } + .width('100%').height(80) + .backgroundColor(mail.status === 'unread' ? Theme.accentSoft : Theme.surface) + .onClick(() => { + this.openMail(mail); + }) + } + + /** + * 会话组头:一行说清"这是哪条线索、几封、几封没读",点它展开/收起。 + * + * 组头取组内**最新一封**的别名与主题(与 WebUI `mailGroups.ts` 同口径): + * 会话主题会随任务推进被改写,最新的那个最贴切。 + */ + @Builder + GroupHeader(g: SessionGroup) { + Row() { + Text(this.isExpanded(g.key) ? '▾' : '▸') + .fontSize(12).fontColor(Theme.textMuted).width(18) + Column() { + Row() { + Text(g.alias.length > 0 ? g.alias : '(未命名会话)') + .fontSize(13).fontWeight(FontWeight.Bold).fontColor(Theme.textPrimary) + .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis }) + .layoutWeight(1) + if (g.unreadCount > 0) { + Text(g.unreadCount + '') + .fontSize(10).fontColor(Theme.surface) + .backgroundColor(Theme.danger) + .borderRadius(9).width(18).height(18) + .textAlign(TextAlign.Center) + } + } + .width('100%') + + Row() { + Text(g.subject.length > 0 ? g.subject : '(无主题)') + .fontSize(12).fontColor(Theme.textMuted) + .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis }) + .layoutWeight(1) + Blank() + Text(g.mails.length + ' 封').fontSize(11).fontColor(Theme.textSubtle) + } + .width('100%').margin({ top: 2 }) + } + .layoutWeight(1).alignItems(HorizontalAlign.Start) + } + .width('100%').height(60) + .padding({ left: 12, right: 12 }) + .alignItems(VerticalAlign.Center) + .backgroundColor(Theme.surfaceMuted) + .onClick(() => { + this.toggleExpanded(g.key); + }) + } + + @Builder + MailItem(mail: MailLike) { Row() { Column() { if (mail.status === 'unread') { @@ -495,6 +637,16 @@ struct ContactsTab { @State contacts: Contact[] = []; @State loading: boolean = false; @State error: string = ''; + /** + * 视图:'list'(跟谁在聊)/ 'card'(在聊什么、进展如何)。 + * + * 这不是装饰:WebUI 里「会话」从来不是一个入口,它是**两处已有视图** —— + * 列表视图是 `ContactRow`,卡片视图是 `WorkCard`(标题「工作列表」)。 + * 鸿蒙侧原来把会话单列成一个 tab,等于把"卡片视图"放错了位置。 + * 顺序:先在这里补上卡片视图,再把平级「会话」tab 撤掉(撤早了会丢信息: + * 轮次预算 / status / from_agent 就没地方看了)。 + */ + @State contactView: string = 'list'; private mailApi: MailApi | null = null; aboutToAppear(): void { @@ -522,12 +674,22 @@ struct ContactsTab { } } + /** 切换视图:切换规则本身在 MailGrouping.nextContactView(判据直接执行那一层) */ + switchView(): void { + this.contactView = nextContactView(this.contactView); + } + build() { Column() { Row() { - Text('联系人').fontSize(20).fontWeight(FontWeight.Bold).fontColor(Theme.textPrimary) + Text(contactViewTitle(this.contactView)).fontSize(20).fontWeight(FontWeight.Bold).fontColor(Theme.textPrimary) + Blank() + Text(this.contactView === 'list' ? '▦' : '☰') + .fontSize(18).fontColor(Theme.textMuted) + .width(40).height(40).textAlign(TextAlign.Center) + .onClick(() => { this.switchView(); }) } - .width('100%').height(56).padding({ left: 16 }) + .width('100%').height(56).padding({ left: 16, right: 8 }) .backgroundColor(Theme.surface) if (this.loading) { @@ -546,6 +708,18 @@ struct ContactsTab { Text('📭 暂无联系人').fontSize(16).fontColor(Theme.textSubtle) } .width('100%').layoutWeight(1).justifyContent(FlexAlign.Center) + } else if (this.contactView === 'card') { + // 卡片视图:竖向堆叠(320~400vp 放不下多列),每项一张卡 + List({ space: 8 }) { + ForEach(this.contacts, (c: Contact, idx: number) => { + ListItem() { + this.WorkCard(c) + } + .width('100%') + }, (_c: Contact, idx: number) => idx.toString()) + } + .width('100%').layoutWeight(1) + .padding({ left: 12, right: 12, top: 8, bottom: 8 }) } else { List({ space: 1 }) { ForEach(this.contacts, (c: Contact, idx: number) => { @@ -563,6 +737,91 @@ struct ContactsTab { .backgroundColor(Theme.pageBg) } + /** + * 工作卡片 —— 对应 WebUI 的 `WorkCard`(卡片视图)。 + * + * 列表答「跟谁在聊」,卡片答「在聊什么、进展如何」:主题是主角, + * 最新一封说了什么、谁说的,以及**往返预算还剩多少**(预算是任务的属性, + * 快跑满的任务需要人介入 —— 这就是为什么卡片视图要先于删 tab 落地)。 + */ + @Builder + WorkCard(c: Contact) { + Column() { + Row() { + Text(c.agent_name) + .fontSize(13).fontWeight(FontWeight.Bold).fontColor(Theme.textPrimary) + .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis }) + Text(c.path) + .fontSize(11).fontColor(Theme.textSubtle) + .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis }) + .margin({ left: 6 }).layoutWeight(1) + if (c.unread_count > 0) { + Text(c.unread_count + '') + .fontSize(10).fontColor(Theme.surface) + .backgroundColor(Theme.accent) + .borderRadius(9).width(18).height(18) + .textAlign(TextAlign.Center) + } + } + .width('100%') + + Row() { + Text('›').fontSize(12).fontColor(Theme.accent) + Text(c.session_alias.length > 0 ? c.session_alias : '(未命名会话)') + .fontSize(11).fontColor(Theme.accent) + .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis }) + .margin({ left: 4 }).layoutWeight(1) + } + .width('100%').margin({ top: 2 }) + + // 主题是这张卡片的主角:它回答「这条线索在干什么」 + Text(c.subject.length > 0 ? c.subject : '(无主题)') + .fontSize(13).fontColor(Theme.textPrimary) + .maxLines(2).textOverflow({ overflow: TextOverflow.Ellipsis }) + .margin({ top: 6 }) + + if (c.last_preview.length > 0) { + Row() { + Text(lastFromIsHuman(c.agent_name, c.last_from) ? '👤' : '🤖') + .fontSize(11).margin({ right: 4 }) + Text(c.last_preview) + .fontSize(11).fontColor(Theme.textMuted) + .maxLines(2).textOverflow({ overflow: TextOverflow.Ellipsis }) + .layoutWeight(1) + } + .width('100%').margin({ top: 6 }).alignItems(VerticalAlign.Top) + } + + Row() { + Text(c.mail_count + ' 封 · ' + c.last_activity) + .fontSize(11).fontColor(Theme.textSubtle) + Blank() + if (c.permission_mode.length > 0) { + Text(c.permission_mode) + .fontSize(10).fontColor(Theme.permFg(c.permission_mode)) + .backgroundColor(Theme.permBg(c.permission_mode)).borderRadius(4) + .padding({ left: 5, right: 5, top: 1, bottom: 1 }) + .margin({ right: 6 }) + } + // 往返预算:上限为 0 = 不限,不显示(与 WebUI BudgetChip 同判据) + if (budgetLabel(c.max_rounds, c.used_rounds).length > 0) { + Text(budgetLabel(c.max_rounds, c.used_rounds)) + .fontSize(10) + .fontColor(Theme.budgetFg(budgetState(c.max_rounds, c.used_rounds))) + .backgroundColor(Theme.budgetBg(budgetState(c.max_rounds, c.used_rounds))) + .borderRadius(9) + .padding({ left: 6, right: 6, top: 1, bottom: 1 }) + } + } + .width('100%').margin({ top: 8 }) + } + .width('100%') + .padding(12) + .borderRadius(Theme.radiusControl) + .backgroundColor(Theme.surface) + .border({ width: 1, color: Theme.border }) + } + @Builder ContactItem(c: Contact, idx: number) { Row() { diff --git a/docs/HARMONY-ALIGN-PLAN.md b/docs/HARMONY-ALIGN-PLAN.md index 943c106..45fbde5 100644 --- a/docs/HARMONY-ALIGN-PLAN.md +++ b/docs/HARMONY-ALIGN-PLAN.md @@ -248,3 +248,69 @@ AppImage 与 `linux-unpacked` 正常 —— 交付前要确认这个环境问题 鸿蒙侧**视觉与点击**仍未验(模拟器在本机沙箱下起不来,见 5.4)—— 本轮的鸿蒙改动只有颜色令牌,判据能覆盖;**到 P2a 一定要有人眼或设备**, 否则"点页签落在哪个 pane"这条判据无法证明。 + +--- + +## 七、P2a 落地(dsh,2026-09-14 稍晚) + +按 pi 给的顺序:**先补视图与折叠,再删 tab**。本轮做完前一半,tab 保留。 + +### 7.1 做法:把"用户真正会点的那一层"的内核抽出来,让判据**执行**它 + +没有设备,"点一下"在鸿蒙上暂时无法自动验。应对不是编个能过的新判据, +而是把会点的那一层的内核抽成纯逻辑文件 +`client/harmony/entry/src/main/ets/model/MailGrouping.ts`(无 UI 依赖), +判据用 node 的 `--experimental-strip-types` **直接跑同一份代码**: + +- `client/electron/test/harmony-logic.test.mjs`(14 条)—— 断言的是**行为**: + 折叠后组头是不是最新一封、单封是不是不成组、同一时刻是否用 `mail_id` 倒序兜底、 + 时间解析失败会不会让顺序依赖入参、多账号同名会话会不会被错并、预算剩 1 个来回是哪档。 +- 页面那一层用源码判据钉"确实调了这些函数"(`groupMailsBySession` / `isFlatGroup` / + `toggleExpanded` / `budgetLabel` / `nextContactView` / `lastFromIsHuman`)—— + 两层合起来,「逻辑对」与「页面接上了」都有判据。 +- **变异验证(4 处,全部判红)**:去掉组内排序 → 2 条红;预算阈值 `<=1` 改 `<1` → 1 条红; + 分组键去掉账号前缀 → 1 条红;页面不再区分单封组 → 1 条红。 + +这条判据已接进 `npm test`(`node --experimental-strip-types --no-warnings --test`)。 + +### 7.2 收件箱:按会话折叠 + +- 组头取组内**最新一封**的别名与主题(与 WebUI `mailGroups.ts` 同口径), + 带未读数徽标与「N 封」;点组头展开/收起。 +- **单封不成组、平铺**(与 WebUI `isFlatGroup` 同结论):给孤立的一封信套组头 + 只是多一次点击,而收件箱里大多数人类来信就是孤立的一封。 +- 多账号是鸿蒙特有:分组键带账号前缀(同一 `session_id` 出现在两个账号是两件事); + `session_id` 缺失时按 `mail:` 各自成组。 + +### 7.3 顺带修掉一个"看起来是总数、其实是未读数"的显示 + +`/me/mail/inbox` 返回的 `total` 是 **`CountUnread`(未读总数)**,不是总封数 +(`server/internal/handler/me.go` 的 `MeGetInbox`)。鸿蒙底部原来写「共 N 封」, +于是同一屏上会出现「共 7 封」和「未读 7」这种自相矛盾的两行字。 + +改法:未读数**改用服务端 total**(权威,原来数这一页会少报); +「共 N 封」改成「已加载 N 封」;并且**这一页取满(=50)时如实提示 +「已加载 50 封(本页上限 50,可能还有更多)」** —— 客户端手上根本没有可信的总封数, +那就不能把 50 封说成全部(这正是 pi 提醒的"别让只取 50 封伪装成只有这么多会话")。 +WebUI 侧完全不读这个字段(`mailStore` 里没有 `total`),所以这条只影响鸿蒙。 + +### 7.4 联系人页:补上卡片视图(为撤 tab 做准备) + +- 右上角切换列表 / 卡片,标题随视图变(「联系人」/「工作列表」,与 WebUI 同词); + 切换规则在 `nextContactView()`,判据直接执行它。 +- 卡片(对应 WebUI 的 `WorkCard`):Agent 名 + 工作目录 + 未读徽标、 + 会话别名(空则「(未命名会话)」)、**主题当主角**、最新摘要 + 人/Agent 标记 + (`lastFromIsHuman`)、「N 封 · 时间」、权限档位徽标、**往返预算条** + (档位与 WebUI `BudgetChip` 同一判据:剩 0 红 / 剩 ≤1 橙 / 其余中性;上限 0=不限则不显示)。 +- 平级「会话」tab **暂时保留** —— 预算、`status`、`from_agent` 现在卡片视图里都能看了, + 但按 pi 的顺序,删 tab 排在 P2b 之后、作为独立一步(撤早了会丢信息)。 + +### 7.5 本轮验证与如实标注 + +- `hvigorw assembleHap` **BUILD SUCCESSFUL**(`.ts` 纯逻辑模块能被 `.ets` 引用, + 实测可行 —— 这是"判据能执行同一份代码"的前提)。 +- `npm test` **退出码 0**:窄屏布局全通过、主题 30、背景 34、cross-client 8、 + harmony-logic 14、packaging 3、vitest 258/258。 +- **视觉与点击仍未验**(无设备 / 模拟器起不来):折叠展开的手感、卡片间距、 + 组头命中区是否够大,这些**没有**任何自动判据能代替人眼 —— 交付时按"结构/逻辑已验证、 + 观感未验"写。下一轮:P2b 通信页签(收件箱/发件箱/授权 + 徽标)。