2026-01-05 11:10:59 +08:00

664 lines
17 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>管理后台 - 声速测定</title>
<style>
:root {
color-scheme: light;
--bg: #ffffff;
--fg: #111827;
--muted: #6b7280;
--border: #e5e7eb;
--panel: #f9fafb;
--danger: #b91c1c;
--ok: #065f46;
--accent: #2563eb;
--mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
--sans: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji",
"Segoe UI Emoji";
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: var(--sans);
background: var(--bg);
color: var(--fg);
}
header {
position: sticky;
top: 0;
z-index: 10;
background: var(--bg);
border-bottom: 1px solid var(--border);
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 12px 16px;
}
.row {
display: flex;
gap: 12px;
flex-wrap: wrap;
align-items: center;
}
.spacer {
flex: 1;
}
input,
select,
button {
font: inherit;
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: 8px;
background: #fff;
color: var(--fg);
}
button {
cursor: pointer;
}
button.primary {
border-color: var(--accent);
background: var(--accent);
color: #fff;
}
button.ghost {
background: #fff;
}
.badge {
font-size: 12px;
padding: 3px 8px;
border-radius: 999px;
border: 1px solid var(--border);
background: var(--panel);
color: var(--muted);
}
.badge.ok {
color: var(--ok);
border-color: rgba(6, 95, 70, 0.25);
background: rgba(6, 95, 70, 0.06);
}
.badge.err {
color: var(--danger);
border-color: rgba(185, 28, 28, 0.25);
background: rgba(185, 28, 28, 0.06);
}
main {
max-width: 1200px;
margin: 0 auto;
padding: 16px;
display: grid;
grid-template-columns: 1.2fr 0.8fr;
gap: 16px;
}
@media (max-width: 1000px) {
main {
grid-template-columns: 1fr;
}
}
.panel {
border: 1px solid var(--border);
border-radius: 12px;
background: var(--panel);
}
.panel .panel-hd {
padding: 12px 12px 8px;
border-bottom: 1px solid var(--border);
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.panel .panel-bd {
padding: 12px;
}
h2 {
font-size: 14px;
margin: 0;
color: var(--muted);
letter-spacing: 0.02em;
text-transform: uppercase;
}
.tabs {
display: inline-flex;
gap: 6px;
}
.tab {
padding: 8px 10px;
border-radius: 10px;
border: 1px solid var(--border);
background: #fff;
}
.tab.active {
border-color: rgba(37, 99, 235, 0.35);
background: rgba(37, 99, 235, 0.06);
color: var(--accent);
}
table {
width: 100%;
border-collapse: collapse;
background: #fff;
border: 1px solid var(--border);
border-radius: 10px;
overflow: hidden;
}
th,
td {
text-align: left;
padding: 10px 10px;
border-bottom: 1px solid var(--border);
vertical-align: top;
font-size: 13px;
}
th {
color: var(--muted);
font-weight: 600;
background: #fff;
}
tr:hover td {
background: rgba(37, 99, 235, 0.04);
}
.mono {
font-family: var(--mono);
font-size: 12px;
word-break: break-all;
}
.small {
font-size: 12px;
color: var(--muted);
}
.kv {
display: grid;
grid-template-columns: 160px 1fr;
gap: 8px;
font-size: 13px;
}
pre {
margin: 0;
padding: 12px;
background: #0b1220;
color: #e5e7eb;
border-radius: 10px;
overflow: auto;
font-family: var(--mono);
font-size: 12px;
line-height: 1.45;
max-height: 70vh;
}
.footer-row {
display: flex;
gap: 8px;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
}
.linkbtn {
border: 0;
background: transparent;
color: var(--accent);
padding: 0;
cursor: pointer;
}
.error {
color: var(--danger);
}
</style>
</head>
<body>
<header>
<div class="container">
<div class="row">
<strong>声速测定 - 管理后台</strong>
<span id="status" class="badge">未连接</span>
<div class="spacer"></div>
<label class="small">ADMIN_TOKEN</label>
<input id="token" style="min-width: 260px" placeholder="粘贴 ADMIN_TOKEN" />
<button id="saveToken" class="primary">保存</button>
<button id="clearToken" class="ghost">清除</button>
<button id="refresh" class="ghost">刷新</button>
</div>
<div class="row" style="margin-top: 10px">
<div class="tabs">
<button id="tabTracker" class="tab active">Tracker</button>
<button id="tabRecord" class="tab">Record</button>
</div>
<span id="stats" class="small"></span>
</div>
</div>
</header>
<main>
<section class="panel">
<div class="panel-hd">
<h2 id="listTitle">Tracker 列表</h2>
<div class="spacer"></div>
<label class="small">From</label>
<input id="from" type="datetime-local" />
<label class="small">To</label>
<input id="to" type="datetime-local" />
</div>
<div class="panel-bd">
<div class="row" id="filtersTracker">
<input id="fSession" placeholder="sessionId模糊" style="min-width: 220px" />
<input id="fEvent" placeholder="event模糊" style="min-width: 180px" />
<input id="fStep" placeholder="step模糊" style="min-width: 180px" />
<select id="pageSize">
<option value="20">20 / 页</option>
<option value="50" selected>50 / 页</option>
<option value="100">100 / 页</option>
<option value="200">200 / 页</option>
</select>
<button id="apply" class="primary">查询</button>
<button id="reset" class="ghost">重置</button>
</div>
<div class="row" id="filtersRecord" style="display: none">
<input id="rSession" placeholder="sessionId模糊" style="min-width: 220px" />
<select id="rNotes">
<option value="">notes全部</option>
<option value="has">notes</option>
<option value="empty">notes</option>
</select>
<select id="rPageSize">
<option value="20">20 / 页</option>
<option value="50" selected>50 / 页</option>
<option value="100">100 / 页</option>
<option value="200">200 / 页</option>
</select>
<button id="rApply" class="primary">查询</button>
<button id="rReset" class="ghost">重置</button>
</div>
<div id="error" class="small error" style="margin: 10px 0"></div>
<div style="overflow: auto">
<table id="table">
<thead>
<tr id="thead"></tr>
</thead>
<tbody id="tbody"></tbody>
</table>
</div>
<div class="footer-row" style="margin-top: 10px">
<div class="small" id="pagerInfo"></div>
<div class="row">
<button id="prev" class="ghost">上一页</button>
<button id="next" class="ghost">下一页</button>
</div>
</div>
</div>
</section>
<aside class="panel">
<div class="panel-hd">
<h2>详情</h2>
<div class="spacer"></div>
<button id="copy" class="ghost">复制 JSON</button>
<button id="download" class="ghost">下载 JSON</button>
</div>
<div class="panel-bd">
<div class="kv" style="margin-bottom: 10px">
<div class="small">当前选中</div>
<div class="mono" id="selectedId"></div>
</div>
<pre id="detail">选择左侧某一行查看详情</pre>
</div>
</aside>
</main>
<script type="module">
const $ = (id) => document.getElementById(id);
const state = {
mode: 'tracker',
page: 1,
total: 0,
pageSize: 50,
selected: null,
};
function setStatus(ok, text) {
const el = $('status');
el.textContent = text;
el.className = 'badge ' + (ok ? 'ok' : 'err');
}
function getToken() {
return (localStorage.getItem('ADMIN_TOKEN') || '').trim();
}
function setToken(v) {
localStorage.setItem('ADMIN_TOKEN', (v || '').trim());
}
async function api(path, params = undefined) {
const token = getToken();
if (!token) throw new Error('未设置 ADMIN_TOKEN');
let url = path;
if (params) {
const u = new URL(location.origin + path);
for (const [k, v] of Object.entries(params)) {
if (v === undefined || v === null || v === '') continue;
u.searchParams.set(k, String(v));
}
url = u.pathname + u.search;
}
const res = await fetch(url, {
headers: {
Authorization: 'Bearer ' + token,
},
});
const json = await res.json().catch(() => ({}));
if (!res.ok || !json.ok) {
throw new Error(json?.error || ('请求失败:' + res.status));
}
return json;
}
function toISOFromLocal(value) {
if (!value) return '';
// datetime-local 输出不带时区;按本地时间构造 Date
const d = new Date(value);
if (Number.isNaN(d.getTime())) return '';
return d.toISOString();
}
function setDetail(obj, id) {
state.selected = obj;
$('selectedId').textContent = id || '—';
$('detail').textContent = obj ? JSON.stringify(obj, null, 2) : '—';
}
function setError(msg) {
$('error').textContent = msg || '';
}
function renderTableHead() {
const thead = $('thead');
thead.innerHTML = '';
const cols =
state.mode === 'tracker'
?
['createdAt', 'event', 'step', 'sessionId', 'ip', 'id']
:
['createdAt', 'sessionId', 'notes', 'ip', 'id'];
for (const c of cols) {
const th = document.createElement('th');
th.textContent = c;
thead.appendChild(th);
}
}
function renderRows(rows) {
const tbody = $('tbody');
tbody.innerHTML = '';
for (const r of rows) {
const tr = document.createElement('tr');
tr.style.cursor = 'pointer';
tr.addEventListener('click', async () => {
try {
setError('');
const detail =
state.mode === 'tracker'
? await api('/api/admin/tracker/' + r.id)
: await api('/api/admin/record/' + r.id);
setDetail(detail.row, r.id);
} catch (e) {
setError(e?.message || String(e));
}
});
const cols =
state.mode === 'tracker'
?
[
new Date(r.createdAt).toLocaleString(),
r.event || '',
r.step || '',
r.sessionId || '',
r.ip || '',
r.id,
]
:
[
new Date(r.createdAt).toLocaleString(),
r.sessionId || '',
r.notes || '',
r.ip || '',
r.id,
];
for (let i = 0; i < cols.length; i++) {
const td = document.createElement('td');
const v = cols[i];
if (i === cols.length - 1) td.className = 'mono';
td.textContent = v;
tr.appendChild(td);
}
tbody.appendChild(tr);
}
}
function renderPager() {
const totalPages = Math.max(1, Math.ceil(state.total / state.pageSize));
$('pagerInfo').textContent = `${state.page} / ${totalPages} 页,共 ${state.total}`;
$('prev').disabled = state.page <= 1;
$('next').disabled = state.page >= totalPages;
}
async function refreshStats() {
try {
const s = await api('/api/admin/stats');
setStatus(true, '已连接');
$('stats').textContent = `Tracker${s.tracker.total}24h ${s.tracker.last24h}7d ${s.tracker.last7d} | Record${s.record.total}24h ${s.record.last24h}7d ${s.record.last7d}`;
} catch (e) {
setStatus(false, '未连接');
$('stats').textContent = '';
throw e;
}
}
async function loadList() {
setError('');
renderTableHead();
const from = toISOFromLocal($('from').value);
const to = toISOFromLocal($('to').value);
if (state.mode === 'tracker') {
state.pageSize = Number($('pageSize').value || 50);
const params = {
page: state.page,
pageSize: state.pageSize,
from,
to,
sessionId: $('fSession').value.trim(),
event: $('fEvent').value.trim(),
step: $('fStep').value.trim(),
};
const data = await api('/api/admin/tracker', params);
state.total = data.total;
renderRows(data.rows);
renderPager();
$('listTitle').textContent = 'Tracker 列表';
} else {
state.pageSize = Number($('rPageSize').value || 50);
const params = {
page: state.page,
pageSize: state.pageSize,
from,
to,
sessionId: $('rSession').value.trim(),
notes: $('rNotes').value,
};
const data = await api('/api/admin/record', params);
state.total = data.total;
renderRows(data.rows);
renderPager();
$('listTitle').textContent = 'Record 列表';
}
}
function switchMode(mode) {
state.mode = mode;
state.page = 1;
state.total = 0;
setDetail(null, null);
$('tabTracker').classList.toggle('active', mode === 'tracker');
$('tabRecord').classList.toggle('active', mode === 'record');
$('filtersTracker').style.display = mode === 'tracker' ? '' : 'none';
$('filtersRecord').style.display = mode === 'record' ? '' : 'none';
renderTableHead();
$('tbody').innerHTML = '';
$('pagerInfo').textContent = '—';
}
$('saveToken').addEventListener('click', async () => {
setToken($('token').value);
try {
await refreshStats();
await loadList();
} catch (e) {
setError(e?.message || String(e));
}
});
$('clearToken').addEventListener('click', () => {
setToken('');
$('token').value = '';
setStatus(false, '未连接');
$('stats').textContent = '';
setError('已清除 token');
});
$('refresh').addEventListener('click', async () => {
try {
await refreshStats();
await loadList();
} catch (e) {
setError(e?.message || String(e));
}
});
$('apply').addEventListener('click', async () => {
state.page = 1;
try {
await loadList();
} catch (e) {
setError(e?.message || String(e));
}
});
$('reset').addEventListener('click', async () => {
$('fSession').value = '';
$('fEvent').value = '';
$('fStep').value = '';
$('from').value = '';
$('to').value = '';
state.page = 1;
try {
await loadList();
} catch (e) {
setError(e?.message || String(e));
}
});
$('rApply').addEventListener('click', async () => {
state.page = 1;
try {
await loadList();
} catch (e) {
setError(e?.message || String(e));
}
});
$('rReset').addEventListener('click', async () => {
$('rSession').value = '';
$('rNotes').value = '';
$('from').value = '';
$('to').value = '';
state.page = 1;
try {
await loadList();
} catch (e) {
setError(e?.message || String(e));
}
});
$('prev').addEventListener('click', async () => {
state.page = Math.max(1, state.page - 1);
try {
await loadList();
} catch (e) {
setError(e?.message || String(e));
}
});
$('next').addEventListener('click', async () => {
const totalPages = Math.max(1, Math.ceil(state.total / state.pageSize));
state.page = Math.min(totalPages, state.page + 1);
try {
await loadList();
} catch (e) {
setError(e?.message || String(e));
}
});
$('tabTracker').addEventListener('click', () => switchMode('tracker'));
$('tabRecord').addEventListener('click', () => switchMode('record'));
$('copy').addEventListener('click', async () => {
try {
if (!state.selected) throw new Error('没有可复制的内容');
await navigator.clipboard.writeText(JSON.stringify(state.selected, null, 2));
setError('已复制到剪贴板');
} catch (e) {
setError(e?.message || String(e));
}
});
$('download').addEventListener('click', () => {
try {
if (!state.selected) throw new Error('没有可下载的内容');
const blob = new Blob([JSON.stringify(state.selected, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
const id = $('selectedId').textContent || 'row';
a.href = url;
a.download = `${state.mode}-${id}.json`;
a.click();
URL.revokeObjectURL(url);
} catch (e) {
setError(e?.message || String(e));
}
});
// init
$('token').value = getToken();
switchMode('tracker');
(async () => {
try {
if (getToken()) {
await refreshStats();
await loadList();
} else {
setStatus(false, '未连接');
setError('请先在顶部设置 ADMIN_TOKEN');
}
} catch (e) {
setError(e?.message || String(e));
}
})();
</script>
</body>
</html>