374 lines
8.6 KiB
HTML
374 lines
8.6 KiB
HTML
<!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,
|
||
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;
|
||
}
|
||
.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;
|
||
}
|
||
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;
|
||
}
|
||
.small {
|
||
font-size: 12px;
|
||
color: var(--muted);
|
||
}
|
||
.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>
|
||
<a href="/admin" class="small" style="text-decoration: none; color: var(--accent)">返回管理后台</a>
|
||
</div>
|
||
<div class="row" style="margin-top: 10px">
|
||
<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>
|
||
</header>
|
||
|
||
<main>
|
||
<section class="panel">
|
||
<div class="panel-hd">
|
||
<h2>做过实验的学生</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">
|
||
<input id="studentId" placeholder="studentId(模糊)" style="min-width: 240px" />
|
||
<button id="apply" class="primary">查询</button>
|
||
<button id="reset" class="ghost">重置</button>
|
||
<span class="small" id="summary"></span>
|
||
</div>
|
||
|
||
<div id="error" class="small error" style="margin: 10px 0"></div>
|
||
|
||
<div style="overflow: auto">
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>studentId</th>
|
||
<th>count</th>
|
||
<th>lastAt</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="tbody"></tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</main>
|
||
|
||
<script type="module">
|
||
const $ = (id) => document.getElementById(id);
|
||
|
||
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 '';
|
||
const d = new Date(value);
|
||
if (Number.isNaN(d.getTime())) return '';
|
||
return d.toISOString();
|
||
}
|
||
|
||
function setError(msg) {
|
||
$('error').textContent = msg || '';
|
||
}
|
||
|
||
function renderRows(rows) {
|
||
const tbody = $('tbody');
|
||
tbody.innerHTML = '';
|
||
for (const r of rows) {
|
||
const tr = document.createElement('tr');
|
||
|
||
const cols = [
|
||
r.studentId || '',
|
||
String(r.count ?? ''),
|
||
r.lastAt ? new Date(r.lastAt).toLocaleString() : '',
|
||
];
|
||
|
||
for (let i = 0; i < cols.length; i++) {
|
||
const td = document.createElement('td');
|
||
if (i === 0) td.className = 'mono';
|
||
td.textContent = cols[i];
|
||
tr.appendChild(td);
|
||
}
|
||
tbody.appendChild(tr);
|
||
}
|
||
}
|
||
|
||
async function loadList() {
|
||
setError('');
|
||
const from = toISOFromLocal($('from').value);
|
||
const to = toISOFromLocal($('to').value);
|
||
|
||
const params = {
|
||
from,
|
||
to,
|
||
studentId: $('studentId').value.trim(),
|
||
};
|
||
|
||
const data = await api('/api/admin/students', params);
|
||
$('summary').textContent = `共 ${data.total} 名学生`;
|
||
renderRows(data.rows || []);
|
||
}
|
||
|
||
$('saveToken').addEventListener('click', async () => {
|
||
setToken($('token').value);
|
||
try {
|
||
await loadList();
|
||
setStatus(true, '已连接');
|
||
} catch (e) {
|
||
setStatus(false, '未连接');
|
||
setError(e?.message || String(e));
|
||
}
|
||
});
|
||
|
||
$('clearToken').addEventListener('click', () => {
|
||
setToken('');
|
||
$('token').value = '';
|
||
setStatus(false, '未连接');
|
||
setError('已清除 token');
|
||
$('summary').textContent = '';
|
||
$('tbody').innerHTML = '';
|
||
});
|
||
|
||
$('refresh').addEventListener('click', async () => {
|
||
try {
|
||
await loadList();
|
||
setStatus(true, '已连接');
|
||
} catch (e) {
|
||
setStatus(false, '未连接');
|
||
setError(e?.message || String(e));
|
||
}
|
||
});
|
||
|
||
$('apply').addEventListener('click', async () => {
|
||
try {
|
||
await loadList();
|
||
setStatus(true, '已连接');
|
||
} catch (e) {
|
||
setStatus(false, '未连接');
|
||
setError(e?.message || String(e));
|
||
}
|
||
});
|
||
|
||
$('reset').addEventListener('click', async () => {
|
||
$('studentId').value = '';
|
||
$('from').value = '';
|
||
$('to').value = '';
|
||
try {
|
||
await loadList();
|
||
setStatus(true, '已连接');
|
||
} catch (e) {
|
||
setStatus(false, '未连接');
|
||
setError(e?.message || String(e));
|
||
}
|
||
});
|
||
|
||
// init
|
||
$('token').value = getToken();
|
||
(async () => {
|
||
try {
|
||
if (getToken()) {
|
||
await loadList();
|
||
setStatus(true, '已连接');
|
||
} else {
|
||
setStatus(false, '未连接');
|
||
setError('请先在顶部设置 ADMIN_TOKEN');
|
||
}
|
||
} catch (e) {
|
||
setStatus(false, '未连接');
|
||
setError(e?.message || String(e));
|
||
}
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>
|