mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 13:02:49 +08:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
34655e5f50 | ||
|
|
491bc53dec | ||
|
|
8f5bb26b3a | ||
|
|
4ba4b013b0 | ||
|
|
484202e90d | ||
|
|
9f0ac74fbc | ||
|
|
51c954993a |
No files matched your search
@@ -0,0 +1,196 @@
|
||||
# Auto-pause on Empty Channel — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use checkbox (`- [ ]`) syntax.
|
||||
|
||||
**Goal:** Auto-pause playback when the bot's channel empties (no disconnect) and auto-resume when someone returns — only resuming tracks we auto-paused — gated by the existing global `autoPauseOnEmpty` flag.
|
||||
|
||||
**Architecture:** A pure decision function decides pause/resume from (player state, autoPaused, flag, userCount). `BotInstance` owns an `autoPaused` flag and a `checkChannelOccupancy()` that the existing 30s idle poll AND new TS enter/leave/move events both call. The toggle is wired into `/api/bot/settings` + the Settings UI.
|
||||
|
||||
**Tech:** Node ESM + TS, Vitest, Express, Vue 3.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-05-30-autopause-empty-channel-design.md`
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Pure occupancy-decision function
|
||||
|
||||
**Files:** Create `src/bot/auto-pause.ts`, `src/bot/auto-pause.test.ts`.
|
||||
|
||||
- [ ] **Step 1 — failing test** `src/bot/auto-pause.test.ts`:
|
||||
|
||||
```typescript
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { decideOccupancyAction } from "./auto-pause.js";
|
||||
|
||||
describe("decideOccupancyAction", () => {
|
||||
// (playerState, autoPaused, enabled, userCount) => "pause" | "resume" | "none"
|
||||
it("pauses when empty while playing and enabled", () => {
|
||||
expect(decideOccupancyAction("playing", false, true, 0)).toBe("pause");
|
||||
});
|
||||
it("does not pause when the feature is disabled", () => {
|
||||
expect(decideOccupancyAction("playing", false, false, 0)).toBe("none");
|
||||
});
|
||||
it("does not pause when idle (nothing playing)", () => {
|
||||
expect(decideOccupancyAction("idle", false, true, 0)).toBe("none");
|
||||
});
|
||||
it("does not pause when already paused", () => {
|
||||
expect(decideOccupancyAction("paused", false, true, 0)).toBe("none");
|
||||
});
|
||||
it("resumes when re-populated and we auto-paused", () => {
|
||||
expect(decideOccupancyAction("paused", true, true, 2)).toBe("resume");
|
||||
});
|
||||
it("does NOT resume a user-paused track on re-population", () => {
|
||||
expect(decideOccupancyAction("paused", false, true, 2)).toBe("none");
|
||||
});
|
||||
it("does nothing when re-populated and already playing", () => {
|
||||
expect(decideOccupancyAction("playing", false, true, 2)).toBe("none");
|
||||
});
|
||||
it("resume is independent of the enabled flag (we already auto-paused)", () => {
|
||||
expect(decideOccupancyAction("paused", true, false, 1)).toBe("resume");
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2 — run, expect fail:** `npx vitest run src/bot/auto-pause.test.ts` → module missing.
|
||||
|
||||
- [ ] **Step 3 — implement** `src/bot/auto-pause.ts`:
|
||||
|
||||
```typescript
|
||||
export type PlayerStateName = "idle" | "playing" | "paused";
|
||||
export type OccupancyAction = "pause" | "resume" | "none";
|
||||
|
||||
/**
|
||||
* Decide what auto-pause should do given the channel occupancy.
|
||||
* - empty (userCount <= 0): pause iff enabled and currently playing.
|
||||
* - re-populated (userCount > 0): resume iff we previously auto-paused and are still paused.
|
||||
* `autoPaused` distinguishes our auto-pause from a user pause, so user pauses are never resumed.
|
||||
*/
|
||||
export function decideOccupancyAction(
|
||||
playerState: PlayerStateName,
|
||||
autoPaused: boolean,
|
||||
enabled: boolean,
|
||||
userCount: number,
|
||||
): OccupancyAction {
|
||||
const empty = userCount <= 0;
|
||||
if (empty) {
|
||||
if (enabled && playerState === "playing") return "pause";
|
||||
return "none";
|
||||
}
|
||||
if (autoPaused && playerState === "paused") return "resume";
|
||||
return "none";
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4 — run, expect pass:** `npx vitest run src/bot/auto-pause.test.ts` → 8 pass.
|
||||
- [ ] **Step 5 — commit:** `git add src/bot/auto-pause.ts src/bot/auto-pause.test.ts && git commit -m "feat(autopause): pure occupancy-decision function"`
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Wire decision into BotInstance (autoPaused flag + checkChannelOccupancy)
|
||||
|
||||
**Files:** Modify `src/bot/instance.ts`.
|
||||
|
||||
Context: `_startIdlePoller` (~lines 190-206) polls every 30s, computes `userCount = (await getClientsInChannel()).length - 1`, and calls `_scheduleIdleCheck()` (empty) / `_cancelIdleTimer()` (occupied). `cmdPause`/`cmdResume` (~484-494), `cmdStop` (~496-505), and the playback start (`cmdPlay`/resolveAndPlay) wrap `player`. There's an unused `channelUserCount` field (~line 68). The instance has `this.config` (BotConfig) and `this.player`.
|
||||
|
||||
- [ ] **Step 1 — add state + helper.** Add a private field `private autoPaused = false;`. Create a method that centralizes occupancy handling and is called with a freshly-computed userCount:
|
||||
|
||||
```typescript
|
||||
import { decideOccupancyAction } from "./auto-pause.js";
|
||||
|
||||
private handleOccupancy(userCount: number): void {
|
||||
// idle-disconnect (unchanged behavior)
|
||||
if (userCount <= 0) this._scheduleIdleCheck();
|
||||
else this._cancelIdleTimer();
|
||||
|
||||
// auto-pause
|
||||
const action = decideOccupancyAction(
|
||||
this.player.getState() as "idle" | "playing" | "paused",
|
||||
this.autoPaused,
|
||||
this.config.autoPauseOnEmpty,
|
||||
userCount,
|
||||
);
|
||||
if (action === "pause") {
|
||||
this.player.pause();
|
||||
this.autoPaused = true;
|
||||
this.emit("stateChange");
|
||||
} else if (action === "resume") {
|
||||
this.player.resume();
|
||||
this.autoPaused = false;
|
||||
this.emit("stateChange");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2 — route the idle poller through it.** In `_startIdlePoller`, replace the inline `userCount`→schedule/cancel logic with: compute `userCount` then `this.handleOccupancy(userCount)`. (Keep the 30s interval + the same getClientsInChannel call + error handling.) Remove the now-redundant inline schedule/cancel branch (it lives in `handleOccupancy`).
|
||||
|
||||
- [ ] **Step 3 — clear autoPaused on user actions + lifecycle.** In `cmdPause`, `cmdResume`, `cmdStop`, and the play-start path (`cmdPlay`/wherever playback (re)starts), set `this.autoPaused = false`. In the `disconnected` handler and on (re)connect, set `this.autoPaused = false`. (These ensure a user pause is never auto-resumed and the flag resets across connections.)
|
||||
|
||||
- [ ] **Step 4 — `updateAutoPause`.** Add (mirrors `updateIdleTimeout`):
|
||||
|
||||
```typescript
|
||||
updateAutoPause(enabled: boolean): void {
|
||||
this.config.autoPauseOnEmpty = enabled;
|
||||
// if turning off, leave current playback as-is; if a track was auto-paused, optionally resume:
|
||||
if (!enabled && this.autoPaused && this.player.getState() === "paused") {
|
||||
this.player.resume();
|
||||
this.autoPaused = false;
|
||||
this.emit("stateChange");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5 — verify:** `npx tsc --noEmit` → exit 0. `npx vitest run src/bot src/audio` → pass (existing tests unaffected).
|
||||
- [ ] **Step 6 — commit:** `git add src/bot/instance.ts && git commit -m "feat(autopause): drive pause/resume from channel occupancy in BotInstance"`
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Re-emit TS member events for instant reaction
|
||||
|
||||
**Files:** Modify `src/ts-protocol/client.ts`, `src/bot/instance.ts`.
|
||||
|
||||
Context: `client.ts` forwards `textMessage`/`disconnected`/`connected` and only debug-logs `clientEnter` (~lines 219-224); `clientLeave`/`clientMoved` are not handled. `BotInstance.setupTsEvents()` (~lines 132-156) wires tsClient events.
|
||||
|
||||
- [ ] **Step 1 — re-emit in client.ts.** Where `clientEnter` is logged, also `this.emit("clientEnter", info)`. Add subscriptions for `clientLeave` and `clientMoved` that `this.emit(...)` them upward (match the existing forwarding style; just propagate, no payload transformation needed since the instance re-queries).
|
||||
|
||||
- [ ] **Step 2 — react in instance.ts.** In `setupTsEvents()`, add handlers: on `clientEnter` / `clientLeave` / `clientMoved`, call a small `async refreshOccupancy()` that does `const clients = await this.getClientsInChannel(); this.handleOccupancy(clients.length - 1);` (guarded with try/catch + only when connected). This gives near-instant pause/resume; the 30s poll remains the fallback.
|
||||
|
||||
- [ ] **Step 3 — verify:** `npx tsc --noEmit` → 0. `npx vitest run src/bot` → pass.
|
||||
- [ ] **Step 4 — commit:** `git add src/ts-protocol/client.ts src/bot/instance.ts && git commit -m "feat(autopause): re-emit client enter/leave/move for instant pause/resume"`
|
||||
|
||||
---
|
||||
|
||||
## Task 4: API wiring for the toggle
|
||||
|
||||
**Files:** Modify `src/web/api/bot.ts`; add/extend a test.
|
||||
|
||||
Context: `GET /api/bot/settings` returns `{ idleTimeoutMinutes }`; `POST /api/bot/settings` validates `idleTimeoutMinutes`, sets `config.idleTimeoutMinutes`, `saveConfig`, then loops `botManager.getAllBots()` → `bot.updateIdleTimeout(...)`. This route is `requirePermission("bot.manage")`-gated.
|
||||
|
||||
- [ ] **Step 1 — failing API test** (extend the existing bot settings test or add one): `GET /api/bot/settings` returns `autoPauseOnEmpty` (boolean); `POST /api/bot/settings` with `{ autoPauseOnEmpty: false }` persists it (a follow-up GET reflects false) and calls `updateAutoPause` on bots. Model the harness on the existing settings test.
|
||||
|
||||
- [ ] **Step 2 — run, expect fail.**
|
||||
|
||||
- [ ] **Step 3 — implement.** In `GET /settings`, add `autoPauseOnEmpty: options.config.autoPauseOnEmpty` to the response. In `POST /settings`, if `typeof req.body.autoPauseOnEmpty === "boolean"`, set `config.autoPauseOnEmpty`, include it in the `saveConfig`, and loop bots calling `bot.updateAutoPause(config.autoPauseOnEmpty)`. Keep the existing `idleTimeoutMinutes` handling intact (handle both fields in one save).
|
||||
|
||||
- [ ] **Step 4 — verify:** `npx vitest run src/web` → pass; `npx tsc --noEmit` → 0.
|
||||
- [ ] **Step 5 — commit:** `git add src/web/api/bot.ts <test> && git commit -m "feat(autopause): expose autoPauseOnEmpty via /api/bot/settings"`
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Frontend toggle in Settings
|
||||
|
||||
**Files:** Modify `web/src/views/Settings.vue` (and the settings load/save it uses).
|
||||
|
||||
Context: The **行为设置** section (already `v-if="can('bot.manage')"`) holds the idle-timeout control, loaded via `loadIdleTimeout()` (GET /api/bot/settings) and saved via `saveIdleTimeout()` (POST). Read these first.
|
||||
|
||||
- [ ] **Step 1 — implement.** Add an `autoPauseOnEmpty` ref. In the settings load, populate it from the GET response. Add a checkbox/toggle in the 行为设置 section labelled e.g. "频道无人时自动暂停" bound to it, and include `autoPauseOnEmpty` in the POST payload of the save function (alongside `idleTimeoutMinutes`, or via its own save — match the existing pattern). Use existing form/toggle CSS classes.
|
||||
- [ ] **Step 2 — verify:** `cd web && npx vue-tsc --noEmit` → exit 0; read template back for correctness.
|
||||
- [ ] **Step 3 — commit:** `git add web/src/views/Settings.vue && git commit -m "feat(autopause): autoPauseOnEmpty toggle in Settings"`
|
||||
|
||||
---
|
||||
|
||||
## Final verification
|
||||
- [ ] `npx tsc --noEmit` → 0
|
||||
- [ ] `npx vitest run src/` → all pass
|
||||
- [ ] `cd web && npx vue-tsc --noEmit` → 0
|
||||
- [ ] `npm run build` → succeeds
|
||||
- [ ] Manual: with a bot playing, leave its channel → music auto-pauses (no disconnect); rejoin → resumes. Manually pause, leave, rejoin → stays paused. Toggle off in Settings → no auto-pause.
|
||||
@@ -1,156 +0,0 @@
|
||||
# Dedicated-link Bot Scoping (+ refresh fix) — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use checkbox (`- [ ]`) syntax.
|
||||
|
||||
**Goal:** Opening a dedicated link locks the WebUI to that one bot (selector shows only it, switching disabled, with an explicit exit); the lock is carried in the URL (`?bot=<id>`) so it survives refresh — fixing item 4 too.
|
||||
|
||||
**Architecture:** A `scopedBotId` in the Pinia player store is the runtime lock; the URL query `?bot=<id>` is the durable source of truth. A router `beforeEach` syncs scope from the query and re-attaches `?bot` across in-app navigation while scoped. `BotRedirect` seeds it; Navbar renders the lock; graceful clear if the bot doesn't exist.
|
||||
|
||||
**Tech:** Vue 3 + Pinia + vue-router, TypeScript. (Frontend isn't unit-tested in this repo → verify via `vue-tsc` + manual; extract one pure helper to unit-test.)
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-05-30-dedicated-link-scope-design.md`
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Store scope state + pure resolve helper (with test)
|
||||
|
||||
**Files:** Modify `web/src/stores/player.ts`; create `web/src/stores/scope.ts` + `web/src/stores/scope.test.ts`.
|
||||
|
||||
READ `web/src/stores/player.ts` first: `activeBotId` state (~line 52), `setActiveBotId` action (~127-133), `fetchBots` (~196-198 default to bots[0]), `activeBot` getter (~73-75), and the localStorage pattern used by `theme` (~175-183) for reference (we are NOT using localStorage, but match code style).
|
||||
|
||||
- [ ] **Step 1 — pure helper + failing test.** Create `web/src/stores/scope.ts`:
|
||||
|
||||
```typescript
|
||||
/** 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;
|
||||
}
|
||||
```
|
||||
|
||||
`web/src/stores/scope.test.ts`:
|
||||
|
||||
```typescript
|
||||
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();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2 — run, expect fail:** `npx vitest run web/src/stores/scope.test.ts` → module missing.
|
||||
(Note: the repo's vitest runs from root; this test lives under web/. If the root vitest config doesn't include web/src, run it via the web workspace: `cd web && npx vitest run src/stores/scope.test.ts`. Use whichever picks it up; confirm it FAILS first.)
|
||||
|
||||
- [ ] **Step 3 — implement the helper** (code above).
|
||||
|
||||
- [ ] **Step 4 — add scope state to `web/src/stores/player.ts`:**
|
||||
- state: `scopedBotId: null as string | null`.
|
||||
- getter: `isScoped: (state) => state.scopedBotId !== null`.
|
||||
- actions:
|
||||
- `setScope(id: string)` → `this.scopedBotId = id;` and also set `this.activeBotId = id` (scoped == active), then ensure that bot's queue is loaded like `setActiveBotId` does.
|
||||
- `clearScope()` → `this.scopedBotId = null;`.
|
||||
- `applyScopeFromQuery(requestedId: string | null)` → uses `resolveScopedBot(requestedId, this.bots.map(b => b.id))`; if result non-null → `setScope(result)`; if null and a scope was requested → `clearScope()`. (Called after bots are loaded.)
|
||||
- Guard `setActiveBotId(id)`: at the top, `if (this.scopedBotId !== null && id !== this.scopedBotId) return;` so switching is blocked while scoped.
|
||||
|
||||
- [ ] **Step 5 — run helper test, expect pass:** `cd web && npx vitest run src/stores/scope.test.ts` → 3 pass. `cd web && npx vue-tsc --noEmit` → exit 0.
|
||||
|
||||
- [ ] **Step 6 — commit:** `git add web/src/stores/scope.ts web/src/stores/scope.test.ts web/src/stores/player.ts && git commit -m "feat(scope): player store scopedBotId + resolveScopedBot helper"`
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Router guard — sync scope from `?bot` + preserve across navigation
|
||||
|
||||
**Files:** Modify `web/src/router/index.ts`.
|
||||
|
||||
READ the file: the existing `beforeEach` (~lines 36-60) handles needsSetup/auth. Add scope handling AFTER auth resolves (so we don't fight the login redirect). Import the player store (use it inside the guard via `usePlayerStore()` — Pinia is active by the time navigation runs).
|
||||
|
||||
- [ ] **Step 1 — implement.** In `beforeEach`, after the existing auth/needsSetup logic decides the navigation is allowed to proceed to `to` (i.e., not redirecting to /login or /first-run), add:
|
||||
|
||||
```typescript
|
||||
const store = usePlayerStore();
|
||||
const qBot = typeof to.query.bot === "string" ? to.query.bot : null;
|
||||
if (qBot) {
|
||||
// entering/with a scope in the URL — store will validate against bots later
|
||||
store.scopedBotId = qBot; // tentative; applyScopeFromQuery (after fetchBots) confirms/clears
|
||||
return next();
|
||||
}
|
||||
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 next({ ...to, query: { ...to.query, bot: store.scopedBotId } });
|
||||
}
|
||||
}
|
||||
return next();
|
||||
```
|
||||
|
||||
(Adapt to the file's existing `next()` style — it may use `next(...)`/return. Ensure this runs only for allowed navigations, not when redirecting to /login. The exit action in Task 4 calls `store.clearScope()` BEFORE navigating to `/`, so `store.scopedBotId` is null and the re-attach branch is skipped — that's how exit works.)
|
||||
|
||||
- [ ] **Step 2 — verify:** `cd web && npx vue-tsc --noEmit` → exit 0. Re-read the guard to ensure no redirect loop (when `to.query.bot === store.scopedBotId`, it does NOT redirect again).
|
||||
|
||||
- [ ] **Step 3 — commit:** `git add web/src/router/index.ts && git commit -m "feat(scope): router guard syncs + preserves ?bot across navigation"`
|
||||
|
||||
---
|
||||
|
||||
## Task 3: BotRedirect seeds the URL scope
|
||||
|
||||
**Files:** Modify `web/src/views/BotRedirect.vue`.
|
||||
|
||||
READ it: onMounted reads `route.params.id`, ensures `store.fetchBots()`, finds the bot; if found `store.setActiveBotId(id)` + `router.replace('/')`; else shows not-found.
|
||||
|
||||
- [ ] **Step 1 — implement.** Change the found-branch to seed scope via the URL instead of bouncing to a bare `/`:
|
||||
- keep the fetchBots + existence check,
|
||||
- if found: `router.replace({ path: '/', query: { bot: botId } })` (the router guard + store will set the scope). Optionally also call `store.setScope(botId)` directly for immediacy.
|
||||
- if not found: unchanged (show "机器人不存在或未加载").
|
||||
|
||||
- [ ] **Step 2 — verify:** `cd web && npx vue-tsc --noEmit` → exit 0.
|
||||
- [ ] **Step 3 — commit:** `git add web/src/views/BotRedirect.vue && git commit -m "feat(scope): dedicated link seeds ?bot scope instead of bare redirect"`
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Navbar lock UI + apply-scope-on-load
|
||||
|
||||
**Files:** Modify `web/src/components/Navbar.vue`, `web/src/App.vue`.
|
||||
|
||||
READ both: Navbar has `controllableBots` (computed) + the dropdown selector + `selectBot`; App.vue onMounted calls `playerStore.fetchBots()` (+ loadTheme/connect).
|
||||
|
||||
- [ ] **Step 1 — Navbar lock.** When `store.isScoped`:
|
||||
- render only the scoped bot (a `displayedBots` computed → if scoped, `controllableBots.filter(b => b.id === store.scopedBotId)`, else `controllableBots`),
|
||||
- disable the dropdown open / switching (no chevron, or make the trigger non-interactive) so the user can't switch,
|
||||
- hide other bots' "copy link" affordances (only the scoped bot remains anyway),
|
||||
- show a small "专属模式" badge and an "退出" button → `store.clearScope(); router.push('/')` (clear BEFORE navigating so the guard doesn't re-attach `?bot`). Import `useRouter` if not present.
|
||||
When not scoped: behavior unchanged.
|
||||
|
||||
- [ ] **Step 2 — apply scope on load (App.vue).** After `fetchBots()` resolves in onMounted, call `playerStore.applyScopeFromQuery(routeBot)` where `routeBot` is the current `?bot` query (via `useRoute().query.bot` as string|null). This confirms a refreshed `?bot` against the loaded bots and sets activeBotId (or gracefully clears if the bot is gone). (If Task 2's guard already set `scopedBotId` tentatively, this validates it against the now-loaded bot list.)
|
||||
|
||||
- [ ] **Step 3 — verify:** `cd web && npx vue-tsc --noEmit` → exit 0. Read templates back for valid syntax; confirm read-only displays aren't broken and the non-scoped path is unchanged.
|
||||
- [ ] **Step 4 — commit:** `git add web/src/components/Navbar.vue web/src/App.vue && git commit -m "feat(scope): lock Navbar selector to scoped bot + apply scope on load"`
|
||||
|
||||
---
|
||||
|
||||
## Final verification
|
||||
- [ ] `cd web && npx vue-tsc --noEmit` → exit 0
|
||||
- [ ] `npx tsc --noEmit` → exit 0 (backend unaffected)
|
||||
- [ ] `cd web && npx vitest run src/stores/scope.test.ts` (or root vitest if it includes web) → pass
|
||||
- [ ] `npm run build` → succeeds
|
||||
- [ ] Manual: open `/bot/<id>` → URL becomes `/?bot=<id>`, selector shows only that bot, switching disabled; **refresh → still locked** (item 4 fixed); navigate to Search → URL keeps `?bot`; refresh on Search → still locked; click 退出 → back to all bots (`/`, no `?bot`); open `/` directly → full multi-bot control; open `/?bot=<nonexistent>` → gracefully shows all bots (no lock).
|
||||
|
||||
## Notes
|
||||
- Backend per-bot authorization (PR #80) is the real security boundary; this is a UX lock.
|
||||
- No localStorage — URL is the source of truth, so the lock is shareable and self-clearing.
|
||||
- Item 4 is fixed as a consequence of carrying `?bot` in the URL across refresh/navigation.
|
||||
@@ -0,0 +1,101 @@
|
||||
# Auto-pause on empty channel — design
|
||||
|
||||
**Issue:** [#79](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/79) item 3
|
||||
**Date:** 2026-05-30
|
||||
**Status:** Approved (brainstorm), pending implementation plan
|
||||
|
||||
## Problem
|
||||
|
||||
When everyone leaves the bot's voice channel, music keeps playing to an empty room.
|
||||
The maintainer wants an option to **auto-pause when the channel is empty** (no disconnect)
|
||||
and resume when someone returns.
|
||||
|
||||
## Decisions (from brainstorm)
|
||||
|
||||
- **Global toggle**, reusing the **already-declared but currently dead** `config.autoPauseOnEmpty`
|
||||
(`src/data/config.ts`, default `true`). No per-bot granularity (YAGNI).
|
||||
- **Event-driven, near-instant** reaction (not the 30s poll alone) — subscribe to TS client
|
||||
enter/leave/move events; keep the existing 30s idle poll as a fallback.
|
||||
- **Auto-resume only what we auto-paused** — a user-paused track is never auto-resumed.
|
||||
- Independent of the existing **idle-disconnect** (`idleTimeoutMinutes`): both share the same
|
||||
emptiness signal but act independently (pause immediately; disconnect after N minutes).
|
||||
|
||||
## Current state (verified)
|
||||
|
||||
- `client.ts` `getClientsInChannel()` returns all clients in the bot's channel *including the
|
||||
bot*; callers compute "others" as `length - 1`. No persistent roster.
|
||||
- The library emits `clientEnter` / `clientLeave` / `clientMoved`; `client.ts` currently only
|
||||
*logs* `clientEnter` and does not re-emit leave/moved.
|
||||
- The idle poller in `instance.ts` (`_startIdlePoller`, every 30s) already computes
|
||||
`userCount = getClientsInChannel().length - 1` and, when `<= 0`, schedules an
|
||||
idle-disconnect after `idleTimeoutMinutes`.
|
||||
- `player.pause()` / `player.resume()` already pause/resume **without disconnecting** (ffmpeg
|
||||
stays alive, no voice sent). The player only knows `idle|playing|paused` — there is **no**
|
||||
auto-vs-user-pause distinction today.
|
||||
- `BotConfig.autoPauseOnEmpty` exists (default true) but is **read nowhere**.
|
||||
|
||||
## Design
|
||||
|
||||
### Occupancy signal (shared)
|
||||
Extract the idle poller's count into one method on `BotInstance`:
|
||||
`checkChannelOccupancy()` → queries `getClientsInChannel()`, computes `userCount = length - 1`,
|
||||
and drives **both** the existing idle-disconnect timer (unchanged behavior) **and** the new
|
||||
auto-pause logic below. It is called by:
|
||||
1. the existing 30s poll (fallback), and
|
||||
2. new TS event handlers.
|
||||
|
||||
### Event subscription
|
||||
`client.ts`: subscribe to and **re-emit** `clientEnter`, `clientLeave`, `clientMoved` up to
|
||||
`BotInstance`. `BotInstance.setupTsEvents()` calls `checkChannelOccupancy()` on each (a re-query
|
||||
is simplest, since `clientLeave` carries no channel id). This gives near-instant pause/resume;
|
||||
the poll remains as a safety net.
|
||||
|
||||
### Auto-pause logic (inside `checkChannelOccupancy`)
|
||||
Add a private `autoPaused = false` flag to `BotInstance`.
|
||||
- **Empty** (`userCount <= 0`): if `config.autoPauseOnEmpty` **and** `player.getState() === "playing"`
|
||||
→ `player.pause()`, `autoPaused = true`, emit `stateChange`. (Idle-disconnect timer still
|
||||
scheduled as today.)
|
||||
- **Re-populated** (`userCount > 0`): if `autoPaused` **and** `player.getState() === "paused"`
|
||||
→ `player.resume()`, `autoPaused = false`, emit `stateChange`. (Idle timer cancelled as today.)
|
||||
|
||||
### `autoPaused` bookkeeping (so user pauses are respected)
|
||||
Clear `autoPaused = false` in `cmdPause`, `cmdResume`, `cmdStop`, `cmdPlay`, and on
|
||||
connect/disconnect (the `disconnected` handler calls `player.stop()` → idle). Net effect: only a
|
||||
track *we* auto-paused gets auto-resumed; a user-paused track stays paused when someone returns.
|
||||
|
||||
### Config wiring
|
||||
- `GET /api/bot/settings`: include `autoPauseOnEmpty` in the payload (alongside `idleTimeoutMinutes`).
|
||||
- `POST /api/bot/settings`: accept + validate a boolean `autoPauseOnEmpty`, `saveConfig`, and
|
||||
propagate to live bots via a new `BotInstance.updateAutoPause(enabled)` (mirrors
|
||||
`updateIdleTimeout`). Since the instance reads `this.config.autoPauseOnEmpty` live, propagation
|
||||
can be as simple as updating the stored config reference / a field the check reads.
|
||||
- Frontend `Settings.vue` → the **行为设置** section (already `bot.manage`-gated): add a toggle
|
||||
for `autoPauseOnEmpty` next to the idle-timeout control; load it in the settings fetch and send
|
||||
it on save.
|
||||
|
||||
## Components / files
|
||||
|
||||
- `src/ts-protocol/client.ts` — subscribe + re-emit `clientEnter`/`clientLeave`/`clientMoved`.
|
||||
- `src/bot/instance.ts` — `autoPaused` field; `checkChannelOccupancy()` (refactored from the
|
||||
idle poller, drives idle + auto-pause); event handlers; clear `autoPaused` in user commands +
|
||||
connect/disconnect; `updateAutoPause(enabled)`.
|
||||
- `src/web/api/bot.ts` — `GET`/`POST /settings` handle `autoPauseOnEmpty`.
|
||||
- `web/src/views/Settings.vue` (+ player store settings load/save) — the toggle.
|
||||
- `src/data/config.ts` — field already exists (no change beyond confirming default).
|
||||
|
||||
## Testing
|
||||
|
||||
- **Decision unit test (TDD):** extract the pause/resume decision into a testable method, e.g.
|
||||
`applyOccupancy(userCount)` operating on an injected fake player (`getState`/`pause`/`resume`)
|
||||
+ the `autoPaused` flag + the config flag. Cases: empty+playing+enabled → pause + `autoPaused`;
|
||||
re-populated+`autoPaused`+paused → resume + clear; re-populated when NOT `autoPaused` (user
|
||||
pause) → no resume; flag disabled → no pause; empty while idle (not playing) → no-op.
|
||||
- **API test:** `GET`/`POST /api/bot/settings` round-trips `autoPauseOnEmpty` (validates boolean,
|
||||
persists, propagates).
|
||||
- Live TS event wiring is verified by code review + a manual run (can't unit-test a real server).
|
||||
|
||||
## Non-goals
|
||||
|
||||
- No per-bot toggle (global only). No change to idle-disconnect behavior. No new dependency.
|
||||
- Reaction relies on events the bot can already see (same-channel members are always in view);
|
||||
no extra channel subscription needed.
|
||||
@@ -1,65 +0,0 @@
|
||||
# Dedicated-link bot scoping (+ refresh fix) — design
|
||||
|
||||
**Issue:** [#79](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/79) items 2 and 4
|
||||
**Date:** 2026-05-30
|
||||
**Status:** Approved (brainstorm), pending implementation plan
|
||||
|
||||
## Problem
|
||||
|
||||
- **Item 2:** A dedicated link (`/bot/:id`) is meant to give someone control of *one* bot, but today it just sets the active bot and bounces to `/`; the user can still switch to any other bot from the top-right selector.
|
||||
- **Item 4 (bug):** After opening a dedicated link, refreshing the page loses the bot — the UI falls back to the first bot.
|
||||
|
||||
Root cause (verified): `BotRedirect.vue` does `router.replace('/')` (dropping the id), and `activeBotId` is in-memory-only Pinia state with no persistence, so a reload resets it to `bots[0]`.
|
||||
|
||||
## Decision (from brainstorm: Q2 = URL-carried scope)
|
||||
|
||||
Carry the scoped bot in the **URL query** (`?bot=<id>`). One mechanism fixes **both** items: the URL is durable across refresh (item 4) and shareable/self-clearing, and the frontend locks the selector to the scoped bot (item 2). No localStorage sticky-lock; plain `/` (no `?bot`) = full control. Backend per-bot authorization (PR #80) remains the real boundary — this is a UX lock.
|
||||
|
||||
## Design
|
||||
|
||||
### Scope state (store)
|
||||
Add to the player store:
|
||||
- `scopedBotId: string | null` — the bot the UI is locked to.
|
||||
- getter `isScoped` = `scopedBotId !== null`.
|
||||
- action `setScope(id)` / `clearScope()`.
|
||||
- `setActiveBotId(id)` becomes a no-op (or ignores) when `isScoped` and `id !== scopedBotId`, so stray switch attempts can't change bots.
|
||||
|
||||
### URL as the durable source of truth
|
||||
- `BotRedirect.vue` (`/bot/:id`): instead of `router.replace('/')`, validate the bot exists, then `router.replace({ path: '/', query: { bot: id } })`. (Keeps the "clean" home URL but with `?bot=`.)
|
||||
- **Router `beforeEach` guard** (the heart of it):
|
||||
- If `to.query.bot` is present → `store.setScope(to.query.bot)` and continue.
|
||||
- Else if `store.isScoped` (a scope is active and this navigation dropped the param) → redirect to the same route **with** `query.bot = store.scopedBotId` re-attached (so the lock survives in-app navigation to /search, /library, etc.).
|
||||
- Else → no scope; continue.
|
||||
This keeps `?bot=` on the URL for every route while scoped, so a refresh on *any* route re-establishes the lock → **fixes item 4**.
|
||||
- On app load / after `fetchBots()`: apply `scopedBotId`/`?bot` to `activeBotId`; if the scoped bot doesn't exist or isn't in the user's allowed set, **clear the scope gracefully** (fall back to normal multi-bot view) rather than locking onto a dead id.
|
||||
|
||||
### Exit
|
||||
- `clearScope()` sets `scopedBotId = null`; the exit affordance navigates to `/` *after* clearing, so the guard won't re-attach `?bot`. This is the only way to leave scoped mode (self-clearing, intentional).
|
||||
|
||||
### Navbar (the lock UI)
|
||||
- When `isScoped`: the bot selector shows **only** the scoped bot, the dropdown/switching is disabled (no chevron / non-interactive), and other bots' "copy link" affordances are not shown.
|
||||
- Show a small "专属模式" indicator with an "退出" control → `clearScope()` + navigate to `/`.
|
||||
- When not scoped: unchanged (full selector over `controllableBots`).
|
||||
|
||||
### Active-bot coherence
|
||||
Because every player action already routes through `activeBotId`, locking `activeBotId === scopedBotId` guarantees all controls affect only the scoped bot. The store's `activeBot` getter `bots[0]` fallback still degrades safely if the scoped id ever fails to match (combined with the graceful-clear above).
|
||||
|
||||
## Components / files
|
||||
|
||||
- `web/src/stores/player.ts` — `scopedBotId` state, `isScoped`, `setScope`/`clearScope`, guard in `setActiveBotId`, apply scope→active in `fetchBots`/init (graceful clear if missing).
|
||||
- `web/src/router/index.ts` — `beforeEach` scope sync + `?bot` preservation.
|
||||
- `web/src/views/BotRedirect.vue` — set scope + `replace({ path: '/', query: { bot: id } })`.
|
||||
- `web/src/components/Navbar.vue` — locked selector + "专属模式/退出" affordance.
|
||||
- `web/src/App.vue` — ensure scope is applied to `activeBotId` after `fetchBots` on load (if not already handled by the store/guard).
|
||||
|
||||
## Testing
|
||||
|
||||
Vue UI isn't unit-tested in this repo, so verification is `vue-tsc` + manual run. The **store scope logic is testable** if a lightweight test harness exists for Pinia stores; otherwise assert the pure pieces:
|
||||
- `setActiveBotId` ignores a switch to a non-scoped bot while scoped; allows the scoped bot.
|
||||
- `clearScope` resets state.
|
||||
- A small helper for "resolve scope from query + bots list → {scopedBotId, activeBotId} or cleared-if-missing" can be extracted and unit-tested.
|
||||
Manual: open `/bot/<id>` → locked to that bot, selector shows only it; refresh → still locked (item 4 fixed); navigate to Search then refresh → still locked; click 退出 → back to all bots; open `/` directly → full control (no lock).
|
||||
|
||||
## Non-goals
|
||||
|
||||
- No localStorage persistence (URL is the source of truth). No backend change (per-bot auth already exists in #80). No change to how dedicated links are generated (still `<base>/bot/<id>`); only what happens when one is opened.
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { decideOccupancyAction } from "./auto-pause.js";
|
||||
|
||||
describe("decideOccupancyAction", () => {
|
||||
it("pauses when empty while playing and enabled", () => {
|
||||
expect(decideOccupancyAction("playing", false, true, 0)).toBe("pause");
|
||||
});
|
||||
it("does not pause when the feature is disabled", () => {
|
||||
expect(decideOccupancyAction("playing", false, false, 0)).toBe("none");
|
||||
});
|
||||
it("does not pause when idle (nothing playing)", () => {
|
||||
expect(decideOccupancyAction("idle", false, true, 0)).toBe("none");
|
||||
});
|
||||
it("does not pause when already paused", () => {
|
||||
expect(decideOccupancyAction("paused", false, true, 0)).toBe("none");
|
||||
});
|
||||
it("resumes when re-populated and we auto-paused", () => {
|
||||
expect(decideOccupancyAction("paused", true, true, 2)).toBe("resume");
|
||||
});
|
||||
it("does NOT resume a user-paused track on re-population", () => {
|
||||
expect(decideOccupancyAction("paused", false, true, 2)).toBe("none");
|
||||
});
|
||||
it("does nothing when re-populated and already playing", () => {
|
||||
expect(decideOccupancyAction("playing", false, true, 2)).toBe("none");
|
||||
});
|
||||
it("resume is independent of the enabled flag (we already auto-paused)", () => {
|
||||
expect(decideOccupancyAction("paused", true, false, 1)).toBe("resume");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
export type PlayerStateName = "idle" | "playing" | "paused";
|
||||
export type OccupancyAction = "pause" | "resume" | "none";
|
||||
|
||||
/**
|
||||
* Decide what auto-pause should do given channel occupancy.
|
||||
* - empty (userCount <= 0): pause iff enabled and currently playing.
|
||||
* - re-populated (userCount > 0): resume iff we previously auto-paused and are still paused.
|
||||
* `autoPaused` distinguishes our auto-pause from a user pause, so user pauses are never resumed.
|
||||
*/
|
||||
export function decideOccupancyAction(
|
||||
playerState: PlayerStateName,
|
||||
autoPaused: boolean,
|
||||
enabled: boolean,
|
||||
userCount: number,
|
||||
): OccupancyAction {
|
||||
const empty = userCount <= 0;
|
||||
if (empty) {
|
||||
if (enabled && playerState === "playing") return "pause";
|
||||
return "none";
|
||||
}
|
||||
if (autoPaused && playerState === "paused") return "resume";
|
||||
return "none";
|
||||
}
|
||||
+63
-5
@@ -17,6 +17,7 @@ import type { BotDatabase, ProfileConfig } from "../data/database.js";
|
||||
import type { BotConfig } from "../data/config.js";
|
||||
import { BotProfileManager } from "./profile.js";
|
||||
import type { AvatarStore } from "../data/avatars.js";
|
||||
import { decideOccupancyAction } from "./auto-pause.js";
|
||||
|
||||
export interface BotInstanceOptions {
|
||||
id: string;
|
||||
@@ -66,6 +67,7 @@ export class BotInstance extends EventEmitter {
|
||||
private isAdvancing = false;
|
||||
private idleTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private channelUserCount = 0;
|
||||
private autoPaused = false;
|
||||
private profileManager: BotProfileManager;
|
||||
private isFmMode = false;
|
||||
|
||||
@@ -143,6 +145,8 @@ export class BotInstance extends EventEmitter {
|
||||
// short-circuited on !this.connected, leaving player stuck as "playing".
|
||||
this.connected = false;
|
||||
this.player.stop();
|
||||
// A lifecycle change must not leave a stale auto-resume armed.
|
||||
this.autoPaused = false;
|
||||
// Only emit externally once per lifecycle so clients don't see a
|
||||
// duplicate "disconnected" after an explicit disconnect() call.
|
||||
if (this.disconnectEmitted) return;
|
||||
@@ -151,8 +155,26 @@ export class BotInstance extends EventEmitter {
|
||||
});
|
||||
|
||||
this.tsClient.on("connected", () => {
|
||||
// Fresh connection — clear any stale auto-pause flag from a prior session.
|
||||
this.autoPaused = false;
|
||||
this._startIdlePoller();
|
||||
});
|
||||
|
||||
// React near-instantly to channel membership changes. The 30s idle
|
||||
// poller remains the fallback if any of these events are missed.
|
||||
this.tsClient.on("clientEnter", () => void this.refreshOccupancy());
|
||||
this.tsClient.on("clientLeave", () => void this.refreshOccupancy());
|
||||
this.tsClient.on("clientMoved", () => void this.refreshOccupancy());
|
||||
}
|
||||
|
||||
private async refreshOccupancy(): Promise<void> {
|
||||
if (!this.connected) return;
|
||||
try {
|
||||
const clients = await this.tsClient.getClientsInChannel();
|
||||
this.handleOccupancy(clients.length - 1);
|
||||
} catch {
|
||||
// ignore — the 30s poll is the fallback
|
||||
}
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
@@ -187,6 +209,16 @@ export class BotInstance extends EventEmitter {
|
||||
if (minutes === 0) this._cancelIdleTimer();
|
||||
}
|
||||
|
||||
/** 外部更新 autoPauseOnEmpty(由 API 保存时调用) */
|
||||
updateAutoPause(enabled: boolean): void {
|
||||
this.config.autoPauseOnEmpty = enabled;
|
||||
if (!enabled && this.autoPaused && this.player.getState() === "paused") {
|
||||
this.player.resume();
|
||||
this.autoPaused = false;
|
||||
this.emit("stateChange");
|
||||
}
|
||||
}
|
||||
|
||||
private _startIdlePoller(): void {
|
||||
// 每 30 秒检查一次频道人数
|
||||
const poll = async () => {
|
||||
@@ -194,17 +226,35 @@ export class BotInstance extends EventEmitter {
|
||||
try {
|
||||
const clients = await this.tsClient.getClientsInChannel();
|
||||
const userCount = clients.length - 1; // 排除 bot 自身
|
||||
if (userCount <= 0) {
|
||||
this._scheduleIdleCheck();
|
||||
} else {
|
||||
this._cancelIdleTimer();
|
||||
}
|
||||
this.handleOccupancy(userCount);
|
||||
} catch { /* ignore */ }
|
||||
setTimeout(poll, 30_000);
|
||||
};
|
||||
setTimeout(poll, 30_000);
|
||||
}
|
||||
|
||||
private handleOccupancy(userCount: number): void {
|
||||
// idle-disconnect (unchanged behavior)
|
||||
if (userCount <= 0) this._scheduleIdleCheck();
|
||||
else this._cancelIdleTimer();
|
||||
// auto-pause
|
||||
const action = decideOccupancyAction(
|
||||
this.player.getState(),
|
||||
this.autoPaused,
|
||||
this.config.autoPauseOnEmpty,
|
||||
userCount,
|
||||
);
|
||||
if (action === "pause") {
|
||||
this.player.pause();
|
||||
this.autoPaused = true;
|
||||
this.emit("stateChange");
|
||||
} else if (action === "resume") {
|
||||
this.player.resume();
|
||||
this.autoPaused = false;
|
||||
this.emit("stateChange");
|
||||
}
|
||||
}
|
||||
|
||||
private _scheduleIdleCheck(): void {
|
||||
if (this.idleTimer !== null) return; // 已经在倒计时,不重复创建
|
||||
const minutes = this.config.idleTimeoutMinutes ?? 0;
|
||||
@@ -381,6 +431,9 @@ export class BotInstance extends EventEmitter {
|
||||
}
|
||||
song.url = url;
|
||||
this.player.play(url, 0, song.duration);
|
||||
// Fresh playback (re)start — clear auto-pause so a later occupancy
|
||||
// change won't try to "resume" a track the user already restarted.
|
||||
this.autoPaused = false;
|
||||
this.database.addPlayHistory({
|
||||
botId: this.id,
|
||||
songId: song.id,
|
||||
@@ -483,18 +536,23 @@ export class BotInstance extends EventEmitter {
|
||||
|
||||
private cmdPause(): string {
|
||||
this.player.pause();
|
||||
// User-initiated pause — clear auto-pause so occupancy won't auto-resume it.
|
||||
this.autoPaused = false;
|
||||
this.emit("stateChange");
|
||||
return "Paused";
|
||||
}
|
||||
|
||||
private cmdResume(): string {
|
||||
this.player.resume();
|
||||
// User-initiated resume — drop any auto-pause flag.
|
||||
this.autoPaused = false;
|
||||
this.emit("stateChange");
|
||||
return "Resumed";
|
||||
}
|
||||
|
||||
private cmdStop(): string {
|
||||
this.player.stop();
|
||||
this.autoPaused = false;
|
||||
this.queue.clear();
|
||||
this.isFmMode = false;
|
||||
this.profileManager.onSongChange(null).catch((err) => {
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
type Identity,
|
||||
type TextMessage,
|
||||
type ClientInfo,
|
||||
type ClientLeftViewEvent,
|
||||
type ClientMovedEvent,
|
||||
type FileUploadInfo,
|
||||
} from "@honeybbq/teamspeak-client";
|
||||
import type { Logger } from "../logger.js";
|
||||
@@ -221,6 +223,20 @@ export class TS3Client extends EventEmitter {
|
||||
{ nickname: info.nickname, id: info.id },
|
||||
"Client entered"
|
||||
);
|
||||
this.emit("clientEnter", info);
|
||||
});
|
||||
|
||||
this.client.on("clientLeave", (ev: ClientLeftViewEvent) => {
|
||||
this.logger.debug({ id: ev.id }, "Client left");
|
||||
this.emit("clientLeave", ev);
|
||||
});
|
||||
|
||||
this.client.on("clientMoved", (ev: ClientMovedEvent) => {
|
||||
this.logger.debug(
|
||||
{ id: ev.id, targetChannelID: ev.targetChannelID.toString() },
|
||||
"Client moved"
|
||||
);
|
||||
this.emit("clientMoved", ev);
|
||||
});
|
||||
|
||||
await this.client.connect();
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import express from "express";
|
||||
import cookieParser from "cookie-parser";
|
||||
import request from "supertest";
|
||||
import pino from "pino";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { createDatabase, type BotDatabase } from "../../data/database.js";
|
||||
import { createUserStore } from "../../data/users.js";
|
||||
import { createSessionStore } from "../../data/sessions.js";
|
||||
import { createAvatarStore } from "../../data/avatars.js";
|
||||
import { createRequireAuth } from "../middleware/requireAuth.js";
|
||||
import { createBotRouter } from "./bot.js";
|
||||
import { getDefaultConfig, type BotConfig } from "../../data/config.js";
|
||||
import { SESSION_COOKIE_NAME } from "../auth/validateSession.js";
|
||||
import type { BotManager } from "../../bot/manager.js";
|
||||
|
||||
/** Records every updateIdleTimeout / updateAutoPause call so the test can assert propagation. */
|
||||
function makeFakeBot() {
|
||||
return {
|
||||
idleTimeoutCalls: [] as number[],
|
||||
autoPauseCalls: [] as boolean[],
|
||||
updateIdleTimeout(minutes: number) {
|
||||
this.idleTimeoutCalls.push(minutes);
|
||||
},
|
||||
updateAutoPause(enabled: boolean) {
|
||||
this.autoPauseCalls.push(enabled);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("bot router /settings", () => {
|
||||
let botDb: BotDatabase;
|
||||
let app: express.Express;
|
||||
let cookie: string;
|
||||
let config: BotConfig;
|
||||
let configPath: string;
|
||||
let tmpDir: string;
|
||||
let fakeBots: ReturnType<typeof makeFakeBot>[];
|
||||
|
||||
beforeEach(async () => {
|
||||
botDb = createDatabase(":memory:");
|
||||
const users = createUserStore(botDb.db);
|
||||
const sessions = createSessionStore(botDb.db);
|
||||
const alice = await users.createUser("alice", "pw-alice", "admin");
|
||||
cookie = `${SESSION_COOKIE_NAME}=${sessions.createSession(alice.id).token}`;
|
||||
|
||||
tmpDir = mkdtempSync(join(tmpdir(), "botsettings-"));
|
||||
configPath = join(tmpDir, "config.json");
|
||||
config = { ...getDefaultConfig(), idleTimeoutMinutes: 15, autoPauseOnEmpty: true };
|
||||
|
||||
fakeBots = [makeFakeBot(), makeFakeBot()];
|
||||
const fakeManager = {
|
||||
getAllBots: () => fakeBots,
|
||||
} as unknown as BotManager;
|
||||
const avatarStore = createAvatarStore(tmpDir);
|
||||
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use(cookieParser());
|
||||
app.use("/api", createRequireAuth(sessions));
|
||||
app.use(
|
||||
"/api/bot",
|
||||
createBotRouter(fakeManager, config, configPath, pino({ level: "silent" }), botDb, avatarStore),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
botDb.close();
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("requires auth", async () => {
|
||||
const res = await request(app).get("/api/bot/settings");
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("GET /settings includes autoPauseOnEmpty reflecting config", async () => {
|
||||
const res = await request(app).get("/api/bot/settings").set("Cookie", cookie);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.idleTimeoutMinutes).toBe(15);
|
||||
expect(res.body.autoPauseOnEmpty).toBe(true);
|
||||
});
|
||||
|
||||
it("POST /settings with autoPauseOnEmpty:false persists and propagates to bots", async () => {
|
||||
const res = await request(app)
|
||||
.post("/api/bot/settings")
|
||||
.set("Cookie", cookie)
|
||||
.send({ autoPauseOnEmpty: false });
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
// in-memory config mutated
|
||||
expect(config.autoPauseOnEmpty).toBe(false);
|
||||
|
||||
// propagated to every live bot
|
||||
for (const bot of fakeBots) {
|
||||
expect(bot.autoPauseCalls).toEqual([false]);
|
||||
}
|
||||
|
||||
// follow-up GET reflects the new value
|
||||
const followUp = await request(app).get("/api/bot/settings").set("Cookie", cookie);
|
||||
expect(followUp.body.autoPauseOnEmpty).toBe(false);
|
||||
});
|
||||
|
||||
it("POST /settings still handles idleTimeoutMinutes (no regression)", async () => {
|
||||
const res = await request(app)
|
||||
.post("/api/bot/settings")
|
||||
.set("Cookie", cookie)
|
||||
.send({ idleTimeoutMinutes: 42 });
|
||||
expect(res.status).toBe(200);
|
||||
expect(config.idleTimeoutMinutes).toBe(42);
|
||||
for (const bot of fakeBots) {
|
||||
expect(bot.idleTimeoutCalls).toEqual([42]);
|
||||
}
|
||||
const followUp = await request(app).get("/api/bot/settings").set("Cookie", cookie);
|
||||
expect(followUp.body.idleTimeoutMinutes).toBe(42);
|
||||
});
|
||||
|
||||
it("POST /settings handles both fields together", async () => {
|
||||
const res = await request(app)
|
||||
.post("/api/bot/settings")
|
||||
.set("Cookie", cookie)
|
||||
.send({ idleTimeoutMinutes: 7, autoPauseOnEmpty: false });
|
||||
expect(res.status).toBe(200);
|
||||
expect(config.idleTimeoutMinutes).toBe(7);
|
||||
expect(config.autoPauseOnEmpty).toBe(false);
|
||||
for (const bot of fakeBots) {
|
||||
expect(bot.idleTimeoutCalls).toEqual([7]);
|
||||
expect(bot.autoPauseCalls).toEqual([false]);
|
||||
}
|
||||
});
|
||||
|
||||
it("POST /settings with only autoPauseOnEmpty does not touch idleTimeout bots", async () => {
|
||||
const res = await request(app)
|
||||
.post("/api/bot/settings")
|
||||
.set("Cookie", cookie)
|
||||
.send({ autoPauseOnEmpty: false });
|
||||
expect(res.status).toBe(200);
|
||||
for (const bot of fakeBots) {
|
||||
expect(bot.idleTimeoutCalls).toEqual([]);
|
||||
expect(bot.autoPauseCalls).toEqual([false]);
|
||||
}
|
||||
});
|
||||
|
||||
it("POST /settings ignores non-boolean autoPauseOnEmpty without 400", async () => {
|
||||
const res = await request(app)
|
||||
.post("/api/bot/settings")
|
||||
.set("Cookie", cookie)
|
||||
.send({ idleTimeoutMinutes: 5, autoPauseOnEmpty: "yes" });
|
||||
expect(res.status).toBe(200);
|
||||
// idleTimeout still applied
|
||||
expect(config.idleTimeoutMinutes).toBe(5);
|
||||
// autoPause left at its prior value, not propagated
|
||||
expect(config.autoPauseOnEmpty).toBe(true);
|
||||
for (const bot of fakeBots) {
|
||||
expect(bot.autoPauseCalls).toEqual([]);
|
||||
}
|
||||
});
|
||||
});
|
||||
+37
-21
@@ -21,6 +21,43 @@ export function createBotRouter(
|
||||
res.json({ bots });
|
||||
});
|
||||
|
||||
// GET /api/bot/settings — 读取全局 bot 行为设置
|
||||
// NOTE: must be registered before "/:id" so it isn't shadowed by the param route.
|
||||
router.get("/settings", (_req, res) => {
|
||||
res.json({
|
||||
idleTimeoutMinutes: config.idleTimeoutMinutes ?? 0,
|
||||
autoPauseOnEmpty: config.autoPauseOnEmpty,
|
||||
});
|
||||
});
|
||||
|
||||
// POST /api/bot/settings — 保存全局 bot 行为设置
|
||||
router.post("/settings", (req, res) => {
|
||||
const { idleTimeoutMinutes, autoPauseOnEmpty } = req.body;
|
||||
|
||||
const hasIdle = idleTimeoutMinutes !== undefined;
|
||||
if (hasIdle && (typeof idleTimeoutMinutes !== "number" || idleTimeoutMinutes < 0)) {
|
||||
res.status(400).json({ error: "idleTimeoutMinutes must be a non-negative number" });
|
||||
return;
|
||||
}
|
||||
|
||||
const hasAutoPause = typeof autoPauseOnEmpty === "boolean";
|
||||
|
||||
if (hasIdle) config.idleTimeoutMinutes = idleTimeoutMinutes;
|
||||
if (hasAutoPause) config.autoPauseOnEmpty = autoPauseOnEmpty;
|
||||
saveConfig(configPath, config);
|
||||
|
||||
// 通知所有 bot 实例更新
|
||||
for (const bot of botManager.getAllBots()) {
|
||||
if (hasIdle) bot.updateIdleTimeout(config.idleTimeoutMinutes);
|
||||
if (hasAutoPause) bot.updateAutoPause(config.autoPauseOnEmpty);
|
||||
}
|
||||
|
||||
res.json({
|
||||
idleTimeoutMinutes: config.idleTimeoutMinutes ?? 0,
|
||||
autoPauseOnEmpty: config.autoPauseOnEmpty,
|
||||
});
|
||||
});
|
||||
|
||||
router.get("/:id", (req, res) => {
|
||||
const bot = botManager.getBot(req.params.id);
|
||||
if (!bot) {
|
||||
@@ -185,27 +222,6 @@ export function createBotRouter(
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/bot/settings — 读取全局 bot 行为设置
|
||||
router.get("/settings", (_req, res) => {
|
||||
res.json({ idleTimeoutMinutes: config.idleTimeoutMinutes ?? 0 });
|
||||
});
|
||||
|
||||
// POST /api/bot/settings — 保存全局 bot 行为设置
|
||||
router.post("/settings", (req, res) => {
|
||||
const { idleTimeoutMinutes } = req.body;
|
||||
if (typeof idleTimeoutMinutes !== "number" || idleTimeoutMinutes < 0) {
|
||||
res.status(400).json({ error: "idleTimeoutMinutes must be a non-negative number" });
|
||||
return;
|
||||
}
|
||||
config.idleTimeoutMinutes = idleTimeoutMinutes;
|
||||
saveConfig(configPath, config);
|
||||
// 通知所有 bot 实例更新定时器
|
||||
for (const bot of botManager.getAllBots()) {
|
||||
bot.updateIdleTimeout(idleTimeoutMinutes);
|
||||
}
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
+2
-10
@@ -140,20 +140,12 @@ function cycleMobileMode() {
|
||||
playerStore.setMode(nextMode);
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
onMounted(() => {
|
||||
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,20 +10,8 @@
|
||||
</div>
|
||||
|
||||
<div class="nav-right">
|
||||
<!-- 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">
|
||||
<!-- 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>
|
||||
@@ -34,7 +22,7 @@
|
||||
<div v-if="dropdownOpen" class="bot-dropdown">
|
||||
<div class="bot-dropdown-header">机器人</div>
|
||||
<div
|
||||
v-for="bot in displayedBots"
|
||||
v-for="bot in store.bots"
|
||||
:key="bot.id"
|
||||
class="bot-card"
|
||||
:class="{ active: bot.id === store.activeBotId }"
|
||||
@@ -150,11 +138,6 @@ 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>>({});
|
||||
@@ -173,14 +156,6 @@ 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(/\/+$/, '');
|
||||
@@ -395,60 +370,6 @@ 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); }
|
||||
|
||||
+1
-7
@@ -11,10 +11,4 @@ installApiClient();
|
||||
const app = createApp(App);
|
||||
app.use(createPinia());
|
||||
app.use(router);
|
||||
// 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'));
|
||||
app.mount('#app');
|
||||
@@ -1,6 +1,5 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router';
|
||||
import { useSession } from '../composables/useSession.js';
|
||||
import { usePlayerStore } from '../stores/player.js';
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
@@ -57,22 +56,6 @@ 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,6 +1,5 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import axios from 'axios';
|
||||
import { resolveScopedBot } from './scope.js';
|
||||
|
||||
export interface Song {
|
||||
id: string;
|
||||
@@ -51,9 +50,6 @@ 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 */
|
||||
@@ -77,10 +73,6 @@ 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;
|
||||
},
|
||||
@@ -133,8 +125,6 @@ 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]) {
|
||||
@@ -142,32 +132,6 @@ 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;
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,10 +0,0 @@
|
||||
/** 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.setScope(botId);
|
||||
router.replace({ path: '/', query: { bot: botId } });
|
||||
store.setActiveBotId(botId);
|
||||
router.replace('/');
|
||||
} else {
|
||||
notFound.value = true;
|
||||
}
|
||||
|
||||
@@ -433,6 +433,18 @@
|
||||
<button class="btn-primary" @click="saveIdleTimeout">保存</button>
|
||||
</div>
|
||||
</div>
|
||||
<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="autoPauseOnEmpty"
|
||||
type="checkbox"
|
||||
class="profile-toggle-switch"
|
||||
@change="saveIdleTimeout"
|
||||
/>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<!-- Bot Profile (TeamSpeak Behavior) -->
|
||||
@@ -885,17 +897,22 @@ async function savePrefix() {
|
||||
|
||||
// Idle timeout
|
||||
const idleTimeout = ref(0);
|
||||
const autoPauseOnEmpty = ref(true);
|
||||
|
||||
async function loadIdleTimeout() {
|
||||
try {
|
||||
const res = await axios.get('/api/bot/settings');
|
||||
idleTimeout.value = res.data.idleTimeoutMinutes ?? 0;
|
||||
autoPauseOnEmpty.value = res.data.autoPauseOnEmpty ?? true;
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
async function saveIdleTimeout() {
|
||||
try {
|
||||
await axios.post('/api/bot/settings', { idleTimeoutMinutes: idleTimeout.value });
|
||||
await axios.post('/api/bot/settings', {
|
||||
idleTimeoutMinutes: idleTimeout.value,
|
||||
autoPauseOnEmpty: autoPauseOnEmpty.value,
|
||||
});
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
@@ -1794,6 +1811,12 @@ onUnmounted(() => {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
// Standalone toggle inside 行为设置 (not part of a bordered list)
|
||||
.behavior-toggle {
|
||||
border-bottom: none;
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.profile-bot-header {
|
||||
padding: 14px 12px;
|
||||
|
||||
Reference in new issue
Block a user