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 读取类接口不受影响)
|
||||
$WRITE_ACTIONS = [
|
||||
'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()) {
|
||||
ny_json(['ok' => false, 'msg' => '安全校验失败(CSRF token 缺失或不匹配),请刷新页面后重试'], 403);
|
||||
@@ -194,9 +194,11 @@ try {
|
||||
$v = (string)$in['export_sort'];
|
||||
$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'])) {
|
||||
$new['full_scores'] = $in['full_scores'];
|
||||
// 说明:「各试卷独立满分」已移出设置,改为在主页面针对当前试卷配置
|
||||
// (project_config / project_config_save),设置里只保留「全局默认满分」。
|
||||
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['api_prefix'] = '/' . trim((string)$new['api_prefix'], '/');
|
||||
@@ -211,18 +213,6 @@ try {
|
||||
$fs = 100;
|
||||
}
|
||||
$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 + 重连;保存满分/布局等不应触发登录卷王
|
||||
$connKeys = ['base_url', 'api_prefix', 'username', 'password', 'verify_ssl'];
|
||||
$connChanged = false;
|
||||
@@ -329,15 +319,13 @@ try {
|
||||
$c = cfg();
|
||||
$built = ny_build_data($c, $projectId, $nameQid, !empty($_GET['force']));
|
||||
$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);
|
||||
if ($fs < 1) {
|
||||
$fs = 100;
|
||||
}
|
||||
// 满分:本卷独立优先,其次全局默认
|
||||
[$fs, $passRate] = ny_resolve_score_cfg($projectId, $c);
|
||||
$result['config'] = [
|
||||
'refresh_interval' => (int)$c['refresh_interval'],
|
||||
'base_url' => $c['base_url'],
|
||||
'full_score' => $fs,
|
||||
'pass_rate' => $passRate,
|
||||
];
|
||||
// 缓存来源标记:便于前端/排查时知道这次是实时拉取还是命中短期缓存
|
||||
$result['_cache'] = ['cached' => $built['cached'], 'at' => $built['at']];
|
||||
@@ -353,11 +341,7 @@ try {
|
||||
$c = cfg();
|
||||
$built = ny_build_data($c, $projectId, $nameQid, !empty($_GET['force']));
|
||||
$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);
|
||||
if ($fs < 1) {
|
||||
$fs = 100;
|
||||
}
|
||||
[$fs, $passRate] = ny_resolve_score_cfg($projectId, $c);
|
||||
|
||||
$students = $result['roster'] ?: [];
|
||||
$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);
|
||||
$submitted = count($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));
|
||||
$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;
|
||||
$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;
|
||||
@@ -473,6 +458,7 @@ try {
|
||||
ny_ok([
|
||||
'project' => $result['project'],
|
||||
'full_score' => $fs,
|
||||
'pass_rate' => $passRate,
|
||||
'overall' => $result['stats'],
|
||||
'classes' => $classes,
|
||||
'updated_at' => $result['updated_at'],
|
||||
@@ -632,6 +618,74 @@ try {
|
||||
}
|
||||
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_absent':
|
||||
|
||||
Reference in New Issue
Block a user