用户:「收件发件授权改为一个导航项,通过内部导航区分,然后新建作为他们内部的一个悬浮的圆形加号」。 所以这一期不是"再加两个页面",而是对齐信息架构。 ## 鸿蒙侧 - 底部第一项 **收件箱 → 通信**(`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)。 **未验**:页签/徽标/悬浮加号在真机上的观感与点击 —— 需设备或模拟器(模拟器要人在命令行启动)。
385 lines
14 KiB
TypeScript
385 lines
14 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;
|
||
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;
|
||
}
|