1723 lines
73 KiB
JavaScript
1723 lines
73 KiB
JavaScript
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=免费小礼物,>0=初级,≥10=中级,≥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");
|