SettingsCenterDialog.vue 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469
  1. <script setup lang="ts">
  2. import { computed, nextTick, reactive, ref, watch } from 'vue'
  3. import type { Profile } from '@shared/types'
  4. import AccountSecurityPanel from '@/components/app/AccountSecurityPanel.vue'
  5. import AgentIdentityPanel from '@/components/app/AgentIdentityPanel.vue'
  6. import DuplexVoicePanel from '@/components/app/DuplexVoicePanel.vue'
  7. import ModelServicesPanel from '@/components/app/ModelServicesPanel.vue'
  8. import NodePairingPanel from '@/components/app/NodePairingPanel.vue'
  9. import WorkspaceNodesPanel from '@/components/workspace/WorkspaceNodesPanel.vue'
  10. import WorkspaceVoiceProviders from '@/components/workspace/WorkspaceVoiceProviders.vue'
  11. import SystemManagementPanel from '@/components/app/SystemManagementPanel.vue'
  12. import SystemOverviewPanel from '@/components/app/SystemOverviewPanel.vue'
  13. import FileAccessPanel from '@/components/app/FileAccessPanel.vue'
  14. import SystemUpdatePanel from '@/components/app/SystemUpdatePanel.vue'
  15. import AgentAvatar from '@/components/common/AgentAvatar.vue'
  16. import type { ProfileIdentityInput } from '@/api/profiles'
  17. import AppIcon from '@/components/common/AppIcon.vue'
  18. import { CheckmarkCircle, EllipseOutline } from '@vicons/ionicons5'
  19. type SettingsPage =
  20. | 'agent-identity'
  21. | 'agent-models'
  22. | 'account-profile'
  23. | 'account-security'
  24. | 'account-mobile'
  25. | 'appearance'
  26. | 'system-overview'
  27. | 'system-file-access'
  28. | 'system-users'
  29. | 'system-connection'
  30. | 'system-memory'
  31. | 'system-push'
  32. | 'system-nodes'
  33. | 'system-voice'
  34. | 'system-update'
  35. type SettingsIcon = 'users' | 'model' | 'settings' | 'panel' | 'monitor' | 'sun' | 'link' | 'bell' | 'audio' | 'refresh'
  36. type ThemePreference = 'light' | 'dark' | 'system'
  37. interface NavigationItem {
  38. key: SettingsPage
  39. label: string
  40. icon: SettingsIcon
  41. }
  42. const props = withDefaults(defineProps<{
  43. open: boolean
  44. botMode?: boolean
  45. initialPage?: SettingsPage
  46. userName?: string
  47. userAvatar?: string
  48. pairingUserName?: string
  49. activeProfile?: Profile
  50. profiles?: Profile[]
  51. theme?: 'light' | 'dark'
  52. themePreference?: ThemePreference
  53. insecureTransport?: boolean
  54. isAdmin?: boolean
  55. upstreamReady?: boolean
  56. upstreamError?: string
  57. identityBusy?: boolean
  58. identityError?: string
  59. identityResetVersion?: number
  60. }>(), {
  61. initialPage: 'agent-identity',
  62. userName: '',
  63. userAvatar: '',
  64. pairingUserName: '',
  65. activeProfile: undefined,
  66. profiles: () => [],
  67. theme: 'light',
  68. themePreference: 'system',
  69. insecureTransport: false,
  70. isAdmin: false,
  71. upstreamReady: false,
  72. upstreamError: '',
  73. identityBusy: false,
  74. identityError: '',
  75. identityResetVersion: 0,
  76. })
  77. const emit = defineEmits<{
  78. close: []
  79. logout: []
  80. 'select-profile': [profile: string]
  81. 'save-identity': [input: ProfileIdentityInput]
  82. 'set-theme': [theme: ThemePreference]
  83. 'switch-mode': []
  84. }>()
  85. const dialog = ref<HTMLElement>()
  86. const contentTitle = ref<HTMLElement>()
  87. const settingsAgentTrigger = ref<HTMLButtonElement>()
  88. const activePage = ref<SettingsPage>('agent-identity')
  89. const profileMenuOpen = ref(false)
  90. const mobileDetailOpen = ref(false)
  91. const updateLocked = ref(false)
  92. const accountCanSave = ref(false)
  93. const dirtyPages = reactive<Partial<Record<SettingsPage, boolean>>>({})
  94. const settingsQuery = ref('')
  95. const agentItems = computed<NavigationItem[]>(() => !props.isAdmin || props.botMode ? [] : [
  96. { key: 'agent-identity', label: '身份与头像', icon: 'users' },
  97. ...(props.isAdmin ? [{ key: 'agent-models', label: '模型与 Provider', icon: 'model' } satisfies NavigationItem] : []),
  98. ])
  99. const accountItems = computed<NavigationItem[]>(() => [
  100. { key: 'account-profile', label: '账号资料', icon: 'users' },
  101. { key: 'account-security', label: '登录与安全', icon: 'settings' },
  102. { key: 'appearance', label: '外观', icon: 'sun' },
  103. ...(props.isAdmin ? [{ key: 'account-mobile', label: '手机登录', icon: 'panel' } satisfies NavigationItem] : []),
  104. ])
  105. const systemItems: NavigationItem[] = [
  106. { key: 'system-overview', label: '系统概览', icon: 'panel' },
  107. { key: 'system-connection', label: 'Hermes 连接', icon: 'link' },
  108. { key: 'system-memory', label: 'Bot 记忆', icon: 'panel' },
  109. { key: 'system-users', label: '用户与权限', icon: 'users' },
  110. { key: 'system-nodes', label: '节点与设备', icon: 'panel' },
  111. { key: 'system-file-access', label: '文件访问', icon: 'panel' },
  112. { key: 'system-push', label: '消息推送', icon: 'bell' },
  113. { key: 'system-voice', label: '双流语音', icon: 'audio' },
  114. { key: 'system-update', label: '更新与回滚', icon: 'refresh' },
  115. ]
  116. const matchesSearch = (item: NavigationItem) => item.label.toLocaleLowerCase().includes(settingsQuery.value.trim().toLocaleLowerCase())
  117. const visibleAccounts = computed(() => accountItems.value.filter(matchesSearch))
  118. const visibleSystems = computed(() => systemItems.filter(matchesSearch))
  119. const visibleAgents = computed(() => agentItems.value.filter(matchesSearch))
  120. const allAllowedPages = computed(() => new Set<SettingsPage>([
  121. ...agentItems.value.map(item => item.key),
  122. ...accountItems.value.map(item => item.key),
  123. ...(props.isAdmin ? systemItems.map(item => item.key) : []),
  124. ]))
  125. const activeDirty = computed(() => Boolean(dirtyPages[activePage.value]))
  126. const showFixedFooter = computed(() => activePage.value === 'agent-identity' || activePage.value === 'account-security')
  127. const activeTitle = computed(() => ({
  128. 'agent-identity': '身份与头像',
  129. 'agent-models': '模型与 Provider',
  130. 'account-profile': '账号资料',
  131. 'account-security': '登录与安全',
  132. 'account-mobile': '手机登录',
  133. appearance: '外观',
  134. 'system-overview': '系统概览',
  135. 'system-file-access': '文件访问',
  136. 'system-users': '用户与权限',
  137. 'system-connection': 'Hermes 连接',
  138. 'system-memory': 'Bot 记忆',
  139. 'system-push': '消息推送',
  140. 'system-nodes': '节点与设备',
  141. 'system-voice': '双流语音',
  142. 'system-update': '更新与回滚',
  143. })[activePage.value])
  144. const accountName = computed(() => props.pairingUserName || props.userName || '当前账号')
  145. const showAgentSelector = computed(() => !props.botMode && activePage.value.startsWith('agent-'))
  146. const activeScope = computed(() => {
  147. if (activePage.value.startsWith('agent-')) return `正在设置:${profileTitle(props.activeProfile)} / ${props.activeProfile?.name || '未选择'}`
  148. if (activePage.value === 'account-profile') return '管理你的账号头像与服务器名称。'
  149. if (activePage.value.startsWith('account-')) return `当前账号:${accountName.value}${props.isAdmin ? ' · 管理员' : ''}`
  150. if (activePage.value === 'appearance') return '选择当前浏览器的显示方式。'
  151. return '全局设置 · 仅管理员'
  152. })
  153. function profileTitle(profile?: Profile): string {
  154. return profile?.agentName || profile?.displayName || profile?.name || '未选择机器人'
  155. }
  156. function setDirty(page: SettingsPage, dirty: boolean) {
  157. dirtyPages[page] = dirty
  158. }
  159. function confirmDiscard(): boolean {
  160. return !activeDirty.value || window.confirm('放弃当前页面未保存的更改?')
  161. }
  162. function selectPage(page: SettingsPage) {
  163. if (page === activePage.value) {
  164. mobileDetailOpen.value = true
  165. void nextTick(() => {
  166. if (window.innerWidth < 768) contentTitle.value?.focus()
  167. })
  168. return
  169. }
  170. if (updateLocked.value || !allAllowedPages.value.has(page) || !confirmDiscard()) return
  171. dirtyPages[activePage.value] = false
  172. activePage.value = page
  173. accountCanSave.value = false
  174. mobileDetailOpen.value = true
  175. profileMenuOpen.value = false
  176. void nextTick(() => {
  177. if (window.innerWidth < 768) contentTitle.value?.focus()
  178. })
  179. }
  180. function requestClose() {
  181. if (updateLocked.value || !confirmDiscard()) return
  182. dirtyPages[activePage.value] = false
  183. emit('close')
  184. }
  185. function backToMenu() {
  186. if (updateLocked.value || !confirmDiscard()) return
  187. dirtyPages[activePage.value] = false
  188. mobileDetailOpen.value = false
  189. void nextTick(() => {
  190. dialog.value?.querySelector<HTMLButtonElement>('.settings-sidebar nav button[aria-current="page"]')?.focus()
  191. })
  192. }
  193. function selectProfile(profile: string) {
  194. if (updateLocked.value || !confirmDiscard()) return
  195. dirtyPages[activePage.value] = false
  196. profileMenuOpen.value = false
  197. emit('select-profile', profile)
  198. void nextTick(() => settingsAgentTrigger.value?.focus())
  199. }
  200. function toggleAgentMenu() {
  201. profileMenuOpen.value = !profileMenuOpen.value
  202. if (!profileMenuOpen.value) return
  203. void nextTick(() => {
  204. dialog.value?.querySelector<HTMLButtonElement>('.settings-agent-menu [role="option"][aria-selected="true"]')?.focus()
  205. })
  206. }
  207. function handleAgentMenuKeydown(event: KeyboardEvent) {
  208. const options = [...(event.currentTarget as HTMLElement).querySelectorAll<HTMLButtonElement>('[role="option"]')]
  209. if (!options.length) return
  210. const current = Math.max(0, options.indexOf(document.activeElement as HTMLButtonElement))
  211. let next: number | undefined
  212. if (event.key === 'ArrowDown') next = (current + 1) % options.length
  213. else if (event.key === 'ArrowUp') next = (current - 1 + options.length) % options.length
  214. else if (event.key === 'Home') next = 0
  215. else if (event.key === 'End') next = options.length - 1
  216. else if (event.key === 'Escape') {
  217. event.preventDefault()
  218. profileMenuOpen.value = false
  219. void nextTick(() => settingsAgentTrigger.value?.focus())
  220. return
  221. }
  222. if (next === undefined) return
  223. event.preventDefault()
  224. options[next]?.focus()
  225. }
  226. function handleEscape() {
  227. if (profileMenuOpen.value) {
  228. profileMenuOpen.value = false
  229. void nextTick(() => settingsAgentTrigger.value?.focus())
  230. return
  231. }
  232. if (window.innerWidth < 768 && mobileDetailOpen.value) { backToMenu(); return }
  233. requestClose()
  234. }
  235. function trapFocus(event: KeyboardEvent) {
  236. const items = [...(dialog.value?.querySelectorAll<HTMLElement>('button:not(:disabled),input:not(:disabled),select:not(:disabled),textarea:not(:disabled),a[href],[tabindex="0"]') ?? [])].filter(el => el.tabIndex >= 0 && el.getClientRects().length)
  237. const first = items[0], last = items.at(-1)
  238. if (!first || !last) return
  239. if (event.shiftKey && (document.activeElement === first || document.activeElement === dialog.value)) { event.preventDefault(); last.focus() }
  240. else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus() }
  241. }
  242. watch(() => [props.open, props.initialPage, props.botMode, props.isAdmin] as const, ([open, initialPage]) => {
  243. if (!open) {
  244. profileMenuOpen.value = false
  245. updateLocked.value = false
  246. return
  247. }
  248. activePage.value = allAllowedPages.value.has(initialPage)
  249. ? initialPage
  250. : !props.botMode && props.isAdmin && props.activeProfile ? 'agent-identity' : 'account-profile'
  251. settingsQuery.value = ''
  252. for (const key of Object.keys(dirtyPages) as SettingsPage[]) dirtyPages[key] = false
  253. profileMenuOpen.value = false
  254. mobileDetailOpen.value = false
  255. void nextTick(() => dialog.value?.focus())
  256. }, { immediate: true })
  257. function requestModeSwitch() {
  258. if (updateLocked.value || !confirmDiscard()) return
  259. emit('switch-mode')
  260. }
  261. </script>
  262. <template>
  263. <Teleport to="body">
  264. <Transition name="settings-center-fade">
  265. <div v-if="open" class="settings-center-layer" role="presentation" @mousedown.self="requestClose">
  266. <section
  267. ref="dialog"
  268. class="settings-center"
  269. :class="{ 'settings-center--mobile-detail': mobileDetailOpen }"
  270. role="dialog"
  271. aria-modal="true"
  272. aria-label="我的设置"
  273. tabindex="-1"
  274. @keydown.esc.capture.prevent.stop="handleEscape"
  275. @keydown.tab="trapFocus"
  276. >
  277. <header class="settings-center__topbar"><h2>我的设置</h2><button class="settings-center__close" type="button" aria-label="关闭我的设置" :disabled="updateLocked" @click="requestClose"><AppIcon name="close" :size="20"/></button></header>
  278. <div class="settings-center__body">
  279. <aside class="settings-sidebar" aria-label="设置分类">
  280. <header class="settings-sidebar__header">
  281. <h2 id="settings-center-title">我的设置</h2>
  282. <button class="settings-center__close" type="button" aria-label="关闭我的设置" :disabled="updateLocked" @click="requestClose"><AppIcon name="close" :size="18" /></button>
  283. </header>
  284. <div class="settings-sidebar__scroll">
  285. <label class="settings-search"><AppIcon name="search" :size="16"/><input v-model="settingsQuery" type="search" aria-label="搜索设置" placeholder="搜索设置" /></label>
  286. <div v-if="showAgentSelector" class="settings-agent-selector">
  287. <button ref="settingsAgentTrigger" type="button" aria-haspopup="listbox" :aria-expanded="profileMenuOpen" @click="toggleAgentMenu">
  288. <AgentAvatar :name="profileTitle(activeProfile)" :avatar="activeProfile?.agentAvatar || ''" :size="34" />
  289. <span><strong>{{ profileTitle(activeProfile) }}</strong><small>{{ activeProfile?.name || '未选择机器人' }}</small></span>
  290. <AppIcon name="chevron-down" :size="14" />
  291. </button>
  292. <div v-if="profileMenuOpen" class="settings-agent-menu" role="listbox" aria-label="切换正在设置的机器人" @keydown="handleAgentMenuKeydown">
  293. <button v-for="profile in profiles" :key="profile.name" type="button" role="option" :aria-selected="profile.name === activeProfile?.name" @click="selectProfile(profile.name)">
  294. <AgentAvatar :name="profileTitle(profile)" :avatar="profile.agentAvatar || ''" :size="24" />
  295. <span><strong>{{ profileTitle(profile) }}</strong><small>{{ profile.name }}</small></span>
  296. <AppIcon v-if="profile.name === activeProfile?.name" name="check" :size="14" />
  297. </button>
  298. </div>
  299. </div>
  300. <nav>
  301. <section v-if="visibleAccounts.length">
  302. <h3>个人</h3>
  303. <button v-for="item in visibleAccounts" :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="18" /><span>{{ item.label }}</span></button>
  304. </section>
  305. <section v-if="isAdmin && visibleSystems.length">
  306. <h3>管理</h3>
  307. <button v-for="item in visibleSystems" :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="18" /><span>{{ item.label }}</span><em v-if="item.key === 'system-voice'">全局</em></button>
  308. </section>
  309. <section v-if="visibleAgents.length">
  310. <h3>基础机器人</h3>
  311. <button v-for="item in visibleAgents" :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="18" /><span>{{ item.label }}</span></button>
  312. </section>
  313. <section v-if="isAdmin && !settingsQuery"><button type="button" :disabled="updateLocked" @click="requestModeSwitch"><AppIcon :name="botMode ? 'chat' : 'users'" :size="18" /><span>{{ botMode ? '进入聊天模式' : '进入 Bot 模式' }}</span></button></section>
  314. <p v-if="!visibleAccounts.length && (!isAdmin || !visibleSystems.length) && !visibleAgents.length" class="settings-search-empty">没有匹配的设置</p>
  315. </nav>
  316. </div>
  317. </aside>
  318. <main class="settings-content" :class="{ 'settings-content--with-footer': showFixedFooter }">
  319. <header class="settings-content__header">
  320. <button v-if="mobileDetailOpen" class="mobile-back" type="button" aria-label="返回设置分类" :disabled="updateLocked" @click="backToMenu"><AppIcon name="chevron-left" :size="20" /></button>
  321. <div class="settings-content__heading"><h3 ref="contentTitle" tabindex="-1">{{ activeTitle }}</h3><p>{{ activeScope }}</p></div>
  322. <button class="settings-center__close settings-detail-close" type="button" aria-label="关闭我的设置" :disabled="updateLocked" @click="requestClose"><AppIcon name="close" :size="18" /></button>
  323. </header>
  324. <div class="settings-content__scroll">
  325. <AgentIdentityPanel
  326. v-if="activePage === 'agent-identity' && activeProfile && !botMode"
  327. :key="activeProfile.name"
  328. :profile="activeProfile"
  329. :busy="identityBusy"
  330. :error="identityError"
  331. :reset-version="identityResetVersion"
  332. form-id="settings-agent-identity-form"
  333. :show-actions="false"
  334. @dirty-change="setDirty('agent-identity', $event)"
  335. @save="emit('save-identity', $event)"
  336. />
  337. <p v-else-if="activePage === 'agent-identity'" class="settings-empty">尚未选择机器人。</p>
  338. <ModelServicesPanel v-else-if="activePage === 'agent-models' && activeProfile && isAdmin && !botMode" :key="activeProfile.name" :profile="activeProfile.name" @dirty-change="setDirty('agent-models', $event)" />
  339. <AccountSecurityPanel
  340. v-else-if="activePage === 'account-security' || activePage === 'account-profile'"
  341. :key="activePage"
  342. :section="activePage === 'account-profile' ? 'profile' : 'security'"
  343. :active="true"
  344. form-id="settings-account-security-form"
  345. :show-actions="false"
  346. @dirty-change="setDirty(activePage, $event)"
  347. @can-save-change="accountCanSave = $event"
  348. @saved="setDirty(activePage, false)"
  349. @logout="emit('logout')"
  350. />
  351. <NodePairingPanel v-else-if="activePage === 'account-mobile'" mode="account" :active="true" :insecure-transport="insecureTransport" :user-name="accountName" />
  352. <section v-else-if="activePage === 'appearance'" class="appearance-panel" aria-label="外观">
  353. <h4>界面主题</h4>
  354. <div class="theme-options" role="radiogroup" aria-label="界面主题">
  355. <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])">
  356. <img :src="`/setting-previews/${option[0]}.png`" alt="" width="480" height="300"/>
  357. <strong>{{ option[1] }}</strong>
  358. <component :is="themePreference === option[0] ? CheckmarkCircle : EllipseOutline" class="theme-choice-mark" aria-hidden="true"/>
  359. </button>
  360. </div>
  361. <p class="appearance-note"><AppIcon name="info" :size="16"/>更改仅影响当前浏览器。</p>
  362. </section>
  363. <SystemOverviewPanel v-else-if="activePage === 'system-overview' && isAdmin" :active="true" :upstream-ready="upstreamReady" :upstream-error="upstreamError" @navigate="selectPage" />
  364. <FileAccessPanel v-else-if="activePage === 'system-file-access' && isAdmin" :profile="activeProfile?.name" @dirty-change="setDirty('system-file-access', $event)" />
  365. <SystemManagementPanel v-else-if="activePage === 'system-users' && isAdmin" section="users" :profiles="profiles" :active="true" @dirty-change="setDirty('system-users', $event)" />
  366. <SystemManagementPanel v-else-if="activePage === 'system-connection' && isAdmin" section="connection" :active="true" :profiles="profiles" :upstream-ready="upstreamReady" :upstream-error="upstreamError" @dirty-change="setDirty('system-connection', $event)" />
  367. <SystemManagementPanel v-else-if="activePage === 'system-memory' && isAdmin" section="memory" :active="true" @dirty-change="setDirty('system-memory', $event)" />
  368. <SystemManagementPanel v-else-if="activePage === 'system-push' && isAdmin" section="push" :active="true" @dirty-change="setDirty('system-push', $event)" />
  369. <WorkspaceNodesPanel v-else-if="activePage === 'system-nodes' && isAdmin" />
  370. <section v-else-if="activePage === 'system-voice' && isAdmin"><DuplexVoicePanel @dirty-change="setDirty('system-voice', $event)" /><WorkspaceVoiceProviders /></section>
  371. <SystemUpdatePanel v-else-if="activePage === 'system-update' && isAdmin" :active="true" @lock-change="updateLocked = $event" />
  372. </div>
  373. <footer v-if="showFixedFooter" class="settings-content__footer">
  374. <button class="settings-footer__cancel" type="button" :disabled="identityBusy" @click="requestClose">取消</button>
  375. <button
  376. v-if="activePage === 'agent-identity'"
  377. class="settings-footer__save"
  378. type="submit"
  379. form="settings-agent-identity-form"
  380. :disabled="identityBusy || !activeDirty"
  381. >{{ identityBusy ? '正在同步…' : '保存更改' }}</button>
  382. <button
  383. v-else
  384. class="settings-footer__save"
  385. type="submit"
  386. form="settings-account-security-form"
  387. :disabled="!accountCanSave"
  388. >保存更改</button>
  389. </footer>
  390. </main>
  391. </div>
  392. </section>
  393. </div>
  394. </Transition>
  395. </Teleport>
  396. </template>
  397. <style scoped>
  398. .settings-center-layer{position:fixed;z-index:300;inset:0;display:grid;place-items:center;padding:24px;background:var(--scrim);backdrop-filter:blur(3px)}
  399. .settings-center{display:flex;flex-direction:column;width:min(900px,calc(100vw - 48px));height:min(650px,calc(100dvh - 64px));overflow:hidden;border:1px solid var(--line);border-radius:14px;outline:0;background:var(--surface);box-shadow:0 24px 72px #0003;color:var(--text-primary)}
  400. .settings-center__topbar{height:60px;box-sizing:border-box;display:flex;align-items:center;justify-content:space-between;flex-shrink:0;padding:8px 16px 8px 20px;border-bottom:1px solid var(--line)}
  401. .settings-center__topbar h2{margin:0;font-size:18px;font-weight:650;letter-spacing:-.02em}
  402. .settings-center__close,.mobile-back{display:grid;width:44px;height:44px;place-items:center;flex-shrink:0;padding:0;border:0;border-radius:9px;background:transparent;color:var(--text-secondary);cursor:pointer}
  403. .settings-center__close:hover,.mobile-back:hover{background:var(--surface-hover);color:var(--text-primary)}
  404. .settings-center__close:disabled,.mobile-back:disabled{cursor:not-allowed;opacity:.45}
  405. .settings-center button:focus-visible,.settings-center input:focus-visible{outline:2px solid var(--accent);outline-offset:-2px}
  406. .mobile-back,.settings-detail-close{display:none}
  407. .settings-center__body{display:grid;flex:1;min-height:0;grid-template-columns:208px minmax(0,1fr)}
  408. .settings-sidebar{position:relative;min-height:0;overflow-y:auto;padding:16px 12px;border-right:1px solid var(--line);background:var(--settings-sidebar);overscroll-behavior:contain;scrollbar-width:thin}
  409. .settings-sidebar__header{display:none}.settings-sidebar__header h2{margin:0;font-size:18px;font-weight:650}
  410. .settings-sidebar__scroll{min-height:0}
  411. .settings-search{display:flex;align-items:center;gap:8px;height:38px;padding:0 10px;margin-bottom:22px;border:1px solid var(--line);border-radius:8px;background:var(--settings-panel);color:var(--text-muted)}
  412. .settings-search input{min-width:0;width:100%;height:100%;padding:0;border:0;outline:none;background:transparent;color:var(--text-primary);font:13px var(--font-ui)}
  413. .settings-search:focus-within{outline:2px solid var(--accent);outline-offset:2px}
  414. .settings-agent-selector{position:relative;margin:0 0 20px;padding-bottom:12px;border-bottom:1px solid var(--line)}
  415. .settings-agent-selector>button{display:grid;width:100%;min-height:44px;grid-template-columns:34px minmax(0,1fr) 16px;align-items:center;gap:8px;padding:2px 4px;border:0;border-radius:9px;background:transparent;color:var(--text-primary);cursor:pointer;text-align:left}
  416. .settings-agent-selector>button:hover{background:var(--surface-hover)}
  417. .settings-agent-selector span,.settings-agent-menu span{display:grid;min-width:0;gap:2px}
  418. .settings-agent-selector strong{overflow:hidden;font-size:13px;text-overflow:ellipsis;white-space:nowrap}.settings-agent-selector small,.settings-agent-menu small{overflow:hidden;color:var(--text-muted);font-size:11px;text-overflow:ellipsis;white-space:nowrap}
  419. .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:10px;background:var(--surface);box-shadow:var(--shadow-float)}
  420. .settings-agent-menu button{display:grid;width:100%;min-height:40px;grid-template-columns:24px minmax(0,1fr) 16px;align-items:center;gap:8px;padding:4px 6px;border:0;border-radius:8px;background:transparent;color:var(--text-primary);cursor:pointer;text-align:left}
  421. .settings-agent-menu button:hover,.settings-agent-menu button[aria-selected=true]{background:var(--surface-hover)}.settings-agent-menu strong{overflow:hidden;font-size:12px;text-overflow:ellipsis;white-space:nowrap}
  422. .settings-sidebar nav{display:grid;gap:22px}
  423. .settings-sidebar nav section{display:grid;gap:3px}.settings-sidebar nav section+section{padding-top:20px;border-top:1px solid var(--line)}
  424. .settings-sidebar h3{margin:0 10px 6px;color:var(--text-muted);font-size:12px;font-weight:500}
  425. .settings-sidebar nav button{display:flex;width:100%;min-height:44px;align-items:center;gap:12px;padding:8px 12px;border:0;border-radius:9px;background:transparent;color:var(--text-primary);cursor:pointer;text-align:left;font:500 14px var(--font-ui)}
  426. .settings-sidebar nav button:hover{background:var(--surface-hover)}.settings-sidebar nav button.active{background:var(--settings-selected);font-weight:600}
  427. .settings-sidebar nav button span{min-width:0;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.settings-sidebar nav button em{color:var(--text-muted);font:normal 10px var(--font-ui)}
  428. .settings-search-empty{margin:0 10px;font-size:13px;line-height:1.6;color:var(--text-secondary)}
  429. .settings-content{display:grid;min-width:0;min-height:0;grid-template-rows:auto minmax(0,1fr);background:var(--surface)}
  430. .settings-content--with-footer{grid-template-rows:auto minmax(0,1fr) 64px}
  431. .settings-content__header{display:grid;grid-template-columns:minmax(0,1fr);align-items:center;gap:8px;padding:24px 24px 16px}
  432. .settings-content__heading{min-width:0}.settings-content__header h3{margin:0;font-size:18px;font-weight:650;letter-spacing:-.01em}.settings-content__header p{margin:6px 0 0;color:var(--text-secondary);font-size:13px;line-height:1.6}
  433. .settings-content__scroll{min-height:0;overflow-y:auto;padding:8px 24px 24px;overscroll-behavior:contain;scrollbar-width:thin}
  434. .settings-content__footer{display:flex;align-items:center;justify-content:flex-end;gap:10px;padding:0 24px;border-top:1px solid var(--line);background:var(--surface)}
  435. .settings-content__footer button{display:inline-flex;min-width:88px;min-height:40px;align-items:center;justify-content:center;padding:0 16px;border-radius:8px;cursor:pointer;font:550 13px var(--font-ui)}
  436. .settings-footer__cancel{border:1px solid var(--line);background:var(--settings-panel);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}
  437. .settings-empty{margin:0;padding:24px;border-radius:12px;background:var(--settings-panel);color:var(--text-muted);font-size:14px;text-align:center}
  438. .appearance-panel{display:grid;gap:16px;padding:20px;background:var(--settings-panel);border-radius:12px}.appearance-panel h4{margin:0;font-size:15px;font-weight:600}
  439. .theme-options{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px}
  440. .theme-options button{display:flex;flex-direction:column;align-items:center;gap:14px;min-width:0;padding:10px 10px 12px;border:2px solid transparent;border-radius:10px;background:var(--surface);color:var(--text-primary);cursor:pointer;font:inherit;box-shadow:0 0 0 1px var(--line)}
  441. .theme-options button:hover{border-color:var(--line-strong)}.theme-options button.active{border-color:var(--accent);box-shadow:none}.theme-options img{display:block;width:100%;height:auto;aspect-ratio:8/5;object-fit:cover;border-radius:7px}.theme-options strong{font-size:13px;font-weight:550}.theme-choice-mark{width:20px;height:20px;color:var(--text-primary)}
  442. .appearance-note{display:flex;align-items:flex-start;gap:8px;margin:2px 0 0;padding-top:16px;border-top:1px solid var(--line);color:var(--text-secondary);font-size:13px;line-height:1.6}
  443. .settings-content__scroll :deep(.account-security-panel>.panel-heading){display:none}.settings-content__scroll :deep(.account-security-panel){gap:20px}.settings-content__scroll :deep(.account-avatar-card),.settings-content__scroll :deep(.security-form),.settings-content__scroll :deep(.server-identity){padding:20px;border:0;border-radius:12px;background:var(--settings-panel)}.settings-content__scroll :deep(.security-form){gap:16px}.settings-content__scroll :deep(.field input){background:var(--surface)}
  444. .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(6px) scale(.99)}
  445. @media(max-width:767px){.settings-center-layer{padding:0}.settings-center{width:100vw;height:100dvh;border:0;border-radius:0}.settings-center__topbar{display:none}.settings-center__body{display:block;height:100%}.settings-sidebar{display:grid;width:100%;height:100%;box-sizing:border-box;grid-template-rows:60px minmax(0,1fr);overflow:hidden;padding:0;border-right:0}.settings-sidebar__header{display:flex;align-items:center;justify-content:space-between;padding:0 12px 0 20px;border-bottom:1px solid var(--line)}.settings-sidebar__scroll{min-height:0;overflow-y:auto;padding:20px 16px 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{min-height:60px;grid-template-columns:44px minmax(0,1fr) 44px;gap:4px;padding:8px 8px 8px 6px;border-bottom:1px solid var(--line)}.settings-content__header h3{font-size:17px}.settings-content__header p{display:none}.settings-center--mobile-detail .mobile-back,.settings-detail-close{display:grid}.settings-content__scroll{padding:20px 16px max(24px,env(safe-area-inset-bottom))}.settings-content--with-footer{grid-template-rows:auto minmax(0,1fr) calc(64px + env(safe-area-inset-bottom))}.settings-content__footer{padding:0 16px env(safe-area-inset-bottom)}.settings-content__footer button{flex:1}.appearance-panel{padding:14px;gap:14px}.theme-options{gap:8px}.theme-options button{padding:6px 6px 10px;gap:12px}.theme-options strong{font-size:12px}}
  446. @media(prefers-reduced-motion:reduce){.settings-center-fade-enter-active,.settings-center-fade-leave-active,.settings-center-fade-enter-active .settings-center,.settings-center-fade-leave-active .settings-center{transition:none}}
  447. </style>