Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6772aeae11 | ||
|
|
ba0e64ed51 |
@@ -317,6 +317,7 @@ try {
|
||||
'name' => (string)($s['name'] ?? ''),
|
||||
'cls' => trim((string)($s['cls'] ?? '')),
|
||||
'grp' => trim((string)($s['grp'] ?? '')),
|
||||
'login' => trim((string)($s['login'] ?? '')),
|
||||
];
|
||||
}
|
||||
Roster::save($students);
|
||||
@@ -326,7 +327,7 @@ try {
|
||||
Roster::clear();
|
||||
ny_ok(['students' => [], 'meta' => Roster::meta()]);
|
||||
|
||||
// ---------------- 导出 ----------------
|
||||
// ---------------- 导出(支持按班级 / 组别筛选,与页面当前筛选保持一致) ----------------
|
||||
case 'export':
|
||||
case 'export_absent':
|
||||
$projectId = (string)($_GET['projectId'] ?? '');
|
||||
@@ -334,34 +335,80 @@ try {
|
||||
ny_fail('请先选择问卷/考试');
|
||||
}
|
||||
$nameQid = (string)($_GET['nameQid'] ?? '');
|
||||
$clsFilter = trim((string)($_GET['cls'] ?? ''));
|
||||
$grpFilter = trim((string)($_GET['grp'] ?? ''));
|
||||
|
||||
// 命中筛选:未选班级/组别时全部通过
|
||||
$hit = static function (array $r) use ($clsFilter, $grpFilter): bool {
|
||||
if ($clsFilter !== '' && trim((string)($r['cls'] ?? '')) !== $clsFilter) {
|
||||
return false;
|
||||
}
|
||||
if ($grpFilter !== '' && trim((string)($r['grp'] ?? '')) !== $grpFilter) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
$c = cfg();
|
||||
$result = DataService::build($c, $projectId, $nameQid);
|
||||
|
||||
$rows = array_values(array_filter((array)($result['rows'] ?? []), $hit));
|
||||
|
||||
// 缺考口径与页面完全保持一致:筛选后名单 − 筛选后已提交(按学号匹配)
|
||||
$roster = array_values(array_filter((array)($result['roster'] ?? []), $hit));
|
||||
if ($roster !== []) {
|
||||
$submitted = [];
|
||||
foreach ($rows as $r) {
|
||||
$sid = trim((string)($r['sid'] ?? ''));
|
||||
if ($sid !== '') {
|
||||
$submitted[$sid] = true;
|
||||
}
|
||||
}
|
||||
$absent = array_values(array_filter(
|
||||
$roster,
|
||||
static fn(array $s): bool => !isset($submitted[trim((string)($s['sid'] ?? ''))])
|
||||
));
|
||||
} else {
|
||||
// 未导入名单时退回后端自带的缺考数组(同样按筛选过滤)
|
||||
$absent = array_values(array_filter((array)($result['absent'] ?? []), $hit));
|
||||
}
|
||||
|
||||
// 文件名附带班级 / 组别,便于分辨
|
||||
$safe = static fn(string $s): string => preg_replace('/[\\\\\/:*?"<>|]/u', '_', $s) ?? '';
|
||||
$clsLabel = $clsFilter === '' ? ''
|
||||
: (mb_strpos($clsFilter, '班') === false ? $clsFilter . '班' : $clsFilter);
|
||||
$grpLabel = $grpFilter === '' ? ''
|
||||
: (mb_strpos($grpFilter, '组') === false ? $grpFilter . '组' : $grpFilter);
|
||||
$tag = ($clsLabel !== '' ? '_' . $safe($clsLabel) : '')
|
||||
. ($grpLabel !== '' ? '_' . $safe($grpLabel) : '');
|
||||
|
||||
$filename = ($action === 'export_absent' ? '缺考名单' : '成绩明细')
|
||||
. '_' . preg_replace('/[\\\\\/:*?"<>|]/u', '_', (string)$result['project']['name'])
|
||||
. '_' . $safe((string)$result['project']['name'])
|
||||
. $tag
|
||||
. '_' . date('Ymd_His') . '.csv';
|
||||
|
||||
header('Content-Type: text/csv; charset=utf-8');
|
||||
header('Content-Disposition: attachment; filename="' . $filename . '"');
|
||||
header('Content-Disposition: attachment; filename="' . $filename . '"; filename*=UTF-8\'\'' . rawurlencode($filename));
|
||||
header('Cache-Control: no-store');
|
||||
echo "\xEF\xBB\xBF";
|
||||
$out = fopen('php://output', 'w');
|
||||
|
||||
if ($action === 'export_absent') {
|
||||
fputcsv($out, ['学号', '姓名', '班级', '组别']);
|
||||
foreach ($result['absent'] as $r) {
|
||||
fputcsv($out, [$r['sid'], $r['name'], $r['cls'] ?? '', $r['grp'] ?? '']);
|
||||
foreach ($absent as $r) {
|
||||
fputcsv($out, [$r['sid'] ?? '', $r['name'] ?? '', $r['cls'] ?? '', $r['grp'] ?? '']);
|
||||
}
|
||||
} else {
|
||||
fputcsv($out, ['学号', '姓名', '班级', '组别', '成绩', '提交时间', '考试时长', '考试时长(秒)']);
|
||||
foreach ($result['rows'] as $r) {
|
||||
foreach ($rows as $r) {
|
||||
fputcsv($out, [
|
||||
$r['sid'],
|
||||
$r['name'],
|
||||
$r['sid'] ?? '',
|
||||
$r['name'] ?? '',
|
||||
$r['cls'] ?? '',
|
||||
$r['grp'] ?? '',
|
||||
$r['score'] === null ? '' : $r['score'],
|
||||
$r['submit_at'],
|
||||
$r['duration_txt'],
|
||||
$r['submit_at'] ?? '',
|
||||
$r['duration_txt'] ?? '',
|
||||
$r['duration'] === null ? '' : $r['duration'],
|
||||
]);
|
||||
}
|
||||
@@ -369,6 +416,41 @@ try {
|
||||
fclose($out);
|
||||
exit;
|
||||
|
||||
// ---------------- 随机抽取:导出白名单(xlsx,列:姓名 / 登陆账号) ----------------
|
||||
case 'draw_export':
|
||||
$raw = (string)file_get_contents('php://input');
|
||||
$in = json_decode($raw, true);
|
||||
if (!is_array($in)) {
|
||||
$in = $_POST + $_GET;
|
||||
}
|
||||
// type=import → 白名单导入模板.xlsx;type=list → 白名单列表.xlsx
|
||||
$type = (($in['type'] ?? '') === 'import') ? 'import' : 'list';
|
||||
$members = [];
|
||||
foreach ((array)($in['members'] ?? []) as $m) {
|
||||
if (!is_array($m)) {
|
||||
continue;
|
||||
}
|
||||
$name = trim((string)($m['name'] ?? ''));
|
||||
// 登陆账号优先(名单导入的"登陆账号"列),没有则回退学号
|
||||
$acct = trim((string)($m['login'] ?? ''));
|
||||
$sid = trim((string)($m['sid'] ?? ''));
|
||||
if ($name === '' && $acct === '' && $sid === '') {
|
||||
continue;
|
||||
}
|
||||
$members[] = [$name, $acct !== '' ? $acct : $sid];
|
||||
}
|
||||
$bin = XlsxWriter::render(
|
||||
array_merge([['姓名', '登陆账号']], $members),
|
||||
['widths' => [16, 24], 'title' => '白名单']
|
||||
);
|
||||
$filename = $type === 'import' ? '白名单导入模板.xlsx' : '白名单列表.xlsx';
|
||||
header('Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
||||
header('Content-Disposition: attachment; filename="whitelist.xlsx"; filename*=UTF-8\'\'' . rawurlencode($filename));
|
||||
header('Content-Length: ' . strlen($bin));
|
||||
header('Cache-Control: no-store');
|
||||
echo $bin;
|
||||
exit;
|
||||
|
||||
default:
|
||||
ny_fail('未知操作:' . $action);
|
||||
}
|
||||
|
||||
+23
-2
@@ -92,6 +92,14 @@
|
||||
el.className = 'alert' + (type === 'info' ? ' info' : '');
|
||||
}
|
||||
|
||||
// 导出提示:说明本次导出的筛选范围
|
||||
function exportTip(what, cls, grp) {
|
||||
const parts = [];
|
||||
if (cls) parts.push(cls + (cls.indexOf('班') >= 0 ? '' : '班'));
|
||||
if (grp) parts.push(grp + (grp.indexOf('组') >= 0 ? '' : '组'));
|
||||
return '已导出' + what + (parts.length ? '(' + parts.join(' / ') + ')' : '(全部班级 / 组别)');
|
||||
}
|
||||
|
||||
/* ---------------- 问卷列表 ---------------- */
|
||||
// 把数组渲染到 #projectSel(抽出便于缓存复用)
|
||||
function renderProjects() {
|
||||
@@ -661,17 +669,24 @@
|
||||
});
|
||||
});
|
||||
|
||||
// 导出跟随当前班级 / 组别筛选(统计卡片与表格显示什么,就导出什么)
|
||||
$('#btnExport').addEventListener('click', () => {
|
||||
const pid = $('#projectSel').value;
|
||||
if (!pid) return showAlert('请先选择问卷 / 考试');
|
||||
const cls = $('#clsSel').value, grp = $('#grpSel').value;
|
||||
location.href = 'api.php?action=export&projectId=' + encodeURIComponent(pid) +
|
||||
'&nameQid=' + encodeURIComponent($('#nameFieldSel').value);
|
||||
'&nameQid=' + encodeURIComponent($('#nameFieldSel').value) +
|
||||
'&cls=' + encodeURIComponent(cls) + '&grp=' + encodeURIComponent(grp);
|
||||
showAlert(exportTip('成绩明细', cls, grp), 'info');
|
||||
});
|
||||
$('#btnExportAbsent').addEventListener('click', () => {
|
||||
const pid = $('#projectSel').value;
|
||||
if (!pid) return showAlert('请先选择问卷 / 考试');
|
||||
const cls = $('#clsSel').value, grp = $('#grpSel').value;
|
||||
location.href = 'api.php?action=export_absent&projectId=' + encodeURIComponent(pid) +
|
||||
'&nameQid=' + encodeURIComponent($('#nameFieldSel').value);
|
||||
'&nameQid=' + encodeURIComponent($('#nameFieldSel').value) +
|
||||
'&cls=' + encodeURIComponent(cls) + '&grp=' + encodeURIComponent(grp);
|
||||
showAlert(exportTip('缺考名单', cls, grp), 'info');
|
||||
});
|
||||
|
||||
/* 弹窗关闭 */
|
||||
@@ -803,6 +818,12 @@
|
||||
// 退出登录:跳转到独立 logout.php(清服务端会话)
|
||||
$('#btnLogout').addEventListener('click', () => { location.href = 'logout.php'; });
|
||||
|
||||
// 3) 支持 index.php#roster 直接打开名单管理(随机抽取页的「名单管理」入口用)
|
||||
if (location.hash === '#roster') {
|
||||
openRoster();
|
||||
if (history.replaceState) history.replaceState(null, '', location.pathname + location.search);
|
||||
}
|
||||
|
||||
// 启动:主界面在 PHP 端已做会话校验(未登录会跳到 login.php),此处直接加载数据
|
||||
schedule();
|
||||
loadProjects();
|
||||
|
||||
+738
@@ -0,0 +1,738 @@
|
||||
/*!
|
||||
* NY 问卷考试系统数据平台 - 随机抽取页逻辑
|
||||
*
|
||||
* 功能:来源切换 / 班级组别筛选 / 分组均衡抽取(5:5) / 排除已抽中 /
|
||||
* 排除名单管理 / 历史记录(重置不丢失) / 白名单下载(登陆账号优先)
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const $ = s => document.querySelector(s);
|
||||
const $$ = s => Array.prototype.slice.call(document.querySelectorAll(s));
|
||||
|
||||
function esc(s) {
|
||||
return String(s == null ? '' : s).replace(/[&<>"']/g, c =>
|
||||
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
}
|
||||
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
||||
|
||||
const LS_RESULT = 'ny_draw_result'; // 当前抽取结果(刷新后仍可下载)
|
||||
const LS_HISTORY = 'ny_draw_history'; // 累计抽中学号(「重置结果」清零)
|
||||
const LS_EXCLUDED = 'ny_draw_excluded'; // 手动排除的学号
|
||||
const LS_LOG = 'ny_draw_log'; // 历史批次(重置不清,上限 50 条)
|
||||
const LS_SETTINGS = 'ny_draw_settings'; // 来源 / 项目 / 班级 / 组别 / 人数 / 开关
|
||||
|
||||
const LOG_MAX = 50;
|
||||
|
||||
const state = {
|
||||
roster: [],
|
||||
projects: [],
|
||||
answers: [],
|
||||
source: 'roster',
|
||||
picked: [],
|
||||
history: new Set(), // 累计抽中(重置结果时清零)
|
||||
excluded: new Set(), // 手动排除
|
||||
log: [], // 历史批次,新的在前
|
||||
drawing: false,
|
||||
};
|
||||
|
||||
/* ---------------- 接口 ---------------- */
|
||||
async function api(action, params) {
|
||||
const q = new URLSearchParams(Object.assign({ action: action }, params || {}));
|
||||
const r = await fetch('api.php?' + q.toString(), { credentials: 'same-origin' });
|
||||
let j;
|
||||
try {
|
||||
j = await r.json();
|
||||
} catch (e) {
|
||||
throw new Error('接口返回异常(HTTP ' + r.status + ')');
|
||||
}
|
||||
if (r.status === 401 || (j && j.ok === false && /未登录/.test(j.msg || ''))) {
|
||||
location.href = 'login.php';
|
||||
throw new Error('未登录');
|
||||
}
|
||||
if (!j || !j.ok) {
|
||||
throw new Error((j && j.msg) || '请求失败');
|
||||
}
|
||||
return j.data;
|
||||
}
|
||||
|
||||
function showAlert(msg, type) {
|
||||
const el = $('#alert');
|
||||
if (!el) return;
|
||||
if (!msg) { el.classList.add('hidden'); return; }
|
||||
el.textContent = msg;
|
||||
el.className = 'alert' + (type === 'info' ? ' info' : '');
|
||||
}
|
||||
|
||||
/* ================= 抽取核心(纯函数,供测试) ================= */
|
||||
|
||||
/** Fisher-Yates 洗牌(返回新数组) */
|
||||
function shuffle(arr) {
|
||||
const a = arr.slice();
|
||||
for (let i = a.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
const t = a[i]; a[i] = a[j]; a[j] = t;
|
||||
}
|
||||
return a;
|
||||
}
|
||||
|
||||
/**
|
||||
* 名额均分到各组(5:5 语义):每组 n/组数,余数轮流补,
|
||||
* 某组人数不足时把差额退回分给未满的组(结果之和 === min(n, 总人数))
|
||||
*/
|
||||
function allocQuotas(sizes, n) {
|
||||
const g = sizes.length;
|
||||
const quota = new Array(g).fill(0);
|
||||
if (g === 0 || n <= 0) return quota;
|
||||
|
||||
// 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]]++;
|
||||
|
||||
// 2) 超过组容量的退回
|
||||
let overflow = 0;
|
||||
for (let i = 0; i < g; i++) {
|
||||
if (quota[i] > sizes[i]) { overflow += quota[i] - sizes[i]; quota[i] = sizes[i]; }
|
||||
}
|
||||
// 3) 差额分给未满的组(剩余容量大者优先)
|
||||
while (overflow > 0) {
|
||||
const byRoom = sizes.map((s, i) => i)
|
||||
.sort((a, b) => (sizes[b] - quota[b]) - (sizes[a] - quota[a]));
|
||||
let progress = false;
|
||||
for (const i of byRoom) {
|
||||
if (overflow <= 0) break;
|
||||
if (quota[i] < sizes[i]) { quota[i]++; overflow--; progress = true; }
|
||||
}
|
||||
if (!progress) break;
|
||||
}
|
||||
return quota;
|
||||
}
|
||||
|
||||
/**
|
||||
* 选择均衡维度:组别(>=2 个非空组)优先,其次班级,否则不均衡
|
||||
*/
|
||||
function balanceKeyOf(pool) {
|
||||
const grps = new Set(pool.map(m => String(m.grp || '').trim()).filter(Boolean));
|
||||
if (grps.size >= 2) return 'grp';
|
||||
const clss = new Set(pool.map(m => String(m.cls || '').trim()).filter(Boolean));
|
||||
if (clss.size >= 2) return 'cls';
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 分组均衡抽取:返回 { picked, detail }
|
||||
* detail 例:["A 3人", "B 3人"],用于提示与历史记录
|
||||
*/
|
||||
function pickBalanced(pool, n, key) {
|
||||
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, []);
|
||||
groups.get(k).push(m);
|
||||
});
|
||||
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 picked = [];
|
||||
const detail = [];
|
||||
gk.forEach((k, i) => {
|
||||
const part = shuffle(groups.get(k)).slice(0, quotas[i]);
|
||||
picked.push(...part);
|
||||
if (quotas[i] > 0) detail.push(k + ' ' + quotas[i] + '人');
|
||||
});
|
||||
return { picked, detail };
|
||||
}
|
||||
|
||||
// 测试钩子(node 端 eval 后通过 window.__nyDrawTest 调用)
|
||||
if (typeof window !== 'undefined') {
|
||||
window.__nyDrawTest = { shuffle, allocQuotas, balanceKeyOf, pickBalanced };
|
||||
}
|
||||
|
||||
/* ================= 数据加载 ================= */
|
||||
async function loadRoster() {
|
||||
const d = await api('roster');
|
||||
state.roster = (d && d.students ? d.students : []).map(s => ({
|
||||
sid: String(s.sid || ''),
|
||||
name: String(s.name || ''),
|
||||
cls: String(s.cls || ''),
|
||||
grp: String(s.grp || ''),
|
||||
login: String(s.login || ''),
|
||||
}));
|
||||
const badge = $('#rosterBadge');
|
||||
if (badge) badge.textContent = state.roster.length;
|
||||
}
|
||||
|
||||
async function loadProjects() {
|
||||
try {
|
||||
state.projects = (await api('projects')) || [];
|
||||
} catch (e) {
|
||||
state.projects = [];
|
||||
}
|
||||
const sel = $('#projectSel');
|
||||
if (!sel) return;
|
||||
|
||||
const isFolder = p => (p.mode || '').toLowerCase().indexOf('folder') >= 0;
|
||||
const parentIds = new Set();
|
||||
state.projects.forEach(p => {
|
||||
const pid = String(p.parent_id || '0');
|
||||
if (pid !== '0' && pid !== '') parentIds.add(pid);
|
||||
});
|
||||
const folderSet = new Set();
|
||||
state.projects.forEach(p => { if (isFolder(p) || parentIds.has(p.id)) folderSet.add(p.id); });
|
||||
|
||||
const nameOf = id => {
|
||||
const f = state.projects.filter(p => p.id === id)[0];
|
||||
return f ? String(f.name || '') : '';
|
||||
};
|
||||
|
||||
const items = state.projects.filter(p => !folderSet.has(p.id));
|
||||
if (!items.length) {
|
||||
sel.innerHTML = '<option value="">(无可用的问卷 / 考试)</option>';
|
||||
return;
|
||||
}
|
||||
sel.innerHTML = items.map(p => {
|
||||
const pid = String(p.parent_id || '0');
|
||||
const fn = (pid !== '0' && pid !== '' && folderSet.has(pid)) ? nameOf(pid) : '';
|
||||
const label = fn ? fn + ' / ' + p.name : p.name;
|
||||
return '<option value="' + esc(p.id) + '">' + esc(label) + '</option>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
async function loadAnswers(pid) {
|
||||
const d = await api('data', { projectId: pid });
|
||||
const loginOf = {};
|
||||
state.roster.forEach(s => { if (s.login) loginOf[s.sid] = s.login; });
|
||||
state.answers = ((d && d.rows) ? d.rows : []).map(r => ({
|
||||
sid: String(r.sid || ''),
|
||||
name: String(r.name || ''),
|
||||
cls: String(r.cls || ''),
|
||||
grp: String(r.grp || ''),
|
||||
login: String(loginOf[r.sid] || ''),
|
||||
}));
|
||||
}
|
||||
|
||||
/* ================= 候选池 ================= */
|
||||
function baseList() {
|
||||
return state.source === 'project' ? state.answers : state.roster;
|
||||
}
|
||||
|
||||
function currentPool() {
|
||||
let list = baseList().slice();
|
||||
const cls = $('#clsSel').value;
|
||||
const grp = $('#grpSel').value;
|
||||
if (cls) list = list.filter(x => (x.cls || '') === cls);
|
||||
if (grp) list = list.filter(x => (x.grp || '') === grp);
|
||||
list = list.filter(x => !state.excluded.has(String(x.sid)));
|
||||
if ($('#excludePicked').checked) {
|
||||
list = list.filter(x => !state.history.has(String(x.sid)));
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
function uniqSorted(arr) {
|
||||
const seen = {};
|
||||
const out = [];
|
||||
arr.forEach(v => { if (v && !seen[v]) { seen[v] = 1; out.push(v); } });
|
||||
out.sort((a, b) => String(a).localeCompare(String(b), 'zh', { numeric: true }));
|
||||
return out;
|
||||
}
|
||||
|
||||
function fillSel(sel, values, allLabel) {
|
||||
if (!sel) return;
|
||||
const cur = sel.value;
|
||||
sel.innerHTML = '<option value="">' + esc(allLabel) + '</option>' +
|
||||
values.map(v => '<option value="' + esc(v) + '">' + esc(v) + '</option>').join('');
|
||||
sel.value = (values.indexOf(cur) >= 0) ? cur : '';
|
||||
}
|
||||
|
||||
function populateFilters() {
|
||||
const list = baseList();
|
||||
fillSel($('#clsSel'), uniqSorted(list.map(x => x.cls || '')), '全部班级');
|
||||
fillSel($('#grpSel'), uniqSorted(list.map(x => x.grp || '')), '全部组别');
|
||||
}
|
||||
|
||||
function rangeText() {
|
||||
const cls = $('#clsSel').value, grp = $('#grpSel').value;
|
||||
const parts = [];
|
||||
if (cls) parts.push(cls);
|
||||
if (grp) parts.push(grp + '组');
|
||||
return parts.length ? parts.join(' / ') : '全部';
|
||||
}
|
||||
|
||||
function renderStats() {
|
||||
const pool = currentPool();
|
||||
$('#dPool').textContent = pool.length;
|
||||
$('#dPicked').textContent = state.picked.length;
|
||||
$('#dHistory').textContent = state.history.size;
|
||||
$('#dExcluded').textContent = state.excluded.size;
|
||||
$('#dSource').textContent = state.source === 'project' ? '已提交人员' : '学号名单';
|
||||
$('#drawHint').textContent = '候选 ' + pool.length + ' 人(' + rangeText() + ')';
|
||||
const lb = $('#logBadge');
|
||||
if (lb) lb.textContent = state.log.length;
|
||||
const ec = $('#exclCount');
|
||||
if (ec) ec.textContent = '已排除 ' + state.excluded.size + ' 人';
|
||||
}
|
||||
|
||||
/* ================= 抽取 ================= */
|
||||
async function doDraw() {
|
||||
if (state.drawing) return;
|
||||
const pool = currentPool();
|
||||
if (!pool.length) {
|
||||
showAlert('当前筛选下没有可抽取的人员(可关闭「排除已抽中」、调整班级 / 组别或排除名单)');
|
||||
return;
|
||||
}
|
||||
let n = parseInt($('#numInput').value, 10);
|
||||
if (!n || n < 1) n = 1;
|
||||
if (n > pool.length) n = pool.length;
|
||||
|
||||
state.drawing = true;
|
||||
const stage = $('#drawStage');
|
||||
const btn = $('#btnDraw');
|
||||
btn.disabled = true;
|
||||
showAlert('');
|
||||
|
||||
// 滚动动画:先快速闪动若干次
|
||||
stage.className = 'draw-stage rolling';
|
||||
const rounds = Math.min(24, 10 + n);
|
||||
for (let i = 0; i < rounds; i++) {
|
||||
const p = pool[Math.floor(Math.random() * pool.length)];
|
||||
stage.textContent = p.name || p.sid || '—';
|
||||
await sleep(40 + i * 7);
|
||||
}
|
||||
|
||||
// 真正抽取:均衡(组别优先,其次班级)或整池随机
|
||||
let picked, detail = [];
|
||||
if ($('#balanceChk').checked) {
|
||||
const key = balanceKeyOf(pool);
|
||||
if (key) {
|
||||
const res = pickBalanced(pool, n, key);
|
||||
picked = res.picked; detail = res.detail;
|
||||
} else {
|
||||
picked = shuffle(pool).slice(0, n);
|
||||
}
|
||||
} else {
|
||||
picked = shuffle(pool).slice(0, n);
|
||||
}
|
||||
|
||||
state.picked = picked;
|
||||
picked.forEach(m => state.history.add(String(m.sid)));
|
||||
|
||||
stage.className = 'draw-stage done';
|
||||
stage.textContent = n === 1
|
||||
? (picked[0].name || picked[0].sid)
|
||||
: ('🎉 已抽取 ' + n + ' 人');
|
||||
|
||||
renderResult();
|
||||
renderStats();
|
||||
pushLog(picked, detail);
|
||||
saveState();
|
||||
|
||||
state.drawing = false;
|
||||
btn.disabled = false;
|
||||
const tail = detail.length ? '(' + detail.join(' / ') + ')' : '';
|
||||
showAlert('已抽取 ' + n + ' 人' + tail + (n > 1 ? ',可点击右上角按钮下载白名单' : ''), 'info');
|
||||
}
|
||||
|
||||
function renderResult() {
|
||||
const tb = $('#drawBody');
|
||||
if (!state.picked.length) {
|
||||
tb.innerHTML = '<tr><td colspan="6" class="empty">尚未抽取</td></tr>';
|
||||
return;
|
||||
}
|
||||
tb.innerHTML = state.picked.map((m, i) =>
|
||||
'<tr>' +
|
||||
'<td class="num">' + (i + 1) + '</td>' +
|
||||
'<td class="sid">' + esc(m.sid) + '</td>' +
|
||||
'<td class="name">' + esc(m.name) + '</td>' +
|
||||
'<td>' + esc(m.cls || '') + '</td>' +
|
||||
'<td>' + esc(m.grp || '') + '</td>' +
|
||||
'<td><button class="btn sm danger" data-drop="' + i + '" title="移出结果并加入排除名单">排除</button></td>' +
|
||||
'</tr>').join('');
|
||||
}
|
||||
|
||||
/* ================= 历史记录 ================= */
|
||||
function pushLog(picked, detail) {
|
||||
state.log.unshift({
|
||||
ts: new Date().toLocaleString('zh-CN', { hour12: false }),
|
||||
source: state.source === 'project' ? '已提交人员' : '学号名单',
|
||||
range: rangeText(),
|
||||
balance: detail.join(' / '),
|
||||
count: picked.length,
|
||||
members: picked.map(m => ({
|
||||
sid: m.sid, name: m.name, cls: m.cls || '', grp: m.grp || '', login: m.login || '',
|
||||
})),
|
||||
});
|
||||
if (state.log.length > LOG_MAX) state.log.length = LOG_MAX;
|
||||
saveLog();
|
||||
}
|
||||
|
||||
function renderLog() {
|
||||
const box = $('#logList');
|
||||
if (!box) return;
|
||||
if (!state.log.length) {
|
||||
box.innerHTML = '<p class="empty" style="text-align:center;padding:30px 0">暂无历史记录</p>';
|
||||
return;
|
||||
}
|
||||
box.innerHTML = state.log.map((e, i) => {
|
||||
const rows = (e.members || []).map((m, j) =>
|
||||
'<tr>' +
|
||||
'<td class="num">' + (j + 1) + '</td>' +
|
||||
'<td class="sid">' + esc(m.sid) + '</td>' +
|
||||
'<td>' + esc(m.name) + '</td>' +
|
||||
'<td>' + esc(m.cls || '') + '</td>' +
|
||||
'<td>' + esc(m.grp || '') + '</td>' +
|
||||
'</tr>').join('');
|
||||
const meta = [e.ts, e.source, '范围:' + e.range, e.count + ' 人'];
|
||||
if (e.balance) meta.push('均衡:' + e.balance);
|
||||
return '<div class="log-item">' +
|
||||
'<div class="log-head">' +
|
||||
'<label class="log-summary"><span>' + esc(meta.join(' · ')) + '</span></label>' +
|
||||
'<span class="log-actions">' +
|
||||
'<button class="btn sm" data-log-restore="' + i + '">恢复结果</button>' +
|
||||
'<button class="btn sm danger" data-log-del="' + i + '">删除</button>' +
|
||||
'</span></div>' +
|
||||
'<details class="log-detail"><summary>查看成员(' + e.count + ')</summary>' +
|
||||
'<div class="table-scroll short"><table class="grid compact">' +
|
||||
'<thead><tr><th style="width:56px">序号</th><th style="width:28%">学号</th><th style="width:26%">姓名</th><th>班级</th><th>组别</th></tr></thead>' +
|
||||
'<tbody>' + rows + '</tbody></table></div>' +
|
||||
'</details>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
/* ================= 排除管理 ================= */
|
||||
let exclQuery = '';
|
||||
|
||||
function renderExcl() {
|
||||
const tb = $('#exclBody');
|
||||
if (!tb) return;
|
||||
let list = baseList().slice();
|
||||
if (exclQuery) {
|
||||
const q = exclQuery.toLowerCase();
|
||||
list = list.filter(x =>
|
||||
String(x.sid).toLowerCase().includes(q) ||
|
||||
String(x.name).toLowerCase().includes(q) ||
|
||||
String(x.cls || '').toLowerCase().includes(q));
|
||||
}
|
||||
if (!list.length) {
|
||||
tb.innerHTML = '<tr><td colspan="5" class="empty">' +
|
||||
(baseList().length ? '没有匹配的人员' : '当前来源暂无人员') + '</td></tr>';
|
||||
return;
|
||||
}
|
||||
tb.innerHTML = list.map(m => {
|
||||
const on = state.excluded.has(String(m.sid)) ? ' checked' : '';
|
||||
return '<tr>' +
|
||||
'<td style="text-align:center"><input type="checkbox" data-excl="' + esc(m.sid) + '"' + on + '></td>' +
|
||||
'<td class="sid">' + esc(m.sid) + '</td>' +
|
||||
'<td>' + esc(m.name) + '</td>' +
|
||||
'<td>' + esc(m.cls || '') + '</td>' +
|
||||
'<td>' + esc(m.grp || '') + '</td>' +
|
||||
'</tr>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
/* ================= 下载(登陆账号优先) ================= */
|
||||
function dlMembers() {
|
||||
return state.picked.map(m => ({
|
||||
name: m.name,
|
||||
sid: m.sid,
|
||||
login: m.login || '',
|
||||
}));
|
||||
}
|
||||
|
||||
async function download(type) {
|
||||
if (!state.picked.length) {
|
||||
showAlert('请先点击「🎲 开始抽取」');
|
||||
return;
|
||||
}
|
||||
const fname = type === 'import' ? '白名单导入模板.xlsx' : '白名单列表.xlsx';
|
||||
try {
|
||||
const r = await fetch('api.php?action=draw_export', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ type: type, members: dlMembers() }),
|
||||
});
|
||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||
const blob = await r.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = fname;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 3000);
|
||||
showAlert('已下载 ' + fname, 'info');
|
||||
} catch (e) {
|
||||
showAlert('导出失败:' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
/* ================= 本地缓存 ================= */
|
||||
function saveState() {
|
||||
try {
|
||||
localStorage.setItem(LS_RESULT, JSON.stringify(state.picked));
|
||||
localStorage.setItem(LS_HISTORY, JSON.stringify(Array.from(state.history)));
|
||||
} catch (e) { /* 忽略 */ }
|
||||
}
|
||||
|
||||
function saveLog() {
|
||||
try {
|
||||
localStorage.setItem(LS_LOG, JSON.stringify(state.log));
|
||||
const lb = $('#logBadge');
|
||||
if (lb) lb.textContent = state.log.length;
|
||||
} catch (e) { /* 忽略 */ }
|
||||
}
|
||||
|
||||
function saveExcluded() {
|
||||
try {
|
||||
localStorage.setItem(LS_EXCLUDED, JSON.stringify(Array.from(state.excluded)));
|
||||
} catch (e) { /* 忽略 */ }
|
||||
}
|
||||
|
||||
function saveSettings() {
|
||||
try {
|
||||
localStorage.setItem(LS_SETTINGS, JSON.stringify({
|
||||
source: state.source,
|
||||
projectId: $('#projectSel').value,
|
||||
cls: $('#clsSel').value,
|
||||
grp: $('#grpSel').value,
|
||||
num: $('#numInput').value,
|
||||
exclude: $('#excludePicked').checked,
|
||||
balance: $('#balanceChk').checked,
|
||||
}));
|
||||
} catch (e) { /* 忽略 */ }
|
||||
}
|
||||
|
||||
function loadState() {
|
||||
const arr = (k) => {
|
||||
try {
|
||||
const v = JSON.parse(localStorage.getItem(k) || '[]');
|
||||
return Array.isArray(v) ? v : [];
|
||||
} catch (e) { return []; }
|
||||
};
|
||||
state.picked = arr(LS_RESULT);
|
||||
state.history = new Set(arr(LS_HISTORY).map(String));
|
||||
state.excluded = new Set(arr(LS_EXCLUDED).map(String));
|
||||
state.log = arr(LS_LOG);
|
||||
}
|
||||
|
||||
function loadSettings() {
|
||||
try {
|
||||
const s = JSON.parse(localStorage.getItem(LS_SETTINGS) || '{}');
|
||||
if (s && typeof s === 'object') return s;
|
||||
} catch (e) { /* 忽略 */ }
|
||||
return {};
|
||||
}
|
||||
|
||||
/* ================= 事件 ================= */
|
||||
function bind() {
|
||||
$('#sourceSel').addEventListener('change', async () => {
|
||||
state.source = $('#sourceSel').value;
|
||||
$('#projectField').classList.toggle('hidden', state.source !== 'project');
|
||||
state.answers = [];
|
||||
state.picked = [];
|
||||
renderResult();
|
||||
if (state.source === 'project') {
|
||||
if (!state.projects.length) await loadProjects();
|
||||
const pid = $('#projectSel').value;
|
||||
if (pid) {
|
||||
try { await loadAnswers(pid); } catch (e) { showAlert(e.message); }
|
||||
}
|
||||
}
|
||||
populateFilters();
|
||||
renderStats();
|
||||
if ($('#exclModal').classList.contains('open')) renderExcl();
|
||||
saveSettings();
|
||||
});
|
||||
|
||||
$('#projectSel').addEventListener('change', async () => {
|
||||
const pid = $('#projectSel').value;
|
||||
if (!pid) { state.answers = []; populateFilters(); renderStats(); return; }
|
||||
showAlert('正在拉取已提交人员…', 'info');
|
||||
try {
|
||||
await loadAnswers(pid);
|
||||
showAlert('');
|
||||
} catch (e) {
|
||||
showAlert(e.message);
|
||||
}
|
||||
populateFilters();
|
||||
renderStats();
|
||||
if ($('#exclModal').classList.contains('open')) renderExcl();
|
||||
saveSettings();
|
||||
});
|
||||
|
||||
['#clsSel', '#grpSel'].forEach(id => {
|
||||
$(id).addEventListener('change', () => { renderStats(); saveSettings(); });
|
||||
});
|
||||
$('#numInput').addEventListener('change', saveSettings);
|
||||
$('#excludePicked').addEventListener('change', () => { renderStats(); saveSettings(); });
|
||||
$('#balanceChk').addEventListener('change', saveSettings);
|
||||
|
||||
$$('[data-num]').forEach(b => {
|
||||
b.addEventListener('click', () => {
|
||||
const v = b.dataset.num;
|
||||
if (v === 'all') {
|
||||
const pool = currentPool();
|
||||
$('#numInput').value = String(Math.max(1, pool.length));
|
||||
} else {
|
||||
$('#numInput').value = v;
|
||||
}
|
||||
saveSettings();
|
||||
});
|
||||
});
|
||||
|
||||
$('#btnDraw').addEventListener('click', doDraw);
|
||||
|
||||
// 重置结果:清空本次结果 + 累计已抽取(历史记录保留)
|
||||
$('#btnReset').addEventListener('click', () => {
|
||||
state.picked = [];
|
||||
state.history = new Set();
|
||||
renderResult();
|
||||
renderStats();
|
||||
saveState();
|
||||
const stage = $('#drawStage');
|
||||
stage.className = 'draw-stage';
|
||||
stage.textContent = '点击「🎲 开始抽取」';
|
||||
showAlert('已重置结果与累计已抽取(历史记录仍保留)', 'info');
|
||||
});
|
||||
|
||||
// 清空历史(顶栏)
|
||||
$('#btnClearLog').addEventListener('click', () => {
|
||||
if (!state.log.length) return showAlert('暂无历史记录');
|
||||
if (!confirm('确定清空全部 ' + state.log.length + ' 条历史记录?此操作不可恢复。')) return;
|
||||
state.log = [];
|
||||
saveLog();
|
||||
renderLog();
|
||||
renderStats();
|
||||
showAlert('已清空历史记录', 'info');
|
||||
});
|
||||
|
||||
$('#btnDlImport').addEventListener('click', () => download('import'));
|
||||
$('#btnDlList').addEventListener('click', () => download('list'));
|
||||
|
||||
/* --- 结果行内:排除并移除 --- */
|
||||
$('#drawBody').addEventListener('click', e => {
|
||||
const btn = e.target.closest('[data-drop]');
|
||||
if (!btn) return;
|
||||
const i = parseInt(btn.dataset.drop, 10);
|
||||
if (isNaN(i) || !state.picked[i]) return;
|
||||
const m = state.picked[i];
|
||||
state.excluded.add(String(m.sid));
|
||||
state.picked.splice(i, 1);
|
||||
saveExcluded();
|
||||
saveState();
|
||||
renderResult();
|
||||
renderStats();
|
||||
showAlert('已将「' + (m.name || m.sid) + '」加入排除名单,可点「🚫 排除管理」撤销', 'info');
|
||||
});
|
||||
|
||||
/* --- 排除管理弹窗 --- */
|
||||
$('#btnExclude').addEventListener('click', () => {
|
||||
exclQuery = '';
|
||||
$('#exclSearch').value = '';
|
||||
renderExcl();
|
||||
renderStats();
|
||||
$('#exclModal').classList.add('open');
|
||||
});
|
||||
$('#exclSearch').addEventListener('input', () => {
|
||||
exclQuery = $('#exclSearch').value.trim().toLowerCase();
|
||||
renderExcl();
|
||||
});
|
||||
$('#exclBody').addEventListener('change', e => {
|
||||
const cb = e.target.closest('[data-excl]');
|
||||
if (!cb) return;
|
||||
if (cb.checked) state.excluded.add(String(cb.dataset.excl));
|
||||
else state.excluded.delete(String(cb.dataset.excl));
|
||||
saveExcluded();
|
||||
renderStats();
|
||||
});
|
||||
$('#btnClearExcl').addEventListener('click', () => {
|
||||
if (!state.excluded.size) return;
|
||||
state.excluded = new Set();
|
||||
saveExcluded();
|
||||
renderExcl();
|
||||
renderStats();
|
||||
});
|
||||
|
||||
/* --- 历史记录弹窗 --- */
|
||||
$('#btnLog').addEventListener('click', () => {
|
||||
renderLog();
|
||||
$('#logModal').classList.add('open');
|
||||
});
|
||||
$('#logList').addEventListener('click', e => {
|
||||
const rst = e.target.closest('[data-log-restore]');
|
||||
const del = e.target.closest('[data-log-del]');
|
||||
if (rst) {
|
||||
const i = parseInt(rst.dataset.logRestore, 10);
|
||||
const entry = state.log[i];
|
||||
if (!entry) return;
|
||||
state.picked = (entry.members || []).map(m => ({
|
||||
sid: m.sid, name: m.name, cls: m.cls || '', grp: m.grp || '', login: m.login || '',
|
||||
}));
|
||||
renderResult();
|
||||
renderStats();
|
||||
saveState();
|
||||
showAlert('已恢复该批次(' + state.picked.length + ' 人),可直接下载白名单', 'info');
|
||||
}
|
||||
if (del) {
|
||||
const i = parseInt(del.dataset.logDel, 10);
|
||||
if (isNaN(i) || !state.log[i]) return;
|
||||
if (!confirm('删除这条历史记录?')) return;
|
||||
state.log.splice(i, 1);
|
||||
saveLog();
|
||||
renderLog();
|
||||
renderStats();
|
||||
}
|
||||
});
|
||||
|
||||
/* --- 弹窗通用关闭 --- */
|
||||
document.addEventListener('click', e => {
|
||||
if (e.target.matches('[data-close]') || e.target.classList.contains('modal')) {
|
||||
$$('.modal').forEach(m => m.classList.remove('open'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------------- 启动 ---------------- */
|
||||
document.addEventListener('DOMContentLoaded', async function () {
|
||||
loadState();
|
||||
const s = loadSettings();
|
||||
if (s.exclude === false) $('#excludePicked').checked = false;
|
||||
if (s.balance === false) $('#balanceChk').checked = false;
|
||||
if (s.num) $('#numInput').value = s.num;
|
||||
if (s.source === 'project') {
|
||||
state.source = 'project';
|
||||
$('#sourceSel').value = 'project';
|
||||
$('#projectField').classList.remove('hidden');
|
||||
}
|
||||
|
||||
bind();
|
||||
renderResult();
|
||||
renderStats();
|
||||
|
||||
try {
|
||||
await loadRoster();
|
||||
} catch (e) {
|
||||
showAlert('名单加载失败:' + e.message);
|
||||
}
|
||||
if (state.source === 'project') {
|
||||
await loadProjects();
|
||||
const pid = s.projectId || $('#projectSel').value;
|
||||
if (pid) {
|
||||
$('#projectSel').value = pid;
|
||||
try { await loadAnswers(pid); } catch (e) { /* 静默 */ }
|
||||
}
|
||||
}
|
||||
populateFilters();
|
||||
if (s.cls) $('#clsSel').value = s.cls;
|
||||
if (s.grp) $('#grpSel').value = s.grp;
|
||||
renderStats();
|
||||
});
|
||||
})();
|
||||
@@ -585,3 +585,73 @@ body.dash.fs-on .group-compare { gap: 12px; }
|
||||
body.dash.fs-on .gpanel { padding: 12px 14px; }
|
||||
body.dash.fs-on .member-list { max-height: 30vh; }
|
||||
|
||||
|
||||
/* ================= 随机抽取页 ================= */
|
||||
.panel-head .actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
|
||||
/* 抽取人数输入框:在 toolbar 内联按钮组中收窄 */
|
||||
.field .inline input[type=number] { width: 78px; flex: none; }
|
||||
|
||||
.draw-stage {
|
||||
margin: 18px; padding: 26px 18px;
|
||||
border: 1px dashed var(--border); border-radius: var(--radius);
|
||||
background: linear-gradient(180deg, #fbfcff, #f6f8ff);
|
||||
text-align: center; font-size: 20px; font-weight: 700;
|
||||
color: var(--text-3); letter-spacing: 1px;
|
||||
min-height: 86px; display: flex; align-items: center; justify-content: center;
|
||||
transition: color .2s ease, background .2s ease;
|
||||
}
|
||||
.draw-stage.rolling {
|
||||
color: var(--primary);
|
||||
background: linear-gradient(180deg, #f2f6ff, #eaf0ff);
|
||||
animation: drawShake .12s infinite alternate;
|
||||
}
|
||||
.draw-stage.done {
|
||||
color: var(--ok);
|
||||
background: linear-gradient(180deg, #f2fbf5, #e9f7ee);
|
||||
font-size: 26px;
|
||||
}
|
||||
@keyframes drawShake {
|
||||
from { transform: translateY(-1px) scale(.995); }
|
||||
to { transform: translateY(1px) scale(1.005); }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.draw-stage.rolling { animation: none; }
|
||||
}
|
||||
|
||||
/* ================= side 布局:两 panel 等高(缺考名单不再留大片空白) ================= */
|
||||
/* 宽屏 side 模式:panel 变纵向 flex,表格区撑满剩余高度(行高由内容多的一侧决定) */
|
||||
@media (min-width: 901px) {
|
||||
.layout[data-layout="side"] > #tablePanel,
|
||||
.layout[data-layout="side"] > #absentPanel {
|
||||
display: flex; flex-direction: column;
|
||||
}
|
||||
.layout[data-layout="side"] > #tablePanel .table-scroll,
|
||||
.layout[data-layout="side"] > #absentPanel .table-scroll {
|
||||
flex: 1 1 auto; min-height: 0; max-height: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ================= 随机抽取:历史记录 ================= */
|
||||
.log-list { display: flex; flex-direction: column; gap: 10px; }
|
||||
.log-item {
|
||||
border: 1px solid var(--border-2); border-radius: 10px;
|
||||
background: #fbfcff; overflow: hidden;
|
||||
}
|
||||
.log-head {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 10px;
|
||||
padding: 10px 14px; flex-wrap: wrap;
|
||||
}
|
||||
.log-summary { font-size: 13px; font-weight: 600; color: var(--text); }
|
||||
.log-summary small { color: var(--text-3); font-weight: 500; }
|
||||
.log-actions { display: flex; gap: 8px; flex: none; }
|
||||
.log-detail summary {
|
||||
cursor: pointer; user-select: none; padding: 7px 14px;
|
||||
font-size: 12px; color: var(--primary); border-top: 1px dashed var(--border-2);
|
||||
list-style: none;
|
||||
}
|
||||
.log-detail summary::-webkit-details-marker { display: none; }
|
||||
.log-detail summary::before { content: '▸ '; }
|
||||
.log-detail[open] summary::before { content: '▾ '; }
|
||||
.log-detail[open] summary { border-bottom: 1px solid var(--border-2); }
|
||||
.log-detail .table-scroll { max-height: 260px; border-radius: 0; }
|
||||
|
||||
@@ -333,6 +333,7 @@ function ny_format_duration(?int $sec): string
|
||||
spl_autoload_register(function (string $class): void {
|
||||
$map = [
|
||||
'XlsxReader' => NY_ROOT . '/lib/XlsxReader.php',
|
||||
'XlsxWriter' => NY_ROOT . '/lib/XlsxWriter.php',
|
||||
'SurveyKingClient' => NY_ROOT . '/lib/SurveyKingClient.php',
|
||||
'Roster' => NY_ROOT . '/lib/Roster.php',
|
||||
'DataService' => NY_ROOT . '/lib/DataService.php',
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
<?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;
|
||||
}
|
||||
|
||||
$rosterCount = Roster::count();
|
||||
?>
|
||||
<!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">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header class="topbar">
|
||||
<div class="brand">
|
||||
<span class="logo">NY</span>
|
||||
<div class="brand-text">
|
||||
<strong>随机抽取</strong>
|
||||
<small>名单 / 已提交人员 → 卷王白名单</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="top-right">
|
||||
<a class="btn" href="index.php#roster" title="前往主系统管理学号名单">名单管理 <b id="rosterBadge"><?= (int)$rosterCount ?></b></a>
|
||||
<button class="btn" id="btnClearLog" title="清空全部历史记录(重置结果不影响历史记录)">清空历史</button>
|
||||
<a class="btn" href="index.php">← 返回主系统</a>
|
||||
<a class="btn" href="logout.php" title="退出登录">退出</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="wrap">
|
||||
|
||||
<section class="toolbar">
|
||||
<div class="field">
|
||||
<label>抽取来源</label>
|
||||
<select id="sourceSel">
|
||||
<option value="roster">学号名单(全部)</option>
|
||||
<option value="project">某试卷已提交人员</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field grow hidden" id="projectField">
|
||||
<label>问卷 / 考试</label>
|
||||
<select id="projectSel"><option value="">加载中…</option></select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>班级</label>
|
||||
<select id="clsSel"><option value="">全部班级</option></select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>组别</label>
|
||||
<select id="grpSel"><option value="">全部组别</option></select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>抽取人数</label>
|
||||
<div class="inline">
|
||||
<input type="number" id="numInput" min="1" step="1" value="1">
|
||||
<button class="btn sm" data-num="1">1</button>
|
||||
<button class="btn sm" data-num="5">5</button>
|
||||
<button class="btn sm" data-num="10">10</button>
|
||||
<button class="btn sm" data-num="all">全部</button>
|
||||
</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="再次抽取时不与本次会话已抽中的人重复;「重置结果」后清零"><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>
|
||||
</section>
|
||||
|
||||
<div id="alert" class="alert hidden"></div>
|
||||
|
||||
<section class="cards" id="statCards">
|
||||
<div class="card accent-blue"><span>候选人数</span><b id="dPool">-</b></div>
|
||||
<div class="card accent-green"><span>本次抽取</span><b id="dPicked">-</b></div>
|
||||
<div class="card warn accent-orange"><span>累计已抽取</span><b id="dHistory">-</b></div>
|
||||
<div class="card accent-red"><span>已排除</span><b id="dExcluded">-</b></div>
|
||||
<div class="card accent-gray"><span>抽取来源</span><b id="dSource">学号名单</b></div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-head">
|
||||
<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="btnDlImport" title="导出为「白名单导入模板.xlsx」">⬇ 白名单导入模板</button>
|
||||
<button class="btn btn-primary" id="btnDlList" title="导出为「白名单列表.xlsx」">⬇ 白名单列表</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="draw-stage" id="drawStage">点击「🎲 开始抽取」</div>
|
||||
|
||||
<div class="table-scroll">
|
||||
<table class="grid">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:70px">序号</th>
|
||||
<th style="width:26%">学号</th>
|
||||
<th style="width:22%">姓名</th>
|
||||
<th>班级</th>
|
||||
<th>组别</th>
|
||||
<th style="width:90px">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="drawBody"><tr><td colspan="6" class="empty">尚未抽取</td></tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="hint">两个下载均为两列「<b>姓名</b> / <b>登陆账号</b>」,登陆账号取名单中导入的「登陆账号」列(缺失时回退学号),按文本格式保存,可直接导入卷王白名单。</p>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<!-- ================= 排除管理 ================= -->
|
||||
<div class="modal" id="exclModal">
|
||||
<div class="modal-box wide">
|
||||
<div class="modal-head"><h3>抽取排除管理</h3><button class="x" data-close>×</button></div>
|
||||
<div class="modal-body">
|
||||
<div class="roster-bar">
|
||||
<input type="text" id="exclSearch" placeholder="搜索学号 / 姓名 / 班级">
|
||||
<span class="spacer"></span>
|
||||
<span class="hint" id="exclCount">已排除 0 人</span>
|
||||
<button class="btn sm danger" id="btnClearExcl">清空排除</button>
|
||||
</div>
|
||||
<div class="table-scroll mid">
|
||||
<table class="grid compact">
|
||||
<thead><tr><th style="width:64px">排除</th><th style="width:24%">学号</th><th style="width:22%">姓名</th><th>班级</th><th>组别</th></tr></thead>
|
||||
<tbody id="exclBody"><tr><td colspan="5" class="empty">加载中…</td></tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="hint">勾选后这些人不再参与抽取(勾选即时生效);仅影响随机抽取,不影响其他功能。</p>
|
||||
</div>
|
||||
<div class="modal-foot"><span class="spacer"></span><button class="btn btn-primary" data-close>完成</button></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ================= 历史记录 ================= -->
|
||||
<div class="modal" id="logModal">
|
||||
<div class="modal-box wide">
|
||||
<div class="modal-head"><h3>抽取历史记录</h3><button class="x" data-close>×</button></div>
|
||||
<div class="modal-body">
|
||||
<div id="logList" class="log-list"><p class="empty" style="text-align:center;padding:30px 0">暂无历史记录</p></div>
|
||||
<p class="hint">每次抽取自动记一笔;「重置结果」不会清除历史记录,可随时恢复某一批次重新下载。</p>
|
||||
</div>
|
||||
<div class="modal-foot">
|
||||
<span class="spacer"></span>
|
||||
<button class="btn btn-primary" data-close>关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="assets/draw.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -35,6 +35,7 @@ $rosterCount = Roster::count();
|
||||
<div class="top-right">
|
||||
<div id="conn" class="conn conn-off"><i></i><span>未连接</span></div>
|
||||
<a class="btn" id="btnDashboard" href="dashboard.php" target="_blank" rel="noopener" title="在新标签页打开数据看板">📊 数据看板</a>
|
||||
<a class="btn" id="btnDraw" href="draw.php" target="_blank" rel="noopener" title="在新标签页打开随机抽取">🎲 随机抽取</a>
|
||||
<button class="btn" id="btnFullscreen" title="切换全屏">⛶ 全屏</button>
|
||||
<button class="btn" id="btnRoster">名单管理 <b id="rosterBadge"><?= (int)$rosterCount ?></b></button>
|
||||
<button class="btn btn-primary" id="btnSettings">设置</button>
|
||||
|
||||
+41
-6
@@ -164,26 +164,57 @@ class Db
|
||||
}
|
||||
$pdo->exec($sql); $n++;
|
||||
|
||||
// roster —— 名单表(学号/姓名/班级/组别)
|
||||
// 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))';
|
||||
'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))';
|
||||
'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)) ' .
|
||||
'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 读成数组
|
||||
*/
|
||||
@@ -227,7 +258,8 @@ class Db
|
||||
if ($pdo === null) {
|
||||
return [];
|
||||
}
|
||||
$stmt = $pdo->query('SELECT sid, name, cls, grp FROM ny_roster ORDER BY id');
|
||||
self::ensureRosterLogin();
|
||||
$stmt = $pdo->query('SELECT sid, name, cls, grp, login FROM ny_roster ORDER BY id');
|
||||
$rows = [];
|
||||
foreach ($stmt->fetchAll() as $r) {
|
||||
$rows[] = [
|
||||
@@ -235,6 +267,7 @@ class Db
|
||||
'name' => (string)$r['name'],
|
||||
'cls' => (string)$r['cls'],
|
||||
'grp' => (string)$r['grp'],
|
||||
'login' => (string)($r['login'] ?? ''),
|
||||
];
|
||||
}
|
||||
return $rows;
|
||||
@@ -247,16 +280,18 @@ class Db
|
||||
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) VALUES(?, ?, ?, ?)');
|
||||
$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();
|
||||
|
||||
+13
-3
@@ -39,6 +39,7 @@ class Roster
|
||||
'name' => trim((string)($s['name'] ?? '')),
|
||||
'cls' => trim((string)($s['cls'] ?? '')),
|
||||
'grp' => trim((string)($s['grp'] ?? '')),
|
||||
'login' => trim((string)($s['login'] ?? '')),
|
||||
];
|
||||
}
|
||||
return $out;
|
||||
@@ -67,6 +68,7 @@ class Roster
|
||||
'name' => $name,
|
||||
'cls' => trim((string)($s['cls'] ?? '')),
|
||||
'grp' => trim((string)($s['grp'] ?? '')),
|
||||
'login' => trim((string)($s['login'] ?? '')),
|
||||
];
|
||||
}
|
||||
if (self::useDb()) {
|
||||
@@ -191,8 +193,9 @@ class Roster
|
||||
}
|
||||
|
||||
/**
|
||||
* 识别 学号/姓名/班级 列
|
||||
* @return array<int,array{sid:string,name:string,cls:string}>
|
||||
* 识别 学号/姓名/班级/组别/登陆账号 列
|
||||
* 登陆账号仅用于随机抽取页导出白名单,界面任何位置都不显示
|
||||
* @return array<int,array{sid:string,name:string,cls:string,grp:string,login:string}>
|
||||
*/
|
||||
public static function rowsToStudents(array $rows): array
|
||||
{
|
||||
@@ -200,6 +203,7 @@ class Roster
|
||||
$nameCol = -1;
|
||||
$clsCol = -1;
|
||||
$grpCol = -1;
|
||||
$loginCol = -1;
|
||||
$headerIdx = -1;
|
||||
|
||||
// 找表头
|
||||
@@ -228,6 +232,9 @@ class Roster
|
||||
if ($grpCol < 0 && preg_match('/组\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;
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($sidCol < 0) {
|
||||
@@ -249,7 +256,10 @@ class Roster
|
||||
$sid = preg_replace('/\.0+$/', '', $sid) ?? $sid;
|
||||
$cls = $clsCol >= 0 ? trim((string)($row[$clsCol] ?? '')) : '';
|
||||
$grp = $grpCol >= 0 ? trim((string)($row[$grpCol] ?? '')) : '';
|
||||
$out[] = ['sid' => $sid, 'name' => $name, 'cls' => $cls, 'grp' => $grp];
|
||||
$login = $loginCol >= 0 ? trim((string)($row[$loginCol] ?? '')) : '';
|
||||
// 登陆账号同样去掉 Excel 数字尾巴(手机号被读成 13912687810.0)
|
||||
$login = preg_replace('/\.0+$/', '', $login) ?? $login;
|
||||
$out[] = ['sid' => $sid, 'name' => $name, 'cls' => $cls, 'grp' => $grp, 'login' => $login];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
<?php
|
||||
/**
|
||||
* NY 问卷考试系统数据平台 - 零依赖 XLSX 生成
|
||||
*
|
||||
* 仅用 PHP 内置函数生成最小可用的 .xlsx 文件:
|
||||
* - 有 zip 扩展时走 ZipArchive;无 zip 扩展时回退到"手写 ZIP(stored)"实现,保证任何环境都能导出
|
||||
* - 单元格统一按「共享字符串 + 文本格式(numFmtId=49)」写入,学号/账号等长数字不会被转成科学计数法
|
||||
* - 第一行固定为表头(加粗 + 居中)
|
||||
*/
|
||||
declare(strict_types=1);
|
||||
|
||||
final class XlsxWriter
|
||||
{
|
||||
/** @var array<string,int> 共享字符串 => 索引 */
|
||||
private array $strIndex = [];
|
||||
/** @var array<int,string> 索引 => 共享字符串 */
|
||||
private array $strings = [];
|
||||
/** @var array<int,array<int,string>> 行数据 */
|
||||
private array $rows = [];
|
||||
/** @var array<int,float> 列宽(1 基) */
|
||||
private array $widths = [];
|
||||
|
||||
// 样式索引(见 stylesXml)
|
||||
private const S_DEFAULT = 0; // 常规
|
||||
private const S_HEADER = 1; // 表头:加粗 + 居中
|
||||
private const S_TEXT = 2; // 文本格式(numFmtId=49)
|
||||
|
||||
/**
|
||||
* 生成 xlsx 二进制内容
|
||||
*
|
||||
* @param array $rows 二维表,第一行视为表头;每个元素会被强制转成字符串
|
||||
* @param array $opts ['widths' => [列宽,...], 'title' => 工作表名]
|
||||
* @return string xlsx 文件二进制
|
||||
*/
|
||||
public static function render(array $rows, array $opts = []): string
|
||||
{
|
||||
$w = new self();
|
||||
$w->rows = array_values($rows);
|
||||
$w->widths = array_values((array)($opts['widths'] ?? []));
|
||||
return $w->build((string)($opts['title'] ?? 'Sheet1'));
|
||||
}
|
||||
|
||||
/* ---------------- 内部:构建 ---------------- */
|
||||
|
||||
private function build(string $title): string
|
||||
{
|
||||
$maxCol = 1;
|
||||
foreach ($this->rows as $r) {
|
||||
$maxCol = max($maxCol, count((array)$r));
|
||||
}
|
||||
$maxRow = max(1, count($this->rows));
|
||||
|
||||
// 先登记所有字符串,生成 sharedStrings 索引
|
||||
$cells = [];
|
||||
foreach ($this->rows as $ri => $row) {
|
||||
$r = (int)$ri + 1;
|
||||
foreach (array_values((array)$row) as $ci => $val) {
|
||||
$c = (int)$ci + 1;
|
||||
$s = $this->intern((string)($val ?? ''));
|
||||
$cells[$r][$c] = $s;
|
||||
}
|
||||
}
|
||||
|
||||
$sheet = $this->sheetXml($cells, $maxRow, $maxCol);
|
||||
$files = [
|
||||
'[Content_Types].xml' => $this->contentTypesXml(),
|
||||
'_rels/.rels' => $this->rootRelsXml(),
|
||||
'docProps/app.xml' => $this->appXml(),
|
||||
'docProps/core.xml' => $this->coreXml($title),
|
||||
'xl/workbook.xml' => $this->workbookXml($title),
|
||||
'xl/_rels/workbook.xml.rels' => $this->workbookRelsXml(),
|
||||
'xl/styles.xml' => $this->stylesXml(),
|
||||
'xl/sharedStrings.xml' => $this->sharedStringsXml(),
|
||||
'xl/worksheets/sheet1.xml' => $sheet,
|
||||
];
|
||||
|
||||
return $this->zip($files);
|
||||
}
|
||||
|
||||
private function intern(string $s): int
|
||||
{
|
||||
if (!array_key_exists($s, $this->strIndex)) {
|
||||
$i = count($this->strings);
|
||||
$this->strIndex[$s] = $i;
|
||||
$this->strings[$i] = $s;
|
||||
}
|
||||
return $this->strIndex[$s];
|
||||
}
|
||||
|
||||
private function sheetXml(array $cells, int $maxRow, int $maxCol): string
|
||||
{
|
||||
$dim = 'A1:' . self::colName($maxCol) . $maxRow;
|
||||
|
||||
$cols = '';
|
||||
for ($c = 1; $c <= $maxCol; $c++) {
|
||||
$w = $this->widths[$c - 1] ?? 16;
|
||||
$cols .= '<col min="' . $c . '" max="' . $c . '" width="' . $w . '" customWidth="1"/>';
|
||||
}
|
||||
|
||||
$data = '';
|
||||
for ($r = 1; $r <= $maxRow; $r++) {
|
||||
$line = '';
|
||||
$has = false;
|
||||
for ($c = 1; $c <= $maxCol; $c++) {
|
||||
if (!isset($cells[$r][$c])) {
|
||||
continue;
|
||||
}
|
||||
$has = true;
|
||||
$s = $r === 1 ? self::S_HEADER : self::S_TEXT;
|
||||
$line .= '<c r="' . self::colName($c) . $r . '" s="' . $s . '" t="s"><v>' .
|
||||
$cells[$r][$c] . '</v></c>';
|
||||
}
|
||||
if (!$has) {
|
||||
continue;
|
||||
}
|
||||
$data .= '<row r="' . $r . '" spans="1:' . $maxCol . '">' . $line . '</row>';
|
||||
}
|
||||
|
||||
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' . "\n" .
|
||||
'<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">' .
|
||||
'<dimension ref="' . $dim . '"/>' .
|
||||
'<sheetViews><sheetView workbookViewId="0"/></sheetViews>' .
|
||||
'<sheetFormatPr defaultRowHeight="13.5"/>' .
|
||||
($cols !== '' ? '<cols>' . $cols . '</cols>' : '') .
|
||||
'<sheetData>' . $data . '</sheetData>' .
|
||||
'</worksheet>';
|
||||
}
|
||||
|
||||
private function contentTypesXml(): string
|
||||
{
|
||||
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' . "\n" .
|
||||
'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">' .
|
||||
'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>' .
|
||||
'<Default Extension="xml" ContentType="application/xml"/>' .
|
||||
'<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>' .
|
||||
'<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>' .
|
||||
'<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>' .
|
||||
'<Override PartName="/xl/sharedStrings.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml"/>' .
|
||||
'<Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/>' .
|
||||
'<Override PartName="/docProps/app.xml" ContentType="application/vnd.openxmlformats-officedocument.extended-properties+xml"/>' .
|
||||
'</Types>';
|
||||
}
|
||||
|
||||
private function rootRelsXml(): string
|
||||
{
|
||||
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' . "\n" .
|
||||
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' .
|
||||
'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>' .
|
||||
'<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" Target="docProps/core.xml"/>' .
|
||||
'<Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties" Target="docProps/app.xml"/>' .
|
||||
'</Relationships>';
|
||||
}
|
||||
|
||||
private function workbookXml(string $title): string
|
||||
{
|
||||
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' . "\n" .
|
||||
'<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" ' .
|
||||
'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">' .
|
||||
'<sheets><sheet name="' . self::xml($title) . '" sheetId="1" r:id="rId1"/></sheets>' .
|
||||
'</workbook>';
|
||||
}
|
||||
|
||||
private function workbookRelsXml(): string
|
||||
{
|
||||
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' . "\n" .
|
||||
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' .
|
||||
'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>' .
|
||||
'<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>' .
|
||||
'<Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/sharedStrings" Target="sharedStrings.xml"/>' .
|
||||
'</Relationships>';
|
||||
}
|
||||
|
||||
private function stylesXml(): string
|
||||
{
|
||||
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' . "\n" .
|
||||
'<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">' .
|
||||
// numFmts: 49 = @(文本)
|
||||
'<numFmts count="1"><numFmt numFmtId="49" formatCode="@"/></numFmts>' .
|
||||
'<fonts count="2">' .
|
||||
'<font><sz val="11"/><color theme="1"/><name val="宋体"/><charset val="134"/></font>' .
|
||||
'<font><b/><sz val="11"/><color theme="1"/><name val="宋体"/><charset val="134"/></font>' .
|
||||
'</fonts>' .
|
||||
'<fills count="2"><fill><patternFill patternType="none"/></fill>' .
|
||||
'<fill><patternFill patternType="gray125"/></fill></fills>' .
|
||||
'<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>' .
|
||||
'<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>' .
|
||||
'<cellXfs count="3">' .
|
||||
'<xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/>' . // 0 常规
|
||||
'<xf numFmtId="0" fontId="1" fillId="0" borderId="0" xfId="0" applyFont="1"><alignment horizontal="center" vertical="center"/></xf>' . // 1 表头
|
||||
'<xf numFmtId="49" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1"><alignment vertical="center"/></xf>' . // 2 文本
|
||||
'</cellXfs>' .
|
||||
'<cellStyles count="1"><cellStyle name="常规" xfId="0" builtinId="0"/></cellStyles>' .
|
||||
'</styleSheet>';
|
||||
}
|
||||
|
||||
private function sharedStringsXml(): string
|
||||
{
|
||||
$count = count($this->strings);
|
||||
$body = '';
|
||||
foreach ($this->strings as $s) {
|
||||
$body .= '<si><t xml:space="preserve">' . self::xml((string)$s) . '</t></si>';
|
||||
}
|
||||
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' . "\n" .
|
||||
'<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="' . $count .
|
||||
'" uniqueCount="' . $count . '">' . $body . '</sst>';
|
||||
}
|
||||
|
||||
private function coreXml(string $title): string
|
||||
{
|
||||
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' . "\n" .
|
||||
'<cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" ' .
|
||||
'xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/" ' .
|
||||
'xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">' .
|
||||
'<dc:title>' . self::xml($title) . '</dc:title>' .
|
||||
'<dc:creator>NY问卷考试系统数据平台</dc:creator>' .
|
||||
'<cp:lastModifiedBy>NY问卷考试系统数据平台</cp:lastModifiedBy>' .
|
||||
'<dcterms:created xsi:type="dcterms:W3CDTF">' . gmdate('Y-m-d\TH:i:s\Z') . '</dcterms:created>' .
|
||||
'<dcterms:modified xsi:type="dcterms:W3CDTF">' . gmdate('Y-m-d\TH:i:s\Z') . '</dcterms:modified>' .
|
||||
'</cp:coreProperties>';
|
||||
}
|
||||
|
||||
private function appXml(): string
|
||||
{
|
||||
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' . "\n" .
|
||||
'<Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/extended-properties" ' .
|
||||
'xmlns:vt="http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes">' .
|
||||
'<Application>NY Exam Platform</Application></Properties>';
|
||||
}
|
||||
|
||||
/* ---------------- 内部:打包 ---------------- */
|
||||
|
||||
/** @param array<string,string> $files 相对路径 => 内容 */
|
||||
private function zip(array $files): string
|
||||
{
|
||||
if (class_exists('ZipArchive')) {
|
||||
$tmp = tempnam(sys_get_temp_dir(), 'nyx');
|
||||
if ($tmp !== false) {
|
||||
$zip = new ZipArchive();
|
||||
if ($zip->open($tmp, ZipArchive::OVERWRITE) === true) {
|
||||
foreach ($files as $name => $content) {
|
||||
$zip->addFromString($name, $content);
|
||||
}
|
||||
$zip->close();
|
||||
$bin = (string)@file_get_contents($tmp);
|
||||
@unlink($tmp);
|
||||
if ($bin !== '') {
|
||||
return $bin;
|
||||
}
|
||||
}
|
||||
@unlink($tmp);
|
||||
}
|
||||
}
|
||||
return self::packManual($files);
|
||||
}
|
||||
|
||||
/**
|
||||
* 无 zip 扩展时的手写打包(stored,不压缩)
|
||||
* 单独公开,便于在没有 ZipArchive 的环境下自检
|
||||
*
|
||||
* @param array<string,string> $files 相对路径 => 内容
|
||||
*/
|
||||
public static function packManual(array $files): string
|
||||
{
|
||||
$local = '';
|
||||
$central = '';
|
||||
$offset = 0;
|
||||
$time = self::dosTime();
|
||||
$date = self::dosDate();
|
||||
|
||||
foreach ($files as $name => $content) {
|
||||
$nameBin = (string)$name;
|
||||
$data = (string)$content;
|
||||
$crc = crc32($data);
|
||||
$len = strlen($data);
|
||||
|
||||
$local .= "\x50\x4b\x03\x04" . pack('vvvvvVVVvv', 20, 0x0800, 0, $time, $date, $crc, $len, $len, strlen($nameBin), 0) . $nameBin . $data;
|
||||
$central .= "\x50\x4b\x01\x02" . pack('vvvvvvVVVvvvvvVV', 20, 20, 0x0800, 0, $time, $date, $crc, $len, $len, strlen($nameBin), 0, 0, 0, 0, 0, $offset) . $nameBin;
|
||||
$offset += 30 + strlen($nameBin) + $len;
|
||||
}
|
||||
|
||||
return $local . $central .
|
||||
"\x50\x4b\x05\x06" . pack('vvvvVVv', 0, 0, count($files), count($files), strlen($central), $offset, 0);
|
||||
}
|
||||
|
||||
private static function dosTime(): int
|
||||
{
|
||||
$t = getdate();
|
||||
return ($t['hours'] << 11) | ($t['minutes'] << 5) | (intdiv($t['seconds'], 2) & 0x1f);
|
||||
}
|
||||
|
||||
private static function dosDate(): int
|
||||
{
|
||||
$t = getdate();
|
||||
return (($t['year'] - 1980) << 9) | ($t['mon'] << 5) | $t['mday'];
|
||||
}
|
||||
|
||||
/* ---------------- 工具 ---------------- */
|
||||
|
||||
private static function xml(string $s): string
|
||||
{
|
||||
return htmlspecialchars($s, ENT_XML1 | ENT_COMPAT, 'UTF-8');
|
||||
}
|
||||
|
||||
/** 列序号(1基) => A / B / ... / AA */
|
||||
private static function colName(int $n): string
|
||||
{
|
||||
$s = '';
|
||||
while ($n > 0) {
|
||||
$m = ($n - 1) % 26;
|
||||
$s = chr(65 + $m) . $s;
|
||||
$n = intdiv($n - 1, 26);
|
||||
}
|
||||
return $s ?: 'A';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
// 测试随机抽取页的均衡分配算法(通过 stub 环境加载 assets/draw.js)
|
||||
// 运行:node tools/test-draw-alloc.js
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// stub 浏览器环境:draw.js 顶部只定义函数,尾部 document.addEventListener
|
||||
global.window = {};
|
||||
global.document = { addEventListener() {} };
|
||||
global.localStorage = { getItem: () => null, setItem: () => {} };
|
||||
global.location = {};
|
||||
|
||||
eval(fs.readFileSync(path.join(__dirname, '..', 'assets', 'draw.js'), 'utf8'));
|
||||
const T = global.window.__nyDrawTest;
|
||||
if (!T) { console.error('FAIL 未找到 __nyDrawTest 测试钩子'); process.exit(1); }
|
||||
|
||||
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 : '')); }
|
||||
}
|
||||
|
||||
/* ---------- 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)));
|
||||
// 抽 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)
|
||||
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 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; }
|
||||
let bad = false;
|
||||
for (let i = 0; i < g; i++) if (q[i] < 0 || q[i] > sizes[i]) bad = true;
|
||||
// 均衡性:所有组均未满时,任意两组名额差 ≤ 1
|
||||
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; }
|
||||
}
|
||||
chk('1000 组随机 fuzz:守恒+不超容量+均衡', fuzzOk, fuzzBad);
|
||||
|
||||
/* ---------- 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 ---------- */
|
||||
{
|
||||
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 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);
|
||||
}
|
||||
{
|
||||
const pool = [{ sid: 'x1', grp: '', cls: '' }, { sid: 'x2', grp: '', cls: '' }];
|
||||
const r = T.pickBalanced(pool, 2, T.balanceKeyOf(pool) || 'grp');
|
||||
chk('无维度也能抽取', r.picked.length === 2, JSON.stringify(r.picked.map(m => m.sid)));
|
||||
}
|
||||
|
||||
console.log('\n结果:' + pass + ' 通过,' + fail + ' 失败');
|
||||
process.exit(fail === 0 ? 0 : 1);
|
||||
@@ -0,0 +1,104 @@
|
||||
<?php
|
||||
/**
|
||||
* 自测:XlsxWriter 生成 → XlsxReader 读回,验证闭环与内容正确
|
||||
* 运行:php tools/test-xlsxwriter.php
|
||||
*/
|
||||
declare(strict_types=1);
|
||||
require __DIR__ . '/../bootstrap.php';
|
||||
|
||||
$out = __DIR__ . '/_xlsxwriter_out.xlsx';
|
||||
|
||||
$rows = [
|
||||
['姓名', '登陆账号'],
|
||||
['张三', '2024010101'],
|
||||
['李四', '2024010102001234'], // 超长学号:必须原样保留,不能变科学计数法
|
||||
['王小明', 'A001'],
|
||||
['Bob "引号" & <标签>', '123456789012345678'],
|
||||
];
|
||||
|
||||
$bin = XlsxWriter::render($rows, ['widths' => [16, 24], 'title' => '白名单']);
|
||||
file_put_contents($out, $bin);
|
||||
|
||||
echo "生成字节数: " . strlen($bin) . PHP_EOL;
|
||||
|
||||
// 1) ZIP 头必须是 PK
|
||||
$pass = 0; $fail = 0;
|
||||
function chk(string $name, bool $ok, string $extra = ''): void
|
||||
{
|
||||
global $pass, $fail;
|
||||
if ($ok) { $pass++; echo " PASS $name" . ($extra ? " — $extra" : '') . PHP_EOL; }
|
||||
else { $fail++; echo " FAIL $name" . ($extra ? " — $extra" : '') . PHP_EOL; }
|
||||
}
|
||||
|
||||
chk('ZIP 魔数 PK', substr($bin, 0, 2) === 'PK');
|
||||
|
||||
// 用 ZipArchive 解开校验包内结构(ZIP 内容已压缩,不能直接在二进制里搜字符串)
|
||||
$parts = [];
|
||||
if (class_exists('ZipArchive')) {
|
||||
$z = new ZipArchive();
|
||||
if ($z->open($out) === true) {
|
||||
for ($i = 0; $i < $z->numFiles; $i++) {
|
||||
$n = $z->getNameIndex($i);
|
||||
$parts[$n] = $z->getFromIndex($i);
|
||||
}
|
||||
$z->close();
|
||||
}
|
||||
}
|
||||
chk('包内含 xl/workbook.xml', isset($parts['xl/workbook.xml']));
|
||||
chk('包内含 xl/worksheets/sheet1.xml', isset($parts['xl/worksheets/sheet1.xml']));
|
||||
chk('包内含 xl/sharedStrings.xml', isset($parts['xl/sharedStrings.xml']));
|
||||
chk('Content_Types 声明 workbook 主部件',
|
||||
strpos((string)($parts['[Content_Types].xml'] ?? ''), 'spreadsheetml.sheet.main+xml') !== false);
|
||||
chk('文本格式 numFmtId=49 已写入',
|
||||
strpos((string)($parts['xl/styles.xml'] ?? ''), 'numFmtId="49"') !== false);
|
||||
chk('学号以共享字符串写入(非纯数字单元格)',
|
||||
strpos((string)($parts['xl/sharedStrings.xml'] ?? ''), '2024010102001234') !== false);
|
||||
chk('列名(A/B)正确',
|
||||
strpos((string)($parts['xl/worksheets/sheet1.xml'] ?? ''), 'r="A1"') !== false
|
||||
&& strpos((string)($parts['xl/worksheets/sheet1.xml'] ?? ''), 'r="B1"') !== false);
|
||||
|
||||
// 2) 用自带的 XlsxReader 读回
|
||||
$back = XlsxReader::read($out);
|
||||
chk('XlsxReader 成功解析', is_array($back) && count($back) > 0, '行数=' . count($back));
|
||||
|
||||
chk('行数一致', count($back) === count($rows), '期望 ' . count($rows) . ',实际 ' . count($back));
|
||||
|
||||
$first = $back[0] ?? [];
|
||||
chk('表头 = 姓名/登陆账号',
|
||||
($first[0] ?? '') === '姓名' && ($first[1] ?? '') === '登陆账号',
|
||||
json_encode($first, JSON_UNESCAPED_UNICODE));
|
||||
|
||||
chk('普通行正确', ($back[1][0] ?? '') === '张三' && ($back[1][1] ?? '') === '2024010101',
|
||||
json_encode($back[1] ?? [], JSON_UNESCAPED_UNICODE));
|
||||
|
||||
chk('超长学号不被转科学计数法', ($back[2][1] ?? '') === '2024010102001234',
|
||||
'实际=' . ($back[2][1] ?? 'NULL'));
|
||||
|
||||
chk('18 位纯数字学号原样保留', ($back[4][1] ?? '') === '123456789012345678',
|
||||
'实际=' . ($back[4][1] ?? 'NULL'));
|
||||
|
||||
chk('特殊字符转义正确', ($back[4][0] ?? '') === 'Bob "引号" & <标签>',
|
||||
'实际=' . ($back[4][0] ?? 'NULL'));
|
||||
|
||||
// 3) 无 ZipArchive 时的手写打包路径(stored):单独生成再用 XlsxReader 读回
|
||||
$manualOut = __DIR__ . '/_xlsxwriter_manual.xlsx';
|
||||
$manual = XlsxWriter::packManual($parts !== [] ? $parts : []);
|
||||
if ($parts !== []) {
|
||||
file_put_contents($manualOut, $manual);
|
||||
chk('手写 ZIP 魔数 PK', substr($manual, 0, 2) === 'PK');
|
||||
$mBack = [];
|
||||
try {
|
||||
$mBack = XlsxReader::read($manualOut);
|
||||
} catch (Throwable $e) {
|
||||
$mBack = [];
|
||||
}
|
||||
chk('手写打包产物可被解析', count($mBack) === count($rows), '行数=' . count($mBack));
|
||||
chk('手写打包内容一致',
|
||||
($mBack[1][0] ?? '') === '张三' && ($mBack[4][1] ?? '') === '123456789012345678',
|
||||
json_encode($mBack[1] ?? [], JSON_UNESCAPED_UNICODE));
|
||||
@unlink($manualOut);
|
||||
}
|
||||
|
||||
@unlink($out);
|
||||
echo PHP_EOL . "结果:$pass 通过,$fail 失败" . PHP_EOL;
|
||||
exit($fail === 0 ? 0 : 1);
|
||||
Reference in New Issue
Block a user