仰望星辰工作室

starmusic-pc

starmusic-pc/ frontend/src/views/SearchView.vue 22.7 KB · 950 行 原始文件
1<script setup>
2/**
3 * SearchView — 搜索页
4 *
5 * 与移动端一致的能力:多平台切换(含综合搜索)、输入联想、搜索历史、热搜榜、分页加载。
6 * 桌面端布局差异:搜索框居中放大;未搜索时「历史 + 热搜」双栏并排;
7 * 平台切换改为分段控件(带滑动指示条),更符合鼠标操作习惯。
8 */
9import { ref, computed, onMounted, onBeforeUnmount, nextTick } from 'vue'
10import Icon from '../components/Icon.vue'
11import MusicList from '../components/MusicList.vue'
12import {
13 search as searchMusic,
14 getSearchSources,
15 getHotSearch,
16 getMusicTip,
17 getSearchHistory,
18 addHistoryWord,
19 removeHistoryWord,
20 clearHistoryList,
21} from '../common/search.js'
22import { normalizeMusicInfo, addListMusics, LIST_IDS } from '../common/playlist.js'
23import { playerState, playerActions } from '../composables/usePlayer.js'
24import { toast, confirm, toastErr, errText } from '../composables/useFeedback.js'
25import event from '../common/event.js'
26
27const sources = ref(getSearchSources())
28const source = ref(sources.value[0] ? sources.value[0].id : 'kw')
29
30const keyword = ref('')
31const searched = ref(false)
32const loading = ref(false)
33const resultList = ref([])
34const page = ref(1)
35const allPage = ref(1)
36const total = ref(0)
37const costMs = ref(0)
38
39const historyList = ref(getSearchHistory())
40const hotList = ref([])
41const hotLoading = ref(false)
42
43const inputRef = ref(null)
44
45// ============ 输入联想 ============
46
47const tips = ref([])
48const tipVisible = ref(false)
49const tipLoading = ref(false)
50let tipTimer = null
51let tipSeq = 0
52
53const requestTips = () => {
54 const text = keyword.value.trim()
55 if (!text) {
56 tips.value = []
57 tipVisible.value = false
58 return
59 }
60 if (tipTimer) clearTimeout(tipTimer)
61 tipTimer = setTimeout(async () => {
62 const seq = ++tipSeq
63 tipLoading.value = true
64 tipVisible.value = true
65 try {
66 const list = await getMusicTip('kw', text)
67 if (seq !== tipSeq) return
68 tips.value = list.slice(0, 8)
69 } catch (e) {
70 if (seq === tipSeq) tips.value = []
71 } finally {
72 if (seq === tipSeq) tipLoading.value = false
73 }
74 }, 200)
75}
76
77const onInput = () => {
78 searchFailed.value = false
79 requestTips()
80}
81
82const onFocus = () => {
83 if (keyword.value.trim()) {
84 tipVisible.value = true
85 if (!tips.value.length) requestTips()
86 }
87}
88
89let blurTimer = null
90const onBlur = () => {
91 if (blurTimer) clearTimeout(blurTimer)
92 blurTimer = setTimeout(() => {
93 tipVisible.value = false
94 }, 220)
95}
96
97const pickTip = (word) => {
98 keyword.value = word
99 tipVisible.value = false
100 handleSearch(word)
101}
102
103// ============ 搜索 ============
104
105const searchFailed = ref('')
106
107const handleSearch = async (word, resetPage = true) => {
108 const text = String(word != null ? word : keyword.value).trim()
109 if (!text) {
110 toast('请输入搜索关键词', 'none', 1600)
111 return
112 }
113 keyword.value = text
114 tipVisible.value = false
115 if (resetPage) {
116 page.value = 1
117 resultList.value = []
118 }
119 searched.value = true
120 loading.value = true
121 searchFailed.value = ''
122 const startedAt = Date.now()
123
124 try {
125 const res = await searchMusic(text, page.value, source.value)
126 const list = (res.list || []).map((item) => normalizeMusicInfo(item))
127 resultList.value = page.value === 1 ? list : [...resultList.value, ...list]
128 allPage.value = res.allPage || 1
129 total.value = res.total || resultList.value.length
130 costMs.value = Date.now() - startedAt
131 if (page.value === 1) {
132 historyList.value = addHistoryWord(text)
133 }
134 } catch (err) {
135 searchFailed.value = errText(err, '搜索失败')
136 if (page.value === 1) resultList.value = []
137 toastErr(searchFailed.value)
138 } finally {
139 loading.value = false
140 }
141}
142
143const hasMore = computed(() => page.value < allPage.value)
144
145const handleLoadMore = () => {
146 if (loading.value || !hasMore.value) return
147 page.value += 1
148 handleSearch(keyword.value, false)
149}
150
151const handleSourceChange = (id) => {
152 if (source.value === id) return
153 source.value = id
154 if (searched.value && keyword.value) handleSearch(keyword.value, true)
155 else loadHot()
156}
157
158const handleMusicClick = (item, index) => {
159 playFrom(index)
160}
161
162const handleMusicPlay = (item, index) => {
163 playFrom(index)
164}
165
166const playFrom = (index) => {
167 const list = [...resultList.value]
168 const info = list[index]
169 if (!info) return
170 // 与移动端一致:点歌即记入试听列表(去重置顶),并以当前搜索结果作为播放队列
171 addListMusics(LIST_IDS.DEFAULT, [info], 'top')
172 playerActions.playListByList(list, index)
173}
174
175const handleMore = (item) => {
176 const index = resultList.value.findIndex((m) => m.id === item.id)
177 playFrom(index < 0 ? 0 : index)
178}
179
180// ============ 热搜 ============
181
182const loadHot = async () => {
183 if (source.value === 'all') {
184 hotList.value = []
185 return
186 }
187 hotLoading.value = true
188 try {
189 const res = await getHotSearch(source.value)
190 hotList.value = res.list || []
191 } catch (e) {
192 hotList.value = []
193 } finally {
194 hotLoading.value = false
195 }
196}
197
198// ============ 历史 ============
199
200const onRemoveWord = (word) => {
201 historyList.value = removeHistoryWord(word)
202 toast('已删除', 'none', 1200)
203}
204
205const onClearHistory = async () => {
206 const ok = await confirm({ title: '清空搜索历史', content: '确定清空所有搜索历史?', confirmText: '清空', danger: true })
207 if (!ok) return
208 historyList.value = clearHistoryList()
209 toast('已清空搜索历史', 'none', 1600)
210}
211
212const resetSearch = () => {
213 searched.value = false
214 keyword.value = ''
215 resultList.value = []
216 page.value = 1
217 searchFailed.value = ''
218 nextTick(() => inputRef.value && inputRef.value.focus())
219}
220
221// ============ 生命周期 ============
222
223let offMusic = null
224onMounted(() => {
225 loadHot()
226 offMusic = event.on('playerMusicInfoChanged', () => {
227 /* currentMusicId 由 playerState 直接计算,无需额外状态 */
228 })
229 nextTick(() => inputRef.value && inputRef.value.focus())
230})
231
232onBeforeUnmount(() => {
233 if (offMusic) offMusic()
234 if (tipTimer) clearTimeout(tipTimer)
235 if (blurTimer) clearTimeout(blurTimer)
236})
237
238const sourceName = computed(() => {
239 const s = sources.value.find((x) => x.id === source.value)
240 return s ? s.name : '综合搜索'
241})
242</script>
243
244<template>
245 <div class="view-search view-scroll scroll-y">
246 <div class="inner">
247 <!-- ============ 头部 ============ -->
248 <header class="head a-fade-up">
249 <div class="head-title">
250 <h1>搜索</h1>
251 <p v-if="searched && !searchFailed">
252 在「{{ sourceName }}」中找到 {{ total }} 首相关歌曲
253 <span v-if="costMs" class="cost">· {{ costMs }}ms</span>
254 </p>
255 <p v-else>支持酷我 / 酷狗 / QQ / 网易 / 咪咕,可一键综合搜索全平台</p>
256 </div>
257 <button v-if="searched" class="ghost-btn press" @click="resetSearch">
258 <Icon name="refresh" :size="14" />
259 <span>重新搜索</span>
260 </button>
261 </header>
262
263 <!-- ============ 搜索框 + 平台切换 ============ -->
264 <div class="search-area a-fade-up" style="--delay: 60ms">
265 <div class="search-box" :class="{ 'is-focus': tipVisible }">
266 <span class="s-icon"><Icon name="search" :size="18" /></span>
267 <input
268 ref="inputRef"
269 v-model="keyword"
270 class="s-input"
271 type="text"
272 placeholder="搜索歌曲、歌手、专辑…"
273 spellcheck="false"
274 @input="onInput"
275 @focus="onFocus"
276 @blur="onBlur"
277 @keydown.enter="handleSearch()"
278 @keydown.esc="tipVisible = false"
279 />
280 <button v-if="keyword" class="s-clear" title="清空" @click="keyword = ''; onInput()">
281 <Icon name="close" :size="15" />
282 </button>
283 <button class="s-btn press" @click="handleSearch()">
284 <Icon name="search" :size="15" width="2.2" />
285 <span>搜索</span>
286 </button>
287
288 <!-- 联想下拉 -->
289 <Transition name="drop">
290 <div v-if="tipVisible && (tips.length || tipLoading)" class="tip-layer">
291 <div v-if="tipLoading" class="tip-loading">
292 <Icon name="refresh" :size="13" spin />
293 <span>搜索建议加载中…</span>
294 </div>
295 <button v-for="(t, i) in tips" :key="t" class="tip-item" @mousedown.prevent="pickTip(t)">
296 <Icon name="search" :size="13" />
297 <span class="ellipsis">{{ t }}</span>
298 <em v-if="i === 0" class="tip-best">最佳匹配</em>
299 </button>
300 </div>
301 </Transition>
302 </div>
303
304 <!-- 平台分段控件 -->
305 <div class="segmented">
306 <button
307 v-for="s in sources"
308 :key="s.id"
309 class="seg-item"
310 :class="{ 'is-active': source === s.id }"
311 @click="handleSourceChange(s.id)"
312 >
313 {{ s.name }}
314 </button>
315 <span
316 class="seg-indicator"
317 :style="{ transform: `translateX(${sources.findIndex((s) => s.id === source) * 100}%)` }"
318 />
319 </div>
320 </div>
321
322 <!-- ============ 结果 ============ -->
323 <Transition name="fade" mode="out-in">
324 <div v-if="searched" key="result" class="result">
325 <div v-if="searchFailed" class="failed">
326 <span class="failed-icon"><Icon name="close" :size="26" :width="2" /></span>
327 <p class="failed-text">{{ searchFailed }}</p>
328 <button class="ghost-btn press" @click="handleSearch(keyword, true)">
329 <Icon name="refresh" :size="14" />
330 <span>重试</span>
331 </button>
332 </div>
333 <MusicList
334 v-else
335 :list="resultList"
336 :loading="loading"
337 :has-more="hasMore"
338 :show-quality="true"
339 :current-music-id="playerState.musicInfo ? playerState.musicInfo.id : ''"
340 :playing="playerState.isPlay"
341 empty-text="未找到相关歌曲,换个关键词或平台试试"
342 @item-click="handleMusicClick"
343 @item-play="handleMusicPlay"
344 @item-more="handleMore"
345 @load-more="handleLoadMore"
346 />
347 </div>
348
349 <!-- 未搜索:历史 + 热搜 -->
350 <div v-else key="idle" class="idle">
351 <section class="panel a-fade-up" style="--delay: 120ms">
352 <header class="panel-head">
353 <h2>
354 <Icon name="clock" :size="15" />
355 <span>搜索历史</span>
356 <em v-if="historyList.length" class="badge">{{ historyList.length }}</em>
357 </h2>
358 <button v-if="historyList.length" class="text-btn" @click="onClearHistory">清空</button>
359 </header>
360 <div v-if="historyList.length" class="chips">
361 <span v-for="(w, i) in historyList" :key="w" class="chip-wrap" :style="{ '--delay': `${i * 18}ms` }">
362 <button class="chip press-soft" @click="handleSearch(w)">
363 <Icon name="search" :size="13" />
364 <span>{{ w }}</span>
365 </button>
366 <button class="chip-del" title="删除" @click="onRemoveWord(w)">
367 <Icon name="close" :size="11" :width="2.4" />
368 </button>
369 </span>
370 </div>
371 <p v-else class="panel-empty">暂无搜索历史,搜过的歌会出现在这里</p>
372 </section>
373
374 <section class="panel a-fade-up" style="--delay: 200ms">
375 <header class="panel-head">
376 <h2>
377 <Icon name="trend" :size="15" />
378 <span>热搜榜</span>
379 <em class="badge soft">{{ sourceName }}</em>
380 </h2>
381 <button class="text-btn" :disabled="hotLoading" @click="loadHot">
382 <Icon name="refresh" :size="13" :spin="hotLoading" />
383 </button>
384 </header>
385 <ol v-if="hotList.length" class="hot">
386 <li v-for="(word, i) in hotList.slice(0, 14)" :key="word" class="hot-item" @click="handleSearch(word)">
387 <span class="hot-rank" :class="{ top3: i < 3 }">{{ i + 1 }}</span>
388 <span class="hot-word ellipsis">{{ word }}</span>
389 <Icon class="hot-arrow" name="chevron-right" :size="14" />
390 </li>
391 </ol>
392 <p v-else class="panel-empty">
393 {{ hotLoading ? '热搜加载中…' : source === 'all' ? '综合搜索模式下不提供热搜' : '暂无热搜数据' }}
394 </p>
395 </section>
396
397 <section class="welcome a-fade-up" style="--delay: 280ms">
398 <span class="welcome-icon"><Icon name="music" :size="26" /></span>
399 <p class="welcome-title">搜索你想听的音乐</p>
400 <p class="welcome-sub">播放地址来自你导入的自定义源,未导入时仅能浏览搜索结果</p>
401 </section>
402 </div>
403 </Transition>
404 </div>
405 </div>
406</template>
407
408<style scoped>
409.view-search {
410 height: 100%;
411 min-height: 0;
412 padding: 26px 32px 18px;
413}
414
415.inner {
416 max-width: 1180px;
417 margin: 0 auto;
418 display: flex;
419 flex-direction: column;
420 gap: 18px;
421 min-height: 100%;
422}
423
424/* ---------- 头部 ---------- */
425
426.head {
427 display: flex;
428 align-items: flex-end;
429 justify-content: space-between;
430 gap: 16px;
431}
432
433.head-title h1 {
434 font-size: 24px;
435 font-weight: 700;
436 letter-spacing: 0.5px;
437 color: var(--text-1);
438}
439
440.head-title p {
441 margin-top: 5px;
442 font-size: 12.5px;
443 color: var(--text-4);
444}
445
446.cost {
447 color: var(--text-5);
448}
449
450.ghost-btn {
451 display: inline-flex;
452 align-items: center;
453 gap: 6px;
454 height: 32px;
455 padding: 0 14px;
456 border-radius: var(--r-full);
457 border: 1px solid var(--line-2);
458 background: var(--surface-2);
459 color: var(--text-2);
460 font-size: 12.5px;
461 transition: border-color var(--t-fast) var(--ease-out), color var(--t-fast) var(--ease-out),
462 background-color var(--t-fast) var(--ease-out);
463}
464
465.ghost-btn:hover {
466 border-color: var(--theme-color);
467 color: var(--theme-color-dark);
468 background: var(--theme-color-soft);
469}
470
471/* ---------- 搜索框 ---------- */
472
473.search-area {
474 display: flex;
475 flex-direction: column;
476 gap: 12px;
477}
478
479.search-box {
480 position: relative;
481 display: flex;
482 align-items: center;
483 gap: 10px;
484 height: 50px;
485 padding: 0 8px 0 16px;
486 border-radius: var(--r-lg);
487 background: var(--surface-1);
488 border: 1.5px solid var(--line-1);
489 box-shadow: var(--sh-sm);
490 transition: border-color var(--t-base) var(--ease-out), box-shadow var(--t-base) var(--ease-out);
491}
492
493.search-box:hover {
494 border-color: var(--line-3);
495}
496
497.search-box.is-focus {
498 border-color: var(--theme-color);
499 box-shadow: 0 0 0 4px rgba(var(--theme-color-rgb), 0.13), var(--sh-md);
500}
501
502.s-icon {
503 display: grid;
504 place-items: center;
505 color: var(--text-4);
506 flex: none;
507}
508
509.s-input {
510 flex: 1;
511 min-width: 0;
512 height: 100%;
513 border: none;
514 background: transparent;
515 font-size: 14.5px;
516 color: var(--text-1);
517 outline: none;
518}
519
520.s-input::placeholder {
521 color: var(--text-5);
522}
523
524.s-clear {
525 display: grid;
526 place-items: center;
527 width: 26px;
528 height: 26px;
529 border-radius: var(--r-full);
530 color: var(--text-4);
531 flex: none;
532 transition: background-color var(--t-fast) var(--ease-out), color var(--t-fast) var(--ease-out);
533}
534
535.s-clear:hover {
536 background: var(--surface-3);
537 color: var(--text-2);
538}
539
540.s-btn {
541 display: inline-flex;
542 align-items: center;
543 gap: 6px;
544 height: 36px;
545 padding: 0 18px;
546 border-radius: var(--r-md);
547 color: #fff;
548 font-size: 13.5px;
549 font-weight: 500;
550 background: linear-gradient(135deg, var(--theme-color), var(--theme-color-dark));
551 box-shadow: 0 4px 14px rgba(var(--theme-color-rgb), 0.34);
552 flex: none;
553 transition: filter var(--t-fast) var(--ease-out), transform var(--t-fast) var(--ease-out);
554}
555
556.s-btn:hover {
557 filter: brightness(1.06);
558}
559
560.s-btn:active {
561 transform: scale(0.96);
562}
563
564/* 联想下拉 */
565.tip-layer {
566 position: absolute;
567 top: calc(100% + 8px);
568 left: 0;
569 right: 0;
570 padding: 6px;
571 border-radius: var(--r-lg);
572 /* 浮层用不透明底色:背景图模式下 --surface-1 半透明,列表会透上来 */
573 background: var(--surface-pop);
574 border: 1px solid var(--line-1);
575 box-shadow: var(--sh-lg);
576 z-index: var(--z-dropdown);
577 max-height: 320px;
578 overflow-y: auto;
579}
580
581.tip-loading {
582 display: flex;
583 align-items: center;
584 gap: 8px;
585 padding: 9px 12px;
586 font-size: 12.5px;
587 color: var(--text-4);
588}
589
590.tip-item {
591 display: flex;
592 align-items: center;
593 gap: 10px;
594 width: 100%;
595 height: 34px;
596 padding: 0 12px;
597 border-radius: var(--r-sm);
598 font-size: 13.5px;
599 color: var(--text-2);
600 transition: background-color var(--t-fast) var(--ease-out), color var(--t-fast) var(--ease-out);
601}
602
603.tip-item:hover {
604 background: var(--theme-color-soft);
605 color: var(--theme-color-dark);
606}
607
608.tip-item > .ellipsis {
609 flex: 1;
610 min-width: 0;
611 text-align: left;
612}
613
614.tip-best {
615 flex: none;
616 font-style: normal;
617 font-size: 10.5px;
618 padding: 1px 6px;
619 border-radius: var(--r-xs);
620 color: var(--theme-color-dark);
621 background: rgba(var(--theme-color-rgb), 0.14);
622}
623
624.drop-enter-active,
625.drop-leave-active {
626 transition: opacity var(--t-fast) var(--ease-out), transform var(--t-fast) var(--ease-spring);
627}
628
629.drop-enter-from,
630.drop-leave-to {
631 opacity: 0;
632 transform: translateY(-8px) scale(0.985);
633}
634
635/* ---------- 平台分段控件 ---------- */
636
637.segmented {
638 position: relative;
639 display: grid;
640 grid-auto-flow: column;
641 grid-auto-columns: 1fr;
642 gap: 0;
643 padding: 4px;
644 border-radius: var(--r-md);
645 background: var(--surface-3);
646 max-width: 660px;
647}
648
649.seg-item {
650 position: relative;
651 z-index: 2;
652 height: 32px;
653 border-radius: var(--r-sm);
654 font-size: 12.5px;
655 color: var(--text-3);
656 transition: color var(--t-base) var(--ease-out);
657}
658
659.seg-item:hover {
660 color: var(--text-1);
661}
662
663.seg-item.is-active {
664 color: var(--theme-color-dark);
665 font-weight: 600;
666}
667
668.seg-indicator {
669 position: absolute;
670 z-index: 1;
671 top: 4px;
672 left: 4px;
673 height: 32px;
674 border-radius: var(--r-sm);
675 background: var(--surface-1);
676 box-shadow: var(--sh-xs);
677 transition: transform var(--t-slow) var(--ease-spring);
678 width: calc((100% - 8px) / 6);
679}
680
681/* ---------- 结果 ---------- */
682
683.result {
684 flex: 1;
685 min-height: 0;
686}
687
688.failed {
689 display: flex;
690 flex-direction: column;
691 align-items: center;
692 gap: 14px;
693 padding: 70px 20px;
694}
695
696.failed-icon {
697 display: grid;
698 place-items: center;
699 width: 64px;
700 height: 64px;
701 border-radius: var(--r-full);
702 background: rgba(244, 63, 94, 0.1);
703 color: var(--color-danger);
704}
705
706.failed-text {
707 font-size: 13.5px;
708 color: var(--text-3);
709}
710
711/* ---------- 未搜索态 ---------- */
712
713.idle {
714 display: grid;
715 grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
716 gap: 16px;
717 align-content: start;
718}
719
720.panel {
721 padding: 16px 18px 18px;
722 border-radius: var(--r-xl);
723 background: var(--surface-1);
724 border: 1px solid var(--line-1);
725 box-shadow: var(--sh-sm);
726}
727
728.panel-head {
729 display: flex;
730 align-items: center;
731 justify-content: space-between;
732 gap: 10px;
733 margin-bottom: 13px;
734}
735
736.panel-head h2 {
737 display: flex;
738 align-items: center;
739 gap: 7px;
740 font-size: 14px;
741 font-weight: 600;
742 color: var(--text-1);
743}
744
745.badge {
746 font-style: normal;
747 font-size: 10.5px;
748 font-weight: 500;
749 padding: 0 6px;
750 height: 17px;
751 line-height: 17px;
752 border-radius: var(--r-full);
753 background: var(--theme-color-soft);
754 color: var(--theme-color-dark);
755}
756
757.badge.soft {
758 background: var(--surface-3);
759 color: var(--text-4);
760}
761
762.text-btn {
763 display: inline-flex;
764 align-items: center;
765 gap: 4px;
766 font-size: 12px;
767 color: var(--text-4);
768 transition: color var(--t-fast) var(--ease-out);
769}
770
771.text-btn:hover:not(:disabled) {
772 color: var(--theme-color);
773}
774
775.text-btn:disabled {
776 opacity: 0.5;
777 cursor: wait;
778}
779
780.chips {
781 display: flex;
782 flex-wrap: wrap;
783 gap: 8px;
784}
785
786.chip-wrap {
787 display: inline-flex;
788 align-items: center;
789 animation: kScaleIn var(--t-base) var(--ease-out) both;
790 animation-delay: var(--delay, 0ms);
791}
792
793.chip {
794 display: inline-flex;
795 align-items: center;
796 gap: 6px;
797 height: 30px;
798 padding: 0 8px 0 11px;
799 border-radius: var(--r-full) 0 0 var(--r-full);
800 background: var(--surface-3);
801 color: var(--text-2);
802 font-size: 12.5px;
803 max-width: 220px;
804 transition: background-color var(--t-fast) var(--ease-out), color var(--t-fast) var(--ease-out);
805}
806
807.chip span {
808 overflow: hidden;
809 white-space: nowrap;
810 text-overflow: ellipsis;
811}
812
813.chip:hover {
814 background: var(--theme-color-soft);
815 color: var(--theme-color-dark);
816}
817
818.chip-del {
819 display: grid;
820 place-items: center;
821 width: 22px;
822 height: 30px;
823 border-radius: 0 var(--r-full) var(--r-full) 0;
824 background: var(--surface-3);
825 color: var(--text-5);
826 transition: background-color var(--t-fast) var(--ease-out), color var(--t-fast) var(--ease-out);
827}
828
829.chip-del:hover {
830 background: rgba(244, 63, 94, 0.12);
831 color: var(--color-danger);
832}
833
834.hot {
835 display: flex;
836 flex-direction: column;
837 gap: 1px;
838}
839
840.hot-item {
841 display: flex;
842 align-items: center;
843 gap: 12px;
844 height: 33px;
845 padding: 0 8px;
846 border-radius: var(--r-sm);
847 cursor: pointer;
848 transition: background-color var(--t-fast) var(--ease-out);
849}
850
851.hot-item:hover {
852 background: var(--surface-3);
853}
854
855.hot-rank {
856 flex: none;
857 width: 20px;
858 text-align: center;
859 font-size: 12.5px;
860 font-weight: 600;
861 font-variant-numeric: tabular-nums;
862 color: var(--text-4);
863}
864
865.hot-rank.top3 {
866 color: var(--theme-color);
867}
868
869.hot-word {
870 flex: 1;
871 min-width: 0;
872 font-size: 13px;
873 color: var(--text-2);
874}
875
876.hot-arrow {
877 flex: none;
878 color: var(--text-5);
879 opacity: 0;
880 transform: translateX(-4px);
881 transition: opacity var(--t-fast) var(--ease-out), transform var(--t-fast) var(--ease-out);
882}
883
884.hot-item:hover .hot-arrow {
885 opacity: 1;
886 transform: translateX(0);
887}
888
889.panel-empty {
890 padding: 16px 4px;
891 font-size: 12.5px;
892 color: var(--text-5);
893}
894
895.welcome {
896 grid-column: 1 / -1;
897 display: flex;
898 flex-direction: column;
899 align-items: center;
900 gap: 8px;
901 padding: 26px;
902 border-radius: var(--r-xl);
903 background: linear-gradient(135deg, var(--theme-color-soft), var(--surface-1) 70%);
904 border: 1px dashed rgba(var(--theme-color-rgb), 0.32);
905}
906
907.welcome-icon {
908 display: grid;
909 place-items: center;
910 width: 52px;
911 height: 52px;
912 border-radius: var(--r-full);
913 color: var(--theme-color);
914 background: var(--surface-1);
915 box-shadow: var(--sh-sm);
916}
917
918.welcome-title {
919 font-size: 14.5px;
920 font-weight: 600;
921 color: var(--text-1);
922}
923
924.welcome-sub {
925 font-size: 12px;
926 color: var(--text-4);
927 text-align: center;
928}
929
930.fade-enter-active,
931.fade-leave-active {
932 transition: opacity var(--t-base) var(--ease-out), transform var(--t-base) var(--ease-out);
933}
934
935.fade-enter-from {
936 opacity: 0;
937 transform: translateY(8px);
938}
939
940.fade-leave-to {
941 opacity: 0;
942}
943
944/* 窄窗口:单栏 */
945@media (max-width: 1020px) {
946 .idle {
947 grid-template-columns: minmax(0, 1fr);
948 }
949}
950</style>