feat: make frontend fully support multiple independent bots

Store queue and timing state per-bot instead of globally, move bot
selector from Home page into Navbar dropdown, show bot name badge
in the player bar, and route WebSocket stateChange queues per-bot.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
saopig1andClaude Opus 4.6 committed 2026-03-30 20:45:24 +08:00
1 parent 75d359f65f
commit bc9b2366d0
6 files changed
+316 -95

No files matched your search

+174 -2
View File
@@ -9,9 +9,36 @@
</div>
<div class="nav-right">
<div v-if="activeBot" class="bot-status" :class="{ online: activeBot.connected }">
<!-- Multi-bot selector -->
<div v-if="store.bots.length > 1" 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>
<Icon icon="mdi:chevron-down" class="bot-chevron" :class="{ rotated: dropdownOpen }" />
</button>
<div v-if="dropdownOpen" class="bot-dropdown">
<button
v-for="bot in store.bots"
:key="bot.id"
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>
</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>
@@ -20,12 +47,33 @@
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { computed, ref, onMounted, onUnmounted } from 'vue';
import { Icon } from '@iconify/vue';
import { usePlayerStore } from '../stores/player.js';
const store = usePlayerStore();
const activeBot = computed(() => store.activeBot);
const dropdownOpen = ref(false);
const selectorRef = ref<HTMLElement | null>(null);
function selectBot(id: string) {
store.setActiveBotId(id);
dropdownOpen.value = false;
}
function onClickOutside(e: MouseEvent) {
if (selectorRef.value && !selectorRef.value.contains(e.target as Node)) {
dropdownOpen.value = false;
}
}
onMounted(() => {
document.addEventListener('click', onClickOutside);
});
onUnmounted(() => {
document.removeEventListener('click', onClickOutside);
});
</script>
<style lang="scss" scoped>
@@ -89,6 +137,130 @@ const activeBot = computed(() => store.activeBot);
}
}
/* Bot selector dropdown */
.bot-selector {
position: relative;
}
.bot-selector-btn {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 14px;
background: var(--hover-bg);
border-radius: var(--radius-sm);
font-size: 13px;
font-weight: 500;
transition: background var(--transition-fast);
cursor: pointer;
&:hover {
background: var(--bg-card);
}
}
.bot-chevron {
font-size: 16px;
opacity: 0.5;
transition: transform 0.2s ease;
&.rotated {
transform: rotate(180deg);
}
}
.bot-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--text-tertiary);
flex-shrink: 0;
&.online {
background: #22c55e;
}
}
.bot-selector-name {
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.bot-dropdown {
position: absolute;
top: calc(100% + 6px);
right: 0;
min-width: 200px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
padding: 4px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
z-index: 200;
}
.bot-dropdown-item {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 8px 12px;
border-radius: var(--radius-sm);
font-size: 13px;
cursor: pointer;
transition: background var(--transition-fast);
&:hover {
background: var(--hover-bg);
}
&.active {
background: rgba(51, 94, 234, 0.12);
color: var(--color-primary);
}
}
.bot-dropdown-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.bot-playing-badge,
.bot-paused-badge,
.bot-idle-badge,
.bot-offline-badge {
font-size: 11px;
padding: 1px 6px;
border-radius: 4px;
font-weight: 500;
flex-shrink: 0;
}
.bot-playing-badge {
background: rgba(34, 197, 94, 0.15);
color: #22c55e;
}
.bot-paused-badge {
background: rgba(234, 179, 8, 0.15);
color: #eab308;
}
.bot-idle-badge {
background: rgba(51, 94, 234, 0.12);
color: var(--color-primary);
}
.bot-offline-badge {
background: var(--hover-bg);
color: var(--text-tertiary);
}
.settings-btn {
font-size: 20px;
opacity: 0.6;