first commit
This commit is contained in:
+663
@@ -0,0 +1,663 @@
|
||||
<!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>
|
||||
Reference in New Issue
Block a user