SettingsCenterDialog.vue 31 KB

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