完成批次 L-R:限流移除/白名单驱动/打印增强/全屏/表格对齐/主题变量/滚动闪烁修复
- 移除登录失败限流;fix-config.php 修正 e2e 串味的 127.0.0.1 连接 - 白名单驱动缺/待考名单(DataService + SurveyKingClient::getWhitelist,复用已取 survey) - 打印成绩明细增强(范围/合格列默认关/去页脚);看板全屏占满;表格 vertical-align:middle;隐藏登陆账号;导入默认追加 - 主题变量收口(:root 新增 brand/primary-2/ok-2 等,替换硬编码渐变);删死规则 - 表头/数据列对齐修复(index 未匹配表、dashboard 成员榜、draw 结果/历史/排除表);看板占位卡片配色一致 - 修复滚动闪烁:row-drop-in 动画经 _dropAnim 门控,仅强制重绘时播放 - 测试:新增 test-whitelist.js;run-all.sh 全绿 132
This commit is contained in:
@@ -87,6 +87,8 @@ function ny_build_data(array $c, string $projectId, string $nameQid, bool $force
|
|||||||
(string)($c['name_keywords'] ?? ''),
|
(string)($c['name_keywords'] ?? ''),
|
||||||
(int)($c['page_size'] ?? 100),
|
(int)($c['page_size'] ?? 100),
|
||||||
Roster::fingerprint(),
|
Roster::fingerprint(),
|
||||||
|
// 本卷独立配置(免考名单/满分/合格率)变化也应让缓存失效,否则改免考后可能短暂看到旧数据
|
||||||
|
json_encode(DataService::projectConfig($projectId) ?? []),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if ($ttl > 0 && !$force) {
|
if ($ttl > 0 && !$force) {
|
||||||
@@ -110,11 +112,16 @@ function ny_build_data(array $c, string $projectId, string $nameQid, bool $force
|
|||||||
// 等不到(构建很慢或锁不被支持)就自己构建,保证可用性
|
// 等不到(构建很慢或锁不被支持)就自己构建,保证可用性
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// try/finally:无论 build 是否抛异常都释放构建锁,避免异常时锁句柄挂到进程退出造成惊群
|
||||||
|
$result = null;
|
||||||
|
try {
|
||||||
$result = DataService::build($c, $projectId, $nameQid);
|
$result = DataService::build($c, $projectId, $nameQid);
|
||||||
if ($ttl > 0) {
|
if ($ttl > 0) {
|
||||||
DataCache::write($key, $result);
|
DataCache::write($key, $result);
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
DataCache::releaseLock($lock);
|
DataCache::releaseLock($lock);
|
||||||
|
}
|
||||||
|
|
||||||
return ['result' => $result, 'cached' => false, 'at' => time()];
|
return ['result' => $result, 'cached' => false, 'at' => time()];
|
||||||
}
|
}
|
||||||
@@ -152,11 +159,13 @@ try {
|
|||||||
$c['db_driver'] = class_exists('Db') ? Db::driver() : '';
|
$c['db_driver'] = class_exists('Db') ? Db::driver() : '';
|
||||||
$c['db_configured'] = class_exists('Db') && Db::isConfigured();
|
$c['db_configured'] = class_exists('Db') && Db::isConfigured();
|
||||||
$c['db_connected'] = class_exists('Db') && Db::isConnected();
|
$c['db_connected'] = class_exists('Db') && Db::isConnected();
|
||||||
$c['db_error'] = class_exists('Db') ? Db::lastError() : '';
|
// 仅在未连接时回传错误详情(供前端诊断连接失败原因);
|
||||||
|
// 已连接时一律不回传 lastError,避免把 SQL/文件路径等内部信息泄露给客户端。
|
||||||
|
$c['db_error'] = (class_exists('Db') && !Db::isConnected()) ? Db::lastError() : '';
|
||||||
ny_ok($c);
|
ny_ok($c);
|
||||||
|
|
||||||
case 'config_save':
|
case 'config_save':
|
||||||
$raw = (string)file_get_contents('php://input');
|
$raw = ny_request_body();
|
||||||
$in = json_decode($raw, true);
|
$in = json_decode($raw, true);
|
||||||
if (!is_array($in)) {
|
if (!is_array($in)) {
|
||||||
$in = $_POST;
|
$in = $_POST;
|
||||||
@@ -250,7 +259,7 @@ try {
|
|||||||
|
|
||||||
// ---------------- 用弹窗内(未保存)的凭据测试连接 ----------------
|
// ---------------- 用弹窗内(未保存)的凭据测试连接 ----------------
|
||||||
case 'config_test':
|
case 'config_test':
|
||||||
$raw = (string)file_get_contents('php://input');
|
$raw = ny_request_body();
|
||||||
$in = json_decode($raw, true);
|
$in = json_decode($raw, true);
|
||||||
if (!is_array($in)) {
|
if (!is_array($in)) {
|
||||||
$in = $_POST;
|
$in = $_POST;
|
||||||
@@ -502,12 +511,49 @@ try {
|
|||||||
}
|
}
|
||||||
// 解析完立即删除:Web 目录下不再留存任何上传文件
|
// 解析完立即删除:Web 目录下不再留存任何上传文件
|
||||||
@unlink($dest);
|
@unlink($dest);
|
||||||
$mode = (string)($_POST['mode'] ?? 'replace');
|
// 默认追加:与「导入名单默认为追加」一致(学号名单管理导入)
|
||||||
|
$mode = (string)($_POST['mode'] ?? 'append');
|
||||||
if ($mode === 'append') {
|
if ($mode === 'append') {
|
||||||
$merged = array_merge(Roster::all(), $res['students']);
|
// 追加:仅加入名单中尚不存在的成员(无学号时按姓名判重),避免重复行。
|
||||||
Roster::save($merged);
|
// 学号与姓名分两套判重键:带学号者以 sid 判重,无学号者以姓名判重,
|
||||||
|
// 避免「已存在带学号 A 的行」被「同名但空学号的导入行」误判为不重复而重复插入。
|
||||||
|
$existing = Roster::all();
|
||||||
|
$seenSid = [];
|
||||||
|
$seenName = [];
|
||||||
|
foreach ($existing as $s) {
|
||||||
|
$sid = strtolower(trim((string)($s['sid'] ?? '')));
|
||||||
|
$name = strtolower(trim((string)($s['name'] ?? '')));
|
||||||
|
if ($sid !== '') {
|
||||||
|
$seenSid[$sid] = true;
|
||||||
|
} elseif ($name !== '') {
|
||||||
|
$seenName[$name] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$added = [];
|
||||||
|
foreach ($res['students'] as $s) {
|
||||||
|
$sid = strtolower(trim((string)($s['sid'] ?? '')));
|
||||||
|
$name = strtolower(trim((string)($s['name'] ?? '')));
|
||||||
|
$dup = false;
|
||||||
|
if ($sid !== '') {
|
||||||
|
if (isset($seenSid[$sid])) { $dup = true; }
|
||||||
|
else { $seenSid[$sid] = true; }
|
||||||
|
} elseif ($name !== '') {
|
||||||
|
if (isset($seenName[$name])) { $dup = true; }
|
||||||
|
else { $seenName[$name] = true; }
|
||||||
|
}
|
||||||
|
if ($dup) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$added[] = $s;
|
||||||
|
}
|
||||||
|
if (!Roster::save(array_merge($existing, $added))) {
|
||||||
|
ny_fail('名单追加失败:' . (class_exists('Db') ? Db::lastError() : '未知错误'));
|
||||||
|
}
|
||||||
|
$res['count'] = count($added); // 仅计入真正新增的人数
|
||||||
} else {
|
} else {
|
||||||
Roster::save($res['students']);
|
if (!Roster::save($res['students'])) {
|
||||||
|
ny_fail('名单导入失败:' . (class_exists('Db') ? Db::lastError() : '未知错误'));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
ny_ok([
|
ny_ok([
|
||||||
'students' => Roster::all(),
|
'students' => Roster::all(),
|
||||||
@@ -517,8 +563,13 @@ try {
|
|||||||
'duplicates' => $res['duplicates'] ?? 0,
|
'duplicates' => $res['duplicates'] ?? 0,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// ---------------- 从卷王白名单导入待考名单(已移除)----------------
|
||||||
|
// 现改为:DataService::build 自动读取每套试卷卷内设置的「答题白名单」,
|
||||||
|
// 白名单成员即「应考名单」,未设置时回退到全名单(按现在的方案显示)。
|
||||||
|
// 名单管理里的「⬇ 从白名单导入」按钮已删除。
|
||||||
|
|
||||||
case 'roster_save':
|
case 'roster_save':
|
||||||
$raw = (string)file_get_contents('php://input');
|
$raw = ny_request_body();
|
||||||
$in = json_decode($raw, true);
|
$in = json_decode($raw, true);
|
||||||
if (!is_array($in) || !isset($in['students']) || !is_array($in['students'])) {
|
if (!is_array($in) || !isset($in['students']) || !is_array($in['students'])) {
|
||||||
ny_fail('参数错误');
|
ny_fail('参数错误');
|
||||||
@@ -536,7 +587,9 @@ try {
|
|||||||
'login' => trim((string)($s['login'] ?? '')),
|
'login' => trim((string)($s['login'] ?? '')),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
Roster::save($students);
|
if (!Roster::save($students)) {
|
||||||
|
ny_fail('名单保存失败:' . (class_exists('Db') ? Db::lastError() : '未知错误'));
|
||||||
|
}
|
||||||
ny_ok(['students' => Roster::all(), 'meta' => Roster::meta()]);
|
ny_ok(['students' => Roster::all(), 'meta' => Roster::meta()]);
|
||||||
|
|
||||||
case 'roster_clear':
|
case 'roster_clear':
|
||||||
@@ -545,7 +598,7 @@ try {
|
|||||||
|
|
||||||
// ---------------- 未匹配答卷 → 加入名单 ----------------
|
// ---------------- 未匹配答卷 → 加入名单 ----------------
|
||||||
case 'unmatched_map':
|
case 'unmatched_map':
|
||||||
$raw = (string)file_get_contents('php://input');
|
$raw = ny_request_body();
|
||||||
$in = json_decode($raw, true);
|
$in = json_decode($raw, true);
|
||||||
if (!is_array($in)) {
|
if (!is_array($in)) {
|
||||||
$in = $_POST;
|
$in = $_POST;
|
||||||
@@ -575,7 +628,9 @@ try {
|
|||||||
if (!$found) {
|
if (!$found) {
|
||||||
$roster[] = ['sid' => $sid, 'name' => $name, 'cls' => $cls, 'grp' => $grp, 'login' => $login];
|
$roster[] = ['sid' => $sid, 'name' => $name, 'cls' => $cls, 'grp' => $grp, 'login' => $login];
|
||||||
}
|
}
|
||||||
Roster::save($roster);
|
if (!Roster::save($roster)) {
|
||||||
|
ny_fail('映射保存失败:' . (class_exists('Db') ? Db::lastError() : '未知错误'));
|
||||||
|
}
|
||||||
ny_ok(['students' => Roster::all(), 'meta' => Roster::meta(), 'mapped' => $name]);
|
ny_ok(['students' => Roster::all(), 'meta' => Roster::meta(), 'mapped' => $name]);
|
||||||
|
|
||||||
// ---------------- 试卷独立配置(满分 / 合格率 / 免考名单)——仅对当前试卷生效 ----------------
|
// ---------------- 试卷独立配置(满分 / 合格率 / 免考名单)——仅对当前试卷生效 ----------------
|
||||||
@@ -603,7 +658,7 @@ try {
|
|||||||
if ($projectId === '') {
|
if ($projectId === '') {
|
||||||
ny_fail('请先选择问卷/考试');
|
ny_fail('请先选择问卷/考试');
|
||||||
}
|
}
|
||||||
$raw = (string)file_get_contents('php://input');
|
$raw = ny_request_body();
|
||||||
$in = json_decode($raw, true);
|
$in = json_decode($raw, true);
|
||||||
if (!is_array($in)) {
|
if (!is_array($in)) {
|
||||||
$in = $_POST;
|
$in = $_POST;
|
||||||
@@ -741,11 +796,22 @@ try {
|
|||||||
// ---------------- 数据行 ----------------
|
// ---------------- 数据行 ----------------
|
||||||
$sortMode = (string)($_GET['sort'] ?? '');
|
$sortMode = (string)($_GET['sort'] ?? '');
|
||||||
if ($isLatest) {
|
if ($isLatest) {
|
||||||
// 每人一行:成绩取「最近一次提交」的分数(不受 dedupe 影响),并附最高分与考试次数
|
// 每人一行:成绩取「最近一次真实提交」的分数(不受 dedupe 影响),并附最高分与考试次数。
|
||||||
|
// 注意:include_temp_save 开启时 attempts_list 可能含暂存草稿,取最新成绩应跳过 temp_save,
|
||||||
|
// 否则导出的"最新成绩"可能是一份草稿分。
|
||||||
$all = [];
|
$all = [];
|
||||||
foreach ($rows as $r) {
|
foreach ($rows as $r) {
|
||||||
$list = array_values((array)($r['attempts_list'] ?? []));
|
$list = array_values((array)($r['attempts_list'] ?? []));
|
||||||
$last = $list !== [] ? $list[count($list) - 1] : null;
|
$last = null;
|
||||||
|
for ($i = count($list) - 1; $i >= 0; $i--) {
|
||||||
|
if (empty($list[$i]['temp_save'])) {
|
||||||
|
$last = $list[$i];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($last === null && $list !== []) {
|
||||||
|
$last = $list[count($list) - 1]; // 全是暂存时退回最后一份
|
||||||
|
}
|
||||||
$all[] = [
|
$all[] = [
|
||||||
'sid' => (string)($r['sid'] ?? ''),
|
'sid' => (string)($r['sid'] ?? ''),
|
||||||
'name' => (string)($r['name'] ?? ''),
|
'name' => (string)($r['name'] ?? ''),
|
||||||
@@ -846,7 +912,7 @@ try {
|
|||||||
|
|
||||||
// ---------------- 随机抽取:导出白名单(xlsx,列:姓名 / 登陆账号) ----------------
|
// ---------------- 随机抽取:导出白名单(xlsx,列:姓名 / 登陆账号) ----------------
|
||||||
case 'draw_export':
|
case 'draw_export':
|
||||||
$raw = (string)file_get_contents('php://input');
|
$raw = ny_request_body();
|
||||||
$in = json_decode($raw, true);
|
$in = json_decode($raw, true);
|
||||||
if (!is_array($in)) {
|
if (!is_array($in)) {
|
||||||
$in = $_POST + $_GET;
|
$in = $_POST + $_GET;
|
||||||
|
|||||||
+170
-26
@@ -252,19 +252,56 @@
|
|||||||
].join('|');
|
].join('|');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 整份数据指纹:仅覆盖"会影响 DOM 渲染"的字段。
|
||||||
|
* 用于 applyData 的无变化守卫——自动刷新拿到与屏幕完全一致的数据时,跳过昂贵整表重绘。
|
||||||
|
* 含 project id/name 防止"不同项目数据恰好相同"导致误跳过。
|
||||||
|
*/
|
||||||
|
function dataSig(d) {
|
||||||
|
const rows = d.rows || [];
|
||||||
|
const roster = d.roster || [];
|
||||||
|
const unmatched = d.unmatched || [];
|
||||||
|
const questions = d.questions || [];
|
||||||
|
const rs = rows.map(rowSig).join('|');
|
||||||
|
const ros = roster.map(r => (r.sid || '') + ':' + (r.cls || '') + ':' + (r.grp || '')).join('|');
|
||||||
|
const um = unmatched.map(u => (u.name || '') + ':' + (u.score == null ? '-' : u.score)).join('|');
|
||||||
|
const q = questions.length + ':' + questions.map(q => q.id).join(',');
|
||||||
|
const proj = (d.project && (d.project.id || d.project.name)) || '';
|
||||||
|
return [
|
||||||
|
proj, d.updated_at || '', rows.length, rs, ros, um, q,
|
||||||
|
(d.absent || []).length, d.whitelist_driven ? 1 : 0, d.whitelist_total || 0,
|
||||||
|
].join('§');
|
||||||
|
}
|
||||||
|
|
||||||
function applyData(d, opts) {
|
function applyData(d, opts) {
|
||||||
opts = opts || {};
|
opts = opts || {};
|
||||||
|
const fromCache = !!opts.fromCache;
|
||||||
state.questions = d.questions || [];
|
state.questions = d.questions || [];
|
||||||
state.rows = d.rows || [];
|
state.rows = d.rows || [];
|
||||||
state.absent = d.absent || [];
|
state.absent = d.absent || [];
|
||||||
state.unmatched = d.unmatched || [];
|
state.unmatched = d.unmatched || [];
|
||||||
state.roster = d.roster || [];
|
state.roster = d.roster || [];
|
||||||
|
state.whitelistDriven = !!d.whitelist_driven;
|
||||||
|
state.whitelistTotal = (d.whitelist_total || 0);
|
||||||
state.fullScore = (d.config && d.config.full_score) ? (parseInt(d.config.full_score, 10) || 100) : 100;
|
state.fullScore = (d.config && d.config.full_score) ? (parseInt(d.config.full_score, 10) || 100) : 100;
|
||||||
state.passRate = (d.config && d.config.pass_rate != null) ? (parseFloat(d.config.pass_rate) || 60) : 60;
|
state.passRate = (d.config && d.config.pass_rate != null) ? (parseFloat(d.config.pass_rate) || 60) : 60;
|
||||||
|
|
||||||
|
// 性能守卫:数据指纹未变化则跳过昂贵整表重绘(自动刷新拿到与屏幕一致的数据时不再每轮重建 DOM)。
|
||||||
|
// 缓存预渲染路径照常保留——刷新间隙保持视觉稳定;仅当屏幕上已是同一份数据时才跳过。
|
||||||
|
const sig = dataSig(d);
|
||||||
|
if (sig === state._renderedSig) {
|
||||||
|
if (!fromCache) { // 真实刷新且数据未变:仅刷新轻量元信息,不重建 DOM
|
||||||
|
state.hasPrev = true; // 维持指纹基准,供下次 diff
|
||||||
|
$('#lastUpdate').textContent = d.updated_at || '—';
|
||||||
|
state.projectName = (d.project && d.project.name) || '';
|
||||||
|
setConn('conn-on', '实时 · ' + (d.project && d.project.name || ''));
|
||||||
|
showAlert('');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 变更检测:学号/成绩/提交时间/考试次数/最近提交时间 任一变化即视为"有新数据"
|
// 变更检测:学号/成绩/提交时间/考试次数/最近提交时间 任一变化即视为"有新数据"
|
||||||
// (纳入 attempts 与 last_submit_ts 才能发现"重考但分数更低"这种提交)
|
// (纳入 attempts 与 last_submit_ts 才能发现"重考但分数更低"这种提交)
|
||||||
const fromCache = !!opts.fromCache;
|
|
||||||
const cur = new Set(state.rows.map(r => r.sid).filter(Boolean));
|
const cur = new Set(state.rows.map(r => r.sid).filter(Boolean));
|
||||||
// 缓存渲染不参与变更检测:本地缓存可能是旧数据,与上一轮真实数据比对会产生假高亮
|
// 缓存渲染不参与变更检测:本地缓存可能是旧数据,与上一轮真实数据比对会产生假高亮
|
||||||
if (state.hasPrev && !fromCache) {
|
if (state.hasPrev && !fromCache) {
|
||||||
@@ -291,7 +328,10 @@
|
|||||||
state.prevMap = map;
|
state.prevMap = map;
|
||||||
state.prevSids = cur;
|
state.prevSids = cur;
|
||||||
}
|
}
|
||||||
|
// 仅在有新数据时做 FLIP 行动画(新行滑入、其它行下移);搜索/排序重排不触发
|
||||||
|
const flipPrev = (state.newSids.size > 0) ? captureFlip($('#tbody')) : null;
|
||||||
renderTable();
|
renderTable();
|
||||||
|
if (flipPrev) playFlip($('#tbody'), flipPrev);
|
||||||
populateClasses();
|
populateClasses();
|
||||||
populateGroups();
|
populateGroups();
|
||||||
renderAbsent();
|
renderAbsent();
|
||||||
@@ -300,8 +340,9 @@
|
|||||||
|
|
||||||
$('#lastUpdate').textContent = d.updated_at || '—';
|
$('#lastUpdate').textContent = d.updated_at || '—';
|
||||||
state.projectName = (d.project && d.project.name) || '';
|
state.projectName = (d.project && d.project.name) || '';
|
||||||
setConn('conn-on', (opts.fromCache ? '缓存 · ' : '') + '实时 · ' + (d.project && d.project.name || ''));
|
setConn('conn-on', (fromCache ? '缓存 · ' : '') + '实时 · ' + (d.project && d.project.name || ''));
|
||||||
if (!opts.fromCache) showAlert('');
|
if (!fromCache) showAlert('');
|
||||||
|
state._renderedSig = sig; // 记录当前屏幕指纹,下次相同则跳过整表重绘
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refresh(silent, force) {
|
async function refresh(silent, force) {
|
||||||
@@ -652,6 +693,42 @@
|
|||||||
btn.hidden = (n === 0);
|
btn.hidden = (n === 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FLIP 行动画:新数据行平滑滑动到新位置,其它行顺势下移(尊重 prefers-reduced-motion)
|
||||||
|
const REDUCED_MOTION = !!(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches);
|
||||||
|
function captureFlip(container) {
|
||||||
|
const m = new Map();
|
||||||
|
if (!container) return m;
|
||||||
|
const trs = container.querySelectorAll('tr[data-sid]');
|
||||||
|
for (const tr of trs) {
|
||||||
|
const sid = tr.getAttribute('data-sid');
|
||||||
|
if (sid) m.set(sid, tr.getBoundingClientRect().top);
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
function playFlip(container, prev) {
|
||||||
|
if (!container || !prev || REDUCED_MOTION) return;
|
||||||
|
const trs = container.querySelectorAll('tr[data-sid]');
|
||||||
|
for (const tr of trs) {
|
||||||
|
const sid = tr.getAttribute('data-sid');
|
||||||
|
if (!sid) continue;
|
||||||
|
const oldTop = prev.get(sid);
|
||||||
|
if (oldTop === undefined) {
|
||||||
|
tr.classList.add('row-drop-in'); // 全新行:轻微落入
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const newTop = tr.getBoundingClientRect().top;
|
||||||
|
const dy = oldTop - newTop;
|
||||||
|
if (Math.abs(dy) < 1) continue;
|
||||||
|
tr.style.transition = 'none';
|
||||||
|
tr.style.transform = 'translateY(' + dy + 'px)';
|
||||||
|
void tr.offsetWidth; // 强制回流,确保起始位生效
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
tr.style.transition = 'transform .42s cubic-bezier(.22,.61,.36,1)';
|
||||||
|
tr.style.transform = '';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function renderTable() {
|
function renderTable() {
|
||||||
const rows = buildRows();
|
const rows = buildRows();
|
||||||
const k = state.sortKey, dir = state.sortAsc ? 1 : -1;
|
const k = state.sortKey, dir = state.sortAsc ? 1 : -1;
|
||||||
@@ -662,8 +739,9 @@
|
|||||||
if (!rows.length) {
|
if (!rows.length) {
|
||||||
tb.innerHTML = '<tr><td colspan="' + COL_COUNT + '" class="empty">' +
|
tb.innerHTML = '<tr><td colspan="' + COL_COUNT + '" class="empty">' +
|
||||||
(state.rows.length ? '没有匹配的记录' : '暂无提交记录') + '</td></tr>';
|
(state.rows.length ? '没有匹配的记录' : '暂无提交记录') + '</td></tr>';
|
||||||
|
_lastRange = ''; // 空表也重置窗口签名,下次有数据必重绘
|
||||||
} else {
|
} else {
|
||||||
renderVirtualRows(tb, rows);
|
renderVirtualRows(tb, rows, true); // 排序/搜索/数据变更:强制重绘
|
||||||
}
|
}
|
||||||
|
|
||||||
$$('#dataTable th.sortable').forEach(th => {
|
$$('#dataTable th.sortable').forEach(th => {
|
||||||
@@ -690,7 +768,7 @@
|
|||||||
const isNew = sid !== '' && state.newSids.has(sid);
|
const isNew = sid !== '' && state.newSids.has(sid);
|
||||||
const hl = sid !== '' && state.highlight.has(sid);
|
const hl = sid !== '' && state.highlight.has(sid);
|
||||||
const attempts = (r.attempts || 1);
|
const attempts = (r.attempts || 1);
|
||||||
const clsName = [hl ? 'new' : '', 'clickable'].filter(Boolean).join(' ');
|
const clsName = [hl ? 'new' : '', (_dropAnim && isNew) ? 'row-drop-in' : '', 'clickable'].filter(Boolean).join(' ');
|
||||||
|
|
||||||
const tags = [];
|
const tags = [];
|
||||||
if (isNew) tags.push('<span class="tag tag-new">新</span>');
|
if (isNew) tags.push('<span class="tag tag-new">新</span>');
|
||||||
@@ -720,7 +798,12 @@
|
|||||||
const ROW_H_FALLBACK = 41; // 首帧未测量前的行高估值
|
const ROW_H_FALLBACK = 41; // 首帧未测量前的行高估值
|
||||||
const VS_OVERSCAN = 12; // 视口上下各多渲染的行数,减少快速滚动时的白屏
|
const VS_OVERSCAN = 12; // 视口上下各多渲染的行数,减少快速滚动时的白屏
|
||||||
let measuredRowH = 0; // 实测行高(渲染后回写),CSS/字体/缩放变化都能自动适应
|
let measuredRowH = 0; // 实测行高(渲染后回写),CSS/字体/缩放变化都能自动适应
|
||||||
function renderVirtualRows(tb, rows) {
|
let _lastRange = ''; // 上次可视窗口签名(start:end:total:rh),未变化则跳过重绘,消除滚动闪烁
|
||||||
|
let _dropAnim = true; // 仅"数据/排序/搜索变更"的强制重绘才让新行播放落入动画;滚动复用 DOM 时不重播,避免滑动时一行行闪
|
||||||
|
function renderVirtualRows(tb, rows, force) {
|
||||||
|
const prevDrop = _dropAnim;
|
||||||
|
_dropAnim = !!force; // 滚动复用(force=false)时关闭落入动画,节点重建不再触发 replay
|
||||||
|
try {
|
||||||
const scroller = tb.closest('.table-scroll');
|
const scroller = tb.closest('.table-scroll');
|
||||||
if (!scroller) {
|
if (!scroller) {
|
||||||
// 兜底:找不到滚动容器时退回全量渲染
|
// 兜底:找不到滚动容器时退回全量渲染
|
||||||
@@ -737,6 +820,12 @@
|
|||||||
if (end > total) end = total;
|
if (end > total) end = total;
|
||||||
if (start >= end) start = Math.max(0, end - 1);
|
if (start >= end) start = Math.max(0, end - 1);
|
||||||
|
|
||||||
|
// 可视窗口未变(含小幅滚动仍在 overscan 内)→ 直接复用现有 DOM:
|
||||||
|
// 避免每帧重写 innerHTML 触发整块重绘,这是"滑一下闪一下"的根源。
|
||||||
|
const range = start + ':' + end + ':' + total + ':' + rh;
|
||||||
|
if (!force && range === _lastRange && tb.firstChild) return;
|
||||||
|
_lastRange = range;
|
||||||
|
|
||||||
const frag = [];
|
const frag = [];
|
||||||
if (start > 0) {
|
if (start > 0) {
|
||||||
frag.push('<tr class="vspacer" style="height:' + (start * rh) + 'px;pointer-events:none"><td colspan="' + COL_COUNT + '"></td></tr>');
|
frag.push('<tr class="vspacer" style="height:' + (start * rh) + 'px;pointer-events:none"><td colspan="' + COL_COUNT + '"></td></tr>');
|
||||||
@@ -749,14 +838,20 @@
|
|||||||
}
|
}
|
||||||
tb.innerHTML = frag.join('');
|
tb.innerHTML = frag.join('');
|
||||||
|
|
||||||
// 渲染后回写真实行高:换字体、改样式、页面缩放时窗口定位都不会漂移
|
// 渲染后回写真实行高:换字体、改样式、页面缩放时窗口定位都不会漂移。
|
||||||
|
// 仅在尚未测量时回写一次,避免滚动中反复改动行高导致 spacer 高度抖动(也会造成观感闪烁)。
|
||||||
|
if (measuredRowH === 0) {
|
||||||
const tr0 = tb.querySelector('tr[data-sid]');
|
const tr0 = tb.querySelector('tr[data-sid]');
|
||||||
if (tr0) {
|
if (tr0) {
|
||||||
const h = tr0.offsetHeight;
|
const h = tr0.offsetHeight;
|
||||||
if (h > 12 && h < 300) measuredRowH = h;
|
if (h > 12 && h < 300) measuredRowH = h;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
window.addEventListener('resize', () => { measuredRowH = 0; });
|
} finally {
|
||||||
|
_dropAnim = prevDrop;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener('resize', () => { measuredRowH = 0; _lastRange = ''; });
|
||||||
|
|
||||||
/** 供滚动监听调用:滚动时重算可视窗口(不重新排序,直接复用 state._sorted) */
|
/** 供滚动监听调用:滚动时重算可视窗口(不重新排序,直接复用 state._sorted) */
|
||||||
function onTableScroll() {
|
function onTableScroll() {
|
||||||
@@ -787,13 +882,14 @@
|
|||||||
// 按班统计缺考人数(未按班级筛选时显示概览)
|
// 按班统计缺考人数(未按班级筛选时显示概览)
|
||||||
const hint = $('#absentHint');
|
const hint = $('#absentHint');
|
||||||
if (hint) {
|
if (hint) {
|
||||||
|
const wlNote = state.whitelistDriven ? ('白名单应考 ' + (state.whitelistTotal || list.length) + ' 人 · ') : '';
|
||||||
if (!clsFilter && list.length) {
|
if (!clsFilter && list.length) {
|
||||||
const byCls = {};
|
const byCls = {};
|
||||||
list.forEach(a => { const c = (a.cls || '未分班'); byCls[c] = (byCls[c] || 0) + 1; });
|
list.forEach(a => { const c = (a.cls || '未分班'); byCls[c] = (byCls[c] || 0) + 1; });
|
||||||
const parts = Object.keys(byCls).sort().map(c => c + ' ' + byCls[c] + ' 人');
|
const parts = Object.keys(byCls).sort().map(c => c + ' ' + byCls[c] + ' 人');
|
||||||
hint.textContent = '共 ' + list.length + ' 人 · ' + parts.join(' ');
|
hint.textContent = wlNote + '共 ' + list.length + ' 人 · ' + parts.join(' ');
|
||||||
} else {
|
} else {
|
||||||
hint.textContent = list.length ? '共 ' + list.length + ' 人' : '';
|
hint.textContent = wlNote + (list.length ? '共 ' + list.length + ' 人' : '');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -815,7 +911,7 @@
|
|||||||
'<td>' + esc(u.name) + '</td>' +
|
'<td>' + esc(u.name) + '</td>' +
|
||||||
'<td class="num">' + (u.score == null ? '-' : u.score) + '</td>' +
|
'<td class="num">' + (u.score == null ? '-' : u.score) + '</td>' +
|
||||||
'<td class="num">' + esc(u.submit_at) + '</td>' +
|
'<td class="num">' + esc(u.submit_at) + '</td>' +
|
||||||
'<td><button class="btn xs" data-map="' + esc(u.name) + '">加入名单</button></td></tr>').join('');
|
'<td class="act"><button class="btn xs" data-map="' + esc(u.name) + '">加入名单</button></td></tr>').join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 历史成绩弹窗(主统计页成绩明细):任意成员都可点开,含只考过 1 次的 */
|
/* 历史成绩弹窗(主统计页成绩明细):任意成员都可点开,含只考过 1 次的 */
|
||||||
@@ -854,15 +950,15 @@
|
|||||||
$('#attTitle').textContent = (r.name || '') + '(' + r.sid + ') · 共 ' + attempts + ' 次考试' +
|
$('#attTitle').textContent = (r.name || '') + '(' + r.sid + ') · 共 ' + attempts + ' 次考试' +
|
||||||
(bestScore == null ? '' : ' · 最高 ' + bestScore + ' 分');
|
(bestScore == null ? '' : ' · 最高 ' + bestScore + ' 分');
|
||||||
$('#attBody').innerHTML =
|
$('#attBody').innerHTML =
|
||||||
'<table class="grid compact"><thead><tr><th style="width:80px">次序</th><th>提交时间</th><th>成绩</th><th>考试时长</th></tr></thead><tbody>' +
|
'<table class="grid compact att-table"><thead><tr>' +
|
||||||
|
'<th data-k="attempts" style="width:18%">次序</th>' +
|
||||||
|
'<th data-k="submit_ts" style="width:34%">提交时间</th>' +
|
||||||
|
'<th data-k="score" style="width:24%">成绩</th>' +
|
||||||
|
'<th data-k="duration" style="width:24%">考试时长</th>' +
|
||||||
|
'</tr></thead><tbody>' +
|
||||||
rows + '</tbody></table>';
|
rows + '</tbody></table>';
|
||||||
$('#attemptsModal').classList.add('open');
|
$('#attemptsModal').classList.add('open');
|
||||||
}
|
}
|
||||||
function closeAttemptsModal() {
|
|
||||||
const m = $('#attemptsModal');
|
|
||||||
if (m) m.classList.remove('open');
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------------- 自动刷新 ---------------- */
|
/* ---------------- 自动刷新 ---------------- */
|
||||||
// 失败退避:连续失败时延长下次刷新间隔(1x → 2x → 4x,封顶 8x),成功后复位。
|
// 失败退避:连续失败时延长下次刷新间隔(1x → 2x → 4x,封顶 8x),成功后复位。
|
||||||
// 用 setTimeout 链而非 setInterval,便于动态调整间隔;后台标签页暂停刷新,回到前台立即补一次。
|
// 用 setTimeout 链而非 setInterval,便于动态调整间隔;后台标签页暂停刷新,回到前台立即补一次。
|
||||||
@@ -994,7 +1090,7 @@
|
|||||||
String(r.sid).toLowerCase().includes(kw) || String(r.name).toLowerCase().includes(kw));
|
String(r.sid).toLowerCase().includes(kw) || String(r.name).toLowerCase().includes(kw));
|
||||||
const tb = $('#rosterBody');
|
const tb = $('#rosterBody');
|
||||||
if (!rows.length) {
|
if (!rows.length) {
|
||||||
tb.innerHTML = '<tr><td colspan="6" class="empty">暂无名单,请上传文件</td></tr>';
|
tb.innerHTML = '<tr><td colspan="5" class="empty">暂无名单,请上传文件</td></tr>';
|
||||||
$('#btnDelSel').hidden = true;
|
$('#btnDelSel').hidden = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1006,8 +1102,7 @@
|
|||||||
'<td><input value="' + esc(r.sid) + '" data-f="sid"></td>' +
|
'<td><input value="' + esc(r.sid) + '" data-f="sid"></td>' +
|
||||||
'<td><input value="' + esc(r.name) + '" data-f="name"></td>' +
|
'<td><input value="' + esc(r.name) + '" data-f="name"></td>' +
|
||||||
'<td><input value="' + esc(r.cls || '') + '" data-f="cls" placeholder="可选"></td>' +
|
'<td><input value="' + esc(r.cls || '') + '" data-f="cls" placeholder="可选"></td>' +
|
||||||
'<td><input value="' + esc(r.grp || '') + '" data-f="grp" placeholder="A/B"></td>' +
|
'<td><input value="' + esc(r.grp || '') + '" data-f="grp" placeholder="A/B"></td></tr>';
|
||||||
'<td><input value="' + esc(r.login || '') + '" data-f="login" placeholder="可选"></td></tr>';
|
|
||||||
}).join('');
|
}).join('');
|
||||||
updateDelSelBtn();
|
updateDelSelBtn();
|
||||||
}
|
}
|
||||||
@@ -1298,7 +1393,7 @@
|
|||||||
async function uploadRoster(file) {
|
async function uploadRoster(file) {
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
fd.append('file', file);
|
fd.append('file', file);
|
||||||
const mode = (document.querySelector('input[name=impMode]:checked') || {}).value || 'replace';
|
const mode = (document.querySelector('input[name=impMode]:checked') || {}).value || 'append';
|
||||||
fd.append('mode', mode);
|
fd.append('mode', mode);
|
||||||
try {
|
try {
|
||||||
const d = await api('roster_import', { form: fd });
|
const d = await api('roster_import', { form: fd });
|
||||||
@@ -1311,6 +1406,19 @@
|
|||||||
|
|
||||||
$('#rosterSearch').addEventListener('input', renderRoster);
|
$('#rosterSearch').addEventListener('input', renderRoster);
|
||||||
|
|
||||||
|
// 下载名单导入模板(CSV,带 BOM 便于 Excel 正确识别 UTF-8)
|
||||||
|
$('#btnTpl').addEventListener('click', () => {
|
||||||
|
const header = ['学号', '姓名', '班级', '组别', '登陆账号'];
|
||||||
|
const sample = ['2024001', '张三', '高一(1)班', 'A', 'zhangsan'];
|
||||||
|
const csv = '' + [header.join(','), sample.join(',')].join('\r\n');
|
||||||
|
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = URL.createObjectURL(blob);
|
||||||
|
a.download = '名单导入模板.csv';
|
||||||
|
document.body.appendChild(a); a.click(); a.remove();
|
||||||
|
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
|
||||||
|
});
|
||||||
|
|
||||||
// 新增学生:弹出表单,填写后追加(不再直接在表格底部插入空行)
|
// 新增学生:弹出表单,填写后追加(不再直接在表格底部插入空行)
|
||||||
$('#btnAddRow').addEventListener('click', () => {
|
$('#btnAddRow').addEventListener('click', () => {
|
||||||
$('#addSid').value = ''; $('#addName').value = '';
|
$('#addSid').value = ''; $('#addName').value = '';
|
||||||
@@ -1432,7 +1540,6 @@
|
|||||||
} catch (err) { showAlert(err.message); }
|
} catch (err) { showAlert(err.message); }
|
||||||
});
|
});
|
||||||
|
|
||||||
/* 成绩快照 / 趋势 */
|
|
||||||
function curPid() { return $('#projectSel').value; }
|
function curPid() { return $('#projectSel').value; }
|
||||||
|
|
||||||
/* 成绩明细打印:默认按成绩降序,可选按班级/组别分组对比 */
|
/* 成绩明细打印:默认按成绩降序,可选按班级/组别分组对比 */
|
||||||
@@ -1496,8 +1603,46 @@
|
|||||||
].concat(extra || []).join(' | ');
|
].concat(extra || []).join(' | ');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 展开每位学生的每次提交为独立一行(用于「打印全部记录」),与 buildRows 内的 expandAttempts 区分命名避免覆盖
|
||||||
|
function expandAttemptsForPrint(rows, desc) {
|
||||||
|
const out = [];
|
||||||
|
rows.forEach(r => {
|
||||||
|
const list = (r.attempts_list || []);
|
||||||
|
if (!list.length) {
|
||||||
|
const one = Object.assign({}, r);
|
||||||
|
one.idx = 1; one.is_best = true;
|
||||||
|
out.push(one);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
list.forEach(a => {
|
||||||
|
out.push({
|
||||||
|
sid: r.sid, name: r.name, cls: r.cls, grp: r.grp,
|
||||||
|
score: a.score, submit_ts: a.submit_ts, submit_at: a.submit_at,
|
||||||
|
duration: a.duration, duration_txt: a.duration_txt,
|
||||||
|
answer_id: a.answer_id, temp_save: a.temp_save,
|
||||||
|
attempts: r.attempts, best_score: r.best_score,
|
||||||
|
idx: a.idx, is_best: a.is_best,
|
||||||
|
last_submit_ts: r.last_submit_ts, last_submit_at: r.last_submit_at,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
out.sort((x, y) => {
|
||||||
|
let d = (y.submit_ts || 0) - (x.submit_ts || 0);
|
||||||
|
if (!desc) d = -d;
|
||||||
|
if (d !== 0) return d;
|
||||||
|
const a = String(x.sid || ''), b = String(y.sid || '');
|
||||||
|
const na = /^\d+$/.test(a), nb = /^\d+$/.test(b);
|
||||||
|
if (na && nb) return Number(a) - Number(b);
|
||||||
|
return a.localeCompare(b, 'zh');
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
function printTable() {
|
function printTable() {
|
||||||
const rows = (state._sorted || []).slice();
|
const scope = $('#ptScope').value;
|
||||||
|
let rows = (scope === 'all')
|
||||||
|
? expandAttemptsForPrint(state._sorted || [], true)
|
||||||
|
: (state._sorted || []).slice();
|
||||||
if (!rows.length) return showAlert('当前没有可打印的成绩', 'warn');
|
if (!rows.length) return showAlert('当前没有可打印的成绩', 'warn');
|
||||||
|
|
||||||
const sortMode = $('#ptSort').value;
|
const sortMode = $('#ptSort').value;
|
||||||
@@ -1587,7 +1732,7 @@
|
|||||||
let body = '<h2>' + esc(title) + '</h2>' +
|
let body = '<h2>' + esc(title) + '</h2>' +
|
||||||
(proj ? '<div class="sub">' + esc(proj) + '</div>' : '') +
|
(proj ? '<div class="sub">' + esc(proj) + '</div>' : '') +
|
||||||
'<div class="meta">' + esc(printMetaLine([
|
'<div class="meta">' + esc(printMetaLine([
|
||||||
'共 ' + all.n + ' 人',
|
(scope === 'all' ? '共 ' + all.n + ' 条记录' : '共 ' + all.n + ' 人'),
|
||||||
'平均 ' + all.avg,
|
'平均 ' + all.avg,
|
||||||
'最高 ' + all.max + ' / 最低 ' + all.min,
|
'最高 ' + all.max + ' / 最低 ' + all.min,
|
||||||
'合格 ' + all.pass + ' 人(' + all.passRate + '%)',
|
'合格 ' + all.pass + ' 人(' + all.passRate + '%)',
|
||||||
@@ -1609,7 +1754,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
body += '<div class="sign">教师签字:____________ 教研组长:____________ 日期:____ 年 ____ 月 ____ 日</div>';
|
body += '<div class="sign">教师签字:____________ 教研组长:____________ 日期:____ 年 ____ 月 ____ 日</div>';
|
||||||
body += '<div class="foot">' + esc(NY.appName || '') + ' · 本文档由系统生成,仅供教学参考</div>';
|
|
||||||
|
|
||||||
printDoc(title, body, { orient: orient });
|
printDoc(title, body, { orient: orient });
|
||||||
showAlert('已调起打印,如未弹出请在打印对话框中选择正确打印机', 'info');
|
showAlert('已调起打印,如未弹出请在打印对话框中选择正确打印机', 'info');
|
||||||
@@ -1619,7 +1763,7 @@
|
|||||||
if (!curPid()) return showAlert('请先选择问卷 / 考试');
|
if (!curPid()) return showAlert('请先选择问卷 / 考试');
|
||||||
const n = (state._sorted || []).length;
|
const n = (state._sorted || []).length;
|
||||||
const a = (state._absentList || []).length;
|
const a = (state._absentList || []).length;
|
||||||
$('#ptHint').textContent = '当前将打印 ' + n + ' 条成绩' + (a ? '(可附带 ' + a + ' 名缺考学生)' : '');
|
$('#ptHint').textContent = '当前可打印 ' + n + ' 名学生的成绩' + (a ? '(可附带 ' + a + ' 名缺考学生)' : '') + ';弹窗中可切换为「打印全部记录」';
|
||||||
if (!$('#ptTitle').value.trim() && state.projectName) {
|
if (!$('#ptTitle').value.trim() && state.projectName) {
|
||||||
$('#ptTitle').value = state.projectName + ' 成绩明细';
|
$('#ptTitle').value = state.projectName + ' 成绩明细';
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-9
@@ -473,23 +473,18 @@
|
|||||||
function renderResult() {
|
function renderResult() {
|
||||||
const tb = $('#drawBody');
|
const tb = $('#drawBody');
|
||||||
if (!state.picked.length) {
|
if (!state.picked.length) {
|
||||||
tb.innerHTML = '<tr><td colspan="7" class="empty">尚未抽取</td></tr>';
|
tb.innerHTML = '<tr><td colspan="6" class="empty">尚未抽取</td></tr>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// 抽取结果表不显示登陆账号(仅导出白名单才含该列),故只渲染 5 个数据列 + 操作列
|
||||||
tb.innerHTML = state.picked.map((m, i) => {
|
tb.innerHTML = state.picked.map((m, i) => {
|
||||||
// 登陆账号为空 → 导出会回退到学号,这里显式标红提示教师先去名单里补全
|
|
||||||
const login = String(m.login || '');
|
|
||||||
const loginCell = login
|
|
||||||
? esc(login)
|
|
||||||
: '<span class="muted" title="名单未填登陆账号,导出将回退用学号">' + esc(m.sid || '') + ' ⚠</span>';
|
|
||||||
return '<tr>' +
|
return '<tr>' +
|
||||||
'<td class="num">' + (i + 1) + '</td>' +
|
'<td class="num">' + (i + 1) + '</td>' +
|
||||||
'<td class="sid">' + esc(m.sid) + '</td>' +
|
'<td class="sid">' + esc(m.sid) + '</td>' +
|
||||||
'<td class="name">' + esc(m.name) + '</td>' +
|
'<td class="name">' + esc(m.name) + '</td>' +
|
||||||
'<td>' + esc(m.cls || '') + '</td>' +
|
'<td>' + esc(m.cls || '') + '</td>' +
|
||||||
'<td>' + esc(m.grp || '') + '</td>' +
|
'<td>' + esc(m.grp || '') + '</td>' +
|
||||||
'<td class="sid">' + loginCell + '</td>' +
|
'<td class="act"><button class="btn sm danger" data-drop="' + i + '" title="移出结果并加入排除名单">排除</button></td>' +
|
||||||
'<td><button class="btn sm danger" data-drop="' + i + '" title="移出结果并加入排除名单">排除</button></td>' +
|
|
||||||
'</tr>';
|
'</tr>';
|
||||||
}).join('');
|
}).join('');
|
||||||
}
|
}
|
||||||
@@ -538,7 +533,7 @@
|
|||||||
'</span></div>' +
|
'</span></div>' +
|
||||||
'<details class="log-detail"><summary>查看成员(' + e.count + ')</summary>' +
|
'<details class="log-detail"><summary>查看成员(' + e.count + ')</summary>' +
|
||||||
'<div class="table-scroll short"><table class="grid compact">' +
|
'<div class="table-scroll short"><table class="grid compact">' +
|
||||||
'<thead><tr><th style="width:56px">序号</th><th style="width:28%">学号</th><th style="width:26%">姓名</th><th>班级</th><th>组别</th></tr></thead>' +
|
'<thead><tr><th style="width:8%;text-align:right">序号</th><th style="width:28%">学号</th><th style="width:26%">姓名</th><th style="width:24%">班级</th><th style="width:14%">组别</th></tr></thead>' +
|
||||||
'<tbody>' + rows + '</tbody></table></div>' +
|
'<tbody>' + rows + '</tbody></table></div>' +
|
||||||
'</details>' +
|
'</details>' +
|
||||||
'</div>';
|
'</div>';
|
||||||
|
|||||||
+38
-54
@@ -6,14 +6,22 @@
|
|||||||
--text: #1d2433;
|
--text: #1d2433;
|
||||||
--text-2: #5b6577;
|
--text-2: #5b6577;
|
||||||
--text-3: #8b95a7;
|
--text-3: #8b95a7;
|
||||||
|
/* 主色阶:--primary 基准、--primary-2 悬停/激活深一档、--primary-3 浅色渐变端 */
|
||||||
--primary: #2f6bff;
|
--primary: #2f6bff;
|
||||||
|
--primary-2: #2559db;
|
||||||
|
--primary-3: #5b8bff;
|
||||||
--primary-weak: #eaf0ff;
|
--primary-weak: #eaf0ff;
|
||||||
|
--brand-2: #6a4bff; /* 品牌渐变收尾(紫)—— logo/面板条/登录条 */
|
||||||
--ok: #12a86a;
|
--ok: #12a86a;
|
||||||
|
--ok-2: #3ec99a; /* 成功渐变亮端(toast) */
|
||||||
--ok-weak: #e7f7ef;
|
--ok-weak: #e7f7ef;
|
||||||
--warn: #e08c1a;
|
--warn: #e08c1a;
|
||||||
|
--warn-2: #f0a54a; /* 警告渐变亮端(toast) */
|
||||||
--warn-weak: #fdf3e2;
|
--warn-weak: #fdf3e2;
|
||||||
--danger: #e0483d;
|
--danger: #e0483d;
|
||||||
|
--danger-2: #ef6c64; /* 危险渐变亮端(toast) */
|
||||||
--danger-weak: #fdecea;
|
--danger-weak: #fdecea;
|
||||||
|
--ink: #1b2440; /* 登录页深底 */
|
||||||
--shadow: 0 1px 2px rgba(20, 30, 60, .05), 0 8px 24px rgba(20, 30, 60, .06);
|
--shadow: 0 1px 2px rgba(20, 30, 60, .05), 0 8px 24px rgba(20, 30, 60, .06);
|
||||||
--shadow-lg: 0 12px 32px rgba(20, 30, 60, .12);
|
--shadow-lg: 0 12px 32px rgba(20, 30, 60, .12);
|
||||||
--radius: 12px;
|
--radius: 12px;
|
||||||
@@ -120,7 +128,7 @@ body {
|
|||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
.btn-primary { background: var(--primary); border-color: var(--primary); color: #fff; }
|
.btn-primary { background: var(--primary); border-color: var(--primary); color: #fff; }
|
||||||
.btn-primary:hover { background: #2559db; border-color: #2559db; }
|
.btn-primary:hover { background: var(--primary-2); border-color: var(--primary-2); }
|
||||||
.btn.sm { padding: 5px 10px; font-size: 12px; }
|
.btn.sm { padding: 5px 10px; font-size: 12px; }
|
||||||
.btn.xs { padding: 3px 8px; font-size: 11px; }
|
.btn.xs { padding: 3px 8px; font-size: 11px; }
|
||||||
.btn.danger { color: var(--danger); border-color: #f3cfcb; }
|
.btn.danger { color: var(--danger); border-color: #f3cfcb; }
|
||||||
@@ -260,8 +268,6 @@ select:focus, input:focus { border-color: var(--primary); box-shadow: 0 0 0 3px
|
|||||||
.dot.stale { background: var(--danger); animation: blink 1s infinite; }
|
.dot.stale { background: var(--danger); animation: blink 1s infinite; }
|
||||||
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: .25; } }
|
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: .25; } }
|
||||||
|
|
||||||
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
|
||||||
@media (max-width: 900px) { .grid-2 { grid-template-columns: 1fr; } }
|
|
||||||
|
|
||||||
/* ---------- 三 panel 布局容器(默认 / 成绩明细与缺考名单同行) ---------- */
|
/* ---------- 三 panel 布局容器(默认 / 成绩明细与缺考名单同行) ---------- */
|
||||||
.layout {
|
.layout {
|
||||||
@@ -296,25 +302,31 @@ select:focus, input:focus { border-color: var(--primary); box-shadow: 0 0 0 3px
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- 表格 ---------- */
|
/* ---------- 表格 ---------- */
|
||||||
.table-scroll { overflow: auto; max-height: 62vh; }
|
.table-scroll { overflow: auto; max-height: 62vh; scrollbar-gutter: stable; }
|
||||||
.table-scroll.short { max-height: 320px; }
|
.table-scroll.short { max-height: 320px; }
|
||||||
.table-scroll.mid { max-height: 380px; }
|
.table-scroll.mid { max-height: 380px; }
|
||||||
|
|
||||||
table.grid { width: 100%; border-collapse: collapse; font-size: 13px; }
|
table.grid { width: 100%; border-collapse: separate; border-spacing: 0; font-size: 13px; table-layout: fixed; }
|
||||||
table.grid th {
|
table.grid th {
|
||||||
position: sticky; top: 0; z-index: 2;
|
position: sticky; top: 0; z-index: 2;
|
||||||
background: linear-gradient(180deg, #fbfcfe, #f4f7fc); color: var(--text-2);
|
background: linear-gradient(180deg, #fbfcfe, #f4f7fc); color: var(--text-2);
|
||||||
text-align: left; font-weight: 600; font-size: 12px;
|
text-align: left; font-weight: 600; font-size: 12px;
|
||||||
padding: 11px 14px; border-bottom: 1px solid var(--border);
|
padding: 11px 14px;
|
||||||
white-space: nowrap;
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||||
|
/* 用 box-shadow 画分隔线而非 border:border 在 border-collapse:separate + sticky 下
|
||||||
|
不会与下方 td 边框产生折叠重绘缝隙(消除吸顶表头在滚动边界的 1px 双线/错位观感) */
|
||||||
|
background-clip: padding-box;
|
||||||
box-shadow: inset 0 -1px 0 var(--border);
|
box-shadow: inset 0 -1px 0 var(--border);
|
||||||
}
|
}
|
||||||
table.grid th.sortable { cursor: pointer; user-select: none; }
|
table.grid th.sortable { cursor: pointer; user-select: none; }
|
||||||
table.grid th.sortable:hover { color: var(--primary); }
|
table.grid th.sortable:hover { color: var(--primary); }
|
||||||
table.grid th .arrow { color: var(--primary); margin-left: 3px; }
|
table.grid th .arrow { color: var(--primary); margin-left: 3px; }
|
||||||
table.grid td { padding: 10px 14px; border-bottom: 1px solid var(--border-2); }
|
table.grid td { padding: 10px 14px; border-bottom: 1px solid var(--border-2); vertical-align: middle; background-clip: padding-box; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
/* 含按钮的操作列:不能被 ellipsis 的 overflow:hidden 裁掉按钮 */
|
||||||
|
table.grid td.act { overflow: visible; text-overflow: clip; }
|
||||||
/* 虚拟滚动占位行:不占视觉空间、不响应 hover/条纹、不显示边框 */
|
/* 虚拟滚动占位行:不占视觉空间、不响应 hover/条纹、不显示边框 */
|
||||||
table.grid tbody tr.vspacer td { padding: 0; border: 0; background: transparent !important; }
|
table.grid tbody tr.vspacer td { padding: 0; border: 0; background: transparent !important; }
|
||||||
|
table.grid tbody tr.vspacer:nth-child(even) td { background: transparent !important; }
|
||||||
table.grid tbody tr.vspacer:hover td { background: transparent !important; }
|
table.grid tbody tr.vspacer:hover td { background: transparent !important; }
|
||||||
table.grid tbody tr:nth-child(even) td { background: #fbfcff; }
|
table.grid tbody tr:nth-child(even) td { background: #fbfcff; }
|
||||||
table.grid tbody tr:hover td { background: var(--primary-weak); }
|
table.grid tbody tr:hover td { background: var(--primary-weak); }
|
||||||
@@ -324,6 +336,7 @@ td.num { font-variant-numeric: tabular-nums; white-space: nowrap; }
|
|||||||
td.sid { font-weight: 600; color: var(--text-2); font-variant-numeric: tabular-nums; }
|
td.sid { font-weight: 600; color: var(--text-2); font-variant-numeric: tabular-nums; }
|
||||||
/* 数值列右对齐:成绩 / 次数 / 时长纵向扫读时更容易对齐比对 */
|
/* 数值列右对齐:成绩 / 次数 / 时长纵向扫读时更容易对齐比对 */
|
||||||
table.grid td.num, table.grid td.score { text-align: right; }
|
table.grid td.num, table.grid td.score { text-align: right; }
|
||||||
|
/* 表头对齐 = data-k 派生,与单元格的正则保持一致;避免"表头左对齐、数据右对齐"造成的视觉错列 */
|
||||||
table.grid thead th[data-k="score"],
|
table.grid thead th[data-k="score"],
|
||||||
table.grid thead th[data-k="attempts"],
|
table.grid thead th[data-k="attempts"],
|
||||||
table.grid thead th[data-k="submit_ts"],
|
table.grid thead th[data-k="submit_ts"],
|
||||||
@@ -335,17 +348,22 @@ td.score { font-weight: 700; font-variant-numeric: tabular-nums; }
|
|||||||
.score-high { color: var(--ok); }
|
.score-high { color: var(--ok); }
|
||||||
.score-mid { color: var(--warn); }
|
.score-mid { color: var(--warn); }
|
||||||
.score-low { color: var(--danger); }
|
.score-low { color: var(--danger); }
|
||||||
.diff { animation: flash 1.6s ease-out; }
|
|
||||||
@keyframes flash { from { background: #fff6d9; } to { background: transparent; } }
|
/* 新数据行动画:FLIP 滑动(行移动) + 全新行落入;尊重 prefers-reduced-motion */
|
||||||
|
@keyframes rowDrop { from { opacity: 0; transform: translateY(-12px); } to { opacity: 1; transform: translateY(0); } }
|
||||||
|
table.grid tbody tr.row-drop-in { animation: rowDrop .42s ease; }
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
table.grid tbody tr.row-drop-in { animation: none; }
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------- Toast 顶部飘窗通知 ---------- */
|
/* ---------- Toast 顶部飘窗通知 ---------- */
|
||||||
.toast {
|
.toast {
|
||||||
background: linear-gradient(135deg, #2f6bff, #5b8bff);
|
background: linear-gradient(135deg, var(--primary), var(--primary-3));
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
.toast-ok { background: linear-gradient(135deg, #12a86a, #3ec99a); color: #fff; }
|
.toast-ok { background: linear-gradient(135deg, var(--ok), var(--ok-2)); color: #fff; }
|
||||||
.toast-error{ background: linear-gradient(135deg, #e0483d, #ef6c64); color: #fff; }
|
.toast-error{ background: linear-gradient(135deg, var(--danger), var(--danger-2)); color: #fff; }
|
||||||
.toast-warn { background: linear-gradient(135deg, #e08c1a, #f0a54a); color: #fff; }
|
.toast-warn { background: linear-gradient(135deg, var(--warn), var(--warn-2)); color: #fff; }
|
||||||
@keyframes toastIn {
|
@keyframes toastIn {
|
||||||
from { opacity: 0; transform: translateY(-12px) scale(.96); }
|
from { opacity: 0; transform: translateY(-12px) scale(.96); }
|
||||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||||
@@ -405,6 +423,9 @@ table.grid tbody tr.new.clickable:hover td { background: #d6f2e2; }
|
|||||||
.x { border: 0; background: none; font-size: 22px; line-height: 1; cursor: pointer; color: var(--text-3); }
|
.x { border: 0; background: none; font-size: 22px; line-height: 1; cursor: pointer; color: var(--text-3); }
|
||||||
.x:hover { color: var(--text); }
|
.x:hover { color: var(--text); }
|
||||||
.modal-body { padding: 18px; overflow: auto; }
|
.modal-body { padding: 18px; overflow: auto; }
|
||||||
|
/* 弹窗内的表格不做吸顶:弹窗自身是滚动容器,吸顶表头会与内容错层(且贴边观感差) */
|
||||||
|
.modal-body table.grid th { position: static; }
|
||||||
|
.modal-body table.grid { box-shadow: inset 0 0 0 1px var(--border-2); border-radius: 8px; }
|
||||||
.modal-foot {
|
.modal-foot {
|
||||||
display: flex; align-items: center; gap: 8px;
|
display: flex; align-items: center; gap: 8px;
|
||||||
padding: 12px 18px; border-top: 1px solid var(--border); background: #fbfcfe;
|
padding: 12px 18px; border-top: 1px solid var(--border); background: #fbfcfe;
|
||||||
@@ -419,11 +440,6 @@ table.grid tbody tr.new.clickable:hover td { background: #d6f2e2; }
|
|||||||
.fi label .req { color: var(--danger, #e5484d); margin-left: 2px; }
|
.fi label .req { color: var(--danger, #e5484d); margin-left: 2px; }
|
||||||
.fi-inline { display: flex; flex-wrap: wrap; gap: 16px; align-items: center; }
|
.fi-inline { display: flex; flex-wrap: wrap; gap: 16px; align-items: center; }
|
||||||
.fi-inline .chk { font-weight: 500; font-size: 13px; color: var(--text); }
|
.fi-inline .chk { font-weight: 500; font-size: 13px; color: var(--text); }
|
||||||
.fullscores { display: flex; flex-direction: column; gap: 6px; }
|
|
||||||
.fullscores .row { display: flex; align-items: center; gap: 8px; }
|
|
||||||
.fullscores label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
||||||
.fullscores input { width: 90px; flex: none; }
|
|
||||||
.fullscores .empty { padding: 6px 0; color: var(--text-3); font-size: 12px; text-align: left; }
|
|
||||||
.hint { margin: 0; font-size: 11px; color: var(--text-3); }
|
.hint { margin: 0; font-size: 11px; color: var(--text-3); }
|
||||||
.fi.checks { flex-direction: row; flex-wrap: wrap; gap: 18px; }
|
.fi.checks { flex-direction: row; flex-wrap: wrap; gap: 18px; }
|
||||||
.fi.checks label { display: flex; align-items: center; gap: 6px; font-weight: 500; font-size: 13px; color: var(--text); }
|
.fi.checks label { display: flex; align-items: center; gap: 6px; font-weight: 500; font-size: 13px; color: var(--text); }
|
||||||
@@ -487,9 +503,6 @@ table.grid tbody tr.new.clickable:hover td { background: #d6f2e2; }
|
|||||||
.login-box .fi { margin-bottom: 14px; }
|
.login-box .fi { margin-bottom: 14px; }
|
||||||
.login-actions { display: flex; gap: 10px; margin-top: 6px; }
|
.login-actions { display: flex; gap: 10px; margin-top: 6px; }
|
||||||
.login-actions .btn { flex: 1; }
|
.login-actions .btn { flex: 1; }
|
||||||
.login-msg { margin-top: 12px; font-size: 12px; min-height: 18px; white-space: pre-wrap; }
|
|
||||||
.login-msg.ok { color: var(--ok); }
|
|
||||||
.login-msg.err { color: var(--danger); }
|
|
||||||
|
|
||||||
/* ---------- 全屏适配 ---------- */
|
/* ---------- 全屏适配 ---------- */
|
||||||
body.fs-on .wrap { max-width: none; padding-top: 8px; }
|
body.fs-on .wrap { max-width: none; padding-top: 8px; }
|
||||||
@@ -509,7 +522,6 @@ body.fs-on .topbar { padding-top: 8px; padding-bottom: 8px; }
|
|||||||
|
|
||||||
/* 平板横屏(769~1024):卡片改 4 列、工具栏字段占满整行 */
|
/* 平板横屏(769~1024):卡片改 4 列、工具栏字段占满整行 */
|
||||||
@media (max-width: 1024px) {
|
@media (max-width: 1024px) {
|
||||||
.grid-2 { grid-template-columns: 1fr; }
|
|
||||||
.field.grow, .field.grow-2 { flex: 1 1 100%; min-width: 0; }
|
.field.grow, .field.grow-2 { flex: 1 1 100%; min-width: 0; }
|
||||||
.cards { grid-template-columns: repeat(4, 1fr); }
|
.cards { grid-template-columns: repeat(4, 1fr); }
|
||||||
.toolbar { gap: 14px; }
|
.toolbar { gap: 14px; }
|
||||||
@@ -529,8 +541,6 @@ body.fs-on .topbar { padding-top: 8px; padding-bottom: 8px; }
|
|||||||
.field { flex: 1 1 100%; min-width: 0; }
|
.field { flex: 1 1 100%; min-width: 0; }
|
||||||
.field.btns { flex: 1 1 100%; flex-wrap: wrap; row-gap: 8px; }
|
.field.btns { flex: 1 1 100%; flex-wrap: wrap; row-gap: 8px; }
|
||||||
.field.btns select { width: 100%; }
|
.field.btns select { width: 100%; }
|
||||||
/* 导出区的复选标签在小屏独占一行,避免和按钮挤成一团 */
|
|
||||||
.field.btns > label[style*="cursor:pointer"] { flex: 1 1 auto; }
|
|
||||||
.cards { grid-template-columns: repeat(3, 1fr); gap: 8px; }
|
.cards { grid-template-columns: repeat(3, 1fr); gap: 8px; }
|
||||||
.card { padding: 10px 12px; }
|
.card { padding: 10px 12px; }
|
||||||
.card b { font-size: 20px; }
|
.card b { font-size: 20px; }
|
||||||
@@ -612,15 +622,6 @@ body.fs-on .topbar { padding-top: 8px; padding-bottom: 8px; }
|
|||||||
.sec-title { margin: 22px 0 4px; font-size: 15px; font-weight: 700; display: flex; align-items: center; gap: 8px; }
|
.sec-title { margin: 22px 0 4px; font-size: 15px; font-weight: 700; display: flex; align-items: center; gap: 8px; }
|
||||||
.sec-title .tag-gray { background: var(--border-2); color: var(--text-3); padding: 1px 9px; border-radius: 999px; font-size: 12px; font-weight: 600; }
|
.sec-title .tag-gray { background: var(--border-2); color: var(--text-3); padding: 1px 9px; border-radius: 999px; font-size: 12px; font-weight: 600; }
|
||||||
|
|
||||||
.class-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 16px; margin-top: 14px; }
|
|
||||||
.class-card {
|
|
||||||
background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius);
|
|
||||||
padding: 16px 18px; box-shadow: var(--shadow); transition: transform .18s, box-shadow .18s;
|
|
||||||
}
|
|
||||||
.class-card:hover { transform: translateY(-2px); box-shadow: var(--shadow-lg); }
|
|
||||||
.class-card h3 { margin: 0 0 2px; font-size: 16px; display: flex; justify-content: space-between; align-items: baseline; gap: 8px; }
|
|
||||||
.class-card .rate-num { font-size: 15px; font-weight: 800; white-space: nowrap; }
|
|
||||||
.class-card .sub { font-size: 12px; color: var(--text-3); margin-bottom: 12px; }
|
|
||||||
.bar { height: 9px; border-radius: 999px; background: var(--border-2); overflow: hidden; }
|
.bar { height: 9px; border-radius: 999px; background: var(--border-2); overflow: hidden; }
|
||||||
.bar > i { display: block; height: 100%; border-radius: 999px; transition: width .5s ease; }
|
.bar > i { display: block; height: 100%; border-radius: 999px; transition: width .5s ease; }
|
||||||
.class-metrics { display: grid; grid-template-columns: 1fr 1fr; gap: 12px 16px; margin-top: 14px; }
|
.class-metrics { display: grid; grid-template-columns: 1fr 1fr; gap: 12px 16px; margin-top: 14px; }
|
||||||
@@ -628,23 +629,10 @@ body.fs-on .topbar { padding-top: 8px; padding-bottom: 8px; }
|
|||||||
.class-metrics .m span { color: var(--text-3); font-size: 11px; }
|
.class-metrics .m span { color: var(--text-3); font-size: 11px; }
|
||||||
.class-metrics .m b { font-size: 17px; font-weight: 700; }
|
.class-metrics .m b { font-size: 17px; font-weight: 700; }
|
||||||
.metric-ok b { color: var(--ok); }
|
.metric-ok b { color: var(--ok); }
|
||||||
.metric-warn b { color: var(--warn); }
|
|
||||||
.metric-danger b { color: var(--danger); }
|
.metric-danger b { color: var(--danger); }
|
||||||
|
|
||||||
.empty { text-align: center; color: var(--text-3); padding: 40px 0; }
|
.empty { text-align: center; color: var(--text-3); padding: 40px 0; }
|
||||||
|
|
||||||
/* 班级卡片:可点击进入该班详情 */
|
|
||||||
.class-card.clickable { cursor: pointer; }
|
|
||||||
.class-card.clickable:hover { border-color: var(--primary); }
|
|
||||||
.grp-tag {
|
|
||||||
display: inline-block; margin-top: 12px; padding: 2px 10px; border-radius: 999px;
|
|
||||||
font-size: 11px; font-weight: 600; color: var(--primary); background: var(--primary-weak);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 班级详情头部 */
|
|
||||||
.detail-head { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; margin-top: 18px; }
|
|
||||||
.detail-head h2 { margin: 0; font-size: 18px; font-weight: 700; }
|
|
||||||
|
|
||||||
/* 分组对比:A 在左、B 在右 */
|
/* 分组对比:A 在左、B 在右 */
|
||||||
.group-compare { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 14px; }
|
.group-compare { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 14px; }
|
||||||
@media (max-width: 860px) { .group-compare { grid-template-columns: 1fr; } }
|
@media (max-width: 860px) { .group-compare { grid-template-columns: 1fr; } }
|
||||||
@@ -679,8 +667,8 @@ body.fs-on .topbar { padding-top: 8px; padding-bottom: 8px; }
|
|||||||
.rk-3 { background: #fbe9dd; color: #b45309; }
|
.rk-3 { background: #fbe9dd; color: #b45309; }
|
||||||
|
|
||||||
/* 防溢出:网格子项允许收缩,长内容不撑破页面 */
|
/* 防溢出:网格子项允许收缩,长内容不撑破页面 */
|
||||||
.class-grid, .group-compare, .cards, .gpanel, .class-card { min-width: 0; }
|
.group-compare, .cards, .gpanel { min-width: 0; }
|
||||||
.gpanel, .class-card { overflow: hidden; }
|
.gpanel { overflow: hidden; }
|
||||||
.member-list td.name { max-width: 140px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.member-list td.name { max-width: 140px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
|
||||||
/* 看板全屏:整页不溢出屏幕(外层锁 100vh + overflow:hidden,内部区域自行滚动) */
|
/* 看板全屏:整页不溢出屏幕(外层锁 100vh + overflow:hidden,内部区域自行滚动) */
|
||||||
@@ -693,13 +681,9 @@ body.dash.fs-on .sec-title { margin: 12px 0 4px; font-size: 14px; }
|
|||||||
body.dash.fs-on .cards { gap: 10px; margin-top: 10px; }
|
body.dash.fs-on .cards { gap: 10px; margin-top: 10px; }
|
||||||
body.dash.fs-on .card { padding: 10px 12px; }
|
body.dash.fs-on .card { padding: 10px 12px; }
|
||||||
body.dash.fs-on .card b { font-size: 20px; }
|
body.dash.fs-on .card b { font-size: 20px; }
|
||||||
body.dash.fs-on #overview, body.dash.fs-on #detail {
|
|
||||||
flex: 1 1 auto; min-height: 0; overflow: auto;
|
|
||||||
}
|
|
||||||
body.dash.fs-on .class-grid { grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 12px; }
|
|
||||||
body.dash.fs-on .group-compare { gap: 12px; }
|
body.dash.fs-on .group-compare { gap: 12px; }
|
||||||
body.dash.fs-on .gpanel { padding: 12px 14px; }
|
body.dash.fs-on .gpanel { padding: 12px 14px; }
|
||||||
body.dash.fs-on .member-list { max-height: 30vh; }
|
body.dash.fs-on .member-list { max-height: none; }
|
||||||
|
|
||||||
|
|
||||||
/* ================= 随机抽取页 ================= */
|
/* ================= 随机抽取页 ================= */
|
||||||
|
|||||||
+20
-1
@@ -126,6 +126,19 @@ function ny_csrf_token(): string
|
|||||||
return $_SESSION['ny_csrf'];
|
return $_SESSION['ny_csrf'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取请求原始 body(仅读一次并缓存)。
|
||||||
|
* 写接口既要校验 CSRF(从 body 取 _csrf)又要解析业务 JSON,二者都需读 php://input;
|
||||||
|
* 大多数 SAPI 下 php://input 可重复读,但个别环境只能读一次,因此这里统一缓存避免第二次读为空。
|
||||||
|
*/
|
||||||
|
function ny_request_body(): string
|
||||||
|
{
|
||||||
|
if (!isset($GLOBALS['__ny_body'])) {
|
||||||
|
$GLOBALS['__ny_body'] = (string)@file_get_contents('php://input');
|
||||||
|
}
|
||||||
|
return $GLOBALS['__ny_body'];
|
||||||
|
}
|
||||||
|
|
||||||
/** 校验 CSRF token(写操作调用)。失败返回 false 并应拒绝请求。 */
|
/** 校验 CSRF token(写操作调用)。失败返回 false 并应拒绝请求。 */
|
||||||
function ny_csrf_ok(): bool
|
function ny_csrf_ok(): bool
|
||||||
{
|
{
|
||||||
@@ -136,7 +149,7 @@ function ny_csrf_ok(): bool
|
|||||||
$given = (string)$_POST['_csrf'];
|
$given = (string)$_POST['_csrf'];
|
||||||
} else {
|
} else {
|
||||||
// JSON body 里的 _csrf(api.php 写接口多走 JSON)
|
// JSON body 里的 _csrf(api.php 写接口多走 JSON)
|
||||||
$raw = (string)file_get_contents('php://input');
|
$raw = ny_request_body();
|
||||||
if ($raw !== '') {
|
if ($raw !== '') {
|
||||||
$j = json_decode($raw, true);
|
$j = json_decode($raw, true);
|
||||||
if (is_array($j) && isset($j['_csrf'])) {
|
if (is_array($j) && isset($j['_csrf'])) {
|
||||||
@@ -286,6 +299,12 @@ function ny_save_config(array $cfg): bool
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
$fileOk = ny_write_json(ny_config_file(), $cfg);
|
$fileOk = ny_write_json(ny_config_file(), $cfg);
|
||||||
|
// 尽量收紧配置文件权限(仅属主可读写),降低明文密码被其它本地用户读取的风险。
|
||||||
|
// Windows 下 chmod 基本无效(真正的防护是 data/ 目录禁止 Web 访问,见 .htaccess / 部署文档);
|
||||||
|
// 明文密码仍需保留以便卷王 token 失效时平台自动重新登录,无法改为只存哈希。
|
||||||
|
if ($fileOk && is_file(ny_config_file())) {
|
||||||
|
@chmod(ny_config_file(), 0600);
|
||||||
|
}
|
||||||
if (!$ok) {
|
if (!$ok) {
|
||||||
$GLOBALS['__ny_cfg_source'] = 'file';
|
$GLOBALS['__ny_cfg_source'] = 'file';
|
||||||
}
|
}
|
||||||
|
|||||||
+47
-4
@@ -94,6 +94,36 @@ function rateColor(r){
|
|||||||
return 'var(--danger)';
|
return 'var(--danger)';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* FLIP 行移动画:新数据行滑到新排名、其它行顺势下移(尊重 prefers-reduced-motion) */
|
||||||
|
const REDUCED_MOTION = !!(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches);
|
||||||
|
function captureFlip(container){
|
||||||
|
const m = new Map();
|
||||||
|
if (!container) return m;
|
||||||
|
container.querySelectorAll('tr[data-sid]').forEach(tr => {
|
||||||
|
const sid = tr.getAttribute('data-sid');
|
||||||
|
if (sid) m.set(sid, tr.getBoundingClientRect().top);
|
||||||
|
});
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
function playFlip(container, prev){
|
||||||
|
if (!container || !prev || REDUCED_MOTION) return;
|
||||||
|
container.querySelectorAll('tr[data-sid]').forEach(tr => {
|
||||||
|
const sid = tr.getAttribute('data-sid');
|
||||||
|
if (!sid) return;
|
||||||
|
const oldTop = prev.get(sid);
|
||||||
|
if (oldTop === undefined) { tr.classList.add('row-drop-in'); return; }
|
||||||
|
const dy = oldTop - tr.getBoundingClientRect().top;
|
||||||
|
if (Math.abs(dy) < 1) return;
|
||||||
|
tr.style.transition = 'none';
|
||||||
|
tr.style.transform = 'translateY(' + dy + 'px)';
|
||||||
|
void tr.offsetWidth;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
tr.style.transition = 'transform .42s cubic-bezier(.22,.61,.36,1)';
|
||||||
|
tr.style.transform = '';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/* URL 状态:?pid=&class=&auto=1|0 */
|
/* URL 状态:?pid=&class=&auto=1|0 */
|
||||||
function readUrl(){
|
function readUrl(){
|
||||||
const u = new URL(location.href);
|
const u = new URL(location.href);
|
||||||
@@ -237,7 +267,7 @@ function membersTable(members){
|
|||||||
return '<div class="empty" style="padding:18px 0">暂无考试成员</div>';
|
return '<div class="empty" style="padding:18px 0">暂无考试成员</div>';
|
||||||
}
|
}
|
||||||
return '<div class="member-list"><table class="grid compact">' +
|
return '<div class="member-list"><table class="grid compact">' +
|
||||||
'<thead><tr><th style="width:60px">排名</th><th>姓名</th><th style="width:80px">成绩</th></tr></thead><tbody>' +
|
'<thead><tr><th style="width:20%;text-align:right">排名</th><th style="width:55%">姓名</th><th style="width:25%" data-k="score">成绩</th></tr></thead><tbody>' +
|
||||||
members.map(m => {
|
members.map(m => {
|
||||||
const rk = (m.rank == null ? '' : m.rank);
|
const rk = (m.rank == null ? '' : m.rank);
|
||||||
const badge = rk === 1 ? 'rk rk-1' : (rk === 2 ? 'rk rk-2' : (rk === 3 ? 'rk rk-3' : 'rk'));
|
const badge = rk === 1 ? 'rk rk-1' : (rk === 2 ? 'rk rk-2' : (rk === 3 ? 'rk rk-3' : 'rk'));
|
||||||
@@ -255,7 +285,7 @@ function membersTable(members){
|
|||||||
const tag = att ? ' <span class="tag tag-att" title="查看 ' + m.attempts + ' 次提交明细">刷' + m.attempts + '次</span>' : '';
|
const tag = att ? ' <span class="tag tag-att" title="查看 ' + m.attempts + ' 次提交明细">刷' + m.attempts + '次</span>' : '';
|
||||||
// 注意:不要把 sid 直接拼进 onclick="...",JSON.stringify 产生的双引号会提前闭合
|
// 注意:不要把 sid 直接拼进 onclick="...",JSON.stringify 产生的双引号会提前闭合
|
||||||
// HTML 属性(onclick="openAttempts("123")"),点击必然报错。改用 data-sid + 事件委托。
|
// HTML 属性(onclick="openAttempts("123")"),点击必然报错。改用 data-sid + 事件委托。
|
||||||
return '<tr' + (att ? ' class="clickable" data-sid="' + esc(m.sid) + '" title="查看提交明细"' : '') + '>' +
|
return '<tr' + (att ? ' class="clickable"' : '') + ' data-sid="' + esc(m.sid) + '"' + (att ? ' title="查看提交明细"' : '') + '>' +
|
||||||
'<td class="num"><span class="' + badge + '">' + rk + '</span></td>' +
|
'<td class="num"><span class="' + badge + '">' + rk + '</span></td>' +
|
||||||
'<td class="name">' + esc(m.name) + tag + '</td>' +
|
'<td class="name">' + esc(m.name) + tag + '</td>' +
|
||||||
'<td class="score num ' + cls + '">' + (sc == null ? '-' : sc) + '</td>' +
|
'<td class="score num ' + cls + '">' + (sc == null ? '-' : sc) + '</td>' +
|
||||||
@@ -280,7 +310,11 @@ function openAttempts(sid){
|
|||||||
}).join('');
|
}).join('');
|
||||||
$('#attTitle').textContent = info.name + ' · 共 ' + (info.attempts || list.length) + ' 次提交';
|
$('#attTitle').textContent = info.name + ' · 共 ' + (info.attempts || list.length) + ' 次提交';
|
||||||
$('#attBody').innerHTML = list.length
|
$('#attBody').innerHTML = list.length
|
||||||
? '<table class="grid compact"><thead><tr><th>提交时间</th><th>成绩</th><th>考试时长</th></tr></thead><tbody>' + rows + '</tbody></table>'
|
? '<table class="grid compact att-table"><thead><tr>' +
|
||||||
|
'<th data-k="submit_ts" style="width:40%">提交时间</th>' +
|
||||||
|
'<th data-k="score" style="width:30%">成绩</th>' +
|
||||||
|
'<th data-k="duration" style="width:30%">考试时长</th>' +
|
||||||
|
'</tr></thead><tbody>' + rows + '</tbody></table>'
|
||||||
: '<div class="empty">无提交明细</div>';
|
: '<div class="empty">无提交明细</div>';
|
||||||
document.querySelector('#attemptsModal').classList.add('open');
|
document.querySelector('#attemptsModal').classList.add('open');
|
||||||
}
|
}
|
||||||
@@ -306,13 +340,19 @@ document.addEventListener('keydown', e => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
/* ---------- 看板占位(未选问卷/班级) ---------- */
|
/* ---------- 看板占位(未选问卷/班级) ---------- */
|
||||||
|
/* 占位卡片配色:与真实看板卡片(renderBoard)一一对应,保证空态左侧色条一致 */
|
||||||
|
const PLACEHOLDER_ACCENT = {
|
||||||
|
'总人数': 'accent-blue', '已提交': 'accent-green', '缺考': 'accent-orange',
|
||||||
|
'提交率': 'accent-purple', '平均分': 'accent-teal', '合格率': 'accent-indigo'
|
||||||
|
};
|
||||||
function renderPlaceholderBoard(){
|
function renderPlaceholderBoard(){
|
||||||
const cards = [
|
const cards = [
|
||||||
['总人数','-'],['已提交','-'],['缺考','-'],
|
['总人数','-'],['已提交','-'],['缺考','-'],
|
||||||
['提交率','-'],['平均分','-'],['合格率','-']
|
['提交率','-'],['平均分','-'],['合格率','-']
|
||||||
];
|
];
|
||||||
$('#boardSummary').innerHTML = cards.map(c =>
|
$('#boardSummary').innerHTML = cards.map(c =>
|
||||||
'<div class="card"><span>' + c[0] + '</span><b>' + c[1] + '</b></div>').join('');
|
// 占位卡片与真实看板卡片顺序、配色一致(accent-* 决定左侧色条),避免空态蓝条与真实卡片不一致
|
||||||
|
'<div class="card ' + PLACEHOLDER_ACCENT[c[0]] + '"><span>' + c[0] + '</span><b>' + c[1] + '</b></div>').join('');
|
||||||
$('#boardBody').innerHTML = '<div class="empty">请选择问卷与班级查看考试整体情况</div>';
|
$('#boardBody').innerHTML = '<div class="empty">请选择问卷与班级查看考试整体情况</div>';
|
||||||
// 注意:heroTitle 已随导航栏精简移除,只保留 heroSub(承载当前问卷/班级提示)
|
// 注意:heroTitle 已随导航栏精简移除,只保留 heroSub(承载当前问卷/班级提示)
|
||||||
const _hs = document.getElementById('heroSub');
|
const _hs = document.getElementById('heroSub');
|
||||||
@@ -480,7 +520,10 @@ async function refreshBoard(manual){
|
|||||||
sel.disabled = !classes.length;
|
sel.disabled = !classes.length;
|
||||||
if (classes.length) { sel.value = classes[0]; CUR.cls = classes[0]; writeUrl({ cls: classes[0] }); }
|
if (classes.length) { sel.value = classes[0]; CUR.cls = classes[0]; writeUrl({ cls: classes[0] }); }
|
||||||
}
|
}
|
||||||
|
// 刷新前捕获各行位置,刷新后做 FLIP:新数据行滑入、其它行下移
|
||||||
|
const flipPrev = captureFlip($('#boardBody'));
|
||||||
renderBoard();
|
renderBoard();
|
||||||
|
playFlip($('#boardBody'), flipPrev);
|
||||||
$('#lastUpdate').textContent = fresh.updated_at || '-';
|
$('#lastUpdate').textContent = fresh.updated_at || '-';
|
||||||
markLive();
|
markLive();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -110,19 +110,18 @@ $rosterCount = Roster::count();
|
|||||||
<table class="grid">
|
<table class="grid">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th style="width:64px">序号</th>
|
<th style="width:8%">序号</th>
|
||||||
<th style="width:22%">学号</th>
|
<th style="width:22%">学号</th>
|
||||||
<th style="width:18%">姓名</th>
|
<th style="width:18%">姓名</th>
|
||||||
<th>班级</th>
|
<th style="width:24%">班级</th>
|
||||||
<th>组别</th>
|
<th style="width:18%">组别</th>
|
||||||
<th style="width:22%">登陆账号</th>
|
<th style="width:10%">操作</th>
|
||||||
<th style="width:88px">操作</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="drawBody"><tr><td colspan="7" class="empty">尚未抽取</td></tr></tbody>
|
<tbody id="drawBody"><tr><td colspan="6" class="empty">尚未抽取</td></tr></tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<p class="hint">两个下载与「📋 复制名单」均为两列「<b>姓名</b> / <b>登陆账号</b>」,登陆账号取名单中导入的「登陆账号」列。<b>表中「登陆账号」列显示为空即表示名单里没有填,导出时会自动回退用学号</b>,请先到主系统「名单管理」补全,否则学生无法登录考试。</p>
|
<p class="hint">抽取结果表不显示登陆账号;「⬇ 白名单导入模板」「⬇ 白名单列表」与「📋 复制名单」均导出两列「<b>姓名</b> / <b>登陆账号</b>」,登陆账号取名单中导入的「登陆账号」列。<b>若名单未填登陆账号,导出时会自动回退用学号</b>,请先到主系统「名单管理」补全,否则学生无法登录考试。</p>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
@@ -139,7 +138,7 @@ $rosterCount = Roster::count();
|
|||||||
</div>
|
</div>
|
||||||
<div class="table-scroll mid">
|
<div class="table-scroll mid">
|
||||||
<table class="grid compact">
|
<table class="grid compact">
|
||||||
<thead><tr><th style="width:64px">排除</th><th style="width:24%">学号</th><th style="width:22%">姓名</th><th>班级</th><th>组别</th></tr></thead>
|
<thead><tr><th style="width:10%;text-align:center">排除</th><th style="width:24%">学号</th><th style="width:22%">姓名</th><th style="width:26%">班级</th><th style="width:18%">组别</th></tr></thead>
|
||||||
<tbody id="exclBody"><tr><td colspan="5" class="empty">加载中…</td></tr></tbody>
|
<tbody id="exclBody"><tr><td colspan="5" class="empty">加载中…</td></tr></tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -125,14 +125,14 @@ $rosterCount = Roster::count();
|
|||||||
<table class="grid" id="dataTable">
|
<table class="grid" id="dataTable">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th class="sortable" data-k="sid">学号</th>
|
<th class="sortable" data-k="sid" style="width:16%">学号</th>
|
||||||
<th class="sortable" data-k="name">姓名</th>
|
<th class="sortable" data-k="name" style="width:12%">姓名</th>
|
||||||
<th class="sortable" data-k="cls">班级</th>
|
<th class="sortable" data-k="cls" style="width:10%">班级</th>
|
||||||
<th class="sortable" data-k="grp">组别</th>
|
<th class="sortable" data-k="grp" style="width:7%">组别</th>
|
||||||
<th class="sortable" data-k="score">成绩</th>
|
<th class="sortable" data-k="score" style="width:10%">成绩</th>
|
||||||
<th class="sortable" data-k="attempts" title="该生提交过的次数,点击表头可排序">考试次数</th>
|
<th class="sortable" data-k="attempts" style="width:11%" title="该生提交过的次数,点击表头可排序">考试次数</th>
|
||||||
<th class="sortable" data-k="submit_ts" title="最近一次提交时间">提交时间</th>
|
<th class="sortable" data-k="submit_ts" style="width:22%" title="最近一次提交时间">提交时间</th>
|
||||||
<th class="sortable" data-k="duration">考试时长</th>
|
<th class="sortable" data-k="duration" style="width:12%">考试时长</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="tbody"><tr><td colspan="8" class="empty">请选择问卷 / 考试</td></tr></tbody>
|
<tbody id="tbody"><tr><td colspan="8" class="empty">请选择问卷 / 考试</td></tr></tbody>
|
||||||
@@ -150,7 +150,7 @@ $rosterCount = Roster::count();
|
|||||||
</div>
|
</div>
|
||||||
<div class="table-scroll short">
|
<div class="table-scroll short">
|
||||||
<table class="grid compact" id="absentTable">
|
<table class="grid compact" id="absentTable">
|
||||||
<thead><tr><th>学号</th><th>姓名</th><th>班级</th><th>组别</th></tr></thead>
|
<thead><tr><th style="width:25%">学号</th><th style="width:30%">姓名</th><th style="width:25%">班级</th><th style="width:20%">组别</th></tr></thead>
|
||||||
<tbody id="absentBody"><tr><td colspan="4" class="empty">—</td></tr></tbody>
|
<tbody id="absentBody"><tr><td colspan="4" class="empty">—</td></tr></tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -160,7 +160,7 @@ $rosterCount = Roster::count();
|
|||||||
<div class="panel-head"><h2>未匹配答卷 <small id="unmatchedHint">姓名不在名单中</small></h2></div>
|
<div class="panel-head"><h2>未匹配答卷 <small id="unmatchedHint">姓名不在名单中</small></h2></div>
|
||||||
<div class="table-scroll short">
|
<div class="table-scroll short">
|
||||||
<table class="grid compact">
|
<table class="grid compact">
|
||||||
<thead><tr><th>姓名</th><th>成绩</th><th>提交时间</th><th style="width:90px">操作</th></tr></thead>
|
<thead><tr><th style="width:25%">姓名</th><th style="width:18%" data-k="score">成绩</th><th style="width:35%" data-k="submit_ts">提交时间</th><th style="width:22%">操作</th></tr></thead>
|
||||||
<tbody id="unmatchedBody"><tr><td colspan="4" class="empty">—</td></tr></tbody>
|
<tbody id="unmatchedBody"><tr><td colspan="4" class="empty">—</td></tr></tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -289,9 +289,10 @@ $rosterCount = Roster::count();
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="roster-bar">
|
<div class="roster-bar">
|
||||||
|
<button class="btn sm" id="btnTpl" title="下载名单导入模板(CSV,含「登陆账号」列)">⬇ 下载模板</button>
|
||||||
<div class="inline">
|
<div class="inline">
|
||||||
<label class="chk"><input type="radio" name="impMode" value="replace" checked> 覆盖</label>
|
<label class="chk"><input type="radio" name="impMode" value="replace"> 覆盖</label>
|
||||||
<label class="chk"><input type="radio" name="impMode" value="append"> 追加</label>
|
<label class="chk"><input type="radio" name="impMode" value="append" checked> 追加</label>
|
||||||
</div>
|
</div>
|
||||||
<input type="text" id="rosterSearch" placeholder="搜索学号 / 姓名">
|
<input type="text" id="rosterSearch" placeholder="搜索学号 / 姓名">
|
||||||
<span class="spacer"></span>
|
<span class="spacer"></span>
|
||||||
@@ -302,7 +303,7 @@ $rosterCount = Roster::count();
|
|||||||
</div>
|
</div>
|
||||||
<div class="table-scroll mid">
|
<div class="table-scroll mid">
|
||||||
<table class="grid compact" id="rosterTable">
|
<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>
|
<thead><tr><th style="width:6%"><input type="checkbox" id="rosterCheckAll" title="全选"></th><th style="width:26%">学号</th><th style="width:22%">姓名</th><th style="width:26%">班级</th><th style="width:20%">组别</th></tr></thead>
|
||||||
<tbody id="rosterBody"></tbody>
|
<tbody id="rosterBody"></tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -344,7 +345,7 @@ $rosterCount = Roster::count();
|
|||||||
<label>组别</label>
|
<label>组别</label>
|
||||||
<input type="text" id="addGrp" placeholder="如 A / B,可选">
|
<input type="text" id="addGrp" placeholder="如 A / B,可选">
|
||||||
</div>
|
</div>
|
||||||
<div class="fi">
|
<div class="fi" hidden>
|
||||||
<label>登陆账号</label>
|
<label>登陆账号</label>
|
||||||
<input type="text" id="addLogin" placeholder="可选,用于抽取页登录">
|
<input type="text" id="addLogin" placeholder="可选,用于抽取页登录">
|
||||||
</div>
|
</div>
|
||||||
@@ -420,9 +421,17 @@ $rosterCount = Roster::count();
|
|||||||
<option value="landscape">横向(A4,列多时不易折行)</option>
|
<option value="landscape">横向(A4,列多时不易折行)</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="fi">
|
||||||
|
<label>打印范围</label>
|
||||||
|
<select id="ptScope">
|
||||||
|
<option value="latest" selected>按最新成绩打印(每人一行)</option>
|
||||||
|
<option value="all">打印全部记录(含每位学生历史提交)</option>
|
||||||
|
</select>
|
||||||
|
<p class="hint">全部记录会把每位学生的每次提交各打一行,完整留存历史;按最新成绩则每人仅显示当前代表成绩。</p>
|
||||||
|
</div>
|
||||||
<div class="fi checks">
|
<div class="fi checks">
|
||||||
<label><input type="checkbox" id="ptRank" checked> 显示名次(全表连续编号)</label>
|
<label><input type="checkbox" id="ptRank" checked> 显示名次(全表连续编号)</label>
|
||||||
<label><input type="checkbox" id="ptPass" checked> 显示「是否合格」列</label>
|
<label><input type="checkbox" id="ptPass"> 显示「是否合格」列(默认不显示)</label>
|
||||||
<label><input type="checkbox" id="ptAttempts" checked> 显示考试次数</label>
|
<label><input type="checkbox" id="ptAttempts" checked> 显示考试次数</label>
|
||||||
<label><input type="checkbox" id="ptAbsent" title="成绩表后另起一页附缺考名单,含备注列供手写"> 附缺考名单(另起一页)</label>
|
<label><input type="checkbox" id="ptAbsent" title="成绩表后另起一页附缺考名单,含备注列供手写"> 附缺考名单(另起一页)</label>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -103,15 +103,4 @@ final class DataCache
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 清空所有数据缓存(名单变更 / 手动清缓存时使用) */
|
|
||||||
public static function clear(): int
|
|
||||||
{
|
|
||||||
$n = 0;
|
|
||||||
foreach (glob(self::dir() . '/data_*.json') ?: [] as $f) {
|
|
||||||
if (@unlink($f)) {
|
|
||||||
$n++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return $n;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+86
-22
@@ -30,9 +30,15 @@ class DataService
|
|||||||
return $out;
|
return $out;
|
||||||
}
|
}
|
||||||
|
|
||||||
// children 在根或子节点
|
// 不同卷王版本题目可能挂在 children / questions / list 下,统一兼容
|
||||||
$self = $node;
|
$self = $node;
|
||||||
$children = (isset($node['children']) && is_array($node['children'])) ? $node['children'] : null;
|
$children = null;
|
||||||
|
foreach (['children', 'questions', 'list', 'items'] as $ck) {
|
||||||
|
if (isset($node[$ck]) && is_array($node[$ck])) {
|
||||||
|
$children = $node[$ck];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$id = isset($self['id']) ? (string)$self['id'] : '';
|
$id = isset($self['id']) ? (string)$self['id'] : '';
|
||||||
$title = isset($self['title']) ? self::cleanTitle((string)$self['title']) : '';
|
$title = isset($self['title']) ? self::cleanTitle((string)$self['title']) : '';
|
||||||
@@ -138,26 +144,15 @@ class DataService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$students = Roster::all();
|
// 基础名单(全名单):用于答卷匹配,不受白名单影响
|
||||||
// 免考:从名单中剔除,使其既不计入「总人数」也不计入「缺考」
|
$rosterAll = Roster::all();
|
||||||
if ($exempt !== []) {
|
if ($exempt !== []) {
|
||||||
$students = array_values(array_filter(
|
$rosterAll = array_values(array_filter(
|
||||||
$students,
|
$rosterAll,
|
||||||
static fn(array $s): bool => !isset($exempt[trim((string)($s['sid'] ?? ''))])
|
static fn(array $s): bool => !isset($exempt[trim((string)($s['sid'] ?? ''))])
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
// 姓名 -> 学号 / 班级(规范化)
|
// 注:$students(应考名单)与 $studentInfo 在解析出 $survey(含白名单)后统一构建,见下方
|
||||||
$studentInfo = [];
|
|
||||||
foreach ($students as $s) {
|
|
||||||
$k = ny_norm_name((string)$s['name']);
|
|
||||||
if ($k !== '') {
|
|
||||||
$studentInfo[$k] = [
|
|
||||||
'sid' => (string)$s['sid'],
|
|
||||||
'cls' => (string)($s['cls'] ?? ''),
|
|
||||||
'grp' => (string)($s['grp'] ?? ''),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$project = $client->getProject($projectId);
|
$project = $client->getProject($projectId);
|
||||||
$survey = $project['survey'] ?? null;
|
$survey = $project['survey'] ?? null;
|
||||||
@@ -167,6 +162,73 @@ class DataService
|
|||||||
$survey = $decoded;
|
$survey = $decoded;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 卷内「答题白名单」:决定「应考名单」(缺/待考名单来源)。
|
||||||
|
// 未设置白名单时「按现在的方案显示」(使用全名单)。
|
||||||
|
$paperWhitelist = [];
|
||||||
|
try {
|
||||||
|
$paperWhitelist = $client->getWhitelist($projectId, $survey);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$paperWhitelist = [];
|
||||||
|
}
|
||||||
|
$useWhitelist = ($paperWhitelist !== []);
|
||||||
|
$whitelistTotal = 0;
|
||||||
|
|
||||||
|
if ($useWhitelist) {
|
||||||
|
// 以学号 / 登陆账号 / 姓名(大小写不敏感)把白名单成员匹配回名单,
|
||||||
|
// 命中的取名单里的姓名/班级/组别;未命中的以白名单值作占位,避免漏统计。
|
||||||
|
$rosterMap = [];
|
||||||
|
foreach ($rosterAll as $s) {
|
||||||
|
$sid = strtolower(trim((string)($s['sid'] ?? '')));
|
||||||
|
$login = strtolower(trim((string)($s['login'] ?? '')));
|
||||||
|
$name = strtolower(trim((string)($s['name'] ?? '')));
|
||||||
|
if ($sid) { $rosterMap['sid:' . $sid] = $s; }
|
||||||
|
if ($login) { $rosterMap['login:' . $login] = $s; }
|
||||||
|
if ($name) { $rosterMap['name:' . $name] = $s; }
|
||||||
|
}
|
||||||
|
$students = [];
|
||||||
|
$seen = [];
|
||||||
|
foreach ($paperWhitelist as $v) {
|
||||||
|
$key = strtolower(trim((string)$v));
|
||||||
|
$m = $rosterMap['sid:' . $key] ?? $rosterMap['login:' . $key] ?? $rosterMap['name:' . $key] ?? null;
|
||||||
|
if ($m !== null) {
|
||||||
|
$sid = trim((string)($m['sid'] ?? ''));
|
||||||
|
if ($sid !== '' && isset($seen[$sid])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($sid !== '') { $seen[$sid] = true; }
|
||||||
|
$students[] = $m;
|
||||||
|
} else {
|
||||||
|
if (isset($seen['wk:' . $key])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$seen['wk:' . $key] = true;
|
||||||
|
$students[] = ['sid' => $v, 'name' => $v, 'cls' => '', 'grp' => '', 'login' => ''];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$whitelistTotal = count($students);
|
||||||
|
} else {
|
||||||
|
$students = $rosterAll;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 姓名 -> 学号 / 班级(规范化):全名单 + 白名单独有成员都纳入,
|
||||||
|
// 保证所有提交(含白名单外提交)都能匹配到姓名。
|
||||||
|
$studentInfo = [];
|
||||||
|
$addInfo = static function (array $s) use (&$studentInfo): void {
|
||||||
|
$k = ny_norm_name((string)($s['name'] ?? ''));
|
||||||
|
if ($k !== '') {
|
||||||
|
$studentInfo[$k] = [
|
||||||
|
'sid' => (string)$s['sid'],
|
||||||
|
'cls' => (string)($s['cls'] ?? ''),
|
||||||
|
'grp' => (string)($s['grp'] ?? ''),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
foreach ($rosterAll as $s) { $addInfo($s); }
|
||||||
|
if ($useWhitelist) {
|
||||||
|
foreach ($students as $s) { $addInfo($s); }
|
||||||
|
}
|
||||||
|
|
||||||
$questions = self::flattenQuestions($survey);
|
$questions = self::flattenQuestions($survey);
|
||||||
|
|
||||||
$keywords = (string)($cfg['name_keywords'] ?? '姓名');
|
$keywords = (string)($cfg['name_keywords'] ?? '姓名');
|
||||||
@@ -218,7 +280,7 @@ class DataService
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
$v = ny_flatten_value($answerMap[$qid]);
|
$v = ny_flatten_value($answerMap[$qid]);
|
||||||
if ($v !== '' && isset($nameMap[ny_norm_name($v)])) {
|
if ($v !== '' && isset($studentInfo[ny_norm_name($v)])) {
|
||||||
$name = $v;
|
$name = $v;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -227,7 +289,7 @@ class DataService
|
|||||||
if ($name === '' && $questions === []) {
|
if ($name === '' && $questions === []) {
|
||||||
foreach ($answerMap as $v) {
|
foreach ($answerMap as $v) {
|
||||||
$s = ny_flatten_value($v);
|
$s = ny_flatten_value($v);
|
||||||
if ($s !== '' && isset($nameMap[ny_norm_name($s)])) {
|
if ($s !== '' && isset($studentInfo[ny_norm_name($s)])) {
|
||||||
$name = $s;
|
$name = $s;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -252,8 +314,8 @@ class DataService
|
|||||||
$ts = ny_to_timestamp($a['updateAt'] ?? null);
|
$ts = ny_to_timestamp($a['updateAt'] ?? null);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 考试时长
|
// 考试时长(metaInfo 可能缺失,先整体判空避免每答案两条 Undefined key 警告)
|
||||||
$ai = $a['metaInfo']['answerInfo'] ?? [];
|
$ai = ($a['metaInfo'] ?? [])['answerInfo'] ?? [];
|
||||||
$start = is_array($ai) ? (int)($ai['startTime'] ?? 0) : 0;
|
$start = is_array($ai) ? (int)($ai['startTime'] ?? 0) : 0;
|
||||||
$end = is_array($ai) ? (int)($ai['endTime'] ?? 0) : 0;
|
$end = is_array($ai) ? (int)($ai['endTime'] ?? 0) : 0;
|
||||||
$dur = null;
|
$dur = null;
|
||||||
@@ -371,6 +433,8 @@ class DataService
|
|||||||
'stats' => $stats,
|
'stats' => $stats,
|
||||||
'roster' => $students,
|
'roster' => $students,
|
||||||
'roster_count' => count($students),
|
'roster_count' => count($students),
|
||||||
|
'whitelist_driven' => $useWhitelist,
|
||||||
|
'whitelist_total' => $whitelistTotal,
|
||||||
// 本卷独立配置(前端据此回显满分/合格率/免考);full_score 已按本卷解析
|
// 本卷独立配置(前端据此回显满分/合格率/免考);full_score 已按本卷解析
|
||||||
'project_cfg' => [
|
'project_cfg' => [
|
||||||
'full_score' => $pCfg['full_score'] ?? null,
|
'full_score' => $pCfg['full_score'] ?? null,
|
||||||
|
|||||||
+9
-70
@@ -56,17 +56,6 @@ class Db
|
|||||||
return self::$err;
|
return self::$err;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 连接参数(SQLite 固定,保留接口以兼容既有调用) */
|
|
||||||
public static function params(): array
|
|
||||||
{
|
|
||||||
return ['driver' => 'sqlite', 'file' => self::dbFile()];
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function saveParams(array $p): bool
|
|
||||||
{
|
|
||||||
return true; // SQLite 无需外部配置
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 建立/复用连接;失败返回 null 而不抛异常(上层要能优雅降级) */
|
/** 建立/复用连接;失败返回 null 而不抛异常(上层要能优雅降级) */
|
||||||
public static function connect(array $p = []): PDO
|
public static function connect(array $p = []): PDO
|
||||||
{
|
{
|
||||||
@@ -180,25 +169,6 @@ class Db
|
|||||||
|
|
||||||
// ---------------- 设置 ----------------
|
// ---------------- 设置 ----------------
|
||||||
|
|
||||||
public static function settingsAll(): array
|
|
||||||
{
|
|
||||||
$pdo = self::pdo();
|
|
||||||
if (!$pdo) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
$p = self::prefix();
|
|
||||||
$st = $pdo->query("SELECT k, v FROM {$p}settings");
|
|
||||||
$out = [];
|
|
||||||
foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $r) {
|
|
||||||
$out[(string)$r['k']] = (string)$r['v'];
|
|
||||||
}
|
|
||||||
return $out;
|
|
||||||
} catch (Throwable $e) {
|
|
||||||
self::$err = $e->getMessage();
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** SQLite 用 ON CONFLICT 做 upsert(其它方言见历史版本) */
|
/** SQLite 用 ON CONFLICT 做 upsert(其它方言见历史版本) */
|
||||||
public static function settingsSet(string $k, string $v): void
|
public static function settingsSet(string $k, string $v): void
|
||||||
@@ -265,12 +235,16 @@ class Db
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 全量替换名单(roster_save 语义) */
|
/**
|
||||||
public static function rosterReplace(array $students): void
|
* 全量替换名单(roster_save 语义)
|
||||||
|
* @return bool 成功 true;DB 不可用或事务失败 false(失败原因见 lastError)
|
||||||
|
*/
|
||||||
|
public static function rosterReplace(array $students): bool
|
||||||
{
|
{
|
||||||
$pdo = self::pdo();
|
$pdo = self::pdo();
|
||||||
if (!$pdo) {
|
if (!$pdo) {
|
||||||
return;
|
self::$err = '数据库未连接';
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
$p = self::prefix();
|
$p = self::prefix();
|
||||||
@@ -296,11 +270,13 @@ class Db
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
$pdo->commit();
|
$pdo->commit();
|
||||||
|
return true;
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
if ($pdo->inTransaction()) {
|
if ($pdo->inTransaction()) {
|
||||||
$pdo->rollBack();
|
$pdo->rollBack();
|
||||||
}
|
}
|
||||||
self::$err = $e->getMessage();
|
self::$err = $e->getMessage();
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -321,29 +297,6 @@ class Db
|
|||||||
|
|
||||||
// ---------------- 每卷独立配置(满分 / 合格率 / 免考名单) ----------------
|
// ---------------- 每卷独立配置(满分 / 合格率 / 免考名单) ----------------
|
||||||
|
|
||||||
/** @return array<string,array> 全部试卷配置 [project_id => cfg] */
|
|
||||||
public static function projectConfigAll(): array
|
|
||||||
{
|
|
||||||
$pdo = self::pdo();
|
|
||||||
if (!$pdo) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
$p = self::prefix();
|
|
||||||
$st = $pdo->query("SELECT project_id, config_json FROM {$p}project_config");
|
|
||||||
$out = [];
|
|
||||||
foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $r) {
|
|
||||||
$j = json_decode((string)($r['config_json'] ?? '{}'), true);
|
|
||||||
if (is_array($j)) {
|
|
||||||
$out[(string)$r['project_id']] = $j;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return $out;
|
|
||||||
} catch (Throwable $e) {
|
|
||||||
self::$err = $e->getMessage();
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return array 单卷配置(缺省返回空结构) */
|
/** @return array 单卷配置(缺省返回空结构) */
|
||||||
public static function projectConfigGet(string $projectId): array
|
public static function projectConfigGet(string $projectId): array
|
||||||
@@ -394,18 +347,4 @@ class Db
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function projectConfigDelete(string $projectId): void
|
|
||||||
{
|
|
||||||
$pdo = self::pdo();
|
|
||||||
if (!$pdo) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
$p = self::prefix();
|
|
||||||
$st = $pdo->prepare("DELETE FROM {$p}project_config WHERE project_id = ?");
|
|
||||||
$st->execute([$projectId]);
|
|
||||||
} catch (Throwable $e) {
|
|
||||||
self::$err = $e->getMessage();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-27
@@ -110,9 +110,23 @@ class Roster
|
|||||||
'login' => trim((string)($s['login'] ?? '')),
|
'login' => trim((string)($s['login'] ?? '')),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
// 按学号去重(同 sid 取最后一条):roster 表 sid 为主键,重复 sid 会触发约束冲突导致整批回滚。
|
||||||
|
// 去重避免「保存成功但名单实际没变」的静默丢失。
|
||||||
|
$bySid = [];
|
||||||
|
$noSid = [];
|
||||||
|
foreach ($clean as $s) {
|
||||||
|
if ((string)($s['sid'] ?? '') !== '') {
|
||||||
|
$bySid[$s['sid']] = $s;
|
||||||
|
} else {
|
||||||
|
$noSid[] = $s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$clean = array_values($bySid);
|
||||||
|
foreach ($noSid as $s) {
|
||||||
|
$clean[] = $s;
|
||||||
|
}
|
||||||
if (self::useDb()) {
|
if (self::useDb()) {
|
||||||
Db::rosterReplace($clean);
|
return Db::rosterReplace($clean);
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
return ny_write_json(self::file(), [
|
return ny_write_json(self::file(), [
|
||||||
'students' => $clean,
|
'students' => $clean,
|
||||||
@@ -157,31 +171,6 @@ class Roster
|
|||||||
return $mt . ':' . $sz . ':' . $hash;
|
return $mt . ':' . $sz . ':' . $hash;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 首次使用时,若名单为空且存在 导入模板.xlsx,自动导入
|
|
||||||
*/
|
|
||||||
public static function ensureSeeded(): bool
|
|
||||||
{
|
|
||||||
if (self::count() > 0) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
$candidates = [
|
|
||||||
dirname(NY_ROOT) . '/导入模板.xlsx',
|
|
||||||
NY_ROOT . '/导入模板.xlsx',
|
|
||||||
NY_DATA . '/导入模板.xlsx',
|
|
||||||
];
|
|
||||||
foreach ($candidates as $f) {
|
|
||||||
if (is_file($f)) {
|
|
||||||
try {
|
|
||||||
$res = self::importFile($f, 'xlsx');
|
|
||||||
return $res['count'] > 0;
|
|
||||||
} catch (Throwable $e) {
|
|
||||||
// 忽略
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 解析上传文件 -> 学生数组
|
* 解析上传文件 -> 学生数组
|
||||||
|
|||||||
@@ -483,6 +483,100 @@ class SurveyKingClient
|
|||||||
return is_array($r['data']) ? $r['data'] : [];
|
return is_array($r['data']) ? $r['data'] : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取问卷设置的「答题白名单」成员列表(待考名单来源)
|
||||||
|
*
|
||||||
|
* 卷王不同版本字段形状不一,这里尽量兼容:
|
||||||
|
* - survey.surveySetting.whiteLists:{ 题ID: [值,...] }(社区版常见)
|
||||||
|
* - survey.surveySetting.whiteList:{ 题ID: [值,...] }
|
||||||
|
* - 也可能直接是字符串或嵌套数组
|
||||||
|
* 返回去重后的纯值数组(顺序保持首次出现),值本身通常是学号 / 姓名 / 登陆账号。
|
||||||
|
* 未设置白名单返回空数组(上层据此「按现在的方案显示」)。
|
||||||
|
*
|
||||||
|
* @param string $projectId 问卷 ID
|
||||||
|
* @param array|null $survey 可传入已解析的 survey 对象,避免重复请求 API(DataService::build 已取过 project)
|
||||||
|
* @return array<int,string>
|
||||||
|
*/
|
||||||
|
public function getWhitelist(string $projectId, ?array $survey = null): array
|
||||||
|
{
|
||||||
|
if ($survey === null) {
|
||||||
|
$proj = $this->getProject($projectId);
|
||||||
|
if (!is_array($proj)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$survey = $proj['survey'] ?? null;
|
||||||
|
if (is_string($survey) && $survey !== '') {
|
||||||
|
$decoded = json_decode($survey, true);
|
||||||
|
if (is_array($decoded)) {
|
||||||
|
$survey = $decoded;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 兼容 { surveySetting: ... } 与 { survey: { surveySetting: ... } } 两种结构,
|
||||||
|
// 也允许白名单直接挂在 survey 顶层(whiteLists / whiteList / whitelist)。
|
||||||
|
$setting = null;
|
||||||
|
if (is_array($survey)) {
|
||||||
|
if (isset($survey['surveySetting']) && is_array($survey['surveySetting'])) {
|
||||||
|
$setting = $survey['surveySetting'];
|
||||||
|
} elseif (isset($survey['survey']) && is_array($survey['survey']) && isset($survey['survey']['surveySetting'])) {
|
||||||
|
$setting = $survey['survey']['surveySetting'];
|
||||||
|
} else {
|
||||||
|
// 顶层直接是白名单结构
|
||||||
|
foreach (['whiteLists', 'whiteList', 'whitelist'] as $wk) {
|
||||||
|
if (isset($survey[$wk]) && (is_array($survey[$wk]) || is_string($survey[$wk]))) {
|
||||||
|
$setting = $survey;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!is_array($setting)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return self::parseWhitelist($setting);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 surveySetting 中解析白名单成员(去重、保留首次出现顺序)。
|
||||||
|
* @return array<int,string>
|
||||||
|
*/
|
||||||
|
public static function parseWhitelist(array $setting): array
|
||||||
|
{
|
||||||
|
$raw = $setting['whiteLists'] ?? $setting['whiteList'] ?? $setting['whitelist'] ?? null;
|
||||||
|
if ($raw === null || $raw === [] || $raw === '') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$out = [];
|
||||||
|
$walk = static function ($node) use (&$out, &$walk): void {
|
||||||
|
if (is_array($node)) {
|
||||||
|
// 列表或对象都逐值递归(兼容 { 题ID: [值] })
|
||||||
|
foreach ($node as $v) {
|
||||||
|
$walk($v);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (is_string($node) && trim($node) !== '') {
|
||||||
|
$out[] = trim($node);
|
||||||
|
} elseif (is_scalar($node) && $node !== '' && $node !== null) {
|
||||||
|
$out[] = (string)$node;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
$walk($raw);
|
||||||
|
|
||||||
|
// 去重(大小写不敏感),保留首次出现顺序
|
||||||
|
$seen = [];
|
||||||
|
$res = [];
|
||||||
|
foreach ($out as $item) {
|
||||||
|
$key = strtolower($item);
|
||||||
|
if (!isset($seen[$key])) {
|
||||||
|
$seen[$key] = true;
|
||||||
|
$res[] = $item;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $res;
|
||||||
|
}
|
||||||
|
|
||||||
/** 答卷列表(自动翻页取全量) */
|
/** 答卷列表(自动翻页取全量) */
|
||||||
public function listAnswers(string $projectId): array
|
public function listAnswers(string $projectId): array
|
||||||
{
|
{
|
||||||
|
|||||||
+10
-4
@@ -15,6 +15,8 @@ final class XlsxWriter
|
|||||||
private array $strIndex = [];
|
private array $strIndex = [];
|
||||||
/** @var array<int,string> 索引 => 共享字符串 */
|
/** @var array<int,string> 索引 => 共享字符串 */
|
||||||
private array $strings = [];
|
private array $strings = [];
|
||||||
|
/** @var array<int,int> 索引 => 出现次数(sst 的 count 用,区别于 uniqueCount) */
|
||||||
|
private array $strCount = [];
|
||||||
/** @var array<int,array{name:string,rows:array,widths:array}> 工作表(按输出顺序) */
|
/** @var array<int,array{name:string,rows:array,widths:array}> 工作表(按输出顺序) */
|
||||||
private array $sheets = [];
|
private array $sheets = [];
|
||||||
|
|
||||||
@@ -156,8 +158,11 @@ final class XlsxWriter
|
|||||||
$i = count($this->strings);
|
$i = count($this->strings);
|
||||||
$this->strIndex[$s] = $i;
|
$this->strIndex[$s] = $i;
|
||||||
$this->strings[$i] = $s;
|
$this->strings[$i] = $s;
|
||||||
|
$this->strCount[$i] = 0;
|
||||||
}
|
}
|
||||||
return $this->strIndex[$s];
|
$i = $this->strIndex[$s];
|
||||||
|
$this->strCount[$i] = ($this->strCount[$i] ?? 0) + 1;
|
||||||
|
return $i;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function sheetXml(array $cells, int $maxRow, int $maxCol, array $widths = []): string
|
private function sheetXml(array $cells, int $maxRow, int $maxCol, array $widths = []): string
|
||||||
@@ -287,14 +292,15 @@ final class XlsxWriter
|
|||||||
|
|
||||||
private function sharedStringsXml(): string
|
private function sharedStringsXml(): string
|
||||||
{
|
{
|
||||||
$count = count($this->strings);
|
$unique = count($this->strings);
|
||||||
|
$total = array_sum($this->strCount);
|
||||||
$body = '';
|
$body = '';
|
||||||
foreach ($this->strings as $s) {
|
foreach ($this->strings as $s) {
|
||||||
$body .= '<si><t xml:space="preserve">' . self::xml((string)$s) . '</t></si>';
|
$body .= '<si><t xml:space="preserve">' . self::xml((string)$s) . '</t></si>';
|
||||||
}
|
}
|
||||||
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' . "\n" .
|
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' . "\n" .
|
||||||
'<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="' . $count .
|
'<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="' . $total .
|
||||||
'" uniqueCount="' . $count . '">' . $body . '</sst>';
|
'" uniqueCount="' . $unique . '">' . $body . '</sst>';
|
||||||
}
|
}
|
||||||
|
|
||||||
private function coreXml(string $title): string
|
private function coreXml(string $title): string
|
||||||
|
|||||||
+1
-1
@@ -30,7 +30,7 @@ echo "================ Node e2e(需 mock-server + PHP 内置服务器) =====
|
|||||||
# 彻底避免污染生产 data/ny.db(曾因此把登录页连接信息写成 127.0.0.1)。
|
# 彻底避免污染生产 data/ny.db(曾因此把登录页连接信息写成 127.0.0.1)。
|
||||||
NY_DATA_TMP="$(pwd)/.ny-test-data"
|
NY_DATA_TMP="$(pwd)/.ny-test-data"
|
||||||
mkdir -p "$NY_DATA_TMP"
|
mkdir -p "$NY_DATA_TMP"
|
||||||
for f in tools/test-config-save.js tools/test-upload-guard.js; do
|
for f in tools/test-config-save.js tools/test-upload-guard.js tools/test-whitelist.js; do
|
||||||
echo ""
|
echo ""
|
||||||
echo ">>> $f (NY_DATA=$NY_DATA_TMP)"
|
echo ">>> $f (NY_DATA=$NY_DATA_TMP)"
|
||||||
NY_DATA="$NY_DATA_TMP" "$NODE" "$f"
|
NY_DATA="$NY_DATA_TMP" "$NODE" "$f"
|
||||||
|
|||||||
@@ -43,6 +43,10 @@ answers.push({
|
|||||||
|
|
||||||
const SURVEY = {
|
const SURVEY = {
|
||||||
id: 'root', title: '高一数学月考', type: 'survey',
|
id: 'root', title: '高一数学月考', type: 'survey',
|
||||||
|
surveySetting: {
|
||||||
|
// 答题白名单:待考名单来源(键为题目 ID,值为允许的答案列表;这里用学号作标识)
|
||||||
|
whiteLists: { q1: ['90001', '90002', '90003', '90004', '90005'] },
|
||||||
|
},
|
||||||
children: [
|
children: [
|
||||||
{ id: 'pg1', type: 'pagination', title: '基本信息', children: [
|
{ id: 'pg1', type: 'pagination', title: '基本信息', children: [
|
||||||
{ id: 'q1', type: 'text', title: '<p>姓名</p>' },
|
{ id: 'q1', type: 'text', title: '<p>姓名</p>' },
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
// e2e: 卷内「答题白名单」自动驱动缺/待考名单(DataService::build)
|
||||||
|
// 运行:node tools/test-whitelist.js (NY_DEBUG=1 可打印调试信息)
|
||||||
|
//
|
||||||
|
// 覆盖:
|
||||||
|
// 1) p1 设置了卷内白名单 → 应考名单恰好为白名单成员,缺考仅来自白名单(不含白名单外成员)
|
||||||
|
// 2) p2 未设置白名单 → 按现状(全名单)显示,白名单外成员仍在应考/缺考中
|
||||||
|
// 3) 已移除的 roster_import_whitelist 接口不再可用(按钮已删除,改为自动驱动)
|
||||||
|
// 4) 上传防护不被破坏(导入后 data/uploads 即清)
|
||||||
|
const http = require('http');
|
||||||
|
const net = require('net');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { spawn } = require('child_process');
|
||||||
|
|
||||||
|
const ROOT = path.resolve(__dirname, '..');
|
||||||
|
const PHP_PORT = 18082;
|
||||||
|
const MOCK_PORT = 19911;
|
||||||
|
const PHP = process.env.PHP_BIN || 'C:/Users/Zhang/.workbuddy/binaries/php/php.exe';
|
||||||
|
const INI = process.env.PHP_INI || '-d extension_dir=C:/Users/Zhang/.workbuddy/binaries/php/ext -d extension=pdo_sqlite';
|
||||||
|
const DEBUG = !!process.env.NY_DEBUG;
|
||||||
|
|
||||||
|
function resolveDataDir() {
|
||||||
|
let p = process.env.NY_DATA || path.join(ROOT, 'data');
|
||||||
|
if (process.platform === 'win32' && /^\/[a-zA-Z]\//.test(p)) p = p[1].toUpperCase() + p.slice(2);
|
||||||
|
p = path.resolve(p);
|
||||||
|
process.env.NY_DATA = p;
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
const DATA = resolveDataDir();
|
||||||
|
fs.mkdirSync(DATA, { recursive: true });
|
||||||
|
const UPLOADS = path.join(DATA, 'uploads');
|
||||||
|
|
||||||
|
const moved = [], copied = [];
|
||||||
|
for (const f of ['ny.db', 'cache/token.json']) {
|
||||||
|
const p = path.join(DATA, f);
|
||||||
|
if (fs.existsSync(p)) {
|
||||||
|
const bak = p + '.nybak';
|
||||||
|
try { fs.renameSync(p, bak); moved.push([p, bak]); } catch (e) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const f of ['config.json', 'roster.json', 'project_config.json']) {
|
||||||
|
const p = path.join(DATA, f);
|
||||||
|
if (fs.existsSync(p)) {
|
||||||
|
const bak = p + '.nybak';
|
||||||
|
try { fs.copyFileSync(p, bak); copied.push([p, bak]); } catch (e) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const cfgFile = path.join(DATA, 'config.json');
|
||||||
|
fs.writeFileSync(cfgFile, JSON.stringify({
|
||||||
|
base_url: 'http://127.0.0.1:' + MOCK_PORT,
|
||||||
|
api_prefix: '/api',
|
||||||
|
username: 'mock',
|
||||||
|
password: 'surveyking',
|
||||||
|
verify_ssl: false,
|
||||||
|
timeout: 10,
|
||||||
|
refresh_interval: 15,
|
||||||
|
page_size: 50,
|
||||||
|
include_temp_save: false,
|
||||||
|
dedupe: true,
|
||||||
|
name_keywords: '姓名,名字',
|
||||||
|
full_score: 100,
|
||||||
|
pass_rate: 60,
|
||||||
|
layout: 'default',
|
||||||
|
}, null, 2));
|
||||||
|
|
||||||
|
let mock = null, srv = null, cleanedUp = false;
|
||||||
|
function killAll() { for (const p of [srv, mock]) { try { p && p.kill('SIGKILL'); } catch (e) {} } }
|
||||||
|
function cleanup() {
|
||||||
|
if (cleanedUp) return;
|
||||||
|
cleanedUp = true;
|
||||||
|
killAll();
|
||||||
|
for (const f of ['ny.db', 'ny.db-wal', 'ny.db-shm', 'config.json', 'cache/token.json']) {
|
||||||
|
const p = path.join(DATA, f);
|
||||||
|
try { if (fs.existsSync(p)) fs.unlinkSync(p); } catch (e) {}
|
||||||
|
}
|
||||||
|
for (const [p, bak] of moved.concat(copied)) {
|
||||||
|
try { if (fs.existsSync(bak)) fs.copyFileSync(bak, p); } catch (e) {}
|
||||||
|
try { if (fs.existsSync(bak)) fs.unlinkSync(bak); } catch (e) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
process.on('exit', cleanup);
|
||||||
|
process.on('SIGINT', () => { cleanup(); process.exit(1); });
|
||||||
|
process.on('uncaughtException', (e) => { console.error('FATAL', e); cleanup(); process.exit(2); });
|
||||||
|
|
||||||
|
mock = spawn('node', ['tools/mock-server.js'], { cwd: ROOT, stdio: DEBUG ? 'inherit' : 'ignore' });
|
||||||
|
const phpArgs = INI.split(/\s+/).filter(Boolean).concat(['-S', '127.0.0.1:' + PHP_PORT, '-t', ROOT]);
|
||||||
|
srv = spawn(PHP, phpArgs, { cwd: ROOT, stdio: DEBUG ? 'inherit' : 'ignore' });
|
||||||
|
|
||||||
|
function waitPort(port, timeoutMs) {
|
||||||
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
(function attempt() {
|
||||||
|
const s = net.connect({ host: '127.0.0.1', port });
|
||||||
|
const done = (ok) => { s.destroy(); resolve(ok); };
|
||||||
|
s.setTimeout(800, () => done(false));
|
||||||
|
s.on('connect', () => done(true));
|
||||||
|
s.on('error', () => { if (Date.now() > deadline) return done(false); setTimeout(attempt, 300); });
|
||||||
|
s.on('timeout', () => { if (Date.now() > deadline) return done(false); setTimeout(attempt, 300); });
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const cookieJar = [];
|
||||||
|
let CSRF = '';
|
||||||
|
function baseHeaders() {
|
||||||
|
const h = {};
|
||||||
|
if (cookieJar.length) h['Cookie'] = cookieJar.map(c => c.replace(/;.*$/, '')).join('; ');
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
function absorb(res) {
|
||||||
|
for (const sc of res.headers['set-cookie'] || []) {
|
||||||
|
const pair = sc.split(';')[0];
|
||||||
|
const name = pair.split('=')[0];
|
||||||
|
const i = cookieJar.findIndex(x => x.startsWith(name + '='));
|
||||||
|
if (i >= 0) cookieJar[i] = pair; else cookieJar.push(pair);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function request(method, urlPath, headers, bodyBuf) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const h = Object.assign({}, headers || {});
|
||||||
|
if (bodyBuf) h['Content-Length'] = bodyBuf.length;
|
||||||
|
const r = http.request({ host: '127.0.0.1', port: PHP_PORT, method, path: urlPath, headers: h }, res => {
|
||||||
|
const chunks = [];
|
||||||
|
res.on('data', c => chunks.push(c));
|
||||||
|
res.on('end', () => { absorb(res); const raw = Buffer.concat(chunks).toString('utf8'); let j = null; try { j = JSON.parse(raw); } catch (e) {} resolve({ status: res.statusCode, body: j, raw }); });
|
||||||
|
});
|
||||||
|
r.on('error', reject);
|
||||||
|
if (bodyBuf) r.write(bodyBuf);
|
||||||
|
r.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const get = (p) => request('GET', p, baseHeaders());
|
||||||
|
function formPost(p, fields) {
|
||||||
|
const body = Buffer.from(new URLSearchParams(fields).toString());
|
||||||
|
return request('POST', p, Object.assign({ 'Content-Type': 'application/x-www-form-urlencoded' }, baseHeaders()), body);
|
||||||
|
}
|
||||||
|
function upload(urlPath, fields, file) {
|
||||||
|
const boundary = '----nyUpload' + Math.random().toString(16).slice(2);
|
||||||
|
const parts = [];
|
||||||
|
for (const [k, v] of Object.entries(fields)) parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="${k}"\r\n\r\n${v}\r\n`));
|
||||||
|
parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${file.filename}"\r\nContent-Type: application/octet-stream\r\n\r\n`));
|
||||||
|
parts.push(Buffer.isBuffer(file.content) ? file.content : Buffer.from(file.content));
|
||||||
|
parts.push(Buffer.from(`\r\n--${boundary}--\r\n`));
|
||||||
|
return request('POST', urlPath, Object.assign({ 'Content-Type': 'multipart/form-data; boundary=' + boundary }, baseHeaders()), Buffer.concat(parts));
|
||||||
|
}
|
||||||
|
|
||||||
|
let pass = 0, fail = 0;
|
||||||
|
function chk(name, cond, extra) {
|
||||||
|
if (cond) { pass++; console.log(' PASS ' + name + (extra ? ' — ' + extra : '')); }
|
||||||
|
else { fail++; console.log(' FAIL ' + name + (extra ? ' — ' + extra : '')); }
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const mockUp = await waitPort(MOCK_PORT, 15000);
|
||||||
|
const phpUp = await waitPort(PHP_PORT, 15000);
|
||||||
|
chk('mock 卷王服务已就绪', mockUp);
|
||||||
|
chk('PHP 内置服务器已就绪', phpUp);
|
||||||
|
if (!mockUp || !phpUp) { console.log(' 服务未就绪,终止测试'); cleanup(); process.exit(1); }
|
||||||
|
|
||||||
|
for (let i = 0; i < 3 && CSRF === ''; i++) {
|
||||||
|
await formPost('/login.php', { username: 'mock', password: 'surveyking' });
|
||||||
|
const ac = await get('/api.php?action=auth_check');
|
||||||
|
if (ac.body && ac.body.data && ac.body.data.auth === true) {
|
||||||
|
const cg = await get('/api.php?action=config_get');
|
||||||
|
CSRF = (cg.body && cg.body.data && cg.body.data.csrf_token) || '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
chk('已登录并取到 CSRF token', CSRF !== '');
|
||||||
|
if (CSRF === '') { console.log(' 登录失败,终止测试'); cleanup(); process.exit(1); }
|
||||||
|
|
||||||
|
console.log('\n--- 1) 先导入一份名单(含白名单内 90001-90005 + 一个白名单外 90099) ---');
|
||||||
|
const csv = '学号,姓名,班级\n90001,甲,1班\n90002,乙,1班\n90003,丙,1班\n90004,丁,1班\n90005,戊,1班\n90099,外,1班\n';
|
||||||
|
const imp = await upload('/api.php?action=roster_import', { mode: 'replace', _csrf: CSRF }, {
|
||||||
|
filename: 'roster.csv', content: Buffer.from('\xEF\xBB\xBF' + csv, 'utf8'),
|
||||||
|
});
|
||||||
|
chk('名单导入成功', !!(imp.body && imp.body.ok === true), (imp.body && (imp.body.error || imp.body.msg)) || '');
|
||||||
|
chk('导入人数为 6', !!(imp.body && imp.body.data && imp.body.data.imported === 6), imp.body ? String(imp.body.data && imp.body.data.imported) : '');
|
||||||
|
|
||||||
|
console.log('\n--- 2) p1 卷内白名单驱动应考名单 ---');
|
||||||
|
const d1 = await get('/api.php?action=data&projectId=p1');
|
||||||
|
chk('data(p1) 成功', !!(d1.body && d1.body.ok === true), (d1.body && (d1.body.error || d1.body.msg)) || d1.raw.slice(0, 120));
|
||||||
|
const d1d = (d1.body && d1.body.data) || {};
|
||||||
|
chk('p1 标记为白名单驱动', d1d.whitelist_driven === true, 'whitelist_driven=' + d1d.whitelist_driven);
|
||||||
|
chk('p1 白名单应考 5 人', d1d.whitelist_total === 5, 'whitelist_total=' + d1d.whitelist_total);
|
||||||
|
const r1 = (d1d.roster || []).map(s => String(s.sid)).sort();
|
||||||
|
chk('p1 应考名单恰好 90001-90005', JSON.stringify(r1) === JSON.stringify(['90001', '90002', '90003', '90004', '90005']), JSON.stringify(r1));
|
||||||
|
const a1 = (d1d.absent || []).map(s => String(s.sid)).sort();
|
||||||
|
chk('p1 缺考仅来自白名单(不含 90099)', !a1.includes('90099'), JSON.stringify(a1));
|
||||||
|
chk('p1 缺考是白名单子集', a1.every(s => r1.includes(s)), JSON.stringify(a1));
|
||||||
|
|
||||||
|
console.log('\n--- 3) p2 未设置白名单 → 按现状(全名单)---');
|
||||||
|
const d2 = await get('/api.php?action=data&projectId=p2');
|
||||||
|
chk('data(p2) 成功', !!(d2.body && d2.body.ok === true), (d2.body && (d2.body.error || d2.body.msg)) || '');
|
||||||
|
const d2d = (d2.body && d2.body.data) || {};
|
||||||
|
chk('p2 非白名单驱动', d2d.whitelist_driven === false, 'whitelist_driven=' + d2d.whitelist_driven);
|
||||||
|
const r2 = (d2d.roster || []).map(s => String(s.sid)).sort();
|
||||||
|
chk('p2 含白名单外成员 90099', r2.includes('90099'), JSON.stringify(r2));
|
||||||
|
|
||||||
|
console.log('\n--- 4) 已移除的 roster_import_whitelist 接口不再可用 ---');
|
||||||
|
const removed = await formPost('/api.php?action=roster_import_whitelist', { projectId: 'p1', mode: 'replace', _csrf: CSRF });
|
||||||
|
chk('roster_import_whitelist 已停用', !!(removed.body && removed.body.ok === false), (removed.body && (removed.body.error || removed.body.msg)) || '');
|
||||||
|
|
||||||
|
console.log('\n--- 5) 上传防护仍有效(导入后 data/uploads 即清) ---');
|
||||||
|
const left = fs.existsSync(UPLOADS) ? fs.readdirSync(UPLOADS).filter(f => f !== '.htaccess' && f !== 'index.html') : [];
|
||||||
|
chk('data/uploads 无残留', left.length === 0, JSON.stringify(left));
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
console.log(`结果:${pass} 通过 / ${fail} 失败(共 ${pass + fail})`);
|
||||||
|
cleanup();
|
||||||
|
process.exit(fail === 0 ? 0 : 1);
|
||||||
|
})();
|
||||||
Reference in New Issue
Block a user