Compare commits

..
10 Commits
Author SHA1 Message Date
TIANYAO ZHANG 6b143e1a56 Merge pull request #63 from XuVIIJay/pr/setup-scripts
安装脚本优化(对国内网络环境安装友好)
2026-05-16 02:02:10 +08:00
XuVIIJay 5728209573 fix(setup.sh): add Node.js and npm version check before install 2026-05-15 21:03:28 +08:00
XuVIIJay 02d8b39d75 fix(setup.bat): remove hardcoded personal Node.js paths 2026-05-15 20:56:59 +08:00
XuVIIJay f87aaaf8c3 feat(scripts): optimize setup/start scripts for China network 2026-05-15 20:42:51 +08:00
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
9 changed files with 760 additions and 166 deletions

No files matched your search

-2
View File
@@ -19,8 +19,6 @@
"Read(//tmp/**)" "Read(//tmp/**)"
], ],
"deny": [ "deny": [
"Bash(git push * main)",
"Bash(git push * master)",
"Bash(git push --force *)", "Bash(git push --force *)",
"Bash(rm -rf /)" "Bash(rm -rf /)"
] ]
+161
View File
@@ -0,0 +1,161 @@
#!/usr/bin/env node
/**
* Download native binaries (ffmpeg + @discordjs/opus) from npmmirror CDN.
* Called by setup.bat after npm install --ignore-scripts.
*
* Usage: node scripts/download-binaries.mjs [cdn_base_url]
*/
import { existsSync, mkdirSync, writeFileSync, statSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, dirname } from "node:path";
import { createGunzip } from "node:zlib";
import { pipeline } from "node:stream/promises";
import { createWriteStream } from "node:fs";
import { get } from "node:https";
import { Readable } from "node:stream";
import { execSync } from "node:child_process";
import { createRequire } from "node:module";
import { fileURLToPath } from "node:url";
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
const CDN = process.argv[2] || "https://cdn.npmmirror.com/binaries";
const PLATFORM = process.platform;
const ARCH = process.arch;
const NODE_ABI = process.versions.modules;
function download(url) {
return new Promise((resolve, reject) => {
const req = get(url, { timeout: 120000 }, (res) => {
if (res.statusCode < 200 || res.statusCode >= 400) {
reject(new Error(`HTTP ${res.statusCode}: ${url}`));
return;
}
const chunks = [];
res.on("data", (c) => chunks.push(c));
res.on("end", () => resolve(Buffer.concat(chunks)));
});
req.on("error", reject);
req.on("timeout", () => { req.destroy(); reject(new Error("timeout")); });
});
}
function log(msg) {
console.log(` [binary] ${msg}`);
}
function isValidSize(filePath, minBytes) {
try { return statSync(filePath).size >= minBytes; } catch { return false; }
}
async function downloadFfmpeg() {
const ffDir = join(ROOT, "node_modules", "ffmpeg-static");
const ffName = PLATFORM === "win32" ? "ffmpeg.exe" : "ffmpeg";
const ffDest = join(ffDir, ffName);
if (!existsSync(ffDir)) { log("ffmpeg-static not installed, skipping"); return false; }
if (existsSync(ffDest)) {
if (isValidSize(ffDest, 50 * 1024 * 1024)) {
log("ffmpeg already exists, skipping");
return true;
}
log("ffmpeg exists but seems corrupted (too small), re-downloading...");
}
const url = `${CDN}/ffmpeg-static/b6.1.1/ffmpeg-${PLATFORM}-${ARCH}.gz`;
log("Downloading ffmpeg...");
const buf = await download(url);
await pipeline(Readable.from(buf), createGunzip(), createWriteStream(ffDest));
try { execSync(`chmod +x "${ffDest}"`); } catch {}
const size = ((await statSync(ffDest)).size / 1024 / 1024).toFixed(1);
log(`ffmpeg OK (${size} MB)`);
return true;
}
async function downloadOpus() {
const opusDir = join(ROOT, "node_modules", "@discordjs", "opus");
const prebuildName = `node-v${NODE_ABI}-napi-v3-${PLATFORM}-${ARCH}-unknown-unknown`;
const opusDest = join(opusDir, "prebuild", prebuildName, "opus.node");
if (!existsSync(opusDir)) { log("@discordjs/opus not installed, skipping"); return false; }
if (existsSync(opusDest)) {
if (isValidSize(opusDest, 100 * 1024)) {
log("@discordjs/opus already exists, skipping");
return true;
}
log("@discordjs/opus exists but seems corrupted (too small), re-downloading...");
}
const url = `${CDN}/@discordjs/opus/v0.10.0/opus-v0.10.0-node-v${NODE_ABI}-napi-v3-${PLATFORM}-${ARCH}-unknown-unknown.tar.gz`;
log("Downloading @discordjs/opus...");
try {
const buf = await download(url);
mkdirSync(dirname(opusDest), { recursive: true });
const require = createRequire(import.meta.url);
const tar = require("tar");
const tmpFile = join(tmpdir(), `discordjs-opus-${Date.now()}.tar.gz`);
writeFileSync(tmpFile, buf);
await tar.extract({ cwd: join(opusDir, "prebuild"), file: tmpFile });
log("@discordjs/opus OK");
return true;
} catch (err) {
log(`CDN download failed (${err.message}), trying to build from source...`);
try {
execSync("npm rebuild @discordjs/opus", { cwd: ROOT, stdio: "inherit" });
if (existsSync(opusDest) && isValidSize(opusDest, 100 * 1024)) {
log("@discordjs/opus built from source OK");
return true;
}
log("Source build completed but .node file not found");
return false;
} catch (buildErr) {
log(`Source build failed: ${buildErr.message}`);
log("Install build tools: sudo apt install build-essential (Ubuntu/Debian)");
log(" sudo yum groupinstall 'Development Tools' (CentOS/RHEL)");
return false;
}
}
}
async function downloadBetterSqlite3() {
const pkgDir = join(ROOT, "node_modules", "better-sqlite3");
const dest = join(pkgDir, "build", "Release", "better_sqlite3.node");
if (!existsSync(pkgDir)) { log("better-sqlite3 not installed, skipping"); return false; }
if (existsSync(dest)) {
if (isValidSize(dest, 500 * 1024)) {
log("better-sqlite3 already exists, skipping");
return true;
}
log("better-sqlite3 exists but seems corrupted (too small), re-downloading...");
}
const version = "12.8.0";
const url = `${CDN}/better-sqlite3/v${version}/better-sqlite3-v${version}-node-v${NODE_ABI}-${PLATFORM}-${ARCH}.tar.gz`;
log("Downloading better-sqlite3...");
const buf = await download(url);
const require = createRequire(import.meta.url);
const tar = require("tar");
const tmpFile = join(tmpdir(), `better-sqlite3-${Date.now()}.tar.gz`);
writeFileSync(tmpFile, buf);
mkdirSync(dirname(dest), { recursive: true });
await tar.extract({ cwd: pkgDir, file: tmpFile });
if (existsSync(dest)) {
log(`better-sqlite3 OK (${((await statSync(dest)).size / 1024).toFixed(0)} KB)`);
return true;
}
log("better-sqlite3 extracted but .node file not found at expected path");
return false;
}
try {
const results = await Promise.all([downloadFfmpeg(), downloadOpus(), downloadBetterSqlite3()]);
if (results.some(Boolean)) {
console.log(" [binary] All downloads complete");
}
} catch (e) {
console.error(` [binary] ERROR: ${e.message}`);
process.exit(1);
}
+85 -112
View File
@@ -4,14 +4,13 @@ chcp 65001 >nul
title TSMusicBot Setup title TSMusicBot Setup
:: ============================================================ :: ============================================================
:: TSMusicBot Setup Script (Robust Edition) :: TSMusicBot Setup Script (Windows)
:: - Auto-detect China network, switch to npmmirror :: - Auto-detect China network, switch to npmmirror
:: - Strict error checking at every step :: - Download native binaries from CDN (避开 GitHub)
:: - Detailed logging to setup.log :: - 自动修复 PowerShell 环境变量
:: - Skip already-completed steps on re-run
:: ============================================================ :: ============================================================
set "SCRIPT_VERSION=2.0" set "SCRIPT_VERSION=2.1"
set "MIN_NODE_MAJOR=20" set "MIN_NODE_MAJOR=20"
set "LOG_FILE=%~dp0..\setup.log" set "LOG_FILE=%~dp0..\setup.log"
set "FAILED=0" set "FAILED=0"
@@ -44,42 +43,29 @@ echo.
:: ============================================================ :: ============================================================
:: Step 1: Check Node.js :: Step 1: Check Node.js
:: ============================================================ :: ============================================================
call :step "1/6" "Checking Node.js" call :step "1/7" "Checking Node.js"
where node >nul 2>&1 where node >nul 2>&1
if errorlevel 1 ( if not errorlevel 1 goto :check_node_version
call :error "Node.js not found in PATH."
echo.
echo Please install Node.js %MIN_NODE_MAJOR% LTS or newer from one of:
echo - https://nodejs.org/ ^(official^)
echo - https://nodejs.cn/ ^(China mirror, recommended for CN users^)
echo.
echo After installation:
echo 1. Close this window completely
echo 2. Open a NEW Command Prompt
echo 3. Run scripts\setup.bat again
echo.
pause
exit /b 1
)
:: Check Node version >= 20 call :error "Node.js not found in PATH."
for /f "tokens=1 delims=v." %%a in ('node --version 2^>nul') do set "NODE_RAW=%%a" echo.
for /f "tokens=1 delims=v." %%a in ('node --version 2^>nul') do ( echo Please install Node.js %MIN_NODE_MAJOR% LTS or newer from:
for /f "tokens=1 delims=." %%b in ("%%a") do set "NODE_MAJOR=%%b" echo https://nodejs.org/ (official)
) echo https://nodejs.cn/ (China mirror, recommended)
echo.
pause
exit /b 1
:: Robust version parse :check_node_version
for /f "delims=" %%v in ('node --version 2^>nul') do set "NODE_VER=%%v" for /f "delims=" %%v in ('node --version 2^>nul') do set "NODE_VER=%%v"
set "NODE_VER_NUM=%NODE_VER:v=%" for /f "tokens=1 delims=v." %%a in ("%NODE_VER%") do set "NODE_MAJOR=%%a"
for /f "tokens=1 delims=." %%a in ("%NODE_VER_NUM%") do set "NODE_MAJOR=%%a"
call :log "Node.js version: %NODE_VER%" call :log "Node.js version: %NODE_VER%"
echo [OK] Node.js found: %NODE_VER% echo [OK] Node.js found: %NODE_VER%
if %NODE_MAJOR% LSS %MIN_NODE_MAJOR% ( if %NODE_MAJOR% LSS %MIN_NODE_MAJOR% (
call :error "Node.js version too old. Need %MIN_NODE_MAJOR%+, found %NODE_VER%." call :error "Node.js version too old. Need %MIN_NODE_MAJOR%+, found %NODE_VER%."
echo Please upgrade Node.js to version %MIN_NODE_MAJOR% LTS or newer.
pause pause
exit /b 1 exit /b 1
) )
@@ -88,12 +74,11 @@ echo.
:: ============================================================ :: ============================================================
:: Step 2: Check npm :: Step 2: Check npm
:: ============================================================ :: ============================================================
call :step "2/6" "Checking npm" call :step "2/7" "Checking npm"
where npm >nul 2>&1 where npm >nul 2>&1
if errorlevel 1 ( if errorlevel 1 (
call :error "npm not found. This is unusual since Node.js is installed." call :error "npm not found."
echo Please reinstall Node.js to fix this.
pause pause
exit /b 1 exit /b 1
) )
@@ -106,117 +91,91 @@ echo.
:: ============================================================ :: ============================================================
:: Step 3: Detect network and configure mirror :: Step 3: Detect network and configure mirror
:: ============================================================ :: ============================================================
call :step "3/6" "Checking network" call :step "3/7" "Checking network"
set "USE_MIRROR=0" set "USE_MIRROR=0"
set "MIRROR_REGISTRY=https://registry.npmjs.org"
:: Try reaching npm registry with a short timeout echo Testing connection to npm registry...
echo Testing connection to registry.npmjs.org...
call :log "Testing npm registry connectivity..." call :log "Testing npm registry connectivity..."
:: Use curl if available (more reliable than ping for HTTPS) ping -n 1 -w 4000 registry.npmjs.org >nul 2>&1
where curl >nul 2>&1 if errorlevel 1 (
if not errorlevel 1 ( echo [WARN] Cannot reach npm registry quickly, using China mirror.
curl -s -o nul -m 5 -w "%%{http_code}" https://registry.npmjs.org/ > "%TEMP%\npmtest.txt" 2>nul call :log "npm registry unreachable via ping"
set /p HTTP_CODE=<"%TEMP%\npmtest.txt"
del "%TEMP%\npmtest.txt" >nul 2>&1
if "!HTTP_CODE!"=="200" (
echo [OK] npm registry reachable.
call :log "npm registry HTTP 200 OK"
) else (
echo [WARN] npm registry slow or unreachable ^(code: !HTTP_CODE!^).
call :log "npm registry returned: !HTTP_CODE!"
set "USE_MIRROR=1" set "USE_MIRROR=1"
)
) else ( ) else (
:: Fallback to ping
ping -n 1 -w 3000 registry.npmjs.org >nul 2>&1
if errorlevel 1 (
echo [WARN] Cannot reach npm registry quickly.
set "USE_MIRROR=1"
) else (
echo [OK] npm registry reachable. echo [OK] npm registry reachable.
) call :log "npm registry reachable"
) )
if "%USE_MIRROR%"=="1" ( if "%USE_MIRROR%"=="1" (
echo. echo.
echo Slow connection detected. Switching to China mirror ^(npmmirror.com^)... echo [INFO] Using China mirror (npmmirror.com)
call :log "Switching to npmmirror.com" call :log "Switching to npmmirror.com"
call npm config set registry https://registry.npmmirror.com >>"%LOG_FILE%" 2>&1 set "MIRROR_REGISTRY=https://registry.npmmirror.com"
call npm config set disturl https://registry.npmmirror.com/-/binary/node >>"%LOG_FILE%" 2>&1 set "CDN_MIRROR=https://cdn.npmmirror.com/binaries"
call npm config set electron_mirror https://registry.npmmirror.com/-/binary/electron/ >>"%LOG_FILE%" 2>&1 ) else (
call npm config set sqlite3_binary_host_mirror https://registry.npmmirror.com/-/binary/better-sqlite3 >>"%LOG_FILE%" 2>&1 set "CDN_MIRROR="
call npm config set node_sqlite3_binary_host_mirror https://registry.npmmirror.com/-/binary/better-sqlite3 >>"%LOG_FILE%" 2>&1
call npm config set sharp_binary_host https://registry.npmmirror.com/-/binary/sharp >>"%LOG_FILE%" 2>&1
call npm config set sharp_libvips_binary_host https://registry.npmmirror.com/-/binary/sharp-libvips >>"%LOG_FILE%" 2>&1
call npm config set FFMPEG_BINARIES_URL https://registry.npmmirror.com/-/binary/ffmpeg-static >>"%LOG_FILE%" 2>&1
call npm config set @discordjs:registry https://registry.npmmirror.com >>"%LOG_FILE%" 2>&1
echo [OK] Mirror configured.
) )
echo. echo.
:: ============================================================ :: ============================================================
:: Step 4: Install backend dependencies :: Step 4: Install backend dependencies (跳过二进制)
:: ============================================================ :: ============================================================
call :step "4/6" "Installing backend dependencies" call :step "4/7" "Installing backend dependencies"
if exist "node_modules\.package-lock.json" ( if exist "node_modules\.package-lock.json" (
echo Found existing node_modules. Checking integrity... echo Found existing node_modules. Checking integrity...
call :log "Existing node_modules detected, running npm install to verify"
) )
echo Running: npm install ^(this can take 5-15 minutes on slow networks^) echo Running: npm install --ignore-scripts (跳过 GitHub 二进制下载)
echo Press Ctrl+C to abort.
echo. echo.
call npm install >>"%LOG_FILE%" 2>&1 call npm install --registry=%MIRROR_REGISTRY% --ignore-scripts >>"%LOG_FILE%" 2>&1
if errorlevel 1 ( if errorlevel 1 (
call :error "Backend npm install failed." call :error "Backend npm install failed."
echo. echo Check the log: %LOG_FILE%
echo Common causes:
echo - Network timeout ^(retry with VPN or check %LOG_FILE%^)
echo - Native module compile failure ^(missing Python/VS Build Tools^)
echo - Disk space full
echo.
echo Try manually:
echo cd /d "%PROJECT_ROOT%"
echo npm install --verbose
echo.
pause pause
exit /b 1 exit /b 1
) )
echo [OK] Backend dependencies installed. echo [OK] Backend dependencies installed.
echo. echo.
:: ============================================================
:: Step 4b: Download native binaries from CDN
:: ============================================================
call :step "4b/7" "Downloading native binaries"
node scripts/download-binaries.mjs %CDN_MIRROR% >>"%LOG_FILE%" 2>&1
if errorlevel 1 (
echo [WARN] Binary download had issues. Check %LOG_FILE% for details.
) else (
echo [OK] Native binaries installed.
)
echo.
:: ============================================================ :: ============================================================
:: Step 5: Install frontend dependencies :: Step 5: Install frontend dependencies
:: ============================================================ :: ============================================================
call :step "5/6" "Installing frontend dependencies" call :step "5/7" "Installing frontend dependencies"
if not exist "web\package.json" ( if not exist "web\package.json" (
call :error "web\package.json not found. Repository may be incomplete." call :error "web\package.json not found."
echo Please re-clone the repository:
echo git clone https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
pause pause
exit /b 1 exit /b 1
) )
echo Running: npm install ^(in web/ directory^) echo Running: npm install (in web/)
echo. echo.
pushd web >nul pushd web >nul
call npm install >>"%LOG_FILE%" 2>&1 call npm install --registry=%MIRROR_REGISTRY% >>"%LOG_FILE%" 2>&1
set "WEB_INSTALL_RESULT=!errorlevel!" set "WEB_INSTALL_RESULT=!errorlevel!"
popd >nul popd >nul
if !WEB_INSTALL_RESULT! neq 0 ( if !WEB_INSTALL_RESULT! neq 0 (
call :error "Frontend npm install failed ^(exit code !WEB_INSTALL_RESULT!^)." call :error "Frontend npm install failed."
echo.
echo Try manually:
echo cd /d "%PROJECT_ROOT%\web"
echo npm install --verbose
echo.
pause pause
exit /b 1 exit /b 1
) )
@@ -224,29 +183,48 @@ echo [OK] Frontend dependencies installed.
echo. echo.
:: ============================================================ :: ============================================================
:: Step 6: Build project (backend + frontend) :: Step 6: Build project
:: ============================================================ :: ============================================================
call :step "6/6" "Building project" call :step "6/7" "Building project"
echo Running: npm run build echo Running: npm run build
echo. echo.
call npm run build >>"%LOG_FILE%" 2>&1 call npm run build >>"%LOG_FILE%" 2>&1
if errorlevel 1 ( if errorlevel 1 (
call :error "Build failed." call :error "Build failed. Check: %LOG_FILE%"
echo.
echo Check the log file for details: %LOG_FILE%
echo.
echo Try manually:
echo cd /d "%PROJECT_ROOT%"
echo npm run build
echo.
pause pause
exit /b 1 exit /b 1
) )
echo [OK] Build succeeded. echo [OK] Build succeeded.
echo. echo.
:: ============================================================
:: Step 7: Ensure PowerShell in PATH (修复 jdymusic CDN 播放)
:: ============================================================
call :step "7/7" "Checking PowerShell PATH"
where powershell >nul 2>&1
if errorlevel 1 (
echo [WARN] PowerShell not found in PATH.
echo Attempting to fix...
set "POWERSHELL_PATH=C:\Windows\System32\WindowsPowerShell\v1.0"
if exist "!POWERSHELL_PATH!\powershell.exe" (
:: 为用户添加永久 PATH 环境变量
echo [INFO] Adding PowerShell to user PATH...
call setx PATH "!POWERSHELL_PATH!;%PATH%" >nul 2>&1
echo [OK] PowerShell added to PATH. Please restart your terminal.
) else (
echo [WARN] Could not find powershell.exe on this system.
echo If you encounter playback issues with some NetEase songs,
echo run: set PATH=%%PATH%%;C:\Windows\System32\WindowsPowerShell\v1.0\
echo before running scripts\start.bat
)
) else (
echo [OK] PowerShell found in PATH.
)
echo.
:: ============================================================ :: ============================================================
:: Verify build outputs :: Verify build outputs
:: ============================================================ :: ============================================================
@@ -263,18 +241,13 @@ if not exist "web\dist" (
) )
if "!BUILD_OK!"=="0" ( if "!BUILD_OK!"=="0" (
echo.
echo Build completed but expected output is missing. echo Build completed but expected output is missing.
echo Check %LOG_FILE% for details.
pause pause
exit /b 1 exit /b 1
) )
echo [OK] Build outputs verified. echo [OK] Build outputs verified.
echo. echo.
:: ============================================================
:: Optional: config.json hint
:: ============================================================
if not exist "config.json" ( if not exist "config.json" (
echo [INFO] config.json will be auto-generated on first launch. echo [INFO] config.json will be auto-generated on first launch.
) else ( ) else (
@@ -294,9 +267,8 @@ echo.
echo Next steps: echo Next steps:
echo 1. Run: scripts\start.bat echo 1. Run: scripts\start.bat
echo 2. Open: http://localhost:3000 echo 2. Open: http://localhost:3000
echo 3. Follow the in-browser setup wizard.
echo. echo.
echo Setup log saved to: %LOG_FILE% echo Setup log: %LOG_FILE%
echo. echo.
pause pause
exit /b 0 exit /b 0
@@ -319,3 +291,4 @@ goto :eof
:log :log
echo [%time%] %~1 >> "%LOG_FILE%" echo [%time%] %~1 >> "%LOG_FILE%"
goto :eof goto :eof
+145
View File
@@ -0,0 +1,145 @@
#!/usr/bin/env bash
set -euo pipefail
#
# TSMusicBot Setup Script (Linux/macOS)
# - Auto-detect China network, switch to npmmirror
# - Download native binaries from CDN (避开 GitHub)
# - One-click setup, same as setup.bat for Windows
#
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
LOG_FILE="$PROJECT_DIR/setup.log"
echo "============================================"
echo " TSMusicBot - First-Time Setup (Linux)"
echo "============================================"
echo ""
echo "Log file: $LOG_FILE"
echo ""
# ---- Check Node.js ----
if ! command -v node &>/dev/null; then
echo "[ERROR] Node.js not found. Please install Node.js 20+ from https://nodejs.org"
echo " or https://nodejs.cn/ (China mirror)."
exit 1
fi
echo "[OK] Node.js $(node -v)"
if ! command -v npm &>/dev/null; then
echo "[ERROR] npm not found."
exit 1
fi
echo "[OK] npm v$(npm -v)"
echo ""
# ---- Detect China network ----
USE_MIRROR=0
MIRROR_REGISTRY="https://registry.npmjs.org"
CDN_MIRROR=""
echo "Testing connection to npm registry..."
if ping -c 1 -W 4 registry.npmjs.org &>/dev/null; then
echo "[OK] npm registry reachable."
else
echo "[WARN] Cannot reach npm registry, using China mirror."
USE_MIRROR=1
fi
if [ "$USE_MIRROR" = "1" ]; then
echo "[INFO] Using China mirror (npmmirror.com)"
MIRROR_REGISTRY="https://registry.npmmirror.com"
CDN_MIRROR="https://cdn.npmmirror.com/binaries"
export npm_config_registry="$MIRROR_REGISTRY"
fi
echo ""
# ---- Check build tools (needed for native module fallback) ----
if ! command -v gcc &>/dev/null && ! command -v clang &>/dev/null; then
echo "[INFO] No C compiler found. If CDN binaries are unavailable,"
echo " native modules may fail. Install build tools:"
echo " sudo apt install build-essential (Ubuntu/Debian)"
echo " sudo yum groupinstall 'Development Tools' (CentOS/RHEL)"
echo ""
fi
# ---- Step 1: Install dependencies (skip GitHub binaries) ----
echo "---- 1/5: Installing Node.js dependencies ----"
echo ""
cd "$PROJECT_DIR"
npm install --registry="$MIRROR_REGISTRY" --ignore-scripts 2>&1 | tee -a "$LOG_FILE"
echo "[OK] Dependencies installed."
echo ""
# ---- Step 2: Download native binaries from CDN ----
echo "---- 2/5: Downloading native binaries ----"
echo ""
if node scripts/download-binaries.mjs $CDN_MIRROR 2>&1 | tee -a "$LOG_FILE"; then
echo "[OK] Native binaries installed."
else
echo "[WARN] Some native binaries had issues (will try source build as fallback)."
fi
echo ""
# ---- Step 3: Install web panel dependencies ----
echo "---- 3/5: Installing web panel dependencies ----"
echo ""
if [ -f "web/package.json" ]; then
cd "$PROJECT_DIR/web"
npm install --registry="$MIRROR_REGISTRY" 2>&1 | tee -a "$LOG_FILE"
cd "$PROJECT_DIR"
echo "[OK] Web panel dependencies installed."
else
echo "[SKIP] web/package.json not found."
fi
echo ""
# ---- Step 4: Build project ----
echo "---- 4/5: Building project ----"
echo ""
npm run build 2>&1 | tee -a "$LOG_FILE"
echo "[OK] Build succeeded."
echo ""
# ---- Step 5: Verify ----
echo "---- 5/5: Verifying build ----"
echo ""
BUILD_OK=1
if [ ! -d "dist" ]; then
echo "[ERROR] dist/ directory missing."
BUILD_OK=0
fi
if [ -d "web" ] && [ ! -d "web/dist" ]; then
echo "[ERROR] web/dist/ directory missing."
BUILD_OK=0
fi
if [ "$BUILD_OK" = "0" ]; then
echo "Build completed but expected output is missing."
exit 1
fi
echo "[OK] Build outputs verified."
echo ""
if [ ! -f "config.json" ]; then
echo "[INFO] config.json will be auto-generated on first launch."
fi
echo ""
echo "============================================"
echo " Setup Complete!"
echo "============================================"
echo ""
echo "Next steps:"
echo " 1. Run: npm start"
echo " 2. Open: http://localhost:3000"
echo ""
echo "Setup log: $LOG_FILE"
echo ""
+13 -16
View File
@@ -7,7 +7,7 @@ echo.
where node >nul 2>&1 where node >nul 2>&1
if %errorlevel% neq 0 ( if %errorlevel% neq 0 (
echo Node.js is not installed. echo Node.js is not installed.
echo Run scripts\setup.bat for automatic installation, or install Node.js 20+ from https://nodejs.org echo Run scripts\setup.bat first.
pause pause
exit /b 1 exit /b 1
) )
@@ -15,33 +15,30 @@ if %errorlevel% neq 0 (
:: Resolve project root (one level up from scripts/) :: Resolve project root (one level up from scripts/)
cd /d "%~dp0.." cd /d "%~dp0.."
:: Install dependencies if needed :: Check if dependencies are installed
if not exist "node_modules" ( if not exist "node_modules" (
echo Installing dependencies... echo Dependencies not found. Please run scripts\setup.bat first.
call npm install --production
if %errorlevel% neq 0 (
echo Failed to install dependencies.
pause pause
exit /b 1 exit /b 1
)
) )
:: Build if dist/ doesn't exist :: Check if build output exists
if not exist "dist" ( if not exist "dist" (
echo Building project... echo Build not found. Please run scripts\setup.bat first.
call npx tsc
if %errorlevel% neq 0 (
echo Build failed.
pause pause
exit /b 1 exit /b 1
)
) )
:: FFmpeg is bundled via ffmpeg-static — no PATH check needed. :: Ensure PowerShell is in PATH (fix for jdymusic CDN playback on some systems)
echo FFmpeg is bundled via node_modules (ffmpeg-static). where powershell >nul 2>&1
echo. if errorlevel 1 (
if exist "C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe" (
set "PATH=%PATH%;C:\Windows\System32\WindowsPowerShell\v1.0\"
)
)
:: Start the application :: Start the application
node dist/index.js node dist/index.js
pause pause
+20 -2
View File
@@ -638,9 +638,27 @@ export class BotInstance extends EventEmitter {
} }
private async cmdAlbum(cmd: ParsedCommand): Promise<string> { 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 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"; if (songs.length === 0) return "Album is empty or not found";
this.queue.clear(); this.queue.clear();
+2 -2
View File
@@ -112,12 +112,12 @@ export class NeteaseProvider implements MusicProvider {
params: { params: {
keywords: query, keywords: query,
type: 1000, type: 1000,
limit: 5, limit: 10,
...this.cookieParams, ...this.cookieParams,
}, },
}), }),
this.api.get("/cloudsearch", { this.api.get("/cloudsearch", {
params: { keywords: query, type: 10, limit: 5, ...this.cookieParams }, params: { keywords: query, type: 10, limit: 10, ...this.cookieParams },
}), }),
]); ]);
+115 -9
View File
@@ -12,14 +12,25 @@ import type {
} from "./provider.js"; } from "./provider.js";
import { parseLyrics } from "./netease.js"; import { parseLyrics } from "./netease.js";
// Direct QQ Music API client — bypasses the local API server for search // Primary search client: u.y.qq.com/cgi-bin/musicu.fcg (JSON sub-request
// because @sansenjian/qq-music-api still uses the broken c.y.qq.com endpoint. // batch). Was broken ca. 2026-05 due to two upstream API changes:
const qqDirectApi = axios.create({ // 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", baseURL: "https://u.y.qq.com",
timeout: 10000, timeout: 10000,
headers: { referer: "https://y.qq.com" }, 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). // Direct client for c.y.qq.com endpoints (collected playlists / favorites).
// The bundled qq-music-api wrapper doesn't expose these endpoints. // The bundled qq-music-api wrapper doesn't expose these endpoints.
const qqFavApi = axios.create({ const qqFavApi = axios.create({
@@ -82,25 +93,55 @@ export class QQMusicProvider implements MusicProvider {
} }
async search(query: string, limit = 20): Promise<SearchResult> { async search(query: string, limit = 20): Promise<SearchResult> {
// 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;
// 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({ const reqData = JSON.stringify({
req_0: { req_0: {
module: "music.search.SearchCgiService", module: "music.search.SearchCgiService",
method: "DoSearchForQQMusicDesktop", method: "DoSearchForQQMusicDesktop",
param: { searchid: "1", query, num_per_page: Math.min(limit, 50), search_type: 0 }, param: { query, num_per_page: numPerPage, search_type: 0 },
}, },
req_album: { req_album: {
module: "music.search.SearchCgiService", module: "music.search.SearchCgiService",
method: "DoSearchForQQMusicDesktop", method: "DoSearchForQQMusicDesktop",
param: { searchid: "1", query, num_per_page: 5, search_type: 8 }, 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 qqDirectApi.get("/cgi-bin/musicu.fcg", { const res = await qqMusicuApi.get("/cgi-bin/musicu.fcg", {
params: { format: "json", data: reqData }, params: { format: "json", data: reqData },
}); });
const list: any[] =
res.data?.req_0?.data?.body?.song?.list ?? [];
const songs: Song[] = list.map((s: any) => ({ 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), id: String(s.mid ?? s.id),
name: s.title ?? s.name ?? "", name: s.title ?? s.name ?? "",
artist: (s.singer ?? []).map((a: any) => a.name).join(" / "), artist: (s.singer ?? []).map((a: any) => a.name).join(" / "),
@@ -115,6 +156,71 @@ export class QQMusicProvider implements MusicProvider {
const albumList: any[] = res.data?.req_album?.data?.body?.album?.list ?? []; const albumList: any[] = res.data?.req_album?.data?.body?.album?.list ?? [];
const albums = mapQqAlbums(albumList); 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.albumname ?? s.album?.name ?? "",
duration: s.interval ?? 0,
coverUrl: s.albummid
? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${s.albummid}.jpg`
: "",
platform: "qq",
}));
const albumList: any[] =
albumRes.status === "fulfilled"
? (albumRes.value.data?.data?.album?.list ?? [])
: [];
const albums = mapQqAlbums(albumList);
return { songs, playlists: [], albums }; return { songs, playlists: [], albums };
} }
+219 -23
View File
@@ -20,42 +20,89 @@
<div v-if="loading" class="loading">搜索中...</div> <div v-if="loading" class="loading">搜索中...</div>
<template v-else-if="songs.length || albums.length || playlists.length"> <template v-else-if="allSongs.length || allAlbums.length || allPlaylists.length">
<section v-if="albums.length" class="result-section"> <div class="source-bar">
<h2 class="section-title">专辑</h2> <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"> <div class="card-grid">
<router-link <router-link
v-for="al in albums" v-for="al in filteredAlbums"
:key="`${al.platform}-${al.id}`" :key="`${al.platform}-${al.id}`"
:to="`/album/${al.id}?platform=${al.platform}`" :to="`/album/${al.id}?platform=${al.platform}`"
class="card hover-scale" class="card hover-scale"
> >
<CoverArt :url="al.coverUrl" :size="160" :radius="10" :show-shadow="true" /> <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> <div class="card-sub">{{ al.artist }}</div>
</router-link> </router-link>
</div> </div>
</section> </section>
<section v-if="playlists.length" class="result-section"> <section v-if="activeTab === 'playlists' && filteredPlaylists.length" class="result-section">
<h2 class="section-title">歌单</h2>
<div class="card-grid"> <div class="card-grid">
<router-link <router-link
v-for="pl in playlists" v-for="pl in filteredPlaylists"
:key="`${pl.platform}-${pl.id}`" :key="`${pl.platform}-${pl.id}`"
:to="`/playlist/${pl.id}?platform=${pl.platform}`" :to="`/playlist/${pl.id}?platform=${pl.platform}`"
class="card hover-scale" class="card hover-scale"
> >
<CoverArt :url="pl.coverUrl" :size="160" :radius="10" :show-shadow="true" /> <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> </router-link>
</div> </div>
</section> </section>
<section v-if="songs.length" class="result-section"> <section v-if="activeTab === 'songs' && filteredSongs.length" class="result-section">
<h2 class="section-title">单曲</h2>
<SongCard <SongCard
v-for="(song, i) in songs" v-for="(song, i) in filteredSongs"
:key="`${song.platform}-${song.id}`" :key="`${song.platform}-${song.id}`"
:song="song" :song="song"
:index="i + 1" :index="i + 1"
@@ -72,8 +119,8 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue'; import { ref, computed, watch, onMounted } from 'vue';
import { useRoute } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import { Icon } from '@iconify/vue'; import { Icon } from '@iconify/vue';
import axios from 'axios'; import axios from 'axios';
import { usePlayerStore } from '../stores/player.js'; import { usePlayerStore } from '../stores/player.js';
@@ -83,34 +130,87 @@ import CoverArt from '../components/CoverArt.vue';
const store = usePlayerStore(); const store = usePlayerStore();
const route = useRoute(); 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 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 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; } interface Playlist { id: string; name: string; coverUrl: string; songCount?: number; platform: string; }
const songs = ref<Song[]>([]); const allSongs = ref<Song[]>([]);
const albums = ref<Album[]>([]); const allAlbums = ref<Album[]>([]);
const playlists = ref<Playlist[]>([]); const allPlaylists = ref<Playlist[]>([]);
const loading = ref(false); const loading = ref(false);
const searched = 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() { async function doSearch() {
if (!query.value.trim()) return; if (!query.value.trim()) return;
loading.value = true; loading.value = true;
searched.value = true; searched.value = true;
activeTab.value = 'songs';
router.replace({ query: { q: query.value } });
try { try {
const res = await axios.get('/api/music/search/all', { params: { q: query.value } }); const res = await axios.get('/api/music/search/all', { params: { q: query.value } });
songs.value = res.data.songs ?? []; allSongs.value = res.data.songs ?? [];
albums.value = res.data.albums ?? []; allAlbums.value = res.data.albums ?? [];
playlists.value = res.data.playlists ?? []; allPlaylists.value = res.data.playlists ?? [];
} catch { } catch {
songs.value = []; albums.value = []; playlists.value = []; allSongs.value = []; allAlbums.value = []; allPlaylists.value = [];
} finally { } finally {
loading.value = false; 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(() => { onMounted(() => {
if (query.value) doSearch(); if (query.value) doSearch();
}); });
@@ -180,14 +280,80 @@ onMounted(() => {
gap: 2px; 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 { .result-section {
margin-bottom: 32px; margin-bottom: 32px;
.section-title { font-size: 18px; margin: 0 0 12px; opacity: 0.85; }
} }
.card-grid { .card-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 16px; gap: 16px 28px;
} }
.card { .card {
display: flex; display: flex;
@@ -198,4 +364,34 @@ onMounted(() => {
.card-name { font-size: 14px; line-height: 1.3; max-height: 2.6em; overflow: hidden; } .card-name { font-size: 14px; line-height: 1.3; max-height: 2.6em; overflow: hidden; }
.card-sub { font-size: 12px; opacity: 0.6; } .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> </style>