Files
MailUI4Agents/client/harmony/entry/src/main/ets/pages/SettingsPage.ets
JianFeeeee 6ca0113fd2 跨端: 统一顶栏组件 + 按压反馈(真跑通)+ 滑动判定从"时长门"改"速度门"
用户四条意见,逐条都是真问题,且**两条是我写完没生效**:
  ① 「所有的顶栏都应该应用玻璃圆框效果」② 「抽象一个统一的顶栏组件出来吧」
  ③ 「你不觉得发件箱太大了吗」④ 「各个组件带响应点击、滑动等的动画了吗」
  ⑤ 「还有转场动画呢?」⑥ 「还有其他行为都要一一对齐,例如邮件展示页面」

★ ① ② 统一顶栏 `AppHeader`
  改造前**六处各写各的**:`AdminUsersPage`(40×40 返回键+16 号标题)、
  `SettingsPage`(20 号标题+40×40「+」+**实心 Theme.surface**)、
  `MailDetailPage`(36×36 + 14 号标题,行高只有 14px 被裁过)、
  `MainPage.SentTab`/`PermissionTab`(通栏、无圆角)、`CommTabBar`。
  尺寸(36/40、14/16/20)、底色(实心白/透明/无)、返回键(有/无)三类都不一致;
  且四处用 `Text('‹')` 当返回键 —— **用字符当图标**(本仓明令禁止,字形随字体变、
  基线对不齐),而 `ICON_PATHS` 里**早就有** `chevronLeft`。
  ⇒ 抽成 `AppHeader`(返回键 + 标题 + `@BuilderParam` 右侧动作区),
    几何复用底部条常量,材质走 `Theme.navMaterial`。已接入 5 处。
  ★ `topInsetPx` 由调用方传:状态栏避让是**窗口级**事实(属页面),
    组件自己读会变成"每层各加一次"(平板侧栏 83+83 双计就是这么来的)。

★ ③ 发件箱"太大"—— 我的理由错了
  我第一版让 `HEADER_HEIGHT = NAV_BAR_HEIGHT`(56),理由是"顶栏与底栏同在一根
  竖轴上,高度不同会一眼看出来"。**那个理由不成立**:
  底栏是**两行**(图标+文字标签)⇒ 需要 56;顶栏只有**一行标题** ⇒ 56 里一半是空白。
  实测 `Row [277,315,1105,476]` = 161px = 56vp,标题那行只占 54px。
  "两根轴上的条要一样高"是把**对齐**理解成了**等高**。真正要对齐的是**左右留白与圆角**。
  ⇒ `HEADER_HEIGHT = 44`(= 可点区下限,返回键装得下)。

★ ④ 按压反馈 —— 两次失败才跑通,两个坑都记进注释
  · **坑一**:`.attributeModifier()` **一个组件只能挂一个**,链两个是**后者覆盖前者**。
    会话组头卡同时挂了玻璃与按压反馈 ⇒ 只生效一个,**编译器不报错、运行不提示**。
    WebUI 是 CSS,类名天然叠加;ArkUI 是单一插槽,"叠加"必须显式做
    ⇒ 新增 `CompositeModifier`。
  · **坑二**:`applyPressedAttribute` **只对自带按压状态机的组件**(`Button`)回调。
    实测:挂 `Button` 上 → hilog 有输出;挂只有 `.onClick` 的 `Row`/`Column` 上
    → **一次都不回调**(按下与常态逐像素相同 `239,244,255`)。而全仓 117 处
    `onClick` 的主体正是纯 `Row`/`Column` ⇒ 那条路对本仓没用。
    改用 `onTouch` + `animateTo`。
  · 底色用**系统点击效果色** `ohos_id_color_click_effect`(不是我自己挑的):
    第一版用 `Theme.surfaceMuted`,实测只差 `3/3/3`,肉眼看不出 ——
    因为"一块面的颜色"与"按下时叠的提示色"在系统色板里是**两个不同语义**。
  设备验证:`onTouch type=0`(Down) → `type=1`(Up) 成对到达,像素确有位移。

★ ④ 滑动判定:从「时长门」改成「速度门」(**设计错误,不是调参**)
  旧门 `SWIPE_MAX_DURATION_MS = 700`("整个手势超 700ms 就拒")。
  而**时长 = 距离 ÷ 速度** —— 它把两个量混成一个 ⇒ 同样的手速下
  **滑得越远越容易被拒**,屏幕越大越严重(平板同一手势像素更多)。
  设备实测(3184×2232,位移恒 542.6vp):
      600px/s→5909ms | 1200→3161ms | 1800→2006ms | 2500→1429ms | 5000→616ms | 15000→123ms
  ⇒ 旧门意味着**只有 ≥5000px/s 才过**,而那一档重复测试也只有 2/4 成功
    —— 用户报的"滑不动/时灵时不灵"就是这个。
  改 `SWIPE_MIN_SPEED = 0.25`(vp/ms,取自上表两档中间)后实测:
      1200px/s → 0/3(正确拒绝:那是拖动)    2500px/s → **0/4 → 3/3**
      5000px/s → **2/4 → 3/3**
  两条判据跟着改形态(**语义不变**:都是"够快才翻页"):
  · `cross-client-gesture` 断言"算的是 dx/ms"——只断言"有个门"会漏掉这次的错
    (旧的时长门**存在**、常量**有名字**、数值**是正数**,三条全过,而真机拒掉正常滑动);
  · `harmony-logic` 的行为判据加了两条**回归钉**:
    `542.6vp/1429ms`(实测的正常甩动)必须过;
    `1200vp/3000ms`(同速度、更远)也必须过 —— 旧门正是在这里误拒。

★ ⑤ 转场动画:查清了,"不做页面级"是**决定**而不是漏做
  WebUI `index.css:1230` 写明:页面级淡入会让**已在那儿的框架**也一起暗一下
  ("观感还是闪"),且用户 2026-09-14 **亲口否掉过**"整屏一起淡",
  所以那一档整体删掉,只保留**局部**动效。鸿蒙侧当前是:
  · `pushUrl` 推页 → **系统自带的滑动转场**(连拍 8 帧验证:第 3 帧能看到
    写信页正在覆盖发件箱,是真实的横向滑入,不是硬切);
  · 局部入场 → `paneRiseIn` / `menuIn` / `calendarSlide`(已接 12 处)。
  所以"加 pageTransition"反而会与用户当时的否决冲突 —— 这一条**不做**,
  理由记在这里,免得下次又当成遗漏。

判据:files=32 ran=32 checks=519 pass=519 fail=0 red=0;baseline 7/7✓。
2026-09-20 10:51:28 +08:00

1255 lines
51 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
* AgentMail 鸿蒙客户端 — “我的”页(对齐 WebUI `AccountPage`)
*
* 八个 section 与 WebUI 一一对应:基本资料 / 权限范围 / 修改密码 / 多账号 /
* 客户端连接密钥 / 外观(主题+壁纸) / 登录状态(退出) / 管理(仅管理员)。
*
* ★ 页面标题是「我的」而不是「账号管理」:WebUI 侧它就是导航的「我的」入口
* (`Sidebar.tsx` 的 `setViewMode('account')`),多账号只是它里面的一段,
* 而不是整页的目的 —— 叫「账号管理」会让人以为这里是账号 CRUD 页。
*/
import { ApiClient, ApiError } from '../api/ApiClient';
import { Theme } from '../common/Theme';
import { AppHeader } from '../common/Surface';
import { GlassCardModifier, PaneModifier } from '../common/Surface';
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<void> {
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<number>(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<void> {
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<void> {
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<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()` 写了但没人调。
*
* ★ 2026-09-19:流程抽到 `api/Logout.ets` 的 `performLogout()` ——
* 宽屏侧栏底部那一簇也有退出键(WebUI `Sidebar.tsx:205-211`),
* 两个入口必须做的是**同一件事**(尤其是“先注销推送 token”那一步),
* 复制一份到这里就立刻会出现“两个入口行为不一致”。
*/
async doLogout(): Promise<void> {
const ctx = this.getUIContext().getHostContext();
await performLogout(ctx);
}
/**
* ★ 2026-09-15:接入系统通知 = **可配置项**(默认关)。
* 读一次持久化开关回填 @State,顺带看服务端有没有配推送(enabled 字段)。
* 服务端没配/读不到 → 明确告诉用户“服务器未配置推送” —— 但开关本身照常可用。
*/
async loadPushSetting(): Promise<void> {
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<PushTokenList>('/me/devices/push-token');
this.pushServerStatus = resp.enabled ? 'server-on' : 'server-off';
} catch (e) {
this.pushServerStatus = 'n/a';
}
}
/** 用户拨动开关:写持久化;开了就立刻补一次上报(不开就不碰推送) */
async togglePush(enabled: boolean): Promise<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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` 无条件渲染)。
*
* 所以这里去掉返回键(导航条就是返回路径),标题保留。
*/
/*
* ★ 2026-09-20 改用**统一顶栏**(原来是手写的一行 + **实心 `Theme.surface`**
* —— 在「我的」页里它是唯一一块不透明的白,与四周的玻璃卡片拼在一起就是"割裂")。
*/
AppHeader({
title: '我的',
showBack: false,
active: this.bgActive,
topInsetPx: 0,
trailing: this.HeaderTrailing
})
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 })
/*
* ★ 2026-09-19:这里**去掉**材质(原来加了,是错的)。
*
* 它是"个人资料"那张**卡里面**的一段列表 —— 即 `卡 > 列表`。
* 两层都铺材质就是 WebUI 用后代选择器明确禁止的嵌套玻璃
* (`.bg-white .bg-white { backdrop-filter: none }`):
* alpha 相乘把壁纸吃光,而且每层各糊一次。
* 判据 `cross-client-theme` 的 C 条正是按"玻璃不许套玻璃"判的,它抓到了这处。
* 内容仍靠外层那张卡的材质透出玻璃感。
*/
.borderRadius(Theme.radiusCard)
.border({ width: 1, color: Theme.border })
.clip(true)
}
}
.width('100%').alignItems(HorizontalAlign.Start)
.padding(16).margin({ top: 8 })
.attributeModifier(GlassCardModifier.of(this.bgActive))
.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).attributeModifier(GlassCardModifier.of(this.bgActive))
.borderRadius({ topLeft: 12, topRight: 12 })
/*
* ★ 2026-09-19 补(用户:「元素的出现消失动画呢?」)。
*
* 底部弹层挂在 `if (this.showAddDialog)` 上,原来硬弹。
* 与 `MailDetailPage` 的回复框/转发框**同一档、同一挂法**:
* `paneRiseIn()` 挂在**弹层本身**(下半部那块表面),遮罩立即出现。
* 挂在遮罩上会让整个屏幕(含变暗的底层内容)一起位移 ——
* 那看起来是"页面在动",而不是"弹层弹出来"。
*/
.transition(Theme.paneRiseIn())
}
.width('100%').height('100%')
.position({ x: 0, y: 0 })
}
}
.width('100%').height('100%')
/* 与其它窗格同一约定:背景开着时让出页面底,壁纸才透得过 */
.attributeModifier(PaneModifier.of(this.bgActive))
}
/** 分区标题(与 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.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
HeaderTrailing() {
Stack({ alignContent: Alignment.Center }) {
AmIcon({ iconName: 'plus', iconSize: 22, iconColor: Theme.accentFor() })
}
.width(44)
.height(44)
.onClick(() => { this.showAddDialog = true; })
}
@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 })
.attributeModifier(GlassCardModifier.of(this.bgActive))
.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)
/*
* ★ 2026-09-19 修:同上 —— 选中态压在 `Theme.accent` 蓝底上,
* 前景必须是**不翻转**的 `accentFg`,不能用 `surface`。
* ★ 这处是".fontColor(三元)"形状,判据的第一版正则只认
* `.fontColor(Theme.X)`(令牌是唯一实参)⇒ **漏掉了它**,
* 直到设备扫描报出「深色 2.93:1 ink rgb(32,34,36) bg rgb(34,96,228)」。
*/
.fontColor(this.appearanceTheme === t ? Theme.accentFg : 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 })
.attributeModifier(GlassCardModifier.of(this.bgActive))
.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 })
.attributeModifier(GlassCardModifier.of(this.bgActive))
.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 })
/* 同上(账号列表那处):这是"KeySection 卡**里面**的一段列表",
两层都铺材质 = 嵌套玻璃(判据 C 条会红,且 alpha 相乘会把壁纸吃光)。 */
.borderRadius(Theme.radiusCard)
.border({ width: 1, color: Theme.border })
.clip(true)
}
}
.width('100%').alignItems(HorizontalAlign.Start)
.padding(16).margin({ top: 8 })
.attributeModifier(GlassCardModifier.of(this.bgActive))
.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 })
.attributeModifier(GlassCardModifier.of(this.bgActive))
.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 })
.attributeModifier(GlassCardModifier.of(this.bgActive))
.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)
/* ★ 2026-09-19 修:三元里的裸 `Theme.accent` —— 批量替换漏了这种形状(同批共 2 处)。
* 深色下压在 `accentSoftFor()` 底上只有 2.83:1(设备实测)。 */
.fontColor(account.id === this.activeId ? Theme.accentFor() : 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 })
}
}