- DataService::build 新增 aggregateByStudent 按学号聚合: dedupe=true 仅保留最新一份;false 则每学号一行(代表成绩取最高分) 附 attempts(次数) 与 attempts_list(每次提交明细),修正提交人数被多次提交inflate的问题 - api.php dashboard 透传 attempts/attempts_list;statOf 增加 attempts 求和;export 增加刷题次数列 - dashboard.php 成员榜加"刷N次"徽标,点击下钻弹窗看每次提交(时间/成绩/时长/暂存);hero 显示总提交次数 - 主统计页成绩明细同样加"刷N次"徽标与下钻弹窗 - stats 新增 attempts_total
509 lines
22 KiB
PHP
509 lines
22 KiB
PHP
<?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);
|
||
$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'] >= 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,
|
||
'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,
|
||
'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'],
|
||
(int)($r['attempts'] ?? 1),
|
||
]);
|
||
}
|
||
}
|
||
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,
|
||
]);
|
||
}
|