Files
Live-streaming/frontend/admin/src/main.js
T
2026-08-15 14:43:56 +08:00

1723 lines
73 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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: `
<div class="auth-shell" v-if="!sessionChecked">
<div class="auth-card">
<h1>直播联动后台</h1>
<p class="muted">正在检查后台状态...</p>
</div>
</div>
<div class="auth-shell" v-else-if="!bootstrapped">
<div class="message-stack" aria-live="polite">
<div v-for="item in messages" :key="item.id" :class="['message', item.type]">{{ item.text }}</div>
</div>
<div class="auth-card">
<h1>初始化后台口令</h1>
<p class="muted">首次初始化仅允许本机访问。设置完成后,局域网内后台统一使用这一个共享密码登录。</p>
<label>后台密码<input v-model="bootstrapPassword" type="password" placeholder="至少 8 位"></label>
<button class="primary" @click="bootstrapAdmin">初始化并登录</button>
</div>
</div>
<div class="auth-shell" v-else-if="!authenticated">
<div class="message-stack" aria-live="polite">
<div v-for="item in messages" :key="item.id" :class="['message', item.type]">{{ item.text }}</div>
</div>
<div class="auth-card">
<h1>后台登录</h1>
<p class="muted">后台数据通过实时推送同步,登录后可进行积分、换歌、用户和权限管理。</p>
<label>共享密码<input v-model="loginPassword" type="password" @keyup.enter="loginAdmin"></label>
<button class="primary" @click="loginAdmin">登录后台</button>
</div>
</div>
<div class="shell" v-else>
<div class="message-stack" aria-live="polite">
<div v-for="item in messages" :key="item.id" :class="['message', item.type]">{{ item.text }}</div>
</div>
<aside class="sidebar">
<div class="brand">
<div class="brand-title">直播联动后台</div>
<div class="brand-sub">Real-time Control Console</div>
</div>
<nav class="nav">
<button v-for="[id,label] in tabs" :key="id" :class="{active: activeTab===id}" @click="activeTab=id">{{ label }}</button>
</nav>
<div class="sidebar-footer">
<span :class="['status-dot', appStatus[1]]"></span>
<span>{{ appStatus[0] }}</span>
</div>
</aside>
<main class="main">
<header class="topbar">
<div>
<h1>{{ tabs.find((tab) => tab[0] === activeTab)?.[1] }}</h1>
<p>配置 revision {{ state.config_revision || '-' }} · {{ lastLoadedAt || '未刷新' }} · {{ streamLabel() }}</p>
</div>
<div class="top-actions">
<button class="secondary" @click="refreshAll" :disabled="loading">全量刷新</button>
<button class="primary" v-if="draft" @click="saveDraft()" :disabled="saving">保存配置</button>
<button class="secondary" @click="logoutAdmin">退出登录</button>
</div>
</header>
<section v-if="activeTab==='overview'" class="stack">
<div class="metrics">
<div class="metric"><span>排队人数</span><strong>{{ queue.length || 0 }}</strong></div>
<div class="metric"><span>总用户</span><strong>{{ users.length || 0 }}</strong></div>
<div class="metric"><span>待播点歌</span><strong>{{ songQueue.length || 0 }}</strong></div>
<div class="metric"><span>当前队首</span><strong>{{ currentOperator }}</strong></div>
</div>
<div class="panel">
<h2>运行状态</h2>
<div class="info-grid">
<label>服务状态</label><span>{{ state.service_state || '-' }}</span>
<label>上号状态</label><span>{{ state.login_status || '-' }}</span>
<label>当前配置组</label><span>{{ state.current_group || '-' }}</span>
<label>当前计费 UID</label><span>{{ state.billing_uid || '-' }}</span>
<label>当前歌曲</label><span>{{ music.current?.title || '-' }}</span>
<label>当前点歌</label><span>{{ activeSongRequest?.name || '-' }}</span>
</div>
</div>
<div class="panel">
<h2>访问地址</h2>
<div class="url-list">
<div class="url-item"><span>本机后台</span><a :href="localAdminUrl" target="_blank" rel="noreferrer">{{ localAdminUrl }}</a></div>
<div v-for="url in lanAdminUrls" :key="url" class="url-item"><span>手机后台</span><a :href="url" target="_blank" rel="noreferrer">{{ url }}</a></div>
</div>
</div>
<div class="panel">
<h2>服务健康</h2>
<table>
<thead><tr><th>服务</th><th>状态</th><th>重启</th><th>信息</th><th>错误</th></tr></thead>
<tbody>
<tr v-for="svc in state.services || []" :key="svc.name">
<td>{{ svc.name }}</td>
<td><span :class="['pill', String(svc.state || '').toLowerCase()]">{{ svc.state }}</span></td>
<td>{{ svc.restarts || 0 }}</td>
<td>{{ svc.message || '-' }}</td>
<td class="error-cell">{{ svc.error || '-' }}</td>
</tr>
</tbody>
</table>
</div>
<div class="panel">
<h2>最近弹幕</h2>
<div class="list-block">
<div class="list-row" v-for="item in state.recent_danmu || []" :key="item.ts">
<strong>{{ item.uname }}</strong>
<span class="muted">{{ item.text }}</span>
</div>
<div v-if="!(state.recent_danmu && state.recent_danmu.length)" class="empty">暂无弹幕</div>
</div>
</div>
<div class="panel">
<h2>快捷操作</h2>
<div class="actions-row">
<button class="danger" @click="adminAction('kill_bgi')">停止 BetterGI</button>
<button class="secondary" @click="adminAction('clear_queue')">清空队列</button>
<button class="secondary" @click="adminAction('reset_signin')">重置签到</button>
<button class="secondary" @click="skipCurrentSong">跳过当前点歌</button>
</div>
</div>
</section>
<section v-if="activeTab==='config' && draft" class="form-grid">
<div class="panel">
<h2>B站直播间</h2>
<label>直播间号<input v-model.number="draft.bilibili.room_id" type="number"></label>
<label>SESSDATA<input v-model="draft.bilibili.sessdata" type="password"></label>
<label>bili_jct<input v-model="draft.bilibili.bili_jct" type="password"></label>
<label class="inline"><input v-model="draft.bilibili.cookie_auto_refresh_enabled" type="checkbox"> 自动续期</label>
<label>检查间隔(小时)<input v-model.number="draft.bilibili.cookie_check_interval_hours" type="number" min="1" max="168" step="1"></label>
<div class="bilibili-qr-login">
<div class="panel-head">
<div>
<strong>B站账号登录</strong>
<small :class="['qr-status', bilibiliQr.state]">{{ bilibiliQr.message || (bilibiliQr.credentialConfigured ? '续期凭据已保存' : '未保存续期凭据') }}</small>
</div>
<button class="secondary" type="button" :disabled="bilibiliQrStarting" @click="startBilibiliQrLogin">
{{ bilibiliQrStarting ? '生成中...' : (bilibiliQr.hasQrImage ? '重新生成' : '扫码登录') }}
</button>
</div>
<div v-if="bilibiliQr.hasQrImage" class="bilibili-qr-body">
<img class="bilibili-qr-image" :src="bilibiliQr.imageUrl" alt="B站登录二维码">
<div class="bilibili-qr-state">
<strong>{{ bilibiliQr.state === 'awaiting_confirm' ? '等待手机确认' : '等待扫码' }}</strong>
<span>{{ bilibiliQr.expiresIn }} 秒后过期</span>
</div>
</div>
<div v-else-if="bilibiliQr.state === 'completed'" class="bilibili-account-state">
<strong>{{ bilibiliQr.account?.uname || 'B站账号' }}</strong>
<span>登录凭据已更新</span>
</div>
</div>
</div>
<div class="panel">
<h2>BetterGI</h2>
<label>BetterGI.exe<input v-model="draft.bettergi.exe_path"></label>
<label>工作目录<input v-model="draft.bettergi.work_dir"></label>
<label>超管 UID<input v-model="draft.global.admin_uidsText" @blur="draft.global.admin_uids = splitList(draft.global.admin_uidsText).map((item) => Number(item)).filter((item) => Number.isFinite(item))" placeholder="逗号分隔 UID"></label>
</div>
<div class="panel">
<h2>队列积分</h2>
<label>初始积分<input v-model.number="draft.queue.initial_points" type="number"></label>
<label>签到积分<input v-model.number="draft.queue.signin_points" type="number"></label>
<label>积分上限<input v-model.number="draft.queue.max_points" type="number"></label>
<label>每分钟扣分<input v-model.number="draft.queue.points_per_minute" type="number"></label>
<label>上号窗口秒数<input v-model.number="draft.queue.admin_window_seconds" type="number"></label>
<label>默认配置组<input v-model="draft.queue.default_group"></label>
</div>
<div class="panel">
<h2>前台视觉</h2>
<label>界面预设<select v-model="draft.frontend.theme"><option value="classic">经典(绿野)</option><option value="aurora">极光(暗夜蓝紫)</option></select></label>
<label>背景图片<input v-model="draft.frontend.background_image"></label>
<label>透明度<input v-model.number="draft.frontend.background_opacity" type="number" min="0" max="1" step="0.05"></label>
<label>模糊<input v-model.number="draft.frontend.background_blur" type="number" min="0" max="30"></label>
<label>适配<select v-model="draft.frontend.background_fit"><option>cover</option><option>contain</option><option>fill</option></select></label>
<label class="file-row">上传背景<input type="file" accept="image/*" @change="uploadBackground"></label>
</div>
</section>
<section v-if="activeTab==='rules' && draft" class="stack">
<div class="panel">
<h2>点歌权限</h2>
<div class="role-chip-row">
<label v-for="[roleId, roleLabel] in roleOptions" :key="roleId" class="role-chip">
<input type="checkbox" :checked="hasRole(draft.music_monitor.request_player.allowed_roles, roleId)" @change="toggleRole(draft.music_monitor.request_player.allowed_roles, roleId)">
<span>{{ roleLabel }}</span>
</label>
</div>
</div>
<div class="panel">
<div class="panel-head">
<h2>内置指令权限与别名</h2>
</div>
<div class="cmd-grid">
<div v-for="(_, key) in draft.commands" :key="key" class="cmd-block">
<div class="cmd-row">
<label class="inline cmd-toggle">
<input v-model="draft.commands[key].enabled" type="checkbox">
<span class="cmd-label">{{ cmdLabel(key) }}</span>
<span v-if="cmdHasArg(key)" class="cmd-arg-hint">带参数</span>
</label>
<input class="cmd-aliases" :value="cmdAliasesText(key)" @input="setCmdAliases(key, $event.target.value)" :disabled="!draft.commands[key].enabled" placeholder="别名,逗号分隔">
</div>
<div class="role-chip-row compact">
<label v-for="[roleId, roleLabel] in roleOptions" :key="roleId" class="role-chip">
<input type="checkbox" :checked="hasRole(draft.commands[key].allowed_roles, roleId)" @change="toggleRole(draft.commands[key].allowed_roles, roleId)">
<span>{{ roleLabel }}</span>
</label>
</div>
</div>
</div>
</div>
<div class="panel">
<div class="panel-head">
<h2>自定义规则</h2>
<button class="primary" @click="addRule">新增规则</button>
</div>
<div v-for="(rule,index) in draft.rules" :key="index" class="rule-card">
<div class="rule-row">
<input v-model="rule.keyword" placeholder="关键词">
<select v-model="rule.match_type"><option>contains</option><option>exact</option><option>startswith</option><option>regex</option></select>
<input :value="normalizeRuleGroups(rule)" @input="setRuleGroups(rule, $event.target.value)" placeholder="配置组,逗号分隔">
<input v-model.number="rule.cooldown" type="number" title="冷却秒数">
<button class="danger" @click="removeRule(index)">删除</button>
</div>
<label>回复文案<textarea v-model="rule.reply"></textarea></label>
<div class="role-chip-row compact">
<label v-for="[roleId, roleLabel] in roleOptions" :key="roleId" class="role-chip">
<input type="checkbox" :checked="hasRole(rule.allowed_roles, roleId)" @change="toggleRole(rule.allowed_roles, roleId)">
<span>{{ roleLabel }}</span>
</label>
</div>
</div>
</div>
</section>
<section v-if="activeTab==='queue'" class="stack">
<div class="panel">
<div class="panel-head">
<h2>当前队列</h2>
<button class="danger" @click="adminAction('clear_queue')">清空队列</button>
</div>
<table>
<thead><tr><th>#</th><th>UID</th><th>昵称</th><th>积分</th><th>操作</th></tr></thead>
<tbody>
<tr v-for="u in queueUsers" :key="u.uid" :class="{ 'row-admin': u.uid === state.current_admin }">
<td>{{ u.index }}<span v-if="u.uid === state.current_admin" class="tag-admin">队首</span></td>
<td>{{ u.uid }}</td><td>{{ u.uname }}</td><td>{{ u.points }}</td>
<td class="table-actions">
<button class="secondary" @click="addPoints(u.uid, 5)">+5</button>
<button class="secondary" @click="addPoints(u.uid, -5)">-5</button>
<button class="danger" @click="kickUser(Number(u.uid))">移出</button>
</td>
</tr>
<tr v-if="!queueUsers.length"><td colspan="5" class="empty">队列为空</td></tr>
</tbody>
</table>
</div>
</section>
<section v-if="activeTab==='users'" class="stack">
<div class="panel">
<div class="panel-head">
<h2>用户积分与封禁</h2>
<input class="search" v-model="userQuery" @change="loadUsers" placeholder="搜索 UID / 昵称">
</div>
<table>
<thead><tr><th>UID</th><th>昵称</th><th>积分</th><th>角色</th><th>封禁</th><th>备注</th><th>操作</th></tr></thead>
<tbody>
<tr v-for="user in filteredUsers" :key="user.uid">
<td>{{ user.uid }}</td>
<td>{{ user.uname }}</td>
<td>{{ user.points }}</td>
<td>{{ roleOptions.find(([id]) => id === user.role)?.[1] || user.role }}</td>
<td>
<label class="mini-check"><input v-model="user.blocked_all" type="checkbox"> 全禁</label>
<label class="mini-check"><input v-model="user.blocked_queue" type="checkbox"> 禁排队</label>
<label class="mini-check"><input v-model="user.blocked_song_request" type="checkbox"> 禁点歌</label>
</td>
<td><input v-model="user.note" placeholder="备注"></td>
<td class="table-actions">
<button class="secondary" @click="addPoints(user.uid, 5)">+5</button>
<button class="secondary" @click="addPoints(user.uid, -5)">-5</button>
<button class="secondary" @click="saveUserFlags(user)">保存</button>
<button class="danger" @click="kickUser(Number(user.uid))">踢出队列</button>
<button class="danger" @click="deleteUser(Number(user.uid))">删除</button>
</td>
</tr>
</tbody>
</table>
</div>
</section>
<section v-if="activeTab==='redemption'" class="stack">
<div class="panel">
<div class="panel-head">
<h2>创建兑换码</h2>
<button class="secondary" @click="loadRedemptionCodes">刷新数据</button>
</div>
<div class="redemption-create-grid">
<label>兑换码<input v-model.trim="redemptionForm.code" maxlength="100" placeholder="手动填写完整兑换码"></label>
<label>兑换积分<input v-model.number="redemptionForm.points" type="number" min="1" step="1"></label>
<label>生效时间(北京时间)<input v-model="redemptionForm.starts_at" type="datetime-local"></label>
<label>失效时间(北京时间)<input v-model="redemptionForm.ends_at" type="datetime-local"></label>
<label>总兑换次数<input v-model="redemptionForm.max_redemptions" type="number" min="1" step="1" placeholder="留空表示不限"></label>
<label class="inline redemption-enabled"><input v-model="redemptionForm.enabled" type="checkbox"> 创建后立即启用</label>
</div>
<div class="actions-row">
<button class="primary" @click="createRedemptionCode" :disabled="redemptionSaving">创建兑换码</button>
</div>
</div>
<div class="panel">
<div class="panel-head">
<h2>兑换码列表</h2>
<span class="muted">英文不区分大小写,每个用户对同一码只能兑换一次</span>
</div>
<table>
<thead><tr><th>兑换码</th><th>积分</th><th>有效期(北京时间)</th><th>兑换进度</th><th>状态</th><th>操作</th></tr></thead>
<tbody>
<tr v-for="code in redemptionCodes" :key="code.id">
<td><strong>{{ code.code }}</strong></td>
<td>+{{ code.points }}</td>
<td>
<div>{{ formatRedemptionTime(code.starts_at) }}</div>
<div class="muted small">至 {{ formatRedemptionTime(code.ends_at) }}</div>
</td>
<td>{{ code.redeemed_count }} / {{ code.max_redemptions ?? '不限' }}<div class="muted small">剩余 {{ code.remaining_count ?? '不限' }}</div></td>
<td><span class="pill" :class="{running: redemptionCodeStatus(code)==='生效中', failed: ['已停用','已过期','已兑完'].includes(redemptionCodeStatus(code))}">{{ redemptionCodeStatus(code) }}</span></td>
<td class="table-actions">
<button class="secondary" @click="toggleRedemptionCode(code)">{{ code.enabled ? '停用' : '启用' }}</button>
<button class="danger" @click="deleteRedemptionCode(code)">删除</button>
</td>
</tr>
<tr v-if="!redemptionCodes.length"><td colspan="6" class="empty">尚未创建兑换码</td></tr>
</tbody>
</table>
</div>
<div class="panel">
<h2>兑换记录</h2>
<table>
<thead><tr><th>时间(北京时间)</th><th>兑换码</th><th>用户</th><th>积分</th><th>余额变化</th><th>状态</th></tr></thead>
<tbody>
<tr v-for="record in redemptionRecords" :key="record.id">
<td>{{ formatRedemptionTime(record.redeemed_at) }}</td>
<td>{{ record.code }}</td>
<td>{{ record.uname }}<div class="muted small">UID {{ record.uid }}</div></td>
<td>+{{ record.points }}</td>
<td>{{ record.balance_before }} → {{ record.balance_after ?? '处理中' }}</td>
<td><span class="pill" :class="{running: record.status==='completed', starting: record.status==='pending'}">{{ record.status === 'completed' ? '已完成' : '处理中' }}</span></td>
</tr>
<tr v-if="!redemptionRecords.length"><td colspan="6" class="empty">暂无兑换记录</td></tr>
</tbody>
</table>
</div>
</section>
<section v-if="activeTab==='songs'" class="stack">
<div class="panel">
<div class="panel-head">
<h2>手动搜歌加歌</h2>
<div class="actions-row">
<input class="search wide-search" v-model="songKeyword" @keyup.enter="searchSongs" placeholder="输入歌名、歌手或网易云链接">
<button class="primary" @click="searchSongs" :disabled="songSearching">搜索</button>
</div>
</div>
<table v-if="songResults.length">
<thead><tr><th>歌曲</th><th>歌手</th><th>ID</th><th>操作</th></tr></thead>
<tbody>
<tr v-for="song in songResults" :key="song.id">
<td>{{ song.name }}</td>
<td>{{ song.artist }}</td>
<td>{{ song.id }}</td>
<td class="table-actions">
<button class="primary" @click="addSong(song)">加入队列</button>
<button class="secondary" @click="toggleBanSong(song.id, true)">禁点此歌</button>
</td>
</tr>
</tbody>
</table>
<div v-else class="empty">搜索结果会显示在这里。</div>
</div>
<div class="panel">
<div class="panel-head">
<h2>当前播放与待播点歌</h2>
<div class="actions-row">
<button class="secondary" @click="skipCurrentSong">跳过当前</button>
<button class="danger" @click="clearSongs">清空待播</button>
</div>
</div>
<div class="song-current">
<div class="song-current-card">
<img v-if="music.current?.cover" class="song-cover" :src="music.current.cover" :alt="music.current?.title || '当前歌曲封面'" referrerpolicy="no-referrer" />
<div v-else class="song-cover song-cover-placeholder">♪</div>
<div class="song-current-text"><span class="muted">当前系统歌曲</span><strong>{{ music.current?.title || '-' }}</strong><small>{{ music.current?.artist || '-' }}</small></div>
</div>
<div><span class="muted">当前点歌任务</span><strong>{{ activeSongRequest?.name || '-' }}</strong><small>{{ activeSongRequest?.artist || '-' }}</small></div>
</div>
<table>
<thead><tr><th>#</th><th>歌曲</th><th>点歌人</th><th>来源</th><th>操作</th></tr></thead>
<tbody>
<tr v-for="(song, index) in songQueue" :key="song.id + '-' + index">
<td>{{ index + 1 }}</td>
<td>{{ song.name }}<div class="muted small">{{ song.artist }}</div></td>
<td>{{ song.uname || '后台' }}</td>
<td>{{ song.source === 'admin' ? '后台' : '观众' }}</td>
<td class="table-actions">
<button class="secondary" @click="moveSong(song, 0)">置顶</button>
<button class="secondary" @click="moveSong(song, Math.max(index - 1, 0))">上移</button>
<button class="secondary" @click="moveSong(song, index + 1)">下移</button>
<button class="primary" @click="playSongNow(song)">立即播</button>
<button class="danger" @click="removeSong(song)">删除</button>
<button class="danger" @click="toggleBanSong(song.id, true)">禁点歌</button>
<button class="danger" v-if="song.uid" @click="toggleBanUser(song.uid, song.uname, true)">禁点人</button>
</td>
</tr>
<tr v-if="!songQueue.length"><td colspan="5" class="empty">当前没有待播点歌</td></tr>
</tbody>
</table>
</div>
<div class="form-grid">
<div class="panel">
<h2>禁点名单</h2>
<div class="list-block">
<div class="list-row" v-for="songId in bannedSongs" :key="songId">
<strong>{{ songId }}</strong>
<button class="secondary" @click="toggleBanSong(songId, false)">解除</button>
</div>
<div v-if="!bannedSongs.length" class="empty">暂无禁点歌曲</div>
</div>
</div>
<div class="panel">
<h2>禁点用户</h2>
<div class="list-block">
<div class="list-row" v-for="user in bannedUsers" :key="user.uid">
<strong>{{ user.uname }}</strong>
<span class="muted">{{ user.uid }}</span>
<button class="secondary" @click="toggleBanUser(Number(user.uid), user.uname, false)">解除</button>
</div>
<div v-if="!bannedUsers.length" class="empty">暂无禁点用户</div>
</div>
</div>
</div>
<div class="panel">
<h2>播放历史</h2>
<div class="list-block">
<div class="list-row" v-for="item in songHistory" :key="item.id + '-' + item.finished_at">
<strong>{{ item.name }}</strong>
<span class="muted">{{ item.artist }} · {{ item.status }}</span>
</div>
<div v-if="!songHistory.length" class="empty">暂无点歌历史</div>
</div>
</div>
</section>
<section v-if="activeTab==='media' && draft" class="form-grid">
<div class="panel">
<h2>弹幕播报</h2>
<label class="inline"><input v-model="draft.broadcast.enable_danmu_reply" type="checkbox"> 指令回复发到直播间</label>
<label class="inline"><input v-model="draft.broadcast.enable_system_danmu" type="checkbox"> 系统通知发到直播间</label>
<label>发送间隔秒数<input v-model.number="draft.broadcast.danmu_interval_sec" type="number" step="0.5"></label>
</div>
<div class="panel">
<h2>TTS 语音</h2>
<label class="inline"><input v-model="draft.broadcast.enable_tts" type="checkbox"> 启用 TTS</label>
<p class="muted">取消勾选的类别仍会发送文字弹幕,只是不加入 TTS 队列。</p>
<div class="checkbox-grid">
<label class="inline"><input v-model="draft.broadcast.tts_categories.signin" type="checkbox"> 签到回复</label>
<label class="inline"><input v-model="draft.broadcast.tts_categories.queue" type="checkbox"> 排队回复</label>
<label class="inline"><input v-model="draft.broadcast.tts_categories.song_request" type="checkbox"> 点歌回复</label>
<label class="inline"><input v-model="draft.broadcast.tts_categories.login" type="checkbox"> 上号与确认</label>
<label class="inline"><input v-model="draft.broadcast.tts_categories.execution" type="checkbox"> 配置组执行</label>
<label class="inline"><input v-model="draft.broadcast.tts_categories.points" type="checkbox"> 积分查询</label>
<label class="inline"><input v-model="draft.broadcast.tts_categories.help" type="checkbox"> 帮助回复</label>
<label class="inline"><input v-model="draft.broadcast.tts_categories.reset" type="checkbox"> 重置流程</label>
<label class="inline"><input v-model="draft.broadcast.tts_categories.system" type="checkbox"> 系统通知</label>
</div>
<label>Provider<select v-model="draft.broadcast.tts_provider"><option>none</option><option>faster-qwen3-tts</option><option>dots-tts</option></select></label>
<label>设备<select v-model="draft.broadcast.tts['faster-qwen3-tts'].device"><option>cuda</option><option>cpu</option></select></label>
<label>测试文本<textarea v-model="ttsTestText"></textarea></label>
<button class="secondary" @click="testTts">测试 TTS</button>
<p class="muted">{{ ttsTestResult }}</p>
</div>
<div class="panel">
<h2>礼物特效</h2>
<p class="muted">向直播前台注入一条模拟礼物,验证特效链路;不会发弹幕、不触发 TTS、不计入统计。折算价值决定特效档位:0=免费小礼物,&gt;0=初级,&ge;10=中级,&ge;100=豪华。</p>
<label>观众昵称<input v-model.trim="giftTestName"></label>
<label>礼物名称<input v-model.trim="giftTestGift"></label>
<label>数量<input v-model.number="giftTestNum" type="number" min="1" max="999"></label>
<label>折算价值(元)<input v-model.number="giftTestValue" type="number" min="0" step="0.1"></label>
<button class="secondary" type="button" @click="testGiftEffect">测试礼物特效</button>
<p class="muted">{{ giftTestResult }}</p>
</div>
<div class="panel">
<h2>音乐监听</h2>
<label>平台<input v-model="draft.music_monitor.platform"></label>
<label>匹配目标<textarea v-model="draft.music_monitor.targetsText" @blur="draft.music_monitor.targets = splitList(draft.music_monitor.targetsText)"></textarea></label>
<label>轮询秒数<input v-model.number="draft.music_monitor.interval_sec" type="number" step="0.1"></label>
<label class="inline"><input v-model="draft.music_monitor.allow_all" type="checkbox"> 允许任意播放器</label>
<label class="inline"><input v-model="draft.music_monitor.cover_enabled" type="checkbox"> 同步封面</label>
</div>
<div class="panel">
<h2>观众点歌</h2>
<label class="inline"><input v-model="draft.music_monitor.request_player.enabled" type="checkbox"> 启用点歌</label>
<label>触发词<textarea v-model="draft.music_monitor.request_player.commandsText" @blur="draft.music_monitor.request_player.commands = splitList(draft.music_monitor.request_player.commandsText)"></textarea></label>
<label>每次扣分<input v-model.number="draft.music_monitor.request_player.cost_points" type="number"></label>
<label>歌曲最长秒数<input v-model.number="draft.music_monitor.request_player.max_duration_sec" type="number" min="60" max="600" step="30"></label>
<p class="muted">观众点歌必须能获取歌曲时长,超过 600 秒(10分钟)的歌曲会被拒绝。</p>
<label>提前切入秒数<input v-model.number="draft.music_monitor.request_player.handoff_lead_sec" type="number" min="0.3" max="3" step="0.1"></label>
<p class="muted">默认在当前歌曲还剩 1.2 秒时播放点歌,避免原播放列表下一首先响几秒;点歌结束后继续原来的下一首。</p>
<label>网易云 MUSIC_U<input v-model.trim="draft.music_monitor.request_player.netease_music_u" type="password" autocomplete="off" placeholder="登录 Cookie"></label>
<button class="secondary" type="button" @click="testNeteaseLogin">验证并保存 MUSIC_U</button>
<div class="netease-qr-login">
<div class="panel-head">
<div>
<strong>网易云扫码登录</strong>
<small :class="['qr-status', neteaseQr.state]">{{ neteaseQr.message || (neteaseQr.credentialConfigured ? 'MUSIC_U 已保存' : '尚未保存登录凭据') }}</small>
</div>
<button class="secondary" type="button" :disabled="neteaseQrStarting" @click="startNeteaseQrLogin">
{{ neteaseQrStarting ? '生成中...' : (neteaseQr.hasQrImage ? '重新生成' : '扫码登录') }}
</button>
</div>
<div v-if="neteaseQr.hasQrImage" class="bilibili-qr-body">
<img class="bilibili-qr-image" :src="neteaseQr.imageUrl" alt="网易云登录二维码">
<div class="bilibili-qr-state">
<strong>{{ neteaseQr.state === 'awaiting_confirm' ? '等待手机确认' : '等待扫码' }}</strong>
<span>{{ neteaseQr.expiresIn }} 秒后过期</span>
<span>请使用网易云音乐客户端扫码,登录成功后会自动保存。</span>
</div>
</div>
<div v-else-if="neteaseQr.state === 'completed'" class="bilibili-account-state">
<strong>{{ neteaseQr.account?.nickname || '网易云账号' }}</strong>
<span>{{ Number(neteaseQr.account?.vip_type || 0) > 0 ? 'VIP账号' : '普通账号' }} · MUSIC_U 已自动保存并热加载</span>
</div>
</div>
</div>
</section>
<section v-if="activeTab==='system' && draft" class="form-grid">
<div class="panel">
<h2>直播时间</h2>
<label>开播时间<input v-model="draft.system.live_start_time" type="time"></label>
<label>关播时间<input v-model="draft.system.live_end_time" type="time"></label>
<p class="muted">系统会在开播前10分钟自动打开原神和B站直播姬,开播时自动开启推流;关播前1分钟关闭原神和BetterGI,关播时自动停止推流。支持跨日,例如20:00至02:00。</p>
</div>
<div class="panel">
<h2>程序路径</h2>
<label>B站直播姬 EXE 路径<input v-model="draft.system.bilibili_live_exe" placeholder="C:\\...\\livehime.exe"></label>
<label>原神 EXE 路径<input v-model="draft.system.genshin_exe" placeholder="C:\\...\\YuanShen.exe"></label>
<p class="muted">时间页面只需要维护开播和关播时间;程序路径通常配置一次即可。</p>
</div>
<div class="panel">
<h2>推流按钮设置</h2>
<label>窗口标题关键字<input v-model="draft.system.bilibili_push_window_keyword" placeholder="直播姬"></label>
<div class="coordinate-grid">
<label>开启 X 比例<input v-model.number="draft.system.bilibili_push_click_x_ratio" type="number" min="0" max="1" step="0.001"></label>
<label>开启 Y 比例<input v-model.number="draft.system.bilibili_push_click_y_ratio" type="number" min="0" max="1" step="0.001"></label>
<label>关闭 X 比例<input v-model.number="draft.system.bilibili_stop_push_click_x_ratio" type="number" min="0" max="1" step="0.001"></label>
<label>关闭 Y 比例<input v-model.number="draft.system.bilibili_stop_push_click_y_ratio" type="number" min="0" max="1" step="0.001"></label>
</div>
<label class="inline"><input v-model="draft.system.bilibili_stop_push_confirm_enter" type="checkbox"> 关播点击后按 Enter 确认</label>
<div class="actions-row">
<button class="danger" @click="testBilibiliPush">测试开启推流</button>
<button class="danger" @click="testBilibiliStopPush">测试停止推流</button>
</div>
</div>
<div class="panel">
<h2>扫码登录维护</h2>
<p class="muted">扫码失败或超时触发自动重置时,系统会先删除当前 Windows 用户的 HKCU\\Software\\miHoYoSDK 注册表键。下面的按钮仅用于单独测试清理行为。</p>
<div class="actions-row">
<button class="danger" @click="testDeleteMihoyoSdkRegistry">测试清理 miHoYoSDK 注册表</button>
</div>
</div>
<div class="panel">
<h2>保存直播时间</h2>
<p class="muted">非直播时段收到有效指令时,只回复“当前未开播哦~”,不会进入TTS。</p>
<div class="actions-row">
<button class="primary" @click="saveSystemSchedule" :disabled="saving">保存并应用</button>
<button class="secondary" @click="loadConfig">重新载入</button>
</div>
</div>
</section>
<section v-if="activeTab==='logs'" class="logs-grid">
<div class="panel">
<h2>系统日志</h2>
<pre class="log">{{ systemLog.join('\\n') }}</pre>
</div>
<div class="panel">
<h2>BetterGI 日志</h2>
<pre class="log">{{ bgiLog.join('\\n') }}</pre>
</div>
</section>
<section v-if="activeTab==='raw'" class="panel raw-panel">
<h2>完整配置 JSON</h2>
<textarea class="raw-editor" v-model="rawConfig" spellcheck="false"></textarea>
<div class="actions-row">
<button class="primary" @click="saveRawConfig">保存 JSON</button>
<button class="secondary" @click="rawConfig = JSON.stringify(config, null, 2)">还原</button>
</div>
</section>
</main>
</div>
`
}).mount("#app");