- 登录门:进入页面先输入系统网址/API路由/账号/密码,测试连接通过后才显示登录按钮,登录后才进入主界面;密码每次需输入,防止依赖一次配置直接查看数据 - 每套试卷独立满分配置(设置中按当前试卷单独设置,合格率按 ≥60% 满分计算) - 班级联动统计与面板计数(成绩明细/缺考名单标题计数随班级过滤) - 移除最高/最低分获得者显示 - 平均用时不换行(避免“秒”字换行) - 卷王地址默认 https://exam.nxxy335.top,默认勾选校验 HTTPS 证书 - 统计卡片含合格率(≥60% 满分) - .gitignore 排除 data/(账号密码/令牌/学生名单,禁止入库)
224 lines
7.4 KiB
PHP
224 lines
7.4 KiB
PHP
<?php
|
|
/**
|
|
* 极简 XLSX 读取器(无第三方依赖)
|
|
* 优先使用 ZipArchive,缺失时回退到纯 PHP 的 ZIP 解析(支持 stored / deflate)。
|
|
*/
|
|
declare(strict_types=1);
|
|
|
|
class XlsxReader
|
|
{
|
|
/**
|
|
* @return array<int, array<int, string>> 二维数组(行 => 列)
|
|
*/
|
|
public static function read(string $file): array
|
|
{
|
|
if (!is_file($file)) {
|
|
throw new RuntimeException('文件不存在:' . $file);
|
|
}
|
|
$entries = self::readZip($file);
|
|
|
|
$shared = [];
|
|
if (isset($entries['xl/sharedStrings.xml'])) {
|
|
$shared = self::parseSharedStrings($entries['xl/sharedStrings.xml']);
|
|
}
|
|
|
|
$sheet = self::firstSheetContent($entries);
|
|
if ($sheet === null) {
|
|
throw new RuntimeException('未找到工作表内容');
|
|
}
|
|
return self::parseSheet($sheet, $shared);
|
|
}
|
|
|
|
// ---------------- ZIP ----------------
|
|
private static function readZip(string $file): array
|
|
{
|
|
if (class_exists('ZipArchive')) {
|
|
$out = [];
|
|
$za = new ZipArchive();
|
|
if ($za->open($file) === true) {
|
|
for ($i = 0; $i < $za->numFiles; $i++) {
|
|
$name = $za->getNameIndex($i);
|
|
if ($name === false) {
|
|
continue;
|
|
}
|
|
$content = $za->getFromIndex($i);
|
|
$out[$name] = $content === false ? '' : $content;
|
|
}
|
|
$za->close();
|
|
return $out;
|
|
}
|
|
}
|
|
return self::parseZipRaw($file);
|
|
}
|
|
|
|
private static function parseZipRaw(string $file): array
|
|
{
|
|
$data = (string)@file_get_contents($file);
|
|
$len = strlen($data);
|
|
if ($len < 22) {
|
|
throw new RuntimeException('不是有效的 xlsx 文件');
|
|
}
|
|
$eocd = -1;
|
|
for ($i = $len - 22; $i >= 0; $i--) {
|
|
if (substr($data, $i, 4) === "\x50\x4b\x05\x06") {
|
|
$eocd = $i;
|
|
break;
|
|
}
|
|
}
|
|
if ($eocd < 0) {
|
|
throw new RuntimeException('不是有效的 xlsx/zip 文件(未找到目录结束标记)');
|
|
}
|
|
$count = self::u16($data, $eocd + 10);
|
|
$offset = self::u32($data, $eocd + 16);
|
|
|
|
$out = [];
|
|
$p = $offset;
|
|
for ($i = 0; $i < $count; $i++) {
|
|
if (substr($data, $p, 4) !== "\x50\x4b\x01\x02") {
|
|
break;
|
|
}
|
|
$method = self::u16($data, $p + 10);
|
|
$compSize = self::u32($data, $p + 20);
|
|
$nameLen = self::u16($data, $p + 28);
|
|
$extraLen = self::u16($data, $p + 30);
|
|
$commentLen = self::u16($data, $p + 32);
|
|
$localOff = self::u32($data, $p + 42);
|
|
$name = substr($data, $p + 46, $nameLen);
|
|
|
|
$lNameLen = self::u16($data, $localOff + 26);
|
|
$lExtraLen = self::u16($data, $localOff + 28);
|
|
$start = $localOff + 30 + $lNameLen + $lExtraLen;
|
|
$raw = substr($data, $start, $compSize);
|
|
|
|
if ($method === 0) {
|
|
$content = $raw;
|
|
} else {
|
|
$content = @gzinflate($raw);
|
|
if ($content === false) {
|
|
$content = @gzinflate(substr($raw, 2));
|
|
}
|
|
}
|
|
|
|
$out[str_replace('\\', '/', $name)] = $content === false ? '' : $content;
|
|
$p += 46 + $nameLen + $extraLen + $commentLen;
|
|
}
|
|
return $out;
|
|
}
|
|
|
|
private static function u16(string $d, int $off): int
|
|
{
|
|
$s = substr($d, $off, 2);
|
|
return $s === '' || strlen($s) < 2 ? 0 : unpack('v', $s)[1];
|
|
}
|
|
|
|
private static function u32(string $d, int $off): int
|
|
{
|
|
$s = substr($d, $off, 4);
|
|
return $s === '' || strlen($s) < 4 ? 0 : unpack('V', $s)[1];
|
|
}
|
|
|
|
// ---------------- XML ----------------
|
|
private static function firstSheetContent(array $entries): ?string
|
|
{
|
|
if (isset($entries['xl/worksheets/sheet1.xml'])) {
|
|
return $entries['xl/worksheets/sheet1.xml'];
|
|
}
|
|
$found = [];
|
|
foreach (array_keys($entries) as $k) {
|
|
if (preg_match('#^xl/worksheets/sheet\d+\.xml$#', $k)) {
|
|
$found[] = $k;
|
|
}
|
|
}
|
|
if ($found === []) {
|
|
return null;
|
|
}
|
|
sort($found, SORT_NATURAL);
|
|
return $entries[$found[0]];
|
|
}
|
|
|
|
private static function parseSharedStrings(string $xml): array
|
|
{
|
|
$out = [];
|
|
if (preg_match_all('#<si>(.*?)</si>#s', $xml, $m) > 0) {
|
|
foreach ($m[1] as $si) {
|
|
if (preg_match_all('#<t[^>]*>(.*?)</t>#s', $si, $tm) > 0) {
|
|
$out[] = implode('', array_map(
|
|
static fn($x) => html_entity_decode($x, ENT_QUOTES | ENT_HTML5, 'UTF-8'),
|
|
$tm[1]
|
|
));
|
|
} else {
|
|
$out[] = '';
|
|
}
|
|
}
|
|
}
|
|
return $out;
|
|
}
|
|
|
|
private static function parseSheet(string $xml, array $shared): array
|
|
{
|
|
$rows = [];
|
|
if (preg_match_all('#<row\b[^>]*>(.*?)</row>#s', $xml, $rm) === 0) {
|
|
return $rows;
|
|
}
|
|
foreach ($rm[1] as $rowXml) {
|
|
$cells = [];
|
|
if (preg_match_all('#<c(?:\s([^>]*?))?(?:/>|>(.*?)</c>)#s', $rowXml, $cm, PREG_SET_ORDER) > 0) {
|
|
$autoIdx = 0;
|
|
foreach ($cm as $c) {
|
|
$attrs = $c[1] ?? '';
|
|
$inner = $c[2] ?? '';
|
|
|
|
$idx = $autoIdx;
|
|
if (preg_match('#\br="([A-Z]+)\d*"#', $attrs, $r)) {
|
|
$idx = self::colToIndex($r[1]);
|
|
}
|
|
$autoIdx = $idx + 1;
|
|
|
|
$type = preg_match('#\bt="([^"]+)"#', $attrs, $tm) ? $tm[1] : '';
|
|
|
|
if ($type === 'inlineStr') {
|
|
$val = '';
|
|
if (preg_match_all('#<t[^>]*>(.*?)</t>#s', $inner, $im) > 0) {
|
|
$val = implode('', array_map(
|
|
static fn($x) => html_entity_decode($x, ENT_QUOTES | ENT_HTML5, 'UTF-8'),
|
|
$im[1]
|
|
));
|
|
}
|
|
} else {
|
|
$val = preg_match('#<v>(.*?)</v>#s', $inner, $vm)
|
|
? html_entity_decode($vm[1], ENT_QUOTES | ENT_HTML5, 'UTF-8')
|
|
: '';
|
|
if ($type === 's') {
|
|
$i = (int)$val;
|
|
$val = $shared[$i] ?? '';
|
|
} elseif ($type === 'b') {
|
|
$val = ((int)$val) === 1 ? 'TRUE' : 'FALSE';
|
|
}
|
|
}
|
|
$cells[$idx] = $val;
|
|
}
|
|
}
|
|
if ($cells === []) {
|
|
continue;
|
|
}
|
|
$max = max(array_keys($cells));
|
|
$line = [];
|
|
for ($i = 0; $i <= $max; $i++) {
|
|
$line[] = $cells[$i] ?? '';
|
|
}
|
|
$rows[] = $line;
|
|
}
|
|
return $rows;
|
|
}
|
|
|
|
private static function colToIndex(string $letters): int
|
|
{
|
|
$n = 0;
|
|
$len = strlen($letters);
|
|
for ($i = 0; $i < $len; $i++) {
|
|
$n = $n * 26 + (ord($letters[$i]) - 64);
|
|
}
|
|
return $n - 1;
|
|
}
|
|
}
|