/_.json * 每份快照内容为 { label, at, project:{id,name}, stats, students:[{sid,name,cls,grp,score,best_score,attempts,last_submit_at}] } */ declare(strict_types=1); final class Snapshot { private static function dir(string $projectId): string { $d = NY_DATA . '/snapshots/' . preg_replace('/[^A-Za-z0-9_-]/', '_', $projectId); if (!is_dir($d)) { @mkdir($d, 0777, true); } return $d; } /** 从聚合结果里抽取轻量学生摘要(只保留趋势对比需要的字段) */ public static function summarize(array $result, string $label): array { $students = []; foreach ((array)($result['rows'] ?? []) as $r) { $students[] = [ 'sid' => (string)($r['sid'] ?? ''), 'name' => (string)($r['name'] ?? ''), 'cls' => (string)($r['cls'] ?? ''), 'grp' => (string)($r['grp'] ?? ''), 'score' => ($r['score'] ?? null) === null ? null : (float)$r['score'], 'best_score' => ($r['best_score'] ?? null) === null ? null : (float)$r['best_score'], 'attempts' => (int)($r['attempts'] ?? 1), 'last_submit_at' => (string)($r['last_submit_at'] ?? ($r['submit_at'] ?? '-')), ]; } // 按学号排序,保证趋势表行序稳定 usort($students, static function ($a, $b): int { $sa = $a['sid']; $sb = $b['sid']; if (is_numeric($sa) && is_numeric($sb)) { return $sa <=> $sb; } return strnatcmp((string)$sa, (string)$sb); }); return [ 'label' => trim($label) === '' ? '快照 ' . date('m-d H:i') : trim($label), 'at' => time(), 'at_txt' => date('Y-m-d H:i:s'), 'project' => [ 'id' => (string)($result['project']['id'] ?? ''), 'name' => (string)($result['project']['name'] ?? ''), ], 'stats' => $result['stats'] ?? [], 'students' => $students, ]; } /** 保存一份快照,返回文件名 */ public static function save(string $projectId, array $snap): string { $dir = self::dir($projectId); $name = time() . '_' . substr(md5($snap['label'] . $snap['at']), 0, 8) . '.json'; @file_put_contents($dir . '/' . $name, json_encode($snap, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), LOCK_EX); return $name; } /** 列出某项目的全部快照(按时间倒序) */ public static function list(string $projectId): array { $dir = self::dir($projectId); $out = []; foreach (glob($dir . '/*.json') ?: [] as $f) { $raw = @file_get_contents($f); if ($raw === false) { continue; } $j = json_decode($raw, true); if (!is_array($j)) { continue; } $out[] = [ 'file' => basename($f), 'label' => (string)($j['label'] ?? basename($f)), 'at' => (int)($j['at'] ?? 0), 'at_txt' => (string)($j['at_txt'] ?? ''), 'project' => $j['project'] ?? [], 'count' => count((array)($j['students'] ?? [])), ]; } usort($out, static fn($a, $b) => ($b['at'] <=> $a['at'])); return $out; } /** 读取单份快照完整内容 */ public static function get(string $projectId, string $file): ?array { $file = basename($file); // 防路径穿越 $path = self::dir($projectId) . '/' . $file; if (!is_file($path)) { return null; } $j = json_decode((string)@file_get_contents($path), true); return is_array($j) ? $j : null; } /** 删除单份快照 */ public static function delete(string $projectId, string $file): bool { $file = basename($file); $path = self::dir($projectId) . '/' . $file; if (is_file($path)) { return @unlink($path); } return false; } /** * 跨快照趋势:以学号为行、各快照为列,输出每个学生在每份快照里的成绩。 * 用 best_score(真实最高分)作为趋势值,未出现记 null。 */ public static function trend(array $snapshots): array { // 按时间升序排,最早的列在最前 $snaps = $snapshots; usort($snaps, static fn($a, $b) => ($a['at'] <=> $b['at'])); $cols = []; $bySid = []; foreach ($snaps as $sn) { $colKey = (string)$sn['at'] . ':' . (string)$sn['label']; $cols[] = ['key' => $colKey, 'label' => (string)$sn['label'], 'at_txt' => (string)($sn['at_txt'] ?? '')]; foreach ((array)($sn['students'] ?? []) as $st) { $sid = (string)($st['sid'] ?? ''); if ($sid === '') { continue; } if (!isset($bySid[$sid])) { $bySid[$sid] = [ 'sid' => $sid, 'name' => (string)($st['name'] ?? ''), 'cls' => (string)($st['cls'] ?? ''), 'scores' => [], ]; } $bySid[$sid]['scores'][$colKey] = ($st['best_score'] ?? null) === null ? null : (float)$st['best_score']; } } $rows = array_values($bySid); // 按学号排序 usort($rows, static function ($a, $b): int { if (is_numeric($a['sid']) && is_numeric($b['sid'])) { return $a['sid'] <=> $b['sid']; } return strnatcmp((string)$a['sid'], (string)$b['sid']); }); return ['columns' => $cols, 'rows' => $rows]; } }