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: `
排队人数{{ 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 || '-' }}
服务健康
| 服务 | 状态 | 重启 | 信息 | 错误 |
| {{ svc.name }} |
{{ svc.state }} |
{{ svc.restarts || 0 }} |
{{ svc.message || '-' }} |
{{ svc.error || '-' }} |
最近弹幕
{{ item.uname }}
{{ item.text }}
暂无弹幕
快捷操作
当前队列
| # | UID | 昵称 | 积分 | 操作 |
| {{ u.index }}队首 |
{{ u.uid }} | {{ u.uname }} | {{ u.points }} |
|
| 队列为空 |
兑换码列表
英文不区分大小写,每个用户对同一码只能兑换一次
| 兑换码 | 积分 | 有效期(北京时间) | 兑换进度 | 状态 | 操作 |
| {{ 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' ? '后台' : '观众' }} |
|
| 当前没有待播点歌 |
播放历史
{{ item.name }}
{{ item.artist }} · {{ item.status }}
暂无点歌历史
系统日志
{{ systemLog.join('\\n') }}
BetterGI 日志
{{ bgiLog.join('\\n') }}
`
}).mount("#app");