feat(export/dashboard): 导出增强与大屏公开化

导出-每次考试/多考试对比:
- 合格次数阈值等标记项抽为常显区块,三类成绩表共用
- 附缺考名单扩展到多考试导出(取所选考试全都没交的人)
- 块表每场固定2列(成绩+合并空位),修复漏推空串导致的整行左移/串列
- 列宽按实际内容估算,合并区空位只给最小宽度
- 合格次数始终显示实际次数(含0),仅 ≥ 阈值标绿底
- 打印标题改为「成绩明细」,缺考名单分页单独成页
- 打印样式加 print-color-adjust:exact,绿底/红字可正常印出

数据大屏:
- ?class= 班级大屏免登录公开访问,接口侧放行并按班级裁剪后回传
- 单班大屏移除退出按钮,仅首页保留
- 修复红绿判定真bug:membersTable 误用 window.DASH(DASH为let声明取不到)
  改为 scoreGrade() 直接读自由变量,按后台合格率正确判红/黄/绿

测试/样例/文档同步更新,全量回归通过。
This commit is contained in:
2026-09-27 22:04:17 +08:00
parent 0ba9c7c33c
commit d05fd4a9b5
34 changed files with 4290 additions and 305 deletions
+467 -85
View File
@@ -1332,50 +1332,147 @@
showAlert('已清除新数据高亮', 'info');
});
/* 导出排序切换(即时保存到服务端配置) */
$('#exportSortSel').addEventListener('change', async () => {
state.exportSort = $('#exportSortSel').value;
if ($('#cfgExportSort')) $('#cfgExportSort').value = state.exportSort;
/* 导出排序文案(统一导出弹窗用) */
function exportSortLabel(sort) {
return (sort === 'sid') ? '按学号'
: (sort === 'score_desc') ? '按成绩降序'
: (sort === 'score_asc') ? '按成绩升序'
: '按提交时间';
}
function currentExportSort() {
const el = $('#exSort') || $('#exportSortSel');
const sort = el ? el.value : 'time';
return (sort === 'sid') ? 'sid' : ((sort === 'score_desc') ? 'score_desc' : ((sort === 'score_asc') ? 'score_asc' : 'time'));
}
/* ============================================================================
统一导出 / 打印弹窗
过去工具栏有一排导出按钮(导出成绩 / 每次考试 / 缺考 / 多考试导出 / 多考试打印),
按钮太多、口径分散。现在统一成一个「导出」按钮 + 一个弹窗:
1. 导出内容(成绩明细 / 每次考试 / 缺考名单 / 多考试对比)
2. 统计范围(班级 / 组别多选)
3. 成绩口径与列(最新 / 最高、排序、每人一行 / 每次一行、分表、附缺考)
4. 多考试对比(形态、对比成绩、合格次数阈值、标红 / 标绿、勾选考试)
5. 打印设置(纸张方向、标题)
弹窗里的选择会记住(localStorage),下次打开沿用。
========================================================================== */
const EX_LS_KEY = 'ny.export.opts.v1';
/** 读取上次的导出配置(异常 / 无存储时给默认值) */
function exLoadOpts() {
const def = {
type: 'detail', cls: [], grp: [], rowScore: 'latest', sort: state.exportSort || 'time',
detailMode: 'latest', split: false, withAbsent: true, multiMode: 'block',
multiScore: 'latest', passTimes: 1, markBest: true, passRow: true, orient: 'landscape',
printTitle: '',
};
try {
await api('config_save', { method: 'POST', body: { export_sort: state.exportSort } });
} catch (e) { /* 静默:本地已生效 */ }
});
const raw = localStorage.getItem(EX_LS_KEY);
if (!raw) return def;
const o = JSON.parse(raw);
return Object.assign(def, (o && typeof o === 'object') ? o : {});
} catch (e) { return def; }
}
function exSaveOpts(o) {
try { localStorage.setItem(EX_LS_KEY, JSON.stringify(o)); } catch (e) { /* 隐私模式:忽略 */ }
}
let exOpts = exLoadOpts();
// 导出跟随当前班级 / 组别筛选(统计卡片与表格显示什么,就导出什么)
$('#btnExport').addEventListener('click', () => {
const pid = $('#projectSel').value;
if (!pid) return showAlert('请先选择问卷 / 考试');
const cls = $('#clsSel').value, grp = $('#grpSel').value;
const sort = (state.exportSort === 'sid') ? 'sid' : ((state.exportSort === 'score_desc') ? 'score_desc' : ((state.exportSort === 'score_asc') ? 'score_asc' : 'time'));
// 导出提示文案(与排序方式对应,让用户看清"这次按什么排")
const sortLabel = (sort === 'sid') ? '按学号'
: (sort === 'score_desc') ? '按成绩降序'
: (sort === 'score_asc') ? '按成绩升序'
: '按提交时间';
const latest = ($('#exportLatest') && $('#exportLatest').checked) ? 'latest' : 'all';
const split = ($('#exportSplit') && $('#exportSplit').checked) ? 'cls' : '';
const qs = 'api.php?action=export&projectId=' + encodeURIComponent(pid) +
'&nameQid=' + encodeURIComponent($('#nameFieldSel').value) +
'&cls=' + encodeURIComponent(cls) + '&grp=' + encodeURIComponent(grp) +
'&sort=' + sort + '&mode=' + latest + (split ? '&split=' + split : '');
location.href = qs;
const modeLabel = (latest === 'latest') ? '(每人一行·最新成绩)' : '(每次考试一行)';
const splitLabel = (split === 'cls') ? ',按班级分表' : '';
showAlert(exportTip('成绩明细', cls, grp) + ',排序:' + sortLabel + splitLabel + modeLabel, '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) +
'&cls=' + encodeURIComponent(cls) + '&grp=' + encodeURIComponent(grp);
showAlert(exportTip('缺考名单', cls, grp), 'info');
});
/** 把当前弹窗里的选择收集成对象(导出 / 打印都基于它拼查询串) */
function exCollect() {
return {
type: (document.querySelector('input[name=exType]:checked') || {}).value || 'detail',
cls: exSelVals($('#exClsSel')),
grp: exSelVals($('#exGrpSel')),
rowScore: $('#exRowScore').value,
sort: $('#exSort').value,
detailMode: $('#exDetailMode').value,
split: $('#exSplit').checked,
withAbsent: $('#exWithAbsent').checked,
multiMode: $('#exMultiMode').value,
multiScore: $('#exMultiScore').value,
passTimes: Math.max(1, parseInt($('#exPassTimes').value, 10) || 1),
markBest: $('#exMarkBest').checked,
passRow: $('#exPassRow').checked,
orient: $('#exOrient').value,
printTitle: $('#exPrintTitle').value.trim(),
};
}
/** 把对象写回弹窗控件 */
function exApply(o) {
const t = document.querySelector('input[name=exType][value="' + (o.type || 'detail') + '"]');
if (t) t.checked = true;
exSetSel($('#exClsSel'), o.cls || []);
exFillGroups(o.grp || []);
if ($('#exRowScore')) $('#exRowScore').value = o.rowScore || 'latest';
if ($('#exSort')) $('#exSort').value = o.sort || 'time';
if ($('#exDetailMode'))$('#exDetailMode').value= o.detailMode || 'latest';
if ($('#exSplit')) $('#exSplit').checked = !!o.split;
if ($('#exWithAbsent'))$('#exWithAbsent').checked = o.withAbsent !== false;
if ($('#exMultiMode')) $('#exMultiMode').value = o.multiMode || 'block';
if ($('#exMultiScore'))$('#exMultiScore').value= o.multiScore || 'latest';
if ($('#exPassTimes')) $('#exPassTimes').value = o.passTimes || 1;
if ($('#exMarkBest')) $('#exMarkBest').checked = o.markBest !== false;
if ($('#exPassRow')) $('#exPassRow').checked = o.passRow !== false;
if ($('#exOrient')) $('#exOrient').value = o.orient || 'landscape';
if ($('#exPrintTitle'))$('#exPrintTitle').value= o.printTitle || '';
}
/* ---------------- 多考试对比导出 ---------------- */
// 仅列出真正的"考试"(排除文件夹型项目,folder 或被其他项目作为父级引用)
function nonFolderProjects() {
/* ---------------- 班级 / 组别多选(不选 = 全部) ---------------- */
function exSelVals(sel) {
if (!sel) return [];
return Array.prototype.slice.call(sel.options).filter(o => o.selected).map(o => o.value).filter(Boolean);
}
function exSetSel(sel, vals) {
if (!sel) return;
const want = vals || [];
Array.prototype.slice.call(sel.options).forEach(o => { o.selected = want.indexOf(o.value) >= 0; });
}
function exFillGroups(keep) {
const grpSel = $('#exGrpSel');
if (!grpSel) return;
const clsPicked = exSelVals($('#exClsSel'));
const pool = (state.roster || []).filter(s =>
clsPicked.length === 0 || clsPicked.indexOf(s.cls || '') >= 0);
const vals = Array.from(new Set(pool.map(s => s.grp).filter(Boolean)))
.sort((a, b) => a.localeCompare(b, 'zh', { numeric: true }));
grpSel.innerHTML = vals.map(v => '<option value="' + esc(v) + '">' + esc(v) + '</option>').join('');
// 组别随班级联动后,原选择里仍存在的继续选中
exSetSel(grpSel, (keep || []).filter(v => vals.indexOf(v) >= 0));
}
let exLastMain = null; // 上次打开时的主界面筛选(用于判断是否要跟随)
function exFillFilters(forceFollow) {
const clsSel = $('#exClsSel');
if (!clsSel) return;
const keepCls = exSelVals(clsSel);
const keepGrp = exSelVals($('#exGrpSel'));
const vals = Array.from(new Set((state.roster || []).map(s => s.cls).filter(Boolean)))
.sort((a, b) => a.localeCompare(b, 'zh', { numeric: true }));
clsSel.innerHTML = vals.map(v => '<option value="' + esc(v) + '">' + esc(v) + '</option>').join('');
// 首次打开、或主界面筛选被改过 → 跟随主界面;否则保留弹窗里上次的选择
const mainCls = $('#clsSel') ? $('#clsSel').value : '';
const mainGrp = $('#grpSel') ? $('#grpSel').value : '';
const mainKey = mainCls + '\u0000' + mainGrp;
const follow = forceFollow || (mainKey !== exLastMain);
exLastMain = mainKey;
if (follow) {
exSetSel(clsSel, mainCls ? [mainCls] : []);
exFillGroups(mainGrp ? [mainGrp] : []);
} else {
exSetSel(clsSel, keepCls.filter(v => vals.indexOf(v) >= 0));
exFillGroups(keepGrp);
}
const hint = $('#exScopeHint');
if (hint) {
hint.textContent = vals.length
? '不选 = 全部(Ctrl / ⌘ 多选)'
: '名单未填写班级(可到「名单管理」补全)';
}
}
/* ---------------- 多考试勾选列表 ---------------- */
function exNonFolderProjects() {
const isFolder = p => (p.mode || '').toLowerCase().includes('folder');
const parentIds = new Set();
state.projects.forEach(p => {
@@ -1386,9 +1483,10 @@
state.projects.forEach(p => { if (isFolder(p) || parentIds.has(p.id)) folderSet.add(p.id); });
return state.projects.filter(p => !folderSet.has(p.id));
}
function renderMultiExportModal() {
const body = $('#meBody');
const list = nonFolderProjects();
function exRenderExamList() {
const body = $('#exExamBody');
if (!body) return;
const list = exNonFolderProjects();
body.innerHTML = '';
if (!list.length) {
body.innerHTML = '<tr><td colspan="3" class="empty">(暂无可导出的考试,请先在右上角刷新问卷列表)</td></tr>';
@@ -1398,54 +1496,323 @@
const tr = document.createElement('tr');
const checked = (p.id === cur) ? ' checked' : '';
tr.innerHTML =
'<td><input type="checkbox" class="me-chk" value="' + esc(p.id) + '"' + checked + '></td>' +
'<td><input type="checkbox" class="ex-chk" value="' + esc(p.id) + '"' + checked + '></td>' +
'<td>' + (i + 1) + '</td>' +
'<td>' + esc(p.name) + '</td>';
body.appendChild(tr);
});
}
updMeMeta();
exUpdPickCount();
}
function updMeMeta() {
const n = $$('#meBody .me-chk').filter(c => c.checked).length;
$('#meMeta').textContent = '已选 ' + n + ' 场';
const all = $$('#meBody .me-chk');
$('#meCheckAll').checked = all.length > 0 && all.every(c => c.checked);
function exPickedIds() {
return $$('#exExamBody .ex-chk').filter(c => c.checked).map(c => c.value);
}
$('#btnExportMulti').addEventListener('click', () => {
if (!state.projects.length) return showAlert('请先刷新问卷列表');
renderMultiExportModal();
$('#multiExportModal').classList.add('open');
function exUpdPickCount() {
const all = $$('#exExamBody .ex-chk');
const n = all.filter(c => c.checked).length;
const el = $('#exPickCount');
if (el) el.textContent = '已选 ' + n + ' 场';
if ($('#exCheckAll')) $('#exCheckAll').checked = all.length > 0 && all.every(c => c.checked);
exSyncSummary();
}
/* ---------------- 分区显隐 + 汇总提示 ---------------- */
function exType() {
const el = document.querySelector('input[name=exType]:checked');
return (el && el.value) || 'detail';
}
function exScopeText(o) {
return [
(o.cls && o.cls.length) ? ('班级 ' + o.cls.join('/')) : '全部班级',
(o.grp && o.grp.length) ? ('组别 ' + o.grp.join('/')) : '全部组别',
].join(' · ');
}
const EX_DETAIL_HINTS = {
latest: '每人一行(汇总):可用「每人成绩」切换最新 / 最高成绩;可附缺考名单、按班级分工作表。',
all: '每次考试一行(明细):保留每位学生的历史提交;「每人成绩」此时不生效。',
};
function exSyncSummary() {
const o = exCollect();
const t = o.type;
// 分区显隐:只有相关区块才露出来,避免满屏选项
if ($('#exDetailSec')) $('#exDetailSec').hidden = (t !== 'detail' && t !== 'attempts');
if ($('#exMultiSec')) $('#exMultiSec').hidden = (t !== 'multi');
// 标记与合格次数:成绩明细 / 每次考试 / 多考试对比都要用(缺考名单不需要)
if ($('#exMarkSec')) $('#exMarkSec').hidden = (t === 'absent');
// 附缺考名单:三类都会出工作表,缺考名单本身不需要再附
if ($('#exExtraSec')) $('#exExtraSec').hidden = (t === 'absent');
if ($('#exPrintSec')) $('#exPrintSec').hidden = false;
// 成绩明细:每次考试一行时「每人成绩」不生效
const isAll = (o.detailMode === 'all');
if ($('#exRowScore')) $('#exRowScore').disabled = isAll;
// 「按班级分工作表」只有单考试的成绩明细支持;「附缺考名单」三类都支持
if ($('#exSplit')) {
$('#exSplit').disabled = (t !== 'detail');
$('#exSplit').parentElement.style.opacity = (t === 'detail') ? '' : '.45';
}
if ($('#exDetailHint')) $('#exDetailHint').textContent = EX_DETAIL_HINTS[o.detailMode] || '';
if ($('#exScopeSec')) $('#exScopeSec').hidden = false;
if ($('#exScopeSum')) $('#exScopeSum').textContent = '· ' + exScopeText(o);
// 打印标题占位随类型变化,让用户知道默认会是什么
if ($('#exPrintTitle')) {
$('#exPrintTitle').placeholder = (t === 'multi') ? '留空 = 多考试成绩明细'
: (t === 'absent') ? '留空 = 缺考名单'
: (t === 'attempts') ? '留空 = 每次考试成绩明细'
: '留空 = 成绩明细';
}
// 底部一句话汇总,导出 / 打印前一眼看清会得到什么
const scope = exScopeText(o);
let line = '';
if (t === 'detail') {
line = '将导出「成绩明细」· ' + (o.detailMode === 'all' ? '每次考试一行' : '每人一行(' + (o.rowScore === 'best' ? '最高成绩' : '最新成绩') + ')')
+ ' · ' + exportSortLabel(o.sort) + (o.split ? ' · 按班级分表' : '') + ' · ' + scope;
} else if (t === 'attempts') {
line = '将导出「每次考试」· 最高分标红' + (o.passRow ? '、合格次数达标标绿' : '') + ' · ' + scope;
} else if (t === 'absent') {
line = '将导出「缺考名单」· ' + scope;
} else {
const n = exPickedIds().length;
const mode = (o.multiMode === 'block') ? '两行表头(每次考试 + 合格次数)'
: (o.multiMode === 'summary') ? '成绩对比(每人一行)' : '两行表头 + 成绩对比';
line = '将导出「多考试对比」· 已选 ' + n + ' 场 · ' + mode
+ ' · 最高分标红' + (o.passRow ? '、合格次数达标标绿' : '') + ' · ' + scope;
}
if ($('#exSummaryLine')) $('#exSummaryLine').textContent = line;
const btn = $('#btnExExport');
if (btn) btn.disabled = (t === 'multi' && exPickedIds().length === 0);
}
/** 打开统一导出弹窗 */
function openExportModal() {
// 清单(班级 / 组别 / 考试列表)每次都重建:名单可能刚导入过
exFillFilters(true);
exRenderExamList();
exApply(exOpts);
// 应用后再联动一次组别(班级选择会影响组别候选)
exFillGroups(exOpts.grp || []);
exSyncSummary();
$('#exportModal').classList.add('open');
}
$('#btnExportOpen').addEventListener('click', openExportModal);
// 弹窗内的任何改动都:记住 + 刷新分区与汇总
$('#exportModal').addEventListener('change', () => {
exOpts = exCollect();
exSaveOpts(exOpts);
exSyncSummary();
});
$('#meCheckAll').addEventListener('change', () => {
$$('#meBody .me-chk').forEach(c => { c.checked = $('#meCheckAll').checked; });
updMeMeta();
$('#exClsSel').addEventListener('change', () => {
exFillGroups(exSelVals($('#exGrpSel')));
exOpts = exCollect();
exSaveOpts(exOpts);
exSyncSummary();
});
$('#btnMeSelAll').addEventListener('click', () => {
$$('#meBody .me-chk').forEach(c => { c.checked = true; });
$('#meCheckAll').checked = true;
updMeMeta();
$('#exCheckAll').addEventListener('change', () => {
$$('#exExamBody .ex-chk').forEach(c => { c.checked = $('#exCheckAll').checked; });
exUpdPickCount();
});
$('#btnMeClear').addEventListener('click', () => {
$$('#meBody .me-chk').forEach(c => { c.checked = false; });
$('#meCheckAll').checked = false;
updMeMeta();
$('#btnExPickAll').addEventListener('click', () => {
$$('#exExamBody .ex-chk').forEach(c => { c.checked = true; });
exUpdPickCount();
});
$('#meBody').addEventListener('change', e => {
if (e.target && e.target.classList.contains('me-chk')) updMeMeta();
$('#btnExPickNone').addEventListener('click', () => {
$$('#exExamBody .ex-chk').forEach(c => { c.checked = false; });
exUpdPickCount();
});
$('#btnMeExport').addEventListener('click', () => {
const ids = $$('#meBody .me-chk').filter(c => c.checked).map(c => c.value);
if (!ids.length) return showAlert('请至少勾选一场考试');
const scoreMode = (document.querySelector('input[name=meScoreMode]:checked') || {}).value || 'latest';
const qs = 'api.php?action=export_multi&projectIds=' + encodeURIComponent(ids.join(',')) +
'&nameQid=' + encodeURIComponent($('#nameFieldSel').value) +
'&scoreMode=' + encodeURIComponent(scoreMode) + '&sort=sid';
$('#multiExportModal').classList.remove('open');
$('#exExamBody').addEventListener('change', e => {
if (e.target && e.target.classList.contains('ex-chk')) exUpdPickCount();
});
/* ---------------- 拼查询串(导出与打印共用同一套口径) ---------------- */
function exBuildQuery(o, isPrint) {
const nameQid = encodeURIComponent($('#nameFieldSel').value);
const cls = encodeURIComponent((o.cls || []).join(','));
const grp = encodeURIComponent((o.grp || []).join(','));
const base = 'api.php?';
if (o.type === 'absent') {
// 缺考名单:走单考试导出接口的 export_absent(只输出缺考名单一张表)
const pid = $('#projectSel').value;
if (!pid) return null;
return base + 'action=export_absent&projectId=' + encodeURIComponent(pid) +
'&nameQid=' + nameQid + '&cls=' + cls + '&grp=' + grp +
(isPrint ? '&format=json' : '');
}
if (o.type === 'detail') {
const pid = $('#projectSel').value;
if (!pid) return null;
let q = base + 'action=export&projectId=' + encodeURIComponent(pid) +
'&nameQid=' + nameQid + '&cls=' + cls + '&grp=' + grp +
'&sort=' + o.sort + '&mode=' + (o.detailMode === 'all' ? 'all' : 'latest');
if (o.detailMode === 'latest' && o.rowScore === 'best') q += '&scoreMode=best';
if (o.split) q += '&split=cls';
if (!o.withAbsent) q += '&noAbsent=1';
// 打印要拿 rows/merges/cell_styles 渲染视图,不能去下载 xlsx
if (isPrint) q += '&format=json';
return q;
}
// attempts(单场每次考试)与 multi(多考试对比)都走 export_multi
let ids;
if (o.type === 'attempts') {
const pid = $('#projectSel').value;
if (!pid) return null;
ids = [pid];
} else {
ids = exPickedIds();
if (!ids.length) return null;
}
const mode = (o.type === 'attempts') ? 'block' : o.multiMode;
let q = base + 'action=export_multi&projectIds=' + encodeURIComponent(ids.join(',')) +
'&nameQid=' + nameQid + '&mode=' + mode + '&sort=sid' +
'&cls=' + cls + '&grp=' + grp +
'&passTimes=' + (o.passTimes || 1) +
(o.markBest ? '' : '&markBest=0') +
(o.passRow ? '' : '&passRow=0');
// 附缺考名单:与成绩明细同一开关,后端在所选考试全都没交的人里筛
if (!o.withAbsent) q += '&noAbsent=1';
if (mode !== 'block') {
q += '&scoreMode=' + encodeURIComponent(o.multiScore); // 仅成绩对比表需要
}
// 单场「每次考试」且不带耗时 / 提交时间列 → 用宽表版式(截图二)
if (o.type === 'attempts' && ids.length === 1) {
q += '&wide=1';
}
if (isPrint) q += '&format=json';
return q;
}
function exTypeName(o) {
if (o.type === 'absent') return '缺考名单';
if (o.type === 'attempts') return '「每次考试」表';
if (o.type === 'multi') return '多考试对比表';
return '成绩明细';
}
/* ---------------- 导出 Excel ---------------- */
$('#btnExExport').addEventListener('click', () => {
const o = exCollect();
if (o.type === 'multi' && !exPickedIds().length) return showAlert('请至少勾选一场考试');
if (o.type !== 'multi' && !$('#projectSel').value) return showAlert('请先选择问卷 / 考试');
const qs = exBuildQuery(o, false);
if (!qs) return showAlert('请先选择问卷 / 考试');
$('#exportModal').classList.remove('open');
location.href = qs;
showAlert('正在导出 ' + ids.length + ' 场考试成绩对比(' + (scoreMode === 'highest' ? '最高成绩' : '最新成绩') + ',按学号排序)', 'info');
showAlert('正在导出' + exTypeName(o) + '(' + exScopeText(o) + ')', 'info');
});
/* ---------------- 打印 ----------------
与导出的 Excel 用同一套服务端装配逻辑(format=json 拿回 rows/merges/cell_styles),
前端只负责把「合并区间」翻成 colspan / rowspan、把「cell_styles」翻成红字 / 绿底,
保证打印版式与 Excel 完全一致。 */
function sheetToHtml(sheet) {
const rows = (sheet && sheet.rows) || [];
const merges = (sheet && sheet.merges) || [];
const styles = (sheet && sheet.cell_styles) || {};
const hRows = Math.max(1, (sheet && sheet.header_rows) || 1);
const span = {}, covered = {};
merges.forEach(m => {
const r1 = m[0] - 1, c1 = m[1] - 1, r2 = m[2] - 1, c2 = m[3] - 1;
span[r1 + '|' + c1] = [r2 - r1 + 1, c2 - c1 + 1];
for (let r = r1; r <= r2; r++) {
for (let c = c1; c <= c2; c++) {
if (r === r1 && c === c1) continue;
covered[r + '|' + c] = 1;
}
}
});
// 合并区间内的其它格子继承左上角首格的标记(否则绿底只覆盖半格)
const markOf = (r, c) => {
const k = (r + 1) + ',' + (c + 1);
if (styles[k]) return styles[k];
const m = merges.find(mm =>
r >= mm[0] - 1 && r <= mm[2] - 1 && c >= mm[1] - 1 && c <= mm[3] - 1);
return m ? (styles[(m[0]) + ',' + (m[1])] || '') : '';
};
let thead = '', tbody = '';
rows.forEach((row, r) => {
let tds = '';
for (let c = 0; c < row.length; c++) {
if (covered[r + '|' + c]) continue; // 被合并吃掉的格子不再输出
const sp = span[r + '|' + c] || [1, 1];
const txt = (row[c] == null) ? '' : String(row[c]);
const mark = markOf(r, c);
const cls = (mark === 'red') ? ' class="max"'
: (mark === 'green') ? ' class="pass"'
: (txt === '未考') ? ' class="absent"' : '';
tds += '<td' +
(sp[0] > 1 ? ' rowspan="' + sp[0] + '"' : '') +
(sp[1] > 1 ? ' colspan="' + sp[1] + '"' : '') +
cls + '>' + esc(txt) + '</td>';
}
const tr = '<tr>' + tds + '</tr>';
if (r < hRows) thead += tr; else tbody += tr;
});
// 表头放进 <thead>:多页打印时每页自动重复
return '<table class="bt"><thead>' + thead + '</thead><tbody>' + tbody + '</tbody></table>';
}
async function exPrint() {
const o = exCollect();
if (o.type === 'multi' && !exPickedIds().length) return showAlert('请至少勾选一场考试');
const qs = exBuildQuery(o, true);
if (!qs) return showAlert('请先选择问卷 / 考试');
const btn = $('#btnExPrint');
const oldText = btn.textContent;
btn.disabled = true;
btn.textContent = '准备中…';
try {
const res = await fetch(qs, { credentials: 'same-origin' });
const d = await res.json();
if (!d || !d.ok) throw new Error((d && d.msg) || '获取打印数据失败');
const data = d.data || {};
const sheets = data.sheets || [];
const meta = data.meta || {};
if (!sheets.length) throw new Error('没有可打印的数据');
const title = (o.printTitle || '') ||
(o.type === 'multi' ? '多考试成绩明细'
: (o.type === 'attempts' ? (state.projectName ? state.projectName + ' 成绩明细' : '成绩明细')
: (o.type === 'absent' ? (state.projectName ? state.projectName + ' 缺考名单' : '缺考名单')
: (state.projectName ? state.projectName + ' 成绩明细' : '成绩明细'))));
const markNote = o.type === 'detail' && o.detailMode !== 'all' && meta.passLine
? '(成绩 ≥ ' + meta.passLine + ' 标绿)'
: (meta.pass_row ? ('合格次数 ≥ ' + (meta.pass_times || 1) + ' 次达标(标绿)') : '');
let body = '<h2>' + esc(title) + '</h2>' +
(o.type === 'multi'
? '<div class="sub">' + esc((meta.examNames || []).join(' · ')) + '</div>'
: '') +
'<div class="meta">' + esc([
o.type === 'multi' ? ('考试 ' + (meta.examCount || (meta.examNames || []).length) + ' 场') : '',
'范围:' + exScopeText(o),
markNote,
'打印时间:' + new Date().toLocaleString(),
].filter(Boolean).join(' | ')) + '</div>';
// 多张表(如成绩明细 + 附缺考名单)逐张出小标题再接表格。
// 「每次考试」主表跟随大标题,不再重复写「每次考试」;缺考名单另起一页。
sheets.forEach((sh, i) => {
const isMainAttempt = (o.type === 'attempts' && i === 0);
const showGrp = (sheets.length > 1 || isMainAttempt) && sh.name !== '每次考试';
if (showGrp) {
const pb = (sh.name === '缺考名单') ? ' page-break' : '';
body += '<h3 class="grp' + pb + '">' + esc(sh.name) + '</h3>';
}
body += sheetToHtml(sh);
});
printDoc(title, body, { orient: o.orient });
$('#exportModal').classList.remove('open');
showAlert('已调起打印(' + (sheets.length > 1 ? sheets.length + ' 张表 · ' : '')
+ (o.orient === 'portrait' ? '纵向' : '横向')
+ '),如未弹出请在打印对话框中选择正确打印机', 'info');
} catch (err) {
showAlert(err && err.message ? err.message : '打印失败', 'error');
} finally {
btn.disabled = false;
btn.textContent = oldText;
}
}
$('#btnExPrint').addEventListener('click', exPrint);
/* 弹窗关闭:只关最上层那一个(新增学生等弹窗是叠在名单管理之上的,
关全部会连带把底层弹窗一起关掉,不符合预期) */
function closeTopModal() {
@@ -1744,6 +2111,9 @@
const css =
'@page{size:A4 ' + orient + ';margin:14mm 10mm}' +
'html,body{margin:0;padding:0}' +
/* 底纹能不能印出来全看这一句:浏览器默认不打印背景色(打印对话框里的
"背景图形" 多数人是关着的),print-color-adjust:exact 才是让它生效的开关。 */
'html,body,.pass,.absent,.max{-webkit-print-color-adjust:exact;print-color-adjust:exact}' +
'body{font-family:"Microsoft YaHei","微软雅黑",sans-serif;color:#000;font-size:12px;line-height:1.5}' +
'h2{margin:0 0 6px;font-size:17px;text-align:center}' +
'.sub{text-align:center;font-size:12px;color:#444;margin-bottom:4px}' +
@@ -1756,10 +2126,20 @@
'td.c,th.c{text-align:center}' +
'td.score{font-weight:700}' +
'td.fail{color:#b00}' +
// 多考试 / 每次考试表(两行表头 + 合并单元格):居中;未考标灰、
// 最高分红字、合格次数达标绿底(与导出的 Excel 完全同源)
'table.bt{font-size:11px}' +
'table.bt td,table.bt th{text-align:center;padding:3px 5px}' +
'table.bt td.absent{color:#888}' +
'table.bt td.max{color:#d0021b;font-weight:700}' +
'table.bt td.pass{background:#63c384;font-weight:700;-webkit-print-color-adjust:exact;print-color-adjust:exact}' +
// 表头渲染出来是 <td>(不是 <th>),所以底色要写 thead td,否则两行表头是白的
'table.bt th,table.bt thead td{background:#eaeaea;font-weight:700}' +
'table.bt thead tr:first-child td{background:#eaeaea}' +
'h3.grp{font-size:13px;margin:14px 0 5px;padding:5px 8px;background:#f2f2f2;border-left:4px solid #333;page-break-after:avoid}' +
'h3.grp small{font-weight:400;color:#555}' +
'.sign{margin-top:16px;font-size:12px;color:#333;page-break-inside:avoid}' +
'.foot{margin-top:8px;font-size:10px;color:#777;text-align:center}';
// 缺考名单等附加工作表另起一页,避免和成绩表挤在同一页底部
'h3.grp.page-break{page-break-before:always}';
const html = '<!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8">' +
'<title>' + esc(title) + '</title><style>' + css + '</style></head><body>' + bodyHtml + '</body></html>';
@@ -1831,9 +2211,16 @@
function printTable() {
const scope = $('#ptScope').value;
const rowScore = ($('#ptRowScore') && $('#ptRowScore').value === 'best') ? 'best' : 'latest';
let rows = (scope === 'all')
? expandAttemptsForPrint(state._sorted || [], true)
: (state._sorted || []).slice();
// 每人一行时可按「最高成绩」打印:用 best_score 顶替代表成绩
if (scope !== 'all' && rowScore === 'best') {
rows = rows.map(r => Object.assign({}, r, {
score: (r.best_score == null || r.best_score === '') ? null : Number(r.best_score),
}));
}
if (!rows.length) return showAlert('当前没有可打印的成绩', 'warn');
const sortMode = $('#ptSort').value;
@@ -1944,8 +2331,6 @@
}
}
body += '<div class="sign">教师签字:____________  教研组长:____________  日期:____ 年 ____ 月 ____ 日</div>';
printDoc(title, body, { orient: orient });
showAlert('已调起打印,如未弹出请在打印对话框中选择正确打印机', 'info');
}
@@ -2077,10 +2462,7 @@
const meta = '<h2>' + esc(title) + '</h2>' +
(proj ? '<div class="sub">' + esc(proj) + '</div>' : '') +
'<div class="meta">' + esc(printMetaLine(['共 ' + list.length + ' 人'])) + '</div>';
const sign = '<div class="sign">教师签字:____________  日期:____ 年 ____ 月 ____ 日</div>' +
'<div class="foot">' + esc(NY.appName || '') + ' · 缺考名单,请核对后签字确认</div>';
printDoc(title, meta + body + sign, { orient: 'portrait' });
printDoc(title, meta + body, { orient: 'portrait' });
showAlert('已调起打印,如未弹出请在打印对话框中选择正确打印机', 'info');
});
+118 -1
View File
@@ -431,7 +431,76 @@ table.grid tbody tr.new.clickable:hover td { background: #d6f2e2; }
/* 弹窗内的表格不做吸顶:弹窗自身是滚动容器,吸顶表头会与内容错层(且贴边观感差) */
.modal-body table.grid th { position: static; }
.modal-body table.grid { box-shadow: inset 0 0 0 1px var(--border-2); border-radius: 8px; }
/* 多考试对比导出:顶部工具条 */
/* ================= 统一导出 / 打印弹窗 ================= */
.ex-sec {
padding: 12px 14px; margin: 0 0 12px;
border: 1px solid var(--border); border-radius: 10px; background: #fbfcfe;
}
.ex-sec[hidden] { display: none; }
.ex-sec-title {
font-size: 12px; font-weight: 700; color: var(--text-2);
letter-spacing: .3px; margin: 0 0 10px;
display: flex; align-items: center; gap: 8px;
}
.ex-sec-title small { font-weight: 500; color: var(--text-3, #8b93a1); }
/* 导出内容:2×2 卡片单选,一眼看清四种导出各是什么 */
.ex-types { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.ex-type {
display: flex; align-items: flex-start; gap: 9px; padding: 10px 12px;
border: 1px solid var(--border); border-radius: 9px; background: #fff;
cursor: pointer; transition: border-color .15s, box-shadow .15s, background .15s;
}
.ex-type:hover { border-color: #b9c2d0; }
.ex-type:has(input:checked) {
border-color: var(--primary, #2f6fed); background: #f3f7ff;
box-shadow: 0 0 0 1px var(--primary, #2f6fed) inset;
}
.ex-type input { margin-top: 3px; flex: none; }
.ex-type-t { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
.ex-type-t b { font-size: 13px; color: var(--text); }
.ex-type-t small { font-size: 11.5px; color: var(--text-3, #8b93a1); line-height: 1.45; }
/* 字段行 */
.ex-row { display: flex; flex-wrap: wrap; gap: 14px 18px; align-items: flex-start; }
.ex-field {
display: flex; flex-direction: column; gap: 6px; min-width: 150px;
font-size: 12px; font-weight: 600; color: var(--text-2);
}
.ex-field select, .ex-field input[type=text], .ex-field input[type=number] {
font-size: 13px; font-weight: 400; color: var(--text);
}
.ex-field-tip {
min-width: 200px; flex: 1 1 240px; font-weight: 400;
display: flex; flex-direction: column; gap: 4px; justify-content: flex-end;
}
.ex-field-grow { flex: 1 1 240px; }
.ex-field-grow input { width: 100%; }
/* 班级 / 组别多选:竖排标签 + 列表,便于 Ctrl 多选 */
#exClsSel, #exGrpSel {
width: 168px; height: 96px; padding: 4px 6px; font-size: 12px;
border: 1px solid var(--border); border-radius: 8px;
background: #fff; color: var(--text); outline: none;
}
#exGrpSel { width: 132px; }
.ex-checks {
display: flex; flex-wrap: wrap; gap: 8px 20px; margin-top: 12px;
font-size: 12.5px; color: var(--text);
}
.ex-checks label { display: flex; align-items: center; gap: 6px; cursor: pointer; }
/* 多考试:勾选列表 */
.ex-pick { margin-top: 12px; }
.ex-pick-head {
display: flex; align-items: center; gap: 10px; margin: 0 0 8px;
font-size: 12.5px; font-weight: 600; color: var(--text-2);
}
.ex-pick-head small { font-weight: 500; color: var(--text-3, #8b93a1); }
.table-scroll.mid { max-height: 208px; overflow: auto; }
/* 合规的旧类名保留(其它地方仍在用 .me-bar 之类的排版习惯) */
.me-bar { display: flex; align-items: center; gap: 16px; margin: 0 0 10px; }
.me-bar .chk { margin: 0; white-space: nowrap; }
.modal-foot {
@@ -626,6 +695,51 @@ body.fs-on .topbar { padding-top: 8px; padding-bottom: 8px; }
.dash-controls .inline { color: #fff; }
.dash-controls .btn { background: rgba(255, 255, 255, .14); border-color: transparent; color: #fff; }
.dash-controls .btn:hover { background: rgba(255, 255, 255, .24); }
/* 单班大屏:顶栏只保留「试卷 / 自动刷新 / 全屏」,班级选择与「← 主系统」隐藏
(退出按钮本形态下不再渲染,这里不再靠 CSS 藏) */
body.dash-class #classSelWrap,
body.dash-class #linkIndex { display: none !important; }
/* 公开(免登录)班级大屏的角标 */
.pub-tag {
display: inline-block; padding: 1px 8px; border-radius: 999px; font-size: 12px;
background: rgba(255, 255, 255, .16); border: 1px solid rgba(255, 255, 255, .38); color: #fff;
}
/* 首页汇总卡(5 卡)在该形态下无意义 */
body.dash-home #boardSummary { display: none; }
.dash-hero .brand-cls {
display: inline-block; margin-left: 2px; padding: 2px 10px; border-radius: 999px;
background: rgba(255, 255, 255, .18); font-size: 13px; font-weight: 600;
}
/* ---------- 首页:班级入口卡片 ---------- */
.cls-grid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(248px, 1fr));
gap: 14px; margin-top: 12px;
}
.cls-card {
min-width: 0; cursor: pointer; user-select: none;
background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius);
padding: 14px 16px 12px; box-shadow: var(--shadow);
transition: transform .18s ease, box-shadow .18s ease, border-color .18s ease;
}
.cls-card:hover { transform: translateY(-3px); border-color: var(--primary); box-shadow: var(--shadow-lg); }
.cls-card:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
.cls-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.cls-name { font-size: 16px; font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cls-go { flex: none; font-size: 12px; font-weight: 700; color: var(--primary); }
.cls-kpis { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px 8px; margin-top: 10px; }
.cls-kpi { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.cls-kpi span { font-size: 11px; color: var(--text-3); }
.cls-kpi b { font-size: 18px; font-weight: 700; font-variant-numeric: tabular-nums; }
.cls-kpi.k-ok b { color: var(--ok); }
.cls-kpi.k-warn b { color: var(--warn); }
.cls-bar { height: 8px; border-radius: 999px; background: var(--border-2); overflow: hidden; margin-top: 12px; }
.cls-bar > i { display: block; height: 100%; border-radius: 999px; transition: width .5s ease; }
.cls-sub {
margin-top: 7px; font-size: 11.5px; color: var(--text-3);
display: flex; justify-content: space-between; gap: 8px;
}
@media (max-width: 860px) { .cls-grid { grid-template-columns: 1fr; } }
.sec-title { margin: 22px 0 4px; font-size: 15px; font-weight: 700; display: flex; align-items: center; gap: 8px; }
.sec-title .tag-gray { background: var(--border-2); color: var(--text-3); padding: 1px 9px; border-radius: 999px; font-size: 12px; font-weight: 600; }
@@ -692,6 +806,9 @@ body.dash.fs-on .card b { font-size: 20px; }
body.dash.fs-on .group-compare { gap: 12px; }
body.dash.fs-on .gpanel { padding: 12px 14px; }
body.dash.fs-on .member-list { max-height: none; }
body.dash.fs-on .cls-grid { grid-template-columns: repeat(auto-fill, minmax(212px, 1fr)); gap: 10px; }
body.dash.fs-on .cls-card { padding: 10px 12px; }
body.dash.fs-on .cls-kpi b { font-size: 15px; }
/* ================= 随机抽取页 ================= */