Compare commits

..
11 Commits
Author SHA1 Message Date
TIANYAO ZHANG 8861f39ecc Merge pull request #62 from NoSetViolin/main
fix(qq): switch search to c.y.qq.com client_search_cp with u.y.qq.com fallback
2026-05-14 22:44:41 +08:00
NoSetViolinandClaude Opus 4.7 6d5755ced7 feat(search): source filter bar, album search, and UX polish
- Add platform source filter bar (网易云/QQ/B站) above category tabs with
  localStorage persistence to remember user preference
- Remove "全部" option, single-source view only
- Increase album/playlist card column-gap to 28px for better spacing
- Sync search query to URL via router.replace so back-navigation from
  album/playlist detail pages restores search results
- Fix !album command: add name-based album search (matching !playlist
  behavior) so "!album APT." searches by name instead of treating it as ID

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 18:56:14 +08:00
NoSetViolinandClaude Opus 4.7 997bb17ceb feat(search): add tabbed category navigation + align NetEase result counts
- Replace single-page layout with pill-slider tabs (单曲/专辑/歌单) under
  the search box, showing only one category at a time with result counts
- NetEase album/playlist search limit raised from 5 to 10 to match QQ

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 16:56:18 +08:00
NoSetViolinandClaude Opus 4.7 ea6501ea81 feat(web): add platform badges to album and playlist cards in search
Album and playlist results now show source tags (网易云, QQ, B站, YouTube)
matching the existing SongCard platform badge style.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 16:34:26 +08:00
NoSetViolinandClaude Opus 4.7 1d2da33d3c fix(qq): apply musicu.fcg upstream fixes + add playlist search
Per https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/61:
- Remove searchid param (its presence now causes empty results)
- Enforce num_per_page >= 10 (lower values return empty)
- Fix search_type: 2 for albums, 3 for playlists (8 was "user")

Now uses the fixed musicu.fcg as primary (supports songs + albums +
playlists), with client_search_cp as fallback.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 16:28:12 +08:00
阿梓喵_あずにゃんandClaude Opus 4.7 0e68e287b7 fix(qq): switch search to c.y.qq.com client_search_cp with u.y.qq.com fallback
u.y.qq.com/cgi-bin/musicu.fcg started returning is_filter=-9 (empty results)
for unauthenticated requests. Primary search now uses the classic
c.y.qq.com/soso/fcgi-bin/client_search_cp endpoint, with the old musicu.fcg
path kept as a fallback in case the primary endpoint changes format or goes
down.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 15:55:21 +08:00
TIANYAO ZHANG ecdb2d253e Merge pull request #60 from XuVIIJay/feat/forward-stack
随机播放模式双向可回溯
2026-05-12 22:23:35 +08:00
XuVIIJay 8860347bfe Update README.md 2026-05-12 00:04:20 +08:00
XuVIIJay beb99f1d8e Update README.md 2026-05-11 23:49:34 +08:00
XuVIIJay 63f1ced2bc feat(queue): add forwardStack for reversible prev/next navigation
In Random/RandomLoop modes, prev now records the current position
to a forwardStack, and next pops from it first. This ensures prev→next
navigation is fully reversible for any number of steps (up to 50).

Also rebuild playedIndices in prev() so songs reached via backward
navigation become available for random selection again.
2026-05-11 23:45:04 +08:00
saopig1andClaude Opus 4.7 fa6013d22e docs(commands): surface existing !remove <position> in help and README
Closes #59. The command was already implemented but not advertised in
!help output or the README command table, so users assumed it was missing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 20:56:49 +08:00
7 changed files with 423 additions and 80 deletions

No files matched your search

-2
View File
@@ -19,8 +19,6 @@
"Read(//tmp/**)"
],
"deny": [
"Bash(git push * main)",
"Bash(git push * master)",
"Bash(git push --force *)",
"Bash(rm -rf /)"
]
+1
View File
@@ -253,6 +253,7 @@ sudo systemctl start tsmusicbot
| `!stop` | 停止播放并清空队列 |
| `!vol <0-100>` | 设置音量 |
| `!queue` | 查看播放队列 |
| `!remove <位置>` | 从队列中删除指定位置的歌曲(位置从 1 开始,见 `!queue`) |
| `!mode <seq\|loop\|random\|rloop>` | 切换播放模式 |
| `!playlist <歌单名或ID>` | 加载歌单(支持名称模糊搜索和 ID) |
| `!playlist -q <歌单名>` | 从 QQ 音乐搜索并加载歌单 |
+47 -24
View File
@@ -22,6 +22,7 @@ export class PlayQueue {
private mode: PlayMode = PlayMode.Sequential;
private playedIndices = new Set<number>();
private history: number[] = [];
private forwardStack: number[] = [];
private static readonly HISTORY_LIMIT = 50;
private pushHistory(idx: number): void {
@@ -100,12 +101,14 @@ export class PlayQueue {
this.currentIndex = -1;
this.playedIndices.clear();
this.history = [];
this.forwardStack = [];
}
play(): QueuedSong | null {
if (this.songs.length === 0) return null;
this.playedIndices.clear();
this.history = [];
this.forwardStack = [];
this.currentIndex = 0;
this.playedIndices.add(0);
return this.songs[0];
@@ -118,6 +121,7 @@ export class PlayQueue {
// shuffle from this point. History tracking is independent and
// unaffected by this clear.
this.playedIndices.clear();
this.forwardStack = [];
this.currentIndex = index;
this.playedIndices.add(index);
return this.songs[index];
@@ -139,32 +143,45 @@ export class PlayQueue {
this.currentIndex = (this.currentIndex + 1) % this.songs.length;
return this.songs[this.currentIndex];
}
case PlayMode.Random: {
const unplayed: number[] = [];
for (let i = 0; i < this.songs.length; i++) {
if (!this.playedIndices.has(i)) unplayed.push(i);
}
if (unplayed.length === 0) return null;
const nextIndex =
unplayed[Math.floor(Math.random() * unplayed.length)];
this.pushHistory(this.currentIndex);
this.currentIndex = nextIndex;
this.playedIndices.add(nextIndex);
return this.songs[nextIndex];
}
case PlayMode.Random:
case PlayMode.RandomLoop: {
if (this.songs.length === 1) {
// 优先回到前进栈记录的位置(prev 退回的歌)
if (this.forwardStack.length > 0) {
const target = this.forwardStack.pop()!;
if (target !== this.currentIndex) {
this.pushHistory(this.currentIndex);
this.currentIndex = target;
this.playedIndices.add(target);
return this.songs[target];
}
}
// 前进栈为空,走纯随机逻辑
if (this.mode === PlayMode.Random) {
const unplayed: number[] = [];
for (let i = 0; i < this.songs.length; i++) {
if (!this.playedIndices.has(i)) unplayed.push(i);
}
if (unplayed.length === 0) return null;
const nextIndex =
unplayed[Math.floor(Math.random() * unplayed.length)];
this.pushHistory(this.currentIndex);
this.currentIndex = 0;
return this.songs[0];
this.currentIndex = nextIndex;
this.playedIndices.add(nextIndex);
return this.songs[nextIndex];
} else {
if (this.songs.length === 1) {
this.pushHistory(this.currentIndex);
this.currentIndex = 0;
return this.songs[0];
}
let idx: number;
do {
idx = Math.floor(Math.random() * this.songs.length);
} while (idx === this.currentIndex);
this.pushHistory(this.currentIndex);
this.currentIndex = idx;
return this.songs[idx];
}
let idx: number;
do {
idx = Math.floor(Math.random() * this.songs.length);
} while (idx === this.currentIndex);
this.pushHistory(this.currentIndex);
this.currentIndex = idx;
return this.songs[idx];
}
}
}
@@ -172,13 +189,18 @@ export class PlayQueue {
prev(): QueuedSong | null {
if (this.songs.length === 0) return null;
// 记录当前位置到前进栈,供 next 优先返回
if (this.currentIndex >= 0 && this.forwardStack.length < PlayQueue.HISTORY_LIMIT) {
this.forwardStack.push(this.currentIndex);
}
// Preferred: pop from the back-stack so prev means "the song I
// actually played before this one," not "the previous array slot."
while (this.history.length > 0) {
const idx = this.history.pop()!;
if (idx >= 0 && idx < this.songs.length) {
this.currentIndex = idx;
this.playedIndices.add(idx);
this.playedIndices = new Set([...this.history, this.currentIndex]);
return this.songs[idx];
}
// Stale entry (song removed) — keep popping.
@@ -227,6 +249,7 @@ export class PlayQueue {
this.mode = mode;
this.playedIndices.clear();
this.history = [];
this.forwardStack = [];
if (this.currentIndex >= 0) {
this.playedIndices.add(this.currentIndex);
}
+21 -2
View File
@@ -638,9 +638,27 @@ export class BotInstance extends EventEmitter {
}
private async cmdAlbum(cmd: ParsedCommand): Promise<string> {
if (!cmd.args) return "Usage: !album <album ID>";
if (!cmd.args) return "Usage: !album <album name or ID>";
const provider = this.getProvider(cmd.flags);
const songs = await provider.getAlbumSongs(cmd.args);
const id = this.extractId(cmd.args);
const isNumericId = /^\d+$/.test(cmd.args.trim());
let albumId: string;
if (isNumericId || id !== cmd.args) {
// Input is a numeric ID or URL containing an ID — use directly
albumId = id;
} else {
// Name-based search
const result = await provider.search(cmd.args);
const albums = result.albums ?? [];
if (albums.length === 0)
return `No albums found for: ${cmd.args}`;
albumId = albums[0].id;
}
const songs = await provider.getAlbumSongs(albumId);
if (songs.length === 0) return "Album is empty or not found";
this.queue.clear();
@@ -778,6 +796,7 @@ export class BotInstance extends EventEmitter {
`${p}stop — Stop and clear queue`,
`${p}vol <0-100> — Set volume`,
`${p}queue — Show queue`,
`${p}remove <pos> — Remove song at position (see ${p}queue)`,
`${p}mode <seq|loop|random|rloop> — Play mode`,
`${p}playlist <name or id> — Load playlist by name or ID`,
`${p}playlist -q <name or id> — Load playlist from QQ Music`,
+2 -2
View File
@@ -112,12 +112,12 @@ export class NeteaseProvider implements MusicProvider {
params: {
keywords: query,
type: 1000,
limit: 5,
limit: 10,
...this.cookieParams,
},
}),
this.api.get("/cloudsearch", {
params: { keywords: query, type: 10, limit: 5, ...this.cookieParams },
params: { keywords: query, type: 10, limit: 10, ...this.cookieParams },
}),
]);
+133 -27
View File
@@ -12,14 +12,25 @@ import type {
} from "./provider.js";
import { parseLyrics } from "./netease.js";
// Direct QQ Music API client — bypasses the local API server for search
// because @sansenjian/qq-music-api still uses the broken c.y.qq.com endpoint.
const qqDirectApi = axios.create({
// Primary search client: u.y.qq.com/cgi-bin/musicu.fcg (JSON sub-request
// batch). Was broken ca. 2026-05 due to two upstream API changes:
// 1. searchid param must NOT be present (causes all lists to be empty)
// 2. num_per_page must be >= 10 (lower values return empty)
// Both fixes applied per https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/61
const qqMusicuApi = axios.create({
baseURL: "https://u.y.qq.com",
timeout: 10000,
headers: { referer: "https://y.qq.com" },
});
// Fallback search client: c.y.qq.com/soso/fcgi-bin/client_search_cp (classic
// endpoint, song + album only, no playlist support).
const qqSearchApi = axios.create({
baseURL: "https://c.y.qq.com",
timeout: 10000,
headers: { referer: "https://y.qq.com" },
});
// Direct client for c.y.qq.com endpoints (collected playlists / favorites).
// The bundled qq-music-api wrapper doesn't expose these endpoints.
const qqFavApi = axios.create({
@@ -82,37 +93,132 @@ export class QQMusicProvider implements MusicProvider {
}
async search(query: string, limit = 20): Promise<SearchResult> {
const reqData = JSON.stringify({
req_0: {
module: "music.search.SearchCgiService",
method: "DoSearchForQQMusicDesktop",
param: { searchid: "1", query, num_per_page: Math.min(limit, 50), search_type: 0 },
},
req_album: {
module: "music.search.SearchCgiService",
method: "DoSearchForQQMusicDesktop",
param: { searchid: "1", query, num_per_page: 5, search_type: 8 },
},
});
const res = await qqDirectApi.get("/cgi-bin/musicu.fcg", {
params: { format: "json", data: reqData },
});
const list: any[] =
res.data?.req_0?.data?.body?.song?.list ?? [];
// Primary: u.y.qq.com/cgi-bin/musicu.fcg — supports songs + albums +
// playlists. Fixed per https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/61
// (removed searchid, num_per_page >= 10, corrected search_type values).
const primary = await this.searchViaMusicuFcg(query, limit);
if (primary) return primary;
const songs: Song[] = list.map((s: any) => ({
id: String(s.mid ?? s.id),
name: s.title ?? s.name ?? "",
// Fallback: c.y.qq.com/soso/fcgi-bin/client_search_cp (song + album,
// no playlist support). Kept as redundancy.
return this.searchViaClientSearchCp(query, limit);
}
/** Primary search via u.y.qq.com/cgi-bin/musicu.fcg.
*
* Two upstream API changes (2026-05) required fixes:
* 1. Omit `searchid` — its presence now causes all lists to be empty.
* 2. `num_per_page` >= 10 — lower values return empty.
* 3. `search_type: 2` for albums, `3` for playlists (8 was "user"). */
private async searchViaMusicuFcg(
query: string,
limit: number
): Promise<SearchResult | null> {
try {
const numPerPage = Math.max(10, Math.min(limit, 50));
const reqData = JSON.stringify({
req_0: {
module: "music.search.SearchCgiService",
method: "DoSearchForQQMusicDesktop",
param: { query, num_per_page: numPerPage, search_type: 0 },
},
req_album: {
module: "music.search.SearchCgiService",
method: "DoSearchForQQMusicDesktop",
param: { query, num_per_page: 10, search_type: 2 },
},
req_playlist: {
module: "music.search.SearchCgiService",
method: "DoSearchForQQMusicDesktop",
param: { query, num_per_page: 10, search_type: 3 },
},
});
const res = await qqMusicuApi.get("/cgi-bin/musicu.fcg", {
params: { format: "json", data: reqData },
});
const songList: any[] =
res.data?.req_0?.data?.body?.song?.list ?? [];
if (songList.length === 0) return null;
const songs: Song[] = songList.map((s: any) => ({
id: String(s.mid ?? s.id),
name: s.title ?? s.name ?? "",
artist: (s.singer ?? []).map((a: any) => a.name).join(" / "),
album: s.album?.name ?? s.album?.title ?? "",
duration: s.interval ?? 0,
coverUrl: s.album?.mid
? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${s.album.mid}.jpg`
: "",
platform: "qq",
}));
const albumList: any[] = res.data?.req_album?.data?.body?.album?.list ?? [];
const albums = mapQqAlbums(albumList);
const playlistList: any[] = res.data?.req_playlist?.data?.body?.songlist?.list ?? [];
const playlists: Playlist[] = playlistList.map((p: any) => ({
id: String(p.dissid ?? p.id ?? ""),
name: p.dissname ?? p.title ?? "",
coverUrl: p.imgurl ?? p.logo ?? "",
songCount: p.songnum ?? p.song_count ?? 0,
platform: "qq" as const,
}));
return { songs, playlists, albums };
} catch {
return null;
}
}
/** Fallback search via c.y.qq.com/soso/fcgi-bin/client_search_cp */
private async searchViaClientSearchCp(
query: string,
limit: number
): Promise<SearchResult> {
const songParams = {
w: query,
format: "json",
p: 1,
n: Math.min(limit, 50),
type: 0,
cr: 1,
};
const albumParams = {
w: query,
format: "json",
p: 1,
n: 5,
t: 8,
cr: 1,
};
const [songRes, albumRes] = await Promise.allSettled([
qqSearchApi.get("/soso/fcgi-bin/client_search_cp", { params: songParams }),
qqSearchApi.get("/soso/fcgi-bin/client_search_cp", { params: albumParams }),
]);
const songList: any[] =
songRes.status === "fulfilled"
? (songRes.value.data?.data?.song?.list ?? [])
: [];
const songs: Song[] = songList.map((s: any) => ({
id: String(s.songmid ?? s.songid ?? ""),
name: s.songname ?? s.name ?? "",
artist: (s.singer ?? []).map((a: any) => a.name).join(" / "),
album: s.album?.name ?? s.album?.title ?? "",
album: s.albumname ?? s.album?.name ?? "",
duration: s.interval ?? 0,
coverUrl: s.album?.mid
? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${s.album.mid}.jpg`
coverUrl: s.albummid
? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${s.albummid}.jpg`
: "",
platform: "qq",
}));
const albumList: any[] = res.data?.req_album?.data?.body?.album?.list ?? [];
const albumList: any[] =
albumRes.status === "fulfilled"
? (albumRes.value.data?.data?.album?.list ?? [])
: [];
const albums = mapQqAlbums(albumList);
return { songs, playlists: [], albums };
+219 -23
View File
@@ -20,42 +20,89 @@
<div v-if="loading" class="loading">搜索中...</div>
<template v-else-if="songs.length || albums.length || playlists.length">
<section v-if="albums.length" class="result-section">
<h2 class="section-title">专辑</h2>
<template v-else-if="allSongs.length || allAlbums.length || allPlaylists.length">
<div class="source-bar">
<button
class="source-btn"
:class="{ active: selectedSource === 'netease' }"
@click="selectedSource = 'netease'"
>网易云</button>
<button
class="source-btn"
:class="{ active: selectedSource === 'qq' }"
@click="selectedSource = 'qq'"
>QQ</button>
<button
class="source-btn"
:class="{ active: selectedSource === 'bilibili' }"
@click="selectedSource = 'bilibili'"
>B站</button>
</div>
<div class="tab-bar">
<button
class="tab"
:class="{ active: activeTab === 'songs' }"
@click="activeTab = 'songs'"
>
单曲<span class="tab-count">{{ filteredSongs.length }}</span>
</button>
<button
v-if="selectedSource !== 'bilibili'"
class="tab"
:class="{ active: activeTab === 'albums' }"
@click="activeTab = 'albums'"
>
专辑<span class="tab-count">{{ filteredAlbums.length }}</span>
</button>
<button
v-if="selectedSource !== 'bilibili'"
class="tab"
:class="{ active: activeTab === 'playlists' }"
@click="activeTab = 'playlists'"
>
歌单<span class="tab-count">{{ filteredPlaylists.length }}</span>
</button>
</div>
<section v-if="activeTab === 'albums' && filteredAlbums.length" class="result-section">
<div class="card-grid">
<router-link
v-for="al in albums"
v-for="al in filteredAlbums"
:key="`${al.platform}-${al.id}`"
:to="`/album/${al.id}?platform=${al.platform}`"
class="card hover-scale"
>
<CoverArt :url="al.coverUrl" :size="160" :radius="10" :show-shadow="true" />
<div class="card-name">{{ al.name }}</div>
<div class="card-name">
{{ al.name }}
<span class="platform-badge" :class="badgeClass(al.platform)">{{ badgeLabel(al.platform) }}</span>
</div>
<div class="card-sub">{{ al.artist }}</div>
</router-link>
</div>
</section>
<section v-if="playlists.length" class="result-section">
<h2 class="section-title">歌单</h2>
<section v-if="activeTab === 'playlists' && filteredPlaylists.length" class="result-section">
<div class="card-grid">
<router-link
v-for="pl in playlists"
v-for="pl in filteredPlaylists"
:key="`${pl.platform}-${pl.id}`"
:to="`/playlist/${pl.id}?platform=${pl.platform}`"
class="card hover-scale"
>
<CoverArt :url="pl.coverUrl" :size="160" :radius="10" :show-shadow="true" />
<div class="card-name">{{ pl.name }}</div>
<div class="card-name">
{{ pl.name }}
<span class="platform-badge" :class="badgeClass(pl.platform)">{{ badgeLabel(pl.platform) }}</span>
</div>
</router-link>
</div>
</section>
<section v-if="songs.length" class="result-section">
<h2 class="section-title">单曲</h2>
<section v-if="activeTab === 'songs' && filteredSongs.length" class="result-section">
<SongCard
v-for="(song, i) in songs"
v-for="(song, i) in filteredSongs"
:key="`${song.platform}-${song.id}`"
:song="song"
:index="i + 1"
@@ -72,8 +119,8 @@
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useRoute } from 'vue-router';
import { ref, computed, watch, onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { Icon } from '@iconify/vue';
import axios from 'axios';
import { usePlayerStore } from '../stores/player.js';
@@ -83,34 +130,87 @@ import CoverArt from '../components/CoverArt.vue';
const store = usePlayerStore();
const route = useRoute();
const router = useRouter();
const SOURCE_STORAGE_KEY = 'search-source';
function loadSource(): 'netease' | 'qq' | 'bilibili' {
try {
const stored = localStorage.getItem(SOURCE_STORAGE_KEY);
if (stored === 'netease' || stored === 'qq' || stored === 'bilibili') return stored;
} catch { /* localStorage blocked */ }
return 'netease';
}
const query = ref((route.query.q as string) || '');
const activeTab = ref<'songs' | 'albums' | 'playlists'>('songs');
const selectedSource = ref<'netease' | 'qq' | 'bilibili'>(loadSource());
interface Album { id: string; name: string; artist: string; coverUrl: string; songCount?: number; platform: string; }
interface Playlist { id: string; name: string; coverUrl: string; songCount?: number; platform: string; }
const songs = ref<Song[]>([]);
const albums = ref<Album[]>([]);
const playlists = ref<Playlist[]>([]);
const allSongs = ref<Song[]>([]);
const allAlbums = ref<Album[]>([]);
const allPlaylists = ref<Playlist[]>([]);
const loading = ref(false);
const searched = ref(false);
const filteredSongs = computed(() =>
allSongs.value.filter((s) => s.platform === selectedSource.value)
);
const filteredAlbums = computed(() =>
allAlbums.value.filter((a) => a.platform === selectedSource.value)
);
const filteredPlaylists = computed(() =>
allPlaylists.value.filter((p) => p.platform === selectedSource.value)
);
// Persist source preference
watch(selectedSource, (src) => {
try { localStorage.setItem(SOURCE_STORAGE_KEY, src); } catch { /* ignore */ }
});
// B站 has no albums/playlists — force songs tab when switching to B站
watch(selectedSource, (src) => {
if (src === 'bilibili' && activeTab.value !== 'songs') {
activeTab.value = 'songs';
}
});
async function doSearch() {
if (!query.value.trim()) return;
loading.value = true;
searched.value = true;
activeTab.value = 'songs';
router.replace({ query: { q: query.value } });
try {
const res = await axios.get('/api/music/search/all', { params: { q: query.value } });
songs.value = res.data.songs ?? [];
albums.value = res.data.albums ?? [];
playlists.value = res.data.playlists ?? [];
allSongs.value = res.data.songs ?? [];
allAlbums.value = res.data.albums ?? [];
allPlaylists.value = res.data.playlists ?? [];
} catch {
songs.value = []; albums.value = []; playlists.value = [];
allSongs.value = []; allAlbums.value = []; allPlaylists.value = [];
} finally {
loading.value = false;
}
}
function badgeLabel(platform: string): string {
if (platform === 'qq') return 'QQ';
if (platform === 'bilibili') return 'B站';
if (platform === 'youtube') return 'YouTube';
return '网易云';
}
function badgeClass(platform: string): string {
if (platform === 'qq') return 'badge-qq';
if (platform === 'bilibili') return 'badge-bilibili';
if (platform === 'youtube') return 'badge-youtube';
return 'badge-netease';
}
onMounted(() => {
if (query.value) doSearch();
});
@@ -180,14 +280,80 @@ onMounted(() => {
gap: 2px;
}
.source-bar {
display: flex;
gap: 8px;
margin-bottom: 12px;
}
.source-btn {
padding: 5px 16px;
border-radius: var(--radius-sm);
font-size: 13px;
font-family: inherit;
font-weight: var(--fw-semi);
color: var(--text-secondary);
background: var(--bg-card);
cursor: pointer;
transition: all var(--transition-fast);
white-space: nowrap;
&:hover { color: var(--text-primary); }
&.active {
color: var(--color-primary);
background: rgba(51, 94, 234, 0.12);
}
}
.tab-bar {
display: flex;
gap: 6px;
margin-bottom: 24px;
padding: 4px;
background: var(--bg-card);
border-radius: var(--radius-md);
width: fit-content;
}
.tab {
padding: 8px 20px;
border-radius: calc(var(--radius-md) - 2px);
font-size: 14px;
font-family: inherit;
font-weight: var(--fw-semi);
color: var(--text-secondary);
background: transparent;
cursor: pointer;
transition: all var(--transition-fast);
white-space: nowrap;
&:hover { color: var(--text-primary); }
&.active {
background: var(--color-primary);
color: #fff;
.tab-count { opacity: 0.85; }
}
}
.tab-count {
margin-left: 5px;
opacity: 0.55;
font-weight: var(--fw-regular);
font-size: 13px;
&::before { content: '('; }
&::after { content: ')'; }
}
.result-section {
margin-bottom: 32px;
.section-title { font-size: 18px; margin: 0 0 12px; opacity: 0.85; }
}
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 16px;
gap: 16px 28px;
}
.card {
display: flex;
@@ -198,4 +364,34 @@ onMounted(() => {
.card-name { font-size: 14px; line-height: 1.3; max-height: 2.6em; overflow: hidden; }
.card-sub { font-size: 12px; opacity: 0.6; }
}
.platform-badge {
vertical-align: middle;
flex-shrink: 0;
font-size: var(--fs-micro);
font-weight: var(--fw-semi);
padding: 1px 5px;
border-radius: var(--radius-xs);
line-height: 1.4;
}
.badge-netease {
background: var(--brand-netease-15);
color: var(--brand-netease);
}
.badge-qq {
background: var(--brand-qq-15);
color: var(--brand-qq);
}
.badge-bilibili {
background: var(--brand-bilibili-15);
color: var(--brand-bilibili);
}
.badge-youtube {
background: var(--brand-youtube-12);
color: var(--brand-youtube);
}
</style>