Files
ny-exam-platform/lib/Roster.php
T
nxxy335 9ee2aec2f0 feat: 全面打磨平台交互
- 名单导入容错:rowsToStudents 加去重/跳过统计、表头正则扩充、.0 尾巴清理;importFile 返回精确 count
- 看板:修复 .repeat(0) 空状态坏代码;新增实时点 stale 指示(手动/自动刷新失败变红 + 提示刷新中断);
        手动刷新按钮加刷新中…反馈;记住自动刷新开关与间隔(localStorage + ?interval= 深链)
- 主统计页:后台静默刷新连续失败时连接指示转实时刷新中断告警;记住用户手动调整的刷新间隔
- 设置页:新增 config_test 端点 + 独立测试连接按钮(用弹窗内未保存凭据测连接,不落盘);
        保存按钮加保存中…防重提交;登录失败不清旧 token
- 抽取页:重置结果加二次确认;新增复制名单按钮(两列制表符分隔,可粘到微信/Excel)
- 修复看板预先存在 JS 语法错误(少一个右括号导致整页 JS 失效)
2026-09-09 18:53:00 +08:00

292 lines
10 KiB
PHP
Raw 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;
}
/** @return array<int,array{sid:string,name:string,cls:string}> */
public static function all(): array
{
if (self::useDb()) {
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'] ?? '')),
];
}
if (self::useDb()) {
Db::rosterReplace($clean);
return true;
}
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());
}
/**
* 首次使用时,若名单为空且存在 导入模板.xlsx,自动导入
*/
public static function ensureSeeded(): bool
{
if (self::count() > 0) {
return false;
}
$candidates = [
dirname(NY_ROOT) . '/导入模板.xlsx',
NY_ROOT . '/导入模板.xlsx',
NY_DATA . '/导入模板.xlsx',
];
foreach ($candidates as $f) {
if (is_file($f)) {
try {
$res = self::importFile($f, 'xlsx');
return $res['count'] > 0;
} catch (Throwable $e) {
// 忽略
}
}
}
return false;
}
/**
* 解析上传文件 -> 学生数组
* @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;
}
}