继续「全面对齐 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` 成功。
**未验**:附件标记(有意不做);抄送行在**深色**下的对比度未单独验。
425 lines
16 KiB
TypeScript
425 lines
16 KiB
TypeScript
/*
|
||
* 收件箱的会话折叠 / 联系人页的卡片视图 / 往返预算 —— **纯逻辑,无 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;
|
||
}
|