Files
ny-exam-platform/api.php
T
nxxy335 157f257728 feat: 成绩明细打印 + SQLite + 每卷独立配置(满分/合格率/免考) + 导航精简
- 成绩明细打印:默认成绩降序,可选按班级/组别分组对比(段首小计)
- 修复看板退出全屏无反应(exitFullscreen 挂在 document 而非 documentElement)
- 导航栏精简:三页统一为单一主标题,移除 Logo 与副标题
- 优化三端布局:重排断点、竖排隐藏 spacer、触摸设备热区
- 全面启用 SQLite(data/ny.db):配置/名单/每卷配置入库,无 pdo_sqlite 自动降级文件
- 新增独立合格率配置(全局 + 每卷),合格率不再写死 60%
- 新增免考配置,仅对当前试卷生效,试卷间隔离
- 试卷独立满分从设置移出,改到主页面「试卷设置」
- 测试:新增 4 条每卷配置隔离断言,全套 113 项通过
2026-09-10 20:22:17 +08:00

936 lines
42 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* NY 问卷考试系统数据平台 - 后端接口
*/
declare(strict_types=1);
require __DIR__ . '/bootstrap.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;
}
/**
* 带短期缓存的数据构建(含并发锁,避免多人同时刷新时重复打卷王造成惊群)
*
* 缓存 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', '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();
$c['db_error'] = class_exists('Db') ? Db::lastError() : '';
ny_ok($c);
case 'config_save':
$raw = (string)file_get_contents('php://input');
$in = json_decode($raw, true);
if (!is_array($in)) {
$in = $_POST;
}
$old = cfg();
$allowed = ['base_url', 'api_prefix', 'username', 'timeout', 'refresh_interval', 'page_size', 'name_keywords', 'history_display', 'export_sort'];
$new = $old;
foreach ($allowed as $k) {
if (array_key_exists($k, $in)) {
$new[$k] = is_string($in[$k]) ? trim((string)$in[$k]) : $in[$k];
}
}
// 密码:保留首尾空格的原貌,空 / 占位符 = 不修改
if (array_key_exists('password', $in)) {
$pwdIn = (string)$in['password'];
if ($pwdIn !== '' && $pwdIn !== '******') {
$new['password'] = $pwdIn;
}
}
foreach (['verify_ssl', 'include_temp_save', 'dedupe'] as $k) {
if (array_key_exists($k, $in)) {
$new[$k] = filter_var($in[$k], FILTER_VALIDATE_BOOL);
}
}
// 布局配置
if (array_key_exists('layout', $in)) {
$new['layout'] = ($in['layout'] === 'side') ? 'side' : 'default';
}
// 历史成绩展示方式:click(点击成员弹窗,默认) / inline(明细内全部展开)
if (array_key_exists('history_display', $in)) {
$new['history_display'] = ($in['history_display'] === 'inline') ? 'inline' : 'click';
}
// 成绩导出排序:time(按提交时间降序,默认)/ sid(按学号)/ score_desc(成绩降序)/ score_asc(成绩升序)
if (array_key_exists('export_sort', $in)) {
$v = (string)$in['export_sort'];
$new['export_sort'] = in_array($v, ['time','sid','score_desc','score_asc'], true) ? $v : 'time';
}
// 说明:「各试卷独立满分」已移出设置,改为在主页面针对当前试卷配置
// (project_config / project_config_save),设置里只保留「全局默认满分」。
if (array_key_exists('pass_rate', $in)) {
$pr = (float)$in['pass_rate'];
$new['pass_rate'] = ($pr >= 1 && $pr <= 100) ? $pr : 60;
}
$new['base_url'] = rtrim((string)$new['base_url'], '/');
$new['api_prefix'] = '/' . trim((string)$new['api_prefix'], '/');
$new['timeout'] = (int)$new['timeout'];
$new['refresh_interval'] = (int)$new['refresh_interval'];
if ($new['refresh_interval'] < 3) {
$new['refresh_interval'] = 3;
}
// 默认满分
$fs = (int)($in['full_score'] ?? $new['full_score'] ?? 100);
if ($fs < 1) {
$fs = 100;
}
$new['full_score'] = $fs;
// 仅当连接信息真的变了才清 token + 重连;保存满分/布局等不应触发登录卷王
$connKeys = ['base_url', 'api_prefix', 'username', 'password', 'verify_ssl'];
$connChanged = false;
foreach ($connKeys as $k) {
if ((string)($old[$k] ?? '') !== (string)($new[$k] ?? '')) {
$connChanged = true;
break;
}
}
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 = (string)file_get_contents('php://input');
$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'] ?: [];
$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']);
});
ny_ok([
'project' => $result['project'],
'full_score' => $fs,
'pass_rate' => $passRate,
'overall' => $result['stats'],
'classes' => $classes,
'updated_at' => $result['updated_at'],
]);
// ---------------- 名单 ----------------
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'];
$ext = strtolower(pathinfo((string)$f['name'], PATHINFO_EXTENSION));
$dest = NY_DATA . '/uploads/roster_' . date('Ymd_His') . '.' . ($ext !== '' ? $ext : 'dat');
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());
}
$mode = (string)($_POST['mode'] ?? 'replace');
if ($mode === 'append') {
$merged = array_merge(Roster::all(), $res['students']);
Roster::save($merged);
} else {
Roster::save($res['students']);
}
ny_ok([
'students' => Roster::all(),
'meta' => Roster::meta(),
'imported' => $res['count'],
'skipped' => $res['skipped'] ?? 0,
'duplicates' => $res['duplicates'] ?? 0,
]);
case 'roster_save':
$raw = (string)file_get_contents('php://input');
$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'] ?? '')),
];
}
Roster::save($students);
ny_ok(['students' => Roster::all(), 'meta' => Roster::meta()]);
case 'roster_clear':
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 '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 = (string)file_get_contents('php://input');
$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');
$filename = ($action === 'export_absent' ? '缺考名单' : '成绩明细')
. '_' . $safe((string)$result['project']['name'])
. $tag
. ($isLatest ? '_最新成绩' : '')
. '_' . date('Ymd_His') . '.xlsx';
// ---------------- 列定义(可通过 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;
}
// ---------------- 数据行 ----------------
$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),
];
}
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 ($sortMode === 'score_desc' || $sortMode === 'score_asc') {
$all = ny_sort_by_score($all, $sortMode === 'score_desc');
} else {
$all = DataService::sortForExport($all, ($sortMode === 'sid') ? 'sid' : 'time');
}
}
// ---------------- 组装工作表 ----------------
$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,列:姓名 / 登陆账号) ----------------
case 'draw_export':
$raw = (string)file_get_contents('php://input');
$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, '登录失败');
ny_fail($msg, [
'auth' => $isAuth,
'trace' => defined('NY_DEBUG') && NY_DEBUG ? $e->getTraceAsString() : null,
]);
}