feat(export/dashboard): 导出增强与大屏公开化
导出-每次考试/多考试对比: - 合格次数阈值等标记项抽为常显区块,三类成绩表共用 - 附缺考名单扩展到多考试导出(取所选考试全都没交的人) - 块表每场固定2列(成绩+合并空位),修复漏推空串导致的整行左移/串列 - 列宽按实际内容估算,合并区空位只给最小宽度 - 合格次数始终显示实际次数(含0),仅 ≥ 阈值标绿底 - 打印标题改为「成绩明细」,缺考名单分页单独成页 - 打印样式加 print-color-adjust:exact,绿底/红字可正常印出 数据大屏: - ?class= 班级大屏免登录公开访问,接口侧放行并按班级裁剪后回传 - 单班大屏移除退出按钮,仅首页保留 - 修复红绿判定真bug:membersTable 误用 window.DASH(DASH为let声明取不到) 改为 scoreGrade() 直接读自由变量,按后台合格率正确判红/黄/绿 测试/样例/文档同步更新,全量回归通过。
This commit is contained in:
+467
-85
@@ -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');
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user