starmusic-pc
1<script setup>
2/**
3 * AccountView — 账号与云同步
4 *
5 * 未登录:登录 / 注册(算术验证码)
6 * 已登录:账号信息、云同步(备份 / 恢复)、账号管理(改密 / 退出)
7 * 备份内容与移动端一致:收藏 + 试听 + 自建歌单 + 全部自定义音源脚本 + 当前启用源名称。
8 */
9import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
10import Icon from '../components/Icon.vue'
11import UiSwitch from '../components/UiSwitch.vue'
12import {
13 getLocalAccount,
14 isLoggedIn,
15 getCaptcha,
16 login,
17 register,
18 logout,
19 fetchProfile,
20 changePassword,
21 backupToCloud,
22 restoreFromCloud,
23 isAutoBackupEnabled,
24 setAutoBackup,
25} from '../common/account.js'
26import { getList, LIST_IDS } from '../common/playlist.js'
27import { getUserApiList } from '../common/data.js'
28import { normalizeTimestamp } from '../common/utils.js'
29import { toast, toastOk, toastErr, confirm, errText } from '../composables/useFeedback.js'
30import event from '../common/event.js'
31
32const account = ref(getLocalAccount())
33/** 自动备份开关状态 */
34const autoBackup = ref(isAutoBackupEnabled())
35// 必须把响应式的 account 也作为依赖:登录态存在本地存储里,本身不是响应式数据,
36// 写成 computed(() => isLoggedIn()) 属于「无响应式依赖的 computed」,会被 Vue
37// 永久缓存 —— 表现就是登录成功后界面仍停在登录表单(退出登录同理)。
38const loggedIn = computed(() => !!account.value && isLoggedIn())
39
40const refreshAccount = () => {
41 account.value = getLocalAccount()
42 autoBackup.value = isAutoBackupEnabled()
43}
44
45// ============ 登录 / 注册 ============
46
47const mode = ref('login')
48const form = ref({ username: '', password: '', confirm: '', captchaAnswer: '' })
49const captcha = ref({ id: '', question: '' })
50const submitting = ref(false)
51
52const switchMode = (next) => {
53 if (mode.value === next) return
54 mode.value = next
55 form.value = { username: '', password: '', confirm: '', captchaAnswer: '' }
56 if (next === 'register') refreshCaptcha()
57}
58
59const refreshCaptcha = async () => {
60 try {
61 const res = await getCaptcha()
62 captcha.value = { id: res.captcha_id || res.id || '', question: res.question || '' }
63 } catch (e) {
64 captcha.value = { id: '', question: '' }
65 toastErr(errText(e, '获取验证码失败'))
66 }
67}
68
69const handleSubmit = async () => {
70 if (submitting.value) return
71 const username = form.value.username.trim()
72 const password = form.value.password
73 if (!username || !password) {
74 toast('请输入用户名和密码', 'none', 1800)
75 return
76 }
77 if (mode.value === 'register') {
78 if (password !== form.value.confirm) {
79 toast('两次输入的密码不一致', 'none', 1800)
80 return
81 }
82 if (!captcha.value.id) {
83 toast('请先获取验证码', 'none', 1800)
84 return
85 }
86 if (!String(form.value.captchaAnswer).trim()) {
87 toast('请输入验证码答案', 'none', 1800)
88 return
89 }
90 }
91
92 submitting.value = true
93 try {
94 if (mode.value === 'login') {
95 await login({ username, password })
96 toastOk('登录成功')
97 } else {
98 await register({
99 username,
100 password,
101 captchaId: captcha.value.id,
102 captchaAnswer: form.value.captchaAnswer,
103 })
104 toastOk('注册成功,已自动登录')
105 form.value = { username: '', password: '', confirm: '', captchaAnswer: '' }
106 }
107 refreshAccount()
108 await silentProfile()
109 } catch (err) {
110 toastErr(errText(err, '操作失败'))
111 // 验证码一次性消费,失败后刷新
112 if (mode.value === 'register') refreshCaptcha()
113 } finally {
114 submitting.value = false
115 }
116}
117
118// ============ 已登录态 ============
119
120const syncing = ref(false)
121const pwdVisible = ref(false)
122const pwdForm = ref({ old: '', next: '', confirm: '' })
123const pwdSubmitting = ref(false)
124
125const avatarChar = computed(() => {
126 const name = account.value && account.value.username
127 return name ? String(name).charAt(0).toUpperCase() : '·'
128})
129
130const formatTime = (ts) => {
131 if (!ts) return '未知'
132 // 服务端时间戳是秒级,必须归一化,否则会被当成 1970 年
133 const time = typeof ts === 'number' ? normalizeTimestamp(ts) : Date.parse(String(ts).replace(/-/g, '/'))
134 if (!Number.isFinite(time)) return String(ts)
135 const d = new Date(time)
136 const p = (n) => String(n).padStart(2, '0')
137 return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`
138}
139
140const formatRelative = (ts) => {
141 if (!ts) return '尚未备份'
142 // 服务端时间戳是秒级,必须归一化,否则会被当成 1970 年
143 const time = typeof ts === 'number' ? normalizeTimestamp(ts) : Date.parse(String(ts).replace(/-/g, '/'))
144 if (!Number.isFinite(time)) return String(ts)
145 const diff = Date.now() - time
146 if (diff < 60_000) return '刚刚'
147 if (diff < 3600_000) return `${Math.floor(diff / 60_000)} 分钟前`
148 if (diff < 86400_000) return `${Math.floor(diff / 3600_000)} 小时前`
149 if (diff < 172800_000) return '昨天'
150 if (diff < 604800_000) return `${Math.floor(diff / 86400_000)} 天前`
151 return formatTime(time)
152}
153
154/** 列表版本号:列表发生变化时自增,驱动本地数据统计重新计算 */
155const listVersion = ref(0)
156
157const localStatsText = computed(() => {
158 void listVersion.value
159 return `收藏 ${getList(LIST_IDS.LOVE).length} 首 · 试听 ${getList(LIST_IDS.DEFAULT).length} 首 · 音源 ${getUserApiList().length} 个`
160})
161
162const silentProfile = async () => {
163 try {
164 const data = await fetchProfile()
165 refreshAccount()
166 return data
167 } catch (e) {
168 refreshAccount()
169 return null
170 }
171}
172
173const handleBackup = async () => {
174 if (syncing.value) return
175 syncing.value = true
176 uni.showLoading({ title: '备份中…', mask: true })
177 try {
178 const res = await backupToCloud()
179 uni.hideLoading()
180 toastOk(`已备份 ${res.playlistCount} 首歌、${res.sourcesCount} 个音源`, 3200)
181 } catch (e) {
182 uni.hideLoading()
183 toastErr(errText(e, '备份失败'))
184 } finally {
185 syncing.value = false
186 }
187}
188
189/** 切换自动备份开关(开启后会立即安排一次备份) */
190const handleAutoBackupToggle = (on) => {
191 setAutoBackup(on)
192 autoBackup.value = on
193 if (on) toastOk('已开启自动备份')
194 else toast('已关闭自动备份', 'none', 1600)
195}
196
197const handleRestore = async () => {
198 const ok = await confirm({
199 title: '从云端恢复',
200 content:
201 '将把云端备份的歌单合并到本地(收藏 / 试听列表),并导入缺失的自定义音源。\n本地已有数据不会被删除,是否继续?',
202 confirmText: '恢复',
203 })
204 if (!ok) return
205
206 syncing.value = true
207 uni.showLoading({ title: '恢复中…', mask: true })
208 try {
209 const res = await restoreFromCloud()
210 uni.hideLoading()
211 const total = res.loveCount + res.defaultCount + res.customCount + res.sourceCount
212 if (!total) toast('云端没有需要恢复的数据', 'none', 2400)
213 else
214 toastOk(
215 `恢复成功:收藏 ${res.loveCount} 首,试听 ${res.defaultCount} 首,自建歌单 ${res.customCount} 首,音源 ${res.sourceCount} 个`,
216 3600,
217 )
218 listVersion.value++
219 } catch (e) {
220 uni.hideLoading()
221 toastErr(errText(e, '恢复失败'))
222 } finally {
223 syncing.value = false
224 }
225}
226
227const handleChangePassword = async () => {
228 const { old, next, confirm: again } = pwdForm.value
229 if (!old || !next || !again) {
230 toast('请填写完整', 'none', 1800)
231 return
232 }
233 if (next.length < 6) {
234 toast('新密码至少 6 位', 'none', 1800)
235 return
236 }
237 if (next !== again) {
238 toast('两次输入的新密码不一致', 'none', 1800)
239 return
240 }
241 pwdSubmitting.value = true
242 try {
243 await changePassword({ oldPassword: old, newPassword: next })
244 toastOk('密码已修改')
245 pwdVisible.value = false
246 pwdForm.value = { old: '', next: '', confirm: '' }
247 } catch (e) {
248 toastErr(errText(e, '修改失败'))
249 } finally {
250 pwdSubmitting.value = false
251 }
252}
253
254const handleLogout = async () => {
255 const ok = await confirm({
256 title: '退出登录',
257 content: '退出后本机将不再自动同步,云端备份仍会保留。',
258 confirmText: '退出',
259 danger: true,
260 })
261 if (!ok) return
262 await logout()
263 refreshAccount()
264 toast('已退出登录', 'none', 1800)
265}
266
267// ============ 生命周期 ============
268
269let offAccount = null
270let offList = null
271let offAutoBackup = null
272
273onMounted(() => {
274 refreshAccount()
275 if (isLoggedIn()) silentProfile()
276 offAccount = event.on('accountChanged', refreshAccount)
277 offList = event.on('listUpdated', () => {
278 listVersion.value++
279 })
280 // 自动备份完成后刷新「最近备份」时间
281 offAutoBackup = event.on('autoBackupDone', (ok) => {
282 if (ok) refreshAccount()
283 })
284})
285
286onBeforeUnmount(() => {
287 if (offAccount) offAccount()
288 if (offList) offList()
289 if (offAutoBackup) offAutoBackup()
290})
291</script>
292
293<template>
294 <div class="view-account view-scroll scroll-y">
295 <div class="inner">
296 <header class="head a-fade-up">
297 <h1>账号与云同步</h1>
298 <p>登录后可把歌单与自定义音源备份到云端,换设备一键找回</p>
299 </header>
300
301 <!-- ================= 未登录 ================= -->
302 <template v-if="!loggedIn">
303 <section class="auth-card a-scale-in">
304 <div class="auth-tabs">
305 <button class="auth-tab" :class="{ 'is-on': mode === 'login' }" @click="switchMode('login')">登录</button>
306 <button class="auth-tab" :class="{ 'is-on': mode === 'register' }" @click="switchMode('register')">
307 注册
308 </button>
309 <span class="auth-indicator" :class="{ 'is-right': mode === 'register' }" />
310 </div>
311
312 <div class="fields">
313 <label class="field">
314 <span class="field-label">用户名</span>
315 <input
316 v-model="form.username"
317 class="ipt"
318 type="text"
319 maxlength="16"
320 placeholder="2-16 位中文、字母、数字或下划线"
321 spellcheck="false"
322 />
323 </label>
324
325 <label class="field">
326 <span class="field-label">密码</span>
327 <input
328 v-model="form.password"
329 class="ipt"
330 type="password"
331 maxlength="64"
332 placeholder="至少 6 位"
333 @keydown.enter="mode === 'login' && handleSubmit()"
334 />
335 </label>
336
337 <label v-if="mode === 'register'" class="field">
338 <span class="field-label">确认密码</span>
339 <input
340 v-model="form.confirm"
341 class="ipt"
342 type="password"
343 maxlength="64"
344 placeholder="再次输入密码"
345 />
346 </label>
347
348 <div v-if="mode === 'register'" class="field">
349 <span class="field-label">验证码</span>
350 <div class="captcha-row">
351 <button class="captcha" :title="'点击刷新验证码'" @click="refreshCaptcha">
352 <Icon name="refresh" :size="13" />
353 <span>{{ captcha.question || '点击获取验证码' }}</span>
354 </button>
355 <input
356 v-model="form.captchaAnswer"
357 class="ipt captcha-input"
358 type="text"
359 placeholder="答案"
360 @keydown.enter="handleSubmit"
361 />
362 </div>
363 <span class="field-hint">点击左侧算式可刷新验证码</span>
364 </div>
365 </div>
366
367 <button class="submit press" :disabled="submitting" @click="handleSubmit">
368 <Icon v-if="submitting" name="refresh" :size="15" spin />
369 <span>{{ submitting ? '处理中…' : mode === 'login' ? '登 录' : '注 册' }}</span>
370 </button>
371
372 <p class="auth-tip">
373 <Icon name="shield" :size="13" />
374 账号仅用于歌单与音源备份,不会上传任何播放记录
375 </p>
376 </section>
377 </template>
378
379 <!-- ================= 已登录 ================= -->
380 <template v-else>
381 <!-- 账号卡 -->
382 <section class="card acct a-fade-up" style="--delay: 40ms">
383 <span class="avatar">{{ avatarChar }}</span>
384 <div class="acct-info">
385 <span class="acct-name">{{ account && account.username }}</span>
386 <span class="acct-meta">
387 UID {{ (account && account.user_id) || '—' }}
388 <template v-if="account && account.created_at"> · 注册于 {{ formatTime(account.created_at) }}</template>
389 </span>
390 </div>
391 <span class="acct-badge">
392 <Icon name="cloud" :size="13" />
393 已登录
394 </span>
395 </section>
396
397 <!-- 云同步 -->
398 <section class="card a-fade-up" style="--delay: 80ms">
399 <header class="card-head">
400 <Icon name="cloud" :size="15" />
401 <h2>云同步</h2>
402 </header>
403
404 <div class="stat-grid">
405 <div class="stat">
406 <span class="stat-label">最近备份</span>
407 <span class="stat-value">{{ formatRelative(account && account.sync_time) }}</span>
408 </div>
409 <div class="stat">
410 <span class="stat-label">本地数据</span>
411 <span class="stat-value">{{ localStatsText }}</span>
412 </div>
413 </div>
414
415 <div class="row">
416 <div class="row-label">
417 <span class="label">自动备份</span>
418 <span class="desc">歌单或音源发生变化时自动覆盖云端备份</span>
419 </div>
420 <UiSwitch :model-value="autoBackup" @update:model-value="handleAutoBackupToggle" />
421 </div>
422
423 <div class="sync-block">
424 <div class="sync-info">
425 <span class="sync-title">备份到云端</span>
426 <span class="sync-desc">上传本地歌单(收藏、试听、自建)与全部自定义音源,覆盖云端旧备份</span>
427 </div>
428 <button class="btn is-primary press" :disabled="syncing" @click="handleBackup">
429 <Icon v-if="syncing" name="refresh" :size="14" spin />
430 <span>立即备份</span>
431 </button>
432 </div>
433
434 <div class="sync-block">
435 <div class="sync-info">
436 <span class="sync-title">从云端恢复</span>
437 <span class="sync-desc">把云端歌单合并到本地列表,并导入缺失的音源(本地数据不会被删除)</span>
438 </div>
439 <button class="btn press" :disabled="syncing" @click="handleRestore">
440 <Icon v-if="syncing" name="refresh" :size="14" spin />
441 <span>恢复数据</span>
442 </button>
443 </div>
444 </section>
445
446 <!-- 账号管理 -->
447 <section class="card a-fade-up" style="--delay: 120ms">
448 <header class="card-head">
449 <Icon name="user" :size="15" />
450 <h2>账号管理</h2>
451 </header>
452
453 <div class="row">
454 <div class="row-label">
455 <span class="label">修改密码</span>
456 <span class="desc">修改成功后当前设备保持登录</span>
457 </div>
458 <button class="mini-btn press" @click="pwdVisible = !pwdVisible">
459 <Icon :name="pwdVisible ? 'chevron-up' : 'chevron-down'" :size="14" />
460 <span>{{ pwdVisible ? '收起' : '展开' }}</span>
461 </button>
462 </div>
463
464 <Transition name="expand">
465 <div v-if="pwdVisible" class="pwd-form">
466 <input v-model="pwdForm.old" class="ipt" type="password" placeholder="当前密码" />
467 <input v-model="pwdForm.next" class="ipt" type="password" placeholder="新密码(至少 6 位)" />
468 <input v-model="pwdForm.confirm" class="ipt" type="password" placeholder="确认新密码" @keydown.enter="handleChangePassword" />
469 <button class="btn is-primary press" :disabled="pwdSubmitting" @click="handleChangePassword">
470 <Icon v-if="pwdSubmitting" name="refresh" :size="14" spin />
471 <span>保存新密码</span>
472 </button>
473 </div>
474 </Transition>
475
476 <div class="row">
477 <div class="row-label">
478 <span class="label">退出登录</span>
479 <span class="desc">退出后本机不再自动同步</span>
480 </div>
481 <button class="mini-btn is-danger press" @click="handleLogout">
482 <Icon name="external" :size="14" />
483 <span>退出登录</span>
484 </button>
485 </div>
486 </section>
487 </template>
488 </div>
489 </div>
490</template>
491
492<style scoped>
493.view-account {
494 height: 100%;
495 min-height: 0;
496 padding: 26px 32px 32px;
497}
498
499.inner {
500 max-width: 620px;
501 margin: 0 auto;
502 display: flex;
503 flex-direction: column;
504 gap: 14px;
505}
506
507.head h1 {
508 font-size: 24px;
509 font-weight: 700;
510 letter-spacing: 0.5px;
511 color: var(--text-1);
512}
513
514.head p {
515 margin-top: 5px;
516 font-size: 12.5px;
517 color: var(--text-4);
518}
519
520/* ---------- 未登录 ---------- */
521
522.auth-card {
523 padding: 22px;
524 border-radius: var(--r-xl);
525 background: var(--surface-1);
526 border: 1px solid var(--line-1);
527 box-shadow: var(--sh-md);
528}
529
530.auth-tabs {
531 position: relative;
532 display: grid;
533 grid-template-columns: 1fr 1fr;
534 padding: 4px;
535 border-radius: var(--r-md);
536 background: var(--surface-3);
537 margin-bottom: 20px;
538}
539
540.auth-tab {
541 position: relative;
542 z-index: 2;
543 height: 34px;
544 border-radius: var(--r-sm);
545 font-size: 13.5px;
546 color: var(--text-3);
547 transition: color var(--t-base) var(--ease-out);
548}
549
550.auth-tab.is-on {
551 color: var(--theme-color-dark);
552 font-weight: 600;
553}
554
555.auth-indicator {
556 position: absolute;
557 z-index: 1;
558 top: 4px;
559 left: 4px;
560 width: calc(50% - 4px);
561 height: 34px;
562 border-radius: var(--r-sm);
563 background: var(--surface-1);
564 box-shadow: var(--sh-xs);
565 transition: transform var(--t-slow) var(--ease-spring);
566}
567
568.auth-indicator.is-right {
569 transform: translateX(100%);
570}
571
572.fields {
573 display: flex;
574 flex-direction: column;
575 gap: 14px;
576}
577
578.field {
579 display: flex;
580 flex-direction: column;
581 gap: 6px;
582}
583
584.field-label {
585 font-size: 12.5px;
586 color: var(--text-3);
587}
588
589.ipt {
590 width: 100%;
591 height: 42px;
592 padding: 0 13px;
593 border-radius: var(--r-md);
594 border: 1.5px solid var(--line-2);
595 background: var(--surface-2);
596 color: var(--text-1);
597 font-size: 13.5px;
598 outline: none;
599 transition: border-color var(--t-base) var(--ease-out), box-shadow var(--t-base) var(--ease-out),
600 background-color var(--t-base) var(--ease-out);
601}
602
603.ipt:focus {
604 border-color: var(--theme-color);
605 box-shadow: 0 0 0 4px rgba(var(--theme-color-rgb), 0.12);
606 background: var(--surface-1);
607}
608
609.captcha-row {
610 display: flex;
611 gap: 10px;
612}
613
614.captcha {
615 display: inline-flex;
616 align-items: center;
617 justify-content: center;
618 gap: 7px;
619 min-width: 148px;
620 height: 42px;
621 padding: 0 14px;
622 border-radius: var(--r-md);
623 border: 1.5px dashed var(--line-3);
624 background: var(--theme-color-soft);
625 color: var(--theme-color-dark);
626 font-size: 14px;
627 font-weight: 600;
628 letter-spacing: 1px;
629 transition: border-color var(--t-fast) var(--ease-out), transform var(--t-fast) var(--ease-out);
630}
631
632.captcha:hover {
633 border-color: var(--theme-color);
634}
635
636.captcha:active {
637 transform: scale(0.98);
638}
639
640.captcha-input {
641 flex: 1;
642 min-width: 0;
643}
644
645.field-hint {
646 font-size: 11.5px;
647 color: var(--text-5);
648}
649
650.submit {
651 width: 100%;
652 height: 44px;
653 margin-top: 20px;
654 display: inline-flex;
655 align-items: center;
656 justify-content: center;
657 gap: 8px;
658 border-radius: var(--r-md);
659 color: #fff;
660 font-size: 14.5px;
661 font-weight: 600;
662 letter-spacing: 2px;
663 background: linear-gradient(135deg, var(--theme-color), var(--theme-color-dark));
664 box-shadow: 0 8px 22px rgba(var(--theme-color-rgb), 0.34);
665 transition: filter var(--t-fast) var(--ease-out), transform var(--t-fast) var(--ease-out);
666}
667
668.submit:hover:not(:disabled) {
669 filter: brightness(1.06);
670}
671
672.submit:disabled {
673 opacity: 0.66;
674 cursor: wait;
675}
676
677.auth-tip {
678 display: flex;
679 align-items: center;
680 justify-content: center;
681 gap: 6px;
682 margin-top: 14px;
683 font-size: 11.5px;
684 color: var(--text-5);
685}
686
687/* ---------- 已登录 ---------- */
688
689.card {
690 padding: 18px 20px 8px;
691 border-radius: var(--r-xl);
692 background: var(--surface-1);
693 border: 1px solid var(--line-1);
694 box-shadow: var(--sh-sm);
695}
696
697.acct {
698 display: flex;
699 align-items: center;
700 gap: 14px;
701 padding: 18px 20px;
702 background: linear-gradient(120deg, var(--theme-color-soft), var(--surface-1) 64%);
703 border-color: rgba(var(--theme-color-rgb), 0.24);
704}
705
706.avatar {
707 display: grid;
708 place-items: center;
709 width: 52px;
710 height: 52px;
711 border-radius: var(--r-full);
712 color: #fff;
713 font-size: 20px;
714 font-weight: 600;
715 background: linear-gradient(135deg, var(--theme-color), var(--theme-color-dark));
716 box-shadow: 0 8px 20px rgba(var(--theme-color-rgb), 0.34);
717 flex: none;
718}
719
720.acct-info {
721 flex: 1;
722 min-width: 0;
723 display: flex;
724 flex-direction: column;
725 gap: 3px;
726}
727
728.acct-name {
729 font-size: 17px;
730 font-weight: 700;
731 color: var(--text-1);
732}
733
734.acct-meta {
735 font-size: 11.5px;
736 color: var(--text-4);
737}
738
739.acct-badge {
740 display: inline-flex;
741 align-items: center;
742 gap: 5px;
743 padding: 3px 10px;
744 border-radius: var(--r-full);
745 background: var(--surface-1);
746 color: var(--theme-color-dark);
747 font-size: 11.5px;
748 flex: none;
749 box-shadow: var(--sh-xs);
750}
751
752.card-head {
753 display: flex;
754 align-items: center;
755 gap: 8px;
756 margin-bottom: 10px;
757 color: var(--theme-color);
758}
759
760.card-head h2 {
761 font-size: 14px;
762 font-weight: 600;
763 color: var(--text-1);
764}
765
766.stat-grid {
767 display: grid;
768 grid-template-columns: 1fr 1fr;
769 gap: 10px;
770 margin-bottom: 6px;
771}
772
773.stat {
774 display: flex;
775 flex-direction: column;
776 gap: 4px;
777 padding: 12px 14px;
778 border-radius: var(--r-lg);
779 background: var(--surface-2);
780 border: 1px solid var(--line-1);
781}
782
783.stat-label {
784 font-size: 11px;
785 color: var(--text-5);
786 letter-spacing: 0.4px;
787}
788
789.stat-value {
790 font-size: 13px;
791 font-weight: 500;
792 color: var(--text-1);
793}
794
795.sync-block {
796 display: flex;
797 align-items: center;
798 justify-content: space-between;
799 gap: 16px;
800 padding: 14px 0;
801 border-bottom: 1px solid var(--line-1);
802}
803
804.sync-block:last-of-type {
805 border-bottom: none;
806}
807
808.sync-info {
809 display: flex;
810 flex-direction: column;
811 gap: 3px;
812 min-width: 0;
813}
814
815.sync-title {
816 font-size: 13.5px;
817 font-weight: 500;
818 color: var(--text-1);
819}
820
821.sync-desc {
822 font-size: 11.5px;
823 color: var(--text-4);
824 line-height: 1.6;
825}
826
827.row {
828 display: flex;
829 align-items: center;
830 justify-content: space-between;
831 gap: 16px;
832 padding: 13px 0;
833 border-bottom: 1px solid var(--line-1);
834}
835
836.row:last-of-type {
837 border-bottom: none;
838}
839
840.row-label {
841 display: flex;
842 flex-direction: column;
843 gap: 3px;
844 min-width: 0;
845}
846
847.label {
848 font-size: 13.5px;
849 font-weight: 500;
850 color: var(--text-1);
851}
852
853.desc {
854 font-size: 11.5px;
855 color: var(--text-4);
856}
857
858.pwd-form {
859 display: flex;
860 flex-direction: column;
861 gap: 10px;
862 padding: 4px 0 16px;
863}
864
865.btn,
866.mini-btn {
867 display: inline-flex;
868 align-items: center;
869 gap: 6px;
870 height: 32px;
871 padding: 0 15px;
872 border-radius: var(--r-sm);
873 border: 1px solid var(--line-2);
874 background: var(--surface-1);
875 color: var(--text-2);
876 font-size: 12.5px;
877 white-space: nowrap;
878 flex: none;
879 transition: border-color var(--t-fast) var(--ease-out), background-color var(--t-fast) var(--ease-out),
880 color var(--t-fast) var(--ease-out);
881}
882
883.btn:hover:not(:disabled),
884.mini-btn:hover:not(:disabled) {
885 border-color: var(--theme-color);
886 background: var(--theme-color-soft);
887 color: var(--theme-color-dark);
888}
889
890.btn:disabled {
891 opacity: 0.6;
892 cursor: wait;
893}
894
895.btn.is-primary {
896 color: #fff;
897 border-color: transparent;
898 background: linear-gradient(135deg, var(--theme-color), var(--theme-color-dark));
899 box-shadow: 0 4px 14px rgba(var(--theme-color-rgb), 0.3);
900}
901
902.btn.is-primary:hover:not(:disabled) {
903 filter: brightness(1.06);
904 color: #fff;
905}
906
907.mini-btn.is-danger {
908 color: var(--color-danger);
909 border-color: rgba(244, 63, 94, 0.3);
910}
911
912.mini-btn.is-danger:hover {
913 background: rgba(244, 63, 94, 0.1);
914 border-color: var(--color-danger);
915 color: var(--color-danger);
916}
917
918.expand-enter-active,
919.expand-leave-active {
920 transition: opacity var(--t-base) var(--ease-out), max-height var(--t-slow) var(--ease-out);
921 overflow: hidden;
922 max-height: 320px;
923}
924
925.expand-enter-from,
926.expand-leave-to {
927 opacity: 0;
928 max-height: 0;
929}
930
931.pwd-form .btn.is-primary {
932 align-self: flex-start;
933}
934</style>