mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-01 20:42:50 +08:00
Merge PR #82: feat(scope) lock UI to a bot via dedicated link
This commit is contained in:
commit
d1544bab42
10 files changed
+401
-8
No files matched your search
+10
-2
@@ -140,12 +140,20 @@ function cycleMobileMode() {
|
||||
playerStore.setMode(nextMode);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
onMounted(async () => {
|
||||
playerStore.loadTheme();
|
||||
connect();
|
||||
playerStore.fetchBots();
|
||||
syncTimer = setInterval(() => playerStore.syncElapsed(), 3000);
|
||||
mobileRaf = requestAnimationFrame(updateMobileProgress);
|
||||
// Reconcile the dedicated-link scope only after the bot list is known: the
|
||||
// router guard sets scopedBotId tentatively from ?bot, but applyScopeFromQuery
|
||||
// validates it against the loaded bots (locks if it exists, clears if stale).
|
||||
await playerStore.fetchBots();
|
||||
// Read from the authoritative current route (not a possibly-stale reactive
|
||||
// snapshot) so the scope reconciles against the ?bot present at refresh time.
|
||||
const routeBot = router.currentRoute.value.query.bot;
|
||||
const qBot = typeof routeBot === 'string' ? routeBot : null;
|
||||
playerStore.applyScopeFromQuery(qBot);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
|
||||
@@ -10,8 +10,20 @@
|
||||
</div>
|
||||
|
||||
<div class="nav-right">
|
||||
<!-- Bot selector (always shown when at least one bot exists) -->
|
||||
<div v-if="store.bots.length > 0" class="bot-selector" ref="selectorRef">
|
||||
<!-- Scoped (dedicated link): static label locked to the one bot, no switching -->
|
||||
<div v-if="store.isScoped" class="bot-selector scoped" ref="selectorRef">
|
||||
<div class="bot-selector-btn static">
|
||||
<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>
|
||||
<span class="scope-badge">专属模式</span>
|
||||
</div>
|
||||
<button class="scope-exit-btn" @click="exitScope" title="退出专属模式">退出</button>
|
||||
</div>
|
||||
|
||||
<!-- Normal: full selector with switching (shown when at least one bot exists) -->
|
||||
<div v-else-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>
|
||||
@@ -22,7 +34,7 @@
|
||||
<div v-if="dropdownOpen" class="bot-dropdown">
|
||||
<div class="bot-dropdown-header">机器人</div>
|
||||
<div
|
||||
v-for="bot in store.bots"
|
||||
v-for="bot in displayedBots"
|
||||
:key="bot.id"
|
||||
class="bot-card"
|
||||
:class="{ active: bot.id === store.activeBotId }"
|
||||
@@ -138,6 +150,11 @@ async function onLogout() {
|
||||
navRouter.replace({ name: 'login' });
|
||||
}
|
||||
const activeBot = computed(() => store.activeBot);
|
||||
// While scoped (dedicated link), the selector is locked to the single scoped
|
||||
// bot; otherwise the full list is shown and switching is allowed.
|
||||
const displayedBots = computed(() =>
|
||||
store.isScoped ? store.bots.filter((b) => b.id === store.scopedBotId) : store.bots,
|
||||
);
|
||||
const dropdownOpen = ref(false);
|
||||
const selectorRef = ref<HTMLElement | null>(null);
|
||||
const togglingBots = ref<Record<string, boolean>>({});
|
||||
@@ -156,6 +173,14 @@ function selectBot(id: string) {
|
||||
dropdownOpen.value = false;
|
||||
}
|
||||
|
||||
// Leave dedicated-link mode. Clear scope BEFORE navigating so the router guard
|
||||
// (which re-attaches ?bot from scopedBotId) sees a null scope and lets us out.
|
||||
function exitScope() {
|
||||
store.clearScope();
|
||||
dropdownOpen.value = false;
|
||||
navRouter.push('/');
|
||||
}
|
||||
|
||||
function resolveBaseUrl(): string {
|
||||
const base = publicBaseUrl.value;
|
||||
if (base && /^https?:\/\//i.test(base)) return base.replace(/\/+$/, '');
|
||||
@@ -370,6 +395,60 @@ onUnmounted(() => {
|
||||
}
|
||||
}
|
||||
|
||||
/* Scoped (dedicated-link) selector: locked, non-interactive label + exit */
|
||||
.bot-selector.scoped {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.bot-selector-btn.static {
|
||||
cursor: default;
|
||||
|
||||
&:hover {
|
||||
background: var(--hover-bg);
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
}
|
||||
|
||||
.scope-badge {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
color: var(--color-primary);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
background: var(--color-primary-15);
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.scope-exit-btn {
|
||||
padding: 8px 14px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--hover-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: background var(--transition-fast), border-color var(--transition-fast);
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-card);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
padding: 6px 10px;
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
.bot-state-mini {
|
||||
font-size: 14px;
|
||||
&.playing { color: var(--color-online); }
|
||||
|
||||
+7
-1
@@ -11,4 +11,10 @@ installApiClient();
|
||||
const app = createApp(App);
|
||||
app.use(createPinia());
|
||||
app.use(router);
|
||||
app.mount('#app');
|
||||
// Wait for the initial navigation (and the beforeEach guard that reads ?bot)
|
||||
// to fully resolve before mounting, so the reactive route query is populated
|
||||
// when App.onMounted runs and the dedicated-bot scope locks the right bot.
|
||||
// .catch keeps parity with the old unconditional mount: if the initial
|
||||
// navigation errors (e.g. a transient network failure in the auth guard),
|
||||
// still render the shell rather than leaving a blank page.
|
||||
router.isReady().catch(() => {}).then(() => app.mount('#app'));
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router';
|
||||
import { useSession } from '../composables/useSession.js';
|
||||
import { usePlayerStore } from '../stores/player.js';
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
@@ -56,6 +57,22 @@ router.beforeEach(async (to) => {
|
||||
if (!session.isAuthenticated.value) {
|
||||
return { name: 'login', query: { next: to.fullPath } };
|
||||
}
|
||||
|
||||
// Navigation is allowed to proceed to `to` past here (auth/setup redirects above take precedence).
|
||||
// Sync + preserve the dedicated-link scope carried by ?bot.
|
||||
const store = usePlayerStore();
|
||||
const qBot = typeof to.query.bot === 'string' && to.query.bot ? to.query.bot : null;
|
||||
if (qBot) {
|
||||
// URL carries a scope — set tentatively; App.vue's applyScopeFromQuery (after fetchBots) validates/clears it.
|
||||
store.scopedBotId = qBot;
|
||||
return true;
|
||||
}
|
||||
if (store.scopedBotId) {
|
||||
// scoped, but this navigation dropped ?bot → re-attach so the lock survives in-app nav + refresh.
|
||||
if (to.query.bot !== store.scopedBotId) {
|
||||
return { path: to.path, query: { ...to.query, bot: store.scopedBotId }, hash: to.hash };
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import axios from 'axios';
|
||||
import { resolveScopedBot } from './scope.js';
|
||||
|
||||
export interface Song {
|
||||
id: string;
|
||||
@@ -50,6 +51,9 @@ export const usePlayerStore = defineStore('player', {
|
||||
state: () => ({
|
||||
bots: [] as BotStatus[],
|
||||
activeBotId: null as string | null,
|
||||
/** When set, the UI is locked to a single bot (dedicated link, from ?bot).
|
||||
* Source of truth is the URL — never persisted to localStorage. */
|
||||
scopedBotId: null as string | null,
|
||||
/** Per-bot queues keyed by botId */
|
||||
queues: {} as Record<string, Song[]>,
|
||||
/** Per-bot timing state keyed by botId */
|
||||
@@ -73,6 +77,10 @@ export const usePlayerStore = defineStore('player', {
|
||||
activeBot(): BotStatus | null {
|
||||
return this.bots.find((b) => b.id === this.activeBotId) ?? this.bots[0] ?? null;
|
||||
},
|
||||
/** True when the UI is locked to a single bot via a dedicated link. */
|
||||
isScoped(): boolean {
|
||||
return this.scopedBotId !== null;
|
||||
},
|
||||
currentSong(): Song | null {
|
||||
return this.activeBot?.currentSong ?? null;
|
||||
},
|
||||
@@ -125,6 +133,8 @@ export const usePlayerStore = defineStore('player', {
|
||||
},
|
||||
|
||||
setActiveBotId(id: string) {
|
||||
// While scoped to a dedicated link, switching bots is blocked.
|
||||
if (this.scopedBotId !== null && id !== this.scopedBotId) return;
|
||||
this.activeBotId = id;
|
||||
// Fetch queue for newly active bot if we don't have it yet
|
||||
if (!this.queues[id]) {
|
||||
@@ -132,6 +142,32 @@ export const usePlayerStore = defineStore('player', {
|
||||
}
|
||||
},
|
||||
|
||||
/** Lock the UI to a single bot (dedicated link). Sets scope first so the
|
||||
* setActiveBotId guard does not block the switch to the scoped bot. */
|
||||
setScope(id: string) {
|
||||
this.scopedBotId = id;
|
||||
this.activeBotId = id;
|
||||
// Lazily fetch this bot's queue, mirroring setActiveBotId.
|
||||
if (!this.queues[id]) {
|
||||
this.fetchQueue();
|
||||
}
|
||||
},
|
||||
|
||||
clearScope() {
|
||||
this.scopedBotId = null;
|
||||
},
|
||||
|
||||
/** Reconcile the scope with the desired id from the URL (?bot). A stale or
|
||||
* forbidden id resolves to null and clears the scope rather than locking. */
|
||||
applyScopeFromQuery(requestedId: string | null) {
|
||||
const r = resolveScopedBot(requestedId, this.bots.map((b) => b.id));
|
||||
if (r) {
|
||||
this.setScope(r);
|
||||
} else if (requestedId) {
|
||||
this.clearScope();
|
||||
}
|
||||
},
|
||||
|
||||
updateBotStatus(botId: string, status: BotStatus) {
|
||||
const prev = this.bots.find((b) => b.id === botId);
|
||||
const prevSongId = prev?.currentSong?.id;
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { resolveScopedBot } from "./scope.js";
|
||||
|
||||
describe("resolveScopedBot", () => {
|
||||
it("returns null when no id requested", () => {
|
||||
expect(resolveScopedBot(null, ["a", "b"])).toBeNull();
|
||||
expect(resolveScopedBot(undefined, ["a"])).toBeNull();
|
||||
expect(resolveScopedBot("", ["a"])).toBeNull();
|
||||
});
|
||||
it("returns the id when it exists in the bot list", () => {
|
||||
expect(resolveScopedBot("b", ["a", "b"])).toBe("b");
|
||||
});
|
||||
it("clears (null) when the requested id is not a known bot", () => {
|
||||
expect(resolveScopedBot("ghost", ["a", "b"])).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
/** Given the desired scoped id (from ?bot) and the known bot ids, decide the
|
||||
* effective scope. Returns the id if it exists, else null (graceful clear:
|
||||
* a stale/forbidden id never locks the UI). */
|
||||
export function resolveScopedBot(
|
||||
requestedId: string | null | undefined,
|
||||
knownBotIds: readonly string[],
|
||||
): string | null {
|
||||
if (!requestedId) return null;
|
||||
return knownBotIds.includes(requestedId) ? requestedId : null;
|
||||
}
|
||||
@@ -22,8 +22,8 @@ onMounted(async () => {
|
||||
}
|
||||
const bot = store.bots.find((b) => b.id === botId);
|
||||
if (bot) {
|
||||
store.setActiveBotId(botId);
|
||||
router.replace('/');
|
||||
store.setScope(botId);
|
||||
router.replace({ path: '/', query: { bot: botId } });
|
||||
} else {
|
||||
notFound.value = true;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user