feat: 完善/优化/新增大批次 + 安全加固 + 性能优化

- 修复再次提交不置顶(排序键改用 last_submit_ts)
- 后端结果缓存 + 并发锁(DataCache / ny_build_data / Roster 指纹失效)
- 导出 xlsx 多表:按最新成绩 / 按班级分表 / 可选列
- 名单管理:新增改弹窗、删除多选+二次确认、登陆账号列
- 安全:CSRF token + 登录失败限流(5次锁10分钟)
- 未匹配答卷一键加入名单(unmatched_map)
- 缺考名单增强:复制学号/姓名、按班统计、打印
- 成绩快照 + 跨卷趋势(Snapshot)
- 异常检测线索(短时长高分/高频重考/雷同成绩)
- 前端性能:虚拟滚动、搜索防抖、后台暂停、失败退避
- 测试沉淀:test-new-features.php + run-all.sh
This commit is contained in:
2026-09-10 17:59:45 +08:00
parent 87750b20c4
commit 0fd880624f
14 changed files with 1784 additions and 196 deletions
+151 -7
View File
@@ -79,7 +79,7 @@ $rosterCount = Roster::count();
<label>搜索</label>
<input type="text" id="kw" placeholder="输入学号或姓名筛选">
</div>
<div class="field btns">
<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>
@@ -88,8 +88,17 @@ $rosterCount = Roster::count();
<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>
@@ -133,9 +142,15 @@ $rosterCount = Roster::count();
</section>
<section class="panel" id="absentPanel">
<div class="panel-head"><h2>缺考名单 <small id="absentHint"></small></h2></div>
<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">
<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>
@@ -146,8 +161,8 @@ $rosterCount = Roster::count();
<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></tr></thead>
<tbody id="unmatchedBody"><tr><td colspan="3" class="empty">—</td></tr></tbody>
<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>
@@ -178,7 +193,8 @@ $rosterCount = Roster::count();
</div>
<div class="fi">
<label>每页拉取条数</label>
<input type="number" id="cfgPageSize" min="20" max="1000">
<input type="number" id="cfgPageSize" min="20" max="200">
<p class="hint">实际生效上限 200,超出会自动夹到 200(防止单页内存耗尽)</p>
</div>
<div class="fi">
<label>默认满分(用于合格率)</label>
@@ -280,12 +296,13 @@ $rosterCount = Roster::count();
<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:28%">学号</th><th style="width:22%">姓名</th><th>班级</th><th>组别</th><th style="width:70px">操作</th></tr></thead>
<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>
@@ -306,6 +323,133 @@ $rosterCount = Roster::count();
</div>
</div>
<!-- 新增学生弹窗 -->
<div class="modal" id="addStudentModal">
<div class="modal-box">
<div class="modal-head"><h3>新增学生</h3><button class="x" data-close>&times;</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>&times;</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>&times;</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>&times;</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>&times;</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) ?>,