first commit

This commit is contained in:
2026-08-15 14:43:56 +08:00
commit 63b8c28e1d
340 changed files with 59515 additions and 0 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>直播联动后台</title>
<script type="module" crossorigin src="/admin/assets/index-CIcFYlv1.js"></script>
<link rel="stylesheet" crossorigin href="/admin/assets/index-BB8VhdaG.css">
</head>
<body>
<div id="app"></div>
</body>
</html>
+633
View File
@@ -0,0 +1,633 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="color-scheme" content="dark">
<title>直播数据中心</title>
<style>
:root {
--bg: #07100d;
--panel: rgba(15, 31, 25, 0.92);
--panel-2: rgba(21, 42, 34, 0.92);
--line: rgba(161, 231, 193, 0.14);
--line-strong: rgba(161, 231, 193, 0.28);
--text: #f1fff7;
--muted: #9fbaac;
--green: #72d39c;
--mint: #a5e7c1;
--gold: #f2c66d;
--blue: #7eb5ff;
--purple: #c2a2ff;
--red: #ff897f;
--orange: #ffa968;
--shadow: 0 20px 55px rgba(0, 0, 0, 0.34);
}
* { box-sizing: border-box; }
html { min-height: 100%; background: var(--bg); }
body {
min-height: 100vh;
margin: 0;
color: var(--text);
font-family: Inter, "Microsoft YaHei", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background:
radial-gradient(circle at 12% 0%, rgba(76, 177, 119, 0.18), transparent 31rem),
radial-gradient(circle at 90% 10%, rgba(80, 134, 202, 0.12), transparent 34rem),
linear-gradient(145deg, #07100d 0%, #0a1712 55%, #07100d 100%);
}
button, input, select { font: inherit; }
button { cursor: pointer; }
a { color: inherit; }
.shell { width: min(1680px, calc(100% - 40px)); margin: 0 auto; padding: 28px 0 54px; }
.topbar {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 24px;
margin-bottom: 22px;
}
.eyebrow {
display: inline-flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
color: var(--mint);
font-size: 12px;
font-weight: 800;
letter-spacing: 0.16em;
text-transform: uppercase;
}
.eyebrow::before { content: ""; width: 22px; height: 2px; border-radius: 9px; background: var(--green); }
h1 { margin: 0; font-size: clamp(28px, 4vw, 46px); line-height: 1.08; letter-spacing: -0.04em; }
.subtitle { margin: 10px 0 0; color: var(--muted); font-size: 14px; }
.top-actions { display: flex; gap: 10px; flex-wrap: wrap; justify-content: flex-end; }
.btn {
min-height: 40px;
padding: 0 15px;
border: 1px solid var(--line-strong);
border-radius: 12px;
color: var(--text);
background: rgba(19, 39, 31, 0.78);
transition: 0.18s ease;
}
.btn:hover { transform: translateY(-1px); border-color: rgba(165, 231, 193, 0.55); background: rgba(28, 56, 45, 0.9); }
.btn.primary { border-color: transparent; color: #062013; background: linear-gradient(135deg, var(--mint), var(--green)); font-weight: 800; }
.panel {
border: 1px solid var(--line);
border-radius: 20px;
background: linear-gradient(145deg, rgba(18, 37, 30, 0.94), rgba(10, 24, 19, 0.94));
box-shadow: var(--shadow);
backdrop-filter: blur(16px);
}
.toolbar {
display: flex;
align-items: end;
justify-content: space-between;
gap: 18px;
padding: 18px;
margin-bottom: 18px;
}
.filters { display: flex; align-items: end; gap: 12px; flex-wrap: wrap; }
.field { display: grid; gap: 7px; }
.field label { color: var(--muted); font-size: 12px; font-weight: 700; }
.field input, .field select {
min-height: 40px;
min-width: 148px;
padding: 0 12px;
border: 1px solid var(--line-strong);
border-radius: 11px;
outline: none;
color: var(--text);
background: rgba(6, 17, 13, 0.74);
}
.field input:focus, .field select:focus { border-color: var(--green); box-shadow: 0 0 0 3px rgba(114, 211, 156, 0.12); }
.status { display: flex; align-items: center; gap: 9px; color: var(--muted); font-size: 13px; text-align: right; }
.dot { width: 9px; height: 9px; border-radius: 50%; background: var(--green); box-shadow: 0 0 14px rgba(114, 211, 156, 0.8); }
.dot.error { background: var(--red); box-shadow: 0 0 14px rgba(255, 137, 127, 0.65); }
.summary-grid { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 14px; margin-bottom: 18px; }
.summary-card { padding: 17px 18px; min-height: 122px; position: relative; overflow: hidden; }
.summary-card::after { content: ""; position: absolute; width: 90px; height: 90px; right: -34px; bottom: -40px; border-radius: 50%; background: var(--accent, var(--green)); opacity: 0.12; filter: blur(2px); }
.summary-label { color: var(--muted); font-size: 12px; font-weight: 700; }
.summary-value { margin-top: 10px; font-size: clamp(25px, 2.6vw, 38px); font-weight: 850; letter-spacing: -0.04em; }
.summary-note { margin-top: 7px; color: var(--muted); font-size: 11px; }
.chart-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
.chart-card { min-width: 0; padding: 19px; }
.chart-card.wide { grid-column: 1 / -1; }
.chart-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 14px; }
.chart-title { margin: 0; font-size: 16px; }
.chart-desc { margin: 5px 0 0; color: var(--muted); font-size: 12px; }
.legend { display: flex; gap: 12px; flex-wrap: wrap; justify-content: flex-end; }
.legend-item { display: inline-flex; align-items: center; gap: 6px; color: var(--muted); font-size: 11px; white-space: nowrap; }
.legend-swatch { width: 9px; height: 9px; border-radius: 3px; }
.chart-wrap { width: 100%; min-height: 290px; position: relative; overflow-x: auto; overflow-y: hidden; }
.chart-wrap svg { display: block; width: 100%; min-width: 620px; height: 290px; overflow: visible; }
.chart-empty { height: 290px; display: grid; place-items: center; color: var(--muted); border: 1px dashed var(--line-strong); border-radius: 14px; }
.axis-label { fill: #90aa9d; font-size: 10px; }
.grid-line { stroke: rgba(161, 231, 193, 0.11); stroke-width: 1; }
.axis-line { stroke: rgba(161, 231, 193, 0.25); stroke-width: 1; }
.data-line { fill: none; stroke-width: 2.5; stroke-linejoin: round; stroke-linecap: round; }
.data-point { stroke: #0c1d17; stroke-width: 2; }
.chart-hit { fill: transparent; cursor: crosshair; }
.table-card { margin-top: 16px; padding: 19px; }
.table-scroll { overflow: auto; max-height: 520px; border: 1px solid var(--line); border-radius: 14px; }
table { width: 100%; border-collapse: collapse; min-width: 1120px; font-size: 12px; }
th, td { padding: 12px 13px; text-align: right; border-bottom: 1px solid var(--line); white-space: nowrap; }
th:first-child, td:first-child { text-align: left; position: sticky; left: 0; background: #10231c; }
th { position: sticky; top: 0; z-index: 2; color: var(--mint); background: #10231c; font-size: 11px; }
th:first-child { z-index: 3; }
tbody tr:hover td { background-color: rgba(114, 211, 156, 0.055); }
tbody tr:hover td:first-child { background-color: #163027; }
.tooltip {
position: fixed;
z-index: 20;
pointer-events: none;
min-width: 132px;
padding: 10px 11px;
border: 1px solid var(--line-strong);
border-radius: 11px;
color: var(--text);
background: rgba(5, 15, 11, 0.96);
box-shadow: 0 14px 36px rgba(0,0,0,.45);
font-size: 11px;
opacity: 0;
transform: translateY(5px);
transition: opacity .1s ease, transform .1s ease;
}
.tooltip.visible { opacity: 1; transform: translateY(0); }
.tooltip-date { color: var(--mint); font-weight: 800; margin-bottom: 6px; }
.tooltip-row { display: flex; justify-content: space-between; gap: 16px; margin-top: 4px; color: var(--muted); }
.tooltip-row strong { color: var(--text); }
.login-overlay {
position: fixed; inset: 0; z-index: 30; display: none; place-items: center; padding: 20px;
background: rgba(3, 9, 7, 0.78); backdrop-filter: blur(13px);
}
.login-overlay.visible { display: grid; }
.login-card { width: min(420px, 100%); padding: 26px; }
.login-card h2 { margin: 0 0 8px; }
.login-card p { margin: 0 0 20px; color: var(--muted); font-size: 13px; line-height: 1.7; }
.login-card input { width: 100%; min-height: 44px; padding: 0 13px; border: 1px solid var(--line-strong); border-radius: 11px; color: var(--text); background: #07140f; outline: none; }
.login-card .btn { width: 100%; margin-top: 12px; }
.login-error { min-height: 18px; margin-top: 9px; color: var(--red); font-size: 12px; }
.toast { position: fixed; right: 24px; bottom: 24px; z-index: 40; max-width: 380px; padding: 13px 15px; border: 1px solid var(--line-strong); border-radius: 12px; background: rgba(7, 20, 15, 0.96); box-shadow: var(--shadow); opacity: 0; transform: translateY(16px); pointer-events: none; transition: .2s ease; }
.toast.visible { opacity: 1; transform: translateY(0); }
@media (max-width: 1200px) { .summary-grid { grid-template-columns: repeat(3, 1fr); } }
@media (max-width: 820px) {
.shell { width: min(100% - 24px, 1680px); padding-top: 18px; }
.topbar, .toolbar { align-items: stretch; flex-direction: column; }
.top-actions { justify-content: flex-start; }
.status { text-align: left; }
.summary-grid { grid-template-columns: repeat(2, 1fr); }
.chart-grid { grid-template-columns: 1fr; }
.chart-card.wide { grid-column: auto; }
}
@media (max-width: 520px) {
.summary-grid { grid-template-columns: 1fr; }
.filters { display: grid; grid-template-columns: 1fr 1fr; width: 100%; }
.field input, .field select { min-width: 0; width: 100%; }
.filters .btn { width: 100%; }
}
</style>
</head>
<body>
<main class="shell">
<header class="topbar">
<div>
<div class="eyebrow">Live Operations Analytics</div>
<h1>直播数据中心</h1>
<p class="subtitle">按北京时间凌晨 4 点切换业务日 · 每日聚合数据</p>
</div>
<div class="top-actions">
<a class="btn" href="/admin">返回管理后台</a>
<button class="btn" id="exportBtn">导出 CSV</button>
<button class="btn primary" id="refreshBtn">刷新数据</button>
</div>
</header>
<section class="toolbar panel">
<div class="filters">
<div class="field">
<label for="rangePreset">日期范围</label>
<select id="rangePreset">
<option value="7">最近 7 天</option>
<option value="30" selected>最近 30 天</option>
<option value="90">最近 90 天</option>
<option value="all">全部数据</option>
<option value="custom">自定义</option>
</select>
</div>
<div class="field">
<label for="startDate">开始日期</label>
<input id="startDate" type="date">
</div>
<div class="field">
<label for="endDate">结束日期</label>
<input id="endDate" type="date">
</div>
<button class="btn" id="applyBtn">应用</button>
</div>
<div class="status"><span class="dot" id="statusDot"></span><span id="statusText">正在读取数据库…</span></div>
</section>
<section class="summary-grid" id="summaryGrid"></section>
<section class="chart-grid">
<article class="chart-card panel wide">
<div class="chart-head">
<div><h2 class="chart-title">直播与系统运行时长</h2><p class="chart-desc">折线图 · 单位:小时</p></div>
<div class="legend" data-legend="duration"></div>
</div>
<div class="chart-wrap" id="durationChart"></div>
</article>
<article class="chart-card panel">
<div class="chart-head">
<div><h2 class="chart-title">用户参与</h2><p class="chart-desc">签到、入队与点歌人数</p></div>
<div class="legend" data-legend="users"></div>
</div>
<div class="chart-wrap" id="usersChart"></div>
</article>
<article class="chart-card panel">
<div class="chart-head">
<div><h2 class="chart-title">队列运行情况</h2><p class="chart-desc">入队、离队、超时及峰值</p></div>
<div class="legend" data-legend="queue"></div>
</div>
<div class="chart-wrap" id="queueChart"></div>
</article>
<article class="chart-card panel">
<div class="chart-head">
<div><h2 class="chart-title">点歌播放结果</h2><p class="chart-desc">请求、成功、跳过与错误</p></div>
<div class="legend" data-legend="songs"></div>
</div>
<div class="chart-wrap" id="songsChart"></div>
</article>
<article class="chart-card panel">
<div class="chart-head">
<div><h2 class="chart-title">礼物数据</h2><p class="chart-desc">礼物件数与送礼人数</p></div>
<div class="legend" data-legend="gifts"></div>
</div>
<div class="chart-wrap" id="giftsChart"></div>
</article>
<article class="chart-card panel">
<div class="chart-head">
<div><h2 class="chart-title">配置组执行</h2><p class="chart-desc">运行总数、成功与失败</p></div>
<div class="legend" data-legend="groups"></div>
</div>
<div class="chart-wrap" id="groupsChart"></div>
</article>
<article class="chart-card panel">
<div class="chart-head">
<div><h2 class="chart-title">TTS 与播报</h2><p class="chart-desc">请求量及成功结果</p></div>
<div class="legend" data-legend="tts"></div>
</div>
<div class="chart-wrap" id="ttsChart"></div>
</article>
</section>
<section class="table-card panel">
<div class="chart-head">
<div><h2 class="chart-title">每日数据明细</h2><p class="chart-desc">横向滚动可查看全部核心指标</p></div>
<div class="legend-item" id="rowCount">0 个业务日</div>
</div>
<div class="table-scroll"><table id="dataTable"></table></div>
</section>
</main>
<div class="login-overlay" id="loginOverlay">
<form class="login-card panel" id="loginForm">
<h2>需要管理后台登录</h2>
<p>每日统计接口复用现有管理后台权限。登录后会留在当前数据页面,不会暴露用户明细或敏感凭据。</p>
<input id="passwordInput" type="password" autocomplete="current-password" placeholder="请输入管理后台密码" required>
<button class="btn primary" type="submit">登录并查看数据</button>
<div class="login-error" id="loginError"></div>
</form>
</div>
<div class="tooltip" id="tooltip"></div>
<div class="toast" id="toast"></div>
<script>
const $ = (selector) => document.querySelector(selector);
const state = { rows: [], loading: false, authMode: "login", currentBusinessDate: "" };
const colors = { green: "#72d39c", mint: "#a5e7c1", gold: "#f2c66d", blue: "#7eb5ff", purple: "#c2a2ff", red: "#ff897f", orange: "#ffa968" };
const charts = {
duration: [
{ key: "live_duration_ms", label: "直播时长", color: colors.green, transform: v => num(v) / 3600000, format: v => `${formatNumber(v, 1)} 小时` },
{ key: "process_duration_ms", label: "系统运行", color: colors.blue, transform: v => num(v) / 3600000, format: v => `${formatNumber(v, 1)} 小时` },
{ key: "bilibili_connection_duration_ms", label: "B站连接", color: colors.purple, transform: v => num(v) / 3600000, format: v => `${formatNumber(v, 1)} 小时` }
],
users: [
{ key: "unique_signin_users", label: "签到人数", color: colors.green },
{ key: "unique_queue_users", label: "排队人数", color: colors.blue },
{ key: "unique_song_users", label: "点歌人数", color: colors.gold }
],
queue: [
{ key: "queue_join_count", label: "入队次数", color: colors.green },
{ key: "leave_count", label: "离队次数", color: colors.blue },
{ key: "timeout_count", label: "超时次数", color: colors.red },
{ key: "max_queue_size", label: "最大队列", color: colors.gold }
],
songs: [
{ key: "song_request_count", label: "点歌请求", color: colors.blue },
{ key: "played_count", label: "播放成功", color: colors.green },
{ key: "skipped_count", label: "跳过", color: colors.gold },
{ key: "error_count", label: "播放错误", color: colors.red }
],
gifts: [
{ key: "gift_quantity", label: "礼物件数", color: colors.gold },
{ key: "unique_gifters", label: "送礼人数", color: colors.purple }
],
groups: [
{ key: "group_run_count", label: "运行总数", color: colors.blue },
{ key: "group_run_success_count", label: "执行成功", color: colors.green },
{ key: "group_run_failed_count", label: "执行失败", color: colors.red }
],
tts: [
{ key: "tts_request_count", label: "TTS请求", color: colors.purple },
{ key: "tts_success_count", label: "TTS成功", color: colors.green },
{ key: "broadcast_request_count", label: "播报请求", color: colors.blue },
{ key: "broadcast_success_count", label: "播报成功", color: colors.gold }
]
};
function num(value) { const parsed = Number(value); return Number.isFinite(parsed) ? parsed : 0; }
function sum(key) { return state.rows.reduce((total, row) => total + num(row[key]), 0); }
function formatNumber(value, digits = 0) { return new Intl.NumberFormat("zh-CN", { maximumFractionDigits: digits, minimumFractionDigits: digits }).format(num(value)); }
function formatDuration(ms) {
const hours = num(ms) / 3600000;
if (hours >= 1) return `${formatNumber(hours, 1)} 小时`;
return `${formatNumber(num(ms) / 60000, 0)} 分钟`;
}
function formatAverageWait(value, row) {
return num(row?.wait_sample_count) > 0 && value != null ? formatDuration(value) : "—";
}
function formatCurrencyCny(value) { return `¥${formatNumber(value, 1)}`; }
function formatDateLabel(date) { return date ? date.slice(5).replace("-", "/") : ""; }
function escapeHtml(value) { return String(value ?? "").replace(/[&<>'"]/g, char => ({"&":"&amp;","<":"&lt;",">":"&gt;","'":"&#39;",'"':"&quot;"}[char])); }
function setPreset(value) {
const end = parseIsoDate(state.currentBusinessDate || currentBusinessDate());
const start = new Date(end);
if (value === "all") {
$("#startDate").value = "";
$("#endDate").value = "";
return;
}
if (value === "custom") return;
start.setUTCDate(start.getUTCDate() - (Number(value) - 1));
$("#startDate").value = isoDate(start);
$("#endDate").value = isoDate(end);
}
function currentBusinessDate() {
const shanghaiNow = new Date(new Date().toLocaleString("en-US", { timeZone: "Asia/Shanghai" }));
shanghaiNow.setHours(shanghaiNow.getHours() - 4);
return `${shanghaiNow.getFullYear()}-${String(shanghaiNow.getMonth() + 1).padStart(2, "0")}-${String(shanghaiNow.getDate()).padStart(2, "0")}`;
}
function parseIsoDate(value) { return new Date(`${value}T00:00:00Z`); }
function isoDate(date) { return date.toISOString().slice(0, 10); }
function setStatus(text, error = false) {
$("#statusText").textContent = text;
$("#statusDot").classList.toggle("error", error);
}
function toast(message) {
const el = $("#toast"); el.textContent = message; el.classList.add("visible");
clearTimeout(toast.timer); toast.timer = setTimeout(() => el.classList.remove("visible"), 2600);
}
async function loadData() {
if (state.loading) return;
state.loading = true;
setStatus("正在读取数据库…");
$("#refreshBtn").disabled = true;
const params = new URLSearchParams();
if ($("#startDate").value) params.set("start_date", $("#startDate").value);
if ($("#endDate").value) params.set("end_date", $("#endDate").value);
try {
const response = await fetch(`/api/admin/statistics?${params}`, { credentials: "same-origin" });
const payload = await response.json().catch(() => ({}));
if (response.status === 428) {
state.authMode = "bootstrap";
$("#loginOverlay h2").textContent = "初始化管理后台";
$("#loginOverlay p").textContent = "这是首次使用管理后台。请在本机设置初始密码,完成后即可查看每日统计数据。";
$("#loginForm button").textContent = "设置密码并查看数据";
$("#loginOverlay").classList.add("visible");
setStatus("等待初始化管理后台", true);
return;
}
if (response.status === 401) {
state.authMode = "login";
$("#loginOverlay h2").textContent = "需要管理后台登录";
$("#loginOverlay p").textContent = "每日统计接口复用现有管理后台权限。登录后会留在当前数据页面,不会暴露用户明细或敏感凭据。";
$("#loginForm button").textContent = "登录并查看数据";
$("#loginOverlay").classList.add("visible");
setStatus("等待管理后台登录", true);
return;
}
if (!response.ok || !payload.success) throw new Error(payload.error || `请求失败 (${response.status})`);
state.rows = Array.isArray(payload.rows) ? payload.rows : [];
state.currentBusinessDate = payload.current_business_date || state.currentBusinessDate;
$("#loginOverlay").classList.remove("visible");
renderAll();
setStatus(`已加载 ${state.rows.length} 个业务日`);
} catch (error) {
setStatus(error.message || "数据读取失败", true);
toast(error.message || "数据读取失败");
} finally {
state.loading = false;
$("#refreshBtn").disabled = false;
}
}
function renderSummary() {
const days = state.rows.length;
const cards = [
{ label: "统计业务日", value: `${days}`, note: state.rows.length ? `${state.rows[0].business_date}${state.rows.at(-1).business_date}` : "当前范围暂无数据", color: colors.mint },
{ label: "直播总时长", value: formatDuration(sum("live_duration_ms")), note: `系统运行 ${formatDuration(sum("process_duration_ms"))}`, color: colors.green },
{ label: "签到人数", value: formatNumber(sum("unique_signin_users")), note: `签到 ${formatNumber(sum("signin_count"))}`, color: colors.blue },
{ label: "入队次数", value: formatNumber(sum("queue_join_count")), note: `峰值 ${formatNumber(Math.max(0, ...state.rows.map(row => num(row.max_queue_size))))}`, color: colors.purple },
{ label: "点歌请求", value: formatNumber(sum("song_request_count")), note: `成功 ${formatNumber(sum("played_count"))} · 错误 ${formatNumber(sum("error_count"))}`, color: colors.gold },
{ label: "礼物件数", value: formatNumber(sum("gift_quantity")), note: `送礼 ${formatNumber(sum("unique_gifters"))} 人次 · 价值 ${formatCurrencyCny(sum("gift_total_value"))}`, color: colors.orange }
];
$("#summaryGrid").innerHTML = cards.map(card => `
<article class="summary-card panel" style="--accent:${card.color}">
<div class="summary-label">${escapeHtml(card.label)}</div>
<div class="summary-value">${escapeHtml(card.value)}</div>
<div class="summary-note">${escapeHtml(card.note)}</div>
</article>`).join("");
}
function renderLegend(name) {
const target = document.querySelector(`[data-legend="${name}"]`);
target.innerHTML = charts[name].map(series => `<span class="legend-item"><i class="legend-swatch" style="background:${series.color}"></i>${escapeHtml(series.label)}</span>`).join("");
}
function niceMax(value) {
if (value <= 0) return 1;
const power = Math.pow(10, Math.floor(Math.log10(value)));
const normalized = value / power;
const nice = normalized <= 1 ? 1 : normalized <= 2 ? 2 : normalized <= 5 ? 5 : 10;
return nice * power;
}
function renderChart(targetId, series, type = "bar") {
const target = document.getElementById(targetId);
if (!state.rows.length) { target.innerHTML = `<div class="chart-empty">当前日期范围暂无统计数据</div>`; return; }
const width = Math.max(620, state.rows.length * 38 + 80);
const height = 290, left = 48, right = 18, top = 18, bottom = 42;
const plotW = width - left - right, plotH = height - top - bottom;
const values = state.rows.flatMap(row => series.map(item => item.transform ? item.transform(row[item.key]) : num(row[item.key])));
const max = niceMax(Math.max(0, ...values));
const y = value => top + plotH - (value / max) * plotH;
const xStep = plotW / Math.max(1, state.rows.length);
let svg = `<svg viewBox="0 0 ${width} ${height}" role="img" style="min-width:${width}px">`;
for (let i = 0; i <= 4; i++) {
const value = max * (i / 4), yy = y(value);
svg += `<line class="grid-line" x1="${left}" y1="${yy}" x2="${width-right}" y2="${yy}"></line>`;
svg += `<text class="axis-label" x="${left-8}" y="${yy+3}" text-anchor="end">${formatNumber(value, value < 10 && max <= 20 ? 1 : 0)}</text>`;
}
svg += `<line class="axis-line" x1="${left}" y1="${top+plotH}" x2="${width-right}" y2="${top+plotH}"></line>`;
const labelEvery = Math.max(1, Math.ceil(state.rows.length / 10));
state.rows.forEach((row, index) => {
if (index % labelEvery === 0 || index === state.rows.length - 1) {
const xx = left + xStep * (index + .5);
svg += `<text class="axis-label" x="${xx}" y="${height-16}" text-anchor="middle">${escapeHtml(formatDateLabel(row.business_date))}</text>`;
}
});
if (type === "line") {
series.forEach(item => {
const points = state.rows.map((row, index) => {
const value = item.transform ? item.transform(row[item.key]) : num(row[item.key]);
return `${left + xStep * (index + .5)},${y(value)}`;
}).join(" ");
svg += `<polyline class="data-line" stroke="${item.color}" points="${points}"></polyline>`;
state.rows.forEach((row, index) => {
const value = item.transform ? item.transform(row[item.key]) : num(row[item.key]);
svg += `<circle class="data-point" fill="${item.color}" cx="${left+xStep*(index+.5)}" cy="${y(value)}" r="3.5"></circle>`;
});
});
} else {
const groupWidth = xStep * .72, barWidth = Math.max(2, groupWidth / series.length - 2);
state.rows.forEach((row, index) => series.forEach((item, seriesIndex) => {
const value = item.transform ? item.transform(row[item.key]) : num(row[item.key]);
const xx = left + xStep * index + (xStep-groupWidth)/2 + seriesIndex*(barWidth+2);
const yy = y(value), hh = Math.max(0, top+plotH-yy);
svg += `<rect fill="${item.color}" opacity=".82" x="${xx}" y="${yy}" width="${barWidth}" height="${hh}" rx="2"></rect>`;
}));
}
state.rows.forEach((row, index) => {
svg += `<rect class="chart-hit" data-chart-index="${index}" data-chart-target="${targetId}" x="${left+xStep*index}" y="${top}" width="${xStep}" height="${plotH}"></rect>`;
});
svg += `</svg>`;
target.innerHTML = svg;
target.dataset.series = JSON.stringify(series.map(item => ({ key: item.key, label: item.label, color: item.color, hasTransform: Boolean(item.transform) })));
target._series = series;
}
function renderTable() {
const columns = [
["business_date", "业务日", v => v], ["live_duration_ms", "直播时长", formatDuration], ["process_duration_ms", "系统运行", formatDuration],
["danmu_count", "弹幕"], ["unique_signin_users", "签到人数"], ["queue_join_count", "入队"], ["max_queue_size", "最大队列"],
["avg_wait_ms", "平均等待", (v, row) => formatAverageWait(v, row)], ["group_run_count", "配置组"], ["group_run_success_count", "组成功"],
["song_request_count", "点歌"], ["played_count", "播放成功"], ["skipped_count", "跳过"], ["error_count", "播放错误"],
["gift_quantity", "礼物件数"], ["unique_gifters", "送礼人数"], ["gift_total_value", "礼物价值(元)", v => formatCurrencyCny(v)],
["tts_request_count", "TTS请求"], ["tts_success_count", "TTS成功"], ["service_error_count", "服务错误"], ["admin_action_count", "后台操作"]
];
$("#dataTable").innerHTML = `<thead><tr>${columns.map(column => `<th>${escapeHtml(column[1])}</th>`).join("")}</tr></thead><tbody>${state.rows.slice().reverse().map(row => `<tr>${columns.map(column => `<td>${escapeHtml(column[2] ? column[2](row[column[0]], row) : formatNumber(row[column[0]]))}</td>`).join("")}</tr>`).join("")}</tbody>`;
$("#rowCount").textContent = `${state.rows.length} 个业务日`;
}
function renderAll() {
renderSummary();
Object.keys(charts).forEach(renderLegend);
renderChart("durationChart", charts.duration, "line");
renderChart("usersChart", charts.users);
renderChart("queueChart", charts.queue);
renderChart("songsChart", charts.songs);
renderChart("giftsChart", charts.gifts);
renderChart("groupsChart", charts.groups);
renderChart("ttsChart", charts.tts);
renderTable();
}
function showTooltip(event, hit) {
const index = Number(hit.dataset.chartIndex);
const target = document.getElementById(hit.dataset.chartTarget);
const row = state.rows[index];
if (!row || !target?._series) return;
const tooltip = $("#tooltip");
tooltip.innerHTML = `<div class="tooltip-date">${escapeHtml(row.business_date)}</div>${target._series.map(item => {
const value = item.transform ? item.transform(row[item.key]) : num(row[item.key]);
const display = item.format ? item.format(value) : formatNumber(value, value % 1 ? 1 : 0);
return `<div class="tooltip-row"><span><i class="legend-swatch" style="display:inline-block;background:${item.color};margin-right:6px"></i>${escapeHtml(item.label)}</span><strong>${escapeHtml(display)}</strong></div>`;
}).join("")}`;
tooltip.style.left = `${Math.min(window.innerWidth - 220, event.clientX + 14)}px`;
tooltip.style.top = `${Math.max(12, event.clientY - 24)}px`;
tooltip.classList.add("visible");
}
function exportCsv() {
if (!state.rows.length) { toast("当前没有可导出的数据"); return; }
const keys = Object.keys(state.rows[0]);
const safeCsvValue = raw => {
let value = String(raw ?? "");
if (/^[\t\r ]*[=+\-@]/.test(value)) value = `'${value}`;
return `"${value.replace(/"/g, '""')}"`;
};
const csv = [keys.join(","), ...state.rows.map(row => keys.map(key => safeCsvValue(row[key])).join(","))].join("\r\n");
const blob = new Blob(["\ufeff", csv], { type: "text/csv;charset=utf-8" });
const link = document.createElement("a");
link.href = URL.createObjectURL(blob);
link.download = `直播每日统计_${$("#startDate").value || "全部"}_${$("#endDate").value || "全部"}.csv`;
link.click();
URL.revokeObjectURL(link.href);
}
$("#rangePreset").addEventListener("change", event => setPreset(event.target.value));
$("#applyBtn").addEventListener("click", loadData);
$("#refreshBtn").addEventListener("click", loadData);
$("#exportBtn").addEventListener("click", exportCsv);
$("#startDate").addEventListener("change", () => $("#rangePreset").value = "custom");
$("#endDate").addEventListener("change", () => $("#rangePreset").value = "custom");
document.addEventListener("pointermove", event => {
const hit = event.target.closest?.(".chart-hit");
if (hit) showTooltip(event, hit); else if (event.pointerType === "mouse") $("#tooltip").classList.remove("visible");
});
document.addEventListener("pointerdown", event => {
const hit = event.target.closest?.(".chart-hit");
if (hit) showTooltip(event, hit); else $("#tooltip").classList.remove("visible");
});
$("#loginForm").addEventListener("submit", async event => {
event.preventDefault();
const errorEl = $("#loginError"); errorEl.textContent = "";
try {
const endpoint = state.authMode === "bootstrap" ? "/api/admin/bootstrap" : "/api/admin/login";
const response = await fetch(endpoint, { method: "POST", credentials: "same-origin", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ password: $("#passwordInput").value }) });
const payload = await response.json().catch(() => ({}));
if (!response.ok || !payload.success) throw new Error(payload.error || (state.authMode === "bootstrap" ? "初始化失败" : "登录失败"));
$("#passwordInput").value = "";
await loadData();
} catch (error) { errorEl.textContent = error.message || "登录失败"; }
});
setPreset("30");
loadData();
</script>
</body>
</html>
+2681
View File
File diff suppressed because it is too large Load Diff
+390
View File
@@ -0,0 +1,390 @@
[
{
"name": "万相石",
"image": "/uploads/group_万相石.webp"
},
{
"name": "云岩裂叶",
"image": "/uploads/group_云岩裂叶.webp"
},
{
"name": "便携轴承",
"image": "/uploads/group_便携轴承.webp"
},
{
"name": "冬凌草",
"image": "/uploads/group_冬凌草.webp"
},
{
"name": "初露之源",
"image": "/uploads/group_初露之源.webp"
},
{
"name": "劫波莲",
"image": "/uploads/group_劫波莲.webp"
},
{
"name": "发光髓",
"image": "/uploads/group_发光髓.webp"
},
{
"name": "嘟嘟莲",
"image": "/uploads/group_嘟嘟莲.webp"
},
{
"name": "圣金虫",
"image": "/uploads/group_圣金虫.webp"
},
{
"name": "堇瓜",
"image": "/uploads/group_堇瓜.webp"
},
{
"name": "塞西莉亚花",
"image": "/uploads/group_塞西莉亚花.webp"
},
{
"name": "墩墩桃",
"image": "/uploads/group_墩墩桃.webp"
},
{
"name": "夏槲果",
"image": "/uploads/group_夏槲果.webp"
},
{
"name": "夜泊石",
"image": "/uploads/group_夜泊石.webp"
},
{
"name": "天云草实",
"image": "/uploads/group_天云草实.webp"
},
{
"name": "子探测单元",
"image": "/uploads/group_子探测单元.webp"
},
{
"name": "宿影花",
"image": "/uploads/group_宿影花.webp"
},
{
"name": "寒涌石",
"image": "/uploads/group_寒涌石.webp"
},
{
"name": "小灯草",
"image": "/uploads/group_小灯草.webp"
},
{
"name": "帕蒂沙兰",
"image": "/uploads/group_帕蒂沙兰.webp"
},
{
"name": "幽光星星",
"image": "/uploads/group_幽光星星.webp"
},
{
"name": "幽灯蕈",
"image": "/uploads/group_幽灯蕈.webp"
},
{
"name": "微光角菌",
"image": "/uploads/group_微光角菌.webp"
},
{
"name": "悼灵花",
"image": "/uploads/group_悼灵花.webp"
},
{
"name": "慕风蘑菇",
"image": "/uploads/group_慕风蘑菇.webp"
},
{
"name": "日落果",
"image": "/uploads/group_日落果.webp"
},
{
"name": "星螺",
"image": "/uploads/group_星螺.webp"
},
{
"name": "晶化骨髓",
"image": "/uploads/group_晶化骨髓.webp"
},
{
"name": "晶蝶",
"image": "/uploads/group_晶蝶.webp"
},
{
"name": "月莲",
"image": "/uploads/group_月莲.webp"
},
{
"name": "月落银",
"image": "/uploads/group_月落银.webp"
},
{
"name": "松果",
"image": "/uploads/group_松果.webp"
},
{
"name": "松珀香",
"image": "/uploads/group_松珀香.webp"
},
{
"name": "松茸",
"image": "/uploads/group_松茸.webp"
},
{
"name": "枣椰",
"image": "/uploads/group_枣椰.webp"
},
{
"name": "枯叶紫英",
"image": "/uploads/group_枯叶紫英.webp"
},
{
"name": "柔灯铃",
"image": "/uploads/group_柔灯铃.webp"
},
{
"name": "树王圣体菇",
"image": "/uploads/group_树王圣体菇.webp"
},
{
"name": "汐藻",
"image": "/uploads/group_汐藻.webp"
},
{
"name": "沙脂蛹",
"image": "/uploads/group_沙脂蛹.webp"
},
{
"name": "泡泡桔",
"image": "/uploads/group_泡泡桔.webp"
},
{
"name": "浪沫羽鳃",
"image": "/uploads/group_浪沫羽鳃.webp"
},
{
"name": "海灵芝",
"image": "/uploads/group_海灵芝.webp"
},
{
"name": "海草",
"image": "/uploads/group_海草.webp"
},
{
"name": "海露花",
"image": "/uploads/group_海露花.webp"
},
{
"name": "清心",
"image": "/uploads/group_清心.webp"
},
{
"name": "清水玉",
"image": "/uploads/group_清水玉.webp"
},
{
"name": "湖光铃兰",
"image": "/uploads/group_湖光铃兰.webp"
},
{
"name": "灼灼彩菊",
"image": "/uploads/group_灼灼彩菊.webp"
},
{
"name": "烈焰花花蕊",
"image": "/uploads/group_烈焰花花蕊.webp"
},
{
"name": "烛伞蘑菇",
"image": "/uploads/group_烛伞蘑菇.webp"
},
{
"name": "烬芯花",
"image": "/uploads/group_烬芯花.webp"
},
{
"name": "狗粮",
"image": "/uploads/group_狗粮.webp"
},
{
"name": "珊瑚真珠",
"image": "/uploads/group_珊瑚真珠.webp"
},
{
"name": "琉璃百合",
"image": "/uploads/group_琉璃百合.webp"
},
{
"name": "琉璃袋",
"image": "/uploads/group_琉璃袋.webp"
},
{
"name": "琉鳞石",
"image": "/uploads/group_琉鳞石.webp"
},
{
"name": "白灵果",
"image": "/uploads/group_白灵果.webp"
},
{
"name": "白萝卜",
"image": "/uploads/group_白萝卜.webp"
},
{
"name": "石珀",
"image": "/uploads/group_石珀.webp"
},
{
"name": "神秘的肉",
"image": "/uploads/group_神秘的肉.webp"
},
{
"name": "空羽蛾",
"image": "/uploads/group_空羽蛾.webp"
},
{
"name": "竹笋",
"image": "/uploads/group_竹笋.webp"
},
{
"name": "红果果菇",
"image": "/uploads/group_红果果菇.webp"
},
{
"name": "绝云椒椒",
"image": "/uploads/group_绝云椒椒.webp"
},
{
"name": "绯樱绣球",
"image": "/uploads/group_绯樱绣球.webp"
},
{
"name": "肉龙掌",
"image": "/uploads/group_肉龙掌.webp"
},
{
"name": "胡萝卜",
"image": "/uploads/group_胡萝卜.webp"
},
{
"name": "苍晶螺",
"image": "/uploads/group_苍晶螺.webp"
},
{
"name": "苦种",
"image": "/uploads/group_苦种.webp"
},
{
"name": "苹果",
"image": "/uploads/group_苹果.webp"
},
{
"name": "茉洁草",
"image": "/uploads/group_茉洁草.webp"
},
{
"name": "莲蓬",
"image": "/uploads/group_莲蓬.webp"
},
{
"name": "落落莓",
"image": "/uploads/group_落落莓.webp"
},
{
"name": "蒲公英籽",
"image": "/uploads/group_蒲公英籽.webp"
},
{
"name": "薄荷",
"image": "/uploads/group_薄荷.webp"
},
{
"name": "蘑菇",
"image": "/uploads/group_蘑菇.webp"
},
{
"name": "虹彩蔷薇",
"image": "/uploads/group_虹彩蔷薇.webp"
},
{
"name": "蜥蜴尾巴",
"image": "/uploads/group_蜥蜴尾巴.webp"
},
{
"name": "血斛",
"image": "/uploads/group_血斛.webp"
},
{
"name": "赤念果",
"image": "/uploads/group_赤念果.webp"
},
{
"name": "金鱼草",
"image": "/uploads/group_金鱼草.webp"
},
{
"name": "钩钩果",
"image": "/uploads/group_钩钩果.webp"
},
{
"name": "霓裳花",
"image": "/uploads/group_霓裳花.webp"
},
{
"name": "霜盏花",
"image": "/uploads/group_霜盏花.webp"
},
{
"name": "青蜜莓",
"image": "/uploads/group_青蜜莓.webp"
},
{
"name": "须弥蔷薇",
"image": "/uploads/group_须弥蔷薇.webp"
},
{
"name": "颗粒果",
"image": "/uploads/group_颗粒果.webp"
},
{
"name": "风息花",
"image": "/uploads/group_风息花.webp"
},
{
"name": "风车菊",
"image": "/uploads/group_风车菊.webp"
},
{
"name": "香辛果",
"image": "/uploads/group_香辛果.webp"
},
{
"name": "马尾",
"image": "/uploads/group_马尾.webp"
},
{
"name": "鬼兜虫",
"image": "/uploads/group_鬼兜虫.webp"
},
{
"name": "鱼肉",
"image": "/uploads/group_鱼肉.webp"
},
{
"name": "鸟蛋",
"image": "/uploads/group_鸟蛋.webp"
},
{
"name": "鸣草",
"image": "/uploads/group_鸣草.webp"
},
{
"name": "霜仙花",
"image": "/uploads/group_霜仙花.webp"
}
]
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Some files were not shown because too many files have changed in this diff Show More