diff --git a/client/electron/test/harmony-admin.test.mjs b/client/electron/test/harmony-admin.test.mjs index 4dfcc30..b89c467 100644 --- a/client/electron/test/harmony-admin.test.mjs +++ b/client/electron/test/harmony-admin.test.mjs @@ -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\('\/auth\/me'\)/, + '★ MeApi.get() 必须调 `/auth/me`(`/me` 不存在 ⇒ 恒 404 ⇒ 管理入口永不显示)'); + assert.ok(!/get\('\/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/, '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 —— 密钥全文只在创建响应里出现一次,没有它就没法展示'); +}); diff --git a/client/electron/test/harmony-reply-target.test.mjs b/client/electron/test/harmony-reply-target.test.mjs new file mode 100644 index 0000000..8ad52bf --- /dev/null +++ b/client/electron/test/harmony-reply-target.test.mjs @@ -0,0 +1,197 @@ +/* + * 鸿蒙侧的三维地址拼装 —— 跑的是客户端真正会跑的那份逻辑。 + * + * `client/harmony/entry/src/main/ets/model/ReplyTarget.ts` 是纯逻辑、无 UI 依赖, + * 本文件用 node 的 `--experimental-strip-types` **直接执行它**,断言的是**行为** + * (空 path 有没有留下 `@`、给人拼几段、工作目录从哪取)。 + * + * 为什么必须有这个文件:`formatAddress` 的输出直接进 `send_mail` 的 `to` 字段, + * 拼错了就是**投递到错误的会话或直接 404**,而这件事在界面上看不出来 + * (发送会成功,信只是进了别处)。这个坑 WebUI 侧踩过并写在注释里: + * 漏掉空 path 那个 `@` 的 `jianf.任务` 会被整串当成**名字**。 + * + * 同时对齐后端的 `server/internal/models/address.go`(`FormatAddress`)。 + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync, existsSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +/* + * `code()` 剥掉注释、`prose()` 保留 —— 本文件判的是**代码里有什么调用/什么值**, + * 所以必须用 `code()`。 + * + * 这里踩过一次(写完就红):详情页的注释里**写着**那个 bug 形状 + * (「原先是 `this.fromName + '@'`」),用原文去 match 会把它自己的解释当代码, + * 产生假红 —— 而 `read.mjs` 的头注释正是为这类误报写的。 + */ +import { code } from './lib/read.mjs'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const ROOT = join(HERE, '..', '..', '..'); +const HARMONY_ETS = join(ROOT, 'client/harmony/entry/src/main/ets'); +const MODULE_TS = join(HARMONY_ETS, 'model/ReplyTarget.ts'); + +const R = await import(pathToFileURL(MODULE_TS).href); + +test('formatAddress:三段都要对(含空 path 仍留 `@`)', () => { + // 裸名字 = 默认会话(没有 @ 也没有 path) + assert.equal(R.formatAddress('jianf', '', ''), 'jianf'); + // 有 path 无 session + assert.equal(R.formatAddress('pi', '/home/program/agentmail', ''), 'pi@/home/program/agentmail'); + // 三段齐全 + assert.equal(R.formatAddress('pi', '/home', 'fix-leak'), 'pi@/home.fix-leak'); + /* + * ★ 空 path 但有 session:**必须留 `@`**。 + * 去掉它得到 `jianf.任务`,后端 ParseAddress 会把整串当成 name + * (实测返回 name="jianf.任务"),那是个不存在的 Agent ⇒ 投递 404。 + * 正确形式 `jianf@.任务` 解析成 name=jianf / path='' / session=任务。 + */ + assert.equal(R.formatAddress('jianf', '', '任务'), 'jianf@.任务', + '空 path + 有 session 时必须保留 `@`,否则整串被当成名字、投递 404'); + // 空名字给空串(不拼出 `@path.session` 这种没有收件人的地址) + assert.equal(R.formatAddress('', '/home', 'x'), ''); + // 前后空白要 trim 掉(用户输入常带) + assert.equal(R.formatAddress(' pi ', ' /home ', ' x '), 'pi@/home.x'); +}); + +test('★ participantAddress:人只有名字,Agent 要三段', () => { + /* + * 人没有工作目录、也不需要指定会话(发给人就是进他的收件箱)。 + * 给人拼 `jianf@.某会话` 是把 Agent 的维度硬套在人身上。 + */ + assert.equal(R.participantAddress('jianf', true, '/home/program/agentmail', '某会话'), 'jianf', + '人类参与者只显示名字,不带 path 也不带会话位'); + // Agent:三段(哪一个 Agent、哪个目录、哪条线索 —— 少一段都不是可投递地址) + assert.equal(R.participantAddress('pi', false, '/home/program/agentmail', '任务'), + 'pi@/home/program/agentmail.任务'); + // Agent 但会话未命名:两段(path 仍在) + assert.equal(R.participantAddress('pi', false, '/home/program/agentmail', ''), + 'pi@/home/program/agentmail'); +}); + +test('★ 判据自检:workspace 必须来自**会话**(拿 from_workspace 会拼出 dsh@dsh)', () => { + /* + * 这是一个"容易写错且看不出来"的点:`mails.from_workspace` 对 Agent + * 存的是 **Agent 名**而不是路径(历史遗留,见 server 的 models.go 注释)。 + * 用 participantAddress('dsh', false, 'dsh', '') 会得到 `dsh@dsh` —— + * 一个语法合法但语义错误的地址(指向名为 dsh 的目录)。 + * + * 这条钉的是**调用方传的是哪个字段**,不是函数本身: + * 源码里必须出现 `session_workspace`,且详情页不得用 `from_workspace`/`to_workspace` 拼地址。 + */ + const models = code(join(HARMONY_ETS, 'model/Models.ets')); + assert.match(models, /session_workspace:\s*string/, + 'MailDetail 要有 session_workspace 字段(拼地址的 path 只能从会话取)'); + + const detail = code(join(HARMONY_ETS, 'pages/MailDetailPage.ets')); + assert.match(detail, /this\.sessionWorkspace/, + '详情页拼地址要用 sessionWorkspace'); + assert.ok(!/from_workspace|to_workspace/.test(detail), + '★ 详情页不得用 from_workspace/to_workspace 拼地址 —— 它们对 Agent 存的是 Agent 名,会拼出 `dsh@dsh`'); + // 显示的假名要示范一下那个错值,让读者知道判据防的是什么 + assert.equal(R.participantAddress('dsh', false, 'dsh', ''), 'dsh@dsh', + '自检:拿 Agent 名当 path 正是会得到 `dsh@dsh`(判据要防的就是它)'); +}); + +test('★ mailReplyTarget:会话位必须带上(否则回信落到默认会话)', () => { + /* + * 这是**线上真出现过的 bug 形状**:鸿蒙详情页原来写 `req.to = this.fromName + '@'`。 + * 那个游离的 `@` 让 `pi@` 被解析成「有 path、无 session」⇒ 落到该 Agent 的 + * **默认会话**,而不是用户正在看的这条线索。 + */ + const target = R.mailReplyTarget( + 'pi', false, 'jianf', true, '/home/program/agentmail', '某任务', 'jianf' + ); + assert.equal(target, 'pi@/home/program/agentmail.某任务', + '对端是 pi(Agent)⇒ 三段,会话位必须在'); + assert.ok(target.endsWith('.某任务'), '会话别名要在地址里'); + assert.notEqual(target, 'pi@', '★ 绝不能是那个有 bug 的形式(游离的 @)'); + + /* + * 回信给**人**时:path 为空,但**会话位仍要带上**。 + * + * 这里有个容易想反的点:**显示**地址与**回复**地址规则不同。 + * · 显示走 `participantAddress()`:人只有名字(不带会话位)—— + * 因为「他是谁」不需要用会话去限定; + * · 回复走 `mailReplyTarget()`:**无论对端是人是 Agent 都带会话位**—— + * 因为「回进哪条线索」是必需的,人也会有多条并行的线索。 + * + * 所以这里得到的是 `someone@.某任务`(空 path 仍留下 `@`,会话位在)。 + * 与 WebUI 的 `mailReplyTarget` 逐字一致(它也是无条件传 `mail.session_alias`)。 + */ + const toHuman = R.mailReplyTarget( + 'someone', true, 'jianf', true, '/home/program/agentmail', '某任务', 'jianf' + ); + assert.equal(toHuman, 'someone@.某任务', + '回给人:path 为空但会话位要在(显示与回复的规则不同,见注释)'); + assert.ok(toHuman.includes('@'), '★ 空 path 也必须留下 `@`,否则整串被当成名字'); + + /* 回给人且**会话未命名**时:只剩名字(没有会话位可带) */ + assert.equal( + R.mailReplyTarget('someone', true, 'jianf', true, '/home', '', 'jianf'), + 'someone', + '会话未命名时回给人就只是名字' + ); + + /* 「我发的信」应该回给收件人,不是回给自己 */ + const iSent = R.mailReplyTarget( + 'jianf', true, 'pi', false, '/home/program/agentmail', '某任务', 'jianf' + ); + assert.equal(iSent, 'pi@/home/program/agentmail.某任务', + '我发的信要回给收件人 pi,不是回给自己'); +}); + +test('★ 详情页不得再用 `fromName + \'@\'` 拼回复地址(回归防护)', () => { + const detail = code(join(HARMONY_ETS, 'pages/MailDetailPage.ets')); + assert.ok(!/fromName\s*\+\s*'@'/.test(detail), + "★ `req.to = this.fromName + '@'` 是那个丢会话位的 bug 形状 —— 必须用 replyTargetAddress()"); + assert.match(detail, /req\.to\s*=\s*this\.replyTargetAddress\(\)/, + '回复的收件地址要走 replyTargetAddress()(内部经 mailReplyTarget 带上会话位)'); +}); + +test('★ 详情页的细节对齐 WebUI:本地时间 / 中文档位 / 三段地址', () => { + const detail = code(join(HARMONY_ETS, 'pages/MailDetailPage.ets')); + + // 时间:必须是本地格式化,不是把 ISO 原文吐出来 + assert.match(detail, /function localDateTime\(/, '要有本地时间格式化函数'); + assert.ok(!/Text\(this\.createdAt\)/.test(detail), + '★ 不得直接把 createdAt(ISO 原文)显示给用户 —— 要过 localDateTime()'); + assert.match(detail, /localDateTime\(this\.createdAt\)/, '时间行要用 localDateTime()'); + + // 档位:中文标签,不是英文 mode + assert.match(detail, /permissionLabel\(mode\)/, + '★ 档位要显示中文(permissionLabel),不是英文 plan/workspace/full'); + assert.ok(!/Text\(mode\)\s*\n?\s*\.fontSize/.test(detail), + '不得直接显示英文 mode 字面量'); + + // 头部可折叠(默认收起)—— WebUI CollapsibleHeader 的语义 + assert.match(detail, /headerOpen/, '头部要有可折叠状态'); + assert.match(detail, /@State headerOpen:\s*boolean\s*=\s*false/, + '★ 头部要**默认收起**(WebUI:顶部信息常驻会把可读区压成一条缝)'); + + // 回复入口是右下悬浮球,不是底部通栏按钮 + assert.match(detail, /iconName: 'chatBubble'/, '回复入口要用气泡图标(WebUI reply-fab)'); + assert.ok(!/Text\('回复'\)\.fontSize\(15\)/.test(detail), + '★ 不得再用底部通栏「回复」按钮(WebUI 已改成右下悬浮球)'); +}); + +test('★ 对齐后端:FormatAddress 的三分支与 Go 源码一致', () => { + /* + * 「拼地址」这件事有三份实现(WebUI TS / 鸿蒙 TS / 后端 Go), + * 三份不一致时**投递会静默失败**。这里直接去读 Go 源码, + * 确认分支结构与本模块相同(不是比字符串,是比**分支条件**)。 + */ + const goPath = join(ROOT, 'server/internal/models/address.go'); + if (!existsSync(goPath)) { + return; // 没这个文件就无从判起(判据不假装验过) + } + const go = readFileSync(goPath, 'utf8'); + assert.match(go, /func FormatAddress\(name, path, session string\) string/, + '后端要有 FormatAddress'); + // 三分支:session 空 + path 空 → name;session 空 + 有 path → name@path;否则 name@path.session + assert.match(go, /if session == "" \{\s*if path == "" \{\s*return name\s*\}\s*return name \+ "@" \+ path\s*\}/, + '后端 FormatAddress 的两个空 session 分支要与本模块一致'); + assert.match(go, /return name \+ "@" \+ path \+ "\." \+ session/, + '后端第三分支(保留 @ 与 .)要与本模块一致'); +}); diff --git a/client/harmony/entry/src/main/ets/api/AccountManager.ets b/client/harmony/entry/src/main/ets/api/AccountManager.ets index f03969d..c8812ed 100644 --- a/client/harmony/entry/src/main/ets/api/AccountManager.ets +++ b/client/harmony/entry/src/main/ets/api/AccountManager.ets @@ -161,6 +161,26 @@ export class AccountManager { return true; } + /** + * 清空全部账号(退出登录用)。 + * + * ★ **必须清全部,不能只清“当前”**:`LoginPage.aboutToAppear()` 的快速登录 + * 路径是「`getActiveAccount() !== null` ⇒ 直接 pushUrl MainPage」, + * 只要库里还留着一个旧账号,退出就会立刻被自动恢复 ⇒ 退回不去、看起来像点了没反应。 + * WebUI 侧的 `logout()` 只是 `set({phase:'anonymous'})`,因为它没有 + * “本机多账号库自动恢复”这条路径;鸿蒙有,所以退出得连库一起清。 + * + * 返回清掉的账号数(判据用它验“真的清了”而不是只看 `activeId`)。 + */ + async clearAll(): Promise { + const n: number = this.accounts.length; + this.accounts = []; + this.activeId = ''; + await this.persist(); + hilog.info(DOMAIN, TAG, 'cleared %{public}d accounts (logout)', n); + return n; + } + /** 更新账号信息(如 token 过期重新登录) */ async updateAccount(accountId: string, token: string, displayName: string): Promise { const idx: number = this.findIndex(accountId); diff --git a/client/harmony/entry/src/main/ets/api/AdminApi.ets b/client/harmony/entry/src/main/ets/api/AdminApi.ets index 71b63b5..837eb23 100644 --- a/client/harmony/entry/src/main/ets/api/AdminApi.ets +++ b/client/harmony/entry/src/main/ets/api/AdminApi.ets @@ -112,10 +112,15 @@ export class AdminApi { } /** - * `GET /me` → 当前登录用户。 + * `GET /auth/me` → 当前登录用户。 * - * 放在这里而不是 `AuthApi`:管理页的**可见性**靠它(`role === 'admin'`), - * 而 `AuthApi` 是登录/登出那条链。同名函数分处两个类会让人以为有一处是死的。 + * 放在这里而不是 `AuthApi`:管理页与「我的」页的**可见性**靠它 + * (`role === 'admin'` 决定「管理」入口显不显示),而 `AuthApi` 是登录/登出那条链。 + * 同名函数分处两个类会让人以为有一处是死的。 + * + * ★ 返回的 `AdminUser` 含 `status` / `created_at` / `last_login` / + * `allowed_agents` / `allowed_paths` —— 「我的」页的基本资料与权限范围 + * 两块直接用它们,不再单独写一个只取 `role` 的调用。 */ export class MeApi { private client: ApiClient; @@ -124,9 +129,23 @@ export class MeApi { this.client = client; } - /** 当前用户。**失败要抛**,不要吞成"不是管理员"。 */ + /** + * 当前用户。**失败要抛**,不要吞成"不是管理员"。 + * + * ★ 路径是 `/auth/me`,**不是** `/me`。 + * + * 这里原先写的是 `/me` —— 而服务端**根本没有**这个路由 + * (`server/cmd/server/main.go` 只注册了 `r.Get("/auth/me", handler.Me)`; + * `/me/*` 下是 mail / sessions / keys / appearance 这些**子资源**)。 + * 后果是这把调用**恒 404** ⇒ `loadRole()` 恒走 catch ⇒ `isAdmin` 恒 false + * ⇒ 「管理」入口对**包括管理员在内**的所有人永远不显示。 + * + * 实测(2026-09-17):`GET /api/v1/me` → 404;`GET /api/v1/auth/me` → 200。 + * 而 `/auth/me` 的响应形状就是本函数期望的 `{user:{…}}`(见 `userOut`), + * 所以只需改路径,不用改解析。 + */ async get(): Promise { - const resp: AdminUserResponse = await this.client.get('/me'); + const resp: AdminUserResponse = await this.client.get('/auth/me'); return resp.user; } } diff --git a/client/harmony/entry/src/main/ets/api/AuthApi.ets b/client/harmony/entry/src/main/ets/api/AuthApi.ets index 02c27c5..5709c9f 100644 --- a/client/harmony/entry/src/main/ets/api/AuthApi.ets +++ b/client/harmony/entry/src/main/ets/api/AuthApi.ets @@ -38,6 +38,12 @@ export class EmptyPayload { empty: boolean = true; } +/** 修改密码请求体(`POST /auth/password`) */ +export class ChangePasswordPayload { + old_password: string = ''; + new_password: string = ''; +} + export class AuthApi { private client: ApiClient; @@ -85,6 +91,23 @@ export class AuthApi { this.client.clearAuth(); } + /** + * 修改密码(`POST /auth/password`)。 + * + * 服务端在成功后**会清掉会话 Cookie**(`ChangePassword` 里的 + * `middleware.ClearSessionCookie`)—— 也就是说改完密码当前登录就失效了, + * 必须重新登录。调用方要把这件事**告诉用户**,不要以为改完还能接着用。 + * + * 原来这个端点只有服务端有,客户端 `AuthApi` 根本没包它 —— + * 结果「我的」页与 WebUI 的 `AccountPage` 少了整整一块「修改密码」。 + */ + async changePassword(oldPassword: string, newPassword: string): Promise { + const payload: ChangePasswordPayload = new ChangePasswordPayload(); + payload.old_password = oldPassword; + payload.new_password = newPassword; + await this.client.post('/auth/password', payload); + } + /** 创建客户端密钥 */ async createKey(label: string): Promise { const payload: CreateKeyPayload = new CreateKeyPayload(); diff --git a/client/harmony/entry/src/main/ets/model/Models.ets b/client/harmony/entry/src/main/ets/model/Models.ets index 81f348b..ef2c7f3 100644 --- a/client/harmony/entry/src/main/ets/model/Models.ets +++ b/client/harmony/entry/src/main/ets/model/Models.ets @@ -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 = ''; } /** 地址补全候选 */ diff --git a/client/harmony/entry/src/main/ets/model/ReplyTarget.ts b/client/harmony/entry/src/main/ets/model/ReplyTarget.ts new file mode 100644 index 0000000..58b9e19 --- /dev/null +++ b/client/harmony/entry/src/main/ets/model/ReplyTarget.ts @@ -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); +} diff --git a/client/harmony/entry/src/main/ets/pages/MailDetailPage.ets b/client/harmony/entry/src/main/ets/pages/MailDetailPage.ets index 1bf738d..e4e0608 100644 --- a/client/harmony/entry/src/main/ets/pages/MailDetailPage.ets +++ b/client/harmony/entry/src/main/ets/pages/MailDetailPage.ets @@ -9,9 +9,37 @@ import { Theme } from '../common/Theme'; import { Markdown } from '@luvi/lv-markdown-in'; import { MailApi } from '../api/MailApi'; import { AccountManager, AccountInfo } from '../api/AccountManager'; -import { MailDetail, SendMailRequest } from '../model/Models'; +import { MailDetail, SendMailRequest, Address } from '../model/Models'; import { MailDetailParams } from '../model/RouteParams'; import { AmIcon } from '../common/Icons'; +import { permissionLabel } from '../model/MailGrouping'; +import { + participantAddress, + mailReplyTarget, + formatAddress +} from '../model/ReplyTarget'; + +/** + * 本地时间:与 WebUI `new Date(x).toLocaleString('zh-CN')` 同一口径。 + * + * 原先详情页直接把 `mail.created_at`(ISO 8601,如 `2026-09-15T03:37:07.14758Z`) + * 吐到界面上 —— 用户读到的是 UTC 串,既不好读也不是本地时间。 + * 与列表页的 `compactMailTime()` 分开:列表要短(`09/15 11:37`), + * 详情要完整(含秒与年月日)。两处都走本地时区,不用 ISO 原文。 + */ +function localDateTime(iso: string): string { + const value: Date = new Date(iso); + if (Number.isNaN(value.getTime())) { + return ''; + } + const y: number = value.getFullYear(); + const mo: string = (value.getMonth() + 1).toString().padStart(2, '0'); + const d: string = value.getDate().toString().padStart(2, '0'); + const h: string = value.getHours().toString().padStart(2, '0'); + const mi: string = value.getMinutes().toString().padStart(2, '0'); + const s: string = value.getSeconds().toString().padStart(2, '0'); + return y + '/' + mo + '/' + d + ' ' + h + ':' + mi + ':' + s; +} @Component export struct MailDetailView { @@ -28,6 +56,13 @@ export struct MailDetailView { @State subject: string = ''; @State fromName: string = ''; @State toName: string = ''; + @State fromHuman: boolean = false; + @State toHuman: boolean = false; + @State sessionWorkspace: string = ''; + @State ccList: Address[] = []; + @State mailType: string = ''; + /** 已读状态:`unread` 时头部常驻一个「未读」点(与 WebUI 同一位置与语义) */ + @State status: string = ''; @State body: string = ''; @State createdAt: string = ''; @State permissionMode: string = ''; @@ -39,6 +74,18 @@ export struct MailDetailView { @State sending: boolean = false; @State sessionId: string = ''; @State switchingPerm: boolean = false; + /** + * 头部**默认收起**,点标题行展开。 + * + * 与 WebUI `CollapsibleHeader` 同一策略,理由也是同一条: + * 「顶部邮件信息 + 底部输入框同时常驻会把可读区压成一条缝」—— + * 邮件的用途是读,头部信息不是每时每刻都要用的。 + * WebUI 实测 1280×800 下头部占 17%、回复框占 31%,留给正文只剩 48%, + * 而手机竖屏比那更窄,这条更需要。 + */ + @State headerOpen: boolean = false; + /** 当前登录用户名 —— 决定「这封是不是我发的」,进而决定回给谁(见 ReplyTarget) */ + @State me: string = ''; private mailApi: MailApi | null = null; @@ -69,6 +116,13 @@ export struct MailDetailView { return; } this.accountId = account.id; + /* + * 当前登录用户名:决定「这封是不是我发的」,进而决定回给谁。 + * 与 WebUI 的 `useAuthStore(s => s.user?.username)` 同一口径 —— + * 写死 'human' 是多用户之前的遗留(登录名可能是 jianf,判据恒为假), + * 那会让「自己发的信」也算成别人发的,回复时回给自己。 + */ + this.me = account.username; const accountClient: ApiClient = new ApiClient(ctx); accountClient.setBase(account.server); accountClient.setToken(account.token); @@ -94,6 +148,12 @@ export struct MailDetailView { this.subject = mail.subject; this.fromName = mail.from_name; this.toName = mail.to_name; + this.fromHuman = mail.from_human; + this.toHuman = mail.to_human; + this.sessionWorkspace = mail.session_workspace; + this.ccList = mail.cc_list; + this.mailType = mail.mail_type; + this.status = mail.status; this.body = mail.body; this.createdAt = mail.created_at; this.permissionMode = mail.permission_mode; @@ -134,28 +194,177 @@ export struct MailDetailView { this.getUIContext().getRouter().back(); } + /** + * 发件方在这条会话里的**完整地址**(`name@path.session`)。 + * + * 与 WebUI `Header` 的 `participantAddress(mail.from_name, mail.from_human, ws, alias)` + * 逐字对齐:人只有名字(不分段也不带会话位),Agent 要三段。 + * workspace 取**会话的** `session_workspace`,不取 `from_workspace` + * (后者对 Agent 存的是 Agent 名,拿它拼会得到 `dsh@dsh`)。 + */ + fromAddress(): string { + return participantAddress( + this.fromName, this.fromHuman, this.sessionWorkspace, this.sessionAlias + ); + } + + /** 收件方在这条会话里的完整地址 */ + toAddress(): string { + return participantAddress( + this.toName, this.toHuman, this.sessionWorkspace, this.sessionAlias + ); + } + + /** + * 抄送行文案:优先用 `raw`(用户输入的原文)。 + * + * 不用拼回来的字符串替:`.new` 这类**原始意图**在 `raw` 里, + * 换成真实别名就把「他要新建一条线索」这件事抹掉了。 + */ + ccText(): string { + const parts: string[] = []; + for (let i = 0; i < this.ccList.length; i++) { + const a: Address = this.ccList[i]; + const shown: string = a.raw.length > 0 + ? a.raw + : formatAddress(a.name, a.path, a.session); + if (shown.length > 0) { + parts.push(shown); + } + } + return parts.join('、'); + } + + /** + * 单封邮件视图的回复目标地址。 + * + * **这是发信真正用的地址**,不是显示用的。原先是 `this.fromName + '@'` —— + * 那个游离的 `@` 让 `name@` 被解析成「有 path、无 session」,落到该 Agent 的 + * **默认会话**,而不是用户正在看的这条线索。 + */ + replyTargetAddress(): string { + return mailReplyTarget( + this.fromName, this.fromHuman, this.toName, this.toHuman, + this.sessionWorkspace, this.sessionAlias, this.me + ); + } + + /** 详情里的一行「标签 + 值」(对应 WebUI `Header` 的 ``) */ + @Builder + MetaRow(label: string, value: string) { + Row() { + Text(label) + .fontSize(11).fontColor(Theme.textSubtle) + .width(36).flexShrink(0) + Text(value) + .fontSize(12).fontColor(Theme.textMuted) + .layoutWeight(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + } + .width('100%') + .margin({ bottom: 2 }) + .alignItems(VerticalAlign.Top) + } + + /** + * 权限档位行:**中文标签**,不是英文 `plan/workspace/full`。 + * + * `permissionLabel()` 早就在 `MailGrouping.ts` 里了(列表页也用的它), + * 而详情页原来直接吐英文 mode —— 同一个概念在同一个 App 里两种写法。 + * 标签与 WebUI `MODE_LABEL` 逐字一致(只读 / 目录内 / 全权),可点可改。 + */ + @Builder + PermissionRow() { + Row() { + Text('权限') + .fontSize(11).fontColor(Theme.textSubtle) + .width(36).flexShrink(0) + if (this.switchingPerm) { + LoadingProgress().width(16).height(16) + } else { + ForEach(['plan', 'workspace', 'full'], (mode: string) => { + Text(permissionLabel(mode)) + .fontSize(11) + .fontColor(this.permissionMode === mode ? Theme.surface : Theme.textMuted) + .backgroundColor(this.permissionMode === mode ? Theme.accent : Theme.chipNeutralBg) + .borderRadius(4) + .padding({ left: 8, right: 8, top: 3, bottom: 3 }) + .margin({ right: 6 }) + .onClick(() => { this.switchPermission(mode); }) + }, (mode: string) => mode) + } + } + .width('100%') + } + build() { Column() { - // 顶栏 - Row() { - Text('‹') - .fontSize(24).fontColor(Theme.accent) - .width(40).height(40) - .textAlign(TextAlign.Center) - .onClick(() => { - this.goBack(); - }) - Text(this.subject.length > 20 ? this.subject.substring(0, 20) + '…' : this.subject) - .fontSize(16).fontWeight(FontWeight.Bold).fontColor(Theme.textPrimary) + /* + * ── 可折叠头部(与 WebUI `CollapsibleHeader` 同构)── + * + * 收起态只留一行:返回键 + 标题 + 必须常驻的状态点(未读 / 权限请求)+ 展开箭头。 + * 点这一行展开收发件人、时间、抄送、档位与操作行。 + * 返回键**不能**套在展开点击里(button 嵌 button)—— 与 WebUI 同一取舍。 + */ + Column() { + Row() { + Text('‹') + .fontSize(24).fontColor(Theme.accent) + .width(36).height(36) + .textAlign(TextAlign.Center) + .onClick(() => { this.goBack(); }) + + Row() { + Text(this.subject) + .fontSize(14).fontWeight(FontWeight.Bold).fontColor(Theme.textPrimary) + .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis }) + .layoutWeight(1) + + /* 状态点:收起态也要看得见(与 WebUI 的 meta 同一位置与语义) */ + if (this.status === 'unread') { + Text('未读') + .fontSize(10).fontColor(Theme.accent) + .backgroundColor(Theme.accentSoft).borderRadius(4) + .padding({ left: 6, right: 6, top: 2, bottom: 2 }) + .margin({ left: 6 }) + } + if (this.mailType === 'permission_request') { + Text('权限请求') + .fontSize(10).fontColor(Theme.warnFg) + .backgroundColor(Theme.warnBg).borderRadius(4) + .padding({ left: 6, right: 6, top: 2, bottom: 2 }) + .margin({ left: 6 }) + } + + AmIcon({ iconName: 'chevronRight', iconSize: 16, iconColor: Theme.textSubtle }) + .rotate({ angle: this.headerOpen ? 90 : 0 }) + .margin({ left: 6 }) + } .layoutWeight(1) - Text(this.permissionMode) - .fontSize(11).fontColor(Theme.permFg(this.permissionMode)) - .backgroundColor(Theme.permBg(this.permissionMode)) - .borderRadius(4).padding({ left: 6, right: 6, top: 2, bottom: 2 }) + .onClick(() => { this.headerOpen = !this.headerOpen; }) + } + .width('100%') + + if (this.headerOpen) { + Column() { + this.MetaRow('发件', this.fromAddress()) + this.MetaRow('收件', this.toAddress()) + if (this.ccList.length > 0) { + this.MetaRow('抄送', this.ccText()) + } + this.MetaRow('时间', localDateTime(this.createdAt)) + this.PermissionRow() + } + .width('100%') + .margin({ top: 8 }) + } } - .width('100%').height(56) - .padding({ left: 8, right: 12 }) + .width('100%') + .padding({ left: 8, right: 12, top: 8, bottom: 8 }) .backgroundColor(Theme.surface) + .onClick(() => {}) + + Divider().color(Theme.border) if (this.loading) { Column() { @@ -172,107 +381,62 @@ export struct MailDetailView { .width('100%').layoutWeight(1) .justifyContent(FlexAlign.Center) } else { - Scroll() { - Column() { - // 元信息卡片 + Stack({ alignContent: Alignment.BottomEnd }) { + Scroll() { Column() { - Row() { - Text('发件人').fontSize(12).fontColor(Theme.textSubtle).width(60) - Text(this.fromName).fontSize(14).fontColor(Theme.textPrimary) - }.width('100%').margin({ bottom: 6 }) + /* + * 元信息**不在正文区**了:它已经上移到可折叠头部(用户展开才显示)。 + * 这里只留正文:主题 + Markdown 正文。 + * 原来那张常驻元信息卡把可读区压掉一块,而那正是用户抱怨的事。 + */ + Text(this.subject) + .fontSize(20) + .fontWeight(FontWeight.Bold) + .fontColor(Theme.textPrimary) + .width('100%') + .padding({ left: 16, right: 16, top: 16, bottom: 8 }) - Row() { - Text('收件人').fontSize(12).fontColor(Theme.textSubtle).width(60) - Text(this.toName).fontSize(14).fontColor(Theme.textPrimary) - }.width('100%').margin({ bottom: 6 }) + /* + * 邮件正文:**用 Markdown 渲染**,不再直接吐原文。 + * + * 2026-09-15 之前这里是 `Text(this.body)` —— 正文是 Markdown, + * 于是用户看到的是 `**加粗**`、`# 标题`、`| 表 |` 的**字面量**。 + * WebUI 侧一直用 `react-markdown` + `remark-gfm` 渲染 + *(`MailView.tsx` 的 `.markdown` 容器),这是两端**功能不对等**的一处硬缺口。 + * + * 用第三方库 `@luvi/lv-markdown-in`(鸿蒙原生 ArkTS 渲染引擎, + * **不依赖 WebView**):解析与渲染全在原生层,`ohpm install` 一行接入。 + */ + Markdown({ text: this.body }) + .width('100%') + .padding({ left: 16, right: 16, bottom: 16 }) - if (this.sessionAlias.length > 0) { - Row() { - Text('会话').fontSize(12).fontColor(Theme.textSubtle).width(60) - Text(this.sessionAlias).fontSize(13).fontColor(Theme.accent) - }.width('100%').margin({ bottom: 6 }) - } - - Row() { - Text('时间').fontSize(12).fontColor(Theme.textSubtle).width(60) - Text(this.createdAt).fontSize(13).fontColor(Theme.textMuted) - }.width('100%').margin({ bottom: 6 }) - - Row() { - Text('权限').fontSize(12).fontColor(Theme.textSubtle).width(60) - if (this.switchingPerm) { - LoadingProgress().width(16).height(16) - } else { - ForEach(['plan', 'workspace', 'full'], (mode: string) => { - Text(mode) - .fontSize(11) - .fontColor(this.permissionMode === mode ? Theme.surface : Theme.textMuted) - .backgroundColor(this.permissionMode === mode ? Theme.accent : Theme.border) - .borderRadius(4) - .padding({ left: 8, right: 8, top: 3, bottom: 3 }) - .margin({ right: 6 }) - .onClick(() => { this.switchPermission(mode); }) - }, (mode: string) => mode) - } - }.width('100%') + /* 让出回复球的高度:球是浮在正文之上的,不让出最后一段会压在球底下 */ + Blank().height(80) } .width('100%') - .padding(16) - .backgroundColor(Theme.pageBg) - .borderRadius(8) - .margin({ left: 12, right: 12, top: 8 }) - - // 邮件正文 - Text(this.subject) - .fontSize(20) - .fontWeight(FontWeight.Bold) - .fontColor(Theme.textPrimary) - .width('100%') - .padding({ left: 16, right: 16, top: 16, bottom: 8 }) - - /* - * 邮件正文:**用 Markdown 渲染**,不再直接吐原文。 - * - * 2026-09-15 之前这里是 `Text(this.body)` —— 正文是 Markdown, - * 于是用户看到的是 `**加粗**`、`# 标题`、`| 表 |` 的**字面量**。 - * WebUI 侧一直用 `react-markdown` + `remark-gfm` 渲染 - *(`MailView.tsx` 的 `.markdown` 容器),这是两端**功能不对等**的一处硬缺口。 - * - * 用第三方库 `@luvi/lv-markdown-in`(鸿蒙原生 ArkTS 渲染引擎, - * **不依赖 WebView**):解析与渲染全在原生层,`ohpm install` 一行接入。 - * 我一度自己写了一个 GFM 子集解析器 —— 随后按用户指示换成第三方库: - * 自研那份只覆盖子集、还要自己养判据,而库里有 LaTeX / Prism 高亮 / - * 大文本懒渲染 / 70+ 样式 API,都是自研要重做的。 - */ - Markdown({ text: this.body }) - .width('100%') - .padding({ left: 16, right: 16, bottom: 16 }) - - Blank().height(60) } - .width('100%') - } - .layoutWeight(1) - .scrollBar(BarState.Off) + .width('100%').height('100%') + .scrollBar(BarState.Off) - // 底部回复按钮 - Row() { - Button() { - Row() { - AmIcon({ iconName: 'forward', iconSize: 16, iconColor: Theme.accentFg }) - Text('回复').fontSize(15).fontColor(Theme.accentFg).margin({ left: 6 }) - } - } - .width('90%').height(44) + /* + * 回复入口:**右下角悬浮球**,与 WebUI 的 `reply-fab` 同形同位置。 + * + * 原先是底部通栏按钮(占 56vp 常驻)—— WebUI 的注释把理由写得很清楚: + * 「顶部邮件信息 + 底部输入框同时常驻会把可读区压成一条缝」, + * 实测 1280×800 下回复框占 31%;输入框不是阅读时每刻都要用的东西, + * 就该按需展开。球同时与列表页的 ComposeFab 同一套观感,不新增一种视觉语言。 + */ + Button() { + AmIcon({ iconName: 'chatBubble', iconSize: 24, iconColor: Theme.accentFg }) + } + .width(56).height(56) + .borderRadius(28) .backgroundColor(Theme.accent) - .fontSize(15) - .onClick(() => { - this.showReplyBox = true; - }) + .margin({ right: 16, bottom: 16 }) + .onClick(() => { this.showReplyBox = true; }) } - .width('100%').height(56) - .justifyContent(FlexAlign.Center) - .backgroundColor(Theme.surface) + .width('100%').layoutWeight(1) // 回复弹层 if (this.showReplyBox) { @@ -285,8 +449,9 @@ export struct MailDetailView { // 回复框 Column() { - Text('回复给 ' + this.fromName) - .fontSize(16).fontWeight(FontWeight.Bold).fontColor(Theme.textPrimary) + Text('回复给 ' + this.replyTargetAddress()) + .fontSize(14).fontWeight(FontWeight.Bold).fontColor(Theme.textPrimary) + .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis }) .margin({ bottom: 12 }) TextArea({ placeholder: '输入回复内容…' }) @@ -337,7 +502,15 @@ export struct MailDetailView { this.sending = true; try { const req: SendMailRequest = new SendMailRequest(); - req.to = this.fromName + '@'; + /* + * ★ 用 `replyTargetAddress()`,**不是** `this.fromName + '@'`。 + * + * 原先那个游离的 `@` 让 `name@` 被后端 `ParseAddress` 解析成 + * 「有 path、无 session」⇒ 落到该 Agent 的**默认会话**,而不是用户 + * 正在看的这条线索 —— 回复会跑到另一条任务里去。 + * 正确形式是 `name@path.session`(人则只有名字),会话位必须带上。 + */ + req.to = this.replyTargetAddress(); req.subject = 'Re: ' + this.subject; req.body = this.replyBody; req.reply_to = this.mailId; diff --git a/client/harmony/entry/src/main/ets/pages/SettingsPage.ets b/client/harmony/entry/src/main/ets/pages/SettingsPage.ets index 78c00de..55fd10f 100644 --- a/client/harmony/entry/src/main/ets/pages/SettingsPage.ets +++ b/client/harmony/entry/src/main/ets/pages/SettingsPage.ets @@ -1,19 +1,28 @@ /* - * AgentMail 鸿蒙客户端 — 设置页(账号管理) - * 多账号列表 / 添加新账号 / 删除 / 切换默认账号 + * AgentMail 鸿蒙客户端 — “我的”页(对齐 WebUI `AccountPage`) + * + * 八个 section 与 WebUI 一一对应:基本资料 / 权限范围 / 修改密码 / 多账号 / + * 客户端连接密钥 / 外观(主题+壁纸) / 登录状态(退出) / 管理(仅管理员)。 + * + * ★ 页面标题是「我的」而不是「账号管理」:WebUI 侧它就是导航的「我的」入口 + * (`Sidebar.tsx` 的 `setViewMode('account')`),多账号只是它里面的一段, + * 而不是整页的目的 —— 叫「账号管理」会让人以为这里是账号 CRUD 页。 */ import { ApiClient, ApiError } from '../api/ApiClient'; import { Theme } from '../common/Theme'; import { AuthApi } from '../api/AuthApi'; import { hilog } from '@kit.PerformanceAnalysisKit'; import { common } from '@kit.AbilityKit'; +import { LengthMetrics } from '@kit.ArkUI'; +import { AmIcon } from '../common/Icons'; +import { LIST_FADE_LENGTH } from '../model/NavItems'; import { AccountManager, AccountInfo } from '../api/AccountManager'; import { SseService } from '../api/SseService'; import { AppearanceStore } from '../common/AppearanceStore'; import { AppearanceApi } from '../api/AppearanceApi'; import { AppearanceSnapshot, statusLabel } from '../model/Appearance'; import { MeApi } from '../api/AdminApi'; -import { AdminUser } from '../model/Models'; +import { AdminUser, UserKey } from '../model/Models'; import { isAdminRole } from '../model/AdminUsers'; import { ApiBaseCheck, validateApiBase } from '../model/ApiBase'; import { BackgroundPicker } from '../common/BackgroundPicker'; @@ -39,6 +48,33 @@ struct SettingsPage { @State appearanceStatus: string = 'local-only'; /** 当前账号是不是管理员 —— 只用来决定「管理」入口显不显示(口径见 model/AdminUsers.ts 的 isAdminRole) */ @State isAdmin: boolean = false; + /* + * ── 「我的」页的个人资料与密钥(对齐 WebUI `AccountPage`)── + * + * 原来这一页叫「账号管理」、内容只有多账号列表 + 外观 + 推送 + 管理入口, + * 而 WebUI 的 `AccountPage` 有八个 section。缺的这四块(基本资料 / 权限范围 / + * 修改密码 / 客户端连接密钥)**服务端都有、`AuthApi` 大半也写好了**: + * `listKeys` / `createKey` / `revokeKey` / `logout` 在 `AuthApi.ets` 里一直没人调。 + * + * `profile` 直接存 `MeApi.get()` 的返回(`AdminUser` 含 `status` / + * `created_at` / `last_login` / `allowed_agents` / `allowed_paths`)—— + * `loadRole()` 本来就在拉它,只是把别的字段丢掉了。 + */ + @State profile: AdminUser | null = null; + @State keys: UserKey[] = []; + @State keysLoading: boolean = false; + @State keysError: string = ''; + /** 新签发密钥的全文:**只在创建响应里出现一次**,关掉就再也看不到 */ + @State newKeyToken: string = ''; + @State keyLabel: string = ''; + @State creatingKey: boolean = false; + /* 修改密码表单 */ + @State oldPw: string = ''; + @State newPw: string = ''; + @State confirmPw: string = ''; + @State pwBusy: boolean = false; + @State pwMsg: string = ''; + @State pwError: string = ''; /* * ★ 2026-09-15:接入系统通知 = **可配置项**(用户强调多次:“用户可以自由部署自己的 agentmail”)。 * 默认 **关** —— 没开就不取 token / 不弹权限 / 不打网关。开关持久化在 PushService 的偏好里。 @@ -74,6 +110,7 @@ struct SettingsPage { this.loadAppearance(); this.loadRole(); this.loadPushSetting(); + this.loadKeys(); }); } } @@ -157,12 +194,159 @@ struct SettingsPage { } try { const me: AdminUser = await new MeApi(client).get(); + /* + * 连资料一起存下来:`/me` 返回的不只是 `role`,还有 + * `status` / `created_at` / `last_login` / `allowed_agents` / `allowed_paths`。 + * 原来只取了 `role`,其余字段白白拉回来就扔 —— 于是「基本资料」 + * 与「权限范围」两块在界面上就无法显示,而数据一直都在。 + */ + this.profile = me; this.isAdmin = isAdminRole(me.role); } catch (e) { this.isAdmin = false; } } + /** 拉一次「客户端连接密钥」(`GET /me/keys`) */ + async loadKeys(): Promise { + const client: ApiClient | null = this.client; + if (client === null) { + return; + } + this.keysLoading = true; + this.keysError = ''; + try { + this.keys = await new AuthApi(client).listKeys(); + } catch (e) { + const ae = e as ApiError; + this.keysError = ae.message.length > 0 ? ae.message : '加载失败'; + } finally { + this.keysLoading = false; + } + } + + /** + * 新建一把客户端密钥。 + * + * 服务端只在**创建响应**里给一次全文(之后列表只返回 `token_hint`), + * 所以必须当场存入 `newKeyToken` 让用户看得见 —— 这正是 WebUI + * `NewKeyBanner` 那句「全文仅显示这一次」的由来。 + */ + async createKey(): Promise { + const client: ApiClient | null = this.client; + if (client === null || this.creatingKey) { + return; + } + this.creatingKey = true; + this.keysError = ''; + try { + const key: UserKey = await new AuthApi(client).createKey(this.keyLabel.trim()); + this.newKeyToken = key.key_token; + this.keyLabel = ''; + await this.loadKeys(); + } catch (e) { + const ae = e as ApiError; + this.keysError = ae.message.length > 0 ? ae.message : '创建失败'; + } finally { + this.creatingKey = false; + } + } + + /** 吹销一把密钥 */ + async revokeKey(keyId: string): Promise { + const client: ApiClient | null = this.client; + if (client === null) { + return; + } + this.keysError = ''; + try { + await new AuthApi(client).revokeKey(keyId); + await this.loadKeys(); + } catch (e) { + const ae = e as ApiError; + this.keysError = ae.message.length > 0 ? ae.message : '吹销失败'; + } + } + + /** + * 修改密码(`POST /auth/password`)。 + * + * 与 WebUI `AccountPage.canSubmit` 同一套前置校验: + * 旧密码非空、新密码至少 8 位、两次一致。 + * 服务端在成功后**会清掉会话 Cookie** ⇒ 当前登录失效,必须告诉用户重新登录。 + */ + async changePassword(): Promise { + const client: ApiClient | null = this.client; + if (client === null || this.pwBusy) { + return; + } + this.pwError = ''; + this.pwMsg = ''; + if (this.oldPw.length === 0) { + this.pwError = '请输入当前密码'; + return; + } + if (this.newPw.length < 8) { + this.pwError = '新密码至少 8 位'; + return; + } + if (this.newPw !== this.confirmPw) { + this.pwError = '两次密码不一致'; + return; + } + this.pwBusy = true; + try { + await new AuthApi(client).changePassword(this.oldPw, this.newPw); + this.pwMsg = '密码已修改,请重新登录'; + this.oldPw = ''; + this.newPw = ''; + this.confirmPw = ''; + } catch (e) { + const ae = e as ApiError; + this.pwError = ae.message.length > 0 ? ae.message : '修改失败'; + } finally { + this.pwBusy = false; + } + } + + /** + * 退出登录。 + * + * WebUI 把它放在「我的」页底部(`AccountPage` 的「登录状态」section), + * 理由原话:「低频且不可逆的动作,与密码、密钥同属『账号自身』」。 + * 鸿蒙侧原来**全 App 没有退出口** —— `AuthApi.logout()` 写了但没人调。 + * + * 顺序有讲究:先注销本机推送 token(否则服务端会继续向这台设备推 + * 已经登出的账号的邮件),再清凭证,最后回登录页。 + */ + async doLogout(): Promise { + const ctx = this.getUIContext().getHostContext(); + const client: ApiClient | null = this.client; + if (ctx !== undefined && client !== null) { + /* 注销推送:失败不阻断退出(本地凭证该清还是要清) */ + try { + const push: PushService = PushService.getInstance(ctx as common.UIAbilityContext); + await push.unregister(client); + } catch (e) { + hilog.info(0x0001, 'SettingsPage', '退出时注销推送失败(继续退出):%{public}s', JSON.stringify(e)); + } + } + if (client !== null) { + await new AuthApi(client).logout(); + } + /* + * ★ 清**全部**账号,不是只清当前:`LoginPage.aboutToAppear()` 的快速登录 + * 路径是「`getActiveAccount() !== null` ⇒ 直接 pushUrl MainPage」, + * 只要库里还留着一个旧账号,退出就会立刻被自动恢复 —— 退回不去。 + */ + if (this.acctMgr !== null) { + await this.acctMgr.clearAll(); + } + SseService.getInstance().disconnectAll(); + /* replaceUrl 而不是 pushUrl:退出后不该还能“返回”到已登出的设置页 */ + this.getUIContext().getRouter().replaceUrl({ url: 'pages/LoginPage' }); + } + /** * ★ 2026-09-15:接入系统通知 = **可配置项**(默认关)。 * 读一次持久化开关回填 @State,顺带看服务端有没有配推送(enabled 字段)。 @@ -369,7 +553,7 @@ struct SettingsPage { Text('‹').fontSize(24).fontColor(Theme.accent).width(40).height(40) .textAlign(TextAlign.Center) .onClick(() => { this.getUIContext().getRouter().back(); }) - Text('账号管理').fontSize(16).fontWeight(FontWeight.Bold).fontColor(Theme.textPrimary) + Text('我的').fontSize(16).fontWeight(FontWeight.Bold).fontColor(Theme.textPrimary) .layoutWeight(1) Text('+').fontSize(24).fontColor(Theme.accent).width(40).height(40) .textAlign(TextAlign.Center) @@ -380,159 +564,76 @@ struct SettingsPage { Divider().color(Theme.border) - if (this.accounts.length === 0) { + /* + * ★ 整页**一个滚动容器**(对齐 WebUI `AccountPage` 的 `.overflow-y-auto`)。 + * + * 原来账号列表是 `layoutWeight(1)`(占满剩余高度),后面几块 section 直接排在 + * 它下面 —— 父容器高度就那么多,**排在 flex-1 之后的兄弟会被挤出可视区且无法滚到**。 + * WebUI 侧把这个坑写在 `AccountPage` 的注释里:「缺了它的后果:这个页的内容 + * 比视口高,而父级是 overflow-hidden 的 flex 列 —— 超出那段直接被裁掉, + * 没有任何办法滚到」。账号越多 / 屏越矮时越明显,而「退出登录」正好在最下面。 + */ + Scroll() { Column() { - Text('暂无账号').fontSize(16).fontColor(Theme.textMuted) - Button('添加第一个账号') - .margin({ top: 16 }).backgroundColor(Theme.accent) - .onClick(() => { this.showAddDialog = true; }) - } - .width('100%').layoutWeight(1).justifyContent(FlexAlign.Center) - } else { - List({ space: 1 }) { - ForEach(this.accounts, (account: AccountInfo) => { - ListItem() { - this.AccountItem(account) - } - .height(72) - }, (account: AccountInfo) => account.id) - } - .width('100%').layoutWeight(1) - .divider({ strokeWidth: 1, color: Theme.border, startMargin: 16, endMargin: 16 }) - } + /* 基本资料 / 权限范围(对齐 WebUI 的前两个 section) */ + this.ProfileSection() - /* - * ── 外观(主题 / 壁纸)── - * - * 主题只表达"偏好哪一种",深浅两套颜色**由系统给**(`setColorMode`): - * 这就是「用系统方案」在这里的意思 —— 不自己维护一套深色色值。 - * - * 状态(已同步 / 仅本机)必须显示:WebUI 侧的教训是"降级不可见", - * 用户以为换设备也能带走,打开另一台才发现没有。 - */ - Column() { - Row() { - Text('外观').fontSize(14).fontWeight(FontWeight.Bold).fontColor(Theme.textPrimary) - Blank() - Text(statusLabel(this.appearanceStatus)) - .fontSize(11) - .fontColor(this.appearanceStatus === 'synced' ? Theme.textMuted : Theme.warnFg) - } - .width('100%') - - Row() { - ForEach(['system', 'light', 'dark'], (t: string) => { - Text(t === 'system' ? '跟随系统' : (t === 'light' ? '浅色' : '深色')) - .fontSize(13) - .fontColor(this.appearanceTheme === t ? Theme.surface : Theme.textPrimary) - .backgroundColor(this.appearanceTheme === t ? Theme.accent : Theme.surfaceMuted) - .borderRadius(Theme.radiusControl) - .padding({ left: 12, right: 12, top: 6, bottom: 6 }) - .margin({ right: 8 }) - .onClick(() => { this.setTheme(t); }) - }, (t: string) => t) - } - .width('100%').margin({ top: 10 }) - - Text('主题由系统按色彩模式给色(深浅两套不靠手写色值);换账号时外观跟着账号走。') - .fontSize(11).fontColor(Theme.textSubtle).margin({ top: 6 }) - - Divider().color(Theme.border).margin({ top: 12, bottom: 12 }) - - /* - * 背景(P4c:不设 / 预设 / 自定义图片)。 - * - * 四值用 `@Link` 双向绑(`$` 前缀是 ArkTS 的 @Link 传参语法); - * `onUserChanged` 只在**用户动作**里被调 ⇒ 这里推服务端。 - */ - BackgroundPicker({ - bgKind: $bgKind, - bgPresetId: $bgPresetId, - bgDim: $bgDim, - bgBlur: $bgBlur, - statusText: statusLabel(this.appearanceStatus), - onUserChanged: (kind: string, presetId: string, dim: number, blur: number) => { - this.setBackground(kind, presetId, dim, blur); - }, - onUploaded: (message: string) => { this.onWallpaperUploaded(message); }, - onUploadFailed: (reason: string) => { - this.getUIContext().getPromptAction().showToast({ message: reason }); - } - }) - } - .width('100%').alignItems(HorizontalAlign.Start) - .padding(16).margin({ top: 8 }) - .backgroundColor(Theme.surface) - .borderRadius(Theme.radiusCard) - - /* - * ── 系统通知(★ 2026-09-15:**可配置项**,默认关)── - * - * 用户可以自由部署自己的 agentmail:没有服务端推送凭证时, - * 关着 = 不取 token / 不弹权限 / 不打网关,零开销; - * 开了 = 取 token + 申请通知权限 + 上报服务端(全部静默,失败只写日志)。 - */ - Column() { - Row() { + /* + * ── 多账号(对齐 WebUI 的 AccountList section)── + */ Column() { - Text('系统通知').fontSize(14).fontWeight(FontWeight.Bold).fontColor(Theme.textPrimary) - Text(this.pushServerStatus === 'server-off' - ? '服务器未配置推送' - : (this.pushServerStatus === 'server-on' ? '服务器已开启推送通道' : '推送通道状态未知')) - .fontSize(11) - .fontColor(this.pushServerStatus === 'server-on' ? Theme.textMuted : Theme.warnFg) - .margin({ top: 2 }) + Text('多账号').fontSize(14).fontWeight(FontWeight.Bold).fontColor(Theme.textPrimary) + .width('100%') + if (this.accounts.length === 0) { + Text('暂无账号') + .fontSize(13).fontColor(Theme.textMuted) + .width('100%').margin({ top: 10 }) + Button('添加第一个账号') + .margin({ top: 12 }).backgroundColor(Theme.accent) + .onClick(() => { this.showAddDialog = true; }) + } else { + Column() { + ForEach(this.accounts, (account: AccountInfo) => { + this.AccountItem(account) + }, (account: AccountInfo) => account.id) + } + .width('100%').margin({ top: 10 }) + .backgroundColor(Theme.surface) + .borderRadius(Theme.radiusCard) + .border({ width: 1, color: Theme.border }) + .clip(true) + } } - .layoutWeight(1).alignItems(HorizontalAlign.Start) + .width('100%').alignItems(HorizontalAlign.Start) + .padding(16).margin({ top: 8 }) + .backgroundColor(Theme.surface) + .borderRadius(Theme.radiusCard) - Toggle({ type: ToggleType.Switch, isOn: this.pushEnabled }) - .onChange((isOn: boolean) => { - this.togglePush(isOn); - }) + /* 外观(主题 / 壁纸) */ + this.AppearanceSection() + + /* 系统通知 */ + this.PushSection() + + /* 客户端连接密钥(对齐 WebUI 的 KeyPanel section) */ + this.KeySection() + + /* 修改密码 + 退出登录(对齐 WebUI 的「修改密码」与「登录状态」两段) */ + this.SecuritySection() + + /* 管理入口(仅管理员) */ + this.AdminSection() + + Blank().height(24) } .width('100%') - - Text('新邮件到达时由系统弹通知(点通知直达邮件)。未配置服务端推送时请保持关闭。') - .fontSize(11).fontColor(Theme.textSubtle).margin({ top: 6 }) - } - .width('100%').alignItems(HorizontalAlign.Start) - .padding(16).margin({ top: 8 }) - .backgroundColor(Theme.surface) - .borderRadius(Theme.radiusCard) - - /* - * ── 管理入口(仅管理员可见)── - * - * WebUI 侧的位置是「"我的"页底部、仅管理员可见」(`App.tsx`: - * `viewMode === 'admin' && user?.role === 'admin'`)。鸿蒙侧对应的位置就是这里。 - * - * ★ 可见性来自 `/me` 的 `role`,口径与 WebUI 严格一致(`isAdminRole`)。 - * ★ **读不到身份时不显示**(`isAdmin` 初值 false),而不是乐观显示: - * 乐观显示会让每个普通用户都看到一个点进去 403 的入口。 - * 代价是"管理员在网络抖动时看不到入口",那是可接受的(刷新即恢复)—— - * 反过来则是每个人都被误导。 - */ - if (this.isAdmin) { - Column() { - Row() { - Text('管理').fontSize(Theme.fontBody).fontColor(Theme.textPrimary) - .layoutWeight(1) - Text('›').fontSize(Theme.fontBody).fontColor(Theme.textSubtle) - } - .width('100%') - Text('用户管理(新建 / 编辑 / 启停 / 重置密码)') - .fontSize(Theme.fontTiny).fontColor(Theme.textSubtle) - .width('100%').margin({ top: 4 }) - } - .width('100%').alignItems(HorizontalAlign.Start) - .padding(16).margin({ top: 8 }) - .backgroundColor(Theme.surface) - .borderRadius(Theme.radiusCard) - .onClick(() => { - this.getUIContext().getRouter().pushUrl({ url: 'pages/AdminUsersPage' }); - }) } + .width('100%').layoutWeight(1) + .scrollBar(BarState.Off) + /* WebUI `.overflow-y-auto` 的上下渐隐:内容滑到边缘不硬截断 */ + .fadingEdge(true, { fadingEdgeLength: LengthMetrics.vp(LIST_FADE_LENGTH) }) + // 新增账号弹层(留在最外层,不被滚动容器裁掉) if (this.showAddDialog) { Column() { Column() @@ -585,6 +686,427 @@ struct SettingsPage { .backgroundColor(Theme.pageBg) } + /** 分区标题(与 WebUI `AccountPage` 的 `

` 同形:小字、灰、上间距) */ + @Builder + SectionTitle(title: string) { + Text(title) + .fontSize(11).fontColor(Theme.textMuted) + .width('100%').margin({ bottom: 8 }) + } + + /** 资料行「标签 + 值」(对应 WebUI `AccountPage` 的 ``) */ + @Builder + InfoRow(label: string, value: string) { + Row() { + Text(label) + .fontSize(12).fontColor(Theme.textSubtle) + .width(80).flexShrink(0) + Text(value) + .fontSize(14).fontColor(Theme.textPrimary) + .layoutWeight(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + } + .width('100%').margin({ bottom: 6 }) + .alignItems(VerticalAlign.Top) + } + + /** + * 基本资料 + 权限范围(对齐 WebUI `AccountPage` 的前两个 section)。 + * + * 数据来自 `/me`(`loadRole()` 里一起存进 `profile`)—— + * 服务端**一直**在返回这些字段,客户端原来只取了 `role`,其余全丢。 + */ + @Builder + ProfileSection() { + Column() { + if (this.profile === null) { + Text('加载中…').fontSize(13).fontColor(Theme.textMuted).width('100%') + } else { + this.InfoRow('用户名', this.profile.username) + this.InfoRow('显示名', this.profile.display_name) + this.InfoRow('角色', this.profile.role === 'admin' ? '管理员' : '普通用户') + this.InfoRow('状态', this.profile.status === 'active' ? '启用' : '禁用') + this.InfoRow('创建时间', this.profile.created_at ?? '-') + this.InfoRow('最后登录', + (this.profile.last_login !== undefined && this.profile.last_login.length > 0) + ? this.profile.last_login : '从未登录') + + /* + * 权限范围:**非管理员才显示**(管理员天然不受限,显示出来只是噪声)—— + * 与 WebUI 的 `{user.role !== 'admin' && (
…)}` 同一条条件。 + */ + if (this.profile.role !== 'admin') { + Divider().color(Theme.border).margin({ top: 10, bottom: 12 }) + this.SectionTitle('权限范围') + /* 空数组 = **不限**(不是“一个都不能用”)—— 与 WebUI 同义 */ + this.InfoRow('可调用 Agent', + this.profile.allowed_agents.length === 0 + ? '不限(全部可用)' : this.profile.allowed_agents.join(', ')) + this.InfoRow('可访问目录', + this.profile.allowed_paths.length === 0 + ? '不限(全部可用)' : this.profile.allowed_paths.join(', ')) + } + } + } + .width('100%').alignItems(HorizontalAlign.Start) + .padding(16).margin({ top: 10 }) + .backgroundColor(Theme.surface) + .borderRadius(Theme.radiusCard) + } + + /** + * 外观(主题 / 壁纸)—— 与 WebUI 的 `ThemePicker` + `BackgroundPicker` 两段同构。 + * + * 主题只表达"偏好哪一种",深浅两套颜色**由系统给**(`setColorMode`)。 + * 状态(已同步 / 仅本机)必须显示:WebUI 侧的教训是"降级不可见"。 + */ + @Builder + AppearanceSection() { + Column() { + Row() { + Text('外观').fontSize(14).fontWeight(FontWeight.Bold).fontColor(Theme.textPrimary) + Blank() + Text(statusLabel(this.appearanceStatus)) + .fontSize(11) + .fontColor(this.appearanceStatus === 'synced' ? Theme.textMuted : Theme.warnFg) + } + .width('100%') + + Row() { + ForEach(['system', 'light', 'dark'], (t: string) => { + Text(t === 'system' ? '跟随系统' : (t === 'light' ? '浅色' : '深色')) + .fontSize(13) + .fontColor(this.appearanceTheme === t ? Theme.surface : Theme.textPrimary) + .backgroundColor(this.appearanceTheme === t ? Theme.accent : Theme.surfaceMuted) + .borderRadius(Theme.radiusControl) + .padding({ left: 12, right: 12, top: 6, bottom: 6 }) + .margin({ right: 8 }) + .onClick(() => { this.setTheme(t); }) + }, (t: string) => t) + } + .width('100%').margin({ top: 10 }) + + Text('主题由系统按色彩模式给色(深浅两套不靠手写色值);换账号时外观跟着账号走。') + .fontSize(11).fontColor(Theme.textSubtle).margin({ top: 6 }) + + Divider().color(Theme.border).margin({ top: 12, bottom: 12 }) + + /* + * 背景(P4c:不设 / 预设 / 自定义图片)。 + * + * 四值用 `@Link` 双向绑(`$` 前缀是 ArkTS 的 @Link 传参语法); + * `onUserChanged` 只在**用户动作**里被调 ⇒ 这里推服务端。 + */ + BackgroundPicker({ + bgKind: $bgKind, + bgPresetId: $bgPresetId, + bgDim: $bgDim, + bgBlur: $bgBlur, + statusText: statusLabel(this.appearanceStatus), + onUserChanged: (kind: string, presetId: string, dim: number, blur: number) => { + this.setBackground(kind, presetId, dim, blur); + }, + onUploaded: (message: string) => { this.onWallpaperUploaded(message); }, + onUploadFailed: (reason: string) => { + this.getUIContext().getPromptAction().showToast({ message: reason }); + } + }) + } + .width('100%').alignItems(HorizontalAlign.Start) + .padding(16).margin({ top: 8 }) + .backgroundColor(Theme.surface) + .borderRadius(Theme.radiusCard) + } + + /** + * 系统通知(★ 2026-09-15:**可配置项**,默认关)。 + * + * 用户可以自由部署自己的 agentmail:没有服务端推送凭证时, + * 关着 = 不取 token / 不弹权限 / 不打网关,零开销。 + */ + @Builder + PushSection() { + Column() { + Row() { + Column() { + Text('系统通知').fontSize(14).fontWeight(FontWeight.Bold).fontColor(Theme.textPrimary) + Text(this.pushServerStatus === 'server-off' + ? '服务器未配置推送' + : (this.pushServerStatus === 'server-on' ? '服务器已开启推送通道' : '推送通道状态未知')) + .fontSize(11) + .fontColor(this.pushServerStatus === 'server-on' ? Theme.textMuted : Theme.warnFg) + .margin({ top: 2 }) + } + .layoutWeight(1).alignItems(HorizontalAlign.Start) + + Toggle({ type: ToggleType.Switch, isOn: this.pushEnabled }) + .onChange((isOn: boolean) => { + this.togglePush(isOn); + }) + } + .width('100%') + + Text('新邮件到达时由系统弹通知(点通知直达邮件)。未配置服务端推送时请保持关闭。') + .fontSize(11).fontColor(Theme.textSubtle).margin({ top: 6 }) + } + .width('100%').alignItems(HorizontalAlign.Start) + .padding(16).margin({ top: 8 }) + .backgroundColor(Theme.surface) + .borderRadius(Theme.radiusCard) + } + + /** + * 客户端连接密钥(对齐 WebUI `AccountPage` 的 `KeyPanel` section)。 + * + * `AuthApi.listKeys/createKey/revokeKey` 在客户端里**一直存在但没人调用** —— + * 这一块就是让它们真的能用。 + */ + @Builder + KeySection() { + Column() { + Row() { + Text('客户端连接密钥') + .fontSize(14).fontWeight(FontWeight.Bold).fontColor(Theme.textPrimary) + .layoutWeight(1) + AmIcon({ iconName: 'key', iconSize: 18, iconColor: Theme.textMuted }) + } + .width('100%') + + Text('第三方客户端用该密钥访问自己的邮箱(Authorization: Bearer)。它不能用于注册 Agent。') + .fontSize(11).fontColor(Theme.textSubtle).margin({ top: 6 }) + + if (this.keysError.length > 0) { + Text(this.keysError) + .fontSize(12).fontColor(Theme.danger).margin({ top: 8 }) + } + + /* + * 新签发密钥的一次性展示条。 + * + * 密钥全文只在**创建响应**里出现一次(之后列表只返回 `token_hint`), + * 所以必须明确提示「关掉就再也看不到」—— 与 WebUI `NewKeyBanner` 同一口径。 + */ + if (this.newKeyToken.length > 0) { + Column() { + Text('密钥已创建。全文仅显示这一次,关闭后无法再次查看。') + .fontSize(11).fontColor(Theme.warnFg) + .width('100%') + Text(this.newKeyToken) + .fontSize(11).fontColor(Theme.textPrimary) + .width('100%').margin({ top: 6 }) + Text('关闭') + .fontSize(11).fontColor(Theme.accent).margin({ top: 6 }) + .onClick(() => { this.newKeyToken = ''; }) + } + .width('100%').margin({ top: 10 }).padding(10) + .backgroundColor(Theme.warnBg) + .borderRadius(Theme.radiusControl) + } + + /* 新建:备注 + 创建 */ + Row() { + TextInput({ placeholder: '备注(如 我的笔记本 / CI 机器)', text: this.keyLabel }) + .layoutWeight(1).height(40) + .onChange((v: string) => { this.keyLabel = v; }) + Button(this.creatingKey ? '创建中…' : '新建') + .height(40).margin({ left: 8 }) + .backgroundColor(Theme.accent).fontSize(13) + .enabled(!this.creatingKey) + .onClick(() => { this.createKey(); }) + } + .width('100%').margin({ top: 10 }) + + if (this.keysLoading) { + LoadingProgress().width(24).height(24).margin({ top: 12 }) + } else if (this.keys.length === 0) { + Text('暂无密钥').fontSize(12).fontColor(Theme.textMuted).margin({ top: 12 }) + } else { + Column() { + ForEach(this.keys, (k: UserKey) => { + this.KeyRow(k) + }, (k: UserKey) => k.key_id) + } + .width('100%').margin({ top: 10 }) + .backgroundColor(Theme.surface) + .borderRadius(Theme.radiusCard) + .border({ width: 1, color: Theme.border }) + .clip(true) + } + } + .width('100%').alignItems(HorizontalAlign.Start) + .padding(16).margin({ top: 8 }) + .backgroundColor(Theme.surface) + .borderRadius(Theme.radiusCard) + } + + /** 密钥类型的中文说明(与 WebUI `KEY_TYPE_LABEL` 逐字一致) */ + keyTypeLabel(t: string): string { + if (t === 'permanent') { + return '长期'; + } + if (t === 'one_time') { + return '一次性'; + } + if (t === 'timed') { + return '限时'; + } + return t; + } + + /** + * 一把密钥的状态:过期 / 已用完 / 可用。 + * 与 WebUI `KeyPanel.keyState` 同一套判据(顺序也一样:先看一次性用完,再看限时过期)。 + */ + keyState(k: UserKey): string { + if (k.key_type === 'one_time' && k.used_at.length > 0) { + return '已使用'; + } + if (k.key_type === 'timed' && k.expires_at.length > 0) { + const exp: number = Date.parse(k.expires_at); + if (!Number.isNaN(exp) && exp < Date.now()) { + return '已过期'; + } + } + return '可用'; + } + + /** 密钥列表里的一行 */ + @Builder + KeyRow(k: UserKey) { + Row() { + Column() { + Text(k.token_hint) + .fontSize(11).fontColor(Theme.textPrimary) + Text((k.label.length > 0 ? k.label : '(无备注)') + ' · ' + this.keyTypeLabel(k.key_type)) + .fontSize(11).fontColor(Theme.textMuted).margin({ top: 2 }) + } + .layoutWeight(1).alignItems(HorizontalAlign.Start) + + Text(this.keyState(k)) + .fontSize(11) + .fontColor(k.status === 'active' ? Theme.approveFg : Theme.textSubtle) + .margin({ right: 12 }) + + Text('吹销') + .fontSize(12).fontColor(Theme.danger) + .onClick(() => { this.revokeKey(k.key_id); }) + } + .width('100%') + .padding({ left: 12, right: 12, top: 10, bottom: 10 }) + .alignItems(VerticalAlign.Center) + .border({ width: { bottom: 1 }, color: Theme.border }) + } + + /** + * 修改密码 + 退出登录。 + * + * WebUI 把它们分成两段(「修改密码」与「登录状态」),中间隔着密钥与外观; + * 这里合并为一片连续区域,因为两者都是「账号自身」且都是低频动作 —— + * 但**内容与顺序**与 WebUI 一致:先密码、后退出。 + */ + @Builder + SecuritySection() { + Column() { + Row() { + AmIcon({ iconName: 'lock', iconSize: 16, iconColor: Theme.textMuted }) + Text('修改密码') + .fontSize(14).fontWeight(FontWeight.Bold).fontColor(Theme.textPrimary) + .margin({ left: 6 }) + } + .width('100%') + + TextInput({ placeholder: '当前密码', text: this.oldPw }) + .width('100%').height(44).margin({ top: 10 }) + .type(InputType.Password) + .onChange((v: string) => { this.oldPw = v; }) + + TextInput({ placeholder: '新密码(至少 8 位)', text: this.newPw }) + .width('100%').height(44).margin({ top: 8 }) + .type(InputType.Password) + .onChange((v: string) => { this.newPw = v; }) + + TextInput({ placeholder: '确认新密码', text: this.confirmPw }) + .width('100%').height(44).margin({ top: 8 }) + .type(InputType.Password) + .onChange((v: string) => { this.confirmPw = v; }) + + /* 两次不一致就地提示(与 WebUI `mismatch` 同一行为,不等提交才发现) */ + if (this.confirmPw.length > 0 && this.newPw !== this.confirmPw) { + Text('两次密码不一致') + .fontSize(11).fontColor(Theme.danger).margin({ top: 6 }) + } + if (this.pwError.length > 0) { + Text(this.pwError) + .fontSize(12).fontColor(Theme.danger).margin({ top: 8 }) + } + if (this.pwMsg.length > 0) { + Text(this.pwMsg) + .fontSize(12).fontColor(Theme.approveFg).margin({ top: 8 }) + } + + Button(this.pwBusy ? '保存中' : '保存') + .height(40).margin({ top: 12 }) + .backgroundColor(Theme.accent).fontSize(14) + .enabled(!this.pwBusy) + .onClick(() => { this.changePassword(); }) + + Divider().color(Theme.border).margin({ top: 16, bottom: 14 }) + + /* + * 退出登录。 + * + * WebUI 把它放在「我的」页底部(`AccountPage` 的「登录状态」section), + * 理由原话:「低频且不可逆的动作,与密码、密钥同属『账号自身』」; + * 「窄屏下这也是唯一的退出口」。 + */ + Text('登录状态').fontSize(11).fontColor(Theme.textMuted).width('100%') + Button() { + Row() { + AmIcon({ iconName: 'logout', iconSize: 16, iconColor: Theme.textPrimary }) + Text('退出登录').fontSize(14).fontColor(Theme.textPrimary).margin({ left: 6 }) + } + } + .height(40).margin({ top: 8 }) + .backgroundColor(Theme.surfaceMuted) + .onClick(() => { this.doLogout(); }) + } + .width('100%').alignItems(HorizontalAlign.Start) + .padding(16).margin({ top: 8 }) + .backgroundColor(Theme.surface) + .borderRadius(Theme.radiusCard) + } + + /** + * 管理入口(仅管理员可见)。 + * + * WebUI 侧的位置是「"我的"页底部、仅管理员可见」。 + * ★ **读不到身份时不显示**(`isAdmin` 初值 false),而不是乐观显示: + * 乐观显示会让每个普通用户都看到一个点进去 403 的入口。 + */ + @Builder + AdminSection() { + if (this.isAdmin) { + Column() { + Row() { + Text('管理').fontSize(Theme.fontBody).fontColor(Theme.textPrimary) + .layoutWeight(1) + Text('›').fontSize(Theme.fontBody).fontColor(Theme.textSubtle) + } + .width('100%') + Text('用户管理(新建 / 编辑 / 启停 / 重置密码)') + .fontSize(Theme.fontTiny).fontColor(Theme.textSubtle) + .width('100%').margin({ top: 4 }) + } + .width('100%').alignItems(HorizontalAlign.Start) + .padding(16).margin({ top: 8 }) + .backgroundColor(Theme.surface) + .borderRadius(Theme.radiusCard) + .onClick(() => { + this.getUIContext().getRouter().pushUrl({ url: 'pages/AdminUsersPage' }); + }) + } + } + @Builder AccountItem(account: AccountInfo) { Row() { @@ -608,7 +1130,7 @@ struct SettingsPage { .margin({ top: 3 }) .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis }) } - .layoutWeight(1).height('100%') + .layoutWeight(1) .alignItems(HorizontalAlign.Start) .justifyContent(FlexAlign.Center) @@ -623,9 +1145,10 @@ struct SettingsPage { .fontSize(12).fontColor(Theme.danger) .onClick(() => { this.removeAccount(account.id); }) } - .width('100%').height('100%') + .width('100%').height(72) .padding({ left: 16, right: 16 }) .alignItems(VerticalAlign.Center) .backgroundColor(account.id === this.activeId ? Theme.accentSoft : Theme.surface) + .border({ width: { bottom: 1 }, color: Theme.border }) } }