Files
MailUI4Agents/client/harmony/entry/src/main/ets/model/MailGrouping.ts
JianFeeeee f655453424 跨端: 审计补强 —— 邮件行的「抄送 N」+ 断点差异登记 + 两处"看起来有其实没有"的澄清
继续「全面对齐 WebUI 和鸿蒙」。这一轮做的是**逐页对照审计**(子代理通道被
session daemon 的端口占用堵死,改为自己逐处读源码对照)。

## 一、补上邮件行的「抄送 N」(真缺失)

WebUI `MailList.tsx:350` 行上有「抄送 N」,鸿蒙**完全没有**。而数据一直在:
`cc_list` 服务端确实返回(实测回包字段列表里有),只是鸿蒙的 `MailLike`
接口漏了这个字段 ⇒ 一封抄送给多个人的邮件在列表里看不出任何区别。

修法:接口加 `cc_count`,`MailSummary` 加 `cc_list` + `cc_count`,
收件箱/发件箱两处填充派生值,行上按 WebUI 的位置渲染。
**设备实测**(造了一封带 2 个抄送的真邮件):行上出现「抄送 2」,
位置与 WebUI 一致(主题行下方、灰字)。

★ 接口用**数字**而不是 getter:`MailSummary implements MailLike`,
而 ArkTS 的 interface 里不能声明 getter(编译报 "incorrectly implements interface")。

## 二、有意**不抄**附件标记(发现 WebUI 那段是死代码)

WebUI 行上还有一个 📎 + 数量的标记(`MailList.tsx:351-355`)。但核对服务端
**实测回包**:`GET /me/mail/inbox` 既没有 `attachments` 也没有 `has_attachments`
⇒ `mail.attachments?.length ?? 0` **恒为 0**,那个标记在 WebUI 上**从不出现**。

所以鸿蒙这一轮**有意不抄它** —— 照抄一个不工作的东西,只会多一处
"看起来有、永远不亮"的代码。要做这个功能得先让服务端在列表回包带上附件计数
(一次 JOIN 的事),那是独立的一件事,已登记进 `docs/DEBTS.json`
(`mail-list-attachment-count`)。

★ 这一条与鸿蒙 `MailSummary.has_attachments` 那个字段一起处理掉了:
它还留在那里会误导人(服务端永不返回它 ⇒ 恒 false)。

## 三、两端宽屏断点不同 —— 登记 + 判据(此前**无任何记录**)

审计点名要核实的这条确认成立:

    WebUI: `NARROW_QUERY = '(max-width: 1023px)'`
           含义 = 「三栏(60 导航 + 320 列表 + ≥520 详情 ≈ 900px,再加余量)放不下就退化单栏」
    鸿蒙:  `isWide = width >= 768`
           含义 = 「要不要显示**侧栏**」(鸿蒙内容区是一个窗格,没有并排三栏)

**含义不同,所以数值不同本身不算错** —— 这与手势阈值同一条口径
(语义各自成立时,数值不必强求一致)。但**用户可见的后果**是:768–1023 宽
(常见竖屏平板、窄窗口)下 WebUI 是单栏+底栏、鸿蒙是侧栏+内容,
同一宽度在两端长得不一样。

处理:① 登记进 `docs/DEBTS.json`(`wide-breakpoint-divergence`,
带三个待定选项);② 加判据 —— 它**不**要求两端取值相同,而是要求
「取值可读 + 含义写清 + 差异被登记」三件事同时成立。

★ 写这条判据时又踩了同一坑:用 `code()` 读注释 ⇒ 永远红。
`criteria-hygiene` 判据的头顶就写着"判代码用 code、判理由用 prose"。

## 四、判据

`run-all.mjs` → `checks=505 pass=505 fail=0 skip=0 red=0 broken=0 unreported=0`
(cross-client-theme 15→16)。`hvigorw assembleHap` 成功。

**未验**:附件标记(有意不做);抄送行在**深色**下的对比度未单独验。
2026-09-19 15:13:51 +08:00

425 lines
16 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;
/**
* 抄送清单 —— WebUI 行上显示「抄送 N」(`MailList.tsx:350`)。
*
* 服务端确实返回它(实测 inbox 回包字段里有 `cc_list`),此前鸿蒙的
* `MailLike` 接口漏了这个字段 ⇒ 即使模型里有也没法在行上读。
*
* 用**数字**而不是数组:`MailLike` 是 interface(ArkTS 接口里不能有 getter),
* 而列表行只需要个数。派生放到 `MailSummary` 的填充处(`cc_count = cc_list.length`)。
*/
cc_count: number;
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;
}
/**
* ISO 时间戳 → `MM/DD HH:MM`(**设备本地时区**)。
*
* ★ 加它的原因:联系人卡片与列表原来直接把 `last_activity` 原样印出来,
* 于是屏上是 `2026-09-18T02:50:35.49065Z` —— 一个秒级微秒的 UTC 串,
* 既读不出"多久以前",也和 WebUI 的 `09/18 10:50` 不是一回事
* (WebUI 用 `toLocaleString('zh-CN', {month,day,hour,minute})`)。
*
* ★ 必须走 `Date` 解析再取**本地**字段,不许 `.slice()` 切字符串:
* 那是拿 UTC 的月/日当时刻用,UTC+8 的 09-15 00:30 会被显示成 09-14 16:30。
* 这与 `Calendar.localIsoOf` 那条禁令同源(那里错的是格子,这里错的是"什么时候")。
*
* 解析失败返回空串 —— 调用侧对空串当"没有时间"处理,不显示半个日期。
* 月/日补零(`09/18` 而不是 `9/18`),与 WebUI 的 `2-digit` 一致。
*/
export function shortTimeOf(iso: string): string {
const ms: number = Date.parse(iso);
if (Number.isNaN(ms)) {
return '';
}
const d: Date = new Date(ms);
return pad2(d.getMonth() + 1) + '/' + pad2(d.getDate())
+ ' ' + pad2(d.getHours()) + ':' + pad2(d.getMinutes());
}
/** 两位补零(`shortTimeOf` 与月/日/时/分显示共用) */
function pad2(n: number): string {
return n < 10 ? '0' + n : '' + n;
}
/** 时间倒序;同一时刻用 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;
}