用户四条意见,逐条都是真问题,且**两条是我写完没生效**:
① 「所有的顶栏都应该应用玻璃圆框效果」② 「抽象一个统一的顶栏组件出来吧」
③ 「你不觉得发件箱太大了吗」④ 「各个组件带响应点击、滑动等的动画了吗」
⑤ 「还有转场动画呢?」⑥ 「还有其他行为都要一一对齐,例如邮件展示页面」
★ ① ② 统一顶栏 `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✓。
1255 lines
51 KiB
Plaintext
1255 lines
51 KiB
Plaintext
/*
|
||
* 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 })
|
||
}
|
||
}
|