完成批次 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:
2026-09-13 10:25:48 +08:00
parent 804b04de60
commit 94ac381579
17 changed files with 856 additions and 301 deletions
+85 -19
View File
@@ -87,6 +87,8 @@ function ny_build_data(array $c, string $projectId, string $nameQid, bool $force
(string)($c['name_keywords'] ?? ''),
(int)($c['page_size'] ?? 100),
Roster::fingerprint(),
// 本卷独立配置(免考名单/满分/合格率)变化也应让缓存失效,否则改免考后可能短暂看到旧数据
json_encode(DataService::projectConfig($projectId) ?? []),
]);
if ($ttl > 0 && !$force) {
@@ -110,11 +112,16 @@ function ny_build_data(array $c, string $projectId, string $nameQid, bool $force
// 等不到(构建很慢或锁不被支持)就自己构建,保证可用性
}
$result = DataService::build($c, $projectId, $nameQid);
if ($ttl > 0) {
DataCache::write($key, $result);
// try/finally:无论 build 是否抛异常都释放构建锁,避免异常时锁句柄挂到进程退出造成惊群
$result = null;
try {
$result = DataService::build($c, $projectId, $nameQid);
if ($ttl > 0) {
DataCache::write($key, $result);
}
} finally {
DataCache::releaseLock($lock);
}
DataCache::releaseLock($lock);
return ['result' => $result, 'cached' => false, 'at' => time()];
}
@@ -152,11 +159,13 @@ try {
$c['db_driver'] = class_exists('Db') ? Db::driver() : '';
$c['db_configured'] = class_exists('Db') && Db::isConfigured();
$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);
case 'config_save':
$raw = (string)file_get_contents('php://input');
$raw = ny_request_body();
$in = json_decode($raw, true);
if (!is_array($in)) {
$in = $_POST;
@@ -250,7 +259,7 @@ try {
// ---------------- 用弹窗内(未保存)的凭据测试连接 ----------------
case 'config_test':
$raw = (string)file_get_contents('php://input');
$raw = ny_request_body();
$in = json_decode($raw, true);
if (!is_array($in)) {
$in = $_POST;
@@ -502,12 +511,49 @@ try {
}
// 解析完立即删除:Web 目录下不再留存任何上传文件
@unlink($dest);
$mode = (string)($_POST['mode'] ?? 'replace');
// 默认追加:与「导入名单默认为追加」一致(学号名单管理导入)
$mode = (string)($_POST['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 {
Roster::save($res['students']);
if (!Roster::save($res['students'])) {
ny_fail('名单导入失败:' . (class_exists('Db') ? Db::lastError() : '未知错误'));
}
}
ny_ok([
'students' => Roster::all(),
@@ -517,8 +563,13 @@ try {
'duplicates' => $res['duplicates'] ?? 0,
]);
// ---------------- 从卷王白名单导入待考名单(已移除)----------------
// 现改为:DataService::build 自动读取每套试卷卷内设置的「答题白名单」,
// 白名单成员即「应考名单」,未设置时回退到全名单(按现在的方案显示)。
// 名单管理里的「⬇ 从白名单导入」按钮已删除。
case 'roster_save':
$raw = (string)file_get_contents('php://input');
$raw = ny_request_body();
$in = json_decode($raw, true);
if (!is_array($in) || !isset($in['students']) || !is_array($in['students'])) {
ny_fail('参数错误');
@@ -536,7 +587,9 @@ try {
'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()]);
case 'roster_clear':
@@ -545,7 +598,7 @@ try {
// ---------------- 未匹配答卷 → 加入名单 ----------------
case 'unmatched_map':
$raw = (string)file_get_contents('php://input');
$raw = ny_request_body();
$in = json_decode($raw, true);
if (!is_array($in)) {
$in = $_POST;
@@ -575,7 +628,9 @@ try {
if (!$found) {
$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]);
// ---------------- 试卷独立配置(满分 / 合格率 / 免考名单)——仅对当前试卷生效 ----------------
@@ -603,7 +658,7 @@ try {
if ($projectId === '') {
ny_fail('请先选择问卷/考试');
}
$raw = (string)file_get_contents('php://input');
$raw = ny_request_body();
$in = json_decode($raw, true);
if (!is_array($in)) {
$in = $_POST;
@@ -741,11 +796,22 @@ try {
// ---------------- 数据行 ----------------
$sortMode = (string)($_GET['sort'] ?? '');
if ($isLatest) {
// 每人一行:成绩取「最近一次提交」的分数(不受 dedupe 影响),并附最高分与考试次数
// 每人一行:成绩取「最近一次真实提交」的分数(不受 dedupe 影响),并附最高分与考试次数。
// 注意:include_temp_save 开启时 attempts_list 可能含暂存草稿,取最新成绩应跳过 temp_save,
// 否则导出的"最新成绩"可能是一份草稿分。
$all = [];
foreach ($rows as $r) {
$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[] = [
'sid' => (string)($r['sid'] ?? ''),
'name' => (string)($r['name'] ?? ''),
@@ -846,7 +912,7 @@ try {
// ---------------- 随机抽取:导出白名单(xlsx,列:姓名 / 登陆账号) ----------------
case 'draw_export':
$raw = (string)file_get_contents('php://input');
$raw = ny_request_body();
$in = json_decode($raw, true);
if (!is_array($in)) {
$in = $_POST + $_GET;