Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0fd880624f | ||
|
|
87750b20c4 | ||
|
|
a65ec682ce | ||
|
|
6def49b969 | ||
|
|
137e2cb665 |
@@ -13,12 +13,128 @@ function cfg(): array
|
|||||||
return ny_load_config();
|
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 外,未登录一律拒访
|
// 会话保护:除 auth_check 外,未登录一律拒访
|
||||||
if ($action !== 'auth_check' && !ny_is_auth()) {
|
if ($action !== 'auth_check' && !ny_is_auth()) {
|
||||||
ny_json(['ok' => false, 'msg' => '未登录或会话已过期,请重新登录'], 401);
|
ny_json(['ok' => false, 'msg' => '未登录或会话已过期,请重新登录'], 401);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CSRF 防护:所有写操作必须携带会话绑定的 token(GET 读取类接口不受影响)
|
||||||
|
$WRITE_ACTIONS = [
|
||||||
|
'config_save', 'roster_save', 'roster_import', 'roster_clear',
|
||||||
|
'snapshot_save', 'snapshot_delete', 'unmatched_map',
|
||||||
|
];
|
||||||
|
if (in_array($action, $WRITE_ACTIONS, true) && !ny_csrf_ok()) {
|
||||||
|
ny_json(['ok' => false, 'msg' => '安全校验失败(CSRF token 缺失或不匹配),请刷新页面后重试'], 403);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
switch ($action) {
|
switch ($action) {
|
||||||
// 登录状态检查
|
// 登录状态检查
|
||||||
@@ -30,6 +146,7 @@ try {
|
|||||||
$c = cfg();
|
$c = cfg();
|
||||||
$c['has_password'] = ((string)$c['password']) !== '';
|
$c['has_password'] = ((string)$c['password']) !== '';
|
||||||
unset($c['password']);
|
unset($c['password']);
|
||||||
|
$c['csrf_token'] = ny_csrf_token();
|
||||||
// 诊断信息:配置存在哪里、数据库通不通(登录卷王失败时优先看这里)
|
// 诊断信息:配置存在哪里、数据库通不通(登录卷王失败时优先看这里)
|
||||||
$c['storage'] = ny_config_source();
|
$c['storage'] = ny_config_source();
|
||||||
$c['db_driver'] = class_exists('Db') ? Db::driver() : '';
|
$c['db_driver'] = class_exists('Db') ? Db::driver() : '';
|
||||||
@@ -72,9 +189,10 @@ try {
|
|||||||
if (array_key_exists('history_display', $in)) {
|
if (array_key_exists('history_display', $in)) {
|
||||||
$new['history_display'] = ($in['history_display'] === 'inline') ? 'inline' : 'click';
|
$new['history_display'] = ($in['history_display'] === 'inline') ? 'inline' : 'click';
|
||||||
}
|
}
|
||||||
// 成绩导出排序:time(按提交时间降序,默认) / sid(按学号)
|
// 成绩导出排序:time(按提交时间降序,默认)/ sid(按学号)/ score_desc(成绩降序)/ score_asc(成绩升序)
|
||||||
if (array_key_exists('export_sort', $in)) {
|
if (array_key_exists('export_sort', $in)) {
|
||||||
$new['export_sort'] = ($in['export_sort'] === 'sid') ? 'sid' : 'time';
|
$v = (string)$in['export_sort'];
|
||||||
|
$new['export_sort'] = in_array($v, ['time','sid','score_desc','score_asc'], true) ? $v : 'time';
|
||||||
}
|
}
|
||||||
// 各试卷独立满分(覆盖整个 full_scores 映射)
|
// 各试卷独立满分(覆盖整个 full_scores 映射)
|
||||||
if (array_key_exists('full_scores', $in) && is_array($in['full_scores'])) {
|
if (array_key_exists('full_scores', $in) && is_array($in['full_scores'])) {
|
||||||
@@ -209,7 +327,8 @@ try {
|
|||||||
}
|
}
|
||||||
$nameQid = (string)($_GET['nameQid'] ?? '');
|
$nameQid = (string)($_GET['nameQid'] ?? '');
|
||||||
$c = cfg();
|
$c = cfg();
|
||||||
$result = DataService::build($c, $projectId, $nameQid);
|
$built = ny_build_data($c, $projectId, $nameQid, !empty($_GET['force']));
|
||||||
|
$result = $built['result'];
|
||||||
$fsMap = is_array($c['full_scores'] ?? null) ? $c['full_scores'] : [];
|
$fsMap = is_array($c['full_scores'] ?? null) ? $c['full_scores'] : [];
|
||||||
$fs = isset($fsMap[$projectId]) ? (int)$fsMap[$projectId] : (int)($c['full_score'] ?? 100);
|
$fs = isset($fsMap[$projectId]) ? (int)$fsMap[$projectId] : (int)($c['full_score'] ?? 100);
|
||||||
if ($fs < 1) {
|
if ($fs < 1) {
|
||||||
@@ -220,6 +339,8 @@ try {
|
|||||||
'base_url' => $c['base_url'],
|
'base_url' => $c['base_url'],
|
||||||
'full_score' => $fs,
|
'full_score' => $fs,
|
||||||
];
|
];
|
||||||
|
// 缓存来源标记:便于前端/排查时知道这次是实时拉取还是命中短期缓存
|
||||||
|
$result['_cache'] = ['cached' => $built['cached'], 'at' => $built['at']];
|
||||||
ny_ok($result);
|
ny_ok($result);
|
||||||
|
|
||||||
// ---------------- 数据看板(按班级聚合,面向班主任/学生整体情况) ----------------
|
// ---------------- 数据看板(按班级聚合,面向班主任/学生整体情况) ----------------
|
||||||
@@ -230,7 +351,8 @@ try {
|
|||||||
}
|
}
|
||||||
$nameQid = (string)($_GET['nameQid'] ?? '');
|
$nameQid = (string)($_GET['nameQid'] ?? '');
|
||||||
$c = cfg();
|
$c = cfg();
|
||||||
$result = DataService::build($c, $projectId, $nameQid);
|
$built = ny_build_data($c, $projectId, $nameQid, !empty($_GET['force']));
|
||||||
|
$result = $built['result'];
|
||||||
$fsMap = is_array($c['full_scores'] ?? null) ? $c['full_scores'] : [];
|
$fsMap = is_array($c['full_scores'] ?? null) ? $c['full_scores'] : [];
|
||||||
$fs = isset($fsMap[$projectId]) ? (int)$fsMap[$projectId] : (int)($c['full_score'] ?? 100);
|
$fs = isset($fsMap[$projectId]) ? (int)$fsMap[$projectId] : (int)($c['full_score'] ?? 100);
|
||||||
if ($fs < 1) {
|
if ($fs < 1) {
|
||||||
@@ -417,6 +539,99 @@ try {
|
|||||||
Roster::clear();
|
Roster::clear();
|
||||||
ny_ok(['students' => [], 'meta' => Roster::meta()]);
|
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 'export':
|
case 'export':
|
||||||
case 'export_absent':
|
case 'export_absent':
|
||||||
@@ -440,7 +655,9 @@ try {
|
|||||||
};
|
};
|
||||||
|
|
||||||
$c = cfg();
|
$c = cfg();
|
||||||
$result = DataService::build($c, $projectId, $nameQid);
|
// 复用与 data 接口相同的结果缓存:导出不必再全量拉一次卷王
|
||||||
|
$built = ny_build_data($c, $projectId, $nameQid, !empty($_GET['force']));
|
||||||
|
$result = $built['result'];
|
||||||
|
|
||||||
$rows = array_values(array_filter((array)($result['rows'] ?? []), $hit));
|
$rows = array_values(array_filter((array)($result['rows'] ?? []), $hit));
|
||||||
|
|
||||||
@@ -472,57 +689,145 @@ try {
|
|||||||
$tag = ($clsLabel !== '' ? '_' . $safe($clsLabel) : '')
|
$tag = ($clsLabel !== '' ? '_' . $safe($clsLabel) : '')
|
||||||
. ($grpLabel !== '' ? '_' . $safe($grpLabel) : '');
|
. ($grpLabel !== '' ? '_' . $safe($grpLabel) : '');
|
||||||
|
|
||||||
|
$mode = ((string)($_GET['mode'] ?? '') === 'latest') ? 'latest' : 'all';
|
||||||
|
$splitCls = ((string)($_GET['split'] ?? '') === 'cls');
|
||||||
|
$isLatest = ($mode === 'latest');
|
||||||
$filename = ($action === 'export_absent' ? '缺考名单' : '成绩明细')
|
$filename = ($action === 'export_absent' ? '缺考名单' : '成绩明细')
|
||||||
. '_' . $safe((string)$result['project']['name'])
|
. '_' . $safe((string)$result['project']['name'])
|
||||||
. $tag
|
. $tag
|
||||||
. '_' . date('Ymd_His') . '.csv';
|
. ($isLatest ? '_最新成绩' : '')
|
||||||
|
. '_' . date('Ymd_His') . '.xlsx';
|
||||||
|
|
||||||
header('Content-Type: text/csv; charset=utf-8');
|
// ---------------- 列定义(可通过 cols= 参数挑选) ----------------
|
||||||
header('Content-Disposition: attachment; filename="' . $filename . '"; filename*=UTF-8\'\'' . rawurlencode($filename));
|
$colLabels = [
|
||||||
header('Cache-Control: no-store');
|
'sid' => '学号',
|
||||||
echo "\xEF\xBB\xBF";
|
'name' => '姓名',
|
||||||
$out = fopen('php://output', 'w');
|
'cls' => '班级',
|
||||||
|
'grp' => '组别',
|
||||||
if ($action === 'export_absent') {
|
'score' => $isLatest ? '最新成绩' : '成绩',
|
||||||
// 与页面缺考名单一致:按学号升序(纯数字按数值,否则自然排序)
|
'best_score' => '最高分',
|
||||||
usort($absent, static function ($x, $y): int {
|
'attempts' => '考试次数',
|
||||||
$a = trim((string)($x['sid'] ?? ''));
|
'idx' => '第几次',
|
||||||
$b = trim((string)($y['sid'] ?? ''));
|
'submit_at' => $isLatest ? '最近提交时间' : '提交时间',
|
||||||
if (is_numeric($a) && is_numeric($b)) {
|
'duration_txt' => '考试时长',
|
||||||
return $a <=> $b;
|
'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;
|
||||||
}
|
}
|
||||||
return strnatcmp($a, $b);
|
|
||||||
});
|
// ---------------- 数据行 ----------------
|
||||||
fputcsv($out, ['学号', '姓名', '班级', '组别']);
|
$sortMode = (string)($_GET['sort'] ?? '');
|
||||||
foreach ($absent as $r) {
|
if ($isLatest) {
|
||||||
fputcsv($out, [$r['sid'] ?? '', $r['name'] ?? '', $r['cls'] ?? '', $r['grp'] ?? '']);
|
// 每人一行:成绩取「最近一次提交」的分数(不受 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 {
|
} else {
|
||||||
// 导出全部考试记录(同一学号考了 N 次就有 N 行),并保留"考试次数"
|
// 每次考试一行(同一学号考了 N 次就有 N 行)
|
||||||
$sortMode = (string)($_GET['sort'] ?? '');
|
|
||||||
$sortMode = ($sortMode === 'sid') ? 'sid' : 'time';
|
|
||||||
$all = DataService::expandAttempts($rows, true);
|
$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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// time=按提交时间降序;sid=按学号(同学号内按提交时间降序)
|
// ---------------- 组装工作表 ----------------
|
||||||
$all = DataService::sortForExport($all, $sortMode);
|
$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);
|
||||||
|
}
|
||||||
|
|
||||||
fputcsv($out, ['学号', '姓名', '班级', '组别', '成绩', '考试次数', '第几次', '提交时间', '考试时长', '考试时长(秒)']);
|
$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) {
|
foreach ($all as $r) {
|
||||||
fputcsv($out, [
|
$byCls[trim((string)($r['cls'] ?? '')) ?: '(未分班)'][] = $r;
|
||||||
$r['sid'] ?? '',
|
}
|
||||||
$r['name'] ?? '',
|
ksort($byCls, SORT_NATURAL | SORT_FLAG_CASE);
|
||||||
$r['cls'] ?? '',
|
foreach ($byCls as $clsName => $list) {
|
||||||
$r['grp'] ?? '',
|
$sheets[] = ['name' => (string)$clsName, 'rows' => $toRows($list), 'widths' => $widths];
|
||||||
$r['score'] === null ? '' : $r['score'],
|
}
|
||||||
(int)($r['attempts'] ?? 1),
|
} else {
|
||||||
(int)($r['idx'] ?? 1),
|
$sheets[] = ['name' => $isLatest ? '成绩明细(最新)' : '成绩明细', 'rows' => $toRows($all), 'widths' => $widths];
|
||||||
$r['submit_at'] ?? '',
|
}
|
||||||
$r['duration_txt'] ?? '',
|
// 附加:缺考名单 / 未匹配答卷(仅非空时输出)
|
||||||
$r['duration'] === null ? '' : $r['duration'],
|
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]];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fclose($out);
|
|
||||||
|
// 说明:所有单元格按「共享字符串 + 文本格式」写入,
|
||||||
|
// 既不会把长学号变成科学计数法,也不会把 = + - @ 开头的内容当公式执行(规避 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;
|
exit;
|
||||||
|
|
||||||
// ---------------- 随机抽取:导出白名单(xlsx,列:姓名 / 登陆账号) ----------------
|
// ---------------- 随机抽取:导出白名单(xlsx,列:姓名 / 登陆账号) ----------------
|
||||||
|
|||||||
+525
-54
@@ -28,6 +28,9 @@
|
|||||||
highlight: new Set(), // 出现过新提交/新数据的学号,绿色底纹
|
highlight: new Set(), // 出现过新提交/新数据的学号,绿色底纹
|
||||||
hlTimer: null, // 自动清除高亮的定时器
|
hlTimer: null, // 自动清除高亮的定时器
|
||||||
hlAutoClearSec: 30, // 新数据高亮每隔多少秒自动清除一次
|
hlAutoClearSec: 30, // 新数据高亮每隔多少秒自动清除一次
|
||||||
|
_sorted: null, // 虚拟滚动:最近一次排序后的完整行数组(缓存,避免重复排序)
|
||||||
|
_kwDebounce: null, // 搜索框防抖定时器
|
||||||
|
csrf: '', // CSRF token(来自 config_get),写请求自动附带
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ---------------- 本地存储 ---------------- */
|
/* ---------------- 本地存储 ---------------- */
|
||||||
@@ -57,12 +60,19 @@
|
|||||||
const init = { method: opts.method || 'GET' };
|
const init = { method: opts.method || 'GET' };
|
||||||
if (opts.body) {
|
if (opts.body) {
|
||||||
init.headers = { 'Content-Type': 'application/json' };
|
init.headers = { 'Content-Type': 'application/json' };
|
||||||
|
// 写操作附带 CSRF token(在 body 内,后端 ny_csrf_ok 会从 JSON 里读取 _csrf)
|
||||||
|
if (state.csrf) opts.body._csrf = state.csrf;
|
||||||
init.body = JSON.stringify(opts.body);
|
init.body = JSON.stringify(opts.body);
|
||||||
}
|
}
|
||||||
if (opts.form) {
|
if (opts.form) {
|
||||||
init.method = 'POST';
|
init.method = 'POST';
|
||||||
|
if (state.csrf) opts.form.append('_csrf', state.csrf);
|
||||||
init.body = opts.form;
|
init.body = opts.form;
|
||||||
}
|
}
|
||||||
|
if (opts.method === 'POST' && state.csrf) {
|
||||||
|
init.headers = init.headers || {};
|
||||||
|
init.headers['X-CSRF-Token'] = state.csrf;
|
||||||
|
}
|
||||||
let url = 'api.php?action=' + encodeURIComponent(action);
|
let url = 'api.php?action=' + encodeURIComponent(action);
|
||||||
if (opts.params) {
|
if (opts.params) {
|
||||||
for (const k in opts.params) {
|
for (const k in opts.params) {
|
||||||
@@ -91,19 +101,51 @@
|
|||||||
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------------- Toast 顶部飘窗通知(替代旧 inline alert) ---------------- */
|
||||||
|
let toastTimer = null;
|
||||||
|
const TOAST_DURATION = 3500; // 默认显示 3.5 秒
|
||||||
|
const TOAST_LIMIT = 5; // 最多同时展示 5 条(旧消息优先被挤掉)
|
||||||
|
function showToast(msg, type) {
|
||||||
|
if (msg === '' || msg == null) return; // 空消息不弹(showAlert('') 兼容旧调用 → 静默返回)
|
||||||
|
// type: ''=默认(蓝) | 'info'=绿 | 'error'=红 | 'warn'=橙
|
||||||
|
const el = document.getElementById('toastContainer') || (function () {
|
||||||
|
const c = document.createElement('div');
|
||||||
|
c.id = 'toastContainer';
|
||||||
|
c.style.cssText = 'position:fixed;top:16px;left:50%;transform:translateX(-50%);z-index:9999;display:flex;flex-direction:column;gap:8px;pointer-events:none;max-width:calc(100vw - 32px);width:560px';
|
||||||
|
document.body.appendChild(c);
|
||||||
|
return c;
|
||||||
|
})();
|
||||||
|
// 限流:超过上限移除最旧
|
||||||
|
while (el.children.length >= TOAST_LIMIT) {
|
||||||
|
el.firstChild.remove();
|
||||||
|
}
|
||||||
|
const t = document.createElement('div');
|
||||||
|
const cls = type === 'error' ? 'toast-error' : (type === 'warn' ? 'toast-warn' : (type === 'info' ? 'toast-ok' : 'toast'));
|
||||||
|
t.className = 'toast ' + cls;
|
||||||
|
t.innerHTML = '<span>' + esc(msg).replace(/\n/g, '<br>') + '</span>';
|
||||||
|
t.style.cssText = 'pointer-events:auto;padding:10px 16px;border-radius:10px;font-size:13px;line-height:1.55;box-shadow:0 8px 30px rgba(15,23,42,.18);animation:toastIn .25s ease;white-space:pre-wrap;word-break:break-word';
|
||||||
|
el.appendChild(t);
|
||||||
|
// 点击关闭
|
||||||
|
t.addEventListener('click', () => { t.style.animation='toastOut .2s ease forwards'; setTimeout(() => t.remove(), 200); });
|
||||||
|
// 自动消失
|
||||||
|
setTimeout(() => {
|
||||||
|
if (t.parentNode) { t.style.animation='toastOut .2s ease forwards'; setTimeout(() => t.remove(), 200); }
|
||||||
|
}, TOAST_DURATION);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 兼容旧调用:showAlert 现在走 toast(type 映射) */
|
||||||
|
function showAlert(msg, type) {
|
||||||
|
if (msg === '' || msg == null) return; // 旧版 showAlert('') 用于"清空内联提示",toast 无需此操作
|
||||||
|
const t = type === 'info' ? 'ok' : (type === 'error' ? 'error' : '');
|
||||||
|
showToast(msg, t);
|
||||||
|
}
|
||||||
|
|
||||||
function setConn(cls, text) {
|
function setConn(cls, text) {
|
||||||
const el = $('#conn');
|
const el = $('#conn');
|
||||||
el.className = 'conn ' + cls;
|
el.className = 'conn ' + cls;
|
||||||
el.innerHTML = '<i></i><span>' + esc(text) + '</span>';
|
el.innerHTML = '<i></i><span>' + esc(text) + '</span>';
|
||||||
}
|
}
|
||||||
|
|
||||||
function showAlert(msg, type) {
|
|
||||||
const el = $('#alert');
|
|
||||||
if (!msg) { el.classList.add('hidden'); return; }
|
|
||||||
el.textContent = msg;
|
|
||||||
el.className = 'alert' + (type === 'info' ? ' info' : '');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 导出提示:说明本次导出的筛选范围
|
// 导出提示:说明本次导出的筛选范围
|
||||||
function exportTip(what, cls, grp) {
|
function exportTip(what, cls, grp) {
|
||||||
const parts = [];
|
const parts = [];
|
||||||
@@ -217,6 +259,7 @@
|
|||||||
state.absent = d.absent || [];
|
state.absent = d.absent || [];
|
||||||
state.unmatched = d.unmatched || [];
|
state.unmatched = d.unmatched || [];
|
||||||
state.roster = d.roster || [];
|
state.roster = d.roster || [];
|
||||||
|
state.anomalies = d.anomalies || { fast_high: [], frequent: [], identical: [] };
|
||||||
state.fullScore = (d.config && d.config.full_score) ? (parseInt(d.config.full_score, 10) || 100) : 100;
|
state.fullScore = (d.config && d.config.full_score) ? (parseInt(d.config.full_score, 10) || 100) : 100;
|
||||||
|
|
||||||
// 变更检测:学号/成绩/提交时间/考试次数/最近提交时间 任一变化即视为"有新数据"
|
// 变更检测:学号/成绩/提交时间/考试次数/最近提交时间 任一变化即视为"有新数据"
|
||||||
@@ -256,16 +299,17 @@
|
|||||||
renderHints();
|
renderHints();
|
||||||
|
|
||||||
$('#lastUpdate').textContent = d.updated_at || '—';
|
$('#lastUpdate').textContent = d.updated_at || '—';
|
||||||
|
state.projectName = (d.project && d.project.name) || '';
|
||||||
setConn('conn-on', (opts.fromCache ? '缓存 · ' : '') + '实时 · ' + (d.project && d.project.name || ''));
|
setConn('conn-on', (opts.fromCache ? '缓存 · ' : '') + '实时 · ' + (d.project && d.project.name || ''));
|
||||||
if (!opts.fromCache) showAlert('');
|
if (!opts.fromCache) showAlert('');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refresh(silent) {
|
async function refresh(silent, force) {
|
||||||
const pid = $('#projectSel').value;
|
const pid = $('#projectSel').value;
|
||||||
if (!pid) return;
|
if (!pid) return;
|
||||||
if (state.loading && !silent) return;
|
if (state.loading && !silent) return;
|
||||||
state.loading = true;
|
state.loading = true;
|
||||||
if (!silent) setConn('conn-load', '拉取数据…');
|
if (!silent) setConn('conn-load', force ? '重新拉取数据…' : '拉取数据…');
|
||||||
|
|
||||||
// 1) 先读本地缓存 → 立即渲染,刷新期间保持视觉稳定
|
// 1) 先读本地缓存 → 立即渲染,刷新期间保持视觉稳定
|
||||||
const cached = LS.get(dataKey(pid), null);
|
const cached = LS.get(dataKey(pid), null);
|
||||||
@@ -277,7 +321,9 @@
|
|||||||
|
|
||||||
// 2) 后台异步拉真实数据
|
// 2) 后台异步拉真实数据
|
||||||
try {
|
try {
|
||||||
const d = await api('data', { params: { projectId: pid, nameQid: $('#nameFieldSel').value } });
|
const params = { projectId: pid, nameQid: $('#nameFieldSel').value };
|
||||||
|
if (force) params.force = 1;
|
||||||
|
const d = await api('data', { params });
|
||||||
applyData(d);
|
applyData(d);
|
||||||
state.connFail = 0;
|
state.connFail = 0;
|
||||||
LS.set(dataKey(pid), d);
|
LS.set(dataKey(pid), d);
|
||||||
@@ -287,9 +333,21 @@
|
|||||||
LS.set(LS_ROSTER, { ts: Date.now(), students: d.roster || [] });
|
LS.set(LS_ROSTER, { ts: Date.now(), students: d.roster || [] });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e.auth) {
|
if (e.auth) {
|
||||||
// 卷王账号未配置或登录已失效:引导重新登录,避免卡在"已显示本地缓存"的死状态
|
// 卷王账号未配置或登录已失效:引导重新登录(强制清掉本地会话 + 跳登录页)
|
||||||
|
// 关键:必须先调 logout.php 把服务端会话清空,否则 login.php 发现会话还在会把人踢回 index.php,形成"跳不过去"的死循环
|
||||||
|
if (!state._authRedirecting) {
|
||||||
|
state._authRedirecting = true;
|
||||||
|
if (state.timer) { clearTimeout(state.timer); state.timer = null; }
|
||||||
|
if (state.hlTimer) clearInterval(state.hlTimer);
|
||||||
showAlert('卷王账号未配置或登录已失效,正在跳转登录页…', 'error');
|
showAlert('卷王账号未配置或登录已失效,正在跳转登录页…', 'error');
|
||||||
setTimeout(() => { location.href = 'login.php'; }, 900);
|
// 异步清会话,忽略失败;登录页端也有探测兜底(token 过期时主动销毁会话)
|
||||||
|
fetch('logout.php', { method: 'POST', credentials: 'same-origin' })
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => {
|
||||||
|
// 200ms 足够让 toast 入场,又不至于让用户干等
|
||||||
|
setTimeout(() => { window.location.href = 'login.php'; }, 200);
|
||||||
|
});
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!cached) {
|
if (!cached) {
|
||||||
@@ -305,6 +363,8 @@
|
|||||||
state.loading = false;
|
state.loading = false;
|
||||||
$('#liveDot').classList.add('on');
|
$('#liveDot').classList.add('on');
|
||||||
setTimeout(() => $('#liveDot').classList.remove('on'), 900);
|
setTimeout(() => $('#liveDot').classList.remove('on'), 900);
|
||||||
|
// 自动刷新是 setTimeout 链:每轮结束重新排下一轮(成功复位、失败按退避延长)
|
||||||
|
schedule();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -444,7 +504,7 @@
|
|||||||
const rosterForGrp = grp ? rosterForCls.filter(s => (s.grp || '') === grp) : rosterForCls;
|
const rosterForGrp = grp ? rosterForCls.filter(s => (s.grp || '') === grp) : rosterForCls;
|
||||||
const absentForGrp = Math.max(0, rosterForGrp.length - rowsForGrp.length);
|
const absentForGrp = Math.max(0, rosterForGrp.length - rowsForGrp.length);
|
||||||
$('#tableHint').textContent = '共 ' + rowsForGrp.length + ' 人已提交 · 名单 ' + rosterForGrp.length + ' 人';
|
$('#tableHint').textContent = '共 ' + rowsForGrp.length + ' 人已提交 · 名单 ' + rosterForGrp.length + ' 人';
|
||||||
$('#absentHint').textContent = absentForGrp ? '共 ' + absentForGrp + ' 人' : '';
|
// absentHint 由 renderAbsent 统一维护(含按班统计),此处不再重复设置
|
||||||
}
|
}
|
||||||
|
|
||||||
function fmtDur(sec) {
|
function fmtDur(sec) {
|
||||||
@@ -582,27 +642,37 @@
|
|||||||
const rows = buildRows();
|
const rows = buildRows();
|
||||||
const k = state.sortKey, dir = state.sortAsc ? 1 : -1;
|
const k = state.sortKey, dir = state.sortAsc ? 1 : -1;
|
||||||
rows.sort((a, b) => cmpBy(k, dir, a, b));
|
rows.sort((a, b) => cmpBy(k, dir, a, b));
|
||||||
|
state._sorted = rows;
|
||||||
|
|
||||||
const tb = $('#tbody');
|
const tb = $('#tbody');
|
||||||
if (!rows.length) {
|
if (!rows.length) {
|
||||||
tb.innerHTML = '<tr><td colspan="' + COL_COUNT + '" class="empty">' +
|
tb.innerHTML = '<tr><td colspan="' + COL_COUNT + '" class="empty">' +
|
||||||
(state.rows.length ? '没有匹配的记录' : '暂无提交记录') + '</td></tr>';
|
(state.rows.length ? '没有匹配的记录' : '暂无提交记录') + '</td></tr>';
|
||||||
return;
|
} else {
|
||||||
|
renderVirtualRows(tb, rows);
|
||||||
}
|
}
|
||||||
tb.innerHTML = rows.map(r => {
|
|
||||||
|
$$('#dataTable th.sortable').forEach(th => {
|
||||||
|
const isCur = th.dataset.k === state.sortKey;
|
||||||
|
th.innerHTML = th.textContent.replace(/[\s↑↓]+$/, '') + (isCur ? ' <span class="arrow">' + (state.sortAsc ? '↑' : '↓') + '</span>' : '');
|
||||||
|
});
|
||||||
|
|
||||||
|
renderHlBadge();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 单行 HTML(与虚拟滚动共用,避免逻辑重复) */
|
||||||
|
function rowHtml(r) {
|
||||||
const sid = String(r.sid || '');
|
const sid = String(r.sid || '');
|
||||||
const isNew = sid !== '' && state.newSids.has(sid);
|
const isNew = sid !== '' && state.newSids.has(sid);
|
||||||
const hl = sid !== '' && state.highlight.has(sid);
|
const hl = sid !== '' && state.highlight.has(sid);
|
||||||
const attempts = (r.attempts || 1);
|
const attempts = (r.attempts || 1);
|
||||||
const clsName = [hl ? 'new' : '', 'clickable'].filter(Boolean).join(' ');
|
const clsName = [hl ? 'new' : '', 'clickable'].filter(Boolean).join(' ');
|
||||||
|
|
||||||
// 姓名后缀标记
|
|
||||||
const tags = [];
|
const tags = [];
|
||||||
if (isNew) tags.push('<span class="tag tag-new">新</span>');
|
if (isNew) tags.push('<span class="tag tag-new">新</span>');
|
||||||
if (r._att && r.is_best && attempts > 1) tags.push('<span class="tag tag-best">最佳</span>');
|
if (r._att && r.is_best && attempts > 1) tags.push('<span class="tag tag-best">最佳</span>');
|
||||||
if (r._att && r.temp_save) tags.push('<span class="tag tag-gray">暂存</span>');
|
if (r._att && r.temp_save) tags.push('<span class="tag tag-gray">暂存</span>');
|
||||||
|
|
||||||
// 考试次数:全部展示模式下额外标出"第几次"
|
|
||||||
const attCell = r._att && attempts > 1
|
const attCell = r._att && attempts > 1
|
||||||
? attempts + ' <small style="color:var(--text-3)">第' + r.idx + '次</small>'
|
? attempts + ' <small style="color:var(--text-3)">第' + r.idx + '次</small>'
|
||||||
: String(attempts);
|
: String(attempts);
|
||||||
@@ -617,14 +687,49 @@
|
|||||||
'<td class="num">' + esc(r.submit_at) + '</td>' +
|
'<td class="num">' + esc(r.submit_at) + '</td>' +
|
||||||
'<td class="num">' + esc(r.duration_txt) + '</td>' +
|
'<td class="num">' + esc(r.duration_txt) + '</td>' +
|
||||||
'</tr>';
|
'</tr>';
|
||||||
}).join('');
|
}
|
||||||
|
|
||||||
$$('#dataTable th.sortable').forEach(th => {
|
/**
|
||||||
const isCur = th.dataset.k === state.sortKey;
|
* 虚拟滚动:只渲染可视窗口内的行,用顶部/底部 spacer 撑起完整滚动高度。
|
||||||
th.innerHTML = th.textContent.replace(/[\s↑↓]+$/, '') + (isCur ? ' <span class="arrow">' + (state.sortAsc ? '↑' : '↓') + '</span>' : '');
|
* 行数很多(尤其「全部展开」历史模式下)时,避免一次性插入成千上万 DOM 节点导致卡顿。
|
||||||
});
|
*/
|
||||||
|
const ROW_H = 41; // 估算单行高度(含 padding/border),用于窗口定位
|
||||||
|
const VS_OVERSCAN = 12; // 视口上下各多渲染的行数,减少快速滚动时的白屏
|
||||||
|
function renderVirtualRows(tb, rows) {
|
||||||
|
const scroller = tb.closest('.table-scroll');
|
||||||
|
if (!scroller) {
|
||||||
|
// 兜底:找不到滚动容器时退回全量渲染
|
||||||
|
tb.innerHTML = rows.map(rowHtml).join('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const scrollTop = scroller.scrollTop;
|
||||||
|
const viewH = scroller.clientHeight;
|
||||||
|
const total = rows.length;
|
||||||
|
let start = Math.floor(scrollTop / ROW_H) - VS_OVERSCAN;
|
||||||
|
let end = Math.ceil((scrollTop + viewH) / ROW_H) + VS_OVERSCAN;
|
||||||
|
if (start < 0) start = 0;
|
||||||
|
if (end > total) end = total;
|
||||||
|
if (start >= end) start = Math.max(0, end - 1);
|
||||||
|
|
||||||
renderHlBadge();
|
const frag = [];
|
||||||
|
if (start > 0) {
|
||||||
|
frag.push('<tr class="vspacer" style="height:' + (start * ROW_H) + 'px;pointer-events:none"><td colspan="' + COL_COUNT + '"></td></tr>');
|
||||||
|
}
|
||||||
|
for (let i = start; i < end; i++) {
|
||||||
|
frag.push(rowHtml(rows[i]));
|
||||||
|
}
|
||||||
|
if (end < total) {
|
||||||
|
frag.push('<tr class="vspacer" style="height:' + ((total - end) * ROW_H) + 'px;pointer-events:none"><td colspan="' + COL_COUNT + '"></td></tr>');
|
||||||
|
}
|
||||||
|
tb.innerHTML = frag.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 供滚动监听调用:滚动时重算可视窗口(不重新排序,直接复用 state._sorted) */
|
||||||
|
function onTableScroll() {
|
||||||
|
const tb = $('#tbody');
|
||||||
|
const rows = state._sorted;
|
||||||
|
if (!tb || !rows || !rows.length) return;
|
||||||
|
renderVirtualRows(tb, rows);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderAbsent() {
|
function renderAbsent() {
|
||||||
@@ -637,24 +742,40 @@
|
|||||||
const rosterForCls = clsFilter ? (state.roster || []).filter(s => (s.cls || '') === clsFilter) : (state.roster || []);
|
const rosterForCls = clsFilter ? (state.roster || []).filter(s => (s.cls || '') === clsFilter) : (state.roster || []);
|
||||||
const rosterForGrp = grpFilter ? rosterForCls.filter(s => (s.grp || '') === grpFilter) : rosterForCls;
|
const rosterForGrp = grpFilter ? rosterForCls.filter(s => (s.grp || '') === grpFilter) : rosterForCls;
|
||||||
const list = rosterForGrp.filter(s => !submittedSids.has(s.sid));
|
const list = rosterForGrp.filter(s => !submittedSids.has(s.sid));
|
||||||
|
state._absentList = list; // 供复制/打印按钮使用
|
||||||
const tb = $('#absentBody');
|
const tb = $('#absentBody');
|
||||||
if (!list.length) {
|
if (!list.length) {
|
||||||
tb.innerHTML = '<tr><td colspan="4" class="empty">' + (clsFilter ? '本班全部已提交 🎉' : '全部已提交 🎉') + '</td></tr>';
|
tb.innerHTML = '<tr><td colspan="4" class="empty">' + (clsFilter ? '本班全部已提交 🎉' : '全部已提交 🎉') + '</td></tr>';
|
||||||
return;
|
} else {
|
||||||
}
|
|
||||||
tb.innerHTML = list.map(a =>
|
tb.innerHTML = list.map(a =>
|
||||||
'<tr><td class="sid">' + esc(a.sid) + '</td><td>' + esc(a.name) + '</td><td>' + esc(a.cls || '') + '</td><td>' + esc(a.grp || '') + '</td></tr>').join('');
|
'<tr><td class="sid">' + esc(a.sid) + '</td><td>' + esc(a.name) + '</td><td>' + esc(a.cls || '') + '</td><td>' + esc(a.grp || '') + '</td></tr>').join('');
|
||||||
}
|
}
|
||||||
|
// 按班统计缺考人数(未按班级筛选时显示概览)
|
||||||
|
const hint = $('#absentHint');
|
||||||
|
if (hint) {
|
||||||
|
if (!clsFilter && list.length) {
|
||||||
|
const byCls = {};
|
||||||
|
list.forEach(a => { const c = (a.cls || '未分班'); byCls[c] = (byCls[c] || 0) + 1; });
|
||||||
|
const parts = Object.keys(byCls).sort().map(c => c + ' ' + byCls[c] + ' 人');
|
||||||
|
hint.textContent = '共 ' + list.length + ' 人 · ' + parts.join(' ');
|
||||||
|
} else {
|
||||||
|
hint.textContent = list.length ? '共 ' + list.length + ' 人' : '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function renderUnmatched() {
|
function renderUnmatched() {
|
||||||
const tb = $('#unmatchedBody');
|
const tb = $('#unmatchedBody');
|
||||||
if (!state.unmatched.length) {
|
if (!state.unmatched.length) {
|
||||||
tb.innerHTML = '<tr><td colspan="3" class="empty">无</td></tr>';
|
tb.innerHTML = '<tr><td colspan="4" class="empty">无</td></tr>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
tb.innerHTML = state.unmatched.map(u =>
|
tb.innerHTML = state.unmatched.map(u =>
|
||||||
'<tr><td>' + esc(u.name) + '</td><td class="num">' + (u.score == null ? '-' : u.score) +
|
'<tr>' +
|
||||||
'</td><td class="num">' + esc(u.submit_at) + '</td></tr>').join('');
|
'<td>' + esc(u.name) + '</td>' +
|
||||||
|
'<td class="num">' + (u.score == null ? '-' : u.score) + '</td>' +
|
||||||
|
'<td class="num">' + esc(u.submit_at) + '</td>' +
|
||||||
|
'<td><button class="btn xs" data-map="' + esc(u.name) + '">加入名单</button></td></tr>').join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 历史成绩弹窗(主统计页成绩明细):任意成员都可点开,含只考过 1 次的 */
|
/* 历史成绩弹窗(主统计页成绩明细):任意成员都可点开,含只考过 1 次的 */
|
||||||
@@ -667,11 +788,16 @@
|
|||||||
// 后端未返回明细时,用代表行兜底成一条
|
// 后端未返回明细时,用代表行兜底成一条
|
||||||
list = [{
|
list = [{
|
||||||
idx: 1, is_best: true, score: r.score, submit_at: r.submit_at,
|
idx: 1, is_best: true, score: r.score, submit_at: r.submit_at,
|
||||||
|
submit_ts: r.submit_ts || r.last_submit_ts || 0,
|
||||||
duration_txt: r.duration_txt, temp_save: !!r.temp_save,
|
duration_txt: r.duration_txt, temp_save: !!r.temp_save,
|
||||||
}];
|
}];
|
||||||
}
|
}
|
||||||
// 新 -> 旧 展示
|
// 新 -> 旧 展示(缺 submit_ts 时按 idx 倒序兜底,保证"次序"看起来合理)
|
||||||
const desc = list.slice().sort((a, b) => (b.submit_ts || 0) - (a.submit_ts || 0));
|
const desc = list.slice().sort((a, b) => {
|
||||||
|
const ta = a.submit_ts || 0, tb = b.submit_ts || 0;
|
||||||
|
if (ta !== tb) return tb - ta;
|
||||||
|
return (b.idx || 0) - (a.idx || 0);
|
||||||
|
});
|
||||||
const rows = desc.map((a, i) => {
|
const rows = desc.map((a, i) => {
|
||||||
const sc = (a.score == null) ? '<span class="muted">未判分</span>' : esc(a.score);
|
const sc = (a.score == null) ? '<span class="muted">未判分</span>' : esc(a.score);
|
||||||
const best = (a.is_best && attempts > 1) ? ' <span class="tag tag-best">最佳</span>' : '';
|
const best = (a.is_best && attempts > 1) ? ' <span class="tag tag-best">最佳</span>' : '';
|
||||||
@@ -698,11 +824,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ---------------- 自动刷新 ---------------- */
|
/* ---------------- 自动刷新 ---------------- */
|
||||||
|
// 失败退避:连续失败时延长下次刷新间隔(1x → 2x → 4x,封顶 8x),成功后复位。
|
||||||
|
// 用 setTimeout 链而非 setInterval,便于动态调整间隔;后台标签页暂停刷新,回到前台立即补一次。
|
||||||
function schedule() {
|
function schedule() {
|
||||||
if (state.timer) clearInterval(state.timer);
|
if (state.timer) { clearTimeout(state.timer); state.timer = null; }
|
||||||
if (!$('#autoRefresh').checked) return;
|
if (!$('#autoRefresh').checked) return;
|
||||||
const sec = parseInt($('#intervalSel').value, 10) || 15;
|
const base = (parseInt($('#intervalSel').value, 10) || 15) * 1000;
|
||||||
state.timer = setInterval(() => refresh(true), sec * 1000);
|
const backoff = Math.min(8, Math.pow(2, state.connFail)) * base;
|
||||||
|
const delay = (document.hidden ? Math.max(backoff, 5000) : backoff);
|
||||||
|
state.timer = setTimeout(() => { refresh(true); }, delay);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------------- 新数据高亮自动清除 ---------------- */
|
/* ---------------- 新数据高亮自动清除 ---------------- */
|
||||||
@@ -749,6 +879,7 @@
|
|||||||
async function openSettings() {
|
async function openSettings() {
|
||||||
try {
|
try {
|
||||||
const c = await api('config_get');
|
const c = await api('config_get');
|
||||||
|
if (c.csrf_token) state.csrf = c.csrf_token;
|
||||||
$('#cfgPageSize').value = c.page_size || 200;
|
$('#cfgPageSize').value = c.page_size || 200;
|
||||||
$('#cfgInterval').value = c.refresh_interval || 15;
|
$('#cfgInterval').value = c.refresh_interval || 15;
|
||||||
$('#cfgTimeout').value = c.timeout || 20;
|
$('#cfgTimeout').value = c.timeout || 20;
|
||||||
@@ -764,7 +895,7 @@
|
|||||||
renderFullScores((c.full_scores && typeof c.full_scores === 'object') ? c.full_scores : {});
|
renderFullScores((c.full_scores && typeof c.full_scores === 'object') ? c.full_scores : {});
|
||||||
// 历史成绩展示方式 + 导出排序
|
// 历史成绩展示方式 + 导出排序
|
||||||
state.historyDisplay = (c.history_display === 'inline') ? 'inline' : 'click';
|
state.historyDisplay = (c.history_display === 'inline') ? 'inline' : 'click';
|
||||||
state.exportSort = (c.export_sort === 'sid') ? 'sid' : 'time';
|
state.exportSort = (c.export_sort === 'sid') ? 'sid' : ((c.export_sort === 'score_desc') ? 'score_desc' : ((c.export_sort === 'score_asc') ? 'score_asc' : 'time'));
|
||||||
$('#cfgHistoryDisplay').value = state.historyDisplay;
|
$('#cfgHistoryDisplay').value = state.historyDisplay;
|
||||||
$('#cfgExportSort').value = state.exportSort;
|
$('#cfgExportSort').value = state.exportSort;
|
||||||
if ($('#exportSortSel')) $('#exportSortSel').value = state.exportSort;
|
if ($('#exportSortSel')) $('#exportSortSel').value = state.exportSort;
|
||||||
@@ -784,17 +915,26 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 渲染"各试卷独立满分"列表
|
// 渲染"各试卷独立满分"列表
|
||||||
|
// 注意:#fullScoresHint 必须位于 #fullScoresBox 之外(见 index.php),
|
||||||
|
// 否则 box.innerHTML 覆盖会销毁它,下面的 textContent 赋值会抛 "Cannot set properties of null"。
|
||||||
|
// 这里仍保留 null 守卫,避免任何 DOM 结构变动导致整个设置弹窗打不开。
|
||||||
|
function setFullScoresHint(text) {
|
||||||
|
const hint = $('#fullScoresHint');
|
||||||
|
if (hint) hint.textContent = text;
|
||||||
|
}
|
||||||
function renderFullScores(scores) {
|
function renderFullScores(scores) {
|
||||||
const box = $('#fullScoresBox');
|
const box = $('#fullScoresBox');
|
||||||
if (!box) return;
|
if (!box) return;
|
||||||
if (!state.projects.length) {
|
if (!state.projects.length) {
|
||||||
box.innerHTML = '<span class="hint">暂无试卷,请先连接卷王系统并选择问卷</span>';
|
box.innerHTML = '<span class="hint">暂无试卷,请先连接卷王系统并选择问卷</span>';
|
||||||
|
setFullScoresHint('给某卷填入数字即可单独覆盖默认满分;留空=用默认');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// 排除文件夹项:模式为文件夹的无可答卷
|
// 排除文件夹项:模式为文件夹的无可答卷
|
||||||
const surveys = state.projects.filter(p => !((p.mode || '').toLowerCase().includes('folder')));
|
const surveys = state.projects.filter(p => !((p.mode || '').toLowerCase().includes('folder')));
|
||||||
if (!surveys.length) {
|
if (!surveys.length) {
|
||||||
box.innerHTML = '<span class="hint">尚未获取到可答卷</span>';
|
box.innerHTML = '<span class="hint">尚未获取到可答卷</span>';
|
||||||
|
setFullScoresHint('给某卷填入数字即可单独覆盖默认满分;留空=用默认');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
box.innerHTML = surveys.map(p => {
|
box.innerHTML = surveys.map(p => {
|
||||||
@@ -804,11 +944,20 @@
|
|||||||
'<input type="number" min="1" max="1000" data-pid="' + esc(p.id) + '" value="' + esc(val) + '" placeholder="默认">' +
|
'<input type="number" min="1" max="1000" data-pid="' + esc(p.id) + '" value="' + esc(val) + '" placeholder="默认">' +
|
||||||
'</div>';
|
'</div>';
|
||||||
}).join('');
|
}).join('');
|
||||||
$('#fullScoresHint').textContent = '默认分值为空时使用上方"默认满分";填写即单独覆盖该卷';
|
setFullScoresHint('默认分值为空时使用上方"默认满分";填写即单独覆盖该卷');
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------------- 名单 ---------------- */
|
/* ---------------- 名单 ---------------- */
|
||||||
let rosterDraft = [];
|
let rosterDraft = [];
|
||||||
|
const rosterSel = new Set(); // 勾选待删除的行下标(对应 rosterDraft 索引)
|
||||||
|
|
||||||
|
// 更新「删除选中」按钮的可见性与计数
|
||||||
|
function updateDelSelBtn() {
|
||||||
|
const btn = $('#btnDelSel');
|
||||||
|
if (!btn) return;
|
||||||
|
btn.hidden = (rosterSel.size === 0);
|
||||||
|
btn.textContent = '删除选中' + (rosterSel.size > 0 ? '(' + rosterSel.size + ')' : '');
|
||||||
|
}
|
||||||
|
|
||||||
async function openRoster() {
|
async function openRoster() {
|
||||||
try {
|
try {
|
||||||
@@ -827,24 +976,28 @@
|
|||||||
String(r.sid).toLowerCase().includes(kw) || String(r.name).toLowerCase().includes(kw));
|
String(r.sid).toLowerCase().includes(kw) || String(r.name).toLowerCase().includes(kw));
|
||||||
const tb = $('#rosterBody');
|
const tb = $('#rosterBody');
|
||||||
if (!rows.length) {
|
if (!rows.length) {
|
||||||
tb.innerHTML = '<tr><td colspan="4" class="empty">暂无名单,请上传文件</td></tr>';
|
tb.innerHTML = '<tr><td colspan="6" class="empty">暂无名单,请上传文件</td></tr>';
|
||||||
|
$('#btnDelSel').hidden = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
tb.innerHTML = rows.map((r, i) => {
|
tb.innerHTML = rows.map((r, i) => {
|
||||||
const gi = rosterDraft.indexOf(r);
|
const gi = rosterDraft.indexOf(r);
|
||||||
|
const checked = rosterSel.has(gi) ? ' checked' : '';
|
||||||
return '<tr data-i="' + gi + '">' +
|
return '<tr data-i="' + gi + '">' +
|
||||||
|
'<td><input type="checkbox" class="roster-cb" data-i="' + gi + '"' + checked + '></td>' +
|
||||||
'<td><input value="' + esc(r.sid) + '" data-f="sid"></td>' +
|
'<td><input value="' + esc(r.sid) + '" data-f="sid"></td>' +
|
||||||
'<td><input value="' + esc(r.name) + '" data-f="name"></td>' +
|
'<td><input value="' + esc(r.name) + '" data-f="name"></td>' +
|
||||||
'<td><input value="' + esc(r.cls || '') + '" data-f="cls" placeholder="可选"></td>' +
|
'<td><input value="' + esc(r.cls || '') + '" data-f="cls" placeholder="可选"></td>' +
|
||||||
'<td><input value="' + esc(r.grp || '') + '" data-f="grp" placeholder="A/B"></td>' +
|
'<td><input value="' + esc(r.grp || '') + '" data-f="grp" placeholder="A/B"></td>' +
|
||||||
'<td><span class="link-del" data-del="' + gi + '">删除</span></td></tr>';
|
'<td><input value="' + esc(r.login || '') + '" data-f="login" placeholder="可选"></td></tr>';
|
||||||
}).join('');
|
}).join('');
|
||||||
|
updateDelSelBtn();
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------------- 事件绑定 ---------------- */
|
/* ---------------- 事件绑定 ---------------- */
|
||||||
document.addEventListener('DOMContentLoaded', function () {
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
|
||||||
$('#btnRefresh').addEventListener('click', () => refresh(false));
|
$('#btnRefresh').addEventListener('click', () => refresh(false, true));
|
||||||
$('#btnSettings').addEventListener('click', openSettings);
|
$('#btnSettings').addEventListener('click', openSettings);
|
||||||
$('#btnRoster').addEventListener('click', openRoster);
|
$('#btnRoster').addEventListener('click', openRoster);
|
||||||
$('#btnFullscreen').addEventListener('click', toggleFullscreen);
|
$('#btnFullscreen').addEventListener('click', toggleFullscreen);
|
||||||
@@ -852,7 +1005,11 @@
|
|||||||
if (document.webkitFullscreenEnabled === undefined && !document.documentElement.requestFullscreen && !document.documentElement.webkitRequestFullscreen) {
|
if (document.webkitFullscreenEnabled === undefined && !document.documentElement.requestFullscreen && !document.documentElement.webkitRequestFullscreen) {
|
||||||
$('#btnFullscreen').style.display = 'none';
|
$('#btnFullscreen').style.display = 'none';
|
||||||
}
|
}
|
||||||
$('#kw').addEventListener('input', renderTable);
|
// 搜索框防抖:连续输入时只渲染最后一次,避免每个字符都全量重排
|
||||||
|
$('#kw').addEventListener('input', () => {
|
||||||
|
if (state._kwDebounce) clearTimeout(state._kwDebounce);
|
||||||
|
state._kwDebounce = setTimeout(() => { renderTable(); }, 200);
|
||||||
|
});
|
||||||
$('#clsSel').addEventListener('change', () => {
|
$('#clsSel').addEventListener('change', () => {
|
||||||
populateGroups();
|
populateGroups();
|
||||||
rememberFilters();
|
rememberFilters();
|
||||||
@@ -887,7 +1044,16 @@
|
|||||||
});
|
});
|
||||||
$('#autoRefresh').addEventListener('change', e => {
|
$('#autoRefresh').addEventListener('change', e => {
|
||||||
if (e.target.checked) { schedule(); refresh(true); }
|
if (e.target.checked) { schedule(); refresh(true); }
|
||||||
else if (state.timer) clearInterval(state.timer);
|
else if (state.timer) { clearTimeout(state.timer); state.timer = null; }
|
||||||
|
});
|
||||||
|
|
||||||
|
// 后台标签页暂停自动刷新(省流量/CPU);回到前台立即补一次并重置倒计时
|
||||||
|
document.addEventListener('visibilitychange', () => {
|
||||||
|
if (document.hidden) {
|
||||||
|
if (state.timer) { clearTimeout(state.timer); state.timer = null; }
|
||||||
|
} else {
|
||||||
|
if ($('#autoRefresh').checked) { refresh(true); schedule(); }
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
$$('#dataTable th.sortable').forEach(th => {
|
$$('#dataTable th.sortable').forEach(th => {
|
||||||
@@ -898,10 +1064,31 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
/* 成绩明细行点击 → 历史成绩弹窗(事件委托,避免内联 onclick 找不到 IIFE 内函数) */
|
// 虚拟滚动:滚动成绩表时按需渲染可视窗口(throttle 到 rAF 避免高频抖动)
|
||||||
|
(function () {
|
||||||
|
const scroller = $('#tbody') && $('#tbody').closest('.table-scroll');
|
||||||
|
if (!scroller) return;
|
||||||
|
let ticking = false;
|
||||||
|
scroller.addEventListener('scroll', () => {
|
||||||
|
if (ticking) return;
|
||||||
|
ticking = true;
|
||||||
|
requestAnimationFrame(() => { onTableScroll(); ticking = false; });
|
||||||
|
});
|
||||||
|
// 窗口尺寸变化(缩放/布局切换)时重算可视行
|
||||||
|
if (window.ResizeObserver) {
|
||||||
|
new ResizeObserver(() => { if (!ticking) { ticking = true; requestAnimationFrame(() => { onTableScroll(); ticking = false; }); } })
|
||||||
|
.observe(scroller);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
/* 成绩明细行点击 → 历史成绩弹窗(事件委托,避免内联 onclick 找不到 IIFE 内函数)
|
||||||
|
注意:先绑 tbody 的 click,再绑 document 的 modal-close click;事件冒泡到 tbody 时就处理掉,
|
||||||
|
让 modal-close 只对弹窗自身 / [data-close] 元素生效,避免误关。 */
|
||||||
$('#tbody').addEventListener('click', e => {
|
$('#tbody').addEventListener('click', e => {
|
||||||
const tr = e.target.closest('tr[data-sid]');
|
const tr = e.target.closest('tr[data-sid]');
|
||||||
if (!tr) return;
|
if (!tr) return;
|
||||||
|
// 仅响应"打开成员历史"动作;表头排序 sortable 在 #dataTable 上另行处理
|
||||||
|
if (tr.parentNode && tr.parentNode.id !== 'tbody') return;
|
||||||
const sid = tr.getAttribute('data-sid');
|
const sid = tr.getAttribute('data-sid');
|
||||||
if (sid) openAttemptsModal(sid);
|
if (sid) openAttemptsModal(sid);
|
||||||
});
|
});
|
||||||
@@ -928,12 +1115,22 @@
|
|||||||
const pid = $('#projectSel').value;
|
const pid = $('#projectSel').value;
|
||||||
if (!pid) return showAlert('请先选择问卷 / 考试');
|
if (!pid) return showAlert('请先选择问卷 / 考试');
|
||||||
const cls = $('#clsSel').value, grp = $('#grpSel').value;
|
const cls = $('#clsSel').value, grp = $('#grpSel').value;
|
||||||
const sort = (state.exportSort === 'sid') ? 'sid' : 'time';
|
const sort = (state.exportSort === 'sid') ? 'sid' : ((state.exportSort === 'score_desc') ? 'score_desc' : ((state.exportSort === 'score_asc') ? 'score_asc' : 'time'));
|
||||||
location.href = 'api.php?action=export&projectId=' + encodeURIComponent(pid) +
|
// 导出提示文案(与排序方式对应,让用户看清"这次按什么排")
|
||||||
|
const sortLabel = (sort === 'sid') ? '按学号'
|
||||||
|
: (sort === 'score_desc') ? '按成绩降序'
|
||||||
|
: (sort === 'score_asc') ? '按成绩升序'
|
||||||
|
: '按提交时间';
|
||||||
|
const latest = ($('#exportLatest') && $('#exportLatest').checked) ? 'latest' : 'all';
|
||||||
|
const split = ($('#exportSplit') && $('#exportSplit').checked) ? 'cls' : '';
|
||||||
|
const qs = 'api.php?action=export&projectId=' + encodeURIComponent(pid) +
|
||||||
'&nameQid=' + encodeURIComponent($('#nameFieldSel').value) +
|
'&nameQid=' + encodeURIComponent($('#nameFieldSel').value) +
|
||||||
'&cls=' + encodeURIComponent(cls) + '&grp=' + encodeURIComponent(grp) +
|
'&cls=' + encodeURIComponent(cls) + '&grp=' + encodeURIComponent(grp) +
|
||||||
'&sort=' + sort;
|
'&sort=' + sort + '&mode=' + latest + (split ? '&split=' + split : '');
|
||||||
showAlert(exportTip('成绩明细', cls, grp) + ',排序:' + (sort === 'sid' ? '按学号' : '按提交时间'), 'info');
|
location.href = qs;
|
||||||
|
const modeLabel = (latest === 'latest') ? '(每人一行·最新成绩)' : '(每次考试一行)';
|
||||||
|
const splitLabel = (split === 'cls') ? ',按班级分表' : '';
|
||||||
|
showAlert(exportTip('成绩明细', cls, grp) + ',排序:' + sortLabel + splitLabel + modeLabel, 'info');
|
||||||
});
|
});
|
||||||
$('#btnExportAbsent').addEventListener('click', () => {
|
$('#btnExportAbsent').addEventListener('click', () => {
|
||||||
const pid = $('#projectSel').value;
|
const pid = $('#projectSel').value;
|
||||||
@@ -996,7 +1193,10 @@
|
|||||||
opts.forEach(v => { if (Math.abs(v - iv) < Math.abs(best - iv)) best = v; });
|
opts.forEach(v => { if (Math.abs(v - iv) < Math.abs(best - iv)) best = v; });
|
||||||
$('#intervalSel').value = String(best);
|
$('#intervalSel').value = String(best);
|
||||||
schedule();
|
schedule();
|
||||||
|
// 仅连接信息变化时才重新加载项目列表(避免非连接设置保存触发破坏性刷新)
|
||||||
|
if (resp && resp.conn_changed) {
|
||||||
loadProjects();
|
loadProjects();
|
||||||
|
}
|
||||||
// 展示方式可能变化 → 立即重渲染表格(不重新拉数据)
|
// 展示方式可能变化 → 立即重渲染表格(不重新拉数据)
|
||||||
state.historyDisplay = payload.history_display;
|
state.historyDisplay = payload.history_display;
|
||||||
state.exportSort = payload.export_sort;
|
state.exportSort = payload.export_sort;
|
||||||
@@ -1083,21 +1283,70 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
$('#rosterSearch').addEventListener('input', renderRoster);
|
$('#rosterSearch').addEventListener('input', renderRoster);
|
||||||
|
|
||||||
|
// 新增学生:弹出表单,填写后追加(不再直接在表格底部插入空行)
|
||||||
$('#btnAddRow').addEventListener('click', () => {
|
$('#btnAddRow').addEventListener('click', () => {
|
||||||
rosterDraft.push({ sid: '', name: '', cls: '' });
|
$('#addSid').value = ''; $('#addName').value = '';
|
||||||
renderRoster();
|
$('#addCls').value = ''; $('#addGrp').value = ''; $('#addLogin').value = '';
|
||||||
|
$('#addStudentHint').textContent = '';
|
||||||
|
$('#addStudentModal').classList.add('open');
|
||||||
|
$('#addSid').focus();
|
||||||
});
|
});
|
||||||
|
$('#btnAddStudentOk').addEventListener('click', () => {
|
||||||
|
const sid = $('#addSid').value.trim();
|
||||||
|
const name = $('#addName').value.trim();
|
||||||
|
if (!sid || !name) {
|
||||||
|
$('#addStudentHint').textContent = '学号和姓名必填';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 学号去重提示(不强制拦截,仅提醒)
|
||||||
|
if (rosterDraft.some(s => String(s.sid).trim() === sid)) {
|
||||||
|
$('#addStudentHint').textContent = '⚠️ 该学号已存在,仍会添加(请确认)';
|
||||||
|
}
|
||||||
|
rosterDraft.push({
|
||||||
|
sid, name,
|
||||||
|
cls: $('#addCls').value.trim(),
|
||||||
|
grp: $('#addGrp').value.trim(),
|
||||||
|
login: $('#addLogin').value.trim(),
|
||||||
|
});
|
||||||
|
renderRoster();
|
||||||
|
$('#addStudentModal').classList.remove('open');
|
||||||
|
showAlert('已添加学生:' + sid + ' ' + name, 'info');
|
||||||
|
});
|
||||||
|
|
||||||
$('#btnClearRoster').addEventListener('click', async () => {
|
$('#btnClearRoster').addEventListener('click', async () => {
|
||||||
if (!confirm('确定清空全部名单?')) return;
|
if (!confirm('确定清空全部名单?此操作不可撤销。')) return;
|
||||||
try {
|
try {
|
||||||
const d = await api('roster_clear', { method: 'POST' });
|
const d = await api('roster_clear', { method: 'POST' });
|
||||||
rosterDraft = [];
|
rosterDraft = [];
|
||||||
|
rosterSel.clear();
|
||||||
renderRoster();
|
renderRoster();
|
||||||
$('#rosterBadge').textContent = '0';
|
$('#rosterBadge').textContent = '0';
|
||||||
$('#rosterMeta').textContent = '共 0 人';
|
$('#rosterMeta').textContent = '共 0 人';
|
||||||
} catch (e) { showAlert(e.message); }
|
} catch (e) { showAlert(e.message); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 删除选中(多选 + 二次确认)
|
||||||
|
$('#btnDelSel').addEventListener('click', () => {
|
||||||
|
const n = rosterSel.size;
|
||||||
|
if (!n) return;
|
||||||
|
if (!confirm('确定删除选中的 ' + n + ' 名学生?此操作需点击「保存名单」后才会落盘。')) return;
|
||||||
|
const idx = Array.from(rosterSel).sort((a, b) => b - a); // 从大到小删,避免下标错位
|
||||||
|
idx.forEach(i => rosterDraft.splice(i, 1));
|
||||||
|
rosterSel.clear();
|
||||||
|
renderRoster();
|
||||||
|
showAlert('已删除 ' + n + ' 名学生(记得保存名单)', 'info');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 全选 / 取消全选
|
||||||
|
$('#rosterCheckAll').addEventListener('change', e => {
|
||||||
|
rosterSel.clear();
|
||||||
|
if (e.target.checked) {
|
||||||
|
rosterDraft.forEach((_, i) => rosterSel.add(i));
|
||||||
|
}
|
||||||
|
renderRoster();
|
||||||
|
});
|
||||||
|
|
||||||
$('#rosterBody').addEventListener('input', e => {
|
$('#rosterBody').addEventListener('input', e => {
|
||||||
const tr = e.target.closest('tr');
|
const tr = e.target.closest('tr');
|
||||||
if (!tr) return;
|
if (!tr) return;
|
||||||
@@ -1105,11 +1354,15 @@
|
|||||||
if (isNaN(i)) return;
|
if (isNaN(i)) return;
|
||||||
rosterDraft[i][e.target.dataset.f] = e.target.value;
|
rosterDraft[i][e.target.dataset.f] = e.target.value;
|
||||||
});
|
});
|
||||||
$('#rosterBody').addEventListener('click', e => {
|
|
||||||
const id = e.target.getAttribute && e.target.getAttribute('data-del');
|
// 勾选 / 取消勾选(事件委托;input 事件会冒泡到这里时已被上面的 input 处理,这里只处理 checkbox)
|
||||||
if (id == null) return;
|
$('#rosterBody').addEventListener('change', e => {
|
||||||
rosterDraft.splice(parseInt(id, 10), 1);
|
if (!e.target.classList || !e.target.classList.contains('roster-cb')) return;
|
||||||
renderRoster();
|
const i = parseInt(e.target.dataset.i, 10);
|
||||||
|
if (isNaN(i)) return;
|
||||||
|
if (e.target.checked) rosterSel.add(i);
|
||||||
|
else rosterSel.delete(i);
|
||||||
|
updateDelSelBtn();
|
||||||
});
|
});
|
||||||
|
|
||||||
$('#btnSaveRoster').addEventListener('click', async () => {
|
$('#btnSaveRoster').addEventListener('click', async () => {
|
||||||
@@ -1123,6 +1376,223 @@
|
|||||||
} catch (e) { showAlert(e.message); }
|
} catch (e) { showAlert(e.message); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/* 未匹配答卷 → 加入名单(映射到学号) */
|
||||||
|
let mapUmName = '';
|
||||||
|
$('#unmatchedBody').addEventListener('click', e => {
|
||||||
|
const name = e.target.getAttribute && e.target.getAttribute('data-map');
|
||||||
|
if (name == null) return;
|
||||||
|
mapUmName = name;
|
||||||
|
$('#mapUmName').textContent = name;
|
||||||
|
$('#mapUmSid').value = ''; $('#mapUmCls').value = ''; $('#mapUmGrp').value = '';
|
||||||
|
$('#mapUmHint').textContent = '';
|
||||||
|
$('#mapUnmatchedModal').classList.add('open');
|
||||||
|
$('#mapUmSid').focus();
|
||||||
|
});
|
||||||
|
$('#btnMapUmOk').addEventListener('click', async () => {
|
||||||
|
const sid = $('#mapUmSid').value.trim();
|
||||||
|
if (!sid) { $('#mapUmHint').textContent = '请输入学号'; return; }
|
||||||
|
try {
|
||||||
|
const d = await api('unmatched_map', { method: 'POST', body: {
|
||||||
|
name: mapUmName, sid,
|
||||||
|
cls: $('#mapUmCls').value.trim(),
|
||||||
|
grp: $('#mapUmGrp').value.trim(),
|
||||||
|
}});
|
||||||
|
$('#mapUnmatchedModal').classList.remove('open');
|
||||||
|
showAlert('已将「' + mapUmName + '」映射到学号 ' + sid + ' 并加入名单', 'info');
|
||||||
|
if ($('#rosterBadge')) $('#rosterBadge').textContent = (d.students || []).length;
|
||||||
|
// 刷新数据,让该答卷从「未匹配」归位到成绩明细
|
||||||
|
if ($('#projectSel').value) refresh(false, true);
|
||||||
|
} catch (err) { showAlert(err.message); }
|
||||||
|
});
|
||||||
|
|
||||||
|
/* 成绩快照 / 趋势 */
|
||||||
|
function curPid() { return $('#projectSel').value; }
|
||||||
|
|
||||||
|
async function loadSnapshots() {
|
||||||
|
const pid = curPid();
|
||||||
|
if (!pid) return;
|
||||||
|
try {
|
||||||
|
const d = await api('snapshot_list', { params: { projectId: pid } });
|
||||||
|
const list = d.snapshots || [];
|
||||||
|
const tb = $('#snapBody');
|
||||||
|
if (!list.length) {
|
||||||
|
tb.innerHTML = '<tr><td colspan="5" class="empty">暂无快照,点击「保存当前快照」创建第一份</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tb.innerHTML = list.map(s =>
|
||||||
|
'<tr>' +
|
||||||
|
'<td>' + esc(s.label) + '</td>' +
|
||||||
|
'<td class="num">' + esc(s.at_txt) + '</td>' +
|
||||||
|
'<td class="num">' + s.count + '</td>' +
|
||||||
|
'<td>' + esc((s.project && s.project.name) || '') + '</td>' +
|
||||||
|
'<td><span class="link-del" data-snapdel="' + esc(s.file) + '">删除</span></td></tr>').join('');
|
||||||
|
} catch (e) { showAlert(e.message); }
|
||||||
|
}
|
||||||
|
|
||||||
|
$('#btnSnapshot').addEventListener('click', () => {
|
||||||
|
if (!curPid()) return showAlert('请先选择问卷 / 考试');
|
||||||
|
$('#snapLabel').value = '';
|
||||||
|
$('#snapTitle').textContent = '成绩快照 · ' + (state.projectName || '');
|
||||||
|
loadSnapshots();
|
||||||
|
$('#snapshotModal').classList.add('open');
|
||||||
|
});
|
||||||
|
|
||||||
|
$('#btnSnapSave').addEventListener('click', async () => {
|
||||||
|
const pid = curPid();
|
||||||
|
if (!pid) return showAlert('请先选择问卷 / 考试');
|
||||||
|
try {
|
||||||
|
const d = await api('snapshot_save', { method: 'POST', params: { projectId: pid, nameQid: $('#nameFieldSel').value }, body: { label: $('#snapLabel').value.trim() } });
|
||||||
|
showAlert('快照已保存:' + d.label + '(' + d.count + ' 人)', 'info');
|
||||||
|
$('#snapLabel').value = '';
|
||||||
|
loadSnapshots();
|
||||||
|
} catch (e) { showAlert(e.message); }
|
||||||
|
});
|
||||||
|
|
||||||
|
$('#btnSnapRefresh').addEventListener('click', loadSnapshots);
|
||||||
|
|
||||||
|
$('#snapBody').addEventListener('click', async e => {
|
||||||
|
const f = e.target.getAttribute && e.target.getAttribute('data-snapdel');
|
||||||
|
if (f == null) return;
|
||||||
|
if (!confirm('确定删除该快照?此操作不可撤销。')) return;
|
||||||
|
try {
|
||||||
|
await api('snapshot_delete', { method: 'POST', params: { projectId: curPid() }, body: { file: f } });
|
||||||
|
showAlert('快照已删除', 'info');
|
||||||
|
loadSnapshots();
|
||||||
|
} catch (err) { showAlert(err.message); }
|
||||||
|
});
|
||||||
|
|
||||||
|
async function renderTrend() {
|
||||||
|
const pid = curPid();
|
||||||
|
if (!pid) return showAlert('请先选择问卷 / 考试');
|
||||||
|
const body = $('#trendBody'), head = $('#trendHead');
|
||||||
|
body.innerHTML = '<tr><td colspan="10" class="empty">加载中…</td></tr>';
|
||||||
|
try {
|
||||||
|
const d = await api('snapshot_trend', { params: { projectId: pid } });
|
||||||
|
const cols = d.columns || [], rows = d.rows || [];
|
||||||
|
$('#trendSub').textContent = state.projectName || '';
|
||||||
|
if (!cols.length) {
|
||||||
|
head.innerHTML = ''; body.innerHTML = '<tr><td colspan="10" class="empty">还没有快照,请先保存快照</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
head.innerHTML = '<tr><th>学号</th><th>姓名</th><th>班级</th>' +
|
||||||
|
cols.map(c => '<th title="' + esc(c.at_txt) + '">' + esc(c.label) + '</th>').join('') + '</tr>';
|
||||||
|
const colSpan = cols.length + 3;
|
||||||
|
if (!rows.length) {
|
||||||
|
body.innerHTML = '<tr><td colspan="' + colSpan + '" class="empty">无数据</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
body.innerHTML = rows.map(r => {
|
||||||
|
let cells = '<td class="sid">' + esc(r.sid) + '</td><td>' + esc(r.name) + '</td><td>' + esc(r.cls || '') + '</td>';
|
||||||
|
cols.forEach(c => {
|
||||||
|
const v = r.scores[c.key];
|
||||||
|
cells += '<td class="num">' + (v == null ? '—' : v) + '</td>';
|
||||||
|
});
|
||||||
|
return '<tr>' + cells + '</tr>';
|
||||||
|
}).join('');
|
||||||
|
} catch (e) { showAlert(e.message); }
|
||||||
|
}
|
||||||
|
|
||||||
|
$('#btnSnapTrend').addEventListener('click', () => {
|
||||||
|
$('#snapshotModal').classList.remove('open');
|
||||||
|
renderTrend();
|
||||||
|
$('#trendModal').classList.add('open');
|
||||||
|
});
|
||||||
|
$('#btnTrend').addEventListener('click', () => {
|
||||||
|
if (!curPid()) return showAlert('请先选择问卷 / 考试');
|
||||||
|
renderTrend();
|
||||||
|
$('#trendModal').classList.add('open');
|
||||||
|
});
|
||||||
|
|
||||||
|
/* 异常检测线索 */
|
||||||
|
function renderAnomaly() {
|
||||||
|
const a = state.anomalies || { fast_high: [], frequent: [], identical: [] };
|
||||||
|
const box = $('#anomalyBody');
|
||||||
|
const sec = [];
|
||||||
|
const empty = '<span class="muted">无</span>';
|
||||||
|
|
||||||
|
// 1) 短时长高得分
|
||||||
|
sec.push('<h4 style="margin:14px 0 6px">⚡ 用时异常短却得分高(' + a.fast_high.length + ')</h4>');
|
||||||
|
sec.push('<div class="table-scroll short"><table class="grid compact">' +
|
||||||
|
'<thead><tr><th>学号</th><th>姓名</th><th>班级</th><th>成绩</th><th>用时</th></tr></thead><tbody>');
|
||||||
|
if (!a.fast_high.length) sec.push('<tr><td colspan="5" class="empty">' + empty + '</td></tr>');
|
||||||
|
else a.fast_high.forEach(x => {
|
||||||
|
sec.push('<tr><td class="sid">' + esc(x.sid) + '</td><td>' + esc(x.name) + '</td><td>' + esc(x.cls || '') +
|
||||||
|
'</td><td class="num">' + x.score + '</td><td class="num">' + esc(x.duration_txt || (x.duration + 's')) + '</td></tr>');
|
||||||
|
});
|
||||||
|
sec.push('</tbody></table></div>');
|
||||||
|
|
||||||
|
// 2) 高频重考
|
||||||
|
sec.push('<h4 style="margin:14px 0 6px">🔄 高频重考(' + a.frequent.length + ')</h4>');
|
||||||
|
sec.push('<div class="table-scroll short"><table class="grid compact">' +
|
||||||
|
'<thead><tr><th>学号</th><th>姓名</th><th>班级</th><th>考试次数</th><th>最高分</th></tr></thead><tbody>');
|
||||||
|
if (!a.frequent.length) sec.push('<tr><td colspan="5" class="empty">' + empty + '</td></tr>');
|
||||||
|
else a.frequent.forEach(x => {
|
||||||
|
sec.push('<tr><td class="sid">' + esc(x.sid) + '</td><td>' + esc(x.name) + '</td><td>' + esc(x.cls || '') +
|
||||||
|
'</td><td class="num">' + x.attempts + '</td><td class="num">' + (x.best_score == null ? '-' : x.best_score) + '</td></tr>');
|
||||||
|
});
|
||||||
|
sec.push('</tbody></table></div>');
|
||||||
|
|
||||||
|
// 3) 雷同成绩
|
||||||
|
sec.push('<h4 style="margin:14px 0 6px">⚠️ 多人成绩雷同(' + a.identical.length + ' 组)</h4>');
|
||||||
|
sec.push('<div class="table-scroll short"><table class="grid compact">' +
|
||||||
|
'<thead><tr><th>成绩</th><th>人数</th><th>学生</th></tr></thead><tbody>');
|
||||||
|
if (!a.identical.length) sec.push('<tr><td colspan="3" class="empty">' + empty + '</td></tr>');
|
||||||
|
else a.identical.forEach(g => {
|
||||||
|
const names = g.members.map(m => m.name + '(' + m.sid + ')').join('、');
|
||||||
|
sec.push('<tr><td class="num">' + g.score + '</td><td class="num">' + g.count + '</td><td>' + esc(names) + '</td></tr>');
|
||||||
|
});
|
||||||
|
sec.push('</tbody></table></div>');
|
||||||
|
|
||||||
|
box.innerHTML = sec.join('');
|
||||||
|
}
|
||||||
|
$('#btnAnomaly').addEventListener('click', () => {
|
||||||
|
if (!curPid()) return showAlert('请先选择问卷 / 考试');
|
||||||
|
renderAnomaly();
|
||||||
|
$('#anomalyModal').classList.add('open');
|
||||||
|
});
|
||||||
|
|
||||||
|
/* 缺考名单增强:复制学号 / 复制姓名 / 打印 */
|
||||||
|
function copyText(text, what) {
|
||||||
|
const done = () => showAlert(what + '已复制到剪贴板', 'info');
|
||||||
|
const fail = () => showAlert('复制失败,请手动复制', 'error');
|
||||||
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||||
|
navigator.clipboard.writeText(text).then(done, fail);
|
||||||
|
} else {
|
||||||
|
// 兜底:临时 textarea + execCommand
|
||||||
|
const ta = document.createElement('textarea');
|
||||||
|
ta.value = text; ta.style.position = 'fixed'; ta.style.opacity = '0';
|
||||||
|
document.body.appendChild(ta); ta.select();
|
||||||
|
try { document.execCommand('copy'); done(); } catch (e) { fail(); }
|
||||||
|
document.body.removeChild(ta);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$('#btnCopySids').addEventListener('click', () => {
|
||||||
|
const list = state._absentList || [];
|
||||||
|
if (!list.length) return showAlert('当前没有缺考学生', 'warn');
|
||||||
|
copyText(list.map(a => a.sid).join(','), '学号');
|
||||||
|
});
|
||||||
|
$('#btnCopyNames').addEventListener('click', () => {
|
||||||
|
const list = state._absentList || [];
|
||||||
|
if (!list.length) return showAlert('当前没有缺考学生', 'warn');
|
||||||
|
copyText(list.map(a => a.name).join('、'), '姓名');
|
||||||
|
});
|
||||||
|
$('#btnPrintAbsent').addEventListener('click', () => {
|
||||||
|
const list = state._absentList || [];
|
||||||
|
if (!list.length) return showAlert('当前没有缺考学生', 'warn');
|
||||||
|
const rows = list.map(a =>
|
||||||
|
'<tr><td>' + esc(a.sid) + '</td><td>' + esc(a.name) + '</td><td>' + esc(a.cls || '') + '</td><td>' + esc(a.grp || '') + '</td></tr>').join('');
|
||||||
|
const project = state.projectName || '';
|
||||||
|
const title = (project ? project + ' · ' : '') + '缺考名单(' + list.length + ' 人)';
|
||||||
|
const w = window.open('', '_blank');
|
||||||
|
if (!w) return showAlert('浏览器拦截了弹窗,请允许后重试', 'error');
|
||||||
|
w.document.write('<!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8"><title>' + esc(title) + '</title>' +
|
||||||
|
'<style>body{font-family:sans-serif;margin:24px}h2{margin:0 0 8px}table{border-collapse:collapse;width:100%}td,th{border:1px solid #333;padding:6px 10px;text-align:left;font-size:13px}th{background:#eee}</style></head><body>' +
|
||||||
|
'<h2>' + esc(title) + '</h2><p>打印时间:' + new Date().toLocaleString() + '</p>' +
|
||||||
|
'<table><thead><tr><th>学号</th><th>姓名</th><th>班级</th><th>组别</th></tr></thead><tbody>' + rows + '</tbody></table>' +
|
||||||
|
'<script>window.onload=function(){setTimeout(function(){window.print()},300)}<\/script></body></html>');
|
||||||
|
w.document.close();
|
||||||
|
});
|
||||||
|
|
||||||
/* 启动 */
|
/* 启动 */
|
||||||
// 1) 恢复布局偏好(仅样式,不涉及数据)
|
// 1) 恢复布局偏好(仅样式,不涉及数据)
|
||||||
applyLayout(LS.get(LS_LAYOUT, 'default'));
|
applyLayout(LS.get(LS_LAYOUT, 'default'));
|
||||||
@@ -1158,8 +1628,9 @@
|
|||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const c = await api('config_get');
|
const c = await api('config_get');
|
||||||
|
if (c.csrf_token) state.csrf = c.csrf_token; // 提前拿到 CSRF token,写请求才能通过校验
|
||||||
state.historyDisplay = (c.history_display === 'inline') ? 'inline' : 'click';
|
state.historyDisplay = (c.history_display === 'inline') ? 'inline' : 'click';
|
||||||
state.exportSort = (c.export_sort === 'sid') ? 'sid' : 'time';
|
state.exportSort = (c.export_sort === 'sid') ? 'sid' : ((c.export_sort === 'score_desc') ? 'score_desc' : ((c.export_sort === 'score_asc') ? 'score_asc' : 'time'));
|
||||||
if ($('#cfgHistoryDisplay')) $('#cfgHistoryDisplay').value = state.historyDisplay;
|
if ($('#cfgHistoryDisplay')) $('#cfgHistoryDisplay').value = state.historyDisplay;
|
||||||
if ($('#cfgExportSort')) $('#cfgExportSort').value = state.exportSort;
|
if ($('#cfgExportSort')) $('#cfgExportSort').value = state.exportSort;
|
||||||
if ($('#exportSortSel')) $('#exportSortSel').value = state.exportSort;
|
if ($('#exportSortSel')) $('#exportSortSel').value = state.exportSort;
|
||||||
|
|||||||
+26
-5
@@ -57,12 +57,33 @@
|
|||||||
return j.data;
|
return j.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------------- Toast 顶部飘窗通知(与 app.js 一致) ---------------- */
|
||||||
|
const TOAST_DURATION = 3500;
|
||||||
|
const TOAST_LIMIT = 5;
|
||||||
|
function showToast(msg, type) {
|
||||||
|
if (msg === '' || msg == null) return;
|
||||||
|
const el = document.getElementById('toastContainer') || (function () {
|
||||||
|
const c = document.createElement('div');
|
||||||
|
c.id = 'toastContainer';
|
||||||
|
c.style.cssText = 'position:fixed;top:16px;left:50%;transform:translateX(-50%);z-index:9999;display:flex;flex-direction:column;gap:8px;pointer-events:none;max-width:calc(100vw - 32px);width:560px';
|
||||||
|
document.body.appendChild(c);
|
||||||
|
return c;
|
||||||
|
})();
|
||||||
|
while (el.children.length >= TOAST_LIMIT) el.firstChild.remove();
|
||||||
|
const t = document.createElement('div');
|
||||||
|
const cls = type === 'error' ? 'toast-error' : (type === 'warn' ? 'toast-warn' : (type === 'info' ? 'toast-ok' : 'toast'));
|
||||||
|
t.className = 'toast ' + cls;
|
||||||
|
t.innerHTML = '<span>' + esc(msg).replace(/\n/g, '<br>') + '</span>';
|
||||||
|
t.style.cssText = 'pointer-events:auto;padding:10px 16px;border-radius:10px;font-size:13px;line-height:1.55;box-shadow:0 8px 30px rgba(15,23,42,.18);animation:toastIn .25s ease;white-space:pre-wrap;word-break:break-word';
|
||||||
|
el.appendChild(t);
|
||||||
|
t.addEventListener('click', () => { t.style.animation = 'toastOut .2s ease forwards'; setTimeout(() => t.remove(), 200); });
|
||||||
|
setTimeout(() => { if (t.parentNode) { t.style.animation = 'toastOut .2s ease forwards'; setTimeout(() => t.remove(), 200); } }, TOAST_DURATION);
|
||||||
|
}
|
||||||
|
/** 兼容旧 showAlert 调用 → 走 toast(draw.js 内部已有 esc,此处直接复用) */
|
||||||
function showAlert(msg, type) {
|
function showAlert(msg, type) {
|
||||||
const el = $('#alert');
|
if (msg === '' || msg == null) return;
|
||||||
if (!el) return;
|
const t = type === 'info' ? 'ok' : (type === 'error' ? 'error' : '');
|
||||||
if (!msg) { el.classList.add('hidden'); return; }
|
showToast(msg, t);
|
||||||
el.textContent = msg;
|
|
||||||
el.className = 'alert' + (type === 'info' ? ' info' : '');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ================= 抽取核心(纯函数,供测试) ================= */
|
/* ================= 抽取核心(纯函数,供测试) ================= */
|
||||||
|
|||||||
@@ -117,6 +117,7 @@ body {
|
|||||||
.btn-primary { background: var(--primary); border-color: var(--primary); color: #fff; }
|
.btn-primary { background: var(--primary); border-color: var(--primary); color: #fff; }
|
||||||
.btn-primary:hover { background: #2559db; border-color: #2559db; }
|
.btn-primary:hover { background: #2559db; border-color: #2559db; }
|
||||||
.btn.sm { padding: 5px 10px; font-size: 12px; }
|
.btn.sm { padding: 5px 10px; font-size: 12px; }
|
||||||
|
.btn.xs { padding: 3px 8px; font-size: 11px; }
|
||||||
.btn.danger { color: var(--danger); border-color: #f3cfcb; }
|
.btn.danger { color: var(--danger); border-color: #f3cfcb; }
|
||||||
.btn.danger:hover { background: var(--danger-weak); }
|
.btn.danger:hover { background: var(--danger-weak); }
|
||||||
.btn b {
|
.btn b {
|
||||||
@@ -289,6 +290,9 @@ table.grid th.sortable { cursor: pointer; user-select: none; }
|
|||||||
table.grid th.sortable:hover { color: var(--primary); }
|
table.grid th.sortable:hover { color: var(--primary); }
|
||||||
table.grid th .arrow { color: var(--primary); margin-left: 3px; }
|
table.grid th .arrow { color: var(--primary); margin-left: 3px; }
|
||||||
table.grid td { padding: 10px 14px; border-bottom: 1px solid var(--border-2); }
|
table.grid td { padding: 10px 14px; border-bottom: 1px solid var(--border-2); }
|
||||||
|
/* 虚拟滚动占位行:不占视觉空间、不响应 hover/条纹、不显示边框 */
|
||||||
|
table.grid tbody tr.vspacer td { padding: 0; border: 0; background: transparent !important; }
|
||||||
|
table.grid tbody tr.vspacer:hover td { background: transparent !important; }
|
||||||
table.grid tbody tr:nth-child(even) td { background: #fbfcff; }
|
table.grid tbody tr:nth-child(even) td { background: #fbfcff; }
|
||||||
table.grid tbody tr:hover td { background: var(--primary-weak); }
|
table.grid tbody tr:hover td { background: var(--primary-weak); }
|
||||||
table.grid.compact td, table.grid.compact th { padding: 7px 12px; }
|
table.grid.compact td, table.grid.compact th { padding: 7px 12px; }
|
||||||
@@ -303,6 +307,23 @@ td.score { font-weight: 700; font-variant-numeric: tabular-nums; }
|
|||||||
.diff { animation: flash 1.6s ease-out; }
|
.diff { animation: flash 1.6s ease-out; }
|
||||||
@keyframes flash { from { background: #fff6d9; } to { background: transparent; } }
|
@keyframes flash { from { background: #fff6d9; } to { background: transparent; } }
|
||||||
|
|
||||||
|
/* ---------- Toast 顶部飘窗通知 ---------- */
|
||||||
|
.toast {
|
||||||
|
background: linear-gradient(135deg, #2f6bff, #5b8bff);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.toast-ok { background: linear-gradient(135deg, #12a86a, #3ec99a); color: #fff; }
|
||||||
|
.toast-error{ background: linear-gradient(135deg, #e0483d, #ef6c64); color: #fff; }
|
||||||
|
.toast-warn { background: linear-gradient(135deg, #e08c1a, #f0a54a); color: #fff; }
|
||||||
|
@keyframes toastIn {
|
||||||
|
from { opacity: 0; transform: translateY(-12px) scale(.96); }
|
||||||
|
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||||
|
}
|
||||||
|
@keyframes toastOut {
|
||||||
|
from { opacity: 1; transform: translateY(0) scale(1); }
|
||||||
|
to { opacity: 0; transform: translateY(-8px) scale(.96); }
|
||||||
|
}
|
||||||
|
|
||||||
/* 新数据绿色底纹:常亮(不再闪烁),由"清除高亮"或切换项目/姓名题清除 */
|
/* 新数据绿色底纹:常亮(不再闪烁),由"清除高亮"或切换项目/姓名题清除 */
|
||||||
table.grid tbody tr.new td { background: #e7f7ee; }
|
table.grid tbody tr.new td { background: #e7f7ee; }
|
||||||
table.grid tbody tr.new:nth-child(even) td { background: #e7f7ee; }
|
table.grid tbody tr.new:nth-child(even) td { background: #e7f7ee; }
|
||||||
@@ -364,6 +385,7 @@ table.grid tbody tr.new.clickable:hover td { background: #d6f2e2; }
|
|||||||
.fi { display: flex; flex-direction: column; gap: 6px; }
|
.fi { display: flex; flex-direction: column; gap: 6px; }
|
||||||
.fi.full { grid-column: 1 / -1; }
|
.fi.full { grid-column: 1 / -1; }
|
||||||
.fi label { font-size: 12px; font-weight: 600; color: var(--text-2); }
|
.fi label { font-size: 12px; font-weight: 600; color: var(--text-2); }
|
||||||
|
.fi label .req { color: var(--danger, #e5484d); margin-left: 2px; }
|
||||||
.fi-inline { display: flex; flex-wrap: wrap; gap: 16px; align-items: center; }
|
.fi-inline { display: flex; flex-wrap: wrap; gap: 16px; align-items: center; }
|
||||||
.fi-inline .chk { font-weight: 500; font-size: 13px; color: var(--text); }
|
.fi-inline .chk { font-weight: 500; font-size: 13px; color: var(--text); }
|
||||||
.fullscores { display: flex; flex-direction: column; gap: 6px; }
|
.fullscores { display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
|||||||
+115
@@ -36,6 +36,30 @@ if ($missing !== []) {
|
|||||||
date_default_timezone_set('Asia/Shanghai');
|
date_default_timezone_set('Asia/Shanghai');
|
||||||
mb_internal_encoding('UTF-8');
|
mb_internal_encoding('UTF-8');
|
||||||
|
|
||||||
|
// ---------------- 内存上限(数据接口可能处理大量答卷)----------------
|
||||||
|
// 拉取卷王全量答卷做 json_decode 时极易触顶 128M;在未被禁用 / 未被 php_admin_value 硬锁的前提下
|
||||||
|
// 临时放宽到 512M。若主机用 php_admin_value 锁定了 memory_limit,下面的 ini_set 会静默失效,
|
||||||
|
// 需要改主机 php.ini(或 fpm 池配置)才能生效。
|
||||||
|
(function (): void {
|
||||||
|
$cur = (string) ini_get('memory_limit');
|
||||||
|
if ($cur === '' || $cur === '-1') {
|
||||||
|
return; // 未设置 / 已无限制,无需处理
|
||||||
|
}
|
||||||
|
$curBytes = (int) $cur;
|
||||||
|
$unit = strtoupper(substr($cur, -1));
|
||||||
|
if ($unit === 'G') {
|
||||||
|
$curBytes *= 1024 * 1024 * 1024;
|
||||||
|
} elseif ($unit === 'M') {
|
||||||
|
$curBytes *= 1024 * 1024;
|
||||||
|
} elseif ($unit === 'K') {
|
||||||
|
$curBytes *= 1024;
|
||||||
|
}
|
||||||
|
$want = 512 * 1024 * 1024;
|
||||||
|
if ($curBytes > 0 && $curBytes < $want) {
|
||||||
|
@ini_set('memory_limit', '512M');
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
// ---------------- 会话 ----------------
|
// ---------------- 会话 ----------------
|
||||||
function ny_session_start(): void
|
function ny_session_start(): void
|
||||||
{
|
{
|
||||||
@@ -80,6 +104,91 @@ function ny_logout(): void
|
|||||||
session_destroy();
|
session_destroy();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------- CSRF 防护 ----------------
|
||||||
|
/**
|
||||||
|
* 会话绑定的 CSRF token:写操作(POST)必须携带,防止跨站请求伪造。
|
||||||
|
* 前端从 config_get / auth_check 响应里拿到 csrf_token,后续写请求附在
|
||||||
|
* X-CSRF-Token 请求头或 body 的 _csrf 字段。
|
||||||
|
*/
|
||||||
|
function ny_csrf_token(): string
|
||||||
|
{
|
||||||
|
ny_session_start();
|
||||||
|
if (empty($_SESSION['ny_csrf']) || !is_string($_SESSION['ny_csrf'])) {
|
||||||
|
$_SESSION['ny_csrf'] = bin2hex(random_bytes(16));
|
||||||
|
}
|
||||||
|
return $_SESSION['ny_csrf'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 校验 CSRF token(写操作调用)。失败返回 false 并应拒绝请求。 */
|
||||||
|
function ny_csrf_ok(): bool
|
||||||
|
{
|
||||||
|
$given = '';
|
||||||
|
if (!empty($_SERVER['HTTP_X_CSRF_TOKEN'])) {
|
||||||
|
$given = (string)$_SERVER['HTTP_X_CSRF_TOKEN'];
|
||||||
|
} elseif (!empty($_POST['_csrf'])) {
|
||||||
|
$given = (string)$_POST['_csrf'];
|
||||||
|
} else {
|
||||||
|
// JSON body 里的 _csrf(api.php 写接口多走 JSON)
|
||||||
|
$raw = (string)file_get_contents('php://input');
|
||||||
|
if ($raw !== '') {
|
||||||
|
$j = json_decode($raw, true);
|
||||||
|
if (is_array($j) && isset($j['_csrf'])) {
|
||||||
|
$given = (string)$j['_csrf'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$token = ny_csrf_token();
|
||||||
|
return $given !== '' && hash_equals($token, $given);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- 登录失败限流 ----------------
|
||||||
|
/**
|
||||||
|
* 登录失败限流:按 IP 记录连续失败次数与时间戳(写入 data/login_fail.json)。
|
||||||
|
* 5 次失败后锁 10 分钟。仅防御暴力破解,不做超强语义(单机小工具足够)。
|
||||||
|
*/
|
||||||
|
function ny_login_throttle_check(): array
|
||||||
|
{
|
||||||
|
$path = NY_DATA . '/login_fail.json';
|
||||||
|
$ip = (string)($_SERVER['REMOTE_ADDR'] ?? 'unknown');
|
||||||
|
$rec = ['count' => 0, 'until' => 0];
|
||||||
|
if (is_file($path)) {
|
||||||
|
$j = json_decode((string)file_get_contents($path), true);
|
||||||
|
if (is_array($j) && isset($j[$ip])) {
|
||||||
|
$rec = array_merge($rec, $j[$ip]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $rec;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ny_login_throttle_hit(): void
|
||||||
|
{
|
||||||
|
$path = NY_DATA . '/login_fail.json';
|
||||||
|
$ip = (string)($_SERVER['REMOTE_ADDR'] ?? 'unknown');
|
||||||
|
$all = is_file($path) ? (json_decode((string)file_get_contents($path), true) ?: []) : [];
|
||||||
|
$rec = isset($all[$ip]) ? $all[$ip] : ['count' => 0, 'until' => 0];
|
||||||
|
$rec['count'] = ((int)($rec['count'] ?? 0)) + 1;
|
||||||
|
if ($rec['count'] >= 5) {
|
||||||
|
$rec['count'] = 0; // 重新计数,进入冷却
|
||||||
|
$rec['until'] = time() + 600; // 锁 10 分钟
|
||||||
|
}
|
||||||
|
$all[$ip] = $rec;
|
||||||
|
@file_put_contents($path, json_encode($all, JSON_UNESCAPED_UNICODE), LOCK_EX);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ny_login_throttle_reset(): void
|
||||||
|
{
|
||||||
|
$path = NY_DATA . '/login_fail.json';
|
||||||
|
$ip = (string)($_SERVER['REMOTE_ADDR'] ?? 'unknown');
|
||||||
|
if (!is_file($path)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$all = json_decode((string)file_get_contents($path), true) ?: [];
|
||||||
|
if (isset($all[$ip])) {
|
||||||
|
unset($all[$ip]);
|
||||||
|
@file_put_contents($path, json_encode($all, JSON_UNESCAPED_UNICODE), LOCK_EX);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** 判断是否已安装(只看文件 config:base_url 非空即视为已具备连接配置) */
|
/** 判断是否已安装(只看文件 config:base_url 非空即视为已具备连接配置) */
|
||||||
function ny_is_installed(): bool
|
function ny_is_installed(): bool
|
||||||
{
|
{
|
||||||
@@ -124,6 +233,10 @@ function ny_default_config(): array
|
|||||||
'history_display' => 'click',
|
'history_display' => 'click',
|
||||||
// 成绩导出排序:time=按提交时间降序(默认);sid=按学号(同学号内按提交时间降序)
|
// 成绩导出排序:time=按提交时间降序(默认);sid=按学号(同学号内按提交时间降序)
|
||||||
'export_sort' => 'time',
|
'export_sort' => 'time',
|
||||||
|
// 数据结果缓存秒数:0=每次实时拉取;建议 10~30。
|
||||||
|
// 拉取卷王全量答卷是重操作(多次 HTTP + 大量解码),缓存可避免多人同时刷新时重复打卷王。
|
||||||
|
// 名单一变指纹就变会自动失效;点"立即刷新"也会 force=1 跳过缓存。
|
||||||
|
'cache_ttl' => 15,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -306,6 +419,8 @@ spl_autoload_register(function (string $class): void {
|
|||||||
'SurveyKingClient' => NY_ROOT . '/lib/SurveyKingClient.php',
|
'SurveyKingClient' => NY_ROOT . '/lib/SurveyKingClient.php',
|
||||||
'Roster' => NY_ROOT . '/lib/Roster.php',
|
'Roster' => NY_ROOT . '/lib/Roster.php',
|
||||||
'DataService' => NY_ROOT . '/lib/DataService.php',
|
'DataService' => NY_ROOT . '/lib/DataService.php',
|
||||||
|
'DataCache' => NY_ROOT . '/lib/DataCache.php',
|
||||||
|
'Snapshot' => NY_ROOT . '/lib/Snapshot.php',
|
||||||
'Db' => NY_ROOT . '/lib/Db.php',
|
'Db' => NY_ROOT . '/lib/Db.php',
|
||||||
];
|
];
|
||||||
if (isset($map[$class]) && is_file($map[$class])) {
|
if (isset($map[$class]) && is_file($map[$class])) {
|
||||||
|
|||||||
@@ -89,8 +89,6 @@ $rosterCount = Roster::count();
|
|||||||
<div class="field full hidden" id="ratioBox" style="grid-column:1/-1"></div>
|
<div class="field full hidden" id="ratioBox" style="grid-column:1/-1"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div id="alert" class="alert hidden"></div>
|
|
||||||
|
|
||||||
<section class="cards" id="statCards">
|
<section class="cards" id="statCards">
|
||||||
<div class="card accent-blue"><span>候选人数</span><b id="dPool">-</b></div>
|
<div class="card accent-blue"><span>候选人数</span><b id="dPool">-</b></div>
|
||||||
<div class="card accent-green"><span>本次抽取</span><b id="dPicked">-</b></div>
|
<div class="card accent-green"><span>本次抽取</span><b id="dPicked">-</b></div>
|
||||||
|
|||||||
@@ -79,20 +79,29 @@ $rosterCount = Roster::count();
|
|||||||
<label>搜索</label>
|
<label>搜索</label>
|
||||||
<input type="text" id="kw" placeholder="输入学号或姓名筛选">
|
<input type="text" id="kw" placeholder="输入学号或姓名筛选">
|
||||||
</div>
|
</div>
|
||||||
<div class="field btns">
|
<div class="field btns" style="flex-wrap:wrap;gap:8px">
|
||||||
<label style="display:flex;align-items:center;gap:6px;font-size:12px;color:var(--text-2)">导出排序
|
<label style="display:flex;align-items:center;gap:6px;font-size:12px;color:var(--text-2)">导出排序
|
||||||
<select id="exportSortSel" style="height:30px;border-radius:8px;border:1px solid var(--border);padding:0 6px;background:#fff">
|
<select id="exportSortSel" style="height:30px;border-radius:8px;border:1px solid var(--border);padding:0 6px;background:#fff">
|
||||||
<option value="time">按提交时间</option>
|
<option value="time">按提交时间</option>
|
||||||
<option value="sid">按学号</option>
|
<option value="sid">按学号</option>
|
||||||
|
<option value="score_desc">按成绩降序</option>
|
||||||
|
<option value="score_asc">按成绩升序</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
<label style="display:flex;align-items:center;gap:4px;font-size:12px;color:var(--text-2);cursor:pointer" title="每人只导一行:最近一次成绩 + 最高分 + 考试次数">
|
||||||
|
<input type="checkbox" id="exportLatest" style="width:auto;height:auto">按最新成绩
|
||||||
|
</label>
|
||||||
|
<label style="display:flex;align-items:center;gap:4px;font-size:12px;color:var(--text-2);cursor:pointer" title="按班级拆分多个工作表(xlsx 多表)">
|
||||||
|
<input type="checkbox" id="exportSplit" style="width:auto;height:auto">按班级分表
|
||||||
|
</label>
|
||||||
<button class="btn" id="btnExport">导出成绩</button>
|
<button class="btn" id="btnExport">导出成绩</button>
|
||||||
<button class="btn" id="btnExportAbsent">导出缺考</button>
|
<button class="btn" id="btnExportAbsent">导出缺考</button>
|
||||||
|
<button class="btn" id="btnSnapshot" title="保存当前成绩为一份带标签的时间点快照">存快照</button>
|
||||||
|
<button class="btn" id="btnTrend" title="查看本卷各快照间每名学生的成绩趋势">趋势</button>
|
||||||
|
<button class="btn" id="btnAnomaly" title="异常检测线索:短时长高分 / 高频重考 / 雷同成绩">异常</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div id="alert" class="alert hidden"></div>
|
|
||||||
|
|
||||||
<section class="cards" id="statCards">
|
<section class="cards" id="statCards">
|
||||||
<div class="card accent-blue"><span>名单总人数</span><b id="sTotal">-</b></div>
|
<div class="card accent-blue"><span>名单总人数</span><b id="sTotal">-</b></div>
|
||||||
<div class="card ok accent-green"><span>已提交</span><b id="sSubmit">-</b></div>
|
<div class="card ok accent-green"><span>已提交</span><b id="sSubmit">-</b></div>
|
||||||
@@ -133,9 +142,15 @@ $rosterCount = Roster::count();
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel" id="absentPanel">
|
<section class="panel" id="absentPanel">
|
||||||
<div class="panel-head"><h2>缺考名单 <small id="absentHint"></small></h2></div>
|
<div class="panel-head">
|
||||||
|
<h2>缺考名单 <small id="absentHint"></small></h2>
|
||||||
|
<span class="spacer"></span>
|
||||||
|
<button class="btn sm" id="btnCopySids" title="复制全部缺考学号(逗号分隔)">复制学号</button>
|
||||||
|
<button class="btn sm" id="btnCopyNames" title="复制全部缺考姓名">复制姓名</button>
|
||||||
|
<button class="btn sm" id="btnPrintAbsent" title="打印缺考名单">打印</button>
|
||||||
|
</div>
|
||||||
<div class="table-scroll short">
|
<div class="table-scroll short">
|
||||||
<table class="grid compact">
|
<table class="grid compact" id="absentTable">
|
||||||
<thead><tr><th>学号</th><th>姓名</th><th>班级</th><th>组别</th></tr></thead>
|
<thead><tr><th>学号</th><th>姓名</th><th>班级</th><th>组别</th></tr></thead>
|
||||||
<tbody id="absentBody"><tr><td colspan="4" class="empty">—</td></tr></tbody>
|
<tbody id="absentBody"><tr><td colspan="4" class="empty">—</td></tr></tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -146,8 +161,8 @@ $rosterCount = Roster::count();
|
|||||||
<div class="panel-head"><h2>未匹配答卷 <small>姓名不在名单中</small></h2></div>
|
<div class="panel-head"><h2>未匹配答卷 <small>姓名不在名单中</small></h2></div>
|
||||||
<div class="table-scroll short">
|
<div class="table-scroll short">
|
||||||
<table class="grid compact">
|
<table class="grid compact">
|
||||||
<thead><tr><th>姓名</th><th>成绩</th><th>提交时间</th></tr></thead>
|
<thead><tr><th>姓名</th><th>成绩</th><th>提交时间</th><th style="width:90px">操作</th></tr></thead>
|
||||||
<tbody id="unmatchedBody"><tr><td colspan="3" class="empty">—</td></tr></tbody>
|
<tbody id="unmatchedBody"><tr><td colspan="4" class="empty">—</td></tr></tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -178,7 +193,8 @@ $rosterCount = Roster::count();
|
|||||||
</div>
|
</div>
|
||||||
<div class="fi">
|
<div class="fi">
|
||||||
<label>每页拉取条数</label>
|
<label>每页拉取条数</label>
|
||||||
<input type="number" id="cfgPageSize" min="20" max="1000">
|
<input type="number" id="cfgPageSize" min="20" max="200">
|
||||||
|
<p class="hint">实际生效上限 200,超出会自动夹到 200(防止单页内存耗尽)</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="fi">
|
<div class="fi">
|
||||||
<label>默认满分(用于合格率)</label>
|
<label>默认满分(用于合格率)</label>
|
||||||
@@ -198,6 +214,8 @@ $rosterCount = Roster::count();
|
|||||||
<select id="cfgExportSort">
|
<select id="cfgExportSort">
|
||||||
<option value="time">按提交时间(默认)</option>
|
<option value="time">按提交时间(默认)</option>
|
||||||
<option value="sid">按学号(同学号按提交时间)</option>
|
<option value="sid">按学号(同学号按提交时间)</option>
|
||||||
|
<option value="score_desc">按成绩降序</option>
|
||||||
|
<option value="score_asc">按成绩升序</option>
|
||||||
</select>
|
</select>
|
||||||
<p class="hint">导出包含每次考试记录,始终带「考试次数」列</p>
|
<p class="hint">导出包含每次考试记录,始终带「考试次数」列</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -209,9 +227,11 @@ $rosterCount = Roster::count();
|
|||||||
<div class="fi full">
|
<div class="fi full">
|
||||||
<label>各试卷独立满分</label>
|
<label>各试卷独立满分</label>
|
||||||
<div id="fullScoresBox" class="fullscores">
|
<div id="fullScoresBox" class="fullscores">
|
||||||
<span class="hint" id="fullScoresHint">未选中试卷时,此处为空;选中某套试卷后保存即在设置中为该卷单独设定满分</span>
|
<span class="hint">未选中试卷时,此处为空;选中某套试卷后保存即在设置中为该卷单独设定满分</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="hint">给某卷填入数字即可单独覆盖默认满分;留空=用默认</p>
|
<!-- 注意:#fullScoresHint 必须放在 #fullScoresBox 之外,
|
||||||
|
否则 renderFullScores() 的 box.innerHTML 覆盖会把它销毁,导致 textContent 赋值报 null -->
|
||||||
|
<p class="hint" id="fullScoresHint">给某卷填入数字即可单独覆盖默认满分;留空=用默认</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="fi full checks">
|
<div class="fi full checks">
|
||||||
<label><input type="checkbox" id="cfgDedupe"> 同一学号只保留最新一份答卷</label>
|
<label><input type="checkbox" id="cfgDedupe"> 同一学号只保留最新一份答卷</label>
|
||||||
@@ -219,9 +239,8 @@ $rosterCount = Roster::count();
|
|||||||
<label><input type="checkbox" id="cfgSsl"> 校验 HTTPS 证书</label>
|
<label><input type="checkbox" id="cfgSsl"> 校验 HTTPS 证书</label>
|
||||||
</div>
|
</div>
|
||||||
<p class="hint" style="margin-top:-4px">
|
<p class="hint" style="margin-top:-4px">
|
||||||
<b>「只保留最新一份」务必注意</b>:勾选后每人只算最后一次提交,
|
<b>「只保留最新一份」说明</b>:勾选后主明细表的「成绩」列取该生最新一次提交的分数,
|
||||||
「考试次数」恒为 1、历史成绩只有一条。<b>想看考试次数与多次考试记录,请取消勾选</b>
|
取消勾选则取最高分。<b>无论是否勾选,「考试次数」与「历史成绩」始终展示全部提交记录</b>。
|
||||||
(取消后每人一行,成绩取最高分,历史里可看到每一次)。
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="fi full" style="margin-top:6px">
|
<div class="fi full" style="margin-top:6px">
|
||||||
@@ -277,12 +296,13 @@ $rosterCount = Roster::count();
|
|||||||
<input type="text" id="rosterSearch" placeholder="搜索学号 / 姓名">
|
<input type="text" id="rosterSearch" placeholder="搜索学号 / 姓名">
|
||||||
<span class="spacer"></span>
|
<span class="spacer"></span>
|
||||||
<button class="btn sm" id="btnAddRow">+ 新增</button>
|
<button class="btn sm" id="btnAddRow">+ 新增</button>
|
||||||
|
<button class="btn sm danger" id="btnDelSel" hidden>删除选中</button>
|
||||||
<button class="btn sm danger" id="btnClearRoster">清空</button>
|
<button class="btn sm danger" id="btnClearRoster">清空</button>
|
||||||
<button class="btn sm btn-primary" id="btnSaveRoster">保存名单</button>
|
<button class="btn sm btn-primary" id="btnSaveRoster">保存名单</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="table-scroll mid">
|
<div class="table-scroll mid">
|
||||||
<table class="grid compact" id="rosterTable">
|
<table class="grid compact" id="rosterTable">
|
||||||
<thead><tr><th style="width:28%">学号</th><th style="width:22%">姓名</th><th>班级</th><th>组别</th><th style="width:70px">操作</th></tr></thead>
|
<thead><tr><th style="width:34px"><input type="checkbox" id="rosterCheckAll" title="全选"></th><th style="width:24%">学号</th><th style="width:20%">姓名</th><th>班级</th><th>组别</th><th>登陆账号</th></tr></thead>
|
||||||
<tbody id="rosterBody"></tbody>
|
<tbody id="rosterBody"></tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -303,6 +323,133 @@ $rosterCount = Roster::count();
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 新增学生弹窗 -->
|
||||||
|
<div class="modal" id="addStudentModal">
|
||||||
|
<div class="modal-box">
|
||||||
|
<div class="modal-head"><h3>新增学生</h3><button class="x" data-close>×</button></div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="fi">
|
||||||
|
<label>学号 <b class="req">*</b></label>
|
||||||
|
<input type="text" id="addSid" placeholder="必填">
|
||||||
|
</div>
|
||||||
|
<div class="fi">
|
||||||
|
<label>姓名 <b class="req">*</b></label>
|
||||||
|
<input type="text" id="addName" placeholder="必填">
|
||||||
|
</div>
|
||||||
|
<div class="fi">
|
||||||
|
<label>班级</label>
|
||||||
|
<input type="text" id="addCls" placeholder="可选">
|
||||||
|
</div>
|
||||||
|
<div class="fi">
|
||||||
|
<label>组别</label>
|
||||||
|
<input type="text" id="addGrp" placeholder="如 A / B,可选">
|
||||||
|
</div>
|
||||||
|
<div class="fi">
|
||||||
|
<label>登陆账号</label>
|
||||||
|
<input type="text" id="addLogin" placeholder="可选,用于抽取页登录">
|
||||||
|
</div>
|
||||||
|
<p class="hint" id="addStudentHint"></p>
|
||||||
|
</div>
|
||||||
|
<div class="modal-foot">
|
||||||
|
<span class="spacer"></span>
|
||||||
|
<button class="btn" data-close>取消</button>
|
||||||
|
<button class="btn btn-primary" id="btnAddStudentOk">添加</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 未匹配答卷映射到名单 -->
|
||||||
|
<div class="modal" id="mapUnmatchedModal">
|
||||||
|
<div class="modal-box">
|
||||||
|
<div class="modal-head"><h3>加入名单</h3><button class="x" data-close>×</button></div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p class="hint" style="margin:0 0 12px">答卷姓名:<b id="mapUmName"></b>(不在名单中)</p>
|
||||||
|
<div class="fi">
|
||||||
|
<label>学号 <b class="req">*</b></label>
|
||||||
|
<input type="text" id="mapUmSid" placeholder="输入该生的学号">
|
||||||
|
</div>
|
||||||
|
<div class="fi">
|
||||||
|
<label>班级</label>
|
||||||
|
<input type="text" id="mapUmCls" placeholder="可选">
|
||||||
|
</div>
|
||||||
|
<div class="fi">
|
||||||
|
<label>组别</label>
|
||||||
|
<input type="text" id="mapUmGrp" placeholder="可选">
|
||||||
|
</div>
|
||||||
|
<p class="hint" id="mapUmHint"></p>
|
||||||
|
</div>
|
||||||
|
<div class="modal-foot">
|
||||||
|
<span class="spacer"></span>
|
||||||
|
<button class="btn" data-close>取消</button>
|
||||||
|
<button class="btn btn-primary" id="btnMapUmOk">加入名单</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 成绩快照 / 趋势 -->
|
||||||
|
<div class="modal" id="snapshotModal">
|
||||||
|
<div class="modal-box wide">
|
||||||
|
<div class="modal-head"><h3 id="snapTitle">成绩快照</h3><button class="x" data-close>×</button></div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="fi" style="margin-bottom:10px">
|
||||||
|
<label>快照标签(可选)</label>
|
||||||
|
<input type="text" id="snapLabel" placeholder="如:第一次考试、补考后…">
|
||||||
|
</div>
|
||||||
|
<div class="snap-toolbar" style="display:flex;gap:8px;margin-bottom:12px">
|
||||||
|
<button class="btn sm btn-primary" id="btnSnapSave">保存当前快照</button>
|
||||||
|
<span class="spacer"></span>
|
||||||
|
<button class="btn sm" id="btnSnapRefresh">刷新列表</button>
|
||||||
|
<button class="btn sm" id="btnSnapTrend">查看趋势</button>
|
||||||
|
</div>
|
||||||
|
<div class="table-scroll mid">
|
||||||
|
<table class="grid compact" id="snapTable">
|
||||||
|
<thead><tr><th>标签</th><th>保存时间</th><th>人数</th><th>项目</th><th style="width:70px">操作</th></tr></thead>
|
||||||
|
<tbody id="snapBody"><tr><td colspan="5" class="empty">暂无快照</td></tr></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-foot">
|
||||||
|
<span class="spacer"></span>
|
||||||
|
<button class="btn" data-close>关闭</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 跨快照趋势 -->
|
||||||
|
<div class="modal" id="trendModal">
|
||||||
|
<div class="modal-box wide">
|
||||||
|
<div class="modal-head"><h3>成绩趋势 <small id="trendSub"></small></h3><button class="x" data-close>×</button></div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="table-scroll" style="max-height:60vh">
|
||||||
|
<table class="grid compact" id="trendTable">
|
||||||
|
<thead id="trendHead"></thead>
|
||||||
|
<tbody id="trendBody"><tr><td colspan="10" class="empty">加载中…</td></tr></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<p class="hint" id="trendHint">趋势值为每名学生的「最高分」,未出现该快照的记「—」</p>
|
||||||
|
</div>
|
||||||
|
<div class="modal-foot">
|
||||||
|
<span class="spacer"></span>
|
||||||
|
<button class="btn" data-close>关闭</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 异常检测线索 -->
|
||||||
|
<div class="modal" id="anomalyModal">
|
||||||
|
<div class="modal-box wide">
|
||||||
|
<div class="modal-head"><h3>异常检测线索</h3><button class="x" data-close>×</button></div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p class="hint" style="margin:0 0 12px">以下为自动统计的「防作弊线索」,仅供人工复核判断,不代表结论。</p>
|
||||||
|
<div id="anomalyBody"></div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-foot">
|
||||||
|
<span class="spacer"></span>
|
||||||
|
<button class="btn" data-close>关闭</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
window.NY = {
|
window.NY = {
|
||||||
appName: <?= json_encode(NY_APP_NAME, JSON_UNESCAPED_UNICODE) ?>,
|
appName: <?= json_encode(NY_APP_NAME, JSON_UNESCAPED_UNICODE) ?>,
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* 数据结果缓存(短期 TTL)
|
||||||
|
*
|
||||||
|
* 目的:拉取卷王全量答卷是重操作(多次 HTTP + 大量 json_decode),
|
||||||
|
* 多个老师同时查看同一套卷、或页面按 5~15 秒自动刷新时,会重复打卷王。
|
||||||
|
* 这里把 DataService::build() 的结果按「项目 + 配置 + 名单指纹」缓存一小段时间。
|
||||||
|
*
|
||||||
|
* 失效保证:名单变化(Roster::fingerprint)、连接/去重等配置变化都会改变 key。
|
||||||
|
* 另外可显式 force=1 跳过缓存(前端"立即刷新"按钮使用)。
|
||||||
|
*/
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
final class DataCache
|
||||||
|
{
|
||||||
|
private static function dir(): string
|
||||||
|
{
|
||||||
|
return NY_DATA . '/cache';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 由若干要素拼成缓存 key */
|
||||||
|
public static function key(array $parts): string
|
||||||
|
{
|
||||||
|
return md5(implode("\x1f", array_map(static fn($v) => (string)$v, $parts)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function path(string $key): string
|
||||||
|
{
|
||||||
|
return self::dir() . '/data_' . $key . '.json';
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function lockPath(string $key): string
|
||||||
|
{
|
||||||
|
return self::dir() . '/data_' . $key . '.lock';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读取缓存;不存在 / 过期 / 损坏都返回 null
|
||||||
|
* @return array{at:int,result:array}|null
|
||||||
|
*/
|
||||||
|
public static function read(string $key, int $ttl): ?array
|
||||||
|
{
|
||||||
|
$f = self::path($key);
|
||||||
|
if (!is_file($f)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$raw = @file_get_contents($f);
|
||||||
|
if ($raw === false || $raw === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$d = json_decode($raw, true);
|
||||||
|
if (!is_array($d) || !isset($d['at']) || !is_array($d['result'])) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if ($ttl > 0 && (time() - (int)$d['at']) > $ttl) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return ['at' => (int)$d['at'], 'result' => $d['result']];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function write(string $key, array $result): bool
|
||||||
|
{
|
||||||
|
if (!is_dir(self::dir())) {
|
||||||
|
@mkdir(self::dir(), 0775, true);
|
||||||
|
}
|
||||||
|
$tmp = self::path($key) . '.tmp';
|
||||||
|
$ok = @file_put_contents($tmp, json_encode(
|
||||||
|
['at' => time(), 'result' => $result],
|
||||||
|
JSON_UNESCAPED_UNICODE
|
||||||
|
), LOCK_EX);
|
||||||
|
if ($ok === false) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return @rename($tmp, self::path($key));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取构建锁(防止并发惊群:同一 key 只有一个请求真正去拉卷王)。
|
||||||
|
*
|
||||||
|
* @return resource|null 拿到锁返回句柄(需配合 releaseLock 释放);拿不到返回 null
|
||||||
|
*/
|
||||||
|
public static function acquireLock(string $key)
|
||||||
|
{
|
||||||
|
if (!is_dir(self::dir())) {
|
||||||
|
@mkdir(self::dir(), 0775, true);
|
||||||
|
}
|
||||||
|
$fh = @fopen(self::lockPath($key), 'c');
|
||||||
|
if ($fh === false) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!flock($fh, LOCK_EX | LOCK_NB)) {
|
||||||
|
fclose($fh);
|
||||||
|
return null; // 已有其它请求在构建
|
||||||
|
}
|
||||||
|
return $fh;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function releaseLock($fh): void
|
||||||
|
{
|
||||||
|
if (is_resource($fh)) {
|
||||||
|
@flock($fh, LOCK_UN);
|
||||||
|
@fclose($fh);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 清空所有数据缓存(名单变更 / 手动清缓存时使用) */
|
||||||
|
public static function clear(): int
|
||||||
|
{
|
||||||
|
$n = 0;
|
||||||
|
foreach (glob(self::dir() . '/data_*.json') ?: [] as $f) {
|
||||||
|
if (@unlink($f)) {
|
||||||
|
$n++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $n;
|
||||||
|
}
|
||||||
|
}
|
||||||
+93
-2
@@ -235,8 +235,12 @@ class DataService
|
|||||||
$attemptsTotal = $agg['attempts_total'];
|
$attemptsTotal = $agg['attempts_total'];
|
||||||
|
|
||||||
// 成绩明细默认按提交时间降序(最新在前),学号作为次级排序
|
// 成绩明细默认按提交时间降序(最新在前),学号作为次级排序
|
||||||
usort($rows, static function (array $x, array $y): int {
|
// 重要:排序键必须用「最近一次提交时间 last_submit_ts」,而不是代表行的 submit_ts。
|
||||||
$d = ($y['submit_ts'] ?? 0) <=> ($x['submit_ts'] ?? 0);
|
// 否则当 dedupe=false(代表成绩=最高分)时,学生重考且分数更低,代表行仍是最高分那次,
|
||||||
|
// submit_ts 不变 → 该行会停在原来的位置只高亮,不会置顶,教师登记时看不到新提交。
|
||||||
|
$sortTs = static fn(array $r): int => (int)($r['last_submit_ts'] ?? 0) ?: (int)($r['submit_ts'] ?? 0);
|
||||||
|
usort($rows, static function (array $x, array $y) use ($sortTs): int {
|
||||||
|
$d = $sortTs($y) <=> $sortTs($x);
|
||||||
if ($d !== 0) {
|
if ($d !== 0) {
|
||||||
return $d;
|
return $d;
|
||||||
}
|
}
|
||||||
@@ -290,6 +294,9 @@ class DataService
|
|||||||
'attempts_total'=> $attemptsTotal,
|
'attempts_total'=> $attemptsTotal,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// 异常检测(防作弊线索,仅供教师人工判断,不做自动判定)
|
||||||
|
$anomalies = self::detectAnomalies($rows, $stats);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'project' => [
|
'project' => [
|
||||||
'id' => ny_str($project['id'] ?? $projectId),
|
'id' => ny_str($project['id'] ?? $projectId),
|
||||||
@@ -303,12 +310,96 @@ class DataService
|
|||||||
'absent' => $absent,
|
'absent' => $absent,
|
||||||
'unmatched' => $unmatched,
|
'unmatched' => $unmatched,
|
||||||
'stats' => $stats,
|
'stats' => $stats,
|
||||||
|
'anomalies' => $anomalies,
|
||||||
'roster' => $students,
|
'roster' => $students,
|
||||||
'roster_count' => count($students),
|
'roster_count' => count($students),
|
||||||
'updated_at' => date('Y-m-d H:i:s'),
|
'updated_at' => date('Y-m-d H:i:s'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 异常检测(防作弊线索):基于聚合后的学生行,输出三类线索供教师人工复核。
|
||||||
|
* 1) 短时长高得分:用时远低于平均(<平均 40% 且 <180s)却接近满分;
|
||||||
|
* 2) 高频重考:考试次数明显多于多数人(>1 且 >= 平均次数+3);
|
||||||
|
* 3) 雷同成绩:多人分数完全相同(疑似互抄,需满足该分数下人数 >=3)。
|
||||||
|
*
|
||||||
|
* 返回值:['fast_high'=>[], 'frequent'=>[], 'identical'=>[['score'=>x,'count'=>n,'names'=>[...]]]]
|
||||||
|
*/
|
||||||
|
public static function detectAnomalies(array $rows, array $stats): array
|
||||||
|
{
|
||||||
|
$avgDur = (int)($stats['avg_duration'] ?? 0);
|
||||||
|
$fullHint = max(1, (float)($stats['max_score'] ?? 100));
|
||||||
|
|
||||||
|
$fastHigh = [];
|
||||||
|
$frequent = [];
|
||||||
|
$attemptsAll = [];
|
||||||
|
|
||||||
|
foreach ($rows as $r) {
|
||||||
|
$dur = $r['duration'] ?? null;
|
||||||
|
$score = $r['score'] ?? null;
|
||||||
|
$attempts = (int)($r['attempts'] ?? 1);
|
||||||
|
$attemptsAll[] = $attempts;
|
||||||
|
|
||||||
|
// 短时长高得分:有时长记录、明显快于平均、分数 >= 满分 85%
|
||||||
|
if ($dur !== null && $avgDur > 0 && $dur < max(60, (int)($avgDur * 0.4))
|
||||||
|
&& $score !== null && $score >= $fullHint * 0.85) {
|
||||||
|
$fastHigh[] = [
|
||||||
|
'sid' => (string)($r['sid'] ?? ''),
|
||||||
|
'name' => (string)($r['name'] ?? ''),
|
||||||
|
'cls' => (string)($r['cls'] ?? ''),
|
||||||
|
'score' => $score,
|
||||||
|
'duration' => $dur,
|
||||||
|
'duration_txt' => (string)($r['duration_txt'] ?? ''),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 高频重考:考试次数明显偏多
|
||||||
|
if ($attempts > 1) {
|
||||||
|
$frequent[] = [
|
||||||
|
'sid' => (string)($r['sid'] ?? ''),
|
||||||
|
'name' => (string)($r['name'] ?? ''),
|
||||||
|
'cls' => (string)($r['cls'] ?? ''),
|
||||||
|
'attempts' => $attempts,
|
||||||
|
'best_score' => $r['best_score'] ?? null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 高频重考阈值:平均次数 + 3(且至少 4 次才够"高频")
|
||||||
|
$avgAttempts = $attemptsAll !== [] ? array_sum($attemptsAll) / count($attemptsAll) : 0;
|
||||||
|
$freqThreshold = max(4, (int)ceil($avgAttempts + 3));
|
||||||
|
$frequent = array_values(array_filter($frequent, static fn($f) => $f['attempts'] >= $freqThreshold));
|
||||||
|
usort($frequent, static fn($a, $b) => $b['attempts'] <=> $a['attempts']);
|
||||||
|
|
||||||
|
// 雷同成绩:按分数分组,出现 >=3 人同分(且分数不为 null)视为疑似
|
||||||
|
$scoreGroups = [];
|
||||||
|
foreach ($rows as $r) {
|
||||||
|
$score = $r['score'] ?? null;
|
||||||
|
if ($score === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$key = (string)$score;
|
||||||
|
$scoreGroups[$key][] = [
|
||||||
|
'sid' => (string)($r['sid'] ?? ''),
|
||||||
|
'name' => (string)($r['name'] ?? ''),
|
||||||
|
'cls' => (string)($r['cls'] ?? ''),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
$identical = [];
|
||||||
|
foreach ($scoreGroups as $score => $members) {
|
||||||
|
if (count($members) >= 3) {
|
||||||
|
$identical[] = ['score' => (float)$score, 'count' => count($members), 'members' => $members];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
usort($identical, static fn($a, $b) => $b['count'] <=> $a['count']);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'fast_high' => $fastHigh,
|
||||||
|
'frequent' => $frequent,
|
||||||
|
'identical' => $identical,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 把聚合后的学生行展开为"每次考试一行",用于导出全部考试记录 / 明细内全部展示。
|
* 把聚合后的学生行展开为"每次考试一行",用于导出全部考试记录 / 明细内全部展示。
|
||||||
* 每行携带该次考试的成绩、提交时间、时长,以及该学生的总考试次数 attempts 与第几次 idx。
|
* 每行携带该次考试的成绩、提交时间、时长,以及该学生的总考试次数 attempts 与第几次 idx。
|
||||||
|
|||||||
@@ -98,6 +98,23 @@ class Roster
|
|||||||
return count(self::all());
|
return count(self::all());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 名单指纹:名单内容一变就变,用于数据缓存 key。
|
||||||
|
* 只取「人数 + 文件修改时间 + 内容的轻量哈希」,避免每次全量序列化。
|
||||||
|
*/
|
||||||
|
public static function fingerprint(): string
|
||||||
|
{
|
||||||
|
if (self::useDb()) {
|
||||||
|
return 'db:' . self::count();
|
||||||
|
}
|
||||||
|
$f = self::file();
|
||||||
|
$mt = is_file($f) ? (string)filemtime($f) : '0';
|
||||||
|
$sz = is_file($f) ? (string)filesize($f) : '0';
|
||||||
|
$d = ny_read_json($f, null);
|
||||||
|
$hash = is_array($d) ? md5(json_encode($d['students'] ?? [], JSON_UNESCAPED_UNICODE)) : '0';
|
||||||
|
return $mt . ':' . $sz . ':' . $hash;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 首次使用时,若名单为空且存在 导入模板.xlsx,自动导入
|
* 首次使用时,若名单为空且存在 导入模板.xlsx,自动导入
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* 成绩快照:把某次「拉取到的成绩」落盘成一份带标签的时间点存档,
|
||||||
|
* 用于跨时间 / 跨试卷的趋势对比(例如某学生几次考试的分数变化)。
|
||||||
|
*
|
||||||
|
* 存储位置:data/snapshots/<projectId>/<unix秒>_<md5(标签)>.json
|
||||||
|
* 每份快照内容为 { label, at, project:{id,name}, stats, students:[{sid,name,cls,grp,score,best_score,attempts,last_submit_at}] }
|
||||||
|
*/
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
final class Snapshot
|
||||||
|
{
|
||||||
|
private static function dir(string $projectId): string
|
||||||
|
{
|
||||||
|
$d = NY_DATA . '/snapshots/' . preg_replace('/[^A-Za-z0-9_-]/', '_', $projectId);
|
||||||
|
if (!is_dir($d)) {
|
||||||
|
@mkdir($d, 0777, true);
|
||||||
|
}
|
||||||
|
return $d;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从聚合结果里抽取轻量学生摘要(只保留趋势对比需要的字段) */
|
||||||
|
public static function summarize(array $result, string $label): array
|
||||||
|
{
|
||||||
|
$students = [];
|
||||||
|
foreach ((array)($result['rows'] ?? []) as $r) {
|
||||||
|
$students[] = [
|
||||||
|
'sid' => (string)($r['sid'] ?? ''),
|
||||||
|
'name' => (string)($r['name'] ?? ''),
|
||||||
|
'cls' => (string)($r['cls'] ?? ''),
|
||||||
|
'grp' => (string)($r['grp'] ?? ''),
|
||||||
|
'score' => ($r['score'] ?? null) === null ? null : (float)$r['score'],
|
||||||
|
'best_score' => ($r['best_score'] ?? null) === null ? null : (float)$r['best_score'],
|
||||||
|
'attempts' => (int)($r['attempts'] ?? 1),
|
||||||
|
'last_submit_at' => (string)($r['last_submit_at'] ?? ($r['submit_at'] ?? '-')),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
// 按学号排序,保证趋势表行序稳定
|
||||||
|
usort($students, static function ($a, $b): int {
|
||||||
|
$sa = $a['sid']; $sb = $b['sid'];
|
||||||
|
if (is_numeric($sa) && is_numeric($sb)) {
|
||||||
|
return $sa <=> $sb;
|
||||||
|
}
|
||||||
|
return strnatcmp((string)$sa, (string)$sb);
|
||||||
|
});
|
||||||
|
|
||||||
|
return [
|
||||||
|
'label' => trim($label) === '' ? '快照 ' . date('m-d H:i') : trim($label),
|
||||||
|
'at' => time(),
|
||||||
|
'at_txt' => date('Y-m-d H:i:s'),
|
||||||
|
'project' => [
|
||||||
|
'id' => (string)($result['project']['id'] ?? ''),
|
||||||
|
'name' => (string)($result['project']['name'] ?? ''),
|
||||||
|
],
|
||||||
|
'stats' => $result['stats'] ?? [],
|
||||||
|
'students' => $students,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 保存一份快照,返回文件名 */
|
||||||
|
public static function save(string $projectId, array $snap): string
|
||||||
|
{
|
||||||
|
$dir = self::dir($projectId);
|
||||||
|
$name = time() . '_' . substr(md5($snap['label'] . $snap['at']), 0, 8) . '.json';
|
||||||
|
@file_put_contents($dir . '/' . $name, json_encode($snap, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), LOCK_EX);
|
||||||
|
return $name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 列出某项目的全部快照(按时间倒序) */
|
||||||
|
public static function list(string $projectId): array
|
||||||
|
{
|
||||||
|
$dir = self::dir($projectId);
|
||||||
|
$out = [];
|
||||||
|
foreach (glob($dir . '/*.json') ?: [] as $f) {
|
||||||
|
$raw = @file_get_contents($f);
|
||||||
|
if ($raw === false) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$j = json_decode($raw, true);
|
||||||
|
if (!is_array($j)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$out[] = [
|
||||||
|
'file' => basename($f),
|
||||||
|
'label' => (string)($j['label'] ?? basename($f)),
|
||||||
|
'at' => (int)($j['at'] ?? 0),
|
||||||
|
'at_txt' => (string)($j['at_txt'] ?? ''),
|
||||||
|
'project' => $j['project'] ?? [],
|
||||||
|
'count' => count((array)($j['students'] ?? [])),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
usort($out, static fn($a, $b) => ($b['at'] <=> $a['at']));
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 读取单份快照完整内容 */
|
||||||
|
public static function get(string $projectId, string $file): ?array
|
||||||
|
{
|
||||||
|
$file = basename($file); // 防路径穿越
|
||||||
|
$path = self::dir($projectId) . '/' . $file;
|
||||||
|
if (!is_file($path)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$j = json_decode((string)@file_get_contents($path), true);
|
||||||
|
return is_array($j) ? $j : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除单份快照 */
|
||||||
|
public static function delete(string $projectId, string $file): bool
|
||||||
|
{
|
||||||
|
$file = basename($file);
|
||||||
|
$path = self::dir($projectId) . '/' . $file;
|
||||||
|
if (is_file($path)) {
|
||||||
|
return @unlink($path);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 跨快照趋势:以学号为行、各快照为列,输出每个学生在每份快照里的成绩。
|
||||||
|
* 用 best_score(真实最高分)作为趋势值,未出现记 null。
|
||||||
|
*/
|
||||||
|
public static function trend(array $snapshots): array
|
||||||
|
{
|
||||||
|
// 按时间升序排,最早的列在最前
|
||||||
|
$snaps = $snapshots;
|
||||||
|
usort($snaps, static fn($a, $b) => ($a['at'] <=> $b['at']));
|
||||||
|
|
||||||
|
$cols = [];
|
||||||
|
$bySid = [];
|
||||||
|
foreach ($snaps as $sn) {
|
||||||
|
$colKey = (string)$sn['at'] . ':' . (string)$sn['label'];
|
||||||
|
$cols[] = ['key' => $colKey, 'label' => (string)$sn['label'], 'at_txt' => (string)($sn['at_txt'] ?? '')];
|
||||||
|
foreach ((array)($sn['students'] ?? []) as $st) {
|
||||||
|
$sid = (string)($st['sid'] ?? '');
|
||||||
|
if ($sid === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!isset($bySid[$sid])) {
|
||||||
|
$bySid[$sid] = [
|
||||||
|
'sid' => $sid,
|
||||||
|
'name' => (string)($st['name'] ?? ''),
|
||||||
|
'cls' => (string)($st['cls'] ?? ''),
|
||||||
|
'scores' => [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
$bySid[$sid]['scores'][$colKey] = ($st['best_score'] ?? null) === null
|
||||||
|
? null
|
||||||
|
: (float)$st['best_score'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows = array_values($bySid);
|
||||||
|
// 按学号排序
|
||||||
|
usort($rows, static function ($a, $b): int {
|
||||||
|
if (is_numeric($a['sid']) && is_numeric($b['sid'])) {
|
||||||
|
return $a['sid'] <=> $b['sid'];
|
||||||
|
}
|
||||||
|
return strnatcmp((string)$a['sid'], (string)$b['sid']);
|
||||||
|
});
|
||||||
|
|
||||||
|
return ['columns' => $cols, 'rows' => $rows];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -187,7 +187,10 @@ class SurveyKingClient
|
|||||||
$msg = (string)($decoded['msg'] ?? $decoded['message'] ?? '');
|
$msg = (string)($decoded['msg'] ?? $decoded['message'] ?? '');
|
||||||
}
|
}
|
||||||
|
|
||||||
return ['status' => $status, 'headers' => $h, 'data' => $data, 'raw' => $rawBody, 'code' => $code, 'msg' => $msg];
|
// 仅保留前若干字符用于错误诊断;完整正文不再随返回值常驻内存,
|
||||||
|
// 避免大答卷响应在 json_decode 之外再额外占用一倍内存。
|
||||||
|
$rawDiag = mb_strcut($rawBody, 0, 2000);
|
||||||
|
return ['status' => $status, 'headers' => $h, 'data' => $data, 'raw' => $rawDiag, 'code' => $code, 'msg' => $msg];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -448,7 +451,7 @@ class SurveyKingClient
|
|||||||
/** 问卷 / 考试列表 */
|
/** 问卷 / 考试列表 */
|
||||||
public function listProjects(): array
|
public function listProjects(): array
|
||||||
{
|
{
|
||||||
$size = max(20, (int)($this->cfg['page_size'] ?? 200));
|
$size = min(200, max(20, (int)($this->cfg['page_size'] ?? 100)));
|
||||||
$all = [];
|
$all = [];
|
||||||
for ($page = 1; $page <= 50; $page++) {
|
for ($page = 1; $page <= 50; $page++) {
|
||||||
$r = $this->request('/project/list', ['current' => $page, 'pageSize' => $size]);
|
$r = $this->request('/project/list', ['current' => $page, 'pageSize' => $size]);
|
||||||
@@ -483,7 +486,9 @@ class SurveyKingClient
|
|||||||
/** 答卷列表(自动翻页取全量) */
|
/** 答卷列表(自动翻页取全量) */
|
||||||
public function listAnswers(string $projectId): array
|
public function listAnswers(string $projectId): array
|
||||||
{
|
{
|
||||||
$size = max(20, (int)($this->cfg['page_size'] ?? 200));
|
// 单页上限夹在 20~200:无论配置 page_size 多大,单次 json_decode 的体量都被限制住,
|
||||||
|
// 避免一份超大响应直接撑爆 128M。100 页 × 200 = 最多 2 万份,足以覆盖学校级考试。
|
||||||
|
$size = min(200, max(20, (int)($this->cfg['page_size'] ?? 100)));
|
||||||
$all = [];
|
$all = [];
|
||||||
for ($page = 1; $page <= 100; $page++) {
|
for ($page = 1; $page <= 100; $page++) {
|
||||||
$r = $this->request('/answer/list', [
|
$r = $this->request('/answer/list', [
|
||||||
@@ -502,10 +507,14 @@ class SurveyKingClient
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
$total = is_array($data) ? (int)($data['total'] ?? 0) : 0;
|
$total = is_array($data) ? (int)($data['total'] ?? 0) : 0;
|
||||||
|
// 及时释放本页引用,降低峰值内存占用
|
||||||
|
$r = null;
|
||||||
|
$data = null;
|
||||||
|
$list = null;
|
||||||
if ($total > 0 && count($all) >= $total) {
|
if ($total > 0 && count($all) >= $total) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if (count($list) < $size) {
|
if (count($all) < $size) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+124
-32
@@ -15,10 +15,8 @@ final class XlsxWriter
|
|||||||
private array $strIndex = [];
|
private array $strIndex = [];
|
||||||
/** @var array<int,string> 索引 => 共享字符串 */
|
/** @var array<int,string> 索引 => 共享字符串 */
|
||||||
private array $strings = [];
|
private array $strings = [];
|
||||||
/** @var array<int,array<int,string>> 行数据 */
|
/** @var array<int,array{name:string,rows:array,widths:array}> 工作表(按输出顺序) */
|
||||||
private array $rows = [];
|
private array $sheets = [];
|
||||||
/** @var array<int,float> 列宽(1 基) */
|
|
||||||
private array $widths = [];
|
|
||||||
|
|
||||||
// 样式索引(见 stylesXml)
|
// 样式索引(见 stylesXml)
|
||||||
private const S_DEFAULT = 0; // 常规
|
private const S_DEFAULT = 0; // 常规
|
||||||
@@ -26,57 +24,132 @@ final class XlsxWriter
|
|||||||
private const S_TEXT = 2; // 文本格式(numFmtId=49)
|
private const S_TEXT = 2; // 文本格式(numFmtId=49)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 生成 xlsx 二进制内容
|
* 生成单表 xlsx 二进制内容(兼容旧调用)
|
||||||
*
|
*
|
||||||
* @param array $rows 二维表,第一行视为表头;每个元素会被强制转成字符串
|
* @param array $rows 二维表,第一行视为表头;每个元素会被强制转成字符串
|
||||||
* @param array $opts ['widths' => [列宽,...], 'title' => 工作表名]
|
* @param array $opts ['widths' => [列宽,...], 'title' => 工作表名]
|
||||||
* @return string xlsx 文件二进制
|
* @return string xlsx 文件二进制
|
||||||
*/
|
*/
|
||||||
public static function render(array $rows, array $opts = []): string
|
public static function render(array $rows, array $opts = []): string
|
||||||
|
{
|
||||||
|
return self::renderMulti([[
|
||||||
|
'name' => (string)($opts['title'] ?? 'Sheet1'),
|
||||||
|
'rows' => $rows,
|
||||||
|
'widths' => array_values((array)($opts['widths'] ?? [])),
|
||||||
|
]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成多工作表 xlsx
|
||||||
|
*
|
||||||
|
* 说明:所有单元格都写成「共享字符串 + 文本格式(numFmtId=49)」,
|
||||||
|
* 因此既不会出现长学号被转成科学计数法,也不会把以 = + - @ 开头的内容
|
||||||
|
* 当成公式执行(天然规避 CSV 公式注入问题)。
|
||||||
|
*
|
||||||
|
* @param array $sheets 每个元素:['name'=>工作表名, 'rows'=>二维表, 'widths'=>[列宽...]]
|
||||||
|
* @return string xlsx 文件二进制
|
||||||
|
*/
|
||||||
|
public static function renderMulti(array $sheets): string
|
||||||
{
|
{
|
||||||
$w = new self();
|
$w = new self();
|
||||||
$w->rows = array_values($rows);
|
foreach ($sheets as $sh) {
|
||||||
$w->widths = array_values((array)($opts['widths'] ?? []));
|
if (!is_array($sh)) {
|
||||||
return $w->build((string)($opts['title'] ?? 'Sheet1'));
|
continue;
|
||||||
|
}
|
||||||
|
$rows = array_values((array)($sh['rows'] ?? []));
|
||||||
|
if ($rows === []) {
|
||||||
|
continue; // 空表不输出,避免 Excel 提示修复
|
||||||
|
}
|
||||||
|
$w->sheets[] = [
|
||||||
|
'name' => self::safeSheetName((string)($sh['name'] ?? ''), count($w->sheets) + 1, $w->sheets),
|
||||||
|
'rows' => $rows,
|
||||||
|
'widths' => array_values((array)($sh['widths'] ?? [])),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if ($w->sheets === []) {
|
||||||
|
// 保底:至少输出一张空表
|
||||||
|
$w->sheets[] = ['name' => 'Sheet1', 'rows' => [['']], 'widths' => []];
|
||||||
|
}
|
||||||
|
return $w->build();
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------------- 内部:构建 ---------------- */
|
/* ---------------- 内部:构建 ---------------- */
|
||||||
|
|
||||||
private function build(string $title): string
|
private function build(): string
|
||||||
{
|
{
|
||||||
|
$files = [
|
||||||
|
'[Content_Types].xml' => '', // 占位,下面按表数量生成
|
||||||
|
];
|
||||||
|
$sheetXmls = [];
|
||||||
|
$names = [];
|
||||||
|
|
||||||
|
foreach ($this->sheets as $i => $sh) {
|
||||||
|
$n = $i + 1;
|
||||||
|
|
||||||
$maxCol = 1;
|
$maxCol = 1;
|
||||||
foreach ($this->rows as $r) {
|
foreach ($sh['rows'] as $r) {
|
||||||
$maxCol = max($maxCol, count((array)$r));
|
$maxCol = max($maxCol, count((array)$r));
|
||||||
}
|
}
|
||||||
$maxRow = max(1, count($this->rows));
|
$maxRow = max(1, count($sh['rows']));
|
||||||
|
|
||||||
// 先登记所有字符串,生成 sharedStrings 索引
|
// 登记共享字符串并生成单元格索引
|
||||||
$cells = [];
|
$cells = [];
|
||||||
foreach ($this->rows as $ri => $row) {
|
foreach (array_values($sh['rows']) as $ri => $row) {
|
||||||
$r = (int)$ri + 1;
|
$r = (int)$ri + 1;
|
||||||
foreach (array_values((array)$row) as $ci => $val) {
|
foreach (array_values((array)$row) as $ci => $val) {
|
||||||
$c = (int)$ci + 1;
|
$c = (int)$ci + 1;
|
||||||
$s = $this->intern((string)($val ?? ''));
|
$cells[$r][$c] = $this->intern((string)($val ?? ''));
|
||||||
$cells[$r][$c] = $s;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
$sheetXmls['xl/worksheets/sheet' . $n . '.xml'] = $this->sheetXml($cells, $maxRow, $maxCol, $sh['widths']);
|
||||||
|
$names[] = $sh['name'];
|
||||||
|
}
|
||||||
|
|
||||||
$sheet = $this->sheetXml($cells, $maxRow, $maxCol);
|
$count = count($this->sheets);
|
||||||
$files = [
|
$files = [
|
||||||
'[Content_Types].xml' => $this->contentTypesXml(),
|
'[Content_Types].xml' => $this->contentTypesXml($count),
|
||||||
'_rels/.rels' => $this->rootRelsXml(),
|
'_rels/.rels' => $this->rootRelsXml(),
|
||||||
'docProps/app.xml' => $this->appXml(),
|
'docProps/app.xml' => $this->appXml(),
|
||||||
'docProps/core.xml' => $this->coreXml($title),
|
'docProps/core.xml' => $this->coreXml($names[0] ?? 'Sheet1'),
|
||||||
'xl/workbook.xml' => $this->workbookXml($title),
|
'xl/workbook.xml' => $this->workbookXml($names),
|
||||||
'xl/_rels/workbook.xml.rels' => $this->workbookRelsXml(),
|
'xl/_rels/workbook.xml.rels' => $this->workbookRelsXml($count),
|
||||||
'xl/styles.xml' => $this->stylesXml(),
|
'xl/styles.xml' => $this->stylesXml(),
|
||||||
'xl/sharedStrings.xml' => $this->sharedStringsXml(),
|
'xl/sharedStrings.xml' => $this->sharedStringsXml(),
|
||||||
'xl/worksheets/sheet1.xml' => $sheet,
|
] + $sheetXmls;
|
||||||
];
|
|
||||||
|
|
||||||
return $this->zip($files);
|
return $this->zip($files);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工作表名安全化:Excel 限制 31 字符、禁止 []:*?/\、不可重复、不可为空
|
||||||
|
*/
|
||||||
|
private static function safeSheetName(string $name, int $seq, array $existing): string
|
||||||
|
{
|
||||||
|
$bad = ['\\', '/', '?', '*', '[', ']', ':'];
|
||||||
|
$n = trim(str_replace($bad, '_', $name));
|
||||||
|
if ($n === '') {
|
||||||
|
$n = 'Sheet' . $seq;
|
||||||
|
}
|
||||||
|
if (function_exists('mb_substr')) {
|
||||||
|
$n = mb_substr($n, 0, 31, 'UTF-8');
|
||||||
|
} else {
|
||||||
|
$n = substr($n, 0, 31);
|
||||||
|
}
|
||||||
|
// 去重
|
||||||
|
$base = $n;
|
||||||
|
$i = 2;
|
||||||
|
$taken = [];
|
||||||
|
foreach ($existing as $e) {
|
||||||
|
$taken[strtolower($e['name'])] = true;
|
||||||
|
}
|
||||||
|
while (isset($taken[strtolower($n)])) {
|
||||||
|
$suffix = '(' . $i . ')';
|
||||||
|
$n = (function_exists('mb_substr') ? mb_substr($base, 0, 31 - strlen($suffix), 'UTF-8') : substr($base, 0, 31 - strlen($suffix))) . $suffix;
|
||||||
|
$i++;
|
||||||
|
}
|
||||||
|
return $n;
|
||||||
|
}
|
||||||
|
|
||||||
private function intern(string $s): int
|
private function intern(string $s): int
|
||||||
{
|
{
|
||||||
if (!array_key_exists($s, $this->strIndex)) {
|
if (!array_key_exists($s, $this->strIndex)) {
|
||||||
@@ -87,13 +160,13 @@ final class XlsxWriter
|
|||||||
return $this->strIndex[$s];
|
return $this->strIndex[$s];
|
||||||
}
|
}
|
||||||
|
|
||||||
private function sheetXml(array $cells, int $maxRow, int $maxCol): string
|
private function sheetXml(array $cells, int $maxRow, int $maxCol, array $widths = []): string
|
||||||
{
|
{
|
||||||
$dim = 'A1:' . self::colName($maxCol) . $maxRow;
|
$dim = 'A1:' . self::colName($maxCol) . $maxRow;
|
||||||
|
|
||||||
$cols = '';
|
$cols = '';
|
||||||
for ($c = 1; $c <= $maxCol; $c++) {
|
for ($c = 1; $c <= $maxCol; $c++) {
|
||||||
$w = $this->widths[$c - 1] ?? 16;
|
$w = $widths[$c - 1] ?? 16;
|
||||||
$cols .= '<col min="' . $c . '" max="' . $c . '" width="' . $w . '" customWidth="1"/>';
|
$cols .= '<col min="' . $c . '" max="' . $c . '" width="' . $w . '" customWidth="1"/>';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,14 +199,19 @@ final class XlsxWriter
|
|||||||
'</worksheet>';
|
'</worksheet>';
|
||||||
}
|
}
|
||||||
|
|
||||||
private function contentTypesXml(): string
|
private function contentTypesXml(int $sheetCount): string
|
||||||
{
|
{
|
||||||
|
$overrides = '';
|
||||||
|
for ($i = 1; $i <= max(1, $sheetCount); $i++) {
|
||||||
|
$overrides .= '<Override PartName="/xl/worksheets/sheet' . $i . '.xml" ' .
|
||||||
|
'ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>';
|
||||||
|
}
|
||||||
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' . "\n" .
|
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' . "\n" .
|
||||||
'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">' .
|
'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">' .
|
||||||
'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>' .
|
'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>' .
|
||||||
'<Default Extension="xml" ContentType="application/xml"/>' .
|
'<Default Extension="xml" ContentType="application/xml"/>' .
|
||||||
'<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>' .
|
'<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>' .
|
||||||
'<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>' .
|
$overrides .
|
||||||
'<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>' .
|
'<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>' .
|
||||||
'<Override PartName="/xl/sharedStrings.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml"/>' .
|
'<Override PartName="/xl/sharedStrings.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml"/>' .
|
||||||
'<Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/>' .
|
'<Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/>' .
|
||||||
@@ -151,22 +229,36 @@ final class XlsxWriter
|
|||||||
'</Relationships>';
|
'</Relationships>';
|
||||||
}
|
}
|
||||||
|
|
||||||
private function workbookXml(string $title): string
|
/** @param array<int,string> $names 工作表名(已安全化) */
|
||||||
|
private function workbookXml(array $names): string
|
||||||
{
|
{
|
||||||
|
$sheets = '';
|
||||||
|
foreach (array_values($names) as $i => $nm) {
|
||||||
|
$n = (int)$i + 1;
|
||||||
|
$sheets .= '<sheet name="' . self::xml((string)$nm) . '" sheetId="' . $n . '" r:id="rId' . $n . '"/>';
|
||||||
|
}
|
||||||
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' . "\n" .
|
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' . "\n" .
|
||||||
'<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" ' .
|
'<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" ' .
|
||||||
'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">' .
|
'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">' .
|
||||||
'<sheets><sheet name="' . self::xml($title) . '" sheetId="1" r:id="rId1"/></sheets>' .
|
'<sheets>' . $sheets . '</sheets>' .
|
||||||
'</workbook>';
|
'</workbook>';
|
||||||
}
|
}
|
||||||
|
|
||||||
private function workbookRelsXml(): string
|
private function workbookRelsXml(int $sheetCount): string
|
||||||
{
|
{
|
||||||
|
$rels = '';
|
||||||
|
for ($i = 1; $i <= max(1, $sheetCount); $i++) {
|
||||||
|
$rels .= '<Relationship Id="rId' . $i . '" ' .
|
||||||
|
'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" ' .
|
||||||
|
'Target="worksheets/sheet' . $i . '.xml"/>';
|
||||||
|
}
|
||||||
|
$stylesId = max(1, $sheetCount) + 1;
|
||||||
|
$sstId = max(1, $sheetCount) + 2;
|
||||||
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' . "\n" .
|
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' . "\n" .
|
||||||
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' .
|
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' .
|
||||||
'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>' .
|
$rels .
|
||||||
'<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>' .
|
'<Relationship Id="rId' . $stylesId . '" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>' .
|
||||||
'<Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/sharedStrings" Target="sharedStrings.xml"/>' .
|
'<Relationship Id="rId' . $sstId . '" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/sharedStrings" Target="sharedStrings.xml"/>' .
|
||||||
'</Relationships>';
|
'</Relationships>';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,8 +8,19 @@ declare(strict_types=1);
|
|||||||
require __DIR__ . '/bootstrap.php';
|
require __DIR__ . '/bootstrap.php';
|
||||||
|
|
||||||
if (ny_is_auth()) {
|
if (ny_is_auth()) {
|
||||||
|
// 已登录但卷王 token 可能已过期:用当前 token 探一次卷王 /system,能 200 即视为有效,否则清掉本地会话强制重新登录
|
||||||
|
try {
|
||||||
|
$client = new SurveyKingClient($cfg);
|
||||||
|
$client->ping();
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
// 卷王 token 已失效或配置错误 → 清会话 + 强制显示登录页(避免 login.php 把人踢回 index.php 形成死循环)
|
||||||
|
$_SESSION = [];
|
||||||
|
@session_destroy();
|
||||||
|
}
|
||||||
|
if (ny_is_auth()) {
|
||||||
header('Location: index.php');
|
header('Location: index.php');
|
||||||
exit;
|
exit;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$cfg = ny_load_config();
|
$cfg = ny_load_config();
|
||||||
@@ -17,6 +28,12 @@ $err = '';
|
|||||||
$ok = false;
|
$ok = false;
|
||||||
|
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
// 登录失败限流:5 次失败锁 10 分钟,防止暴力破解
|
||||||
|
$throttle = ny_login_throttle_check();
|
||||||
|
if ($throttle['until'] > time()) {
|
||||||
|
$wait = (int)(($throttle['until'] - time()) / 60) + 1;
|
||||||
|
$err = '尝试次数过多,请 ' . $wait . ' 分钟后再试。';
|
||||||
|
} else {
|
||||||
$user = trim((string)($_POST['username'] ?? $cfg['username']));
|
$user = trim((string)($_POST['username'] ?? $cfg['username']));
|
||||||
$pwd = (string)($_POST['password'] ?? '');
|
$pwd = (string)($_POST['password'] ?? '');
|
||||||
// 允许用户在本页调整账号;密码必须输入
|
// 允许用户在本页调整账号;密码必须输入
|
||||||
@@ -36,11 +53,14 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
$cfg['password'] = $pwd;
|
$cfg['password'] = $pwd;
|
||||||
ny_save_config($cfg);
|
ny_save_config($cfg);
|
||||||
ny_login();
|
ny_login();
|
||||||
|
ny_login_throttle_reset();
|
||||||
$ok = true;
|
$ok = true;
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
|
ny_login_throttle_hit();
|
||||||
$err = '登录失败:' . $e->getMessage();
|
$err = '登录失败:' . $e->getMessage();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
?>
|
?>
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# 一键跑完所有回归测试(PHP 单元自测 + Node e2e)。
|
||||||
|
# 用法:bash tests/run-all.sh
|
||||||
|
set -e
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
PHP="${PHP_BIN:-C:/Users/Zhang/.workbuddy/binaries/php/php.exe}"
|
||||||
|
NODE="${NODE_BIN:-C:/Users/Zhang/.workbuddy/binaries/node/versions/22.22.2-2/node.exe}"
|
||||||
|
INI="-d extension_dir=C:/Users/Zhang/.workbuddy/binaries/php/ext -d extension=pdo_sqlite"
|
||||||
|
|
||||||
|
echo "================ PHP 自测 ================"
|
||||||
|
for f in tests/test-aggregate.php tests/test-new-features.php tools/test-xlsxwriter.php; do
|
||||||
|
echo ""
|
||||||
|
echo ">>> $f"
|
||||||
|
"$PHP" $INI "$f"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "================ Node e2e(需 mock-server + PHP 内置服务器) ================"
|
||||||
|
for f in tools/test-config-save.js tools/test-draw-alloc.js; do
|
||||||
|
echo ""
|
||||||
|
echo ">>> $f"
|
||||||
|
"$NODE" "$f"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "全部测试完成。"
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* 新功能回归测试:
|
||||||
|
* 1) 异常检测 detectAnomalies(短时长高分 / 高频重考 / 雷同成绩)
|
||||||
|
* 2) 成绩快照 Snapshot::summarize / trend
|
||||||
|
* 3) 成绩明细排序:再次提交应置顶(last_submit_ts 排序)
|
||||||
|
* 4) XlsxWriter 多工作表 renderMulti(分表 + 名字去重/截断)
|
||||||
|
*/
|
||||||
|
declare(strict_types=1);
|
||||||
|
require __DIR__ . '/../bootstrap.php';
|
||||||
|
|
||||||
|
$pass = 0;
|
||||||
|
$fail = 0;
|
||||||
|
function check(string $name, bool $cond, string $detail = ''): void
|
||||||
|
{
|
||||||
|
global $pass, $fail;
|
||||||
|
if ($cond) { $pass++; echo " ✅ {$name}\n"; }
|
||||||
|
else { $fail++; echo " ❌ {$name} {$detail}\n"; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============ 1) 异常检测 ============ */
|
||||||
|
echo "\n== 异常检测 ==\n";
|
||||||
|
|
||||||
|
// 构造:3 人同分(雷同)、1 人短时长高分、1 人高频重考
|
||||||
|
function mkRow(string $sid, string $name, ?float $score, ?int $duration, int $attempts): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'sid' => $sid, 'name' => $name, 'cls' => '1班', 'grp' => 'A',
|
||||||
|
'score' => $score, 'duration' => $duration, 'duration_txt' => $duration === null ? '-' : ($duration . '秒'),
|
||||||
|
'attempts' => $attempts, 'best_score' => $score,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows = [
|
||||||
|
mkRow('S01', '甲', 90.0, 600, 1),
|
||||||
|
mkRow('S02', '乙', 90.0, 610, 1), // 与甲同分
|
||||||
|
mkRow('S03', '丙', 90.0, 620, 1), // 三人同分 → 雷同
|
||||||
|
mkRow('S04', '丁', 95.0, 30, 1), // 短时长(30s)+ 高分
|
||||||
|
mkRow('S05', '戊', 70.0, 500, 9), // 高频重考 9 次
|
||||||
|
];
|
||||||
|
$stats = ['avg_duration' => 600, 'max_score' => 100.0];
|
||||||
|
$anom = DataService::detectAnomalies($rows, $stats);
|
||||||
|
|
||||||
|
check('雷同成绩:捕获 90 分 3 人组', count($anom['identical']) >= 1 && (float)$anom['identical'][0]['score'] === 90.0,
|
||||||
|
json_encode($anom['identical'], JSON_UNESCAPED_UNICODE));
|
||||||
|
check('雷同成绩:人数 = 3', (int)($anom['identical'][0]['count'] ?? 0) === 3);
|
||||||
|
check('短时长高分:捕获丁', count($anom['fast_high']) >= 1 && ($anom['fast_high'][0]['sid'] ?? '') === 'S04',
|
||||||
|
json_encode($anom['fast_high'], JSON_UNESCAPED_UNICODE));
|
||||||
|
check('高频重考:捕获戊(9 次)', count($anom['frequent']) >= 1 && ($anom['frequent'][0]['sid'] ?? '') === 'S05',
|
||||||
|
json_encode($anom['frequent'], JSON_UNESCAPED_UNICODE));
|
||||||
|
check('正常学生(甲 1 次)不被误报高频', !in_array('S01', array_column($anom['frequent'], 'sid'), true));
|
||||||
|
|
||||||
|
/* ============ 2) 快照 summarize / trend ============ */
|
||||||
|
echo "\n== 成绩快照 ==\n";
|
||||||
|
|
||||||
|
$result1 = [
|
||||||
|
'project' => ['id' => 'P1', 'name' => '数学测验'],
|
||||||
|
'stats' => ['avg_score' => 85],
|
||||||
|
'rows' => [
|
||||||
|
['sid' => 'S01', 'name' => '甲', 'cls' => '1班', 'grp' => 'A', 'score' => 90, 'best_score' => 95, 'attempts' => 2, 'last_submit_at' => '2026-09-10 10:00:00'],
|
||||||
|
['sid' => 'S02', 'name' => '乙', 'cls' => '1班', 'grp' => 'A', 'score' => 80, 'best_score' => 80, 'attempts' => 1, 'last_submit_at' => '2026-09-10 10:05:00'],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
$result2 = [
|
||||||
|
'project' => ['id' => 'P1', 'name' => '数学测验'],
|
||||||
|
'stats' => ['avg_score' => 88],
|
||||||
|
'rows' => [
|
||||||
|
['sid' => 'S01', 'name' => '甲', 'cls' => '1班', 'grp' => 'A', 'score' => 92, 'best_score' => 96, 'attempts' => 3, 'last_submit_at' => '2026-09-10 12:00:00'],
|
||||||
|
['sid' => 'S03', 'name' => '丙', 'cls' => '2班', 'grp' => 'B', 'score' => 88, 'best_score' => 88, 'attempts' => 1, 'last_submit_at' => '2026-09-10 12:10:00'],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
$snap1 = Snapshot::summarize($result1, '第一次');
|
||||||
|
$snap2 = Snapshot::summarize($result2, '第二次');
|
||||||
|
$snap1['at'] = 100; $snap2['at'] = 200; // 固定时间戳便于排序
|
||||||
|
|
||||||
|
check('summarize 抽取学生数 = 2', count($snap1['students']) === 2);
|
||||||
|
check('summarize 保留 best_score', (float)$snap1['students'][0]['best_score'] === 95.0);
|
||||||
|
check('summarize 按学号排序', $snap1['students'][0]['sid'] === 'S01' && $snap1['students'][1]['sid'] === 'S02');
|
||||||
|
|
||||||
|
$trend = Snapshot::trend([$snap1, $snap2]);
|
||||||
|
check('trend 列数 = 2(两份快照)', count($trend['columns']) === 2, 'got ' . count($trend['columns']));
|
||||||
|
check('trend 列按时间升序(先第一次后第二次)', $trend['columns'][0]['label'] === '第一次' && $trend['columns'][1]['label'] === '第二次');
|
||||||
|
check('trend 行数 = 3(S01/S02/S03 并集)', count($trend['rows']) === 3, 'got ' . count($trend['rows']));
|
||||||
|
$s01 = null; foreach ($trend['rows'] as $r) { if ($r['sid'] === 'S01') $s01 = $r; }
|
||||||
|
check('S01 两次快照成绩 = 95 / 96', $s01 !== null && $s01['scores'][$trend['columns'][0]['key']] == 95 && $s01['scores'][$trend['columns'][1]['key']] == 96);
|
||||||
|
$s02 = null; foreach ($trend['rows'] as $r) { if ($r['sid'] === 'S02') $s02 = $r; }
|
||||||
|
check('S02 第二次快照缺席记 null', $s02 !== null && !array_key_exists($trend['columns'][1]['key'], $s02['scores']));
|
||||||
|
|
||||||
|
/* ============ 3) 排序:再次提交置顶 ============ */
|
||||||
|
echo "\n== 排序(last_submit_ts 置顶)==\n";
|
||||||
|
// 甲:代表成绩=最高分(98,早提交);最近一次提交=重考 70(晚提交)→ last_submit_ts 更晚,应排在乙前
|
||||||
|
$aggRows = [
|
||||||
|
['sid' => 'S01', 'name' => '甲', 'score' => 98.0, 'submit_ts' => 1000, 'last_submit_ts' => 5000, 'last_submit_at' => '2026-09-10 12:00:00'],
|
||||||
|
['sid' => 'S02', 'name' => '乙', 'score' => 90.0, 'submit_ts' => 2000, 'last_submit_ts' => 2000, 'last_submit_at' => '2026-09-10 10:00:00'],
|
||||||
|
];
|
||||||
|
$sortTs = static fn(array $r): int => (int)($r['last_submit_ts'] ?? 0) ?: (int)($r['submit_ts'] ?? 0);
|
||||||
|
usort($aggRows, static function (array $x, array $y) use ($sortTs): int {
|
||||||
|
$d = $sortTs($y) <=> $sortTs($x);
|
||||||
|
return $d !== 0 ? $d : strcmp((string)($x['sid'] ?? ''), (string)($y['sid'] ?? ''));
|
||||||
|
});
|
||||||
|
check('重考后的甲(last_submit_ts=5000)排在乙前', $aggRows[0]['sid'] === 'S01', json_encode($aggRows, JSON_UNESCAPED_UNICODE));
|
||||||
|
|
||||||
|
/* ============ 4) XlsxWriter 多工作表 ============ */
|
||||||
|
echo "\n== XlsxWriter 多工作表 ==\n";
|
||||||
|
$sheets = [
|
||||||
|
['name' => '1班', 'rows' => [['学号', '姓名'], ['S01', '甲']], 'widths' => [16, 14]],
|
||||||
|
['name' => '2班', 'rows' => [['学号', '姓名'], ['S02', '乙']], 'widths' => [16, 14]],
|
||||||
|
['name' => '成绩明细', 'rows' => [['学号', '成绩'], ['S03', 90]], 'widths' => [16, 11]],
|
||||||
|
['name' => '成绩明细', 'rows' => [['学号', '成绩'], ['S04', 80]], 'widths' => [16, 11]], // 重名 → 应去重
|
||||||
|
['name' => str_repeat('长', 40), 'rows' => [['X'], [1]], 'widths' => [10]], // 超长名 → 截断
|
||||||
|
];
|
||||||
|
$bin = XlsxWriter::renderMulti($sheets);
|
||||||
|
check('renderMulti 产出 PK ZIP', substr($bin, 0, 2) === 'PK');
|
||||||
|
|
||||||
|
$names = [];
|
||||||
|
if (class_exists('ZipArchive')) {
|
||||||
|
$tmp = __DIR__ . '/_multi.xlsx';
|
||||||
|
file_put_contents($tmp, $bin);
|
||||||
|
$z = new ZipArchive();
|
||||||
|
if ($z->open($tmp) === true) {
|
||||||
|
for ($i = 0; $i < $z->numFiles; $i++) {
|
||||||
|
$n = $z->getNameIndex($i);
|
||||||
|
if (strpos($n, 'xl/worksheets/sheet') === 0) {
|
||||||
|
$names[] = $n;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$z->close();
|
||||||
|
}
|
||||||
|
@unlink($tmp);
|
||||||
|
}
|
||||||
|
check('产出 5 个工作表文件', count($names) === 5, 'got ' . count($names) . ': ' . json_encode($names));
|
||||||
|
check('sheet 文件名连续 sheet1..sheet5', in_array('xl/worksheets/sheet5.xml', $names, true));
|
||||||
|
|
||||||
|
echo "\n========================================\n";
|
||||||
|
echo "通过 {$pass} · 失败 {$fail}\n";
|
||||||
|
exit($fail === 0 ? 0 : 1);
|
||||||
@@ -77,6 +77,8 @@ function chk(name, cond, extra) {
|
|||||||
|
|
||||||
function req(method, path, body, headers) {
|
function req(method, path, body, headers) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
|
// 写操作自动附带 CSRF token(后端对所有写 action 强制校验)
|
||||||
|
if (body && CSRF && !body._csrf) body = Object.assign({}, body, { _csrf: CSRF });
|
||||||
const data = body ? JSON.stringify(body) : '';
|
const data = body ? JSON.stringify(body) : '';
|
||||||
const h = Object.assign({
|
const h = Object.assign({
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -114,6 +116,7 @@ function req(method, path, body, headers) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
const cookieJar = [];
|
const cookieJar = [];
|
||||||
|
let CSRF = ''; // 从 config_get 取到的会话 CSRF token,POST 写请求自动附带
|
||||||
|
|
||||||
function formPost(path, fields) {
|
function formPost(path, fields) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
@@ -204,8 +207,7 @@ async function login() {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
// 之后用 ping 触发 SurveyKing 登录
|
// 之后用 ping 触发 SurveyKing 登录
|
||||||
const p1 = await req('GET', '/api.php?action=ping&_t=' + Date.now());
|
const p1 = await req('GET', '/api.php?action=ping&_t=' + Date.now()); console.log(' [debug] ping raw:', p1.status, 'loc=' + p1.loc, (p1.raw||'').slice(0, 200));
|
||||||
console.log(' [debug] ping raw:', p1.status, 'loc=' + p1.loc, (p1.raw||'').slice(0, 200));
|
|
||||||
chk('ping 200', p1.status === 200, 'status=' + p1.status + ' body=' + (p1.raw||'').slice(0, 200));
|
chk('ping 200', p1.status === 200, 'status=' + p1.status + ' body=' + (p1.raw||'').slice(0, 200));
|
||||||
chk('ping.ok', p1.body && p1.body.ok === true, JSON.stringify(p1.body));
|
chk('ping.ok', p1.body && p1.body.ok === true, JSON.stringify(p1.body));
|
||||||
await delay(100);
|
await delay(100);
|
||||||
@@ -218,6 +220,9 @@ async function login() {
|
|||||||
'storage=' + (cg1.body && cg1.body.data && cg1.body.data.storage));
|
'storage=' + (cg1.body && cg1.body.data && cg1.body.data.storage));
|
||||||
chk('不返回明文密码', cg1.body && cg1.body.data && cg1.body.data.password === undefined);
|
chk('不返回明文密码', cg1.body && cg1.body.data && cg1.body.data.password === undefined);
|
||||||
chk('has_password=true', cg1.body && cg1.body.data && cg1.body.data.has_password === true);
|
chk('has_password=true', cg1.body && cg1.body.data && cg1.body.data.has_password === true);
|
||||||
|
// 取 CSRF token,后续写请求自动附带
|
||||||
|
CSRF = (cg1.body && cg1.body.data && cg1.body.data.csrf_token) || '';
|
||||||
|
chk('config_get 返回 csrf_token', CSRF !== '', 'csrf=' + CSRF);
|
||||||
|
|
||||||
console.log('\n--- 3) config_save 不改连接信息:不应清 token / 不应触发 ping ---');
|
console.log('\n--- 3) config_save 不改连接信息:不应清 token / 不应触发 ping ---');
|
||||||
const before = fs.existsSync(tokenFile) ? fs.readFileSync(tokenFile, 'utf8') : '';
|
const before = fs.existsSync(tokenFile) ? fs.readFileSync(tokenFile, 'utf8') : '';
|
||||||
|
|||||||
Reference in New Issue
Block a user