Files
ny-exam-platform/lib/Roster.php
T
nxxy335 6772aeae11 feat: 抽取页分组均衡5:5 / 重置清累计 / 历史记录 / 排除管理 / 登陆账号字段 / side等高
随机抽取页:
- 分组均衡抽取:有组别(≥2个)时默认按名额均分到组(12/8抽10→5/5),
  其次按班级均分,否则整池随机;可勾选关闭
- 名额均分算法 allocQuotas:floor 均分 + 余数小组优先补齐 +
  超容量退回 + 差额给剩余容量大者
- 重置结果:同时清零累计已抽取数(历史记录仍保留)
- 历史记录:每批次入 localStorage ny_draw_log(上限50),
  重置不清,支持查看详情/恢复结果/删除/清空
- 抽取排除:排除管理弹窗(搜索/清空),结果行可"移出并加入排除"
- 白名单列表下载:登陆账号优先,缺失回退学号

名单登陆账号:
- 模板解析识别"登陆/登录账号/账号/手机号"列,手机号去 .0 尾巴
- Db ny_roster 增 login 列 + 旧表自动升级(三方言)
- roster_save 透传 login;draw_export 使用 login
- 登陆账号仅用于白名单列表下载,其余界面不展示

布局:side 模式下成绩明细/缺考名单 panel 改 flex 撑满,
.table-scroll 取消 320px 限高,两侧等高不再留白

测试:tools/test-draw-alloc.js 18/18 通过(含 1000 组随机 fuzz)
2026-09-09 12:29:49 +08:00

267 lines
8.7 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}
*/
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('文件为空或无法解析');
}
return ['count' => count($rows), 'students' => self::rowsToStudents($rows)];
}
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;
}
/**
* 识别 学号/姓名/班级/组别/登陆账号 列
* 登陆账号仅用于随机抽取页导出白名单,界面任何位置都不显示
* @return array<int,array{sid:string,name:string,cls:string,grp:string,login:string}>
*/
public static function rowsToStudents(array $rows): array
{
$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*号|no\.?|id/i', $joined)
&& preg_match('/姓\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*no\.?\s*$|^\s*id\s*$/i', $c)) {
$sidCol = $j;
}
if ($nameCol < 0 && preg_match('/姓\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*$|group/i', $c)) {
$grpCol = $j;
}
if ($loginCol < 0 && preg_match('/登\s*[陆录]\s*账\s*号|登\s*[陆录]\s*账号|账\s*号|手\s*机\s*号|手\s*机/i', $c)) {
$loginCol = $j;
}
}
}
if ($sidCol < 0) {
$sidCol = 0;
}
if ($nameCol < 0) {
$nameCol = $sidCol === 0 ? 1 : 0;
}
$out = [];
for ($i = $start; $i < count($rows); $i++) {
$row = $rows[$i];
$sid = trim((string)($row[$sidCol] ?? ''));
$name = trim((string)($row[$nameCol] ?? ''));
if ($sid === '' && $name === '') {
continue;
}
// 学号去掉 .0 尾巴(Excel 数字读取)
$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] ?? '')) : '';
// 登陆账号同样去掉 Excel 数字尾巴(手机号被读成 13912687810.0)
$login = preg_replace('/\.0+$/', '', $login) ?? $login;
$out[] = ['sid' => $sid, 'name' => $name, 'cls' => $cls, 'grp' => $grp, 'login' => $login];
}
return $out;
}
}