(float)$y['score']) * $dir; if ($d !== 0) { return $d; } return ((int)($y['submit_ts'] ?? 0)) <=> ((int)($x['submit_ts'] ?? 0)); }); return $rows; } /** 缺考名单工作表行(按学号升序) */ function ny_absent_sheet_rows(array $absent): array { usort($absent, static function ($x, $y): int { $a = trim((string)($x['sid'] ?? '')); $b = trim((string)($y['sid'] ?? '')); if (is_numeric($a) && is_numeric($b)) { return $a <=> $b; } return strnatcmp($a, $b); }); $out = [['学号', '姓名', '班级', '组别']]; foreach ($absent as $r) { $out[] = [ (string)($r['sid'] ?? ''), (string)($r['name'] ?? ''), (string)($r['cls'] ?? ''), (string)($r['grp'] ?? ''), ]; } return $out; } /** * 带短期缓存的数据构建(含并发锁,避免多人同时刷新时重复打卷王造成惊群) * * 缓存 key 由「项目 + 姓名字段 + 连接/去重等配置 + 名单指纹」组成: * 名单一变指纹就变,因此不会出现"改了名单还显示旧数据"。 * force=true 时跳过缓存(前端"立即刷新"按钮使用)。 * * @return array{result:array,cached:bool,at:int} */ function ny_build_data(array $c, string $projectId, string $nameQid, bool $force = false): array { $ttl = (int)($c['cache_ttl'] ?? 15); if ($ttl < 0) { $ttl = 0; } $key = DataCache::key([ $projectId, $nameQid, (string)$c['base_url'], (string)$c['api_prefix'], (string)$c['username'], !empty($c['dedupe']) ? 1 : 0, !empty($c['include_temp_save']) ? 1 : 0, (string)($c['name_keywords'] ?? ''), (int)($c['page_size'] ?? 100), Roster::fingerprint(), ]); if ($ttl > 0 && !$force) { $hit = DataCache::read($key, $ttl); if ($hit !== null) { return ['result' => $hit['result'], 'cached' => true, 'at' => $hit['at']]; } } // 并发锁:同一 key 只允许一个请求真正去拉卷王 $lock = DataCache::acquireLock($key); if ($lock === null) { // 已有别的请求在构建:短暂等待其落盘后直接复用(允许略旧,总比同时压垮卷王好) for ($i = 0; $i < 15; $i++) { usleep(200000); // 200ms $hit = DataCache::read($key, max($ttl, 60)); if ($hit !== null) { return ['result' => $hit['result'], 'cached' => true, 'at' => $hit['at']]; } } // 等不到(构建很慢或锁不被支持)就自己构建,保证可用性 } $result = DataService::build($c, $projectId, $nameQid); if ($ttl > 0) { DataCache::write($key, $result); } DataCache::releaseLock($lock); return ['result' => $result, 'cached' => false, 'at' => time()]; } // 会话保护:除 auth_check 外,未登录一律拒访 if ($action !== 'auth_check' && !ny_is_auth()) { ny_json(['ok' => false, 'msg' => '未登录或会话已过期,请重新登录'], 401); exit; } // CSRF 防护:所有写操作必须携带会话绑定的 token(GET 读取类接口不受影响) $WRITE_ACTIONS = [ 'config_save', 'roster_save', 'roster_import', 'roster_clear', 'snapshot_save', 'snapshot_delete', 'unmatched_map', ]; if (in_array($action, $WRITE_ACTIONS, true) && !ny_csrf_ok()) { ny_json(['ok' => false, 'msg' => '安全校验失败(CSRF token 缺失或不匹配),请刷新页面后重试'], 403); exit; } try { switch ($action) { // 登录状态检查 case 'auth_check': ny_ok(['auth' => ny_is_auth(), 'installed' => ny_is_installed()]); // ---------------- 配置 ---------------- case 'config_get': $c = cfg(); $c['has_password'] = ((string)$c['password']) !== ''; unset($c['password']); $c['csrf_token'] = ny_csrf_token(); // 诊断信息:配置存在哪里、数据库通不通(登录卷王失败时优先看这里) $c['storage'] = ny_config_source(); $c['db_driver'] = class_exists('Db') ? Db::driver() : ''; $c['db_configured'] = class_exists('Db') && Db::isConfigured(); $c['db_connected'] = class_exists('Db') && Db::isConnected(); $c['db_error'] = class_exists('Db') ? Db::lastError() : ''; ny_ok($c); case 'config_save': $raw = (string)file_get_contents('php://input'); $in = json_decode($raw, true); if (!is_array($in)) { $in = $_POST; } $old = cfg(); $allowed = ['base_url', 'api_prefix', 'username', 'timeout', 'refresh_interval', 'page_size', 'name_keywords', 'history_display', 'export_sort']; $new = $old; foreach ($allowed as $k) { if (array_key_exists($k, $in)) { $new[$k] = is_string($in[$k]) ? trim((string)$in[$k]) : $in[$k]; } } // 密码:保留首尾空格的原貌,空 / 占位符 = 不修改 if (array_key_exists('password', $in)) { $pwdIn = (string)$in['password']; if ($pwdIn !== '' && $pwdIn !== '******') { $new['password'] = $pwdIn; } } foreach (['verify_ssl', 'include_temp_save', 'dedupe'] as $k) { if (array_key_exists($k, $in)) { $new[$k] = filter_var($in[$k], FILTER_VALIDATE_BOOL); } } // 布局配置 if (array_key_exists('layout', $in)) { $new['layout'] = ($in['layout'] === 'side') ? 'side' : 'default'; } // 历史成绩展示方式:click(点击成员弹窗,默认) / inline(明细内全部展开) if (array_key_exists('history_display', $in)) { $new['history_display'] = ($in['history_display'] === 'inline') ? 'inline' : 'click'; } // 成绩导出排序:time(按提交时间降序,默认)/ sid(按学号)/ score_desc(成绩降序)/ score_asc(成绩升序) if (array_key_exists('export_sort', $in)) { $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']; } $new['base_url'] = rtrim((string)$new['base_url'], '/'); $new['api_prefix'] = '/' . trim((string)$new['api_prefix'], '/'); $new['timeout'] = (int)$new['timeout']; $new['refresh_interval'] = (int)$new['refresh_interval']; if ($new['refresh_interval'] < 3) { $new['refresh_interval'] = 3; } // 默认满分 $fs = (int)($in['full_score'] ?? $new['full_score'] ?? 100); if ($fs < 1) { $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; foreach ($connKeys as $k) { if ((string)($old[$k] ?? '') !== (string)($new[$k] ?? '')) { $connChanged = true; break; } } ny_save_config($new); if ($connChanged) { @unlink(NY_DATA . '/cache/token.json'); } // 返回的 config 不再带明文密码 $outCfg = ny_load_config(); $outCfg['has_password'] = ((string)$outCfg['password']) !== ''; unset($outCfg['password']); $res = [ 'saved' => true, 'config' => $outCfg, 'conn_changed' => $connChanged, 'storage' => ny_config_source(), ]; // 连接信息修改后自动测一次,给出明确结果(避免用户配置对错无从知晓) if ($connChanged) { try { $client = new SurveyKingClient(ny_load_config()); $p = $client->ping(); $res['ping'] = ['ok' => true, 'projects' => $p['projects'], 'elapsed' => $p['elapsed']]; } catch (Throwable $e) { $res['ping'] = ['ok' => false, 'msg' => $e->getMessage()]; } } ny_ok($res); // ---------------- 用弹窗内(未保存)的凭据测试连接 ---------------- case 'config_test': $raw = (string)file_get_contents('php://input'); $in = json_decode($raw, true); if (!is_array($in)) { $in = $_POST; } // 以已保存配置为基底,仅覆盖弹窗中填写的字段,避免漏填导致误测 $test = ny_load_config(); foreach (['base_url', 'api_prefix', 'username', 'verify_ssl', 'timeout'] as $k) { if (array_key_exists($k, $in)) { $test[$k] = is_string($in[$k]) ? trim((string)$in[$k]) : $in[$k]; } } if (array_key_exists('password', $in)) { $pwdIn = (string)$in['password']; if ($pwdIn !== '' && $pwdIn !== '******') { $test['password'] = $pwdIn; } } $test['base_url'] = rtrim((string)($test['base_url'] ?? ''), '/'); $test['api_prefix'] = '/' . trim((string)($test['api_prefix'] ?? '/api'), '/'); try { $client = new SurveyKingClient($test); $p = $client->ping(); ny_ok(['connected' => true, 'projects' => $p['projects'], 'elapsed' => $p['elapsed']]); } catch (Throwable $e) { ny_ok(['connected' => false, 'msg' => $e->getMessage()]); } // ---------------- 连接测试 ---------------- case 'ping': $client = new SurveyKingClient(cfg()); $res = $client->ping(); $res['session'] = $client->sessionInfo(); ny_ok($res); case 'diag': $client = new SurveyKingClient(cfg()); ny_ok($client->diagnose()); // ---------------- 问卷列表 ---------------- case 'projects': $client = new SurveyKingClient(cfg()); $list = $client->listProjects(); $out = []; foreach ($list as $p) { if (!is_array($p)) { continue; } $out[] = [ 'id' => ny_str($p['id'] ?? ''), 'name' => ny_str($p['name'] ?? '') ?: '未命名', 'mode' => ny_str($p['mode'] ?? ''), 'total' => (int)($p['total'] ?? 0), 'status' => (int)($p['status'] ?? 0), 'parent_id' => (string)($p['parentId'] ?? '0'), ]; } ny_ok($out); // ---------------- 数据 ---------------- case 'data': $projectId = (string)($_GET['projectId'] ?? ''); if ($projectId === '') { ny_fail('请先选择问卷/考试'); } $nameQid = (string)($_GET['nameQid'] ?? ''); $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; } $result['config'] = [ 'refresh_interval' => (int)$c['refresh_interval'], 'base_url' => $c['base_url'], 'full_score' => $fs, ]; // 缓存来源标记:便于前端/排查时知道这次是实时拉取还是命中短期缓存 $result['_cache'] = ['cached' => $built['cached'], 'at' => $built['at']]; ny_ok($result); // ---------------- 数据看板(按班级聚合,面向班主任/学生整体情况) ---------------- case 'dashboard': $projectId = (string)($_GET['projectId'] ?? ''); if ($projectId === '') { ny_fail('请先选择问卷/考试'); } $nameQid = (string)($_GET['nameQid'] ?? ''); $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; } $students = $result['roster'] ?: []; $rows = $result['rows'] ?: []; $byCls = []; foreach ($students as $s) { $k = trim((string)($s['cls'] ?? '')) ?: '(未分班)'; $byCls[$k][] = $s; } $rowsByCls = []; foreach ($rows as $r) { $k = trim((string)($r['cls'] ?? '')) ?: '(未分班)'; $rowsByCls[$k][] = $r; } // 通用统计器:给定学生集合 + 答卷集合,算一组指标 $statOf = static function (array $stu, array $rs, int $fs): 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))); $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; return [ 'total' => $total, 'submitted' => $submitted, 'attempts' => $attempts, 'absent' => max(0, $total - $submitted), 'rate' => $total > 0 ? round($submitted / $total * 100, 1) : 0, 'avg' => $avg, 'pass_rate' => $passRate, 'max' => $scores !== [] ? max($scores) : null, 'min' => $scores !== [] ? min($scores) : null, 'avg_duration' => $avgDur, ]; }; // 已提交成员榜:按成绩从高到低,缺成绩者排最后,并给出排名 // 透传 attempts / attempts_list,供前端展示"同人多刷卷" $membersOf = static function (array $rs): array { $ms = []; foreach ($rs as $r) { $ms[] = [ 'sid' => (string)($r['sid'] ?? ''), 'name' => (string)($r['name'] ?? ''), 'score' => ($r['score'] ?? null), 'attempts' => (int)($r['attempts'] ?? 1), 'attempts_list' => $r['attempts_list'] ?? [], ]; } usort($ms, static function (array $a, array $b): int { $as = $a['score']; $bs = $b['score']; if ($as === null && $bs === null) { return 0; } if ($as === null) { return 1; // 无成绩排最后 } if ($bs === null) { return -1; } return $bs <=> $as; // 成绩降序 }); foreach ($ms as $i => $m) { $ms[$i]['rank'] = $i + 1; } return $ms; }; $classes = []; foreach ($byCls as $k => $stu) { $rs = $rowsByCls[$k] ?? []; $row = $statOf($stu, $rs, $fs); $row['class'] = $k; $row['members'] = $membersOf($rs); // 班级内按组别(A/B 等)再聚合,用于看板下钻对比 $stuByGrp = []; foreach ($stu as $s) { $g = trim((string)($s['grp'] ?? '')) ?: '(未分组)'; $stuByGrp[$g][] = $s; } $rowsByGrp = []; foreach ($rs as $r) { $g = trim((string)($r['grp'] ?? '')) ?: '(未分组)'; $rowsByGrp[$g][] = $r; } $groups = []; foreach ($stuByGrp as $g => $gstu) { $gs = $statOf($gstu, $rowsByGrp[$g] ?? [], $fs); $gs['group'] = $g; $gs['members'] = $membersOf($rowsByGrp[$g] ?? []); $groups[] = $gs; } usort($groups, static fn(array $a, array $b): int => strcmp((string)$a['group'], (string)$b['group'])); $row['groups'] = $groups; $classes[] = $row; } usort($classes, static function (array $a, array $b): int { $an = is_numeric($a['class']) ? (int)$a['class'] : $a['class']; $bn = is_numeric($b['class']) ? (int)$b['class'] : $b['class']; if (is_int($an) && is_int($bn)) { return $an <=> $bn; } return strcmp((string)$a['class'], (string)$b['class']); }); ny_ok([ 'project' => $result['project'], 'full_score' => $fs, 'overall' => $result['stats'], 'classes' => $classes, 'updated_at' => $result['updated_at'], ]); // ---------------- 名单 ---------------- case 'roster': ny_ok(['students' => Roster::all(), 'meta' => Roster::meta()]); case 'roster_import': if (empty($_FILES['file']) || !is_uploaded_file($_FILES['file']['tmp_name'])) { ny_fail('请选择要导入的文件'); } $f = $_FILES['file']; $ext = strtolower(pathinfo((string)$f['name'], PATHINFO_EXTENSION)); $dest = NY_DATA . '/uploads/roster_' . date('Ymd_His') . '.' . ($ext !== '' ? $ext : 'dat'); if (!@move_uploaded_file($f['tmp_name'], $dest)) { ny_fail('文件保存失败,请检查 data/uploads 目录权限'); } try { $res = Roster::importFile($dest, $ext); } catch (Throwable $e) { @unlink($dest); ny_fail($e->getMessage()); } $mode = (string)($_POST['mode'] ?? 'replace'); if ($mode === 'append') { $merged = array_merge(Roster::all(), $res['students']); Roster::save($merged); } else { Roster::save($res['students']); } ny_ok([ 'students' => Roster::all(), 'meta' => Roster::meta(), 'imported' => $res['count'], 'skipped' => $res['skipped'] ?? 0, 'duplicates' => $res['duplicates'] ?? 0, ]); case 'roster_save': $raw = (string)file_get_contents('php://input'); $in = json_decode($raw, true); if (!is_array($in) || !isset($in['students']) || !is_array($in['students'])) { ny_fail('参数错误'); } $students = []; foreach ($in['students'] as $s) { if (!is_array($s)) { continue; } $students[] = [ 'sid' => (string)($s['sid'] ?? ''), 'name' => (string)($s['name'] ?? ''), 'cls' => trim((string)($s['cls'] ?? '')), 'grp' => trim((string)($s['grp'] ?? '')), 'login' => trim((string)($s['login'] ?? '')), ]; } Roster::save($students); ny_ok(['students' => Roster::all(), 'meta' => Roster::meta()]); case 'roster_clear': Roster::clear(); ny_ok(['students' => [], 'meta' => Roster::meta()]); // ---------------- 未匹配答卷 → 加入名单 ---------------- case 'unmatched_map': $raw = (string)file_get_contents('php://input'); $in = json_decode($raw, true); if (!is_array($in)) { $in = $_POST; } $name = trim((string)($in['name'] ?? '')); $sid = trim((string)($in['sid'] ?? '')); if ($name === '' || $sid === '') { ny_fail('姓名与学号必填'); } $cls = trim((string)($in['cls'] ?? '')); $grp = trim((string)($in['grp'] ?? '')); $login = trim((string)($in['login'] ?? '')); $roster = Roster::all(); // 若学号已存在则更新其姓名为该答卷姓名(映射到已有学生);否则新增一行 $found = false; foreach ($roster as &$s) { if (trim((string)($s['sid'] ?? '')) === $sid) { $s['name'] = $name; if ($cls !== '') { $s['cls'] = $cls; } if ($grp !== '') { $s['grp'] = $grp; } if ($login !== '') { $s['login'] = $login; } $found = true; break; } } unset($s); if (!$found) { $roster[] = ['sid' => $sid, 'name' => $name, 'cls' => $cls, 'grp' => $grp, 'login' => $login]; } Roster::save($roster); ny_ok(['students' => Roster::all(), 'meta' => Roster::meta(), 'mapped' => $name]); // ---------------- 成绩快照 / 趋势 ---------------- case 'snapshot_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; } $label = trim((string)($in['label'] ?? '')); // 复用缓存构建(不强制,快照本就是"当前看到的成绩") $c = cfg(); $nameQid = (string)($_GET['nameQid'] ?? ''); $built = ny_build_data($c, $projectId, $nameQid); $snap = Snapshot::summarize($built['result'], $label); $file = Snapshot::save($projectId, $snap); ny_ok(['file' => $file, 'label' => $snap['label'], 'at_txt' => $snap['at_txt'], 'count' => count($snap['students'])]); case 'snapshot_list': $projectId = (string)($_GET['projectId'] ?? ''); if ($projectId === '') { ny_fail('请先选择问卷/考试'); } ny_ok(['snapshots' => Snapshot::list($projectId)]); case 'snapshot_delete': $projectId = (string)($_GET['projectId'] ?? $_POST['projectId'] ?? ''); $raw = (string)file_get_contents('php://input'); $in = json_decode($raw, true); if (!is_array($in)) { $in = $_POST; } $file = (string)($in['file'] ?? ''); if ($projectId === '' || $file === '') { ny_fail('参数错误'); } ny_ok(['deleted' => Snapshot::delete($projectId, $file)]); case 'snapshot_trend': $projectId = (string)($_GET['projectId'] ?? ''); if ($projectId === '') { ny_fail('请先选择问卷/考试'); } // 支持跨项目对比:projectId 可用逗号分隔多个项目,取各自快照混合趋势 $pids = array_values(array_filter(array_map('trim', explode(',', $projectId)))); $snaps = []; foreach ($pids as $pid) { foreach (Snapshot::list($pid) as $s) { $full = Snapshot::get($pid, $s['file']); if ($full !== null) { $snaps[] = $full; } } } ny_ok(Snapshot::trend($snaps)); // ---------------- 导出(支持按班级 / 组别筛选,与页面当前筛选保持一致) ---------------- case 'export': case 'export_absent': $projectId = (string)($_GET['projectId'] ?? ''); if ($projectId === '') { ny_fail('请先选择问卷/考试'); } $nameQid = (string)($_GET['nameQid'] ?? ''); $clsFilter = trim((string)($_GET['cls'] ?? '')); $grpFilter = trim((string)($_GET['grp'] ?? '')); // 命中筛选:未选班级/组别时全部通过 $hit = static function (array $r) use ($clsFilter, $grpFilter): bool { if ($clsFilter !== '' && trim((string)($r['cls'] ?? '')) !== $clsFilter) { return false; } if ($grpFilter !== '' && trim((string)($r['grp'] ?? '')) !== $grpFilter) { return false; } return true; }; $c = cfg(); // 复用与 data 接口相同的结果缓存:导出不必再全量拉一次卷王 $built = ny_build_data($c, $projectId, $nameQid, !empty($_GET['force'])); $result = $built['result']; $rows = array_values(array_filter((array)($result['rows'] ?? []), $hit)); // 缺考口径与页面完全保持一致:筛选后名单 − 筛选后已提交(按学号匹配) $roster = array_values(array_filter((array)($result['roster'] ?? []), $hit)); if ($roster !== []) { $submitted = []; foreach ($rows as $r) { $sid = trim((string)($r['sid'] ?? '')); if ($sid !== '') { $submitted[$sid] = true; } } $absent = array_values(array_filter( $roster, static fn(array $s): bool => !isset($submitted[trim((string)($s['sid'] ?? ''))]) )); } else { // 未导入名单时退回后端自带的缺考数组(同样按筛选过滤) $absent = array_values(array_filter((array)($result['absent'] ?? []), $hit)); } // 文件名附带班级 / 组别,便于分辨 $safe = static fn(string $s): string => preg_replace('/[\\\\\/:*?"<>|]/u', '_', $s) ?? ''; $clsLabel = $clsFilter === '' ? '' : (mb_strpos($clsFilter, '班') === false ? $clsFilter . '班' : $clsFilter); $grpLabel = $grpFilter === '' ? '' : (mb_strpos($grpFilter, '组') === false ? $grpFilter . '组' : $grpFilter); $tag = ($clsLabel !== '' ? '_' . $safe($clsLabel) : '') . ($grpLabel !== '' ? '_' . $safe($grpLabel) : ''); $mode = ((string)($_GET['mode'] ?? '') === 'latest') ? 'latest' : 'all'; $splitCls = ((string)($_GET['split'] ?? '') === 'cls'); $isLatest = ($mode === 'latest'); $filename = ($action === 'export_absent' ? '缺考名单' : '成绩明细') . '_' . $safe((string)$result['project']['name']) . $tag . ($isLatest ? '_最新成绩' : '') . '_' . date('Ymd_His') . '.xlsx'; // ---------------- 列定义(可通过 cols= 参数挑选) ---------------- $colLabels = [ 'sid' => '学号', 'name' => '姓名', 'cls' => '班级', 'grp' => '组别', 'score' => $isLatest ? '最新成绩' : '成绩', 'best_score' => '最高分', 'attempts' => '考试次数', 'idx' => '第几次', 'submit_at' => $isLatest ? '最近提交时间' : '提交时间', 'duration_txt' => '考试时长', 'duration' => '考试时长(秒)', ]; $defaultCols = $isLatest ? ['sid', 'name', 'cls', 'grp', 'score', 'best_score', 'attempts', 'submit_at', 'duration_txt', 'duration'] : ['sid', 'name', 'cls', 'grp', 'score', 'attempts', 'idx', 'submit_at', 'duration_txt', 'duration']; $want = array_values(array_filter( array_map('trim', explode(',', (string)($_GET['cols'] ?? ''))), static fn($v): bool => $v !== '' )); $cols = $want !== [] ? array_values(array_intersect($defaultCols, $want)) : $defaultCols; if ($cols === []) { $cols = $defaultCols; } // ---------------- 数据行 ---------------- $sortMode = (string)($_GET['sort'] ?? ''); if ($isLatest) { // 每人一行:成绩取「最近一次提交」的分数(不受 dedupe 影响),并附最高分与考试次数 $all = []; foreach ($rows as $r) { $list = array_values((array)($r['attempts_list'] ?? [])); $last = $list !== [] ? $list[count($list) - 1] : null; $all[] = [ 'sid' => (string)($r['sid'] ?? ''), 'name' => (string)($r['name'] ?? ''), 'cls' => (string)($r['cls'] ?? ''), 'grp' => (string)($r['grp'] ?? ''), 'score' => $last !== null ? ($last['score'] ?? null) : ($r['score'] ?? null), 'best_score' => $r['best_score'] ?? null, 'attempts' => (int)($r['attempts'] ?? 1), 'idx' => (int)($r['attempts'] ?? 1), 'submit_ts' => (int)($r['last_submit_ts'] ?? 0), 'submit_at' => (string)($r['last_submit_at'] ?? ($last['submit_at'] ?? ($r['submit_at'] ?? '-'))), 'duration_txt' => (string)($last['duration_txt'] ?? ($r['duration_txt'] ?? '-')), 'duration' => $last['duration'] ?? ($r['duration'] ?? null), ]; } if ($sortMode === 'score_desc' || $sortMode === 'score_asc') { $all = ny_sort_by_score($all, $sortMode === 'score_desc'); } else { $all = DataService::sortForExport($all, ($sortMode === 'sid') ? 'sid' : 'time'); } } else { // 每次考试一行(同一学号考了 N 次就有 N 行) $all = DataService::expandAttempts($rows, true); if ($sortMode === 'score_desc' || $sortMode === 'score_asc') { $all = ny_sort_by_score($all, $sortMode === 'score_desc'); } else { $all = DataService::sortForExport($all, ($sortMode === 'sid') ? 'sid' : 'time'); } } // ---------------- 组装工作表 ---------------- $toRows = static function (array $list) use ($cols, $colLabels): array { $head = []; foreach ($cols as $c) { $head[] = $colLabels[$c]; } $out = [$head]; foreach ($list as $r) { $line = []; foreach ($cols as $c) { $v = $r[$c] ?? null; $line[] = ($v === null || $v === '') ? '' : (string)$v; } $out[] = $line; } return $out; }; $widths = []; foreach ($cols as $c) { $widths[] = in_array($c, ['sid', 'submit_at'], true) ? 20 : ($c === 'name' ? 14 : 11); } $sheets = []; if ($action === 'export_absent') { $sheets[] = ['name' => '缺考名单', 'rows' => ny_absent_sheet_rows($absent), 'widths' => [16, 14, 16, 10]]; } else { if ($splitCls) { // 按班级分表(班级名即工作表名) $byCls = []; foreach ($all as $r) { $byCls[trim((string)($r['cls'] ?? '')) ?: '(未分班)'][] = $r; } ksort($byCls, SORT_NATURAL | SORT_FLAG_CASE); foreach ($byCls as $clsName => $list) { $sheets[] = ['name' => (string)$clsName, 'rows' => $toRows($list), 'widths' => $widths]; } } else { $sheets[] = ['name' => $isLatest ? '成绩明细(最新)' : '成绩明细', 'rows' => $toRows($all), 'widths' => $widths]; } // 附加:缺考名单 / 未匹配答卷(仅非空时输出) if ($absent !== []) { $sheets[] = ['name' => '缺考名单', 'rows' => ny_absent_sheet_rows($absent), 'widths' => [16, 14, 16, 10]]; } $unmatched = array_values((array)($result['unmatched'] ?? [])); if ($unmatched !== []) { $um = [['姓名', '成绩', '提交时间']]; foreach ($unmatched as $u) { $um[] = [ (string)($u['name'] ?? ''), ($u['score'] ?? null) === null ? '' : (string)$u['score'], (string)($u['submit_at'] ?? ''), ]; } $sheets[] = ['name' => '未匹配答卷', 'rows' => $um, 'widths' => [16, 10, 20]]; } } // 说明:所有单元格按「共享字符串 + 文本格式」写入, // 既不会把长学号变成科学计数法,也不会把 = + - @ 开头的内容当公式执行(规避 CSV 公式注入) $bin = XlsxWriter::renderMulti($sheets); header('Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); header('Content-Disposition: attachment; filename="export.xlsx"; filename*=UTF-8\'\'' . rawurlencode($filename)); header('Content-Length: ' . strlen($bin)); header('Cache-Control: no-store'); echo $bin; exit; // ---------------- 随机抽取:导出白名单(xlsx,列:姓名 / 登陆账号) ---------------- case 'draw_export': $raw = (string)file_get_contents('php://input'); $in = json_decode($raw, true); if (!is_array($in)) { $in = $_POST + $_GET; } // type=import → 白名单导入模板.xlsx;type=list → 白名单列表.xlsx $type = (($in['type'] ?? '') === 'import') ? 'import' : 'list'; $members = []; foreach ((array)($in['members'] ?? []) as $m) { if (!is_array($m)) { continue; } $name = trim((string)($m['name'] ?? '')); // 登陆账号优先(名单导入的"登陆账号"列),没有则回退学号 $acct = trim((string)($m['login'] ?? '')); $sid = trim((string)($m['sid'] ?? '')); if ($name === '' && $acct === '' && $sid === '') { continue; } $members[] = [$name, $acct !== '' ? $acct : $sid]; } $bin = XlsxWriter::render( array_merge([['姓名', '登陆账号']], $members), ['widths' => [16, 24], 'title' => '白名单'] ); $filename = $type === 'import' ? '白名单导入模板.xlsx' : '白名单列表.xlsx'; header('Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); header('Content-Disposition: attachment; filename="whitelist.xlsx"; filename*=UTF-8\'\'' . rawurlencode($filename)); header('Content-Length: ' . strlen($bin)); header('Cache-Control: no-store'); echo $bin; exit; default: ny_fail('未知操作:' . $action); } } catch (Throwable $e) { $msg = $e->getMessage(); // 鉴权类错误(账号未配置 / 登录卷王失败)打标,前端据此跳登录页自愈 $isAuth = str_contains($msg, '未配置卷王登录账号') || str_contains($msg, '登录卷王失败') || str_contains($msg, '登录失败'); ny_fail($msg, [ 'auth' => $isAuth, 'trace' => defined('NY_DEBUG') && NY_DEBUG ? $e->getTraceAsString() : null, ]); }