Files
MailUI4Agents/client/harmony/entry/src/main/ets/model/MailGrouping.ts
JianFeeeee 73f886aea5 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)。
**未验**:页签/徽标/悬浮加号在真机上的观感与点击 —— 需设备或模拟器(模拟器要人在命令行启动)。
2026-09-14 14:13:45 +08:00

385 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
* 收件箱的会话折叠 / 联系人页的卡片视图 / 往返预算 —— **纯逻辑,无 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;
/** 收件人显示名 —— 发件箱那一栏行上显示的是它(收件箱显示 from_name) */
to_name: string;
subject: string;
body_preview: string;
created_at: string;
status: string;
/**
* 邮件类型:`permission_request` 是**待办**(等人点头),其余是要读的内容。
* 与 WebUI 的 `Mail.mail_type` 同名同义 —— 收件箱与授权两栏就按它分家。
*/
mail_type: string;
/** 决策结果(空串 = 还没人处理过)。后端用 COALESCE 归一成空串,所以空串与 null 同义。 */
permission_result: 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;
}
/*
* ─────────────── 权限档位与"强制力"(对应 WebUI 的 PermissionChip) ───────────────
*
* 为什么强制力也得上界面(WebUI `PermissionChip.tsx` 的注释原话):
* 「只显示档位会让人以为 plan 档管住了 homeagent,而 homeagent 没有工具拦截点、
* 档位只是提示词建议(advisory)。差异可见才符合 I-5(失败必须当场可见)」
*
* 鸿蒙侧在触屏上更得说清:WebUI 把说明放在 `title`(悬停提示),手指没有悬停 ——
* 所以这里除了照搬 WebUI 的标记形状(实心 / 靶心 / 空心),说明文字走"点一下弹出来",
* 并且**文案与 WebUI 逐字一致**:判据从 WebUI 源码里抽出字符串直接比对,任一边改口径就红。
* (port 成两份文案是没办法的事:鸿蒙跑不了 TSX;可执行的比对是它的替代品。)
*/
/** 档位 → 中文短标签(与 WebUI `PermissionChip.tsx` 的 `MODE_LABEL` 逐字一致) */
export function permissionLabel(mode: string): string {
if (mode === 'plan') {
return '只读';
}
if (mode === 'workspace') {
return '目录内';
}
if (mode === 'full') {
return '全权';
}
return ''; // 空档位(人→人的信、旧会话)不显示徽标
}
/**
* 归一化强制力:认不出的值按 advisory。
*
* 保守方向与 WebUI 的 `normalizeEnforcement`、后端 `Normalize` 同语义 ——
* 认不出就当"平台不强制",绝不当成"平台拦得住"。
*/
export function enforcementKey(e: string): string {
if (e === 'native' || e === 'partial' || e === 'advisory') {
return e;
}
return 'advisory';
}
/** 强制力 → 标记形状:native 实心 / partial 靶心 / advisory 空心(与 WebUI 的三种点同构) */
export function enforcementGlyph(e: string): string {
const k: string = enforcementKey(e);
if (k === 'native') {
return '●';
}
if (k === 'partial') {
return '◉';
}
return '○';
}
/** 强制力 → 中文短标签(与 WebUI 的 `ENFORCEMENT_LABEL` 逐字一致) */
export function enforcementLabel(e: string): string {
const k: string = enforcementKey(e);
if (k === 'native') {
return '平台强制';
}
if (k === 'partial') {
return '平台强制(覆盖不完整)';
}
return '仅提示';
}
/**
* 档位 + 强制力 → 一句人话(点徽标弹出来)。
*
* **文案与 WebUI `permissionModeHint` 逐字一致**:两个客户端对同一个任务给出的
* "平台实际做到了什么"必须是同一句话,否则人在两边看到两种保证。
*/
export function permissionHint(mode: string, enforcement: string): string {
const e: string = enforcementKey(enforcement);
if (mode === 'plan') {
if (e === 'native') {
return 'plan 档:只读。写/改/执行会被平台强制拦下,本档只用来查与想。';
}
if (e === 'partial') {
return 'plan 档:只读。平台会拦截写/改/执行,但覆盖不完整(沙箱能力受限,有已知缺口)——不要把「会被拦下」当保证,请把结论写在回信里。';
}
return 'plan 档:只读(advisory,平台不强制)。请把结论写在回信里。';
}
if (mode === 'full') {
return 'full 档:全权。工具调用不需额外授权。';
}
if (e === 'native') {
return 'workspace 档:目录内可动,越界需经授权。';
}
if (e === 'partial') {
return 'workspace 档:目录内可动,越界会请求授权 —— 但沙箱覆盖不完整(有已知缺口),请主动把改动限制在工作目录内。';
}
return 'workspace 档(advisory,平台不强制)。请把改动限制在工作目录内。';
}
/** 徽标文字:标签 + 强制力标记(空档位返回空串,页面据此不渲染) */
export function permissionChipText(mode: string, enforcement: string): string {
const label: string = permissionLabel(mode);
if (label.length === 0) {
return '';
}
return label + ' ' + enforcementGlyph(enforcement);
}
/*
* ───────── 权限请求从普通邮件里分出来(对应 WebUI `mailGroups.ts` 的同名三个函数) ─────────
*
* 权限请求不是「一封信」而是「一件待办」:它的生命周期是「等人点头 → 决策完就作废」。
* 混在收件箱里两者互相伤害:一次 Agent 任务能连着产生十几封权限请求,
* 把真正需要阅读的来信挤到看不见的地方(WebUI 侧实测过:17 封权限邮件挤掉另外两个会话)。
* 所以收件箱只放要读的,授权栏只放要批的。
*/
/** 权限邮件且尚无决策结果。空串与 null 都算未决策。 */
export function isPendingPermission(m: MailLike): boolean {
return m.mail_type === 'permission_request' && !m.permission_result;
}
/** 待决策的权限请求数 —— 授权栏徽标的数字,也是「有人被卡住、要人动手」的唯一信号。 */
export function countPendingPermissions(mails: MailLike[]): number {
let n: number = 0;
for (let i = 0; i < mails.length; i++) {
if (isPendingPermission(mails[i])) {
n += 1;
}
}
return n;
}
/** 分家结果(用 class 而不是匿名对象字面量:ArkTS 要求对象字面量处处有类型) */
export class MailSplit {
normal: MailLike[] = [];
permissions: MailLike[] = [];
}
export function splitByPermission(mails: MailLike[]): MailSplit {
const out: MailSplit = new MailSplit();
for (let i = 0; i < mails.length; i++) {
const m: MailLike = mails[i];
if (m.mail_type === 'permission_request') {
out.permissions.push(m);
} else {
out.normal.push(m);
}
}
return out;
}