Quellcode durchsuchen

Improve team UI state reconciliation

LSamien vor 2 Monaten
Ursprung
Commit
02fbe74e37

+ 55 - 211
design-qa.md

@@ -1,234 +1,78 @@
-# 侧栏 Design QA
+# 设置中心 Design QA
 
-## 对照目标
-
-- Source visual truth: `/tmp/hermes-yaoyao-sidebar-qa/grok-source-desktop.png`
-- Final implementation: `/tmp/hermes-yaoyao-sidebar-qa/implementation-final-1237x788.png`
-- Search state: `/tmp/hermes-yaoyao-sidebar-qa/implementation-final-search-1237x788.png`
-- Collapsed state: `/tmp/hermes-yaoyao-sidebar-qa/implementation-final-collapsed-1237x788.png`
-- Mobile drawer: `/tmp/hermes-yaoyao-sidebar-qa/implementation-final-mobile-390x844.png`
-- Full-view comparison: `/tmp/hermes-yaoyao-sidebar-qa/comparison-full.png`
-- Focused sidebar comparison: `/tmp/hermes-yaoyao-sidebar-qa/comparison-sidebar.png`
-
-## 归一化
-
-- Desktop source and implementation are both 1237 × 788 pixels, CSS viewport 1237 × 788, device scale factor 1.
-- Focused comparison crops the same left 264 × 788 region from both images and appends them side by side without scaling.
-- Mobile implementation is 390 × 844 pixels at CSS viewport 390 × 844 and device scale factor 1. The reference only defines the desktop information hierarchy, so mobile is evaluated as a responsive continuation rather than a pixel clone.
-- State: light theme, authenticated fake-9119 account, chat workspace, two history sessions.
-
-## Full-view comparison
-
-The implementation now keeps the same dominant composition as the source: a restrained light single-column sidebar separated from a large canvas, with the composer remaining the primary main-canvas interaction. Main-canvas copy and controls intentionally follow Hermes YaoYao requirements rather than cloning Grok product content.
-
-## Focused sidebar comparison
-
-- Information order matches: brand/collapse → search → new chat → feature entries → history → account area.
-- Search and new-chat rows use the same 40px full-width rhythm and low-contrast active fill.
-- History is an unboxed, compact list with pinned treatment and date buckets.
-- The footer stays pinned to the bottom. The extra Agent selector is intentional because multi-Profile switching is a product requirement.
-
-## Required fidelity surfaces
-
-- Fonts and typography: system sans-serif, 12–13px navigation hierarchy and compact metadata closely match the source density. Product name and secondary previews are intentional Hermes additions.
-- Spacing and layout rhythm: expanded width 264px, top row 58px, search/new-chat 40px, 10px horizontal inset; search input occupies the exact trigger rectangle at x=10, y=58, width=243, height=40.
-- Colors and tokens: neutral white/gray surfaces, black primary type, quiet dividers and one low-contrast selected row match the source. Existing theme tokens retain dark-theme support.
-- Image quality and assets: the canonical mobile AppIcon source is used at native aspect ratio; no substitute logo or CSS drawing is present.
-- Copy and content: “搜索 / 新建聊天 / 历史记录” match the requested structure. Hermes-only entries remain “群聊 / 文件库 / 产物”.
-
-## Interaction and responsive evidence
-
-- Search opens in place, focuses the input, and Escape clears/restores the trigger.
-- Collapsed width is 68px; search and new-chat remain available as named icon buttons, and collapsed search expands the sidebar before focusing.
-- Collapse state persists across reload.
-- The 390px drawer is 304px wide, reports `aria-hidden=false` while open, and makes the main canvas inert.
-- Browser console error/warning collection was empty for the final desktop/search/collapse/mobile flow.
-
-## Comparison history
-
-### Pass 1 — blocked
-
-- P1: search opened a second input inside the history body instead of replacing the top search row.
-- P1: collapsed mode removed search and new-chat actions.
-- P2: history lacked today/yesterday/earlier scan grouping.
-
-Fixes: positioned the live search input in the top row, synchronized close state across desktop/mobile copies, retained icon-only primary actions in collapsed mode, auto-expanded collapsed search, and added timestamp-derived history buckets.
-
-### Pass 2 — passed
-
-Post-fix screenshots confirm the search trigger and input share the exact rectangle, collapsed actions remain usable, and the history bucket appears without disturbing the fixed footer. No actionable P0/P1/P2 mismatch remains.
-
-## Follow-up polish
-
-- P3: session rows retain a secondary preview line for faster Hermes history scanning.
-
-## Account and collapse annotation pass
-
-- Source visual truth: current browser annotations supplied with the user request, targeting the duplicate Agent/account rows, desktop wordmark, and collapsed control placement.
-- Final expanded implementation: `/tmp/hermes-yaoyao-sidebar-qa/account-merged-expanded.png`
-- Final collapsed implementation: `/tmp/hermes-yaoyao-sidebar-qa/account-merged-collapsed.png`
-
-Findings resolved:
-
-- The Agent selector and account row are now one account switcher: avatar, username, active Agent, Agent menu, theme control, and logout menu live in the same footer surface.
-- Desktop rail uses only the mobile AppIcon; the `夭夭 Web` wordmark is no longer rendered there.
-- In the 68px collapsed rail, the collapse/expand control moves to the bottom directly above the signed-in account. The theme action hides in this compact state and remains available when expanded or on mobile.
-
-No new actionable P0/P1/P2 issue was observed in the expanded or collapsed browser-rendered states.
-
-## New-chat empty-state annotation pass
-
-- Source visual truth: current user-supplied mobile browser annotation requesting a large gray product logo and the copy “聊点什么”.
-- Final browser-rendered implementation: `/tmp/hermes-yaoyao-sidebar-qa/new-chat-empty.png`
-
-The ordinary chat empty state now renders the canonical mobile AppIcon at 150px mobile / 180px desktop with muted gray opacity and a single “聊点什么” prompt. Existing conversation timelines and the group-chat empty state retain their separate message-oriented presentation.
-
-## Transport-warning annotation pass
-
-The trusted-LAN plaintext banner is no longer rendered. The explicit server-side configuration gate for non-loopback HTTP remains unchanged; only the persistent canvas warning was removed as requested.
-
-## Composer dashboard-alignment pass
-
-- Source visual truth: user-supplied Dashboard composer annotations showing the reasoning slider popover, settings dropdown, and context-usage row.
-- Final reasoning implementation: `/tmp/hermes-yaoyao-sidebar-qa/composer-reasoning-popover.png`
-- Final settings implementation: `/tmp/hermes-yaoyao-sidebar-qa/composer-settings-popover.png`
-
-The ordinary-chat composer now exposes an anchored reasoning slider with the same default-to-maximum range and rainbow rail, an anchored settings popover for tool-trace visibility, and a context row showing used, maximum, remaining tokens, and progress. The initial fallback is `0 / 256.0k · 剩余 256.0k`; live 9119 usage replaces it when available.
-
-## Settings-menu annotation pass
-
-- Source visual truth: Dashboard settings dropdown annotation with “显示工具轨迹” and “语音输入设置”.
-- Final browser-rendered implementation: `/tmp/hermes-yaoyao-sidebar-qa/composer-settings-voice-row.png`
-
-The settings popover now matches the two-row structure. Tool trace is functional. Voice input remains outside the current release scope, and choosing its settings row explicitly reports that it is not yet enabled rather than exposing a deceptive microphone control.
-
-## Library preview modal pass
-
-File-library and artifact-grid selection now opens a centered modal rather than a persistent right Inspector. The shared preview surface handles images, video, audio, PDFs, Office documents, spreadsheets, text/code, links, and generic downloadable files. A real current-profile text file was opened successfully in the browser-rendered modal; the focused E2E path opens and closes a PDF modal without console errors.
-
-## Contextual navigation pass
-
-The feature strip now hides the current workspace and the artifact shortcut. In a group room it presents “对话 / 文件库”, preventing a redundant “群聊” destination while retaining direct artifact routes and source links.
-
-## Transparent conversation-header pass
-
-The normal-chat timeline header now renders as a transparent overlay with no horizontal divider or title band. An active stored session exposes a single top-right ellipsis action that opens only rename and delete controls; group-room headers retain their own management controls.
-
-## Product-scope navigation pass
-
-The chat workspace now shows only “群聊 / 文件库” as alternative destinations. The current “对话” item is omitted, and the artifact route redirects to chat with no visible product entry.
-
-## Agent-name source pass
-
-Bootstrap now enriches Hermes profiles from the YaoYao plugin `/profiles` settings endpoint. Session rows use the configured `agentName` at the right edge, falling back only when the plugin does not provide a name. A current browser run verified the plugin-configured name “竹儿” on session entries.
-
-## Pinned-session pass
-
-Pinned sessions are now rendered as their own “已置顶 N” section, with the existing pin glyph retained on each session row and the configured Agent name at the right edge. Server order remains authoritative; the client only supplies the visual grouping.
-
-## Message-surface alignment pass
-
-Chat and group timelines now use the reference cool blue-gray conversation canvas. Tool calls render as compact monospace “› tool_name” traces with expandable details; user attachments lead the right-aligned message bubble as a file card, followed by the user’s text. Thinking rows use the lightweight dashed disclosure treatment, and branch sessions receive a centered “FORK 来源” separator when parent metadata is present.
-
-final result: passed
-
-## Mobile login simplification pass
-
-- Source visual truth: the four user browser annotations attached in the current turn, captured at 542 × 788 and marking the product wordmark, brand block, login copy, and footer note.
-- Browser-rendered implementation: `/tmp/hermes-yaoyao-login-centered-542x788.jpg`, 542 × 788 CSS viewport, light theme, anonymous/error login state.
-- Density normalization: source and implementation use the same CSS viewport; implementation capture is 542 × 788 pixels at density 1.
-- Full-view comparison: the 72px AppIcon is centered over the form, the product wordmark is absent, “登录 Hermes” is centered, and both requested small-copy regions are removed.
-- Focused evidence: DOM measurements report panel center `271px` and Logo center `271px`; heading `text-align: center`; `.brand-mark__name`, `.login-copy p`, and `.login-note` counts are all `0`; no horizontal overflow and no console errors/warnings.
-- Fonts and typography: the existing UI font, 18px/630 login heading, and compact form hierarchy are preserved.
-- Spacing and layout rhythm: the centered brand-to-heading gap is 34px; form alignment and control sizing remain unchanged.
-- Colors and visual tokens: existing neutral canvas, form surfaces, error state, and theme control remain unchanged.
-- Image quality and asset fidelity: the original mobile AppIcon is retained at native proportions with no replacement asset.
-- Copy and content: “夭夭 Web”, the 9119 explanatory subtitle, and the credential-storage footer note are removed exactly as annotated.
-- No actionable P0/P1/P2 findings.
-
-final result: passed
-
-## File-card scale pass
-
-- Source visual truth: current `yaoyao-webui` file-library implementation and user screenshot, using 220px minimum cards, 154px previews, 36px type icons, 13px titles, and 11px metadata.
-- Browser-rendered implementation: `/tmp/hermes-yaoyao-files-large-cards.png`, `/files`, 1280 × 720 CSS viewport, light theme.
-- Measured focused evidence: first live card width `224.5px`, preview height `154px`, title `13px`, metadata `11px`.
-- Typography, spacing, neutral colors, file-type icon scale, Chinese type labels, and real image/video thumbnail behavior now follow the reference proportions. The wider conversation sidebar remains an intentional product difference requested by the user.
-- No actionable P0/P1/P2 findings.
-
-final result: passed
-
-## File-library layout pass
-
-- Source visual truth: user-supplied Hermes Dashboard file-library screenshots (1645 × 899), showing type tags and a dense document grid.
-- Final browser-rendered implementation: `/tmp/hermes-yaoyao-files-current.png` (1280 × 720 CSS viewport, density 1), `/files`, light theme, authenticated 9119 session.
-- Focused evidence: in-app browser DOM confirmed the left context now contains 100 historical conversations, while the main file surface contains top-level `全部 / 图片 / 视频 / 音频 / 文档 / 其他` tags, search, refresh, and file cards. The group workspace exposes only `对话` in its feature strip; `文件库` is absent.
-
-**Findings**
+## Findings
 
-- No actionable P0/P1/P2 differences for the requested layout. The implementation intentionally retains the wider existing session sidebar rather than the reference’s narrow icon rail, because the user explicitly asked for the original conversation list in this location.
+- 当前没有可执行的 P0、P1 或 P2 差异。
+- P3:补充稿的“模型与 Provider”页包含统一保存栏,而实际 Provider 的新增、编辑、测试、设为默认和删除均是独立即时操作。实现有意不显示一个无真实提交语义的全局“保存更改”,避免误导。
+- P3:账号安全补充稿本身为 `1505 × 1045`,与主设计的桌面比例不一致。实现遵循主设计的固定壳体,并让账号内容仅在右侧区域滚动;固定保存栏始终可见。
 
-**Required fidelity surfaces**
+## Source visual truth
 
-- Fonts and typography: existing Inter/system typography and compact 9–15px hierarchy retained; tag labels and card titles remain single-line and truncate safely.
-- Spacing and layout rhythm: file filters moved from the sidebar into a 46px top tag row; the main grid begins immediately below and session history occupies the prior filter column.
-- Colors and visual tokens: existing neutral white/gray token palette, selected tag surface, borders, and focus treatment retained.
-- Image quality and asset fidelity: real file thumbnails remain native image/video previews; document cards use the existing icon system.
-- Copy and content: all labels are localized and reflect live 9119 counts/status.
+- 主视觉:`/Users/samien/.codex/generated_images/01a04b82-a660-7c43-a4d1-5c0a84482b5c/exec-9f6f7523-6cfa-418f-9103-d748e5958d68.png`
+- 模型目录:`/Users/samien/.codex/generated_images/01a04b82-a660-7c43-a4d1-5c0a84482b5c/exec-b44ab548-2154-4432-944f-70b012ca9567.png`
+- 账号安全:`/Users/samien/.codex/generated_images/01a04b82-a660-7c43-a4d1-5c0a84482b5c/exec-73b26de9-b178-48dd-a616-3933f87990f3.png`
+- 系统概览:`/Users/samien/.codex/generated_images/01a04b82-a660-7c43-a4d1-5c0a84482b5c/exec-d8382bf2-4998-4ff8-bfd3-35d297071297.png`
 
-**Implementation checklist**
+源图像素尺寸依次为 `1717 × 916`、`1683 × 934`、`1505 × 1045`、`1717 × 916`。主视觉、模型和系统概览按相同比例归一到 `1637 × 874`;账号安全只作为内容层级参考,未拉伸为桌面比例。
 
-- [x] Move file type filtering and search to the main file-library toolbar.
-- [x] Restore conversation history in the file-library sidebar.
-- [x] Remove file-library navigation from group chat.
-- [x] Verify tag filtering, file grid, session list, and group navigation.
+## Implementation evidence
 
-final result: passed
+- 身份与头像:`/Users/samien/.codex/visualizations/2026/08/29/01a04b82-a660-7c43-a4d1-5c0a84482b5c/settings-center-implementation/08-identity-desktop-fixed-footer.png`
+- 模型与 Provider:`/Users/samien/.codex/visualizations/2026/08/29/01a04b82-a660-7c43-a4d1-5c0a84482b5c/settings-center-implementation/11-models-desktop-final.png`
+- 登录与安全:`/Users/samien/.codex/visualizations/2026/08/29/01a04b82-a660-7c43-a4d1-5c0a84482b5c/settings-center-implementation/09-security-desktop-fixed-footer.png`
+- 系统概览:`/Users/samien/.codex/visualizations/2026/08/29/01a04b82-a660-7c43-a4d1-5c0a84482b5c/settings-center-implementation/10-overview-desktop-final.png`
+- 移动端分类:`/Users/samien/.codex/visualizations/2026/08/29/01a04b82-a660-7c43-a4d1-5c0a84482b5c/settings-center-implementation/12-mobile-menu-final.png`
+- 移动端账号页:`/Users/samien/.codex/visualizations/2026/08/29/01a04b82-a660-7c43-a4d1-5c0a84482b5c/settings-center-implementation/07-mobile-security-fixed-footer.png`
 
-## 团队动物头像 Design QA
+桌面实现截图与 CSS 视口均为 `1637 × 874`,`devicePixelRatio = 1`。移动端实现截图与 CSS 视口均为 `390 × 844`,`devicePixelRatio = 1`。
 
-- source visual truth path: `/Users/samien/git/hermes-yaoyao/src/client/assets/team-avatars/{fox,whale,owl,rabbit,bear}.png`
-- Web implementation screenshot: `/Users/samien/.codex/visualizations/2026/08/27/01a040a6-d36a-7fb3-bb37-2f181f6fd5e8/team-avatar-web-implementation.png`
-- iOS implementation screenshot: `/Users/samien/.codex/visualizations/2026/08/27/01a040a6-d36a-7fb3-bb37-2f181f6fd5e8/team-avatar-ios-implementation.png`
-- combined comparison evidence: `/Users/samien/.codex/visualizations/2026/08/27/01a040a6-d36a-7fb3-bb37-2f181f6fd5e8/team-avatar-qa-comparison.png`
-- viewport: Web 1035 x 874 CSS px; iPhone 17 simulator 402 x 874 pt
-- pixels and density: source assets 512 x 512 px; Web capture 1035 x 874 px at 1x; iOS capture 1206 x 2622 px at 3x; comparison canvas normalizes both implementations into one 1200 x 1260 px image
-- state: existing legacy team with empty avatar, team list visible, avatar settings visible, light theme
+## Comparison evidence
 
-## Full-view comparison evidence
+- 身份全景并排:`/Users/samien/.codex/visualizations/2026/08/29/01a04b82-a660-7c43-a4d1-5c0a84482b5c/settings-center-implementation/compare-identity.png`
+- 模型密集内容并排:`/Users/samien/.codex/visualizations/2026/08/29/01a04b82-a660-7c43-a4d1-5c0a84482b5c/settings-center-implementation/compare-models.png`
+- 账号表单并排:`/Users/samien/.codex/visualizations/2026/08/29/01a04b82-a660-7c43-a4d1-5c0a84482b5c/settings-center-implementation/compare-security.png`
+- 系统概览并排:`/Users/samien/.codex/visualizations/2026/08/29/01a04b82-a660-7c43-a4d1-5c0a84482b5c/settings-center-implementation/compare-overview.png`
 
-The combined comparison shows the five source animals at full size, the deployed Web team list and settings panel, and the iOS simulator team list and settings picker. Both clients preserve the same silhouette, foreground color, pale background, circular crop, and ordering.
+全景比较用于壳体、分栏、层级和整体密度;模型目录与账号表单比较作为聚焦区域,用于检查 ID、标签、字段、按钮、滚动和固定操作栏。
 
-## Focused region comparison evidence
+## Required fidelity surfaces
 
-A separate crop was not needed: the combined canvas keeps the Web/iOS avatar-setting rows large enough to inspect all five silhouettes and also retains the small list-avatar state. The original implementation screenshots remain available for pixel-level inspection.
+- 字体与排版:沿用现有 `--font-ui` 系统字体;页面标题 `23px`,导航 `14px`,正文与表单主要为 `13–16px`。标题、分组、标签和帮助文本层级与设计一致,没有截断关键 Provider ID。
+- 间距与布局:桌面壳体实测 `1080 × 780`,标题栏 `64px`、左栏 `292px`、编辑页底栏 `76px`。系统概览右侧 `clientHeight = scrollHeight = 604px`;设置左栏 `clientHeight = scrollHeight = 714px`,全部导航在首屏完整可见。长表单仅滚动右内容区。
+- 颜色与视觉 token:复用现有白色/暖灰表面、`--line`、`--surface-soft`、`--accent`、`--success` 和 `--danger`。选中态用浅灰,键盘焦点用紫色描边;成功与危险状态同时有文字,不只依赖颜色。
+- 图像与资产:继续使用真实 `AgentAvatar` 数据和现有品牌/`AppIcon` 体系;设计没有要求新增位图资产,未使用占位图、CSS 绘图或临时 SVG 替代。
+- 文案与内容:保留完整 Provider/model 目录与完整 `custom:tingly` ID;双流语音明确标记“全局”;账号、Agent 和系统作用域均在页面标题下持续显示。
+- 响应式与可访问性:`390 × 844` 下设置中心为全屏导航栈,页面无横向溢出(`scrollWidth = innerWidth = 390`),分类页 `clientHeight = scrollHeight = 788px`,固定底栏为 `390 × 68`。当前分类使用 `aria-current="page"`;控制行、返回/关闭和底栏按钮命中区域达到约 `40–44px`,表单使用永久标签并保留明显焦点态。关闭后焦点实测返回 `aria-label="打开设置中心"` 的原按钮。
 
-## Findings
+## Primary interactions tested
 
-- No actionable P0/P1/P2 findings.
-- Fonts and typography: existing platform typography and hierarchy are unchanged; new labels remain readable at Web and iOS caption sizes.
-- Spacing and layout rhythm: five circular choices fit without clipping or crowding; preview, picker, random action, and upload action remain visually grouped.
-- Colors and visual tokens: exact flat foreground/background colors from the source assets are retained; selection uses each platform's existing accent token.
-- Image quality and asset fidelity: source raster assets remain sharp at 23–58 px Web sizes and 34–58 pt iOS sizes, with clean circular masks and no composite-avatar seam or gray center.
-- Copy and content: both clients use the same animal labels, “随机一个”, and “上传图片”; legacy empty avatars explicitly explain that the old composite was replaced.
+- Agent 切换弹层只显示 Agent 列表,独立设置按钮打开统一中心。
+- 桌面设置导航可进入身份、模型、账号安全、系统概览、用户、Hermes 连接、双流语音和更新页面。
+- 管理员系统组与非管理员隐藏逻辑由组件测试覆盖。
+- 未保存内容切页确认、系统升级锁定切页/关闭由组件测试覆盖。
+- 账号登录与节点配对按作用域拆分,定时器与扫码资源清理由组件测试覆盖。
+- 移动抽屉打开后背景 header/main 均不可聚焦,焦点进入“关闭导航”;进入设置详情后焦点移到页面标题,返回后移到当前分类,关闭中心后回到“打开导航”。
+- 两处 Agent 选择器均使用 `listbox / option` 状态,并支持方向键、Home、End 与 Escape;用户行操作的 accessible name 包含目标用户名。
+- 9119 用户名、密码及双流语音 API Key 在浏览器中保持空白;Provider 完整目录与只读/可编辑状态来自真实本地服务。
+- 浏览器控制台最终无 warning/error。
 
 ## Comparison history
 
-- Initial pass: no P0/P1/P2 mismatch found, so no visual-fix iteration was required.
-
-## Primary interactions tested
+1. 初次比较发现 P2:身份和账号安全的保存操作位于滚动内容中,未匹配固定底栏。
+2. 修复:为两页增加外部表单 ID 和统一固定 `取消 / 保存更改` 底栏;桌面底栏实测 `786 × 76`,移动端底栏实测 `390 × 68`。
+3. 复核:`08-identity-desktop-fixed-footer.png`、`09-security-desktop-fixed-footer.png` 与 `07-mobile-security-fixed-footer.png` 证明操作栏固定,右内容区仍可独立滚动,无横向溢出。
+4. 独立代码审查发现更新任务恢复/启动锁、Agent 草稿刷新、扫码资源竞态、APNs/FCM 草稿互相覆盖等 P1/P2 风险;实现加入生命周期 token、真实 dirty baseline、分离配置快照、并发锁与业务 401 隔离。
+5. 第二次复核确认 Agent 弹层仅剩 Agent 列表,桌面与移动设置导航首屏完整可见,关闭焦点正确恢复;新增组件测试覆盖 Provider/语音丢稿、更新恢复、摄像头停止、账号 401 和推送并发草稿。
+6. 无障碍浏览器复核补齐移动详情/返回焦点、抽屉背景隔离、Agent 复合控件键盘操作及用户行按钮上下文;`390 × 844` 与 `1637 × 874` 实测焦点位置正确,控制台仍无 warning/error。
 
-- Existing empty avatar renders as a deterministic animal in the Web list and settings preview.
-- Web settings expose five selectable animals, random selection, and image upload.
-- iOS team list renders animal avatars; settings expose the same five choices, random selection, and Photos upload.
-- Browser console checked with no new avatar-related errors.
+## Verification
 
-## Implementation checklist
+- `npm run typecheck`:通过。
+- `npm test`:61 个测试文件、317 项测试全部通过。
+- `npm run build`:品牌校验、发布版本校验、客户端与服务端生产构建全部通过。
+- `git diff --check`:通过。
+- 两轮独立最终审查:无剩余 P0、P1 或 P2。
 
-- [x] Five source animals render on Web and iOS.
-- [x] Team avatars remain circular at list and settings sizes.
-- [x] Legacy empty/composite state is replaced deterministically.
-- [x] New teams receive a random animal when no explicit choice is made.
-- [x] Uploaded custom images remain supported.
+## Final result
 
 final result: passed

+ 1 - 1
src/client/api/auth.ts

@@ -72,7 +72,7 @@ export async function changeCredentials(input: {
   username?: string
 }): Promise<CurrentUser> {
   const payload = record(unwrapData(await apiRequest<unknown>('/api/app/account/credentials', {
-    method: 'PUT', body: input as unknown as JsonValue,
+    method: 'PUT', body: input as unknown as JsonValue, notifyUnauthorized: false,
   })))
   return normalizeUser(payload.user ?? payload)
 }

+ 21 - 24
src/client/components/app/AccountSecurityDialog.vue

@@ -1,36 +1,33 @@
 <script setup lang="ts">
-import { ref, watch } from 'vue'
 import AppIcon from '@/components/common/AppIcon.vue'
+import AccountSecurityPanel from '@/components/app/AccountSecurityPanel.vue'
 import { useAuthStore } from '@/stores/auth'
 
 const props = defineProps<{ open: boolean }>()
 const emit = defineEmits<{ close: [] }>()
 const auth = useAuthStore()
-const username = ref('')
-const currentPassword = ref('')
-const newPassword = ref('')
-const confirmation = ref('')
-const busy = ref(false)
-const error = ref('')
 
-async function save() {
-  if (newPassword.value !== confirmation.value) { error.value = '两次输入的新密码不一致'; return }
-  busy.value = true; error.value = ''
-  try {
-    await auth.changeCredentials({
-      currentPassword: currentPassword.value,
-      newPassword: newPassword.value,
-      username: auth.user?.role === 'admin' ? username.value.trim() : undefined,
-    })
-    emit('close')
-  } catch (cause) { error.value = cause instanceof Error ? cause.message : '修改账号失败' }
-  finally { busy.value = false }
+async function logout() {
+  await auth.logout()
+  emit('close')
 }
-watch(() => props.open, open => {
-  if (open) { username.value = auth.user?.username || ''; currentPassword.value = ''; newPassword.value = ''; confirmation.value = ''; error.value = '' }
-})
 </script>
 
-<template><Teleport to="body"><div v-if="open" class="layer" @mousedown.self="emit('close')"><section role="dialog" aria-modal="true" aria-label="账号安全"><header><h2>账号安全</h2><button class="icon-button" aria-label="关闭" @click="emit('close')"><AppIcon name="close" /></button></header><form @submit.prevent="save"><label v-if="auth.user?.role === 'admin'"><span>管理员用户名</span><input v-model="username" autocomplete="username" /></label><label><span>当前密码</span><input v-model="currentPassword" type="password" autocomplete="current-password" /></label><label><span>新密码</span><input v-model="newPassword" type="password" autocomplete="new-password" placeholder="至少 8 个字符" /></label><label><span>确认新密码</span><input v-model="confirmation" type="password" autocomplete="new-password" /></label><p v-if="error">{{ error }}</p><button class="solid-button" :disabled="busy || !currentPassword || newPassword.length < 8 || newPassword !== confirmation">{{ busy ? '正在保存…' : '保存修改' }}</button></form></section></div></Teleport></template>
+<template>
+  <Teleport to="body">
+    <div v-if="open" class="layer" @mousedown.self="emit('close')">
+      <section class="dialog" role="dialog" aria-modal="true" aria-label="账号安全">
+        <button class="icon-button close-button" type="button" aria-label="关闭" @click="emit('close')"><AppIcon name="close" /></button>
+        <AccountSecurityPanel :open="open" @saved="emit('close')" @logout="logout" />
+      </section>
+    </div>
+  </Teleport>
+</template>
 
-<style scoped>.layer{position:fixed;z-index:285;inset:0;display:grid;place-items:center;padding:20px;background:var(--scrim);backdrop-filter:blur(6px)}section{width:min(420px,100%);padding:19px;border:1px solid var(--line);border-radius:18px;background:var(--surface-raised);box-shadow:var(--shadow-float)}header{display:flex;align-items:center;justify-content:space-between}h2{margin:0;font-size:18px}form{display:grid;gap:12px;margin-top:18px}label{display:grid;gap:5px;color:var(--text-secondary);font-size:10px}input{height:40px;padding:0 10px;border:1px solid var(--line);border-radius:9px;background:var(--surface-soft);color:var(--text-primary)}p{margin:0;color:var(--danger);font-size:10px}.solid-button{min-height:41px}@media(max-width:520px){.layer{place-items:end center;padding:0}section{border-radius:18px 18px 0 0;padding-bottom:max(20px,env(safe-area-inset-bottom))}}</style>
+<style scoped>
+.layer{position:fixed;z-index:285;inset:0;display:grid;place-items:center;padding:20px;background:var(--scrim);backdrop-filter:blur(6px)}
+.dialog{position:relative;width:min(680px,100%);max-height:calc(100dvh - 40px);box-sizing:border-box;padding:24px;overflow:auto;border:1px solid var(--line);border-radius:18px;background:var(--surface-raised);box-shadow:var(--shadow-float)}
+.dialog :deep(.panel-heading){padding-right:34px}
+.close-button{position:absolute;z-index:1;top:18px;right:18px}
+@media(max-width:620px){.layer{place-items:end center;padding:0}.dialog{width:100%;max-height:calc(100dvh - 12px);padding:20px 18px max(20px,env(safe-area-inset-bottom));border-radius:18px 18px 0 0}.close-button{top:14px;right:14px}}
+</style>

+ 204 - 0
src/client/components/app/AccountSecurityPanel.vue

@@ -0,0 +1,204 @@
+<script setup lang="ts">
+import { computed, ref, watch } from 'vue'
+import AppIcon from '@/components/common/AppIcon.vue'
+import { useAuthStore } from '@/stores/auth'
+
+const props = withDefaults(defineProps<{
+  active?: boolean
+  open?: boolean
+  formId?: string
+  showActions?: boolean
+}>(), {
+  active: true,
+  open: undefined,
+  formId: undefined,
+  showActions: true,
+})
+
+const emit = defineEmits<{
+  saved: []
+  logout: []
+  'dirty-change': [dirty: boolean]
+  'can-save-change': [canSave: boolean]
+}>()
+
+const auth = useAuthStore()
+const initialUsername = ref('')
+const username = ref('')
+const currentPassword = ref('')
+const newPassword = ref('')
+const confirmation = ref('')
+const busy = ref(false)
+const error = ref('')
+const notice = ref('')
+
+const isAdmin = computed(() => auth.user?.role === 'admin')
+const isActive = computed(() => props.open ?? props.active)
+const passwordsMatch = computed(() => Boolean(confirmation.value) && newPassword.value === confirmation.value)
+const confirmationState = computed<'empty' | 'match' | 'mismatch'>(() => {
+  if (!confirmation.value) return 'empty'
+  return passwordsMatch.value ? 'match' : 'mismatch'
+})
+const dirty = computed(() => (
+  (isAdmin.value && username.value.trim() !== initialUsername.value)
+  || Boolean(currentPassword.value || newPassword.value || confirmation.value)
+))
+const canSave = computed(() => (
+  !busy.value
+  && Boolean(currentPassword.value)
+  && newPassword.value.length >= 8
+  && passwordsMatch.value
+  && (!isAdmin.value || Boolean(username.value.trim()))
+))
+
+function reset() {
+  const currentUsername = auth.user?.username || ''
+  initialUsername.value = currentUsername
+  username.value = currentUsername
+  currentPassword.value = ''
+  newPassword.value = ''
+  confirmation.value = ''
+  error.value = ''
+  notice.value = ''
+  emit('dirty-change', false)
+}
+
+async function save() {
+  const normalizedUsername = username.value.trim()
+  if (isAdmin.value && !normalizedUsername) { error.value = '管理员用户名不能为空'; return }
+  if (!currentPassword.value) { error.value = '请输入当前密码'; return }
+  if (newPassword.value.length < 8) { error.value = '新密码至少需要 8 个字符'; return }
+  if (!passwordsMatch.value) { error.value = '两次输入的新密码不一致'; return }
+
+  busy.value = true
+  error.value = ''
+  notice.value = ''
+  try {
+    await auth.changeCredentials({
+      currentPassword: currentPassword.value,
+      newPassword: newPassword.value,
+      ...(isAdmin.value ? { username: normalizedUsername } : {}),
+    })
+    const nextUsername = isAdmin.value ? normalizedUsername : (auth.user?.username || initialUsername.value)
+    initialUsername.value = nextUsername
+    username.value = nextUsername
+    currentPassword.value = ''
+    newPassword.value = ''
+    confirmation.value = ''
+    notice.value = '账号与密码已更新'
+    emit('dirty-change', false)
+    emit('saved')
+  } catch (cause) {
+    error.value = cause instanceof Error ? cause.message : '修改账号失败'
+  } finally {
+    busy.value = false
+  }
+}
+
+watch(isActive, active => {
+  if (active) reset()
+}, { immediate: true })
+
+watch(dirty, value => emit('dirty-change', value), { flush: 'sync' })
+watch(canSave, value => emit('can-save-change', value), { immediate: true, flush: 'sync' })
+</script>
+
+<template>
+  <section class="account-security-panel" aria-label="登录与安全">
+    <header class="panel-heading">
+      <div>
+        <p class="panel-eyebrow">当前账号</p>
+        <h2>登录与安全</h2>
+        <p class="panel-description">管理此 8800 Web 账号的登录凭据。修改成功后,请使用新凭据登录。</p>
+      </div>
+      <span class="account-badge">{{ auth.user?.username || '当前账号' }}</span>
+    </header>
+
+    <form :id="formId" class="security-form" @submit.prevent="save">
+      <h3 v-if="isAdmin" class="security-section-title">账号信息</h3>
+      <label v-if="isAdmin" class="field">
+        <span>管理员用户名</span>
+        <input v-model="username" name="username" autocomplete="username" :disabled="busy" />
+        <small>仅修改当前 8800 Web 管理账号,不会更改 9119 服务账号。</small>
+      </label>
+
+      <h3 class="security-section-title security-section-title--password">修改密码</h3>
+      <label class="field">
+        <span>当前密码</span>
+        <input v-model="currentPassword" name="current-password" type="password" autocomplete="current-password" :disabled="busy" />
+        <small>用于确认是你本人;密码不会被回显或保存在浏览器中。</small>
+      </label>
+
+      <div class="password-grid">
+        <label class="field">
+          <span>新密码</span>
+          <input v-model="newPassword" name="new-password" type="password" autocomplete="new-password" :disabled="busy" />
+          <small>至少 8 个字符,建议混合使用字母、数字与符号。</small>
+        </label>
+
+        <label class="field">
+          <span>确认新密码</span>
+          <input v-model="confirmation" name="password-confirmation" type="password" autocomplete="new-password" :disabled="busy" />
+          <small
+            class="match-status"
+            :class="`match-status--${confirmationState}`"
+            :role="confirmationState === 'mismatch' ? 'alert' : 'status'"
+            aria-live="polite"
+          >
+            {{ confirmationState === 'match' ? '两次输入一致' : confirmationState === 'mismatch' ? '两次输入不一致' : '再次输入新密码以确认' }}
+          </small>
+        </label>
+      </div>
+
+      <p v-if="error" class="form-message form-message--error" role="alert">{{ error }}</p>
+      <p v-else-if="notice" class="form-message form-message--success" role="status">{{ notice }}</p>
+
+      <footer v-if="showActions" class="form-actions">
+        <span>保存后不会修改 Agent、Provider 或系统服务配置。</span>
+        <button class="solid-button" type="submit" :disabled="!canSave">{{ busy ? '正在保存…' : '保存修改' }}</button>
+      </footer>
+    </form>
+
+    <section class="logout-card" aria-label="退出登录">
+      <div>
+        <strong>退出当前账号</strong>
+        <p>只退出这个浏览器中的当前 Web 会话,不会停止服务,也不会影响其他设备。</p>
+      </div>
+      <button class="logout-button" type="button" :disabled="busy" @click="emit('logout')">
+        <AppIcon name="logout" :size="15" />退出当前账号
+      </button>
+    </section>
+  </section>
+</template>
+
+<style scoped>
+.account-security-panel{display:grid;max-width:620px;gap:28px;color:var(--text-primary)}
+.panel-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:20px;padding-bottom:18px;border-bottom:1px solid var(--line)}
+.panel-eyebrow{margin:0 0 5px;color:var(--text-muted);font-size:12px;font-weight:700;letter-spacing:.08em;text-transform:uppercase}
+.panel-heading h2{margin:0;font-size:22px;letter-spacing:-.035em}
+.panel-description{max-width:520px;margin:8px 0 0;color:var(--text-secondary);font-size:13px;line-height:1.65}
+.account-badge{max-width:180px;padding:6px 10px;overflow:hidden;border:1px solid var(--line);border-radius:999px;background:var(--surface-soft);color:var(--text-muted);font-size:12px;text-overflow:ellipsis;white-space:nowrap}
+.security-form{display:grid;gap:20px}
+.security-section-title{margin:0;font-size:16px}.security-section-title--password{margin-top:8px;padding-top:24px;border-top:1px solid var(--line)}
+.field{display:grid;gap:8px;color:var(--text-secondary);font-size:14px;font-weight:650}
+.field input{width:100%;height:46px;box-sizing:border-box;padding:0 13px;border:1px solid var(--line);border-radius:9px;outline:0;background:var(--surface-raised);color:var(--text-primary);font:14px var(--font-ui);font-weight:400}
+.field input:focus{border-color:var(--line-strong);box-shadow:0 0 0 3px var(--focus-ring)}
+.field input:disabled{cursor:wait;opacity:.6}
+.field small{min-height:18px;color:var(--text-muted);font-size:12px;font-weight:400;line-height:1.5}
+.password-grid{display:grid;grid-template-columns:1fr;gap:20px}
+.match-status--match{color:var(--success,#21845b)!important}
+.match-status--mismatch{color:var(--danger)!important}
+.form-message{margin:0;padding:11px 12px;border-radius:8px;font-size:13px}
+.form-message--error{background:color-mix(in srgb,var(--danger) 8%,transparent);color:var(--danger)}
+.form-message--success{background:color-mix(in srgb,var(--success,#21845b) 9%,transparent);color:var(--success,#21845b)}
+.form-actions{display:flex;align-items:center;justify-content:space-between;gap:16px;padding-top:2px}
+.form-actions>span{color:var(--text-muted);font-size:12px;line-height:1.5}
+.solid-button{display:inline-flex;min-width:120px;min-height:40px;align-items:center;justify-content:center;border:0;border-radius:9px;background:var(--accent);color:var(--text-on-solid);cursor:pointer;font-size:13px;font-weight:650}
+.solid-button:disabled{cursor:not-allowed;opacity:.45}
+.logout-card{display:flex;align-items:center;justify-content:space-between;gap:18px;padding:16px 0;border-top:1px solid var(--line)}
+.logout-card strong{font-size:14px}
+.logout-card p{max-width:470px;margin:5px 0 0;color:var(--text-muted);font-size:12px;line-height:1.55}
+.logout-button{display:inline-flex;min-height:40px;flex:0 0 auto;align-items:center;justify-content:center;gap:7px;padding:0 12px;border:1px solid color-mix(in srgb,var(--danger) 35%,var(--line));border-radius:9px;background:var(--surface-raised);color:var(--danger);cursor:pointer;font-size:13px;font-weight:650}
+.logout-button:disabled{cursor:wait;opacity:.5}
+@media(max-width:620px){.panel-heading{align-items:flex-start;flex-direction:column;gap:10px}.form-actions,.logout-card{align-items:stretch;flex-direction:column}.form-actions .solid-button,.logout-button{width:100%}}
+</style>

Datei-Diff unterdrückt, da er zu groß ist
+ 8 - 68
src/client/components/app/AgentIdentityDialog.vue


+ 133 - 0
src/client/components/app/AgentIdentityPanel.vue

@@ -0,0 +1,133 @@
+<script setup lang="ts">
+import { computed, ref, watch } from 'vue'
+import type { Profile } from '@shared/types'
+import AgentAvatar from '@/components/common/AgentAvatar.vue'
+import AppIcon from '@/components/common/AppIcon.vue'
+
+const props = withDefaults(defineProps<{
+  profile: Profile
+  busy?: boolean
+  error?: string
+  formId?: string
+  showActions?: boolean
+  resetVersion?: number
+}>(), {
+  busy: false,
+  error: '',
+  formId: undefined,
+  showActions: true,
+  resetVersion: 0,
+})
+const emit = defineEmits<{
+  save: [input: { title: string; avatarDataURL?: string | null }]
+  'dirty-change': [dirty: boolean]
+}>()
+
+const title = ref('')
+const avatarDataURL = ref<string | null>(null)
+const avatarTouched = ref(false)
+const localError = ref('')
+const fileInput = ref<HTMLInputElement>()
+const baseline = ref<{ title: string; avatarDataURL: string | null }>({ title: '', avatarDataURL: null })
+let profileGeneration = 0
+
+function resetFromProfile() {
+  profileGeneration += 1
+  const profile = props.profile
+  const nextTitle = profile.agentName || profile.displayName || profile.name || ''
+  const nextAvatarDataURL = profile.agentAvatar || null
+  title.value = nextTitle
+  avatarDataURL.value = nextAvatarDataURL
+  baseline.value = { title: nextTitle, avatarDataURL: nextAvatarDataURL }
+  avatarTouched.value = false
+  localError.value = ''
+}
+
+watch([() => props.profile.name, () => props.resetVersion], resetFromProfile, { immediate: true })
+
+const dirty = computed(() => title.value !== baseline.value.title || avatarDataURL.value !== baseline.value.avatarDataURL)
+watch(dirty, value => emit('dirty-change', value), { immediate: true })
+
+function readImage(file: File): Promise<string> {
+  return new Promise((resolve, reject) => {
+    const reader = new FileReader()
+    reader.onload = () => typeof reader.result === 'string' ? resolve(reader.result) : reject(new Error('无法读取图片'))
+    reader.onerror = () => reject(new Error('无法读取图片'))
+    reader.readAsDataURL(file)
+  })
+}
+
+function resizeImage(source: string): Promise<string> {
+  return new Promise((resolve, reject) => {
+    const image = new Image()
+    image.onload = () => {
+      const edge = Math.min(256, Math.max(image.width, image.height))
+      const ratio = edge / Math.max(image.width, image.height)
+      const canvas = document.createElement('canvas')
+      canvas.width = Math.max(1, Math.round(image.width * ratio))
+      canvas.height = Math.max(1, Math.round(image.height * ratio))
+      const context = canvas.getContext('2d')
+      if (!context) return reject(new Error('当前浏览器不支持图片处理'))
+      context.drawImage(image, 0, 0, canvas.width, canvas.height)
+      resolve(canvas.toDataURL('image/png'))
+    }
+    image.onerror = () => reject(new Error('请选择有效的 PNG、JPEG 或 WebP 图片'))
+    image.src = source
+  })
+}
+
+async function chooseAvatar(event: Event) {
+  const file = (event.target as HTMLInputElement).files?.[0]
+  if (!file) return
+  const generation = profileGeneration
+  try {
+    if (!['image/png', 'image/jpeg', 'image/webp'].includes(file.type)) throw new Error('请选择 PNG、JPEG 或 WebP 图片')
+    if (file.size > 10 * 1024 * 1024) throw new Error('图片不能超过 10 MB')
+    const nextAvatar = await resizeImage(await readImage(file))
+    if (generation !== profileGeneration) return
+    avatarDataURL.value = nextAvatar
+    avatarTouched.value = true
+    localError.value = ''
+  } catch (cause) {
+    localError.value = cause instanceof Error ? cause.message : '处理头像失败'
+  } finally {
+    if (fileInput.value) fileInput.value.value = ''
+  }
+}
+
+function submit() {
+  const normalized = title.value.trim().replace(/\s+/g, ' ')
+  if (!normalized) { localError.value = '请输入 Agent 名称'; return }
+  emit('save', { title: normalized, ...(avatarTouched.value ? { avatarDataURL: avatarDataURL.value } : {}) })
+  title.value = normalized
+}
+</script>
+
+<template>
+  <form :id="formId" class="identity-form" @submit.prevent="submit">
+    <div class="identity-avatar">
+      <AgentAvatar :name="title || profile.name" :avatar="avatarDataURL || ''" :size="104" />
+      <div>
+        <strong>Bots 头像</strong>
+        <small>会压缩为 256 px,并写入原生 Agent 头像;所有设备同步。</small>
+        <p>
+          <button class="quiet-button" type="button" :disabled="busy" @click="fileInput?.click()"><AppIcon name="image" :size="14" />选择图片</button>
+          <button v-if="avatarDataURL" class="quiet-button" type="button" :disabled="busy" @click="avatarDataURL = null; avatarTouched = true">移除</button>
+        </p>
+      </div>
+      <input ref="fileInput" class="sr-only" type="file" accept="image/png,image/jpeg,image/webp" @change="chooseAvatar" />
+    </div>
+    <label>名称<input v-model="title" :disabled="busy" maxlength="100" autocomplete="off" /></label>
+    <p v-if="localError || error" class="identity-error" role="alert">{{ localError || error }}</p>
+    <footer v-if="showActions" class="identity-actions">
+      <slot name="actions">
+        <button class="primary-button" type="submit" :disabled="busy">{{ busy ? '正在同步…' : '保存并同步' }}</button>
+      </slot>
+    </footer>
+  </form>
+</template>
+
+<style scoped>
+.identity-form{display:grid;max-width:720px}.identity-avatar{display:flex;align-items:flex-start;gap:24px;margin-bottom:32px;padding:0;background:transparent}.identity-avatar>div{display:grid;gap:7px;padding-top:4px}.identity-avatar strong{font-size:16px}.identity-avatar small{max-width:480px;color:var(--text-muted);font-size:13px;line-height:1.55}.identity-avatar p{display:flex;gap:8px;margin:6px 0 0}.identity-form label{display:grid;max-width:480px;gap:8px;color:var(--text-secondary);font-size:14px;font-weight:650}.identity-form input:not(.sr-only){width:100%;min-height:46px;box-sizing:border-box;padding:0 13px;border:1px solid var(--line);border-radius:9px;outline:0;background:var(--surface-raised);color:var(--text-primary);font:14px var(--font-ui);font-weight:400}.identity-form input:focus{border-color:var(--line-strong);box-shadow:0 0 0 3px var(--focus-ring)}.quiet-button,.primary-button,.identity-actions :slotted(.quiet-button),.identity-actions :slotted(.primary-button){display:inline-flex;min-height:40px;align-items:center;justify-content:center;gap:7px;padding:0 14px;border:0;border-radius:9px;cursor:pointer;font-size:13px;font-weight:600}.quiet-button,.identity-actions :slotted(.quiet-button){border:1px solid var(--line);background:var(--surface-raised);color:var(--text-secondary)}.primary-button,.identity-actions :slotted(.primary-button){min-width:118px;background:var(--accent);color:var(--text-on-solid)}.quiet-button:disabled,.primary-button:disabled,.identity-actions :slotted(.quiet-button:disabled),.identity-actions :slotted(.primary-button:disabled){cursor:wait;opacity:.5}.identity-error{margin:14px 0 0;color:var(--danger);font-size:13px}.identity-actions{display:flex;justify-content:flex-end;gap:10px;margin-top:30px;padding-top:18px;border-top:1px solid var(--line)}.sr-only{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
+@media(max-width:600px){.identity-avatar{align-items:flex-start;flex-direction:column}.identity-form label{max-width:none}}
+</style>

Datei-Diff unterdrückt, da er zu groß ist
+ 18 - 5
src/client/components/app/DuplexVoicePanel.vue


Datei-Diff unterdrückt, da er zu groß ist
+ 58 - 15
src/client/components/app/ModelServicesPanel.vue


+ 407 - 0
src/client/components/app/NodePairingPanel.vue

@@ -0,0 +1,407 @@
+<script setup lang="ts">
+import { computed, onBeforeUnmount, ref, watch } from 'vue'
+import QRCode from 'qrcode'
+import AppIcon from '@/components/common/AppIcon.vue'
+import { accountPairingStatus, createAccountPairing, type AccountPairingSession } from '@/api/accountPairing'
+import {
+  createPairing,
+  pairChildNode,
+  pairedDevices,
+  pairingStatus,
+  revokePairedDevice,
+  type NodeScope,
+  type PairedDevice,
+  type PairingSession,
+} from '@/api/pairing'
+
+const props = withDefaults(defineProps<{
+  active?: boolean
+  mode: 'account' | 'nodes'
+  insecureTransport?: boolean
+  userName?: string
+  isAdmin?: boolean
+}>(), {
+  active: true,
+  insecureTransport: false,
+  userName: '',
+  isAdmin: false,
+})
+const emit = defineEmits<{ 'dirty-change': [dirty: boolean] }>()
+
+const ALL_SCOPES: NodeScope[] = [
+  'agents.read', 'history.read', 'sessions.execute', 'groups.read', 'groups.execute',
+]
+const pairing = ref<PairingSession>()
+const accountPairing = ref<AccountPairingSession>()
+const qrImage = ref('')
+const accountQrImage = ref('')
+const accountPairingComplete = ref(false)
+const devices = ref<PairedDevice[]>([])
+const nodeID = ref('')
+const busy = ref(false)
+const error = ref('')
+const childCode = ref('')
+const childName = ref('')
+const scanVideo = ref<HTMLVideoElement>()
+const scanning = ref(false)
+const scanStarting = ref(false)
+const now = ref(Date.now())
+let timer: number | undefined
+let accountTimer: number | undefined
+let scanTimer: number | undefined
+let scanStream: MediaStream | undefined
+let lifecycleToken = 0
+let scanToken = 0
+let nodePolling = false
+let accountPolling = false
+
+type BarcodeDetectorLike = { detect(source: ImageBitmapSource): Promise<Array<{ rawValue?: string }>> }
+type BarcodeDetectorConstructor = new (options: { formats: string[] }) => BarcodeDetectorLike
+
+const secondsRemaining = computed(() => pairing.value
+  ? Math.max(0, Math.ceil((pairing.value.expiresAt - now.value) / 1_000))
+  : 0)
+const accountSecondsRemaining = computed(() => accountPairing.value
+  ? Math.max(0, Math.ceil((accountPairing.value.expiresAt - now.value) / 1_000))
+  : 0)
+const dirty = computed(() => props.mode === 'nodes' && Boolean(childName.value.trim() || childCode.value.trim()))
+watch(dirty, value => emit('dirty-change', value), { immediate: true })
+
+function stopPolling() {
+  if (timer !== undefined) window.clearInterval(timer)
+  timer = undefined
+  nodePolling = false
+}
+
+function stopAccountPolling() {
+  if (accountTimer !== undefined) window.clearInterval(accountTimer)
+  accountTimer = undefined
+  accountPolling = false
+}
+
+function stopScanning() {
+  scanToken += 1
+  scanStarting.value = false
+  if (scanTimer !== undefined) window.clearInterval(scanTimer)
+  scanTimer = undefined
+  scanStream?.getTracks().forEach(track => track.stop())
+  scanStream = undefined
+  scanning.value = false
+}
+
+function resetTransientState() {
+  lifecycleToken += 1
+  stopPolling()
+  stopAccountPolling()
+  stopScanning()
+  pairing.value = undefined
+  accountPairing.value = undefined
+  qrImage.value = ''
+  accountQrImage.value = ''
+  accountPairingComplete.value = false
+  childCode.value = ''
+  childName.value = ''
+  error.value = ''
+  busy.value = false
+}
+
+function isCurrent(token: number, mode: 'account' | 'nodes'): boolean {
+  return token === lifecycleToken && props.active && props.mode === mode
+}
+
+function isCurrentScan(token: number, lifecycle: number): boolean {
+  return token === scanToken && isCurrent(lifecycle, 'nodes')
+}
+
+async function pollAccountPairing(token = lifecycleToken) {
+  if (accountPolling || !isCurrent(token, 'account')) return
+  accountPolling = true
+  now.value = Date.now()
+  const current = accountPairing.value
+  if (!current || accountSecondsRemaining.value <= 0) { stopAccountPolling(); return }
+  try {
+    const status = await accountPairingStatus(current.pairingId)
+    if (!isCurrent(token, 'account') || accountPairing.value?.pairingId !== current.pairingId) return
+    if (status.state === 'claimed') {
+      accountPairingComplete.value = true
+      stopAccountPolling()
+    } else if (status.state === 'expired') stopAccountPolling()
+  } catch { /* Keep a still-valid QR code during transient polling failures. */ }
+  finally { accountPolling = false }
+}
+
+async function beginAccountPairing() {
+  if (busy.value || !props.active || props.mode !== 'account') return
+  const token = lifecycleToken
+  busy.value = true
+  error.value = ''
+  accountPairingComplete.value = false
+  stopAccountPolling()
+  try {
+    const created = await createAccountPairing()
+    if (!isCurrent(token, 'account')) return
+    accountPairing.value = created
+    now.value = Date.now()
+    const image = await QRCode.toDataURL(created.qrPayload, {
+      width: 296, margin: 2, errorCorrectionLevel: 'M', color: { dark: '#191918', light: '#ffffff' },
+    })
+    if (!isCurrent(token, 'account')) return
+    accountQrImage.value = image
+    accountTimer = window.setInterval(() => { void pollAccountPairing(token) }, 1_000)
+  } catch (cause) {
+    if (isCurrent(token, 'account')) error.value = cause instanceof Error ? cause.message : '无法创建手机登录二维码'
+  } finally {
+    if (isCurrent(token, 'account')) busy.value = false
+  }
+}
+
+async function startScanning() {
+  if (scanStarting.value || scanning.value || !props.active || props.mode !== 'nodes') return
+  const lifecycle = lifecycleToken
+  const Detector = (window as unknown as { BarcodeDetector?: BarcodeDetectorConstructor }).BarcodeDetector
+  if (!Detector || !navigator.mediaDevices?.getUserMedia) {
+    error.value = '当前浏览器不支持直接扫码,请粘贴配对码。'
+    return
+  }
+  stopScanning()
+  const token = scanToken
+  scanStarting.value = true
+  error.value = ''
+  let ownedStream: MediaStream | undefined
+  const releaseOwnedStream = () => {
+    ownedStream?.getTracks().forEach(track => track.stop())
+    if (scanStream === ownedStream) {
+      scanStream = undefined
+      scanning.value = false
+    }
+  }
+  try {
+    const stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' }, audio: false })
+    ownedStream = stream
+    if (!isCurrentScan(token, lifecycle)) {
+      releaseOwnedStream()
+      return
+    }
+    scanStream = stream
+    scanStarting.value = false
+    scanning.value = true
+    await new Promise(resolve => window.setTimeout(resolve, 0))
+    if (!isCurrentScan(token, lifecycle)) { releaseOwnedStream(); return }
+    if (scanVideo.value) { scanVideo.value.srcObject = stream; await scanVideo.value.play() }
+    if (!isCurrentScan(token, lifecycle)) { releaseOwnedStream(); return }
+    const detector = new Detector({ formats: ['qr_code'] })
+    scanTimer = window.setInterval(() => {
+      if (!isCurrentScan(token, lifecycle)) { releaseOwnedStream(); return }
+      const video = scanVideo.value
+      if (!video || video.readyState < 2) return
+      void detector.detect(video).then(values => {
+        const payload = values.find(value => value.rawValue?.startsWith('yaoyao://pair'))?.rawValue
+        if (payload && isCurrentScan(token, lifecycle)) { childCode.value = payload; stopScanning() }
+      }).catch(() => undefined)
+    }, 350)
+  } catch {
+    if (isCurrentScan(token, lifecycle)) {
+      error.value = '无法使用摄像头,请检查权限或直接粘贴配对码。'
+      stopScanning()
+    } else {
+      releaseOwnedStream()
+    }
+  } finally {
+    if (isCurrentScan(token, lifecycle)) scanStarting.value = false
+  }
+}
+
+async function refreshDevices(token = lifecycleToken) {
+  const response = await pairedDevices()
+  if (!isCurrent(token, 'nodes')) return
+  devices.value = response.devices
+  nodeID.value = response.nodeId
+}
+
+async function poll(token = lifecycleToken) {
+  if (nodePolling || !isCurrent(token, 'nodes')) return
+  nodePolling = true
+  now.value = Date.now()
+  const current = pairing.value
+  if (!current || secondsRemaining.value <= 0) { stopPolling(); return }
+  try {
+    const status = await pairingStatus(current.pairingId)
+    if (!isCurrent(token, 'nodes') || pairing.value?.pairingId !== current.pairingId) return
+    if (status.state === 'claimed') {
+      pairing.value = undefined
+      qrImage.value = ''
+      stopPolling()
+      await refreshDevices(token)
+    } else if (status.state === 'expired') stopPolling()
+  } catch { /* A transient poll failure must not discard a still-valid QR code. */ }
+  finally { nodePolling = false }
+}
+
+async function beginPairing() {
+  if (!props.isAdmin || busy.value || !props.active || props.mode !== 'nodes') return
+  const token = lifecycleToken
+  busy.value = true
+  error.value = ''
+  stopPolling()
+  try {
+    const created = await createPairing(ALL_SCOPES)
+    if (!isCurrent(token, 'nodes')) return
+    pairing.value = created
+    now.value = Date.now()
+    const image = await QRCode.toDataURL(created.qrPayload, {
+      width: 296, margin: 2, errorCorrectionLevel: 'M', color: { dark: '#191918', light: '#ffffff' },
+    })
+    if (!isCurrent(token, 'nodes')) return
+    qrImage.value = image
+    timer = window.setInterval(() => { void poll(token) }, 1_000)
+  } catch (cause) {
+    if (isCurrent(token, 'nodes')) error.value = cause instanceof Error ? cause.message : '无法创建配对二维码'
+  } finally {
+    if (isCurrent(token, 'nodes')) busy.value = false
+  }
+}
+
+async function addChild() {
+  if (!props.isAdmin || busy.value || !props.active || props.mode !== 'nodes') return
+  const token = lifecycleToken
+  busy.value = true
+  error.value = ''
+  try {
+    await pairChildNode(childCode.value.trim(), childName.value.trim() || undefined)
+    if (!isCurrent(token, 'nodes')) return
+    childCode.value = ''
+    childName.value = ''
+  } catch (cause) {
+    if (isCurrent(token, 'nodes')) error.value = cause instanceof Error ? cause.message : '添加子节点失败'
+  } finally {
+    if (isCurrent(token, 'nodes')) busy.value = false
+  }
+}
+
+async function revoke(device: PairedDevice) {
+  if (!props.isAdmin || !window.confirm(`撤销“${device.name}”的 Hermes 访问权限?`)) return
+  if (busy.value || !props.active || props.mode !== 'nodes') return
+  const token = lifecycleToken
+  busy.value = true
+  error.value = ''
+  try {
+    await revokePairedDevice(device.id)
+    if (!isCurrent(token, 'nodes')) return
+    await refreshDevices(token)
+  } catch (cause) {
+    if (isCurrent(token, 'nodes')) error.value = cause instanceof Error ? cause.message : '撤销设备失败'
+  } finally {
+    if (isCurrent(token, 'nodes')) busy.value = false
+  }
+}
+
+watch(() => [props.active, props.mode] as const, ([active, mode]) => {
+  resetTransientState()
+  if (!active) return
+  if (mode === 'account') {
+    void beginAccountPairing()
+    return
+  }
+  if (!props.isAdmin) return
+  const token = lifecycleToken
+  busy.value = true
+  void refreshDevices(token)
+    .catch(cause => { if (isCurrent(token, 'nodes')) error.value = cause instanceof Error ? cause.message : '读取配对设备失败' })
+    .finally(() => { if (isCurrent(token, 'nodes')) busy.value = false })
+}, { immediate: true })
+
+onBeforeUnmount(resetTransientState)
+</script>
+
+<template>
+  <section class="pairing-panel" :aria-label="mode === 'account' ? '手机登录' : '节点与设备'">
+    <p v-if="mode === 'account'" class="pairing-intro">手机扫描登录二维码后,会把这台 8800 保存为普通服务器账号;不会添加成子节点。</p>
+    <p v-else class="pairing-intro">管理当前 8800 的直接子节点与已授权设备。账号登录设备不会在这里显示。</p>
+    <p v-if="mode === 'account' && insecureTransport" class="pairing-warning"><AppIcon name="alert" :size="16" />当前是局域网 HTTP,二维码免密配对仅适用于可信网络。正式使用请配置 HTTPS/WSS 或 Tailscale。</p>
+    <p v-if="error" class="pairing-error" role="alert">{{ error }}</p>
+
+    <template v-if="mode === 'account'">
+      <div class="device-heading"><strong>扫码登录并添加服务器</strong><small>{{ userName }}</small></div>
+      <section v-if="accountPairing" class="qr-card">
+        <img v-if="accountQrImage" :src="accountQrImage" alt="手机扫码登录并添加服务器" />
+        <strong>{{ accountPairingComplete ? '手机已添加此服务器' : '在 iOS 登录页选择扫码' }}</strong>
+        <span v-if="accountPairingComplete">该二维码已经失效</span>
+        <span v-else-if="accountSecondsRemaining">二维码将在 {{ accountSecondsRemaining }} 秒后失效</span>
+        <span v-else>二维码已失效</span>
+        <button v-if="accountPairingComplete || !accountSecondsRemaining" class="quiet-button" type="button" @click="beginAccountPairing">重新生成</button>
+      </section>
+      <p v-else-if="busy" class="device-empty">正在生成登录二维码…</p>
+    </template>
+
+    <template v-else-if="isAdmin">
+      <section class="device-section device-section--first">
+        <div class="device-heading"><strong>授权为 8800 子节点</strong><small>管理员</small></div>
+        <section v-if="pairing" class="qr-card">
+          <img v-if="qrImage" :src="qrImage" alt="添加此 Hermes 节点的二维码" />
+          <strong>仅用于子节点配对</strong>
+          <span v-if="secondsRemaining">二维码将在 {{ secondsRemaining }} 秒后失效</span>
+          <span v-else>二维码已失效</span>
+          <button v-if="!secondsRemaining" class="quiet-button" type="button" @click="beginPairing">重新生成</button>
+        </section>
+        <button v-else class="solid-button create-pairing" type="button" :disabled="busy" @click="beginPairing"><AppIcon name="users" :size="17" />{{ busy ? '正在准备…' : '生成子节点配对二维码' }}</button>
+      </section>
+
+      <section class="device-section">
+        <div class="device-heading"><strong>已授权设备</strong><small v-if="nodeID">节点 {{ nodeID.slice(0, 8) }}</small></div>
+        <p v-if="!busy && !devices.length" class="device-empty">还没有已授权设备。</p>
+        <div v-for="device in devices" :key="device.id" class="device-row">
+          <span><b>{{ device.name }}</b><small>{{ device.scopes.includes('history.read') ? 'Bots、历史与团队' : '受限访问' }}</small></span>
+          <button type="button" :disabled="busy" @click="revoke(device)">撤销</button>
+        </div>
+      </section>
+
+      <section class="device-section child-section">
+        <div class="device-heading"><strong>添加 8800 子节点</strong><small>只管理直接子节点</small></div>
+        <label><span>节点名称(可选)</span><input v-model="childName" :disabled="busy" placeholder="例如:办公室 Mac" /></label>
+        <label><span>子节点配对码</span><textarea v-model="childCode" rows="3" :disabled="busy" placeholder="粘贴子节点生成的 yaoyao://pair…" /></label>
+        <video v-if="scanning" ref="scanVideo" class="scanner-video" muted playsinline />
+        <div class="child-actions">
+          <button class="quiet-button" type="button" :disabled="busy || scanStarting" @click="scanning ? stopScanning() : startScanning()">{{ scanStarting ? '正在打开摄像头…' : scanning ? '停止扫码' : '使用摄像头扫码' }}</button>
+          <button class="solid-button" type="button" :disabled="busy || !childCode.trim()" @click="addChild">验证并添加子节点</button>
+        </div>
+      </section>
+    </template>
+  </section>
+</template>
+
+<style scoped>
+.pairing-panel { display: grid; gap: 18px; }
+.pairing-intro { margin: 0; max-width: 650px; color: var(--text-secondary); font-size: 14px; line-height: 1.65; }
+.pairing-warning,.pairing-error { margin: 0; padding: 12px 14px; border-radius: 10px; font-size: 13px; line-height: 1.55; }
+.pairing-warning { display: flex; gap: 8px; background: color-mix(in srgb, var(--warning, #bd7611) 10%, transparent); color: var(--text-secondary); }
+.pairing-error { background: color-mix(in srgb, var(--danger) 9%, transparent); color: var(--danger); }
+.device-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
+.device-heading strong { font-size: 16px; }
+.device-heading small,.device-empty { color: var(--text-muted); font-size: 13px; }
+.device-empty { margin: 0; }
+.qr-card { display: grid; width: min(360px, 100%); justify-self: center; justify-items: center; gap: 9px; padding: 18px; border: 1px solid var(--line); border-radius: 14px; background: #fff; color: #191918; }
+.qr-card img { width: min(260px, 100%); aspect-ratio: 1; }
+.qr-card strong { font-size: 14px; }
+.qr-card span { color: #686865; font-size: 12px; }
+.device-section { display: grid; gap: 10px; padding-top: 20px; border-top: 1px solid var(--line); }
+.device-section--first { padding-top: 0; border-top: 0; }
+.device-row { display: flex; min-height: 54px; align-items: center; gap: 12px; padding: 8px 2px; border-bottom: 1px solid var(--line); }
+.device-row span { display: flex; min-width: 0; flex: 1; flex-direction: column; gap: 4px; }
+.device-row b { overflow: hidden; font-size: 14px; text-overflow: ellipsis; white-space: nowrap; }
+.device-row small { color: var(--text-muted); font-size: 12px; }
+.device-row button { border: 0; background: transparent; color: var(--danger); font-size: 13px; cursor: pointer; }
+.child-section label { display: grid; gap: 6px; color: var(--text-secondary); font-size: 13px; font-weight: 600; }
+.child-section input,.child-section textarea { padding: 10px 12px; border: 1px solid var(--line); border-radius: 9px; outline: 0; background: var(--surface-soft); color: var(--text-primary); font: inherit; resize: vertical; }
+.child-section input:focus,.child-section textarea:focus { border-color: var(--line-strong); box-shadow: 0 0 0 3px var(--focus-ring); }
+.scanner-video { width: 100%; max-height: 260px; border-radius: 11px; background: #111; object-fit: cover; }
+.child-actions { display: flex; justify-content: flex-end; gap: 10px; }
+.quiet-button,.solid-button { display: inline-flex; min-height: 40px; align-items: center; justify-content: center; gap: 7px; padding: 0 14px; border: 0; border-radius: 9px; cursor: pointer; font: 600 13px var(--font-ui); }
+.quiet-button { border: 1px solid var(--line); background: var(--surface-raised); color: var(--text-secondary); }
+.solid-button { background: var(--accent); color: var(--text-on-solid); }
+.create-pairing { width: 100%; min-height: 44px; }
+button:disabled { cursor: not-allowed; opacity: .5; }
+@media (max-width: 700px) {
+  .qr-card { width: 100%; box-sizing: border-box; }
+  .child-actions { align-items: stretch; flex-direction: column; }
+}
+</style>

+ 457 - 0
src/client/components/app/SettingsCenterDialog.vue

@@ -0,0 +1,457 @@
+<script setup lang="ts">
+import { computed, nextTick, reactive, ref, watch } from 'vue'
+import type { Profile } from '@shared/types'
+import AccountSecurityPanel from '@/components/app/AccountSecurityPanel.vue'
+import AgentIdentityPanel from '@/components/app/AgentIdentityPanel.vue'
+import DuplexVoicePanel from '@/components/app/DuplexVoicePanel.vue'
+import ModelServicesPanel from '@/components/app/ModelServicesPanel.vue'
+import NodePairingPanel from '@/components/app/NodePairingPanel.vue'
+import SystemManagementPanel from '@/components/app/SystemManagementPanel.vue'
+import SystemOverviewPanel from '@/components/app/SystemOverviewPanel.vue'
+import SystemUpdatePanel from '@/components/app/SystemUpdatePanel.vue'
+import AgentAvatar from '@/components/common/AgentAvatar.vue'
+import AppIcon from '@/components/common/AppIcon.vue'
+
+type SettingsPage =
+  | 'agent-identity'
+  | 'agent-models'
+  | 'account-security'
+  | 'account-mobile'
+  | 'appearance'
+  | 'system-overview'
+  | 'system-users'
+  | 'system-connection'
+  | 'system-push'
+  | 'system-nodes'
+  | 'system-voice'
+  | 'system-update'
+
+type SettingsIcon = 'users' | 'model' | 'settings' | 'panel' | 'sun' | 'link' | 'bell' | 'audio' | 'refresh'
+type ThemePreference = 'light' | 'dark' | 'system'
+
+interface NavigationItem {
+  key: SettingsPage
+  label: string
+  icon: SettingsIcon
+}
+
+const props = withDefaults(defineProps<{
+  open: boolean
+  initialPage?: SettingsPage
+  userName?: string
+  pairingUserName?: string
+  activeProfile?: Profile
+  profiles?: Profile[]
+  theme?: 'light' | 'dark'
+  themePreference?: ThemePreference
+  insecureTransport?: boolean
+  isAdmin?: boolean
+  upstreamReady?: boolean
+  upstreamError?: string
+  identityBusy?: boolean
+  identityError?: string
+  identityResetVersion?: number
+}>(), {
+  initialPage: 'agent-identity',
+  userName: '',
+  pairingUserName: '',
+  activeProfile: undefined,
+  profiles: () => [],
+  theme: 'light',
+  themePreference: 'system',
+  insecureTransport: false,
+  isAdmin: false,
+  upstreamReady: false,
+  upstreamError: '',
+  identityBusy: false,
+  identityError: '',
+  identityResetVersion: 0,
+})
+
+const emit = defineEmits<{
+  close: []
+  logout: []
+  'select-profile': [profile: string]
+  'save-identity': [input: { title: string; avatarDataURL?: string | null }]
+  'set-theme': [theme: ThemePreference]
+}>()
+
+const dialog = ref<HTMLElement>()
+const contentTitle = ref<HTMLElement>()
+const settingsAgentTrigger = ref<HTMLButtonElement>()
+const activePage = ref<SettingsPage>('agent-identity')
+const profileMenuOpen = ref(false)
+const mobileDetailOpen = ref(false)
+const updateLocked = ref(false)
+const accountCanSave = ref(false)
+const dirtyPages = reactive<Partial<Record<SettingsPage, boolean>>>({})
+
+const agentItems = computed<NavigationItem[]>(() => [
+  { key: 'agent-identity', label: '身份与头像', icon: 'users' },
+  ...(props.isAdmin ? [{ key: 'agent-models', label: '模型与 Provider', icon: 'model' } satisfies NavigationItem] : []),
+])
+const accountItems: NavigationItem[] = [
+  { key: 'account-security', label: '登录与安全', icon: 'settings' },
+  { key: 'account-mobile', label: '手机登录', icon: 'panel' },
+  { key: 'appearance', label: '外观', icon: 'sun' },
+]
+const systemItems: NavigationItem[] = [
+  { key: 'system-overview', label: '系统概览', icon: 'panel' },
+  { key: 'system-users', label: '用户与权限', icon: 'users' },
+  { key: 'system-connection', label: 'Hermes 连接', icon: 'link' },
+  { key: 'system-push', label: '消息推送', icon: 'bell' },
+  { key: 'system-nodes', label: '节点与设备', icon: 'panel' },
+  { key: 'system-voice', label: '双流语音', icon: 'audio' },
+  { key: 'system-update', label: '更新与回滚', icon: 'refresh' },
+]
+
+const allAllowedPages = computed(() => new Set<SettingsPage>([
+  ...agentItems.value.map(item => item.key),
+  ...accountItems.map(item => item.key),
+  ...(props.isAdmin ? systemItems.map(item => item.key) : []),
+]))
+const activeDirty = computed(() => Boolean(dirtyPages[activePage.value]))
+const showFixedFooter = computed(() => activePage.value === 'agent-identity' || activePage.value === 'account-security')
+const activeTitle = computed(() => ({
+  'agent-identity': '身份与头像',
+  'agent-models': '模型与 Provider',
+  'account-security': '登录与安全',
+  'account-mobile': '手机登录',
+  appearance: '外观',
+  'system-overview': '系统概览',
+  'system-users': '用户与权限',
+  'system-connection': 'Hermes 连接',
+  'system-push': '消息推送',
+  'system-nodes': '节点与设备',
+  'system-voice': '双流语音',
+  'system-update': '更新与回滚',
+})[activePage.value])
+const accountName = computed(() => props.pairingUserName || props.userName || '当前账号')
+const activeScope = computed(() => {
+  if (activePage.value.startsWith('agent-')) return `正在设置:${profileTitle(props.activeProfile)} / ${props.activeProfile?.name || '未选择'}`
+  if (activePage.value.startsWith('account-')) return `当前账号:${accountName.value}${props.isAdmin ? ' · 管理员' : ''}`
+  if (activePage.value === 'appearance') return '仅影响当前浏览器'
+  return '全局设置 · 仅管理员'
+})
+
+function profileTitle(profile?: Profile): string {
+  return profile?.agentName || profile?.displayName || profile?.name || '未选择 Agent'
+}
+
+function setDirty(page: SettingsPage, dirty: boolean) {
+  dirtyPages[page] = dirty
+}
+
+function confirmDiscard(): boolean {
+  return !activeDirty.value || window.confirm('放弃当前页面未保存的更改?')
+}
+
+function selectPage(page: SettingsPage) {
+  if (page === activePage.value) {
+    mobileDetailOpen.value = true
+    void nextTick(() => {
+      if (window.innerWidth < 768) contentTitle.value?.focus()
+    })
+    return
+  }
+  if (updateLocked.value || !allAllowedPages.value.has(page) || !confirmDiscard()) return
+  dirtyPages[activePage.value] = false
+  activePage.value = page
+  accountCanSave.value = false
+  mobileDetailOpen.value = true
+  profileMenuOpen.value = false
+  void nextTick(() => {
+    if (window.innerWidth < 768) contentTitle.value?.focus()
+  })
+}
+
+function requestClose() {
+  if (updateLocked.value || !confirmDiscard()) return
+  dirtyPages[activePage.value] = false
+  emit('close')
+}
+
+function backToMenu() {
+  if (updateLocked.value || !confirmDiscard()) return
+  dirtyPages[activePage.value] = false
+  mobileDetailOpen.value = false
+  void nextTick(() => {
+    dialog.value?.querySelector<HTMLButtonElement>('.settings-sidebar nav button[aria-current="page"]')?.focus()
+  })
+}
+
+function selectProfile(profile: string) {
+  if (updateLocked.value || !confirmDiscard()) return
+  dirtyPages[activePage.value] = false
+  profileMenuOpen.value = false
+  emit('select-profile', profile)
+  void nextTick(() => settingsAgentTrigger.value?.focus())
+}
+
+function toggleAgentMenu() {
+  profileMenuOpen.value = !profileMenuOpen.value
+  if (!profileMenuOpen.value) return
+  void nextTick(() => {
+    dialog.value?.querySelector<HTMLButtonElement>('.settings-agent-menu [role="option"][aria-selected="true"]')?.focus()
+  })
+}
+
+function handleAgentMenuKeydown(event: KeyboardEvent) {
+  const options = [...(event.currentTarget as HTMLElement).querySelectorAll<HTMLButtonElement>('[role="option"]')]
+  if (!options.length) return
+  const current = Math.max(0, options.indexOf(document.activeElement as HTMLButtonElement))
+  let next: number | undefined
+  if (event.key === 'ArrowDown') next = (current + 1) % options.length
+  else if (event.key === 'ArrowUp') next = (current - 1 + options.length) % options.length
+  else if (event.key === 'Home') next = 0
+  else if (event.key === 'End') next = options.length - 1
+  else if (event.key === 'Escape') {
+    event.preventDefault()
+    profileMenuOpen.value = false
+    void nextTick(() => settingsAgentTrigger.value?.focus())
+    return
+  }
+  if (next === undefined) return
+  event.preventDefault()
+  options[next]?.focus()
+}
+
+function handleEscape() {
+  if (profileMenuOpen.value) {
+    profileMenuOpen.value = false
+    void nextTick(() => settingsAgentTrigger.value?.focus())
+    return
+  }
+  if (window.innerWidth < 768 && mobileDetailOpen.value) { backToMenu(); return }
+  requestClose()
+}
+
+watch(() => [props.open, props.initialPage] as const, ([open, initialPage]) => {
+  if (!open) {
+    profileMenuOpen.value = false
+    updateLocked.value = false
+    return
+  }
+  activePage.value = allAllowedPages.value.has(initialPage)
+    ? initialPage
+    : props.activeProfile ? 'agent-identity' : 'account-security'
+  for (const key of Object.keys(dirtyPages) as SettingsPage[]) dirtyPages[key] = false
+  profileMenuOpen.value = false
+  mobileDetailOpen.value = false
+  void nextTick(() => dialog.value?.focus())
+}, { immediate: true })
+</script>
+
+<template>
+  <Teleport to="body">
+    <Transition name="settings-center-fade">
+      <div v-if="open" class="settings-center-layer" role="presentation" @mousedown.self="requestClose">
+        <section
+          ref="dialog"
+          class="settings-center"
+          :class="{ 'settings-center--mobile-detail': mobileDetailOpen }"
+          role="dialog"
+          aria-modal="true"
+          aria-labelledby="settings-center-title"
+          tabindex="-1"
+          @keydown.esc.capture.prevent.stop="handleEscape"
+        >
+          <header class="settings-center__header">
+            <button v-if="mobileDetailOpen" class="mobile-back" type="button" aria-label="返回设置分类" :disabled="updateLocked" @click="backToMenu"><AppIcon name="chevron-left" :size="20" /></button>
+            <h2 id="settings-center-title">设置中心</h2>
+            <button class="settings-center__close" type="button" aria-label="关闭设置中心" :disabled="updateLocked" @click="requestClose"><AppIcon name="close" :size="20" /></button>
+          </header>
+
+          <div class="settings-center__body">
+            <aside class="settings-sidebar" aria-label="设置分类">
+              <div class="settings-agent-selector">
+                <button ref="settingsAgentTrigger" type="button" aria-haspopup="listbox" :aria-expanded="profileMenuOpen" @click="toggleAgentMenu">
+                  <AgentAvatar :name="profileTitle(activeProfile)" :avatar="activeProfile?.agentAvatar || ''" :size="46" />
+                  <span><strong>{{ profileTitle(activeProfile) }}</strong><small>{{ activeProfile?.name || '未选择 Agent' }}</small></span>
+                  <AppIcon name="chevron-down" :size="16" />
+                </button>
+                <div v-if="profileMenuOpen" class="settings-agent-menu" role="listbox" aria-label="切换正在设置的 Agent" @keydown="handleAgentMenuKeydown">
+                  <button v-for="profile in profiles" :key="profile.name" type="button" role="option" :aria-selected="profile.name === activeProfile?.name" @click="selectProfile(profile.name)">
+                    <AgentAvatar :name="profileTitle(profile)" :avatar="profile.agentAvatar || ''" :size="28" />
+                    <span><strong>{{ profileTitle(profile) }}</strong><small>{{ profile.name }}</small></span>
+                    <AppIcon v-if="profile.name === activeProfile?.name" name="check" :size="16" />
+                  </button>
+                </div>
+              </div>
+
+              <nav>
+                <section>
+                  <h3>当前 Agent</h3>
+                  <button v-for="item in agentItems" :key="item.key" type="button" :class="{ active: activePage === item.key }" :aria-current="activePage === item.key ? 'page' : undefined" @click="selectPage(item.key)"><AppIcon :name="item.icon" :size="20" /><span>{{ item.label }}</span></button>
+                </section>
+                <section>
+                  <h3>账号</h3>
+                  <button v-for="item in accountItems" :key="item.key" type="button" :class="{ active: activePage === item.key }" :aria-current="activePage === item.key ? 'page' : undefined" @click="selectPage(item.key)"><AppIcon :name="item.icon" :size="20" /><span>{{ item.label }}</span></button>
+                </section>
+                <section v-if="isAdmin">
+                  <h3>系统 · 仅管理员</h3>
+                  <button v-for="item in systemItems" :key="item.key" type="button" :class="{ active: activePage === item.key }" :aria-current="activePage === item.key ? 'page' : undefined" @click="selectPage(item.key)"><AppIcon :name="item.icon" :size="20" /><span>{{ item.label }}</span><em v-if="item.key === 'system-voice'">全局</em></button>
+                </section>
+              </nav>
+            </aside>
+
+            <main class="settings-content" :class="{ 'settings-content--with-footer': showFixedFooter }">
+              <header class="settings-content__header">
+                <h3 ref="contentTitle" tabindex="-1">{{ activeTitle }}</h3>
+                <p>{{ activeScope }}</p>
+              </header>
+              <div class="settings-content__scroll">
+                <AgentIdentityPanel
+                  v-if="activePage === 'agent-identity' && activeProfile"
+                  :key="activeProfile.name"
+                  :profile="activeProfile"
+                  :busy="identityBusy"
+                  :error="identityError"
+                  :reset-version="identityResetVersion"
+                  form-id="settings-agent-identity-form"
+                  :show-actions="false"
+                  @dirty-change="setDirty('agent-identity', $event)"
+                  @save="emit('save-identity', $event)"
+                />
+                <p v-else-if="activePage === 'agent-identity'" class="settings-empty">尚未选择 Agent。</p>
+                <ModelServicesPanel v-else-if="activePage === 'agent-models' && activeProfile && isAdmin" :key="activeProfile.name" :profile="activeProfile.name" @dirty-change="setDirty('agent-models', $event)" />
+                <AccountSecurityPanel
+                  v-else-if="activePage === 'account-security'"
+                  :active="true"
+                  form-id="settings-account-security-form"
+                  :show-actions="false"
+                  @dirty-change="setDirty('account-security', $event)"
+                  @can-save-change="accountCanSave = $event"
+                  @saved="setDirty('account-security', false)"
+                  @logout="emit('logout')"
+                />
+                <NodePairingPanel v-else-if="activePage === 'account-mobile'" mode="account" :active="true" :insecure-transport="insecureTransport" :user-name="accountName" />
+                <section v-else-if="activePage === 'appearance'" class="appearance-panel" aria-label="外观">
+                  <p>选择当前浏览器使用的界面主题。</p>
+                  <div class="theme-options" role="radiogroup" aria-label="界面主题">
+                    <button v-for="option in ([['system', '跟随系统', '自动匹配设备外观'], ['light', '浅色', '始终使用浅色界面'], ['dark', '深色', '始终使用深色界面']] as const)" :key="option[0]" type="button" role="radio" :aria-checked="themePreference === option[0]" :class="{ active: themePreference === option[0] }" @click="emit('set-theme', option[0])">
+                      <AppIcon :name="option[0] === 'dark' ? 'moon' : 'sun'" :size="22" />
+                      <span><strong>{{ option[1] }}</strong><small>{{ option[2] }}</small></span>
+                      <AppIcon v-if="themePreference === option[0]" name="check" :size="18" />
+                    </button>
+                  </div>
+                </section>
+                <SystemOverviewPanel v-else-if="activePage === 'system-overview' && isAdmin" :active="true" :upstream-ready="upstreamReady" :upstream-error="upstreamError" @navigate="selectPage" />
+                <SystemManagementPanel v-else-if="activePage === 'system-users' && isAdmin" section="users" :active="true" @dirty-change="setDirty('system-users', $event)" />
+                <SystemManagementPanel v-else-if="activePage === 'system-connection' && isAdmin" section="connection" :active="true" :upstream-ready="upstreamReady" :upstream-error="upstreamError" @dirty-change="setDirty('system-connection', $event)" />
+                <SystemManagementPanel v-else-if="activePage === 'system-push' && isAdmin" section="push" :active="true" @dirty-change="setDirty('system-push', $event)" />
+                <NodePairingPanel v-else-if="activePage === 'system-nodes' && isAdmin" mode="nodes" :active="true" :is-admin="true" @dirty-change="setDirty('system-nodes', $event)" />
+                <DuplexVoicePanel v-else-if="activePage === 'system-voice' && isAdmin" @dirty-change="setDirty('system-voice', $event)" />
+                <SystemUpdatePanel v-else-if="activePage === 'system-update' && isAdmin" :active="true" @lock-change="updateLocked = $event" />
+              </div>
+              <footer v-if="showFixedFooter" class="settings-content__footer">
+                <button class="settings-footer__cancel" type="button" :disabled="identityBusy" @click="requestClose">取消</button>
+                <button
+                  v-if="activePage === 'agent-identity'"
+                  class="settings-footer__save"
+                  type="submit"
+                  form="settings-agent-identity-form"
+                  :disabled="identityBusy || !activeDirty"
+                >{{ identityBusy ? '正在同步…' : '保存更改' }}</button>
+                <button
+                  v-else
+                  class="settings-footer__save"
+                  type="submit"
+                  form="settings-account-security-form"
+                  :disabled="!accountCanSave"
+                >保存更改</button>
+              </footer>
+            </main>
+          </div>
+        </section>
+      </div>
+    </Transition>
+  </Teleport>
+</template>
+
+<style scoped>
+.settings-center-layer { position: fixed; z-index: 300; inset: 0; display: grid; place-items: center; padding: 32px; background: color-mix(in srgb, #000 24%, transparent); backdrop-filter: blur(4px); }
+.settings-center { display: grid; width: min(1080px, calc(100vw - 64px)); height: min(780px, calc(100dvh - 64px)); overflow: hidden; grid-template-rows: 64px minmax(0, 1fr); border: 1px solid var(--line); border-radius: 16px; outline: 0; background: var(--surface-raised); box-shadow: 0 20px 60px rgba(0,0,0,.18); color: var(--text-primary); }
+.settings-center__header { display: flex; align-items: center; justify-content: space-between; padding: 0 20px 0 24px; border-bottom: 1px solid var(--line); }
+.settings-center__header h2 { margin: 0; font-size: 20px; letter-spacing: -.025em; }
+.settings-center__close,.mobile-back { display: grid; width: 44px; height: 44px; place-items: center; padding: 0; border: 0; border-radius: 10px; background: transparent; color: var(--text-primary); cursor: pointer; }
+.settings-center__close:hover,.mobile-back:hover { background: var(--surface-soft); }
+.settings-center__close:disabled,.mobile-back:disabled { cursor: not-allowed; opacity: .45; }
+.mobile-back { display: none; }
+.settings-center__body { display: grid; min-height: 0; grid-template-columns: 292px minmax(0, 1fr); }
+.settings-sidebar { position: relative; min-height: 0; overflow-y: auto; padding: 14px 24px 16px; border-right: 1px solid var(--line); background: color-mix(in srgb, var(--surface-soft) 42%, var(--surface-raised)); }
+.settings-agent-selector { position: sticky; z-index: 3; top: -14px; margin: -14px 0 10px; padding: 14px 0 10px; border-bottom: 1px solid var(--line); background: color-mix(in srgb, var(--surface-soft) 42%, var(--surface-raised)); }
+.settings-agent-selector > button { display: grid; width: 100%; min-height: 52px; grid-template-columns: 46px minmax(0, 1fr) 20px; align-items: center; gap: 12px; padding: 3px 6px; border: 0; border-radius: 10px; background: transparent; color: var(--text-primary); cursor: pointer; text-align: left; }
+.settings-agent-selector > button:hover { background: var(--surface-hover); }
+.settings-agent-selector span,.settings-agent-menu span { display: grid; min-width: 0; gap: 3px; }
+.settings-agent-selector strong { overflow: hidden; font-size: 17px; text-overflow: ellipsis; white-space: nowrap; }
+.settings-agent-selector small,.settings-agent-menu small { overflow: hidden; color: var(--text-muted); font-size: 13px; text-overflow: ellipsis; white-space: nowrap; }
+.settings-agent-menu { position: absolute; z-index: 5; top: calc(100% - 8px); right: 0; left: 0; padding: 6px; border: 1px solid var(--line); border-radius: 12px; background: var(--surface-raised); box-shadow: var(--shadow-float); }
+.settings-agent-menu button { display: grid; width: 100%; min-height: 46px; grid-template-columns: 28px minmax(0, 1fr) 18px; align-items: center; gap: 10px; padding: 6px 8px; border: 0; border-radius: 8px; background: transparent; color: var(--text-primary); cursor: pointer; text-align: left; }
+.settings-agent-menu button:hover,.settings-agent-menu button[aria-selected="true"] { background: var(--surface-hover); }
+.settings-agent-menu strong { overflow: hidden; font-size: 13px; text-overflow: ellipsis; white-space: nowrap; }
+.settings-sidebar nav { display: grid; gap: 6px; }
+.settings-sidebar nav section { display: grid; gap: 1px; padding-bottom: 7px; border-bottom: 1px solid var(--line); }
+.settings-sidebar nav section:last-child { padding-bottom: 0; border-bottom: 0; }
+.settings-sidebar h3 { margin: 0 6px 3px; color: var(--text-muted); font-size: 12px; font-weight: 560; }
+.settings-sidebar nav button { display: flex; width: 100%; min-height: 40px; align-items: center; gap: 14px; padding: 0 10px; border: 0; border-radius: 9px; background: transparent; color: var(--text-primary); cursor: pointer; text-align: left; font: 600 14px var(--font-ui); }
+.settings-sidebar nav button:hover { background: var(--surface-hover); }
+.settings-sidebar nav button.active { background: var(--surface-soft); }
+.settings-sidebar nav button:focus-visible { outline: 0; box-shadow: inset 0 0 0 2px color-mix(in srgb, #7c4dff 72%, var(--line-strong)); }
+.settings-sidebar nav button span { min-width: 0; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.settings-sidebar nav button em { padding: 2px 6px; border: 1px solid var(--line); border-radius: 999px; color: var(--text-muted); font: normal 10px var(--font-ui); }
+.settings-content { display: grid; min-width: 0; min-height: 0; grid-template-rows: auto minmax(0, 1fr); background: var(--surface-raised); }
+.settings-content--with-footer { grid-template-rows: auto minmax(0, 1fr) 76px; }
+.settings-content__header { padding: 26px 40px 16px; border-bottom: 1px solid var(--line); }
+.settings-content__header h3 { margin: 0; font-size: 23px; letter-spacing: -.035em; }
+.settings-content__header p { margin: 8px 0 0; color: var(--text-secondary); font-size: 14px; }
+.settings-content__scroll { min-height: 0; overflow-y: auto; padding: 22px 40px 24px; overscroll-behavior: contain; }
+.settings-content__footer { display: flex; align-items: center; justify-content: flex-end; gap: 12px; padding: 0 24px; border-top: 1px solid var(--line); background: var(--surface-raised); }
+.settings-content__footer button { display: inline-flex; min-width: 104px; min-height: 40px; align-items: center; justify-content: center; padding: 0 16px; border-radius: 9px; cursor: pointer; font: 650 13px var(--font-ui); }
+.settings-footer__cancel { border: 1px solid var(--line); background: var(--surface-raised); color: var(--text-primary); }
+.settings-footer__save { border: 0; background: var(--accent); color: var(--text-on-solid); }
+.settings-content__footer button:disabled { cursor: not-allowed; opacity: .45; }
+.settings-empty { margin: 0; padding: 24px; border-radius: 12px; background: var(--surface-soft); color: var(--text-muted); font-size: 14px; text-align: center; }
+.appearance-panel { display: grid; gap: 18px; }
+.appearance-panel > p { margin: 0; color: var(--text-secondary); font-size: 14px; }
+.theme-options { display: grid; border-top: 1px solid var(--line); }
+.theme-options button { display: grid; min-height: 72px; grid-template-columns: 28px minmax(0, 1fr) 20px; align-items: center; gap: 14px; padding: 10px 8px; border: 0; border-bottom: 1px solid var(--line); border-radius: 9px; background: transparent; color: var(--text-primary); cursor: pointer; text-align: left; }
+.theme-options button:hover,.theme-options button.active { background: var(--surface-soft); }
+.theme-options button:focus-visible { outline: 0; box-shadow: inset 0 0 0 2px color-mix(in srgb, #7c4dff 72%, var(--line-strong)); }
+.theme-options span { display: grid; gap: 5px; }
+.theme-options strong { font-size: 15px; }
+.theme-options small { color: var(--text-muted); font-size: 13px; }
+.settings-content__scroll :deep(.account-security-panel > .panel-heading) { display: none; }
+.settings-center-fade-enter-active,.settings-center-fade-leave-active { transition: opacity 150ms ease; }
+.settings-center-fade-enter-active .settings-center,.settings-center-fade-leave-active .settings-center { transition: transform 180ms var(--ease-out); }
+.settings-center-fade-enter-from,.settings-center-fade-leave-to { opacity: 0; }
+.settings-center-fade-enter-from .settings-center,.settings-center-fade-leave-to .settings-center { transform: translateY(8px) scale(.99); }
+@media (max-width: 1023px) {
+  .settings-center-layer { padding: 16px; }
+  .settings-center { width: calc(100vw - 32px); height: calc(100dvh - 32px); }
+  .settings-center__body { grid-template-columns: 260px minmax(0, 1fr); }
+  .settings-sidebar { padding-inline: 18px; }
+  .settings-content__header { padding-inline: 30px; }
+  .settings-content__scroll { padding-inline: 30px; }
+}
+@media (max-width: 767px) {
+  .settings-center-layer { padding: 0; }
+  .settings-center { width: 100vw; height: 100dvh; grid-template-rows: 56px minmax(0, 1fr); border: 0; border-radius: 0; }
+  .settings-center__header { padding: 0 8px 0 16px; }
+  .settings-center__header h2 { font-size: 18px; }
+  .settings-center--mobile-detail .settings-center__header { display: grid; grid-template-columns: 44px minmax(0, 1fr) 44px; gap: 4px; padding-left: 6px; }
+  .settings-center--mobile-detail .mobile-back { display: grid; }
+  .settings-center__body { display: block; }
+  .settings-sidebar { width: 100%; height: 100%; box-sizing: border-box; border-right: 0; padding: 18px 20px max(24px, env(safe-area-inset-bottom)); }
+  .settings-center--mobile-detail .settings-sidebar { display: none; }
+  .settings-content { display: none; width: 100%; height: 100%; }
+  .settings-center--mobile-detail .settings-content { display: grid; }
+  .settings-content__header { padding: 22px 20px 17px; }
+  .settings-content__header h3 { font-size: 21px; }
+  .settings-content__scroll { padding: 22px 20px max(28px, env(safe-area-inset-bottom)); }
+  .settings-content--with-footer { grid-template-rows: auto minmax(0, 1fr) calc(68px + env(safe-area-inset-bottom)); }
+  .settings-content__footer { padding: 0 20px env(safe-area-inset-bottom); }
+  .settings-content__footer button { flex: 1; }
+  .settings-agent-selector { top: -18px; margin-top: -18px; padding-top: 18px; }
+}
+</style>

Datei-Diff unterdrückt, da er zu groß ist
+ 63 - 243
src/client/components/app/SystemManagementDialog.vue


+ 466 - 0
src/client/components/app/SystemManagementPanel.vue

@@ -0,0 +1,466 @@
+<script setup lang="ts">
+import { computed, ref, watch } from 'vue'
+import AppIcon from '@/components/common/AppIcon.vue'
+import { createUser, deleteUser, listUsers, setUpstreamCredentials, updateUser, type ManagedUser } from '@/api/admin'
+import {
+  getPushSystemStatus,
+  saveFCMPushSystemConfig,
+  savePushSystemConfig,
+  type APNsEnvironment,
+  type PushSystemStatus,
+} from '@/api/push'
+
+type SystemManagementSection = 'users' | 'connection' | 'push'
+
+const props = withDefaults(defineProps<{
+  section: SystemManagementSection
+  active?: boolean
+  upstreamReady?: boolean
+  upstreamError?: string
+}>(), {
+  active: true,
+  upstreamReady: false,
+  upstreamError: '',
+})
+
+const emit = defineEmits<{ 'dirty-change': [dirty: boolean] }>()
+
+const users = ref<ManagedUser[]>([])
+const username = ref('')
+const password = ref('')
+const upstreamUsername = ref('')
+const upstreamPassword = ref('')
+const connectionBaseline = ref({ username: '', password: '' })
+const busy = ref(false)
+const error = ref('')
+const pushStatus = ref<PushSystemStatus>()
+const pushStatusLoadError = ref('')
+const pushBusy = ref(false)
+const pushError = ref('')
+const pushNotice = ref('')
+const pushKeyFile = ref('')
+const pushKeyId = ref('')
+const pushTeamId = ref('')
+const pushTopic = ref('cn.samien.yaoyao.hermes')
+const pushEnvironments = ref<APNsEnvironment[]>(['development', 'production'])
+const fcmServiceAccountFile = ref('')
+const fcmProjectId = ref('')
+const fcmPackageName = ref('cn.samien.yaoyao.hermes')
+const fcmBusy = ref(false)
+const fcmError = ref('')
+const fcmNotice = ref('')
+
+interface APNsFormSnapshot {
+  keyFile: string
+  keyId: string
+  teamId: string
+  topic: string
+  environments: string
+}
+
+interface FCMFormSnapshot {
+  serviceAccountFile: string
+  projectId: string
+  packageName: string
+}
+
+const apnsBaseline = ref<APNsFormSnapshot>()
+const fcmBaseline = ref<FCMFormSnapshot>()
+const pushManagedByEnvironment = computed(() => pushStatus.value?.source === 'environment')
+const fcmStatus = computed(() => pushStatus.value?.providers?.fcm)
+const fcmManagedByEnvironment = computed(() => fcmStatus.value?.source === 'environment')
+const fcmCanSave = computed(() => Boolean(
+  fcmStatus.value?.managementAvailable && fcmStatus.value.editable && !fcmBusy.value && !pushBusy.value,
+))
+const fcmStatusText = computed(() => {
+  if (!fcmStatus.value) return '当前版本未提供 Android 推送状态'
+  if (!fcmStatus.value.configured && fcmStatus.value.lastError) return `FCM 配置无效:${fcmStatus.value.lastError}`
+  if (!fcmStatus.value.configured) return '尚未配置 FCM 服务账号文件'
+  if (fcmStatus.value.healthy) return 'FCM 推送服务正常'
+  return fcmStatus.value.lastError || 'FCM 推送服务异常'
+})
+const pushCanSave = computed(() => Boolean(
+  pushStatus.value?.managementAvailable
+    && pushStatus.value.editable
+    && !pushBusy.value
+    && !fcmBusy.value,
+))
+const pushStatusText = computed(() => {
+  if (!pushStatus.value) return pushStatusLoadError.value || '当前版本未提供推送状态'
+  if (!pushStatus.value.configured && pushStatus.value.lastError) return `APNs 配置无效:${pushStatus.value.lastError}`
+  if (!pushStatus.value.configured) return '尚未配置 APNs 密钥文件'
+  if (pushStatus.value.healthy) return 'APNs 推送服务正常'
+  return pushStatus.value.lastError || 'APNs 推送服务异常'
+})
+
+function apnsSnapshot(): APNsFormSnapshot {
+  return {
+    keyFile: pushKeyFile.value,
+    keyId: pushKeyId.value,
+    teamId: pushTeamId.value,
+    topic: pushTopic.value,
+    environments: [...pushEnvironments.value].sort().join(','),
+  }
+}
+
+function fcmSnapshot(): FCMFormSnapshot {
+  return {
+    serviceAccountFile: fcmServiceAccountFile.value,
+    projectId: fcmProjectId.value,
+    packageName: fcmPackageName.value,
+  }
+}
+
+const dirty = computed(() => {
+  if (props.section === 'users') return Boolean(username.value.trim() || password.value)
+  if (props.section === 'connection') {
+    return upstreamUsername.value !== connectionBaseline.value.username
+      || upstreamPassword.value !== connectionBaseline.value.password
+  }
+  if (props.section !== 'push') return false
+  const apnsDirty = Boolean(apnsBaseline.value) && Object.keys(apnsSnapshot()).some(key => apnsSnapshot()[key as keyof APNsFormSnapshot] !== apnsBaseline.value?.[key as keyof APNsFormSnapshot])
+  const fcmDirty = Boolean(fcmBaseline.value) && Object.keys(fcmSnapshot()).some(key => fcmSnapshot()[key as keyof FCMFormSnapshot] !== fcmBaseline.value?.[key as keyof FCMFormSnapshot])
+  return apnsDirty || fcmDirty
+})
+
+watch(dirty, value => emit('dirty-change', value), { immediate: true })
+
+function hydrateAPNsForm(status: PushSystemStatus) {
+  pushKeyFile.value = status.keyFile ?? ''
+  pushKeyId.value = status.keyId ?? ''
+  pushTeamId.value = status.teamId ?? ''
+  pushTopic.value = status.topic || 'cn.samien.yaoyao.hermes'
+  pushEnvironments.value = status.environments.length
+    ? [...status.environments]
+    : ['development', 'production']
+  apnsBaseline.value = apnsSnapshot()
+}
+
+function hydrateFCMForm(status: PushSystemStatus) {
+  const fcm = status.providers?.fcm
+  fcmServiceAccountFile.value = fcm?.serviceAccountFile ?? ''
+  fcmProjectId.value = fcm?.projectId ?? ''
+  fcmPackageName.value = fcm?.packageName || 'cn.samien.yaoyao.hermes'
+  fcmBaseline.value = fcmSnapshot()
+}
+
+function hydratePushForm(status: PushSystemStatus) {
+  hydrateAPNsForm(status)
+  hydrateFCMForm(status)
+}
+
+async function refresh() {
+  error.value = ''
+  if (props.section === 'users') {
+    users.value = await listUsers()
+    return
+  }
+  if (props.section !== 'push') return
+
+  pushStatusLoadError.value = ''
+  const next = await getPushSystemStatus().catch(cause => {
+    if ((cause as { status?: unknown })?.status !== 404) {
+      pushStatusLoadError.value = `无法读取 APNs 状态:${cause instanceof Error ? cause.message : '请求失败'}`
+    }
+    return undefined
+  })
+  pushStatus.value = next
+  if (next) hydratePushForm(next)
+  else {
+    apnsBaseline.value = undefined
+    fcmBaseline.value = undefined
+  }
+}
+
+defineExpose({ refresh })
+
+async function run(action: () => Promise<void>, refreshAfter = true) {
+  if (busy.value) return
+  busy.value = true
+  error.value = ''
+  try {
+    await action()
+    if (refreshAfter) await refresh()
+  } catch (cause) {
+    error.value = cause instanceof Error ? cause.message : '操作失败'
+  } finally {
+    busy.value = false
+  }
+}
+
+function add() {
+  void run(async () => {
+    await createUser(username.value.trim(), password.value)
+    username.value = ''
+    password.value = ''
+  })
+}
+
+function toggle(user: ManagedUser) {
+  void run(async () => { await updateUser(user.id, { enabled: !user.enabled }) })
+}
+
+function reset(user: ManagedUser) {
+  const value = window.prompt(`为“${user.username}”设置临时密码(至少 8 个字符)`)
+  if (value) void run(async () => { await updateUser(user.id, { password: value }) })
+}
+
+function remove(user: ManagedUser) {
+  if (window.confirm(`删除用户“${user.username}”?`)) {
+    void run(async () => { await deleteUser(user.id) })
+  }
+}
+
+async function saveUpstream() {
+  if (busy.value) return
+  const submittedUsername = upstreamUsername.value.trim()
+  const submittedPassword = upstreamPassword.value
+  await run(async () => {
+    await setUpstreamCredentials(submittedUsername, submittedPassword)
+    upstreamUsername.value = submittedUsername
+    upstreamPassword.value = ''
+    connectionBaseline.value = { username: submittedUsername, password: '' }
+  }, false)
+}
+
+async function savePush() {
+  if (!pushStatus.value?.editable || pushManagedByEnvironment.value || pushBusy.value || fcmBusy.value) return
+  const keyFile = pushKeyFile.value.trim()
+  const keyId = pushKeyId.value.trim()
+  const teamId = pushTeamId.value.trim()
+  const topic = pushTopic.value.trim()
+  const environments = (['development', 'production'] as const)
+    .filter(environment => pushEnvironments.value.includes(environment))
+  pushError.value = ''
+  pushNotice.value = ''
+  if (!keyFile.startsWith('/')) {
+    pushError.value = '请输入 8800 所在机器上的 .p8 文件绝对路径'
+    return
+  }
+  if (!/^[A-Za-z0-9]{1,128}$/.test(keyId)) {
+    pushError.value = 'Key ID 仅支持 1–128 位字母或数字'
+    return
+  }
+  if (!/^[A-Za-z0-9]{1,128}$/.test(teamId)) {
+    pushError.value = 'Team ID 仅支持 1–128 位字母或数字'
+    return
+  }
+  if (topic.length > 255 || !/^[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?$/.test(topic)) {
+    pushError.value = 'Topic 必须是有效的 App Bundle ID'
+    return
+  }
+  if (!environments.length) {
+    pushError.value = '至少选择 Sandbox 或 Production'
+    return
+  }
+  pushBusy.value = true
+  try {
+    const next = await savePushSystemConfig({ keyFile, keyId, teamId, topic, environments })
+    pushStatus.value = next
+    hydrateAPNsForm(next)
+    const environmentText = environments.length === 2
+      ? 'Sandbox 与 Production'
+      : environments[0] === 'development' ? 'Sandbox' : 'Production'
+    pushNotice.value = `${environmentText} 验证通过,APNs 已启用`
+  } catch (cause) {
+    pushError.value = cause instanceof Error ? cause.message : '无法验证并启用 APNs'
+  } finally {
+    pushBusy.value = false
+  }
+}
+
+async function saveFCM() {
+  if (!fcmStatus.value?.editable || fcmManagedByEnvironment.value || fcmBusy.value || pushBusy.value) return
+  const serviceAccountFile = fcmServiceAccountFile.value.trim()
+  const projectId = fcmProjectId.value.trim()
+  const packageName = fcmPackageName.value.trim()
+  fcmError.value = ''
+  fcmNotice.value = ''
+  if (!serviceAccountFile.startsWith('/')) {
+    fcmError.value = '请输入 8800 所在机器上的服务账号 JSON 文件绝对路径'
+    return
+  }
+  if (!/^[a-z][a-z0-9-]{4,61}[a-z0-9]$/.test(projectId)) {
+    fcmError.value = '请输入有效的 Firebase Project ID'
+    return
+  }
+  if (packageName !== 'cn.samien.yaoyao.hermes') {
+    fcmError.value = 'Android 包名必须是 cn.samien.yaoyao.hermes'
+    return
+  }
+  fcmBusy.value = true
+  try {
+    const next = await saveFCMPushSystemConfig({ serviceAccountFile, projectId, packageName })
+    pushStatus.value = next
+    hydrateFCMForm(next)
+    fcmNotice.value = 'Firebase 权限与项目验证通过,FCM 已启用'
+  } catch (cause) {
+    fcmError.value = cause instanceof Error ? cause.message : '无法验证并启用 FCM'
+  } finally {
+    fcmBusy.value = false
+  }
+}
+
+watch(() => [props.active, props.section] as const, ([active, section]) => {
+  if (!active) return
+  if (section === 'push') {
+    pushError.value = ''
+    pushNotice.value = ''
+    fcmError.value = ''
+    fcmNotice.value = ''
+  }
+  void refresh().catch(cause => {
+    error.value = cause instanceof Error ? cause.message : '读取用户失败'
+  })
+}, { immediate: true })
+</script>
+
+<template>
+  <section class="system-management-panel" :data-section="section">
+    <p v-if="error" class="error" role="alert">{{ error }}</p>
+
+    <div v-if="section === 'users'" class="block">
+      <h3>用户</h3>
+      <div v-for="user in users" :key="user.id" class="user">
+        <span>
+          <b>{{ user.username }}</b>
+          <small>{{ user.role === 'admin' ? '管理员' : user.enabled ? (user.mustChangePassword ? '等待修改临时密码' : '普通用户') : '已禁用' }}</small>
+        </span>
+        <template v-if="user.role !== 'admin'">
+          <button type="button" :aria-label="`为用户 ${user.username} 重置密码`" :disabled="busy" @click="reset(user)">重置密码</button>
+          <button type="button" :aria-label="`${user.enabled ? '禁用' : '启用'}用户 ${user.username}`" :disabled="busy" @click="toggle(user)">{{ user.enabled ? '禁用' : '启用' }}</button>
+          <button class="danger" type="button" :aria-label="`删除用户 ${user.username}`" :disabled="busy" @click="remove(user)">删除</button>
+        </template>
+      </div>
+      <form @submit.prevent="add">
+        <label><span>新用户名</span><input v-model="username" name="managed-username" autocomplete="off" :disabled="busy" /></label>
+        <label><span>临时密码</span><input v-model="password" name="managed-temporary-password" type="password" autocomplete="new-password" :disabled="busy" /><small>至少 8 位</small></label>
+        <button class="solid-button" :disabled="busy || !username.trim() || password.length < 8">创建用户</button>
+      </form>
+    </div>
+
+    <div v-else-if="section === 'connection'" class="block">
+      <h3>9119 服务账号</h3>
+      <p :class="{ ok: upstreamReady }">{{ upstreamReady ? '上游连接正常' : upstreamError || '尚未验证上游连接' }}</p>
+      <form @submit.prevent="saveUpstream">
+        <label><span>9119 用户名</span><input v-model="upstreamUsername" name="upstream-username" autocomplete="off" :disabled="busy" /></label>
+        <label><span>9119 密码</span><input v-model="upstreamPassword" name="upstream-password" type="password" autocomplete="new-password" :disabled="busy" /></label>
+        <button class="solid-button" :disabled="busy || !upstreamUsername.trim() || !upstreamPassword">验证并保存</button>
+      </form>
+    </div>
+
+    <template v-else>
+      <div class="block push-block">
+        <h3>iOS 消息推送</h3>
+        <p class="push-health" :class="{ ok: pushStatus?.configured && pushStatus?.healthy }">{{ pushStatusText }}</p>
+        <div v-if="pushStatus" class="push-status">
+          <span><small>Topic</small><b>{{ pushStatus.topic || '—' }}</b></span>
+          <span><small>注册设备</small><b>{{ pushStatus.registrationCount }}</b></span>
+          <span><small>待发送</small><b>{{ pushStatus.pendingCount }}</b></span>
+        </div>
+
+        <template v-if="pushStatus?.managementAvailable">
+          <p v-if="pushManagedByEnvironment" class="push-managed-note">当前 APNs 配置由服务环境变量管理,Web 仅可查看。请修改 LaunchAgent、Docker 或进程环境后重启服务。</p>
+          <p v-else-if="!pushStatus.editable" class="push-managed-note">当前 APNs 配置为只读,请在 8800 服务端修改后重启服务。</p>
+          <form class="push-config-form" @submit.prevent="savePush">
+            <label class="wide"><span>.p8 本地路径</span><input v-model="pushKeyFile" name="push-key-file" :readonly="!pushStatus.editable || pushBusy || fcmBusy" autocomplete="off" spellcheck="false" placeholder="/Users/…/AuthKey_XXXXXXXXXX.p8" /><small>填写 8800 所在机器上的绝对路径,私钥内容不会经过浏览器。</small></label>
+            <label><span>Key ID</span><input v-model="pushKeyId" name="push-key-id" :readonly="!pushStatus.editable || pushBusy || fcmBusy" autocomplete="off" spellcheck="false" /></label>
+            <label><span>Team ID</span><input v-model="pushTeamId" name="push-team-id" :readonly="!pushStatus.editable || pushBusy || fcmBusy" autocomplete="off" spellcheck="false" /></label>
+            <label class="wide"><span>Topic(Bundle ID)</span><input v-model="pushTopic" name="push-topic" :readonly="!pushStatus.editable || pushBusy || fcmBusy" autocomplete="off" spellcheck="false" /></label>
+            <fieldset class="wide" :disabled="!pushStatus.editable || pushBusy || fcmBusy">
+              <legend>推送环境</legend>
+              <label><input v-model="pushEnvironments" type="checkbox" value="development" />Sandbox(开发)</label>
+              <label><input v-model="pushEnvironments" type="checkbox" value="production" />Production(TestFlight / App Store)</label>
+            </fieldset>
+            <div v-if="pushStatus.warnings.length" class="wide push-warnings" aria-live="polite">
+              <p v-for="warning in pushStatus.warnings" :key="`${warning.code}:${warning.message}`" class="push-warning" role="status"><AppIcon name="alert" :size="14" />{{ warning.message }}</p>
+            </div>
+            <p v-if="pushError" class="wide push-error" role="alert">{{ pushError }}</p>
+            <p v-else-if="pushNotice" class="wide push-notice" role="status">{{ pushNotice }}</p>
+            <footer v-if="pushStatus.editable" class="wide"><button class="solid-button" :disabled="!pushCanSave">{{ pushBusy ? '验证中…' : '验证并启用' }}</button></footer>
+          </form>
+        </template>
+        <p v-else-if="pushStatus" class="push-managed-note">当前服务版本只提供推送状态,不能在 Web 中修改配置。</p>
+      </div>
+
+      <div class="block push-block">
+        <h3>Android 消息推送</h3>
+        <p class="push-health" :class="{ ok: fcmStatus?.configured && fcmStatus?.healthy }">{{ fcmStatusText }}</p>
+        <div v-if="fcmStatus" class="push-status">
+          <span><small>Firebase Project</small><b>{{ fcmStatus.projectId || '—' }}</b></span>
+          <span><small>注册设备</small><b>{{ fcmStatus.registrationCount }}</b></span>
+          <span><small>待发送</small><b>{{ fcmStatus.pendingCount }}</b></span>
+        </div>
+
+        <template v-if="fcmStatus?.managementAvailable">
+          <p v-if="fcmManagedByEnvironment" class="push-managed-note">当前 FCM 配置由服务环境变量管理,Web 仅可查看。请修改 LaunchAgent、Docker 或进程环境后重启服务。</p>
+          <p v-else-if="!fcmStatus.editable" class="push-managed-note">当前 FCM 配置为只读,请在 8800 服务端修改后重启服务。</p>
+          <form class="push-config-form" @submit.prevent="saveFCM">
+            <label class="wide"><span>服务账号 JSON 本地路径</span><input v-model="fcmServiceAccountFile" name="fcm-service-account-file" :readonly="!fcmStatus.editable || fcmBusy || pushBusy" autocomplete="off" spellcheck="false" placeholder="/Users/…/firebase-service-account.json" /><small>只填写 8800 所在机器上的绝对路径;JSON 内容和私钥不会经过浏览器,也不会写入夭夭配置。</small></label>
+            <label><span>Firebase Project ID</span><input v-model="fcmProjectId" name="fcm-project-id" :readonly="!fcmStatus.editable || fcmBusy || pushBusy" autocomplete="off" spellcheck="false" /></label>
+            <label><span>Android 包名</span><input v-model="fcmPackageName" name="fcm-package-name" :readonly="!fcmStatus.editable || fcmBusy || pushBusy" autocomplete="off" spellcheck="false" /></label>
+            <div v-if="fcmStatus.warnings.length" class="wide push-warnings" aria-live="polite">
+              <p v-for="warning in fcmStatus.warnings" :key="`${warning.code}:${warning.message}`" class="push-warning" role="status"><AppIcon name="alert" :size="14" />{{ warning.message }}</p>
+            </div>
+            <p v-if="fcmError" class="wide push-error" role="alert">{{ fcmError }}</p>
+            <p v-else-if="fcmNotice" class="wide push-notice" role="status">{{ fcmNotice }}</p>
+            <footer v-if="fcmStatus.editable" class="wide"><button class="solid-button" :disabled="!fcmCanSave">{{ fcmBusy ? '验证中…' : '验证并启用' }}</button></footer>
+          </form>
+        </template>
+        <p v-else-if="fcmStatus" class="push-managed-note">当前服务版本只提供 FCM 状态,不能在 Web 中修改配置。</p>
+      </div>
+    </template>
+  </section>
+</template>
+
+<style scoped>
+.system-management-panel { display: grid; }
+.block { min-width: 0; margin: 0; padding: 0; }
+.block + .block { margin-top: 26px; padding-top: 24px; border-top: 1px solid var(--line); }
+.block h3 { margin: 0 0 14px; font-size: 16px; }
+.block p { color: var(--danger); font-size: 13px; }
+.block p.ok { color: var(--success, #21845b); }
+.error { margin: 0 0 12px; color: var(--danger); font-size: 13px; }
+.push-status { display: grid; grid-template-columns: minmax(0, 2fr) 1fr 1fr; gap: 8px; }
+.push-status span { display: flex; min-width: 0; flex-direction: column; gap: 5px; padding: 12px; border-radius: 9px; background: var(--surface-soft); }
+.push-status small { color: var(--text-muted); font-size: 11px; }
+.push-status b { overflow: hidden; font-size: 13px; text-overflow: ellipsis; white-space: nowrap; }
+.push-config-form { grid-template-columns: 1fr 1fr; margin-top: 16px; padding: 16px; border: 1px solid var(--line); border-radius: 11px; background: var(--surface-soft); }
+.push-config-form .wide { grid-column: 1 / -1; }
+.push-config-form > label { display: grid; min-width: 0; gap: 6px; color: var(--text-secondary); font-size: 12px; font-weight: 650; }
+.push-config-form > label small { color: var(--text-muted); font-size: 11px; font-weight: 400; line-height: 1.5; }
+.push-config-form input[readonly] { opacity: .72; cursor: default; }
+.push-config-form fieldset { display: flex; flex-wrap: wrap; gap: 12px; margin: 0; padding: 9px 10px; border: 1px solid var(--line); border-radius: 9px; }
+.push-config-form legend { padding: 0 4px; color: var(--text-secondary); font-size: 12px; font-weight: 650; }
+.push-config-form fieldset label { display: inline-flex; align-items: center; gap: 6px; color: var(--text-secondary); font-size: 12px; }
+.push-config-form fieldset input { width: 14px; height: 14px; accent-color: var(--accent); }
+.push-managed-note { margin: 10px 0 0; padding: 9px 10px; border-radius: 9px; background: var(--surface-soft); color: var(--text-muted) !important; line-height: 1.55; }
+.push-warnings { display: grid; gap: 6px; }
+.push-warning { display: flex; align-items: flex-start; gap: 6px; margin: 0; padding: 9px 10px; border-radius: 9px; background: color-mix(in srgb, var(--warning, #bd7611) 10%, transparent); color: var(--text-secondary) !important; line-height: 1.55; }
+.push-error, .push-notice { margin: 0; padding: 9px 10px; border-radius: 9px; line-height: 1.55; }
+.push-error { background: color-mix(in srgb, var(--danger) 8%, transparent); }
+.push-notice { background: color-mix(in srgb, var(--success, #21845b) 9%, transparent); color: var(--success, #21845b) !important; }
+.push-config-form footer { display: flex; justify-content: flex-end; }
+.user { display: flex; min-height: 58px; align-items: center; gap: 10px; border-bottom: 1px solid var(--line); }
+.user span { display: flex; min-width: 0; flex: 1; flex-direction: column; }
+.user b { font-size: 14px; }
+.user small { color: var(--text-muted); font-size: 12px; }
+.user button { min-height: 34px; padding: 0 8px; border: 0; border-radius: 7px; background: transparent; color: var(--text-secondary); font-size: 12px; cursor: pointer; }
+.user button:hover { background: var(--surface-soft); }
+.user button.danger { color: var(--danger); }
+form { display: grid; grid-template-columns: 1fr 1fr auto; align-items: end; gap: 10px; margin-top: 16px; }
+form > label { display: grid; min-width: 0; gap: 6px; color: var(--text-secondary); font-size: 12px; font-weight: 650; }
+form > label small { color: var(--text-muted); font-size: 11px; font-weight: 400; }
+input:not([type="checkbox"]) { min-width: 0; height: 42px; padding: 0 11px; border: 1px solid var(--line); border-radius: 9px; outline: 0; background: var(--surface-raised); color: var(--text-primary); font: 13px var(--font-ui); }
+input:not([type="checkbox"]):focus { border-color: var(--line-strong); box-shadow: 0 0 0 3px var(--focus-ring); }
+.solid-button { min-height: 42px; padding: 0 14px; border: 0; border-radius: 9px; background: var(--accent); color: var(--text-on-solid); font: 600 13px var(--font-ui); }
+.solid-button:disabled,.user button:disabled { cursor: not-allowed; opacity: .5; }
+@media (max-width: 600px) {
+  form { grid-template-columns: 1fr; }
+  .user { flex-wrap: wrap; padding: 8px 0; }
+  .user span { flex-basis: 100%; }
+  .push-status { grid-template-columns: 1fr; }
+  .push-config-form { grid-template-columns: 1fr; }
+  .push-config-form .wide { grid-column: 1; }
+}
+</style>

+ 139 - 0
src/client/components/app/SystemOverviewPanel.vue

@@ -0,0 +1,139 @@
+<script setup lang="ts">
+import { computed, ref, watch } from 'vue'
+import AppIcon from '@/components/common/AppIcon.vue'
+import { listUsers, type ManagedUser } from '@/api/admin'
+import { getDuplexVoiceSettings, type DuplexVoiceSettings } from '@/api/agentManagement'
+import { pairedDevices, type PairedDevicesResponse } from '@/api/pairing'
+import { getPushSystemStatus, type PushSystemStatus } from '@/api/push'
+import { systemUpdateStatus, type SystemUpdateStatus } from '@/api/systemUpdate'
+
+type Destination = 'system-users' | 'system-connection' | 'system-push' | 'system-nodes' | 'system-voice' | 'system-update'
+
+const props = withDefaults(defineProps<{
+  active?: boolean
+  upstreamReady?: boolean
+  upstreamError?: string
+}>(), {
+  active: true,
+  upstreamReady: false,
+  upstreamError: '',
+})
+const emit = defineEmits<{ navigate: [destination: Destination] }>()
+
+const users = ref<ManagedUser[]>()
+const push = ref<PushSystemStatus>()
+const devices = ref<PairedDevicesResponse>()
+const voice = ref<DuplexVoiceSettings>()
+const update = ref<SystemUpdateStatus>()
+const checking = ref(false)
+const error = ref('')
+
+const userStatus = computed(() => users.value
+  ? `${users.value.filter(user => user.role === 'admin').length} 位管理员 · ${users.value.length} 位用户`
+  : '尚未读取用户状态')
+const pushStatus = computed(() => {
+  if (!push.value) return '尚未读取推送状态'
+  const apns = push.value.configured && push.value.healthy ? 'APNs 已启用' : 'APNs 未就绪'
+  const fcm = push.value.providers?.fcm
+  return `${apns} · ${fcm?.configured && fcm.healthy ? 'FCM 已启用' : 'FCM 未配置'}`
+})
+const deviceStatus = computed(() => devices.value ? `${devices.value.devices.length} 台已授权` : '尚未读取设备状态')
+const voiceStatus = computed(() => voice.value ? `已配置 ${voice.value.voices.length} 个音色` : '尚未读取语音状态')
+const updateStatus = computed(() => {
+  if (!update.value) return '尚未读取版本状态'
+  const plugin = update.value.installedPluginVersion || update.value.current.pluginVersion || '未安装'
+  return `Web ${update.value.current.webVersion} · 插件 ${plugin}`
+})
+
+async function refresh() {
+  checking.value = true
+  error.value = ''
+  const results = await Promise.allSettled([
+    listUsers(),
+    getPushSystemStatus(),
+    pairedDevices(),
+    getDuplexVoiceSettings(),
+    systemUpdateStatus(),
+  ])
+  if (results[0]?.status === 'fulfilled') users.value = results[0].value
+  if (results[1]?.status === 'fulfilled') push.value = results[1].value
+  if (results[2]?.status === 'fulfilled') devices.value = results[2].value
+  if (results[3]?.status === 'fulfilled') voice.value = results[3].value
+  if (results[4]?.status === 'fulfilled') update.value = results[4].value
+  const failed = results.filter(result => result.status === 'rejected').length
+  if (failed) error.value = failed === results.length ? '无法读取系统状态' : `${failed} 项状态暂时无法读取,可进入对应页面重试。`
+  checking.value = false
+}
+
+watch(() => props.active, active => { if (active) void refresh() }, { immediate: true })
+</script>
+
+<template>
+  <section class="system-overview-panel" aria-label="系统概览">
+    <div class="overview-toolbar">
+      <p>影响整个夭夭安装及所有 Agent,仅管理员可见。</p>
+      <button type="button" :disabled="checking" @click="refresh"><AppIcon name="refresh" :size="16" />{{ checking ? '检查中…' : '检查系统状态' }}</button>
+    </div>
+    <p v-if="error" class="overview-warning" role="status">{{ error }}</p>
+
+    <div class="overview-list">
+      <button type="button" @click="emit('navigate', 'system-users')">
+        <AppIcon name="users" :size="22" />
+        <span><strong>用户与权限</strong><small>{{ userStatus }}</small></span>
+        <AppIcon class="chevron" name="chevron-left" :size="18" />
+      </button>
+      <button type="button" @click="emit('navigate', 'system-connection')">
+        <AppIcon name="link" :size="22" />
+        <span><strong>Hermes 连接</strong><small :class="{ success: upstreamReady }">{{ upstreamReady ? '9119 连接正常' : upstreamError || '尚未验证上游连接' }}</small></span>
+        <AppIcon class="chevron" name="chevron-left" :size="18" />
+      </button>
+      <button type="button" @click="emit('navigate', 'system-push')">
+        <AppIcon name="bell" :size="22" />
+        <span><strong>消息推送</strong><small>{{ pushStatus }}</small></span>
+        <AppIcon class="chevron" name="chevron-left" :size="18" />
+      </button>
+      <button type="button" @click="emit('navigate', 'system-nodes')">
+        <AppIcon name="panel" :size="22" />
+        <span><strong>节点与设备</strong><small>{{ deviceStatus }}</small></span>
+        <AppIcon class="chevron" name="chevron-left" :size="18" />
+      </button>
+      <button type="button" @click="emit('navigate', 'system-voice')">
+        <AppIcon name="audio" :size="22" />
+        <span><strong>双流语音 <em>全局</em></strong><small>{{ voiceStatus }}</small></span>
+        <AppIcon class="chevron" name="chevron-left" :size="18" />
+      </button>
+      <button type="button" @click="emit('navigate', 'system-update')">
+        <AppIcon name="refresh" :size="22" />
+        <span><strong>更新与回滚</strong><small>{{ updateStatus }} <b v-if="update && !update.updateAvailable && update.versionsMatch">已是最新版本</b></small></span>
+        <AppIcon class="chevron" name="chevron-left" :size="18" />
+      </button>
+    </div>
+
+    <p class="ownership-note">8800 通过官方 API 管理 Hermes 9119;外部文件与进程不在此处控制。</p>
+  </section>
+</template>
+
+<style scoped>
+.system-overview-panel { display: grid; gap: 16px; }
+.overview-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 18px; }
+.overview-toolbar p { margin: 0; color: var(--text-secondary); font-size: 14px; }
+.overview-toolbar button { display: inline-flex; min-height: 40px; flex: 0 0 auto; align-items: center; gap: 7px; padding: 0 14px; border: 1px solid var(--line); border-radius: 9px; background: var(--surface-raised); color: var(--text-primary); cursor: pointer; font: 600 13px var(--font-ui); }
+.overview-toolbar button:disabled { cursor: wait; opacity: .55; }
+.overview-warning { margin: 0; padding: 10px 12px; border-radius: 9px; background: color-mix(in srgb, var(--warning, #bd7611) 10%, transparent); color: var(--text-secondary); font-size: 12px; }
+.overview-list { border-top: 1px solid var(--line); }
+.overview-list > button { display: grid; width: 100%; min-height: 76px; grid-template-columns: 28px minmax(0, 1fr) 22px; align-items: center; gap: 15px; padding: 12px 8px; border: 0; border-bottom: 1px solid var(--line); border-radius: 9px; background: transparent; color: var(--text-primary); cursor: pointer; text-align: left; }
+.overview-list > button:hover { background: var(--surface-soft); }
+.overview-list > button:focus-visible { outline: 0; box-shadow: inset 0 0 0 2px color-mix(in srgb, #7c4dff 72%, var(--line-strong)); }
+.overview-list > button > span { display: grid; min-width: 0; gap: 6px; }
+.overview-list strong { display: flex; align-items: center; gap: 8px; font-size: 15px; }
+.overview-list small { color: var(--text-muted); font-size: 13px; }
+.overview-list small.success,.overview-list small b { color: var(--success, #21845b); font-weight: 600; }
+.overview-list em { padding: 2px 7px; border: 1px solid var(--line-strong); border-radius: 999px; color: var(--text-muted); font: normal 11px var(--font-ui); }
+.chevron { transform: rotate(180deg); color: var(--text-muted); }
+.ownership-note { margin: 2px 0 0; color: var(--text-muted); font-size: 12px; line-height: 1.6; }
+@media (max-width: 700px) {
+  .overview-toolbar { align-items: stretch; flex-direction: column; }
+  .overview-toolbar button { justify-content: center; }
+  .overview-list > button { min-height: 72px; padding-inline: 4px; }
+}
+</style>

+ 210 - 0
src/client/components/app/SystemUpdatePanel.vue

@@ -0,0 +1,210 @@
+<script setup lang="ts">
+import { computed, onBeforeUnmount, ref, watch } from 'vue'
+import AppIcon from '@/components/common/AppIcon.vue'
+import {
+  applySystemUpdate,
+  checkSystemUpdate,
+  rollbackSystemUpdate,
+  systemUpdateJob,
+  systemUpdateStatus,
+  type SystemUpdateStatus,
+  type UpdateJob,
+} from '@/api/systemUpdate'
+
+const props = withDefaults(defineProps<{ active?: boolean }>(), { active: true })
+const emit = defineEmits<{ 'lock-change': [locked: boolean] }>()
+
+const status = ref<SystemUpdateStatus>()
+const job = ref<UpdateJob>()
+const busy = ref(false)
+const checking = ref(false)
+const error = ref('')
+const trackingTimedOut = ref(false)
+const operationStarting = ref(false)
+let pollToken = 0
+let lifecycleToken = 0
+
+const terminal = computed(() => job.value && ['succeeded', 'failed', 'rolled_back'].includes(job.value.state))
+const jobRunning = computed(() => Boolean(job.value && !terminal.value))
+const locked = computed(() => operationStarting.value || (jobRunning.value && !trackingTimedOut.value))
+const currentPlugin = computed(() => status.value?.installedPluginVersion || '未安装')
+const canApply = computed(() => Boolean(status.value?.supported && status.value.updateAvailable && status.value.latest && !jobRunning.value && !busy.value))
+
+function stopPolling() { pollToken += 1 }
+function reloadPage() { window.location.reload() }
+
+async function refresh(checkRemote = false): Promise<SystemUpdateStatus | undefined> {
+  error.value = ''
+  checking.value = checkRemote
+  try {
+    status.value = checkRemote ? await checkSystemUpdate() : await systemUpdateStatus()
+    if (status.value.job) job.value = status.value.job
+    return status.value
+  } catch (cause) {
+    error.value = cause instanceof Error ? cause.message : '无法读取系统版本'
+  } finally {
+    checking.value = false
+  }
+  return undefined
+}
+
+async function poll(next: UpdateJob) {
+  job.value = next
+  trackingTimedOut.value = false
+  const token = ++pollToken
+  for (let attempt = 0; attempt < 300 && token === pollToken; attempt += 1) {
+    if (['succeeded', 'failed', 'rolled_back'].includes(job.value.state)) break
+    await new Promise(resolvePromise => window.setTimeout(resolvePromise, 1_000))
+    try {
+      job.value = await systemUpdateJob(next.id)
+    } catch {
+      // 8800 会在原子切换期间短暂不可用;继续等待新服务读取同一任务文件。
+    }
+  }
+  if (token === pollToken && job.value && !['succeeded', 'failed', 'rolled_back'].includes(job.value.state)) {
+    error.value = '升级仍在后台执行,请稍后重新打开此页面查看'
+    trackingTimedOut.value = true
+  }
+  if (token === pollToken && !trackingTimedOut.value) await refresh(false)
+}
+
+async function loadAndResume(token: number) {
+  await refresh(false)
+  if (token !== lifecycleToken || !props.active) return
+  if (job.value && !['succeeded', 'failed', 'rolled_back'].includes(job.value.state)) {
+    await poll(job.value)
+    return
+  }
+  await refresh(true)
+  if (token !== lifecycleToken || !props.active) return
+  if (job.value && !['succeeded', 'failed', 'rolled_back'].includes(job.value.state)) await poll(job.value)
+}
+
+async function applyUpdate() {
+  const target = status.value?.latest
+  if (!target || !window.confirm(`升级 Web 到 ${target.webVersion}?插件 ${target.pluginVersion} 将通过 9119 更新。`)) return
+  const token = lifecycleToken
+  operationStarting.value = true
+  busy.value = true
+  error.value = ''
+  try {
+    const next = await applySystemUpdate(target.releaseVersion)
+    if (token !== lifecycleToken || !props.active) return
+    operationStarting.value = false
+    await poll(next)
+  } catch (cause) {
+    if (token === lifecycleToken) error.value = cause instanceof Error ? cause.message : '无法启动系统升级'
+  } finally {
+    if (token === lifecycleToken) {
+      operationStarting.value = false
+      busy.value = false
+    }
+  }
+}
+
+async function rollback() {
+  if (!window.confirm('回滚到上一次 Web 版本?插件继续由 9119 保持最新版本。')) return
+  const token = lifecycleToken
+  operationStarting.value = true
+  busy.value = true
+  error.value = ''
+  try {
+    const next = await rollbackSystemUpdate()
+    if (token !== lifecycleToken || !props.active) return
+    operationStarting.value = false
+    await poll(next)
+  } catch (cause) {
+    if (token === lifecycleToken) error.value = cause instanceof Error ? cause.message : '无法启动系统回滚'
+  } finally {
+    if (token === lifecycleToken) {
+      operationStarting.value = false
+      busy.value = false
+    }
+  }
+}
+
+watch(locked, value => emit('lock-change', value), { immediate: true })
+watch(() => props.active, active => {
+  const token = ++lifecycleToken
+  stopPolling()
+  error.value = ''
+  trackingTimedOut.value = false
+  operationStarting.value = false
+  if (!active) {
+    emit('lock-change', false)
+    return
+  }
+  job.value = undefined
+  busy.value = true
+  void loadAndResume(token)
+    .finally(() => { if (token === lifecycleToken) busy.value = false })
+}, { immediate: true })
+
+onBeforeUnmount(() => { lifecycleToken += 1; operationStarting.value = false; stopPolling(); emit('lock-change', false) })
+</script>
+
+<template>
+  <section class="system-update-panel" aria-label="更新与回滚">
+    <p class="system-update-intro">Web 服务独立升级;Hermes Dashboard 插件由 9119 自动更新到最新兼容版本。</p>
+    <p v-if="error" class="system-update-error" role="alert"><AppIcon name="alert" :size="16" />{{ error }}</p>
+
+    <section v-if="status" class="version-grid" aria-label="版本信息">
+      <article><small>当前 Web</small><strong>{{ status.current.webVersion }}</strong></article>
+      <article><small>当前插件</small><strong>{{ currentPlugin }}</strong></article>
+      <article><small>最新 Web</small><strong>{{ status.latest?.webVersion || status.current.webVersion }}</strong></article>
+      <article><small>配套插件</small><strong>{{ status.latest?.pluginVersion || status.current.pluginVersion }}</strong></article>
+    </section>
+
+    <p v-if="status && !status.versionsMatch" class="version-warning"><AppIcon name="alert" :size="15" />当前插件版本落后,登录后会自动通过 9119 更新。</p>
+    <p v-if="status?.installationMode === 'source'" class="mode-note">首次升级会把运行服务迁移到可回滚的版本目录;Git 工作区不会被覆盖。</p>
+    <p v-if="status && !status.supported" class="mode-note">{{ status.unsupportedReason }}</p>
+
+    <section v-if="job" class="update-progress" :class="`update-progress--${job.state}`" aria-live="polite">
+      <span class="progress-icon"><AppIcon :name="job.state === 'succeeded' || job.state === 'rolled_back' ? 'check' : job.state === 'failed' ? 'alert' : 'refresh'" :size="18" /></span>
+      <span><strong>{{ job.message }}</strong><small v-if="job.error">{{ job.error }}</small></span>
+    </section>
+
+    <footer>
+      <button v-if="job?.state === 'succeeded' || job?.state === 'rolled_back'" class="quiet-button" type="button" @click="reloadPage"><AppIcon name="refresh" :size="16" />刷新页面</button>
+      <button v-else-if="status?.canRollback && !jobRunning" class="quiet-button danger" type="button" :disabled="busy" @click="rollback">回滚上一版本</button>
+      <button class="quiet-button" type="button" :disabled="checking || busy || operationStarting || jobRunning" @click="refresh(true)"><AppIcon name="refresh" :size="16" />{{ checking ? '检查中…' : '检查更新' }}</button>
+      <button class="solid-button" type="button" :disabled="!canApply" @click="applyUpdate"><AppIcon name="download" :size="16" />{{ jobRunning ? '升级中…' : status?.updateAvailable ? '升级配套版本' : '已是最新版本' }}</button>
+    </footer>
+  </section>
+</template>
+
+<style scoped>
+.system-update-panel { display: grid; gap: 18px; }
+.system-update-intro { margin: 0; max-width: 620px; color: var(--text-secondary); font-size: 14px; line-height: 1.65; }
+.system-update-error,.version-warning { display: flex; align-items: flex-start; gap: 8px; margin: 0; padding: 12px 14px; border-radius: 10px; font-size: 13px; line-height: 1.55; }
+.system-update-error { background: color-mix(in srgb, var(--danger) 9%, transparent); color: var(--danger); }
+.version-warning { background: color-mix(in srgb, var(--warning, #bd7611) 10%, transparent); color: var(--text-secondary); }
+.version-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); border-block: 1px solid var(--line); }
+.version-grid article { display: grid; gap: 7px; padding: 18px 20px; border-bottom: 1px solid var(--line); }
+.version-grid article:nth-child(odd) { border-right: 1px solid var(--line); }
+.version-grid article:nth-last-child(-n+2) { border-bottom: 0; }
+.version-grid small { color: var(--text-muted); font-size: 12px; }
+.version-grid strong { font-size: 20px; letter-spacing: -.02em; }
+.mode-note { margin: -4px 0 0; color: var(--text-muted); font-size: 12px; line-height: 1.55; }
+.update-progress { display: flex; gap: 12px; padding: 14px; border: 1px solid var(--line); border-radius: 12px; background: var(--surface-soft); }
+.progress-icon { display: grid; width: 34px; height: 34px; flex: 0 0 34px; place-items: center; border-radius: 50%; background: var(--surface-raised); color: var(--text-secondary); }
+.update-progress > span:last-child { display: grid; gap: 4px; min-width: 0; }
+.update-progress strong { font-size: 14px; }
+.update-progress small { color: var(--danger); font-size: 12px; line-height: 1.5; overflow-wrap: anywhere; }
+.update-progress:not(.update-progress--failed, .update-progress--succeeded, .update-progress--rolled_back) .progress-icon { animation: update-spin 1s linear infinite; }
+.update-progress--succeeded .progress-icon,.update-progress--rolled_back .progress-icon { color: var(--success); }
+.update-progress--failed .progress-icon { color: var(--danger); }
+footer { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 10px; margin-top: 6px; padding-top: 18px; border-top: 1px solid var(--line); }
+button { display: inline-flex; min-height: 40px; align-items: center; justify-content: center; gap: 7px; padding: 0 14px; border: 0; border-radius: 9px; cursor: pointer; font: 600 13px var(--font-ui); }
+.quiet-button { border: 1px solid var(--line); background: var(--surface-raised); color: var(--text-secondary); }
+.solid-button { background: var(--accent); color: var(--text-on-solid); }
+button:disabled { cursor: not-allowed; opacity: .5; }
+.danger { margin-right: auto; color: var(--danger); }
+@keyframes update-spin { to { transform: rotate(360deg); } }
+@media (max-width: 700px) {
+  .version-grid { grid-template-columns: 1fr; }
+  .version-grid article,.version-grid article:nth-child(odd),.version-grid article:nth-last-child(-n+2) { border-right: 0; border-bottom: 1px solid var(--line); }
+  .version-grid article:last-child { border-bottom: 0; }
+  footer .solid-button { flex: 1 1 100%; }
+}
+</style>

+ 151 - 56
src/client/components/app/WorkspaceShell.vue

@@ -5,10 +5,7 @@ import type { Profile } from '@shared/types'
 import AgentAvatar from '@/components/common/AgentAvatar.vue'
 import AppIcon from '@/components/common/AppIcon.vue'
 import BrandMark from '@/components/common/BrandMark.vue'
-import NodePairingDialog from '@/components/app/NodePairingDialog.vue'
-import SystemUpdateDialog from '@/components/app/SystemUpdateDialog.vue'
-import SystemManagementDialog from '@/components/app/SystemManagementDialog.vue'
-import AccountSecurityDialog from '@/components/app/AccountSecurityDialog.vue'
+import SettingsCenterDialog from '@/components/app/SettingsCenterDialog.vue'
 import YaoYaoSidebarIcon from '@/components/common/YaoYaoSidebarIcon.vue'
 
 type NavItem = {
@@ -18,6 +15,20 @@ type NavItem = {
   icon: 'chat' | 'groups' | 'files'
 }
 
+type SettingsPage =
+  | 'agent-identity'
+  | 'agent-models'
+  | 'account-security'
+  | 'account-mobile'
+  | 'appearance'
+  | 'system-overview'
+  | 'system-users'
+  | 'system-connection'
+  | 'system-push'
+  | 'system-nodes'
+  | 'system-voice'
+  | 'system-update'
+
 const SIDEBAR_COLLAPSED_KEY = 'hermes-yaoyao:sidebar-collapsed'
 const SIDEBAR_SEARCH_EVENT = 'hermes-yaoyao:sidebar-search'
 const SIDEBAR_SEARCH_CLOSE_EVENT = 'hermes-yaoyao:sidebar-search-close'
@@ -28,6 +39,7 @@ const props = withDefaults(defineProps<{
   activeProfile?: Profile
   profiles?: Profile[]
   theme?: 'light' | 'dark'
+  themePreference?: 'light' | 'dark' | 'system'
   insecureTransport?: boolean
   sidebarTitle?: string
   sidebarSubtitle?: string
@@ -38,12 +50,16 @@ const props = withDefaults(defineProps<{
   isAdmin?: boolean
   upstreamReady?: boolean
   upstreamError?: string
+  identityBusy?: boolean
+  identityError?: string
+  identityResetVersion?: number
 }>(), {
   userName: '',
   pairingUserName: '',
   activeProfile: undefined,
   profiles: () => [],
   theme: 'light',
+  themePreference: 'system',
   insecureTransport: false,
   sidebarTitle: '',
   sidebarSubtitle: '',
@@ -54,13 +70,17 @@ const props = withDefaults(defineProps<{
   isAdmin: false,
   upstreamReady: false,
   upstreamError: '',
+  identityBusy: false,
+  identityError: '',
+  identityResetVersion: 0,
 })
 
 const emit = defineEmits<{
   logout: []
   toggleTheme: []
+  setTheme: [theme: 'light' | 'dark' | 'system']
   selectProfile: [profile: string]
-  editProfile: []
+  saveIdentity: [input: { title: string; avatarDataURL?: string | null }]
   closeInspector: []
 }>()
 
@@ -68,12 +88,14 @@ const route = useRoute()
 const router = useRouter()
 const mobileDrawerOpen = ref(false)
 const profileMenuOpen = ref(false)
+const settingsOpen = ref(false)
+const settingsPage = ref<SettingsPage>('agent-identity')
+const settingsReturnFocus = ref<HTMLButtonElement>()
+const mobileNavigationTrigger = ref<HTMLButtonElement>()
+const mobileDrawerClose = ref<HTMLButtonElement>()
+const profileMenuTrigger = ref<HTMLButtonElement>()
 const sidebarCollapsed = ref(false)
 const sidebarSearchOpen = ref(false)
-const nodePairingOpen = ref(false)
-const systemUpdateOpen = ref(false)
-const systemManagementOpen = ref(false)
-const accountSecurityOpen = ref(false)
 const desktopSidebarContext = ref<HTMLElement | null>(null)
 const mobileSidebarContext = ref<HTMLElement | null>(null)
 
@@ -110,11 +132,76 @@ async function navigate(path: string) {
   await router.push(path)
 }
 
+function openMobileDrawer() {
+  mobileDrawerOpen.value = true
+  void nextTick(() => mobileDrawerClose.value?.focus())
+}
+
+function closeMobileDrawer() {
+  mobileDrawerOpen.value = false
+  void nextTick(() => mobileNavigationTrigger.value?.focus())
+}
+
 function closeMenus(event: MouseEvent) {
   const target = event.target as HTMLElement
   if (!target.closest('.sidebar-account-switcher')) profileMenuOpen.value = false
 }
 
+function toggleProfileMenu(event: MouseEvent) {
+  const trigger = event.currentTarget
+  if (trigger instanceof HTMLButtonElement) profileMenuTrigger.value = trigger
+  profileMenuOpen.value = !profileMenuOpen.value
+  if (!profileMenuOpen.value) return
+  void nextTick(() => {
+    trigger instanceof HTMLElement
+      && trigger.closest('.sidebar-account-switcher')?.querySelector<HTMLButtonElement>('.profile-menu [role="option"][aria-selected="true"]')?.focus()
+  })
+}
+
+function chooseProfile(name: string) {
+  emit('selectProfile', name)
+  profileMenuOpen.value = false
+  void nextTick(() => profileMenuTrigger.value?.focus())
+}
+
+function handleProfileMenuKeydown(event: KeyboardEvent) {
+  const options = [...(event.currentTarget as HTMLElement).querySelectorAll<HTMLButtonElement>('[role="option"]')]
+  if (!options.length) return
+  const current = Math.max(0, options.indexOf(document.activeElement as HTMLButtonElement))
+  let next: number | undefined
+  if (event.key === 'ArrowDown') next = (current + 1) % options.length
+  else if (event.key === 'ArrowUp') next = (current - 1 + options.length) % options.length
+  else if (event.key === 'Home') next = 0
+  else if (event.key === 'End') next = options.length - 1
+  else if (event.key === 'Escape') {
+    event.preventDefault()
+    profileMenuOpen.value = false
+    void nextTick(() => profileMenuTrigger.value?.focus())
+    return
+  }
+  if (next === undefined) return
+  event.preventDefault()
+  options[next]?.focus()
+}
+
+function openSettings(page: SettingsPage = 'agent-identity', event?: MouseEvent) {
+  const trigger = event?.currentTarget
+  if (trigger instanceof HTMLButtonElement) {
+    settingsReturnFocus.value = trigger.closest('.mobile-drawer')
+      ? mobileNavigationTrigger.value
+      : trigger
+  }
+  profileMenuOpen.value = false
+  mobileDrawerOpen.value = false
+  settingsPage.value = page
+  settingsOpen.value = true
+}
+
+function closeSettings() {
+  settingsOpen.value = false
+  void nextTick(() => settingsReturnFocus.value?.focus())
+}
+
 function toggleSidebar() {
   sidebarCollapsed.value = !sidebarCollapsed.value
   try { localStorage.setItem(SIDEBAR_COLLAPSED_KEY, sidebarCollapsed.value ? '1' : '0') } catch { /* optional persistence */ }
@@ -181,9 +268,9 @@ onBeforeUnmount(() => {
 </script>
 
 <template>
-  <div class="workspace-shell" :class="{ 'workspace-shell--collapsed': sidebarCollapsed, 'workspace-shell--sidebar-focused': sidebarFocusMode }">
-    <header class="mobile-header">
-      <button class="icon-button" type="button" aria-label="打开导航" @click="mobileDrawerOpen = true">
+  <div class="workspace-shell" :class="{ 'workspace-shell--collapsed': sidebarCollapsed, 'workspace-shell--sidebar-focused': sidebarFocusMode }" :inert="settingsOpen">
+    <header class="mobile-header" :inert="mobileDrawerOpen">
+      <button ref="mobileNavigationTrigger" class="icon-button" type="button" aria-label="打开导航" @click="openMobileDrawer">
         <AppIcon name="menu" :size="20" />
       </button>
       <button class="mobile-brand" type="button" aria-label="返回对话" @click="navigate('/chat')">
@@ -271,7 +358,7 @@ onBeforeUnmount(() => {
 
       <div class="sidebar-footer">
         <div class="sidebar-account-switcher">
-          <button class="sidebar-account-switcher__main" type="button" :title="`切换 Agent:${profileTitle(activeProfile)}`" @click="profileMenuOpen = !profileMenuOpen">
+          <button class="sidebar-account-switcher__main" type="button" :title="`切换 Agent:${profileTitle(activeProfile)}`" aria-haspopup="listbox" :aria-expanded="profileMenuOpen" @click="toggleProfileMenu">
             <AgentAvatar :name="profileTitle(activeProfile)" :avatar="activeProfile?.agentAvatar || ''" :size="30" />
             <span class="account-copy">
               <strong>{{ profileTitle(activeProfile) }}</strong>
@@ -279,32 +366,26 @@ onBeforeUnmount(() => {
             </span>
             <AppIcon class="sidebar-account-switcher__chevron" name="chevron-down" :size="14" />
           </button>
-          <button class="sidebar-account__utility" type="button" :title="theme === 'dark' ? '切换浅色主题' : '切换深色主题'" @click="emit('toggleTheme')">
-            <AppIcon :name="theme === 'dark' ? 'sun' : 'moon'" :size="17" />
-          </button>
-          <button class="sidebar-account__utility" type="button" title="手机登录与节点" aria-label="手机登录与节点" @click="nodePairingOpen = true">
-            <AppIcon name="users" :size="17" />
+          <button class="sidebar-settings-trigger" type="button" title="设置中心" aria-label="打开设置中心" @click="openSettings('agent-identity', $event)">
+            <AppIcon name="settings" :size="17" />
+            <span>设置</span>
           </button>
           <Transition name="menu-fade">
-            <div v-if="profileMenuOpen" class="profile-menu">
+            <div v-if="profileMenuOpen" class="profile-menu" role="listbox" aria-label="切换 Agent" @keydown="handleProfileMenuKeydown">
+              <strong class="profile-menu__heading">切换 Agent</strong>
               <button
                 v-for="profile in profiles"
                 :key="profile.name"
                 type="button"
+                role="option"
                 :class="{ active: profile.name === activeProfile?.name }"
-                @click="emit('selectProfile', profile.name); profileMenuOpen = false"
+                :aria-selected="profile.name === activeProfile?.name"
+                @click="chooseProfile(profile.name)"
               >
                 <AgentAvatar :name="profileTitle(profile)" :avatar="profile.agentAvatar || ''" :size="24" />
                 <strong>{{ profileTitle(profile) }}</strong>
                 <AppIcon v-if="profile.name === activeProfile?.name" name="check" :size="15" />
               </button>
-              <button class="profile-menu__edit" type="button" :disabled="!activeProfile" @click="profileMenuOpen = false; emit('editProfile')"><AppIcon name="settings" :size="15" /><strong>Agent 管理</strong></button>
-              <button class="profile-menu__edit" type="button" @click="profileMenuOpen = false; accountSecurityOpen = true"><AppIcon name="settings" :size="15" /><strong>账号安全</strong></button>
-              <button v-if="isAdmin" class="profile-menu__edit" type="button" @click="profileMenuOpen = false; systemManagementOpen = true"><AppIcon name="settings" :size="15" /><strong>系统管理</strong></button>
-              <button v-if="isAdmin" class="profile-menu__update" type="button" @click="profileMenuOpen = false; systemUpdateOpen = true"><AppIcon name="download" :size="15" /><strong>系统更新</strong></button>
-              <button class="profile-menu__logout" type="button" @click="profileMenuOpen = false; emit('logout')">
-                <AppIcon name="logout" :size="15" /><strong>退出登录</strong>
-              </button>
             </div>
           </Transition>
         </div>
@@ -312,7 +393,7 @@ onBeforeUnmount(() => {
     </aside>
 
     <Transition name="drawer-fade">
-      <button v-if="mobileDrawerOpen" class="drawer-scrim" type="button" aria-label="关闭导航" @click="mobileDrawerOpen = false" />
+      <button v-if="mobileDrawerOpen" class="drawer-scrim" type="button" aria-label="关闭导航" @click="closeMobileDrawer" />
     </Transition>
     <aside
       class="mobile-drawer"
@@ -325,7 +406,7 @@ onBeforeUnmount(() => {
         <button class="sidebar-brand" type="button" aria-label="返回对话" @click="navigate('/chat')">
           <BrandMark :size="32" compact />
         </button>
-        <button class="icon-button" type="button" aria-label="关闭导航" @click="mobileDrawerOpen = false">
+        <button ref="mobileDrawerClose" class="icon-button" type="button" aria-label="关闭导航" @click="closeMobileDrawer">
           <AppIcon name="close" />
         </button>
       </div>
@@ -384,7 +465,7 @@ onBeforeUnmount(() => {
 
       <div class="sidebar-footer mobile-drawer__footer">
         <div class="sidebar-account-switcher">
-          <button class="sidebar-account-switcher__main" type="button" :title="`切换 Agent:${profileTitle(activeProfile)}`" @click="profileMenuOpen = !profileMenuOpen">
+          <button class="sidebar-account-switcher__main" type="button" :title="`切换 Agent:${profileTitle(activeProfile)}`" aria-haspopup="listbox" :aria-expanded="profileMenuOpen" @click="toggleProfileMenu">
             <AgentAvatar :name="profileTitle(activeProfile)" :avatar="activeProfile?.agentAvatar || ''" :size="30" />
             <span class="account-copy">
               <strong>{{ profileTitle(activeProfile) }}</strong>
@@ -392,32 +473,26 @@ onBeforeUnmount(() => {
             </span>
             <AppIcon class="sidebar-account-switcher__chevron" name="chevron-down" :size="14" />
           </button>
-          <button class="sidebar-account__utility" type="button" :title="theme === 'dark' ? '切换浅色主题' : '切换深色主题'" @click="emit('toggleTheme')">
-            <AppIcon :name="theme === 'dark' ? 'sun' : 'moon'" :size="17" />
-          </button>
-          <button class="sidebar-account__utility" type="button" title="手机登录与节点" aria-label="手机登录与节点" @click="nodePairingOpen = true; mobileDrawerOpen = false">
-            <AppIcon name="users" :size="17" />
+          <button class="sidebar-settings-trigger" type="button" title="设置中心" aria-label="打开设置中心" @click="openSettings('agent-identity', $event)">
+            <AppIcon name="settings" :size="17" />
+            <span>设置</span>
           </button>
           <Transition name="menu-fade">
-            <div v-if="profileMenuOpen" class="profile-menu">
+            <div v-if="profileMenuOpen" class="profile-menu" role="listbox" aria-label="切换 Agent" @keydown="handleProfileMenuKeydown">
+              <strong class="profile-menu__heading">切换 Agent</strong>
               <button
                 v-for="profile in profiles"
                 :key="profile.name"
                 type="button"
+                role="option"
                 :class="{ active: profile.name === activeProfile?.name }"
-                @click="emit('selectProfile', profile.name); profileMenuOpen = false"
+                :aria-selected="profile.name === activeProfile?.name"
+                @click="chooseProfile(profile.name)"
               >
                 <AgentAvatar :name="profileTitle(profile)" :avatar="profile.agentAvatar || ''" :size="24" />
                 <strong>{{ profileTitle(profile) }}</strong>
                 <AppIcon v-if="profile.name === activeProfile?.name" name="check" :size="15" />
               </button>
-              <button class="profile-menu__edit" type="button" :disabled="!activeProfile" @click="profileMenuOpen = false; emit('editProfile')"><AppIcon name="settings" :size="15" /><strong>Agent 管理</strong></button>
-              <button class="profile-menu__edit" type="button" @click="profileMenuOpen = false; accountSecurityOpen = true; mobileDrawerOpen = false"><AppIcon name="settings" :size="15" /><strong>账号安全</strong></button>
-              <button v-if="isAdmin" class="profile-menu__edit" type="button" @click="profileMenuOpen = false; systemManagementOpen = true; mobileDrawerOpen = false"><AppIcon name="settings" :size="15" /><strong>系统管理</strong></button>
-              <button v-if="isAdmin" class="profile-menu__update" type="button" @click="profileMenuOpen = false; systemUpdateOpen = true; mobileDrawerOpen = false"><AppIcon name="download" :size="15" /><strong>系统更新</strong></button>
-              <button class="profile-menu__logout" type="button" @click="profileMenuOpen = false; emit('logout')">
-                <AppIcon name="logout" :size="15" /><strong>退出登录</strong>
-              </button>
             </div>
           </Transition>
         </div>
@@ -439,10 +514,28 @@ onBeforeUnmount(() => {
       </aside>
     </Transition>
 
-    <NodePairingDialog :open="nodePairingOpen" :insecure-transport="insecureTransport" :user-name="pairingUserName || userName" :is-admin="isAdmin" @close="nodePairingOpen = false" />
-    <SystemUpdateDialog :open="systemUpdateOpen" @close="systemUpdateOpen = false" />
-    <SystemManagementDialog :open="systemManagementOpen" :upstream-ready="upstreamReady" :upstream-error="upstreamError" @close="systemManagementOpen = false" />
-    <AccountSecurityDialog :open="accountSecurityOpen" @close="accountSecurityOpen = false" />
+    <SettingsCenterDialog
+      :open="settingsOpen"
+      :initial-page="settingsPage"
+      :user-name="userName"
+      :pairing-user-name="pairingUserName"
+      :active-profile="activeProfile"
+      :profiles="profiles"
+      :theme="theme"
+      :theme-preference="themePreference"
+      :insecure-transport="insecureTransport"
+      :is-admin="isAdmin"
+      :upstream-ready="upstreamReady"
+      :upstream-error="upstreamError"
+      :identity-busy="identityBusy"
+      :identity-error="identityError"
+      :identity-reset-version="identityResetVersion"
+      @close="closeSettings"
+      @logout="emit('logout')"
+      @select-profile="emit('selectProfile', $event)"
+      @save-identity="emit('saveIdentity', $event)"
+      @set-theme="emit('setTheme', $event)"
+    />
   </div>
 </template>
 
@@ -478,7 +571,7 @@ onBeforeUnmount(() => {
 .sidebar-brand-row { display: flex; min-height: 58px; align-items: center; padding: 9px 20px 7px; }
 .sidebar-brand { display: flex; min-width: 0; align-items: center; padding: 0; border: 0; border-radius: 10px; background: transparent; cursor: pointer; }
 .sidebar-brand:hover { background: transparent; }
-.sidebar-collapse, .sidebar-account__utility {
+.sidebar-collapse {
   display: grid;
   width: 34px;
   height: 34px;
@@ -491,7 +584,7 @@ onBeforeUnmount(() => {
   color: var(--text-muted);
   cursor: pointer;
 }
-.sidebar-collapse:hover, .sidebar-account__utility:hover { background: var(--surface-hover); color: var(--text-primary); }
+.sidebar-collapse:hover { background: var(--surface-hover); color: var(--text-primary); }
 .sidebar-collapse { position: absolute; top: 12px; right: 11px; transition: transform 180ms var(--ease-out), color 140ms ease, background-color 140ms ease; }
 .sidebar-collapse--collapsed { transform: rotate(180deg); }
 
@@ -591,19 +684,20 @@ onBeforeUnmount(() => {
 .workspace-shell--sidebar-focused .desktop-sidebar--collapsed .sidebar-context { display: flex; flex: 1; }
 
 .sidebar-footer { flex: 0 0 auto; padding: 7px 10px 10px; background: var(--surface); }
-.sidebar-account-switcher { position: relative; z-index: 30; display: flex; min-height: 46px; align-items: center; gap: 6px; padding: 4px 7px; border-top: 1px solid var(--line); }
+.sidebar-account-switcher { position: relative; z-index: 30; display: flex; min-height: 46px; align-items: center; gap: 6px; padding: 4px 2px; border-top: 1px solid var(--line); }
 .sidebar-account-switcher__main { display: flex; min-width: 0; min-height: 38px; flex: 1; align-items: center; gap: 9px; padding: 4px 1px; border: 0; border-radius: 8px; background: transparent; color: var(--text-primary); cursor: pointer; text-align: left; }
 .sidebar-account-switcher__main:hover { background: var(--surface-soft); }
+.sidebar-settings-trigger { display: flex; min-width: 58px; height: 38px; flex: 0 0 auto; align-items: center; justify-content: center; gap: 5px; padding: 0 8px; border: 0; border-radius: 8px; background: transparent; color: var(--text-muted); cursor: pointer; font: 600 11px var(--font-ui); }
+.sidebar-settings-trigger:hover { background: var(--surface-soft); color: var(--text-primary); }
+.sidebar-settings-trigger:focus-visible { outline: 0; box-shadow: inset 0 0 0 1px var(--line-strong), 0 0 0 3px var(--focus-ring); }
 .sidebar-account__avatar { display: grid; width: 30px; height: 30px; flex: 0 0 30px; place-items: center; border-radius: 50%; background: #c91e55; color: #fff; font-size: 12px; font-weight: 700; }
 .sidebar-account-switcher__chevron { flex: 0 0 auto; color: var(--text-muted); }
 .profile-menu { position: absolute; right: 0; bottom: calc(100% + 6px); left: 0; padding: 5px; border: 1px solid var(--line); border-radius: 12px; background: var(--surface-raised); box-shadow: var(--shadow-float); }
+.profile-menu__heading { display: block; padding: 8px 8px 5px; color: var(--text-muted); font-size: 10px; font-weight: 650; }
 .profile-menu button { display: flex; width: 100%; min-height: 38px; align-items: center; gap: 9px; padding: 5px 8px; border: 0; border-radius: 8px; background: transparent; cursor: pointer; text-align: left; }
 .profile-menu button:hover, .profile-menu button.active { background: var(--surface-hover); }
 .profile-menu button > span:not(.agent-avatar) { display: grid; width: 25px; height: 25px; place-items: center; border-radius: 8px; background: var(--surface-soft); color: var(--text-secondary); font-size: 10px; }
 .profile-menu button strong { flex: 1; overflow: hidden; font-size: 12px; text-overflow: ellipsis; }
-.profile-menu__edit { margin-top: 4px; border-top: 1px solid var(--line) !important; color: var(--text-secondary); }
-.profile-menu__update { color: var(--text-secondary); }
-.profile-menu__logout { margin-top: 4px; border-top: 1px solid var(--line) !important; color: var(--danger); }
 .account-copy { display: flex; min-width: 0; flex: 1; flex-direction: column; }
 .account-copy strong, .account-copy span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
 .account-copy strong { font-size: 12px; font-weight: 650; }
@@ -624,9 +718,10 @@ onBeforeUnmount(() => {
 .desktop-sidebar--collapsed .sidebar-account-switcher__chevron,
 .desktop-sidebar--collapsed .account-copy { display: none; }
 .desktop-sidebar--collapsed .sidebar-footer { display: flex; order: 2; margin-top: 0; flex-direction: column; align-items: center; padding: 0 8px 8px; }
-.desktop-sidebar--collapsed .sidebar-account-switcher { width: 42px; flex-direction: column-reverse; gap: 3px; padding: 0; border-top: 0; }
+.desktop-sidebar--collapsed .sidebar-account-switcher { width: 42px; flex-direction: column; gap: 3px; padding: 0; border-top: 0; }
 .desktop-sidebar--collapsed .sidebar-account-switcher__main { width: 42px; min-height: 42px; flex: 0 0 42px; justify-content: center; padding: 0; }
-.desktop-sidebar--collapsed .sidebar-account__utility { display: none; }
+.desktop-sidebar--collapsed .sidebar-settings-trigger { width: 42px; min-width: 42px; height: 42px; padding: 0; }
+.desktop-sidebar--collapsed .sidebar-settings-trigger span { display: none; }
 .desktop-sidebar--collapsed .profile-menu { right: auto; bottom: 0; left: calc(100% + 8px); width: 220px; }
 
 .workspace-main { position: relative; display: flex; min-width: 0; min-height: 0; flex-direction: column; overflow: hidden; background: var(--canvas); }

+ 9 - 8
src/client/components/workspace/WorkspaceView.vue

@@ -1,7 +1,6 @@
 <script setup lang="ts">
 import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
 import WorkspaceShell from '@/components/app/WorkspaceShell.vue'
-import AgentIdentityDialog from '@/components/app/AgentIdentityDialog.vue'
 import { updateProfileIdentity } from '@/api/profiles'
 import { useAuthStore } from '@/stores/auth'
 import { useThemeStore } from '@/stores/theme'
@@ -20,9 +19,9 @@ const auth = useAuthStore()
 const theme = useThemeStore()
 
 const userName = computed(() => auth.user?.displayName || auth.user?.username || '')
-const identityOpen = ref(false)
 const identityBusy = ref(false)
 const identityError = ref('')
+const identityResetVersion = ref(0)
 
 async function logout() { await auth.logout() }
 function selectProfile(name: string) { auth.selectProfile(name) }
@@ -35,7 +34,8 @@ async function saveIdentity(input: { title: string; avatarDataURL?: string | nul
     await updateProfileIdentity(profile, input)
     await auth.refreshProfiles()
     await auth.refreshProfileAvatars()
-    identityOpen.value = false
+    identityError.value = ''
+    identityResetVersion.value += 1
   } catch (cause) {
     identityError.value = cause instanceof Error ? cause.message : '保存 Agent 身份失败'
   } finally {
@@ -47,9 +47,6 @@ async function refreshOnFocus() {
   await auth.refreshProfileAvatars()
 }
 function refreshOnWindowFocus() { void refreshOnFocus().catch(() => undefined) }
-function openIdentity() {
-  void auth.refreshProfileAvatars().finally(() => { identityOpen.value = true })
-}
 onMounted(() => {
   window.addEventListener('focus', refreshOnWindowFocus)
   void auth.refreshProfileAvatars().catch(() => undefined)
@@ -64,10 +61,14 @@ onBeforeUnmount(() => window.removeEventListener('focus', refreshOnWindowFocus))
     :active-profile="auth.activeProfile"
     :profiles="auth.profiles"
     :theme="theme.resolvedTheme"
+    :theme-preference="theme.theme"
     :insecure-transport="auth.insecureLan"
     :is-admin="auth.user?.role === 'admin'"
     :upstream-ready="auth.upstreamReady"
     :upstream-error="auth.upstreamError"
+    :identity-busy="identityBusy"
+    :identity-error="identityError"
+    :identity-reset-version="identityResetVersion"
     :sidebar-title="sidebarTitle"
     :sidebar-subtitle="sidebarSubtitle"
     :sidebar-context-title="sidebarContextTitle"
@@ -76,8 +77,9 @@ onBeforeUnmount(() => window.removeEventListener('focus', refreshOnWindowFocus))
     :inspector-close-label="inspectorCloseLabel"
     @logout="logout"
     @toggle-theme="theme.toggle"
+    @set-theme="theme.setTheme"
     @select-profile="selectProfile"
-    @edit-profile="openIdentity"
+    @save-identity="saveIdentity"
     @close-inspector="emit('closeInspector')"
   >
     <template #sidebar-action><slot name="sidebar-action" /></template>
@@ -87,5 +89,4 @@ onBeforeUnmount(() => window.removeEventListener('focus', refreshOnWindowFocus))
     <template #default><slot /></template>
     <template #inspector><slot name="inspector" /></template>
   </WorkspaceShell>
-  <AgentIdentityDialog :open="identityOpen" :profile="auth.activeProfile" :busy="identityBusy" :error="identityError" :is-admin="auth.user?.role === 'admin'" @close="identityOpen = false" @save="saveIdentity" />
 </template>

+ 100 - 0
tests/client/accountSecurityPanel.test.ts

@@ -0,0 +1,100 @@
+import { flushPromises, mount } from '@vue/test-utils'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import AccountSecurityDialog from '@/components/app/AccountSecurityDialog.vue'
+import AccountSecurityPanel from '@/components/app/AccountSecurityPanel.vue'
+
+const auth = vi.hoisted(() => ({
+  user: { id: 'admin-1', username: 'owner', role: 'admin' } as { id: string; username: string; role: string } | undefined,
+  changeCredentials: vi.fn(async () => undefined),
+  logout: vi.fn(async () => undefined),
+}))
+
+vi.mock('@/stores/auth', () => ({ useAuthStore: () => auth }))
+
+async function fill(wrapper: ReturnType<typeof mount>, name: string, value: string) {
+  await wrapper.get<HTMLInputElement>(`input[name="${name}"]`).setValue(value)
+}
+
+beforeEach(() => {
+  auth.user = { id: 'admin-1', username: 'owner', role: 'admin' }
+  auth.changeCredentials.mockResolvedValue(undefined)
+})
+
+afterEach(() => {
+  document.body.innerHTML = ''
+  vi.clearAllMocks()
+})
+
+describe('Account security panel', () => {
+  it('saves the trimmed administrator username and resets its dirty state', async () => {
+    const wrapper = mount(AccountSecurityPanel, { props: { active: true } })
+
+    expect(wrapper.text()).toContain('不会更改 9119 服务账号')
+    expect(wrapper.emitted('dirty-change')?.at(-1)).toEqual([false])
+    await fill(wrapper, 'username', '  owner-two  ')
+    await fill(wrapper, 'current-password', 'current-secret')
+    await fill(wrapper, 'new-password', 'new-secret-123')
+    await fill(wrapper, 'password-confirmation', 'new-secret-123')
+
+    expect(wrapper.text()).toContain('两次输入一致')
+    expect(wrapper.emitted('dirty-change')?.some(event => event[0] === true)).toBe(true)
+    await wrapper.get('form').trigger('submit')
+    await flushPromises()
+
+    expect(auth.changeCredentials).toHaveBeenCalledWith({
+      currentPassword: 'current-secret',
+      newPassword: 'new-secret-123',
+      username: 'owner-two',
+    })
+    expect(wrapper.emitted('saved')).toHaveLength(1)
+    expect(wrapper.emitted('dirty-change')?.at(-1)).toEqual([false])
+    expect(wrapper.get<HTMLInputElement>('input[name="current-password"]').element.value).toBe('')
+    expect(wrapper.text()).toContain('账号与密码已更新')
+  })
+
+  it('does not expose or submit an administrator username for a regular user', async () => {
+    auth.user = { id: 'user-1', username: 'member', role: 'user' }
+    const wrapper = mount(AccountSecurityPanel, { props: { active: true } })
+
+    expect(wrapper.find('input[name="username"]').exists()).toBe(false)
+    await fill(wrapper, 'current-password', 'current-secret')
+    await fill(wrapper, 'new-password', 'member-secret-123')
+    await fill(wrapper, 'password-confirmation', 'member-secret-123')
+    await wrapper.get('form').trigger('submit')
+    await flushPromises()
+
+    expect(auth.changeCredentials).toHaveBeenCalledWith({
+      currentPassword: 'current-secret',
+      newPassword: 'member-secret-123',
+    })
+  })
+
+  it('resets when activated and exposes logout as an independent action', async () => {
+    const wrapper = mount(AccountSecurityPanel, { props: { active: false } })
+    await fill(wrapper, 'current-password', 'discard-me')
+    expect(wrapper.emitted('dirty-change')?.at(-1)).toEqual([true])
+
+    await wrapper.setProps({ active: true })
+    expect(wrapper.get<HTMLInputElement>('input[name="current-password"]').element.value).toBe('')
+    expect(wrapper.emitted('dirty-change')?.at(-1)).toEqual([false])
+
+    await wrapper.get<HTMLButtonElement>('.logout-button').trigger('click')
+    expect(wrapper.emitted('logout')).toHaveLength(1)
+    expect(auth.logout).not.toHaveBeenCalled()
+  })
+
+  it('keeps the legacy dialog wrapper operational', async () => {
+    const wrapper = mount(AccountSecurityDialog, {
+      attachTo: document.body,
+      props: { open: true },
+    })
+
+    expect(document.querySelector('[role="dialog"][aria-label="账号安全"]')).not.toBeNull()
+    document.querySelector<HTMLButtonElement>('.logout-button')!.click()
+    await flushPromises()
+
+    expect(auth.logout).toHaveBeenCalledOnce()
+    expect(wrapper.emitted('close')).toHaveLength(1)
+    wrapper.unmount()
+  })
+})

+ 31 - 0
tests/client/agentManagementDialog.test.ts

@@ -2,6 +2,8 @@ import { flushPromises, mount } from '@vue/test-utils'
 import { afterEach, describe, expect, it, vi } from 'vitest'
 import { nextTick } from 'vue'
 import AgentIdentityDialog from '@/components/app/AgentIdentityDialog.vue'
+import AgentIdentityPanel from '@/components/app/AgentIdentityPanel.vue'
+import ModelServicesPanel from '@/components/app/ModelServicesPanel.vue'
 import { deleteModelService, listModelServices, saveDuplexVoiceSettings, saveLegacyModelService, saveModelService, validateModelService } from '@/api/agentManagement'
 
 vi.mock('@/api/agentManagement', () => ({
@@ -111,4 +113,33 @@ describe('Agent management dialog', () => {
     expect(saveDuplexVoiceSettings).not.toHaveBeenCalled()
     wrapper.unmount()
   })
+
+  it('preserves an identity draft across same-profile refreshes and resets only after save acknowledgement', async () => {
+    const wrapper = mount(AgentIdentityPanel, { props: { profile, resetVersion: 0 } })
+    const name = wrapper.get<HTMLInputElement>('input[autocomplete="off"]')
+    await name.setValue('本地未保存名称')
+    await wrapper.setProps({ profile: { ...profile, agentName: '远端刷新名称' } })
+    expect(name.element.value).toBe('本地未保存名称')
+
+    await wrapper.setProps({ resetVersion: 1 })
+    expect(name.element.value).toBe('远端刷新名称')
+  })
+
+  it('does not mark an untouched editor dirty or clear a saved key after typing then erasing', async () => {
+    const wrapper = mount(ModelServicesPanel, { props: { profile: 'default' } })
+    await flushPromises()
+    const legacyCard = wrapper.findAll<HTMLElement>('.service-card').find(card => card.text().includes('custom:tingly'))!
+    await legacyCard.get('button').trigger('click')
+    expect(wrapper.emitted('dirty-change')?.at(-1)).toEqual([false])
+
+    const key = wrapper.get<HTMLInputElement>('.service-editor input[type="password"]')
+    await key.setValue('temporary-key')
+    await key.setValue('')
+    expect(wrapper.emitted('dirty-change')?.at(-1)).toEqual([false])
+    await wrapper.get<HTMLFormElement>('.service-editor').trigger('submit')
+    await flushPromises()
+
+    expect(saveLegacyModelService).toHaveBeenCalledOnce()
+    expect(vi.mocked(saveLegacyModelService).mock.calls[0]?.[2]).not.toHaveProperty('api_key')
+  })
 })

+ 20 - 1
tests/client/authProtocol.test.ts

@@ -1,5 +1,5 @@
 import { afterEach, describe, expect, it, vi } from 'vitest'
-import { bootstrap, login } from '@/api/auth'
+import { bootstrap, changeCredentials, login } from '@/api/auth'
 import {
   apiRequest,
   clearApiSecurityContext,
@@ -90,6 +90,25 @@ describe('authentication and CSRF protocol', () => {
     }
   })
 
+  it('does not expire the session when the current password is rejected', async () => {
+    let expirations = 0
+    const unsubscribe = onApiUnauthorized(() => { expirations += 1 })
+    setApiCsrfToken('csrf-account')
+    vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({
+      message: '当前密码错误',
+      code: 'invalid_current_password',
+    }), { status: 401, headers: { 'content-type': 'application/json' } })))
+    try {
+      await expect(changeCredentials({
+        currentPassword: 'wrong',
+        newPassword: 'new-password',
+      })).rejects.toMatchObject({ status: 401, code: 'invalid_current_password' })
+      expect(expirations).toBe(0)
+    } finally {
+      unsubscribe()
+    }
+  })
+
   it('surfaces the first structured upstream validation error', async () => {
     setApiCsrfToken('csrf-validation')
     vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({

+ 172 - 0
tests/client/nodePairingPanel.test.ts

@@ -0,0 +1,172 @@
+import { flushPromises, mount } from '@vue/test-utils'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import QRCode from 'qrcode'
+import NodePairingPanel from '@/components/app/NodePairingPanel.vue'
+import { accountPairingStatus, createAccountPairing } from '@/api/accountPairing'
+import {
+  createPairing,
+  pairChildNode,
+  pairedDevices,
+  pairingStatus,
+  revokePairedDevice,
+  type NodeScope,
+} from '@/api/pairing'
+
+vi.mock('qrcode', () => ({
+  default: {
+    toDataURL: vi.fn(async () => 'data:image/png;base64,cXItY29kZQ=='),
+  },
+}))
+
+vi.mock('@/api/accountPairing', () => ({
+  accountPairingStatus: vi.fn(),
+  createAccountPairing: vi.fn(),
+}))
+
+vi.mock('@/api/pairing', () => ({
+  createPairing: vi.fn(),
+  pairChildNode: vi.fn(),
+  pairedDevices: vi.fn(),
+  pairingStatus: vi.fn(),
+  revokePairedDevice: vi.fn(),
+}))
+
+const allScopes: NodeScope[] = [
+  'agents.read',
+  'history.read',
+  'sessions.execute',
+  'groups.read',
+  'groups.execute',
+]
+
+beforeEach(() => {
+  vi.useFakeTimers()
+  vi.setSystemTime(new Date('2026-08-29T04:00:00.000Z'))
+  vi.mocked(createAccountPairing).mockResolvedValue({
+    protocolVersion: 1,
+    serviceType: 'hermes-yaoyao',
+    pairingId: 'account-pairing-1',
+    expiresAt: Date.now() + 60_000,
+    qrPayload: 'yaoyao://login?v=1&id=account-pairing-1',
+  })
+  vi.mocked(accountPairingStatus).mockResolvedValue({ state: 'pending' })
+  vi.mocked(pairedDevices).mockResolvedValue({
+    nodeId: 'node-12345678',
+    fingerprint: 'fingerprint-1',
+    devices: [],
+  })
+  vi.mocked(createPairing).mockResolvedValue({
+    protocolVersion: 1,
+    pairingId: 'node-pairing-1',
+    nodeId: 'node-12345678',
+    fingerprint: 'fingerprint-1',
+    scopes: [...allScopes],
+    expiresAt: Date.now() + 60_000,
+    qrPayload: 'yaoyao://pair?v=1&id=node-pairing-1',
+  })
+  vi.mocked(pairingStatus).mockResolvedValue({ state: 'pending' })
+  vi.mocked(pairChildNode).mockResolvedValue(undefined)
+  vi.mocked(revokePairedDevice).mockResolvedValue(undefined)
+})
+
+afterEach(() => {
+  document.body.innerHTML = ''
+  vi.clearAllTimers()
+  vi.useRealTimers()
+  vi.clearAllMocks()
+  vi.restoreAllMocks()
+  vi.unstubAllGlobals()
+  Object.defineProperty(navigator, 'mediaDevices', { configurable: true, value: undefined })
+})
+
+describe('NodePairingPanel', () => {
+  it('account 模式只创建账号登录二维码,并在停用时清理轮询', async () => {
+    const wrapper = mount(NodePairingPanel, {
+      attachTo: document.body,
+      props: { active: true, mode: 'account', isAdmin: true, userName: 'admin' },
+      global: { stubs: { AppIcon: true } },
+    })
+    await flushPromises()
+
+    expect(createAccountPairing).toHaveBeenCalledOnce()
+    expect(QRCode.toDataURL).toHaveBeenCalledWith(
+      'yaoyao://login?v=1&id=account-pairing-1',
+      expect.objectContaining({ width: 296, margin: 2, errorCorrectionLevel: 'M' }),
+    )
+    expect(pairedDevices).not.toHaveBeenCalled()
+    expect(createPairing).not.toHaveBeenCalled()
+    expect(wrapper.text()).toContain('在 iOS 登录页选择扫码')
+    expect(vi.getTimerCount()).toBe(1)
+
+    await wrapper.setProps({ active: false })
+    await flushPromises()
+    expect(vi.getTimerCount()).toBe(0)
+
+    wrapper.unmount()
+  })
+
+  it('nodes 模式先只加载设备,生成二维码时传递完整授权范围,并在卸载时清理轮询', async () => {
+    const wrapper = mount(NodePairingPanel, {
+      attachTo: document.body,
+      props: { active: true, mode: 'nodes', isAdmin: true },
+      global: { stubs: { AppIcon: true } },
+    })
+    await flushPromises()
+
+    expect(pairedDevices).toHaveBeenCalledOnce()
+    expect(createAccountPairing).not.toHaveBeenCalled()
+    expect(createPairing).not.toHaveBeenCalled()
+    expect(QRCode.toDataURL).not.toHaveBeenCalled()
+    expect(wrapper.text()).toContain('还没有已授权设备')
+    expect(vi.getTimerCount()).toBe(0)
+
+    await wrapper.get('button.create-pairing').trigger('click')
+    await flushPromises()
+
+    expect(createPairing).toHaveBeenCalledWith(allScopes)
+    expect(QRCode.toDataURL).toHaveBeenCalledWith(
+      'yaoyao://pair?v=1&id=node-pairing-1',
+      expect.objectContaining({ width: 296, margin: 2, errorCorrectionLevel: 'M' }),
+    )
+    expect(vi.getTimerCount()).toBe(1)
+
+    wrapper.unmount()
+    expect(vi.getTimerCount()).toBe(0)
+  })
+
+  it('stops a camera stream whose startup becomes stale before video playback completes', async () => {
+    let resolvePlay: (() => void) | undefined
+    const play = new Promise<void>(resolve => { resolvePlay = resolve })
+    const track = { stop: vi.fn() }
+    const getUserMedia = vi.fn(async () => ({ getTracks: () => [track] }) as unknown as MediaStream)
+    Object.defineProperty(navigator, 'mediaDevices', { configurable: true, value: { getUserMedia } })
+    vi.stubGlobal('BarcodeDetector', class {
+      detect = vi.fn(async () => [])
+    })
+    const playSpy = vi.spyOn(HTMLMediaElement.prototype, 'play').mockReturnValue(play)
+    const wrapper = mount(NodePairingPanel, {
+      attachTo: document.body,
+      props: { active: true, mode: 'nodes', isAdmin: true },
+      global: { stubs: { AppIcon: true } },
+    })
+    await flushPromises()
+
+    const scan = wrapper.findAll<HTMLButtonElement>('button').find(button => button.text().includes('使用摄像头扫码'))!
+    await scan.trigger('click')
+    await flushPromises()
+    await vi.advanceTimersByTimeAsync(0)
+    await flushPromises()
+    expect(getUserMedia).toHaveBeenCalledOnce()
+    expect(playSpy).toHaveBeenCalledOnce()
+
+    const stop = wrapper.findAll<HTMLButtonElement>('button').find(button => button.text().includes('停止扫码'))!
+    await stop.trigger('click')
+    expect(track.stop).toHaveBeenCalledOnce()
+    resolvePlay?.()
+    await flushPromises()
+
+    expect(vi.getTimerCount()).toBe(0)
+    expect(wrapper.text()).not.toContain('停止扫码')
+    wrapper.unmount()
+  })
+})

+ 205 - 0
tests/client/settingsCenterDialog.test.ts

@@ -0,0 +1,205 @@
+import { mount, type VueWrapper } from '@vue/test-utils'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { defineComponent } from 'vue'
+import type { Profile } from '@shared/types'
+import SettingsCenterDialog from '@/components/app/SettingsCenterDialog.vue'
+
+const profiles: Profile[] = [
+  { name: 'ops:blue/team', agentName: '运维 Agent', isDefault: false },
+  { name: 'default', agentName: '丫头', isDefault: true },
+]
+
+function simpleStub(name: string, testId: string) {
+  return defineComponent({ name, template: `<div data-testid="${testId}"></div>` })
+}
+
+const ModelServicesPanelStub = defineComponent({
+  name: 'ModelServicesPanel',
+  props: { profile: { type: String, required: true } },
+  emits: ['dirty-change'],
+  template: '<div data-testid="model-services" :data-profile="profile"><button data-testid="dirty-model" type="button" @click="$emit(\'dirty-change\', true)">dirty</button></div>',
+})
+
+const DuplexVoicePanelStub = defineComponent({
+  name: 'DuplexVoicePanel',
+  emits: ['dirty-change'],
+  template: '<div data-testid="duplex-voice"><button data-testid="dirty-voice" type="button" @click="$emit(\'dirty-change\', true)">dirty</button></div>',
+})
+
+const SystemUpdatePanelStub = defineComponent({
+  name: 'SystemUpdatePanel',
+  emits: ['lock-change'],
+  template: `
+    <div data-testid="system-update">
+      <button data-testid="lock-update" type="button" @click="$emit('lock-change', true)">lock</button>
+      <button data-testid="unlock-update" type="button" @click="$emit('lock-change', false)">unlock</button>
+    </div>
+  `,
+})
+
+const childStubs = {
+  Teleport: true,
+  AgentAvatar: true,
+  AppIcon: true,
+  AgentIdentityPanel: simpleStub('AgentIdentityPanel', 'agent-identity'),
+  ModelServicesPanel: ModelServicesPanelStub,
+  AccountSecurityPanel: simpleStub('AccountSecurityPanel', 'account-security'),
+  NodePairingPanel: simpleStub('NodePairingPanel', 'node-pairing'),
+  SystemOverviewPanel: simpleStub('SystemOverviewPanel', 'system-overview'),
+  SystemManagementPanel: simpleStub('SystemManagementPanel', 'system-management'),
+  DuplexVoicePanel: DuplexVoicePanelStub,
+  SystemUpdatePanel: SystemUpdatePanelStub,
+}
+
+function mountSettings(overrides: Record<string, unknown> = {}) {
+  return mount(SettingsCenterDialog, {
+    attachTo: document.body,
+    props: {
+      open: true,
+      activeProfile: profiles[0],
+      profiles,
+      userName: '管理员',
+      pairingUserName: 'owner',
+      isAdmin: true,
+      ...overrides,
+    } as never,
+    global: { stubs: childStubs },
+  })
+}
+
+function navigationButton(wrapper: VueWrapper, label: string) {
+  const button = wrapper.findAll<HTMLButtonElement>('.settings-sidebar nav button')
+    .find(candidate => candidate.text().includes(label))
+  expect(button, `missing navigation button: ${label}`).toBeDefined()
+  return button!
+}
+
+afterEach(() => {
+  document.body.innerHTML = ''
+  vi.restoreAllMocks()
+  Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1024 })
+})
+
+describe('Settings center dialog', () => {
+  it('groups administrator pages and routes Agent, voice, and system content to the right scope', async () => {
+    const wrapper = mountSettings()
+
+    expect(wrapper.findAll('.settings-sidebar nav h3').map(heading => heading.text())).toEqual([
+      '当前 Agent',
+      '账号',
+      '系统 · 仅管理员',
+    ])
+
+    await navigationButton(wrapper, '模型与 Provider').trigger('click')
+    expect(navigationButton(wrapper, '模型与 Provider').attributes('aria-current')).toBe('page')
+    const modelPanel = wrapper.get('[data-testid="model-services"]')
+    expect(modelPanel.attributes('data-profile')).toBe('ops:blue/team')
+    expect(wrapper.get('.settings-content__header').text()).toContain('正在设置:运维 Agent / ops:blue/team')
+
+    const voiceButton = navigationButton(wrapper, '双流语音')
+    expect(voiceButton.text()).toContain('全局')
+    expect(voiceButton.element.closest('section')?.textContent).toContain('系统 · 仅管理员')
+    await voiceButton.trigger('click')
+    expect(wrapper.find('[data-testid="duplex-voice"]').exists()).toBe(true)
+    expect(wrapper.get('.settings-content__header').text()).toContain('全局设置 · 仅管理员')
+  })
+
+  it('hides model management and the complete system group from non-admin users', () => {
+    const wrapper = mountSettings({ isAdmin: false, initialPage: 'system-update' })
+    const navigation = wrapper.get('.settings-sidebar nav')
+
+    expect(navigation.findAll('h3').map(heading => heading.text())).toEqual(['当前 Agent', '账号'])
+    expect(navigation.text()).not.toContain('模型与 Provider')
+    expect(navigation.text()).not.toContain('系统 · 仅管理员')
+    expect(navigation.text()).not.toContain('系统概览')
+    expect(navigation.text()).not.toContain('双流语音')
+    expect(navigation.text()).not.toContain('更新与回滚')
+    expect(wrapper.find('[data-testid="system-update"]').exists()).toBe(false)
+    expect(wrapper.get('.settings-content__header').text()).toContain('身份与头像')
+  })
+
+  it('blocks closing and page changes while the update panel reports a lock', async () => {
+    const wrapper = mountSettings({ initialPage: 'system-update' })
+    expect(wrapper.find('[data-testid="system-update"]').exists()).toBe(true)
+
+    await wrapper.get('[data-testid="lock-update"]').trigger('click')
+    expect(wrapper.get<HTMLButtonElement>('.settings-center__close').element.disabled).toBe(true)
+
+    await navigationButton(wrapper, '登录与安全').trigger('click')
+    await wrapper.get('.settings-center-layer').trigger('mousedown')
+    expect(wrapper.emitted('close')).toBeUndefined()
+    expect(wrapper.get('.settings-content__header').text()).toContain('更新与回滚')
+    expect(wrapper.find('[data-testid="account-security"]').exists()).toBe(false)
+
+    await wrapper.get('[data-testid="unlock-update"]').trigger('click')
+    await navigationButton(wrapper, '登录与安全').trigger('click')
+    expect(wrapper.find('[data-testid="account-security"]').exists()).toBe(true)
+  })
+
+  it('asks before discarding an edited page and keeps the current page when declined', async () => {
+    const confirm = vi.spyOn(window, 'confirm').mockReturnValueOnce(false).mockReturnValueOnce(true)
+    const wrapper = mountSettings()
+    wrapper.findComponent({ name: 'AgentIdentityPanel' }).vm.$emit('dirty-change', true)
+    await wrapper.vm.$nextTick()
+
+    await navigationButton(wrapper, '登录与安全').trigger('click')
+    expect(confirm).toHaveBeenCalledWith('放弃当前页面未保存的更改?')
+    expect(wrapper.get('.settings-content__header').text()).toContain('身份与头像')
+
+    await navigationButton(wrapper, '登录与安全').trigger('click')
+    expect(wrapper.find('[data-testid="account-security"]').exists()).toBe(true)
+  })
+
+  it('protects Provider and global voice drafts from silent navigation or close', async () => {
+    const confirm = vi.spyOn(window, 'confirm').mockReturnValue(false)
+    const modelWrapper = mountSettings({ initialPage: 'agent-models' })
+    await modelWrapper.get('[data-testid="dirty-model"]').trigger('click')
+    await navigationButton(modelWrapper, '登录与安全').trigger('click')
+    expect(confirm).toHaveBeenCalledWith('放弃当前页面未保存的更改?')
+    expect(modelWrapper.find('[data-testid="model-services"]').exists()).toBe(true)
+    modelWrapper.unmount()
+
+    confirm.mockClear()
+    const voiceWrapper = mountSettings({ initialPage: 'system-voice' })
+    await voiceWrapper.get('[data-testid="dirty-voice"]').trigger('click')
+    await voiceWrapper.get('.settings-center__close').trigger('click')
+    expect(confirm).toHaveBeenCalledWith('放弃当前页面未保存的更改?')
+    expect(voiceWrapper.emitted('close')).toBeUndefined()
+  })
+
+  it('moves mobile focus into details and back to the active category', async () => {
+    Object.defineProperty(window, 'innerWidth', { configurable: true, value: 390 })
+    const wrapper = mountSettings()
+    const identity = navigationButton(wrapper, '身份与头像')
+    await identity.trigger('click')
+    await wrapper.vm.$nextTick()
+    expect(document.activeElement).toBe(wrapper.get('.settings-content__header h3').element)
+
+    await wrapper.get('.mobile-back').trigger('click')
+    await wrapper.vm.$nextTick()
+    expect(document.activeElement).toBe(identity.element)
+
+    const security = navigationButton(wrapper, '登录与安全')
+    await security.trigger('click')
+    await wrapper.vm.$nextTick()
+    expect(document.activeElement).toBe(wrapper.get('.settings-content__header h3').element)
+
+    await wrapper.get('.mobile-back').trigger('click')
+    await wrapper.vm.$nextTick()
+    expect(document.activeElement).toBe(security.element)
+  })
+
+  it('supports listbox keyboard navigation for the Agent selector', async () => {
+    const wrapper = mountSettings()
+    const trigger = wrapper.get('.settings-agent-selector > button')
+    expect(trigger.attributes('aria-haspopup')).toBe('listbox')
+    await trigger.trigger('click')
+    await wrapper.vm.$nextTick()
+    const menu = wrapper.get('.settings-agent-menu')
+    const options = menu.findAll<HTMLButtonElement>('[role="option"]')
+    expect(document.activeElement).toBe(options[0]!.element)
+
+    await menu.trigger('keydown', { key: 'ArrowDown' })
+    expect(document.activeElement).toBe(options[1]!.element)
+  })
+})

+ 64 - 0
tests/client/systemManagementDialog.test.ts

@@ -74,6 +74,24 @@ afterEach(() => {
 })
 
 describe('System management APNs configuration', () => {
+  it('includes the target username in every row action accessible name', async () => {
+    vi.mocked(listUsers).mockResolvedValueOnce([{
+      id: 'user-1',
+      username: 'member',
+      role: 'user',
+      enabled: true,
+      mustChangePassword: false,
+      createdAt: 1,
+      updatedAt: 1,
+    }])
+    const wrapper = await mountOpen()
+
+    expect(document.querySelector('[aria-label="为用户 member 重置密码"]')).not.toBeNull()
+    expect(document.querySelector('[aria-label="禁用用户 member"]')).not.toBeNull()
+    expect(document.querySelector('[aria-label="删除用户 member"]')).not.toBeNull()
+    wrapper.unmount()
+  })
+
   it('validates and saves an FCM service-account path without exposing secret contents', async () => {
     const fcm = {
       configured: false,
@@ -232,4 +250,50 @@ describe('System management APNs configuration', () => {
     expect(input('push-key-file').value).toBe('/srv/secrets/AuthKey_TEST.p8')
     wrapper.unmount()
   })
+
+  it('preserves an unsaved FCM draft while APNs is saved and locks the sibling form during the request', async () => {
+    const fcm = {
+      configured: true,
+      healthy: true,
+      registrationCount: 0,
+      pendingCount: 0,
+      source: 'file' as const,
+      editable: true,
+      managementAvailable: true,
+      serviceAccountFile: '/srv/secrets/firebase.json',
+      projectId: 'server-project',
+      packageName: 'cn.samien.yaoyao.hermes',
+      warnings: [],
+    }
+    vi.mocked(getPushSystemStatus).mockResolvedValueOnce(pushStatus({
+      source: 'file',
+      keyFile: '/srv/secrets/AuthKey_TEST.p8',
+      keyId: 'KEY123',
+      teamId: 'TEAM123',
+      providers: { fcm },
+    }))
+    let resolveSave: ((status: PushSystemStatus) => void) | undefined
+    vi.mocked(savePushSystemConfig).mockReturnValueOnce(new Promise(resolve => { resolveSave = resolve }))
+    const wrapper = await mountOpen()
+    enter('fcm-project-id', 'local-unsaved-project')
+    await nextTick()
+
+    button('验证并启用')!.click()
+    await nextTick()
+    expect(input('fcm-project-id').readOnly).toBe(true)
+    resolveSave?.(pushStatus({
+      configured: true,
+      healthy: true,
+      source: 'file',
+      keyFile: '/srv/secrets/AuthKey_TEST.p8',
+      keyId: 'KEY123',
+      teamId: 'TEAM123',
+      providers: { fcm: { ...fcm, projectId: 'server-response-project' } },
+    }))
+    await flushPromises()
+
+    expect(input('fcm-project-id').value).toBe('local-unsaved-project')
+    expect(input('fcm-project-id').readOnly).toBe(false)
+    wrapper.unmount()
+  })
 })

+ 112 - 0
tests/client/systemUpdatePanel.test.ts

@@ -0,0 +1,112 @@
+import { flushPromises, mount } from '@vue/test-utils'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import SystemUpdatePanel from '@/components/app/SystemUpdatePanel.vue'
+
+const api = vi.hoisted(() => ({
+  applySystemUpdate: vi.fn(),
+  checkSystemUpdate: vi.fn(),
+  rollbackSystemUpdate: vi.fn(),
+  systemUpdateJob: vi.fn(),
+  systemUpdateStatus: vi.fn(),
+}))
+
+vi.mock('@/api/systemUpdate', () => api)
+
+const manifest = {
+  schemaVersion: 1 as const,
+  releaseVersion: '0.2.17',
+  webVersion: '0.2.17',
+  pluginVersion: '1.7.3',
+  gitTag: 'v0.2.17',
+}
+
+const activeJob = {
+  id: 'job-1',
+  operation: 'update' as const,
+  state: 'verifying' as const,
+  message: '正在验证',
+  createdAt: '2026-08-29T00:00:00Z',
+  updatedAt: '2026-08-29T00:00:01Z',
+}
+const doneJob = { ...activeJob, state: 'succeeded' as const, message: '更新完成' }
+const readyStatus = {
+  current: manifest,
+  installedPluginVersion: '1.7.3',
+  versionsMatch: true,
+  installationMode: 'release' as const,
+  latest: manifest,
+  updateAvailable: false,
+  supported: true,
+  canRollback: true,
+}
+
+beforeEach(() => {
+  vi.useFakeTimers()
+  api.systemUpdateStatus.mockReset()
+  api.checkSystemUpdate.mockReset()
+  api.systemUpdateJob.mockReset()
+  api.applySystemUpdate.mockReset()
+  api.rollbackSystemUpdate.mockReset()
+  api.systemUpdateStatus.mockResolvedValue(readyStatus)
+  api.checkSystemUpdate.mockResolvedValue(readyStatus)
+  api.systemUpdateJob.mockResolvedValue(doneJob)
+})
+
+afterEach(() => {
+  document.body.innerHTML = ''
+  vi.clearAllTimers()
+  vi.useRealTimers()
+  vi.restoreAllMocks()
+})
+
+describe('SystemUpdatePanel', () => {
+  it('resumes polling an existing non-terminal job and releases the settings lock', async () => {
+    api.systemUpdateStatus
+      .mockResolvedValueOnce({ ...readyStatus, job: activeJob })
+      .mockResolvedValueOnce({ ...readyStatus, job: doneJob })
+    const wrapper = mount(SystemUpdatePanel, {
+      props: { active: true },
+      global: { stubs: { AppIcon: true } },
+    })
+    await flushPromises()
+    expect(wrapper.emitted('lock-change')?.some(event => event[0] === true)).toBe(true)
+
+    await vi.advanceTimersByTimeAsync(1_000)
+    await flushPromises()
+    expect(api.systemUpdateJob).toHaveBeenCalledWith('job-1')
+    expect(wrapper.text()).toContain('更新完成')
+    expect(wrapper.emitted('lock-change')?.at(-1)).toEqual([false])
+    expect(api.checkSystemUpdate).not.toHaveBeenCalled()
+  })
+
+  it('locks while an update mutation is starting and ignores a stale response after deactivation', async () => {
+    const updateAvailable = {
+      ...readyStatus,
+      latest: { ...manifest, releaseVersion: '0.2.18', webVersion: '0.2.18', gitTag: 'v0.2.18' },
+      updateAvailable: true,
+    }
+    api.systemUpdateStatus.mockResolvedValue(updateAvailable)
+    api.checkSystemUpdate.mockResolvedValue(updateAvailable)
+    let resolveApply: ((job: typeof activeJob) => void) | undefined
+    api.applySystemUpdate.mockReturnValue(new Promise(resolve => { resolveApply = resolve }))
+    vi.spyOn(window, 'confirm').mockReturnValue(true)
+    const wrapper = mount(SystemUpdatePanel, {
+      props: { active: true },
+      global: { stubs: { AppIcon: true } },
+    })
+    await flushPromises()
+
+    const apply = wrapper.findAll<HTMLButtonElement>('button').find(button => button.text().includes('升级配套版本'))!
+    await apply.trigger('click')
+    await wrapper.vm.$nextTick()
+    expect(wrapper.emitted('lock-change')?.at(-1)).toEqual([true])
+    const check = wrapper.findAll<HTMLButtonElement>('button').find(button => button.text().includes('检查更新'))!
+    expect(check.element.disabled).toBe(true)
+
+    await wrapper.setProps({ active: false })
+    resolveApply?.(activeJob)
+    await flushPromises()
+    expect(api.systemUpdateJob).not.toHaveBeenCalled()
+    expect(wrapper.emitted('lock-change')?.at(-1)).toEqual([false])
+  })
+})

+ 119 - 0
tests/client/workspaceShell.test.ts

@@ -0,0 +1,119 @@
+import { mount } from '@vue/test-utils'
+import { afterEach, beforeEach, describe, expect, it } from 'vitest'
+import { createMemoryHistory, createRouter } from 'vue-router'
+import { defineComponent } from 'vue'
+import type { Profile } from '@shared/types'
+import WorkspaceShell from '@/components/app/WorkspaceShell.vue'
+
+const profiles: Profile[] = [
+  { name: 'default', agentName: '丫头', isDefault: true },
+  { name: 'ops:blue/team', agentName: '运维 Agent', isDefault: false },
+]
+
+const SettingsCenterDialogStub = defineComponent({
+  name: 'SettingsCenterDialog',
+  emits: ['close'],
+  props: {
+    open: Boolean,
+    initialPage: String,
+  },
+  template: '<div v-if="open" data-testid="settings-center" :data-page="initialPage"><button data-testid="close-settings" type="button" @click="$emit(\'close\')">close</button></div>',
+})
+
+async function mountShell() {
+  const router = createRouter({
+    history: createMemoryHistory(),
+    routes: [
+      { path: '/chat', component: { template: '<div></div>' } },
+      { path: '/groups', component: { template: '<div></div>' } },
+      { path: '/files', component: { template: '<div></div>' } },
+    ],
+  })
+  await router.push('/chat')
+  await router.isReady()
+  return mount(WorkspaceShell, {
+    attachTo: document.body,
+    props: {
+      activeProfile: profiles[0],
+      profiles,
+      userName: 'owner',
+      pairingUserName: 'owner',
+      isAdmin: true,
+      sidebarTitle: '历史记录',
+    },
+    global: {
+      plugins: [router],
+      stubs: {
+        SettingsCenterDialog: SettingsCenterDialogStub,
+        AgentAvatar: true,
+        AppIcon: true,
+        BrandMark: true,
+        YaoYaoSidebarIcon: true,
+      },
+    },
+  })
+}
+
+beforeEach(() => {
+  localStorage.clear()
+})
+
+afterEach(() => {
+  document.body.innerHTML = ''
+})
+
+describe('Workspace shell account controls', () => {
+  it('keeps Agent switching focused and opens settings from its independent button', async () => {
+    const wrapper = await mountShell()
+    const desktop = wrapper.get('.desktop-sidebar')
+
+    const agentTrigger = desktop.get('.sidebar-account-switcher__main')
+    expect(agentTrigger.attributes('aria-haspopup')).toBe('listbox')
+    expect(agentTrigger.attributes('aria-expanded')).toBe('false')
+    await agentTrigger.trigger('click')
+    expect(agentTrigger.attributes('aria-expanded')).toBe('true')
+    const menu = desktop.get('.profile-menu')
+    expect(menu.attributes('role')).toBe('listbox')
+    expect(menu.text()).toContain('切换 Agent')
+    expect(menu.text()).toContain('丫头')
+    expect(menu.text()).toContain('运维 Agent')
+    expect(menu.text()).not.toContain('Agent 设置')
+    expect(menu.findAll('button')).toHaveLength(profiles.length)
+    expect(menu.findAll('[role="option"]').map(option => option.attributes('aria-selected'))).toEqual(['true', 'false'])
+    expect(menu.text()).not.toContain('账号安全')
+    expect(menu.text()).not.toContain('系统管理')
+    expect(menu.text()).not.toContain('系统更新')
+    expect(menu.text()).not.toContain('退出登录')
+
+    expect(document.activeElement).toBe(menu.find('[role="option"][aria-selected="true"]').element)
+    await menu.trigger('keydown', { key: 'ArrowDown' })
+    const targetProfile = menu.findAll<HTMLButtonElement>('button')
+      .find(button => button.text().includes('运维 Agent'))!
+    expect(document.activeElement).toBe(targetProfile.element)
+    await targetProfile.trigger('click')
+    expect(wrapper.emitted('selectProfile')).toEqual([['ops:blue/team']])
+    expect(desktop.find('.profile-menu').exists()).toBe(false)
+
+    const settingsTrigger = desktop.get<HTMLButtonElement>('.sidebar-settings-trigger')
+    await settingsTrigger.trigger('click')
+    const settings = wrapper.get('[data-testid="settings-center"]')
+    expect(settings.attributes('data-page')).toBe('agent-identity')
+    expect(desktop.find('.profile-menu').exists()).toBe(false)
+
+    await wrapper.get('[data-testid="close-settings"]').trigger('click')
+    await wrapper.vm.$nextTick()
+    expect(document.activeElement).toBe(settingsTrigger.element)
+
+    const mobileNavigation = wrapper.get<HTMLButtonElement>('.mobile-header button[aria-label="打开导航"]')
+    await mobileNavigation.trigger('click')
+    await wrapper.vm.$nextTick()
+    expect(wrapper.get('.mobile-header').attributes()).toHaveProperty('inert')
+    expect(document.activeElement).toBe(wrapper.get('.mobile-drawer button[aria-label="关闭导航"]').element)
+    await wrapper.get('.mobile-drawer .sidebar-settings-trigger').trigger('click')
+    await wrapper.get('[data-testid="close-settings"]').trigger('click')
+    await wrapper.vm.$nextTick()
+    expect(document.activeElement).toBe(mobileNavigation.element)
+
+    wrapper.unmount()
+  })
+})

Einige Dateien werden nicht angezeigt, da zu viele Dateien in diesem Diff geändert wurden.