导出-每次考试/多考试对比: - 合格次数阈值等标记项抽为常显区块,三类成绩表共用 - 附缺考名单扩展到多考试导出(取所选考试全都没交的人) - 块表每场固定2列(成绩+合并空位),修复漏推空串导致的整行左移/串列 - 列宽按实际内容估算,合并区空位只给最小宽度 - 合格次数始终显示实际次数(含0),仅 ≥ 阈值标绿底 - 打印标题改为「成绩明细」,缺考名单分页单独成页 - 打印样式加 print-color-adjust:exact,绿底/红字可正常印出 数据大屏: - ?class= 班级大屏免登录公开访问,接口侧放行并按班级裁剪后回传 - 单班大屏移除退出按钮,仅首页保留 - 修复红绿判定真bug:membersTable 误用 window.DASH(DASH为let声明取不到) 改为 scoreGrade() 直接读自由变量,按后台合格率正确判红/黄/绿 测试/样例/文档同步更新,全量回归通过。
1284 lines
62 KiB
PHP
1284 lines
62 KiB
PHP
<?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,
|
||
]);
|
||
}
|