添加学号追踪

This commit is contained in:
feie9456 2026-01-05 12:02:14 +08:00
parent 744d1b65f8
commit bf6804c49e
5 changed files with 455 additions and 3 deletions

View File

@ -166,7 +166,6 @@
.mono { .mono {
font-family: var(--mono); font-family: var(--mono);
font-size: 12px; font-size: 12px;
word-break: break-all;
} }
.small { .small {
font-size: 12px; font-size: 12px;
@ -216,6 +215,7 @@
<strong>声速测定 - 管理后台</strong> <strong>声速测定 - 管理后台</strong>
<span id="status" class="badge">未连接</span> <span id="status" class="badge">未连接</span>
<div class="spacer"></div> <div class="spacer"></div>
<a href="/admin/students" class="small" style="text-decoration: none; color: var(--accent)">学生列表</a>
<label class="small">ADMIN_TOKEN</label> <label class="small">ADMIN_TOKEN</label>
<input id="token" style="min-width: 260px" placeholder="粘贴 ADMIN_TOKEN" /> <input id="token" style="min-width: 260px" placeholder="粘贴 ADMIN_TOKEN" />
<button id="saveToken" class="primary">保存</button> <button id="saveToken" class="primary">保存</button>
@ -245,6 +245,7 @@
<div class="panel-bd"> <div class="panel-bd">
<div class="row" id="filtersTracker"> <div class="row" id="filtersTracker">
<input id="fSession" placeholder="sessionId模糊" style="min-width: 220px" /> <input id="fSession" placeholder="sessionId模糊" style="min-width: 220px" />
<input id="fStudent" placeholder="studentId模糊" style="min-width: 160px" />
<input id="fEvent" placeholder="event模糊" style="min-width: 180px" /> <input id="fEvent" placeholder="event模糊" style="min-width: 180px" />
<input id="fStep" placeholder="step模糊" style="min-width: 180px" /> <input id="fStep" placeholder="step模糊" style="min-width: 180px" />
<select id="pageSize"> <select id="pageSize">
@ -259,6 +260,7 @@
<div class="row" id="filtersRecord" style="display: none"> <div class="row" id="filtersRecord" style="display: none">
<input id="rSession" placeholder="sessionId模糊" style="min-width: 220px" /> <input id="rSession" placeholder="sessionId模糊" style="min-width: 220px" />
<input id="rStudent" placeholder="studentId模糊" style="min-width: 160px" />
<select id="rNotes"> <select id="rNotes">
<option value="">notes全部</option> <option value="">notes全部</option>
<option value="has">notes</option> <option value="has">notes</option>
@ -387,9 +389,9 @@
const cols = const cols =
state.mode === 'tracker' state.mode === 'tracker'
? ?
['createdAt', 'event', 'step', 'sessionId', 'ip', 'id'] ['createdAt', 'event', 'step', 'sessionId', 'studentId', 'ip', 'id']
: :
['createdAt', 'sessionId', 'notes', 'ip', 'id']; ['createdAt', 'sessionId', 'studentId', 'notes', 'ip', 'id'];
for (const c of cols) { for (const c of cols) {
const th = document.createElement('th'); const th = document.createElement('th');
th.textContent = c; th.textContent = c;
@ -425,6 +427,7 @@
r.event || '', r.event || '',
r.step || '', r.step || '',
r.sessionId || '', r.sessionId || '',
r.studentId || '',
r.ip || '', r.ip || '',
r.id, r.id,
] ]
@ -432,6 +435,7 @@
[ [
new Date(r.createdAt).toLocaleString(), new Date(r.createdAt).toLocaleString(),
r.sessionId || '', r.sessionId || '',
r.studentId || '',
r.notes || '', r.notes || '',
r.ip || '', r.ip || '',
r.id, r.id,
@ -482,6 +486,7 @@
from, from,
to, to,
sessionId: $('fSession').value.trim(), sessionId: $('fSession').value.trim(),
studentId: $('fStudent').value.trim(),
event: $('fEvent').value.trim(), event: $('fEvent').value.trim(),
step: $('fStep').value.trim(), step: $('fStep').value.trim(),
}; };
@ -498,6 +503,7 @@
from, from,
to, to,
sessionId: $('rSession').value.trim(), sessionId: $('rSession').value.trim(),
studentId: $('rStudent').value.trim(),
notes: $('rNotes').value, notes: $('rNotes').value,
}; };
const data = await api('/api/admin/record', params); const data = await api('/api/admin/record', params);
@ -560,6 +566,7 @@
$('reset').addEventListener('click', async () => { $('reset').addEventListener('click', async () => {
$('fSession').value = ''; $('fSession').value = '';
$('fStudent').value = '';
$('fEvent').value = ''; $('fEvent').value = '';
$('fStep').value = ''; $('fStep').value = '';
$('from').value = ''; $('from').value = '';
@ -583,6 +590,7 @@
$('rReset').addEventListener('click', async () => { $('rReset').addEventListener('click', async () => {
$('rSession').value = ''; $('rSession').value = '';
$('rStudent').value = '';
$('rNotes').value = ''; $('rNotes').value = '';
$('from').value = ''; $('from').value = '';
$('to').value = ''; $('to').value = '';

View File

@ -76,6 +76,10 @@ app.get('/admin', (req, res) => {
res.sendFile(path.join(__dirname, 'admin.html')); res.sendFile(path.join(__dirname, 'admin.html'));
}); });
app.get('/admin/students', (_req, res) => {
res.sendFile(path.join(__dirname, 'students.html'));
});
app.get('/api/admin/stats', requireAdmin, async (_req, res) => { app.get('/api/admin/stats', requireAdmin, async (_req, res) => {
const now = new Date(); const now = new Date();
const d24h = new Date(now.getTime() - 24 * 60 * 60 * 1000); const d24h = new Date(now.getTime() - 24 * 60 * 60 * 1000);
@ -109,11 +113,13 @@ app.get('/api/admin/tracker', requireAdmin, async (req, res) => {
const sessionId = safeContains(req.query.sessionId); const sessionId = safeContains(req.query.sessionId);
const event = safeContains(req.query.event); const event = safeContains(req.query.event);
const step = safeContains(req.query.step); const step = safeContains(req.query.step);
const studentId = safeContains(req.query.studentId);
const from = toDate(req.query.from); const from = toDate(req.query.from);
const to = toDate(req.query.to); const to = toDate(req.query.to);
const where = { const where = {
...(sessionId ? { sessionId: { contains: sessionId, mode: 'insensitive' as const } } : {}), ...(sessionId ? { sessionId: { contains: sessionId, mode: 'insensitive' as const } } : {}),
...(studentId ? { studentId: { contains: studentId, mode: 'insensitive' as const } } : {}),
...(event ? { event: { contains: event, mode: 'insensitive' as const } } : {}), ...(event ? { event: { contains: event, mode: 'insensitive' as const } } : {}),
...(step ? { step: { contains: step, mode: 'insensitive' as const } } : {}), ...(step ? { step: { contains: step, mode: 'insensitive' as const } } : {}),
...(from || to ...(from || to
@ -140,6 +146,7 @@ app.get('/api/admin/tracker', requireAdmin, async (req, res) => {
event: true, event: true,
step: true, step: true,
sessionId: true, sessionId: true,
studentId: true,
ip: true, ip: true,
userAgent: true, userAgent: true,
}, },
@ -172,12 +179,14 @@ app.get('/api/admin/record', requireAdmin, async (req, res) => {
const skip = (page - 1) * pageSize; const skip = (page - 1) * pageSize;
const sessionId = safeContains(req.query.sessionId); const sessionId = safeContains(req.query.sessionId);
const studentId = safeContains(req.query.studentId);
const from = toDate(req.query.from); const from = toDate(req.query.from);
const to = toDate(req.query.to); const to = toDate(req.query.to);
const notes = safeContains(req.query.notes); const notes = safeContains(req.query.notes);
const where = { const where = {
...(sessionId ? { sessionId: { contains: sessionId, mode: 'insensitive' as const } } : {}), ...(sessionId ? { sessionId: { contains: sessionId, mode: 'insensitive' as const } } : {}),
...(studentId ? { studentId: { contains: studentId, mode: 'insensitive' as const } } : {}),
...(notes === 'has' ? { notes: { not: null } } : {}), ...(notes === 'has' ? { notes: { not: null } } : {}),
...(notes === 'empty' ? { notes: null } : {}), ...(notes === 'empty' ? { notes: null } : {}),
...(from || to ...(from || to
@ -202,6 +211,7 @@ app.get('/api/admin/record', requireAdmin, async (req, res) => {
id: true, id: true,
createdAt: true, createdAt: true,
sessionId: true, sessionId: true,
studentId: true,
notes: true, notes: true,
ip: true, ip: true,
userAgent: true, userAgent: true,
@ -215,6 +225,47 @@ app.get('/api/admin/record', requireAdmin, async (req, res) => {
} }
}); });
app.get('/api/admin/students', requireAdmin, async (req, res) => {
const studentId = safeContains(req.query.studentId);
const from = toDate(req.query.from);
const to = toDate(req.query.to);
const where = {
studentId: { not: null as any },
...(studentId ? { studentId: { contains: studentId, mode: 'insensitive' as const } } : {}),
...(from || to
? {
createdAt: {
...(from ? { gte: from } : {}),
...(to ? { lte: to } : {}),
},
}
: {}),
};
try {
const rows = await prisma.recordSubmission.groupBy({
by: ['studentId'],
where: where as any,
_count: { _all: true },
_max: { createdAt: true },
orderBy: [{ _max: { createdAt: 'desc' } }],
});
res.status(200).json({
ok: true,
total: rows.length,
rows: rows.map((r) => ({
studentId: r.studentId,
count: r._count._all,
lastAt: r._max.createdAt,
})),
});
} catch (e: any) {
res.status(500).json({ ok: false, error: String(e?.message ?? e) });
}
});
app.get('/api/admin/record/:id', requireAdmin, async (req, res) => { app.get('/api/admin/record/:id', requireAdmin, async (req, res) => {
const id = req.params.id; const id = req.params.id;
try { try {
@ -234,6 +285,7 @@ type TrackerPayload = {
data?: unknown; data?: unknown;
ts?: number; ts?: number;
sessionId?: string; sessionId?: string;
studentId?: string;
step?: string; step?: string;
}; };
@ -247,6 +299,7 @@ app.post('/api/tracker', async (req, res) => {
const sessionId = typeof body.sessionId === 'string' ? body.sessionId : null; const sessionId = typeof body.sessionId === 'string' ? body.sessionId : null;
const step = typeof body.step === 'string' ? body.step : null; const step = typeof body.step === 'string' ? body.step : null;
const studentId = typeof body.studentId === 'string' && body.studentId.trim() ? body.studentId.trim() : null;
const userAgent = typeof req.headers['user-agent'] === 'string' ? req.headers['user-agent'] : null; const userAgent = typeof req.headers['user-agent'] === 'string' ? req.headers['user-agent'] : null;
const ip = (req.headers['x-forwarded-for'] as string | undefined)?.split(',')[0]?.trim() || req.ip; const ip = (req.headers['x-forwarded-for'] as string | undefined)?.split(',')[0]?.trim() || req.ip;
@ -257,6 +310,7 @@ app.post('/api/tracker', async (req, res) => {
event, event,
step, step,
sessionId, sessionId,
studentId,
data: (body.data ?? null) as any, data: (body.data ?? null) as any,
ip: ip ?? null, ip: ip ?? null,
userAgent, userAgent,
@ -270,6 +324,7 @@ app.post('/api/tracker', async (req, res) => {
type RecordPayload = { type RecordPayload = {
ts?: number; ts?: number;
studentId?: string;
raw: unknown; raw: unknown;
computed: unknown; computed: unknown;
context: unknown; context: unknown;
@ -284,6 +339,12 @@ app.post('/api/record', async (req, res) => {
} }
const notes = typeof body.notes === 'string' ? body.notes : null; const notes = typeof body.notes === 'string' ? body.notes : null;
const studentId =
typeof body.studentId === 'string' && body.studentId.trim()
? body.studentId.trim()
: (typeof (body as any)?.context?.studentId === 'string' && (body as any).context.studentId.trim()
? (body as any).context.studentId.trim()
: null);
const userAgent = typeof req.headers['user-agent'] === 'string' ? req.headers['user-agent'] : null; const userAgent = typeof req.headers['user-agent'] === 'string' ? req.headers['user-agent'] : null;
const ip = (req.headers['x-forwarded-for'] as string | undefined)?.split(',')[0]?.trim() || req.ip; const ip = (req.headers['x-forwarded-for'] as string | undefined)?.split(',')[0]?.trim() || req.ip;
@ -300,6 +361,7 @@ app.post('/api/record', async (req, res) => {
context: body.context as any, context: body.context as any,
notes, notes,
sessionId, sessionId,
studentId,
ip: ip ?? null, ip: ip ?? null,
userAgent, userAgent,
}, },

View File

@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "RecordSubmission" ADD COLUMN "studentId" TEXT;
-- AlterTable
ALTER TABLE "TrackerEvent" ADD COLUMN "studentId" TEXT;

View File

@ -15,6 +15,8 @@ model TrackerEvent {
step String? step String?
sessionId String? sessionId String?
studentId String?
data Json? data Json?
ip String? ip String?
@ -35,6 +37,8 @@ model RecordSubmission {
notes String? notes String?
sessionId String? sessionId String?
studentId String?
ip String? ip String?
userAgent String? userAgent String?
} }

373
students.html Normal file
View File

@ -0,0 +1,373 @@
<!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>