mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 04:52:50 +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
@@ -166,6 +166,9 @@ onMounted(async () => {
|
|||||||
// Search or Playlist render hearts correctly without first visiting Home.
|
// Search or Playlist render hearts correctly without first visiting Home.
|
||||||
// (fire-and-forget; fetchFavorites swallows the 401 when not yet logged in.)
|
// (fire-and-forget; fetchFavorites swallows the 401 when not yet logged in.)
|
||||||
playerStore.fetchFavorites();
|
playerStore.fetchFavorites();
|
||||||
|
// Non-critical: reads savedQueuesEnabled so the nav entry can show/hide.
|
||||||
|
// Guests get a 403 (swallowed) → the entry stays hidden for them.
|
||||||
|
if (!session.isGuest.value) playerStore.fetchBotSettings();
|
||||||
syncTimer = setInterval(() => playerStore.syncElapsed(), 3000);
|
syncTimer = setInterval(() => playerStore.syncElapsed(), 3000);
|
||||||
mobileRaf = requestAnimationFrame(updateMobileProgress);
|
mobileRaf = requestAnimationFrame(updateMobileProgress);
|
||||||
// Reconcile the dedicated-link scope only after the bot list is known: the
|
// Reconcile the dedicated-link scope only after the bot list is known: the
|
||||||
|
|||||||
@@ -7,6 +7,12 @@
|
|||||||
<RouterLink to="/search" class="nav-link" active-class="active">搜索</RouterLink>
|
<RouterLink to="/search" class="nav-link" active-class="active">搜索</RouterLink>
|
||||||
<RouterLink to="/library" class="nav-link" active-class="active">音乐库</RouterLink>
|
<RouterLink to="/library" class="nav-link" active-class="active">音乐库</RouterLink>
|
||||||
<RouterLink to="/history" class="nav-link" active-class="active">播放历史</RouterLink>
|
<RouterLink to="/history" class="nav-link" active-class="active">播放历史</RouterLink>
|
||||||
|
<RouterLink
|
||||||
|
v-if="store.savedQueuesEnabled && !session.isGuest.value"
|
||||||
|
to="/saved-queues"
|
||||||
|
class="nav-link"
|
||||||
|
active-class="active"
|
||||||
|
>已存队列</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="nav-right">
|
<div class="nav-right">
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@ const router = createRouter({
|
|||||||
},
|
},
|
||||||
{ path: '/lyrics', name: 'lyrics', component: () => import('../views/Lyrics.vue') },
|
{ path: '/lyrics', name: 'lyrics', component: () => import('../views/Lyrics.vue') },
|
||||||
{ path: '/history', name: 'history', component: () => import('../views/History.vue') },
|
{ path: '/history', name: 'history', component: () => import('../views/History.vue') },
|
||||||
|
{ path: '/saved-queues', name: 'saved-queues', component: () => import('../views/SavedQueues.vue') },
|
||||||
{ path: '/settings', name: 'settings', component: () => import('../views/Settings.vue') },
|
{ path: '/settings', name: 'settings', component: () => import('../views/Settings.vue') },
|
||||||
{ path: '/setup', name: 'setup', component: () => import('../views/Setup.vue') },
|
{ path: '/setup', name: 'setup', component: () => import('../views/Setup.vue') },
|
||||||
{ path: '/bot/:id', name: 'bot', component: () => import('../views/BotRedirect.vue') },
|
{ path: '/bot/:id', name: 'bot', component: () => import('../views/BotRedirect.vue') },
|
||||||
|
|||||||
@@ -115,6 +115,10 @@ export const usePlayerStore = defineStore('player', {
|
|||||||
enabledProviders: [] as string[],
|
enabledProviders: [] as string[],
|
||||||
defaultSource: 'netease' as string,
|
defaultSource: 'netease' as string,
|
||||||
|
|
||||||
|
// Whether the admin enabled save/load queues (#119). Gates the nav entry
|
||||||
|
// and the /saved-queues page. Fetched from /api/bot/settings (non-guest).
|
||||||
|
savedQueuesEnabled: false,
|
||||||
|
|
||||||
// Home page cache, split by source
|
// Home page cache, split by source
|
||||||
recommendPlaylists: { netease: [] as PlaylistItem[], qq: [] as PlaylistItem[], kugou: [] as PlaylistItem[], spotify: [] as PlaylistItem[] },
|
recommendPlaylists: { netease: [] as PlaylistItem[], qq: [] as PlaylistItem[], kugou: [] as PlaylistItem[], spotify: [] as PlaylistItem[] },
|
||||||
dailySongs: { netease: [] as Song[], qq: [] as Song[], kugou: [] as Song[], spotify: [] as Song[] },
|
dailySongs: { netease: [] as Song[], qq: [] as Song[], kugou: [] as Song[], spotify: [] as Song[] },
|
||||||
@@ -595,6 +599,18 @@ export const usePlayerStore = defineStore('player', {
|
|||||||
return this.favoritedPlaylists.some((f) => f.playlistId === playlistId && f.platform === platform);
|
return this.favoritedPlaylists.some((f) => f.playlistId === playlistId && f.platform === platform);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Load global bot settings we care about client-side (currently just the
|
||||||
|
* saved-queues gate). Guests get a 403 here, which we swallow so the nav
|
||||||
|
* entry simply stays hidden for them. */
|
||||||
|
async fetchBotSettings() {
|
||||||
|
try {
|
||||||
|
const res = await axios.get('/api/bot/settings');
|
||||||
|
this.savedQueuesEnabled = res.data?.savedQueuesEnabled === true;
|
||||||
|
} catch {
|
||||||
|
// not critical (guests 403; keep previous value otherwise)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
/** Load the server-side source gate (enabledProviders + default). */
|
/** Load the server-side source gate (enabledProviders + default). */
|
||||||
async fetchProviders() {
|
async fetchProviders() {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -0,0 +1,301 @@
|
|||||||
|
<template>
|
||||||
|
<div class="saved-queues-page">
|
||||||
|
<button class="back-btn" @click="$router.back()">
|
||||||
|
<Icon icon="mdi:arrow-left" />
|
||||||
|
返回
|
||||||
|
</button>
|
||||||
|
<h1 class="page-title">已保存队列</h1>
|
||||||
|
|
||||||
|
<div v-if="disabled" class="empty">
|
||||||
|
此功能未启用。请在「设置 → 行为设置」中开启「保存/加载播放清单」。
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<!-- Save current queue -->
|
||||||
|
<section class="save-card">
|
||||||
|
<div class="save-title">保存当前队列</div>
|
||||||
|
<div class="save-hint">
|
||||||
|
将机器人 <strong>{{ activeBotName }}</strong> 当前的播放队列保存为一份清单,稍后可加载或追加。
|
||||||
|
</div>
|
||||||
|
<div class="save-row">
|
||||||
|
<input
|
||||||
|
v-model="newName"
|
||||||
|
class="input"
|
||||||
|
type="text"
|
||||||
|
placeholder="清单名称"
|
||||||
|
maxlength="80"
|
||||||
|
@keyup.enter="onSave"
|
||||||
|
/>
|
||||||
|
<label class="shared-check">
|
||||||
|
<input v-model="newShared" type="checkbox" />
|
||||||
|
共享
|
||||||
|
</label>
|
||||||
|
<button class="btn-primary" :disabled="!canSave || saving" @click="onSave">
|
||||||
|
{{ saving ? '保存中…' : '保存' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div v-if="loading" class="loading">加载中...</div>
|
||||||
|
<div v-else-if="queues.length === 0" class="empty">还没有已保存的队列</div>
|
||||||
|
|
||||||
|
<div v-else class="queue-list">
|
||||||
|
<div v-for="q in queues" :key="q.id" class="queue-row">
|
||||||
|
<div class="queue-info">
|
||||||
|
<span class="queue-name">{{ q.name }}</span>
|
||||||
|
<span v-if="isShared(q)" class="badge shared">共享</span>
|
||||||
|
<span v-else class="badge private">私有</span>
|
||||||
|
<span class="queue-count">{{ q.songCount }} 首</span>
|
||||||
|
</div>
|
||||||
|
<div class="queue-actions">
|
||||||
|
<button class="btn-sm primary" :disabled="!activeBotId || busyId === q.id" @click="onLoad(q, 'replace')">
|
||||||
|
加载
|
||||||
|
</button>
|
||||||
|
<button class="btn-sm" :disabled="!activeBotId || busyId === q.id" @click="onLoad(q, 'append')">
|
||||||
|
追加
|
||||||
|
</button>
|
||||||
|
<button class="btn-sm danger" :disabled="busyId === q.id" @click="onDelete(q)">
|
||||||
|
删除
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from 'vue';
|
||||||
|
import { Icon } from '@iconify/vue';
|
||||||
|
import { usePlayerStore } from '../stores/player.js';
|
||||||
|
import { useSavedQueues } from '../composables/useSavedQueues.js';
|
||||||
|
import { isShared, type SavedQueueMeta } from '../composables/savedQueues.js';
|
||||||
|
|
||||||
|
const store = usePlayerStore();
|
||||||
|
const { queues, loading, disabled, list, save, load, remove } = useSavedQueues();
|
||||||
|
|
||||||
|
const newName = ref('');
|
||||||
|
const newShared = ref(false);
|
||||||
|
const saving = ref(false);
|
||||||
|
const busyId = ref<number | null>(null);
|
||||||
|
|
||||||
|
const activeBotId = computed(() => store.activeBotId);
|
||||||
|
const activeBotName = computed(() => store.activeBot?.name ?? '(未选择)');
|
||||||
|
const canSave = computed(() => !!activeBotId.value && newName.value.trim().length > 0);
|
||||||
|
|
||||||
|
async function onSave() {
|
||||||
|
if (!canSave.value || saving.value) return;
|
||||||
|
saving.value = true;
|
||||||
|
try {
|
||||||
|
await save(activeBotId.value!, newName.value.trim(), newShared.value);
|
||||||
|
store.notify(`已保存「${newName.value.trim()}」`, 'info');
|
||||||
|
newName.value = '';
|
||||||
|
newShared.value = false;
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const msg = (e as { response?: { data?: { error?: string } } })?.response?.data?.error;
|
||||||
|
store.notify(msg || '保存失败', 'error');
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onLoad(q: SavedQueueMeta, mode: 'replace' | 'append') {
|
||||||
|
if (!activeBotId.value) return;
|
||||||
|
busyId.value = q.id;
|
||||||
|
try {
|
||||||
|
const res = await load(q.id, activeBotId.value, mode);
|
||||||
|
store.notify(
|
||||||
|
mode === 'append' ? `已追加「${q.name}」(${res.loaded} 首)` : `已加载「${q.name}」(${res.loaded} 首)`,
|
||||||
|
'info',
|
||||||
|
);
|
||||||
|
store.fetchQueue();
|
||||||
|
} catch {
|
||||||
|
store.notify('加载失败', 'error');
|
||||||
|
} finally {
|
||||||
|
busyId.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onDelete(q: SavedQueueMeta) {
|
||||||
|
busyId.value = q.id;
|
||||||
|
try {
|
||||||
|
await remove(q.id);
|
||||||
|
store.notify(`已删除「${q.name}」`, 'info');
|
||||||
|
} catch {
|
||||||
|
store.notify('删除失败', 'error');
|
||||||
|
} finally {
|
||||||
|
busyId.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (!store.activeBotId) {
|
||||||
|
await store.fetchBots();
|
||||||
|
}
|
||||||
|
await list();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.back-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
opacity: 0.7;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
transition: opacity var(--transition-fast);
|
||||||
|
&:hover { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 800;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-card {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 18px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-hint {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 180px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
background: var(--hover-bg);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: inherit;
|
||||||
|
&:focus { outline: none; border-color: var(--color-primary); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.shared-check {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
white-space: nowrap;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
padding: 10px 18px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: #fff;
|
||||||
|
border: 1px solid var(--color-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: filter var(--transition-fast);
|
||||||
|
&:hover:not(:disabled) { filter: brightness(1.08); }
|
||||||
|
&:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-name {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-count {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
&.shared { background: var(--color-primary-15); color: var(--color-primary); }
|
||||||
|
&.private { background: var(--hover-bg); color: var(--text-secondary); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-sm {
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--hover-bg);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
color: var(--text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
&:hover:not(:disabled) { background: var(--bg-secondary); border-color: var(--color-primary); }
|
||||||
|
&:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
&.primary { background: var(--color-primary); border-color: var(--color-primary); color: #fff; }
|
||||||
|
&.danger { color: #ef4444; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
text-align: center;
|
||||||
|
padding: 60px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
text-align: center;
|
||||||
|
padding: 60px 20px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -760,6 +760,32 @@
|
|||||||
@change="saveLocalAudioEnabled"
|
@change="saveLocalAudioEnabled"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<label class="profile-toggle behavior-toggle">
|
||||||
|
<div class="profile-toggle-text">
|
||||||
|
<div class="profile-toggle-label">保存/加载播放清单(含重启后自动恢复队列)</div>
|
||||||
|
<div class="profile-toggle-hint">开启后可在网页与聊天命令(!save / !load / !queues)保存和加载播放清单,并在机器人重启后自动恢复并继续播放上次的队列。重启只能从当前曲目的开头恢复(不记忆进度);Spotify 恢复为尽力而为。默认关闭。</div>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
v-model="savedQueuesEnabled"
|
||||||
|
type="checkbox"
|
||||||
|
class="profile-toggle-switch"
|
||||||
|
@change="saveSavedQueuesEnabled"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="profile-toggle behavior-toggle">
|
||||||
|
<div class="profile-toggle-text">
|
||||||
|
<div class="profile-toggle-label">直接播放单曲时不清空队列</div>
|
||||||
|
<div class="profile-toggle-hint">开启后,直接播放单曲会插入到当前歌曲之后并立即播放,播完继续原队列,而不是清空整个队列。仅影响单曲的「直接播放」;歌单/专辑/电台仍会替换队列。默认关闭。</div>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
v-model="playKeepsQueue"
|
||||||
|
type="checkbox"
|
||||||
|
class="profile-toggle-switch"
|
||||||
|
@change="savePlayKeepsQueue"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Guest Mode (admin only) -->
|
<!-- Guest Mode (admin only) -->
|
||||||
@@ -1490,6 +1516,9 @@ const idleTimeout = ref(0);
|
|||||||
// Defaults OFF to match the backend default (config.ts getDefaultConfig).
|
// Defaults OFF to match the backend default (config.ts getDefaultConfig).
|
||||||
const autoPauseOnEmpty = ref(false);
|
const autoPauseOnEmpty = ref(false);
|
||||||
const localAudioEnabled = ref(true);
|
const localAudioEnabled = ref(true);
|
||||||
|
// Saved-queues + play-keeps-queue toggles (#119), both default OFF.
|
||||||
|
const savedQueuesEnabled = ref(false);
|
||||||
|
const playKeepsQueue = ref(false);
|
||||||
|
|
||||||
async function loadIdleTimeout() {
|
async function loadIdleTimeout() {
|
||||||
try {
|
try {
|
||||||
@@ -1497,6 +1526,9 @@ async function loadIdleTimeout() {
|
|||||||
idleTimeout.value = res.data.idleTimeoutMinutes ?? 0;
|
idleTimeout.value = res.data.idleTimeoutMinutes ?? 0;
|
||||||
autoPauseOnEmpty.value = res.data.autoPauseOnEmpty ?? false;
|
autoPauseOnEmpty.value = res.data.autoPauseOnEmpty ?? false;
|
||||||
localAudioEnabled.value = res.data.localAudioEnabled ?? true;
|
localAudioEnabled.value = res.data.localAudioEnabled ?? true;
|
||||||
|
savedQueuesEnabled.value = res.data.savedQueuesEnabled ?? false;
|
||||||
|
playKeepsQueue.value = res.data.playKeepsQueue ?? false;
|
||||||
|
store.savedQueuesEnabled = savedQueuesEnabled.value;
|
||||||
applyGuestModeFromServer(res.data.guestMode);
|
applyGuestModeFromServer(res.data.guestMode);
|
||||||
applyAdminGroupsFromServer(res.data.adminGroups);
|
applyAdminGroupsFromServer(res.data.adminGroups);
|
||||||
applySpotifyConfig(res.data.spotify);
|
applySpotifyConfig(res.data.spotify);
|
||||||
@@ -1528,6 +1560,22 @@ async function saveLocalAudioEnabled() {
|
|||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function saveSavedQueuesEnabled() {
|
||||||
|
try {
|
||||||
|
const res = await axios.post('/api/bot/settings', { savedQueuesEnabled: savedQueuesEnabled.value });
|
||||||
|
savedQueuesEnabled.value = res.data.savedQueuesEnabled ?? savedQueuesEnabled.value;
|
||||||
|
// Keep the shared store flag (nav gate) in sync without a reload.
|
||||||
|
store.savedQueuesEnabled = savedQueuesEnabled.value;
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function savePlayKeepsQueue() {
|
||||||
|
try {
|
||||||
|
const res = await axios.post('/api/bot/settings', { playKeepsQueue: playKeepsQueue.value });
|
||||||
|
playKeepsQueue.value = res.data.playKeepsQueue ?? playKeepsQueue.value;
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
// --- Spotify (Connect) settings (§8) ---
|
// --- Spotify (Connect) settings (§8) ---
|
||||||
// NOTE: This card's status comes ONLY from /api/spotify/status (playback OAuth
|
// NOTE: This card's status comes ONLY from /api/spotify/status (playback OAuth
|
||||||
// `authorized`). It is deliberately NOT wired to the player store's
|
// `authorized`). It is deliberately NOT wired to the player store's
|
||||||
|
|||||||
Reference in new issue
Block a user