feat(harmony): P2a —— 收件箱按会话折叠 + 联系人页卡片视图(判据直接跑同一份逻辑)

按 pi 的结论落地 P2a 的前一半:**先补视图与折叠,再删平级「会话」tab**(tab 本轮保留)。

## 判据怎么"点用户真正会点的那一层"

鸿蒙侧没有设备(`hdc list targets` 为空、模拟器在本机沙箱下起不来),"点一下"暂时
无法自动验。应对不是编个能过的新判据,而是把会点的那一层的内核抽成纯逻辑:
`entry/src/main/ets/model/MailGrouping.ts`(无 UI 依赖),判据用 node 的
`--experimental-strip-types` **执行同一份代码**(`test/harmony-logic.test.mjs`,14 条),
断言的是行为而不是"源码里出现过某个字符串":

- 折叠后组头是不是**最新一封**、组内是否时间倒序、组间排序、同一时刻用 `mail_id` 倒序兜底;
- 时间解析失败**不能让顺序依赖入参**(WebUI 侧踩过的 NaN 比较坑);
- 多账号合并下同名 `session_id` 不能被错并成一组;`session_id` 缺失时各自成组;
- 预算档位与 WebUI `BudgetChip` 完全一致(剩 0 用尽 / ≤1 将尽 / 上限 0 不显示);
- 视图切换与卡片上"最新一封是人还是 Agent"的判据。

页面那一层另用源码判据钉"确实调了这些函数",两层合起来覆盖「逻辑对」+「页面接上了」。
**变异验证 4 处全部判红**:去掉组内排序(2 条红)、预算阈值 `<=1` 改 `<1`、
分组键去掉账号前缀、页面不再区分单封组。

## 收件箱折叠

- 组头取组内最新一封的别名与主题,带未读数徽标与「N 封」,点它展开/收起;
- **单封不成组、平铺**(与 WebUI `isFlatGroup` 同结论:给孤立一封信套组头只是多一次点击);
- 多账号是鸿蒙特有:分组键带账号前缀;`session_id` 缺失按 `mail:<id>` 各自成组。

## 顺带修掉一个"看起来是总数、其实是未读数"的显示

`/me/mail/inbox` 的 `total` 是 **`CountUnread`(未读总数)**,不是总封数
(`server/internal/handler/me.go`)。鸿蒙底部原写「共 N 封」⇒ 同一屏出现
「共 7 封」和「未读 7」两行自相矛盾的字。改成:未读数用服务端 `total`(权威,
原来数这一页会少报);「共 N 封」→「已加载 N 封」;**这一页取满时如实提示
「已加载 50 封(本页上限 50,可能还有更多)」** —— 客户端没有可信总封数,
就不能把 50 封说成全部(pi 提醒的"别让只取 50 封伪装成只有这么多会话")。
WebUI 侧不读这个字段,故只影响鸿蒙。

## 联系人页补卡片视图(撤 tab 的前置)

- 右上角切列表/卡片,标题「联系人」/「工作列表」(与 WebUI 同词),切换规则在
  `nextContactView()`;
- 卡片对应 WebUI 的 `WorkCard`:Agent 名 + 工作目录 + 未读徽标、会话别名、
  **主题当主角**、最新摘要 + 人/Agent 标记、`N 封 · 时间`、权限档位徽标、
  **往返预算条**(同一档位判据)。
- 平级「会话」tab 暂留:撤 tab 按 pi 的顺序排在后面单独一步(撤早了预算/status/from_agent 没处看)。

## 验证

- `hvigorw assembleHap` **BUILD SUCCESSFUL**(`.ts` 纯逻辑模块被 `.ets` 引用,实测可行)。
- `npm test` **退出码 0**:窄屏布局全通过、主题 30、背景 34、cross-client 8、
  harmony-logic 14、packaging 3、vitest 258/258;新判据已接进 `npm test`。
- **视觉与点击仍未验**(无设备):展开手感、卡片间距、组头命中区没有任何自动判据
  能代替人眼 —— 交付按"结构/逻辑已验证、观感未验"写,未写成已完成。
This commit is contained in:
2026-09-14 13:36:05 +08:00
parent e07e3bf1a1
commit 3729afd96f
7 changed files with 845 additions and 44 deletions

View File

@ -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:<id>` 兜底单独成组 —— 一条脏数据不该让整栏空白。
*/
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<string, number> = new Map<string, number>();
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;
}

View File

@ -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 = '';