feat(harmony): 邮件详情与「我的」页 1:1 对齐 WebUI,并修两个线上 bug

用户:「为什么邮件页面没有对齐 webui?」「我的页面也没有对齐」,
选定「完全 1:1」。两个页面都不是「没做」,而是**做了一半** ——
`AuthApi` 的 logout/listKeys/createKey/revokeKey 全都写好但没人调,
`Models.ets` 的字段声明漏了服务端一直在返回的那些。

── 邮件详情页(对齐 WebUI `MailView.Header` / `CollapsibleHeader`)──
· 头部改**可折叠**(默认收起):收起只留标题 + 必须常驻的状态点
  (未读 / 权限请求)+ 箭头。WebUI 的理由:顶部信息常驻会把可读区压成
  一条缝(实测 1280×800 下头部 17% + 回复框 31%,正文只剩 48%)。
· 发件/收件改**三段式完整地址**:Agent → `pi@/home/program/agentmail.别名`,
  人 → 只有名字。新增 `model/ReplyTarget.ts`(逐字移植 WebUI `replyTarget.ts`,
  并对齐后端 `models.FormatAddress`)。
· 时间:`localDateTime()` → `2026/09/15 11:37:07`(原为裸 ISO `2026-09-15T03:37:07.14758Z`)。
· 档位:`permissionLabel()` → `只读/目录内/全权`(原为英文 `plan/workspace/full`;
  这个函数早就在 `MailGrouping.ts` 里、列表页也在用,只有详情页没用)。
· 新增抄送行;回复入口改**右下悬浮球**(WebUI `reply-fab`),
  不再是底部 56vp 通栏按钮。

── 「我的」页(对齐 WebUI `AccountPage`,补 5 个缺失 section)──
标题由「账号管理」改为「我的」(多账号只是其中一段,不是整页的目的)。
· 基本资料:用户名/显示名/角色/状态/创建时间/最后登录。
· 权限范围:可调用 Agent / 可访问目录(非管理员;空数组 = 不限)。
· 修改密码:三输入框 + 不一致就地提示(新增 `AuthApi.changePassword`,
  该端点服务端一直有、客户端从未包)。
· 客户端连接密钥:列表 / 新建 / 吊销 + 一次性全文提示
  (`AuthApi` 的三个方法终于被调用)。
· 退出登录:先注销推送 token → 清凭证 → **清全部账号** → 回登录页。
· 整页改为**一个 Scroll**:原来账号列表 `layoutWeight(1)` 占满剩余高度,
  排在它后面的 section 被挤出可视区且滚不到(WebUI 注释里正是这个坑),
  而「退出登录」在最下面 ⇒ 等于退不出去。

── 顺带修掉的两个线上 bug(都是判据发现的)──
① `MeApi.get()` 调 `GET /me`,但服务端**没有**这个路由(只有 `/auth/me`;
   `/me/*` 下是 mail/sessions/keys/appearance 子资源)。
   后果:这把调用恒 404 → `loadRole()` 恒走 catch → `isAdmin` 恒 false
   → 「管理」入口对**包括管理员在内**的所有人永远不显示。
   实证:`GET /api/v1/me` → 404;`GET /api/v1/auth/me` → 200
   `{"user":{"role":"admin",...}}`。修复后截图里「管理」入口已出现。
② 详情页回复写 `req.to = this.fromName + '@'`:那个游离的 `@` 让 `name@`
  被后端 `ParseAddress` 解析成「有 path、无 session」⇒ 落到该 Agent 的
  **默认会话**,而不是用户正在看的那条线索。改用 `replyTargetAddress()`。

── 判据(新增 7 + 8 条,全部含变异自检)──
· `harmony-reply-target.test.mjs`(新):直接执行 `ReplyTarget.ts` 断言行为
  —— 空 path 必须留 `@`(否则整串被当成名字、投递 404)、人只有名字、
  会话位必须带上、`from_workspace` 不得用来拼地址(会得到 `dsh@dsh`);
  并读 Go 源码比对三分支结构。
· `harmony-admin.test.mjs`(+5):`/auth/me` 路径(含服务端注册与响应形状)、
  八个 section 齐全、退出清全部账号、整页一个滚动容器、字段来自服务端。
  变异自检:把 `/auth/me` 改回 `/me` → 判据变红(已验证)。

测试:212 passed / 0 failed;`devecocli build` 通过;
模拟器截图逐项核对(详情收起/展开两态、我的页全部 section)。
未验:真机观感;横屏 Auto Split 双栏。
This commit is contained in:
2026-09-17 17:43:03 +08:00
parent 65c3ef8094
commit c523c21e22
9 changed files with 1591 additions and 268 deletions

View File

@ -10,6 +10,20 @@ export class Me {
username: string = '';
display_name: string = '';
role: string = '';
/*
* 下面这几个字段服务端**一直**在返回(`server/internal/handler/auth.go` 的
* `userResponse`:`status` / `created_at` / `allowed_agents` / `allowed_paths` /
* `last_login`),但客户端原来只声明了前 4 个 —— 反序列化时多出来的键被丢掉,
* 于是「我的」页拿不到「权限范围 / 状态 / 创建时间 / 最后登录」,
* 与 WebUI 的 `AccountPage`「基本资料 + 权限范围」两块直接对不上。
*/
status: string = '';
created_at: string = '';
last_login: string = '';
/** 可调用的 Agent 清单;**空数组 = 不限**(与 WebUI 同义,不是「一个都不能用」) */
allowed_agents: string[] = [];
/** 可访问的目录清单;**空数组 = 不限** */
allowed_paths: string[] = [];
}
/** 会话 */
@ -88,6 +102,32 @@ export class MailDetail {
attachments: AttachmentInfo[] = [];
permission_mode: string = '';
permission_enforcement: string = '';
/*
* 下面三个字段服务端返回、WebUI 的 `Header` 都用,客户端原来没收:
*
* · `session_workspace` —— 拼 `name@path.session` 里的 **path**。
* 不能拿 `from_workspace` 替:它对 Agent 存的是 Agent 名而不是路径
* (历史遗留,见 `server/internal/models/models.go` 的注释),
* 拿它拼会得到 `dsh@dsh`。
* · `cc_list` —— 抄送行;缺了它界面上看不到这封信还发给了谁。
* · `mail_type` —— `permission_request` 是待办,决定头部那个橙色状态点。
*/
session_workspace: string = '';
cc_list: Address[] = [];
mail_type: string = '';
}
/**
* 三维地址的**解析结果**(与后端 `models.Address` 同名同义)。
*
* `raw` 是用户输入的原文(可能含 `.new` 这类原始意图)—— 抄送行走它,
* 不要用拼回来的字符串替,那会把 `.new` 换成真实别名而丢掉意图。
*/
export class Address {
name: string = '';
path: string = '';
session: string = '';
raw: string = '';
}
/** 对话树节点 */
@ -171,6 +211,15 @@ export class UserKey {
token_hint: string = '';
status: string = '';
created_at: string = '';
/*
* 下面两个字段服务端返回、WebUI 用它算「可用 / 已使用 / 已过期」
* (`KeyPanel.tsx` 的 `keyState`),客户端原来没收 ⇒ 列表只能显示类型,
* 显示不出这把钥匙到底还能不能用。
*/
expires_at: string = '';
used_at: string = '';
/** 创建响应里**仅此一次**返回的密钥全文(列表接口只给 `token_hint`) */
key_token: string = '';
}
/** 地址补全候选 */

View File

@ -0,0 +1,136 @@
/*
* 三维寻址 `name@path.session` 的拼装 —— **纯逻辑,无 UI 依赖**。
*
* 为什么单独成文件、而不是写在页面里:这几条规则是**判据的对象**。
* `client/electron/test/harmony-reply-target.test.mjs` 用 node 的
* `--strip-types` 直接执行本文件,断言的是**行为**(空 path 有没有留下 `@`、
* 给人拼几段、工作目录从哪取),而不是"源码里出现了某个字符串"。
*
* 与 WebUI 的 `client/electron/src/lib/replyTarget.ts`(`formatAddress` /
* `participantAddress` / `mailCounterpart`)逐字节对齐,
* 并**同时**对齐后端 `server/internal/models/address.go` 的 `FormatAddress` /
* `ParseAddress` —— 三处必须一致,否则拼出来的地址解析不出会话位。
*
* ⚠️ 本文件必须保持**类型可擦除**:不用 `enum`、`namespace`、构造器参数属性,
* 否则 node 的 strip-types 跑不起来,判据就断了。
*/
/** 一个可投递的对端(对应 WebUI 的 `Counterpart`) */
export interface Counterpart {
name: string;
/** 工作目录,可能为空(人类没有工作目录) */
path: string;
/** 这一方是人类用户还是 Agent —— 决定地址拼几段 */
isHuman: boolean;
}
/**
* 拼三维地址 `name@path.session`。**逐字节对齐后端 `models.FormatAddress`。**
*
* 三个分支缺一不可:
*
* session 为空 + path 为空 → `jianf` (裸名字 = 默认会话)
* session 为空 + 有 path → `pi@/home`
* session 非空 → `pi@/home.任务` / `jianf@.任务`
*
* **最后一个分支在 path 为空时仍要保留 `@`。**
* 地址按**最后一个 `.`** 切分:`jianf@.任务` 能正确还原成
* name=`jianf` / path=`` / session=`任务`,而漏掉 `@` 的 `jianf.任务`
* 会被整串当成**名字**(后端 `ParseAddress` 返回 name="jianf.任务")——
* 那是个不存在的 Agent,投递必然 404。
*
* 人类没有工作目录,所以 path 为空是界面上的常态而非边界情形:
* 给人类回信时若丢掉 `@`,整条地址就废了。
*/
export function formatAddress(name: string, path: string, session: string): string {
const n: string = name.trim();
if (n.length === 0) {
return '';
}
const p: string = path.trim();
const s: string = session.trim();
if (s.length === 0) {
return p.length === 0 ? n : n + '@' + p;
}
return n + '@' + p + '.' + s;
}
/**
* 一个参与方在**这封邮件所属会话**里的完整地址。
*
* # 人与 Agent 的地址维度不同
*
* **Agent 要三段**:`name@path.session` 才唯一确定「哪个 Agent、在哪个目录、
* 哪条线索」。同名 Agent 在不同目录是不同的活,同一目录下不同会话是不同的任务
* —— 少任何一段都不是个可投递的地址。
*
* **人只要名字**:人没有工作目录,也不需要指定会话(发给人就是进他的收件箱)。
* 给人拼 `jianf@.某会话` 或 `jianf.某会话` 都是把 Agent 的维度硬套在人身上。
*
* 判据是**显式布尔**(`from_human` / `to_human`)而不是「workspace 是否为空」
* 的启发式:`mails.from_workspace` 对 Agent 存的是 Agent 名而不是路径(历史遗留),
* 拿它当「是不是 Agent」的代理变量会在边界上猜错。
*
* workspace 必须从**会话**取(`session_workspace`),不能用 `from_workspace`:
* 后者对 Agent 存的是 Agent 名,拿它拼会得到 `dsh@dsh`。
*/
export function participantAddress(
name: string,
isHuman: boolean,
workspace: string,
sessionAlias: string
): string {
// 人(无工作目录):只有名字,不带 path 也不带会话位
if (isHuman) {
return formatAddress(name, '', '');
}
return formatAddress(name, workspace.trim(), sessionAlias);
}
/**
* 单封邮件的对端:我发的就回给收件人,别人发的就回给发件人。
*
* `me` 必须是当前登录用户名。传空串时退化为「回给发件人」——
* 那比回给自己安全:最坏的情况是回错人,而不是把信发进虚空。
*
* **原先鸿蒙侧的写法是 `req.to = this.fromName + '@'`**:那个游离的 `@`
* 让 `name@` 被解析成「有 path、无 session」⇒ 落到该 Agent 的**默认会话**,
* 而不是用户正在看的这条线索。这正是 WebUI 侧当年修过的同一个坑
* (注释里的 `jianf@.任务` vs `jianf.任务`)。
*/
export function mailCounterpart(
fromName: string,
fromHuman: boolean,
toName: string,
toHuman: boolean,
sessionWorkspace: string,
me: string
): Counterpart {
const ws: string = sessionWorkspace;
const iSent: boolean = me.length > 0 && fromName === me;
if (iSent) {
return { name: toName, path: toHuman ? '' : ws, isHuman: toHuman };
}
return { name: fromName, path: fromHuman ? '' : ws, isHuman: fromHuman };
}
/**
* 单封邮件视图的回复目标地址。
*
* 会话别名必须带上:不带就落到该 Agent 的**默认会话**,
* 而人明明是在某条具体线索里打字 —— 那会让追加的一句跑到另一条任务里去。
*/
export function mailReplyTarget(
fromName: string,
fromHuman: boolean,
toName: string,
toHuman: boolean,
sessionWorkspace: string,
sessionAlias: string,
me: string
): string {
const peer: Counterpart = mailCounterpart(
fromName, fromHuman, toName, toHuman, sessionWorkspace, me
);
return formatAddress(peer.name, peer.path, sessionAlias);
}