feat: 完善/优化/新增大批次 + 安全加固 + 性能优化
- 修复再次提交不置顶(排序键改用 last_submit_ts) - 后端结果缓存 + 并发锁(DataCache / ny_build_data / Roster 指纹失效) - 导出 xlsx 多表:按最新成绩 / 按班级分表 / 可选列 - 名单管理:新增改弹窗、删除多选+二次确认、登陆账号列 - 安全:CSRF token + 登录失败限流(5次锁10分钟) - 未匹配答卷一键加入名单(unmatched_map) - 缺考名单增强:复制学号/姓名、按班统计、打印 - 成绩快照 + 跨卷趋势(Snapshot) - 异常检测线索(短时长高分/高频重考/雷同成绩) - 前端性能:虚拟滚动、搜索防抖、后台暂停、失败退避 - 测试沉淀:test-new-features.php + run-all.sh
This commit is contained in:
@@ -13,12 +13,128 @@ function cfg(): array
|
||||
return ny_load_config();
|
||||
}
|
||||
|
||||
/**
|
||||
* 按成绩排序(null 成绩恒排末尾,同分按提交时间降序)
|
||||
* @param bool $desc true=降序
|
||||
*/
|
||||
function ny_sort_by_score(array $rows, bool $desc): array
|
||||
{
|
||||
$dir = $desc ? -1 : 1;
|
||||
usort($rows, static function (array $x, array $y) use ($dir): int {
|
||||
$xn = ($x['score'] ?? null) === null;
|
||||
$yn = ($y['score'] ?? null) === null;
|
||||
if ($xn && $yn) {
|
||||
return 0;
|
||||
}
|
||||
if ($xn) {
|
||||
return 1;
|
||||
}
|
||||
if ($yn) {
|
||||
return -1;
|
||||
}
|
||||
$d = ((float)$x['score'] <=> (float)$y['score']) * $dir;
|
||||
if ($d !== 0) {
|
||||
return $d;
|
||||
}
|
||||
return ((int)($y['submit_ts'] ?? 0)) <=> ((int)($x['submit_ts'] ?? 0));
|
||||
});
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/** 缺考名单工作表行(按学号升序) */
|
||||
function ny_absent_sheet_rows(array $absent): array
|
||||
{
|
||||
usort($absent, static function ($x, $y): int {
|
||||
$a = trim((string)($x['sid'] ?? ''));
|
||||
$b = trim((string)($y['sid'] ?? ''));
|
||||
if (is_numeric($a) && is_numeric($b)) {
|
||||
return $a <=> $b;
|
||||
}
|
||||
return strnatcmp($a, $b);
|
||||
});
|
||||
$out = [['学号', '姓名', '班级', '组别']];
|
||||
foreach ($absent as $r) {
|
||||
$out[] = [
|
||||
(string)($r['sid'] ?? ''),
|
||||
(string)($r['name'] ?? ''),
|
||||
(string)($r['cls'] ?? ''),
|
||||
(string)($r['grp'] ?? ''),
|
||||
];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 带短期缓存的数据构建(含并发锁,避免多人同时刷新时重复打卷王造成惊群)
|
||||
*
|
||||
* 缓存 key 由「项目 + 姓名字段 + 连接/去重等配置 + 名单指纹」组成:
|
||||
* 名单一变指纹就变,因此不会出现"改了名单还显示旧数据"。
|
||||
* force=true 时跳过缓存(前端"立即刷新"按钮使用)。
|
||||
*
|
||||
* @return array{result:array,cached:bool,at:int}
|
||||
*/
|
||||
function ny_build_data(array $c, string $projectId, string $nameQid, bool $force = false): array
|
||||
{
|
||||
$ttl = (int)($c['cache_ttl'] ?? 15);
|
||||
if ($ttl < 0) {
|
||||
$ttl = 0;
|
||||
}
|
||||
$key = DataCache::key([
|
||||
$projectId, $nameQid,
|
||||
(string)$c['base_url'], (string)$c['api_prefix'], (string)$c['username'],
|
||||
!empty($c['dedupe']) ? 1 : 0,
|
||||
!empty($c['include_temp_save']) ? 1 : 0,
|
||||
(string)($c['name_keywords'] ?? ''),
|
||||
(int)($c['page_size'] ?? 100),
|
||||
Roster::fingerprint(),
|
||||
]);
|
||||
|
||||
if ($ttl > 0 && !$force) {
|
||||
$hit = DataCache::read($key, $ttl);
|
||||
if ($hit !== null) {
|
||||
return ['result' => $hit['result'], 'cached' => true, 'at' => $hit['at']];
|
||||
}
|
||||
}
|
||||
|
||||
// 并发锁:同一 key 只允许一个请求真正去拉卷王
|
||||
$lock = DataCache::acquireLock($key);
|
||||
if ($lock === null) {
|
||||
// 已有别的请求在构建:短暂等待其落盘后直接复用(允许略旧,总比同时压垮卷王好)
|
||||
for ($i = 0; $i < 15; $i++) {
|
||||
usleep(200000); // 200ms
|
||||
$hit = DataCache::read($key, max($ttl, 60));
|
||||
if ($hit !== null) {
|
||||
return ['result' => $hit['result'], 'cached' => true, 'at' => $hit['at']];
|
||||
}
|
||||
}
|
||||
// 等不到(构建很慢或锁不被支持)就自己构建,保证可用性
|
||||
}
|
||||
|
||||
$result = DataService::build($c, $projectId, $nameQid);
|
||||
if ($ttl > 0) {
|
||||
DataCache::write($key, $result);
|
||||
}
|
||||
DataCache::releaseLock($lock);
|
||||
|
||||
return ['result' => $result, 'cached' => false, 'at' => time()];
|
||||
}
|
||||
|
||||
// 会话保护:除 auth_check 外,未登录一律拒访
|
||||
if ($action !== 'auth_check' && !ny_is_auth()) {
|
||||
ny_json(['ok' => false, 'msg' => '未登录或会话已过期,请重新登录'], 401);
|
||||
exit;
|
||||
}
|
||||
|
||||
// CSRF 防护:所有写操作必须携带会话绑定的 token(GET 读取类接口不受影响)
|
||||
$WRITE_ACTIONS = [
|
||||
'config_save', 'roster_save', 'roster_import', 'roster_clear',
|
||||
'snapshot_save', 'snapshot_delete', 'unmatched_map',
|
||||
];
|
||||
if (in_array($action, $WRITE_ACTIONS, true) && !ny_csrf_ok()) {
|
||||
ny_json(['ok' => false, 'msg' => '安全校验失败(CSRF token 缺失或不匹配),请刷新页面后重试'], 403);
|
||||
exit;
|
||||
}
|
||||
|
||||
try {
|
||||
switch ($action) {
|
||||
// 登录状态检查
|
||||
@@ -30,6 +146,7 @@ try {
|
||||
$c = cfg();
|
||||
$c['has_password'] = ((string)$c['password']) !== '';
|
||||
unset($c['password']);
|
||||
$c['csrf_token'] = ny_csrf_token();
|
||||
// 诊断信息:配置存在哪里、数据库通不通(登录卷王失败时优先看这里)
|
||||
$c['storage'] = ny_config_source();
|
||||
$c['db_driver'] = class_exists('Db') ? Db::driver() : '';
|
||||
@@ -210,7 +327,8 @@ try {
|
||||
}
|
||||
$nameQid = (string)($_GET['nameQid'] ?? '');
|
||||
$c = cfg();
|
||||
$result = DataService::build($c, $projectId, $nameQid);
|
||||
$built = ny_build_data($c, $projectId, $nameQid, !empty($_GET['force']));
|
||||
$result = $built['result'];
|
||||
$fsMap = is_array($c['full_scores'] ?? null) ? $c['full_scores'] : [];
|
||||
$fs = isset($fsMap[$projectId]) ? (int)$fsMap[$projectId] : (int)($c['full_score'] ?? 100);
|
||||
if ($fs < 1) {
|
||||
@@ -221,6 +339,8 @@ try {
|
||||
'base_url' => $c['base_url'],
|
||||
'full_score' => $fs,
|
||||
];
|
||||
// 缓存来源标记:便于前端/排查时知道这次是实时拉取还是命中短期缓存
|
||||
$result['_cache'] = ['cached' => $built['cached'], 'at' => $built['at']];
|
||||
ny_ok($result);
|
||||
|
||||
// ---------------- 数据看板(按班级聚合,面向班主任/学生整体情况) ----------------
|
||||
@@ -231,7 +351,8 @@ try {
|
||||
}
|
||||
$nameQid = (string)($_GET['nameQid'] ?? '');
|
||||
$c = cfg();
|
||||
$result = DataService::build($c, $projectId, $nameQid);
|
||||
$built = ny_build_data($c, $projectId, $nameQid, !empty($_GET['force']));
|
||||
$result = $built['result'];
|
||||
$fsMap = is_array($c['full_scores'] ?? null) ? $c['full_scores'] : [];
|
||||
$fs = isset($fsMap[$projectId]) ? (int)$fsMap[$projectId] : (int)($c['full_score'] ?? 100);
|
||||
if ($fs < 1) {
|
||||
@@ -418,6 +539,99 @@ try {
|
||||
Roster::clear();
|
||||
ny_ok(['students' => [], 'meta' => Roster::meta()]);
|
||||
|
||||
// ---------------- 未匹配答卷 → 加入名单 ----------------
|
||||
case 'unmatched_map':
|
||||
$raw = (string)file_get_contents('php://input');
|
||||
$in = json_decode($raw, true);
|
||||
if (!is_array($in)) {
|
||||
$in = $_POST;
|
||||
}
|
||||
$name = trim((string)($in['name'] ?? ''));
|
||||
$sid = trim((string)($in['sid'] ?? ''));
|
||||
if ($name === '' || $sid === '') {
|
||||
ny_fail('姓名与学号必填');
|
||||
}
|
||||
$cls = trim((string)($in['cls'] ?? ''));
|
||||
$grp = trim((string)($in['grp'] ?? ''));
|
||||
$login = trim((string)($in['login'] ?? ''));
|
||||
$roster = Roster::all();
|
||||
// 若学号已存在则更新其姓名为该答卷姓名(映射到已有学生);否则新增一行
|
||||
$found = false;
|
||||
foreach ($roster as &$s) {
|
||||
if (trim((string)($s['sid'] ?? '')) === $sid) {
|
||||
$s['name'] = $name;
|
||||
if ($cls !== '') { $s['cls'] = $cls; }
|
||||
if ($grp !== '') { $s['grp'] = $grp; }
|
||||
if ($login !== '') { $s['login'] = $login; }
|
||||
$found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
unset($s);
|
||||
if (!$found) {
|
||||
$roster[] = ['sid' => $sid, 'name' => $name, 'cls' => $cls, 'grp' => $grp, 'login' => $login];
|
||||
}
|
||||
Roster::save($roster);
|
||||
ny_ok(['students' => Roster::all(), 'meta' => Roster::meta(), 'mapped' => $name]);
|
||||
|
||||
// ---------------- 成绩快照 / 趋势 ----------------
|
||||
case 'snapshot_save':
|
||||
$projectId = (string)($_GET['projectId'] ?? $_POST['projectId'] ?? '');
|
||||
if ($projectId === '') {
|
||||
ny_fail('请先选择问卷/考试');
|
||||
}
|
||||
$raw = (string)file_get_contents('php://input');
|
||||
$in = json_decode($raw, true);
|
||||
if (!is_array($in)) {
|
||||
$in = $_POST;
|
||||
}
|
||||
$label = trim((string)($in['label'] ?? ''));
|
||||
// 复用缓存构建(不强制,快照本就是"当前看到的成绩")
|
||||
$c = cfg();
|
||||
$nameQid = (string)($_GET['nameQid'] ?? '');
|
||||
$built = ny_build_data($c, $projectId, $nameQid);
|
||||
$snap = Snapshot::summarize($built['result'], $label);
|
||||
$file = Snapshot::save($projectId, $snap);
|
||||
ny_ok(['file' => $file, 'label' => $snap['label'], 'at_txt' => $snap['at_txt'], 'count' => count($snap['students'])]);
|
||||
|
||||
case 'snapshot_list':
|
||||
$projectId = (string)($_GET['projectId'] ?? '');
|
||||
if ($projectId === '') {
|
||||
ny_fail('请先选择问卷/考试');
|
||||
}
|
||||
ny_ok(['snapshots' => Snapshot::list($projectId)]);
|
||||
|
||||
case 'snapshot_delete':
|
||||
$projectId = (string)($_GET['projectId'] ?? $_POST['projectId'] ?? '');
|
||||
$raw = (string)file_get_contents('php://input');
|
||||
$in = json_decode($raw, true);
|
||||
if (!is_array($in)) {
|
||||
$in = $_POST;
|
||||
}
|
||||
$file = (string)($in['file'] ?? '');
|
||||
if ($projectId === '' || $file === '') {
|
||||
ny_fail('参数错误');
|
||||
}
|
||||
ny_ok(['deleted' => Snapshot::delete($projectId, $file)]);
|
||||
|
||||
case 'snapshot_trend':
|
||||
$projectId = (string)($_GET['projectId'] ?? '');
|
||||
if ($projectId === '') {
|
||||
ny_fail('请先选择问卷/考试');
|
||||
}
|
||||
// 支持跨项目对比:projectId 可用逗号分隔多个项目,取各自快照混合趋势
|
||||
$pids = array_values(array_filter(array_map('trim', explode(',', $projectId))));
|
||||
$snaps = [];
|
||||
foreach ($pids as $pid) {
|
||||
foreach (Snapshot::list($pid) as $s) {
|
||||
$full = Snapshot::get($pid, $s['file']);
|
||||
if ($full !== null) {
|
||||
$snaps[] = $full;
|
||||
}
|
||||
}
|
||||
}
|
||||
ny_ok(Snapshot::trend($snaps));
|
||||
|
||||
// ---------------- 导出(支持按班级 / 组别筛选,与页面当前筛选保持一致) ----------------
|
||||
case 'export':
|
||||
case 'export_absent':
|
||||
@@ -441,7 +655,9 @@ try {
|
||||
};
|
||||
|
||||
$c = cfg();
|
||||
$result = DataService::build($c, $projectId, $nameQid);
|
||||
// 复用与 data 接口相同的结果缓存:导出不必再全量拉一次卷王
|
||||
$built = ny_build_data($c, $projectId, $nameQid, !empty($_GET['force']));
|
||||
$result = $built['result'];
|
||||
|
||||
$rows = array_values(array_filter((array)($result['rows'] ?? []), $hit));
|
||||
|
||||
@@ -473,73 +689,145 @@ try {
|
||||
$tag = ($clsLabel !== '' ? '_' . $safe($clsLabel) : '')
|
||||
. ($grpLabel !== '' ? '_' . $safe($grpLabel) : '');
|
||||
|
||||
$mode = ((string)($_GET['mode'] ?? '') === 'latest') ? 'latest' : 'all';
|
||||
$splitCls = ((string)($_GET['split'] ?? '') === 'cls');
|
||||
$isLatest = ($mode === 'latest');
|
||||
$filename = ($action === 'export_absent' ? '缺考名单' : '成绩明细')
|
||||
. '_' . $safe((string)$result['project']['name'])
|
||||
. $tag
|
||||
. '_' . date('Ymd_His') . '.csv';
|
||||
. ($isLatest ? '_最新成绩' : '')
|
||||
. '_' . date('Ymd_His') . '.xlsx';
|
||||
|
||||
header('Content-Type: text/csv; charset=utf-8');
|
||||
header('Content-Disposition: attachment; filename="' . $filename . '"; filename*=UTF-8\'\'' . rawurlencode($filename));
|
||||
header('Cache-Control: no-store');
|
||||
echo "\xEF\xBB\xBF";
|
||||
$out = fopen('php://output', 'w');
|
||||
// ---------------- 列定义(可通过 cols= 参数挑选) ----------------
|
||||
$colLabels = [
|
||||
'sid' => '学号',
|
||||
'name' => '姓名',
|
||||
'cls' => '班级',
|
||||
'grp' => '组别',
|
||||
'score' => $isLatest ? '最新成绩' : '成绩',
|
||||
'best_score' => '最高分',
|
||||
'attempts' => '考试次数',
|
||||
'idx' => '第几次',
|
||||
'submit_at' => $isLatest ? '最近提交时间' : '提交时间',
|
||||
'duration_txt' => '考试时长',
|
||||
'duration' => '考试时长(秒)',
|
||||
];
|
||||
$defaultCols = $isLatest
|
||||
? ['sid', 'name', 'cls', 'grp', 'score', 'best_score', 'attempts', 'submit_at', 'duration_txt', 'duration']
|
||||
: ['sid', 'name', 'cls', 'grp', 'score', 'attempts', 'idx', 'submit_at', 'duration_txt', 'duration'];
|
||||
$want = array_values(array_filter(
|
||||
array_map('trim', explode(',', (string)($_GET['cols'] ?? ''))),
|
||||
static fn($v): bool => $v !== ''
|
||||
));
|
||||
$cols = $want !== [] ? array_values(array_intersect($defaultCols, $want)) : $defaultCols;
|
||||
if ($cols === []) {
|
||||
$cols = $defaultCols;
|
||||
}
|
||||
|
||||
if ($action === 'export_absent') {
|
||||
// 与页面缺考名单一致:按学号升序(纯数字按数值,否则自然排序)
|
||||
usort($absent, static function ($x, $y): int {
|
||||
$a = trim((string)($x['sid'] ?? ''));
|
||||
$b = trim((string)($y['sid'] ?? ''));
|
||||
if (is_numeric($a) && is_numeric($b)) {
|
||||
return $a <=> $b;
|
||||
}
|
||||
return strnatcmp($a, $b);
|
||||
});
|
||||
fputcsv($out, ['学号', '姓名', '班级', '组别']);
|
||||
foreach ($absent as $r) {
|
||||
fputcsv($out, [$r['sid'] ?? '', $r['name'] ?? '', $r['cls'] ?? '', $r['grp'] ?? '']);
|
||||
// ---------------- 数据行 ----------------
|
||||
$sortMode = (string)($_GET['sort'] ?? '');
|
||||
if ($isLatest) {
|
||||
// 每人一行:成绩取「最近一次提交」的分数(不受 dedupe 影响),并附最高分与考试次数
|
||||
$all = [];
|
||||
foreach ($rows as $r) {
|
||||
$list = array_values((array)($r['attempts_list'] ?? []));
|
||||
$last = $list !== [] ? $list[count($list) - 1] : null;
|
||||
$all[] = [
|
||||
'sid' => (string)($r['sid'] ?? ''),
|
||||
'name' => (string)($r['name'] ?? ''),
|
||||
'cls' => (string)($r['cls'] ?? ''),
|
||||
'grp' => (string)($r['grp'] ?? ''),
|
||||
'score' => $last !== null ? ($last['score'] ?? null) : ($r['score'] ?? null),
|
||||
'best_score' => $r['best_score'] ?? null,
|
||||
'attempts' => (int)($r['attempts'] ?? 1),
|
||||
'idx' => (int)($r['attempts'] ?? 1),
|
||||
'submit_ts' => (int)($r['last_submit_ts'] ?? 0),
|
||||
'submit_at' => (string)($r['last_submit_at'] ?? ($last['submit_at'] ?? ($r['submit_at'] ?? '-'))),
|
||||
'duration_txt' => (string)($last['duration_txt'] ?? ($r['duration_txt'] ?? '-')),
|
||||
'duration' => $last['duration'] ?? ($r['duration'] ?? null),
|
||||
];
|
||||
}
|
||||
} else {
|
||||
// 导出全部考试记录(同一学号考了 N 次就有 N 行),并保留"考试次数"
|
||||
$sortMode = (string)($_GET['sort'] ?? '');
|
||||
$all = DataService::expandAttempts($rows, true);
|
||||
|
||||
// time=按提交时间降序;sid=按学号(同学号内按提交时间降序)
|
||||
// score_desc=按成绩降序(同分按提交时间);score_asc=按成绩升序
|
||||
if ($sortMode === 'score_desc' || $sortMode === 'score_asc') {
|
||||
$dir = ($sortMode === 'score_desc') ? -1 : 1;
|
||||
usort($all, static function (array $x, array $y) use ($dir): int {
|
||||
// null 成绩排末尾
|
||||
$xn = ($x['score'] === null);
|
||||
$yn = ($y['score'] === null);
|
||||
if ($xn && $yn) return 0;
|
||||
if ($xn) return 1;
|
||||
if ($yn) return -1;
|
||||
$d = ($x['score'] <=> $y['score']) * $dir;
|
||||
if ($d !== 0) return $d;
|
||||
// 同分按提交时间降序
|
||||
return ((int)($y['submit_ts'] ?? 0)) <=> ((int)($x['submit_ts'] ?? 0));
|
||||
});
|
||||
$all = ny_sort_by_score($all, $sortMode === 'score_desc');
|
||||
} else {
|
||||
$all = DataService::sortForExport($all, ($sortMode === 'sid') ? 'sid' : 'time');
|
||||
}
|
||||
|
||||
fputcsv($out, ['学号', '姓名', '班级', '组别', '成绩', '考试次数', '第几次', '提交时间', '考试时长', '考试时长(秒)']);
|
||||
foreach ($all as $r) {
|
||||
fputcsv($out, [
|
||||
$r['sid'] ?? '',
|
||||
$r['name'] ?? '',
|
||||
$r['cls'] ?? '',
|
||||
$r['grp'] ?? '',
|
||||
$r['score'] === null ? '' : $r['score'],
|
||||
(int)($r['attempts'] ?? 1),
|
||||
(int)($r['idx'] ?? 1),
|
||||
$r['submit_at'] ?? '',
|
||||
$r['duration_txt'] ?? '',
|
||||
$r['duration'] === null ? '' : $r['duration'],
|
||||
]);
|
||||
} else {
|
||||
// 每次考试一行(同一学号考了 N 次就有 N 行)
|
||||
$all = DataService::expandAttempts($rows, true);
|
||||
if ($sortMode === 'score_desc' || $sortMode === 'score_asc') {
|
||||
$all = ny_sort_by_score($all, $sortMode === 'score_desc');
|
||||
} else {
|
||||
$all = DataService::sortForExport($all, ($sortMode === 'sid') ? 'sid' : 'time');
|
||||
}
|
||||
}
|
||||
fclose($out);
|
||||
|
||||
// ---------------- 组装工作表 ----------------
|
||||
$toRows = static function (array $list) use ($cols, $colLabels): array {
|
||||
$head = [];
|
||||
foreach ($cols as $c) {
|
||||
$head[] = $colLabels[$c];
|
||||
}
|
||||
$out = [$head];
|
||||
foreach ($list as $r) {
|
||||
$line = [];
|
||||
foreach ($cols as $c) {
|
||||
$v = $r[$c] ?? null;
|
||||
$line[] = ($v === null || $v === '') ? '' : (string)$v;
|
||||
}
|
||||
$out[] = $line;
|
||||
}
|
||||
return $out;
|
||||
};
|
||||
$widths = [];
|
||||
foreach ($cols as $c) {
|
||||
$widths[] = in_array($c, ['sid', 'submit_at'], true) ? 20 : ($c === 'name' ? 14 : 11);
|
||||
}
|
||||
|
||||
$sheets = [];
|
||||
if ($action === 'export_absent') {
|
||||
$sheets[] = ['name' => '缺考名单', 'rows' => ny_absent_sheet_rows($absent), 'widths' => [16, 14, 16, 10]];
|
||||
} else {
|
||||
if ($splitCls) {
|
||||
// 按班级分表(班级名即工作表名)
|
||||
$byCls = [];
|
||||
foreach ($all as $r) {
|
||||
$byCls[trim((string)($r['cls'] ?? '')) ?: '(未分班)'][] = $r;
|
||||
}
|
||||
ksort($byCls, SORT_NATURAL | SORT_FLAG_CASE);
|
||||
foreach ($byCls as $clsName => $list) {
|
||||
$sheets[] = ['name' => (string)$clsName, 'rows' => $toRows($list), 'widths' => $widths];
|
||||
}
|
||||
} else {
|
||||
$sheets[] = ['name' => $isLatest ? '成绩明细(最新)' : '成绩明细', 'rows' => $toRows($all), 'widths' => $widths];
|
||||
}
|
||||
// 附加:缺考名单 / 未匹配答卷(仅非空时输出)
|
||||
if ($absent !== []) {
|
||||
$sheets[] = ['name' => '缺考名单', 'rows' => ny_absent_sheet_rows($absent), 'widths' => [16, 14, 16, 10]];
|
||||
}
|
||||
$unmatched = array_values((array)($result['unmatched'] ?? []));
|
||||
if ($unmatched !== []) {
|
||||
$um = [['姓名', '成绩', '提交时间']];
|
||||
foreach ($unmatched as $u) {
|
||||
$um[] = [
|
||||
(string)($u['name'] ?? ''),
|
||||
($u['score'] ?? null) === null ? '' : (string)$u['score'],
|
||||
(string)($u['submit_at'] ?? ''),
|
||||
];
|
||||
}
|
||||
$sheets[] = ['name' => '未匹配答卷', 'rows' => $um, 'widths' => [16, 10, 20]];
|
||||
}
|
||||
}
|
||||
|
||||
// 说明:所有单元格按「共享字符串 + 文本格式」写入,
|
||||
// 既不会把长学号变成科学计数法,也不会把 = + - @ 开头的内容当公式执行(规避 CSV 公式注入)
|
||||
$bin = XlsxWriter::renderMulti($sheets);
|
||||
|
||||
header('Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
||||
header('Content-Disposition: attachment; filename="export.xlsx"; filename*=UTF-8\'\'' . rawurlencode($filename));
|
||||
header('Content-Length: ' . strlen($bin));
|
||||
header('Cache-Control: no-store');
|
||||
echo $bin;
|
||||
exit;
|
||||
|
||||
// ---------------- 随机抽取:导出白名单(xlsx,列:姓名 / 登陆账号) ----------------
|
||||
|
||||
Reference in New Issue
Block a user