starmusic-pc
1<script setup>
2/**
3 * AppSideBar — 左侧导航
4 *
5 * 结构:
6 * 1. 主导航(带滑动的选中指示条)
7 * 2. 我的歌单快捷入口(试听 / 收藏 / 自建歌单,实时数量)
8 * 3. 底部:个人账号入口 + 当前音源
9 * 窄窗口(<1080px)自动折叠为图标条,也可手动折叠。
10 */
11import { computed } from 'vue'
12import Icon from './Icon.vue'
13import { NAV_ITEMS, currentView, go, openPlaylist, playlistTarget, sidebarCollapsed } from '../composables/useShell.js'
14import { getList, getCustomLists, LIST_IDS } from '../common/playlist.js'
15import { SOURCE_NAMES } from '../common/constants.js'
16import { getSetting } from '../common/setting.js'
17import { getLocalAccount, isLoggedIn } from '../common/account.js'
18import event from '../common/event.js'
19import { ref, onMounted, onBeforeUnmount } from 'vue'
20
21/** 列表数量需要响应 listUpdated / customListUpdated 事件,用一个自增的版本号驱动重算 */
22const version = ref(0)
23let offList = null
24let offCustom = null
25
26const bump = () => {
27 version.value++
28}
29
30onMounted(() => {
31 offList = event.on('listUpdated', bump)
32 offCustom = event.on('customListUpdated', bump)
33})
34
35onBeforeUnmount(() => {
36 if (offList) offList()
37 if (offCustom) offCustom()
38})
39
40const counts = computed(() => {
41 // 依赖 version 以在列表变化时重算
42 void version.value
43 const map = { [LIST_IDS.DEFAULT]: getList(LIST_IDS.DEFAULT).length, [LIST_IDS.LOVE]: getList(LIST_IDS.LOVE).length }
44 for (const info of getCustomLists()) map[info.id] = getList(info.id).length
45 return map
46})
47
48const customLists = computed(() => {
49 void version.value
50 return getCustomLists()
51})
52
53const quickLists = computed(() => [
54 { id: LIST_IDS.DEFAULT, name: '试听列表', icon: 'clock', count: counts.value[LIST_IDS.DEFAULT] || 0 },
55 { id: LIST_IDS.LOVE, name: '我的收藏', icon: 'heart', count: counts.value[LIST_IDS.LOVE] || 0 },
56 ...customLists.value.slice(0, 8).map((info) => ({
57 id: info.id,
58 name: info.name,
59 icon: 'list-music',
60 count: counts.value[info.id] || 0,
61 })),
62])
63
64const isPlaylistActive = (id) => currentView.value === 'playlists' && playlistTarget.value === id
65
66/** 当前音源名称 */
67const sourceName = computed(() => {
68 void version.value
69 const id = getSetting()['common.apiSource']
70 if (!id) return '内置搜索源'
71 return SOURCE_NAMES[id] || '自定义源'
72})
73
74const currentSourceLabel = ref('')
75const refreshSource = () => {
76 const id = getSetting()['common.apiSource']
77 currentSourceLabel.value = !id ? '内置搜索源' : id.indexOf('user_api') === 0 ? '自定义源' : SOURCE_NAMES[id] || id
78}
79
80// ============ 个人账号入口 ============
81//
82// 侧栏底部账号卡片:未登录显示「未登录 / 点击登录账号」,已登录显示用户名,
83// 两种情况点击都进账号页 —— 未登录时那里就是登录表单,已登录时是个人中心。
84//
85// 注意登录态是从本地存储读的,本身不是响应式数据,必须靠 accountChanged
86// 事件驱动刷新。不要写成 computed(() => isLoggedIn()):这种「无响应式依赖」
87// 的 computed 会被 Vue 永久缓存,登录/退出后永远不会重算。
88
89/** 本地缓存的账号信息({ username, sync_time, ... }),未登录为 null */
90const account = ref(getLocalAccount())
91
92/** 是否已登录:依赖 account 这个 ref,登录/退出后能自动重算 */
93const loggedIn = computed(() => !!account.value && isLoggedIn())
94
95const accountName = computed(() => (account.value && account.value.username ? account.value.username : ''))
96
97/** 头像文字:用户名首字(英文转大写) */
98const avatarText = computed(() => (accountName.value ? accountName.value.slice(0, 1).toUpperCase() : ''))
99
100const accountTip = computed(() => (loggedIn.value ? `已登录:${accountName.value}` : '未登录,点击前往登录'))
101
102const refreshAccount = () => {
103 account.value = getLocalAccount()
104}
105
106/** 点击账号卡片:统一进账号页 */
107const openAccount = () => go('account')
108
109let offSetting = null
110let offAccount = null
111onMounted(() => {
112 refreshSource()
113 refreshAccount()
114 offSetting = event.on('settingUpdated', refreshSource)
115 offAccount = event.on('accountChanged', refreshAccount)
116})
117onBeforeUnmount(() => {
118 if (offSetting) offSetting()
119 if (offAccount) offAccount()
120})
121
122const toggleCollapse = () => {
123 sidebarCollapsed.value = !sidebarCollapsed.value
124}
125</script>
126
127<template>
128 <aside class="sidebar" :class="{ 'is-collapsed': sidebarCollapsed }">
129 <!-- 主导航 -->
130 <nav class="nav">
131 <button
132 v-for="item in NAV_ITEMS"
133 :key="item.key"
134 class="nav-item"
135 :class="{ 'is-active': currentView === item.key }"
136 :title="sidebarCollapsed ? item.label : ''"
137 @click="go(item.key)"
138 >
139 <span class="nav-icon">
140 <Icon :name="item.icon" :size="18" />
141 </span>
142 <span class="nav-label">{{ item.label }}</span>
143 <span v-if="currentView === item.key" class="nav-indicator" />
144 </button>
145 </nav>
146
147 <div class="sep" />
148
149 <!-- 歌单快捷入口 -->
150 <div class="section">
151 <div class="section-head">
152 <span class="section-title">我的歌单</span>
153 <button class="mini-btn" title="管理歌单" @click="go('playlists')">
154 <Icon name="chevron-right" :size="14" />
155 </button>
156 </div>
157 <div class="quick scroll-y">
158 <button
159 v-for="item in quickLists"
160 :key="item.id"
161 class="quick-item"
162 :class="{ 'is-active': isPlaylistActive(item.id) }"
163 :title="sidebarCollapsed ? `${item.name}(${item.count})` : ''"
164 @click="openPlaylist(item.id)"
165 >
166 <span class="quick-icon">
167 <Icon :name="item.icon" :size="15" />
168 </span>
169 <span class="quick-name ellipsis">{{ item.name }}</span>
170 <span class="quick-count">{{ item.count }}</span>
171 </button>
172 <p v-if="!quickLists.length" class="quick-empty">还没有歌单</p>
173 </div>
174 </div>
175
176 <!-- 底部:个人账号入口 + 当前音源 -->
177 <div class="foot">
178 <button class="account-card" :class="{ 'is-guest': !loggedIn }" :title="accountTip" @click="openAccount">
179 <span class="avatar" :class="{ 'is-guest': !loggedIn }">
180 <template v-if="loggedIn">
181 <span class="avatar-text">{{ avatarText }}</span>
182 <span class="avatar-dot" />
183 </template>
184 <Icon v-else name="user" :size="16" />
185 </span>
186 <span class="account-text">
187 <span class="account-name ellipsis">{{ loggedIn ? accountName : '未登录' }}</span>
188 <span class="account-sub ellipsis">{{ loggedIn ? '已登录 · 云同步' : '点击登录账号' }}</span>
189 </span>
190 <Icon class="account-arrow" name="chevron-right" :size="14" />
191 </button>
192
193 <div class="src-row" :title="`当前音源:${currentSourceLabel}`">
194 <Icon name="database" :size="14" />
195 <span class="ellipsis">{{ currentSourceLabel }}</span>
196 </div>
197
198 <button class="collapse-btn" :title="sidebarCollapsed ? '展开侧栏' : '折叠侧栏'" @click="toggleCollapse">
199 <Icon :name="sidebarCollapsed ? 'chevron-right' : 'chevron-left'" :size="14" />
200 </button>
201 </div>
202 </aside>
203</template>
204
205<style scoped>
206.sidebar {
207 width: var(--w-sidebar);
208 flex: none;
209 display: flex;
210 flex-direction: column;
211 min-height: 0;
212 background: var(--surface-1);
213 border-right: 1px solid var(--line-1);
214 padding: 12px 10px 10px;
215 transition: width var(--t-slow) var(--ease-out);
216 position: relative;
217 z-index: 20;
218}
219
220.sidebar.is-collapsed {
221 width: 68px;
222 padding-left: 8px;
223 padding-right: 8px;
224}
225
226/* ---------- 主导航 ---------- */
227
228.nav {
229 display: flex;
230 flex-direction: column;
231 gap: 3px;
232 flex: none;
233}
234
235.nav-item {
236 position: relative;
237 display: flex;
238 align-items: center;
239 gap: 11px;
240 height: 40px;
241 padding: 0 11px;
242 border-radius: var(--r-md);
243 color: var(--text-2);
244 font-size: 13.5px;
245 transition: background-color var(--t-fast) var(--ease-out), color var(--t-fast) var(--ease-out);
246 overflow: hidden;
247}
248
249.nav-item:hover {
250 background: var(--surface-3);
251 color: var(--text-1);
252}
253
254.nav-item.is-active {
255 background: var(--theme-color-soft);
256 color: var(--theme-color-dark);
257 font-weight: 600;
258}
259
260.nav-icon {
261 display: grid;
262 place-items: center;
263 flex: none;
264 width: 20px;
265 transition: transform var(--t-base) var(--ease-overshoot);
266}
267
268.nav-item:hover .nav-icon {
269 transform: scale(1.1);
270}
271
272.nav-item.is-active .nav-icon {
273 transform: scale(1.06);
274}
275
276.nav-label {
277 white-space: nowrap;
278 overflow: hidden;
279 text-overflow: ellipsis;
280 transition: opacity var(--t-base) var(--ease-out);
281}
282
283.nav-indicator {
284 position: absolute;
285 left: 0;
286 top: 50%;
287 width: 3px;
288 height: 18px;
289 margin-top: -9px;
290 border-radius: 0 3px 3px 0;
291 background: var(--theme-color);
292 animation: kSlideInLeft var(--t-base) var(--ease-out);
293}
294
295.sep {
296 height: 1px;
297 margin: 12px 6px 10px;
298 background: var(--line-1);
299 flex: none;
300}
301
302/* ---------- 歌单区 ---------- */
303
304.section {
305 flex: 1;
306 min-height: 0;
307 display: flex;
308 flex-direction: column;
309}
310
311.section-head {
312 display: flex;
313 align-items: center;
314 justify-content: space-between;
315 padding: 0 11px 6px;
316 flex: none;
317}
318
319.section-title {
320 font-size: 11px;
321 letter-spacing: 0.8px;
322 color: var(--text-5);
323 text-transform: uppercase;
324}
325
326.mini-btn {
327 display: grid;
328 place-items: center;
329 width: 20px;
330 height: 20px;
331 border-radius: var(--r-xs);
332 color: var(--text-4);
333 transition: background-color var(--t-fast) var(--ease-out), color var(--t-fast) var(--ease-out);
334}
335
336.mini-btn:hover {
337 background: var(--surface-3);
338 color: var(--theme-color);
339}
340
341.quick {
342 flex: 1;
343 min-height: 0;
344 display: flex;
345 flex-direction: column;
346 gap: 1px;
347}
348
349.quick-item {
350 display: flex;
351 align-items: center;
352 gap: 10px;
353 height: 34px;
354 padding: 0 11px;
355 border-radius: var(--r-sm);
356 color: var(--text-2);
357 font-size: 13px;
358 transition: background-color var(--t-fast) var(--ease-out), color var(--t-fast) var(--ease-out);
359}
360
361.quick-item:hover {
362 background: var(--surface-3);
363 color: var(--text-1);
364}
365
366.quick-item.is-active {
367 background: var(--theme-color-soft);
368 color: var(--theme-color-dark);
369 font-weight: 500;
370}
371
372.quick-icon {
373 display: grid;
374 place-items: center;
375 flex: none;
376 width: 18px;
377 color: inherit;
378 opacity: 0.85;
379}
380
381.quick-name {
382 flex: 1;
383 min-width: 0;
384 text-align: left;
385}
386
387.quick-count {
388 flex: none;
389 font-size: 11px;
390 font-variant-numeric: tabular-nums;
391 color: var(--text-4);
392}
393
394.quick-empty {
395 padding: 8px 11px;
396 font-size: 12px;
397 color: var(--text-5);
398}
399
400/* ---------- 底部 ---------- */
401
402.foot {
403 flex: none;
404 padding-top: 10px;
405 border-top: 1px solid var(--line-1);
406 display: flex;
407 flex-direction: column;
408 gap: 9px;
409}
410
411/* ---------- 个人账号卡片 ---------- */
412
413.account-card {
414 display: flex;
415 align-items: center;
416 gap: 9px;
417 width: 100%;
418 padding: 8px 9px;
419 border-radius: var(--r-md);
420 border: 1px solid var(--line-1);
421 background: var(--surface-2);
422 text-align: left;
423 transition: border-color var(--t-fast) var(--ease-out), background-color var(--t-fast) var(--ease-out),
424 box-shadow var(--t-fast) var(--ease-out), transform var(--t-fast) var(--ease-out);
425}
426
427.account-card:hover {
428 border-color: rgba(var(--theme-color-rgb), 0.42);
429 background: var(--theme-color-soft);
430 box-shadow: var(--sh-xs);
431}
432
433.account-card:active {
434 transform: scale(0.985);
435}
436
437.avatar {
438 position: relative;
439 flex: none;
440 display: grid;
441 place-items: center;
442 width: 30px;
443 height: 30px;
444 border-radius: var(--r-full);
445 background: linear-gradient(135deg, var(--theme-color), var(--theme-color-dark));
446 color: #fff;
447 font-weight: 600;
448 box-shadow: 0 2px 8px rgba(var(--theme-color-rgb), 0.32);
449}
450
451.avatar.is-guest {
452 background: var(--surface-3);
453 color: var(--text-4);
454 box-shadow: none;
455}
456
457.avatar-text {
458 font-size: 14px;
459 line-height: 1;
460}
461
462/* 已登录的小绿点 */
463.avatar-dot {
464 position: absolute;
465 right: -1px;
466 bottom: -1px;
467 width: 9px;
468 height: 9px;
469 border-radius: var(--r-full);
470 background: var(--color-success);
471 border: 1.5px solid var(--surface-1);
472}
473
474.account-text {
475 flex: 1;
476 min-width: 0;
477 display: flex;
478 flex-direction: column;
479 gap: 1px;
480}
481
482.account-name {
483 font-size: 12.5px;
484 font-weight: 600;
485 color: var(--text-1);
486}
487
488.account-card.is-guest .account-name {
489 font-weight: 500;
490 color: var(--text-2);
491}
492
493.account-sub {
494 font-size: 10.5px;
495 color: var(--text-4);
496}
497
498.account-arrow {
499 flex: none;
500 color: var(--text-5);
501 transition: transform var(--t-fast) var(--ease-out), color var(--t-fast) var(--ease-out);
502}
503
504.account-card:hover .account-arrow {
505 color: var(--theme-color);
506 transform: translateX(2px);
507}
508
509.src-row {
510 display: flex;
511 align-items: center;
512 gap: 7px;
513 padding: 0 4px;
514 font-size: 11.5px;
515 color: var(--text-4);
516 min-width: 0;
517}
518
519.collapse-btn {
520 display: grid;
521 place-items: center;
522 height: 26px;
523 margin: 0 2px;
524 border-radius: var(--r-sm);
525 color: var(--text-4);
526 transition: background-color var(--t-fast) var(--ease-out), color var(--t-fast) var(--ease-out);
527}
528
529.collapse-btn:hover {
530 background: var(--surface-3);
531 color: var(--theme-color);
532}
533
534/* ---------- 折叠态 ---------- */
535
536.sidebar.is-collapsed .nav-label,
537.sidebar.is-collapsed .quick-name,
538.sidebar.is-collapsed .quick-count,
539.sidebar.is-collapsed .section-title,
540.sidebar.is-collapsed .mini-btn,
541.sidebar.is-collapsed .account-text,
542.sidebar.is-collapsed .account-arrow,
543.sidebar.is-collapsed .src-row span {
544 display: none;
545}
546
547.sidebar.is-collapsed .nav-item,
548.sidebar.is-collapsed .quick-item {
549 justify-content: center;
550 padding: 0;
551}
552
553/* 折叠态只留头像,居中成一个圆形按钮 */
554.sidebar.is-collapsed .account-card {
555 justify-content: center;
556 padding: 7px 0;
557 border-color: transparent;
558 background: transparent;
559}
560
561.sidebar.is-collapsed .section-head {
562 display: none;
563}
564
565.sidebar.is-collapsed .src-row {
566 justify-content: center;
567}
568
569.sidebar.is-collapsed .foot {
570 gap: 6px;
571}
572</style>