Files
ny-exam-platform/api.php
T
nxxy335 6772aeae11 feat: 抽取页分组均衡5:5 / 重置清累计 / 历史记录 / 排除管理 / 登陆账号字段 / side等高
随机抽取页:
- 分组均衡抽取:有组别(≥2个)时默认按名额均分到组(12/8抽10→5/5),
  其次按班级均分,否则整池随机;可勾选关闭
- 名额均分算法 allocQuotas:floor 均分 + 余数小组优先补齐 +
  超容量退回 + 差额给剩余容量大者
- 重置结果:同时清零累计已抽取数(历史记录仍保留)
- 历史记录:每批次入 localStorage ny_draw_log(上限50),
  重置不清,支持查看详情/恢复结果/删除/清空
- 抽取排除:排除管理弹窗(搜索/清空),结果行可"移出并加入排除"
- 白名单列表下载:登陆账号优先,缺失回退学号

名单登陆账号:
- 模板解析识别"登陆/登录账号/账号/手机号"列,手机号去 .0 尾巴
- Db ny_roster 增 login 列 + 旧表自动升级(三方言)
- roster_save 透传 login;draw_export 使用 login
- 登陆账号仅用于白名单列表下载,其余界面不展示

布局:side 模式下成绩明细/缺考名单 panel 改 flex 撑满,
.table-scroll 取消 320px 限高,两侧等高不再留白

测试:tools/test-draw-alloc.js 18/18 通过(含 1000 组随机 fuzz)
2026-09-09 12:29:49 +08:00

462 lines
20 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 (!ny_is_installed()) {
ny_json(['ok' => false, 'msg' => '系统尚未安装,请先完成安装向导'], 503);
exit;
}
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'] = $c['password'] !== '';
unset($c['password']);
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', 'password', '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];
}
}
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'];
}
$pwdIn = $in['password'] ?? null;
if ($pwdIn === null || $pwdIn === '' || $pwdIn === '******') {
$new['password'] = $old['password'];
}
$new['base_url'] = rtrim($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;
}
// 每套试卷独立满分:选中试卷时写入 full_scores[projectId],否则更新全局默认
$fs = (int)($in['full_score'] ?? $new['full_score'] ?? 100);
if ($fs < 1) {
$fs = 100;
}
$pid = trim((string)($in['project_id'] ?? ''));
if ($pid !== '') {
$scores = is_array($new['full_scores'] ?? null) ? $new['full_scores'] : [];
$scores[$pid] = $fs;
$new['full_scores'] = $scores;
} else {
$new['full_score'] = $fs;
}
ny_save_config($new);
@unlink(NY_DATA . '/cache/token.json'); // 清掉旧令牌
ny_ok(['saved' => true, 'config' => ny_load_config()]);
// ---------------- 连接测试 ----------------
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,
]);
}