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

+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 = '';
}
}