import { createApp } from "vue"; import "./styles.css"; const ROLE_OPTIONS = [ ["super_admin", "一级超管"], ["active_operator", "二级已上号队首"], ["pending_operator", "三级待上号队首"], ["viewer", "四级普通观众"] ]; const COMMAND_ROLE_DEFAULTS = { queue: ROLE_OPTIONS.map(([id]) => id), signin: ROLE_OPTIONS.map(([id]) => id), login: ["super_admin", "pending_operator"], confirm_yes: ["super_admin", "pending_operator"], confirm_no: ["super_admin", "pending_operator"], run: ["super_admin", "active_operator"], leave: ["super_admin", "active_operator", "viewer"], reset: ["super_admin"], points: ROLE_OPTIONS.map(([id]) => id), queue_list: ROLE_OPTIONS.map(([id]) => id), help: ROLE_OPTIONS.map(([id]) => id) }; const CMD_LABELS = { queue: "排队", signin: "签到", login: "上号", confirm_yes: "确认是", confirm_no: "确认不是", run: "执行配置组", leave: "退出", reset: "重置", points: "积分", queue_list: "队列", help: "帮助" }; const CMD_HAS_ARG = { run: true }; const clone = (value) => JSON.parse(JSON.stringify(value ?? {})); const splitList = (value) => String(value || "").replaceAll(",", ",").split(",").map((item) => item.trim()).filter(Boolean); const toDateTimeInput = (value) => { const date = value instanceof Date ? value : new Date(value); const pad = (part) => String(part).padStart(2, "0"); return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`; }; const normalizeRoles = (value, fallback) => { const next = Array.isArray(value) ? value.filter((role) => ROLE_OPTIONS.some(([id]) => id === role)) : []; return next.length ? next : [...fallback]; }; const adminApi = async (url, options = {}) => { const res = await fetch(url, { credentials: "same-origin", headers: options.body && !(options.body instanceof FormData) ? { "Content-Type": "application/json", ...(options.headers || {}) } : options.headers, ...options }); const text = await res.text(); let data = {}; if (text) { try { data = JSON.parse(text); } catch { data = { raw: text }; } } if (!res.ok || data.success === false) { const err = new Error(data.error || data.msg || `请求失败: ${res.status}`); err.status = res.status; err.code = data.code || ""; err.payload = data; throw err; } return data; }; const ensureDraftShape = (draft) => { draft.bilibili ||= {}; draft.bilibili.cookie_auto_refresh_enabled ??= true; draft.bilibili.cookie_check_interval_hours ??= 6; draft.bettergi ||= {}; draft.global ||= {}; draft.queue ||= {}; draft.frontend ||= {}; draft.frontend.theme ||= "classic"; draft.music_monitor ||= {}; draft.music_monitor.request_player ||= {}; draft.broadcast ||= {}; draft.broadcast.tts ||= {}; draft.broadcast.tts["faster-qwen3-tts"] ||= {}; draft.broadcast.tts_categories ||= {}; for (const category of ["signin", "queue", "song_request", "login", "execution", "points", "help", "reset", "system"]) { draft.broadcast.tts_categories[category] ??= true; } draft.system ||= {}; const systemDefaults = { enable_startup_shortcut: true, startup_bat: "run.bat", live_start_time: "09:00", live_end_time: "23:00", auto_reboot_enabled: false, auto_reboot_time: "03:00", launch_bilibili_live_enabled: false, launch_bilibili_live_time: "19:30", bilibili_live_exe: "", launch_genshin_enabled: false, launch_genshin_time: "19:40", genshin_exe: "", bilibili_push_enabled: false, bilibili_push_time: "19:50", bilibili_push_window_keyword: "直播姬", bilibili_push_click_x_ratio: 0.741, bilibili_push_click_y_ratio: 0.907, bilibili_stop_push_enabled: false, bilibili_stop_push_time: "23:00", bilibili_stop_push_click_x_ratio: 0.741, bilibili_stop_push_click_y_ratio: 0.907, bilibili_stop_push_confirm_enter: true }; for (const [key, value] of Object.entries(systemDefaults)) { draft.system[key] ??= value; } draft.rules ||= []; draft.commands ||= {}; draft.music_monitor.targets ||= []; draft.global.admin_uids ||= []; draft.global.admin_uidsText = draft.global.admin_uids.join(","); draft.music_monitor.targetsText = draft.music_monitor.targets.join(","); draft.music_monitor.request_player.commands ||= ["点歌", "dg"]; draft.music_monitor.request_player.handoff_lead_sec ??= 1.2; draft.music_monitor.request_player.max_duration_sec ??= 600; draft.music_monitor.request_player.netease_music_u ??= ""; draft.music_monitor.request_player.commandsText = draft.music_monitor.request_player.commands.join(","); draft.music_monitor.request_player.allowed_roles = normalizeRoles( draft.music_monitor.request_player.allowed_roles, ROLE_OPTIONS.map(([id]) => id) ); for (const [key, label] of Object.entries(CMD_LABELS)) { draft.commands[key] ||= { enabled: true, aliases: [label] }; draft.commands[key].aliases ||= [label]; draft.commands[key].allowed_roles = normalizeRoles( draft.commands[key].allowed_roles, COMMAND_ROLE_DEFAULTS[key] || ROLE_OPTIONS.map(([id]) => id) ); } for (const rule of draft.rules) { if (!rule || typeof rule !== "object") continue; rule.groups ||= []; rule.allowed_roles = normalizeRoles(rule.allowed_roles, ROLE_OPTIONS.map(([id]) => id)); } return draft; }; createApp({ data() { return { tabs: [ ["overview", "总览"], ["config", "配置"], ["rules", "权限/规则"], ["queue", "队列"], ["users", "用户"], ["redemption", "兑换码"], ["songs", "点歌"], ["media", "音乐/TTS"], ["system", "系统定时"], ["logs", "日志"], ["raw", "JSON"] ], activeTab: "overview", loading: false, saving: false, bootstrapped: false, sessionChecked: false, authenticated: false, bootstrapPassword: "", loginPassword: "", messages: [], messageSeq: 0, state: {}, users: [], userQuery: "", redemptionCodes: [], redemptionRecords: [], redemptionSaving: false, redemptionForm: { code: "", points: 5, starts_at: toDateTimeInput(new Date()), ends_at: toDateTimeInput(new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)), max_redemptions: "", enabled: true }, systemLog: [], bgiLog: [], music: {}, config: null, draft: null, rawConfig: "", ttsTestText: "欢迎来到直播间,发送排队即可上号。", ttsTestResult: "", giftTestName: "测试观众", giftTestGift: "小电视飞船", giftTestNum: 1, giftTestValue: 0, giftTestResult: "", lastLoadedAt: "", songKeyword: "", songResults: [], songSearching: false, streamStatus: "idle", fallbackEnabled: false, bilibiliQr: { state: "idle", message: "", expiresIn: 0, hasQrImage: false, credentialConfigured: false, account: null, imageUrl: "" }, bilibiliQrStarting: false, bilibiliQrPolling: false, bilibiliQrTimer: null, neteaseQr: { state: "idle", message: "", expiresIn: 0, hasQrImage: false, credentialConfigured: false, account: null, imageUrl: "" }, neteaseQrStarting: false, neteaseQrPolling: false, neteaseQrTimer: null }; }, computed: { queue() { return this.state.queue || []; }, queueUsers() { const map = this.state.users || {}; return this.queue.map((uid, index) => ({ uid, index: index + 1, ...(map[String(uid)] || { uname: `用户${uid}`, points: 0 }) })); }, filteredUsers() { const q = this.userQuery.trim().toLowerCase(); if (!q) return this.users; return this.users.filter((user) => String(user.uid).includes(q) || String(user.uname || "").toLowerCase().includes(q)); }, appStatus() { if (this.state.config_error) return ["配置异常", "danger"]; if (this.state.service_state === "FAILED") return ["服务失败", "danger"]; if (this.state.service_state === "DEGRADED") return ["服务降级", "danger"]; if (this.state.service_state === "RECONNECTING") return ["重连中", "idle"]; if (this.state.service_state === "STARTING") return ["启动中", "idle"]; if (this.state.bgi_running) return ["BGI运行中", "ok"]; return ["待机", "idle"]; }, currentOperator() { const uid = this.state.current_admin; if (!uid) return "-"; return (this.state.users || {})[String(uid)]?.uname || uid; }, localAdminUrl() { return this.state.access_urls?.local?.admin || `${window.location.origin}/admin`; }, lanAdminUrls() { return this.state.access_urls?.lan_admin || []; }, songQueue() { return this.music.song_requests?.queue || this.music.requests || []; }, activeSongRequest() { return this.music.song_requests?.active || null; }, songHistory() { return this.music.song_requests?.history || []; }, bannedSongs() { return this.music.song_requests?.banned_song_ids || []; }, bannedUsers() { return this.music.song_requests?.banned_users || []; }, roleOptions() { return ROLE_OPTIONS; } }, methods: { notify(text, type = "success") { const item = { id: ++this.messageSeq, text, type }; this.messages.push(item); window.setTimeout(() => { this.messages = this.messages.filter((message) => message.id !== item.id); }, type === "error" ? 4200 : 2600); }, async initialize() { this.loading = true; try { const [bootstrapStatus, session] = await Promise.all([ adminApi("/api/admin/bootstrap-status"), adminApi("/api/admin/session") ]); this.bootstrapped = !!bootstrapStatus.bootstrapped; this.authenticated = !!session.authenticated; if (this.authenticated) { await this.refreshAll(); this.openStream(); } } catch (err) { this.notify(err.message || String(err), "error"); } finally { this.sessionChecked = true; this.loading = false; } }, async refreshAll() { this.loading = true; try { await Promise.all([ this.loadState(), this.loadUsers(), this.loadRedemptionCodes(), this.loadLogs(), this.loadMusic(), this.loadConfig(), this.loadBilibiliQrStatus(), this.loadNeteaseQrStatus() ]); this.lastLoadedAt = new Date().toLocaleTimeString(); } catch (err) { this.handleAuthLoss(err); this.notify(err.message || String(err), "error"); } finally { this.loading = false; } }, async loadState() { this.state = await adminApi("/api/admin/state"); }, async loadUsers() { const data = await adminApi(`/api/admin/users?q=${encodeURIComponent(this.userQuery || "")}`); this.users = data.users || []; }, async loadRedemptionCodes() { const data = await adminApi("/api/admin/redemption-codes"); this.redemptionCodes = data.codes || []; this.redemptionRecords = data.records || []; }, async loadLogs() { const data = await adminApi("/api/admin/logs"); this.systemLog = data.system || []; this.bgiLog = data.bgi || []; }, async loadMusic() { this.music = await adminApi("/api/admin/music"); }, async loadConfig() { const data = await adminApi("/api/admin/config"); this.config = data.config || {}; this.draft = ensureDraftShape(clone(this.config)); this.rawConfig = JSON.stringify(this.config, null, 2); }, handleAuthLoss(err) { if (!err || ![401, 428].includes(err.status)) return; this.authenticated = false; this.bootstrapped = err.status !== 428; this.stopBilibiliQrPolling(); this.stopNeteaseQrPolling(); this.closeStream(); }, async bootstrapAdmin() { if (!this.bootstrapPassword.trim()) { this.notify("请输入后台密码", "error"); return; } try { await adminApi("/api/admin/bootstrap", { method: "POST", body: JSON.stringify({ password: this.bootstrapPassword.trim() }) }); this.bootstrapPassword = ""; this.bootstrapped = true; this.authenticated = true; await this.refreshAll(); this.openStream(); } catch (err) { this.notify(err.message || String(err), "error"); } }, async loginAdmin() { if (!this.loginPassword.trim()) { this.notify("请输入后台密码", "error"); return; } try { await adminApi("/api/admin/login", { method: "POST", body: JSON.stringify({ password: this.loginPassword.trim() }) }); this.loginPassword = ""; this.authenticated = true; await this.refreshAll(); this.openStream(); } catch (err) { this.notify(err.message || String(err), "error"); } }, async logoutAdmin() { try { await adminApi("/api/admin/logout", { method: "POST" }); } catch (err) { this.notify(err.message || String(err), "error"); } finally { this.authenticated = false; this.stopBilibiliQrPolling(); this.stopNeteaseQrPolling(); this.closeStream(); } }, updateBilibiliQr(result) { const hasQrImage = !!result.has_qr_image; this.bilibiliQr = { state: result.state || "idle", message: result.message || "", expiresIn: Number(result.expires_in || 0), hasQrImage, credentialConfigured: !!result.credential_configured, account: result.account || null, imageUrl: hasQrImage ? (this.bilibiliQr.imageUrl || `/api/admin/bilibili-qr-image?t=${Date.now()}`) : "" }; }, async loadBilibiliQrStatus() { const result = await adminApi("/api/admin/bilibili-qr-status"); this.updateBilibiliQr(result); }, stopBilibiliQrPolling() { if (this.bilibiliQrTimer) { window.clearInterval(this.bilibiliQrTimer); this.bilibiliQrTimer = null; } }, startBilibiliQrPolling() { this.stopBilibiliQrPolling(); this.bilibiliQrTimer = window.setInterval(() => this.pollBilibiliQrLogin(), 1600); }, async startBilibiliQrLogin() { this.bilibiliQrStarting = true; this.stopBilibiliQrPolling(); try { const result = await adminApi("/api/admin/action/start_bilibili_qr_login", { method: "POST", body: "{}" }); this.bilibiliQr.imageUrl = ""; this.updateBilibiliQr(result); if (result.state === "failed") { this.notify(result.message || "B站二维码生成失败", "error"); return; } this.startBilibiliQrPolling(); } catch (err) { this.handleAuthLoss(err); this.notify(err.message || String(err), "error"); } finally { this.bilibiliQrStarting = false; } }, async pollBilibiliQrLogin() { if (this.bilibiliQrPolling) return; this.bilibiliQrPolling = true; try { const result = await adminApi("/api/admin/action/poll_bilibili_qr_login", { method: "POST", body: "{}" }); const previousState = this.bilibiliQr.state; this.updateBilibiliQr(result); if (result.state === "completed") { this.stopBilibiliQrPolling(); await this.loadConfig(); const accountName = result.account?.uname || "B站账号"; this.notify(`${accountName} 登录成功,自动续期已启用`); } else if (["expired", "failed"].includes(result.state)) { this.stopBilibiliQrPolling(); if (result.state !== previousState) { this.notify(result.message || "B站扫码登录失败", "error"); } } } catch (err) { this.stopBilibiliQrPolling(); this.handleAuthLoss(err); this.notify(err.message || String(err), "error"); } finally { this.bilibiliQrPolling = false; } }, updateNeteaseQr(result) { const hasQrImage = !!result.has_qr_image; this.neteaseQr = { state: result.state || "idle", message: result.message || "", expiresIn: Number(result.expires_in || 0), hasQrImage, credentialConfigured: !!result.credential_configured, account: result.account || null, imageUrl: hasQrImage ? (this.neteaseQr.imageUrl || `/api/admin/netease-qr-image?t=${Date.now()}`) : "" }; }, async loadNeteaseQrStatus() { const result = await adminApi("/api/admin/netease-qr-status"); this.updateNeteaseQr(result); }, stopNeteaseQrPolling() { if (this.neteaseQrTimer) { window.clearInterval(this.neteaseQrTimer); this.neteaseQrTimer = null; } }, startNeteaseQrPolling() { this.stopNeteaseQrPolling(); this.neteaseQrTimer = window.setInterval(() => this.pollNeteaseQrLogin(), 1600); }, async startNeteaseQrLogin() { this.neteaseQrStarting = true; this.stopNeteaseQrPolling(); try { const result = await adminApi("/api/admin/action/start_netease_qr_login", { method: "POST", body: "{}" }); this.neteaseQr.imageUrl = ""; this.updateNeteaseQr(result); if (result.state === "failed") { this.notify(result.message || "网易云二维码生成失败", "error"); return; } this.startNeteaseQrPolling(); } catch (err) { this.handleAuthLoss(err); this.notify(err.message || String(err), "error"); } finally { this.neteaseQrStarting = false; } }, async pollNeteaseQrLogin() { if (this.neteaseQrPolling) return; this.neteaseQrPolling = true; try { const result = await adminApi("/api/admin/action/poll_netease_qr_login", { method: "POST", body: "{}" }); const previousState = this.neteaseQr.state; this.updateNeteaseQr(result); if (result.state === "completed") { this.stopNeteaseQrPolling(); await this.loadConfig(); const accountName = result.account?.nickname || "网易云账号"; const vipLabel = Number(result.account?.vip_type || 0) > 0 ? "VIP" : "普通账号"; this.notify(`${accountName} 登录成功(${vipLabel}),MUSIC_U 已自动保存`); } else if (["expired", "failed"].includes(result.state)) { this.stopNeteaseQrPolling(); if (result.state !== previousState) { this.notify(result.message || "网易云扫码登录失败", "error"); } } } catch (err) { this.stopNeteaseQrPolling(); this.handleAuthLoss(err); this.notify(err.message || String(err), "error"); } finally { this.neteaseQrPolling = false; } }, openStream() { this.closeStream(); this.streamStatus = "connecting"; const stream = new EventSource("/api/admin/stream"); stream.addEventListener("open", () => { this.streamStatus = "live"; this.stopFallback(); }); for (const eventName of ["state", "users", "music", "logs", "config"]) { stream.addEventListener(eventName, (event) => { const payload = JSON.parse(event.data || "{}"); if (eventName === "state") this.state = payload; if (eventName === "users") this.users = payload.users || []; if (eventName === "music") this.music = payload; if (eventName === "logs") { this.systemLog = payload.system || []; this.bgiLog = payload.bgi || []; } if (eventName === "config") { this.config = payload.config || {}; this.draft = ensureDraftShape(clone(this.config)); this.rawConfig = JSON.stringify(this.config, null, 2); } this.lastLoadedAt = new Date().toLocaleTimeString(); }); } stream.onerror = async () => { this.streamStatus = "reconnecting"; this.startFallback(); try { const session = await adminApi("/api/admin/session"); if (!session.authenticated) { this.authenticated = false; this.closeStream(); } } catch { this.authenticated = false; this.closeStream(); } }; this._stream = stream; }, closeStream() { if (this._stream) { this._stream.close(); this._stream = null; } this.streamStatus = "idle"; this.stopFallback(); }, startFallback() { if (this._fallbackTimer) return; this.fallbackEnabled = true; this._fallbackTimer = window.setInterval(() => { this.refreshAll().catch(() => {}); }, 30000); }, stopFallback() { this.fallbackEnabled = false; if (this._fallbackTimer) { window.clearInterval(this._fallbackTimer); this._fallbackTimer = null; } }, async saveDraft(message = "配置已保存") { this.saving = true; try { const payload = clone(this.draft); payload.global ||= {}; payload.music_monitor ||= {}; payload.music_monitor.request_player ||= {}; payload.global.admin_uids = splitList(payload.global.admin_uidsText).map((item) => Number(item)).filter((item) => Number.isFinite(item)); delete payload.global.admin_uidsText; payload.music_monitor.targets = splitList(payload.music_monitor.targetsText); delete payload.music_monitor.targetsText; payload.music_monitor.request_player.commands = splitList(payload.music_monitor.request_player.commandsText); delete payload.music_monitor.request_player.commandsText; await adminApi("/api/admin/config", { method: "POST", body: JSON.stringify({ config: payload }) }); this.notify(message); } catch (err) { this.handleAuthLoss(err); this.notify(err.message || String(err), "error"); } finally { this.saving = false; } }, async saveRawConfig() { try { this.draft = ensureDraftShape(JSON.parse(this.rawConfig)); } catch (err) { this.notify(`JSON 格式错误: ${err.message}`, "error"); return; } await this.saveDraft("JSON 配置已保存"); }, cmdAliasesText(key) { return (this.draft?.commands?.[key]?.aliases || []).join(","); }, splitList(value) { return splitList(value); }, setCmdAliases(key, value) { this.draft.commands[key].aliases = splitList(value); }, cmdLabel(key) { return CMD_LABELS[key] || key; }, cmdHasArg(key) { return !!CMD_HAS_ARG[key]; }, hasRole(target, role) { return Array.isArray(target) && target.includes(role); }, toggleRole(target, role) { if (!Array.isArray(target)) return; const next = new Set(target); if (next.has(role)) next.delete(role); else next.add(role); target.splice(0, target.length, ...ROLE_OPTIONS.map(([id]) => id).filter((id) => next.has(id))); }, addRule() { this.draft.rules.push({ keyword: "", match_type: "contains", groups: [], cooldown: 60, admin_only: false, reply: "", allowed_roles: ROLE_OPTIONS.map(([id]) => id) }); }, removeRule(index) { this.draft.rules.splice(index, 1); }, normalizeRuleGroups(rule) { return Array.isArray(rule.groups) ? rule.groups.join(",") : String(rule.groups || ""); }, setRuleGroups(rule, value) { rule.groups = splitList(value); }, async adminAction(name, payload = null) { try { await adminApi(`/api/admin/action/${name}`, { method: "POST", body: payload ? JSON.stringify(payload) : undefined }); this.notify("操作已执行"); } catch (err) { this.handleAuthLoss(err); this.notify(err.message || String(err), "error"); } }, async testNeteaseLogin() { try { const result = await adminApi("/api/admin/action/test_netease_login", { method: "POST", body: JSON.stringify({ music_u: this.draft?.music_monitor?.request_player?.netease_music_u || "" }) }); const vipLabel = Number(result.vip_type || 0) > 0 ? "VIP" : "普通账号"; await this.loadConfig(); await this.loadNeteaseQrStatus(); this.notify(`网易云登录有效并已保存:${result.nickname}(${vipLabel})`); } catch (err) { this.handleAuthLoss(err); this.notify(err.message || String(err), "error"); } }, async addPoints(uid, points) { try { await adminApi("/api/admin/users/add-points", { method: "POST", body: JSON.stringify({ uid, points }) }); } catch (err) { this.handleAuthLoss(err); this.notify(err.message || String(err), "error"); } }, async createRedemptionCode() { const form = this.redemptionForm; if (!String(form.code || "").trim()) { this.notify("请填写兑换码", "error"); return; } if (!Number.isInteger(Number(form.points)) || Number(form.points) <= 0) { this.notify("兑换积分必须是正整数", "error"); return; } this.redemptionSaving = true; try { await adminApi("/api/admin/redemption-codes", { method: "POST", body: JSON.stringify({ code: String(form.code).trim(), points: Number(form.points), starts_at: form.starts_at, ends_at: form.ends_at, max_redemptions: form.max_redemptions === "" ? null : Number(form.max_redemptions), enabled: !!form.enabled }) }); form.code = ""; await this.loadRedemptionCodes(); this.notify("兑换码已创建"); } catch (err) { this.handleAuthLoss(err); this.notify(err.message || String(err), "error"); } finally { this.redemptionSaving = false; } }, async toggleRedemptionCode(code) { try { await adminApi(`/api/admin/redemption-codes/${code.id}/enabled`, { method: "POST", body: JSON.stringify({ enabled: !code.enabled }) }); await this.loadRedemptionCodes(); this.notify(code.enabled ? "兑换码已停用" : "兑换码已启用"); } catch (err) { this.handleAuthLoss(err); this.notify(err.message || String(err), "error"); } }, async deleteRedemptionCode(code) { if (!window.confirm(`确认删除兑换码“${code.code}”?兑换记录会保留。`)) return; try { await adminApi(`/api/admin/redemption-codes/${code.id}`, { method: "DELETE" }); await this.loadRedemptionCodes(); this.notify("兑换码已删除,历史记录已保留"); } catch (err) { this.handleAuthLoss(err); this.notify(err.message || String(err), "error"); } }, redemptionCodeStatus(code) { if (!code.enabled) return "已停用"; const now = Date.now(); if (now < new Date(code.starts_at).getTime()) return "未生效"; if (now >= new Date(code.ends_at).getTime()) return "已过期"; if (code.remaining_count === 0) return "已兑完"; return "生效中"; }, formatRedemptionTime(value) { return String(value || "-").replace("T", " ").replace("+08:00", ""); }, async saveUserFlags(user) { try { await adminApi("/api/admin/users/set-flags", { method: "POST", body: JSON.stringify({ uid: Number(user.uid), blocked_all: !!user.blocked_all, blocked_queue: !!user.blocked_queue, blocked_song_request: !!user.blocked_song_request, note: user.note || "" }) }); this.notify("用户状态已保存"); } catch (err) { this.handleAuthLoss(err); this.notify(err.message || String(err), "error"); } }, async deleteUser(uid) { try { await adminApi("/api/admin/users/delete", { method: "POST", body: JSON.stringify({ uid }) }); this.notify("用户已删除"); } catch (err) { this.handleAuthLoss(err); this.notify(err.message || String(err), "error"); } }, async kickUser(uid) { try { await adminApi("/api/admin/users/kick", { method: "POST", body: JSON.stringify({ uid }) }); this.notify("用户已移出队列"); } catch (err) { this.handleAuthLoss(err); this.notify(err.message || String(err), "error"); } }, async searchSongs() { const keyword = this.songKeyword.trim(); if (!keyword) { this.notify("请输入歌曲名或链接", "error"); return; } this.songSearching = true; try { const data = await adminApi(`/api/admin/song-search?q=${encodeURIComponent(keyword)}`); this.songResults = data.results || []; } catch (err) { this.handleAuthLoss(err); this.notify(err.message || String(err), "error"); } finally { this.songSearching = false; } }, async addSong(song) { try { await adminApi("/api/admin/song-requests/add", { method: "POST", body: JSON.stringify({ song }) }); this.notify("歌曲已加入点歌队列"); } catch (err) { this.handleAuthLoss(err); this.notify(err.message || String(err), "error"); } }, async moveSong(song, toIndex) { try { await adminApi("/api/admin/song-requests/move", { method: "POST", body: JSON.stringify({ id: song.id, to_index: toIndex }) }); } catch (err) { this.handleAuthLoss(err); this.notify(err.message || String(err), "error"); } }, async playSongNow(song) { try { await adminApi("/api/admin/song-requests/play-now", { method: "POST", body: JSON.stringify({ id: song.id }) }); this.notify("已立即切歌"); } catch (err) { this.handleAuthLoss(err); this.notify(err.message || String(err), "error"); } }, async skipCurrentSong() { try { await adminApi("/api/admin/song-requests/skip-current", { method: "POST", body: "{}" }); this.notify("已跳过当前歌曲"); } catch (err) { this.handleAuthLoss(err); this.notify(err.message || String(err), "error"); } }, async removeSong(song) { try { await adminApi("/api/admin/song-requests/remove", { method: "POST", body: JSON.stringify({ id: song.id }) }); } catch (err) { this.handleAuthLoss(err); this.notify(err.message || String(err), "error"); } }, async clearSongs() { try { await adminApi("/api/admin/song-requests/clear", { method: "POST", body: "{}" }); this.notify("点歌队列已清空"); } catch (err) { this.handleAuthLoss(err); this.notify(err.message || String(err), "error"); } }, async toggleBanSong(songId, ban) { try { await adminApi("/api/admin/song-requests/ban-song", { method: "POST", body: JSON.stringify({ id: songId, ban }) }); } catch (err) { this.handleAuthLoss(err); this.notify(err.message || String(err), "error"); } }, async toggleBanUser(uid, uname, ban) { try { await adminApi("/api/admin/song-requests/ban-user", { method: "POST", body: JSON.stringify({ uid, uname, ban }) }); } catch (err) { this.handleAuthLoss(err); this.notify(err.message || String(err), "error"); } }, async testTts() { this.ttsTestResult = "合成中..."; try { const data = await adminApi("/api/admin/action/test_tts", { method: "POST", body: JSON.stringify({ text: this.ttsTestText, play: true }) }); this.ttsTestResult = `成功,耗时 ${data.duration_ms}ms,音频 ${data.audio_bytes} bytes`; } catch (err) { this.handleAuthLoss(err); this.ttsTestResult = err.message || String(err); } }, async testGiftEffect() { this.giftTestResult = "注入中..."; try { const data = await adminApi("/api/admin/action/test_gift_effect", { method: "POST", body: JSON.stringify({ uname: this.giftTestName, gift_name: this.giftTestGift, num: this.giftTestNum, value: this.giftTestValue }) }); this.giftTestResult = data.msg || "已注入,请到前台页面查看特效"; } catch (err) { this.handleAuthLoss(err); this.giftTestResult = err.message || String(err); } }, async saveSystemSchedule() { if (!this.draft?.system) return; this.saving = true; try { await adminApi("/api/admin/action/save_system_schedule_config", { method: "POST", body: JSON.stringify(clone(this.draft.system)) }); this.notify("系统定时配置已保存,开机自启设置已同步"); } catch (err) { this.handleAuthLoss(err); this.notify(err.message || String(err), "error"); } finally { this.saving = false; } }, async testDeleteMihoyoSdkRegistry() { if (!window.confirm("确认删除当前 Windows 用户的 HKCU\\Software\\miHoYoSDK 注册表键?此操作用于测试扫码失败重置前的清理行为。")) { return; } try { const data = await adminApi("/api/admin/action/test_delete_mihoyo_sdk_registry", { method: "POST", body: "{}" }); this.notify(data.message || (data.deleted ? "miHoYoSDK 注册表已删除" : "注册表键不存在,无需清理")); } catch (err) { this.handleAuthLoss(err); this.notify(err.message || String(err), "error"); } }, async testBilibiliPush() { try { await adminApi("/api/admin/action/test_bilibili_push", { method: "POST", body: "{}" }); this.notify("已点击直播姬推流位置"); } catch (err) { this.handleAuthLoss(err); this.notify(err.message || String(err), "error"); } }, async testBilibiliStopPush() { try { await adminApi("/api/admin/action/test_bilibili_stop_push", { method: "POST", body: "{}" }); this.notify("已点击关闭推流位置"); } catch (err) { this.handleAuthLoss(err); this.notify(err.message || String(err), "error"); } }, async uploadBackground(event) { const file = event.target.files?.[0]; if (!file) return; const fd = new FormData(); fd.append("background", file); try { await adminApi("/api/admin/upload_background", { method: "POST", body: fd }); this.notify("背景已上传"); } catch (err) { this.handleAuthLoss(err); this.notify(err.message || String(err), "error"); } }, streamLabel() { if (this.streamStatus === "live") return "实时已连接"; if (this.streamStatus === "reconnecting") return this.fallbackEnabled ? "实时重连中 · 30s兜底刷新" : "实时重连中"; if (this.streamStatus === "connecting") return "实时连接中"; return "实时未连接"; } }, mounted() { this.initialize(); }, beforeUnmount() { this.stopBilibiliQrPolling(); this.stopNeteaseQrPolling(); this.closeStream(); }, template: `

直播联动后台

正在检查后台状态...

{{ item.text }}

初始化后台口令

首次初始化仅允许本机访问。设置完成后,局域网内后台统一使用这一个共享密码登录。

{{ item.text }}

后台登录

后台数据通过实时推送同步,登录后可进行积分、换歌、用户和权限管理。

{{ item.text }}

{{ tabs.find((tab) => tab[0] === activeTab)?.[1] }}

配置 revision {{ state.config_revision || '-' }} · {{ lastLoadedAt || '未刷新' }} · {{ streamLabel() }}

排队人数{{ queue.length || 0 }}
总用户{{ users.length || 0 }}
待播点歌{{ songQueue.length || 0 }}
当前队首{{ currentOperator }}

运行状态

{{ state.service_state || '-' }} {{ state.login_status || '-' }} {{ state.current_group || '-' }} {{ state.billing_uid || '-' }} {{ music.current?.title || '-' }} {{ activeSongRequest?.name || '-' }}

访问地址

手机后台{{ url }}

服务健康

服务状态重启信息错误
{{ svc.name }} {{ svc.state }} {{ svc.restarts || 0 }} {{ svc.message || '-' }} {{ svc.error || '-' }}

最近弹幕

{{ item.uname }} {{ item.text }}
暂无弹幕

快捷操作

B站直播间

BetterGI

队列积分

前台视觉

点歌权限

内置指令权限与别名

自定义规则

当前队列

#UID昵称积分操作
{{ u.index }}队首 {{ u.uid }}{{ u.uname }}{{ u.points }}
队列为空

用户积分与封禁

UID昵称积分角色封禁备注操作
{{ user.uid }} {{ user.uname }} {{ user.points }} {{ roleOptions.find(([id]) => id === user.role)?.[1] || user.role }}

创建兑换码

兑换码列表

英文不区分大小写,每个用户对同一码只能兑换一次
兑换码积分有效期(北京时间)兑换进度状态操作
{{ code.code }} +{{ code.points }}
{{ formatRedemptionTime(code.starts_at) }}
至 {{ formatRedemptionTime(code.ends_at) }}
{{ code.redeemed_count }} / {{ code.max_redemptions ?? '不限' }}
剩余 {{ code.remaining_count ?? '不限' }}
{{ redemptionCodeStatus(code) }}
尚未创建兑换码

兑换记录

时间(北京时间)兑换码用户积分余额变化状态
{{ formatRedemptionTime(record.redeemed_at) }} {{ record.code }} {{ record.uname }}
UID {{ record.uid }}
+{{ record.points }} {{ record.balance_before }} → {{ record.balance_after ?? '处理中' }} {{ record.status === 'completed' ? '已完成' : '处理中' }}
暂无兑换记录

手动搜歌加歌

歌曲歌手ID操作
{{ song.name }} {{ song.artist }} {{ song.id }}
搜索结果会显示在这里。

当前播放与待播点歌

当前系统歌曲{{ music.current?.title || '-' }}{{ music.current?.artist || '-' }}
当前点歌任务{{ activeSongRequest?.name || '-' }}{{ activeSongRequest?.artist || '-' }}
#歌曲点歌人来源操作
{{ index + 1 }} {{ song.name }}
{{ song.artist }}
{{ song.uname || '后台' }} {{ song.source === 'admin' ? '后台' : '观众' }}
当前没有待播点歌

禁点名单

{{ songId }}
暂无禁点歌曲

禁点用户

{{ user.uname }} {{ user.uid }}
暂无禁点用户

播放历史

{{ item.name }} {{ item.artist }} · {{ item.status }}
暂无点歌历史

弹幕播报

TTS 语音

取消勾选的类别仍会发送文字弹幕,只是不加入 TTS 队列。

{{ ttsTestResult }}

礼物特效

向直播前台注入一条模拟礼物,验证特效链路;不会发弹幕、不触发 TTS、不计入统计。折算价值决定特效档位:0=免费小礼物,>0=初级,≥10=中级,≥100=豪华。

{{ giftTestResult }}

音乐监听

观众点歌

观众点歌必须能获取歌曲时长,超过 600 秒(10分钟)的歌曲会被拒绝。

默认在当前歌曲还剩 1.2 秒时播放点歌,避免原播放列表下一首先响几秒;点歌结束后继续原来的下一首。

直播时间

系统会在开播前10分钟自动打开原神和B站直播姬,开播时自动开启推流;关播前1分钟关闭原神和BetterGI,关播时自动停止推流。支持跨日,例如20:00至02:00。

程序路径

时间页面只需要维护开播和关播时间;程序路径通常配置一次即可。

推流按钮设置

扫码登录维护

扫码失败或超时触发自动重置时,系统会先删除当前 Windows 用户的 HKCU\\Software\\miHoYoSDK 注册表键。下面的按钮仅用于单独测试清理行为。

保存直播时间

非直播时段收到有效指令时,只回复“当前未开播哦~”,不会进入TTS。

系统日志

{{ systemLog.join('\\n') }}

BetterGI 日志

{{ bgiLog.join('\\n') }}

完整配置 JSON

` }).mount("#app");