mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 21:12:49 +08:00
feat(#119): saved queues web UI + behavior-settings toggles
- Settings → 行为设置: two new toggles (保存/加载播放清单, 单曲直接播放不清空队列) that round-trip savedQueuesEnabled / playKeepsQueue and keep the nav gate in sync. - New "已存队列" page (/saved-queues): save the current queue (with a 共享 option), load (replace) / append / delete saved queues; renders a "feature disabled" state on 403 so it degrades gracefully when the flag is off. - Nav entry gated on the savedQueuesEnabled store flag (hidden for guests). - useSavedQueues API composable + a pure, unit-tested list/ownership helper. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
69a2e8c264
commit
0b10f9553c
9 files changed
+488
No files matched your search
@@ -0,0 +1,34 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { isShared, sortQueues, SHARED_OWNER, type SavedQueueMeta } from './savedQueues';
|
||||
|
||||
const meta = (over: Partial<SavedQueueMeta>): SavedQueueMeta => ({
|
||||
id: 1,
|
||||
ownerId: 'u1',
|
||||
name: 'q',
|
||||
songCount: 0,
|
||||
createdAt: '2026-01-01',
|
||||
updatedAt: '2026-01-01',
|
||||
...over,
|
||||
});
|
||||
|
||||
describe('savedQueues helper', () => {
|
||||
it('flags the shared owner', () => {
|
||||
expect(isShared({ ownerId: SHARED_OWNER })).toBe(true);
|
||||
expect(isShared({ ownerId: 'u1' })).toBe(false);
|
||||
});
|
||||
|
||||
it('sorts by updatedAt descending (newest first)', () => {
|
||||
const out = sortQueues([
|
||||
meta({ id: 1, updatedAt: '2026-01-01' }),
|
||||
meta({ id: 2, updatedAt: '2026-02-01' }),
|
||||
meta({ id: 3, updatedAt: '2026-01-15' }),
|
||||
]);
|
||||
expect(out.map((q) => q.id)).toEqual([2, 3, 1]);
|
||||
});
|
||||
|
||||
it('does not mutate the input array', () => {
|
||||
const input = [meta({ id: 1, updatedAt: '2026-01-01' }), meta({ id: 2, updatedAt: '2026-02-01' })];
|
||||
sortQueues(input);
|
||||
expect(input.map((q) => q.id)).toEqual([1, 2]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
// Pure, framework-free helpers for the Saved Queues feature (#119). Kept
|
||||
// separate from the API composable so the list/ownership logic is unit-testable
|
||||
// without a DOM or axios.
|
||||
|
||||
export interface SavedQueueMeta {
|
||||
id: number;
|
||||
ownerId: string;
|
||||
name: string;
|
||||
songCount: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/** Must match SHARED_QUEUE_OWNER in src/data/database.ts. */
|
||||
export const SHARED_OWNER = '__shared__';
|
||||
|
||||
/** Whether a saved queue lives in the shared bucket (vs. private to a user). */
|
||||
export function isShared(q: Pick<SavedQueueMeta, 'ownerId'>): boolean {
|
||||
return q.ownerId === SHARED_OWNER;
|
||||
}
|
||||
|
||||
/** Newest-updated first; stable for equal timestamps. */
|
||||
export function sortQueues<T extends { updatedAt: string }>(qs: T[]): T[] {
|
||||
return [...qs].sort((a, b) =>
|
||||
a.updatedAt < b.updatedAt ? 1 : a.updatedAt > b.updatedAt ? -1 : 0,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { ref } from 'vue';
|
||||
import axios from 'axios';
|
||||
import { sortQueues, type SavedQueueMeta } from './savedQueues.js';
|
||||
|
||||
/**
|
||||
* API composable for the Saved Queues feature (#119). Wraps /api/saved-queues.
|
||||
* `disabled` becomes true when the server returns 403 (savedQueuesEnabled off),
|
||||
* so the page can render a "feature disabled" state instead of an error.
|
||||
*/
|
||||
export function useSavedQueues() {
|
||||
const queues = ref<SavedQueueMeta[]>([]);
|
||||
const loading = ref(false);
|
||||
const disabled = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
|
||||
async function list(): Promise<void> {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const res = await axios.get('/api/saved-queues');
|
||||
queues.value = sortQueues((res.data?.queues ?? []) as SavedQueueMeta[]);
|
||||
disabled.value = false;
|
||||
} catch (e: unknown) {
|
||||
const status = (e as { response?: { status?: number } })?.response?.status;
|
||||
if (status === 403) {
|
||||
disabled.value = true;
|
||||
queues.value = [];
|
||||
} else {
|
||||
error.value = '加载已保存队列失败';
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function save(botId: string, name: string, shared: boolean): Promise<void> {
|
||||
await axios.post('/api/saved-queues', { botId, name, shared });
|
||||
await list();
|
||||
}
|
||||
|
||||
async function load(id: number, botId: string, mode: 'replace' | 'append'): Promise<{ loaded: number; mode: string }> {
|
||||
const res = await axios.post(`/api/saved-queues/${id}/load`, { botId, mode });
|
||||
return res.data;
|
||||
}
|
||||
|
||||
async function remove(id: number): Promise<void> {
|
||||
await axios.delete(`/api/saved-queues/${id}`);
|
||||
await list();
|
||||
}
|
||||
|
||||
return { queues, loading, disabled, error, list, save, load, remove };
|
||||
}
|
||||
Reference in new issue
Block a user