feat: 成绩明细打印 + SQLite + 每卷独立配置(满分/合格率/免考) + 导航精简
- 成绩明细打印:默认成绩降序,可选按班级/组别分组对比(段首小计) - 修复看板退出全屏无反应(exitFullscreen 挂在 document 而非 documentElement) - 导航栏精简:三页统一为单一主标题,移除 Logo 与副标题 - 优化三端布局:重排断点、竖排隐藏 spacer、触摸设备热区 - 全面启用 SQLite(data/ny.db):配置/名单/每卷配置入库,无 pdo_sqlite 自动降级文件 - 新增独立合格率配置(全局 + 每卷),合格率不再写死 60% - 新增免考配置,仅对当前试卷生效,试卷间隔离 - 试卷独立满分从设置移出,改到主页面「试卷设置」 - 测试:新增 4 条每卷配置隔离断言,全套 113 项通过
This commit is contained in:
@@ -128,7 +128,7 @@ if ($action !== 'auth_check' && !ny_is_auth()) {
|
|||||||
// CSRF 防护:所有写操作必须携带会话绑定的 token(GET 读取类接口不受影响)
|
// CSRF 防护:所有写操作必须携带会话绑定的 token(GET 读取类接口不受影响)
|
||||||
$WRITE_ACTIONS = [
|
$WRITE_ACTIONS = [
|
||||||
'config_save', 'roster_save', 'roster_import', 'roster_clear',
|
'config_save', 'roster_save', 'roster_import', 'roster_clear',
|
||||||
'snapshot_save', 'snapshot_delete', 'unmatched_map',
|
'snapshot_save', 'snapshot_delete', 'unmatched_map', 'project_config_save',
|
||||||
];
|
];
|
||||||
if (in_array($action, $WRITE_ACTIONS, true) && !ny_csrf_ok()) {
|
if (in_array($action, $WRITE_ACTIONS, true) && !ny_csrf_ok()) {
|
||||||
ny_json(['ok' => false, 'msg' => '安全校验失败(CSRF token 缺失或不匹配),请刷新页面后重试'], 403);
|
ny_json(['ok' => false, 'msg' => '安全校验失败(CSRF token 缺失或不匹配),请刷新页面后重试'], 403);
|
||||||
@@ -194,9 +194,11 @@ try {
|
|||||||
$v = (string)$in['export_sort'];
|
$v = (string)$in['export_sort'];
|
||||||
$new['export_sort'] = in_array($v, ['time','sid','score_desc','score_asc'], true) ? $v : 'time';
|
$new['export_sort'] = in_array($v, ['time','sid','score_desc','score_asc'], true) ? $v : 'time';
|
||||||
}
|
}
|
||||||
// 各试卷独立满分(覆盖整个 full_scores 映射)
|
// 说明:「各试卷独立满分」已移出设置,改为在主页面针对当前试卷配置
|
||||||
if (array_key_exists('full_scores', $in) && is_array($in['full_scores'])) {
|
// (project_config / project_config_save),设置里只保留「全局默认满分」。
|
||||||
$new['full_scores'] = $in['full_scores'];
|
if (array_key_exists('pass_rate', $in)) {
|
||||||
|
$pr = (float)$in['pass_rate'];
|
||||||
|
$new['pass_rate'] = ($pr >= 1 && $pr <= 100) ? $pr : 60;
|
||||||
}
|
}
|
||||||
$new['base_url'] = rtrim((string)$new['base_url'], '/');
|
$new['base_url'] = rtrim((string)$new['base_url'], '/');
|
||||||
$new['api_prefix'] = '/' . trim((string)$new['api_prefix'], '/');
|
$new['api_prefix'] = '/' . trim((string)$new['api_prefix'], '/');
|
||||||
@@ -211,18 +213,6 @@ try {
|
|||||||
$fs = 100;
|
$fs = 100;
|
||||||
}
|
}
|
||||||
$new['full_score'] = $fs;
|
$new['full_score'] = $fs;
|
||||||
// 各试卷独立满分:仅当显式指定 apply_pid 时才写入/清除该卷(修复 bug:之前会误用默认满分覆盖当前卷)
|
|
||||||
$applyPid = trim((string)($in['apply_pid'] ?? ''));
|
|
||||||
if ($applyPid !== '') {
|
|
||||||
$scores = is_array($new['full_scores'] ?? null) ? $new['full_scores'] : [];
|
|
||||||
$applyScore = (int)($in['apply_score'] ?? 0);
|
|
||||||
if ($applyScore > 0) {
|
|
||||||
$scores[$applyPid] = $applyScore;
|
|
||||||
} else {
|
|
||||||
unset($scores[$applyPid]);
|
|
||||||
}
|
|
||||||
$new['full_scores'] = $scores;
|
|
||||||
}
|
|
||||||
// 仅当连接信息真的变了才清 token + 重连;保存满分/布局等不应触发登录卷王
|
// 仅当连接信息真的变了才清 token + 重连;保存满分/布局等不应触发登录卷王
|
||||||
$connKeys = ['base_url', 'api_prefix', 'username', 'password', 'verify_ssl'];
|
$connKeys = ['base_url', 'api_prefix', 'username', 'password', 'verify_ssl'];
|
||||||
$connChanged = false;
|
$connChanged = false;
|
||||||
@@ -329,15 +319,13 @@ try {
|
|||||||
$c = cfg();
|
$c = cfg();
|
||||||
$built = ny_build_data($c, $projectId, $nameQid, !empty($_GET['force']));
|
$built = ny_build_data($c, $projectId, $nameQid, !empty($_GET['force']));
|
||||||
$result = $built['result'];
|
$result = $built['result'];
|
||||||
$fsMap = is_array($c['full_scores'] ?? null) ? $c['full_scores'] : [];
|
// 满分:本卷独立优先,其次全局默认
|
||||||
$fs = isset($fsMap[$projectId]) ? (int)$fsMap[$projectId] : (int)($c['full_score'] ?? 100);
|
[$fs, $passRate] = ny_resolve_score_cfg($projectId, $c);
|
||||||
if ($fs < 1) {
|
|
||||||
$fs = 100;
|
|
||||||
}
|
|
||||||
$result['config'] = [
|
$result['config'] = [
|
||||||
'refresh_interval' => (int)$c['refresh_interval'],
|
'refresh_interval' => (int)$c['refresh_interval'],
|
||||||
'base_url' => $c['base_url'],
|
'base_url' => $c['base_url'],
|
||||||
'full_score' => $fs,
|
'full_score' => $fs,
|
||||||
|
'pass_rate' => $passRate,
|
||||||
];
|
];
|
||||||
// 缓存来源标记:便于前端/排查时知道这次是实时拉取还是命中短期缓存
|
// 缓存来源标记:便于前端/排查时知道这次是实时拉取还是命中短期缓存
|
||||||
$result['_cache'] = ['cached' => $built['cached'], 'at' => $built['at']];
|
$result['_cache'] = ['cached' => $built['cached'], 'at' => $built['at']];
|
||||||
@@ -353,11 +341,7 @@ try {
|
|||||||
$c = cfg();
|
$c = cfg();
|
||||||
$built = ny_build_data($c, $projectId, $nameQid, !empty($_GET['force']));
|
$built = ny_build_data($c, $projectId, $nameQid, !empty($_GET['force']));
|
||||||
$result = $built['result'];
|
$result = $built['result'];
|
||||||
$fsMap = is_array($c['full_scores'] ?? null) ? $c['full_scores'] : [];
|
[$fs, $passRate] = ny_resolve_score_cfg($projectId, $c);
|
||||||
$fs = isset($fsMap[$projectId]) ? (int)$fsMap[$projectId] : (int)($c['full_score'] ?? 100);
|
|
||||||
if ($fs < 1) {
|
|
||||||
$fs = 100;
|
|
||||||
}
|
|
||||||
|
|
||||||
$students = $result['roster'] ?: [];
|
$students = $result['roster'] ?: [];
|
||||||
$rows = $result['rows'] ?: [];
|
$rows = $result['rows'] ?: [];
|
||||||
@@ -374,13 +358,14 @@ try {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 通用统计器:给定学生集合 + 答卷集合,算一组指标
|
// 通用统计器:给定学生集合 + 答卷集合,算一组指标
|
||||||
$statOf = static function (array $stu, array $rs, int $fs): array {
|
// 合格率阈值可配(全局 pass_rate 或本卷独立),不再写死 60%
|
||||||
|
$statOf = static function (array $stu, array $rs, int $fs) use ($passRate): array {
|
||||||
$total = count($stu);
|
$total = count($stu);
|
||||||
$submitted = count($rs);
|
$submitted = count($rs);
|
||||||
$attempts = array_sum(array_map(static fn($r) => (int)($r['attempts'] ?? 1), $rs));
|
$attempts = array_sum(array_map(static fn($r) => (int)($r['attempts'] ?? 1), $rs));
|
||||||
$scores = array_values(array_filter(array_map(static fn($r) => $r['score'] ?? null, $rs), static fn($v) => $v !== null));
|
$scores = array_values(array_filter(array_map(static fn($r) => $r['score'] ?? null, $rs), static fn($v) => $v !== null));
|
||||||
$avg = $scores !== [] ? round(array_sum($scores) / count($scores), 2) : null;
|
$avg = $scores !== [] ? round(array_sum($scores) / count($scores), 2) : null;
|
||||||
$pass = count(array_filter($rs, static fn($r) => ($r['score'] ?? null) !== null && ($r['score'] >= 0.6 * $fs)));
|
$pass = count(array_filter($rs, static fn($r) => ($r['score'] ?? null) !== null && ($r['score'] >= ($passRate / 100) * $fs)));
|
||||||
$passRate = $submitted > 0 ? round($pass / $submitted * 100, 1) : 0;
|
$passRate = $submitted > 0 ? round($pass / $submitted * 100, 1) : 0;
|
||||||
$durs = array_values(array_filter(array_map(static fn($r) => $r['duration'] ?? null, $rs), static fn($v) => $v !== null));
|
$durs = array_values(array_filter(array_map(static fn($r) => $r['duration'] ?? null, $rs), static fn($v) => $v !== null));
|
||||||
$avgDur = $durs !== [] ? (int)round(array_sum($durs) / count($durs)) : null;
|
$avgDur = $durs !== [] ? (int)round(array_sum($durs) / count($durs)) : null;
|
||||||
@@ -473,6 +458,7 @@ try {
|
|||||||
ny_ok([
|
ny_ok([
|
||||||
'project' => $result['project'],
|
'project' => $result['project'],
|
||||||
'full_score' => $fs,
|
'full_score' => $fs,
|
||||||
|
'pass_rate' => $passRate,
|
||||||
'overall' => $result['stats'],
|
'overall' => $result['stats'],
|
||||||
'classes' => $classes,
|
'classes' => $classes,
|
||||||
'updated_at' => $result['updated_at'],
|
'updated_at' => $result['updated_at'],
|
||||||
@@ -632,6 +618,74 @@ try {
|
|||||||
}
|
}
|
||||||
ny_ok(Snapshot::trend($snaps));
|
ny_ok(Snapshot::trend($snaps));
|
||||||
|
|
||||||
|
// ---------------- 试卷独立配置(满分 / 合格率 / 免考名单)——仅对当前试卷生效 ----------------
|
||||||
|
case 'project_config':
|
||||||
|
$projectId = (string)($_GET['projectId'] ?? '');
|
||||||
|
if ($projectId === '') {
|
||||||
|
ny_fail('请先选择问卷/考试');
|
||||||
|
}
|
||||||
|
$c = cfg();
|
||||||
|
[$fs, $pr] = ny_resolve_score_cfg($projectId, $c);
|
||||||
|
$pc = DataService::projectConfig($projectId);
|
||||||
|
ny_ok([
|
||||||
|
'project_id' => $projectId,
|
||||||
|
'cfg' => $pc,
|
||||||
|
// 解析后的生效值(前端回显用;null 表示"跟随全局")
|
||||||
|
'effective' => ['full_score' => $fs, 'pass_rate' => $pr],
|
||||||
|
'global' => [
|
||||||
|
'full_score' => (int)($c['full_score'] ?? 100),
|
||||||
|
'pass_rate' => (float)($c['pass_rate'] ?? 60),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
case 'project_config_save':
|
||||||
|
$projectId = (string)($_GET['projectId'] ?? $_POST['projectId'] ?? '');
|
||||||
|
if ($projectId === '') {
|
||||||
|
ny_fail('请先选择问卷/考试');
|
||||||
|
}
|
||||||
|
$raw = (string)file_get_contents('php://input');
|
||||||
|
$in = json_decode($raw, true);
|
||||||
|
if (!is_array($in)) {
|
||||||
|
$in = $_POST;
|
||||||
|
}
|
||||||
|
$old = DataService::projectConfig($projectId);
|
||||||
|
// 满分:留空/0 = 清除(跟随全局)
|
||||||
|
$fsIn = $in['full_score'] ?? null;
|
||||||
|
$fs = ($fsIn === null || $fsIn === '' || (int)$fsIn < 1) ? null : (int)$fsIn;
|
||||||
|
// 合格率:留空 = 清除(跟随全局);否则夹到 1~100
|
||||||
|
$prIn = $in['pass_rate'] ?? null;
|
||||||
|
$pr = ($prIn === null || $prIn === '') ? null : max(1, min(100, (float)$prIn));
|
||||||
|
// 免考名单:支持数组或逗号/换行分隔的字符串
|
||||||
|
$exIn = $in['exempt'] ?? null;
|
||||||
|
$exempt = [];
|
||||||
|
if (is_array($exIn)) {
|
||||||
|
foreach ($exIn as $v) {
|
||||||
|
$v = trim((string)$v);
|
||||||
|
if ($v !== '') {
|
||||||
|
$exempt[] = $v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} elseif (is_string($exIn) && trim($exIn) !== '') {
|
||||||
|
foreach (preg_split('/[,,\s]+/u', trim($exIn)) ?: [] as $v) {
|
||||||
|
$v = trim($v);
|
||||||
|
if ($v !== '') {
|
||||||
|
$exempt[] = $v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$new = [
|
||||||
|
'full_score' => $fs,
|
||||||
|
'pass_rate' => $pr,
|
||||||
|
'exempt' => array_values(array_unique($exempt)),
|
||||||
|
];
|
||||||
|
DataService::projectConfigSave($projectId, $new);
|
||||||
|
$c2 = cfg();
|
||||||
|
[$effFs, $effPr] = ny_resolve_score_cfg($projectId, $c2);
|
||||||
|
ny_ok([
|
||||||
|
'cfg' => $new,
|
||||||
|
'effective' => ['full_score' => $effFs, 'pass_rate' => $effPr],
|
||||||
|
]);
|
||||||
|
|
||||||
// ---------------- 导出(支持按班级 / 组别筛选,与页面当前筛选保持一致) ----------------
|
// ---------------- 导出(支持按班级 / 组别筛选,与页面当前筛选保持一致) ----------------
|
||||||
case 'export':
|
case 'export':
|
||||||
case 'export_absent':
|
case 'export_absent':
|
||||||
|
|||||||
+185
-48
@@ -261,6 +261,7 @@
|
|||||||
state.roster = d.roster || [];
|
state.roster = d.roster || [];
|
||||||
state.anomalies = d.anomalies || { fast_high: [], frequent: [], identical: [] };
|
state.anomalies = d.anomalies || { fast_high: [], frequent: [], identical: [] };
|
||||||
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;
|
||||||
|
|
||||||
// 变更检测:学号/成绩/提交时间/考试次数/最近提交时间 任一变化即视为"有新数据"
|
// 变更检测:学号/成绩/提交时间/考试次数/最近提交时间 任一变化即视为"有新数据"
|
||||||
// (纳入 attempts 与 last_submit_ts 才能发现"重考但分数更低"这种提交)
|
// (纳入 attempts 与 last_submit_ts 才能发现"重考但分数更低"这种提交)
|
||||||
@@ -456,7 +457,8 @@
|
|||||||
const avg = scores.length ? Math.round(scores.reduce((a, b) => a + b, 0) / scores.length * 100) / 100 : null;
|
const avg = scores.length ? Math.round(scores.reduce((a, b) => a + b, 0) / scores.length * 100) / 100 : null;
|
||||||
|
|
||||||
const full = state.fullScore || 100;
|
const full = state.fullScore || 100;
|
||||||
const passCount = rows.filter(r => r.score !== null && r.score !== undefined && r.score >= 0.6 * full).length;
|
const pr = (state.passRate == null ? 60 : state.passRate); // 合格率阈值可配
|
||||||
|
const passCount = rows.filter(r => r.score !== null && r.score !== undefined && r.score >= (pr / 100) * full).length;
|
||||||
const passRate = submitted > 0 ? Math.round(passCount / submitted * 1000) / 10 : 0;
|
const passRate = submitted > 0 ? Math.round(passCount / submitted * 1000) / 10 : 0;
|
||||||
|
|
||||||
let maxScore = null, minScore = null;
|
let maxScore = null, minScore = null;
|
||||||
@@ -490,6 +492,9 @@
|
|||||||
set('#sMax', st.maxScore == null ? '-' : st.maxScore);
|
set('#sMax', st.maxScore == null ? '-' : st.maxScore);
|
||||||
set('#sMin', st.minScore == null ? '-' : st.minScore);
|
set('#sMin', st.minScore == null ? '-' : st.minScore);
|
||||||
set('#sPass', st.passRate + '%');
|
set('#sPass', st.passRate + '%');
|
||||||
|
// 合格率阈值可配(全局或本卷独立),卡片标题实时反映当前生效值
|
||||||
|
const lbl = $('#sPassLabel');
|
||||||
|
if (lbl) lbl.textContent = '(≥' + (state.passRate == null ? 60 : state.passRate) + '%满分)';
|
||||||
set('#sDur', st.avgDur == null ? '-' : fmtDur(st.avgDur));
|
set('#sDur', st.avgDur == null ? '-' : fmtDur(st.avgDur));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -854,10 +859,20 @@
|
|||||||
const el = document.documentElement;
|
const el = document.documentElement;
|
||||||
if (!document.fullscreenElement && !document.webkitFullscreenElement) {
|
if (!document.fullscreenElement && !document.webkitFullscreenElement) {
|
||||||
const req = el.requestFullscreen || el.webkitRequestFullscreen;
|
const req = el.requestFullscreen || el.webkitRequestFullscreen;
|
||||||
if (req) req.call(el);
|
if (req) {
|
||||||
|
const p = req.call(el);
|
||||||
|
// 部分浏览器返回 Promise,被拒绝时静默吞掉会让用户以为"点了没反应"
|
||||||
|
if (p && typeof p.catch === 'function') {
|
||||||
|
p.catch(() => { showAlert('当前浏览器/环境不允许进入全屏,请按 F11 手动切换', 'warn'); });
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
|
// exitFullscreen 挂在 document 上(documentElement.exitFullscreen 恒为 undefined)
|
||||||
const exit = document.exitFullscreen || document.webkitExitFullscreen;
|
const exit = document.exitFullscreen || document.webkitExitFullscreen;
|
||||||
if (exit) exit.call(document);
|
if (exit) {
|
||||||
|
const p = exit.call(document);
|
||||||
|
if (p && typeof p.catch === 'function') p.catch(() => {});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function onFsChange() {
|
function onFsChange() {
|
||||||
@@ -890,9 +905,9 @@
|
|||||||
// 布局
|
// 布局
|
||||||
const layout = c.layout || LS.get(LS_LAYOUT, 'default');
|
const layout = c.layout || LS.get(LS_LAYOUT, 'default');
|
||||||
(document.querySelector('input[name=cfgLayout][value="' + layout + '"]') || {}).checked = true;
|
(document.querySelector('input[name=cfgLayout][value="' + layout + '"]') || {}).checked = true;
|
||||||
// 满分:默认 + 各试卷独立
|
// 全局默认满分 / 合格率(每卷的独立配置移到主页面「试卷设置」)
|
||||||
$('#cfgFull').value = c.full_score || 100;
|
$('#cfgFull').value = c.full_score || 100;
|
||||||
renderFullScores((c.full_scores && typeof c.full_scores === 'object') ? c.full_scores : {});
|
if ($('#cfgPassRate')) $('#cfgPassRate').value = (c.pass_rate == null ? 60 : c.pass_rate);
|
||||||
// 历史成绩展示方式 + 导出排序
|
// 历史成绩展示方式 + 导出排序
|
||||||
state.historyDisplay = (c.history_display === 'inline') ? 'inline' : 'click';
|
state.historyDisplay = (c.history_display === 'inline') ? 'inline' : 'click';
|
||||||
state.exportSort = (c.export_sort === 'sid') ? 'sid' : ((c.export_sort === 'score_desc') ? 'score_desc' : ((c.export_sort === 'score_asc') ? 'score_asc' : 'time'));
|
state.exportSort = (c.export_sort === 'sid') ? 'sid' : ((c.export_sort === 'score_desc') ? 'score_desc' : ((c.export_sort === 'score_asc') ? 'score_asc' : 'time'));
|
||||||
@@ -914,39 +929,6 @@
|
|||||||
$('#settingsModal').classList.add('open');
|
$('#settingsModal').classList.add('open');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 渲染"各试卷独立满分"列表
|
|
||||||
// 注意:#fullScoresHint 必须位于 #fullScoresBox 之外(见 index.php),
|
|
||||||
// 否则 box.innerHTML 覆盖会销毁它,下面的 textContent 赋值会抛 "Cannot set properties of null"。
|
|
||||||
// 这里仍保留 null 守卫,避免任何 DOM 结构变动导致整个设置弹窗打不开。
|
|
||||||
function setFullScoresHint(text) {
|
|
||||||
const hint = $('#fullScoresHint');
|
|
||||||
if (hint) hint.textContent = text;
|
|
||||||
}
|
|
||||||
function renderFullScores(scores) {
|
|
||||||
const box = $('#fullScoresBox');
|
|
||||||
if (!box) return;
|
|
||||||
if (!state.projects.length) {
|
|
||||||
box.innerHTML = '<span class="hint">暂无试卷,请先连接卷王系统并选择问卷</span>';
|
|
||||||
setFullScoresHint('给某卷填入数字即可单独覆盖默认满分;留空=用默认');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// 排除文件夹项:模式为文件夹的无可答卷
|
|
||||||
const surveys = state.projects.filter(p => !((p.mode || '').toLowerCase().includes('folder')));
|
|
||||||
if (!surveys.length) {
|
|
||||||
box.innerHTML = '<span class="hint">尚未获取到可答卷</span>';
|
|
||||||
setFullScoresHint('给某卷填入数字即可单独覆盖默认满分;留空=用默认');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
box.innerHTML = surveys.map(p => {
|
|
||||||
const val = scores[p.id] != null ? scores[p.id] : '';
|
|
||||||
return '<div class="row">' +
|
|
||||||
'<label title="' + esc(p.name) + '">' + esc(p.name) + '</label>' +
|
|
||||||
'<input type="number" min="1" max="1000" data-pid="' + esc(p.id) + '" value="' + esc(val) + '" placeholder="默认">' +
|
|
||||||
'</div>';
|
|
||||||
}).join('');
|
|
||||||
setFullScoresHint('默认分值为空时使用上方"默认满分";填写即单独覆盖该卷');
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------------- 名单 ---------------- */
|
/* ---------------- 名单 ---------------- */
|
||||||
let rosterDraft = [];
|
let rosterDraft = [];
|
||||||
const rosterSel = new Set(); // 勾选待删除的行下标(对应 rosterDraft 索引)
|
const rosterSel = new Set(); // 勾选待删除的行下标(对应 rosterDraft 索引)
|
||||||
@@ -1163,8 +1145,11 @@
|
|||||||
include_temp_save: $('#cfgTemp').checked,
|
include_temp_save: $('#cfgTemp').checked,
|
||||||
verify_ssl: $('#cfgSsl').checked,
|
verify_ssl: $('#cfgSsl').checked,
|
||||||
full_score: parseInt($('#cfgFull').value, 10) || 100,
|
full_score: parseInt($('#cfgFull').value, 10) || 100,
|
||||||
|
pass_rate: (function () {
|
||||||
|
const v = $('#cfgPassRate') ? parseFloat($('#cfgPassRate').value) : NaN;
|
||||||
|
return (isNaN(v) || v < 1 || v > 100) ? 60 : v;
|
||||||
|
})(),
|
||||||
layout: (document.querySelector('input[name=cfgLayout]:checked') || {}).value || 'default',
|
layout: (document.querySelector('input[name=cfgLayout]:checked') || {}).value || 'default',
|
||||||
full_scores: collectFullScores(),
|
|
||||||
username: $('#cfgUser').value.trim(),
|
username: $('#cfgUser').value.trim(),
|
||||||
history_display: $('#cfgHistoryDisplay').value,
|
history_display: $('#cfgHistoryDisplay').value,
|
||||||
export_sort: $('#cfgExportSort').value,
|
export_sort: $('#cfgExportSort').value,
|
||||||
@@ -1245,15 +1230,6 @@
|
|||||||
$('#btnTestConn').addEventListener('click', testConnection);
|
$('#btnTestConn').addEventListener('click', testConnection);
|
||||||
|
|
||||||
// 收集"各试卷独立满分" -> {projectId: score}
|
// 收集"各试卷独立满分" -> {projectId: score}
|
||||||
function collectFullScores() {
|
|
||||||
const out = {};
|
|
||||||
$$('#fullScoresBox input[data-pid]').forEach(inp => {
|
|
||||||
const v = parseInt(inp.value, 10);
|
|
||||||
if (inp.dataset.pid && !isNaN(v) && v > 0) out[inp.dataset.pid] = v;
|
|
||||||
});
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 名单上传 */
|
/* 名单上传 */
|
||||||
const zone = $('#dropZone'), fileInput = $('#rosterFile');
|
const zone = $('#dropZone'), fileInput = $('#rosterFile');
|
||||||
zone.addEventListener('click', () => fileInput.click());
|
zone.addEventListener('click', () => fileInput.click());
|
||||||
@@ -1503,6 +1479,167 @@
|
|||||||
$('#trendModal').classList.add('open');
|
$('#trendModal').classList.add('open');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/* 成绩明细打印:默认按成绩降序,可选按班级/组别分组对比 */
|
||||||
|
function printTable() {
|
||||||
|
const rows = (state._sorted || []).slice();
|
||||||
|
if (!rows.length) return showAlert('当前没有可打印的成绩', 'warn');
|
||||||
|
|
||||||
|
const sortMode = $('#ptSort').value;
|
||||||
|
const groupBy = $('#ptGroup').value; // none | cls | grp
|
||||||
|
const showRank = $('#ptRank').checked;
|
||||||
|
const showAtt = $('#ptAttempts').checked;
|
||||||
|
const full = state.fullScore || 100;
|
||||||
|
const passRate = (state.passRate == null ? 60 : state.passRate);
|
||||||
|
|
||||||
|
// 排序(打印独立于页面排序,默认成绩降序;null 成绩恒排末尾)
|
||||||
|
const cmp = (a, b) => {
|
||||||
|
const an = (a.score == null), bn = (b.score == null);
|
||||||
|
if (an && bn) return 0;
|
||||||
|
if (an) return 1;
|
||||||
|
if (bn) return -1;
|
||||||
|
if (sortMode === 'score_desc') return (b.score - a.score) || ((b.submit_ts || 0) - (a.submit_ts || 0));
|
||||||
|
if (sortMode === 'score_asc') return (a.score - b.score) || ((b.submit_ts || 0) - (a.submit_ts || 0));
|
||||||
|
if (sortMode === 'sid') return String(a.sid).localeCompare(String(b.sid), 'zh', { numeric: true });
|
||||||
|
return ((b.submit_ts || 0) - (a.submit_ts || 0)); // time
|
||||||
|
};
|
||||||
|
rows.sort(cmp);
|
||||||
|
|
||||||
|
const passOf = r => (r.score != null && r.score >= (passRate / 100) * full);
|
||||||
|
const statOf = list => {
|
||||||
|
const sc = list.map(r => r.score).filter(v => v != null);
|
||||||
|
const avg = sc.length ? (sc.reduce((x, y) => x + y, 0) / sc.length) : null;
|
||||||
|
const pass = list.filter(passOf).length;
|
||||||
|
return {
|
||||||
|
n: list.length,
|
||||||
|
avg: avg == null ? '—' : avg.toFixed(1),
|
||||||
|
passRate: list.length ? Math.round(pass / list.length * 1000) / 10 : 0,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const head = '<tr><th>' + (showRank ? '名次' : '') + '</th><th>学号</th><th>姓名</th>' +
|
||||||
|
(groupBy === 'none' ? '<th>班级</th><th>组别</th>' : '') +
|
||||||
|
'<th>成绩</th>' + (showAtt ? '<th>考试次数</th>' : '') + '<th>提交时间</th></tr>';
|
||||||
|
|
||||||
|
let html = '';
|
||||||
|
let rank = 0;
|
||||||
|
const renderRows = (list, offsetRank) => list.map((r, i) => {
|
||||||
|
const rk = (r.score == null) ? '' : (offsetRank + i + 1);
|
||||||
|
return '<tr>' + (showRank ? '<td class="c">' + rk + '</td>' : '') +
|
||||||
|
'<td>' + esc(r.sid || '') + '</td>' +
|
||||||
|
'<td>' + esc(r.name || '') + '</td>' +
|
||||||
|
(groupBy === 'none' ? '<td>' + esc(r.cls || '') + '</td><td>' + esc(r.grp || '') + '</td>' : '') +
|
||||||
|
'<td class="c"><b>' + (r.score == null ? '—' : r.score) + '</b></td>' +
|
||||||
|
(showAtt ? '<td class="c">' + (r.attempts || 1) + '</td>' : '') +
|
||||||
|
'<td>' + esc(r.submit_at || '') + '</td></tr>';
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
if (groupBy === 'none') {
|
||||||
|
html = '<table><thead>' + head + '</thead><tbody>' + renderRows(rows, 0) + '</tbody></table>';
|
||||||
|
} else {
|
||||||
|
const keyOf = (groupBy === 'cls') ? (r => (r.cls || '未分班')) : (r => (r.grp || '未分组'));
|
||||||
|
const groups = {};
|
||||||
|
rows.forEach(r => { const k = keyOf(r); (groups[k] = groups[k] || []).push(r); });
|
||||||
|
Object.keys(groups).sort((a, b) => String(a).localeCompare(String(b), 'zh', { numeric: true }))
|
||||||
|
.forEach(k => {
|
||||||
|
const list = groups[k];
|
||||||
|
const st = statOf(list);
|
||||||
|
html += '<h3 class="grp">' + esc(k) +
|
||||||
|
' <small>(' + st.n + ' 人 · 平均 ' + st.avg + ' · 合格率 ' + st.passRate + '%)</small></h3>';
|
||||||
|
html += '<table><thead>' + head + '</thead><tbody>' + renderRows(list, 0) + '</tbody></table>';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const all = statOf(rows);
|
||||||
|
const title = (state.projectName ? state.projectName + ' · ' : '') + '成绩明细';
|
||||||
|
const cls = $('#clsSel').value, grp = $('#grpSel').value;
|
||||||
|
const scope = (cls ? cls + '班 ' : '') + (grp ? grp + '组' : '');
|
||||||
|
|
||||||
|
const w = window.open('', '_blank');
|
||||||
|
if (!w) return showAlert('浏览器拦截了弹窗,请允许后重试', 'error');
|
||||||
|
w.document.write('<!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8"><title>' + esc(title) + '</title>' +
|
||||||
|
'<style>' +
|
||||||
|
'body{font-family:"Microsoft YaHei",sans-serif;margin:20px;color:#000}' +
|
||||||
|
'h2{margin:0 0 4px;font-size:18px}' +
|
||||||
|
'.meta{font-size:12px;color:#555;margin-bottom:14px}' +
|
||||||
|
'h3.grp{font-size:14px;margin:18px 0 6px;padding:6px 8px;background:#f0f0f0;border-left:4px solid #333}' +
|
||||||
|
'h3.grp small{font-weight:400;color:#555}' +
|
||||||
|
'table{border-collapse:collapse;width:100%;margin-bottom:10px;font-size:12px}' +
|
||||||
|
'th,td{border:1px solid #666;padding:5px 8px;text-align:left}' +
|
||||||
|
'th{background:#e8e8e8;font-weight:700}' +
|
||||||
|
'td.c{text-align:center}' +
|
||||||
|
'@media print{body{margin:0}h3.grp{page-break-after:avoid}table{page-break-inside:auto}tr{page-break-inside:avoid}}' +
|
||||||
|
'</style></head><body>' +
|
||||||
|
'<h2>' + esc(title) + '</h2>' +
|
||||||
|
'<div class="meta">' + esc(scope) + ' 共 ' + all.n + ' 人 · 平均 ' + all.avg +
|
||||||
|
' · 合格率 ' + all.passRate + '%(合格线:满分的 ' + passRate + '%,即 ' +
|
||||||
|
(Math.round(passRate / 100 * full * 10) / 10) + ' 分) · 打印时间:' + new Date().toLocaleString() + '</div>' +
|
||||||
|
html +
|
||||||
|
'<scr' + 'ipt>window.onload=function(){setTimeout(function(){window.print()},300)}</scr' + 'ipt>' +
|
||||||
|
'</body></html>');
|
||||||
|
w.document.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
$('#btnPrintTable').addEventListener('click', () => {
|
||||||
|
if (!curPid()) return showAlert('请先选择问卷 / 考试');
|
||||||
|
const n = (state._sorted || []).length;
|
||||||
|
$('#ptHint').textContent = n ? ('当前将打印 ' + n + ' 条记录') : '暂无数据';
|
||||||
|
$('#printTableModal').classList.add('open');
|
||||||
|
});
|
||||||
|
$('#btnPtPrint').addEventListener('click', () => {
|
||||||
|
$('#printTableModal').classList.remove('open');
|
||||||
|
printTable();
|
||||||
|
});
|
||||||
|
|
||||||
|
/* 试卷独立设置(满分 / 合格率 / 免考名单)——仅对当前选中试卷生效 */
|
||||||
|
async function openProjCfg() {
|
||||||
|
const pid = curPid();
|
||||||
|
if (!pid) return showAlert('请先选择问卷 / 考试');
|
||||||
|
const pname = (($('#projectSel').selectedOptions && $('#projectSel').selectedOptions[0]) || {}).textContent || '';
|
||||||
|
$('#projCfgSub').textContent = pname ? ('· ' + pname) : '';
|
||||||
|
try {
|
||||||
|
const d = await api('project_config', { params: { projectId: pid } });
|
||||||
|
const cfg = d.cfg || {}, eff = d.effective || {}, g = d.global || {};
|
||||||
|
$('#pcFull').value = (cfg.full_score == null ? '' : cfg.full_score);
|
||||||
|
$('#pcPassRate').value = (cfg.pass_rate == null ? '' : cfg.pass_rate);
|
||||||
|
$('#pcExempt').value = (cfg.exempt || []).join('\n');
|
||||||
|
$('#pcFullHint').textContent = '当前生效:' + (eff.full_score || '-') +
|
||||||
|
'(全局默认 ' + (g.full_score || '-') + ')';
|
||||||
|
$('#pcPassHint').textContent = '当前生效:' + (eff.pass_rate || '-') +
|
||||||
|
'%(全局默认 ' + (g.pass_rate || '-') + '%)';
|
||||||
|
} catch (e) { showAlert(e.message); return; }
|
||||||
|
$('#projCfgModal').classList.add('open');
|
||||||
|
}
|
||||||
|
$('#btnProjCfg').addEventListener('click', openProjCfg);
|
||||||
|
|
||||||
|
$('#btnPcSave').addEventListener('click', async () => {
|
||||||
|
const pid = curPid();
|
||||||
|
if (!pid) return showAlert('请先选择问卷 / 考试');
|
||||||
|
try {
|
||||||
|
await api('project_config_save', { method: 'POST', params: { projectId: pid }, body: {
|
||||||
|
full_score: $('#pcFull').value.trim(),
|
||||||
|
pass_rate: $('#pcPassRate').value.trim(),
|
||||||
|
exempt: $('#pcExempt').value,
|
||||||
|
}});
|
||||||
|
$('#projCfgModal').classList.remove('open');
|
||||||
|
showAlert('本卷设置已保存(仅对当前试卷生效)', 'info');
|
||||||
|
refresh(false, true); // 免考会改变名单/缺考口径,需强制重拉
|
||||||
|
} catch (e) { showAlert(e.message); }
|
||||||
|
});
|
||||||
|
|
||||||
|
$('#btnPcReset').addEventListener('click', async () => {
|
||||||
|
const pid = curPid();
|
||||||
|
if (!pid) return;
|
||||||
|
if (!confirm('确定清除本卷的全部独立设置(恢复跟随全局默认)?')) return;
|
||||||
|
try {
|
||||||
|
await api('project_config_save', { method: 'POST', params: { projectId: pid }, body: {
|
||||||
|
full_score: '', pass_rate: '', exempt: '',
|
||||||
|
}});
|
||||||
|
$('#projCfgModal').classList.remove('open');
|
||||||
|
showAlert('本卷设置已清除', 'info');
|
||||||
|
refresh(false, true);
|
||||||
|
} catch (e) { showAlert(e.message); }
|
||||||
|
});
|
||||||
|
|
||||||
/* 异常检测线索 */
|
/* 异常检测线索 */
|
||||||
function renderAnomaly() {
|
function renderAnomaly() {
|
||||||
const a = state.anomalies || { fast_high: [], frequent: [], identical: [] };
|
const a = state.anomalies || { fast_high: [], frequent: [], identical: [] };
|
||||||
|
|||||||
+46
-10
@@ -53,7 +53,12 @@ body {
|
|||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
box-shadow: 0 2px 12px rgba(20, 30, 60, .05);
|
box-shadow: 0 2px 12px rgba(20, 30, 60, .05);
|
||||||
}
|
}
|
||||||
.brand { display: flex; align-items: center; gap: 12px; }
|
.brand { display: flex; align-items: center; gap: 12px; min-width: 0; }
|
||||||
|
/* 导航栏主标题(已移除 Logo 与副标题,只保留单一主标题) */
|
||||||
|
.brand-title {
|
||||||
|
margin: 0; font-size: 16px; font-weight: 700; letter-spacing: .3px;
|
||||||
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||||
|
}
|
||||||
.logo {
|
.logo {
|
||||||
width: 38px; height: 38px;
|
width: 38px; height: 38px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
@@ -469,32 +474,47 @@ body.fs-on .card b { font-size: 28px; }
|
|||||||
body.fs-on .topbar { padding-top: 8px; padding-bottom: 8px; }
|
body.fs-on .topbar { padding-top: 8px; padding-bottom: 8px; }
|
||||||
|
|
||||||
/* ---------- 三端自适应(桌面 / 平板 / 手机) ---------- */
|
/* ---------- 三端自适应(桌面 / 平板 / 手机) ---------- */
|
||||||
|
|
||||||
|
/* 大屏笔记本:略收边距,统计卡片保持一行 */
|
||||||
@media (max-width: 1180px) {
|
@media (max-width: 1180px) {
|
||||||
.wrap { padding: 16px 16px 40px; }
|
.wrap { padding: 16px 16px 40px; }
|
||||||
.topbar { padding: 10px 16px; }
|
.topbar { padding: 10px 16px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 平板横屏(769~1024):卡片改 4 列、工具栏字段占满整行 */
|
||||||
@media (max-width: 1024px) {
|
@media (max-width: 1024px) {
|
||||||
.grid-2 { grid-template-columns: 1fr; }
|
.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); }
|
||||||
|
.toolbar { gap: 14px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 平板竖屏 / 大手机(<=768):整体收紧,工具栏与按钮自动换行 */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
body { font-size: 13px; }
|
body { font-size: 13px; }
|
||||||
.topbar { flex-wrap: wrap; gap: 10px; }
|
.topbar { flex-wrap: wrap; gap: 10px; }
|
||||||
.brand-text small { display: none; }
|
.brand { flex: 1 1 100%; }
|
||||||
|
.brand-title { font-size: 15px; }
|
||||||
.top-right { flex-wrap: wrap; gap: 8px; }
|
.top-right { flex-wrap: wrap; gap: 8px; }
|
||||||
.top-right .btn { padding: 6px 10px; font-size: 12px; }
|
.top-right .btn { padding: 6px 10px; font-size: 12px; }
|
||||||
.btn b { font-size: 10px; }
|
.btn b { font-size: 10px; }
|
||||||
.wrap { padding: 12px 12px 36px; }
|
.wrap { padding: 12px 12px 36px; }
|
||||||
.toolbar { padding: 12px; gap: 10px; }
|
.toolbar { padding: 12px; gap: 10px 12px; }
|
||||||
.field { flex: 1 1 100%; min-width: 0; }
|
.field { flex: 1 1 100%; min-width: 0; }
|
||||||
.field.btns { flex: 1 1 100%; }
|
.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; }
|
||||||
.panel-head { flex-direction: column; align-items: flex-start; gap: 6px; }
|
.card span { font-size: 11px; }
|
||||||
|
/* 面板头部(含按钮)竖排,按钮自动换行 */
|
||||||
|
.panel-head { flex-direction: column; align-items: flex-start; gap: 8px; }
|
||||||
|
/* 竖排时 .spacer(flex:1)会把内容上下撑开,必须隐藏 */
|
||||||
|
.panel-head > .spacer { display: none; }
|
||||||
|
.panel-head .updated { flex-wrap: wrap; width: 100%; }
|
||||||
|
.panel-head .btn, .panel-head .updated .btn { flex: 0 0 auto; }
|
||||||
.table-scroll { max-height: 56vh; }
|
.table-scroll { max-height: 56vh; }
|
||||||
table.grid { font-size: 12px; }
|
table.grid { font-size: 12px; }
|
||||||
table.grid th { padding: 8px 10px; white-space: nowrap; }
|
table.grid th { padding: 8px 10px; white-space: nowrap; }
|
||||||
@@ -503,19 +523,36 @@ body.fs-on .topbar { padding-top: 8px; padding-bottom: 8px; }
|
|||||||
.modal-box, .modal-box.wide { width: 100%; max-height: 92vh; }
|
.modal-box, .modal-box.wide { width: 100%; max-height: 92vh; }
|
||||||
.form-grid { grid-template-columns: 1fr; }
|
.form-grid { grid-template-columns: 1fr; }
|
||||||
.modal-body { padding: 14px; }
|
.modal-body { padding: 14px; }
|
||||||
|
/* 弹窗底栏在小屏换行,按钮可点满宽 */
|
||||||
|
.modal-foot { flex-wrap: wrap; padding: 10px 14px; }
|
||||||
|
.modal-foot > .spacer { display: none; }
|
||||||
|
.modal-foot .btn { flex: 1 1 auto; }
|
||||||
|
.roster-bar { flex-wrap: wrap; }
|
||||||
.roster-bar .chk { font-size: 11px; }
|
.roster-bar .chk { font-size: 11px; }
|
||||||
|
/* 名单表在窄屏可横向滚动,避免 6 列挤爆 */
|
||||||
|
#rosterTable { min-width: 560px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 手机(<=480):卡片两列,进一步压缩字号与内边距 */
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
.cards { grid-template-columns: repeat(2, 1fr); }
|
.cards { grid-template-columns: repeat(2, 1fr); gap: 8px; }
|
||||||
.brand-text strong { font-size: 14px; }
|
.brand-title { font-size: 14px; }
|
||||||
.topbar .logo { width: 32px; height: 32px; font-size: 13px; }
|
|
||||||
.btn { padding: 6px 9px; font-size: 12px; }
|
.btn { padding: 6px 9px; font-size: 12px; }
|
||||||
.field label { font-size: 11px; }
|
.field label { font-size: 11px; }
|
||||||
.roster-bar { gap: 8px; }
|
.roster-bar { gap: 8px; }
|
||||||
.roster-bar input[type=text] { width: 120px; }
|
.roster-bar input[type=text] { width: 120px; }
|
||||||
.panel-head h2 { font-size: 14px; }
|
.panel-head h2 { font-size: 14px; }
|
||||||
#rosterBody input { font-size: 12px; }
|
#rosterBody input { font-size: 12px; }
|
||||||
|
.modal-box { max-height: 94vh; }
|
||||||
|
.modal-body { padding: 12px; }
|
||||||
|
/* 工具条按钮在小屏铺满,便于点按 */
|
||||||
|
.field.btns .btn { flex: 1 1 auto; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 触摸设备:增大点按热区,去掉 hover 残留高亮 */
|
||||||
|
@media (hover: none) and (pointer: coarse) {
|
||||||
|
.btn { min-height: 34px; }
|
||||||
|
table.grid tbody tr:hover td { background: inherit; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- 数据看板 ---------- */
|
/* ---------- 数据看板 ---------- */
|
||||||
@@ -525,8 +562,7 @@ body.fs-on .topbar { padding-top: 8px; padding-bottom: 8px; }
|
|||||||
display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap;
|
display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap;
|
||||||
box-shadow: var(--shadow);
|
box-shadow: var(--shadow);
|
||||||
}
|
}
|
||||||
.dash-hero .brand strong { color: #fff; font-size: 17px; }
|
.dash-hero .brand-title { color: #fff; font-size: 17px; }
|
||||||
.dash-hero .brand small { color: rgba(255, 255, 255, .72); }
|
|
||||||
.dash-hero a.back { color: #fff; opacity: .9; font-size: 13px; text-decoration: none; font-weight: 600; }
|
.dash-hero a.back { color: #fff; opacity: .9; font-size: 13px; text-decoration: none; font-weight: 600; }
|
||||||
.dash-hero a.back:hover { opacity: 1; text-decoration: underline; }
|
.dash-hero a.back:hover { opacity: 1; text-decoration: underline; }
|
||||||
.dash-controls { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
.dash-controls { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||||
|
|||||||
+82
-7
@@ -227,7 +227,9 @@ function ny_default_config(): array
|
|||||||
'dedupe' => true,
|
'dedupe' => true,
|
||||||
'name_keywords' => '姓名,名字,学生姓名,考生姓名,您的姓名,姓名:,姓名:',
|
'name_keywords' => '姓名,名字,学生姓名,考生姓名,您的姓名,姓名:,姓名:',
|
||||||
'full_score' => 100,
|
'full_score' => 100,
|
||||||
'full_scores' => [],
|
// 全局默认合格率(百分比,如 60 表示"≥满分的 60%"算合格)。
|
||||||
|
// 每份试卷可在主页面单独覆盖(project_config),试卷之间互相隔离。
|
||||||
|
'pass_rate' => 60,
|
||||||
'layout' => 'default',
|
'layout' => 'default',
|
||||||
// 历史成绩展示方式:click=点击成员弹窗查看(默认);inline=成绩明细内直接展开每次考试
|
// 历史成绩展示方式:click=点击成员弹窗查看(默认);inline=成绩明细内直接展开每次考试
|
||||||
'history_display' => 'click',
|
'history_display' => 'click',
|
||||||
@@ -258,16 +260,45 @@ function ny_load_config(): array
|
|||||||
{
|
{
|
||||||
$GLOBALS['__ny_cfg_source'] = 'default';
|
$GLOBALS['__ny_cfg_source'] = 'default';
|
||||||
|
|
||||||
// 默认配置 + 文件覆盖(DB 后端本批次已下线,统一走文件存储)
|
$cfg = ny_default_config();
|
||||||
$cfg = ny_default_config();
|
$file = ny_config_file();
|
||||||
$saved = ny_read_json(ny_config_file(), null);
|
$saved = null;
|
||||||
|
|
||||||
|
// 1) 优先读 SQLite(data/ny.db 的 ny_settings.config)
|
||||||
|
if (class_exists('Db') && Db::pdo() !== null) {
|
||||||
|
$raw = Db::settingsGet('config', '');
|
||||||
|
if ($raw !== '') {
|
||||||
|
$j = json_decode($raw, true);
|
||||||
|
if (is_array($j)) {
|
||||||
|
$saved = $j;
|
||||||
|
$GLOBALS['__ny_cfg_source'] = 'db';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// DB 可用但尚无记录 → 把已有 config.json 迁进来(老数据不丢,只迁一次)
|
||||||
|
if ($saved === null) {
|
||||||
|
$fromFile = ny_read_json($file, null);
|
||||||
|
if (is_array($fromFile)) {
|
||||||
|
Db::settingsSet('config', json_encode($fromFile, JSON_UNESCAPED_UNICODE));
|
||||||
|
$saved = $fromFile;
|
||||||
|
$GLOBALS['__ny_cfg_source'] = 'db';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) DB 不可用时退回文件
|
||||||
|
if ($saved === null) {
|
||||||
|
$saved = ny_read_json($file, null);
|
||||||
|
if (is_array($saved)) {
|
||||||
|
$GLOBALS['__ny_cfg_source'] = 'file';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (is_array($saved)) {
|
if (is_array($saved)) {
|
||||||
foreach ($cfg as $k => $v) {
|
foreach ($cfg as $k => $v) {
|
||||||
if (array_key_exists($k, $saved)) {
|
if (array_key_exists($k, $saved)) {
|
||||||
$cfg[$k] = is_bool($v) ? (bool)$saved[$k] : $saved[$k];
|
$cfg[$k] = is_bool($v) ? (bool)$saved[$k] : $saved[$k];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
$GLOBALS['__ny_cfg_source'] = 'file';
|
|
||||||
}
|
}
|
||||||
$cfg['base_url'] = rtrim(trim((string)$cfg['base_url']), '/');
|
$cfg['base_url'] = rtrim(trim((string)$cfg['base_url']), '/');
|
||||||
$cfg['api_prefix'] = '/' . trim((string)$cfg['api_prefix'], '/');
|
$cfg['api_prefix'] = '/' . trim((string)$cfg['api_prefix'], '/');
|
||||||
@@ -276,8 +307,52 @@ function ny_load_config(): array
|
|||||||
|
|
||||||
function ny_save_config(array $cfg): bool
|
function ny_save_config(array $cfg): bool
|
||||||
{
|
{
|
||||||
$GLOBALS['__ny_cfg_source'] = 'file';
|
// 双写:SQLite 为主(成功即算成功),文件为备份(DB 挂掉时还能读回来)
|
||||||
return ny_write_json(ny_config_file(), $cfg);
|
$ok = false;
|
||||||
|
if (class_exists('Db') && Db::pdo() !== null) {
|
||||||
|
try {
|
||||||
|
Db::settingsSet('config', json_encode($cfg, JSON_UNESCAPED_UNICODE));
|
||||||
|
$GLOBALS['__ny_cfg_source'] = 'db';
|
||||||
|
$ok = true;
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$ok = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$fileOk = ny_write_json(ny_config_file(), $cfg);
|
||||||
|
if (!$ok) {
|
||||||
|
$GLOBALS['__ny_cfg_source'] = 'file';
|
||||||
|
}
|
||||||
|
return $ok || $fileOk;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析「本卷满分 + 合格率」:
|
||||||
|
* 优先取该试卷的独立配置(试卷之间互相隔离),没有则用全局配置。
|
||||||
|
*
|
||||||
|
* @return array{0:int,1:float} [满分, 合格率百分比(如 60 表示 60%)]
|
||||||
|
*/
|
||||||
|
function ny_resolve_score_cfg(string $projectId, array $c): array
|
||||||
|
{
|
||||||
|
$pCfg = class_exists('DataService') ? DataService::projectConfig($projectId) : [];
|
||||||
|
|
||||||
|
$fs = (int)($pCfg['full_score'] ?? 0);
|
||||||
|
if ($fs < 1) {
|
||||||
|
$fs = (int)($c['full_score'] ?? 100);
|
||||||
|
}
|
||||||
|
if ($fs < 1) {
|
||||||
|
$fs = 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
$pr = $pCfg['pass_rate'] ?? null;
|
||||||
|
if ($pr === null || $pr === '') {
|
||||||
|
$pr = $c['pass_rate'] ?? 60;
|
||||||
|
}
|
||||||
|
$pr = (float)$pr;
|
||||||
|
if ($pr <= 0 || $pr > 100) {
|
||||||
|
$pr = 60;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [$fs, $pr];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 安全的字符串化(避免数组/对象转字符串报错) */
|
/** 安全的字符串化(避免数组/对象转字符串报错) */
|
||||||
|
|||||||
+29
-16
@@ -25,11 +25,7 @@ if (!ny_is_auth()) {
|
|||||||
|
|
||||||
<div class="dash-hero">
|
<div class="dash-hero">
|
||||||
<div class="brand">
|
<div class="brand">
|
||||||
<span class="logo">NY</span>
|
<h1 class="brand-title">NY问卷考试系统数据平台</h1>
|
||||||
<div class="brand-text">
|
|
||||||
<strong id="heroTitle">班级看板</strong>
|
|
||||||
<small id="heroSub">选择一个问卷与班级查看考试整体情况</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="dash-controls">
|
<div class="dash-controls">
|
||||||
<select id="projectSel" style="min-width:160px"><option value="">请选择问卷 / 考试</option></select>
|
<select id="projectSel" style="min-width:160px"><option value="">请选择问卷 / 考试</option></select>
|
||||||
@@ -47,7 +43,9 @@ if (!ny_is_auth()) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="margin-top:14px;font-size:12px;color:var(--text-3);display:flex;align-items:center;gap:6px;">
|
<div style="margin-top:14px;font-size:12px;color:var(--text-3);display:flex;align-items:center;gap:6px;flex-wrap:wrap;">
|
||||||
|
<span id="heroSub">选择一个问卷与班级查看考试整体情况</span>
|
||||||
|
<span class="spacer" style="flex:1"></span>
|
||||||
最后更新:<span id="lastUpdate">—</span><i id="liveDot" class="dot"></i>
|
最后更新:<span id="lastUpdate">—</span><i id="liveDot" class="dot"></i>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -60,7 +58,7 @@ if (!ny_is_auth()) {
|
|||||||
<div class="card warn accent-orange"><span>缺考</span><b id="bAbsent">-</b></div>
|
<div class="card warn accent-orange"><span>缺考</span><b id="bAbsent">-</b></div>
|
||||||
<div class="card accent-purple"><span>提交率</span><b id="bRate">-</b></div>
|
<div class="card accent-purple"><span>提交率</span><b id="bRate">-</b></div>
|
||||||
<div class="card accent-teal"><span>平均分</span><b id="bAvg">-</b></div>
|
<div class="card accent-teal"><span>平均分</span><b id="bAvg">-</b></div>
|
||||||
<div class="card accent-indigo"><span>合格率(≥60%满分)</span><b id="bPass">-</b></div>
|
<div class="card accent-indigo"><span>合格率<span id="bPassLabel">(≥60%满分)</span></span><b id="bPass">-</b></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div class="sec-title">分组对比 / 考试成员</div>
|
<div class="sec-title">分组对比 / 考试成员</div>
|
||||||
@@ -200,6 +198,9 @@ async function refreshClassSel(){
|
|||||||
try {
|
try {
|
||||||
const data = await api('dashboard', { projectId: pid });
|
const data = await api('dashboard', { projectId: pid });
|
||||||
DASH = data;
|
DASH = data;
|
||||||
|
// 合格率阈值可配(全局或本卷独立),卡片标题实时反映
|
||||||
|
const bl = document.getElementById('bPassLabel');
|
||||||
|
if (bl) bl.textContent = '(≥' + (data.pass_rate == null ? 60 : data.pass_rate) + '%满分)';
|
||||||
const classes = (data.classes || []).map(c => c.class);
|
const classes = (data.classes || []).map(c => c.class);
|
||||||
sel.innerHTML = classes.length
|
sel.innerHTML = classes.length
|
||||||
? classes.map(c => '<option value="' + esc(c) + '">' + esc(c) + '</option>').join('')
|
? classes.map(c => '<option value="' + esc(c) + '">' + esc(c) + '</option>').join('')
|
||||||
@@ -285,8 +286,9 @@ function renderPlaceholderBoard(){
|
|||||||
$('#boardSummary').innerHTML = cards.map(c =>
|
$('#boardSummary').innerHTML = cards.map(c =>
|
||||||
'<div class="card"><span>' + c[0] + '</span><b>' + c[1] + '</b></div>').join('');
|
'<div class="card"><span>' + c[0] + '</span><b>' + c[1] + '</b></div>').join('');
|
||||||
$('#boardBody').innerHTML = '<div class="empty">请选择问卷与班级查看考试整体情况</div>';
|
$('#boardBody').innerHTML = '<div class="empty">请选择问卷与班级查看考试整体情况</div>';
|
||||||
$('#heroTitle').textContent = '班级看板';
|
// 注意:heroTitle 已随导航栏精简移除,只保留 heroSub(承载当前问卷/班级提示)
|
||||||
$('#heroSub').textContent = '选择一个问卷与班级查看考试整体情况';
|
const _hs = document.getElementById('heroSub');
|
||||||
|
if (_hs) _hs.textContent = '选择一个问卷与班级查看考试整体情况';
|
||||||
const d = $('#liveDot'); if (d) { d.classList.remove('on','stale'); }
|
const d = $('#liveDot'); if (d) { d.classList.remove('on','stale'); }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -327,9 +329,8 @@ function renderBoard(){
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 顶部品牌区:动态标题
|
// 顶部提示:当前班级 / 问卷(heroTitle 已移除,只更新 heroSub)
|
||||||
const projectName = ($('#projectSel').selectedOptions[0] || {}).textContent || '';
|
const projectName = ($('#projectSel').selectedOptions[0] || {}).textContent || '';
|
||||||
$('#heroTitle').textContent = cls + ' · 班级看板';
|
|
||||||
|
|
||||||
// 构建"学号 -> 多次刷题明细"映射(班级级 members 已含全班所有人的 attempts_list)
|
// 构建"学号 -> 多次刷题明细"映射(班级级 members 已含全班所有人的 attempts_list)
|
||||||
const MAP = {};
|
const MAP = {};
|
||||||
@@ -342,7 +343,8 @@ function renderBoard(){
|
|||||||
|
|
||||||
const overall = DASH.overall || {};
|
const overall = DASH.overall || {};
|
||||||
const totalAttempts = (overall.attempts_total || 0);
|
const totalAttempts = (overall.attempts_total || 0);
|
||||||
$('#heroSub').textContent = '问卷:' + projectName.replace(/\uff08\d+份\uff09$/, '') + ' · ' +
|
const _hs2 = document.getElementById('heroSub');
|
||||||
|
if (_hs2) _hs2.textContent = cls + ' · 问卷:' + projectName.replace(/\uff08\d+份\uff09$/, '') + ' · ' +
|
||||||
'总 ' + c.total + ' / 已交 ' + c.submitted + ' / 缺考 ' + c.absent +
|
'总 ' + c.total + ' / 已交 ' + c.submitted + ' / 缺考 ' + c.absent +
|
||||||
(totalAttempts ? ' · 共 ' + totalAttempts + ' 次提交' : '');
|
(totalAttempts ? ' · 共 ' + totalAttempts + ' 次提交' : '');
|
||||||
|
|
||||||
@@ -354,7 +356,7 @@ function renderBoard(){
|
|||||||
'<div class="card warn accent-orange"><span>缺考</span><b>' + c.absent + '</b></div>' +
|
'<div class="card warn accent-orange"><span>缺考</span><b>' + c.absent + '</b></div>' +
|
||||||
'<div class="card accent-purple"><span>提交率</span><b style="color:' + color + '">' + c.rate + '%</b></div>' +
|
'<div class="card accent-purple"><span>提交率</span><b style="color:' + color + '">' + c.rate + '%</b></div>' +
|
||||||
'<div class="card accent-teal"><span>平均分</span><b>' + (c.avg != null ? c.avg : '-') + '</b></div>' +
|
'<div class="card accent-teal"><span>平均分</span><b>' + (c.avg != null ? c.avg : '-') + '</b></div>' +
|
||||||
'<div class="card accent-indigo"><span>合格率</span><b>' + c.pass_rate + '%</b></div>';
|
'<div class="card accent-indigo"><span>合格率(≥' + (DASH && DASH.pass_rate != null ? DASH.pass_rate : 60) + '%满分)</span><b>' + c.pass_rate + '%</b></div>';
|
||||||
|
|
||||||
const groups = (c.groups || []).filter(g => g.group !== '(未分组)');
|
const groups = (c.groups || []).filter(g => g.group !== '(未分组)');
|
||||||
const box = $('#boardBody');
|
const box = $('#boardBody');
|
||||||
@@ -392,10 +394,21 @@ function toggleFullscreen(){
|
|||||||
const el = document.documentElement;
|
const el = document.documentElement;
|
||||||
if (!document.fullscreenElement && !document.webkitFullscreenElement) {
|
if (!document.fullscreenElement && !document.webkitFullscreenElement) {
|
||||||
const req = el.requestFullscreen || el.webkitRequestFullscreen;
|
const req = el.requestFullscreen || el.webkitRequestFullscreen;
|
||||||
if (req) req.call(el);
|
if (req) {
|
||||||
|
const p = req.call(el);
|
||||||
|
// 部分浏览器(尤其 Safari)会返回 Promise,被拒绝时静默吞掉会让人以为"点了没反应"
|
||||||
|
if (p && typeof p.catch === 'function') {
|
||||||
|
p.catch(() => { alert('当前浏览器/环境不允许进入全屏,请按 F11 手动切换'); });
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
const ex = el.exitFullscreen || el.webkitExitFullscreen;
|
// 注意:exitFullscreen 挂在 document 上,不是 documentElement 上
|
||||||
if (ex) ex.call(document);
|
// (写成 el.exitFullscreen 恒为 undefined → 退出全屏点了没反应)
|
||||||
|
const ex = document.exitFullscreen || document.webkitExitFullscreen;
|
||||||
|
if (ex) {
|
||||||
|
const p = ex.call(document);
|
||||||
|
if (p && typeof p.catch === 'function') p.catch(() => {});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function onFsChange(){
|
function onFsChange(){
|
||||||
|
|||||||
@@ -26,11 +26,7 @@ $rosterCount = Roster::count();
|
|||||||
|
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<div class="brand">
|
<div class="brand">
|
||||||
<span class="logo">NY</span>
|
<h1 class="brand-title">NY问卷考试系统数据平台</h1>
|
||||||
<div class="brand-text">
|
|
||||||
<strong>随机抽取</strong>
|
|
||||||
<small>名单 / 已提交人员 → 卷王白名单</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="top-right">
|
<div class="top-right">
|
||||||
<a class="btn" href="index.php#roster" title="前往主系统管理学号名单">名单管理 <b id="rosterBadge"><?= (int)$rosterCount ?></b></a>
|
<a class="btn" href="index.php#roster" title="前往主系统管理学号名单">名单管理 <b id="rosterBadge"><?= (int)$rosterCount ?></b></a>
|
||||||
|
|||||||
@@ -22,11 +22,7 @@ $rosterCount = Roster::count();
|
|||||||
|
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<div class="brand">
|
<div class="brand">
|
||||||
<span class="logo">NY</span>
|
<h1 class="brand-title">NY问卷考试系统数据平台</h1>
|
||||||
<div class="brand-text">
|
|
||||||
<strong>NY问卷考试系统数据平台</strong>
|
|
||||||
<small>卷王问卷系统 · 实时数据统计</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="top-right">
|
<div class="top-right">
|
||||||
<div id="conn" class="conn conn-off"><i></i><span>未连接</span></div>
|
<div id="conn" class="conn conn-off"><i></i><span>未连接</span></div>
|
||||||
@@ -99,6 +95,7 @@ $rosterCount = Roster::count();
|
|||||||
<button class="btn" id="btnSnapshot" title="保存当前成绩为一份带标签的时间点快照">存快照</button>
|
<button class="btn" id="btnSnapshot" title="保存当前成绩为一份带标签的时间点快照">存快照</button>
|
||||||
<button class="btn" id="btnTrend" title="查看本卷各快照间每名学生的成绩趋势">趋势</button>
|
<button class="btn" id="btnTrend" title="查看本卷各快照间每名学生的成绩趋势">趋势</button>
|
||||||
<button class="btn" id="btnAnomaly" title="异常检测线索:短时长高分 / 高频重考 / 雷同成绩">异常</button>
|
<button class="btn" id="btnAnomaly" title="异常检测线索:短时长高分 / 高频重考 / 雷同成绩">异常</button>
|
||||||
|
<button class="btn btn-primary" id="btnProjCfg" title="为当前选中的试卷单独设置满分 / 合格率 / 免考名单">试卷设置</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -109,7 +106,7 @@ $rosterCount = Roster::count();
|
|||||||
<div class="card accent-teal"><span>平均分</span><b id="sAvg">-</b></div>
|
<div class="card accent-teal"><span>平均分</span><b id="sAvg">-</b></div>
|
||||||
<div class="card accent-green"><span>最高分</span><b id="sMax">-</b></div>
|
<div class="card accent-green"><span>最高分</span><b id="sMax">-</b></div>
|
||||||
<div class="card accent-red"><span>最低分</span><b id="sMin">-</b></div>
|
<div class="card accent-red"><span>最低分</span><b id="sMin">-</b></div>
|
||||||
<div class="card accent-indigo"><span>合格率(≥60%满分)</span><b id="sPass">-</b></div>
|
<div class="card accent-indigo"><span>合格率<span id="sPassLabel">(≥60%满分)</span></span><b id="sPass">-</b></div>
|
||||||
<div class="card accent-gray"><span>平均用时</span><b id="sDur">-</b></div>
|
<div class="card accent-gray"><span>平均用时</span><b id="sDur">-</b></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -119,6 +116,7 @@ $rosterCount = Roster::count();
|
|||||||
<h2>成绩明细 <small id="tableHint"></small></h2>
|
<h2>成绩明细 <small id="tableHint"></small></h2>
|
||||||
<div class="updated">
|
<div class="updated">
|
||||||
<button class="btn sm" id="btnClearHl" hidden title="清除新数据的绿色高亮标记(每 30 秒自动清除一次)">清除高亮 <b id="hlBadge">0</b></button>
|
<button class="btn sm" id="btnClearHl" hidden title="清除新数据的绿色高亮标记(每 30 秒自动清除一次)">清除高亮 <b id="hlBadge">0</b></button>
|
||||||
|
<button class="btn sm" id="btnPrintTable" title="打印成绩明细(默认按成绩降序,可选分组对比)">打印</button>
|
||||||
最后更新:<span id="lastUpdate">—</span><i id="liveDot" class="dot"></i>
|
最后更新:<span id="lastUpdate">—</span><i id="liveDot" class="dot"></i>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -197,9 +195,14 @@ $rosterCount = Roster::count();
|
|||||||
<p class="hint">实际生效上限 200,超出会自动夹到 200(防止单页内存耗尽)</p>
|
<p class="hint">实际生效上限 200,超出会自动夹到 200(防止单页内存耗尽)</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="fi">
|
<div class="fi">
|
||||||
<label>默认满分(用于合格率)</label>
|
<label>全局默认满分</label>
|
||||||
<input type="number" id="cfgFull" min="1" max="1000">
|
<input type="number" id="cfgFull" min="1" max="1000">
|
||||||
<p class="hint" id="cfgFullHint">未选中试卷时的默认值</p>
|
<p class="hint" id="cfgFullHint">全局默认;每份试卷可在主页面「试卷设置」中单独覆盖</p>
|
||||||
|
</div>
|
||||||
|
<div class="fi">
|
||||||
|
<label>全局默认合格率(%)</label>
|
||||||
|
<input type="number" id="cfgPassRate" min="1" max="100" step="1">
|
||||||
|
<p class="hint">达到满分的该百分比即算合格;每份试卷可单独覆盖</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="fi">
|
<div class="fi">
|
||||||
<label>历史成绩展示方式</label>
|
<label>历史成绩展示方式</label>
|
||||||
@@ -224,15 +227,6 @@ $rosterCount = Roster::count();
|
|||||||
<input type="text" id="cfgKeywords">
|
<input type="text" id="cfgKeywords">
|
||||||
<p class="hint">英文逗号分隔,用于自动识别"姓名"题目</p>
|
<p class="hint">英文逗号分隔,用于自动识别"姓名"题目</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="fi full">
|
|
||||||
<label>各试卷独立满分</label>
|
|
||||||
<div id="fullScoresBox" class="fullscores">
|
|
||||||
<span class="hint">未选中试卷时,此处为空;选中某套试卷后保存即在设置中为该卷单独设定满分</span>
|
|
||||||
</div>
|
|
||||||
<!-- 注意:#fullScoresHint 必须放在 #fullScoresBox 之外,
|
|
||||||
否则 renderFullScores() 的 box.innerHTML 覆盖会把它销毁,导致 textContent 赋值报 null -->
|
|
||||||
<p class="hint" id="fullScoresHint">给某卷填入数字即可单独覆盖默认满分;留空=用默认</p>
|
|
||||||
</div>
|
|
||||||
<div class="fi full checks">
|
<div class="fi full checks">
|
||||||
<label><input type="checkbox" id="cfgDedupe"> 同一学号只保留最新一份答卷</label>
|
<label><input type="checkbox" id="cfgDedupe"> 同一学号只保留最新一份答卷</label>
|
||||||
<label><input type="checkbox" id="cfgTemp"> 包含"暂存"未提交的答卷</label>
|
<label><input type="checkbox" id="cfgTemp"> 包含"暂存"未提交的答卷</label>
|
||||||
@@ -435,6 +429,74 @@ $rosterCount = Roster::count();
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 成绩明细打印设置 -->
|
||||||
|
<div class="modal" id="printTableModal">
|
||||||
|
<div class="modal-box">
|
||||||
|
<div class="modal-head"><h3>打印成绩明细</h3><button class="x" data-close>×</button></div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="fi">
|
||||||
|
<label>排序方式</label>
|
||||||
|
<select id="ptSort">
|
||||||
|
<option value="score_desc" selected>按成绩降序(默认)</option>
|
||||||
|
<option value="score_asc">按成绩升序</option>
|
||||||
|
<option value="sid">按学号</option>
|
||||||
|
<option value="time">按提交时间</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="fi">
|
||||||
|
<label>分组对比</label>
|
||||||
|
<select id="ptGroup">
|
||||||
|
<option value="none" selected>不分组(连续打印)</option>
|
||||||
|
<option value="cls">按班级分组</option>
|
||||||
|
<option value="grp">按组别分组</option>
|
||||||
|
</select>
|
||||||
|
<p class="hint">分组后每个班/组单独一段,段首显示小计(人数/平均分/合格率)</p>
|
||||||
|
</div>
|
||||||
|
<div class="fi checks">
|
||||||
|
<label><input type="checkbox" id="ptRank" checked> 显示名次</label>
|
||||||
|
<label><input type="checkbox" id="ptAttempts" checked> 显示考试次数</label>
|
||||||
|
</div>
|
||||||
|
<p class="hint" id="ptHint"></p>
|
||||||
|
</div>
|
||||||
|
<div class="modal-foot">
|
||||||
|
<span class="spacer"></span>
|
||||||
|
<button class="btn" data-close>取消</button>
|
||||||
|
<button class="btn btn-primary" id="btnPtPrint">打印</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 试卷独立设置(满分 / 合格率 / 免考名单)——仅对当前选中试卷生效 -->
|
||||||
|
<div class="modal" id="projCfgModal">
|
||||||
|
<div class="modal-box">
|
||||||
|
<div class="modal-head"><h3>试卷设置 <small id="projCfgSub"></small></h3><button class="x" data-close>×</button></div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p class="hint" style="margin:0 0 12px">以下设置<b>仅对当前选中的这份试卷生效</b>,各试卷之间互不影响。留空表示跟随全局默认。</p>
|
||||||
|
<div class="fi">
|
||||||
|
<label>本卷满分</label>
|
||||||
|
<input type="number" id="pcFull" min="1" max="1000" placeholder="留空=用全局默认">
|
||||||
|
<p class="hint" id="pcFullHint"></p>
|
||||||
|
</div>
|
||||||
|
<div class="fi">
|
||||||
|
<label>本卷合格率(%)</label>
|
||||||
|
<input type="number" id="pcPassRate" min="1" max="100" step="1" placeholder="留空=用全局默认">
|
||||||
|
<p class="hint" id="pcPassHint"></p>
|
||||||
|
</div>
|
||||||
|
<div class="fi">
|
||||||
|
<label>免考学生(学号)</label>
|
||||||
|
<textarea id="pcExempt" rows="4" placeholder="每行一个学号,也可用逗号分隔"></textarea>
|
||||||
|
<p class="hint">免考学生不计入「名单总人数」与「缺考名单」</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-foot">
|
||||||
|
<button class="btn danger" id="btnPcReset">清除本卷设置</button>
|
||||||
|
<span class="spacer"></span>
|
||||||
|
<button class="btn" data-close>取消</button>
|
||||||
|
<button class="btn btn-primary" id="btnPcSave">保存</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 异常检测线索 -->
|
<!-- 异常检测线索 -->
|
||||||
<div class="modal" id="anomalyModal">
|
<div class="modal" id="anomalyModal">
|
||||||
<div class="modal-box wide">
|
<div class="modal-box wide">
|
||||||
|
|||||||
@@ -79,11 +79,73 @@ class DataService
|
|||||||
/**
|
/**
|
||||||
* 组装结果
|
* 组装结果
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* 读取某份试卷的独立配置(满分 / 合格率 / 免考名单)。
|
||||||
|
* 优先 SQLite(ny_project_config 表);不可用时退回 config.json 里的 project_config 字段,
|
||||||
|
* 保证没有 pdo_sqlite 的环境也能用(只是隔离粒度仍在,只是落到文件)。
|
||||||
|
*
|
||||||
|
* @return array{full_score:?int,pass_rate:?float,exempt:array<int,string>}
|
||||||
|
*/
|
||||||
|
public static function projectConfig(string $projectId): array
|
||||||
|
{
|
||||||
|
$def = ['full_score' => null, 'pass_rate' => null, 'exempt' => []];
|
||||||
|
if ($projectId === '') {
|
||||||
|
return $def;
|
||||||
|
}
|
||||||
|
if (class_exists('Db') && Db::pdo() !== null) {
|
||||||
|
return Db::projectConfigGet($projectId);
|
||||||
|
}
|
||||||
|
// 文件回退
|
||||||
|
$all = [];
|
||||||
|
$saved = ny_read_json(NY_DATA . '/project_config.json', null);
|
||||||
|
if (is_array($saved)) {
|
||||||
|
$all = $saved;
|
||||||
|
}
|
||||||
|
$one = $all[$projectId] ?? null;
|
||||||
|
return is_array($one) ? array_merge($def, $one) : $def;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 写入某份试卷的独立配置 */
|
||||||
|
public static function projectConfigSave(string $projectId, array $cfg): void
|
||||||
|
{
|
||||||
|
if ($projectId === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (class_exists('Db') && Db::pdo() !== null) {
|
||||||
|
Db::projectConfigSet($projectId, $cfg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$all = [];
|
||||||
|
$saved = ny_read_json(NY_DATA . '/project_config.json', null);
|
||||||
|
if (is_array($saved)) {
|
||||||
|
$all = $saved;
|
||||||
|
}
|
||||||
|
$all[$projectId] = $cfg;
|
||||||
|
ny_write_json(NY_DATA . '/project_config.json', $all);
|
||||||
|
}
|
||||||
|
|
||||||
public static function build(array $cfg, string $projectId, string $nameQid = ''): array
|
public static function build(array $cfg, string $projectId, string $nameQid = ''): array
|
||||||
{
|
{
|
||||||
$client = new SurveyKingClient($cfg);
|
$client = new SurveyKingClient($cfg);
|
||||||
|
|
||||||
|
// 每卷独立配置(满分 / 合格率 / 免考名单):试卷之间互相隔离
|
||||||
|
$pCfg = self::projectConfig($projectId);
|
||||||
|
$exempt = [];
|
||||||
|
foreach ((array)($pCfg['exempt'] ?? []) as $sid) {
|
||||||
|
$sid = trim((string)$sid);
|
||||||
|
if ($sid !== '') {
|
||||||
|
$exempt[$sid] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$students = Roster::all();
|
$students = Roster::all();
|
||||||
|
// 免考:从名单中剔除,使其既不计入「总人数」也不计入「缺考」
|
||||||
|
if ($exempt !== []) {
|
||||||
|
$students = array_values(array_filter(
|
||||||
|
$students,
|
||||||
|
static fn(array $s): bool => !isset($exempt[trim((string)($s['sid'] ?? ''))])
|
||||||
|
));
|
||||||
|
}
|
||||||
// 姓名 -> 学号 / 班级(规范化)
|
// 姓名 -> 学号 / 班级(规范化)
|
||||||
$studentInfo = [];
|
$studentInfo = [];
|
||||||
foreach ($students as $s) {
|
foreach ($students as $s) {
|
||||||
@@ -313,6 +375,12 @@ class DataService
|
|||||||
'anomalies' => $anomalies,
|
'anomalies' => $anomalies,
|
||||||
'roster' => $students,
|
'roster' => $students,
|
||||||
'roster_count' => count($students),
|
'roster_count' => count($students),
|
||||||
|
// 本卷独立配置(前端据此回显满分/合格率/免考);full_score 已按本卷解析
|
||||||
|
'project_cfg' => [
|
||||||
|
'full_score' => $pCfg['full_score'] ?? null,
|
||||||
|
'pass_rate' => $pCfg['pass_rate'] ?? null,
|
||||||
|
'exempt' => array_values(array_map('strval', array_keys($exempt))),
|
||||||
|
],
|
||||||
'updated_at' => date('Y-m-d H:i:s'),
|
'updated_at' => date('Y-m-d H:i:s'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
+379
-51
@@ -1,83 +1,411 @@
|
|||||||
<?php
|
<?php
|
||||||
/**
|
/**
|
||||||
* 数据库适配层(旧:pgsql/mysql/sqlite 三方言支持)
|
* 数据库适配层 —— SQLite 单文件后端(零配置、无需额外服务)
|
||||||
* 本批次已下线,仅保留文件存储。如未来需要接入 DB,请在此文件实现 PDO 单例。
|
*
|
||||||
|
* 设计要点:
|
||||||
|
* 1. 数据文件固定为 data/ny.db(SQLite),不需要用户配置任何连接信息。
|
||||||
|
* 2. 若环境没有 pdo_sqlite 扩展,整个层自动降级为「不可用」,
|
||||||
|
* 上层(Roster / 配置)会回退到 JSON 文件存储 —— 保证老环境不会挂。
|
||||||
|
* 3. 所有建表/加列都是幂等的(IF NOT EXISTS / 探测列后 ALTER),可重复执行。
|
||||||
|
* 4. 首次启用时自动把已有的 data/roster.json、data/config.json 迁移进来,
|
||||||
|
* 老数据不丢(迁移只发生在目标表为空时)。
|
||||||
|
*
|
||||||
|
* 表结构:
|
||||||
|
* ny_settings (k TEXT PK, v TEXT) 全局配置(键 'config' 存整份配置 JSON)
|
||||||
|
* ny_roster (sid TEXT PK, name, cls, grp, login)
|
||||||
|
* ny_project_config (project_id TEXT PK, config_json TEXT) 每份试卷独立配置(满分/合格率/免考,试卷间隔离)
|
||||||
*/
|
*/
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
class Db
|
class Db
|
||||||
{
|
{
|
||||||
/** @var PDO|null 当前不可用,始终返回 null */
|
|
||||||
private static ?PDO $pdo = null;
|
private static ?PDO $pdo = null;
|
||||||
|
private static string $err = '';
|
||||||
public static function isConfigured(): bool
|
private static bool $initTried = false;
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function isConnected(): bool
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function driver(): string
|
|
||||||
{
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function lastError(): string
|
|
||||||
{
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function params(): array
|
|
||||||
{
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function saveParams(array $p): bool
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function connect(array $p): PDO
|
|
||||||
{
|
|
||||||
throw new RuntimeException('数据库后端已下线,请使用文件存储(data/config.json / data/roster.json)');
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function pdo(): ?PDO
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function prefix(): string
|
public static function prefix(): string
|
||||||
{
|
{
|
||||||
return 'ny_';
|
return 'ny_';
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function migrate(): int
|
/** 数据文件路径 */
|
||||||
|
public static function dbFile(): string
|
||||||
{
|
{
|
||||||
return 0;
|
return NY_DATA . '/ny.db';
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function ensureRosterLogin(): void {}
|
/** 是否具备启用条件(有 pdo_sqlite 扩展) */
|
||||||
|
public static function isConfigured(): bool
|
||||||
|
{
|
||||||
|
return extension_loaded('pdo_sqlite');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 是否已真正连上 */
|
||||||
|
public static function isConnected(): bool
|
||||||
|
{
|
||||||
|
return self::pdo() !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function driver(): string
|
||||||
|
{
|
||||||
|
return self::pdo() !== null ? 'sqlite' : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function lastError(): string
|
||||||
|
{
|
||||||
|
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 而不抛异常(上层要能优雅降级) */
|
||||||
|
public static function connect(array $p = []): PDO
|
||||||
|
{
|
||||||
|
if (self::$pdo instanceof PDO) {
|
||||||
|
return self::$pdo;
|
||||||
|
}
|
||||||
|
if (!self::isConfigured()) {
|
||||||
|
self::$err = 'PHP 未启用 pdo_sqlite 扩展,已回退到文件存储';
|
||||||
|
throw new RuntimeException(self::$err);
|
||||||
|
}
|
||||||
|
$file = self::dbFile();
|
||||||
|
$dir = dirname($file);
|
||||||
|
if (!is_dir($dir)) {
|
||||||
|
@mkdir($dir, 0777, true);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$pdo = new PDO('sqlite:' . $file, null, null, [
|
||||||
|
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||||
|
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||||
|
PDO::ATTR_EMULATE_PREPARES => false,
|
||||||
|
]);
|
||||||
|
// WAL + 合理超时,避免多人同时刷新时 "database is locked"
|
||||||
|
$pdo->exec('PRAGMA journal_mode=WAL');
|
||||||
|
$pdo->exec('PRAGMA synchronous=NORMAL');
|
||||||
|
$pdo->exec('PRAGMA busy_timeout=5000');
|
||||||
|
$pdo->exec('PRAGMA foreign_keys=ON');
|
||||||
|
self::$pdo = $pdo;
|
||||||
|
self::$err = '';
|
||||||
|
self::migrate();
|
||||||
|
return $pdo;
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
self::$pdo = null;
|
||||||
|
self::$err = $e->getMessage();
|
||||||
|
throw new RuntimeException('SQLite 连接失败:' . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取连接(不可用返回 null) */
|
||||||
|
public static function pdo(): ?PDO
|
||||||
|
{
|
||||||
|
if (self::$pdo instanceof PDO) {
|
||||||
|
return self::$pdo;
|
||||||
|
}
|
||||||
|
if (self::$initTried) {
|
||||||
|
return null; // 已经试过且失败,不再反复重试(每次请求最多试一次)
|
||||||
|
}
|
||||||
|
self::$initTried = true;
|
||||||
|
try {
|
||||||
|
return self::connect();
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 建表 + 索引(幂等,可重复调用) */
|
||||||
|
public static function migrate(): int
|
||||||
|
{
|
||||||
|
$pdo = self::$pdo;
|
||||||
|
if (!$pdo) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
$p = self::prefix();
|
||||||
|
$pdo->exec("CREATE TABLE IF NOT EXISTS {$p}settings (
|
||||||
|
k TEXT PRIMARY KEY,
|
||||||
|
v TEXT NOT NULL DEFAULT '',
|
||||||
|
updated_at INTEGER NOT NULL DEFAULT 0
|
||||||
|
)");
|
||||||
|
$pdo->exec("CREATE TABLE IF NOT EXISTS {$p}roster (
|
||||||
|
sid TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL DEFAULT '',
|
||||||
|
cls TEXT NOT NULL DEFAULT '',
|
||||||
|
grp TEXT NOT NULL DEFAULT '',
|
||||||
|
login TEXT NOT NULL DEFAULT '',
|
||||||
|
sort INTEGER NOT NULL DEFAULT 0,
|
||||||
|
updated_at INTEGER NOT NULL DEFAULT 0
|
||||||
|
)");
|
||||||
|
$pdo->exec("CREATE TABLE IF NOT EXISTS {$p}project_config (
|
||||||
|
project_id TEXT PRIMARY KEY,
|
||||||
|
config_json TEXT NOT NULL DEFAULT '{}',
|
||||||
|
updated_at INTEGER NOT NULL DEFAULT 0
|
||||||
|
)");
|
||||||
|
$pdo->exec("CREATE INDEX IF NOT EXISTS idx_{$p}roster_cls ON {$p}roster(cls)");
|
||||||
|
self::ensureRosterLogin();
|
||||||
|
return 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 旧库缺少 login 列时补上(幂等) */
|
||||||
|
public static function ensureRosterLogin(): void
|
||||||
|
{
|
||||||
|
$pdo = self::$pdo;
|
||||||
|
if (!$pdo) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$p = self::prefix();
|
||||||
|
try {
|
||||||
|
$cols = $pdo->query("PRAGMA table_info({$p}roster)")->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
$has = false;
|
||||||
|
foreach ($cols as $c) {
|
||||||
|
if (($c['name'] ?? '') === 'login') {
|
||||||
|
$has = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!$has) {
|
||||||
|
$pdo->exec("ALTER TABLE {$p}roster ADD COLUMN login TEXT NOT NULL DEFAULT ''");
|
||||||
|
}
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
self::$err = $e->getMessage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- 设置 ----------------
|
||||||
|
|
||||||
public static function settingsAll(): array
|
public static function settingsAll(): array
|
||||||
{
|
{
|
||||||
return [];
|
$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 [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function settingsSet(string $k, string $v): void {}
|
/** SQLite 用 ON CONFLICT 做 upsert(其它方言见历史版本) */
|
||||||
|
public static function settingsSet(string $k, string $v): void
|
||||||
|
{
|
||||||
|
$pdo = self::pdo();
|
||||||
|
if (!$pdo) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$p = self::prefix();
|
||||||
|
$st = $pdo->prepare(
|
||||||
|
"INSERT INTO {$p}settings (k, v, updated_at) VALUES (?, ?, ?)
|
||||||
|
ON CONFLICT(k) DO UPDATE SET v=excluded.v, updated_at=excluded.updated_at"
|
||||||
|
);
|
||||||
|
$st->execute([$k, $v, time()]);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
self::$err = $e->getMessage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function settingsGet(string $k, string $def = ''): string
|
||||||
|
{
|
||||||
|
$pdo = self::pdo();
|
||||||
|
if (!$pdo) {
|
||||||
|
return $def;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$p = self::prefix();
|
||||||
|
$st = $pdo->prepare("SELECT v FROM {$p}settings WHERE k = ?");
|
||||||
|
$st->execute([$k]);
|
||||||
|
$v = $st->fetchColumn();
|
||||||
|
return $v === false ? $def : (string)$v;
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
self::$err = $e->getMessage();
|
||||||
|
return $def;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- 名单 ----------------
|
||||||
|
|
||||||
public static function rosterAll(): array
|
public static function rosterAll(): array
|
||||||
{
|
{
|
||||||
return [];
|
$pdo = self::pdo();
|
||||||
|
if (!$pdo) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$p = self::prefix();
|
||||||
|
$st = $pdo->query("SELECT sid, name, cls, grp, login FROM {$p}roster ORDER BY sort ASC, sid ASC");
|
||||||
|
$out = [];
|
||||||
|
foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $r) {
|
||||||
|
$out[] = [
|
||||||
|
'sid' => (string)($r['sid'] ?? ''),
|
||||||
|
'name' => (string)($r['name'] ?? ''),
|
||||||
|
'cls' => (string)($r['cls'] ?? ''),
|
||||||
|
'grp' => (string)($r['grp'] ?? ''),
|
||||||
|
'login' => (string)($r['login'] ?? ''),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
self::$err = $e->getMessage();
|
||||||
|
return [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function rosterReplace(array $students): void {}
|
/** 全量替换名单(roster_save 语义) */
|
||||||
|
public static function rosterReplace(array $students): void
|
||||||
|
{
|
||||||
|
$pdo = self::pdo();
|
||||||
|
if (!$pdo) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$p = self::prefix();
|
||||||
|
$pdo->beginTransaction();
|
||||||
|
$pdo->exec("DELETE FROM {$p}roster");
|
||||||
|
$st = $pdo->prepare(
|
||||||
|
"INSERT INTO {$p}roster (sid, name, cls, grp, login, sort, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)"
|
||||||
|
);
|
||||||
|
$i = 0;
|
||||||
|
foreach ($students as $s) {
|
||||||
|
if (!is_array($s)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$st->execute([
|
||||||
|
(string)($s['sid'] ?? ''),
|
||||||
|
(string)($s['name'] ?? ''),
|
||||||
|
(string)($s['cls'] ?? ''),
|
||||||
|
(string)($s['grp'] ?? ''),
|
||||||
|
(string)($s['login'] ?? ''),
|
||||||
|
$i++,
|
||||||
|
time(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
$pdo->commit();
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
if ($pdo->inTransaction()) {
|
||||||
|
$pdo->rollBack();
|
||||||
|
}
|
||||||
|
self::$err = $e->getMessage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public static function rosterCount(): int
|
public static function rosterCount(): int
|
||||||
{
|
{
|
||||||
return 0;
|
$pdo = self::pdo();
|
||||||
|
if (!$pdo) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$p = self::prefix();
|
||||||
|
return (int)$pdo->query("SELECT COUNT(*) FROM {$p}roster")->fetchColumn();
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
self::$err = $e->getMessage();
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- 每卷独立配置(满分 / 合格率 / 免考名单) ----------------
|
||||||
|
|
||||||
|
/** @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 单卷配置(缺省返回空结构) */
|
||||||
|
public static function projectConfigGet(string $projectId): array
|
||||||
|
{
|
||||||
|
$def = ['full_score' => null, 'pass_rate' => null, 'exempt' => []];
|
||||||
|
if ($projectId === '') {
|
||||||
|
return $def;
|
||||||
|
}
|
||||||
|
$pdo = self::pdo();
|
||||||
|
if (!$pdo) {
|
||||||
|
return $def;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$p = self::prefix();
|
||||||
|
$st = $pdo->prepare("SELECT config_json FROM {$p}project_config WHERE project_id = ?");
|
||||||
|
$st->execute([$projectId]);
|
||||||
|
$v = $st->fetchColumn();
|
||||||
|
if ($v === false) {
|
||||||
|
return $def;
|
||||||
|
}
|
||||||
|
$j = json_decode((string)$v, true);
|
||||||
|
return is_array($j) ? array_merge($def, $j) : $def;
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
self::$err = $e->getMessage();
|
||||||
|
return $def;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 写入单卷配置(整份覆盖;null 表示"用全局默认") */
|
||||||
|
public static function projectConfigSet(string $projectId, array $cfg): void
|
||||||
|
{
|
||||||
|
if ($projectId === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$pdo = self::pdo();
|
||||||
|
if (!$pdo) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$p = self::prefix();
|
||||||
|
$st = $pdo->prepare(
|
||||||
|
"INSERT INTO {$p}project_config (project_id, config_json, updated_at) VALUES (?, ?, ?)
|
||||||
|
ON CONFLICT(project_id) DO UPDATE SET config_json=excluded.config_json, updated_at=excluded.updated_at"
|
||||||
|
);
|
||||||
|
$st->execute([$projectId, json_encode($cfg, JSON_UNESCAPED_UNICODE), time()]);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
self::$err = $e->getMessage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+43
-1
@@ -19,10 +19,49 @@ class Roster
|
|||||||
return class_exists('Db') && Db::pdo() !== null;
|
return class_exists('Db') && Db::pdo() !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 首次启用 SQLite 时,把已有的 data/roster.json 迁进数据库(老数据不丢)。
|
||||||
|
* 只在「DB 名单为空 且 文件有数据」时执行一次,之后由 DB 全权负责。
|
||||||
|
*/
|
||||||
|
private static function migrateFileToDbOnce(): void
|
||||||
|
{
|
||||||
|
if (!self::useDb()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (Db::rosterCount() > 0) {
|
||||||
|
return; // DB 已有数据,不覆盖
|
||||||
|
}
|
||||||
|
$d = ny_read_json(self::file(), null);
|
||||||
|
if (!is_array($d) || empty($d['students']) || !is_array($d['students'])) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$clean = [];
|
||||||
|
foreach ($d['students'] as $s) {
|
||||||
|
if (!is_array($s)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$clean[] = [
|
||||||
|
'sid' => trim((string)($s['sid'] ?? '')),
|
||||||
|
'name' => trim((string)($s['name'] ?? '')),
|
||||||
|
'cls' => trim((string)($s['cls'] ?? '')),
|
||||||
|
'grp' => trim((string)($s['grp'] ?? '')),
|
||||||
|
'login' => trim((string)($s['login'] ?? '')),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if ($clean !== []) {
|
||||||
|
Db::rosterReplace($clean);
|
||||||
|
}
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
// 迁移失败不影响主流程:仍可退回文件读取
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** @return array<int,array{sid:string,name:string,cls:string}> */
|
/** @return array<int,array{sid:string,name:string,cls:string}> */
|
||||||
public static function all(): array
|
public static function all(): array
|
||||||
{
|
{
|
||||||
if (self::useDb()) {
|
if (self::useDb()) {
|
||||||
|
self::migrateFileToDbOnce();
|
||||||
return Db::rosterAll();
|
return Db::rosterAll();
|
||||||
}
|
}
|
||||||
$d = ny_read_json(self::file(), null);
|
$d = ny_read_json(self::file(), null);
|
||||||
@@ -105,7 +144,10 @@ class Roster
|
|||||||
public static function fingerprint(): string
|
public static function fingerprint(): string
|
||||||
{
|
{
|
||||||
if (self::useDb()) {
|
if (self::useDb()) {
|
||||||
return 'db:' . self::count();
|
// 人数 + 内容哈希:名单内容变了指纹才变(只靠人数会在"换人"时误命中缓存)
|
||||||
|
$n = self::count();
|
||||||
|
$hash = md5(json_encode(self::all(), JSON_UNESCAPED_UNICODE));
|
||||||
|
return 'db:' . $n . ':' . $hash;
|
||||||
}
|
}
|
||||||
$f = self::file();
|
$f = self::file();
|
||||||
$mt = is_file($f) ? (string)filemtime($f) : '0';
|
$mt = is_file($f) ? (string)filemtime($f) : '0';
|
||||||
|
|||||||
+55
-27
@@ -20,6 +20,17 @@ const hasToken = fs.existsSync(tokenFile);
|
|||||||
const tokenSnap = hasToken ? fs.readFileSync(tokenFile, 'utf8') : null;
|
const tokenSnap = hasToken ? fs.readFileSync(tokenFile, 'utf8') : null;
|
||||||
if (hasToken) fs.renameSync(tokenFile, tokenBak);
|
if (hasToken) fs.renameSync(tokenFile, tokenBak);
|
||||||
|
|
||||||
|
// SQLite 优先:若不先移走 data/ny.db,里面的旧配置会盖掉本测试写入的 config.json,
|
||||||
|
// 导致登录用的是上一次遗留的账号密码(表现为"登录成功但 auth_check=false")。
|
||||||
|
// 同时清掉登录限流记录,避免上一轮失败把本轮锁住。
|
||||||
|
const dbFile = path.join(ROOT, 'data', 'ny.db');
|
||||||
|
const dbBak = dbFile + '.bak';
|
||||||
|
const hasDb = fs.existsSync(dbFile);
|
||||||
|
if (hasDb) { try { fs.renameSync(dbFile, dbBak); } catch (e) {} }
|
||||||
|
|
||||||
|
const throttleFile = path.join(ROOT, 'data', 'login_fail.json');
|
||||||
|
if (fs.existsSync(throttleFile)) { try { fs.unlinkSync(throttleFile); } catch (e) {} }
|
||||||
|
|
||||||
fs.writeFileSync(cfgFile, JSON.stringify({
|
fs.writeFileSync(cfgFile, JSON.stringify({
|
||||||
base_url: 'http://127.0.0.1:19911',
|
base_url: 'http://127.0.0.1:19911',
|
||||||
api_prefix: '/api',
|
api_prefix: '/api',
|
||||||
@@ -51,6 +62,14 @@ function cleanup() {
|
|||||||
try {
|
try {
|
||||||
if (hasToken && fs.existsSync(tokenBak)) fs.renameSync(tokenBak, tokenFile);
|
if (hasToken && fs.existsSync(tokenBak)) fs.renameSync(tokenBak, tokenFile);
|
||||||
} catch(e) { console.error('cleanup token failed:', e.message); }
|
} catch(e) { console.error('cleanup token failed:', e.message); }
|
||||||
|
// 恢复 SQLite(本轮测试新建的 ny.db 丢弃,换回原有的)
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(dbFile)) fs.unlinkSync(dbFile);
|
||||||
|
if (hasDb && fs.existsSync(dbBak)) fs.renameSync(dbBak, dbFile);
|
||||||
|
} catch(e) { console.error('cleanup db failed:', e.message); }
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(throttleFile)) fs.unlinkSync(throttleFile);
|
||||||
|
} catch(e) {}
|
||||||
}
|
}
|
||||||
process.on('exit', cleanup);
|
process.on('exit', cleanup);
|
||||||
process.on('SIGINT', () => { cleanup(); process.exit(1); });
|
process.on('SIGINT', () => { cleanup(); process.exit(1); });
|
||||||
@@ -235,35 +254,44 @@ async function login() {
|
|||||||
const after = fs.existsSync(tokenFile) ? fs.readFileSync(tokenFile, 'utf8') : '';
|
const after = fs.existsSync(tokenFile) ? fs.readFileSync(tokenFile, 'utf8') : '';
|
||||||
chk('token 保持未变', before === after, 'changed=' + (before !== after));
|
chk('token 保持未变', before === after, 'changed=' + (before !== after));
|
||||||
|
|
||||||
console.log('\n--- 4) 各试卷独立满分:仅当 apply_pid 显式指定时写 ---');
|
console.log('\n--- 4) 试卷独立配置(满分/合格率/免考)已从设置移出,改走 project_config ---');
|
||||||
// 不传 apply_pid,也不传 full_scores → full_scores 应保留原值(保持空映射)
|
// 每卷满分已不在 config_save 里;改为 project_config_save(试卷间隔离)
|
||||||
const cs2 = await req('POST', '/api.php?action=config_save', {
|
const pc1 = await req('POST', '/api.php?action=project_config_save&projectId=p1', {
|
||||||
full_score: 90,
|
full_score: 150, pass_rate: 70, exempt: 'S1,S2',
|
||||||
});
|
});
|
||||||
const _cs2fs = cs2.body && cs2.body.data && cs2.body.data.config
|
chk('project_config_save 200', pc1.status === 200, 'status=' + pc1.status + ' ' + JSON.stringify(pc1.body));
|
||||||
? cs2.body.data.config.full_scores : undefined;
|
chk('生效满分=150', pc1.body && pc1.body.data && pc1.body.data.effective
|
||||||
chk('未传 apply_pid → full_scores 保持空映射(不被覆盖为非空)',
|
&& pc1.body.data.effective.full_score === 150,
|
||||||
_cs2fs === undefined || Object.keys(_cs2fs).length === 0,
|
JSON.stringify(pc1.body && pc1.body.data && pc1.body.data.effective));
|
||||||
JSON.stringify(_cs2fs));
|
chk('生效合格率=70', pc1.body && pc1.body.data && pc1.body.data.effective
|
||||||
// 显式 apply_pid='p1', apply_score=85 → 写入
|
&& Number(pc1.body.data.effective.pass_rate) === 70,
|
||||||
const cs3 = await req('POST', '/api.php?action=config_save', {
|
JSON.stringify(pc1.body && pc1.body.data && pc1.body.data.effective));
|
||||||
full_score: 90,
|
|
||||||
apply_pid: 'p1',
|
// 隔离性:p2 未配置 → 跟随全局(注:上面的 config_save 已把全局满分改成 95,
|
||||||
apply_score: 85,
|
// 所以这里不能写死 100,必须与响应里的 global 比对)
|
||||||
|
const pc2 = await req('GET', '/api.php?action=project_config&projectId=p2');
|
||||||
|
const gFs = pc2.body && pc2.body.data && pc2.body.data.global ? pc2.body.data.global.full_score : null;
|
||||||
|
const gPr = pc2.body && pc2.body.data && pc2.body.data.global ? pc2.body.data.global.pass_rate : null;
|
||||||
|
chk('p2 隔离:满分跟随全局(=' + gFs + ')', pc2.body && pc2.body.data && pc2.body.data.effective
|
||||||
|
&& pc2.body.data.effective.full_score === gFs,
|
||||||
|
JSON.stringify(pc2.body && pc2.body.data && pc2.body.data.effective) + ' global=' + gFs);
|
||||||
|
chk('p2 隔离:合格率跟随全局(=' + gPr + ')', pc2.body && pc2.body.data && pc2.body.data.effective
|
||||||
|
&& Number(pc2.body.data.effective.pass_rate) === Number(gPr),
|
||||||
|
JSON.stringify(pc2.body && pc2.body.data && pc2.body.data.effective) + ' global=' + gPr);
|
||||||
|
|
||||||
|
// 免考名单往返
|
||||||
|
const pc3 = await req('GET', '/api.php?action=project_config&projectId=p1');
|
||||||
|
const ex = pc3.body && pc3.body.data && pc3.body.data.cfg && pc3.body.data.cfg.exempt;
|
||||||
|
chk('免考名单往返 = [S1,S2]', Array.isArray(ex) && ex.length === 2 && ex[0] === 'S1' && ex[1] === 'S2',
|
||||||
|
JSON.stringify(ex));
|
||||||
|
|
||||||
|
// 清除本卷设置 → 回到全局
|
||||||
|
const pc4 = await req('POST', '/api.php?action=project_config_save&projectId=p1', {
|
||||||
|
full_score: '', pass_rate: '', exempt: '',
|
||||||
});
|
});
|
||||||
chk('apply_pid=85 → full_scores.p1=85', cs3.body && cs3.body.data && cs3.body.data.config
|
chk('清除后 p1 回到全局满分(=' + gFs + ')', pc4.body && pc4.body.data && pc4.body.data.effective
|
||||||
&& cs3.body.data.config.full_scores && cs3.body.data.config.full_scores.p1 === 85,
|
&& pc4.body.data.effective.full_score === gFs,
|
||||||
JSON.stringify(cs3.body.data && cs3.body.data.config && cs3.body.data.config.full_scores));
|
JSON.stringify(pc4.body && pc4.body.data && pc4.body.data.effective) + ' global=' + gFs);
|
||||||
// apply_pid='p1', apply_score=0 → 清除
|
|
||||||
const cs4 = await req('POST', '/api.php?action=config_save', {
|
|
||||||
full_score: 90,
|
|
||||||
apply_pid: 'p1',
|
|
||||||
apply_score: 0,
|
|
||||||
});
|
|
||||||
chk('apply_pid=0 → 清除 p1',
|
|
||||||
cs4.body && cs4.body.data && cs4.body.data.config
|
|
||||||
&& (!cs4.body.data.config.full_scores || cs4.body.data.config.full_scores.p1 === undefined),
|
|
||||||
JSON.stringify(cs4.body.data && cs4.body.data.config && cs4.body.data.config.full_scores));
|
|
||||||
|
|
||||||
console.log('\n--- 5) 修改连接信息:应清 token + 重新登录(ping)---');
|
console.log('\n--- 5) 修改连接信息:应清 token + 重新登录(ping)---');
|
||||||
const tokBefore = fs.existsSync(tokenFile) ? fs.readFileSync(tokenFile, 'utf8') : '';
|
const tokBefore = fs.existsSync(tokenFile) ? fs.readFileSync(tokenFile, 'utf8') : '';
|
||||||
|
|||||||
Reference in New Issue
Block a user