Compare commits
5
Commits
6772aeae11
...
9ee2aec2f0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ee2aec2f0 | ||
|
|
c4c3bf7da1 | ||
|
|
d2f0908385 | ||
|
|
3952c77afe | ||
|
|
6cd17b9cb2 |
@@ -13,11 +13,7 @@ function cfg(): array
|
||||
return ny_load_config();
|
||||
}
|
||||
|
||||
// 会话/安装保护:除 auth_check 外,未安装或未登录一律拒访
|
||||
if (!ny_is_installed()) {
|
||||
ny_json(['ok' => false, 'msg' => '系统尚未安装,请先完成安装向导'], 503);
|
||||
exit;
|
||||
}
|
||||
// 会话保护:除 auth_check 外,未登录一律拒访
|
||||
if ($action !== 'auth_check' && !ny_is_auth()) {
|
||||
ny_json(['ok' => false, 'msg' => '未登录或会话已过期,请重新登录'], 401);
|
||||
exit;
|
||||
@@ -32,8 +28,14 @@ try {
|
||||
// ---------------- 配置 ----------------
|
||||
case 'config_get':
|
||||
$c = cfg();
|
||||
$c['has_password'] = $c['password'] !== '';
|
||||
$c['has_password'] = ((string)$c['password']) !== '';
|
||||
unset($c['password']);
|
||||
// 诊断信息:配置存在哪里、数据库通不通(登录卷王失败时优先看这里)
|
||||
$c['storage'] = ny_config_source();
|
||||
$c['db_driver'] = class_exists('Db') ? Db::driver() : '';
|
||||
$c['db_configured'] = class_exists('Db') && Db::isConfigured();
|
||||
$c['db_connected'] = class_exists('Db') && Db::isConnected();
|
||||
$c['db_error'] = class_exists('Db') ? Db::lastError() : '';
|
||||
ny_ok($c);
|
||||
|
||||
case 'config_save':
|
||||
@@ -43,13 +45,20 @@ try {
|
||||
$in = $_POST;
|
||||
}
|
||||
$old = cfg();
|
||||
$allowed = ['base_url', 'api_prefix', 'username', 'password', 'timeout', 'refresh_interval', 'page_size', 'name_keywords'];
|
||||
$allowed = ['base_url', 'api_prefix', 'username', 'timeout', 'refresh_interval', 'page_size', 'name_keywords'];
|
||||
$new = $old;
|
||||
foreach ($allowed as $k) {
|
||||
if (array_key_exists($k, $in)) {
|
||||
$new[$k] = is_string($in[$k]) ? trim((string)$in[$k]) : $in[$k];
|
||||
}
|
||||
}
|
||||
// 密码:保留首尾空格的原貌,空 / 占位符 = 不修改
|
||||
if (array_key_exists('password', $in)) {
|
||||
$pwdIn = (string)$in['password'];
|
||||
if ($pwdIn !== '' && $pwdIn !== '******') {
|
||||
$new['password'] = $pwdIn;
|
||||
}
|
||||
}
|
||||
foreach (['verify_ssl', 'include_temp_save', 'dedupe'] as $k) {
|
||||
if (array_key_exists($k, $in)) {
|
||||
$new[$k] = filter_var($in[$k], FILTER_VALIDATE_BOOL);
|
||||
@@ -63,33 +72,95 @@ try {
|
||||
if (array_key_exists('full_scores', $in) && is_array($in['full_scores'])) {
|
||||
$new['full_scores'] = $in['full_scores'];
|
||||
}
|
||||
$pwdIn = $in['password'] ?? null;
|
||||
if ($pwdIn === null || $pwdIn === '' || $pwdIn === '******') {
|
||||
$new['password'] = $old['password'];
|
||||
}
|
||||
$new['base_url'] = rtrim($new['base_url'], '/');
|
||||
$new['base_url'] = rtrim((string)$new['base_url'], '/');
|
||||
$new['api_prefix'] = '/' . trim((string)$new['api_prefix'], '/');
|
||||
$new['timeout'] = (int)$new['timeout'];
|
||||
$new['refresh_interval'] = (int)$new['refresh_interval'];
|
||||
if ($new['refresh_interval'] < 3) {
|
||||
$new['refresh_interval'] = 3;
|
||||
}
|
||||
// 每套试卷独立满分:选中试卷时写入 full_scores[projectId],否则更新全局默认
|
||||
// 默认满分
|
||||
$fs = (int)($in['full_score'] ?? $new['full_score'] ?? 100);
|
||||
if ($fs < 1) {
|
||||
$fs = 100;
|
||||
}
|
||||
$pid = trim((string)($in['project_id'] ?? ''));
|
||||
if ($pid !== '') {
|
||||
$scores = is_array($new['full_scores'] ?? null) ? $new['full_scores'] : [];
|
||||
$scores[$pid] = $fs;
|
||||
$new['full_scores'] = $scores;
|
||||
} else {
|
||||
$new['full_score'] = $fs;
|
||||
// 各试卷独立满分:仅当显式指定 apply_pid 时才写入/清除该卷(修复 bug:之前会误用默认满分覆盖当前卷)
|
||||
$applyPid = trim((string)($in['apply_pid'] ?? ''));
|
||||
if ($applyPid !== '') {
|
||||
$scores = is_array($new['full_scores'] ?? null) ? $new['full_scores'] : [];
|
||||
$applyScore = (int)($in['apply_score'] ?? 0);
|
||||
if ($applyScore > 0) {
|
||||
$scores[$applyPid] = $applyScore;
|
||||
} else {
|
||||
unset($scores[$applyPid]);
|
||||
}
|
||||
$new['full_scores'] = $scores;
|
||||
}
|
||||
// 仅当连接信息真的变了才清 token + 重连;保存满分/布局等不应触发登录卷王
|
||||
$connKeys = ['base_url', 'api_prefix', 'username', 'password', 'verify_ssl'];
|
||||
$connChanged = false;
|
||||
foreach ($connKeys as $k) {
|
||||
if ((string)($old[$k] ?? '') !== (string)($new[$k] ?? '')) {
|
||||
$connChanged = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
ny_save_config($new);
|
||||
@unlink(NY_DATA . '/cache/token.json'); // 清掉旧令牌
|
||||
ny_ok(['saved' => true, 'config' => ny_load_config()]);
|
||||
if ($connChanged) {
|
||||
@unlink(NY_DATA . '/cache/token.json');
|
||||
}
|
||||
// 返回的 config 不再带明文密码
|
||||
$outCfg = ny_load_config();
|
||||
$outCfg['has_password'] = ((string)$outCfg['password']) !== '';
|
||||
unset($outCfg['password']);
|
||||
$res = [
|
||||
'saved' => true,
|
||||
'config' => $outCfg,
|
||||
'conn_changed' => $connChanged,
|
||||
'storage' => ny_config_source(),
|
||||
];
|
||||
// 连接信息修改后自动测一次,给出明确结果(避免用户配置对错无从知晓)
|
||||
if ($connChanged) {
|
||||
try {
|
||||
$client = new SurveyKingClient(ny_load_config());
|
||||
$p = $client->ping();
|
||||
$res['ping'] = ['ok' => true, 'projects' => $p['projects'], 'elapsed' => $p['elapsed']];
|
||||
} catch (Throwable $e) {
|
||||
$res['ping'] = ['ok' => false, 'msg' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
ny_ok($res);
|
||||
|
||||
// ---------------- 用弹窗内(未保存)的凭据测试连接 ----------------
|
||||
case 'config_test':
|
||||
$raw = (string)file_get_contents('php://input');
|
||||
$in = json_decode($raw, true);
|
||||
if (!is_array($in)) {
|
||||
$in = $_POST;
|
||||
}
|
||||
// 以已保存配置为基底,仅覆盖弹窗中填写的字段,避免漏填导致误测
|
||||
$test = ny_load_config();
|
||||
foreach (['base_url', 'api_prefix', 'username', 'verify_ssl', 'timeout'] as $k) {
|
||||
if (array_key_exists($k, $in)) {
|
||||
$test[$k] = is_string($in[$k]) ? trim((string)$in[$k]) : $in[$k];
|
||||
}
|
||||
}
|
||||
if (array_key_exists('password', $in)) {
|
||||
$pwdIn = (string)$in['password'];
|
||||
if ($pwdIn !== '' && $pwdIn !== '******') {
|
||||
$test['password'] = $pwdIn;
|
||||
}
|
||||
}
|
||||
$test['base_url'] = rtrim((string)($test['base_url'] ?? ''), '/');
|
||||
$test['api_prefix'] = '/' . trim((string)($test['api_prefix'] ?? '/api'), '/');
|
||||
try {
|
||||
$client = new SurveyKingClient($test);
|
||||
$p = $client->ping();
|
||||
ny_ok(['connected' => true, 'projects' => $p['projects'], 'elapsed' => $p['elapsed']]);
|
||||
} catch (Throwable $e) {
|
||||
ny_ok(['connected' => false, 'msg' => $e->getMessage()]);
|
||||
}
|
||||
|
||||
// ---------------- 连接测试 ----------------
|
||||
case 'ping':
|
||||
@@ -176,6 +247,7 @@ try {
|
||||
$statOf = static function (array $stu, array $rs, int $fs): array {
|
||||
$total = count($stu);
|
||||
$submitted = count($rs);
|
||||
$attempts = array_sum(array_map(static fn($r) => (int)($r['attempts'] ?? 1), $rs));
|
||||
$scores = array_values(array_filter(array_map(static fn($r) => $r['score'] ?? null, $rs), static fn($v) => $v !== null));
|
||||
$avg = $scores !== [] ? round(array_sum($scores) / count($scores), 2) : null;
|
||||
$pass = count(array_filter($rs, static fn($r) => ($r['score'] ?? null) !== null && ($r['score'] >= 0.6 * $fs)));
|
||||
@@ -185,6 +257,7 @@ try {
|
||||
return [
|
||||
'total' => $total,
|
||||
'submitted' => $submitted,
|
||||
'attempts' => $attempts,
|
||||
'absent' => max(0, $total - $submitted),
|
||||
'rate' => $total > 0 ? round($submitted / $total * 100, 1) : 0,
|
||||
'avg' => $avg,
|
||||
@@ -196,6 +269,7 @@ try {
|
||||
};
|
||||
|
||||
// 已提交成员榜:按成绩从高到低,缺成绩者排最后,并给出排名
|
||||
// 透传 attempts / attempts_list,供前端展示"同人多刷卷"
|
||||
$membersOf = static function (array $rs): array {
|
||||
$ms = [];
|
||||
foreach ($rs as $r) {
|
||||
@@ -203,6 +277,8 @@ try {
|
||||
'sid' => (string)($r['sid'] ?? ''),
|
||||
'name' => (string)($r['name'] ?? ''),
|
||||
'score' => ($r['score'] ?? null),
|
||||
'attempts' => (int)($r['attempts'] ?? 1),
|
||||
'attempts_list' => $r['attempts_list'] ?? [],
|
||||
];
|
||||
}
|
||||
usort($ms, static function (array $a, array $b): int {
|
||||
@@ -299,7 +375,13 @@ try {
|
||||
} else {
|
||||
Roster::save($res['students']);
|
||||
}
|
||||
ny_ok(['students' => Roster::all(), 'meta' => Roster::meta(), 'imported' => $res['count']]);
|
||||
ny_ok([
|
||||
'students' => Roster::all(),
|
||||
'meta' => Roster::meta(),
|
||||
'imported' => $res['count'],
|
||||
'skipped' => $res['skipped'] ?? 0,
|
||||
'duplicates' => $res['duplicates'] ?? 0,
|
||||
]);
|
||||
|
||||
case 'roster_save':
|
||||
$raw = (string)file_get_contents('php://input');
|
||||
@@ -399,7 +481,7 @@ try {
|
||||
fputcsv($out, [$r['sid'] ?? '', $r['name'] ?? '', $r['cls'] ?? '', $r['grp'] ?? '']);
|
||||
}
|
||||
} else {
|
||||
fputcsv($out, ['学号', '姓名', '班级', '组别', '成绩', '提交时间', '考试时长', '考试时长(秒)']);
|
||||
fputcsv($out, ['学号', '姓名', '班级', '组别', '成绩', '提交时间', '考试时长', '考试时长(秒)', '刷题次数']);
|
||||
foreach ($rows as $r) {
|
||||
fputcsv($out, [
|
||||
$r['sid'] ?? '',
|
||||
@@ -410,6 +492,7 @@ try {
|
||||
$r['submit_at'] ?? '',
|
||||
$r['duration_txt'] ?? '',
|
||||
$r['duration'] === null ? '' : $r['duration'],
|
||||
(int)($r['attempts'] ?? 1),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -455,7 +538,13 @@ try {
|
||||
ny_fail('未知操作:' . $action);
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
ny_fail($e->getMessage(), [
|
||||
$msg = $e->getMessage();
|
||||
// 鉴权类错误(账号未配置 / 登录卷王失败)打标,前端据此跳登录页自愈
|
||||
$isAuth = str_contains($msg, '未配置卷王登录账号')
|
||||
|| str_contains($msg, '登录卷王失败')
|
||||
|| str_contains($msg, '登录失败');
|
||||
ny_fail($msg, [
|
||||
'auth' => $isAuth,
|
||||
'trace' => defined('NY_DEBUG') && NY_DEBUG ? $e->getTraceAsString() : null,
|
||||
]);
|
||||
}
|
||||
|
||||
+128
-15
@@ -21,7 +21,8 @@
|
||||
sortAsc: false,
|
||||
timer: null,
|
||||
loading: false,
|
||||
lastSig: ''
|
||||
lastSig: '',
|
||||
connFail: 0
|
||||
};
|
||||
|
||||
/* ---------------- 本地存储 ---------------- */
|
||||
@@ -41,6 +42,7 @@
|
||||
const LS_LASTQID = 'ny_last_qid';
|
||||
const LS_FILTERS = 'ny_filters'; // {cls, grp} -> 跟随 projectId
|
||||
const LS_LAYOUT = 'ny_layout';
|
||||
const LS_INTERVAL = 'ny_interval'; // 用户手动调整的刷新间隔(跟随本机)
|
||||
const LS_ROSTER = 'ny_roster_cache'; // 名单 cache(用于首屏补齐班级/组别下拉)
|
||||
const dataKey = pid => 'ny_data_' + pid;
|
||||
|
||||
@@ -70,7 +72,12 @@
|
||||
try { json = JSON.parse(text); } catch (e) {
|
||||
throw new Error('服务端返回异常(可能 PHP 报错):' + text.slice(0, 300));
|
||||
}
|
||||
if (!json.ok) throw new Error(json.msg || '请求失败');
|
||||
if (!json.ok) {
|
||||
const err = new Error(json.msg || '请求失败');
|
||||
err.auth = !!(json.extra && json.extra.auth);
|
||||
err.extra = json.extra || null;
|
||||
throw err;
|
||||
}
|
||||
return json.data;
|
||||
}
|
||||
|
||||
@@ -238,16 +245,26 @@
|
||||
try {
|
||||
const d = await api('data', { params: { projectId: pid, nameQid: $('#nameFieldSel').value } });
|
||||
applyData(d);
|
||||
state.connFail = 0;
|
||||
LS.set(dataKey(pid), d);
|
||||
LS.set(LS_LASTPID, pid);
|
||||
LS.set(LS_LASTQID, $('#nameFieldSel').value || '');
|
||||
// 名单缓存一份(便于刷新前/失败时仍能渲染班级/组别下拉)
|
||||
LS.set(LS_ROSTER, { ts: Date.now(), students: d.roster || [] });
|
||||
} catch (e) {
|
||||
if (e.auth) {
|
||||
// 卷王账号未配置或登录已失效:引导重新登录,避免卡在"已显示本地缓存"的死状态
|
||||
showAlert('卷王账号未配置或登录已失效,正在跳转登录页…', 'error');
|
||||
setTimeout(() => { location.href = 'login.php'; }, 900);
|
||||
return;
|
||||
}
|
||||
if (!cached) {
|
||||
if (!silent) setConn('conn-err', '数据获取失败');
|
||||
showAlert(e.message);
|
||||
} else {
|
||||
// 后台静默刷新失败:保留缓存数据,但把连接指示转为"中断"告警,成功即恢复
|
||||
state.connFail++;
|
||||
if (state.connFail >= 1) setConn('conn-warn', '⚠️ 实时刷新中断 · 已显示本地缓存');
|
||||
showAlert('⚠️ 后台刷新失败,已显示本地缓存:' + e.message);
|
||||
}
|
||||
} finally {
|
||||
@@ -447,10 +464,13 @@
|
||||
const sig = r.sid + '|' + r.score + '|' + r.submit_at;
|
||||
const changed = state.prevMap[r.sid] && state.prevMap[r.sid] !== sig;
|
||||
const isNew = r.sid && state.newSids.has(r.sid);
|
||||
const clsName = (changed ? 'diff ' : '') + (isNew ? 'new' : '');
|
||||
return '<tr class="' + clsName.trim() + '">' +
|
||||
const canDrill = (r.attempts || 1) > 1;
|
||||
const clsName = (changed ? 'diff ' : '') + (isNew ? 'new ' : '') + (canDrill ? 'clickable' : '');
|
||||
const att = canDrill ? ' <span class="tag tag-att" title="查看 ' + r.attempts + ' 次提交明细">刷' + r.attempts + '次</span>' : '';
|
||||
const onclk = canDrill ? ' onclick="openAttemptsModal(' + JSON.stringify(r.sid) + ')"' : '';
|
||||
return '<tr class="' + clsName.trim() + '"' + onclk + '>' +
|
||||
'<td class="sid">' + esc(r.sid) + '</td>' +
|
||||
'<td class="name">' + esc(r.name) + '</td>' +
|
||||
'<td class="name">' + esc(r.name) + att + '</td>' +
|
||||
'<td>' + esc(r.cls || '') + '</td>' +
|
||||
'<td>' + esc(r.grp || '') + '</td>' +
|
||||
'<td class="score num ' + scoreClass(r.score) + '">' + (r.score == null ? '-' : r.score) + '</td>' +
|
||||
@@ -499,6 +519,28 @@
|
||||
'</td><td class="num">' + esc(u.submit_at) + '</td></tr>').join('');
|
||||
}
|
||||
|
||||
/* 多次刷题下钻弹窗(主统计页成绩明细) */
|
||||
function openAttemptsModal(sid) {
|
||||
const r = (state.rows || []).find(x => String(x.sid) === String(sid));
|
||||
if (!r || (r.attempts || 1) <= 1) return;
|
||||
const list = r.attempts_list || [];
|
||||
const rows = list.map(a => {
|
||||
const sc = a.score == null ? '<span class="muted">未判分</span>' : esc(a.score);
|
||||
const tmp = a.temp_save ? ' <span class="tag tag-gray">暂存</span>' : '';
|
||||
return '<tr><td class="num">' + esc(a.submit_at) + '</td><td class="num">' + sc + tmp +
|
||||
'</td><td class="num">' + esc(a.duration_txt || '-') + '</td></tr>';
|
||||
}).join('');
|
||||
$('#attTitle').textContent = (r.name || '') + ' · 共 ' + r.attempts + ' 次提交';
|
||||
$('#attBody').innerHTML = list.length
|
||||
? '<table class="grid compact"><thead><tr><th>提交时间</th><th>成绩</th><th>考试时长</th></tr></thead><tbody>' + rows + '</tbody></table>'
|
||||
: '<div class="empty">无提交明细</div>';
|
||||
$('#attemptsModal').classList.add('open');
|
||||
}
|
||||
function closeAttemptsModal() {
|
||||
const m = $('#attemptsModal');
|
||||
if (m) m.classList.remove('open');
|
||||
}
|
||||
|
||||
/* ---------------- 自动刷新 ---------------- */
|
||||
function schedule() {
|
||||
if (state.timer) clearInterval(state.timer);
|
||||
@@ -550,11 +592,17 @@
|
||||
// 满分:默认 + 各试卷独立
|
||||
$('#cfgFull').value = c.full_score || 100;
|
||||
renderFullScores((c.full_scores && typeof c.full_scores === 'object') ? c.full_scores : {});
|
||||
const pid = $('#projectSel').value;
|
||||
const pObj = state.projects.find(p => p.id === pid);
|
||||
$('#cfgFullHint').textContent = pid
|
||||
? ('当前选中「' + (pObj ? pObj.name : pid) + '」,如需单独设满分请在上方"各试卷独立满分"修改')
|
||||
: '未选中试卷时,作为所有试卷的默认值';
|
||||
// 连接信息(账号回显;密码不回显)
|
||||
$('#cfgBase').value = (c.base_url || '') + (c.api_prefix || '/api');
|
||||
$('#cfgUser').value = c.username || '';
|
||||
$('#cfgPass').value = '';
|
||||
const conn = c.db_configured
|
||||
? (c.db_connected
|
||||
? '数据库:已连接 (' + (c.db_driver || '') + ')'
|
||||
: ('数据库:未连接 · ' + (c.db_error || '未知错误')))
|
||||
: '数据库:未配置(使用文件存储)';
|
||||
$('#cfgConnInfo').textContent = '配置来源:' + (c.storage || 'default') + ' · ' + conn;
|
||||
$('#pingResult').classList.add('hidden');
|
||||
} catch (e) { showAlert(e.message); }
|
||||
$('#settingsModal').classList.add('open');
|
||||
}
|
||||
@@ -655,7 +703,10 @@
|
||||
// 用缓存数据先快速渲染,再后台拉新数据
|
||||
refresh();
|
||||
});
|
||||
$('#intervalSel').addEventListener('change', schedule);
|
||||
$('#intervalSel').addEventListener('change', () => {
|
||||
LS.set(LS_INTERVAL, parseInt($('#intervalSel').value, 10) || 15);
|
||||
schedule();
|
||||
});
|
||||
$('#autoRefresh').addEventListener('change', e => {
|
||||
if (e.target.checked) { schedule(); refresh(true); }
|
||||
else if (state.timer) clearInterval(state.timer);
|
||||
@@ -698,6 +749,9 @@
|
||||
|
||||
/* 设置保存 */
|
||||
$('#btnSaveCfg').addEventListener('click', async () => {
|
||||
const btnSave = $('#btnSaveCfg');
|
||||
btnSave.disabled = true; btnSave.textContent = '保存中…';
|
||||
const pass = $('#cfgPass').value;
|
||||
const payload = {
|
||||
page_size: parseInt($('#cfgPageSize').value, 10) || 200,
|
||||
refresh_interval: parseInt($('#cfgInterval').value, 10) || 15,
|
||||
@@ -709,11 +763,24 @@
|
||||
full_score: parseInt($('#cfgFull').value, 10) || 100,
|
||||
layout: (document.querySelector('input[name=cfgLayout]:checked') || {}).value || 'default',
|
||||
full_scores: collectFullScores(),
|
||||
project_id: $('#projectSel').value
|
||||
username: $('#cfgUser').value.trim(),
|
||||
};
|
||||
if (pass !== '') payload.password = pass; // 留空 = 不修改
|
||||
let resp;
|
||||
try {
|
||||
await api('config_save', { method: 'POST', body: payload });
|
||||
$('#settingsModal').classList.remove('open');
|
||||
resp = await api('config_save', { method: 'POST', body: payload });
|
||||
} catch (e) { showAlert(e.message); btnSave.disabled = false; btnSave.textContent = '保存'; return; }
|
||||
$('#cfgPass').value = ''; // 提交后清空密码框
|
||||
const note = (resp && resp.conn_changed) ? '账号/连接已更新' : '设置已保存';
|
||||
if (resp && resp.ping) {
|
||||
if (resp.ping.ok) {
|
||||
showAlert(note + ',测连接成功(' + resp.ping.projects + ' 个项目 / ' + resp.ping.elapsed + 'ms)', 'info');
|
||||
} else {
|
||||
showAlert(note + ',但测连接失败:' + resp.ping.msg, 'error');
|
||||
}
|
||||
} else {
|
||||
showAlert(note, 'info');
|
||||
}
|
||||
// 应用布局偏好(写入容器 + localStorage)
|
||||
applyLayout(payload.layout);
|
||||
const iv = payload.refresh_interval;
|
||||
@@ -723,9 +790,48 @@
|
||||
$('#intervalSel').value = String(best);
|
||||
schedule();
|
||||
loadProjects();
|
||||
} catch (e) { showAlert(e.message); }
|
||||
// ping 失败时保留弹窗,让用户能立即改回
|
||||
if (!resp || !resp.ping || resp.ping.ok) {
|
||||
$('#settingsModal').classList.remove('open');
|
||||
}
|
||||
btnSave.disabled = false; btnSave.textContent = '保存';
|
||||
});
|
||||
|
||||
/* 独立测试连接(使用弹窗内尚未保存的账号/密码,不落盘) */
|
||||
async function testConnection() {
|
||||
const btn = $('#btnTestConn');
|
||||
const box = $('#pingResult');
|
||||
if (!btn || !box) return;
|
||||
btn.disabled = true; btn.textContent = '测试中…';
|
||||
box.classList.remove('hidden', 'err'); box.classList.add('loading');
|
||||
box.textContent = '正在连接卷王系统测试…';
|
||||
try {
|
||||
// 仅传可编辑的连接字段;base_url / api_prefix 在弹窗中只读,沿用已保存值
|
||||
const r = await api('config_test', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
username: $('#cfgUser').value.trim(),
|
||||
password: $('#cfgPass').value,
|
||||
verify_ssl: $('#cfgSsl').checked
|
||||
}
|
||||
});
|
||||
box.classList.remove('loading');
|
||||
if (r && r.connected) {
|
||||
box.classList.remove('err');
|
||||
box.textContent = '✅ 连接成功:检测到 ' + r.projects + ' 个问卷 / 用时 ' + r.elapsed + 'ms';
|
||||
} else {
|
||||
box.classList.add('err');
|
||||
box.textContent = '❌ 连接失败:' + ((r && r.msg) || '未知错误');
|
||||
}
|
||||
} catch (e) {
|
||||
box.classList.remove('loading'); box.classList.add('err');
|
||||
box.textContent = '❌ 测试请求异常:' + e.message;
|
||||
} finally {
|
||||
btn.disabled = false; btn.textContent = '测试连接';
|
||||
}
|
||||
}
|
||||
$('#btnTestConn').addEventListener('click', testConnection);
|
||||
|
||||
// 收集"各试卷独立满分" -> {projectId: score}
|
||||
function collectFullScores() {
|
||||
const out = {};
|
||||
@@ -812,6 +918,13 @@
|
||||
// 2) 支持 ?projectId=xxx&nameQid=yyy 直接打开某份问卷
|
||||
const qs = new URLSearchParams(location.search);
|
||||
if (qs.get('interval')) $('#intervalSel').value = qs.get('interval');
|
||||
else {
|
||||
const iv = LS.get(LS_INTERVAL, 0);
|
||||
if (iv) {
|
||||
const opts = $$('#intervalSel option').map(o => parseInt(o.value, 10));
|
||||
if (opts.indexOf(iv) >= 0) $('#intervalSel').value = String(iv);
|
||||
}
|
||||
}
|
||||
state.deeplinkProject = qs.get('projectId') || '';
|
||||
state.deeplinkQid = qs.get('nameQid') || '';
|
||||
|
||||
|
||||
+228
-39
@@ -33,6 +33,7 @@
|
||||
history: new Set(), // 累计抽中(重置结果时清零)
|
||||
excluded: new Set(), // 手动排除
|
||||
log: [], // 历史批次,新的在前
|
||||
customRatios: {}, // 自定义比例:{ 组名: 权重 }
|
||||
drawing: false,
|
||||
};
|
||||
|
||||
@@ -77,38 +78,91 @@
|
||||
}
|
||||
|
||||
/**
|
||||
* 名额均分到各组(5:5 语义):每组 n/组数,余数轮流补,
|
||||
* 某组人数不足时把差额退回分给未满的组(结果之和 === min(n, 总人数))
|
||||
* 名额分配:
|
||||
* - sizes 各组容量
|
||||
* - n 要抽的总人数
|
||||
* - weights 各组权重(null=均分;数组=按权重,权重<=0 视为 0)
|
||||
* 算法:最大余额法(largest remainder)—— raw_i = n * w_i / Σw;floor 后把剩余名额
|
||||
* 按小数部分降序分给还没满的组。均分模式是 weights=null 的特例(每组权重 1),
|
||||
* 余数时按"组容量小者优先"补齐,保证每组都有代表。
|
||||
*/
|
||||
function allocQuotas(sizes, n) {
|
||||
function allocQuotas(sizes, n, weights) {
|
||||
const g = sizes.length;
|
||||
const quota = new Array(g).fill(0);
|
||||
if (g === 0 || n <= 0) return quota;
|
||||
const total = sizes.reduce((a, b) => a + b, 0);
|
||||
if (n >= total) return sizes.slice(); // 全量
|
||||
|
||||
// 1) 均分初值 + 余数补齐(小组优先,保证每组有代表)
|
||||
const base = Math.floor(n / g);
|
||||
quota.fill(base);
|
||||
let r = n - base * g;
|
||||
const bySmall = sizes.map((s, i) => i).sort((a, b) => sizes[a] - sizes[b]);
|
||||
for (let k = 0; k < r; k++) quota[bySmall[k % g]]++;
|
||||
const base = new Array(g).fill(0);
|
||||
const frac = new Array(g).fill(0);
|
||||
|
||||
// 2) 超过组容量的退回
|
||||
let overflow = 0;
|
||||
if (Array.isArray(weights) && weights.length === g) {
|
||||
const w = weights.map(x => (Number(x) > 0 ? Number(x) : 0));
|
||||
const wsum = w.reduce((a, b) => a + b, 0);
|
||||
if (wsum > 0) {
|
||||
// 按权重分配:最大余额法
|
||||
for (let i = 0; i < g; i++) {
|
||||
if (quota[i] > sizes[i]) { overflow += quota[i] - sizes[i]; quota[i] = sizes[i]; }
|
||||
const raw = n * w[i] / wsum;
|
||||
base[i] = Math.floor(raw);
|
||||
frac[i] = raw - base[i];
|
||||
}
|
||||
// 3) 差额分给未满的组(剩余容量大者优先)
|
||||
while (overflow > 0) {
|
||||
const byRoom = sizes.map((s, i) => i)
|
||||
.sort((a, b) => (sizes[b] - quota[b]) - (sizes[a] - quota[a]));
|
||||
} else {
|
||||
// 全 0 权重 → 退化为均分
|
||||
const b = Math.floor(n / g);
|
||||
for (let i = 0; i < g; i++) base[i] = b;
|
||||
const rem = n - b * g;
|
||||
const bySmall = sizes.map((s, i) => i)
|
||||
.sort((a, b2) => sizes[a] - sizes[b2] || a - b2);
|
||||
for (let k = 0; k < rem; k++) base[bySmall[k % g]]++;
|
||||
}
|
||||
} else {
|
||||
// 名额均分:每组 n/g,余数小组优先(保证每组都有代表)
|
||||
const b = Math.floor(n / g);
|
||||
for (let i = 0; i < g; i++) base[i] = b;
|
||||
const rem = n - b * g;
|
||||
const bySmall = sizes.map((s, i) => i)
|
||||
.sort((a, b2) => sizes[a] - sizes[b2] || a - b2);
|
||||
for (let k = 0; k < rem; k++) base[bySmall[k % g]]++;
|
||||
}
|
||||
|
||||
// 余数补齐:frac 大的优先;同 frac 时剩余容量大的优先
|
||||
let rest = n - base.reduce((a, b) => a + b, 0);
|
||||
if (rest > 0) {
|
||||
const order = sizes.map((s, i) => i).sort((a, b) =>
|
||||
(frac[b] - frac[a]) || ((sizes[b] - base[b]) - (sizes[a] - base[a])) || (a - b));
|
||||
for (let k = 0; rest > 0 && k < g; k++) {
|
||||
const i = order[k % g];
|
||||
if (base[i] < sizes[i]) { base[i]++; rest--; }
|
||||
}
|
||||
// 若仍有余数(组容量限制),继续按剩余容量大者分配
|
||||
while (rest > 0) {
|
||||
let progress = false;
|
||||
const byRoom = sizes.map((s, i) => i)
|
||||
.sort((a, b) => ((sizes[b] - base[b]) - (sizes[a] - base[a])) || (a - b));
|
||||
for (const i of byRoom) {
|
||||
if (overflow <= 0) break;
|
||||
if (quota[i] < sizes[i]) { quota[i]++; overflow--; progress = true; }
|
||||
if (rest <= 0) break;
|
||||
if (base[i] < sizes[i]) { base[i]++; rest--; progress = true; }
|
||||
}
|
||||
if (!progress) break;
|
||||
}
|
||||
return quota;
|
||||
}
|
||||
|
||||
// 超容量退回(极端比例时兜底)
|
||||
let overflow = 0;
|
||||
for (let i = 0; i < g; i++) {
|
||||
if (base[i] > sizes[i]) { overflow += base[i] - sizes[i]; base[i] = sizes[i]; }
|
||||
}
|
||||
while (overflow > 0) {
|
||||
let progress = false;
|
||||
const byRoom = sizes.map((s, i) => i)
|
||||
.sort((a, b) => ((sizes[b] - base[b]) - (sizes[a] - base[a])) || (a - b));
|
||||
for (const i of byRoom) {
|
||||
if (overflow <= 0) break;
|
||||
if (base[i] < sizes[i]) { base[i]++; overflow--; progress = true; }
|
||||
}
|
||||
if (!progress) break;
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -123,10 +177,11 @@
|
||||
}
|
||||
|
||||
/**
|
||||
* 分组均衡抽取:返回 { picked, detail }
|
||||
* detail 例:["A 3人", "B 3人"],用于提示与历史记录
|
||||
* 分组均衡抽取:返回 { picked, detail, sizes, weights }
|
||||
* weights 数组为各组实际权重(默认加权时=各组人数)
|
||||
* detail 例:["A 6人", "B 4人"],用于提示与历史记录
|
||||
*/
|
||||
function pickBalanced(pool, n, key) {
|
||||
function pickBalanced(pool, n, key, weights) {
|
||||
const label = key === 'grp' ? '(未分组)' : '(未分班)';
|
||||
const groups = new Map();
|
||||
pool.forEach(m => {
|
||||
@@ -137,7 +192,10 @@
|
||||
const gk = Array.from(groups.keys())
|
||||
.sort((a, b) => String(a).localeCompare(String(b), 'zh', { numeric: true }));
|
||||
const sizes = gk.map(k => groups.get(k).length);
|
||||
const quotas = allocQuotas(sizes, n);
|
||||
const w = (Array.isArray(weights) && weights.length === gk.length)
|
||||
? weights
|
||||
: null;
|
||||
const quotas = allocQuotas(sizes, n, w);
|
||||
const picked = [];
|
||||
const detail = [];
|
||||
gk.forEach((k, i) => {
|
||||
@@ -145,7 +203,7 @@
|
||||
picked.push(...part);
|
||||
if (quotas[i] > 0) detail.push(k + ' ' + quotas[i] + '人');
|
||||
});
|
||||
return { picked, detail };
|
||||
return { picked, detail, sizes, weights: w || sizes.slice() };
|
||||
}
|
||||
|
||||
// 测试钩子(node 端 eval 后通过 window.__nyDrawTest 调用)
|
||||
@@ -278,6 +336,36 @@
|
||||
if (ec) ec.textContent = '已排除 ' + state.excluded.size + ' 人';
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算当前候选池的分组信息:{ key, gk[], sizes[] }
|
||||
* key = 'grp' | 'cls' | ''
|
||||
* gk 按字典序排序后的组名
|
||||
* sizes 每组成员数
|
||||
*/
|
||||
function poolGroups(pool) {
|
||||
const key = balanceKeyOf(pool);
|
||||
if (!key) return { key: '', gk: [], sizes: [] };
|
||||
const label = key === 'grp' ? '(未分组)' : '(未分班)';
|
||||
const groups = new Map();
|
||||
pool.forEach(m => {
|
||||
const k = String(m[key] || '').trim() || label;
|
||||
if (!groups.has(k)) groups.set(k, 0);
|
||||
groups.set(k, groups.get(k) + 1);
|
||||
});
|
||||
const gk = Array.from(groups.keys())
|
||||
.sort((a, b) => String(a).localeCompare(String(b), 'zh', { numeric: true }));
|
||||
return { key, gk, sizes: gk.map(k => groups.get(k)) };
|
||||
}
|
||||
|
||||
/** 把对象 {gk:weight} 读成与当前池分组顺序一致的数组,缺组视为 0 */
|
||||
function readCustomWeights(gk) {
|
||||
const src = state.customRatios || {};
|
||||
return gk.map(k => {
|
||||
const v = parseFloat(src[k]);
|
||||
return Number.isFinite(v) && v > 0 ? v : 0;
|
||||
});
|
||||
}
|
||||
|
||||
/* ================= 抽取 ================= */
|
||||
async function doDraw() {
|
||||
if (state.drawing) return;
|
||||
@@ -305,18 +393,23 @@
|
||||
await sleep(40 + i * 7);
|
||||
}
|
||||
|
||||
// 真正抽取:均衡(组别优先,其次班级)或整池随机
|
||||
// 真正抽取:均衡(按模式分组) 或整池随机
|
||||
let picked, detail = [];
|
||||
if ($('#balanceChk').checked) {
|
||||
const key = balanceKeyOf(pool);
|
||||
if (key) {
|
||||
const res = pickBalanced(pool, n, key);
|
||||
const mode = $('#balanceMode').value;
|
||||
if (mode === 'off') {
|
||||
picked = shuffle(pool).slice(0, n);
|
||||
} else {
|
||||
const g = poolGroups(pool);
|
||||
if (g.key) {
|
||||
let weights = null;
|
||||
if (mode === 'weight') weights = g.sizes.slice(); // 默认按组人数加权
|
||||
else if (mode === 'custom') weights = readCustomWeights(g.gk);
|
||||
// mode === 'even' 走 allocQuotas(null) → 均分
|
||||
const res = pickBalanced(pool, n, g.key, weights);
|
||||
picked = res.picked; detail = res.detail;
|
||||
} else {
|
||||
picked = shuffle(pool).slice(0, n);
|
||||
}
|
||||
} else {
|
||||
picked = shuffle(pool).slice(0, n);
|
||||
}
|
||||
|
||||
state.picked = picked;
|
||||
@@ -329,7 +422,7 @@
|
||||
|
||||
renderResult();
|
||||
renderStats();
|
||||
pushLog(picked, detail);
|
||||
pushLog(picked, detail, mode);
|
||||
saveState();
|
||||
|
||||
state.drawing = false;
|
||||
@@ -356,12 +449,13 @@
|
||||
}
|
||||
|
||||
/* ================= 历史记录 ================= */
|
||||
function pushLog(picked, detail) {
|
||||
function pushLog(picked, detail, mode) {
|
||||
const modeTxt = ({ weight: '按人数加权', even: '均分', custom: '自定义比例', off: '不分组' })[mode] || '';
|
||||
state.log.unshift({
|
||||
ts: new Date().toLocaleString('zh-CN', { hour12: false }),
|
||||
source: state.source === 'project' ? '已提交人员' : '学号名单',
|
||||
range: rangeText(),
|
||||
balance: detail.join(' / '),
|
||||
balance: (modeTxt ? modeTxt + ':' : '') + (detail || []).join(' / '),
|
||||
count: picked.length,
|
||||
members: picked.map(m => ({
|
||||
sid: m.sid, name: m.name, cls: m.cls || '', grp: m.grp || '', login: m.login || '',
|
||||
@@ -498,6 +592,14 @@
|
||||
|
||||
function saveSettings() {
|
||||
try {
|
||||
// 收集自定义比例输入
|
||||
const cr = {};
|
||||
$$('#ratioBox input[data-gk]').forEach(inp => {
|
||||
const k = inp.dataset.gk;
|
||||
const v = parseFloat(inp.value);
|
||||
if (k && Number.isFinite(v) && v > 0) cr[k] = v;
|
||||
});
|
||||
state.customRatios = cr;
|
||||
localStorage.setItem(LS_SETTINGS, JSON.stringify({
|
||||
source: state.source,
|
||||
projectId: $('#projectSel').value,
|
||||
@@ -505,7 +607,8 @@
|
||||
grp: $('#grpSel').value,
|
||||
num: $('#numInput').value,
|
||||
exclude: $('#excludePicked').checked,
|
||||
balance: $('#balanceChk').checked,
|
||||
mode: $('#balanceMode').value,
|
||||
customRatios: cr,
|
||||
}));
|
||||
} catch (e) { /* 忽略 */ }
|
||||
}
|
||||
@@ -548,13 +651,14 @@
|
||||
}
|
||||
populateFilters();
|
||||
renderStats();
|
||||
renderRatioBox();
|
||||
if ($('#exclModal').classList.contains('open')) renderExcl();
|
||||
saveSettings();
|
||||
});
|
||||
|
||||
$('#projectSel').addEventListener('change', async () => {
|
||||
const pid = $('#projectSel').value;
|
||||
if (!pid) { state.answers = []; populateFilters(); renderStats(); return; }
|
||||
if (!pid) { state.answers = []; populateFilters(); renderStats(); renderRatioBox(); return; }
|
||||
showAlert('正在拉取已提交人员…', 'info');
|
||||
try {
|
||||
await loadAnswers(pid);
|
||||
@@ -564,16 +668,30 @@
|
||||
}
|
||||
populateFilters();
|
||||
renderStats();
|
||||
renderRatioBox();
|
||||
if ($('#exclModal').classList.contains('open')) renderExcl();
|
||||
saveSettings();
|
||||
});
|
||||
|
||||
['#clsSel', '#grpSel'].forEach(id => {
|
||||
$(id).addEventListener('change', () => { renderStats(); saveSettings(); });
|
||||
$(id).addEventListener('change', () => {
|
||||
renderStats(); renderRatioBox(); saveSettings();
|
||||
});
|
||||
});
|
||||
$('#numInput').addEventListener('change', saveSettings);
|
||||
$('#excludePicked').addEventListener('change', () => { renderStats(); saveSettings(); });
|
||||
$('#balanceChk').addEventListener('change', saveSettings);
|
||||
$('#balanceMode').addEventListener('change', () => {
|
||||
renderRatioBox();
|
||||
saveSettings();
|
||||
});
|
||||
// 班级/组别/来源切换会改变候选池 → 比例输入框也要重画
|
||||
['#sourceSel', '#projectSel', '#clsSel', '#grpSel'].forEach(id => {
|
||||
const el = $(id);
|
||||
if (el) el.addEventListener('change', () => {
|
||||
// 延迟一帧,等 populateFilters 填好再算
|
||||
setTimeout(renderRatioBox, 0);
|
||||
});
|
||||
});
|
||||
|
||||
$$('[data-num]').forEach(b => {
|
||||
b.addEventListener('click', () => {
|
||||
@@ -592,6 +710,9 @@
|
||||
|
||||
// 重置结果:清空本次结果 + 累计已抽取(历史记录保留)
|
||||
$('#btnReset').addEventListener('click', () => {
|
||||
if (state.picked.length || state.history.size) {
|
||||
if (!confirm('确定重置?将清空本次结果与累计已抽取的 ' + state.history.size + ' 人(历史记录仍保留)。')) return;
|
||||
}
|
||||
state.picked = [];
|
||||
state.history = new Set();
|
||||
renderResult();
|
||||
@@ -603,6 +724,37 @@
|
||||
showAlert('已重置结果与累计已抽取(历史记录仍保留)', 'info');
|
||||
});
|
||||
|
||||
// 复制名单:两列「姓名 / 登陆账号」,制表符分隔,便于直接粘贴到 Excel / 微信
|
||||
$('#btnCopy').addEventListener('click', async () => {
|
||||
if (!state.picked.length) {
|
||||
showAlert('请先点击「🎲 开始抽取」再复制');
|
||||
return;
|
||||
}
|
||||
const lines = state.picked.map(m =>
|
||||
(m.name || '') + '\t' + (m.login || m.sid || ''));
|
||||
const text = lines.join('\n');
|
||||
let ok = false;
|
||||
try {
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
ok = true;
|
||||
}
|
||||
} catch (e) { /* 降级到 textarea */ }
|
||||
if (!ok) {
|
||||
try {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
ta.style.position = 'fixed';
|
||||
ta.style.opacity = '0';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
ok = document.execCommand('copy');
|
||||
ta.remove();
|
||||
} catch (e) { ok = false; }
|
||||
}
|
||||
showAlert(ok ? ('已复制 ' + state.picked.length + ' 人的姓名 / 登陆账号到剪贴板') : '复制失败,请手动选择结果复制', ok ? 'info' : '');
|
||||
});
|
||||
|
||||
// 清空历史(顶栏)
|
||||
$('#btnClearLog').addEventListener('click', () => {
|
||||
if (!state.log.length) return showAlert('暂无历史记录');
|
||||
@@ -700,22 +852,59 @@
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染"自定义比例"输入框:按当前候选池的分组(组别/班级)列出,每个组一个数字输入框,
|
||||
* 默认按各组人数预填(即等价"按人数加权"),用户可改。
|
||||
*/
|
||||
function renderRatioBox() {
|
||||
const box = $('#ratioBox');
|
||||
if (!box) return;
|
||||
const pool = currentPool();
|
||||
const g = poolGroups(pool);
|
||||
const mode = $('#balanceMode').value;
|
||||
const show = (mode === 'custom') && g.key;
|
||||
box.classList.toggle('hidden', !show);
|
||||
if (!show) return;
|
||||
box.innerHTML =
|
||||
'<p class="hint" style="margin:0 0 6px">按上面分组的顺序填写权重,例:组A=3,组B=1。留空=不计该组。</p>' +
|
||||
g.gk.map((k, i) => {
|
||||
const def = (state.customRatios && Number.isFinite(parseFloat(state.customRatios[k])))
|
||||
? state.customRatios[k]
|
||||
: g.sizes[i];
|
||||
return '<div class="row" style="display:flex;gap:8px;align-items:center;margin:4px 0">' +
|
||||
'<label style="min-width:120px">' + esc(k) + ' <small style="color:var(--text-3)">(' + g.sizes[i] + '人)</small></label>' +
|
||||
'<input type="number" min="0" step="0.1" data-gk="' + esc(k) + '" value="' + esc(def) + '" placeholder="' + g.sizes[i] + '" style="width:120px">' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
// 改动即时保存
|
||||
box.querySelectorAll('input[data-gk]').forEach(inp => {
|
||||
inp.addEventListener('change', saveSettings);
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------------- 启动 ---------------- */
|
||||
document.addEventListener('DOMContentLoaded', async function () {
|
||||
loadState();
|
||||
const s = loadSettings();
|
||||
if (s.exclude === false) $('#excludePicked').checked = false;
|
||||
if (s.balance === false) $('#balanceChk').checked = false;
|
||||
// 兼容旧版:s.balance === false → 不分组
|
||||
const mode = s.mode || (s.balance === false ? 'off' : 'weight');
|
||||
$('#balanceMode').value = mode;
|
||||
if (s.num) $('#numInput').value = s.num;
|
||||
if (s.source === 'project') {
|
||||
state.source = 'project';
|
||||
$('#sourceSel').value = 'project';
|
||||
$('#projectField').classList.remove('hidden');
|
||||
}
|
||||
if (s.customRatios && typeof s.customRatios === 'object') {
|
||||
state.customRatios = s.customRatios;
|
||||
}
|
||||
|
||||
bind();
|
||||
renderResult();
|
||||
renderStats();
|
||||
// 第一次也要按当前池渲染自定义比例框
|
||||
renderRatioBox();
|
||||
|
||||
try {
|
||||
await loadRoster();
|
||||
|
||||
@@ -80,6 +80,8 @@ body {
|
||||
.conn-err { background: var(--danger-weak); color: var(--danger); }
|
||||
.conn-err i { background: var(--danger); }
|
||||
.conn-load i { background: var(--warn); animation: blink 1s infinite; }
|
||||
.conn-warn { background: var(--warn-weak, #fff4e0); color: var(--warn, #e08c1a); }
|
||||
.conn-warn i { background: var(--warn, #e08c1a); animation: blink 1s infinite; }
|
||||
@keyframes blink { 50% { opacity: .25; } }
|
||||
|
||||
/* ---------- 按钮 ---------- */
|
||||
@@ -231,6 +233,7 @@ select:focus, input:focus { border-color: var(--primary); box-shadow: 0 0 0 3px
|
||||
.updated { font-size: 12px; color: var(--text-3); display: flex; align-items: center; gap: 6px; }
|
||||
.dot { width: 7px; height: 7px; border-radius: 50%; background: #cbd3e2; display: inline-block; }
|
||||
.dot.on { background: var(--ok); animation: pulse 1.6s infinite; }
|
||||
.dot.stale { background: var(--danger); animation: blink 1s infinite; }
|
||||
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: .25; } }
|
||||
|
||||
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||
@@ -311,6 +314,12 @@ table.grid tbody tr.new:hover td { background: #d6f2e2; }
|
||||
.tag-ok { background: var(--ok-weak); color: var(--ok); }
|
||||
.tag-warn { background: var(--warn-weak); color: var(--warn); }
|
||||
.tag-gray { background: var(--border-2); color: var(--text-3); }
|
||||
.tag-att { background: #e8eefc; color: #3457c9; cursor: pointer; }
|
||||
|
||||
/* 多次刷题:成员榜可点击行 */
|
||||
tr.clickable { cursor: pointer; }
|
||||
tr.clickable:hover td { background: var(--border-2); }
|
||||
.muted { color: var(--text-3); }
|
||||
|
||||
/* ---------- 弹窗 ---------- */
|
||||
.modal {
|
||||
@@ -361,6 +370,7 @@ table.grid tbody tr.new:hover td { background: #d6f2e2; }
|
||||
background: var(--ok-weak); color: #0d7a4d; border: 1px solid #c8ecda;
|
||||
}
|
||||
.ping-result.err { background: var(--danger-weak); color: #a3342c; border-color: #f5d3cf; }
|
||||
.ping-result.loading { background: var(--border-2); color: var(--text-2); border-color: var(--border-2); }
|
||||
|
||||
/* ---------- 名单 ---------- */
|
||||
.upload-zone {
|
||||
|
||||
+14
-49
@@ -80,17 +80,13 @@ function ny_logout(): void
|
||||
session_destroy();
|
||||
}
|
||||
|
||||
/** 判断是否已安装(有过连接配置且能流转):文件 config 或 DB settings 存在 base_url 且账号非空 */
|
||||
/** 判断是否已安装(只看文件 config:base_url 非空即视为已具备连接配置) */
|
||||
function ny_is_installed(): bool
|
||||
{
|
||||
$cfg = ny_load_config();
|
||||
if (trim((string)($cfg['base_url'] ?? '')) === '') {
|
||||
return false;
|
||||
}
|
||||
// 有无可用的后端(文件 config 存在 ,或 DB 连得上)
|
||||
if (class_exists('Db') && Db::isConnected()) {
|
||||
return true;
|
||||
}
|
||||
return is_file(ny_config_file());
|
||||
}
|
||||
|
||||
@@ -132,22 +128,20 @@ function ny_config_file(): string
|
||||
return NY_DATA . '/config.json';
|
||||
}
|
||||
|
||||
/**
|
||||
* 配置来源:db | file | default
|
||||
* (default 表示既没读到 DB 也没读到文件——此时账号密码是出厂默认值,几乎必然连不上卷王)
|
||||
*/
|
||||
function ny_config_source(): string
|
||||
{
|
||||
return (string)($GLOBALS['__ny_cfg_source'] ?? 'default');
|
||||
}
|
||||
|
||||
function ny_load_config(): array
|
||||
{
|
||||
// 数据库已初始化且能连上 → 从 DB 读,否则回退文件
|
||||
$dbCfg = ny_db_config();
|
||||
if ($dbCfg !== null) {
|
||||
$cfg = ny_default_config();
|
||||
foreach ($cfg as $k => $v) {
|
||||
if (array_key_exists($k, $dbCfg)) {
|
||||
$cfg[$k] = is_bool($v) ? (bool)$dbCfg[$k] : $dbCfg[$k];
|
||||
}
|
||||
}
|
||||
$cfg['base_url'] = rtrim(trim((string)$cfg['base_url']), '/');
|
||||
$cfg['api_prefix'] = '/' . trim((string)$cfg['api_prefix'], '/');
|
||||
return $cfg;
|
||||
}
|
||||
$GLOBALS['__ny_cfg_source'] = 'default';
|
||||
|
||||
// 默认配置 + 文件覆盖(DB 后端本批次已下线,统一走文件存储)
|
||||
$cfg = ny_default_config();
|
||||
$saved = ny_read_json(ny_config_file(), null);
|
||||
if (is_array($saved)) {
|
||||
@@ -156,6 +150,7 @@ function ny_load_config(): array
|
||||
$cfg[$k] = is_bool($v) ? (bool)$saved[$k] : $saved[$k];
|
||||
}
|
||||
}
|
||||
$GLOBALS['__ny_cfg_source'] = 'file';
|
||||
}
|
||||
$cfg['base_url'] = rtrim(trim((string)$cfg['base_url']), '/');
|
||||
$cfg['api_prefix'] = '/' . trim((string)$cfg['api_prefix'], '/');
|
||||
@@ -164,40 +159,10 @@ function ny_load_config(): array
|
||||
|
||||
function ny_save_config(array $cfg): bool
|
||||
{
|
||||
// 数据库已连接则写入 DB settings(键 config = JSON),否则回退文件
|
||||
if (class_exists('Db')) {
|
||||
$db = Db::pdo();
|
||||
if ($db !== null) {
|
||||
try {
|
||||
Db::settingsSet('config', json_encode($cfg, JSON_UNESCAPED_UNICODE));
|
||||
return true;
|
||||
} catch (Throwable $e) {
|
||||
// 落库失败则回退文件
|
||||
}
|
||||
}
|
||||
}
|
||||
$GLOBALS['__ny_cfg_source'] = 'file';
|
||||
return ny_write_json(ny_config_file(), $cfg);
|
||||
}
|
||||
|
||||
/** 数据库初始化后从 DB 读取配置(无则返回 null) */
|
||||
function ny_db_config(): ?array
|
||||
{
|
||||
if (!class_exists('Db')) {
|
||||
return null;
|
||||
}
|
||||
$db = Db::pdo();
|
||||
if ($db === null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
$raw = (string)(Db::settingsAll()['config'] ?? '');
|
||||
} catch (Throwable $e) {
|
||||
return null;
|
||||
}
|
||||
$data = json_decode($raw, true);
|
||||
return is_array($data) ? $data : null;
|
||||
}
|
||||
|
||||
/** 安全的字符串化(避免数组/对象转字符串报错) */
|
||||
function ny_str(mixed $v): string
|
||||
{
|
||||
|
||||
+296
-186
@@ -1,11 +1,11 @@
|
||||
<?php
|
||||
/**
|
||||
* 班级数据看板(独立页面):每个班级一张专属看板,无概览、无返回按钮。
|
||||
* URL 支持 ?pid=<projectId>&class=<班级名> 分享与刷新恢复;缺省自动选第一个班级。
|
||||
*/
|
||||
declare(strict_types=1);
|
||||
require __DIR__ . '/bootstrap.php';
|
||||
|
||||
if (!ny_is_installed()) {
|
||||
header('Location: install.php');
|
||||
exit;
|
||||
}
|
||||
if (!ny_is_auth()) {
|
||||
header('Location: login.php');
|
||||
exit;
|
||||
@@ -16,7 +16,7 @@ if (!ny_is_auth()) {
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>数据看板 · <?= htmlspecialchars(NY_APP_NAME) ?></title>
|
||||
<title>班级看板 · <?= htmlspecialchars(NY_APP_NAME) ?></title>
|
||||
<link rel="stylesheet" href="assets/style.css">
|
||||
</head>
|
||||
<body class="dash">
|
||||
@@ -27,12 +27,13 @@ if (!ny_is_auth()) {
|
||||
<div class="brand">
|
||||
<span class="logo">NY</span>
|
||||
<div class="brand-text">
|
||||
<strong>NY问卷考试系统 · 数据看板</strong>
|
||||
<small>班级整体情况 · 面向班主任与学生</small>
|
||||
<strong id="heroTitle">班级看板</strong>
|
||||
<small id="heroSub">选择一个问卷与班级查看考试整体情况</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dash-controls">
|
||||
<select id="projectSel"><option value="">请选择问卷 / 考试</option></select>
|
||||
<select id="projectSel" style="min-width:160px"><option value="">请选择问卷 / 考试</option></select>
|
||||
<select id="classSel" style="min-width:140px" disabled><option value="">请先选问卷</option></select>
|
||||
<label class="switch"><input type="checkbox" id="autoRefresh" checked><span></span></label>
|
||||
<select id="intervalSel" style="width:auto">
|
||||
<option value="5">5 秒</option>
|
||||
@@ -50,45 +51,42 @@ if (!ny_is_auth()) {
|
||||
最后更新:<span id="lastUpdate">—</span><i id="liveDot" class="dot"></i>
|
||||
</div>
|
||||
|
||||
<!-- ================= 总览 ================= -->
|
||||
<div id="overview">
|
||||
<div class="sec-title">整体概况</div>
|
||||
<section class="cards" id="summary">
|
||||
<div class="card accent-blue"><span>名单总人数</span><b id="oTotal">-</b></div>
|
||||
<div class="card ok accent-green"><span>已提交</span><b id="oSubmit">-</b></div>
|
||||
<div class="card warn accent-orange"><span>缺考</span><b id="oAbsent">-</b></div>
|
||||
<div class="card accent-purple"><span>提交率</span><b id="oRate">-</b></div>
|
||||
<div class="card accent-teal"><span>平均分</span><b id="oAvg">-</b></div>
|
||||
<div class="card accent-indigo"><span>合格率(≥60%满分)</span><b id="oPass">-</b></div>
|
||||
<!-- ================= 班级看板(单班,无概览) ================= -->
|
||||
<div id="board">
|
||||
<!-- 6 个汇总卡片 -->
|
||||
<section class="cards" id="boardSummary">
|
||||
<div class="card accent-blue"><span>总人数</span><b id="bTotal">-</b></div>
|
||||
<div class="card ok accent-green"><span>已提交</span><b id="bSubmit">-</b></div>
|
||||
<div class="card warn accent-orange"><span>缺考</span><b id="bAbsent">-</b></div>
|
||||
<div class="card accent-purple"><span>提交率</span><b id="bRate">-</b></div>
|
||||
<div class="card accent-teal"><span>平均分</span><b id="bAvg">-</b></div>
|
||||
<div class="card accent-indigo"><span>合格率(≥60%满分)</span><b id="bPass">-</b></div>
|
||||
</section>
|
||||
|
||||
<div class="sec-title">各班整体情况 <span class="tag-gray" id="classCount"></span>
|
||||
<span class="hint" style="margin-left:auto;font-weight:500">点击班级卡片查看该班详情</span>
|
||||
<div class="sec-title">分组对比 / 考试成员</div>
|
||||
<div id="boardBody">
|
||||
<div class="empty">请选择问卷与班级查看考试整体情况</div>
|
||||
</div>
|
||||
<div class="class-grid" id="classGrid">
|
||||
<div class="empty">请选择问卷 / 考试以查看各班数据</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ================= 班级详情 ================= -->
|
||||
<div id="detail" class="hidden">
|
||||
<div class="detail-head">
|
||||
<button class="btn" id="btnBack">← 返回总览</button>
|
||||
<h2 id="detailTitle">班级详情</h2>
|
||||
<span class="tag-gray" id="detailSub"></span>
|
||||
</div>
|
||||
|
||||
<section class="cards" id="detailSummary"></section>
|
||||
|
||||
<div class="sec-title" id="groupSecTitle">分组对比</div>
|
||||
<div id="groupCompare"></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- 多次刷题明细弹窗 -->
|
||||
<div class="modal" id="attemptsModal">
|
||||
<div class="modal-box">
|
||||
<div class="modal-head"><h3 id="attTitle">提交明细</h3><button class="x" data-close onclick="closeAttempts()">×</button></div>
|
||||
<div class="modal-body" id="attBody"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// 弹窗:点击遮罩或带 data-close 的元素关闭
|
||||
document.querySelectorAll('.modal').forEach(m => {
|
||||
m.addEventListener('click', e => {
|
||||
if (e.target === m || e.target.hasAttribute('data-close')) m.classList.remove('open');
|
||||
});
|
||||
});
|
||||
const $ = s => document.querySelector(s);
|
||||
const $$ = s => Array.from(document.querySelectorAll(s));
|
||||
function esc(s){ return String(s == null ? '' : s).replace(/[&<>"]/g, c => ({'&':'&','<':'<','>':'>','"':'"'}[c])); }
|
||||
function rateColor(r){
|
||||
if (r >= 90) return 'var(--ok)';
|
||||
@@ -96,50 +94,64 @@ function rateColor(r){
|
||||
return 'var(--danger)';
|
||||
}
|
||||
|
||||
/* 本地存储(缓存问卷列表/看板数据,重开即恢复) */
|
||||
/* URL 状态:?pid=&class=&auto=1|0 */
|
||||
function readUrl(){
|
||||
const u = new URL(location.href);
|
||||
return {
|
||||
pid: u.searchParams.get('pid') || '',
|
||||
cls: u.searchParams.get('class') || '',
|
||||
auto: u.searchParams.get('auto'),
|
||||
};
|
||||
}
|
||||
function writeUrl({pid, cls, auto}){
|
||||
const u = new URL(location.href);
|
||||
if (pid !== undefined) u.searchParams.set('pid', pid);
|
||||
if (cls !== undefined) u.searchParams.set('class', cls);
|
||||
if (auto !== undefined) u.searchParams.set('auto', auto ? '1' : '0');
|
||||
history.replaceState(null, '', u.pathname + (u.search ? u.search : ''));
|
||||
}
|
||||
|
||||
/* 本地缓存 */
|
||||
const LS = {
|
||||
get(k, def){ try { const v = localStorage.getItem(k); return v == null ? def : JSON.parse(v); } catch(e){ return def; } },
|
||||
set(k, v){ try { localStorage.setItem(k, JSON.stringify(v)); } catch(e){} },
|
||||
del(k){ try { localStorage.removeItem(k); } catch(e){} }
|
||||
set(k, v){ try { localStorage.setItem(k, v); } catch(e){} },
|
||||
};
|
||||
const LS_PROJECTS = 'ny_projects';
|
||||
const LS_DASH = pid => 'ny_dashboard_' + pid;
|
||||
const LS_BOARD = (pid, cls) => 'ny_board_' + pid + '_' + encodeURIComponent(cls);
|
||||
const LS_DASH_AUTO = 'ny_dash_auto';
|
||||
const LS_DASH_INTERVAL= 'ny_dash_interval';
|
||||
|
||||
let DASH = null; // 最近一次看板数据
|
||||
let CUR = null; // 当前查看的班级名
|
||||
let DASH = null;
|
||||
let CUR = readUrl();
|
||||
|
||||
async function api(action, opts){
|
||||
opts = opts || {};
|
||||
/* ---------- API ---------- */
|
||||
async function api(action, params){
|
||||
let url = 'api.php?action=' + encodeURIComponent(action);
|
||||
if (opts.params) {
|
||||
for (const k in opts.params) url += '&' + k + '=' + encodeURIComponent(opts.params[k]);
|
||||
}
|
||||
if (params) for (const k in params) url += '&' + k + '=' + encodeURIComponent(params[k] || '');
|
||||
const res = await fetch(url, { headers: { 'Accept': 'application/json' } });
|
||||
const j = await res.json();
|
||||
if (j && j.ok) return j.data;
|
||||
throw new Error((j && (j.msg || j.error)) || '请求失败');
|
||||
const err = new Error((j && (j.msg || j.error)) || '请求失败');
|
||||
err.auth = !!(j && j.extra && j.extra.auth);
|
||||
throw err;
|
||||
}
|
||||
|
||||
/* ---------- 问卷列表:双策略识别文件夹 + 缓存优先 ---------- */
|
||||
/* ---------- 问卷列表(双策略文件夹识别 + 缓存) ---------- */
|
||||
function renderProjects(list){
|
||||
const sel = $('#projectSel');
|
||||
const keep = sel.value;
|
||||
sel.innerHTML = '<option value="">请选择问卷 / 考试</option>';
|
||||
// 双策略识别文件夹:mode 含 folder,或被其他项目作为 parent_id 引用
|
||||
const isFolder = p => (p.mode || '').toLowerCase().includes('folder');
|
||||
const parentIds = new Set();
|
||||
list.forEach(p => { const pid = (p.parent_id || '0'); if (pid && pid !== '0' && pid !== '') parentIds.add(pid); });
|
||||
const folderSet = new Set();
|
||||
list.forEach(p => { if (isFolder(p) || parentIds.has(p.id)) folderSet.add(p.id); });
|
||||
const childOf = fid => list.filter(p => !folderSet.has(p.id) && (p.parent_id || '0') === fid);
|
||||
|
||||
// 根项
|
||||
list.filter(p => !folderSet.has(p.id)).forEach(p => {
|
||||
const o = document.createElement('option');
|
||||
o.value = p.id; o.textContent = (p.mode ? '[' + p.mode + '] ' : '') + p.name + '(' + (p.total || 0) + '份)';
|
||||
sel.appendChild(o);
|
||||
});
|
||||
// 文件夹
|
||||
list.filter(p => folderSet.has(p.id)).forEach(f => {
|
||||
const kids = childOf(f.id);
|
||||
if (!kids.length) return;
|
||||
@@ -152,69 +164,71 @@ function renderProjects(list){
|
||||
});
|
||||
sel.appendChild(og);
|
||||
});
|
||||
if (keep && list.some(p => p.id === keep)) sel.value = keep;
|
||||
if (CUR.pid && list.some(p => p.id === CUR.pid)) sel.value = CUR.pid;
|
||||
else if (keep && list.some(p => p.id === keep)) sel.value = keep;
|
||||
else {
|
||||
const last = LS.get('ny_dash_last_pid', '');
|
||||
const last = LS.get('ny_board_last_pid', '');
|
||||
if (last && list.some(p => p.id === last)) sel.value = last;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadProjects(){
|
||||
// 1) 先读本地缓存
|
||||
const cached = LS.get(LS_PROJECTS, []);
|
||||
if (Array.isArray(cached) && cached.length) {
|
||||
renderProjects(cached);
|
||||
}
|
||||
// 2) 后台异步刷新
|
||||
if (Array.isArray(cached) && cached.length) renderProjects(cached);
|
||||
try {
|
||||
const list = await api('projects');
|
||||
LS.set(LS_PROJECTS, list || []);
|
||||
LS.set(LS_PROJECTS, JSON.stringify(list || []));
|
||||
renderProjects(list || []);
|
||||
refreshClassSel(); // 项目变了刷新班级列表
|
||||
} catch (e) {
|
||||
if (!cached || !cached.length) {
|
||||
$('#classGrid').innerHTML = '<div class="empty">加载问卷列表失败:' + esc(e.message) + '</div>';
|
||||
$('#boardBody').innerHTML = '<div class="empty">加载问卷列表失败:' + esc(e.message) + '</div>';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderSummary(d){
|
||||
const o = d.overall || {};
|
||||
$('#oTotal').textContent = o.roster_total ?? '-';
|
||||
$('#oSubmit').textContent = o.submitted ?? '-';
|
||||
$('#oAbsent').textContent = o.absent ?? '-';
|
||||
$('#oRate').textContent = (o.submit_rate != null ? o.submit_rate + '%' : '-');
|
||||
$('#oAvg').textContent = o.avg_score != null ? o.avg_score : '-';
|
||||
$('#oPass').textContent = (o.pass_rate != null ? o.pass_rate + '%' : '-');
|
||||
}
|
||||
|
||||
function renderClasses(d){
|
||||
const classes = d.classes || [];
|
||||
$('#classCount').textContent = classes.length ? classes.length + ' 个班级' : '';
|
||||
if (!classes.length) {
|
||||
$('#classGrid').innerHTML = '<div class="empty">暂无班级数据(名单未导入班级信息)</div>';
|
||||
/* ---------- 班级列表(来自 roster) ---------- */
|
||||
async function refreshClassSel(){
|
||||
const sel = $('#classSel');
|
||||
const pid = $('#projectSel').value;
|
||||
if (!pid) {
|
||||
sel.innerHTML = '<option value="">请先选问卷</option>';
|
||||
sel.disabled = true;
|
||||
return;
|
||||
}
|
||||
$('#classGrid').innerHTML = classes.map(c => {
|
||||
const rate = c.rate || 0;
|
||||
const color = rateColor(rate);
|
||||
const gs = (c.groups || []).filter(g => g.group !== '(未分组)');
|
||||
const gtag = gs.length ? gs.map(g => esc(g.group) + '组').join(' / ') : '';
|
||||
return '<div class="class-card clickable" data-cls="' + esc(c.class) + '">' +
|
||||
'<h3><span>' + esc(c.class) + '</span><span class="rate-num" style="color:' + color + '">' + rate + '%</span></h3>' +
|
||||
'<div class="sub">已提交 ' + c.submitted + ' / 总人数 ' + c.total + ' · 缺考 ' + c.absent + '</div>' +
|
||||
'<div class="bar"><i style="width:' + rate + '%;background:' + color + '"></i></div>' +
|
||||
'<div class="class-metrics">' +
|
||||
'<div class="m"><span>平均分</span><b>' + (c.avg != null ? c.avg : '-') + '</b></div>' +
|
||||
'<div class="m"><span>合格率</span><b>' + c.pass_rate + '%</b></div>' +
|
||||
'<div class="m metric-ok"><span>最高分</span><b>' + (c.max != null ? c.max : '-') + '</b></div>' +
|
||||
'<div class="m metric-danger"><span>最低分</span><b>' + (c.min != null ? c.min : '-') + '</b></div>' +
|
||||
'</div>' +
|
||||
(gtag ? '<div class="grp-tag">' + gtag + '</div>' : '') +
|
||||
'</div>';
|
||||
}).join('');
|
||||
// 项目数据来自 api (dashboard),先调用一次拿班级
|
||||
try {
|
||||
const data = await api('dashboard', { projectId: pid });
|
||||
DASH = data;
|
||||
const classes = (data.classes || []).map(c => c.class);
|
||||
sel.innerHTML = classes.length
|
||||
? classes.map(c => '<option value="' + esc(c) + '">' + esc(c) + '</option>').join('')
|
||||
: '<option value="">暂无班级</option>';
|
||||
sel.disabled = !classes.length;
|
||||
// 默认 class:URL 指定 > 上次选择 > 第一个
|
||||
if (CUR.cls && classes.includes(CUR.cls)) {
|
||||
sel.value = CUR.cls;
|
||||
} else {
|
||||
const last = LS.get('ny_board_last_cls_' + pid, '');
|
||||
if (last && classes.includes(last)) sel.value = last;
|
||||
else if (classes.length) sel.value = classes[0];
|
||||
CUR.cls = sel.value;
|
||||
}
|
||||
renderBoard();
|
||||
} catch (e) {
|
||||
if (e.auth) {
|
||||
// 卷王账号未配置或登录失效:跳转登录页自愈
|
||||
$('#boardBody').innerHTML = '<div class="empty">卷王账号未配置或登录已失效,<a href="login.php">点击重新登录</a></div>';
|
||||
setTimeout(() => { location.href = 'login.php'; }, 1200);
|
||||
return;
|
||||
}
|
||||
sel.innerHTML = '<option value="">加载失败</option>';
|
||||
sel.disabled = true;
|
||||
$('#boardBody').innerHTML = '<div class="empty">加载失败:' + esc(e.message) + '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- 成员榜:排名 / 姓名 / 成绩(默认成绩降序) ---------- */
|
||||
/* ---------- 成员榜(含多次刷题徽标与下钻) ---------- */
|
||||
function membersTable(members){
|
||||
if (!members || !members.length) {
|
||||
return '<div class="empty" style="padding:18px 0">暂无考试成员</div>';
|
||||
@@ -226,58 +240,139 @@ function membersTable(members){
|
||||
const badge = rk === 1 ? 'rk rk-1' : (rk === 2 ? 'rk rk-2' : (rk === 3 ? 'rk rk-3' : 'rk'));
|
||||
const sc = m.score;
|
||||
const cls = sc == null ? '' : (sc >= 90 ? 'score-high' : (sc >= 60 ? 'score-mid' : 'score-low'));
|
||||
return '<tr>' +
|
||||
const att = (m.attempts || 1) > 1;
|
||||
const tag = att ? ' <span class="tag tag-att" title="查看 ' + m.attempts + ' 次提交明细">刷' + m.attempts + '次</span>' : '';
|
||||
return '<tr' + (att ? ' class="clickable" onclick="openAttempts(' + JSON.stringify(m.sid) + ')"' : '') + '>' +
|
||||
'<td class="num"><span class="' + badge + '">' + rk + '</span></td>' +
|
||||
'<td class="name">' + esc(m.name) + '</td>' +
|
||||
'<td class="name">' + esc(m.name) + tag + '</td>' +
|
||||
'<td class="score num ' + cls + '">' + (sc == null ? '-' : sc) + '</td>' +
|
||||
'</tr>';
|
||||
}).join('') +
|
||||
'</tbody></table></div>';
|
||||
}
|
||||
|
||||
/* ---------- 班级详情:整体 + A/B 分组对比 ---------- */
|
||||
function showClassDetail(name){
|
||||
if (!DASH) return;
|
||||
const c = (DASH.classes || []).find(x => x.class === name);
|
||||
if (!c) return;
|
||||
CUR = name;
|
||||
/* ---------- 多次刷题下钻弹窗 ---------- */
|
||||
function openAttempts(sid){
|
||||
const info = (window.MEMBER_ATT || {})[sid];
|
||||
if (!info) return;
|
||||
const list = info.list || [];
|
||||
const rows = list.map(a => {
|
||||
const sc = a.score == null ? '<span class="muted">未判分</span>' : esc(a.score);
|
||||
const tmp = a.temp_save ? ' <span class="tag tag-gray">暂存</span>' : '';
|
||||
return '<tr>' +
|
||||
'<td class="num">' + esc(a.submit_at) + '</td>' +
|
||||
'<td class="num">' + sc + tmp + '</td>' +
|
||||
'<td class="num">' + esc(a.duration_txt || '-') + '</td>' +
|
||||
'</tr>';
|
||||
}).join('');
|
||||
$('#attTitle').textContent = info.name + ' · 共 ' + (info.attempts || list.length) + ' 次提交';
|
||||
$('#attBody').innerHTML = list.length
|
||||
? '<table class="grid compact"><thead><tr><th>提交时间</th><th>成绩</th><th>考试时长</th></tr></thead><tbody>' + rows + '</tbody></table>'
|
||||
: '<div class="empty">无提交明细</div>';
|
||||
document.querySelector('#attemptsModal').classList.add('open');
|
||||
}
|
||||
function closeAttempts(){
|
||||
const m = document.querySelector('#attemptsModal');
|
||||
if (m) m.classList.remove('open');
|
||||
}
|
||||
|
||||
$('#overview').classList.add('hidden');
|
||||
$('#detail').classList.remove('hidden');
|
||||
/* ---------- 看板占位(未选问卷/班级) ---------- */
|
||||
function renderPlaceholderBoard(){
|
||||
const cards = [
|
||||
['总人数','-'],['已提交','-'],['缺考','-'],
|
||||
['提交率','-'],['平均分','-'],['合格率','-']
|
||||
];
|
||||
$('#boardSummary').innerHTML = cards.map(c =>
|
||||
'<div class="card"><span>' + c[0] + '</span><b>' + c[1] + '</b></div>').join('');
|
||||
$('#boardBody').innerHTML = '<div class="empty">请选择问卷与班级查看考试整体情况</div>';
|
||||
$('#heroTitle').textContent = '班级看板';
|
||||
$('#heroSub').textContent = '选择一个问卷与班级查看考试整体情况';
|
||||
const d = $('#liveDot'); if (d) { d.classList.remove('on','stale'); }
|
||||
}
|
||||
|
||||
$('#detailTitle').textContent = c.class;
|
||||
$('#detailSub').textContent = '总人数 ' + c.total + ' · 已提交 ' + c.submitted + ' · 缺考 ' + c.absent;
|
||||
/* ---------- 实时状态指示 ---------- */
|
||||
function markLive(){
|
||||
const d = $('#liveDot');
|
||||
if (d) { d.classList.remove('stale'); d.classList.add('on'); }
|
||||
setTimeout(() => { const e = $('#liveDot'); if (e) e.classList.remove('on'); }, 900);
|
||||
}
|
||||
function markStale(msg){
|
||||
const d = $('#liveDot');
|
||||
if (d) { d.classList.remove('on'); d.classList.add('stale'); }
|
||||
const lu = $('#lastUpdate');
|
||||
if (lu) lu.textContent = '刷新中断(仍显示上次数据)';
|
||||
}
|
||||
function handleAuthFail(){
|
||||
$('#boardBody').innerHTML = '<div class="empty">卷王账号未配置或登录已失效,<a href="login.php">点击重新登录</a></div>';
|
||||
setTimeout(() => { location.href = 'login.php'; }, 1200);
|
||||
}
|
||||
|
||||
/* ---------- 渲染单班看板 ---------- */
|
||||
function renderBoard(){
|
||||
const pid = $('#projectSel').value;
|
||||
const cls = $('#classSel').value;
|
||||
CUR.pid = pid; CUR.cls = cls;
|
||||
writeUrl({ pid, cls });
|
||||
|
||||
if (!pid || !cls || !DASH) {
|
||||
renderPlaceholderBoard();
|
||||
return;
|
||||
}
|
||||
LS.set('ny_board_last_pid', JSON.stringify(pid));
|
||||
LS.set('ny_board_last_cls_' + pid, JSON.stringify(cls));
|
||||
|
||||
const c = (DASH.classes || []).find(x => x.class === cls);
|
||||
if (!c) {
|
||||
$('#boardBody').innerHTML = '<div class="empty">该班级无数据(名单中无此班级)</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
// 顶部品牌区:动态标题
|
||||
const projectName = ($('#projectSel').selectedOptions[0] || {}).textContent || '';
|
||||
$('#heroTitle').textContent = cls + ' · 班级看板';
|
||||
|
||||
// 构建"学号 -> 多次刷题明细"映射(班级级 members 已含全班所有人的 attempts_list)
|
||||
const MAP = {};
|
||||
(c.members || []).forEach(m => {
|
||||
if ((m.attempts || 1) > 1) {
|
||||
MAP[m.sid] = { name: m.name, attempts: m.attempts, list: m.attempts_list || [] };
|
||||
}
|
||||
});
|
||||
window.MEMBER_ATT = MAP;
|
||||
|
||||
const overall = DASH.overall || {};
|
||||
const totalAttempts = (overall.attempts_total || 0);
|
||||
$('#heroSub').textContent = '问卷:' + projectName.replace(/\uff08\d+份\uff09$/, '') + ' · ' +
|
||||
'总 ' + c.total + ' / 已交 ' + c.submitted + ' / 缺考 ' + c.absent +
|
||||
(totalAttempts ? ' · 共 ' + totalAttempts + ' 次提交' : '');
|
||||
|
||||
// 6 卡汇总
|
||||
const color = rateColor(c.rate || 0);
|
||||
$('#detailSummary').innerHTML =
|
||||
'<div class="card accent-purple"><span>提交率</span><b style="color:' + color + '">' + c.rate + '%</b></div>' +
|
||||
$('#boardSummary').innerHTML =
|
||||
'<div class="card accent-blue"><span>总人数</span><b>' + c.total + '</b></div>' +
|
||||
'<div class="card ok accent-green"><span>已提交</span><b>' + c.submitted + '</b></div>' +
|
||||
'<div class="card warn accent-orange"><span>缺考</span><b>' + c.absent + '</b></div>' +
|
||||
'<div class="card accent-blue"><span>总人数</span><b>' + c.total + '</b></div>' +
|
||||
'<div class="card accent-purple"><span>提交率</span><b style="color:' + color + '">' + c.rate + '%</b></div>' +
|
||||
'<div class="card accent-teal"><span>平均分</span><b>' + (c.avg != null ? c.avg : '-') + '</b></div>' +
|
||||
'<div class="card accent-indigo"><span>合格率</span><b>' + c.pass_rate + '%</b></div>' +
|
||||
'<div class="card accent-green"><span>最高分</span><b>' + (c.max != null ? c.max : '-') + '</b></div>' +
|
||||
'<div class="card accent-red"><span>最低分</span><b>' + (c.min != null ? c.min : '-') + '</b></div>';
|
||||
'<div class="card accent-indigo"><span>合格率</span><b>' + c.pass_rate + '%</b></div>';
|
||||
|
||||
// 分组对比:过滤掉“未分组”,按组名排序(A 在左,B 在右)
|
||||
const groups = (c.groups || []).filter(g => g.group !== '(未分组)');
|
||||
const box = $('#groupCompare');
|
||||
const box = $('#boardBody');
|
||||
if (!groups.length) {
|
||||
$('#groupSecTitle').textContent = '考试成员';
|
||||
box.innerHTML = '<div class="gpanel">' +
|
||||
'<div class="ghead"><strong>本班考试成员</strong>' +
|
||||
'<span class="rate-num">' + c.submitted + ' 人</span></div>' +
|
||||
'<div class="sub">该班级未设置分组(A/B),以下按成绩从高到低排列</div>' +
|
||||
'<div class="sub">该班级未设置分组(A/B),以下按成绩从高到低排列' +
|
||||
(c.attempts > c.submitted ? ' · 共 ' + c.attempts + ' 次刷题' : '') + '</div>' +
|
||||
membersTable(c.members) + '</div>';
|
||||
return;
|
||||
}
|
||||
$('#groupSecTitle').textContent = '分组对比(' + groups.map(g => g.group).join(' vs ') + ')';
|
||||
} else {
|
||||
box.innerHTML = '<div class="group-compare">' + groups.map(g => {
|
||||
const gc = rateColor(g.rate || 0);
|
||||
return '<div class="gpanel">' +
|
||||
'<div class="ghead"><strong>' + esc(g.group) + ' 组</strong>' +
|
||||
'<span class="rate-num" style="color:' + gc + '">' + g.rate + '%</span></div>' +
|
||||
'<div class="sub">已提交 ' + g.submitted + ' / 总人数 ' + g.total + ' · 缺考 ' + g.absent + '</div>' +
|
||||
'<div class="sub">已提交 ' + g.submitted + ' / 总人数 ' + g.total + ' · 缺考 ' + g.absent +
|
||||
(g.attempts > g.submitted ? ' · 共 ' + g.attempts + ' 次刷题' : '') + '</div>' +
|
||||
'<div class="bar"><i style="width:' + g.rate + '%;background:' + gc + '"></i></div>' +
|
||||
'<div class="class-metrics">' +
|
||||
'<div class="m"><span>平均分</span><b>' + (g.avg != null ? g.avg : '-') + '</b></div>' +
|
||||
@@ -289,53 +384,6 @@ function showClassDetail(name){
|
||||
membersTable(g.members) +
|
||||
'</div>';
|
||||
}).join('') + '</div>';
|
||||
}
|
||||
|
||||
function backToOverview(){
|
||||
CUR = null;
|
||||
$('#detail').classList.add('hidden');
|
||||
$('#overview').classList.remove('hidden');
|
||||
}
|
||||
|
||||
async function loadDashboard(){
|
||||
const pid = $('#projectSel').value;
|
||||
if (!pid) {
|
||||
$('#classGrid').innerHTML = '<div class="empty">请选择问卷 / 考试以查看各班数据</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
// 1) 缓存优先:先渲染上次保存的看板,避免空白
|
||||
const cached = LS.get(LS_DASH(pid), null);
|
||||
if (cached && cached.classes) {
|
||||
DASH = cached;
|
||||
renderSummary(DASH);
|
||||
renderClasses(DASH);
|
||||
$('#lastUpdate').textContent = (cached.updated_at || '—') + '(缓存)';
|
||||
if (CUR) {
|
||||
const still = (DASH.classes || []).some(x => x.class === CUR);
|
||||
if (still) showClassDetail(CUR); else backToOverview();
|
||||
}
|
||||
}
|
||||
|
||||
// 2) 后台拉真实数据
|
||||
try {
|
||||
const fresh = await api('dashboard', { params: { projectId: pid } });
|
||||
DASH = fresh;
|
||||
LS.set(LS_DASH(pid), fresh);
|
||||
LS.set('ny_dash_last_pid', pid);
|
||||
renderSummary(DASH);
|
||||
renderClasses(DASH);
|
||||
$('#lastUpdate').textContent = DASH.updated_at || '-';
|
||||
$('#liveDot').classList.add('on');
|
||||
setTimeout(() => $('#liveDot').classList.remove('on'), 900);
|
||||
if (CUR) {
|
||||
const still = (DASH.classes || []).some(x => x.class === CUR);
|
||||
if (still) showClassDetail(CUR); else backToOverview();
|
||||
}
|
||||
} catch (e) {
|
||||
if (!cached) {
|
||||
$('#classGrid').innerHTML = '<div class="empty">数据加载失败:' + esc(e.message) + '</div>';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -346,7 +394,7 @@ function toggleFullscreen(){
|
||||
const req = el.requestFullscreen || el.webkitRequestFullscreen;
|
||||
if (req) req.call(el);
|
||||
} else {
|
||||
const ex = document.exitFullscreen || document.webkitExitFullscreen;
|
||||
const ex = el.exitFullscreen || el.webkitExitFullscreen;
|
||||
if (ex) ex.call(document);
|
||||
}
|
||||
}
|
||||
@@ -357,31 +405,93 @@ function onFsChange(){
|
||||
if (btn) btn.textContent = on ? '⛶ 退出全屏' : '⛶ 全屏';
|
||||
}
|
||||
|
||||
/* ---------- 自动刷新 ---------- */
|
||||
let timer = null;
|
||||
function schedule(){
|
||||
if (window.__timer) clearInterval(window.__timer);
|
||||
if (timer) { clearInterval(timer); timer = null; }
|
||||
if (!$('#autoRefresh').checked) return;
|
||||
const sec = parseInt($('#intervalSel').value, 10) || 15;
|
||||
window.__timer = setInterval(loadDashboard, sec * 1000);
|
||||
timer = setInterval(refreshBoard, sec * 1000);
|
||||
}
|
||||
async function refreshBoard(manual){
|
||||
const pid = $('#projectSel').value;
|
||||
if (!pid) return;
|
||||
const btn = $('#btnRefresh');
|
||||
if (manual && btn) { btn.disabled = true; btn.textContent = '刷新中…'; }
|
||||
try {
|
||||
const fresh = await api('dashboard', { projectId: pid });
|
||||
DASH = fresh;
|
||||
// 项目集合可能换:刷新班级下拉但保留当前选择
|
||||
const sel = $('#classSel');
|
||||
const keep = sel.value;
|
||||
const classes = (fresh.classes || []).map(c => c.class);
|
||||
if (!classes.includes(keep)) {
|
||||
sel.innerHTML = classes.length
|
||||
? classes.map(c => '<option value="' + esc(c) + '">' + esc(c) + '</option>').join('')
|
||||
: '<option value="">暂无班级</option>';
|
||||
sel.disabled = !classes.length;
|
||||
if (classes.length) { sel.value = classes[0]; CUR.cls = classes[0]; writeUrl({ cls: classes[0] }); }
|
||||
}
|
||||
renderBoard();
|
||||
$('#lastUpdate').textContent = fresh.updated_at || '-';
|
||||
markLive();
|
||||
} catch (e) {
|
||||
if (e.auth) { handleAuthFail(); return; }
|
||||
// 静默续期:保留旧数据,但把实时点变红提示中断
|
||||
markStale(e.message);
|
||||
} finally {
|
||||
if (manual && btn) { btn.disabled = false; btn.textContent = '刷新'; }
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- 事件 ---------- */
|
||||
$('#projectSel').addEventListener('change', () => { backToOverview(); loadDashboard(); });
|
||||
$('#btnRefresh').addEventListener('click', loadDashboard);
|
||||
$('#autoRefresh').addEventListener('change', schedule);
|
||||
$('#intervalSel').addEventListener('change', schedule);
|
||||
$('#projectSel').addEventListener('change', () => { CUR.pid = $('#projectSel').value; CUR.cls = ''; refreshClassSel(); schedule(); });
|
||||
$('#classSel').addEventListener('change', () => { CUR.cls = $('#classSel').value; renderBoard(); });
|
||||
$('#btnRefresh').addEventListener('click', () => refreshBoard(true));
|
||||
$('#autoRefresh').addEventListener('change', () => {
|
||||
writeUrl({ auto: $('#autoRefresh').checked });
|
||||
LS.set(LS_DASH_AUTO, $('#autoRefresh').checked);
|
||||
schedule();
|
||||
});
|
||||
$('#intervalSel').addEventListener('change', () => {
|
||||
LS.set(LS_DASH_INTERVAL, parseInt($('#intervalSel').value, 10) || 15);
|
||||
schedule();
|
||||
});
|
||||
$('#btnFullscreen').addEventListener('click', toggleFullscreen);
|
||||
document.addEventListener('fullscreenchange', onFsChange);
|
||||
document.addEventListener('webkitfullscreenchange', onFsChange);
|
||||
$('#btnBack').addEventListener('click', backToOverview);
|
||||
|
||||
// 班级卡片点击进入详情
|
||||
$('#classGrid').addEventListener('click', e => {
|
||||
const card = e.target.closest('.class-card');
|
||||
if (card && card.dataset.cls) showClassDetail(card.dataset.cls);
|
||||
/* ---------- 偏好恢复(自动刷新开关 + 间隔,含 ?interval= 深链) ---------- */
|
||||
function restoreDashPrefs(){
|
||||
const qs = new URLSearchParams(location.search);
|
||||
if (qs.get('interval')) {
|
||||
$('#intervalSel').value = qs.get('interval');
|
||||
LS.set(LS_DASH_INTERVAL, parseInt($('#intervalSel').value, 10) || 15);
|
||||
} else {
|
||||
const iv = LS.get(LS_DASH_INTERVAL, 0);
|
||||
if (iv) {
|
||||
const opts = Array.prototype.slice.call($('#intervalSel').options).map(o => parseInt(o.value, 10));
|
||||
if (opts.includes(iv)) $('#intervalSel').value = String(iv);
|
||||
}
|
||||
}
|
||||
if (CUR.auto !== '0') {
|
||||
const auto = LS.get(LS_DASH_AUTO, null);
|
||||
if (auto !== null) $('#autoRefresh').checked = !!auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- 启动 ---------- */
|
||||
restoreDashPrefs();
|
||||
loadProjects().then(() => {
|
||||
if (CUR.pid) {
|
||||
$('#projectSel').value = CUR.pid;
|
||||
refreshClassSel().then(() => { renderBoard(); schedule(); });
|
||||
} else {
|
||||
refreshClassSel();
|
||||
}
|
||||
});
|
||||
|
||||
loadProjects().then(loadDashboard);
|
||||
schedule();
|
||||
// auto 参数
|
||||
if (CUR.auto === '0') $('#autoRefresh').checked = false;
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
<?php
|
||||
/**
|
||||
* NY 问卷考试系统数据平台 - 随机抽取
|
||||
* 从名单 / 已提交人员中随机抽取(支持分组均衡 5:5、排除名单、历史记录),
|
||||
* 从名单 / 已提交人员中随机抽取(支持按人数加权/均分/自定义比例/不分组、排除名单、历史记录),
|
||||
* 导出为卷王白名单(两列:姓名 / 登陆账号)
|
||||
*/
|
||||
declare(strict_types=1);
|
||||
require __DIR__ . '/bootstrap.php';
|
||||
|
||||
if (!ny_is_installed()) {
|
||||
header('Location: install.php');
|
||||
exit;
|
||||
}
|
||||
if (!ny_is_auth()) {
|
||||
header('Location: login.php');
|
||||
exit;
|
||||
@@ -77,12 +73,20 @@ $rosterCount = Roster::count();
|
||||
</div>
|
||||
</div>
|
||||
<div class="field btns">
|
||||
<label class="chk" title="有多个组别时按组别均衡抽取(如 A:B = 5:5),无组别时按班级均衡"><input type="checkbox" id="balanceChk" checked> 分组均衡</label>
|
||||
<label class="chk" title="决定有分组时如何分配名额"><span style="color:var(--text-2);font-size:12px">分组比例</span>
|
||||
<select id="balanceMode" style="height:30px;border-radius:8px;border:1px solid var(--border);padding:0 8px;background:#fff;margin-left:4px">
|
||||
<option value="weight">按人数加权(默认)</option>
|
||||
<option value="even">各组均分(5:5)</option>
|
||||
<option value="custom">自定义比例</option>
|
||||
<option value="off">不分组(整池随机)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="chk" title="再次抽取时不与本次会话已抽中的人重复;「重置结果」后清零"><input type="checkbox" id="excludePicked" checked> 排除已抽中</label>
|
||||
<button class="btn" id="btnExclude" title="勾选不参与抽取的人">🚫 排除管理</button>
|
||||
<button class="btn btn-primary" id="btnDraw">🎲 开始抽取</button>
|
||||
<button class="btn" id="btnReset" title="清空本次结果与累计已抽取(历史记录保留)">重置结果</button>
|
||||
</div>
|
||||
<div class="field full hidden" id="ratioBox" style="grid-column:1/-1"></div>
|
||||
</section>
|
||||
|
||||
<div id="alert" class="alert hidden"></div>
|
||||
@@ -100,6 +104,7 @@ $rosterCount = Roster::count();
|
||||
<h2>抽取结果 <small id="drawHint"></small></h2>
|
||||
<div class="actions">
|
||||
<button class="btn" id="btnLog" title="查看历史抽取批次(重置结果后仍保留)">📜 历史 <b id="logBadge">0</b></button>
|
||||
<button class="btn" id="btnCopy" title="复制姓名 / 登陆账号到剪贴板(可直接粘贴到微信 / Excel)">📋 复制名单</button>
|
||||
<button class="btn" id="btnDlImport" title="导出为「白名单导入模板.xlsx」">⬇ 白名单导入模板</button>
|
||||
<button class="btn btn-primary" id="btnDlList" title="导出为「白名单列表.xlsx」">⬇ 白名单列表</button>
|
||||
</div>
|
||||
|
||||
@@ -2,10 +2,6 @@
|
||||
declare(strict_types=1);
|
||||
require __DIR__ . '/bootstrap.php';
|
||||
|
||||
if (!ny_is_installed()) {
|
||||
header('Location: install.php');
|
||||
exit;
|
||||
}
|
||||
if (!ny_is_auth()) {
|
||||
header('Location: login.php');
|
||||
exit;
|
||||
@@ -184,23 +180,46 @@ $rosterCount = Roster::count();
|
||||
<input type="text" id="cfgKeywords">
|
||||
<p class="hint">英文逗号分隔,用于自动识别"姓名"题目</p>
|
||||
</div>
|
||||
<div class="fi">
|
||||
<div class="fi full">
|
||||
<label>各试卷独立满分</label>
|
||||
<div id="fullScoresBox" class="fullscores">
|
||||
<span class="hint" id="fullScoresHint">未选中试卷时,此处为空;选中某套试卷后保存即在设置中为该卷单独设定满分</span>
|
||||
</div>
|
||||
<p class="hint">给某卷填入数字即可单独覆盖默认满分;留空=用默认</p>
|
||||
</div>
|
||||
<div class="fi full checks">
|
||||
<label><input type="checkbox" id="cfgDedupe"> 同一学号只保留最新一份答卷</label>
|
||||
<label><input type="checkbox" id="cfgTemp"> 包含"暂存"未提交的答卷</label>
|
||||
<label><input type="checkbox" id="cfgSsl"> 校验 HTTPS 证书</label>
|
||||
</div>
|
||||
|
||||
<div class="fi full" style="margin-top:6px">
|
||||
<div style="display:flex;align-items:center;gap:8px">
|
||||
<label style="margin:0">🔌 卷王系统连接</label>
|
||||
<span class="hint" id="cfgConnInfo" style="font-size:12px"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fi full">
|
||||
<label>系统地址(不可在此修改)</label>
|
||||
<input type="text" id="cfgBase" readonly>
|
||||
<p class="hint">如需修改请编辑 <code>data/config.json</code>(保存后清 token 自动重连)</p>
|
||||
</div>
|
||||
<div class="fi">
|
||||
<label>登录账号</label>
|
||||
<input type="text" id="cfgUser" autocomplete="username" placeholder="登录卷王的账号">
|
||||
</div>
|
||||
<div class="fi">
|
||||
<label>登录密码</label>
|
||||
<input type="password" id="cfgPass" autocomplete="new-password" placeholder="已保存 · 留空表示不修改">
|
||||
<p class="hint">为安全,已保存的密码不回显;如需修改请直接输入新密码后保存</p>
|
||||
</div>
|
||||
</div>
|
||||
<div id="pingResult" class="ping-result hidden"></div>
|
||||
</div>
|
||||
<div class="modal-foot">
|
||||
<span class="hint" style="align-self:center">连接信息由安装向导配置,此处仅调整显示与统计</span>
|
||||
<span class="hint" style="align-self:center">只有修改账号/密码才会触发重连与测连接</span>
|
||||
<span class="spacer"></span>
|
||||
<button class="btn" id="btnTestConn">测试连接</button>
|
||||
<button class="btn" data-close>取消</button>
|
||||
<button class="btn btn-primary" id="btnSaveCfg">保存</button>
|
||||
</div>
|
||||
@@ -245,6 +264,14 @@ $rosterCount = Roster::count();
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 多次刷题明细弹窗 -->
|
||||
<div class="modal" id="attemptsModal">
|
||||
<div class="modal-box">
|
||||
<div class="modal-head"><h3 id="attTitle">提交明细</h3><button class="x" data-close>×</button></div>
|
||||
<div class="modal-body" id="attBody"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
window.NY = {
|
||||
appName: <?= json_encode(NY_APP_NAME, JSON_UNESCAPED_UNICODE) ?>,
|
||||
|
||||
-203
@@ -1,203 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* 首次安装向导:配置数据存储后端(文件 / PostgreSQL / MariaDB)与卷王系统连接信息。
|
||||
* 访问本页时若已安装则自动跳转 /login。
|
||||
*/
|
||||
declare(strict_types=1);
|
||||
require __DIR__ . '/bootstrap.php';
|
||||
|
||||
// 已安装:默认跳转登录页;仅当带 ?force=1 或提交 POST 时才允许重新配置
|
||||
if (ny_is_installed() && empty($_GET['force']) && $_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
header('Location: login.php');
|
||||
exit;
|
||||
}
|
||||
|
||||
$err = '';
|
||||
$ok = false;
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$backend = (string)($_POST['backend'] ?? 'file');
|
||||
$base = rtrim(trim((string)($_POST['base_url'] ?? '')), '/');
|
||||
$prefix = '/' . trim((string)($_POST['api_prefix'] ?? '/api'), '/');
|
||||
$user = trim((string)($_POST['username'] ?? ''));
|
||||
$pwd = (string)($_POST['password'] ?? '');
|
||||
$verify = !empty($_POST['verify_ssl']);
|
||||
$full = (int)($_POST['full_score'] ?? 100);
|
||||
if ($full < 1) {
|
||||
$full = 100;
|
||||
}
|
||||
|
||||
if ($base === '' || $user === '') {
|
||||
$err = '请填写卷王系统地址与登录账号。';
|
||||
} else {
|
||||
// 1) 若是数据库后端,先保存连接参数并测试+建表
|
||||
if ($backend === 'pgsql' || $backend === 'mysql') {
|
||||
$dbp = [
|
||||
'driver' => $backend,
|
||||
'host' => trim((string)($_POST['db_host'] ?? '127.0.0.1')),
|
||||
'port' => (int)($_POST['db_port'] ?? ($backend === 'pgsql' ? 5432 : 3306)),
|
||||
'dbname' => trim((string)($_POST['db_name'] ?? '')),
|
||||
'user' => trim((string)($_POST['db_user'] ?? '')),
|
||||
'password' => (string)($_POST['db_pass'] ?? ''),
|
||||
];
|
||||
if ($dbp['dbname'] === '') {
|
||||
$err = '请填写数据库名。';
|
||||
} else {
|
||||
try {
|
||||
Db::saveParams($dbp);
|
||||
// 重新建立连接并建表
|
||||
$pdo = Db::connect($dbp);
|
||||
Db::migrate();
|
||||
} catch (Throwable $e) {
|
||||
$err = '数据库连接/初始化失败:' . $e->getMessage();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($err === '') {
|
||||
// 2) 写入系统配置(DB 后端写 settings['config'],文件后端写 config.json)
|
||||
$cfg = ny_default_config();
|
||||
$cfg['base_url'] = $base;
|
||||
$cfg['api_prefix'] = $prefix;
|
||||
$cfg['username'] = $user;
|
||||
$cfg['password'] = $pwd;
|
||||
$cfg['verify_ssl'] = $verify;
|
||||
$cfg['full_score'] = $full;
|
||||
$saved = ny_save_config($cfg);
|
||||
if ($saved) {
|
||||
// 清理 token 缓存(连接信息变化)
|
||||
@unlink(NY_DATA . '/cache/token.json');
|
||||
$ok = true;
|
||||
} else {
|
||||
if ($backend === 'file') {
|
||||
$err = '无法写入文件配置,请检查 data/ 目录权限。';
|
||||
} else {
|
||||
$err = '配置写入失败(已建表但写入配置异常)。';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$cfg = ny_load_config();
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>首次安装 · <?= htmlspecialchars(NY_APP_NAME) ?></title>
|
||||
<link rel="stylesheet" href="assets/style.css">
|
||||
<style>
|
||||
.install-wrap { position:fixed; inset:0; z-index:200; display:flex; align-items:center; justify-content:center; padding:20px; background:linear-gradient(135deg,#1b2440,#2f6bff); overflow:auto; }
|
||||
.install-box { width:640px; max-width:100%; background:#fff; border-radius:18px; padding:28px 28px 30px; box-shadow:0 24px 64px rgba(15,23,42,.35); }
|
||||
.install-box::before { content:''; position:absolute; }
|
||||
.steps { display:flex; gap:8px; margin:6px 0 20px; }
|
||||
.steps i { flex:1; height:4px; border-radius:999px; background:#e2e8f2; }
|
||||
.steps i.on { background:linear-gradient(90deg,#2f6bff,#6a4bff); }
|
||||
.opt-seg { display:flex; gap:10px; }
|
||||
.opt-seg label { flex:1; display:flex; align-items:center; justify-content:center; gap:8px; border:1px solid var(--border); border-radius:10px; padding:12px 8px; cursor:pointer; font-size:13px; font-weight:600; color:var(--text-2); transition:.15s; }
|
||||
.opt-seg label.on { border-color:var(--primary); background:var(--primary-weak); color:var(--primary); }
|
||||
.opt-seg input { display:none; }
|
||||
.db-box { margin-top:14px; padding:14px; border:1px dashed #cfd8ea; border-radius:10px; background:#fbfcfe; }
|
||||
.db-box.hidden { display:none; }
|
||||
.form-grid2 { display:grid; grid-template-columns:1fr 1fr; gap:12px; }
|
||||
.fi.full { grid-column:1 / -1; }
|
||||
.banner { padding:12px 14px; border-radius:10px; font-size:12.5px; margin-bottom:16px; line-height:1.6; }
|
||||
.banner.err { background:var(--danger-weak); color:#a3342c; border:1px solid #f5d3cf; }
|
||||
.banner.ok { background:var(--ok-weak); color:#0d7a4d; border:1px solid #c8ecda; }
|
||||
.forgot { display:flex; justify-content:flex-end; margin-top:6px; font-size:12px; color:var(--text-3); }
|
||||
</style>
|
||||
</head>
|
||||
<body class="dash">
|
||||
<div class="install-wrap">
|
||||
<div class="install-box">
|
||||
<div class="login-brand">
|
||||
<span class="logo">NY</span>
|
||||
<div class="brand-text">
|
||||
<strong><?= htmlspecialchars(NY_APP_NAME) ?></strong>
|
||||
<small>首次安装 · 配置数据存储与系统连接</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="steps"><i class="on"></i><i class="on"></i></div>
|
||||
|
||||
<?php if ($ok): ?>
|
||||
<div class="banner ok">✅ 安装完成!即将进入登录页…</div>
|
||||
<p style="font-size:13px;color:var(--text-2);text-align:center;margin:24px 0 6px">
|
||||
若连接信息有误或未生效,请重新访问本页配置。</p>
|
||||
<div class="login-actions"><button class="btn btn-primary" onclick="location.href='login.php'">前往登录</button></div>
|
||||
<?php else: ?>
|
||||
<form method="post" class="form-grid" id="f">
|
||||
<?php if ($err !== ''): ?><div class="banner err full"><?= htmlspecialchars($err) ?></div><?php endif; ?>
|
||||
|
||||
<div class="fi full">
|
||||
<label>数据存储后端</label>
|
||||
<div class="opt-seg" id="seg">
|
||||
<label><input type="radio" name="backend" value="file" checked><span>✅ 文件存储</span><small style="font-weight:500;color:var(--text-3)">无需数据库,开箱即用</small></label>
|
||||
<label><input type="radio" name="backend" value="pgsql"><span>🐘 PostgreSQL</span><small style="font-weight:500;color:var(--text-3)">生产环境推荐</small></label>
|
||||
<label><input type="radio" name="backend" value="mysql"><span>🍀 MariaDB / MySQL</span><small style="font-weight:500;color:var(--text-3)">兼容 MySQL 协议</small></label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="db-box hidden full" id="dbBox">
|
||||
<div class="form-grid2">
|
||||
<div class="fi"><label>主机</label><input type="text" name="db_host" value="127.0.0.1"></div>
|
||||
<div class="fi"><label>端口</label><input type="number" id="db_port" name="db_port" value="5432"></div>
|
||||
<div class="fi"><label>数据库名</label><input type="text" name="db_name"></div>
|
||||
<div class="fi"><label>账号</label><input type="text" name="db_user"></div>
|
||||
<div class="fi full"><label>密码</label><input type="password" name="db_pass"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fi full">
|
||||
<label>卷王系统地址</label>
|
||||
<input type="text" name="base_url" value="<?= htmlspecialchars($cfg['base_url']) ?>" placeholder="https://exam.nxxy335.top">
|
||||
</div>
|
||||
<div class="fi">
|
||||
<label>接口前缀</label>
|
||||
<input type="text" name="api_prefix" value="<?= htmlspecialchars($cfg['api_prefix']) ?>" placeholder="/api">
|
||||
</div>
|
||||
<div class="fi">
|
||||
<label>默认满分</label>
|
||||
<input type="number" name="full_score" min="1" max="1000" value="<?= (int)$cfg['full_score'] ?>">
|
||||
<p class="hint">用于合格率(≥60%满分),各试卷可在设置中单独调整</p>
|
||||
</div>
|
||||
<div class="fi">
|
||||
<label>登录账号</label>
|
||||
<input type="text" name="username" value="<?= htmlspecialchars($cfg['username']) ?>" placeholder="admin">
|
||||
</div>
|
||||
<div class="fi">
|
||||
<label>登录密码</label>
|
||||
<input type="password" name="password" placeholder="请输入密码">
|
||||
</div>
|
||||
<div class="fi full checks">
|
||||
<label><input type="checkbox" name="verify_ssl" <?= $cfg['verify_ssl'] ? 'checked' : '' ?>> 校验 HTTPS 证书</label>
|
||||
</div>
|
||||
|
||||
<div class="full" style="margin-top:10px">
|
||||
<button class="btn btn-primary" id="btnInstall" style="width:100%;height:44px;font-size:15px">⚙️ 完成安装</button>
|
||||
</div>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
(function(){
|
||||
var seg = document.getElementById('seg');
|
||||
var dbBox = document.getElementById('dbBox');
|
||||
var dbPort = document.getElementById('db_port');
|
||||
function sync(){
|
||||
var v = (document.querySelector('input[name=backend]:checked')||{}).value || 'file';
|
||||
var labels = seg.querySelectorAll('label');
|
||||
labels.forEach(function(l){ l.classList.toggle('on', l.querySelector('input').value === v); });
|
||||
dbBox.classList.toggle('hidden', v === 'file');
|
||||
if (v === 'pgsql') dbPort.value = 5432;
|
||||
if (v === 'mysql') dbPort.value = 3306;
|
||||
}
|
||||
seg.addEventListener('change', sync);
|
||||
sync();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+73
-13
@@ -128,9 +128,8 @@ class DataService
|
||||
$includeTemp = !empty($cfg['include_temp_save']);
|
||||
$dedupe = !empty($cfg['dedupe']);
|
||||
|
||||
$rows = [];
|
||||
$rawRows = []; // 已匹配学号的原始 row(每条 = 一次提交)
|
||||
$unmatched = [];
|
||||
$seenSid = [];
|
||||
|
||||
foreach ($answers as $a) {
|
||||
$tempSave = isset($a['tempSave']) ? (int)$a['tempSave'] : 1;
|
||||
@@ -226,19 +225,15 @@ class DataService
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($dedupe && isset($seenSid[$sid])) {
|
||||
$idx = $seenSid[$sid];
|
||||
// 保留提交时间最新的一份
|
||||
if ($ts >= ($rows[$idx]['submit_ts'] ?? 0)) {
|
||||
$rows[$idx] = $row;
|
||||
$seenSid[$sid] = $idx;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
$seenSid[$sid] = count($rows);
|
||||
$rows[] = $row;
|
||||
$rawRows[] = $row;
|
||||
}
|
||||
|
||||
// 按学号聚合:dedupe=true 仅保留最新一份;false 则每名一行(代表=最高分)
|
||||
// 并附带 attempts(次数) / attempts_list(每次提交明细),供看板展示"多次刷题"
|
||||
$agg = self::aggregateByStudent($rawRows, $dedupe);
|
||||
$rows = $agg['rows'];
|
||||
$attemptsTotal = $agg['attempts_total'];
|
||||
|
||||
// 成绩明细默认按提交时间降序(最新在前),学号作为次级排序
|
||||
usort($rows, static function (array $x, array $y): int {
|
||||
$d = ($y['submit_ts'] ?? 0) <=> ($x['submit_ts'] ?? 0);
|
||||
@@ -292,6 +287,7 @@ class DataService
|
||||
'min_score' => $scores !== [] ? min($scores) : null,
|
||||
'avg_duration' => $durs !== [] ? (int)round(array_sum($durs) / count($durs)) : null,
|
||||
'answer_total' => count($answers),
|
||||
'attempts_total'=> $attemptsTotal,
|
||||
];
|
||||
|
||||
return [
|
||||
@@ -312,4 +308,68 @@ class DataService
|
||||
'updated_at' => date('Y-m-d H:i:s'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 按学号聚合一次提交的答卷记录,解决"同人多次刷卷"在看板中被当成多个人的问题。
|
||||
* - dedupe=true:每学号仅保留提交时间最新的一份(原行为)
|
||||
* - dedupe=false:每学号一行,代表成绩取最高分,附 attempts(次数) / attempts_list(每次提交明细)
|
||||
*
|
||||
* @param array $rows 已匹配学号的原始 row(每条 = 一次提交)
|
||||
* @return array{rows:array, attempts_total:int}
|
||||
*/
|
||||
public static function aggregateByStudent(array $rows, bool $dedupe): array
|
||||
{
|
||||
$bySid = [];
|
||||
foreach ($rows as $r) {
|
||||
$bySid[(string)($r['sid'] ?? '')][] = $r;
|
||||
}
|
||||
$agg = [];
|
||||
$attemptsTotal = 0;
|
||||
foreach ($bySid as $sid => $list) {
|
||||
$attemptsTotal += count($list);
|
||||
// 升序:旧 -> 新
|
||||
$sorted = $list;
|
||||
usort($sorted, static function (array $x, array $y): int {
|
||||
return ($x['submit_ts'] ?? 0) <=> ($y['submit_ts'] ?? 0);
|
||||
});
|
||||
$attemptsList = array_map(static function (array $a): array {
|
||||
return [
|
||||
'submit_at' => $a['submit_at'],
|
||||
'submit_ts' => $a['submit_ts'],
|
||||
'score' => $a['score'],
|
||||
'duration' => $a['duration'],
|
||||
'duration_txt' => $a['duration_txt'],
|
||||
'answer_id' => $a['answer_id'],
|
||||
'temp_save' => $a['temp_save'],
|
||||
];
|
||||
}, $sorted);
|
||||
|
||||
if ($dedupe) {
|
||||
// 仅保留提交时间最新的一份(明细列表也只留这一份,与"只保留最新"语义一致)
|
||||
$row = $sorted[count($sorted) - 1];
|
||||
$row['attempts'] = 1;
|
||||
$row['best_score'] = $row['score'];
|
||||
$row['attempts_list'] = [$attemptsList[count($attemptsList) - 1]];
|
||||
} else {
|
||||
// 代表成绩取最高分(null 成绩不参与比较,全部为 null 时回退为最近一份)
|
||||
$best = null;
|
||||
$bestScore = null;
|
||||
foreach ($sorted as $a) {
|
||||
if ($a['score'] !== null && ($bestScore === null || $a['score'] > $bestScore)) {
|
||||
$bestScore = $a['score'];
|
||||
$best = $a;
|
||||
}
|
||||
}
|
||||
if ($best === null) {
|
||||
$best = $sorted[count($sorted) - 1];
|
||||
}
|
||||
$row = $best;
|
||||
$row['attempts'] = count($list);
|
||||
$row['best_score'] = $bestScore;
|
||||
$row['attempts_list'] = $attemptsList;
|
||||
}
|
||||
$agg[] = $row;
|
||||
}
|
||||
return ['rows' => $agg, 'attempts_total' => $attemptsTotal];
|
||||
}
|
||||
}
|
||||
|
||||
+42
-272
@@ -1,313 +1,83 @@
|
||||
<?php
|
||||
/**
|
||||
* 数据库连接层(统一 PDO)
|
||||
*
|
||||
* 支持 PostgreSQL / MariaDB(MySQL) / SQLite(主要用于本地无 DB 时的降级与测试)。
|
||||
* 连接参数存于 data/db.json;通过 Db::instance() 取单例连接。
|
||||
* 未配置数据库时返回 null,上层回退到文件存储(config.json / roster.json)。
|
||||
* 数据库适配层(旧:pgsql/mysql/sqlite 三方言支持)
|
||||
* 本批次已下线,仅保留文件存储。如未来需要接入 DB,请在此文件实现 PDO 单例。
|
||||
*/
|
||||
declare(strict_types=1);
|
||||
|
||||
class Db
|
||||
{
|
||||
/** @var PDO|null */
|
||||
private static $pdo = null;
|
||||
private static string $driver = '';
|
||||
private static bool $tried = false;
|
||||
private static bool $failed = false;
|
||||
/** @var PDO|null 当前不可用,始终返回 null */
|
||||
private static ?PDO $pdo = null;
|
||||
|
||||
/** 数据库连接参数文件 */
|
||||
public static function dbFile(): string
|
||||
{
|
||||
return NY_DATA . '/db.json';
|
||||
}
|
||||
|
||||
/** 是否已配置数据库(db.json 存在且有 driver/host 等) */
|
||||
public static function isConfigured(): bool
|
||||
{
|
||||
$d = self::params();
|
||||
return !empty($d['driver']);
|
||||
}
|
||||
|
||||
/** 读取连接参数 */
|
||||
public static function params(): array
|
||||
{
|
||||
$d = ny_read_json(self::dbFile(), null);
|
||||
if (!is_array($d)) {
|
||||
return [];
|
||||
}
|
||||
return $d;
|
||||
}
|
||||
|
||||
/** 写入连接参数 */
|
||||
public static function saveParams(array $p): bool
|
||||
{
|
||||
return ny_write_json(self::dbFile(), $p);
|
||||
}
|
||||
|
||||
/** 当前驱动名(用于 SQL 方言):mysql | pgsql | sqlite | '' */
|
||||
public static function driver(): string
|
||||
{
|
||||
self::check();
|
||||
return self::$driver;
|
||||
return false;
|
||||
}
|
||||
|
||||
public static function isConnected(): bool
|
||||
{
|
||||
self::check();
|
||||
return self::$pdo !== null;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 PDO 单例;未配置或连不上返回 null。
|
||||
*/
|
||||
public static function pdo(): ?PDO
|
||||
public static function driver(): string
|
||||
{
|
||||
self::check();
|
||||
return self::$pdo;
|
||||
return '';
|
||||
}
|
||||
|
||||
private static function check(): void
|
||||
public static function lastError(): string
|
||||
{
|
||||
if (self::$tried) {
|
||||
return;
|
||||
return '';
|
||||
}
|
||||
self::$tried = true;
|
||||
$p = self::params();
|
||||
$driver = strtolower((string)($p['driver'] ?? ''));
|
||||
if ($driver === '') {
|
||||
return;
|
||||
|
||||
public static function params(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
self::$driver = $driver;
|
||||
try {
|
||||
self::$pdo = self::connect($p);
|
||||
} catch (Throwable $e) {
|
||||
self::$failed = true;
|
||||
self::$pdo = null;
|
||||
}
|
||||
|
||||
public static function saveParams(array $p): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 根据参数建 PDO 连接 */
|
||||
public static function connect(array $p): PDO
|
||||
{
|
||||
$driver = strtolower((string)($p['driver'] ?? ''));
|
||||
$host = (string)($p['host'] ?? '127.0.0.1');
|
||||
$port = (int)($p['port'] ?? 0);
|
||||
$db = (string)($p['dbname'] ?? ($p['database'] ?? ''));
|
||||
$user = (string)($p['user'] ?? ($p['username'] ?? ''));
|
||||
$pass = (string)($p['password'] ?? '');
|
||||
|
||||
$options = [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
PDO::ATTR_EMULATE_PREPARES => false,
|
||||
];
|
||||
|
||||
if ($driver === 'sqlite') {
|
||||
$dsn = 'sqlite:' . ($p['path'] ?? ($db ?: (NY_DATA . '/app.sqlite')));
|
||||
} elseif ($driver === 'pgsql') {
|
||||
$dsn = 'pgsql:host=' . $host;
|
||||
if ($port > 0) {
|
||||
$dsn .= ';port=' . $port;
|
||||
}
|
||||
$dsn .= ';dbname=' . $db;
|
||||
$dsn .= ';user=' . $user;
|
||||
$dsn .= ';password=' . $pass;
|
||||
} else { // mysql / mariadb
|
||||
$dsn = 'mysql:host=' . $host;
|
||||
if ($port > 0) {
|
||||
$dsn .= ';port=' . $port;
|
||||
}
|
||||
$dsn .= ';dbname=' . $db . ';charset=utf8mb4';
|
||||
$options[PDO::ATTR_PERSISTENT] = false;
|
||||
throw new RuntimeException('数据库后端已下线,请使用文件存储(data/config.json / data/roster.json)');
|
||||
}
|
||||
|
||||
$pdo = new PDO($dsn, $user, $pass, $options);
|
||||
if ($driver === 'mysql') {
|
||||
$pdo->exec("SET NAMES utf8mb4");
|
||||
}
|
||||
return $pdo;
|
||||
public static function pdo(): ?PDO
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 表名前缀 */
|
||||
public static function prefix(): string
|
||||
{
|
||||
return 'ny_';
|
||||
}
|
||||
|
||||
/**
|
||||
* 建表(幂等)。返回已执行语句数量。
|
||||
* 用通用 SQL,三方言均可执行(表名/列名小写、规范命名)。
|
||||
*/
|
||||
public static function migrate(): int
|
||||
{
|
||||
$pdo = self::pdo();
|
||||
if ($pdo === null) {
|
||||
throw new RuntimeException('数据库未连接,无法初始化表结构');
|
||||
}
|
||||
$driver = self::$driver;
|
||||
$n = 0;
|
||||
|
||||
// settings(key,value) —— 系统设置键值表
|
||||
if ($driver === 'sqlite') {
|
||||
$sql = 'CREATE TABLE IF NOT EXISTS ny_settings (' .
|
||||
'k VARCHAR(128) PRIMARY KEY, ' .
|
||||
'v TEXT)';
|
||||
} elseif ($driver === 'pgsql') {
|
||||
$sql = 'CREATE TABLE IF NOT EXISTS ny_settings (' .
|
||||
'k VARCHAR(128) PRIMARY KEY, ' .
|
||||
'v TEXT)';
|
||||
} else {
|
||||
$sql = 'CREATE TABLE IF NOT EXISTS ny_settings (' .
|
||||
'`k` VARCHAR(128) NOT NULL PRIMARY KEY, ' .
|
||||
'`v` TEXT) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4';
|
||||
}
|
||||
$pdo->exec($sql); $n++;
|
||||
|
||||
// roster —— 名单表(学号/姓名/班级/组别/登陆账号)
|
||||
if ($driver === 'sqlite') {
|
||||
$sql = 'CREATE TABLE IF NOT EXISTS ny_roster (' .
|
||||
'id INTEGER PRIMARY KEY AUTOINCREMENT, ' .
|
||||
'sid VARCHAR(64), name VARCHAR(128), cls VARCHAR(64), grp VARCHAR(32), login VARCHAR(64))';
|
||||
} elseif ($driver === 'pgsql') {
|
||||
$sql = 'CREATE TABLE IF NOT EXISTS ny_roster (' .
|
||||
'id SERIAL PRIMARY KEY, ' .
|
||||
'sid VARCHAR(64), name VARCHAR(128), cls VARCHAR(64), grp VARCHAR(32), login VARCHAR(64))';
|
||||
} else {
|
||||
$sql = 'CREATE TABLE IF NOT EXISTS ny_roster (' .
|
||||
'id INT AUTO_INCREMENT PRIMARY KEY, ' .
|
||||
'sid VARCHAR(64), name VARCHAR(128), cls VARCHAR(64), grp VARCHAR(32), login VARCHAR(64)) ' .
|
||||
'ENGINE=InnoDB DEFAULT CHARSET=utf8mb4';
|
||||
}
|
||||
$pdo->exec($sql); $n++;
|
||||
|
||||
// 旧版本升级:补 login 列
|
||||
self::ensureRosterLogin();
|
||||
|
||||
return $n;
|
||||
}
|
||||
|
||||
/** 旧表升级:ny_roster 缺 login 列时自动补(三方言兼容) */
|
||||
public static function ensureRosterLogin(): void
|
||||
{
|
||||
$pdo = self::pdo();
|
||||
if ($pdo === null) {
|
||||
return;
|
||||
}
|
||||
$has = false;
|
||||
if (self::$driver === 'sqlite') {
|
||||
foreach ($pdo->query('PRAGMA table_info(ny_roster)')->fetchAll() as $c) {
|
||||
if (strtolower((string)$c['name']) === 'login') {
|
||||
$has = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} elseif (self::$driver === 'pgsql') {
|
||||
$st = $pdo->prepare("SELECT COUNT(*) FROM information_schema.columns " .
|
||||
"WHERE table_name = 'ny_roster' AND column_name = 'login'");
|
||||
$st->execute();
|
||||
$has = (int)$st->fetchColumn() > 0;
|
||||
} else {
|
||||
$has = $pdo->query("SHOW COLUMNS FROM ny_roster LIKE 'login'")->fetch() !== false;
|
||||
}
|
||||
if (!$has) {
|
||||
$pdo->exec('ALTER TABLE ny_roster ADD COLUMN login VARCHAR(64)');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 把所有 settings 读成数组
|
||||
*/
|
||||
public static function settingsAll(): array
|
||||
{
|
||||
$pdo = self::pdo();
|
||||
if ($pdo === null) {
|
||||
return [];
|
||||
}
|
||||
$out = [];
|
||||
$stmt = $pdo->query('SELECT k, v FROM ny_settings');
|
||||
foreach ($stmt->fetchAll() as $row) {
|
||||
$out[$row['k']] = $row['v'];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
public static function settingsSet(string $k, string $v): void
|
||||
{
|
||||
$pdo = self::pdo();
|
||||
if ($pdo === null) {
|
||||
return;
|
||||
}
|
||||
$drv = self::$driver;
|
||||
if ($drv === 'pgsql') {
|
||||
$sql = 'INSERT INTO ny_settings(k, v) VALUES(?, ?) ' .
|
||||
'ON CONFLICT (k) DO UPDATE SET v = EXCLUDED.v';
|
||||
} elseif ($drv === 'sqlite') {
|
||||
$sql = 'INSERT OR REPLACE INTO ny_settings(k, v) VALUES(?, ?)';
|
||||
} else {
|
||||
$sql = 'INSERT INTO ny_settings(k, v) VALUES(?, ?) ' .
|
||||
'ON DUPLICATE KEY UPDATE v = VALUES(v)';
|
||||
}
|
||||
$pdo->prepare($sql)->execute([$k, $v]);
|
||||
}
|
||||
|
||||
/** 名单:全量读 */
|
||||
public static function rosterAll(): array
|
||||
{
|
||||
$pdo = self::pdo();
|
||||
if ($pdo === null) {
|
||||
return [];
|
||||
}
|
||||
self::ensureRosterLogin();
|
||||
$stmt = $pdo->query('SELECT sid, name, cls, grp, login FROM ny_roster ORDER BY id');
|
||||
$rows = [];
|
||||
foreach ($stmt->fetchAll() as $r) {
|
||||
$rows[] = [
|
||||
'sid' => (string)$r['sid'],
|
||||
'name' => (string)$r['name'],
|
||||
'cls' => (string)$r['cls'],
|
||||
'grp' => (string)$r['grp'],
|
||||
'login' => (string)($r['login'] ?? ''),
|
||||
];
|
||||
}
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/** 名单:清空后全量写 */
|
||||
public static function rosterReplace(array $students): void
|
||||
{
|
||||
$pdo = self::pdo();
|
||||
if ($pdo === null) {
|
||||
return;
|
||||
}
|
||||
self::ensureRosterLogin();
|
||||
$pdo->beginTransaction();
|
||||
try {
|
||||
$pdo->exec('DELETE FROM ny_roster');
|
||||
$st = $pdo->prepare('INSERT INTO ny_roster(sid, name, cls, grp, login) VALUES(?, ?, ?, ?, ?)');
|
||||
foreach ($students as $s) {
|
||||
$st->execute([
|
||||
(string)($s['sid'] ?? ''),
|
||||
(string)($s['name'] ?? ''),
|
||||
(string)($s['cls'] ?? ''),
|
||||
(string)($s['grp'] ?? ''),
|
||||
(string)($s['login'] ?? ''),
|
||||
]);
|
||||
}
|
||||
$pdo->commit();
|
||||
} catch (Throwable $e) {
|
||||
$pdo->rollBack();
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/** 名单:行数 */
|
||||
public static function rosterCount(): int
|
||||
{
|
||||
$pdo = self::pdo();
|
||||
if ($pdo === null) {
|
||||
return 0;
|
||||
}
|
||||
return (int)$pdo->query('SELECT COUNT(*) FROM ny_roster')->fetchColumn();
|
||||
|
||||
public static function ensureRosterLogin(): void {}
|
||||
|
||||
public static function settingsAll(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
public static function settingsSet(string $k, string $v): void {}
|
||||
|
||||
public static function rosterAll(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
public static function rosterReplace(array $students): void {}
|
||||
|
||||
public static function rosterCount(): int
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
+36
-11
@@ -128,6 +128,10 @@ class Roster
|
||||
* 解析上传文件 -> 学生数组
|
||||
* @return array{count:int,students:array}
|
||||
*/
|
||||
/**
|
||||
* 解析上传文件 -> 学生数组
|
||||
* @return array{count:int,skipped:int,duplicates:int,headers:array,students:array}
|
||||
*/
|
||||
public static function importFile(string $file, string $ext = ''): array
|
||||
{
|
||||
if ($ext === '') {
|
||||
@@ -148,9 +152,17 @@ class Roster
|
||||
}
|
||||
|
||||
if ($rows === []) {
|
||||
throw new RuntimeException('文件为空或无法解析');
|
||||
throw new RuntimeException('文件为空或无法解析(请确认是 xlsx/csv 且包含数据行)');
|
||||
}
|
||||
return ['count' => count($rows), 'students' => self::rowsToStudents($rows)];
|
||||
$meta = [];
|
||||
$students = self::rowsToStudents($rows, $meta);
|
||||
return [
|
||||
'count' => count($students),
|
||||
'skipped' => $meta['skipped'],
|
||||
'duplicates' => $meta['duplicates'],
|
||||
'headers' => $meta['headers'],
|
||||
'students' => $students,
|
||||
];
|
||||
}
|
||||
|
||||
public static function parseCsv(string $file): array
|
||||
@@ -195,10 +207,13 @@ class Roster
|
||||
/**
|
||||
* 识别 学号/姓名/班级/组别/登陆账号 列
|
||||
* 登陆账号仅用于随机抽取页导出白名单,界面任何位置都不显示
|
||||
* @param array $rows
|
||||
* @param array $meta 输出:['skipped'=>int,'duplicates'=>int,'headers'=>array]
|
||||
* @return array<int,array{sid:string,name:string,cls:string,grp:string,login:string}>
|
||||
*/
|
||||
public static function rowsToStudents(array $rows): array
|
||||
public static function rowsToStudents(array $rows, array &$meta = []): array
|
||||
{
|
||||
$meta = ['skipped' => 0, 'duplicates' => 0, 'headers' => []];
|
||||
$sidCol = -1;
|
||||
$nameCol = -1;
|
||||
$clsCol = -1;
|
||||
@@ -206,11 +221,11 @@ class Roster
|
||||
$loginCol = -1;
|
||||
$headerIdx = -1;
|
||||
|
||||
// 找表头
|
||||
// 找表头:同一行需同时命中「学号类」与「姓名类」关键词(容错全角空格/间隔)
|
||||
foreach ($rows as $i => $row) {
|
||||
$joined = implode('|', array_map('strval', $row));
|
||||
if (preg_match('/学\s*号|编\s*号|序\s*号|no\.?|id/i', $joined)
|
||||
&& preg_match('/姓\s*名|名\s*字|学生|成员/i', $joined)) {
|
||||
if (preg_match('/学\s*号|编\s*号|序\s*号|学\s*员\s*号|号\s*码|no\.?|id/i', $joined)
|
||||
&& preg_match('/姓\s*名|名\s*字|学\s*生\s*姓\s*名|姓\s*氏|成\s*员/i', $joined)) {
|
||||
$headerIdx = $i;
|
||||
break;
|
||||
}
|
||||
@@ -220,22 +235,25 @@ class Roster
|
||||
$start = $headerIdx + 1;
|
||||
foreach ($rows[$headerIdx] as $j => $cell) {
|
||||
$c = (string)$cell;
|
||||
if ($sidCol < 0 && preg_match('/学\s*号|编\s*号|序\s*号|^\s*no\.?\s*$|^\s*id\s*$/i', $c)) {
|
||||
if ($sidCol < 0 && preg_match('/学\s*号|编\s*号|序\s*号|学\s*员\s*号|号\s*码|^\s*no\.?\s*$|^\s*id\s*$/i', $c)) {
|
||||
$sidCol = $j;
|
||||
}
|
||||
if ($nameCol < 0 && preg_match('/姓\s*名|名\s*字|学生姓名/i', $c)) {
|
||||
if ($nameCol < 0 && preg_match('/姓\s*名|名\s*字|学\s*生\s*姓\s*名|姓\s*氏/i', $c)) {
|
||||
$nameCol = $j;
|
||||
}
|
||||
if ($clsCol < 0 && preg_match('/班\s*级|班级|class/i', $c)) {
|
||||
$clsCol = $j;
|
||||
}
|
||||
if ($grpCol < 0 && preg_match('/组\s*别|分\s*组|^\s*组\s*$|group/i', $c)) {
|
||||
if ($grpCol < 0 && preg_match('/组\s*别|分\s*组|组\s*次|^\s*组\s*$|group/i', $c)) {
|
||||
$grpCol = $j;
|
||||
}
|
||||
if ($loginCol < 0 && preg_match('/登\s*[陆录]\s*账\s*号|登\s*[陆录]\s*账号|账\s*号|手\s*机\s*号|手\s*机/i', $c)) {
|
||||
$loginCol = $j;
|
||||
}
|
||||
}
|
||||
$meta['headers'] = [
|
||||
'sid' => $sidCol, 'name' => $nameCol, 'cls' => $clsCol, 'grp' => $grpCol, 'login' => $loginCol,
|
||||
];
|
||||
}
|
||||
if ($sidCol < 0) {
|
||||
$sidCol = 0;
|
||||
@@ -245,20 +263,27 @@ class Roster
|
||||
}
|
||||
|
||||
$out = [];
|
||||
$seen = [];
|
||||
for ($i = $start; $i < count($rows); $i++) {
|
||||
$row = $rows[$i];
|
||||
$sid = trim((string)($row[$sidCol] ?? ''));
|
||||
$name = trim((string)($row[$nameCol] ?? ''));
|
||||
if ($sid === '' && $name === '') {
|
||||
$meta['skipped']++;
|
||||
continue;
|
||||
}
|
||||
// 学号去掉 .0 尾巴(Excel 数字读取)
|
||||
// 学号/登陆账号去掉 Excel 数字尾巴(如 2024012001.0 / 13912687810.0)
|
||||
$sid = preg_replace('/\.0+$/', '', $sid) ?? $sid;
|
||||
$cls = $clsCol >= 0 ? trim((string)($row[$clsCol] ?? '')) : '';
|
||||
$grp = $grpCol >= 0 ? trim((string)($row[$grpCol] ?? '')) : '';
|
||||
$login = $loginCol >= 0 ? trim((string)($row[$loginCol] ?? '')) : '';
|
||||
// 登陆账号同样去掉 Excel 数字尾巴(手机号被读成 13912687810.0)
|
||||
$login = preg_replace('/\.0+$/', '', $login) ?? $login;
|
||||
// 同一条学号多次出现:保留首次,其余记重复跳过(避免看板/统计被重复计入)
|
||||
if ($sid !== '' && isset($seen[$sid])) {
|
||||
$meta['duplicates']++;
|
||||
continue;
|
||||
}
|
||||
$seen[$sid] = true;
|
||||
$out[] = ['sid' => $sid, 'name' => $name, 'cls' => $cls, 'grp' => $grp, 'login' => $login];
|
||||
}
|
||||
return $out;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<?php
|
||||
/**
|
||||
* 独立登录页:连接信息(系统地址/接口前缀/账号)已在安装向导配置,
|
||||
* 本页仅需输入密码(账号可改)完成卷王登录,成功后进入主系统。
|
||||
* 独立登录页:连接信息(系统地址/接口前缀/账号)已在 data/config.json 中预置,
|
||||
* 本页只需输入密码(账号可改)完成卷王登录,成功后进入主系统。
|
||||
* 连接信息有变请在【设置】中修改。
|
||||
*/
|
||||
declare(strict_types=1);
|
||||
require __DIR__ . '/bootstrap.php';
|
||||
@@ -10,10 +11,6 @@ if (ny_is_auth()) {
|
||||
header('Location: index.php');
|
||||
exit;
|
||||
}
|
||||
if (!ny_is_installed()) {
|
||||
header('Location: install.php');
|
||||
exit;
|
||||
}
|
||||
|
||||
$cfg = ny_load_config();
|
||||
$err = '';
|
||||
@@ -33,8 +30,10 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
try {
|
||||
$client = new SurveyKingClient($test);
|
||||
$client->getToken(true);
|
||||
// 登录成功:把账号写回配置(如需),并建立会话
|
||||
// 登录成功:把账号与密码写回配置(本地单用户工具,持久化密码以便 token
|
||||
// 失效时平台能自动重新登录卷王,避免后台刷新陷入"未配置账号"的死状态)
|
||||
$cfg['username'] = $user;
|
||||
$cfg['password'] = $pwd;
|
||||
ny_save_config($cfg);
|
||||
ny_login();
|
||||
$ok = true;
|
||||
@@ -75,7 +74,6 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
<button class="btn btn-primary" type="submit">登录</button>
|
||||
</div>
|
||||
</form>
|
||||
<div class="forgot"><span style="font-size:12px;color:var(--text-3)">如系统连接信息已变更,请先完成<a href="install.php?force=1" style="color:var(--primary)">重新安装/配置</a></span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -100,7 +100,7 @@ const server = http.createServer((req, res) => {
|
||||
Buffer.from(pwd, 'base64')
|
||||
).toString('utf8');
|
||||
} catch (e) { plain = '<<解密失败>>'; }
|
||||
if (j.username !== 'admin' || plain !== PASSWORD) {
|
||||
if (j.username !== 'mock' || plain !== PASSWORD) {
|
||||
return send(res, 200, { code: 500, data: null, msg: '账号或密码错误(收到明文=' + plain + ')' });
|
||||
}
|
||||
TOKEN = 'mock.jwt.token.' + Date.now();
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
// e2e: config_save 仅连接信息变化才清 token;各试卷独立满分修复;密码不回显
|
||||
// 运行:node tools/test-config-save.js
|
||||
const http = require('http');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { spawn } = require('child_process');
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
const PORT = 18081;
|
||||
const PHP = process.env.PHP_BIN || 'C:/Users/Zhang/.workbuddy/binaries/php/php.exe';
|
||||
const INI = process.env.PHP_INI || '-d extension_dir=C:/Users/Zhang/.workbuddy/binaries/php/ext -d extension=pdo_sqlite';
|
||||
|
||||
// 1) 备份 / 准备文件存储模式
|
||||
const cfgFile = path.join(ROOT, 'data', 'config.json');
|
||||
const cfgBak = cfgFile + '.bak';
|
||||
const origCfg = fs.existsSync(cfgFile) ? fs.readFileSync(cfgFile, 'utf8') : null;
|
||||
const tokenFile = path.join(ROOT, 'data', 'cache', 'token.json');
|
||||
const tokenBak = tokenFile + '.bak';
|
||||
const hasToken = fs.existsSync(tokenFile);
|
||||
const tokenSnap = hasToken ? fs.readFileSync(tokenFile, 'utf8') : null;
|
||||
if (hasToken) fs.renameSync(tokenFile, tokenBak);
|
||||
|
||||
fs.writeFileSync(cfgFile, JSON.stringify({
|
||||
base_url: 'http://127.0.0.1:19911',
|
||||
api_prefix: '/api',
|
||||
username: 'mock',
|
||||
password: 'surveyking',
|
||||
verify_ssl: false,
|
||||
timeout: 10,
|
||||
refresh_interval: 15,
|
||||
page_size: 50,
|
||||
include_temp_save: false,
|
||||
dedupe: true,
|
||||
name_keywords: '姓名,名字',
|
||||
full_score: 100,
|
||||
full_scores: {},
|
||||
layout: 'default',
|
||||
}, null, 2));
|
||||
|
||||
// 统一收尾钩子:恢复原 config + token + kill 子进程。任何退出路径都会触发
|
||||
let cleanedUp = false;
|
||||
function cleanup() {
|
||||
if (cleanedUp) return;
|
||||
cleanedUp = true;
|
||||
try { srv && srv.kill('SIGKILL'); } catch(e){}
|
||||
try { mock && mock.kill('SIGKILL'); } catch(e){}
|
||||
try {
|
||||
if (origCfg !== null) fs.writeFileSync(cfgFile, origCfg);
|
||||
else if (fs.existsSync(cfgFile)) fs.unlinkSync(cfgFile);
|
||||
} catch(e) { console.error('cleanup cfg failed:', e.message); }
|
||||
try {
|
||||
if (hasToken && fs.existsSync(tokenBak)) fs.renameSync(tokenBak, tokenFile);
|
||||
} catch(e) { console.error('cleanup token failed:', e.message); }
|
||||
}
|
||||
process.on('exit', cleanup);
|
||||
process.on('SIGINT', () => { cleanup(); process.exit(1); });
|
||||
process.on('uncaughtException', (e) => { console.error('FATAL', e); cleanup(); process.exit(2); });
|
||||
|
||||
// 2) 启动 mock 卷王
|
||||
let mock = null;
|
||||
let srv = null;
|
||||
mock = spawn('node', ['tools/mock-server.js'], { cwd: ROOT, stdio: 'pipe' });
|
||||
mock.stdout.on('data', d => process.stdout.write('[mock] ' + d));
|
||||
mock.stderr.on('data', d => process.stderr.write('[mock-err] ' + d));
|
||||
|
||||
// 3) 启动 PHP 内置服务器(-t 让所有请求走 index.php 路由)
|
||||
const phpArgs = INI.split(/\s+/).filter(Boolean).concat(['-S', '127.0.0.1:' + PORT, '-t', ROOT]);
|
||||
srv = spawn(PHP, phpArgs, { cwd: ROOT, stdio: 'pipe' });
|
||||
srv.stdout.on('data', d => process.stdout.write('[php] ' + d));
|
||||
srv.stderr.on('data', d => process.stderr.write('[php-err] ' + d));
|
||||
|
||||
let pass = 0, fail = 0;
|
||||
function chk(name, cond, extra) {
|
||||
if (cond) { pass++; console.log(' PASS ' + name + (extra ? ' — ' + extra : '')); }
|
||||
else { fail++; console.log(' FAIL ' + name + (extra ? ' — ' + extra : '')); }
|
||||
}
|
||||
|
||||
function req(method, path, body, headers) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const data = body ? JSON.stringify(body) : '';
|
||||
const h = Object.assign({
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(data),
|
||||
}, headers || {});
|
||||
if (cookieJar.length) h['Cookie'] = cookieJar.map(c => c.replace(/;.*$/, '')).join('; ');
|
||||
const r = http.request({ host: '127.0.0.1', port: PORT, method, path, headers: h }, res => {
|
||||
let chunks = [];
|
||||
res.on('data', c => chunks.push(c));
|
||||
res.on('end', () => {
|
||||
const text = Buffer.concat(chunks).toString('utf8');
|
||||
let j = null;
|
||||
try { j = JSON.parse(text); } catch (e) {}
|
||||
// 收集 Set-Cookie
|
||||
if (res.headers['set-cookie']) {
|
||||
for (const sc of res.headers['set-cookie']) {
|
||||
const pair = sc.split(';')[0];
|
||||
const name = pair.split('=')[0];
|
||||
const idx = cookieJar.findIndex(x => x.startsWith(name + '='));
|
||||
if (idx >= 0) cookieJar[idx] = pair; else cookieJar.push(pair);
|
||||
}
|
||||
}
|
||||
// 跟随 302(不自动跟随 action=login 等会导致副作用的)
|
||||
if (res.statusCode === 302 && res.headers.location && !h['__followed'] && !/login\.php/.test(res.headers.location)) {
|
||||
h['__followed'] = 1;
|
||||
console.log(' [debug] follow', res.statusCode, '→', res.headers.location);
|
||||
return resolve(req('GET', res.headers.location, null, h));
|
||||
}
|
||||
resolve({ status: res.statusCode, body: j, raw: text, setCookie: res.headers['set-cookie'], loc: res.headers.location });
|
||||
});
|
||||
});
|
||||
r.on('error', reject);
|
||||
if (data) r.write(data);
|
||||
r.end();
|
||||
});
|
||||
}
|
||||
const cookieJar = [];
|
||||
|
||||
function formPost(path, fields) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const body = new URLSearchParams(fields).toString();
|
||||
const h = {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'Content-Length': Buffer.byteLength(body),
|
||||
};
|
||||
if (cookieJar.length) h['Cookie'] = cookieJar.map(c => c.replace(/;.*$/, '')).join('; ');
|
||||
const r = http.request({ host: '127.0.0.1', port: PORT, method: 'POST', path, headers: h }, res => {
|
||||
let chunks = [];
|
||||
res.on('data', c => chunks.push(c));
|
||||
res.on('end', () => {
|
||||
const text = Buffer.concat(chunks).toString('utf8');
|
||||
if (res.headers['set-cookie']) {
|
||||
for (const sc of res.headers['set-cookie']) {
|
||||
const pair = sc.split(';')[0];
|
||||
const name = pair.split('=')[0];
|
||||
const idx = cookieJar.findIndex(x => x.startsWith(name + '='));
|
||||
if (idx >= 0) cookieJar[idx] = pair; else cookieJar.push(pair);
|
||||
}
|
||||
}
|
||||
resolve({ status: res.statusCode, body: text, loc: res.headers.location });
|
||||
});
|
||||
});
|
||||
r.on('error', reject);
|
||||
r.write(body); r.end();
|
||||
});
|
||||
}
|
||||
|
||||
function delay(ms) { return new Promise(r => setTimeout(r, ms)); }
|
||||
|
||||
async function login() {
|
||||
// 走 login.php 的表单登录拿会话
|
||||
return await formPost('/login.php', { username: 'mock', password: 'surveyking' });
|
||||
}
|
||||
|
||||
(async () => {
|
||||
// 等服务起来
|
||||
await delay(2000);
|
||||
// 用 inline 脚本快速验证 login 是否能成功(保留 srv 不退出)
|
||||
await new Promise(r => setTimeout(r, 200));
|
||||
const http = require('http');
|
||||
const testCookie = await new Promise((resolve) => {
|
||||
const body = new URLSearchParams({username:'mock',password:'surveyking'}).toString();
|
||||
http.request({host:'127.0.0.1',port:PORT,method:'POST',path:'/login.php',headers:{'Content-Type':'application/x-www-form-urlencoded','Content-Length':Buffer.byteLength(body)}},res=>{
|
||||
let c=[]; res.on('data',d=>c.push(d)); res.on('end',()=>{
|
||||
const text = Buffer.concat(c).toString();
|
||||
const cookie = (res.headers['set-cookie']||[]).map(c=>c.split(';')[0]).join('; ');
|
||||
console.log(' [inline] login status', res.statusCode, 'set-cookie', JSON.stringify(res.headers['set-cookie']));
|
||||
if (text.indexOf('登录失败') >= 0) {
|
||||
const m = text.match(/登录失败[^<]+/);
|
||||
console.log(' [inline] body 错误:', m ? m[0] : '(unknown)');
|
||||
} else {
|
||||
console.log(' [inline] body end:', text.slice(Math.max(0,text.length-200)));
|
||||
}
|
||||
// 拿同一 cookie 调 auth_check
|
||||
http.request({host:'127.0.0.1',port:PORT,method:'GET',path:'/api.php?action=auth_check',headers:{Cookie:cookie}}, rs=>{
|
||||
let cc=[]; rs.on('data',d=>cc.push(d)); rs.on('end',()=>{
|
||||
console.log(' [inline] auth_check', rs.statusCode, Buffer.concat(cc).toString());
|
||||
resolve(cookie);
|
||||
});
|
||||
}).end();
|
||||
});
|
||||
}).end(body);
|
||||
});
|
||||
if (testCookie && testCookie.indexOf('NYSESS=') >= 0) {
|
||||
// 同步 jar
|
||||
cookieJar.length = 0;
|
||||
testCookie.split('; ').forEach(c => cookieJar.push(c));
|
||||
}
|
||||
|
||||
console.log('\n--- 1) 首次登录应成功 ---');
|
||||
// 登录 formPost 不会跟随 302(直接保留 body),但要求 NYSESS cookie 出现
|
||||
const lg = await formPost('/login.php', { username: 'mock', password: 'surveyking' });
|
||||
console.log(' [debug] login loc:', lg.loc, 'body:', (lg.body||'').slice(0, 200));
|
||||
chk('login.php 200/302', lg.status === 302 || lg.status === 200, 'status=' + lg.status);
|
||||
chk('session cookie 已获得', cookieJar.length > 0, 'jar=' + JSON.stringify(cookieJar));
|
||||
// 复查 auth(直接走 Session ID 路径)
|
||||
const ac = await req('GET', '/api.php?action=auth_check&_t=' + Date.now());
|
||||
console.log(' [debug] auth_check:', ac.status, ac.raw);
|
||||
chk('auth_check 通过', ac.body && ac.body.data && ac.body.data.auth === true, JSON.stringify(ac.body));
|
||||
if (!(ac.body && ac.body.data && ac.body.data.auth)) {
|
||||
console.log(' [fatal] 未登录,停止测试');
|
||||
mock.kill('SIGTERM'); srv.kill('SIGTERM');
|
||||
if (origCfg !== null) fs.writeFileSync(cfgFile, origCfg); else if (fs.existsSync(cfgFile)) fs.unlinkSync(cfgFile);
|
||||
if (hasToken && fs.existsSync(tokenBak)) fs.renameSync(tokenBak, tokenFile);
|
||||
process.exit(1);
|
||||
}
|
||||
// 之后用 ping 触发 SurveyKing 登录
|
||||
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));
|
||||
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));
|
||||
await delay(100);
|
||||
chk('token 已缓存', fs.existsSync(tokenFile));
|
||||
|
||||
console.log('\n--- 2) config_get 应返回 storage + 诊断 ---');
|
||||
const cg1 = await req('GET', '/api.php?action=config_get');
|
||||
chk('config_get 200', cg1.status === 200);
|
||||
chk('返回 storage 字段', cg1.body && cg1.body.data && typeof cg1.body.data.storage === 'string',
|
||||
'storage=' + (cg1.body && cg1.body.data && cg1.body.data.storage));
|
||||
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);
|
||||
|
||||
console.log('\n--- 3) config_save 不改连接信息:不应清 token / 不应触发 ping ---');
|
||||
const before = fs.existsSync(tokenFile) ? fs.readFileSync(tokenFile, 'utf8') : '';
|
||||
const cs1 = await req('POST', '/api.php?action=config_save', {
|
||||
full_score: 95,
|
||||
});
|
||||
chk('config_save 200', cs1.status === 200);
|
||||
chk('conn_changed=false', cs1.body && cs1.body.data && cs1.body.data.conn_changed === false);
|
||||
chk('ping 字段不存在', cs1.body && cs1.body.data && cs1.body.data.ping === undefined);
|
||||
const after = fs.existsSync(tokenFile) ? fs.readFileSync(tokenFile, 'utf8') : '';
|
||||
chk('token 保持未变', before === after, 'changed=' + (before !== after));
|
||||
|
||||
console.log('\n--- 4) 各试卷独立满分:仅当 apply_pid 显式指定时写 ---');
|
||||
// 不传 apply_pid,也不传 full_scores → full_scores 应保留原值(保持空映射)
|
||||
const cs2 = await req('POST', '/api.php?action=config_save', {
|
||||
full_score: 90,
|
||||
});
|
||||
const _cs2fs = cs2.body && cs2.body.data && cs2.body.data.config
|
||||
? cs2.body.data.config.full_scores : undefined;
|
||||
chk('未传 apply_pid → full_scores 保持空映射(不被覆盖为非空)',
|
||||
_cs2fs === undefined || Object.keys(_cs2fs).length === 0,
|
||||
JSON.stringify(_cs2fs));
|
||||
// 显式 apply_pid='p1', apply_score=85 → 写入
|
||||
const cs3 = await req('POST', '/api.php?action=config_save', {
|
||||
full_score: 90,
|
||||
apply_pid: 'p1',
|
||||
apply_score: 85,
|
||||
});
|
||||
chk('apply_pid=85 → full_scores.p1=85', cs3.body && cs3.body.data && cs3.body.data.config
|
||||
&& cs3.body.data.config.full_scores && cs3.body.data.config.full_scores.p1 === 85,
|
||||
JSON.stringify(cs3.body.data && cs3.body.data.config && cs3.body.data.config.full_scores));
|
||||
// apply_pid='p1', apply_score=0 → 清除
|
||||
const cs4 = await req('POST', '/api.php?action=config_save', {
|
||||
full_score: 90,
|
||||
apply_pid: 'p1',
|
||||
apply_score: 0,
|
||||
});
|
||||
chk('apply_pid=0 → 清除 p1',
|
||||
cs4.body && cs4.body.data && cs4.body.data.config
|
||||
&& (!cs4.body.data.config.full_scores || cs4.body.data.config.full_scores.p1 === undefined),
|
||||
JSON.stringify(cs4.body.data && cs4.body.data.config && cs4.body.data.config.full_scores));
|
||||
|
||||
console.log('\n--- 5) 修改连接信息:应清 token + 重新登录(ping)---');
|
||||
const tokBefore = fs.existsSync(tokenFile) ? fs.readFileSync(tokenFile, 'utf8') : '';
|
||||
// 最先修改密码为不同值:触发 conn_changed=true + ping.ok=false
|
||||
const cs5 = await req('POST', '/api.php?action=config_save', {
|
||||
password: 'temp-wrong-password',
|
||||
});
|
||||
chk('修改密码后 conn_changed=true', cs5.body && cs5.body.data && cs5.body.data.conn_changed === true);
|
||||
chk('返回 ping.ok=false', cs5.body && cs5.body.data && cs5.body.data.ping && cs5.body.data.ping.ok === false,
|
||||
JSON.stringify(cs5.body && cs5.body.data && cs5.body.data.ping));
|
||||
await delay(200);
|
||||
const tokAfter = fs.existsSync(tokenFile) ? fs.readFileSync(tokenFile, 'utf8') : '';
|
||||
chk('修改同 token 被清', tokBefore !== '' && tokAfter === '', 'before=' + (tokBefore !== '') + ' after=' + (tokAfter !== ''));
|
||||
// 第二步:改回正确密码,应 trigger conn_changed=true + ping.ok=true
|
||||
const cs5b = await req('POST', '/api.php?action=config_save', {
|
||||
password: 'surveyking',
|
||||
});
|
||||
chk('改回正确密码同 conn_changed=true', cs5b.body && cs5b.body.data && cs5b.body.data.conn_changed === true);
|
||||
chk('改回后 ping.ok=true', cs5b.body && cs5b.body.data && cs5b.body.data.ping && cs5b.body.data.ping.ok === true,
|
||||
JSON.stringify(cs5b.body && cs5b.body.data && cs5b.body.data.ping));
|
||||
await delay(200);
|
||||
chk('登录成功同 token 已生成', fs.existsSync(tokenFile), 'token exists=' + fs.existsSync(tokenFile));
|
||||
|
||||
console.log('\n--- 6) config_get 永远不带明文密码 ---');
|
||||
const cg2 = await req('GET', '/api.php?action=config_get');
|
||||
chk('密码不存在', cg2.body && cg2.body.data && cg2.body.data.password === undefined);
|
||||
chk('has_password 标志', cg2.body && cg2.body.data && cg2.body.data.has_password === true);
|
||||
|
||||
console.log('\n--- 7) 修改为错误密码:应自动 ping 失败并保留弹窗提示 ---');
|
||||
const cs6 = await req('POST', '/api.php?action=config_save', {
|
||||
username: 'mock',
|
||||
password: 'wrong-password',
|
||||
});
|
||||
chk('conn_changed=true', cs6.body && cs6.body.data && cs6.body.data.conn_changed === true);
|
||||
chk('ping.ok=false', cs6.body && cs6.body.data && cs6.body.data.ping && cs6.body.data.ping.ok === false,
|
||||
JSON.stringify(cs6.body && cs6.body.data && cs6.body.data.ping));
|
||||
|
||||
console.log('\n结果:' + pass + ' 通过,' + fail + ' 失败');
|
||||
|
||||
// 清理 + 退出(统一走 cleanup 钩子,保证 config/token 一定被还原)
|
||||
cleanup();
|
||||
setTimeout(() => process.exit(fail === 0 ? 0 : 1), 200);
|
||||
})().catch(e => { console.error('FATAL', e); cleanup(); process.exit(2); });
|
||||
+111
-44
@@ -19,79 +19,146 @@ function chk(name, cond, extra) {
|
||||
else { fail++; console.log(' FAIL ' + name + (extra ? ' — ' + extra : '')); }
|
||||
}
|
||||
|
||||
/* ---------- allocQuotas:名额均分(5:5 语义) ---------- */
|
||||
// A/B = 12/8 抽 10 → 各 5(名额均分到组,与组人数无关)
|
||||
chk('12/8 抽10 → 5/5', JSON.stringify(T.allocQuotas([12, 8], 10)) === '[5,5]',
|
||||
JSON.stringify(T.allocQuotas([12, 8], 10)));
|
||||
function sum(a) { return a.reduce((x, y) => x + y, 0); }
|
||||
function eqArr(a, b) { return JSON.stringify(a) === JSON.stringify(b); }
|
||||
|
||||
/* ==================== 均分模式(weights = null) ==================== */
|
||||
// A/B = 12/8 抽 10 → 各 5
|
||||
chk('均分 12/8 抽10 → 5/5', eqArr(T.allocQuotas([12, 8], 10, null), [5, 5]),
|
||||
JSON.stringify(T.allocQuotas([12, 8], 10, null)));
|
||||
// 抽 1 人:余数小组优先
|
||||
chk('抽1人 → 小组优先', JSON.stringify(T.allocQuotas([12, 8], 1)) === '[0,1]',
|
||||
JSON.stringify(T.allocQuotas([12, 8], 1)));
|
||||
// 12/8 抽 3 → 均分 1/1 + 余数给小组 → 1/2
|
||||
chk('12/8 抽3 → 1/2', JSON.stringify(T.allocQuotas([12, 8], 3)) === '[1,2]',
|
||||
JSON.stringify(T.allocQuotas([12, 8], 3)));
|
||||
// 组容量不足:A 只有 1 人,B 有 19 人,抽 10 → A 1 + B 9
|
||||
chk('容量不足差额再分配', JSON.stringify(T.allocQuotas([1, 19], 10)) === '[1,9]',
|
||||
JSON.stringify(T.allocQuotas([1, 19], 10)));
|
||||
// 全抽 → 每组全量
|
||||
chk('全部抽取 = 各组全量', JSON.stringify(T.allocQuotas([12, 8], 20)) === '[12,8]',
|
||||
JSON.stringify(T.allocQuotas([12, 8], 20)));
|
||||
// n 超过总人数 → 全量
|
||||
chk('超额抽取取全量', (() => {
|
||||
const q = T.allocQuotas([3, 4], 99);
|
||||
return JSON.stringify(q) === '[3,4]';
|
||||
})(), JSON.stringify(T.allocQuotas([3, 4], 99)));
|
||||
// 5:5 经典:A 20 / B 20 抽 10 → 5/5
|
||||
chk('20/20 抽10 → 5/5', JSON.stringify(T.allocQuotas([20, 20], 10)) === '[5,5]',
|
||||
JSON.stringify(T.allocQuotas([20, 20], 10)));
|
||||
// 三组 10/10/10 抽 8 → 3/3/2(余数并列时稳定分配)
|
||||
chk('三组 10/10/10 抽8', (() => {
|
||||
const q = T.allocQuotas([10, 10, 10], 8);
|
||||
return q.reduce((a, b) => a + b, 0) === 8 && Math.max(...q) - Math.min(...q) <= 1;
|
||||
})(), JSON.stringify(T.allocQuotas([10, 10, 10], 8)));
|
||||
// 空组 / 0
|
||||
chk('空数组返回空', JSON.stringify(T.allocQuotas([], 5)) === '[]');
|
||||
// 名额总数守恒 + 不超容量 + 均衡(随机 fuzz)
|
||||
chk('均分 抽1人 → 小组优先', eqArr(T.allocQuotas([12, 8], 1, null), [0, 1]),
|
||||
JSON.stringify(T.allocQuotas([12, 8], 1, null)));
|
||||
// 12/8 抽 3 → 1/2
|
||||
chk('均分 12/8 抽3 → 1/2', eqArr(T.allocQuotas([12, 8], 3, null), [1, 2]),
|
||||
JSON.stringify(T.allocQuotas([12, 8], 3, null)));
|
||||
// 容量不足:A=1, B=19, 抽 10 → 1/9
|
||||
chk('均分 容量不足差额再分配', eqArr(T.allocQuotas([1, 19], 10, null), [1, 9]),
|
||||
JSON.stringify(T.allocQuotas([1, 19], 10, null)));
|
||||
// 全抽
|
||||
chk('均分 全部抽取 = 各组全量', eqArr(T.allocQuotas([12, 8], 20, null), [12, 8]),
|
||||
JSON.stringify(T.allocQuotas([12, 8], 20, null)));
|
||||
// 超额
|
||||
chk('均分 超额取全量', eqArr(T.allocQuotas([3, 4], 99, null), [3, 4]),
|
||||
JSON.stringify(T.allocQuotas([3, 4], 99, null)));
|
||||
// 20/20 抽 10
|
||||
chk('均分 20/20 抽10 → 5/5', eqArr(T.allocQuotas([20, 20], 10, null), [5, 5]),
|
||||
JSON.stringify(T.allocQuotas([20, 20], 10, null)));
|
||||
// 三组 10/10/10 抽 8
|
||||
chk('均分 三组 10/10/10 抽8', (() => {
|
||||
const q = T.allocQuotas([10, 10, 10], 8, null);
|
||||
return sum(q) === 8 && Math.max(...q) - Math.min(...q) <= 1;
|
||||
})(), JSON.stringify(T.allocQuotas([10, 10, 10], 8, null)));
|
||||
// 空数组
|
||||
chk('空数组返回空', eqArr(T.allocQuotas([], 5, null), []));
|
||||
|
||||
/* ==================== 加权模式(按人数权重) ==================== */
|
||||
// 12/8 抽 10 → 6/4(按人数加权)
|
||||
chk('加权 12/8 抽10 → 6/4', eqArr(T.allocQuotas([12, 8], 10, [12, 8]), [6, 4]),
|
||||
JSON.stringify(T.allocQuotas([12, 8], 10, [12, 8])));
|
||||
// 12/8 抽 3 → 2/1(raw=[1.8,1.2], base=[1,1], 余数 .8 → A)
|
||||
chk('加权 12/8 抽3 → 2/1', eqArr(T.allocQuotas([12, 8], 3, [12, 8]), [2, 1]),
|
||||
JSON.stringify(T.allocQuotas([12, 8], 3, [12, 8])));
|
||||
// 加权抽 1 → 按权重
|
||||
chk('加权 12/8 抽1 → 1/0', eqArr(T.allocQuotas([12, 8], 1, [12, 8]), [1, 0]),
|
||||
JSON.stringify(T.allocQuotas([12, 8], 1, [12, 8])));
|
||||
// 自定义比例 6:4
|
||||
chk('自定义 6:4 抽10 → 6/4', eqArr(T.allocQuotas([12, 8], 10, [6, 4]), [6, 4]),
|
||||
JSON.stringify(T.allocQuotas([12, 8], 10, [6, 4])));
|
||||
// 自定义 1:1 等价均分
|
||||
chk('自定义 1:1 = 均分', eqArr(T.allocQuotas([12, 8], 10, [1, 1]), [5, 5]),
|
||||
JSON.stringify(T.allocQuotas([12, 8], 10, [1, 1])));
|
||||
// 极端比例 9:1 抽 5
|
||||
chk('自定义 9:1 抽5', (() => {
|
||||
const q = T.allocQuotas([12, 8], 5, [9, 1]);
|
||||
return sum(q) === 5 && q[0] >= 4 && q[1] >= 0 && q[0] <= 12 && q[1] <= 8;
|
||||
})(), JSON.stringify(T.allocQuotas([12, 8], 5, [9, 1])));
|
||||
// 容量约束 [1,19] 抽 10:按权重 1:19 → raw=[0.5,9.5] → base=[0,9] + frac 相同时
|
||||
// 给剩余容量大的组补余数 → A=0, B=10(A 容量只有 1,本来也只可能 0 或 1)
|
||||
chk('加权 容量约束 [1,19] 抽10', (() => {
|
||||
const q = T.allocQuotas([1, 19], 10, [1, 19]);
|
||||
return sum(q) === 10 && q[0] === 0 && q[1] === 10;
|
||||
})(), JSON.stringify(T.allocQuotas([1, 19], 10, [1, 19])));
|
||||
// 全 0 权重 → 退化为均分
|
||||
chk('全0权重 → 均分', eqArr(T.allocQuotas([12, 8], 10, [0, 0]), [5, 5]),
|
||||
JSON.stringify(T.allocQuotas([12, 8], 10, [0, 0])));
|
||||
// null 权重 → 均分
|
||||
chk('null权重 → 均分', eqArr(T.allocQuotas([12, 8], 10, null), [5, 5]),
|
||||
JSON.stringify(T.allocQuotas([12, 8], 10, null)));
|
||||
|
||||
/* ==================== 守恒 + 不超容量 + fuzz ==================== */
|
||||
let fuzzOk = true;
|
||||
let fuzzBad = '';
|
||||
for (let t = 0; t < 1000; t++) {
|
||||
const g = 1 + Math.floor(Math.random() * 5);
|
||||
const sizes = Array.from({ length: g }, () => Math.floor(Math.random() * 30));
|
||||
const n = Math.floor(Math.random() * 60);
|
||||
const q = T.allocQuotas(sizes, n);
|
||||
const sum = q.reduce((a, b) => a + b, 0);
|
||||
// 随机权重
|
||||
const weights = Array.from({ length: g }, () => Math.random() * 10);
|
||||
// 50% 概率用 null 走均分
|
||||
const w = Math.random() < 0.5 ? weights : null;
|
||||
const q = T.allocQuotas(sizes, n, w);
|
||||
const total = sizes.reduce((a, b) => a + b, 0);
|
||||
const expect = Math.min(n, total);
|
||||
if (sum !== expect) { fuzzOk = false; fuzzBad = `sum=${sum} expect=${expect} sizes=${sizes} n=${n}`; break; }
|
||||
if (sum(q) !== expect) { fuzzOk = false; fuzzBad = `sum=${sum(q)} expect=${expect} sizes=${sizes} n=${n} w=${w}`; break; }
|
||||
let bad = false;
|
||||
for (let i = 0; i < g; i++) if (q[i] < 0 || q[i] > sizes[i]) bad = true;
|
||||
// 均衡性:所有组均未满时,任意两组名额差 ≤ 1
|
||||
// 加权模式下"任意两组差 ≤ 1"不一定成立(按权重 9:1 抽 5 应是 5/0)
|
||||
// 但均分模式下应成立
|
||||
if (w === null) {
|
||||
const allNotFull = q.every((v, i) => v < sizes[i]);
|
||||
if (allNotFull && Math.max(...q) - Math.min(...q) > 1) bad = true;
|
||||
if (bad) { fuzzOk = false; fuzzBad = `sizes=${JSON.stringify(sizes)} n=${n} q=${JSON.stringify(q)}`; break; }
|
||||
}
|
||||
if (bad) { fuzzOk = false; fuzzBad = `sizes=${sizes} n=${n} w=${w} q=${q}`; break; }
|
||||
}
|
||||
chk('1000 组随机 fuzz:守恒+不超容量+均衡', fuzzOk, fuzzBad);
|
||||
chk('1000 组随机 fuzz:守恒+不超容量+(均分时)均衡', fuzzOk, fuzzBad);
|
||||
|
||||
/* ---------- balanceKeyOf ---------- */
|
||||
/* ==================== balanceKeyOf ==================== */
|
||||
chk('有 A/B 组别 → 按 grp', T.balanceKeyOf([{ grp: 'A', cls: 'C1' }, { grp: 'B', cls: 'C1' }]) === 'grp');
|
||||
chk('单组别 → 按班级', T.balanceKeyOf([{ grp: 'A', cls: 'C1' }, { grp: 'A', cls: 'C2' }]) === 'cls');
|
||||
chk('无组别多班级 → 按班级', T.balanceKeyOf([{ grp: '', cls: 'C1' }, { grp: '', cls: 'C2' }]) === 'cls');
|
||||
chk('都相同 → 不均衡', T.balanceKeyOf([{ grp: 'A', cls: 'C1' }, { grp: 'A', cls: 'C1' }]) === '');
|
||||
|
||||
/* ---------- pickBalanced ---------- */
|
||||
/* ==================== pickBalanced ==================== */
|
||||
{
|
||||
// 12A + 8B,加权(weights=sizes)抽 10 → 应 6A/4B
|
||||
const pool = [];
|
||||
for (let i = 1; i <= 12; i++) pool.push({ sid: 'A' + i, name: 'A' + i, grp: 'A', cls: 'C' });
|
||||
for (let i = 1; i <= 8; i++) pool.push({ sid: 'B' + i, name: 'B' + i, grp: 'B', cls: 'C' });
|
||||
const r = T.pickBalanced(pool, 10, 'grp');
|
||||
const r = T.pickBalanced(pool, 10, 'grp', [12, 8]);
|
||||
const byGrp = { A: 0, B: 0 };
|
||||
r.picked.forEach(m => byGrp[m.grp]++);
|
||||
chk('均衡抽取 12A/8B 抽10 → 5A/5B', byGrp.A === 5 && byGrp.B === 5, JSON.stringify(byGrp) + ' detail=' + r.detail.join(','));
|
||||
chk('detail 文案', r.detail.join('/') === 'A 5人/B 5人', r.detail.join('/'));
|
||||
chk('结果不重复', new Set(r.picked.map(m => m.sid)).size === 10);
|
||||
chk('加权 pickBalanced 12A/8B 抽10 → 6A/4B', byGrp.A === 6 && byGrp.B === 4,
|
||||
JSON.stringify(byGrp) + ' detail=' + r.detail.join(','));
|
||||
chk('pickBalanced 详情文案', r.detail.join('/') === 'A 6人/B 4人', r.detail.join('/'));
|
||||
chk('pickBalanced 结果不重复', new Set(r.picked.map(m => m.sid)).size === 10);
|
||||
}
|
||||
{
|
||||
// 均分(weights=null)抽 10 → 5A/5B
|
||||
const pool = [];
|
||||
for (let i = 1; i <= 12; i++) pool.push({ sid: 'A' + i, grp: 'A', cls: 'C' });
|
||||
for (let i = 1; i <= 8; i++) pool.push({ sid: 'B' + i, grp: 'B', cls: 'C' });
|
||||
const r = T.pickBalanced(pool, 10, 'grp', null);
|
||||
const byGrp = { A: 0, B: 0 };
|
||||
r.picked.forEach(m => byGrp[m.grp]++);
|
||||
chk('均分 pickBalanced 12A/8B 抽10 → 5A/5B', byGrp.A === 5 && byGrp.B === 5,
|
||||
JSON.stringify(byGrp) + ' detail=' + r.detail.join(','));
|
||||
}
|
||||
{
|
||||
// 自定义比例 9:1 抽 5
|
||||
const pool = [];
|
||||
for (let i = 1; i <= 12; i++) pool.push({ sid: 'A' + i, grp: 'A' });
|
||||
for (let i = 1; i <= 8; i++) pool.push({ sid: 'B' + i, grp: 'B' });
|
||||
const r = T.pickBalanced(pool, 5, 'grp', [9, 1]);
|
||||
const byGrp = { A: 0, B: 0 };
|
||||
r.picked.forEach(m => byGrp[m.grp]++);
|
||||
chk('自定义 9:1 抽5 → 全部 A', byGrp.A === 5 && byGrp.B === 0,
|
||||
JSON.stringify(byGrp));
|
||||
}
|
||||
{
|
||||
// 无维度(key='')走不到
|
||||
const pool = [{ sid: 'x1', grp: '', cls: '' }, { sid: 'x2', grp: '', cls: '' }];
|
||||
const r = T.pickBalanced(pool, 2, T.balanceKeyOf(pool) || 'grp');
|
||||
const r = T.pickBalanced(pool, 2, T.balanceKeyOf(pool) || 'grp', null);
|
||||
chk('无维度也能抽取', r.picked.length === 2, JSON.stringify(r.picked.map(m => m.sid)));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user