/* * 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, isDarkMode, statusLabel } from '../model/Appearance'; import { MeApi } from '../api/AdminApi'; import { AdminUser, UserKey } from '../model/Models'; import { isAdminRole } from '../model/AdminUsers'; import { ApiBaseCheck, validateApiBase } from '../model/ApiBase'; import { BackgroundPicker } from '../common/BackgroundPicker'; import { PushService } from '../api/PushService'; import { performLogout } from '../api/Logout'; import { PushTokenList } from '../model/PushContract'; @Component export struct SettingsPane { /** 背景开关(与其它窗格同一约定:开着就让出页面底) */ @Prop bgActive: boolean = false; /** 底部悬浮条高度(窄屏非 0)—— 本页滚动容器的内容末尾要让出它 */ @Prop navReserve: number = 0; @State accounts: AccountInfo[] = []; @State activeId: string = ''; @State showAddDialog: boolean = false; @State newDisplayName: string = ''; @State newServer: string = ''; @State newUsername: string = ''; @State newToken: string = ''; @State adding: boolean = false; /* * 外观(主题 + 壁纸):服务端是权威、本机只是缓存。 * 页面只保存**显示用的一份副本** —— ArkTS 的 @State 观察不到类内部字段的变化。 */ @State appearanceTheme: string = 'system'; /** * 当前**实际**是不是深色(`appearanceTheme='system'` 时要看系统反馈的模式)。 * * ★ 2026-09-19 加:用来选品牌浅底(`Theme.accentSoftFor`)。 * 实测问题:深色主题下「多账号」那张选中卡片仍是**接近纯白的浅蓝** * (`accentSoft` 是写死的 `#EFF6FF`)—— 在深色页面上刺眼得像 bug。 * WebUI 靠 CSS 变量反转发解决,静态常量没有那层机制。 */ @State isDarkNow: boolean = false; @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 的偏好里。 */ @State pushEnabled: boolean = false; @State pushServerStatus: string = 'n/a'; /* * 背景四值(P4c):由选择器(`BackgroundPicker`,@Link 双向绑)改, * 用户改一次就回调 `onUserChanged` ⇒ 这里推服务端 —— 与 `setTheme` 同一形状。 * * ★ 为什么**不**用「页面 @Watch 到 bgKind/bgDim… 变化就推服务端」: * `adoptSnapshot` 把**服务端**的值复制进来时也会触发那个 @Watch ⇒ * 推一次服务端 ⇒ 再同步 ⇒ 再触发…… 要么死循环,要么得再加一个抑制标志, * 而那个标志本身又是一处能忘掉的状态。 * 改成"选择器在**用户改值**时显式回调"之后,方向是单向的: * 服务端来的值只往下走,不会回头再推一次。**不需要任何抑制标志。** */ /* * 外观变更的发布键(`AppStorage`)—— 改完外观后 MainPage 靠它重算壁纸层。 * 常量而不是字面量:`AppStorage.get('拼错的名字')` 不报错、只会恒为 undefined, * 而那正好是"壁纸永远不跟着变"这个症状(与 windowInsets 同一个坑)。 */ private readonly KEY_APPEARANCE_REVISION: string = 'agentmail.appearance.revision'; @State bgKind: string = 'none'; @State bgPresetId: string = 'aurora'; @State bgDim: number = 12; @State bgBlur: number = 4; private client: ApiClient | null = null; private acctMgr: AccountManager | null = null; aboutToAppear(): void { const ctx = this.getUIContext().getHostContext(); if (ctx !== undefined) { this.client = ApiClient.getInstance(ctx); this.acctMgr = AccountManager.getInstance(ctx); this.acctMgr.load().then(() => { this.refreshList(); this.loadAppearance(); this.loadRole(); this.loadPushSetting(); this.loadKeys(); }); } } /** * 用户改了背景 ⇒ 推一次服务端(与 `setTheme` 同一形状)。 * * ★ 只有**用户动作**会走到这里(选择器改值后回调 `onUserChanged`); * 服务端同步进来的值不会(理由见 `bgKind` 那段的注释)。 * ⇒ "值变了就推一次"只有**这一处**实现,而不是每个 onClick 各推一次。 * * ★ 与 `setTheme` 一样:本机先生效,但**推不上去就要说"仅本机"** * (`appearanceStatus`),不能让用户以为换设备也带着走。 */ async setBackground(kind: string, presetId: string, dim: number, blur: number): Promise { const ctx = this.getUIContext().getHostContext(); const client: ApiClient | null = this.client; if (ctx === undefined) { return; } const store: AppearanceStore = AppearanceStore.getInstance(); const snap: AppearanceSnapshot = store.current(); snap.bgKind = kind; snap.bgPresetId = presetId; snap.bgDim = dim; snap.bgBlur = blur; if (client === null) { this.appearanceStatus = 'local-only'; return; } try { await new AppearanceApi(client).put(snap, store.wallpaper !== null); store.saveLocal(ctx, snap); this.appearanceStatus = 'synced'; } catch (e) { this.appearanceStatus = 'local-only'; } /* * ★★ 2026-09-19 补(设备实测撞出来的**真 bug**):把"外观变了"这件事 * **发布出去**,让 `MainPage` 重算 `bgPlan` 并重画壁纸层。 * * 原来的缺口:本页改了档位、也存进了 store,但**只有本页的 @State 变了** —— * `MainPage` 的 `bgPlan`/`bgActive` 是启动时 `syncAppearance()` 算一次的, * 之后没人再动它。症状:色板出现、状态显示「已同步」、服务端也真的存了, * 而**页面背景一点没变**(壁纸层压根没画出来)。 * * 机制沿用 `AppStorage` 单向发布(与徽标、windowInsets 同一套): * 窗格侧**写**(这里),MainPage 侧 `@Watch` **读**(见 MainPage 的声明)。 * 不用 AppearanceStore 加订阅 —— 单例上加回调列表就要管注销, * 而这里只需要一个"变化计数器"(revision),越简单越不容易漏。 * * 计数而不是布尔:连续改两次压暗也应各触发一次重算 * (布尔从 true 设到 true 不产生变化通知 —— 那是"改了没反应"的经典形态)。 */ try { const prev: number = AppStorage.get(this.KEY_APPEARANCE_REVISION) ?? 0; AppStorage.setOrCreate(this.KEY_APPEARANCE_REVISION, prev + 1); } catch (e) { hilog.warn(0x0001, 'Settings', '发布外观变更失败:%{public}s', JSON.stringify(e)); } } /** * 上传成功后**重新以服务端为准同步**(P4c 第③条)。 * * ★ 刻意**不**在这里把 `bgKind` 设成 'image':服务端才是权威, * 如果服务端因为任何原因没把档位记成 image,本地先改成 image 就会 * 显示一块取不回来的空白(`resolveBackground` 对"image 档但没图"给 none)。 * 同步回来是什么就是什么。 */ async onWallpaperUploaded(message: string): Promise { const ctx = this.getUIContext().getHostContext(); const client: ApiClient | null = this.client; this.getUIContext().getPromptAction().showToast({ message: message }); if (ctx === undefined || client === null) { return; } const store: AppearanceStore = AppearanceStore.getInstance(); await store.syncFromServer(ctx, client); this.adoptSnapshot(store.current(), store.statusText()); } /** 把 store 里的快照复制进 @State(@State 观察不到类内部字段变化) */ adoptSnapshot(snap: AppearanceSnapshot, status: string): void { this.appearanceTheme = snap.theme; /* `system` 时要看系统反馈的色彩模式(`Theme` 是静态类,算不出这件事) */ const ctx2 = this.getUIContext().getHostContext(); if (ctx2 !== undefined) { const mode: number = ctx2.resourceManager.getConfigurationSync().colorMode; this.isDarkNow = isDarkMode(snap.theme, mode); } this.bgKind = snap.bgKind; this.bgPresetId = snap.bgPresetId; this.bgDim = snap.bgDim; this.bgBlur = snap.bgBlur; this.appearanceStatus = status; } /** * 读一次当前身份,决定「管理」入口显不显示。 * * ★ 失败**保持 false**:入口不显示,而不是「显示一个点进去 403 的东西」。 * 这里刻意不弹错误 —— 身份读不到不影响本页任何其它功能, * 为它弹一个 toast 只会让人以为设置页坏了。 */ async loadRole(): Promise { const client: ApiClient | null = this.client; if (client === null) { return; } 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()` 写了但没人调。 * * ★ 2026-09-19:流程抽到 `api/Logout.ets` 的 `performLogout()` —— * 宽屏侧栏底部那一簇也有退出键(WebUI `Sidebar.tsx:205-211`), * 两个入口必须做的是**同一件事**(尤其是“先注销推送 token”那一步), * 复制一份到这里就立刻会出现“两个入口行为不一致”。 */ async doLogout(): Promise { const ctx = this.getUIContext().getHostContext(); await performLogout(ctx); } /** * ★ 2026-09-15:接入系统通知 = **可配置项**(默认关)。 * 读一次持久化开关回填 @State,顺带看服务端有没有配推送(enabled 字段)。 * 服务端没配/读不到 → 明确告诉用户“服务器未配置推送” —— 但开关本身照常可用。 */ async loadPushSetting(): Promise { const ctx = this.getUIContext().getHostContext(); if (ctx === undefined) { return; } this.pushEnabled = PushService.isEnabled(ctx); // 顺带读一次服务端 enabled(有账号时);读不到也是正常态(自部署没配凭证) const client: ApiClient | null = this.client; if (client === null) { this.pushServerStatus = 'n/a'; return; } try { const resp: PushTokenList = await client.get('/me/devices/push-token'); this.pushServerStatus = resp.enabled ? 'server-on' : 'server-off'; } catch (e) { this.pushServerStatus = 'n/a'; } } /** 用户拨动开关:写持久化;开了就立刻补一次上报(不开就不碰推送) */ async togglePush(enabled: boolean): Promise { const ctx = this.getUIContext().getHostContext(); if (ctx === undefined) { return; } this.pushEnabled = enabled; PushService.setEnabled(ctx, enabled); if (!enabled) { return; } // 开了:立刻取 token + 申请权限 + 上报(全部静默,失败下次启动再试) const client: ApiClient | null = this.client; if (client !== null) { // 页面里的 `getHostContext()` 返回 `Context`;这里就是在 ability 里(UIAbilityContext 是其子类) const push: PushService = PushService.getInstance(ctx as common.UIAbilityContext); push.reportToken(client).catch((err: Object) => { hilog.info(0x0001, 'SettingsPage', '开后立即上报失败(静默):%{public}s', JSON.stringify(err)); }); } } /** * 拉一次外观:按合并规则落地(规则在 `model/Appearance.ts`,判据跑那一份)。 * * 这里只把结果复制进 @State 供渲染 —— 主题的**应用**(系统色彩模式)在 store 里做, * 因为那不是页面的事:换页时主题也该保持。 */ async loadAppearance(): Promise { const ctx = this.getUIContext().getHostContext(); const client: ApiClient | null = this.client; if (ctx === undefined || client === null) { return; } const store: AppearanceStore = AppearanceStore.getInstance(); // 缓存按账号分:换账号时读的是那一个账号的那一份 store.loadLocal(ctx, this.activeId); await store.syncFromServer(ctx, client); // 背景四值一并从 store 复制进来(否则选择器显示的还是默认值,与服务端不一致) this.adoptSnapshot(store.current(), store.statusText()); const snap: AppearanceSnapshot = store.current(); this.appearanceTheme = snap.theme; this.appearanceStatus = store.status; } /** 换主题:写服务端 + 立刻应用(写失败也要让本机先跟上,并如实标"仅本机") */ async setTheme(theme: string): Promise { const ctx = this.getUIContext().getHostContext(); const client: ApiClient | null = this.client; if (ctx === undefined) { return; } const store: AppearanceStore = AppearanceStore.getInstance(); const snap: AppearanceSnapshot = store.current(); snap.theme = theme; this.appearanceTheme = theme; store.applyTheme(ctx, theme); if (client === null) { this.appearanceStatus = 'local-only'; return; } try { await new AppearanceApi(client).put(snap, store.wallpaper !== null); store.saveLocal(ctx, snap); this.appearanceStatus = 'synced'; } catch (e) { // 服务端没写成:本机已经生效,但状态必须说实话(否则用户以为换设备也带着走) this.appearanceStatus = 'local-only'; } } refreshList(): void { const manager: AccountManager | null = this.acctMgr; if (manager === null) { return; } this.accounts = manager.getAccounts(); this.activeId = manager.getActiveId(); } async switchTo(accountId: string): Promise { const manager: AccountManager | null = this.acctMgr; const client: ApiClient | null = this.client; if (manager === null || client === null) { return; } const switched: boolean = await manager.switchAccount(accountId); if (!switched) { return; } const account: AccountInfo | null = manager.getActiveAccount(); if (account !== null) { client.setBase(account.server); client.setToken(account.token); this.getUIContext().getPromptAction().showToast({ message: '默认发信账号已设为 ' + account.displayName }); /* * ★ 换账号必须给**新账号**补报一次 token。 * * 服务端语义(pi 邮件 `2518e1a3`):同一个 token 换账号登录是**转移**,不是并存 —— * 也就是"上一件事的登记已被转走",新账号其实**没登记过**。 * `reportToken` 的幂等标记按 `accountKey|token` 存(`PushContract.reportMarker`), * 所以这里调一次必然真的重发,不会被"我报过"挡掉。 * 不补这一处,链路的症状是:切完账号,通知仍然推给**上一个**账号。 */ const ctx: Context | undefined = this.getUIContext().getHostContext(); if (ctx !== undefined) { const push: PushService = PushService.getInstance(ctx as common.UIAbilityContext); push.reportToken(client).catch((err: Object) => { hilog.info(0x0001, 'SettingsPage', '切账号后补报失败(静默):%{public}s', JSON.stringify(err)); }); } } this.refreshList(); } async removeAccount(accountId: string): Promise { const manager: AccountManager | null = this.acctMgr; const client: ApiClient | null = this.client; if (manager === null) { return; } const removed: boolean = await manager.removeAccount(accountId); if (!removed) { return; } SseService.getInstance().disconnectAccount(accountId); const active: AccountInfo | null = manager.getActiveAccount(); if (client !== null) { if (active !== null) { client.setBase(active.server); client.setToken(active.token); } else { client.setToken(''); } } this.refreshList(); this.getUIContext().getPromptAction().showToast({ message: '账号已删除' }); } async addNewAccount(): Promise { const manager: AccountManager | null = this.acctMgr; const ctx = this.getUIContext().getHostContext(); if (manager === null || ctx === undefined || this.adding) { return; } const displayName: string = this.newDisplayName.trim(); const token: string = this.newToken.trim(); const optionalUsername: string = this.newUsername.trim(); if (displayName.length === 0 || token.length === 0) { this.getUIContext().getPromptAction().showToast({ message: '请填写显示名称和 user_key' }); return; } /* * ★ 地址单独校验(与登录页同一份逻辑): * - 少了 `/api/v1` 会被补全 —— 这个入口与登录页一样是"用户手填地址"的地方; * - 不合法就**不往多账号库里存**:那个库会直接喂给 `SseService`(`base + '/events/stream'`), * 存进去的坏地址会让这条账号的实时通道永久连不上,而界面上看不出来。 */ const check: ApiBaseCheck = validateApiBase(this.newServer); if (!check.ok) { this.getUIContext().getPromptAction().showToast({ message: check.error }); return; } const server: string = check.base; // 回填:让用户看见地址被补成了什么,而不是默默变 this.newServer = server; this.adding = true; try { const validationClient: ApiClient = new ApiClient(ctx); validationClient.setBase(server); validationClient.setToken(token); const user = await new AuthApi(validationClient).me(); const username: string = optionalUsername.length > 0 ? optionalUsername : user.username; const account: AccountInfo = await manager.addAccount(server, username, token, displayName); SseService.getInstance().connectForAccount(account.id, account.server, account.token); this.getUIContext().getPromptAction().showToast({ message: '添加成功:' + account.displayName }); this.showAddDialog = false; this.newDisplayName = ''; this.newServer = ''; this.newUsername = ''; this.newToken = ''; this.refreshList(); } catch (e) { const apiError = e as ApiError; this.getUIContext().getPromptAction().showToast({ message: '验证失败: ' + apiError.message }); } finally { this.adding = false; } } build() { Column() { /* * ★ 「我的」是**第四个内容窗格**,不是另开的 @Entry 页(2026-09-17 修正)。 * * 用户:「我的页面完全没有遵守 nav 的导航规则」—— 指的就是原先的形状: * 点底栏「我的」会 `pushUrl('pages/SettingsPage')` 推一个**独立页面**, * 于是底部导航整条消失,用户在那页里要先「返回」才能再切换窗格。 * 而 WebUI 里 `account` 只是一个 `viewMode` —— 它和收件箱、日历一样 * 是**同一个外壳里的一个窗格**,底部导航**一直可见**(`NarrowNav` 无条件渲染)。 * * 所以这里去掉返回键(导航条就是返回路径),标题保留。 */ Row() { Text('我的').fontSize(20).fontWeight(FontWeight.Bold).fontColor(Theme.textPrimary) .layoutWeight(1) Text('+').fontSize(24).fontColor(Theme.accentFor()).width(40).height(40) .textAlign(TextAlign.Center) .onClick(() => { this.showAddDialog = true; }) } .width('100%').height(56).padding({ left: 16, right: 8 }) .backgroundColor(Theme.surface) Divider().color(Theme.border) /* * ★ 整页**一个滚动容器**(对齐 WebUI `AccountPage` 的 `.overflow-y-auto`)。 * * 原来账号列表是 `layoutWeight(1)`(占满剩余高度),后面几块 section 直接排在 * 它下面 —— 父容器高度就那么多,**排在 flex-1 之后的兄弟会被挤出可视区且无法滚到**。 * WebUI 侧把这个坑写在 `AccountPage` 的注释里:「缺了它的后果:这个页的内容 * 比视口高,而父级是 overflow-hidden 的 flex 列 —— 超出那段直接被裁掉, * 没有任何办法滚到」。账号越多 / 屏越矮时越明显,而「退出登录」正好在最下面。 */ Scroll() { Column() { /* 基本资料 / 权限范围(对齐 WebUI 的前两个 section) */ this.ProfileSection() /* * ── 多账号(对齐 WebUI 的 AccountList section)── */ Column() { 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) } } .width('100%').alignItems(HorizontalAlign.Start) .padding(16).margin({ top: 8 }) .backgroundColor(Theme.surface) .borderRadius(Theme.radiusCard) /* 外观(主题 / 壁纸) */ this.AppearanceSection() /* 系统通知 */ this.PushSection() /* 客户端连接密钥(对齐 WebUI 的 KeyPanel section) */ this.KeySection() /* 修改密码 + 退出登录(对齐 WebUI 的「修改密码」与「登录状态」两段) */ this.SecuritySection() /* 管理入口(仅管理员) */ this.AdminSection() Blank().height(24) } .width('100%') } .width('100%').layoutWeight(1) .scrollBar(BarState.Off) /* WebUI `.overflow-y-auto` 的上下渐隐:内容滑到边缘不硬截断 */ .fadingEdge(true, { fadingEdgeLength: LengthMetrics.vp(LIST_FADE_LENGTH) }) /* 末尾让位:最后一段(退出登录)要能滚出悬浮条之下 */ .contentEndOffset(this.navReserve) // 新增账号弹层(留在最外层,不被滚动容器裁掉) if (this.showAddDialog) { Column() { Column() .width('100%').layoutWeight(1) .backgroundColor(Theme.overlay) .onClick(() => { this.showAddDialog = false; }) Column() { Text('添加新账号').fontSize(16).fontWeight(FontWeight.Bold).fontColor(Theme.textPrimary) .margin({ bottom: 16 }) TextInput({ placeholder: '显示名称(必填)', text: this.newDisplayName }) .width('100%').height(44).margin({ bottom: 10 }) .onChange((value: string) => { this.newDisplayName = value; }) TextInput({ placeholder: 'Gateway 地址(必填,含 /api/v1)', text: this.newServer }) .width('100%').height(44).margin({ bottom: 10 }) .onChange((value: string) => { this.newServer = value; }) TextInput({ placeholder: '用户名(可选)', text: this.newUsername }) .width('100%').height(44).margin({ bottom: 10 }) .onChange((value: string) => { this.newUsername = value; }) TextInput({ placeholder: 'user_key(必填)', text: this.newToken }) .width('100%').height(44).margin({ bottom: 16 }) .type(InputType.Password) .onChange((value: string) => { this.newToken = value; }) Row() { Button('取消') .width(80).height(36).backgroundColor(Theme.surfaceMuted).fontColor(Theme.textMuted) .onClick(() => { this.showAddDialog = false; }) Blank() Button(this.adding ? '验证中…' : '添加') .width(80).height(36).backgroundColor(Theme.accent) .enabled(!this.adding) .onClick(() => { this.addNewAccount(); }) } .width('100%') } .width('100%').height('68%') .padding(16).backgroundColor(Theme.surface) .borderRadius({ topLeft: 12, topRight: 12 }) } .width('100%').height('100%') .position({ x: 0, y: 0 }) } } .width('100%').height('100%') /* 与其它窗格同一约定:背景开着时让出页面底,壁纸才透得过 */ .backgroundColor(this.bgActive ? Color.Transparent : 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.textSubtleFor()) .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.warnFgFor()) } .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.textSubtleFor()).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.warnFgFor()) .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.textSubtleFor()).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.textSubtleFor()).margin({ top: 6 }) if (this.keysError.length > 0) { Text(this.keysError) .fontSize(12).fontColor(Theme.dangerFor()).margin({ top: 8 }) } /* * 新签发密钥的一次性展示条。 * * 密钥全文只在**创建响应**里出现一次(之后列表只返回 `token_hint`), * 所以必须明确提示「关掉就再也看不到」—— 与 WebUI `NewKeyBanner` 同一口径。 */ if (this.newKeyToken.length > 0) { Column() { Text('密钥已创建。全文仅显示这一次,关闭后无法再次查看。') .fontSize(11).fontColor(Theme.warnFgFor()) .width('100%') Text(this.newKeyToken) .fontSize(11).fontColor(Theme.textPrimary) .width('100%').margin({ top: 6 }) Text('关闭') .fontSize(11).fontColor(Theme.accentFor()).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.dangerFor()) .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.dangerFor()).margin({ top: 6 }) } if (this.pwError.length > 0) { Text(this.pwError) .fontSize(12).fontColor(Theme.dangerFor()).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.textSubtleFor()) } .width('100%') Text('用户管理(新建 / 编辑 / 启停 / 重置密码)') .fontSize(Theme.fontTiny).fontColor(Theme.textSubtleFor()) .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() { Column() { Row() { Text(account.displayName) .fontSize(15).fontWeight(FontWeight.Bold) .fontColor(account.id === this.activeId ? Theme.accent : Theme.textPrimary) if (account.id === this.activeId) { Text(' 默认') .fontSize(11).fontColor(Theme.accentFor()) .backgroundColor(Theme.accentSoftFor(this.isDarkNow)).borderRadius(4) .padding({ left: 4, right: 4, top: 1, bottom: 1 }) .margin({ left: 6 }) } } .width('100%') Text((account.username.length > 0 ? account.username + ' · ' : '') + account.server) .fontSize(12).fontColor(Theme.textMuted) .margin({ top: 3 }) .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis }) } .layoutWeight(1) .alignItems(HorizontalAlign.Start) .justifyContent(FlexAlign.Center) if (account.id !== this.activeId) { Text('设为默认') .fontSize(12).fontColor(Theme.accentFor()) .margin({ right: 12 }) .onClick(() => { this.switchTo(account.id); }) } Text('删除') .fontSize(12).fontColor(Theme.dangerFor()) .onClick(() => { this.removeAccount(account.id); }) } .width('100%').height(72) .padding({ left: 16, right: 16 }) .alignItems(VerticalAlign.Center) .backgroundColor(account.id === this.activeId ? Theme.accentSoftFor(this.isDarkNow) : Theme.surface) .border({ width: { bottom: 1 }, color: Theme.border }) } }