first commit
@@ -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>
|
||||
@@ -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 => ({"&":"&","<":"<",">":">","'":"'",'"':"""}[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>
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
|
After Width: | Height: | Size: 2.8 MiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 8.0 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 9.9 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 8.7 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 8.8 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 8.0 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 8.8 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 9.5 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 15 KiB |