SettingsCenterDialog.vue 33 KB

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