添加学号追踪
This commit is contained in:
parent
744d1b65f8
commit
bf6804c49e
14
admin.html
14
admin.html
@ -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 = '';
|
||||||
|
|||||||
62
index.ts
62
index.ts
@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@ -0,0 +1,5 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "RecordSubmission" ADD COLUMN "studentId" TEXT;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "TrackerEvent" ADD COLUMN "studentId" TEXT;
|
||||||
@ -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
373
students.html
Normal 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>
|
||||||
Loading…
x
Reference in New Issue
Block a user