- 修复再次提交不置顶(排序键改用 last_submit_ts) - 后端结果缓存 + 并发锁(DataCache / ny_build_data / Roster 指纹失效) - 导出 xlsx 多表:按最新成绩 / 按班级分表 / 可选列 - 名单管理:新增改弹窗、删除多选+二次确认、登陆账号列 - 安全:CSRF token + 登录失败限流(5次锁10分钟) - 未匹配答卷一键加入名单(unmatched_map) - 缺考名单增强:复制学号/姓名、按班统计、打印 - 成绩快照 + 跨卷趋势(Snapshot) - 异常检测线索(短时长高分/高频重考/雷同成绩) - 前端性能:虚拟滚动、搜索防抖、后台暂停、失败退避 - 测试沉淀:test-new-features.php + run-all.sh
463 lines
21 KiB
PHP
463 lines
21 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
require __DIR__ . '/bootstrap.php';
|
|
|
|
if (!ny_is_auth()) {
|
|
header('Location: login.php');
|
|
exit;
|
|
}
|
|
|
|
$cfg = ny_load_config();
|
|
$rosterCount = Roster::count();
|
|
?>
|
|
<!DOCTYPE html>
|
|
<html lang="zh-CN">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title><?= htmlspecialchars(NY_APP_NAME) ?></title>
|
|
<link rel="stylesheet" href="assets/style.css">
|
|
</head>
|
|
<body>
|
|
|
|
<header class="topbar">
|
|
<div class="brand">
|
|
<span class="logo">NY</span>
|
|
<div class="brand-text">
|
|
<strong>NY问卷考试系统数据平台</strong>
|
|
<small>卷王问卷系统 · 实时数据统计</small>
|
|
</div>
|
|
</div>
|
|
<div class="top-right">
|
|
<div id="conn" class="conn conn-off"><i></i><span>未连接</span></div>
|
|
<a class="btn" id="btnDashboard" href="dashboard.php" target="_blank" rel="noopener" title="在新标签页打开数据看板">📊 数据看板</a>
|
|
<a class="btn" id="btnDraw" href="draw.php" target="_blank" rel="noopener" title="在新标签页打开随机抽取">🎲 随机抽取</a>
|
|
<button class="btn" id="btnFullscreen" title="切换全屏">⛶ 全屏</button>
|
|
<button class="btn" id="btnRoster">名单管理 <b id="rosterBadge"><?= (int)$rosterCount ?></b></button>
|
|
<button class="btn btn-primary" id="btnSettings">设置</button>
|
|
<button class="btn" id="btnLogout" title="退出登录">退出</button>
|
|
</div>
|
|
</header>
|
|
|
|
<main class="wrap">
|
|
|
|
<section class="toolbar">
|
|
<div class="field grow">
|
|
<label>问卷 / 考试</label>
|
|
<select id="projectSel"><option value="">加载中…</option></select>
|
|
</div>
|
|
<div class="field">
|
|
<label>姓名字段</label>
|
|
<select id="nameFieldSel"><option value="">自动识别</option></select>
|
|
</div>
|
|
<div class="field">
|
|
<label>班级</label>
|
|
<select id="clsSel"><option value="">全部班级</option></select>
|
|
</div>
|
|
<div class="field">
|
|
<label>组别</label>
|
|
<select id="grpSel"><option value="">全部组别</option></select>
|
|
</div>
|
|
<div class="field">
|
|
<label>自动刷新</label>
|
|
<div class="inline">
|
|
<label class="switch"><input type="checkbox" id="autoRefresh" checked><span></span></label>
|
|
<select id="intervalSel">
|
|
<option value="5">5 秒</option>
|
|
<option value="10">10 秒</option>
|
|
<option value="15" selected>15 秒</option>
|
|
<option value="30">30 秒</option>
|
|
<option value="60">60 秒</option>
|
|
<option value="120">2 分钟</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div class="field btns">
|
|
<button class="btn btn-primary" id="btnRefresh">立即刷新</button>
|
|
</div>
|
|
<div class="field grow-2">
|
|
<label>搜索</label>
|
|
<input type="text" id="kw" placeholder="输入学号或姓名筛选">
|
|
</div>
|
|
<div class="field btns" style="flex-wrap:wrap;gap:8px">
|
|
<label style="display:flex;align-items:center;gap:6px;font-size:12px;color:var(--text-2)">导出排序
|
|
<select id="exportSortSel" style="height:30px;border-radius:8px;border:1px solid var(--border);padding:0 6px;background:#fff">
|
|
<option value="time">按提交时间</option>
|
|
<option value="sid">按学号</option>
|
|
<option value="score_desc">按成绩降序</option>
|
|
<option value="score_asc">按成绩升序</option>
|
|
</select>
|
|
</label>
|
|
<label style="display:flex;align-items:center;gap:4px;font-size:12px;color:var(--text-2);cursor:pointer" title="每人只导一行:最近一次成绩 + 最高分 + 考试次数">
|
|
<input type="checkbox" id="exportLatest" style="width:auto;height:auto">按最新成绩
|
|
</label>
|
|
<label style="display:flex;align-items:center;gap:4px;font-size:12px;color:var(--text-2);cursor:pointer" title="按班级拆分多个工作表(xlsx 多表)">
|
|
<input type="checkbox" id="exportSplit" style="width:auto;height:auto">按班级分表
|
|
</label>
|
|
<button class="btn" id="btnExport">导出成绩</button>
|
|
<button class="btn" id="btnExportAbsent">导出缺考</button>
|
|
<button class="btn" id="btnSnapshot" title="保存当前成绩为一份带标签的时间点快照">存快照</button>
|
|
<button class="btn" id="btnTrend" title="查看本卷各快照间每名学生的成绩趋势">趋势</button>
|
|
<button class="btn" id="btnAnomaly" title="异常检测线索:短时长高分 / 高频重考 / 雷同成绩">异常</button>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="cards" id="statCards">
|
|
<div class="card accent-blue"><span>名单总人数</span><b id="sTotal">-</b></div>
|
|
<div class="card ok accent-green"><span>已提交</span><b id="sSubmit">-</b></div>
|
|
<div class="card warn accent-orange"><span>缺考</span><b id="sAbsent">-</b></div>
|
|
<div class="card accent-teal"><span>平均分</span><b id="sAvg">-</b></div>
|
|
<div class="card accent-green"><span>最高分</span><b id="sMax">-</b></div>
|
|
<div class="card accent-red"><span>最低分</span><b id="sMin">-</b></div>
|
|
<div class="card accent-indigo"><span>合格率(≥60%满分)</span><b id="sPass">-</b></div>
|
|
<div class="card accent-gray"><span>平均用时</span><b id="sDur">-</b></div>
|
|
</section>
|
|
|
|
<div class="layout" id="dataLayout" data-layout="default">
|
|
<section class="panel" id="tablePanel">
|
|
<div class="panel-head">
|
|
<h2>成绩明细 <small id="tableHint"></small></h2>
|
|
<div class="updated">
|
|
<button class="btn sm" id="btnClearHl" hidden title="清除新数据的绿色高亮标记(每 30 秒自动清除一次)">清除高亮 <b id="hlBadge">0</b></button>
|
|
最后更新:<span id="lastUpdate">—</span><i id="liveDot" class="dot"></i>
|
|
</div>
|
|
</div>
|
|
<div class="table-scroll">
|
|
<table class="grid" id="dataTable">
|
|
<thead>
|
|
<tr>
|
|
<th class="sortable" data-k="sid">学号</th>
|
|
<th class="sortable" data-k="name">姓名</th>
|
|
<th class="sortable" data-k="cls">班级</th>
|
|
<th class="sortable" data-k="grp">组别</th>
|
|
<th class="sortable" data-k="score">成绩</th>
|
|
<th class="sortable" data-k="attempts" title="该生提交过的次数,点击表头可排序">考试次数</th>
|
|
<th class="sortable" data-k="submit_ts" title="最近一次提交时间">提交时间</th>
|
|
<th class="sortable" data-k="duration">考试时长</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="tbody"><tr><td colspan="8" class="empty">请选择问卷 / 考试</td></tr></tbody>
|
|
</table>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="panel" id="absentPanel">
|
|
<div class="panel-head">
|
|
<h2>缺考名单 <small id="absentHint"></small></h2>
|
|
<span class="spacer"></span>
|
|
<button class="btn sm" id="btnCopySids" title="复制全部缺考学号(逗号分隔)">复制学号</button>
|
|
<button class="btn sm" id="btnCopyNames" title="复制全部缺考姓名">复制姓名</button>
|
|
<button class="btn sm" id="btnPrintAbsent" title="打印缺考名单">打印</button>
|
|
</div>
|
|
<div class="table-scroll short">
|
|
<table class="grid compact" id="absentTable">
|
|
<thead><tr><th>学号</th><th>姓名</th><th>班级</th><th>组别</th></tr></thead>
|
|
<tbody id="absentBody"><tr><td colspan="4" class="empty">—</td></tr></tbody>
|
|
</table>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="panel" id="unmatchedPanel">
|
|
<div class="panel-head"><h2>未匹配答卷 <small>姓名不在名单中</small></h2></div>
|
|
<div class="table-scroll short">
|
|
<table class="grid compact">
|
|
<thead><tr><th>姓名</th><th>成绩</th><th>提交时间</th><th style="width:90px">操作</th></tr></thead>
|
|
<tbody id="unmatchedBody"><tr><td colspan="4" class="empty">—</td></tr></tbody>
|
|
</table>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
</main>
|
|
|
|
<!-- ================= 设置 ================= -->
|
|
<div class="modal" id="settingsModal">
|
|
<div class="modal-box">
|
|
<div class="modal-head"><h3>系统设置</h3><button class="x" data-close>×</button></div>
|
|
<div class="modal-body">
|
|
<div class="form-grid">
|
|
<div class="fi full">
|
|
<label>布局</label>
|
|
<div class="fi-inline">
|
|
<label class="chk"><input type="radio" name="cfgLayout" value="default" id="layoutDef"> 默认(缺考+未匹配同行)</label>
|
|
<label class="chk"><input type="radio" name="cfgLayout" value="side" id="layoutSide"> 成绩明细与缺考名单同行</label>
|
|
</div>
|
|
<p class="hint">主界面三个面板(成绩明细 / 缺考名单 / 未匹配答卷)的排版方式</p>
|
|
</div>
|
|
<div class="fi">
|
|
<label>默认刷新间隔(秒)</label>
|
|
<input type="number" id="cfgInterval" min="3" max="600">
|
|
</div>
|
|
<div class="fi">
|
|
<label>请求超时(秒)</label>
|
|
<input type="number" id="cfgTimeout" min="3" max="120">
|
|
</div>
|
|
<div class="fi">
|
|
<label>每页拉取条数</label>
|
|
<input type="number" id="cfgPageSize" min="20" max="200">
|
|
<p class="hint">实际生效上限 200,超出会自动夹到 200(防止单页内存耗尽)</p>
|
|
</div>
|
|
<div class="fi">
|
|
<label>默认满分(用于合格率)</label>
|
|
<input type="number" id="cfgFull" min="1" max="1000">
|
|
<p class="hint" id="cfgFullHint">未选中试卷时的默认值</p>
|
|
</div>
|
|
<div class="fi">
|
|
<label>历史成绩展示方式</label>
|
|
<select id="cfgHistoryDisplay">
|
|
<option value="click">点击成员弹窗查看(默认)</option>
|
|
<option value="inline">成绩明细内全部展开</option>
|
|
</select>
|
|
<p class="hint">「全部展开」时每次考试单独一行,直接显示在成绩明细中</p>
|
|
</div>
|
|
<div class="fi">
|
|
<label>成绩导出排序</label>
|
|
<select id="cfgExportSort">
|
|
<option value="time">按提交时间(默认)</option>
|
|
<option value="sid">按学号(同学号按提交时间)</option>
|
|
<option value="score_desc">按成绩降序</option>
|
|
<option value="score_asc">按成绩升序</option>
|
|
</select>
|
|
<p class="hint">导出包含每次考试记录,始终带「考试次数」列</p>
|
|
</div>
|
|
<div class="fi full">
|
|
<label>姓名题关键词</label>
|
|
<input type="text" id="cfgKeywords">
|
|
<p class="hint">英文逗号分隔,用于自动识别"姓名"题目</p>
|
|
</div>
|
|
<div class="fi full">
|
|
<label>各试卷独立满分</label>
|
|
<div id="fullScoresBox" class="fullscores">
|
|
<span class="hint">未选中试卷时,此处为空;选中某套试卷后保存即在设置中为该卷单独设定满分</span>
|
|
</div>
|
|
<!-- 注意:#fullScoresHint 必须放在 #fullScoresBox 之外,
|
|
否则 renderFullScores() 的 box.innerHTML 覆盖会把它销毁,导致 textContent 赋值报 null -->
|
|
<p class="hint" id="fullScoresHint">给某卷填入数字即可单独覆盖默认满分;留空=用默认</p>
|
|
</div>
|
|
<div class="fi full checks">
|
|
<label><input type="checkbox" id="cfgDedupe"> 同一学号只保留最新一份答卷</label>
|
|
<label><input type="checkbox" id="cfgTemp"> 包含"暂存"未提交的答卷</label>
|
|
<label><input type="checkbox" id="cfgSsl"> 校验 HTTPS 证书</label>
|
|
</div>
|
|
<p class="hint" style="margin-top:-4px">
|
|
<b>「只保留最新一份」说明</b>:勾选后主明细表的「成绩」列取该生最新一次提交的分数,
|
|
取消勾选则取最高分。<b>无论是否勾选,「考试次数」与「历史成绩」始终展示全部提交记录</b>。
|
|
</p>
|
|
|
|
<div class="fi full" style="margin-top:6px">
|
|
<div style="display:flex;align-items:center;gap:8px">
|
|
<label style="margin:0">🔌 卷王系统连接</label>
|
|
<span class="hint" id="cfgConnInfo" style="font-size:12px"></span>
|
|
</div>
|
|
</div>
|
|
<div class="fi full">
|
|
<label>系统地址(不可在此修改)</label>
|
|
<input type="text" id="cfgBase" readonly>
|
|
<p class="hint">如需修改请编辑 <code>data/config.json</code>(保存后清 token 自动重连)</p>
|
|
</div>
|
|
<div class="fi">
|
|
<label>登录账号</label>
|
|
<input type="text" id="cfgUser" autocomplete="username" placeholder="登录卷王的账号">
|
|
</div>
|
|
<div class="fi">
|
|
<label>登录密码</label>
|
|
<input type="password" id="cfgPass" autocomplete="new-password" placeholder="已保存 · 留空表示不修改">
|
|
<p class="hint">为安全,已保存的密码不回显;如需修改请直接输入新密码后保存</p>
|
|
</div>
|
|
</div>
|
|
<div id="pingResult" class="ping-result hidden"></div>
|
|
</div>
|
|
<div class="modal-foot">
|
|
<span class="hint" style="align-self:center">只有修改账号/密码才会触发重连与测连接</span>
|
|
<span class="spacer"></span>
|
|
<button class="btn" id="btnTestConn">测试连接</button>
|
|
<button class="btn" data-close>取消</button>
|
|
<button class="btn btn-primary" id="btnSaveCfg">保存</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ================= 名单管理 ================= -->
|
|
<div class="modal" id="rosterModal">
|
|
<div class="modal-box wide">
|
|
<div class="modal-head"><h3>学号名单管理</h3><button class="x" data-close>×</button></div>
|
|
<div class="modal-body">
|
|
<div class="upload-zone" id="dropZone">
|
|
<input type="file" id="rosterFile" accept=".xlsx,.xlsm,.csv,.txt" hidden>
|
|
<div class="uz-inner">
|
|
<strong>点击或拖拽文件到此上传</strong>
|
|
<span>支持 .xlsx / .csv,需包含「学号」「姓名」两列(「班级」「组别」可选,组别如 A/B)</span>
|
|
</div>
|
|
</div>
|
|
<div class="roster-bar">
|
|
<div class="inline">
|
|
<label class="chk"><input type="radio" name="impMode" value="replace" checked> 覆盖</label>
|
|
<label class="chk"><input type="radio" name="impMode" value="append"> 追加</label>
|
|
</div>
|
|
<input type="text" id="rosterSearch" placeholder="搜索学号 / 姓名">
|
|
<span class="spacer"></span>
|
|
<button class="btn sm" id="btnAddRow">+ 新增</button>
|
|
<button class="btn sm danger" id="btnDelSel" hidden>删除选中</button>
|
|
<button class="btn sm danger" id="btnClearRoster">清空</button>
|
|
<button class="btn sm btn-primary" id="btnSaveRoster">保存名单</button>
|
|
</div>
|
|
<div class="table-scroll mid">
|
|
<table class="grid compact" id="rosterTable">
|
|
<thead><tr><th style="width:34px"><input type="checkbox" id="rosterCheckAll" title="全选"></th><th style="width:24%">学号</th><th style="width:20%">姓名</th><th>班级</th><th>组别</th><th>登陆账号</th></tr></thead>
|
|
<tbody id="rosterBody"></tbody>
|
|
</table>
|
|
</div>
|
|
<p class="hint" id="rosterMeta">共 0 人</p>
|
|
</div>
|
|
<div class="modal-foot">
|
|
<span class="spacer"></span>
|
|
<button class="btn" data-close>关闭</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 多次刷题明细弹窗 -->
|
|
<div class="modal" id="attemptsModal">
|
|
<div class="modal-box">
|
|
<div class="modal-head"><h3 id="attTitle">提交明细</h3><button class="x" data-close>×</button></div>
|
|
<div class="modal-body" id="attBody"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 新增学生弹窗 -->
|
|
<div class="modal" id="addStudentModal">
|
|
<div class="modal-box">
|
|
<div class="modal-head"><h3>新增学生</h3><button class="x" data-close>×</button></div>
|
|
<div class="modal-body">
|
|
<div class="fi">
|
|
<label>学号 <b class="req">*</b></label>
|
|
<input type="text" id="addSid" placeholder="必填">
|
|
</div>
|
|
<div class="fi">
|
|
<label>姓名 <b class="req">*</b></label>
|
|
<input type="text" id="addName" placeholder="必填">
|
|
</div>
|
|
<div class="fi">
|
|
<label>班级</label>
|
|
<input type="text" id="addCls" placeholder="可选">
|
|
</div>
|
|
<div class="fi">
|
|
<label>组别</label>
|
|
<input type="text" id="addGrp" placeholder="如 A / B,可选">
|
|
</div>
|
|
<div class="fi">
|
|
<label>登陆账号</label>
|
|
<input type="text" id="addLogin" placeholder="可选,用于抽取页登录">
|
|
</div>
|
|
<p class="hint" id="addStudentHint"></p>
|
|
</div>
|
|
<div class="modal-foot">
|
|
<span class="spacer"></span>
|
|
<button class="btn" data-close>取消</button>
|
|
<button class="btn btn-primary" id="btnAddStudentOk">添加</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 未匹配答卷映射到名单 -->
|
|
<div class="modal" id="mapUnmatchedModal">
|
|
<div class="modal-box">
|
|
<div class="modal-head"><h3>加入名单</h3><button class="x" data-close>×</button></div>
|
|
<div class="modal-body">
|
|
<p class="hint" style="margin:0 0 12px">答卷姓名:<b id="mapUmName"></b>(不在名单中)</p>
|
|
<div class="fi">
|
|
<label>学号 <b class="req">*</b></label>
|
|
<input type="text" id="mapUmSid" placeholder="输入该生的学号">
|
|
</div>
|
|
<div class="fi">
|
|
<label>班级</label>
|
|
<input type="text" id="mapUmCls" placeholder="可选">
|
|
</div>
|
|
<div class="fi">
|
|
<label>组别</label>
|
|
<input type="text" id="mapUmGrp" placeholder="可选">
|
|
</div>
|
|
<p class="hint" id="mapUmHint"></p>
|
|
</div>
|
|
<div class="modal-foot">
|
|
<span class="spacer"></span>
|
|
<button class="btn" data-close>取消</button>
|
|
<button class="btn btn-primary" id="btnMapUmOk">加入名单</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 成绩快照 / 趋势 -->
|
|
<div class="modal" id="snapshotModal">
|
|
<div class="modal-box wide">
|
|
<div class="modal-head"><h3 id="snapTitle">成绩快照</h3><button class="x" data-close>×</button></div>
|
|
<div class="modal-body">
|
|
<div class="fi" style="margin-bottom:10px">
|
|
<label>快照标签(可选)</label>
|
|
<input type="text" id="snapLabel" placeholder="如:第一次考试、补考后…">
|
|
</div>
|
|
<div class="snap-toolbar" style="display:flex;gap:8px;margin-bottom:12px">
|
|
<button class="btn sm btn-primary" id="btnSnapSave">保存当前快照</button>
|
|
<span class="spacer"></span>
|
|
<button class="btn sm" id="btnSnapRefresh">刷新列表</button>
|
|
<button class="btn sm" id="btnSnapTrend">查看趋势</button>
|
|
</div>
|
|
<div class="table-scroll mid">
|
|
<table class="grid compact" id="snapTable">
|
|
<thead><tr><th>标签</th><th>保存时间</th><th>人数</th><th>项目</th><th style="width:70px">操作</th></tr></thead>
|
|
<tbody id="snapBody"><tr><td colspan="5" class="empty">暂无快照</td></tr></tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
<div class="modal-foot">
|
|
<span class="spacer"></span>
|
|
<button class="btn" data-close>关闭</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 跨快照趋势 -->
|
|
<div class="modal" id="trendModal">
|
|
<div class="modal-box wide">
|
|
<div class="modal-head"><h3>成绩趋势 <small id="trendSub"></small></h3><button class="x" data-close>×</button></div>
|
|
<div class="modal-body">
|
|
<div class="table-scroll" style="max-height:60vh">
|
|
<table class="grid compact" id="trendTable">
|
|
<thead id="trendHead"></thead>
|
|
<tbody id="trendBody"><tr><td colspan="10" class="empty">加载中…</td></tr></tbody>
|
|
</table>
|
|
</div>
|
|
<p class="hint" id="trendHint">趋势值为每名学生的「最高分」,未出现该快照的记「—」</p>
|
|
</div>
|
|
<div class="modal-foot">
|
|
<span class="spacer"></span>
|
|
<button class="btn" data-close>关闭</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 异常检测线索 -->
|
|
<div class="modal" id="anomalyModal">
|
|
<div class="modal-box wide">
|
|
<div class="modal-head"><h3>异常检测线索</h3><button class="x" data-close>×</button></div>
|
|
<div class="modal-body">
|
|
<p class="hint" style="margin:0 0 12px">以下为自动统计的「防作弊线索」,仅供人工复核判断,不代表结论。</p>
|
|
<div id="anomalyBody"></div>
|
|
</div>
|
|
<div class="modal-foot">
|
|
<span class="spacer"></span>
|
|
<button class="btn" data-close>关闭</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
window.NY = {
|
|
appName: <?= json_encode(NY_APP_NAME, JSON_UNESCAPED_UNICODE) ?>,
|
|
version: <?= json_encode(NY_VERSION, JSON_UNESCAPED_UNICODE) ?>,
|
|
interval: <?= (int)$cfg['refresh_interval'] ?>
|
|
};
|
|
</script>
|
|
<script src="assets/app.js"></script>
|
|
</body>
|
|
</html>
|