Files
nxxy335 94ac381579 完成批次 L-R:限流移除/白名单驱动/打印增强/全屏/表格对齐/主题变量/滚动闪烁修复
- 移除登录失败限流;fix-config.php 修正 e2e 串味的 127.0.0.1 连接

- 白名单驱动缺/待考名单(DataService + SurveyKingClient::getWhitelist,复用已取 survey)

- 打印成绩明细增强(范围/合格列默认关/去页脚);看板全屏占满;表格 vertical-align:middle;隐藏登陆账号;导入默认追加

- 主题变量收口(:root 新增 brand/primary-2/ok-2 等,替换硬编码渐变);删死规则

- 表头/数据列对齐修复(index 未匹配表、dashboard 成员榜、draw 结果/历史/排除表);看板占位卡片配色一致

- 修复滚动闪烁:row-drop-in 动画经 _dropAnim 门控,仅强制重绘时播放

- 测试:新增 test-whitelist.js;run-all.sh 全绿 132
2026-09-13 10:25:48 +08:00

340 lines
12 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* 学号名单管理
* 存储:data/roster.json -> {"students":[{"sid":"1","name":"张三","cls":"计算机2401"}], "updated_at":"..."}
* cls 为班级(可空),用于一份考试涉及多个班级时区分。
*/
declare(strict_types=1);
class Roster
{
private static function file(): string
{
return NY_DATA . '/roster.json';
}
/** 数据库是否可作为名单后端 */
private static function useDb(): bool
{
return class_exists('Db') && Db::pdo() !== null;
}
/**
* 首次启用 SQLite 时,把已有的 data/roster.json 迁进数据库(老数据不丢)。
* 只在「DB 名单为空 且 文件有数据」时执行一次,之后由 DB 全权负责。
*/
private static function migrateFileToDbOnce(): void
{
if (!self::useDb()) {
return;
}
try {
if (Db::rosterCount() > 0) {
return; // DB 已有数据,不覆盖
}
$d = ny_read_json(self::file(), null);
if (!is_array($d) || empty($d['students']) || !is_array($d['students'])) {
return;
}
$clean = [];
foreach ($d['students'] as $s) {
if (!is_array($s)) {
continue;
}
$clean[] = [
'sid' => trim((string)($s['sid'] ?? '')),
'name' => trim((string)($s['name'] ?? '')),
'cls' => trim((string)($s['cls'] ?? '')),
'grp' => trim((string)($s['grp'] ?? '')),
'login' => trim((string)($s['login'] ?? '')),
];
}
if ($clean !== []) {
Db::rosterReplace($clean);
}
} catch (Throwable $e) {
// 迁移失败不影响主流程:仍可退回文件读取
}
}
/** @return array<int,array{sid:string,name:string,cls:string}> */
public static function all(): array
{
if (self::useDb()) {
self::migrateFileToDbOnce();
return Db::rosterAll();
}
$d = ny_read_json(self::file(), null);
if (!is_array($d) || !isset($d['students']) || !is_array($d['students'])) {
return [];
}
$out = [];
foreach ($d['students'] as $s) {
if (!is_array($s)) {
continue;
}
$out[] = [
'sid' => trim((string)($s['sid'] ?? '')),
'name' => trim((string)($s['name'] ?? '')),
'cls' => trim((string)($s['cls'] ?? '')),
'grp' => trim((string)($s['grp'] ?? '')),
'login' => trim((string)($s['login'] ?? '')),
];
}
return $out;
}
public static function meta(): array
{
$d = ny_read_json(self::file(), null);
return [
'count' => self::count(),
'updated_at' => self::useDb() ? date('Y-m-d H:i:s') : (is_array($d) ? (string)($d['updated_at'] ?? '') : ''),
];
}
public static function save(array $students): bool
{
$clean = [];
foreach ($students as $s) {
$sid = trim((string)($s['sid'] ?? ''));
$name = trim((string)($s['name'] ?? ''));
if ($sid === '' && $name === '') {
continue;
}
$clean[] = [
'sid' => $sid,
'name' => $name,
'cls' => trim((string)($s['cls'] ?? '')),
'grp' => trim((string)($s['grp'] ?? '')),
'login' => trim((string)($s['login'] ?? '')),
];
}
// 按学号去重(同 sid 取最后一条):roster 表 sid 为主键,重复 sid 会触发约束冲突导致整批回滚。
// 去重避免「保存成功但名单实际没变」的静默丢失。
$bySid = [];
$noSid = [];
foreach ($clean as $s) {
if ((string)($s['sid'] ?? '') !== '') {
$bySid[$s['sid']] = $s;
} else {
$noSid[] = $s;
}
}
$clean = array_values($bySid);
foreach ($noSid as $s) {
$clean[] = $s;
}
if (self::useDb()) {
return Db::rosterReplace($clean);
}
return ny_write_json(self::file(), [
'students' => $clean,
'updated_at' => date('Y-m-d H:i:s'),
]);
}
public static function clear(): bool
{
if (self::useDb()) {
Db::rosterReplace([]);
return true;
}
return ny_write_json(self::file(), ['students' => [], 'updated_at' => date('Y-m-d H:i:s')]);
}
public static function count(): int
{
if (self::useDb()) {
return Db::rosterCount();
}
return count(self::all());
}
/**
* 名单指纹:名单内容一变就变,用于数据缓存 key。
* 只取「人数 + 文件修改时间 + 内容的轻量哈希」,避免每次全量序列化。
*/
public static function fingerprint(): string
{
if (self::useDb()) {
// 人数 + 内容哈希:名单内容变了指纹才变(只靠人数会在"换人"时误命中缓存)
$n = self::count();
$hash = md5(json_encode(self::all(), JSON_UNESCAPED_UNICODE));
return 'db:' . $n . ':' . $hash;
}
$f = self::file();
$mt = is_file($f) ? (string)filemtime($f) : '0';
$sz = is_file($f) ? (string)filesize($f) : '0';
$d = ny_read_json($f, null);
$hash = is_array($d) ? md5(json_encode($d['students'] ?? [], JSON_UNESCAPED_UNICODE)) : '0';
return $mt . ':' . $sz . ':' . $hash;
}
/**
* 解析上传文件 -> 学生数组
* @return array{count:int,students:array}
*/
/**
* 解析上传文件 -> 学生数组
* @return array{count:int,skipped:int,duplicates:int,headers:array,students:array}
*/
public static function importFile(string $file, string $ext = ''): array
{
if ($ext === '') {
$ext = strtolower(pathinfo($file, PATHINFO_EXTENSION));
}
$rows = [];
if ($ext === 'xlsx' || $ext === 'xlsm') {
$rows = XlsxReader::read($file);
} elseif ($ext === 'csv' || $ext === 'txt') {
$rows = self::parseCsv($file);
} else {
// 尝试按 xlsx 解析,失败再按 csv
try {
$rows = XlsxReader::read($file);
} catch (Throwable $e) {
$rows = self::parseCsv($file);
}
}
if ($rows === []) {
throw new RuntimeException('文件为空或无法解析(请确认是 xlsx/csv 且包含数据行)');
}
$meta = [];
$students = self::rowsToStudents($rows, $meta);
return [
'count' => count($students),
'skipped' => $meta['skipped'],
'duplicates' => $meta['duplicates'],
'headers' => $meta['headers'],
'students' => $students,
];
}
public static function parseCsv(string $file): array
{
$raw = (string)@file_get_contents($file);
$raw = preg_replace('/^\xEF\xBB\xBF/', '', $raw) ?? $raw;
$rows = [];
if (($fh = fopen('php://memory', 'r+')) === false) {
return [];
}
fwrite($fh, $raw);
rewind($fh);
while (($line = fgetcsv($fh, 0, ',')) !== false) {
if ($line === [null] || (count($line) === 1 && trim((string)$line[0]) === '')) {
continue;
}
$rows[] = array_map(static fn($v) => self::conv((string)$v), $line);
}
fclose($fh);
if ($rows === []) {
// 可能是 tab 分隔
foreach (preg_split('/\r?\n/', $raw) ?: [] as $line) {
if (trim($line) === '') {
continue;
}
$rows[] = array_map([self::class, 'conv'], explode("\t", $line));
}
}
return $rows;
}
private static function conv(string $v): string
{
$v = trim($v);
$enc = mb_detect_encoding($v, ['UTF-8', 'GB18030', 'GBK', 'BIG5'], true);
if ($enc !== false && strtoupper($enc) !== 'UTF-8') {
$v = mb_convert_encoding($v, 'UTF-8', $enc);
}
return $v;
}
/**
* 识别 学号/姓名/班级/组别/登陆账号 列
* 登陆账号仅用于随机抽取页导出白名单,界面任何位置都不显示
* @param array $rows
* @param array $meta 输出:['skipped'=>int,'duplicates'=>int,'headers'=>array]
* @return array<int,array{sid:string,name:string,cls:string,grp:string,login:string}>
*/
public static function rowsToStudents(array $rows, array &$meta = []): array
{
$meta = ['skipped' => 0, 'duplicates' => 0, 'headers' => []];
$sidCol = -1;
$nameCol = -1;
$clsCol = -1;
$grpCol = -1;
$loginCol = -1;
$headerIdx = -1;
// 找表头:同一行需同时命中「学号类」与「姓名类」关键词(容错全角空格/间隔)
foreach ($rows as $i => $row) {
$joined = implode('|', array_map('strval', $row));
if (preg_match('/学\s*号|编\s*号|序\s*号|学\s*员\s*号|号\s*码|no\.?|id/i', $joined)
&& preg_match('/姓\s*名|名\s*字|学\s*生\s*姓\s*名|姓\s*氏|成\s*员/i', $joined)) {
$headerIdx = $i;
break;
}
}
$start = 0;
if ($headerIdx >= 0) {
$start = $headerIdx + 1;
foreach ($rows[$headerIdx] as $j => $cell) {
$c = (string)$cell;
if ($sidCol < 0 && preg_match('/学\s*号|编\s*号|序\s*号|学\s*员\s*号|号\s*码|^\s*no\.?\s*$|^\s*id\s*$/i', $c)) {
$sidCol = $j;
}
if ($nameCol < 0 && preg_match('/姓\s*名|名\s*字|学\s*生\s*姓\s*名|姓\s*氏/i', $c)) {
$nameCol = $j;
}
if ($clsCol < 0 && preg_match('/班\s*级|班级|class/i', $c)) {
$clsCol = $j;
}
if ($grpCol < 0 && preg_match('/组\s*别|分\s*组|组\s*次|^\s*组\s*$|group/i', $c)) {
$grpCol = $j;
}
if ($loginCol < 0 && preg_match('/登\s*[陆录]\s*账\s*号|登\s*[陆录]\s*账号|账\s*号|手\s*机\s*号|手\s*机/i', $c)) {
$loginCol = $j;
}
}
$meta['headers'] = [
'sid' => $sidCol, 'name' => $nameCol, 'cls' => $clsCol, 'grp' => $grpCol, 'login' => $loginCol,
];
}
if ($sidCol < 0) {
$sidCol = 0;
}
if ($nameCol < 0) {
$nameCol = $sidCol === 0 ? 1 : 0;
}
$out = [];
$seen = [];
for ($i = $start; $i < count($rows); $i++) {
$row = $rows[$i];
$sid = trim((string)($row[$sidCol] ?? ''));
$name = trim((string)($row[$nameCol] ?? ''));
if ($sid === '' && $name === '') {
$meta['skipped']++;
continue;
}
// 学号/登陆账号去掉 Excel 数字尾巴(如 2024012001.0 / 13912687810.0)
$sid = preg_replace('/\.0+$/', '', $sid) ?? $sid;
$cls = $clsCol >= 0 ? trim((string)($row[$clsCol] ?? '')) : '';
$grp = $grpCol >= 0 ? trim((string)($row[$grpCol] ?? '')) : '';
$login = $loginCol >= 0 ? trim((string)($row[$loginCol] ?? '')) : '';
$login = preg_replace('/\.0+$/', '', $login) ?? $login;
// 同一条学号多次出现:保留首次,其余记重复跳过(避免看板/统计被重复计入)
if ($sid !== '' && isset($seen[$sid])) {
$meta['duplicates']++;
continue;
}
$seen[$sid] = true;
$out[] = ['sid' => $sid, 'name' => $name, 'cls' => $cls, 'grp' => $grp, 'login' => $login];
}
return $out;
}
}