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:
@ -44,6 +44,7 @@ const ADMIN_TS = join(ETS, 'model/AdminUsers.ts');
|
||||
const ADMIN_PAGE = join(ETS, 'pages/AdminUsersPage.ets');
|
||||
const SETTINGS_PAGE = join(ETS, 'pages/SettingsPage.ets');
|
||||
const ADMIN_API = join(ETS, 'api/AdminApi.ets');
|
||||
const ACCOUNT_MGR = join(ETS, 'api/AccountManager.ets');
|
||||
const PAGES_JSON = join(ROOT, 'client/harmony/entry/src/main/resources/base/profile/main_pages.json');
|
||||
|
||||
// 真跑纯逻辑(有 `@ohos` 依赖的文件不能这样跑;AdminUsers.ts 刻意零 import)
|
||||
@ -320,3 +321,185 @@ test('背景选择器:父组件用 $ 传 @Link(传 this.xxx 会变成单向
|
||||
`★ ${name} 用了 this.${name} ⇒ 那是单向传值,选择器改不动页面的 @State`);
|
||||
}
|
||||
});
|
||||
|
||||
/* ────────── ⑪ 「我的」页对齐 WebUI AccountPage(2026-09-17)────────── */
|
||||
|
||||
test('★ `/me` 不是路由:MeApi 必须调 `/auth/me`(写错 ⇒ isAdmin 恒 false、管理入口永不显示)', () => {
|
||||
/*
|
||||
* 这是一个**线上真实存在过**的 bug,而且症状极隐蔽:
|
||||
*
|
||||
* `MeApi.get()` 原先写的是 `GET /me`。服务端**根本没有**这个路由 ——
|
||||
* `server/cmd/server/main.go` 只注册了 `r.Get("/auth/me", handler.Me)`,
|
||||
* `/me/*` 下挂的是 mail / sessions / keys / appearance 这些**子资源**。
|
||||
*
|
||||
* 后果链:这把调用恒 404 → `loadRole()` 恒走 catch → `isAdmin` 恒 false
|
||||
* → 「管理」入口对**包括管理员在内**的所有人永远不显示。
|
||||
* 而 catch 里是静默的(刻意不弹错,免得用户以为设置页坏了),所以界面上
|
||||
* 只表现为"这个功能好像没做",没人会想到是路径写错。
|
||||
*
|
||||
* 实证(2026-09-17,对着线上服务端):
|
||||
* GET /api/v1/me → 404
|
||||
* GET /api/v1/auth/me → 200 {"user":{"role":"admin",...}}
|
||||
*
|
||||
* 这条同时钉三件事:客户端调的路径、服务端真的注册了这个路径、
|
||||
* 以及响应形状(`{user:{…}}`)与客户端解析的一致。
|
||||
*/
|
||||
const adminApi = code(ADMIN_API);
|
||||
assert.match(adminApi, /client\.get<AdminUserResponse>\('\/auth\/me'\)/,
|
||||
'★ MeApi.get() 必须调 `/auth/me`(`/me` 不存在 ⇒ 恒 404 ⇒ 管理入口永不显示)');
|
||||
assert.ok(!/get<AdminUserResponse>\('\/me'\)/.test(adminApi),
|
||||
'★ 不得再调 `/me` —— 服务端没这个路由');
|
||||
|
||||
// 服务端真的注册了 /auth/me(否则两边都在猜)
|
||||
const mainGo = code(join(ROOT, 'server/cmd/server/main.go'));
|
||||
assert.match(mainGo, /r\.Get\("\/auth\/me",\s*handler\.Me\)/,
|
||||
'服务端要注册 GET /auth/me');
|
||||
// 响应形状 {user:{…}} —— 与 AdminUserResponse 的字段名一致
|
||||
const authGo = code(join(ROOT, 'server/internal/handler/auth.go'));
|
||||
assert.match(authGo, /type userOut struct/, '服务端要有 userOut');
|
||||
/*
|
||||
* `Me` handler 用的是**内联 map** 而不是具名 struct,所以这里判的是
|
||||
* 「它把 toUserOut 包在 `"user"` 键下」而不是一个不存在的 `meResponse`。
|
||||
* 客户端 `AdminUserResponse` 的字段名就叫 `user` —— 两边必须对上,
|
||||
* 对不上就是“反序列化后全是空”(界面摆好了但没数据)。
|
||||
*/
|
||||
const meFn = authGo.slice(authGo.indexOf('func Me('));
|
||||
assert.match(meFn.slice(0, 400), /map\[string\]interface\{\}\{"user":\s*toUserOut\(u\)\}/,
|
||||
'Me 的响应形状必须是 {"user": toUserOut(u)}(客户端按 `user` 这个键解析)');
|
||||
const models2 = code(join(ETS, 'model/Models.ets'));
|
||||
const resp = models2.slice(models2.indexOf('export class AdminUserResponse'));
|
||||
assert.match(resp.slice(0, 200), /user:\s*AdminUser\s*=/, 'AdminUserResponse 的字段名要是 user');
|
||||
});
|
||||
|
||||
test('★ 「我的」页要有 WebUI AccountPage 的八个 section(缺一块就是没对齐)', () => {
|
||||
/*
|
||||
* WebUI `AccountPage.tsx` 的 section 清单(按源码顺序):
|
||||
* ① 基本资料 ② 权限范围 ③ 修改密码 ④ 多账号(AccountList)
|
||||
* ⑤ 客户端连接密钥(KeyPanel) ⑥ 外观(ThemePicker+BackgroundPicker)
|
||||
* ⑦ 登录状态(退出) ⑧ 管理(仅管理员)
|
||||
*
|
||||
* 鸿蒙侧原来只有 ④⑥⑧ 加上推送与添加弹层 —— 差 ①②③⑤⑦ 五块。
|
||||
* 而 `AuthApi` 里的 `listKeys`/`createKey`/`revokeKey`/`logout`
|
||||
* **一直写好但没人调**,`/auth/password` 客户端根本没包 —— 全是"半成品"。
|
||||
*/
|
||||
const src = code(SETTINGS_PAGE);
|
||||
|
||||
// ① 基本资料:六个字段都要有(服务端一直在返回,客户端原来只取 role)
|
||||
for (const label of ['用户名', '显示名', '角色', '状态', '创建时间', '最后登录']) {
|
||||
assert.ok(src.includes(`'${label}'`), `基本资料要有「${label}」行`);
|
||||
}
|
||||
// ② 权限范围:空数组 = 不限(与 WebUI 同义,不是"一个都不能用")
|
||||
assert.ok(src.includes('可调用 Agent') && src.includes('可访问目录'),
|
||||
'权限范围要有「可调用 Agent」「可访问目录」两行');
|
||||
assert.match(src, /'不限(全部可用)'/,
|
||||
'空清单要显示「不限(全部可用)」—— 空数组的语义是不限,不是"一个都不能用"');
|
||||
// ③ 修改密码:三个输入框 + 提交
|
||||
for (const ph of ['当前密码', '新密码(至少 8 位)', '确认新密码']) {
|
||||
assert.ok(src.includes(`'${ph}'`), `修改密码要有「${ph}」输入框`);
|
||||
}
|
||||
assert.match(src, /changePassword\(/, '修改密码要真的调 AuthApi.changePassword');
|
||||
assert.match(src, /两次密码不一致/, '两次不一致要就地提示(与 WebUI 同一行为)');
|
||||
// ⑤ 客户端连接密钥
|
||||
assert.match(src, /listKeys\(\)/, '密钥列表要调 AuthApi.listKeys');
|
||||
assert.match(src, /createKey\(/, '新建密钥要调 AuthApi.createKey');
|
||||
assert.match(src, /revokeKey\(/, '吊销密钥要调 AuthApi.revokeKey');
|
||||
assert.match(src, /全文仅显示这一次/,
|
||||
'新密钥的一次性提示与 WebUI NewKeyBanner 同口径(服务端之后只返回 token_hint)');
|
||||
// ⑦ 退出登录
|
||||
assert.match(src, /doLogout\(\)/, '「我的」页要有退出登录');
|
||||
assert.match(src, /退出登录/, '退出按钮文案');
|
||||
});
|
||||
|
||||
test('★ 退出登录必须清**全部**账号(否则被 LoginPage 快速登录路径立刻恢复)', () => {
|
||||
/*
|
||||
* `LoginPage.aboutToAppear()` 的快速路径是:
|
||||
* `getActiveAccount() !== null` ⇒ 直接 `pushUrl MainPage`,不做任何网络认证。
|
||||
*
|
||||
* 所以退出时若只在内存里把当前 token 清掉、**库里还留着账号**,
|
||||
* 下一次回到登录页会立刻被自动恢复 ⇒ 看起来"点了退出没反应"。
|
||||
* 这是鸿蒙特有的路径(WebUI 没有"本机多账号库自动恢复",
|
||||
* 所以它的 `logout()` 只 `set({phase:'anonymous'})` 就够)。
|
||||
*/
|
||||
const src = code(SETTINGS_PAGE);
|
||||
assert.match(src, /clearAll\(\)/,
|
||||
'★ 退出要调 AccountManager.clearAll()(清全部账号)');
|
||||
assert.ok(!/clearActive\(\)/.test(src),
|
||||
'不得只清"当前"账号 —— 库里的其余账号会让快速登录路径把退出撤销掉');
|
||||
|
||||
// 被调的那个方法真的存在,且真的清空
|
||||
const mgr = code(ACCOUNT_MGR);
|
||||
assert.match(mgr, /async clearAll\(\): Promise<number>/, 'AccountManager 要有 clearAll()');
|
||||
const body = mgr.slice(mgr.indexOf('async clearAll()'));
|
||||
assert.match(body.slice(0, 400), /this\.accounts = \[\]/, 'clearAll 要清空账号数组');
|
||||
assert.match(body.slice(0, 400), /this\.activeId = ''/, 'clearAll 要清空 activeId');
|
||||
assert.match(body.slice(0, 400), /await this\.persist\(\)/, 'clearAll 要落盘(否则重启后又回来了)');
|
||||
});
|
||||
|
||||
test('★ 「我的」页整页一个滚动容器(排在 flex-1 之后的 section 会被裁掉且滚不到)', () => {
|
||||
/*
|
||||
* WebUI `AccountPage` 把这个坑写在注释里:
|
||||
* 「缺了它的后果:这个页的内容(资料 + 权限 + 改密码 + 密钥 + 退出)比视口高,
|
||||
* 而父级是 overflow-hidden 的 flex 列 —— 超出那段直接被裁掉,没有任何办法滚到。
|
||||
* 实测 390px 下内容需 860px、容器只有 795px;1280x800 的桌面上同样看不到
|
||||
* 最后的『退出登录』」。
|
||||
*
|
||||
* 鸿蒙侧原来的形状**正是那个错的**:账号列表 `layoutWeight(1)`(占满剩余高度),
|
||||
* 后面几块 section 直接排它下面 ⇒ 被挤出可视区。账号越多 / 屏越矮越明显,
|
||||
* 而「退出登录」正好在最下面 —— 等于退不出去。
|
||||
*/
|
||||
const src = code(SETTINGS_PAGE);
|
||||
assert.match(src, /Scroll\(\)\s*\{/, '整页要有一个 Scroll 容器');
|
||||
/*
|
||||
* 关键:section 必须在**那个 Scroll 里面**,而不是排在 flex-1 的兄弟位置。
|
||||
* 判法是取 Scroll 的花括号正文,看各个 section builder 在不在里面。
|
||||
*/
|
||||
const at = src.indexOf('Scroll()');
|
||||
assert.ok(at > 0, '要能找到 Scroll');
|
||||
let depth = 0;
|
||||
let start = src.indexOf('{', at);
|
||||
let end = -1;
|
||||
for (let i = start; i < src.length; i++) {
|
||||
if (src[i] === '{') depth++;
|
||||
else if (src[i] === '}') { depth--; if (depth === 0) { end = i; break; } }
|
||||
}
|
||||
assert.ok(end > start, 'Scroll 的花括号要闭合');
|
||||
const inside = src.slice(start, end);
|
||||
for (const sec of ['ProfileSection', 'KeySection', 'SecuritySection', 'AdminSection']) {
|
||||
assert.ok(inside.includes(`this.${sec}()`),
|
||||
`★ ${sec} 必须在 Scroll **内部** —— 排在 flex-1 之后会被裁掉且滚不到(WebUI 的注释里就有这个坑)`);
|
||||
}
|
||||
assert.ok(inside.includes('退出登录') || /SecuritySection/.test(inside),
|
||||
'退出登录要在可滚动区域内(否则退不出去)');
|
||||
// 滚动容器要挂上下渐隐(与列表同一纪律)
|
||||
assert.match(src, /\.fadingEdge\(true,/, '滚动容器要挂 fadingEdge(WebUI .overflow-y-auto 的 mask-image)');
|
||||
});
|
||||
|
||||
test('★ 「我的」页的数据都来自服务端真实字段(不是写死/编造)', () => {
|
||||
/*
|
||||
* 「基本资料」那一块最容易变成"界面摆好了但字段是空的":
|
||||
* `/auth/me` 返回 `status`/`created_at`/`last_login`/`allowed_agents`/`allowed_paths`,
|
||||
* 而客户端 `Me` 原来只声明了 4 个字段 —— 反序列化时多出来的键被丢掉。
|
||||
*/
|
||||
const models = code(join(ETS, 'model/Models.ets'));
|
||||
const me = models.slice(models.indexOf('export class Me {'), models.indexOf('export class Me {') + 900);
|
||||
for (const f of ['status', 'created_at', 'last_login', 'allowed_agents', 'allowed_paths']) {
|
||||
assert.ok(new RegExp(`${f}:`).test(me), `Me 类要有 ${f} 字段(服务端一直在返回它)`);
|
||||
}
|
||||
// 字段名要和服务端 JSON tag 一致,否则反序列化后是空
|
||||
const authGo = code(join(ROOT, 'server/internal/handler/auth.go'));
|
||||
/*
|
||||
* 注意 `last_login` / `created_at` 服务端带 `omitempty`(值空时**整个键不出现**),
|
||||
* 所以客户端那两处必须容忍 `undefined` —— 详情页用的是 `?? '-'` 与
|
||||
* 显式 undefined 判断,不是假定一定有时。
|
||||
*/
|
||||
for (const tag of ['status', 'created_at', 'allowed_agents', 'allowed_paths', 'last_login']) {
|
||||
assert.ok(new RegExp(`json:"${tag}(,omitempty)?"`).test(authGo),
|
||||
`服务端 userOut 要有 json:"${tag}"`);
|
||||
}
|
||||
// 密钥的"可用/已使用/已过期"要靠这两个字段算(WebUI keyState 同口径)
|
||||
const key = models.slice(models.indexOf('export class UserKey'), models.indexOf('export class UserKey') + 700);
|
||||
assert.ok(key.includes('expires_at') && key.includes('used_at'),
|
||||
'UserKey 要有 expires_at / used_at(否则算不出"可用/已使用/已过期")');
|
||||
assert.ok(key.includes('key_token'),
|
||||
'UserKey 要有 key_token —— 密钥全文只在创建响应里出现一次,没有它就没法展示');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user