true, 'config' => ny_load_config()]); // ---------------- 连接测试 ---------------- 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), ]; } ny_ok($out); // ---------------- 数据 ---------------- case 'data': $projectId = (string)($_GET['projectId'] ?? ''); if ($projectId === '') { ny_fail('请先选择问卷/考试'); } $nameQid = (string)($_GET['nameQid'] ?? ''); $c = cfg(); $result = DataService::build($c, $projectId, $nameQid); $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, ]; ny_ok($result); // ---------------- 数据看板(按班级聚合,面向班主任/学生整体情况) ---------------- case 'dashboard': $projectId = (string)($_GET['projectId'] ?? ''); if ($projectId === '') { ny_fail('请先选择问卷/考试'); } $nameQid = (string)($_GET['nameQid'] ?? ''); $c = cfg(); $result = DataService::build($c, $projectId, $nameQid); $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; } $classes = []; foreach ($byCls as $k => $stu) { $rs = $rowsByCls[$k] ?? []; $total = count($stu); $submitted = count($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; $classes[] = [ 'class' => $k, 'total' => $total, 'submitted' => $submitted, '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, ]; } 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']]); 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'] ?? '')), ]; } Roster::save($students); ny_ok(['students' => Roster::all(), 'meta' => Roster::meta()]); case 'roster_clear': Roster::clear(); ny_ok(['students' => [], 'meta' => Roster::meta()]); // ---------------- 导出 ---------------- case 'export': case 'export_absent': $projectId = (string)($_GET['projectId'] ?? ''); if ($projectId === '') { ny_fail('请先选择问卷/考试'); } $nameQid = (string)($_GET['nameQid'] ?? ''); $c = cfg(); $result = DataService::build($c, $projectId, $nameQid); $filename = ($action === 'export_absent' ? '缺考名单' : '成绩明细') . '_' . preg_replace('/[\\\\\/:*?"<>|]/u', '_', (string)$result['project']['name']) . '_' . date('Ymd_His') . '.csv'; header('Content-Type: text/csv; charset=utf-8'); header('Content-Disposition: attachment; filename="' . $filename . '"'); header('Cache-Control: no-store'); echo "\xEF\xBB\xBF"; $out = fopen('php://output', 'w'); if ($action === 'export_absent') { fputcsv($out, ['学号', '姓名', '班级']); foreach ($result['absent'] as $r) { fputcsv($out, [$r['sid'], $r['name'], $r['cls'] ?? '']); } } else { fputcsv($out, ['学号', '姓名', '班级', '成绩', '提交时间', '考试时长', '考试时长(秒)']); foreach ($result['rows'] as $r) { fputcsv($out, [ $r['sid'], $r['name'], $r['cls'] ?? '', $r['score'] === null ? '' : $r['score'], $r['submit_at'], $r['duration_txt'], $r['duration'] === null ? '' : $r['duration'], ]); } } fclose($out); exit; default: ny_fail('未知操作:' . $action); } } catch (Throwable $e) { ny_fail($e->getMessage(), [ 'trace' => defined('NY_DEBUG') && NY_DEBUG ? $e->getTraceAsString() : null, ]); }