仰望星辰工作室

starmusic-pc

starmusic-pc/ frontend/src/views/SettingsView.vue 37.6 KB · 1,430 行 原始文件
1<script setup>
2/**
3 * SettingsView — 设置
4 *
5 * 与移动端对齐的全部设置项:音源(当前源 / 导入 / 已导入源管理)、播放(模式 / 音质 / 倍速)、
6 * 外观(主题色 / 背景图)、诊断(运行日志 / 配置目录 / 导出)、关于(版本 / 检查更新 / 赞助)。
7 */
8import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
9import Icon from '../components/Icon.vue'
10import UiSwitch from '../components/UiSwitch.vue'
11import UiSlider from '../components/UiSlider.vue'
12import UiDialog from '../components/UiDialog.vue'
13import UiContextMenu from '../components/UiContextMenu.vue'
14import CustomSourceImport from '../components/CustomSourceImport.vue'
15import CoverImage from '../components/CoverImage.vue'
16
17import { getSetting, updateSetting } from '../common/setting.js'
18import { getLocalAccount, isLoggedIn } from '../common/account.js'
19import { getUserApiList, removeUserApi } from '../common/data.js'
20import { updateUserApiList, getCurrentApiId } from '../common/user-api/api-executor.js'
21import { setApiSource } from '../common/api-source.js'
22import { getLogs, getLogCount, clearLogs, onLogChange, formatLogsForCopy } from '../common/user-api/logger.js'
23import { getAppVersion, checkUpdateDetail, promptUpdate, startUpdate } from '../common/update.js'
24import { chooseBgImage, clearBgImage, getBgImage } from '../common/background.js'
25import { PLAY_MODE, PLAY_MODE_LIST, SOURCE_NAMES } from '../common/constants.js'
26import { playerActions, playerState } from '../composables/usePlayer.js'
27import { THEMES, appearance, applyTheme } from '../composables/useTheme.js'
28import { go } from '../composables/useShell.js'
29import { toast, toastOk, toastErr, confirm, errText } from '../composables/useFeedback.js'
30import { saveText, openConfigFolder, openStorageFolder, getAppInfo, setClipboard } from '../uni/native.js'
31import event from '../common/event.js'
32import { normalizeTimestamp } from '../common/utils.js'
33
34// ============ 通用 ============
35
36const setting = ref(getSetting())
37const refreshSetting = () => {
38 setting.value = getSetting()
39}
40
41const version = ref(getAppVersion())
42
43const setValue = (key, value) => {
44 updateSetting({ [key]: value })
45 refreshSetting()
46}
47
48// ============ 账号 ============
49
50const account = ref(getLocalAccount())
51const refreshAccount = () => {
52 account.value = getLocalAccount()
53}
54
55const accountTitle = computed(() => (isLoggedIn() && account.value ? account.value.username : '未登录'))
56const accountDesc = computed(() => {
57 if (!isLoggedIn()) return '登录后可把歌单与音源同步到云端'
58 const t = account.value && account.value.sync_time
59 if (!t) return '云端暂无备份,去备份一下'
60 return `上次备份:${relativeTime(t)}`
61})
62
63const relativeTime = (ts) => {
64 // 服务端时间戳是秒级,必须归一化,否则会被当成 1970 年
65 const time = typeof ts === 'number' ? normalizeTimestamp(ts) : Date.parse(String(ts).replace(/-/g, '/'))
66 if (!Number.isFinite(time)) return String(ts)
67 const diff = Date.now() - time
68 if (diff < 60_000) return '刚刚'
69 if (diff < 3600_000) return `${Math.floor(diff / 60_000)} 分钟前`
70 if (diff < 86400_000) return `${Math.floor(diff / 3600_000)} 小时前`
71 if (diff < 172800_000) return '昨天'
72 if (diff < 604800_000) return `${Math.floor(diff / 86400_000)} 天前`
73 const d = new Date(time)
74 return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
75}
76
77const avatarChar = computed(() => {
78 const name = account.value && account.value.username
79 return name ? String(name).charAt(0).toUpperCase() : '+'
80})
81
82// ============ 音源 ============
83
84const userApiList = ref(getUserApiList())
85const refreshApiList = () => {
86 updateUserApiList()
87 userApiList.value = getUserApiList()
88}
89
90const currentApiId = ref(getSetting()['common.apiSource'] || '')
91
92const currentSourceLabel = computed(() => {
93 const id = currentApiId.value
94 if (!id) return '内置搜索源(播放需自定义源)'
95 const found = userApiList.value.find((a) => a.id === id)
96 return found ? found.name : id
97})
98
99const apiStatus = ref({ status: '', message: '', id: '' })
100
101const statusInfo = computed(() => {
102 const s = apiStatus.value.status
103 if (s === 'initing') return { text: '加载中', color: 'var(--text-4)', icon: 'refresh' }
104 if (s === true) return { text: '可用', color: 'var(--color-success)', icon: 'check' }
105 if (s === false) return { text: '失败', color: 'var(--color-danger)', icon: 'close' }
106 return { text: '未知', color: 'var(--text-4)', icon: 'more' }
107})
108
109const sourceMenu = ref({ visible: false, x: 0, y: 0 })
110const sourceMenuRef = ref(null)
111
112const openSourceMenu = (e) => {
113 const rect = e.currentTarget.getBoundingClientRect()
114 sourceMenu.value = { visible: true, x: rect.left, y: rect.bottom + 6 }
115}
116
117const sourceMenuItems = computed(() => [
118 { key: '', label: '内置搜索源', icon: 'search', hint: currentApiId.value === '' ? '使用中' : '' },
119 { divider: true },
120 ...userApiList.value.map((a) => ({
121 key: a.id,
122 label: a.name,
123 icon: 'database',
124 hint: a.id === currentApiId.value ? '使用中' : '',
125 })),
126])
127
128const onSourceSelect = (id) => {
129 setApiSource(id)
130 currentApiId.value = id
131 toast(id ? `已启用「${(userApiList.value.find((a) => a.id === id) || {}).name || id}」` : '已切换为内置搜索源', 'none', 1800)
132}
133
134const enableApi = (api) => {
135 setApiSource(api.id)
136 currentApiId.value = api.id
137 toast(`已启用「${api.name}」`, 'none', 1600)
138}
139
140const deleteApi = async (api) => {
141 const ok = await confirm({
142 title: '删除自定义源',
143 content: `确定删除「${api.name}」吗?删除后需要重新导入才能使用。`,
144 confirmText: '删除',
145 danger: true,
146 })
147 if (!ok) return
148 removeUserApi([api.id])
149 refreshApiList()
150 if (currentApiId.value === api.id) {
151 setApiSource('')
152 currentApiId.value = ''
153 }
154 toastOk('已删除')
155}
156
157// ============ 导入 ============
158
159const importVisible = ref(false)
160
161const onImported = async (info) => {
162 refreshApiList()
163 const ok = await confirm({
164 title: '导入成功',
165 content: `已导入「${info.name}」,是否立即启用?`,
166 confirmText: '立即启用',
167 cancelText: '稍后',
168 })
169 if (ok) {
170 setApiSource(info.id)
171 currentApiId.value = info.id
172 }
173}
174
175// ============ 播放 ============
176
177const modeChips = PLAY_MODE_LIST.map((m) => ({ key: m, label: PLAY_MODE[m] }))
178
179const qualityChips = [
180 { key: '128k', label: '标准' },
181 { key: '320k', label: '高品质' },
182 { key: 'flac', label: '无损' },
183 { key: 'flac24bit', label: 'Hi-Res' },
184]
185
186const rateChips = [
187 { key: 0.75, label: '0.75x' },
188 { key: 1, label: '1.0x' },
189 { key: 1.25, label: '1.25x' },
190 { key: 1.5, label: '1.5x' },
191 { key: 2, label: '2.0x' },
192]
193
194const onModeChange = (mode) => {
195 playerActions.setPlayMethod(mode)
196 refreshSetting()
197 toast(`播放模式:${PLAY_MODE[mode]}`, 'none', 1500)
198}
199
200const onQualityChange = (q) => {
201 setValue('player.playQuality', q)
202 playerState.quality = q
203}
204
205const onRateChange = (r) => {
206 playerActions.setPlaybackRate(r)
207 refreshSetting()
208}
209
210// ============ 外观 ============
211
212const onThemeChange = (theme) => {
213 applyTheme(theme.id)
214 refreshSetting()
215 toast(`已切换为${theme.name}`, 'none', 1500)
216}
217
218const bgBlurPreview = ref(Number(getSetting()['common.bgImageBlur']))
219
220const onBgToggle = async (on) => {
221 setValue('common.bgImageEnabled', on)
222 if (on && !getBgImage()) await pickBg()
223}
224
225const pickBg = async () => {
226 try {
227 await chooseBgImage()
228 if (!getSetting()['common.bgImageEnabled']) setValue('common.bgImageEnabled', true)
229 refreshSetting()
230 toastOk('背景图已设置')
231 } catch (err) {
232 const msg = errText(err, '')
233 if (/PICK_CANCELED|cancel|未选择/i.test(msg)) return
234 if (msg) toastErr(msg)
235 }
236}
237
238const removeBg = () => {
239 clearBgImage()
240 setValue('common.bgImageEnabled', false)
241 refreshSetting()
242 toast('已移除背景图', 'none', 1600)
243}
244
245const onBlurChange = (v) => {
246 setValue('common.bgImageBlur', v)
247}
248
249// ============ 诊断 ============
250
251const logsVisible = ref(false)
252const logs = ref([])
253
254let offLog = null
255let logTimer = null
256
257const refreshLogs = () => {
258 logs.value = getLogs()
259}
260
261const onLogOpen = (open) => {
262 if (open) {
263 refreshLogs()
264 offLog = onLogChange(() => {
265 if (logTimer) return
266 logTimer = setTimeout(() => {
267 logTimer = null
268 refreshLogs()
269 }, 300)
270 })
271 } else if (offLog) {
272 offLog()
273 offLog = null
274 }
275}
276
277const logTypeText = (type) => {
278 const map = { log: '脚本', init: '初始化', request: '请求', response: '返回', error: '错误', http: '网络' }
279 return map[type] || type
280}
281
282const copyLogs = () => {
283 const text = formatLogsForCopy()
284 if (!text) {
285 toast('暂无日志', 'none', 1500)
286 return
287 }
288 uni.setClipboardData({
289 data: text,
290 success: () => toastOk('日志已复制'),
291 })
292}
293
294const exportLogs = async () => {
295 const text = formatLogsForCopy()
296 if (!text) {
297 toast('暂无日志', 'none', 1500)
298 return
299 }
300 const name = `starmusic-logs-${new Date().toISOString().slice(0, 10)}.txt`
301 const path = await saveText(name, text)
302 if (path) toastOk('日志已导出')
303}
304
305const clearLogsAll = () => {
306 clearLogs()
307 refreshLogs()
308 toast('日志已清空', 'none', 1600)
309}
310
311// ============ 关于 ============
312
313const checking = ref(false)
314
315const checkUpdate = async () => {
316 if (checking.value) return
317 checking.value = true
318 uni.showLoading({ title: '检查更新中…' })
319 try {
320 const res = await checkUpdateDetail()
321 uni.hideLoading()
322 if (res.update) {
323 promptUpdate(res.info)
324 } else if (res.info && res.info.log) {
325 await confirm({
326 title: `当前版本 v${version.value}`,
327 content: String(res.info.log),
328 showCancel: false,
329 confirmText: '知道了',
330 })
331 } else {
332 toastOk('已是最新版本')
333 }
334 } catch (e) {
335 uni.hideLoading()
336 toastErr('检查更新失败')
337 } finally {
338 checking.value = false
339 }
340}
341
342const sponsorVisible = ref(false)
343const SPONSOR_IMG = 'https://clearlove.kazx.top/%E6%8D%90%E8%B5%A0.webp'
344
345const openConfig = async () => {
346 const ok = await openConfigFolder()
347 if (!ok) toast('当前环境不支持打开目录', 'none', 1800)
348}
349
350// ============ 数据目录 ============
351
352const dataDir = ref('')
353const storageDir = ref('')
354
355/** 从原生侧读取真实的目录路径(浏览器调试环境下为空) */
356const loadPaths = async () => {
357 const info = await getAppInfo()
358 if (info) {
359 dataDir.value = info.dataDir || ''
360 storageDir.value = info.storageDir || ''
361 }
362}
363
364const copyPath = async (value) => {
365 if (!value) {
366 toast('当前环境拿不到该路径', 'none', 1800)
367 return
368 }
369 const ok = await setClipboard(value)
370 if (ok) toastOk('路径已复制')
371}
372
373const openStorage = async () => {
374 const ok = await openStorageFolder()
375 if (!ok) toast('当前环境不支持打开目录', 'none', 1800)
376}
377
378// ============ 生命周期 ============
379
380let offSetting = null
381let offStatus = null
382let offApiList = null
383let offAccount = null
384
385onMounted(() => {
386 refreshSetting()
387 refreshApiList()
388 refreshAccount()
389 currentApiId.value = getSetting()['common.apiSource'] || ''
390 getAppVersion() && (version.value = getAppVersion())
391
392 offSetting = event.on('settingUpdated', () => {
393 refreshSetting()
394 currentApiId.value = getSetting()['common.apiSource'] || ''
395 bgBlurPreview.value = Number(getSetting()['common.bgImageBlur'])
396 })
397 offStatus = event.on('userApiStatusChanged', (payload) => {
398 apiStatus.value = payload || {}
399 })
400 offApiList = event.on('userApiListChanged', (list) => {
401 userApiList.value = list
402 })
403 offAccount = event.on('accountChanged', refreshAccount)
404
405 // 读取真实的本地目录路径(原生不可用时保持为空,界面会给出提示)
406 loadPaths()
407
408 // 日志条数跟随运行状态刷新(仅在页面可见期间轮询,开销极小)
409 logCountTimer = setInterval(() => {
410 logCount.value = getLogCount()
411 }, 1500)
412})
413
414onBeforeUnmount(() => {
415 if (offSetting) offSetting()
416 if (offStatus) offStatus()
417 if (offApiList) offApiList()
418 if (offAccount) offAccount()
419 if (offLog) offLog()
420 if (logTimer) clearTimeout(logTimer)
421 if (logCountTimer) clearInterval(logCountTimer)
422})
423
424const logCount = ref(getLogCount())
425let logCountTimer = null
426</script>
427
428<template>
429 <div class="view-settings view-scroll scroll-y">
430 <div class="inner">
431 <header class="head a-fade-up">
432 <h1>设置</h1>
433 <p>音源、播放、外观与诊断</p>
434 </header>
435
436 <!-- ============ 账号 ============ -->
437 <button class="account-card a-fade-up" style="--delay: 40ms" @click="go('account')">
438 <span class="acct-avatar">{{ avatarChar }}</span>
439 <span class="acct-info">
440 <span class="acct-name">{{ accountTitle }}</span>
441 <span class="acct-desc">{{ accountDesc }}</span>
442 </span>
443 <Icon v-if="isLoggedIn() && account && account.sync_time" name="check" :size="16" class="acct-ok" />
444 <Icon name="chevron-right" :size="18" class="acct-arrow" />
445 </button>
446
447 <!-- ============ 音源 ============ -->
448 <section class="card a-fade-up" style="--delay: 80ms">
449 <header class="card-head">
450 <Icon name="database" :size="15" />
451 <h2>音源</h2>
452 </header>
453
454 <div class="row">
455 <div class="row-label">
456 <span class="label">当前音源</span>
457 <span class="desc">决定播放地址 / 歌词 / 封面的来源</span>
458 </div>
459 <button ref="sourceMenuRef" class="row-value clickable" @click="openSourceMenu">
460 <span class="ellipsis">{{ currentSourceLabel }}</span>
461 <Icon name="chevron-down" :size="14" />
462 </button>
463 </div>
464
465 <div class="row">
466 <div class="row-label">
467 <span class="label">导入自定义音源</span>
468 <span class="desc">支持粘贴脚本、本地文件、网络地址三种方式</span>
469 </div>
470 <button class="mini-primary press" @click="importVisible = true">
471 <Icon name="plus" :size="14" />
472 <span>导入</span>
473 </button>
474 </div>
475
476 <!-- 已导入源列表 -->
477 <div class="sub-head">
478 <span>已导入的自定义源({{ userApiList.length }}/20)</span>
479 <span v-if="apiStatus.status !== ''" class="status" :style="{ color: statusInfo.color }">
480 <Icon :name="statusInfo.icon" :size="12" :spin="apiStatus.status === 'initing'" />
481 {{ statusInfo.text }}
482 </span>
483 </div>
484
485 <div v-if="userApiList.length" class="api-list">
486 <div
487 v-for="api in userApiList"
488 :key="api.id"
489 class="api-item"
490 :class="{ 'is-active': api.id === currentApiId }"
491 >
492 <span class="api-mark"><Icon name="database" :size="16" /></span>
493 <div class="api-info">
494 <div class="api-name-row">
495 <span class="api-name ellipsis">{{ api.name }}</span>
496 <em v-if="api.version" class="api-ver">v{{ api.version }}</em>
497 <em v-if="api.id === currentApiId" class="api-using">使用中</em>
498 </div>
499 <span class="api-desc ellipsis">
500 <template v-if="api.author">{{ api.author }} · </template>
501 {{ api.description || '暂无描述' }}
502 </span>
503 </div>
504 <div class="api-actions">
505 <button class="api-btn press" :disabled="api.id === currentApiId" @click="enableApi(api)">
506 <Icon name="check" :size="13" />
507 <span>启用</span>
508 </button>
509 <button class="api-btn is-danger press" @click="deleteApi(api)">
510 <Icon name="trash" :size="13" />
511 </button>
512 </div>
513 </div>
514 </div>
515 <p v-else class="empty-line">还没有导入自定义源,导入后即可解析播放地址</p>
516 </section>
517
518 <!-- ============ 播放 ============ -->
519 <section class="card a-fade-up" style="--delay: 120ms">
520 <header class="card-head">
521 <Icon name="play" :size="14" />
522 <h2>播放</h2>
523 </header>
524
525 <div class="row col">
526 <div class="row-label">
527 <span class="label">播放模式</span>
528 <span class="desc">自动下一曲时的选曲规则</span>
529 </div>
530 <div class="chips">
531 <button
532 v-for="c in modeChips"
533 :key="c.key"
534 class="chip"
535 :class="{ 'is-on': setting['player.togglePlayMethod'] === c.key }"
536 @click="onModeChange(c.key)"
537 >
538 {{ c.label }}
539 </button>
540 </div>
541 </div>
542
543 <div class="row col">
544 <div class="row-label">
545 <span class="label">播放音质</span>
546 <span class="desc">受自定义源支持的音质档位约束,会自动降级</span>
547 </div>
548 <div class="chips">
549 <button
550 v-for="c in qualityChips"
551 :key="c.key"
552 class="chip"
553 :class="{ 'is-on': setting['player.playQuality'] === c.key }"
554 @click="onQualityChange(c.key)"
555 >
556 {{ c.label }}
557 </button>
558 </div>
559 </div>
560
561 <div class="row col">
562 <div class="row-label">
563 <span class="label">播放倍速</span>
564 <span class="desc">0.75x ~ 2.0x</span>
565 </div>
566 <div class="chips">
567 <button
568 v-for="c in rateChips"
569 :key="c.key"
570 class="chip"
571 :class="{ 'is-on': Number(setting['player.playbackRate']) === c.key }"
572 @click="onRateChange(c.key)"
573 >
574 {{ c.label }}
575 </button>
576 </div>
577 </div>
578 </section>
579
580 <!-- ============ 外观 ============ -->
581 <section class="card a-fade-up" style="--delay: 160ms">
582 <header class="card-head">
583 <Icon name="palette" :size="15" />
584 <h2>外观</h2>
585 </header>
586
587 <div class="row col">
588 <div class="row-label">
589 <span class="label">主题色</span>
590 <span class="desc">全局强调色,共 {{ THEMES.length }} 套预设</span>
591 </div>
592 <div class="theme-grid">
593 <button
594 v-for="t in THEMES"
595 :key="t.id"
596 class="theme-item"
597 :class="{ 'is-on': appearance.themeId === t.id }"
598 :style="{ '--c': t.color }"
599 @click="onThemeChange(t)"
600 >
601 <span class="theme-dot"><Icon v-if="appearance.themeId === t.id" name="check" :size="13" width="2.6" /></span>
602 <span class="theme-name">{{ t.name }}</span>
603 </button>
604 </div>
605 </div>
606
607 <div class="row">
608 <div class="row-label">
609 <span class="label">自定义背景图片</span>
610 <span class="desc">开启后卡片与栏位会半透明,形成毛玻璃质感</span>
611 </div>
612 <UiSwitch
613 :model-value="!!setting['common.bgImageEnabled']"
614 @update:model-value="onBgToggle"
615 />
616 </div>
617
618 <template v-if="setting['common.bgImageEnabled']">
619 <div class="row">
620 <div class="row-label">
621 <span class="label">背景图</span>
622 <span class="desc">{{ getBgImage() ? '已设置' : '未设置' }}</span>
623 </div>
624 <div class="row-actions">
625 <button class="mini-btn press" @click="pickBg">
626 <Icon name="image" :size="14" />
627 <span>{{ getBgImage() ? '更换' : '选择图片' }}</span>
628 </button>
629 <button v-if="getBgImage()" class="mini-btn is-danger press" @click="removeBg">
630 <Icon name="trash" :size="14" />
631 <span>移除</span>
632 </button>
633 </div>
634 </div>
635
636 <div class="row col">
637 <div class="row-label">
638 <span class="label">模糊程度</span>
639 <span class="desc">{{ setting['common.bgImageBlur'] }} px</span>
640 </div>
641 <div class="slider-wrap">
642 <UiSlider
643 :model-value="Number(setting['common.bgImageBlur'])"
644 :min="0"
645 :max="30"
646 :step="1"
647 @update:model-value="setValue('common.bgImageBlur', $event)"
648 />
649 </div>
650 </div>
651 </template>
652 </section>
653
654 <!-- ============ 诊断 ============ -->
655 <section class="card a-fade-up" style="--delay: 200ms">
656 <header class="card-head">
657 <Icon name="shield" :size="15" />
658 <h2>诊断</h2>
659 </header>
660
661 <div class="row">
662 <div class="row-label">
663 <span class="label">自定义源运行日志</span>
664 <span class="desc">记录脚本初始化、请求往返与错误,仅保留本次运行(最多 400 条)</span>
665 </div>
666 <button class="row-value clickable" @click="logsVisible = true">
667 <span>{{ logCount ? `${logCount} 条` : '暂无' }}</span>
668 <Icon name="chevron-right" :size="14" />
669 </button>
670 </div>
671
672 <div class="row">
673 <div class="row-label">
674 <span class="label">导出日志</span>
675 <span class="desc">保存为 txt 文件,便于排查播放地址获取失败等问题</span>
676 </div>
677 <button class="mini-btn press" @click="exportLogs">
678 <Icon name="download" :size="14" />
679 <span>导出</span>
680 </button>
681 </div>
682
683 <div class="row">
684 <div class="row-label">
685 <span class="label">数据目录</span>
686 <span class="desc">设置、歌单、音源脚本、背景图都存在这里,复制整个文件夹即可备份</span>
687 <code class="path-code" :title="dataDir">{{ dataDir || '(仅桌面版可用)' }}</code>
688 </div>
689 <div class="row-actions">
690 <button class="mini-btn press" :disabled="!dataDir" @click="copyPath(dataDir)">
691 <Icon name="copy" :size="14" />
692 <span>复制</span>
693 </button>
694 <button class="mini-btn press" @click="openConfig">
695 <Icon name="folder" :size="14" />
696 <span>打开</span>
697 </button>
698 </div>
699 </div>
700
701 <div class="row">
702 <div class="row-label">
703 <span class="label">配置文件位置</span>
704 <span class="desc">业务数据的实际载体(WebView2 本地存储)</span>
705 <code class="path-code" :title="storageDir">{{ storageDir || '(首次运行后生成)' }}</code>
706 </div>
707 <div class="row-actions">
708 <button class="mini-btn press" :disabled="!storageDir" @click="copyPath(storageDir)">
709 <Icon name="copy" :size="14" />
710 <span>复制</span>
711 </button>
712 <button class="mini-btn press" @click="openStorage">
713 <Icon name="folder" :size="14" />
714 <span>打开</span>
715 </button>
716 </div>
717 </div>
718 </section>
719
720 <!-- ============ 关于 ============ -->
721 <section class="card a-fade-up" style="--delay: 240ms">
722 <header class="card-head">
723 <Icon name="info" :size="15" />
724 <h2>关于</h2>
725 </header>
726
727 <div class="row">
728 <div class="row-label">
729 <span class="label">版本</span>
730 <span class="desc">星音乐 PC 版 · 与移动端同源</span>
731 </div>
732 <span class="row-value">v{{ version }}</span>
733 </div>
734
735 <div class="row">
736 <div class="row-label">
737 <span class="label">检查更新</span>
738 <span class="desc">获取最新版本,支持应用内下载安装</span>
739 </div>
740 <button class="mini-btn press" :disabled="checking" @click="checkUpdate">
741 <Icon name="refresh" :size="14" :spin="checking" />
742 <span>检查</span>
743 </button>
744 </div>
745
746 <div class="row">
747 <div class="row-label">
748 <span class="label">关于星音乐</span>
749 <span class="desc">产品介绍与隐藏彩蛋</span>
750 </div>
751 <button class="mini-btn press" @click="go('about')">
752 <Icon name="star" :size="14" />
753 <span>查看</span>
754 </button>
755 </div>
756
757 <div class="row">
758 <div class="row-label">
759 <span class="label">赞助项目</span>
760 <span class="desc">支持作者持续维护</span>
761 </div>
762 <button class="mini-btn press" @click="sponsorVisible = true">
763 <Icon name="heart" :size="14" />
764 <span>赞助</span>
765 </button>
766 </div>
767
768 <p class="copyright">© 2026 仰望星辰工作室 · 仅供学习交流,音乐版权归原作者所有</p>
769 </section>
770 </div>
771
772 <!-- ============ 弹层 ============ -->
773 <UiContextMenu
774 :visible="sourceMenu.visible"
775 :x="sourceMenu.x"
776 :y="sourceMenu.y"
777 :items="sourceMenuItems"
778 :width="250"
779 @select="onSourceSelect"
780 @close="sourceMenu.visible = false"
781 />
782
783 <CustomSourceImport v-model:visible="importVisible" @imported="onImported" />
784
785 <!-- 日志弹窗 -->
786 <UiDialog
787 v-model:visible="logsVisible"
788 title="自定义源运行日志"
789 :subtitle="`共 ${logs.length} 条(仅保留本次运行)`"
790 :width="720"
791 @update:visible="onLogOpen"
792 @close="onLogOpen(false)"
793 >
794 <div class="log-list">
795 <p v-if="!logs.length" class="log-empty">暂无日志</p>
796 <div v-for="item in logs" :key="item.id" class="log-item" :class="`t-${item.type}`">
797 <span class="log-time">{{ item.time }}</span>
798 <span class="log-type">{{ logTypeText(item.type) }}</span>
799 <span class="log-msg">
800 {{ item.message }}
801 <em v-if="item.detail" class="log-detail">{{ item.detail }}</em>
802 </span>
803 </div>
804 </div>
805 <template #footer>
806 <button class="ghost-btn press" @click="clearLogsAll">清空</button>
807 <button class="ghost-btn press" @click="copyLogs">复制全部</button>
808 <button class="ghost-btn press" @click="exportLogs">导出文件</button>
809 </template>
810 </UiDialog>
811
812 <!-- 赞助弹窗 -->
813 <UiDialog v-model:visible="sponsorVisible" title="赞助项目" subtitle="感谢你的支持,这将用于服务器与接口维护" :width="420">
814 <div class="sponsor">
815 <img class="sponsor-img" :src="SPONSOR_IMG" alt="赞助" draggable="false" />
816 <p class="sponsor-tip">长按或右键可保存图片</p>
817 </div>
818 </UiDialog>
819 </div>
820</template>
821
822<style scoped>
823.view-settings {
824 height: 100%;
825 min-height: 0;
826 padding: 26px 32px 32px;
827}
828
829.inner {
830 max-width: 900px;
831 margin: 0 auto;
832 display: flex;
833 flex-direction: column;
834 gap: 14px;
835}
836
837.head h1 {
838 font-size: 24px;
839 font-weight: 700;
840 letter-spacing: 0.5px;
841 color: var(--text-1);
842}
843
844.head p {
845 margin-top: 5px;
846 font-size: 12.5px;
847 color: var(--text-4);
848}
849
850/* ---------- 账号卡片 ---------- */
851
852.account-card {
853 display: flex;
854 align-items: center;
855 gap: 14px;
856 width: 100%;
857 padding: 16px 18px;
858 border-radius: var(--r-xl);
859 background: linear-gradient(120deg, var(--theme-color-soft), var(--surface-1) 62%);
860 border: 1px solid rgba(var(--theme-color-rgb), 0.22);
861 text-align: left;
862 transition: box-shadow var(--t-base) var(--ease-out), transform var(--t-base) var(--ease-out);
863}
864
865.account-card:hover {
866 box-shadow: var(--sh-md);
867 transform: translateY(-1px);
868}
869
870.acct-avatar {
871 display: grid;
872 place-items: center;
873 width: 46px;
874 height: 46px;
875 border-radius: var(--r-full);
876 color: #fff;
877 font-size: 18px;
878 font-weight: 600;
879 background: linear-gradient(135deg, var(--theme-color), var(--theme-color-dark));
880 box-shadow: 0 6px 16px rgba(var(--theme-color-rgb), 0.34);
881 flex: none;
882}
883
884.acct-info {
885 flex: 1;
886 min-width: 0;
887 display: flex;
888 flex-direction: column;
889 gap: 3px;
890}
891
892.acct-name {
893 font-size: 15px;
894 font-weight: 600;
895 color: var(--text-1);
896}
897
898.acct-desc {
899 font-size: 12px;
900 color: var(--text-3);
901}
902
903.acct-ok {
904 color: var(--color-success);
905 flex: none;
906}
907
908.acct-arrow {
909 color: var(--text-4);
910 flex: none;
911}
912
913/* ---------- 卡片 ---------- */
914
915.card {
916 padding: 16px 18px 6px;
917 border-radius: var(--r-xl);
918 background: var(--surface-1);
919 border: 1px solid var(--line-1);
920 box-shadow: var(--sh-sm);
921}
922
923.card-head {
924 display: flex;
925 align-items: center;
926 gap: 8px;
927 margin-bottom: 6px;
928 color: var(--theme-color);
929}
930
931.card-head h2 {
932 font-size: 14px;
933 font-weight: 600;
934 color: var(--text-1);
935 letter-spacing: 0.3px;
936}
937
938/* ---------- 行 ---------- */
939
940.row {
941 display: flex;
942 align-items: center;
943 justify-content: space-between;
944 gap: 18px;
945 padding: 12px 0;
946 border-bottom: 1px solid var(--line-1);
947 min-height: 56px;
948}
949
950.row.col {
951 flex-direction: column;
952 align-items: stretch;
953 gap: 11px;
954}
955
956.card .row:last-of-type {
957 border-bottom: none;
958}
959
960.row-label {
961 display: flex;
962 flex-direction: column;
963 gap: 3px;
964 min-width: 0;
965}
966
967/* 非纵向行里让说明区占满剩余宽度:长路径才能正确换行而不顶掉右侧按钮 */
968.row:not(.col) .row-label {
969 flex: 1;
970}
971
972.label {
973 font-size: 13.5px;
974 font-weight: 500;
975 color: var(--text-1);
976}
977
978.desc {
979 font-size: 11.5px;
980 color: var(--text-4);
981 line-height: 1.6;
982}
983
984.path-code {
985 margin-top: 4px;
986 padding: 3px 7px;
987 border-radius: var(--r-xs);
988 background: var(--surface-3);
989 color: var(--text-3);
990 font-family: var(--font-mono);
991 font-size: 11px;
992 line-height: 1.5;
993 word-break: break-all;
994 user-select: text;
995}
996
997.row-value {
998 display: inline-flex;
999 align-items: center;
1000 gap: 6px;
1001 font-size: 12.5px;
1002 color: var(--text-3);
1003 max-width: 46%;
1004 flex: none;
1005}
1006
1007.row-value.clickable {
1008 height: 32px;
1009 padding: 0 11px;
1010 border-radius: var(--r-sm);
1011 background: var(--surface-3);
1012 transition: background-color var(--t-fast) var(--ease-out), color var(--t-fast) var(--ease-out);
1013}
1014
1015.row-value.clickable:hover {
1016 background: var(--theme-color-soft);
1017 color: var(--theme-color-dark);
1018}
1019
1020.row-actions {
1021 display: flex;
1022 align-items: center;
1023 gap: 7px;
1024 flex: none;
1025}
1026
1027.mini-btn,
1028.ghost-btn {
1029 display: inline-flex;
1030 align-items: center;
1031 gap: 6px;
1032 height: 30px;
1033 padding: 0 11px;
1034 border-radius: var(--r-sm);
1035 border: 1px solid var(--line-2);
1036 background: var(--surface-1);
1037 color: var(--text-2);
1038 font-size: 12px;
1039 white-space: nowrap;
1040 transition: border-color var(--t-fast) var(--ease-out), background-color var(--t-fast) var(--ease-out),
1041 color var(--t-fast) var(--ease-out);
1042}
1043
1044.mini-btn:hover:not(:disabled),
1045.ghost-btn:hover {
1046 border-color: var(--theme-color);
1047 background: var(--theme-color-soft);
1048 color: var(--theme-color-dark);
1049}
1050
1051.mini-btn:disabled {
1052 opacity: 0.45;
1053 cursor: not-allowed;
1054}
1055
1056.mini-btn.is-danger {
1057 color: var(--color-danger);
1058 border-color: rgba(244, 63, 94, 0.3);
1059}
1060
1061.mini-btn.is-danger:hover {
1062 background: rgba(244, 63, 94, 0.1);
1063 border-color: var(--color-danger);
1064 color: var(--color-danger);
1065}
1066
1067.mini-primary {
1068 display: inline-flex;
1069 align-items: center;
1070 gap: 6px;
1071 height: 32px;
1072 padding: 0 14px;
1073 border-radius: var(--r-sm);
1074 color: #fff;
1075 font-size: 12.5px;
1076 background: linear-gradient(135deg, var(--theme-color), var(--theme-color-dark));
1077 box-shadow: 0 4px 12px rgba(var(--theme-color-rgb), 0.3);
1078 flex: none;
1079}
1080
1081.mini-primary:hover {
1082 filter: brightness(1.06);
1083}
1084
1085/* ---------- Chips ---------- */
1086
1087.chips {
1088 display: flex;
1089 flex-wrap: wrap;
1090 gap: 7px;
1091}
1092
1093.chip {
1094 height: 30px;
1095 padding: 0 14px;
1096 border-radius: var(--r-full);
1097 background: var(--surface-3);
1098 color: var(--text-3);
1099 font-size: 12.5px;
1100 transition: background-color var(--t-base) var(--ease-out), color var(--t-base) var(--ease-out),
1101 box-shadow var(--t-base) var(--ease-out), transform var(--t-fast) var(--ease-out);
1102}
1103
1104.chip:hover {
1105 color: var(--text-1);
1106}
1107
1108.chip:active {
1109 transform: scale(0.97);
1110}
1111
1112.chip.is-on {
1113 color: #fff;
1114 background: linear-gradient(135deg, var(--theme-color), var(--theme-color-dark));
1115 box-shadow: 0 4px 12px rgba(var(--theme-color-rgb), 0.28);
1116}
1117
1118/* ---------- 主题网格 ---------- */
1119
1120.theme-grid {
1121 display: grid;
1122 grid-template-columns: repeat(auto-fill, minmax(96px, 1fr));
1123 gap: 8px;
1124}
1125
1126.theme-item {
1127 display: flex;
1128 align-items: center;
1129 gap: 8px;
1130 height: 38px;
1131 padding: 0 10px;
1132 border-radius: var(--r-md);
1133 border: 1.5px solid var(--line-1);
1134 background: var(--surface-2);
1135 transition: border-color var(--t-base) var(--ease-out), background-color var(--t-base) var(--ease-out),
1136 transform var(--t-fast) var(--ease-out);
1137}
1138
1139.theme-item:hover {
1140 transform: translateY(-1px);
1141}
1142
1143.theme-item.is-on {
1144 border-color: var(--c);
1145 background: color-mix(in srgb, var(--c) 8%, var(--surface-1));
1146}
1147
1148.theme-dot {
1149 display: grid;
1150 place-items: center;
1151 width: 20px;
1152 height: 20px;
1153 border-radius: var(--r-full);
1154 background: var(--c);
1155 color: #fff;
1156 flex: none;
1157}
1158
1159.theme-name {
1160 font-size: 12px;
1161 color: var(--text-2);
1162 white-space: nowrap;
1163}
1164
1165.slider-wrap {
1166 padding: 0 2px;
1167}
1168
1169/* ---------- 自定义源列表 ---------- */
1170
1171.sub-head {
1172 display: flex;
1173 align-items: center;
1174 justify-content: space-between;
1175 gap: 10px;
1176 padding: 14px 0 8px;
1177 font-size: 11.5px;
1178 color: var(--text-5);
1179 letter-spacing: 0.3px;
1180}
1181
1182.status {
1183 display: inline-flex;
1184 align-items: center;
1185 gap: 5px;
1186 font-size: 11.5px;
1187}
1188
1189.api-list {
1190 display: flex;
1191 flex-direction: column;
1192 gap: 6px;
1193 padding-bottom: 14px;
1194}
1195
1196.api-item {
1197 display: flex;
1198 align-items: center;
1199 gap: 12px;
1200 padding: 10px 12px;
1201 border-radius: var(--r-lg);
1202 border: 1px solid var(--line-1);
1203 background: var(--surface-2);
1204 transition: border-color var(--t-base) var(--ease-out), background-color var(--t-base) var(--ease-out);
1205}
1206
1207.api-item:hover {
1208 background: var(--surface-3);
1209}
1210
1211.api-item.is-active {
1212 border-color: rgba(var(--theme-color-rgb), 0.42);
1213 background: var(--theme-color-soft);
1214}
1215
1216.api-mark {
1217 display: grid;
1218 place-items: center;
1219 width: 34px;
1220 height: 34px;
1221 border-radius: var(--r-md);
1222 background: var(--surface-1);
1223 color: var(--theme-color);
1224 flex: none;
1225 box-shadow: var(--sh-xs);
1226}
1227
1228.api-info {
1229 flex: 1;
1230 min-width: 0;
1231 display: flex;
1232 flex-direction: column;
1233 gap: 2px;
1234}
1235
1236.api-name-row {
1237 display: flex;
1238 align-items: center;
1239 gap: 7px;
1240 min-width: 0;
1241}
1242
1243.api-name {
1244 font-size: 13.5px;
1245 font-weight: 600;
1246 color: var(--text-1);
1247}
1248
1249.api-ver {
1250 font-style: normal;
1251 font-size: 10.5px;
1252 padding: 0 5px;
1253 border-radius: var(--r-xs);
1254 background: var(--surface-1);
1255 color: var(--text-4);
1256}
1257
1258.api-using {
1259 font-style: normal;
1260 font-size: 10.5px;
1261 padding: 0 6px;
1262 border-radius: var(--r-xs);
1263 background: var(--theme-color);
1264 color: #fff;
1265}
1266
1267.api-desc {
1268 font-size: 11.5px;
1269 color: var(--text-4);
1270}
1271
1272.api-actions {
1273 display: flex;
1274 align-items: center;
1275 gap: 6px;
1276 flex: none;
1277}
1278
1279.api-btn {
1280 display: inline-flex;
1281 align-items: center;
1282 gap: 5px;
1283 height: 28px;
1284 padding: 0 10px;
1285 border-radius: var(--r-sm);
1286 border: 1px solid var(--line-2);
1287 background: var(--surface-1);
1288 color: var(--text-2);
1289 font-size: 12px;
1290 transition: border-color var(--t-fast) var(--ease-out), color var(--t-fast) var(--ease-out),
1291 background-color var(--t-fast) var(--ease-out);
1292}
1293
1294.api-btn:hover:not(:disabled) {
1295 border-color: var(--theme-color);
1296 color: var(--theme-color-dark);
1297 background: var(--theme-color-soft);
1298}
1299
1300.api-btn:disabled {
1301 opacity: 0.4;
1302 cursor: not-allowed;
1303}
1304
1305.api-btn.is-danger {
1306 padding: 0 8px;
1307 color: var(--color-danger);
1308 border-color: rgba(244, 63, 94, 0.28);
1309}
1310
1311.api-btn.is-danger:hover {
1312 background: rgba(244, 63, 94, 0.1);
1313 border-color: var(--color-danger);
1314 color: var(--color-danger);
1315}
1316
1317.empty-line {
1318 padding: 10px 0 18px;
1319 font-size: 12.5px;
1320 color: var(--text-5);
1321}
1322
1323.copyright {
1324 padding: 14px 0 18px;
1325 font-size: 11.5px;
1326 color: var(--text-5);
1327 text-align: center;
1328}
1329
1330/* ---------- 日志 ---------- */
1331
1332.log-list {
1333 max-height: 46vh;
1334 overflow-y: auto;
1335 border-radius: var(--r-md);
1336 background: var(--surface-2);
1337 border: 1px solid var(--line-1);
1338 padding: 6px;
1339}
1340
1341.log-empty {
1342 padding: 34px;
1343 text-align: center;
1344 font-size: 12.5px;
1345 color: var(--text-5);
1346}
1347
1348.log-item {
1349 display: grid;
1350 grid-template-columns: 58px 48px minmax(0, 1fr);
1351 gap: 9px;
1352 padding: 6px 8px;
1353 border-radius: var(--r-xs);
1354 font-size: 12px;
1355 line-height: 1.6;
1356 font-family: var(--font-mono);
1357}
1358
1359.log-item:hover {
1360 background: var(--surface-3);
1361}
1362
1363.log-time {
1364 color: var(--text-5);
1365}
1366
1367.log-type {
1368 text-align: center;
1369 border-radius: var(--r-xs);
1370 background: var(--surface-4);
1371 color: var(--text-3);
1372 font-size: 10.5px;
1373 height: 18px;
1374 line-height: 18px;
1375}
1376
1377.log-item.t-error .log-type {
1378 background: rgba(244, 63, 94, 0.14);
1379 color: var(--color-danger);
1380}
1381
1382.log-item.t-response .log-type {
1383 background: rgba(34, 197, 94, 0.14);
1384 color: var(--color-success);
1385}
1386
1387.log-item.t-request .log-type {
1388 background: rgba(59, 130, 246, 0.14);
1389 color: var(--color-info);
1390}
1391
1392.log-item.t-init .log-type {
1393 background: rgba(var(--theme-color-rgb), 0.16);
1394 color: var(--theme-color-dark);
1395}
1396
1397.log-msg {
1398 word-break: break-word;
1399 color: var(--text-2);
1400}
1401
1402.log-detail {
1403 font-style: normal;
1404 display: block;
1405 color: var(--text-4);
1406 font-size: 11px;
1407}
1408
1409/* ---------- 赞助 ---------- */
1410
1411.sponsor {
1412 display: flex;
1413 flex-direction: column;
1414 align-items: center;
1415 gap: 10px;
1416 padding-bottom: 12px;
1417}
1418
1419.sponsor-img {
1420 width: 100%;
1421 max-width: 300px;
1422 border-radius: var(--r-lg);
1423 box-shadow: var(--sh-md);
1424}
1425
1426.sponsor-tip {
1427 font-size: 11.5px;
1428 color: var(--text-5);
1429}
1430</style>