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);