Files
nxxy335 d05fd4a9b5 feat(export/dashboard): 导出增强与大屏公开化
导出-每次考试/多考试对比:
- 合格次数阈值等标记项抽为常显区块,三类成绩表共用
- 附缺考名单扩展到多考试导出(取所选考试全都没交的人)
- 块表每场固定2列(成绩+合并空位),修复漏推空串导致的整行左移/串列
- 列宽按实际内容估算,合并区空位只给最小宽度
- 合格次数始终显示实际次数(含0),仅 ≥ 阈值标绿底
- 打印标题改为「成绩明细」,缺考名单分页单独成页
- 打印样式加 print-color-adjust:exact,绿底/红字可正常印出

数据大屏:
- ?class= 班级大屏免登录公开访问,接口侧放行并按班级裁剪后回传
- 单班大屏移除退出按钮,仅首页保留
- 修复红绿判定真bug:membersTable 误用 window.DASH(DASH为let声明取不到)
  改为 scoreGrade() 直接读自由变量,按后台合格率正确判红/黄/绿

测试/样例/文档同步更新,全量回归通过。
2026-09-27 22:04:17 +08:00

1284 lines
62 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
/**
* NY 问卷考试系统数据平台 - 后端接口
*/
declare(strict_types=1);
require __DIR__ . '/bootstrap.php';
require __DIR__ . '/lib/multi_export.php';
$action = (string)($_GET['action'] ?? $_POST['action'] ?? '');
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;
}
/**
* 多考试对比导出(成绩交叉矩阵)的纯逻辑见 lib/multi_export.php
* (ny_exam_cell_score / ny_assemble_multi_exam_matrix),便于在 api.php 与单测中复用。
*/
/**
* 带短期缓存的数据构建(含并发锁,避免多人同时刷新时重复打卷王造成惊群)
*
* 缓存 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(),
// 本卷独立配置(免考名单/满分/合格率)变化也应让缓存失效,否则改免考后可能短暂看到旧数据
json_encode(DataService::projectConfig($projectId) ?? []),
]);
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']];
}
}
// 等不到(构建很慢或锁不被支持)就自己构建,保证可用性
}
// try/finally:无论 build 是否抛异常都释放构建锁,避免异常时锁句柄挂到进程退出造成惊群
$result = null;
try {
$result = DataService::build($c, $projectId, $nameQid);
if ($ttl > 0) {
DataCache::write($key, $result);
}
} finally {
DataCache::releaseLock($lock);
}
return ['result' => $result, 'cached' => false, 'at' => time()];
}
// 会话保护:白名单外的动作未登录一律拒访。
// dashboard 进白名单是因为班级大屏要公开访问;它在 case 内部自己兜底——
// 未登录时必须带 ?class=,且只回传该班数据(全卷与其它班级一律不回)。
$PUBLIC_ACTIONS = ['auth_check', 'dashboard'];
if (!in_array($action, $PUBLIC_ACTIONS, true) && !ny_is_auth()) {
ny_json(['ok' => false, 'msg' => '未登录或会话已过期,请重新登录'], 401);
exit;
}
// CSRF 防护:所有写操作必须携带会话绑定的 token(GET 读取类接口不受影响)
$WRITE_ACTIONS = [
'config_save', 'roster_save', 'roster_import', 'roster_clear',
'unmatched_map', 'project_config_save',
];
if (in_array($action, $WRITE_ACTIONS, true) && !ny_csrf_ok()) {
ny_json(['ok' => false, 'msg' => '安全校验失败(CSRF token 缺失或不匹配),请刷新页面后重试'], 403);
exit;
}
try {
switch ($action) {
// 登录状态检查
case 'auth_check':
ny_ok(['auth' => ny_is_auth(), 'installed' => ny_is_installed()]);
// ---------------- 配置 ----------------
case 'config_get':
$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() : '';
$c['db_configured'] = class_exists('Db') && Db::isConfigured();
$c['db_connected'] = class_exists('Db') && Db::isConnected();
// 仅在未连接时回传错误详情(供前端诊断连接失败原因);
// 已连接时一律不回传 lastError,避免把 SQL/文件路径等内部信息泄露给客户端。
$c['db_error'] = (class_exists('Db') && !Db::isConnected()) ? Db::lastError() : '';
ny_ok($c);
case 'config_save':
$raw = ny_request_body();
$in = json_decode($raw, true);
if (!is_array($in)) {
$in = $_POST;
}
$old = cfg();
// 合并规则统一放在 ny_apply_config_input(含"账号/密码不许被改空"的兜底),便于单测
$applied = ny_apply_config_input($old, $in);
$new = $applied['config'];
$connChanged = $applied['conn_changed'];
ny_save_config($new);
if ($connChanged) {
@unlink(NY_DATA . '/cache/token.json');
}
// 返回的 config 不再带明文密码
$outCfg = ny_load_config();
$outCfg['has_password'] = ((string)$outCfg['password']) !== '';
unset($outCfg['password']);
$res = [
'saved' => true,
'config' => $outCfg,
'conn_changed' => $connChanged,
'storage' => ny_config_source(),
];
// 连接信息修改后自动测一次,给出明确结果(避免用户配置对错无从知晓)
if ($connChanged) {
try {
$client = new SurveyKingClient(ny_load_config());
$p = $client->ping();
$res['ping'] = ['ok' => true, 'projects' => $p['projects'], 'elapsed' => $p['elapsed']];
} catch (Throwable $e) {
$res['ping'] = ['ok' => false, 'msg' => $e->getMessage()];
}
}
ny_ok($res);
// ---------------- 用弹窗内(未保存)的凭据测试连接 ----------------
case 'config_test':
$raw = ny_request_body();
$in = json_decode($raw, true);
if (!is_array($in)) {
$in = $_POST;
}
// 以已保存配置为基底,仅覆盖弹窗中填写的字段,避免漏填导致误测
$test = ny_load_config();
foreach (['base_url', 'api_prefix', 'username', 'verify_ssl', 'timeout'] as $k) {
if (array_key_exists($k, $in)) {
$test[$k] = is_string($in[$k]) ? trim((string)$in[$k]) : $in[$k];
}
}
if (array_key_exists('password', $in)) {
$pwdIn = (string)$in['password'];
if ($pwdIn !== '' && $pwdIn !== '******') {
$test['password'] = $pwdIn;
}
}
$test['base_url'] = rtrim((string)($test['base_url'] ?? ''), '/');
$test['api_prefix'] = '/' . trim((string)($test['api_prefix'] ?? '/api'), '/');
try {
$client = new SurveyKingClient($test);
$p = $client->ping();
ny_ok(['connected' => true, 'projects' => $p['projects'], 'elapsed' => $p['elapsed']]);
} catch (Throwable $e) {
ny_ok(['connected' => false, 'msg' => $e->getMessage()]);
}
// ---------------- 连接测试 ----------------
case 'ping':
$client = new SurveyKingClient(cfg());
$res = $client->ping();
$res['session'] = $client->sessionInfo();
ny_ok($res);
case 'diag':
$client = new SurveyKingClient(cfg());
ny_ok($client->diagnose());
// ---------------- 问卷列表 ----------------
case 'projects':
$client = new SurveyKingClient(cfg());
$list = $client->listProjects();
$out = [];
foreach ($list as $p) {
if (!is_array($p)) {
continue;
}
$out[] = [
'id' => ny_str($p['id'] ?? ''),
'name' => ny_str($p['name'] ?? '') ?: '未命名',
'mode' => ny_str($p['mode'] ?? ''),
'total' => (int)($p['total'] ?? 0),
'status' => (int)($p['status'] ?? 0),
'parent_id' => (string)($p['parentId'] ?? '0'),
];
}
ny_ok($out);
// ---------------- 数据 ----------------
case 'data':
$projectId = (string)($_GET['projectId'] ?? '');
if ($projectId === '') {
ny_fail('请先选择问卷/考试');
}
$nameQid = (string)($_GET['nameQid'] ?? '');
$c = cfg();
$built = ny_build_data($c, $projectId, $nameQid, !empty($_GET['force']));
$result = $built['result'];
// 满分:本卷独立优先,其次全局默认
[$fs, $passRate] = ny_resolve_score_cfg($projectId, $c);
$result['config'] = [
'refresh_interval' => (int)$c['refresh_interval'],
'base_url' => $c['base_url'],
'full_score' => $fs,
'pass_rate' => $passRate,
];
// 缓存来源标记:便于前端/排查时知道这次是实时拉取还是命中短期缓存
$result['_cache'] = ['cached' => $built['cached'], 'at' => $built['at']];
ny_ok($result);
// ---------------- 数据看板(按班级聚合,面向班主任/学生整体情况) ----------------
case 'dashboard':
$projectId = (string)($_GET['projectId'] ?? '');
if ($projectId === '') {
ny_fail('请先选择问卷/考试');
}
$nameQid = (string)($_GET['nameQid'] ?? '');
$c = cfg();
$built = ny_build_data($c, $projectId, $nameQid, !empty($_GET['force']));
$result = $built['result'];
[$fs, $passRate] = ny_resolve_score_cfg($projectId, $c);
$students = $result['roster'] ?: [];
$rows = $result['rows'] ?: [];
/* 公开访问(未登录)只允许看指定班级,且只看这一个类的数据:
名单、答卷都在服务端先按班级裁掉,不给前端任何"全卷"数据,
避免公开链接顺手拿到其它班级甚至全校的成绩。 */
$publicCls = '';
if (!ny_is_auth()) {
$publicCls = trim((string)($_GET['class'] ?? ''));
if ($publicCls === '') {
ny_fail('缺少班级参数(公开访问需指定 ?class=)');
}
$inCls = static fn($x) => (trim((string)($x['cls'] ?? '')) ?: '(未分班)') === $publicCls;
$students = array_values(array_filter($students, $inCls));
$rows = array_values(array_filter($rows, $inCls));
}
$byCls = [];
foreach ($students as $s) {
$k = trim((string)($s['cls'] ?? '')) ?: '(未分班)';
$byCls[$k][] = $s;
}
$rowsByCls = [];
foreach ($rows as $r) {
$k = trim((string)($r['cls'] ?? '')) ?: '(未分班)';
$rowsByCls[$k][] = $r;
}
// 通用统计器:给定学生集合 + 答卷集合,算一组指标
// 合格率阈值可配(全局 pass_rate 或本卷独立),不再写死 60%
$statOf = static function (array $stu, array $rs, int $fs) use ($passRate): array {
$total = count($stu);
$submitted = count($rs);
$attempts = array_sum(array_map(static fn($r) => (int)($r['attempts'] ?? 1), $rs));
$scores = array_values(array_filter(array_map(static fn($r) => $r['score'] ?? null, $rs), static fn($v) => $v !== null));
$avg = $scores !== [] ? round(array_sum($scores) / count($scores), 2) : null;
$pass = count(array_filter($rs, static fn($r) => ($r['score'] ?? null) !== null && ($r['score'] >= ($passRate / 100) * $fs)));
$passRate = $submitted > 0 ? round($pass / $submitted * 100, 1) : 0;
$durs = array_values(array_filter(array_map(static fn($r) => $r['duration'] ?? null, $rs), static fn($v) => $v !== null));
$avgDur = $durs !== [] ? (int)round(array_sum($durs) / count($durs)) : null;
return [
'total' => $total,
'submitted' => $submitted,
'attempts' => $attempts,
'absent' => max(0, $total - $submitted),
'rate' => $total > 0 ? round($submitted / $total * 100, 1) : 0,
'avg' => $avg,
'pass_rate' => $passRate,
'max' => $scores !== [] ? max($scores) : null,
'min' => $scores !== [] ? min($scores) : null,
'avg_duration' => $avgDur,
];
};
// 已提交成员榜:按成绩从高到低,缺成绩者排最后,并给出排名
// 透传 attempts / attempts_list,供前端展示"同人多刷卷"
$membersOf = static function (array $rs): array {
$ms = [];
foreach ($rs as $r) {
$ms[] = [
'sid' => (string)($r['sid'] ?? ''),
'name' => (string)($r['name'] ?? ''),
'score' => ($r['score'] ?? null),
'attempts' => (int)($r['attempts'] ?? 1),
'attempts_list' => $r['attempts_list'] ?? [],
];
}
usort($ms, static function (array $a, array $b): int {
$as = $a['score'];
$bs = $b['score'];
if ($as === null && $bs === null) {
return 0;
}
if ($as === null) {
return 1; // 无成绩排最后
}
if ($bs === null) {
return -1;
}
return $bs <=> $as; // 成绩降序
});
foreach ($ms as $i => $m) {
$ms[$i]['rank'] = $i + 1;
}
return $ms;
};
$classes = [];
foreach ($byCls as $k => $stu) {
$rs = $rowsByCls[$k] ?? [];
$row = $statOf($stu, $rs, $fs);
$row['class'] = $k;
$row['members'] = $membersOf($rs);
// 班级内按组别(A/B 等)再聚合,用于看板下钻对比
$stuByGrp = [];
foreach ($stu as $s) {
$g = trim((string)($s['grp'] ?? '')) ?: '(未分组)';
$stuByGrp[$g][] = $s;
}
$rowsByGrp = [];
foreach ($rs as $r) {
$g = trim((string)($r['grp'] ?? '')) ?: '(未分组)';
$rowsByGrp[$g][] = $r;
}
$groups = [];
foreach ($stuByGrp as $g => $gstu) {
$gs = $statOf($gstu, $rowsByGrp[$g] ?? [], $fs);
$gs['group'] = $g;
$gs['members'] = $membersOf($rowsByGrp[$g] ?? []);
$groups[] = $gs;
}
usort($groups, static fn(array $a, array $b): int => strcmp((string)$a['group'], (string)$b['group']));
$row['groups'] = $groups;
$classes[] = $row;
}
usort($classes, static function (array $a, array $b): int {
$an = is_numeric($a['class']) ? (int)$a['class'] : $a['class'];
$bn = is_numeric($b['class']) ? (int)$b['class'] : $b['class'];
if (is_int($an) && is_int($bn)) {
return $an <=> $bn;
}
return strcmp((string)$a['class'], (string)$b['class']);
});
$payload = [
'project' => $result['project'],
'full_score' => $fs,
'pass_rate' => $passRate,
'overall' => $result['stats'],
'classes' => $classes,
'updated_at' => $result['updated_at'],
];
if ($publicCls !== '') {
// 公开访问:overall 换成该班自己的汇总(保持 stats 原有键名,前端按 attempts_total 读取),
// classes 只留这一个班,并打上 public 标记供前端展示"公开页面"
$st = $statOf($students, $rows, $fs);
$payload['overall'] = array_merge($result['stats'], [
'roster_total' => $st['total'],
'submitted' => $st['submitted'],
'absent' => $st['absent'],
'submit_rate' => $st['rate'],
'avg_score' => $st['avg'],
'max_score' => $st['max'],
'min_score' => $st['min'],
'avg_duration' => $st['avg_duration'],
'attempts_total' => $st['attempts'],
]);
$payload['public'] = true;
}
ny_ok($payload);
// ---------------- 名单 ----------------
case 'roster':
ny_ok(['students' => Roster::all(), 'meta' => Roster::meta()]);
case 'roster_import':
if (empty($_FILES['file']) || !is_uploaded_file($_FILES['file']['tmp_name'])) {
ny_fail('请选择要导入的文件');
}
$f = $_FILES['file'];
$err = (int)($f['error'] ?? UPLOAD_ERR_OK);
if ($err !== UPLOAD_ERR_OK) {
ny_fail('上传失败(错误码 ' . $err . ')');
}
if ((int)($f['size'] ?? 0) > 20 * 1024 * 1024) {
ny_fail('文件过大(上限 20MB)');
}
// 扩展名白名单:杜绝上传 .php/.phtml 等可执行后缀到 Web 目录
$ext = strtolower(pathinfo((string)$f['name'], PATHINFO_EXTENSION));
if (!in_array($ext, ['xlsx', 'xlsm', 'csv', 'txt'], true)) {
ny_fail('仅支持 .xlsx / .xlsm / .csv / .txt 文件');
}
// 随机文件名:杜绝按时间枚举(原 roster_YYYYMMDD_HHMMSS.ext 可被遍历)
$dir = NY_DATA . '/uploads';
if (!is_dir($dir)) {
@mkdir($dir, 0777, true);
}
$dest = $dir . '/' . bin2hex(random_bytes(16)) . '.' . $ext;
if (!@move_uploaded_file($f['tmp_name'], $dest)) {
ny_fail('文件保存失败,请检查 data/uploads 目录权限');
}
try {
$res = Roster::importFile($dest, $ext);
} catch (Throwable $e) {
@unlink($dest);
ny_fail($e->getMessage());
}
// 解析完立即删除:Web 目录下不再留存任何上传文件
@unlink($dest);
// 默认追加:与「导入名单默认为追加」一致(学号名单管理导入)
$mode = (string)($_POST['mode'] ?? 'append');
if ($mode === 'append') {
// 追加:仅加入名单中尚不存在的成员(无学号时按姓名判重),避免重复行。
// 学号与姓名分两套判重键:带学号者以 sid 判重,无学号者以姓名判重,
// 避免「已存在带学号 A 的行」被「同名但空学号的导入行」误判为不重复而重复插入。
$existing = Roster::all();
$seenSid = [];
$seenName = [];
foreach ($existing as $s) {
$sid = strtolower(trim((string)($s['sid'] ?? '')));
$name = strtolower(trim((string)($s['name'] ?? '')));
if ($sid !== '') {
$seenSid[$sid] = true;
} elseif ($name !== '') {
$seenName[$name] = true;
}
}
$added = [];
foreach ($res['students'] as $s) {
$sid = strtolower(trim((string)($s['sid'] ?? '')));
$name = strtolower(trim((string)($s['name'] ?? '')));
$dup = false;
if ($sid !== '') {
if (isset($seenSid[$sid])) { $dup = true; }
else { $seenSid[$sid] = true; }
} elseif ($name !== '') {
if (isset($seenName[$name])) { $dup = true; }
else { $seenName[$name] = true; }
}
if ($dup) {
continue;
}
$added[] = $s;
}
if (!Roster::save(array_merge($existing, $added))) {
ny_fail('名单追加失败:' . (class_exists('Db') ? Db::lastError() : '未知错误'));
}
$res['count'] = count($added); // 仅计入真正新增的人数
} else {
if (!Roster::save($res['students'])) {
ny_fail('名单导入失败:' . (class_exists('Db') ? Db::lastError() : '未知错误'));
}
}
ny_ok([
'students' => Roster::all(),
'meta' => Roster::meta(),
'imported' => $res['count'],
'skipped' => $res['skipped'] ?? 0,
'duplicates' => $res['duplicates'] ?? 0,
]);
// ---------------- 从卷王白名单导入待考名单(已移除)----------------
// 现改为:DataService::build 自动读取每套试卷卷内设置的「答题白名单」,
// 白名单成员即「应考名单」,未设置时回退到全名单(按现在的方案显示)。
// 名单管理里的「⬇ 从白名单导入」按钮已删除。
case 'roster_save':
$raw = ny_request_body();
$in = json_decode($raw, true);
if (!is_array($in) || !isset($in['students']) || !is_array($in['students'])) {
ny_fail('参数错误');
}
$students = [];
foreach ($in['students'] as $s) {
if (!is_array($s)) {
continue;
}
$students[] = [
'sid' => (string)($s['sid'] ?? ''),
'name' => (string)($s['name'] ?? ''),
'cls' => trim((string)($s['cls'] ?? '')),
'grp' => trim((string)($s['grp'] ?? '')),
'login' => trim((string)($s['login'] ?? '')),
];
}
if (!Roster::save($students)) {
ny_fail('名单保存失败:' . (class_exists('Db') ? Db::lastError() : '未知错误'));
}
ny_ok(['students' => Roster::all(), 'meta' => Roster::meta()]);
case 'roster_clear':
Roster::clear();
ny_ok(['students' => [], 'meta' => Roster::meta()]);
// ---------------- 未匹配答卷 → 加入名单 ----------------
case 'unmatched_map':
$raw = ny_request_body();
$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];
}
if (!Roster::save($roster)) {
ny_fail('映射保存失败:' . (class_exists('Db') ? Db::lastError() : '未知错误'));
}
ny_ok(['students' => Roster::all(), 'meta' => Roster::meta(), 'mapped' => $name]);
// ---------------- 试卷独立配置(满分 / 合格率 / 免考名单)——仅对当前试卷生效 ----------------
case 'project_config':
$projectId = (string)($_GET['projectId'] ?? '');
if ($projectId === '') {
ny_fail('请先选择问卷/考试');
}
$c = cfg();
[$fs, $pr] = ny_resolve_score_cfg($projectId, $c);
$pc = DataService::projectConfig($projectId);
ny_ok([
'project_id' => $projectId,
'cfg' => $pc,
// 解析后的生效值(前端回显用;null 表示"跟随全局")
'effective' => ['full_score' => $fs, 'pass_rate' => $pr],
'global' => [
'full_score' => (int)($c['full_score'] ?? 100),
'pass_rate' => (float)($c['pass_rate'] ?? 60),
],
]);
case 'project_config_save':
$projectId = (string)($_GET['projectId'] ?? $_POST['projectId'] ?? '');
if ($projectId === '') {
ny_fail('请先选择问卷/考试');
}
$raw = ny_request_body();
$in = json_decode($raw, true);
if (!is_array($in)) {
$in = $_POST;
}
$old = DataService::projectConfig($projectId);
// 满分:留空/0 = 清除(跟随全局)
$fsIn = $in['full_score'] ?? null;
$fs = ($fsIn === null || $fsIn === '' || (int)$fsIn < 1) ? null : (int)$fsIn;
// 合格率:留空 = 清除(跟随全局);否则夹到 1~100
$prIn = $in['pass_rate'] ?? null;
$pr = ($prIn === null || $prIn === '') ? null : max(1, min(100, (float)$prIn));
// 免考名单:支持数组或逗号/换行分隔的字符串
$exIn = $in['exempt'] ?? null;
$exempt = [];
if (is_array($exIn)) {
foreach ($exIn as $v) {
$v = trim((string)$v);
if ($v !== '') {
$exempt[] = $v;
}
}
} elseif (is_string($exIn) && trim($exIn) !== '') {
foreach (preg_split('/[,,\s]+/u', trim($exIn)) ?: [] as $v) {
$v = trim($v);
if ($v !== '') {
$exempt[] = $v;
}
}
}
$new = [
'full_score' => $fs,
'pass_rate' => $pr,
'exempt' => array_values(array_unique($exempt)),
];
DataService::projectConfigSave($projectId, $new);
$c2 = cfg();
[$effFs, $effPr] = ny_resolve_score_cfg($projectId, $c2);
ny_ok([
'cfg' => $new,
'effective' => ['full_score' => $effFs, 'pass_rate' => $effPr],
]);
// ---------------- 导出(支持按班级 / 组别筛选,与页面当前筛选保持一致) ----------------
case 'export':
case 'export_absent':
$projectId = (string)($_GET['projectId'] ?? '');
if ($projectId === '') {
ny_fail('请先选择问卷/考试');
}
$nameQid = (string)($_GET['nameQid'] ?? '');
$clsFilter = trim((string)($_GET['cls'] ?? ''));
$grpFilter = trim((string)($_GET['grp'] ?? ''));
// 命中筛选:未选班级/组别时全部通过
$hit = static function (array $r) use ($clsFilter, $grpFilter): bool {
if ($clsFilter !== '' && trim((string)($r['cls'] ?? '')) !== $clsFilter) {
return false;
}
if ($grpFilter !== '' && trim((string)($r['grp'] ?? '')) !== $grpFilter) {
return false;
}
return true;
};
$c = cfg();
// 复用与 data 接口相同的结果缓存:导出不必再全量拉一次卷王
$built = ny_build_data($c, $projectId, $nameQid, !empty($_GET['force']));
$result = $built['result'];
$rows = array_values(array_filter((array)($result['rows'] ?? []), $hit));
// 缺考口径与页面完全保持一致:筛选后名单 − 筛选后已提交(按学号匹配)
$roster = array_values(array_filter((array)($result['roster'] ?? []), $hit));
if ($roster !== []) {
$submitted = [];
foreach ($rows as $r) {
$sid = trim((string)($r['sid'] ?? ''));
if ($sid !== '') {
$submitted[$sid] = true;
}
}
$absent = array_values(array_filter(
$roster,
static fn(array $s): bool => !isset($submitted[trim((string)($s['sid'] ?? ''))])
));
} else {
// 未导入名单时退回后端自带的缺考数组(同样按筛选过滤)
$absent = array_values(array_filter((array)($result['absent'] ?? []), $hit));
}
// 文件名附带班级 / 组别,便于分辨
$safe = static fn(string $s): string => preg_replace('/[\\\\\/:*?"<>|]/u', '_', $s) ?? '';
$clsLabel = $clsFilter === '' ? ''
: (mb_strpos($clsFilter, '班') === false ? $clsFilter . '班' : $clsFilter);
$grpLabel = $grpFilter === '' ? ''
: (mb_strpos($grpFilter, '组') === false ? $grpFilter . '组' : $grpFilter);
$tag = ($clsLabel !== '' ? '_' . $safe($clsLabel) : '')
. ($grpLabel !== '' ? '_' . $safe($grpLabel) : '');
$mode = ((string)($_GET['mode'] ?? '') === 'latest') ? 'latest' : 'all';
$splitCls = ((string)($_GET['split'] ?? '') === 'cls');
$isLatest = ($mode === 'latest');
// 每人一行时的成绩口径:latest(默认,最近一次真实提交)| best(最高分)
// 「成绩明细」的显示选项就是这一项——默认最新成绩,可选最高成绩。
$rowScoreMode = ((string)($_GET['scoreMode'] ?? '') === 'best') ? 'best' : 'latest';
// skipTemp=1:每次考试一行时排除暂存草稿(草稿不是有效成绩);includeTemp=1 可保留
$skipTemp = (!empty($_GET['skipTemp']) || !empty($_GET['skip_temp']))
&& empty($_GET['includeTemp']) && empty($_GET['include_temp']);
$scoreTag = $isLatest ? ($rowScoreMode === 'best' ? '_最高成绩' : '_最新成绩') : '_每次考试';
$filename = ($action === 'export_absent' ? '缺考名单' : '成绩明细')
. '_' . $safe((string)$result['project']['name'])
. $tag
. $scoreTag
. '_' . date('Ymd_His') . '.xlsx';
// ---------------- 列定义(可通过 cols= 参数挑选) ----------------
$colLabels = [
'sid' => '学号',
'name' => '姓名',
'cls' => '班级',
'grp' => '组别',
'score' => $isLatest ? ($rowScoreMode === 'best' ? '最高成绩' : '最新成绩') : '成绩',
'best_score' => '最高分',
'attempts' => '考试次数',
'idx' => '第几次',
'is_best' => '是否最高分',
'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', 'best_score', 'attempts', 'idx', 'is_best', '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;
}
// ---------------- 数据行 ----------------
$sortMode = (string)($_GET['sort'] ?? '');
if ($isLatest) {
// 每人一行:成绩按「最新成绩」(最近一次真实提交)或「最高成绩」(best_score),
// 并附最高分与考试次数。
// 注意:include_temp_save 开启时 attempts_list 可能含暂存草稿,取最新成绩应跳过 temp_save,
// 否则导出的"最新成绩"可能是一份草稿分。
$all = [];
foreach ($rows as $r) {
$list = array_values((array)($r['attempts_list'] ?? []));
$last = null;
for ($i = count($list) - 1; $i >= 0; $i--) {
if (empty($list[$i]['temp_save'])) {
$last = $list[$i];
break;
}
}
if ($last === null && $list !== []) {
$last = $list[count($list) - 1]; // 全是暂存时退回最后一份
}
// best 口径:优先用聚合行 best_score;缺失时从 attempts_list 现算,
// 保证「最高成绩」列与代表成绩一致(避免出现"最高分比成绩还低"的怪表)。
$bestScore = $r['best_score'] ?? null;
if ($rowScoreMode === 'best') {
if ($bestScore === null || $bestScore === '') {
foreach ($list as $a) {
$sv = $a['score'] ?? null;
if ($sv === null || $sv === '') {
continue;
}
if ($bestScore === null || (float)$sv > (float)$bestScore) {
$bestScore = $sv;
}
}
}
}
// 最高分所在那次(用于「提交时间 / 时长」跟随口径,避免最高分配了别次的耗时)
$bestRow = null;
if ($rowScoreMode === 'best' && $bestScore !== null && $bestScore !== '') {
foreach ($list as $a) {
if ((string)($a['score'] ?? '') === (string)$bestScore) {
$bestRow = $a;
break;
}
}
}
$useRow = ($rowScoreMode === 'best') ? ($bestRow ?: $last) : $last;
$all[] = [
'sid' => (string)($r['sid'] ?? ''),
'name' => (string)($r['name'] ?? ''),
'cls' => (string)($r['cls'] ?? ''),
'grp' => (string)($r['grp'] ?? ''),
'score' => ($rowScoreMode === 'best')
? (($bestScore === null || $bestScore === '') ? null : $bestScore)
: ($useRow !== null ? ($useRow['score'] ?? null) : ($r['score'] ?? null)),
'best_score' => $bestScore,
'attempts' => (int)($r['attempts'] ?? 1),
'idx' => (int)($r['attempts'] ?? 1),
'submit_ts' => ($useRow !== null && isset($useRow['submit_ts']))
? (int)$useRow['submit_ts']
: (int)($r['last_submit_ts'] ?? 0),
'submit_at' => (string)($useRow['submit_at'] ?? ($r['last_submit_at'] ?? ($r['submit_at'] ?? '-'))),
'duration_txt' => (string)($useRow['duration_txt'] ?? ($r['duration_txt'] ?? '-')),
'duration' => $useRow['duration'] ?? ($r['duration'] ?? null),
];
}
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');
}
} else {
// 每次考试一行(同一学号考了 N 次就有 N 行)
$all = DataService::expandAttempts($rows, true);
if ($skipTemp) {
$all = array_values(array_filter($all, static fn(array $r): bool => empty($r['temp_save'])));
}
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');
}
}
// 合格线:本卷满分 × 合格率(0 表示本卷没配满分,不判定合格)
[$effFs, $effPr] = ny_resolve_score_cfg($projectId, $c);
$passLineVal = ($effFs > 0 && $effPr > 0) ? ($effFs * $effPr / 100.0) : 0.0;
$scoreColIdx = array_search('score', $cols, true);
// ---------------- 组装工作表 ----------------
// 出表方式 = 「每次考试一行」时,成绩达合格线的行标绿底(与多考试块表的口径一致)。
// $marks 每次调用先重置,因此分班级多工作表时各表的标记不会串到别的表里。
$marks = [];
$toRows = static function (array $list) use ($cols, $colLabels, $mode, $passLineVal, $scoreColIdx, &$marks): array {
$marks = [];
$head = [];
foreach ($cols as $c) {
$head[] = $colLabels[$c];
}
$out = [$head];
$rn = 1; // 工作表内行号(1-based,与 cell_styles 一致)
foreach ($list as $r) {
$rn++;
$line = [];
foreach ($cols as $i => $c) {
$v = $r[$c] ?? null;
$line[] = ($v === null || $v === '') ? '' : (string)$v;
if ($i === $scoreColIdx && $mode !== 'latest' && $passLineVal > 0
&& $v !== null && $v !== '' && is_numeric($v) && (float)$v >= $passLineVal) {
$marks[$rn . ',' . ($i + 1)] = 'green';
}
}
$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,
'cell_styles' => $marks,
];
}
} else {
$sheets[] = [
'name' => $isLatest ? ($rowScoreMode === 'best' ? '成绩明细(最高)' : '成绩明细(最新)') : '成绩明细',
'rows' => $toRows($all),
'widths' => $widths,
'cell_styles' => $marks,
];
}
// 附加:缺考名单 / 未匹配答卷(仅非空时输出)
// noAbsent=1 时不附缺考名单("成绩明细只导成绩"的场景)
$noAbsent = !empty($_GET['noAbsent']) || !empty($_GET['no_absent']);
if ($absent !== [] && !$noAbsent) {
$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]];
}
}
/* format=json:不下载文件,直接回传装配结果。
「打印成绩明细 / 打印缺考名单」走的就是这条,前端拿它渲染打印视图;
否则前端会拿到 xlsx 二进制再 res.json(),报 "Unexpected token 'P'"。
缺考名单工作表也在 $sheets 里,所以勾选「附缺考名单」时打印同样看得到。 */
if (in_array($action, ['export', 'export_absent'], true)
&& (string)($_GET['format'] ?? '') === 'json') {
ny_ok([
'sheets' => $sheets,
'meta' => [
'projectName' => (string)($result['project']['name'] ?? ''),
'passLine' => $passLineVal,
'passRate' => $effPr,
'fullScore' => $effFs,
],
]);
}
// 说明:所有单元格按「共享字符串 + 文本格式」写入,
// 既不会把长学号变成科学计数法,也不会把 = + - @ 开头的内容当公式执行(规避 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;
// ---------------- 多考试对比导出(横向交叉表) ----------------
case 'export_multi':
$idsRaw = (string)($_GET['projectIds'] ?? '');
$ids = array_values(array_unique(array_filter(
array_map('trim', explode(',', $idsRaw)),
static fn($v): bool => $v !== ''
)));
if ($ids === []) {
ny_fail('请至少选择一个问卷 / 考试');
}
$nameQid = (string)($_GET['nameQid'] ?? '');
$scoreMode = ((string)($_GET['scoreMode'] ?? '') === 'highest') ? 'highest' : 'latest';
$c = cfg();
// 班级 / 组别筛选(支持多选,逗号分隔;空 = 全部),与单考试导出口径一致
$multiVals = static function (string $raw): array {
return array_values(array_unique(array_filter(
array_map('trim', explode(',', $raw)),
static function ($v): bool { return $v !== ''; }
)));
};
$clsPick = $multiVals((string)($_GET['cls'] ?? ''));
$grpPick = $multiVals((string)($_GET['grp'] ?? ''));
$hit = static function (array $r) use ($clsPick, $grpPick): bool {
if ($clsPick !== [] && !in_array(trim((string)($r['cls'] ?? '')), $clsPick, true)) {
return false;
}
if ($grpPick !== [] && !in_array(trim((string)($r['grp'] ?? '')), $grpPick, true)) {
return false;
}
return true;
};
// 逐场考试取数(复用与 data 接口相同的缓存),按选择顺序组装交叉矩阵
$exams = [];
$seenCls = [];
$seenGrp = [];
foreach ($ids as $pid) {
$built = ny_build_data($c, $pid, $nameQid, !empty($_GET['force']));
$result = $built['result'];
$rows = array_values(array_filter((array)($result['rows'] ?? []), $hit));
foreach ($rows as $r) {
$cv = trim((string)($r['cls'] ?? ''));
$gv = trim((string)($r['grp'] ?? ''));
if ($cv !== '') { $seenCls[$cv] = true; }
if ($gv !== '') { $seenGrp[$gv] = true; }
}
$exams[$pid] = [
'name' => (string)($result['project']['name'] ?? $pid),
'rows' => $rows,
];
}
// 导出形态:
// block = 两行表头的「每次考试」块表(默认;需求截图版式)
// summary = 成绩对比(一行一个学生、一列一场考试,取最新 / 最高)
// both = 块表 + 成绩对比,一次出
// 兼容旧参数:matrix→summary、merged / attempts→block
$modeRaw = (string)($_GET['mode'] ?? '');
$modeMap = [
'summary' => 'summary',
'matrix' => 'summary',
'block' => 'block',
'merged' => 'block',
'attempts' => 'block',
'att' => 'block',
'both' => 'both',
];
$mode = $modeMap[$modeRaw] ?? 'block';
// 每次考试明细默认跳过暂存草稿(草稿不是有效成绩);includeTemp=1 可保留
$includeTemp = !empty($_GET['includeTemp']) || !empty($_GET['include_temp']);
// 合格线 / 合格次数阈值:用于「合格次数」行(达标绿底)与最高分标红
// 合格线 = 每场考试各自的满分 × 合格率;判「合格次数」时按各场分别算,
// 因此这里逐场解析(本卷独立配置优先,其次全局默认)。
$passTimesIn = $_GET['passTimes'] ?? null;
$passTimes = ($passTimesIn === null || $passTimesIn === '') ? 1 : max(1, (int)$passTimesIn);
$markBest = !array_key_exists('markBest', $_GET) || !empty($_GET['markBest']);
$passRow = !array_key_exists('passRow', $_GET) || !empty($_GET['passRow']);
// 每场考试的合格线(pid => float),随 $exams 一起传进装配函数
$passLineOf = [];
foreach ($ids as $pid) {
[$fsP, $prP] = ny_resolve_score_cfg($pid, $c);
$passLineOf[$pid] = round($fsP * $prP / 100, 4);
}
// 班级 / 组别列:选了多班级 / 多组别才补出(只选 1 个班级且未选组别 → 两列都不出)
$colFlags = ny_multi_col_flags($clsPick, $grpPick, array_keys($seenCls), array_keys($seenGrp));
if (array_key_exists('withCls', $_GET)) {
$colFlags['with_cls'] = !empty($_GET['withCls']);
}
if (array_key_exists('withGrp', $_GET)) {
$colFlags['with_grp'] = !empty($_GET['withGrp']);
}
// 单场 + 明细列不含耗时/时间 → 走「宽表」版式(学号|姓名|第1次…第N次|合格次数),
// 与需求截图二一致;带了耗时/时间则每人一行放不下,退回两行表头块表。
$wantWide = !empty($_GET['wide']);
$sheets = [];
$block = null;
if ($mode === 'block' || $mode === 'both') {
if ($wantWide && count($ids) === 1) {
$pid = $ids[0];
$widRes = ny_assemble_attempt_wide_sheet($exams[$pid], [
'skip_temp' => !$includeTemp,
'pass_line' => $passLineOf[$pid],
'pass_times'=> $passTimes,
'mark_best' => $markBest,
'pass_row' => $passRow,
]);
$sheets[] = [
'name' => '每次考试',
'rows' => $widRes['rows'],
'widths' => $widRes['widths'],
'merges' => $widRes['merges'],
'header_rows' => $widRes['header_rows'],
'cell_styles' => $widRes['cell_styles'],
];
$block = $widRes;
} else {
// 多场:各场合格线可能不同,装配函数按「单条合格线」设计,
// 这里取各场合格线中的最低值作为汇总口径(避免把某场算得过严),
// 并在 meta 里回报每场各自的合格线供前端说明。
$lines = array_values($passLineOf);
$minLine = $lines === [] ? null : min($lines);
$block = ny_assemble_block_sheet($exams, [
'skip_temp' => !$includeTemp,
'with_cls' => $colFlags['with_cls'],
'with_grp' => $colFlags['with_grp'],
'pass_line' => $passRow ? $minLine : null,
'pass_times' => $passTimes,
'mark_best' => $markBest,
'pass_row' => $passRow,
]);
$sheets[] = [
'name' => '每次考试',
'rows' => $block['rows'],
'widths' => $block['widths'],
'merges' => $block['merges'],
'header_rows' => $block['header_rows'],
'cell_styles' => $block['cell_styles'],
];
}
}
if ($mode === 'summary' || $mode === 'both') {
$matrix = ny_assemble_multi_exam_matrix($exams, $scoreMode);
$sheetRows = array_merge([$matrix['head']], $matrix['rows']);
$widths = array_merge([20, 14], array_fill(0, count($matrix['exam_order']), 12));
$sheets[] = ['name' => '成绩对比', 'rows' => $sheetRows, 'widths' => $widths, 'header_rows' => 1];
}
if ($sheets === []) {
$sheets[] = ['name' => '每次考试', 'rows' => [['暂无数据']], 'widths' => [12], 'header_rows' => 1];
}
/* 附缺考名单:与单考试导出同一口径——「名单里有、答卷里没有」即为缺考。
多考试场景取「所选考试全都没交」才算缺考(某场没交但在别场交过的人
已在块表里以「未考」体现,再列进缺考名单属于重复噪音)。 */
$noAbsent = !empty($_GET['noAbsent']) || !empty($_GET['no_absent']);
if (!$noAbsent) {
$roster = Roster::all();
// 逐场收集「已提交学号」,取并集:某场没交但在别场交过的人
// 已在块表里以「未考」体现,不再重复列进缺考名单
$submittedAny = [];
foreach ($exams as $ex) {
foreach ((array)($ex['rows'] ?? []) as $r) {
$sidR = trim((string)($r['sid'] ?? ''));
if ($sidR !== '') {
$submittedAny[$sidR] = true;
}
}
}
$absentRows = [];
foreach ($roster as $s) {
$sidR = trim((string)($s['sid'] ?? ''));
if ($sidR === '' || isset($submittedAny[$sidR])) {
continue;
}
if (!$hit($s)) {
continue; // 跟随班级 / 组别筛选
}
$absentRows[] = $s;
}
if ($absentRows !== []) {
$sheets[] = [
'name' => '缺考名单',
'rows' => ny_absent_sheet_rows($absentRows),
'widths' => [16, 14, 16, 10],
];
}
}
$safe = static fn(string $s): string => preg_replace('/[\\\\\\/:*?"<>|]/u', '_', $s) ?? '';
$stamp = date('Ymd_His');
// 文件名带上筛选范围,便于分辨"这次导的是哪个班"
$scopeTag = ($clsPick !== [] ? '_' . $safe(implode('+', array_slice($clsPick, 0, 3)) . (count($clsPick) > 3 ? '等' : '')) : '')
. ($grpPick !== [] ? '_' . $safe(implode('+', array_slice($grpPick, 0, 3)) . (count($grpPick) > 3 ? '等' : '')) : '');
if ($mode === 'block') {
$filename = '每次考试_' . count($ids) . '场' . $scopeTag . '_' . $stamp . '.xlsx';
} elseif ($mode === 'both') {
$filename = '每次考试与成绩对比_' . count($ids) . '场' . $scopeTag . '_' . $stamp . '.xlsx';
} else {
$filename = '多考试成绩对比_' . count($ids) . '场' . $scopeTag . '_'
. ($scoreMode === 'highest' ? '最高分' : '最新成绩') . '_' . $stamp . '.xlsx';
}
// format=json:把同一套装配结果原样交给前端——「多考试打印」用它渲染 HTML,
// 保证打印版式与导出的 Excel 完全一致(同一份逻辑,不在前端重写排版规则)
if ((string)($_GET['format'] ?? '') === 'json') {
$examNames = [];
foreach ($exams as $pid => $ex) {
$examNames[] = (string)($ex['name'] ?? $pid);
}
ny_ok([
'sheets' => $sheets,
'filename' => $filename,
'meta' => [
'mode' => $mode,
'wide' => ($wantWide && count($ids) === 1),
'examCount' => count($ids),
'examNames' => $examNames,
'cls' => $clsPick,
'grp' => $grpPick,
'scoreMode' => $scoreMode,
'with_cls' => $colFlags['with_cls'],
'with_grp' => $colFlags['with_grp'],
'students' => ($block !== null ? $block['students'] : null),
'rows' => ($block !== null ? $block['total_rows'] : null),
// 合格口径:前端据此在打印说明里写清楚"合格线几分、几次算通过"
'pass_row' => ($block !== null ? !empty($block['pass_row']) : false),
'pass_times' => $passTimes,
'pass_lines' => $passLineOf,
],
]);
}
$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,列:姓名 / 登陆账号) ----------------
case 'draw_export':
$raw = ny_request_body();
$in = json_decode($raw, true);
if (!is_array($in)) {
$in = $_POST + $_GET;
}
// type=import → 白名单导入模板.xlsx;type=list → 白名单列表.xlsx
$type = (($in['type'] ?? '') === 'import') ? 'import' : 'list';
$members = [];
foreach ((array)($in['members'] ?? []) as $m) {
if (!is_array($m)) {
continue;
}
$name = trim((string)($m['name'] ?? ''));
// 登陆账号优先(名单导入的"登陆账号"列),没有则回退学号
$acct = trim((string)($m['login'] ?? ''));
$sid = trim((string)($m['sid'] ?? ''));
if ($name === '' && $acct === '' && $sid === '') {
continue;
}
$members[] = [$name, $acct !== '' ? $acct : $sid];
}
$bin = XlsxWriter::render(
array_merge([['姓名', '登陆账号']], $members),
['widths' => [16, 24], 'title' => '白名单']
);
$filename = $type === 'import' ? '白名单导入模板.xlsx' : '白名单列表.xlsx';
header('Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
header('Content-Disposition: attachment; filename="whitelist.xlsx"; filename*=UTF-8\'\'' . rawurlencode($filename));
header('Content-Length: ' . strlen($bin));
header('Cache-Control: no-store');
echo $bin;
exit;
default:
ny_fail('未知操作:' . $action);
}
} catch (Throwable $e) {
$msg = $e->getMessage();
// 鉴权类错误(账号未配置 / 登录卷王失败)打标,前端据此跳登录页自愈
$isAuth = str_contains($msg, '未配置卷王登录账号')
|| str_contains($msg, '登录卷王失败')
|| str_contains($msg, '登录失败');
// 鉴权失败时回传落盘诊断(账号到底有没有存住、data 目录可不可写),
// 否则前端只能给出"账号未配置"这一句空话,用户无法自查,也容易陷入反复跳登录页。
ny_fail($msg, [
'auth' => $isAuth,
'diag' => $isAuth ? ny_config_diag() : null,
'trace' => defined('NY_DEBUG') && NY_DEBUG ? $e->getTraceAsString() : null,
]);
}