/* * AgentMail 鸿蒙客户端 — 设置页(账号管理) * 多账号列表 / 添加新账号 / 删除 / 切换默认账号 */ import { ApiClient, ApiError } from '../api/ApiClient'; import { Theme } from '../common/Theme'; import { AuthApi } from '../api/AuthApi'; import { AccountManager, AccountInfo } from '../api/AccountManager'; import { SseService } from '../api/SseService'; import { AppearanceStore } from '../common/AppearanceStore'; import { AppearanceApi } from '../api/AppearanceApi'; import { AppearanceSnapshot, statusLabel } from '../model/Appearance'; import { MeApi } from '../api/AdminApi'; import { AdminUser } from '../model/Models'; import { isAdminRole } from '../model/AdminUsers'; import { BackgroundPicker } from '../common/BackgroundPicker'; @Entry @Component struct SettingsPage { @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'; @State appearanceStatus: string = 'local-only'; /** 当前账号是不是管理员 —— 只用来决定「管理」入口显不显示(口径见 model/AdminUsers.ts 的 isAdminRole) */ @State isAdmin: boolean = false; /* * 背景四值(P4c):由选择器(`BackgroundPicker`,@Link 双向绑)改, * 用户改一次就回调 `onUserChanged` ⇒ 这里推服务端 —— 与 `setTheme` 同一形状。 * * ★ 为什么**不**用「页面 @Watch 到 bgKind/bgDim… 变化就推服务端」: * `adoptSnapshot` 把**服务端**的值复制进来时也会触发那个 @Watch ⇒ * 推一次服务端 ⇒ 再同步 ⇒ 再触发…… 要么死循环,要么得再加一个抑制标志, * 而那个标志本身又是一处能忘掉的状态。 * 改成"选择器在**用户改值**时显式回调"之后,方向是单向的: * 服务端来的值只往下走,不会回头再推一次。**不需要任何抑制标志。** */ @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(); }); } } /** * 用户改了背景 ⇒ 推一次服务端(与 `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'; } } /** * 上传成功后**重新以服务端为准同步**(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; 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(); this.isAdmin = isAdminRole(me.role); } catch (e) { this.isAdmin = false; } } /** * 拉一次外观:按合并规则落地(规则在 `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(); } normalizeServer(server: string): string { let normalized: string = server.trim(); while (normalized.length > 0 && normalized.endsWith('/')) { normalized = normalized.substring(0, normalized.length - 1); } return normalized; } 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 }); } 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 server: string = this.normalizeServer(this.newServer); const token: string = this.newToken.trim(); const optionalUsername: string = this.newUsername.trim(); if (displayName.length === 0 || server.length === 0 || token.length === 0) { this.getUIContext().getPromptAction().showToast({ message: '请填写显示名称、Gateway 地址和 user_key' }); return; } 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() { Row() { Text('‹').fontSize(24).fontColor(Theme.accent).width(40).height(40) .textAlign(TextAlign.Center) .onClick(() => { this.getUIContext().getRouter().back(); }) Text('账号管理').fontSize(16).fontWeight(FontWeight.Bold).fontColor(Theme.textPrimary) .layoutWeight(1) Text('+').fontSize(24).fontColor(Theme.accent).width(40).height(40) .textAlign(TextAlign.Center) .onClick(() => { this.showAddDialog = true; }) } .width('100%').height(56).padding({ left: 8, right: 8 }) .backgroundColor(Theme.surface) Divider().color(Theme.border) if (this.accounts.length === 0) { Column() { Text('📭 暂无账号').fontSize(16).fontColor(Theme.textMuted) Button('添加第一个账号') .margin({ top: 16 }).backgroundColor(Theme.accent) .onClick(() => { this.showAddDialog = true; }) } .width('100%').layoutWeight(1).justifyContent(FlexAlign.Center) } else { List({ space: 1 }) { ForEach(this.accounts, (account: AccountInfo) => { ListItem() { this.AccountItem(account) } .height(72) }, (account: AccountInfo) => account.id) } .width('100%').layoutWeight(1) .divider({ strokeWidth: 1, color: Theme.border, startMargin: 16, endMargin: 16 }) } /* * ── 外观(主题 / 壁纸)── * * 主题只表达"偏好哪一种",深浅两套颜色**由系统给**(`setColorMode`): * 这就是「用系统方案」在这里的意思 —— 不自己维护一套深色色值。 * * 状态(已同步 / 仅本机)必须显示:WebUI 侧的教训是"降级不可见", * 用户以为换设备也能带走,打开另一台才发现没有。 */ Column() { Row() { Text('外观').fontSize(14).fontWeight(FontWeight.Bold).fontColor(Theme.textPrimary) Blank() Text(statusLabel(this.appearanceStatus)) .fontSize(11) .fontColor(this.appearanceStatus === 'synced' ? Theme.textMuted : Theme.warnFg) } .width('100%') Row() { ForEach(['system', 'light', 'dark'], (t: string) => { Text(t === 'system' ? '跟随系统' : (t === 'light' ? '浅色' : '深色')) .fontSize(13) .fontColor(this.appearanceTheme === t ? Theme.surface : Theme.textPrimary) .backgroundColor(this.appearanceTheme === t ? Theme.accent : Theme.surfaceMuted) .borderRadius(Theme.radiusControl) .padding({ left: 12, right: 12, top: 6, bottom: 6 }) .margin({ right: 8 }) .onClick(() => { this.setTheme(t); }) }, (t: string) => t) } .width('100%').margin({ top: 10 }) Text('主题由系统按色彩模式给色(深浅两套不靠手写色值);换账号时外观跟着账号走。') .fontSize(11).fontColor(Theme.textSubtle).margin({ top: 6 }) Divider().color(Theme.border).margin({ top: 12, bottom: 12 }) /* * 背景(P4c:不设 / 预设 / 自定义图片)。 * * 四值用 `@Link` 双向绑(`$` 前缀是 ArkTS 的 @Link 传参语法); * `onUserChanged` 只在**用户动作**里被调 ⇒ 这里推服务端。 */ BackgroundPicker({ bgKind: $bgKind, bgPresetId: $bgPresetId, bgDim: $bgDim, bgBlur: $bgBlur, statusText: statusLabel(this.appearanceStatus), onUserChanged: (kind: string, presetId: string, dim: number, blur: number) => { this.setBackground(kind, presetId, dim, blur); }, onUploaded: (message: string) => { this.onWallpaperUploaded(message); }, onUploadFailed: (reason: string) => { this.getUIContext().getPromptAction().showToast({ message: reason }); } }) } .width('100%').alignItems(HorizontalAlign.Start) .padding(16).margin({ top: 8 }) .backgroundColor(Theme.surface) .borderRadius(Theme.radiusCard) /* * ── 管理入口(仅管理员可见)── * * WebUI 侧的位置是「"我的"页底部、仅管理员可见」(`App.tsx`: * `viewMode === 'admin' && user?.role === 'admin'`)。鸿蒙侧对应的位置就是这里。 * * ★ 可见性来自 `/me` 的 `role`,口径与 WebUI 严格一致(`isAdminRole`)。 * ★ **读不到身份时不显示**(`isAdmin` 初值 false),而不是乐观显示: * 乐观显示会让每个普通用户都看到一个点进去 403 的入口。 * 代价是"管理员在网络抖动时看不到入口",那是可接受的(刷新即恢复)—— * 反过来则是每个人都被误导。 */ if (this.isAdmin) { Column() { Row() { Text('管理').fontSize(Theme.fontBody).fontColor(Theme.textPrimary) .layoutWeight(1) Text('›').fontSize(Theme.fontBody).fontColor(Theme.textSubtle) } .width('100%') Text('用户管理(新建 / 编辑 / 启停 / 重置密码)') .fontSize(Theme.fontTiny).fontColor(Theme.textSubtle) .width('100%').margin({ top: 4 }) } .width('100%').alignItems(HorizontalAlign.Start) .padding(16).margin({ top: 8 }) .backgroundColor(Theme.surface) .borderRadius(Theme.radiusCard) .onClick(() => { this.getUIContext().getRouter().pushUrl({ url: 'pages/AdminUsersPage' }); }) } 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 地址(必填)', 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(Theme.pageBg) } @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.accent) .backgroundColor(Theme.accentSoft).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).height('100%') .alignItems(HorizontalAlign.Start) .justifyContent(FlexAlign.Center) if (account.id !== this.activeId) { Text('设为默认') .fontSize(12).fontColor(Theme.accent) .margin({ right: 12 }) .onClick(() => { this.switchTo(account.id); }) } Text('删除') .fontSize(12).fontColor(Theme.danger) .onClick(() => { this.removeAccount(account.id); }) } .width('100%').height('100%') .padding({ left: 16, right: 16 }) .alignItems(VerticalAlign.Center) .backgroundColor(account.id === this.activeId ? Theme.accentSoft : Theme.surface) } }