From ba0e64ed51171ca1da6eb2692657e58f25d97f39 Mon Sep 17 00:00:00 2001 From: nxxy335 Date: Wed, 9 Sep 2026 11:11:14 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AF=BC=E5=87=BA=E6=8C=89=E7=8F=AD?= =?UTF-8?q?=E7=BA=A7/=E7=BB=84=E5=88=AB=E7=AD=9B=E9=80=89=20+=20=E9=9A=8F?= =?UTF-8?q?=E6=9C=BA=E6=8A=BD=E5=8F=96=E9=A1=B5=20+=20=E9=9B=B6=E4=BE=9D?= =?UTF-8?q?=E8=B5=96=20XLSX=20=E5=AF=BC=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 导出跟随分类筛选 - api.php export / export_absent 新增 cls、grp 参数,按当前筛选过滤 rows - 缺考口径与页面完全一致:筛选后名单 − 筛选后已提交(按学号匹配) - 导出文件名附带班级 / 组别(如 成绩明细_高一数学月考_高一(1)班_A组_时间戳.csv) - app.js 两个导出按钮追加 cls/grp 参数并提示导出范围 随机抽取页 draw.php - 来源:学号名单 / 某试卷已提交人员;支持班级、组别筛选与抽取人数设置 - 「排除已抽中」累计去重,抽取结果存入 localStorage,刷新后仍可下载 - 抽取动画(滚动定格),结果表格含序号 / 学号 / 姓名 / 班级 / 组别 - 下载「白名单导入模板.xlsx」「白名单列表.xlsx」:两列 姓名 / 登陆账号 XLSX 导出能力 - 新增 lib/XlsxWriter.php:零依赖生成 xlsx,有 zip 扩展走 ZipArchive, 无则回退手写 ZIP(stored);单元格按共享字符串 + 文本格式(numFmtId=49) 写入,长学号不会变科学计数法 - 新增 api.php action=draw_export,输出与「白名单列表.xlsx」模板一致的结构 其他 - index.php 顶栏新增「随机抽取」入口;支持 index.php#roster 直接打开名单管理 - tools/test-xlsxwriter.php 自测:18 项全通过(含无 ZipArchive 的手写打包路径) --- api.php | 99 ++++++++- assets/app.js | 25 ++- assets/draw.js | 422 ++++++++++++++++++++++++++++++++++++++ assets/style.css | 33 +++ bootstrap.php | 1 + draw.php | 125 +++++++++++ index.php | 1 + lib/XlsxWriter.php | 315 ++++++++++++++++++++++++++++ tools/test-xlsxwriter.php | 104 ++++++++++ 9 files changed, 1113 insertions(+), 12 deletions(-) create mode 100644 assets/draw.js create mode 100644 draw.php create mode 100644 lib/XlsxWriter.php create mode 100644 tools/test-xlsxwriter.php diff --git a/api.php b/api.php index 962690c..f6771fc 100644 --- a/api.php +++ b/api.php @@ -326,7 +326,7 @@ try { Roster::clear(); ny_ok(['students' => [], 'meta' => Roster::meta()]); - // ---------------- 导出 ---------------- + // ---------------- 导出(支持按班级 / 组别筛选,与页面当前筛选保持一致) ---------------- case 'export': case 'export_absent': $projectId = (string)($_GET['projectId'] ?? ''); @@ -334,34 +334,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 +415,39 @@ 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'] ?? '')); + $sid = trim((string)($m['sid'] ?? '')); + if ($name === '' && $sid === '') { + continue; + } + $members[] = [$name, $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); } diff --git a/assets/app.js b/assets/app.js index 3ca6f8e..c855e8c 100644 --- a/assets/app.js +++ b/assets/app.js @@ -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(); diff --git a/assets/draw.js b/assets/draw.js new file mode 100644 index 0000000..1901248 --- /dev/null +++ b/assets/draw.js @@ -0,0 +1,422 @@ +/*! + * NY 问卷考试系统数据平台 - 随机抽取页逻辑 + */ +(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_SETTINGS = 'ny_draw_settings'; // 来源 / 项目 / 班级 / 组别 / 人数 + + const state = { + roster: [], + projects: [], + answers: [], + source: 'roster', + picked: [], + history: new Set(), + 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' : ''); + } + + /* ---------------- 数据加载 ---------------- */ + 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 || ''), + })); + 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 = ''; + 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 ''; + }).join(''); + } + + async function loadAnswers(pid) { + const d = await api('data', { projectId: pid }); + 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 || ''), + })); + } + + /* ---------------- 候选池 ---------------- */ + 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); + 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 = '' + + values.map(v => '').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 renderStats() { + const pool = currentPool(); + $('#dPool').textContent = pool.length; + $('#dPicked').textContent = state.picked.length; + $('#dHistory').textContent = state.history.size; + $('#dSource').textContent = state.source === 'project' ? '已提交人员' : '学号名单'; + + const cls = $('#clsSel').value, grp = $('#grpSel').value; + const parts = []; + if (cls) parts.push(cls); + if (grp) parts.push(grp + '组'); + $('#drawHint').textContent = '候选 ' + pool.length + ' 人' + + (parts.length ? '(' + parts.join(' / ') + ')' : ''); + } + + /* ---------------- 抽取 ---------------- */ + 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; + } + + 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); + } + + const 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(); + saveState(); + + state.drawing = false; + btn.disabled = false; + showAlert('已抽取 ' + n + ' 人' + (n > 1 ? ',可点击右侧按钮下载白名单' : ''), 'info'); + } + + function renderResult() { + const tb = $('#drawBody'); + if (!state.picked.length) { + tb.innerHTML = '尚未抽取'; + return; + } + tb.innerHTML = state.picked.map((m, i) => + '' + + '' + (i + 1) + '' + + '' + esc(m.sid) + '' + + '' + esc(m.name) + '' + + '' + esc(m.cls || '') + '' + + '' + esc(m.grp || '') + '' + + '').join(''); + } + + /* ---------------- 下载 ---------------- */ + 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: state.picked.map(m => ({ name: m.name, sid: m.sid })), + }), + }); + 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 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, + })); + } catch (e) { /* 忽略 */ } + } + + function loadState() { + try { + const p = JSON.parse(localStorage.getItem(LS_RESULT) || '[]'); + if (Array.isArray(p)) state.picked = p; + const h = JSON.parse(localStorage.getItem(LS_HISTORY) || '[]'); + if (Array.isArray(h)) state.history = new Set(h.map(String)); + } catch (e) { /* 忽略 */ } + } + + 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(); + 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(); + saveSettings(); + }); + + ['#clsSel', '#grpSel'].forEach(id => { + $(id).addEventListener('change', () => { renderStats(); saveSettings(); }); + }); + $('#numInput').addEventListener('change', saveSettings); + $('#excludePicked').addEventListener('change', () => { renderStats(); 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 = []; + renderResult(); + renderStats(); + saveState(); + const stage = $('#drawStage'); + stage.className = 'draw-stage'; + stage.textContent = '点击「🎲 开始抽取」'; + showAlert(''); + }); + $('#btnClearHistory').addEventListener('click', () => { + state.history = new Set(); + state.picked = []; + renderResult(); + renderStats(); + saveState(); + showAlert('已清空累计抽取记录', 'info'); + }); + $('#btnDlImport').addEventListener('click', () => download('import')); + $('#btnDlList').addEventListener('click', () => download('list')); + } + + /* ---------------- 启动 ---------------- */ + document.addEventListener('DOMContentLoaded', async function () { + loadState(); + const s = loadSettings(); + if (s.exclude === false) $('#excludePicked').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(); + + 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(); + }); +})(); diff --git a/assets/style.css b/assets/style.css index 4dcc110..e40e970 100644 --- a/assets/style.css +++ b/assets/style.css @@ -585,3 +585,36 @@ 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; } +} diff --git a/bootstrap.php b/bootstrap.php index bcebf84..65189ec 100644 --- a/bootstrap.php +++ b/bootstrap.php @@ -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', diff --git a/draw.php b/draw.php new file mode 100644 index 0000000..9f8e4f1 --- /dev/null +++ b/draw.php @@ -0,0 +1,125 @@ + + + + + + +随机抽取 · <?= htmlspecialchars(NY_APP_NAME) ?> + + + + +
+
+ +
+ 随机抽取 + 名单 / 已提交人员 → 卷王白名单 +
+
+
+ 名单管理 + + ← 返回主系统 + 退出 +
+
+ +
+ +
+
+ + +
+ +
+ + +
+
+ + +
+
+ +
+ + + + + +
+
+
+ + + +
+
+ + + +
+
候选人数-
+
本次抽取-
+
累计已抽取-
+
抽取来源学号名单
+
+ +
+
+

抽取结果

+
+ + +
+
+ +
点击「🎲 开始抽取」
+ +
+ + + + + + + + + + + +
序号学号姓名班级组别
尚未抽取
+
+

两个下载均为两列「姓名 / 登陆账号」(登陆账号取名单中的学号,按文本格式保存,长学号不会变成科学计数法),可直接导入卷王白名单。

+
+
+ + + + diff --git a/index.php b/index.php index a4f5c5c..4ca8b30 100644 --- a/index.php +++ b/index.php @@ -35,6 +35,7 @@ $rosterCount = Roster::count();
未连接
📊 数据看板 + 🎲 随机抽取 diff --git a/lib/XlsxWriter.php b/lib/XlsxWriter.php new file mode 100644 index 0000000..d51d95d --- /dev/null +++ b/lib/XlsxWriter.php @@ -0,0 +1,315 @@ + 共享字符串 => 索引 */ + private array $strIndex = []; + /** @var array 索引 => 共享字符串 */ + private array $strings = []; + /** @var array> 行数据 */ + private array $rows = []; + /** @var array 列宽(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 .= ''; + } + + $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 .= '' . + $cells[$r][$c] . ''; + } + if (!$has) { + continue; + } + $data .= '' . $line . ''; + } + + return '' . "\n" . + '' . + '' . + '' . + '' . + ($cols !== '' ? '' . $cols . '' : '') . + '' . $data . '' . + ''; + } + + private function contentTypesXml(): string + { + return '' . "\n" . + '' . + '' . + '' . + '' . + '' . + '' . + '' . + '' . + '' . + ''; + } + + private function rootRelsXml(): string + { + return '' . "\n" . + '' . + '' . + '' . + '' . + ''; + } + + private function workbookXml(string $title): string + { + return '' . "\n" . + '' . + '' . + ''; + } + + private function workbookRelsXml(): string + { + return '' . "\n" . + '' . + '' . + '' . + '' . + ''; + } + + private function stylesXml(): string + { + return '' . "\n" . + '' . + // numFmts: 49 = @(文本) + '' . + '' . + '' . + '' . + '' . + '' . + '' . + '' . + '' . + '' . + '' . // 0 常规 + '' . // 1 表头 + '' . // 2 文本 + '' . + '' . + ''; + } + + private function sharedStringsXml(): string + { + $count = count($this->strings); + $body = ''; + foreach ($this->strings as $s) { + $body .= '' . self::xml((string)$s) . ''; + } + return '' . "\n" . + '' . $body . ''; + } + + private function coreXml(string $title): string + { + return '' . "\n" . + '' . + '' . self::xml($title) . '' . + 'NY问卷考试系统数据平台' . + 'NY问卷考试系统数据平台' . + '' . gmdate('Y-m-d\TH:i:s\Z') . '' . + '' . gmdate('Y-m-d\TH:i:s\Z') . '' . + ''; + } + + private function appXml(): string + { + return '' . "\n" . + '' . + 'NY Exam Platform'; + } + + /* ---------------- 内部:打包 ---------------- */ + + /** @param array $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 $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'; + } +} diff --git a/tools/test-xlsxwriter.php b/tools/test-xlsxwriter.php new file mode 100644 index 0000000..6d80d07 --- /dev/null +++ b/tools/test-xlsxwriter.php @@ -0,0 +1,104 @@ +', '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);