feat: implement server password login, YouTube source, and improved bot selector UI

- **TS server password** (#7/#9): add serverPassword field across database,
  manager, bot API, and Settings UI — allows joining password-protected servers
- **YouTube audio source** (#1/#10): new YouTubeProvider using yt-dlp binary;
  adds -y flag in chat commands, /api/music supports platform=youtube,
  YouTube badge in SongCard, yt-dlp-wrap npm dependency
- **Bot selector UI** (#14): selector always visible (not just when >1 bot),
  bigger button with border and play-state indicator; per-bot URL routing at
  /bot/:id with BotRedirect view; copy-link button in dropdown

Closes #1, #7, #9, #10, #14

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
saopig1andClaude Sonnet 4.6 committed 2026-04-09 11:58:07 +08:00
1 parent b20ddf2603
commit d5d6abeb1e
16 files changed
+391 -40

No files matched your search

+9 -1
View File
@@ -25,7 +25,8 @@
"pino": "^10.3.1",
"ts3-nodejs-library": "^3.5.1",
"tweetnacl": "^1.0.3",
"ws": "^8.20.0"
"ws": "^8.20.0",
"yt-dlp-wrap": "^2.3.12"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.13",
@@ -6268,6 +6269,13 @@
"engines": {
"node": ">= 4.0.0"
}
},
"node_modules/yt-dlp-wrap": {
"version": "2.3.12",
"resolved": "https://registry.npmjs.org/yt-dlp-wrap/-/yt-dlp-wrap-2.3.12.tgz",
"integrity": "sha512-P8fJ+6M1YjukyJENCTviNLiZ8mokxprR54ho3DsSKPWDcac489OjRiStGEARJr6un6ETS6goTn4CWl/b/rM3aA==",
"deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
"license": "MIT"
}
}
}
+2 -1
View File
@@ -30,7 +30,8 @@
"pino": "^10.3.1",
"ts3-nodejs-library": "^3.5.1",
"tweetnacl": "^1.0.3",
"ws": "^8.20.0"
"ws": "^8.20.0",
"yt-dlp-wrap": "^2.3.12"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.13",
+1 -1
View File
@@ -10,7 +10,7 @@ export interface QueuedSong {
name: string;
artist: string;
album: string;
platform: "netease" | "qq" | "bilibili";
platform: "netease" | "qq" | "bilibili" | "youtube";
url?: string; // resolved lazily at play time
coverUrl: string;
duration: number; // seconds
+7 -1
View File
@@ -23,6 +23,7 @@ export interface BotInstanceOptions {
neteaseProvider: MusicProvider;
qqProvider: MusicProvider;
bilibiliProvider: MusicProvider;
youtubeProvider: MusicProvider;
database: BotDatabase;
config: BotConfig;
logger: Logger;
@@ -51,6 +52,7 @@ export class BotInstance extends EventEmitter {
private neteaseProvider: MusicProvider;
private qqProvider: MusicProvider;
private bilibiliProvider: MusicProvider;
private youtubeProvider: MusicProvider;
private database: BotDatabase;
private config: BotConfig;
private logger: Logger;
@@ -65,6 +67,7 @@ export class BotInstance extends EventEmitter {
this.neteaseProvider = options.neteaseProvider;
this.qqProvider = options.qqProvider;
this.bilibiliProvider = options.bilibiliProvider;
this.youtubeProvider = options.youtubeProvider;
this.database = options.database;
this.config = options.config;
this.logger = options.logger.child({ botId: this.id });
@@ -217,14 +220,16 @@ export class BotInstance extends EventEmitter {
}
}
getProviderFor(platform: "netease" | "qq" | "bilibili"): MusicProvider {
getProviderFor(platform: "netease" | "qq" | "bilibili" | "youtube"): MusicProvider {
if (platform === "bilibili") return this.bilibiliProvider;
if (platform === "youtube") return this.youtubeProvider;
return platform === "qq" ? this.qqProvider : this.neteaseProvider;
}
private getProvider(flags: Set<string>): MusicProvider {
if (flags.has("b")) return this.bilibiliProvider;
if (flags.has("q")) return this.qqProvider;
if (flags.has("y")) return this.youtubeProvider;
return this.neteaseProvider;
}
@@ -477,6 +482,7 @@ export class BotInstance extends EventEmitter {
`${p}play <song> — Search and play`,
`${p}play -q <song> — Search from QQ Music`,
`${p}play -b <song> — Search from BiliBili`,
`${p}play -y <song> — Search from YouTube (yt-dlp)`,
`${p}add <song> — Add to queue`,
`${p}pause/resume — Pause/resume`,
`${p}next/prev — Next/previous`,
+13
View File
@@ -4,6 +4,7 @@ import {
type BotInstanceOptions,
} from "./instance.js";
import type { MusicProvider } from "../music/provider.js";
import { YouTubeProvider } from "../music/youtube.js";
import type { BotDatabase } from "../data/database.js";
import type { BotConfig } from "../data/config.js";
import type { Logger } from "../logger.js";
@@ -23,6 +24,8 @@ export interface CreateBotParams {
serverProtocol?: ServerProtocol;
/** API key for TS6 HTTP Query (port 10080/10443). */
ts6ApiKey?: string;
/** Password required to join the TS server. */
serverPassword?: string;
}
export class BotManager {
@@ -30,6 +33,7 @@ export class BotManager {
private neteaseProvider: MusicProvider;
private qqProvider: MusicProvider;
private bilibiliProvider: MusicProvider;
private youtubeProvider: MusicProvider;
private database: BotDatabase;
private config: BotConfig;
private logger: Logger;
@@ -45,6 +49,7 @@ export class BotManager {
this.neteaseProvider = neteaseProvider;
this.qqProvider = qqProvider;
this.bilibiliProvider = bilibiliProvider;
this.youtubeProvider = new YouTubeProvider();
this.database = database;
this.config = config;
this.logger = logger;
@@ -63,12 +68,14 @@ export class BotManager {
nickname: params.nickname,
defaultChannel: params.defaultChannel,
channelPassword: params.channelPassword,
serverPassword: params.serverPassword,
serverProtocol: params.serverProtocol,
ts6ApiKey: params.ts6ApiKey,
},
neteaseProvider: this.neteaseProvider,
qqProvider: this.qqProvider,
bilibiliProvider: this.bilibiliProvider,
youtubeProvider: this.youtubeProvider,
database: this.database,
config: this.config,
logger: this.logger,
@@ -87,6 +94,7 @@ export class BotManager {
autoStart: params.autoStart ?? false,
serverProtocol: params.serverProtocol ?? "",
ts6ApiKey: params.ts6ApiKey ?? "",
serverPassword: params.serverPassword ?? "",
});
this.logger.info({ botId: id, name: params.name }, "Bot instance created");
@@ -118,6 +126,7 @@ export class BotManager {
channelPassword: params.channelPassword ?? existing.channelPassword,
serverProtocol: params.serverProtocol ?? existing.serverProtocol,
ts6ApiKey: params.ts6ApiKey ?? existing.ts6ApiKey,
serverPassword: params.serverPassword ?? existing.serverPassword,
});
// Update in-memory name immediately (other fields need reconnect)
const bot = this.bots.get(id);
@@ -157,12 +166,14 @@ export class BotManager {
nickname: saved.nickname,
defaultChannel: saved.defaultChannel || undefined,
channelPassword: saved.channelPassword || undefined,
serverPassword: saved.serverPassword || undefined,
serverProtocol: proto === "ts3" || proto === "ts6" ? proto : undefined,
ts6ApiKey: saved.ts6ApiKey || undefined,
},
neteaseProvider: this.neteaseProvider,
qqProvider: this.qqProvider,
bilibiliProvider: this.bilibiliProvider,
youtubeProvider: this.youtubeProvider,
database: this.database,
config: this.config,
logger: this.logger,
@@ -204,12 +215,14 @@ export class BotManager {
identity: saved.identity || undefined,
defaultChannel: saved.defaultChannel || undefined,
channelPassword: saved.channelPassword || undefined,
serverPassword: saved.serverPassword || undefined,
serverProtocol: proto === "ts3" || proto === "ts6" ? proto : undefined,
ts6ApiKey: saved.ts6ApiKey || undefined,
},
neteaseProvider: this.neteaseProvider,
qqProvider: this.qqProvider,
bilibiliProvider: this.bilibiliProvider,
youtubeProvider: this.youtubeProvider,
database: this.database,
config: this.config,
logger: this.logger,
+2
View File
@@ -62,6 +62,7 @@ describe("database", () => {
autoStart: true,
serverProtocol: "",
ts6ApiKey: "",
serverPassword: "",
};
botDb.saveBotInstance(instance);
@@ -90,6 +91,7 @@ describe("database", () => {
autoStart: false,
serverProtocol: "",
ts6ApiKey: "",
serverPassword: "",
});
expect(botDb.deleteBotInstance("bot1")).toBe(true);
+11 -3
View File
@@ -6,7 +6,7 @@ export interface PlayHistoryEntry {
songName: string;
artist: string;
album: string;
platform: "netease" | "qq" | "bilibili";
platform: "netease" | "qq" | "bilibili" | "youtube";
coverUrl: string;
}
@@ -28,6 +28,8 @@ export interface BotInstance {
serverProtocol: string;
/** API key for TS6 HTTP Query */
ts6ApiKey: string;
/** Password to join the TS server (server password) */
serverPassword: string;
identity?: string;
}
@@ -53,6 +55,9 @@ function migrateSchema(db: Database.Database): void {
if (!names.includes("ts6ApiKey")) {
db.exec("ALTER TABLE bot_instances ADD COLUMN ts6ApiKey TEXT NOT NULL DEFAULT ''");
}
if (!names.includes("serverPassword")) {
db.exec("ALTER TABLE bot_instances ADD COLUMN serverPassword TEXT NOT NULL DEFAULT ''");
}
}
function initTables(db: Database.Database): void {
@@ -80,6 +85,7 @@ function initTables(db: Database.Database): void {
autoStart INTEGER NOT NULL DEFAULT 0,
serverProtocol TEXT NOT NULL DEFAULT '',
ts6ApiKey TEXT NOT NULL DEFAULT '',
serverPassword TEXT NOT NULL DEFAULT '',
identity TEXT
);
`);
@@ -101,8 +107,8 @@ export function createDatabase(dbPath: string): BotDatabase {
`);
const upsertInstance = db.prepare(`
INSERT INTO bot_instances (id, name, serverAddress, serverPort, nickname, defaultChannel, channelPassword, autoStart, serverProtocol, ts6ApiKey, identity)
VALUES (@id, @name, @serverAddress, @serverPort, @nickname, @defaultChannel, @channelPassword, @autoStart, @serverProtocol, @ts6ApiKey, @identity)
INSERT INTO bot_instances (id, name, serverAddress, serverPort, nickname, defaultChannel, channelPassword, autoStart, serverProtocol, ts6ApiKey, serverPassword, identity)
VALUES (@id, @name, @serverAddress, @serverPort, @nickname, @defaultChannel, @channelPassword, @autoStart, @serverProtocol, @ts6ApiKey, @serverPassword, @identity)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
serverAddress = excluded.serverAddress,
@@ -113,6 +119,7 @@ export function createDatabase(dbPath: string): BotDatabase {
autoStart = excluded.autoStart,
serverProtocol = excluded.serverProtocol,
ts6ApiKey = excluded.ts6ApiKey,
serverPassword = excluded.serverPassword,
identity = excluded.identity
`);
@@ -148,6 +155,7 @@ export function createDatabase(dbPath: string): BotDatabase {
autoStart: r.autoStart === 1,
serverProtocol: r.serverProtocol ?? "",
ts6ApiKey: r.ts6ApiKey ?? "",
serverPassword: r.serverPassword ?? "",
identity: r.identity ?? undefined,
}));
},
+4 -4
View File
@@ -5,7 +5,7 @@ export interface Song {
album: string;
duration: number; // seconds
coverUrl: string;
platform: "netease" | "qq" | "bilibili";
platform: "netease" | "qq" | "bilibili" | "youtube";
}
export interface SongWithUrl extends Song {
@@ -17,7 +17,7 @@ export interface Playlist {
name: string;
coverUrl: string;
songCount: number;
platform: "netease" | "qq" | "bilibili";
platform: "netease" | "qq" | "bilibili" | "youtube";
}
export interface Album {
@@ -26,7 +26,7 @@ export interface Album {
artist: string;
coverUrl: string;
songCount: number;
platform: "netease" | "qq" | "bilibili";
platform: "netease" | "qq" | "bilibili" | "youtube";
}
export interface LyricLine {
@@ -54,7 +54,7 @@ export interface AuthStatus {
}
export interface MusicProvider {
readonly platform: "netease" | "qq" | "bilibili";
readonly platform: "netease" | "qq" | "bilibili" | "youtube";
search(query: string, limit?: number): Promise<SearchResult>;
getSongUrl(songId: string, quality?: string): Promise<string | null>;
+184
View File
@@ -0,0 +1,184 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { existsSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import type {
MusicProvider,
Song,
SongWithUrl,
Playlist,
Album,
SearchResult,
LyricLine,
QrCodeResult,
AuthStatus,
} from "./provider.js";
const execFileAsync = promisify(execFile);
const __dirname = dirname(fileURLToPath(import.meta.url));
/** Resolve the yt-dlp binary path. Checks the project bin/ dir first, then PATH. */
function findYtDlp(): string {
const candidates = [
join(__dirname, "..", "..", "bin", "yt-dlp.exe"),
join(__dirname, "..", "..", "bin", "yt-dlp"),
"yt-dlp",
"yt-dlp.exe",
];
for (const c of candidates) {
if (!c.includes(join("bin", "yt-dlp")) || existsSync(c)) return c;
// For PATH entries (no directory prefix), fall through to let execFile try
}
return "yt-dlp";
}
async function runYtDlp(args: string[], timeoutMs = 30_000): Promise<string> {
const binary = findYtDlp();
const env = { ...process.env };
if (process.env.HTTPS_PROXY || process.env.HTTP_PROXY) {
// yt-dlp respects these env vars natively
}
const { stdout } = await execFileAsync(binary, args, {
timeout: timeoutMs,
env,
maxBuffer: 10 * 1024 * 1024,
});
return stdout;
}
interface YtDlpEntry {
id: string;
title: string;
uploader?: string;
channel?: string;
duration?: number;
thumbnail?: string;
webpage_url?: string;
url?: string;
entries?: YtDlpEntry[];
_type?: string;
}
function entryToSong(entry: YtDlpEntry): Song {
return {
id: entry.id ?? "",
name: entry.title ?? "Unknown",
artist: entry.uploader ?? entry.channel ?? "YouTube",
album: "YouTube",
duration: Math.round(entry.duration ?? 0),
coverUrl: entry.thumbnail ?? "",
platform: "youtube",
};
}
export class YouTubeProvider implements MusicProvider {
readonly platform = "youtube" as const;
private quality = "bestaudio";
async search(query: string, limit = 5): Promise<SearchResult> {
try {
const raw = await runYtDlp([
`ytsearch${limit}:${query}`,
"--dump-json",
"--flat-playlist",
"--no-warnings",
"--quiet",
]);
const lines = raw.trim().split("\n").filter(Boolean);
const songs: Song[] = lines.map((line) => {
const entry = JSON.parse(line) as YtDlpEntry;
return entryToSong(entry);
});
return { songs, playlists: [], albums: [] };
} catch {
return { songs: [], playlists: [], albums: [] };
}
}
async getSongUrl(songId: string): Promise<string | null> {
try {
const url = `https://www.youtube.com/watch?v=${songId}`;
const raw = await runYtDlp([
url,
"--get-url",
"-f",
"bestaudio[ext=webm]/bestaudio[ext=m4a]/bestaudio",
"--no-warnings",
"--quiet",
], 45_000);
const audioUrl = raw.trim().split("\n")[0];
return audioUrl || null;
} catch {
return null;
}
}
setQuality(quality: string): void {
this.quality = quality;
}
getQuality(): string {
return this.quality;
}
async getSongDetail(songId: string): Promise<Song | null> {
try {
const url = `https://www.youtube.com/watch?v=${songId}`;
const raw = await runYtDlp([url, "--dump-json", "--no-warnings", "--quiet"]);
const entry = JSON.parse(raw.trim()) as YtDlpEntry;
return entryToSong(entry);
} catch {
return null;
}
}
async getPlaylistSongs(playlistId: string): Promise<Song[]> {
try {
const url = playlistId.startsWith("http")
? playlistId
: `https://www.youtube.com/playlist?list=${playlistId}`;
const raw = await runYtDlp([
url,
"--dump-json",
"--flat-playlist",
"--no-warnings",
"--quiet",
], 60_000);
const lines = raw.trim().split("\n").filter(Boolean);
return lines.map((line) => entryToSong(JSON.parse(line) as YtDlpEntry));
} catch {
return [];
}
}
async getRecommendPlaylists(): Promise<Playlist[]> {
return [];
}
async getAlbumSongs(_albumId: string): Promise<Song[]> {
return [];
}
async getLyrics(_songId: string): Promise<LyricLine[]> {
return [];
}
async getQrCode(): Promise<QrCodeResult> {
return { qrUrl: "", key: "" };
}
async checkQrCodeStatus(
_key: string
): Promise<"waiting" | "scanned" | "confirmed" | "expired"> {
return "expired";
}
setCookie(_cookie: string): void {}
getCookie(): string { return ""; }
async getAuthStatus(): Promise<AuthStatus> {
return { loggedIn: true, nickname: "YouTube (yt-dlp)" };
}
}
+4 -2
View File
@@ -43,6 +43,7 @@ export function createBotRouter(
nickname,
defaultChannel,
channelPassword,
serverPassword,
autoStart,
} = req.body;
if (!name || !serverAddress || !nickname) {
@@ -58,6 +59,7 @@ export function createBotRouter(
nickname,
defaultChannel,
channelPassword,
serverPassword,
autoStart: autoStart ?? false,
});
res.status(201).json(bot.getStatus());
@@ -75,10 +77,10 @@ export function createBotRouter(
res.status(404).json({ error: "Bot not found" });
return;
}
const { name, serverAddress, serverPort, nickname, defaultChannel, channelPassword } = req.body;
const { name, serverAddress, serverPort, nickname, defaultChannel, channelPassword, serverPassword } = req.body;
// Update in database
botManager.updateBot(req.params.id, {
name, serverAddress, serverPort, nickname, defaultChannel, channelPassword,
name, serverAddress, serverPort, nickname, defaultChannel, channelPassword, serverPassword,
});
res.json({ success: true });
} catch (err) {
+3
View File
@@ -1,5 +1,6 @@
import { Router } from "express";
import type { MusicProvider } from "../../music/provider.js";
import { YouTubeProvider } from "../../music/youtube.js";
import type { Logger } from "../../logger.js";
export function createMusicRouter(
@@ -9,9 +10,11 @@ export function createMusicRouter(
logger: Logger
): Router {
const router = Router();
const youtubeProvider: MusicProvider = new YouTubeProvider();
function getProvider(platform?: string): MusicProvider {
if (platform === "bilibili") return bilibiliProvider;
if (platform === "youtube") return youtubeProvider;
return platform === "qq" ? qqProvider : neteaseProvider;
}
+82 -25
View File
@@ -9,36 +9,42 @@
</div>
<div class="nav-right">
<!-- Multi-bot selector -->
<div v-if="store.bots.length > 1" class="bot-selector" ref="selectorRef">
<!-- Bot selector (always shown when at least one bot exists) -->
<div v-if="store.bots.length > 0" class="bot-selector" ref="selectorRef">
<button class="bot-selector-btn" @click="dropdownOpen = !dropdownOpen">
<span class="bot-dot" :class="{ online: activeBot?.connected }" />
<span class="bot-selector-name">{{ activeBot?.name ?? '选择机器人' }}</span>
<span v-if="activeBot?.playing && !activeBot?.paused" class="bot-state-mini playing">▶</span>
<span v-else-if="activeBot?.paused" class="bot-state-mini paused">⏸</span>
<Icon icon="mdi:chevron-down" class="bot-chevron" :class="{ rotated: dropdownOpen }" />
</button>
<div v-if="dropdownOpen" class="bot-dropdown">
<button
<div
v-for="bot in store.bots"
:key="bot.id"
class="bot-dropdown-item"
:class="{ active: bot.id === store.activeBotId }"
@click="selectBot(bot.id)"
class="bot-dropdown-row"
>
<span class="bot-dot" :class="{ online: bot.connected }" />
<span class="bot-dropdown-name">{{ bot.name }}</span>
<span v-if="bot.playing && !bot.paused" class="bot-playing-badge">播放中</span>
<span v-else-if="bot.paused" class="bot-paused-badge">已暂停</span>
<span v-else-if="bot.connected" class="bot-idle-badge">空闲</span>
<span v-else class="bot-offline-badge">离线</span>
</button>
<button
class="bot-dropdown-item"
:class="{ active: bot.id === store.activeBotId }"
@click="selectBot(bot.id)"
>
<span class="bot-dot" :class="{ online: bot.connected }" />
<span class="bot-dropdown-name">{{ bot.name }}</span>
<span v-if="bot.playing && !bot.paused" class="bot-playing-badge">播放中</span>
<span v-else-if="bot.paused" class="bot-paused-badge">已暂停</span>
<span v-else-if="bot.connected" class="bot-idle-badge">空闲</span>
<span v-else class="bot-offline-badge">离线</span>
</button>
<button class="bot-link-btn" :title="`复制 ${bot.name} 的专属链接`" @click.stop="copyBotLink(bot.id)">
<Icon icon="mdi:link-variant" />
</button>
</div>
<div class="bot-dropdown-divider" />
<div class="bot-dropdown-hint">点击切换 · 🔗 复制专属链接</div>
</div>
</div>
<!-- Single bot status (original behavior) -->
<div v-else-if="activeBot" class="bot-status" :class="{ online: activeBot.connected }">
{{ activeBot.name }} {{ activeBot.connected ? '在线' : '离线' }}
</div>
<RouterLink to="/settings" class="settings-btn">
<Icon icon="mdi:cog" />
</RouterLink>
@@ -61,6 +67,13 @@ function selectBot(id: string) {
dropdownOpen.value = false;
}
function copyBotLink(id: string) {
const url = `${window.location.origin}/bot/${id}`;
navigator.clipboard.writeText(url).then(() => {
dropdownOpen.value = false;
});
}
function onClickOutside(e: MouseEvent) {
if (selectorRef.value && !selectorRef.value.contains(e.target as Node)) {
dropdownOpen.value = false;
@@ -146,19 +159,28 @@ onUnmounted(() => {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 14px;
padding: 8px 16px;
background: var(--hover-bg);
border-radius: var(--radius-sm);
font-size: 13px;
font-weight: 500;
transition: background var(--transition-fast);
border-radius: var(--radius-md);
font-size: 14px;
font-weight: 600;
min-height: 36px;
border: 1px solid var(--border-color);
transition: background var(--transition-fast), border-color var(--transition-fast);
cursor: pointer;
&:hover {
background: var(--bg-card);
border-color: var(--color-primary);
}
}
.bot-state-mini {
font-size: 11px;
&.playing { color: #22c55e; }
&.paused { color: #eab308; }
}
.bot-chevron {
font-size: 16px;
opacity: 0.5;
@@ -205,7 +227,8 @@ onUnmounted(() => {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
flex: 1;
min-width: 0;
padding: 8px 12px;
border-radius: var(--radius-sm);
font-size: 13px;
@@ -222,6 +245,12 @@ onUnmounted(() => {
}
}
.bot-dropdown-row {
display: flex;
align-items: center;
gap: 2px;
}
.bot-dropdown-name {
flex: 1;
min-width: 0;
@@ -230,6 +259,21 @@ onUnmounted(() => {
white-space: nowrap;
}
.bot-link-btn {
flex-shrink: 0;
padding: 6px 8px;
border-radius: var(--radius-sm);
font-size: 15px;
opacity: 0.4;
transition: opacity var(--transition-fast), background var(--transition-fast);
cursor: pointer;
&:hover {
opacity: 1;
background: var(--hover-bg);
}
}
.bot-playing-badge,
.bot-paused-badge,
.bot-idle-badge,
@@ -261,8 +305,21 @@ onUnmounted(() => {
color: var(--text-tertiary);
}
.bot-dropdown-divider {
height: 1px;
background: var(--border-color);
margin: 4px 0;
}
.bot-dropdown-hint {
padding: 4px 12px 6px;
font-size: 11px;
color: var(--text-tertiary);
text-align: center;
}
.settings-btn {
font-size: 20px;
font-size: 22px;
opacity: 0.6;
transition: opacity var(--transition-fast);
&:hover { opacity: 1; }
+7 -2
View File
@@ -7,8 +7,8 @@
<span class="song-name">{{ song.name }}</span>
<span
class="platform-badge"
:class="song.platform === 'bilibili' ? 'badge-bilibili' : song.platform === 'qq' ? 'badge-qq' : 'badge-netease'"
>{{ song.platform === 'bilibili' ? 'B站' : song.platform === 'qq' ? 'QQ' : '网易云' }}</span>
:class="song.platform === 'bilibili' ? 'badge-bilibili' : song.platform === 'qq' ? 'badge-qq' : song.platform === 'youtube' ? 'badge-youtube' : 'badge-netease'"
>{{ song.platform === 'bilibili' ? 'B站' : song.platform === 'qq' ? 'QQ' : song.platform === 'youtube' ? 'YouTube' : '网易云' }}</span>
</div>
<div class="song-artist">{{ song.artist }}</div>
</div>
@@ -118,6 +118,11 @@ function formatDuration(seconds: number): string {
color: #00a1d6;
}
.badge-youtube {
background: rgba(255, 0, 0, 0.12);
color: #ff0000;
}
.song-artist {
font-size: 12px;
color: var(--text-secondary);
+6
View File
@@ -38,6 +38,12 @@ const router = createRouter({
name: 'setup',
component: () => import('../views/Setup.vue'),
},
{
// Per-bot URL: /bot/:id — sets active bot then redirects to home
path: '/bot/:id',
name: 'bot',
component: () => import('../views/BotRedirect.vue'),
},
],
});
+42
View File
@@ -0,0 +1,42 @@
<template>
<div class="redirect-page">
<span v-if="notFound">机器人不存在或未加载</span>
<span v-else>正在跳转...</span>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { usePlayerStore } from '../stores/player.js';
const route = useRoute();
const router = useRouter();
const store = usePlayerStore();
const notFound = ref(false);
onMounted(async () => {
const botId = route.params.id as string;
if (!store.bots.length) {
await store.fetchBots();
}
const bot = store.bots.find((b) => b.id === botId);
if (bot) {
store.setActiveBotId(botId);
router.replace('/');
} else {
notFound.value = true;
}
});
</script>
<style scoped>
.redirect-page {
display: flex;
align-items: center;
justify-content: center;
height: 60vh;
font-size: 16px;
color: var(--text-secondary);
}
</style>
+14
View File
@@ -76,6 +76,10 @@
<label>频道密码(可选)</label>
<input v-model="editForm.channelPassword" class="input" type="password" />
</div>
<div class="form-group">
<label>服务器密码(可选)</label>
<input v-model="editForm.serverPassword" class="input" type="password" placeholder="服务器有密码时填写" />
</div>
<div class="modal-actions">
<button class="btn-secondary" @click="editingBot = null">取消</button>
<button class="btn-primary" @click="saveEditBot">保存(需重启机器人生效)</button>
@@ -108,6 +112,10 @@
<label>默认频道(可选)</label>
<input v-model="newBotChannel" class="input" placeholder="音乐频道" />
</div>
<div class="form-group">
<label>服务器密码(可选)</label>
<input v-model="newBotServerPassword" class="input" type="password" placeholder="服务器有密码时填写" />
</div>
<button class="btn-primary" @click="createBot">创建</button>
</div>
</section>
@@ -394,6 +402,7 @@ const newBotServer = ref('');
const newBotPort = ref(9987);
const newBotNickname = ref('MusicBot');
const newBotChannel = ref('');
const newBotServerPassword = ref('');
// Edit bot
const editingBot = ref<string | null>(null);
@@ -404,6 +413,7 @@ const editForm = reactive({
nickname: '',
defaultChannel: '',
channelPassword: '',
serverPassword: '',
});
const neteaseCookie = ref('');
@@ -559,6 +569,7 @@ async function createBot() {
serverPort: newBotPort.value || 9987,
nickname: newBotNickname.value || newBotName.value,
defaultChannel: newBotChannel.value || undefined,
serverPassword: newBotServerPassword.value || undefined,
autoStart: false,
});
newBotName.value = '';
@@ -566,6 +577,7 @@ async function createBot() {
newBotPort.value = 9987;
newBotNickname.value = 'MusicBot';
newBotChannel.value = '';
newBotServerPassword.value = '';
await store.fetchBots();
} catch {
// Ignore
@@ -598,6 +610,7 @@ async function openEditBot(bot: any) {
editForm.nickname = res.data.nickname ?? '';
editForm.defaultChannel = res.data.defaultChannel ?? '';
editForm.channelPassword = res.data.channelPassword ?? '';
editForm.serverPassword = res.data.serverPassword ?? '';
} catch {
// Config not found — use defaults
editForm.serverAddress = '';
@@ -605,6 +618,7 @@ async function openEditBot(bot: any) {
editForm.nickname = bot.name;
editForm.defaultChannel = '';
editForm.channelPassword = '';
editForm.serverPassword = '';
}
}