mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-01 20:42:50 +08:00
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:
1 parent
b20ddf2603
commit
d5d6abeb1e
16 files changed
+391
-40
No files matched your search
@@ -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,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);
|
||||
|
||||
@@ -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'),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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 = '';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user