学号 / 姓名 / 成绩 / 提交时间 / 考试时长 */ declare(strict_types=1); class DataService { /** 容器型题目(分页、题组、说明)不参与取值 */ private const CONTAINER_TYPES = [ 'pagination', 'page', 'group', 'survey', 'divider', 'tree', ]; /** 题目标题可能是富文本,去掉标签便于展示与匹配 */ private static function cleanTitle(string $t): string { $t = strip_tags($t); $t = html_entity_decode($t, ENT_QUOTES | ENT_HTML5, 'UTF-8'); return trim(preg_replace('/\s+/u', ' ', $t) ?? ''); } /** * 展平题目树 * @return array */ public static function flattenQuestions(mixed $node, string $path = ''): array { $out = []; if (!is_array($node)) { return $out; } // children 在根或子节点 $self = $node; $children = (isset($node['children']) && is_array($node['children'])) ? $node['children'] : null; $id = isset($self['id']) ? (string)$self['id'] : ''; $title = isset($self['title']) ? self::cleanTitle((string)$self['title']) : ''; $type = isset($self['type']) ? (string)$self['type'] : ''; $isRoot = ($path === ''); if (!$isRoot && $id !== '' && !in_array(strtolower($type), self::CONTAINER_TYPES, true)) { $out[] = [ 'id' => $id, 'title' => $title !== '' ? $title : $id, 'type' => $type, 'path' => $path, ]; } if ($children !== null) { foreach ($children as $idx => $child) { $subPath = $path === '' ? (string)($idx + 1) : $path . '-' . ($idx + 1); foreach (self::flattenQuestions($child, $subPath) as $q) { $out[] = $q; } } } return $out; } /** 根据关键词猜测"姓名"题 */ public static function guessNameQuestion(array $questions, string $keywords): array { $kws = array_values(array_filter(array_map('trim', preg_split('/[,,]/u', $keywords) ?: []))); $candidates = []; foreach ($questions as $q) { $t = (string)$q['title']; foreach ($kws as $kw) { if ($kw !== '' && mb_stripos($t, $kw) !== false) { $candidates[] = $q; break; } } } return $candidates; } /** * 组装结果 */ public static function build(array $cfg, string $projectId, string $nameQid = ''): array { $client = new SurveyKingClient($cfg); $students = Roster::all(); // 姓名 -> 学号 / 班级(规范化) $studentInfo = []; foreach ($students as $s) { $k = ny_norm_name((string)$s['name']); if ($k !== '') { $studentInfo[$k] = [ 'sid' => (string)$s['sid'], 'cls' => (string)($s['cls'] ?? ''), 'grp' => (string)($s['grp'] ?? ''), ]; } } $project = $client->getProject($projectId); $survey = $project['survey'] ?? null; if (is_string($survey) && $survey !== '') { $decoded = json_decode($survey, true); if (is_array($decoded)) { $survey = $decoded; } } $questions = self::flattenQuestions($survey); $keywords = (string)($cfg['name_keywords'] ?? '姓名'); $nameCandidates = self::guessNameQuestion($questions, $keywords); $autoQid = $nameCandidates[0]['id'] ?? ''; // 指定优先 $pickedQid = $nameQid !== '' ? $nameQid : $autoQid; $candidateIds = []; if ($pickedQid !== '') { $candidateIds[] = $pickedQid; } foreach ($nameCandidates as $q) { if (!in_array($q['id'], $candidateIds, true)) { $candidateIds[] = $q['id']; } } $answers = $client->listAnswers($projectId); $includeTemp = !empty($cfg['include_temp_save']); $dedupe = !empty($cfg['dedupe']); $rows = []; $unmatched = []; $seenSid = []; foreach ($answers as $a) { $tempSave = isset($a['tempSave']) ? (int)$a['tempSave'] : 1; if (!$includeTemp && $tempSave === 0) { continue; // 跳过暂存 } $answerMap = isset($a['answer']) && is_array($a['answer']) ? $a['answer'] : []; // 姓名:优先关键词命中的题目 -> 全量扫描能匹配名单的答案 -> 登录用户名 $name = ''; foreach ($candidateIds as $qid) { if (isset($answerMap[$qid])) { $v = ny_flatten_value($answerMap[$qid]); if ($v !== '') { $name = $v; break; } } } if ($name === '') { foreach ($questions as $q) { $qid = (string)$q['id']; if (!array_key_exists($qid, $answerMap)) { continue; } $v = ny_flatten_value($answerMap[$qid]); if ($v !== '' && isset($nameMap[ny_norm_name($v)])) { $name = $v; break; } } } if ($name === '' && $questions === []) { foreach ($answerMap as $v) { $s = ny_flatten_value($v); if ($s !== '' && isset($nameMap[ny_norm_name($s)])) { $name = $s; break; } } } if ($name === '') { $name = ny_str($a['createByName'] ?? ''); } if ($name === '') { $name = '(未填写姓名)'; } // 成绩 $score = null; if (isset($a['examScore']) && is_numeric($a['examScore'])) { $score = (float)$a['examScore']; } // 提交时间 $ts = ny_to_timestamp($a['createAt'] ?? null); if ($ts === 0) { $ts = ny_to_timestamp($a['updateAt'] ?? null); } // 考试时长 $ai = $a['metaInfo']['answerInfo'] ?? []; $start = is_array($ai) ? (int)($ai['startTime'] ?? 0) : 0; $end = is_array($ai) ? (int)($ai['endTime'] ?? 0) : 0; $dur = null; if ($start > 0 && $end > $start) { $dur = (int)round(($end - $start) / 1000); } $key = ny_norm_name($name); $info = $studentInfo[$key] ?? null; $sid = $info['sid'] ?? ''; $cls = $info['cls'] ?? ''; $grp = $info['grp'] ?? ''; $row = [ 'sid' => $sid, 'name' => $name, 'cls' => $cls, 'grp' => $grp, 'score' => $score, 'submit_ts' => $ts, 'submit_at' => $ts > 0 ? date('Y-m-d H:i:s', $ts) : '-', 'duration' => $dur, 'duration_txt'=> ny_format_duration($dur), 'answer_id' => ny_str($a['id'] ?? ''), 'matched' => $sid !== '', 'temp_save' => $tempSave === 0, ]; if ($sid === '') { $unmatched[] = $row; continue; } if ($dedupe && isset($seenSid[$sid])) { $idx = $seenSid[$sid]; // 保留提交时间最新的一份 if ($ts >= ($rows[$idx]['submit_ts'] ?? 0)) { $rows[$idx] = $row; $seenSid[$sid] = $idx; } continue; } $seenSid[$sid] = count($rows); $rows[] = $row; } // 成绩明细默认按提交时间降序(最新在前),学号作为次级排序 usort($rows, static function (array $x, array $y): int { $d = ($y['submit_ts'] ?? 0) <=> ($x['submit_ts'] ?? 0); if ($d !== 0) { return $d; } $a = (string)$x['sid']; $b = (string)$y['sid']; if (is_numeric($a) && is_numeric($b)) { return $a <=> $b; } return strnatcmp($a, $b); }); // 缺考名单 $submittedSids = []; foreach ($rows as $r) { $submittedSids[(string)$r['sid']] = true; } $absent = []; foreach ($students as $s) { if (!isset($submittedSids[(string)$s['sid']])) { $absent[] = [ 'sid' => (string)$s['sid'], 'name' => (string)$s['name'], 'cls' => (string)($s['cls'] ?? ''), 'grp' => (string)($s['grp'] ?? ''), ]; } } usort($absent, static function (array $x, array $y): int { $a = (string)$x['sid']; $b = (string)$y['sid']; if (is_numeric($a) && is_numeric($b)) { return $a <=> $b; } return strnatcmp($a, $b); }); // 统计 $scores = array_values(array_filter(array_map(static fn($r) => $r['score'], $rows), static fn($v) => $v !== null)); $durs = array_values(array_filter(array_map(static fn($r) => $r['duration'], $rows), static fn($v) => $v !== null)); $stats = [ 'roster_total' => count($students), 'submitted' => count($rows), 'absent' => count($absent), 'unmatched' => count($unmatched), 'submit_rate' => count($students) > 0 ? round(count($rows) / count($students) * 100, 1) : 0, 'avg_score' => $scores !== [] ? round(array_sum($scores) / count($scores), 2) : null, 'max_score' => $scores !== [] ? max($scores) : null, 'min_score' => $scores !== [] ? min($scores) : null, 'avg_duration' => $durs !== [] ? (int)round(array_sum($durs) / count($durs)) : null, 'answer_total' => count($answers), ]; return [ 'project' => [ 'id' => ny_str($project['id'] ?? $projectId), 'name' => ny_str($project['name'] ?? ''), 'mode' => ny_str($project['mode'] ?? ''), ], 'questions' => $questions, 'name_qid' => $pickedQid, 'name_candidates' => array_map(static fn($q) => ['id' => $q['id'], 'title' => $q['title']], $nameCandidates), 'rows' => $rows, 'absent' => $absent, 'unmatched' => $unmatched, 'stats' => $stats, 'roster' => $students, 'roster_count' => count($students), 'updated_at' => date('Y-m-d H:i:s'), ]; } }