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

@ -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` 的 `<Row>`) */
@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;

View File

@ -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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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` 的 `<h3>` 同形:小字、灰、上间距) */
@Builder
SectionTitle(title: string) {
Text(title)
.fontSize(11).fontColor(Theme.textMuted)
.width('100%').margin({ bottom: 8 })
}
/** 资料行「标签 + 值」(对应 WebUI `AccountPage` 的 `<Row>`) */
@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' && (<section>…)}` 同一条条件。
*/
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 })
}
}