Files
ny-exam-platform/index.php
T
nxxy335 25a2e8a42c 文档部署到 /ny-exam-platform 子路径 + 移除快照/趋势/异常 + 打印与 UI 全面完善
文档站(#88)
- base 默认改为 /ny-exam-platform/,可用 DOCS_BASE=/ 覆盖本地预览
- 新增 DOCS_OUT 环境变量,避免构建时清空输出目录失败导致产物半截
- 新增 tools/docs-preview.js:按子路径重写路由的本地预览服务
- 删除 docs/guide/snapshot.md,同步清理全部相关文档

移除存快照 / 趋势 / 异常(#89)
- 后端:删除 api.php 的 snapshot_save/list/delete/trend 四个 action 与
  $WRITE_ACTIONS 中的同名条目;删除 lib/Snapshot.php 与自动加载注册;
  DataService 移除 detectAnomalies() 及其调用
- 前端:移除三个按钮与三个弹窗、相关 JS 处理与 state 字段
- 测试:移除对应用例,补充 5 条名单导入列识别断言(17+9+18+32+29+11 全过)

打印完善(#90)
- 改用隐藏 iframe 输出,规避弹窗拦截;@page A4 支持纵向/横向
- 表头每页重复、行内不跨页断开、全表连续名次、可选「是否合格」列
- 分组小计(人数/平均/最高/最低/合格人数与合格率)、可附缺考名单另起一页
- 末尾签字栏;缺考名单打印改为同一套输出

UI / 交互 / Bug 修复(#91)
- dashboard.php:补「← 主系统 / 退出」入口(此前全屏后无法离开);
  修复成员下钻 onclick 里 JSON.stringify 双引号提前闭合属性导致点击报错,
  改为 data-sid 事件委托;补 Esc 关弹窗;后台标签页暂停轮询、切回自动补拉;
  刷新中断原因写入 title;数值字段统一兜底,避免 NaN / undefined%
- draw.php / draw.js:结果表新增「登陆账号」列(导出前可核对,缺失显示 学号 ⚠);
  弹窗只关最上层且支持 Esc;「全部」改为跟随候选池,手工改人数即解除;
  候选池不足时给出明确提示;自定义比例框隐藏时清空,避免旧权重被误读
- app.js / dashboard.php:成绩着色改为按「占本卷满分的百分比」判断,
  修复满分不是 100 时整列误标红
- style.css:数值列右对齐;新增 subbar、draw-stage-sub;
  看板顶栏窄屏下拉与按钮换行,消除横向滚动
2026-09-11 16:23:08 +08:00

481 lines
22 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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">
<h1 class="brand-title">NY问卷考试系统数据平台</h1>
</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>
<button class="btn btn-primary" id="btnProjCfg" title="为当前选中的试卷单独设置满分 / 合格率 / 免考名单">试卷设置</button>
</div>
<div class="field grow-2">
<label>搜索</label>
<input type="text" id="kw" placeholder="输入学号或姓名筛选">
</div>
</section>
<section class="subbar">
<span class="subbar-label">导出</span>
<label class="subbar-item">排序
<select id="exportSortSel">
<option value="time">按提交时间</option>
<option value="sid">按学号</option>
<option value="score_desc">按成绩降序</option>
<option value="score_asc">按成绩升序</option>
</select>
</label>
<label class="subbar-item" title="每人只导一行:最近一次成绩 + 最高分 + 考试次数">
<input type="checkbox" id="exportLatest">按最新成绩
</label>
<label class="subbar-item" title="按班级拆分多个工作表(xlsx 多表)">
<input type="checkbox" id="exportSplit">按班级分表
</label>
<span class="spacer"></span>
<button class="btn" id="btnExport">导出成绩</button>
<button class="btn" id="btnExportAbsent">导出缺考</button>
</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-purple"><span>提交率</span><b id="sRate">-</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>合格率<span id="sPassLabel">(≥60%满分)</span></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>
<button class="btn sm" id="btnPrintTable" title="打印成绩明细(默认按成绩降序,可选分组对比)">打印</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 id="unmatchedHint">姓名不在名单中</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>&times;</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>
<input type="number" id="cfgPassRate" min="1" max="100" step="1">
<p class="hint">达到满分的该百分比即算合格;每份试卷可单独覆盖</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 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" placeholder="https://exam.nxxy335.top" spellcheck="false">
<p class="hint">卷王后台地址,结尾不要带 <code>/</code>;修改后保存会自动重连</p>
</div>
<div class="fi">
<label>接口前缀</label>
<input type="text" id="cfgPrefix" placeholder="/api" spellcheck="false">
<p class="hint">社区版 <code>/api</code>;企业版可能是 <code>/admin-api</code></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>&times;</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>&times;</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>&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="printTableModal">
<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>打印标题</label>
<input type="text" id="ptTitle" placeholder="默认:试卷名 + 成绩明细">
</div>
<div class="fi">
<label>排序方式</label>
<select id="ptSort">
<option value="score_desc" selected>按成绩降序(默认)</option>
<option value="score_asc">按成绩升序</option>
<option value="sid">按学号</option>
<option value="time">按提交时间</option>
</select>
</div>
<div class="fi">
<label>分组对比</label>
<select id="ptGroup">
<option value="none" selected>不分组(连续打印)</option>
<option value="cls">按班级分组</option>
<option value="grp">按组别分组</option>
</select>
<p class="hint">分组后每个班/组单独一段,段首显示小计(人数/平均/最高/最低/合格率)</p>
</div>
<div class="fi">
<label>纸张方向</label>
<select id="ptOrient">
<option value="portrait" selected>纵向(A4,列少时清晰)</option>
<option value="landscape">横向(A4,列多时不易折行)</option>
</select>
</div>
<div class="fi checks">
<label><input type="checkbox" id="ptRank" checked> 显示名次(全表连续编号)</label>
<label><input type="checkbox" id="ptPass" checked> 显示「是否合格」列</label>
<label><input type="checkbox" id="ptAttempts" checked> 显示考试次数</label>
<label><input type="checkbox" id="ptAbsent" title="成绩表后另起一页附缺考名单,含备注列供手写"> 附缺考名单(另起一页)</label>
</div>
<p class="hint">打印内容<b>不受页面当前筛选外的影响</b>,始终按当前班级/组别筛选结果输出;表头会在每页自动重复,末尾附签字栏。</p>
<p class="hint" id="ptHint"></p>
</div>
<div class="modal-foot">
<span class="spacer"></span>
<button class="btn" data-close>取消</button>
<button class="btn btn-primary" id="btnPtPrint">打印</button>
</div>
</div>
</div>
<!-- 试卷独立设置(满分 / 合格率 / 免考名单)——仅对当前选中试卷生效 -->
<div class="modal" id="projCfgModal">
<div class="modal-box">
<div class="modal-head"><h3>试卷设置 <small id="projCfgSub"></small></h3><button class="x" data-close>&times;</button></div>
<div class="modal-body">
<p class="hint" style="margin:0 0 12px">以下设置<b>仅对当前选中的这份试卷生效</b>,各试卷之间互不影响。留空表示跟随全局默认。</p>
<div class="fi">
<label>本卷满分</label>
<input type="number" id="pcFull" min="1" max="1000" placeholder="留空=用全局默认">
<p class="hint" id="pcFullHint"></p>
</div>
<div class="fi">
<label>本卷合格率(%)</label>
<input type="number" id="pcPassRate" min="1" max="100" step="1" placeholder="留空=用全局默认">
<p class="hint" id="pcPassHint"></p>
</div>
<div class="fi">
<label>免考学生(学号)</label>
<textarea id="pcExempt" rows="4" placeholder="每行一个学号,也可用逗号分隔"></textarea>
<p class="hint">免考学生不计入「名单总人数」与「缺考名单」</p>
</div>
</div>
<div class="modal-foot">
<button class="btn danger" id="btnPcReset">清除本卷设置</button>
<span class="spacer"></span>
<button class="btn" data-close>取消</button>
<button class="btn btn-primary" id="btnPcSave">保存</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>