仰望星辰工作室

starmusic-pc

starmusic-pc/ frontend/src/views/PlaylistView.vue 25.6 KB · 998 行 原始文件
1<script setup>
2/**
3 * PlaylistView — 我的歌单
4 *
5 * 覆盖移动端的全部能力:内置列表(试听/收藏)+ 自建歌单 CRUD、播放全部、
6 * 批量多选(删除/收藏)、从分享链接导入歌单。
7 * 桌面端增强:列表 Tab 支持横向滚动与实时数量、批量条停靠在播放条上方、
8 * 行内「更多」改为右键上下文菜单。
9 */
10import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
11import Icon from '../components/Icon.vue'
12import MusicList from '../components/MusicList.vue'
13import UiDialog from '../components/UiDialog.vue'
14import UiContextMenu from '../components/UiContextMenu.vue'
15import CoverImage from '../components/CoverImage.vue'
16import {
17 getList,
18 getCustomLists,
19 createCustomList,
20 renameCustomList,
21 removeCustomList,
22 addListMusics,
23 removeListMusics,
24 clearListMusics,
25 checkListContainsMusic,
26 normalizeMusicInfo,
27 LIST_IDS,
28} from '../common/playlist.js'
29import { importSongListFromUrl, getSongListSources } from '../common/music-sdk/songlist/index.js'
30import { playerState, playerActions } from '../composables/usePlayer.js'
31import { playlistTarget } from '../composables/useShell.js'
32import { toast, toastOk, toastErr, confirm, errText, alert } from '../composables/useFeedback.js'
33import event from '../common/event.js'
34
35// ============ 列表状态 ============
36
37const currentTab = ref(playlistTarget.value || LIST_IDS.DEFAULT)
38const version = ref(0)
39
40const bump = () => {
41 version.value++
42}
43
44const tabs = computed(() => {
45 void version.value
46 return [
47 { id: LIST_IDS.DEFAULT, name: '试听列表', icon: 'clock', count: getList(LIST_IDS.DEFAULT).length },
48 { id: LIST_IDS.LOVE, name: '我的收藏', icon: 'heart', count: getList(LIST_IDS.LOVE).length },
49 ...getCustomLists().map((info) => ({
50 id: info.id,
51 name: info.name,
52 icon: 'list-music',
53 custom: true,
54 count: getList(info.id).length,
55 })),
56 ]
57})
58
59const currentList = computed(() => {
60 void version.value
61 return getList(currentTab.value)
62})
63
64const currentTabInfo = computed(() => tabs.value.find((t) => t.id === currentTab.value) || tabs.value[0])
65const isCustom = computed(() => !!currentTabInfo.value && !!currentTabInfo.value.custom)
66
67const totalDurationText = computed(() => {
68 let seconds = 0
69 for (const item of currentList.value) {
70 const t = item && item.interval
71 if (!t || t === '--/--') continue
72 const parts = String(t).split(':')
73 if (parts.length === 2) seconds += parseInt(parts[0], 10) * 60 + parseInt(parts[1], 10)
74 }
75 if (!seconds) return ''
76 const h = Math.floor(seconds / 3600)
77 const m = Math.round((seconds % 3600) / 60)
78 return h > 0 ? `${h} 小时 ${m} 分` : `${m} 分钟`
79})
80
81// ============ 播放 ============
82
83const playAll = () => {
84 if (!currentList.value.length) {
85 toast('列表为空,先添加一些歌曲吧', 'none', 1800)
86 return
87 }
88 playerActions.playListByList([...currentList.value], 0)
89}
90
91const playAt = (item, index) => {
92 const list = [...currentList.value]
93 playerActions.playListByList(list, index)
94}
95
96const playItemOnly = (item) => {
97 const index = currentList.value.findIndex((m) => m.id === item.id)
98 playAt(item, index < 0 ? 0 : index)
99}
100
101// ============ 批量 ============
102
103const batchMode = ref(false)
104const selectedIds = ref([])
105
106const toggleBatch = () => {
107 batchMode.value = !batchMode.value
108 if (!batchMode.value) selectedIds.value = []
109}
110
111const onBatchToggle = (ids) => {
112 selectedIds.value = ids
113}
114
115const batchAll = () => {
116 if (selectedIds.value.length === currentList.value.length) selectedIds.value = []
117 else selectedIds.value = currentList.value.map((m) => m.id)
118}
119
120const batchDelete = async () => {
121 if (!selectedIds.value.length) return
122 const ok = await confirm({
123 title: '从列表移除',
124 content: `确定把选中的 ${selectedIds.value.length} 首歌从「${currentTabInfo.value.name}」中移除吗?`,
125 confirmText: '移除',
126 danger: true,
127 })
128 if (!ok) return
129 removeListMusics(currentTab.value, selectedIds.value)
130 toastOk(`已移除 ${selectedIds.value.length} 首`)
131 selectedIds.value = []
132 bump()
133}
134
135const batchCollect = () => {
136 if (!selectedIds.value.length) return
137 const isLove = currentTab.value === LIST_IDS.LOVE
138 const targets = currentList.value.filter((m) => selectedIds.value.includes(m.id))
139 if (isLove) {
140 removeListMusics(LIST_IDS.LOVE, selectedIds.value)
141 toastOk(`已取消收藏 ${selectedIds.value.length} 首`)
142 } else {
143 addListMusics(LIST_IDS.LOVE, targets, 'top')
144 toastOk(`已收藏 ${selectedIds.value.length} 首`)
145 }
146 selectedIds.value = []
147 bump()
148}
149
150const clearCurrent = async () => {
151 if (!currentList.value.length) return
152 const ok = await confirm({
153 title: '清空列表',
154 content: `确定清空「${currentTabInfo.value.name}」中的全部歌曲吗?此操作不可恢复。`,
155 confirmText: '清空',
156 danger: true,
157 })
158 if (!ok) return
159 clearListMusics(currentTab.value)
160 toastOk('已清空')
161 bump()
162}
163
164// ============ 行菜单 ============
165
166const menu = ref({ visible: false, x: 0, y: 0, item: null })
167
168const openMenu = (item, pos) => {
169 menu.value = { visible: true, x: pos.x, y: pos.y, item }
170}
171
172const closeMenu = () => {
173 menu.value = { ...menu.value, visible: false }
174}
175
176const menuItems = computed(() => {
177 const item = menu.value.item
178 if (!item) return []
179 const inLove = checkListContainsMusic(LIST_IDS.LOVE, item.id)
180 const inCurrent = checkListContainsMusic(currentTab.value, item.id)
181 return [
182 { key: 'play', label: '播放这首歌', icon: 'play' },
183 { key: 'next', label: '下一首播放', icon: 'queue' },
184 { key: 'collect', label: inLove ? '取消收藏' : '收藏到我的收藏', icon: inLove ? 'heart-fill' : 'heart' },
185 { divider: true },
186 {
187 key: 'remove',
188 label: '从当前列表移除',
189 icon: 'trash',
190 danger: true,
191 disabled: !inCurrent,
192 },
193 ]
194})
195
196const onMenuSelect = (key) => {
197 const item = menu.value.item
198 if (!item) return
199 switch (key) {
200 case 'play':
201 playItemOnly(item)
202 break
203 case 'next': {
204 // 插到当前播放位置之后
205 const list = [...playerState.playList]
206 const at = playerState.playIndex + 1
207 list.splice(at < 0 ? list.length : at, 0, normalizeMusicInfo(item))
208 const keepIndex = playerState.playIndex
209 playerActions.playListByList(list, keepIndex < 0 ? 0 : keepIndex)
210 toastOk('已加入下一首播放')
211 break
212 }
213 case 'collect': {
214 if (checkListContainsMusic(LIST_IDS.LOVE, item.id)) {
215 removeListMusics(LIST_IDS.LOVE, [item.id])
216 toast('已取消收藏', 'none', 1500)
217 } else {
218 addListMusics(LIST_IDS.LOVE, [item], 'top')
219 toastOk('已收藏')
220 }
221 bump()
222 break
223 }
224 case 'remove':
225 removeListMusics(currentTab.value, [item.id])
226 toastOk('已从列表移除')
227 bump()
228 break
229 }
230}
231
232// ============ 新建 / 重命名 ============
233
234const nameDialog = ref({ visible: false, mode: 'create', value: '', error: '' })
235
236const openCreate = () => {
237 nameDialog.value = { visible: true, mode: 'create', value: '', error: '' }
238}
239
240const openRename = () => {
241 if (!isCustom.value) return
242 nameDialog.value = { visible: true, mode: 'rename', value: currentTabInfo.value.name, error: '' }
243}
244
245const nameDialogTitle = computed(() => (nameDialog.value.mode === 'create' ? '新建歌单' : '重命名歌单'))
246
247const submitName = () => {
248 const name = nameDialog.value.value.trim()
249 if (!name) {
250 nameDialog.value.error = '歌单名不能为空'
251 return
252 }
253 try {
254 if (nameDialog.value.mode === 'create') {
255 const info = createCustomList(name)
256 currentTab.value = info.id
257 toastOk(`已创建「${info.name}」`)
258 } else {
259 renameCustomList(currentTab.value, name)
260 toastOk('已重命名')
261 }
262 nameDialog.value.visible = false
263 bump()
264 } catch (err) {
265 nameDialog.value.error = errText(err)
266 }
267}
268
269// ============ 删除歌单 ============
270
271const confirmDeleteList = async () => {
272 if (!isCustom.value) return
273 const ok = await confirm({
274 title: '删除歌单',
275 content: `确定删除「${currentTabInfo.value.name}」及其中全部歌曲吗?此操作不可恢复。`,
276 confirmText: '删除',
277 danger: true,
278 })
279 if (!ok) return
280 removeCustomList(currentTab.value)
281 currentTab.value = LIST_IDS.DEFAULT
282 toastOk('歌单已删除')
283 bump()
284}
285
286// ============ 导入歌单 ============
287
288const importDialog = ref({ visible: false, url: '', loading: false, result: null, error: '' })
289const songListSources = getSongListSources()
290
291const openImport = () => {
292 importDialog.value = { visible: true, url: '', loading: false, result: null, error: '' }
293}
294
295const parseImport = async () => {
296 const url = importDialog.value.url.trim()
297 if (!url) {
298 importDialog.value.error = '请粘贴歌单分享链接'
299 return
300 }
301 importDialog.value.error = ''
302 importDialog.value.loading = true
303 importDialog.value.result = null
304 try {
305 const res = await importSongListFromUrl(url)
306 if (!res.list || !res.list.length) throw new Error('歌单为空或解析失败')
307 importDialog.value.result = {
308 source: res.source,
309 list: res.list.map((m) => normalizeMusicInfo(m)),
310 info: res.info || {},
311 total: res.total || res.list.length,
312 }
313 toastOk(`解析成功:${(res.info && res.info.name) || '歌单'}`)
314 } catch (err) {
315 importDialog.value.error = errText(err, '解析失败')
316 toastErr(importDialog.value.error)
317 } finally {
318 importDialog.value.loading = false
319 }
320}
321
322const confirmImport = () => {
323 const result = importDialog.value.result
324 if (!result) return
325 const target = currentTab.value
326 const before = getList(target).length
327 addListMusics(target, result.list, 'top')
328 const after = getList(target).length
329 // 试听列表有 100 首上限,超出时给出明确提示
330 const overflow = before + result.list.length > after
331 toastOk(
332 overflow
333 ? `已导入 ${after - before} 首到「${currentTabInfo.value.name}」(列表超上限,仅保留前 ${after} 首)`
334 : `已导入 ${result.list.length} 首到「${currentTabInfo.value.name}」`,
335 )
336 importDialog.value.visible = false
337 bump()
338}
339
340const importPreviewCover = computed(() => {
341 const r = importDialog.value.result
342 if (!r || !r.info) return ''
343 return r.info.img || r.info.coverImgUrl || r.info.picUrl || ''
344})
345
346// ============ 生命周期 ============
347
348let offList = null
349let offCustom = null
350
351const syncTab = () => {
352 const exists = tabs.value.some((t) => t.id === currentTab.value)
353 if (!exists) currentTab.value = LIST_IDS.DEFAULT
354}
355
356onMounted(() => {
357 syncTab()
358 offList = event.on('listUpdated', bump)
359 offCustom = event.on('customListUpdated', () => {
360 bump()
361 syncTab()
362 })
363})
364
365onBeforeUnmount(() => {
366 if (offList) offList()
367 if (offCustom) offCustom()
368})
369
370// 侧边栏快捷入口点击后切到目标列表
371watch(playlistTarget, (id) => {
372 if (id) currentTab.value = id
373})
374
375const openConfigFolderUnused = () => {}
376</script>
377
378<template>
379 <div class="view-playlist">
380 <!-- ============ 头部 ============ -->
381 <header class="head a-fade-up">
382 <div class="head-left">
383 <h1>我的歌单</h1>
384 <p>
385 共 {{ tabs.length }} 个列表
386 <template v-if="currentList.length">
387 · 当前 <b>{{ currentList.length }}</b> 首
388 <template v-if="totalDurationText"> · {{ totalDurationText }}</template>
389 </template>
390 </p>
391 </div>
392
393 <div class="head-actions">
394 <button class="btn is-primary press" :disabled="!currentList.length" @click="playAll">
395 <Icon name="play" :size="14" />
396 <span>播放全部</span>
397 </button>
398 <button class="btn press" @click="openCreate">
399 <Icon name="plus" :size="14" />
400 <span>新建歌单</span>
401 </button>
402 <button class="btn press" @click="openImport">
403 <Icon name="link" :size="14" />
404 <span>导入歌单</span>
405 </button>
406 <button class="btn press" :class="{ 'is-on': batchMode }" @click="toggleBatch">
407 <Icon name="check" :size="14" />
408 <span>{{ batchMode ? '退出批量' : '批量管理' }}</span>
409 </button>
410 </div>
411 </header>
412
413 <!-- ============ 列表 Tab ============ -->
414 <nav class="tabs a-fade-up" style="--delay: 60ms">
415 <button
416 v-for="t in tabs"
417 :key="t.id"
418 class="tab"
419 :class="{ 'is-active': currentTab === t.id }"
420 @click="currentTab = t.id"
421 >
422 <Icon :name="t.icon" :size="14" />
423 <span class="tab-name">{{ t.name }}</span>
424 <em class="tab-count">{{ t.count }}</em>
425 </button>
426
427 <div v-if="isCustom" class="tab-extra">
428 <button class="tab-act" title="重命名歌单" @click="openRename">
429 <Icon name="edit" :size="14" />
430 </button>
431 <button class="tab-act is-danger" title="删除歌单" @click="confirmDeleteList">
432 <Icon name="trash" :size="14" />
433 </button>
434 </div>
435 </nav>
436
437 <!-- ============ 工具条 ============ -->
438 <div class="toolbar">
439 <div class="tb-left">
440 <span class="tb-name">{{ currentTabInfo.name }}</span>
441 <span class="tb-meta">{{ currentList.length }} 首</span>
442 </div>
443 <div class="tb-right">
444 <button v-if="batchMode" class="tb-btn" @click="batchAll">
445 <Icon name="check" :size="13" />
446 <span>{{ selectedIds.length === currentList.length && currentList.length ? '取消全选' : '全选' }}</span>
447 </button>
448 <button class="tb-btn" :disabled="!currentList.length" @click="clearCurrent">
449 <Icon name="trash" :size="13" />
450 <span>清空列表</span>
451 </button>
452 </div>
453 </div>
454
455 <!-- ============ 列表 ============ -->
456 <div class="list-area view-scroll scroll-y">
457 <MusicList
458 :list="currentList"
459 :show-quality="false"
460 :show-album="true"
461 :show-actions="true"
462 :batch-mode="batchMode"
463 :selected-ids="selectedIds"
464 :current-music-id="playerState.musicInfo ? playerState.musicInfo.id : ''"
465 :playing="playerState.isPlay"
466 :infinite="false"
467 empty-text="列表还是空的,去搜索页点歌就会出现在这里"
468 @item-click="playAt"
469 @item-play="playAt"
470 @item-more="openMenu"
471 @batch-toggle="onBatchToggle"
472 />
473 </div>
474
475 <!-- ============ 批量操作条 ============ -->
476 <Transition name="batch">
477 <div v-if="batchMode" class="batch-bar">
478 <span class="batch-text">
479 已选 <b>{{ selectedIds.length }}</b> 首
480 </span>
481 <div class="batch-actions">
482 <button class="btn press" :disabled="!selectedIds.length" @click="batchCollect">
483 <Icon :name="currentTab === 'love' ? 'heart' : 'heart-fill'" :size="14" />
484 <span>{{ currentTab === 'love' ? '取消收藏' : '收藏' }}</span>
485 </button>
486 <button class="btn is-danger press" :disabled="!selectedIds.length" @click="batchDelete">
487 <Icon name="trash" :size="14" />
488 <span>移除</span>
489 </button>
490 <button class="btn press" @click="toggleBatch">
491 <Icon name="close" :size="14" />
492 <span>退出</span>
493 </button>
494 </div>
495 </div>
496 </Transition>
497
498 <!-- ============ 命名弹窗 ============ -->
499 <UiDialog
500 v-model:visible="nameDialog.visible"
501 :title="nameDialogTitle"
502 subtitle="歌单名最长 30 个字"
503 :width="430"
504 >
505 <input
506 v-model="nameDialog.value"
507 class="ipt"
508 type="text"
509 maxlength="30"
510 placeholder="请输入歌单名称"
511 @keydown.enter="submitName"
512 />
513 <p v-if="nameDialog.error" class="err">{{ nameDialog.error }}</p>
514 <template #footer>
515 <button class="btn press" @click="nameDialog.visible = false">取消</button>
516 <button class="btn is-primary press" @click="submitName">
517 {{ nameDialog.mode === 'create' ? '创建' : '保存' }}
518 </button>
519 </template>
520 </UiDialog>
521
522 <!-- ============ 导入弹窗 ============ -->
523 <UiDialog
524 v-model:visible="importDialog.visible"
525 title="导入歌单"
526 subtitle="粘贴网易云 / QQ音乐 / 酷我 / 咪咕 / 酷狗的歌单分享链接"
527 :width="560"
528 :before-close="() => !importDialog.loading"
529 >
530 <div class="import-sources">
531 <span v-for="s in songListSources" :key="s.id" class="src-pill">{{ s.name }}</span>
532 </div>
533
534 <div class="import-input">
535 <textarea
536 v-model="importDialog.url"
537 class="ta"
538 rows="3"
539 spellcheck="false"
540 placeholder="https://music.163.com/#/playlist?id=xxx"
541 @input="importDialog.error = ''"
542 />
543 </div>
544
545 <p v-if="importDialog.error" class="err">{{ importDialog.error }}</p>
546
547 <div v-if="importDialog.result" class="preview a-scale-in">
548 <CoverImage :src="importPreviewCover" :size="66" radius="var(--r-md)" :icon-size="24" />
549 <div class="preview-info">
550 <span class="preview-name ellipsis">{{ importDialog.result.info.name || '未命名歌单' }}</span>
551 <span class="preview-meta">
552 {{ importDialog.result.info.author || importDialog.result.info.creator || '未知创建者' }}
553 · 共 {{ importDialog.result.total }} 首
554 </span>
555 <span class="preview-tag">已解析 {{ importDialog.result.list.length }} 首可导入</span>
556 </div>
557 </div>
558
559 <template #footer>
560 <button class="btn press" @click="importDialog.visible = false">取消</button>
561 <button v-if="!importDialog.result" class="btn is-primary press" :disabled="importDialog.loading" @click="parseImport">
562 <Icon v-if="importDialog.loading" name="refresh" :size="14" spin />
563 <span>{{ importDialog.loading ? '解析中…' : '解析链接' }}</span>
564 </button>
565 <button v-else class="btn is-primary press" @click="confirmImport">
566 <span>确认导入</span>
567 </button>
568 </template>
569 </UiDialog>
570
571 <!-- ============ 行菜单 ============ -->
572 <UiContextMenu
573 :visible="menu.visible"
574 :x="menu.x"
575 :y="menu.y"
576 :items="menuItems"
577 :width="216"
578 @select="onMenuSelect"
579 @close="closeMenu"
580 />
581 </div>
582</template>
583
584<style scoped>
585.view-playlist {
586 height: 100%;
587 min-height: 0;
588 display: flex;
589 flex-direction: column;
590 padding: 24px 32px 0;
591 position: relative;
592}
593
594/* ---------- 头部 ---------- */
595
596.head {
597 flex: none;
598 display: flex;
599 align-items: flex-end;
600 justify-content: space-between;
601 gap: 18px;
602 flex-wrap: wrap;
603}
604
605.head-left h1 {
606 font-size: 24px;
607 font-weight: 700;
608 letter-spacing: 0.5px;
609 color: var(--text-1);
610}
611
612.head-left p {
613 margin-top: 5px;
614 font-size: 12.5px;
615 color: var(--text-4);
616}
617
618.head-left b {
619 color: var(--theme-color-dark);
620 font-weight: 600;
621}
622
623.head-actions {
624 display: flex;
625 align-items: center;
626 gap: 8px;
627 flex-wrap: wrap;
628}
629
630.btn {
631 display: inline-flex;
632 align-items: center;
633 gap: 6px;
634 height: 33px;
635 padding: 0 13px;
636 border-radius: var(--r-md);
637 border: 1px solid var(--line-2);
638 background: var(--surface-1);
639 color: var(--text-2);
640 font-size: 12.5px;
641 white-space: nowrap;
642 transition: border-color var(--t-fast) var(--ease-out), color var(--t-fast) var(--ease-out),
643 background-color var(--t-fast) var(--ease-out), transform var(--t-fast) var(--ease-out),
644 box-shadow var(--t-fast) var(--ease-out);
645}
646
647.btn:hover:not(:disabled) {
648 border-color: var(--theme-color);
649 color: var(--theme-color-dark);
650 background: var(--theme-color-soft);
651}
652
653.btn:disabled {
654 opacity: 0.45;
655 cursor: not-allowed;
656}
657
658.btn.is-primary {
659 color: #fff;
660 border-color: transparent;
661 background: linear-gradient(135deg, var(--theme-color), var(--theme-color-dark));
662 box-shadow: 0 4px 14px rgba(var(--theme-color-rgb), 0.32);
663}
664
665.btn.is-primary:hover:not(:disabled) {
666 filter: brightness(1.06);
667 color: #fff;
668}
669
670.btn.is-danger {
671 color: var(--color-danger);
672 border-color: rgba(244, 63, 94, 0.32);
673 background: rgba(244, 63, 94, 0.07);
674}
675
676.btn.is-danger:hover:not(:disabled) {
677 background: rgba(244, 63, 94, 0.14);
678 border-color: var(--color-danger);
679 color: var(--color-danger);
680}
681
682.btn.is-on {
683 color: var(--theme-color-dark);
684 border-color: var(--theme-color);
685 background: var(--theme-color-soft);
686}
687
688/* ---------- Tabs ---------- */
689
690.tabs {
691 flex: none;
692 display: flex;
693 align-items: center;
694 gap: 7px;
695 margin-top: 18px;
696 padding-bottom: 12px;
697 border-bottom: 1px solid var(--line-1);
698 overflow-x: auto;
699 overflow-y: hidden;
700}
701
702.tabs::-webkit-scrollbar {
703 height: 4px;
704}
705
706.tab {
707 display: inline-flex;
708 align-items: center;
709 gap: 6px;
710 height: 32px;
711 padding: 0 12px;
712 border-radius: var(--r-full);
713 background: var(--surface-3);
714 color: var(--text-3);
715 font-size: 12.5px;
716 white-space: nowrap;
717 flex: none;
718 transition: background-color var(--t-base) var(--ease-out), color var(--t-base) var(--ease-out),
719 box-shadow var(--t-base) var(--ease-out), transform var(--t-fast) var(--ease-out);
720}
721
722.tab:hover {
723 color: var(--text-1);
724}
725
726.tab:active {
727 transform: scale(0.97);
728}
729
730.tab.is-active {
731 color: #fff;
732 background: linear-gradient(135deg, var(--theme-color), var(--theme-color-dark));
733 box-shadow: 0 4px 12px rgba(var(--theme-color-rgb), 0.3);
734}
735
736.tab-count {
737 font-style: normal;
738 font-size: 10.5px;
739 padding: 0 5px;
740 height: 15px;
741 line-height: 15px;
742 border-radius: var(--r-full);
743 background: rgba(255, 255, 255, 0.32);
744 color: inherit;
745}
746
747.tab:not(.is-active) .tab-count {
748 background: var(--surface-1);
749 color: var(--text-4);
750}
751
752.tab-extra {
753 display: inline-flex;
754 gap: 4px;
755 margin-left: 4px;
756 padding-left: 10px;
757 border-left: 1px solid var(--line-2);
758 flex: none;
759}
760
761.tab-act {
762 display: grid;
763 place-items: center;
764 width: 28px;
765 height: 28px;
766 border-radius: var(--r-sm);
767 color: var(--text-4);
768 transition: background-color var(--t-fast) var(--ease-out), color var(--t-fast) var(--ease-out);
769}
770
771.tab-act:hover {
772 background: var(--surface-3);
773 color: var(--theme-color);
774}
775
776.tab-act.is-danger:hover {
777 background: rgba(244, 63, 94, 0.1);
778 color: var(--color-danger);
779}
780
781/* ---------- 工具条 ---------- */
782
783.toolbar {
784 flex: none;
785 display: flex;
786 align-items: center;
787 justify-content: space-between;
788 gap: 12px;
789 padding: 12px 2px 8px;
790}
791
792.tb-left {
793 display: flex;
794 align-items: baseline;
795 gap: 9px;
796 min-width: 0;
797}
798
799.tb-name {
800 font-size: 14.5px;
801 font-weight: 600;
802 color: var(--text-1);
803}
804
805.tb-meta {
806 font-size: 11.5px;
807 color: var(--text-4);
808}
809
810.tb-right {
811 display: flex;
812 align-items: center;
813 gap: 6px;
814}
815
816.tb-btn {
817 display: inline-flex;
818 align-items: center;
819 gap: 5px;
820 height: 28px;
821 padding: 0 10px;
822 border-radius: var(--r-sm);
823 color: var(--text-3);
824 font-size: 12px;
825 transition: background-color var(--t-fast) var(--ease-out), color var(--t-fast) var(--ease-out);
826}
827
828.tb-btn:hover:not(:disabled) {
829 background: var(--surface-3);
830 color: var(--text-1);
831}
832
833.tb-btn:disabled {
834 opacity: 0.42;
835 cursor: not-allowed;
836}
837
838/* ---------- 列表 ---------- */
839
840.list-area {
841 flex: 1;
842 min-height: 0;
843 padding-bottom: 16px;
844}
845
846/* ---------- 批量条 ---------- */
847
848.batch-bar {
849 position: absolute;
850 left: 32px;
851 right: 32px;
852 bottom: 16px;
853 z-index: 25;
854 display: flex;
855 align-items: center;
856 justify-content: space-between;
857 gap: 16px;
858 padding: 11px 16px;
859 border-radius: var(--r-lg);
860 background: var(--surface-glass-strong);
861 backdrop-filter: var(--blur-md);
862 -webkit-backdrop-filter: var(--blur-md);
863 border: 1px solid var(--line-1);
864 box-shadow: var(--sh-lg);
865}
866
867.batch-text {
868 font-size: 13px;
869 color: var(--text-2);
870}
871
872.batch-text b {
873 color: var(--theme-color-dark);
874 font-size: 15px;
875 font-weight: 700;
876 padding: 0 2px;
877}
878
879.batch-actions {
880 display: flex;
881 align-items: center;
882 gap: 8px;
883}
884
885.batch-enter-active,
886.batch-leave-active {
887 transition: opacity var(--t-base) var(--ease-out), transform var(--t-base) var(--ease-spring);
888}
889
890.batch-enter-from,
891.batch-leave-to {
892 opacity: 0;
893 transform: translateY(18px) scale(0.98);
894}
895
896/* ---------- 表单 ---------- */
897
898.ipt,
899.ta {
900 width: 100%;
901 padding: 10px 13px;
902 border-radius: var(--r-md);
903 border: 1.5px solid var(--line-2);
904 background: var(--surface-2);
905 color: var(--text-1);
906 font-size: 13.5px;
907 outline: none;
908 resize: vertical;
909 transition: border-color var(--t-base) var(--ease-out), box-shadow var(--t-base) var(--ease-out);
910}
911
912.ipt:focus,
913.ta:focus {
914 border-color: var(--theme-color);
915 box-shadow: 0 0 0 4px rgba(var(--theme-color-rgb), 0.12);
916 background: var(--surface-1);
917}
918
919.ta {
920 font-family: var(--font-mono);
921 font-size: 12.5px;
922 line-height: 1.7;
923}
924
925.err {
926 margin-top: 9px;
927 font-size: 12px;
928 color: var(--color-danger);
929}
930
931.import-sources {
932 display: flex;
933 flex-wrap: wrap;
934 gap: 6px;
935 margin-bottom: 10px;
936}
937
938.src-pill {
939 padding: 2px 9px;
940 border-radius: var(--r-full);
941 background: var(--surface-3);
942 color: var(--text-4);
943 font-size: 11px;
944}
945
946.import-input {
947 margin-bottom: 4px;
948}
949
950.preview {
951 display: flex;
952 align-items: center;
953 gap: 13px;
954 margin-top: 14px;
955 padding: 12px;
956 border-radius: var(--r-lg);
957 background: var(--theme-color-soft);
958 border: 1px solid rgba(var(--theme-color-rgb), 0.22);
959}
960
961.preview-info {
962 display: flex;
963 flex-direction: column;
964 gap: 3px;
965 min-width: 0;
966}
967
968.preview-name {
969 font-size: 14px;
970 font-weight: 600;
971 color: var(--text-1);
972}
973
974.preview-meta {
975 font-size: 12px;
976 color: var(--text-3);
977}
978
979.preview-tag {
980 align-self: flex-start;
981 margin-top: 2px;
982 font-size: 10.5px;
983 padding: 1px 7px;
984 border-radius: var(--r-xs);
985 background: var(--surface-1);
986 color: var(--theme-color-dark);
987}
988
989@media (max-width: 1120px) {
990 .head-actions .btn span {
991 display: none;
992 }
993
994 .head-actions .btn {
995 padding: 0 11px;
996 }
997}
998</style>