Files
ny-exam-platform/api.php
T
nxxy335 3952c77afe refactor: 取消 install.php 安装向导 + 下线 pgsql/mysql 支持 + 班级看板独立
- 删除 install.php:所有入口(index/dashboard/draw/login/api)不再跳安装,
  未登录统一跳 login.php;连接信息变更在【设置】里直接修改 data/config.json
- 下线 pgsql/mysql:
  * bootstrap.php 简化为单文件存储路径(ny_load_config/ny_save_config 直接读写 data/config.json),
    删除 ny_db_config、配置迁移、配置双写分支
  * lib/Db.php 改为纯 stub:pdo() 恒返回 null,避免上层误连;migrate/ensureRosterLogin 等为 no-op
  * Roster.php 经 useDb 永远 false,自动走 roster.json
  * api.php 不再返回 db_driver/db_connected/db_error 诊断
- 班级看板独立(dashboard.php 重写):
  * 删除"← 返回总览"按钮 + 整体概况 #overview 区
  * 进入即展示单班看板:6 张汇总卡 + 分组对比/成员榜
  * 顶部 hero 区动态显示当前班级名 + 问卷名
  * 工具栏新增 #classSel 班级选择器(切换班级不离开本页)
  * URL 支持 ?pid=&class=&auto=1|0 分享与刷新恢复
  * 下拉班级选项随数据动态刷新;保留自动刷新 + 全屏 + 缓存
- e2e 测试(test-config-save)加固 cleanup 钩子:
  * 统一 cleanup() 函数接管所有退出路径
  * 解决之前 e2e 留 residue 把 data/config.json 改成 mock 地址的问题(导致本机登录失败)
- php -l 全文件通过;node --check 内嵌 JS 通过;e2e test-config-save 27/27 + test-draw-alloc 29/29 全过
2026-09-09 14:04:59 +08:00

503 lines
22 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();
}
// 会话保护:除 auth_check 外,未登录一律拒访
if ($action !== 'auth_check' && !ny_is_auth()) {
ny_json(['ok' => false, 'msg' => '未登录或会话已过期,请重新登录'], 401);
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['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'];
$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';
}
// 各试卷独立满分(覆盖整个 full_scores 映射)
if (array_key_exists('full_scores', $in) && is_array($in['full_scores'])) {
$new['full_scores'] = $in['full_scores'];
}
$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;
// 各试卷独立满分:仅当显式指定 apply_pid 时才写入/清除该卷(修复 bug:之前会误用默认满分覆盖当前卷)
$applyPid = trim((string)($in['apply_pid'] ?? ''));
if ($applyPid !== '') {
$scores = is_array($new['full_scores'] ?? null) ? $new['full_scores'] : [];
$applyScore = (int)($in['apply_score'] ?? 0);
if ($applyScore > 0) {
$scores[$applyPid] = $applyScore;
} else {
unset($scores[$applyPid]);
}
$new['full_scores'] = $scores;
}
// 仅当连接信息真的变了才清 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 '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();
$result = DataService::build($c, $projectId, $nameQid);
$fsMap = is_array($c['full_scores'] ?? null) ? $c['full_scores'] : [];
$fs = isset($fsMap[$projectId]) ? (int)$fsMap[$projectId] : (int)($c['full_score'] ?? 100);
if ($fs < 1) {
$fs = 100;
}
$result['config'] = [
'refresh_interval' => (int)$c['refresh_interval'],
'base_url' => $c['base_url'],
'full_score' => $fs,
];
ny_ok($result);
// ---------------- 数据看板(按班级聚合,面向班主任/学生整体情况) ----------------
case 'dashboard':
$projectId = (string)($_GET['projectId'] ?? '');
if ($projectId === '') {
ny_fail('请先选择问卷/考试');
}
$nameQid = (string)($_GET['nameQid'] ?? '');
$c = cfg();
$result = DataService::build($c, $projectId, $nameQid);
$fsMap = is_array($c['full_scores'] ?? null) ? $c['full_scores'] : [];
$fs = isset($fsMap[$projectId]) ? (int)$fsMap[$projectId] : (int)($c['full_score'] ?? 100);
if ($fs < 1) {
$fs = 100;
}
$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;
}
// 通用统计器:给定学生集合 + 答卷集合,算一组指标
$statOf = static function (array $stu, array $rs, int $fs): array {
$total = count($stu);
$submitted = count($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'] >= 0.6 * $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,
'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,
];
};
// 已提交成员榜:按成绩从高到低,缺成绩者排最后,并给出排名
$membersOf = static function (array $rs): array {
$ms = [];
foreach ($rs as $r) {
$ms[] = [
'sid' => (string)($r['sid'] ?? ''),
'name' => (string)($r['name'] ?? ''),
'score' => ($r['score'] ?? null),
];
}
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,
'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']]);
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 '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();
$result = DataService::build($c, $projectId, $nameQid);
$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) : '');
$filename = ($action === 'export_absent' ? '缺考名单' : '成绩明细')
. '_' . $safe((string)$result['project']['name'])
. $tag
. '_' . date('Ymd_His') . '.csv';
header('Content-Type: text/csv; charset=utf-8');
header('Content-Disposition: attachment; filename="' . $filename . '"; filename*=UTF-8\'\'' . rawurlencode($filename));
header('Cache-Control: no-store');
echo "\xEF\xBB\xBF";
$out = fopen('php://output', 'w');
if ($action === 'export_absent') {
fputcsv($out, ['学号', '姓名', '班级', '组别']);
foreach ($absent as $r) {
fputcsv($out, [$r['sid'] ?? '', $r['name'] ?? '', $r['cls'] ?? '', $r['grp'] ?? '']);
}
} else {
fputcsv($out, ['学号', '姓名', '班级', '组别', '成绩', '提交时间', '考试时长', '考试时长(秒)']);
foreach ($rows as $r) {
fputcsv($out, [
$r['sid'] ?? '',
$r['name'] ?? '',
$r['cls'] ?? '',
$r['grp'] ?? '',
$r['score'] === null ? '' : $r['score'],
$r['submit_at'] ?? '',
$r['duration_txt'] ?? '',
$r['duration'] === null ? '' : $r['duration'],
]);
}
}
fclose($out);
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) {
ny_fail($e->getMessage(), [
'trace' => defined('NY_DEBUG') && NY_DEBUG ? $e->getTraceAsString() : null,
]);
}