优化2 patch2

This commit is contained in:
2026-07-21 16:22:29 +08:00 Unverified
parent 4929858ec2
commit 722bb6f016
13 changed files with 381 additions and 65 deletions
+90 -3
View File
@@ -177,6 +177,39 @@ function applyTableFilters(tableId) {
if (table.closest('.qualification-ledger')) updateQualificationSelection(table.closest('.qualification-ledger')); if (table.closest('.qualification-ledger')) updateQualificationSelection(table.closest('.qualification-ledger'));
} }
function updateResultScoreInput(input) {
const row = input.closest('tr');
const error = row?.querySelector('[data-score-error]');
const value = input.value.trim();
const score = Number(value);
const max = Number(input.max);
const invalid = value !== '' && (!Number.isFinite(score) || score < 0 || score > max);
input.dataset.dirty = String(value !== input.defaultValue);
input.setAttribute('aria-invalid', String(invalid));
row?.classList.toggle('score-row-invalid', invalid);
if (error) error.textContent = invalid ? `须在 0—${max} 之间` : '';
const form = input.closest('form');
const dirty = form?.querySelectorAll('[data-result-score][data-dirty="true"]').length || 0;
const count = form?.querySelector('[data-result-dirty-count]');
if (count) count.textContent = dirty ? `${dirty} 条成绩尚未暂存` : '尚无未保存修改';
}
function updateFeatureScoreInput(input) {
const row = input.closest('tr');
const error = row?.querySelector('[data-feature-score-error]');
const value = input.value.trim();
const score = Number(value);
const invalid = value === '' || !Number.isFinite(score) || score < 0 || score > 1000;
input.dataset.dirty = String(value !== input.defaultValue);
input.setAttribute('aria-invalid', String(invalid));
row?.classList.toggle('score-row-invalid', invalid);
if (error) error.textContent = invalid ? '须在 0—1000 之间' : '';
const form = input.closest('form');
const dirty = form?.querySelectorAll('[data-feature-score][data-dirty="true"]').length || 0;
const count = form?.querySelector('[data-feature-dirty-count]');
if (count) count.textContent = dirty ? `${dirty} 条特征分尚未保存` : '尚无未保存修改';
}
async function refreshPublic() { async function refreshPublic() {
state.publicData = await api('/api/public/home'); state.publicData = await api('/api/public/home');
} }
@@ -266,7 +299,7 @@ document.addEventListener('click', async event => {
const reviewNote = window.prompt(target.dataset.status === 'approved' ? '填写审核意见(可留空)' : '请填写退回原因', '') ?? null; const reviewNote = window.prompt(target.dataset.status === 'approved' ? '填写审核意见(可留空)' : '请填写退回原因', '') ?? null;
if (reviewNote == null) return; if (reviewNote == null) return;
await api(`/api/admin/admission-plans/${target.dataset.id}`, { method: 'PATCH', body: { status: target.dataset.status, reviewNote } }); await api(`/api/admin/admission-plans/${target.dataset.id}`, { method: 'PATCH', body: { status: target.dataset.status, reviewNote } });
toast(target.dataset.status === 'approved' ? '招生计划已通过' : '招生计划已退回'); return renderRoute(); toast(target.dataset.status === 'approved' ? '招生计划已通过并自动公示' : '招生计划已退回', target.dataset.status === 'approved' ? '公开通知公告已同步生成招生计划公示' : '招生学校可以修改后重新提交'); return renderRoute();
} }
if (action === 'admission-match') { if (action === 'admission-match') {
if (!window.confirm('确认按“分数优先、遵循志愿”执行本轮投档?填报顺序将锁定。')) return; if (!window.confirm('确认按“分数优先、遵循志愿”执行本轮投档?填报顺序将锁定。')) return;
@@ -517,6 +550,7 @@ document.addEventListener('click', async event => {
if (target.dataset.template === '1') query.set('template', '1'); if (target.dataset.template === '1') query.set('template', '1');
if (target.dataset.batchId) query.set('batchId', target.dataset.batchId); if (target.dataset.batchId) query.set('batchId', target.dataset.batchId);
if (target.dataset.examId) query.set('examId', target.dataset.examId); if (target.dataset.examId) query.set('examId', target.dataset.examId);
else if (target.dataset.resource === 'results' && state.resultExamFilter) query.set('examId', state.resultExamFilter);
window.location.href = `/api/admin/excel/${target.dataset.resource}?${query}`; window.location.href = `/api/admin/excel/${target.dataset.resource}?${query}`;
return; return;
} }
@@ -526,6 +560,7 @@ document.addEventListener('click', async event => {
} }
if (action === 'result-exam-filter') { if (action === 'result-exam-filter') {
state.resultExamFilter = target.dataset.id; state.resultExamFilter = target.dataset.id;
state.resultSubjectFilter = '';
return renderRoute(); return renderRoute();
} }
if (action === 'cancel-result-import') { if (action === 'cancel-result-import') {
@@ -585,6 +620,8 @@ document.addEventListener('input', event => {
if (event.target.matches('[data-action="table-search"]')) { if (event.target.matches('[data-action="table-search"]')) {
applyTableFilters(event.target.dataset.target); applyTableFilters(event.target.dataset.target);
} }
if (event.target.matches('[data-result-score]')) updateResultScoreInput(event.target);
if (event.target.matches('[data-feature-score]')) updateFeatureScoreInput(event.target);
if (event.target.matches('.subject-options input')) { if (event.target.matches('.subject-options input')) {
const form = event.target.closest('form'); const form = event.target.closest('form');
const checked = [...form.querySelectorAll('.subject-options input:checked')]; const checked = [...form.querySelectorAll('.subject-options input:checked')];
@@ -598,6 +635,15 @@ document.addEventListener('input', event => {
}); });
document.addEventListener('change', event => { document.addEventListener('change', event => {
if (event.target.matches('[data-action="result-bulk-exam"]')) {
state.resultExamFilter = event.target.value;
state.resultSubjectFilter = '';
renderRoute();
}
if (event.target.matches('[data-action="result-bulk-subject"]')) {
state.resultSubjectFilter = event.target.value;
renderRoute();
}
if (event.target.matches('[data-qualification-select]')) updateQualificationSelection(event.target.closest('.qualification-ledger')); if (event.target.matches('[data-qualification-select]')) updateQualificationSelection(event.target.closest('.qualification-ledger'));
if (event.target.matches('[data-table-filter]')) applyTableFilters(event.target.dataset.target); if (event.target.matches('[data-table-filter]')) applyTableFilters(event.target.dataset.target);
if (event.target.matches('[data-registration-select]')) updateRegistrationSelection(); if (event.target.matches('[data-registration-select]')) updateRegistrationSelection();
@@ -742,7 +788,7 @@ document.addEventListener('submit', async event => {
const form = event.target.closest('form[data-form]'); const form = event.target.closest('form[data-form]');
if (!form) return; if (!form) return;
event.preventDefault(); event.preventDefault();
const submit = form.querySelector('button[type="submit"]'); const submit = event.submitter || form.querySelector('button[type="submit"]');
const original = submit?.innerHTML; const original = submit?.innerHTML;
if (submit) { submit.disabled = true; submit.textContent = '正在处理…'; } if (submit) { submit.disabled = true; submit.textContent = '正在处理…'; }
try { try {
@@ -943,10 +989,42 @@ document.addEventListener('submit', async event => {
const body = formObject(form); body.published = form.published.checked; const body = formObject(form); body.published = form.published.checked;
await api('/api/admin/results', { method: 'POST', body }); await api('/api/admin/results', { method: 'POST', body });
toast(body.published ? '成绩已发布' : '成绩已保存', '考生端可见状态已更新'); renderRoute(); toast(body.published ? '成绩已发布' : '成绩已保存', '考生端可见状态已更新'); renderRoute();
} else if (kind === 'result-bulk-entry') {
const mode = event.submitter?.dataset.resultMode || 'draft';
const inputs = [...form.querySelectorAll('[data-result-score]')];
const invalid = inputs.find(input => input.value.trim() !== '' && (!Number.isFinite(Number(input.value)) || Number(input.value) < 0 || Number(input.value) > Number(input.max)));
if (invalid) { invalid.focus(); throw new Error(`成绩须在 0—${invalid.max} 之间`); }
const targets = mode === 'publish'
? inputs.filter(input => input.value.trim() !== '')
: inputs.filter(input => input.dataset.dirty === 'true' && input.value.trim() !== '');
if (mode === 'publish') {
const missing = inputs.filter(input => input.value.trim() === '');
if (missing.length) { missing[0].focus(); throw new Error(`还有 ${missing.length} 名考生未录入成绩,补齐后才能整科发布`); }
if (!window.confirm(`确认发布本科学目 ${targets.length} 名考生的成绩吗?发布后考生可立即查询。`)) return;
}
if (!targets.length) throw new Error(mode === 'publish' ? '当前科目没有可发布的成绩' : '请先修改至少一条成绩');
const body = {
examId: form.examId.value,
subjectId: form.subjectId.value,
published: mode === 'publish',
rows: targets.map(input => ({ registrationId: input.dataset.registrationId, score: Number(input.value) }))
};
const result = await api('/api/admin/results/bulk', { method: 'POST', body });
toast(mode === 'publish' ? '本科学目成绩已发布' : '成绩已暂存', `${result.count} 条成绩已在同一事务中保存`);
renderRoute();
} else if (kind === 'feature-score-entry') { } else if (kind === 'feature-score-entry') {
const body = formObject(form); body.featureScore = Number(body.featureScore || 0); const body = formObject(form); body.featureScore = Number(body.featureScore || 0);
await api(`/api/admin/registrations/${body.registrationId}/feature-score`, { method: 'PATCH', body }); await api(`/api/admin/registrations/${body.registrationId}/feature-score`, { method: 'PATCH', body });
toast('特征分已登记', '该分数独立于考试科目,默认值为 0'); renderRoute(); toast('特征分已登记', '该分数独立于考试科目,默认值为 0'); renderRoute();
} else if (kind === 'feature-score-bulk') {
const inputs = [...form.querySelectorAll('[data-feature-score]')];
const invalid = inputs.find(input => input.value.trim() === '' || !Number.isFinite(Number(input.value)) || Number(input.value) < 0 || Number(input.value) > 1000);
if (invalid) { invalid.focus(); throw new Error('特征分必须在 0—1000 之间;未参加时填写 0'); }
const targets = inputs.filter(input => input.dataset.dirty === 'true');
if (!targets.length) throw new Error('请先修改至少一名考生的特征分');
const result = await api('/api/admin/feature-scores/bulk', { method: 'POST', body: { examId: form.examId.value, rows: targets.map(input => ({ registrationId: input.dataset.registrationId, featureScore: Number(input.value) })) } });
toast('特征分已保存', `${result.count} 名考生已更新;其余考生继续保持 0 分`);
renderRoute();
} else if (kind === 'result-import-commit') { } else if (kind === 'result-import-commit') {
const rows = state.resultImportPreview?.rows || []; const rows = state.resultImportPreview?.rows || [];
if (!rows.length) throw new Error('没有可提交的成绩预览'); if (!rows.length) throw new Error('没有可提交的成绩预览');
@@ -1184,7 +1262,16 @@ function openAdmitPreview(reg) {
} }
window.addEventListener('hashchange', renderRoute); window.addEventListener('hashchange', renderRoute);
window.addEventListener('keydown', event => { if (event.key === 'Escape') closeModal(); }); window.addEventListener('keydown', event => {
if (event.key === 'Escape') closeModal();
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 's') {
const form = document.querySelector('form[data-form="result-bulk-entry"]');
if (form) {
event.preventDefault();
form.requestSubmit(form.querySelector('[data-result-mode="draft"]'));
}
}
});
try { try {
await Promise.all([refreshPublic(), refreshSession()]); await Promise.all([refreshPublic(), refreshSession()]);
+8
View File
@@ -1246,6 +1246,14 @@ function createRepository({ client, location, read, transaction, close }) {
auditOperation(log) auditOperation(log)
]); ]);
}, },
async updateFeatureScores(entries) {
const operations = [];
for (const { registration, log } of entries) {
operations.push(operation('UPDATE registrations SET feature_score = ? WHERE id = ?', Number(registration.featureScore || 0), registration.id));
operations.push(auditOperation(log));
}
await transaction(operations);
},
async saveAdmissionRecord(record, log = null) { async saveAdmissionRecord(record, log = null) {
const operations = [operation('DELETE FROM admission_records WHERE id = ?', record.id), operation( const operations = [operation('DELETE FROM admission_records WHERE id = ?', record.id), operation(
`INSERT INTO admission_records ( `INSERT INTO admission_records (
+2 -1
View File
@@ -85,7 +85,8 @@ const resourceSpecs = {
results: { results: {
title: '考试成绩台账', sheet: '成绩', title: '考试成绩台账', sheet: '成绩',
columns: [ columns: [
['candidateNumber', '报名号*', 26, '2026-HZ01-X-0001'], ['candidateName', '姓名(只读参考)', 16, ''], ['candidateNumber', '报名号*', 26, '2026-HZ01-X-0001'], ['cardNumber', '准考证号(只读参考)', 24, ''],
['candidateName', '姓名(只读参考)', 16, ''], ['schoolName', '学校(只读参考)', 24, ''], ['className', '班级(只读参考)', 20, ''],
['examCode', '考试代码*', 20, 'EX-2026-AUT'], ['examName', '考试名称(只读参考)', 28, ''], ['examCode', '考试代码*', 20, 'EX-2026-AUT'], ['examName', '考试名称(只读参考)', 28, ''],
['subjectName', '科目*', 16, '语文'], ['fullScore', '科目满分(只读参考)', 18, ''], ['subjectName', '科目*', 16, '语文'], ['fullScore', '科目满分(只读参考)', 18, ''],
['passRule', '单科及格规则(只读参考)', 26, ''], ['passScore', '实际及格分(只读参考)', 20, ''], ['passRule', '单科及格规则(只读参考)', 26, ''], ['passScore', '实际及格分(只读参考)', 20, ''],
+18 -15
View File
@@ -100,7 +100,7 @@ const initializeDatabase = () => createBaseDatabase({
}); });
const persistentDatabase = await createDatabase({ root, seed: initializeDatabase }); const persistentDatabase = await createDatabase({ root, seed: initializeDatabase });
const cache = await createRedisCache(); const cache = await createRedisCache();
const resultCacheWriteMethods = new Set(['saveResult', 'saveResults', 'updateFeatureScore', 'updateExam', 'archiveExam']); const resultCacheWriteMethods = new Set(['saveResult', 'saveResults', 'updateFeatureScore', 'updateFeatureScores', 'updateExam', 'archiveExam']);
const database = withCacheInvalidation(persistentDatabase, cache, (method, args) => { const database = withCacheInvalidation(persistentDatabase, cache, (method, args) => {
const namespaces = ['public']; const namespaces = ['public'];
const instance = args[0]; const instance = args[0];
@@ -558,23 +558,26 @@ function excelRowsForResource(db, user, resource, searchParams) {
}); });
if (resource === 'results') { if (resource === 'results') {
const examId = cleanText(searchParams.get('examId'), 64); const examId = cleanText(searchParams.get('examId'), 64);
const scopedRegistrations = db.registrations.filter(item => registrationInScope(db, user, item)); const scopedRegistrations = db.registrations.filter(item => item.status === 'approved' && registrationInScope(db, user, item) && (!examId || item.examId === examId));
return db.results.filter(result => scopedRegistrations.some(item => item.id === result.registrationId && (!examId || item.examId === examId))).map(result => { return scopedRegistrations.flatMap(registration => {
const registration = db.registrations.find(item => item.id === result.registrationId);
const exam = db.exams.find(item => item.id === registration?.examId); const exam = db.exams.find(item => item.id === registration?.examId);
const subject = exam?.subjects.find(item => item.id === result.subjectId);
const account = db.users.find(item => item.id === registration?.userId); const account = db.users.find(item => item.id === registration?.userId);
const profile = db.candidateProfiles.find(item => item.userId === registration?.userId); const profile = db.candidateProfiles.find(item => item.userId === registration?.userId);
const evaluation = subjectPassEvaluation(db, result, subject); const schoolClass = db.classes.find(item => item.id === profile?.classId);
const rank = resultRankInfo(db, result); return (exam?.subjects || []).filter(subject => registration.subjectIds.includes(subject.id)).map(subject => {
return { const result = db.results.find(item => item.registrationId === registration.id && item.subjectId === subject.id);
candidateNumber: account?.candidateNumber || '', candidateName: profile?.name || account?.displayName || '', const evaluation = result ? subjectPassEvaluation(db, result, subject) : null;
examCode: exam?.code || '', examName: exam?.name || '', subjectName: subject?.name || '', const rank = result ? resultRankInfo(db, result) : null;
fullScore: subject?.fullScore || '', passRule: subjectPassText(subject), passScore: evaluation.passScore ?? '', score: result.score, return {
rank: rank.rank, rankPercent: rank.rankPercent, candidateNumber: account?.candidateNumber || registration.registrationNumber || '', cardNumber: registration.admitCard?.number || '',
qualified: evaluation.qualified == null ? '不判定' : evaluation.qualified ? '达线' : '未达线', candidateName: profile?.name || account?.displayName || '', schoolName: profile?.school || '', className: schoolClass?.name || profile?.grade || '',
grade: result.published ? resultRankInfo(db, result).grade : '待发布', published: result.published ? '发布' : '不发布', updatedAt: result.updatedAt || result.publishedAt || '' examCode: exam?.code || '', examName: exam?.name || '', subjectName: subject?.name || '',
}; fullScore: subject?.fullScore || '', passRule: subjectPassText(subject), passScore: evaluation?.passScore ?? '', score: result?.score ?? '',
rank: rank?.rank ?? '', rankPercent: rank?.rankPercent ?? '',
qualified: !result || evaluation?.qualified == null ? '' : evaluation.qualified ? '达线' : '未达线',
grade: result ? (result.published ? rank?.grade || '' : '待发布') : '', published: result?.published ? '发布' : '不发布', updatedAt: result?.updatedAt || result?.publishedAt || ''
};
});
}); });
} }
if (resource === 'admit_cards') { if (resource === 'admit_cards') {
+20 -3
View File
@@ -240,12 +240,29 @@ export function createAdminViews(context) {
const preview = state.resultImportPreview; const preview = state.resultImportPreview;
const activeExam = data.exams.find(item => item.id === state.resultExamFilter) || data.exams.find(item => !item.archivedAt && item.registrationCount) || data.exams.find(item => !item.archivedAt) || data.exams[0]; const activeExam = data.exams.find(item => item.id === state.resultExamFilter) || data.exams.find(item => !item.archivedAt && item.registrationCount) || data.exams.find(item => !item.archivedAt) || data.exams[0];
if (activeExam) state.resultExamFilter = activeExam.id; if (activeExam) state.resultExamFilter = activeExam.id;
const activeSubject = activeExam?.subjects.find(item => item.id === state.resultSubjectFilter) || activeExam?.subjects[0];
if (activeSubject) state.resultSubjectFilter = activeSubject.id;
const examResults = activeExam ? data.results.filter(item => item.examId === activeExam.id) : data.results; const examResults = activeExam ? data.results.filter(item => item.examId === activeExam.id) : data.results;
const examAppeals = activeExam ? (data.appeals || []).filter(item => item.result?.examId === activeExam.id) : (data.appeals || []); const examAppeals = activeExam ? (data.appeals || []).filter(item => item.result?.examId === activeExam.id) : (data.appeals || []);
const passRate = activeExam?.complete ? Math.round(activeExam.qualified / activeExam.complete * 100) : null; const passRate = activeExam?.complete ? Math.round(activeExam.qualified / activeExam.complete * 100) : null;
const entry = state.user.adminLevel === 'super' && !activeExam?.archivedAt ? `<section class="panel result-entry result-entry-console"><div class="panel-title"><div><h2>单科成绩录入</h2><p>按考试和科目缩小候选范围,已有成绩会自动回填。</p></div><span>实时校验</span></div><form data-form="result-entry"><div class="result-entry-fields"><label><span>考试 *</span><select name="examId" data-action="result-exam" required><option value="">请选择考试</option>${data.exams.filter(exam => !exam.archivedAt).map(exam => `<option value="${h(exam.id)}" ${exam.id === activeExam?.id ? 'selected' : ''}>${h(exam.name)} · ${h(exam.code)}</option>`).join('')}</select></label><label><span>考试科目 *</span><select name="subjectId" id="resultSubject" data-action="result-subject" required><option value="">请选择科目</option>${(activeExam?.subjects || []).map(subject => `<option value="${h(subject.id)}" data-full-score="${h(subject.fullScore)}" data-pass-score="${h(subject.passScore ?? '')}" data-pass-text="${h(subject.passRule === 'none' ? '不设单科线' : subject.passRule === 'rank_percent' ? `本科排名前 ${subject.passValue}%` : `固定 ${subject.passScore}`)}">${h(subject.name)} · 满分 ${h(subject.fullScore)}</option>`).join('')}</select></label><label><span>考生 *</span><select name="registrationId" data-action="result-candidate" required disabled><option value="">请先选择科目</option></select></label><label><span data-score-label>成绩 *</span><input type="number" name="score" min="0" step="0.5" required></label><label><span>等级规则</span><input value="按同场同科排名自动计算" disabled></label></div><p class="score-rule-hint" data-score-hint>选择科目后显示其独立及格规则。</p><div class="result-entry-submit"><label class="agreement publish-switch"><input type="checkbox" name="published" checked><span>保存后立即向考生发布</span></label><button class="solid-button" type="submit">保存成绩</button></div></form></section>` : ''; const subjectRegistrations = (data.registrations || []).filter(item => item.examId === activeExam?.id && item.subjectIds.includes(activeSubject?.id));
const subjectResults = new Map(examResults.filter(item => item.subjectId === activeSubject?.id).map(item => [item.registrationId, item]));
const recordedCount = subjectRegistrations.filter(item => subjectResults.has(item.id)).length;
const publishedCount = subjectRegistrations.filter(item => subjectResults.get(item.id)?.published).length;
const entryRows = subjectRegistrations.map((registration, index) => {
const result = subjectResults.get(registration.id);
const status = result?.published ? 'published' : result ? 'draft' : 'missing';
return `<tr data-status="${status}"><td class="mono result-row-index">${index + 1}</td><td><div class="person-cell"><span>${h((registration.candidateName || '?').slice(0, 1))}</span><div><strong>${h(registration.candidateName)}</strong><small>${h(registration.schoolName)} · ${h(registration.className)}</small></div></div></td><td><strong class="mono">${h(registration.candidateNumber)}</strong></td><td><strong class="mono">${h(registration.admitCard?.number || '待编排')}</strong></td><td class="result-score-input-cell"><input data-result-score data-registration-id="${h(registration.id)}" type="number" min="0" max="${h(activeSubject?.fullScore || '')}" step="0.5" value="${result ? h(result.score) : ''}" placeholder="0—${h(activeSubject?.fullScore || '')}" aria-label="${h(registration.candidateName)}成绩"><small data-score-error></small></td><td>${result ? badge(result.published ? 'published' : 'draft') : '<span class="result-missing">未录入</span>'}</td><td>${result ? formatDate(result.updatedAt || result.publishedAt, true) : '—'}</td></tr>`;
}).join('');
const entry = state.user.adminLevel === 'super' && !activeExam?.archivedAt ? `<section class="panel result-entry-workbench"><form data-form="result-bulk-entry"><header class="result-workbench-head"><div><span>SCORE ENTRY ROSTER</span><h2>按名单录入单科成绩</h2><p>选定考试和科目后,直接在完整考生名单中录分;暂存不会向考生发布。</p></div><div class="result-workbench-selectors"><label><span>考试</span><select name="examId" data-action="result-bulk-exam">${data.exams.filter(exam => !exam.archivedAt).map(exam => `<option value="${h(exam.id)}" ${exam.id === activeExam?.id ? 'selected' : ''}>${h(exam.name)} · ${h(exam.code)}</option>`).join('')}</select></label><label><span>科目</span><select name="subjectId" data-action="result-bulk-subject">${(activeExam?.subjects || []).map(subject => `<option value="${h(subject.id)}" ${subject.id === activeSubject?.id ? 'selected' : ''}>${h(subject.name)} · 满分 ${h(subject.fullScore)}</option>`).join('')}</select></label></div></header><div class="result-workbench-summary"><span><small>应录人数</small><strong>${subjectRegistrations.length}</strong></span><span><small>已录入</small><strong>${recordedCount}</strong></span><span><small>已暂存</small><strong>${Math.max(0, recordedCount - publishedCount)}</strong></span><span><small>已发布</small><strong>${publishedCount}</strong></span><p>${activeSubject ? `${h(activeSubject.name)} · 满分 ${h(activeSubject.fullScore)} 分 · ${h(activeSubject.passRule === 'none' ? '不设单科线' : activeSubject.passRule === 'rank_percent' ? `本科排名前 ${activeSubject.passValue}%` : `固定 ${activeSubject.passScore} 分达线`)}` : '请选择科目'}</p></div><div class="data-toolbar result-entry-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="resultEntryTable" placeholder="搜索姓名、报名号、准考证号、学校或班级"></label><div class="filter-pills"><button type="button" class="active" data-action="status-filter" data-target="resultEntryTable" data-status="all">全部</button><button type="button" data-action="status-filter" data-target="resultEntryTable" data-status="missing">未录入</button><button type="button" data-action="status-filter" data-target="resultEntryTable" data-status="draft">已暂存</button><button type="button" data-action="status-filter" data-target="resultEntryTable" data-status="published">已发布</button></div></div><div class="table-scroll result-entry-table-wrap"><table id="resultEntryTable"><thead><tr><th>序号</th><th>考生</th><th>报名号</th><th>准考证号</th><th>成绩(0—${h(activeSubject?.fullScore || '—')}</th><th>状态</th><th>最近保存</th></tr></thead><tbody>${entryRows || '<tr><td colspan="7" class="empty-state">该科目暂无已通过报名的考生</td></tr>'}</tbody></table></div><footer class="result-workbench-actions"><div><strong data-result-dirty-count>尚无未保存修改</strong><small>按 Ctrl / ⌘ + S 可快速暂存;发布前必须补齐本科学目全部考生成绩。</small></div><button type="submit" class="ghost-button" data-result-mode="draft">暂存已修改成绩</button><button type="submit" class="solid-button" data-result-mode="publish">发布本科学目成绩</button></footer></form></section>` : '';
const featureRegistrations = (data.registrations || []).filter(item => item.examId === activeExam?.id); const featureRegistrations = (data.registrations || []).filter(item => item.examId === activeExam?.id);
const featureEntry = state.user.adminLevel === 'super' && !activeExam?.archivedAt ? `<section class="panel feature-score-console"><div><span>UNIFIED SPECIALTY TEST</span><h2>特征分登记</h2><p>每场考试、每名考生均有独立特征分;未参加统一测试时保持 0 分,与考试科目和文化课总分分开。</p></div><form data-form="feature-score-entry"><label><span>考生 *</span><select name="registrationId" required><option value="">请选择考生</option>${featureRegistrations.map(item => `<option value="${h(item.id)}">${h(item.candidateNumber)} · ${h(item.candidateName)} · 当前 ${h(item.featureScore || 0)} 分</option>`).join('')}</select></label><label><span>特征分 *</span><input name="featureScore" type="number" min="0" max="1000" step="0.01" value="0" required></label><button class="solid-button" type="submit">登记特征分</button></form></section>` : ''; const featureModified = featureRegistrations.filter(item => Number(item.featureScore || 0) !== 0).length;
const featureRows = featureRegistrations.map((registration, index) => {
const featureScore = Number(registration.featureScore || 0);
const status = `${featureScore ? 'modified' : 'zero'} ${registration.specialtyType ? 'specialty' : 'general'}`;
return `<tr data-status="${status}"><td class="mono result-row-index">${index + 1}</td><td><div class="person-cell"><span>${h((registration.candidateName || '?').slice(0, 1))}</span><div><strong>${h(registration.candidateName)}</strong><small>${h(registration.schoolName)} · ${h(registration.className)}</small></div></div></td><td><strong class="mono">${h(registration.candidateNumber)}</strong></td><td><strong class="mono">${h(registration.admitCard?.number || '待编排')}</strong></td><td><strong>${h(registration.specialtyLabel || '普通生')}</strong><small>${registration.specialtyType ? '特长类别投档时计入' : '普通类别不计特征分'}</small></td><td class="result-score-input-cell feature-score-input-cell"><input data-feature-score data-registration-id="${h(registration.id)}" type="number" min="0" max="1000" step="0.01" value="${h(featureScore)}" aria-label="${h(registration.candidateName)}特征分"><small data-feature-score-error></small></td></tr>`;
}).join('');
const featureEntry = state.user.adminLevel === 'super' && !activeExam?.archivedAt ? `<section class="panel feature-score-workbench"><form data-form="feature-score-bulk"><input type="hidden" name="examId" value="${h(activeExam?.id || '')}"><header><div><span>UNIFIED SPECIALTY TEST</span><h2>按名单登记特征分</h2><p>所有考生默认 0 分;只有录取到特长生招生类别时,特征分才加入文化课总分参与该类别投档。</p></div><div class="feature-score-rule"><strong>分类计分规则</strong><span>普通类别:文化课总分</span><span>特长类别:文化课总分 + 特征分</span></div></header><div class="result-workbench-summary feature-score-summary"><span><small>本场考生</small><strong>${featureRegistrations.length}</strong></span><span><small>默认 0 分</small><strong>${featureRegistrations.length - featureModified}</strong></span><span><small>已修改</small><strong>${featureModified}</strong></span><span><small>具有特长资格</small><strong>${featureRegistrations.filter(item => item.specialtyType).length}</strong></span><p>特征分按考试、按考生独立保存,不改变文化课成绩。</p></div><div class="data-toolbar result-entry-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="featureScoreTable" placeholder="搜索姓名、报名号、准考证号、学校或特长类型"></label><div class="filter-pills"><button type="button" class="active" data-action="status-filter" data-target="featureScoreTable" data-status="all">全部</button><button type="button" data-action="status-filter" data-target="featureScoreTable" data-status="zero">默认 0 分</button><button type="button" data-action="status-filter" data-target="featureScoreTable" data-status="modified">已修改</button><button type="button" data-action="status-filter" data-target="featureScoreTable" data-status="specialty">有特长资格</button></div></div><div class="table-scroll result-entry-table-wrap"><table id="featureScoreTable"><thead><tr><th>序号</th><th>考生</th><th>报名号</th><th>准考证号</th><th>特长资格 / 计分范围</th><th>特征分(0—1000</th></tr></thead><tbody>${featureRows || '<tr><td colspan="6" class="empty-state">本场暂无已通过报名的考生</td></tr>'}</tbody></table></div><footer class="result-workbench-actions"><div><strong data-feature-dirty-count>尚无未保存修改</strong><small>未修改的考生保持 0 分;保存后仅影响特长生类别的投档分。</small></div><button type="submit" class="solid-button">保存已修改特征分</button></footer></form></section>` : '';
const importPreview = preview ? `<section class="panel result-import-preview"><header><div><span>EXCEL STAGING AREA</span><h2>${h(preview.fileName || '成绩导入预览')}</h2><p>此处数据尚未写入数据库。请检查错误、更新覆盖项和发布状态后再提交。</p></div><button class="row-action" data-action="cancel-result-import">取消预览</button></header><div class="import-preview-metrics"><span><small>总行数</small><strong>${preview.summary.total}</strong></span><span class="valid"><small>可提交</small><strong>${preview.summary.valid}</strong></span><span class="invalid"><small>有错误</small><strong>${preview.summary.invalid}</strong></span><span><small>新增 / 更新</small><strong>${preview.summary.create} / ${preview.summary.update}</strong></span><span><small>将发布</small><strong>${preview.summary.publish}</strong></span></div><div class="table-scroll"><table class="import-preview-table"><thead><tr><th>Excel 行</th><th>考生</th><th>考试 / 科目</th><th>成绩</th><th>独立及格线</th><th>发布</th><th>写入方式 / 校验</th></tr></thead><tbody>${preview.rows.map(row => `<tr class="${row.errors.length ? 'row-invalid' : ''}"><td class="mono">${h(row.sourceRow)}</td><td><strong>${h(row.candidateName || '未匹配')}</strong><small class="mono">${h(row.candidateNumber)}</small></td><td><strong>${h(row.examName || row.examCode)}</strong><small>${h(row.subjectName)}</small></td><td><strong>${Number.isFinite(row.score) ? h(row.score) : '—'}</strong><small>${row.scoreRate == null ? '' : `${h(row.scoreRate)}% · ${h(row.grade)}`}</small></td><td><strong>${h(row.passText || '—')}</strong><small>${row.qualified == null ? '不判定' : row.qualified ? '达到单科线' : '未达到单科线'}</small></td><td>${badge(row.published ? 'published' : 'draft')}</td><td>${row.errors.length ? `<ul>${row.errors.map(error => `<li>${h(error)}</li>`).join('')}</ul>` : `<strong>${row.mode === 'update' ? '覆盖已有成绩' : '新增成绩'}</strong><small>校验通过</small>`}</td></tr>`).join('')}</tbody></table></div><form data-form="result-import-commit"><div class="import-commit-bar"><p>${preview.summary.invalid ? `${preview.summary.invalid} 行错误,修正 Excel 后请重新选择文件。` : `确认后将以一个事务写入 ${preview.summary.valid} 条成绩,失败时不会留下部分数据。`}</p><button class="solid-button" type="submit" ${preview.summary.invalid ? 'disabled' : ''}>确认提交到数据库</button></div></form></section>` : ''; const importPreview = preview ? `<section class="panel result-import-preview"><header><div><span>EXCEL STAGING AREA</span><h2>${h(preview.fileName || '成绩导入预览')}</h2><p>此处数据尚未写入数据库。请检查错误、更新覆盖项和发布状态后再提交。</p></div><button class="row-action" data-action="cancel-result-import">取消预览</button></header><div class="import-preview-metrics"><span><small>总行数</small><strong>${preview.summary.total}</strong></span><span class="valid"><small>可提交</small><strong>${preview.summary.valid}</strong></span><span class="invalid"><small>有错误</small><strong>${preview.summary.invalid}</strong></span><span><small>新增 / 更新</small><strong>${preview.summary.create} / ${preview.summary.update}</strong></span><span><small>将发布</small><strong>${preview.summary.publish}</strong></span></div><div class="table-scroll"><table class="import-preview-table"><thead><tr><th>Excel 行</th><th>考生</th><th>考试 / 科目</th><th>成绩</th><th>独立及格线</th><th>发布</th><th>写入方式 / 校验</th></tr></thead><tbody>${preview.rows.map(row => `<tr class="${row.errors.length ? 'row-invalid' : ''}"><td class="mono">${h(row.sourceRow)}</td><td><strong>${h(row.candidateName || '未匹配')}</strong><small class="mono">${h(row.candidateNumber)}</small></td><td><strong>${h(row.examName || row.examCode)}</strong><small>${h(row.subjectName)}</small></td><td><strong>${Number.isFinite(row.score) ? h(row.score) : '—'}</strong><small>${row.scoreRate == null ? '' : `${h(row.scoreRate)}% · ${h(row.grade)}`}</small></td><td><strong>${h(row.passText || '—')}</strong><small>${row.qualified == null ? '不判定' : row.qualified ? '达到单科线' : '未达到单科线'}</small></td><td>${badge(row.published ? 'published' : 'draft')}</td><td>${row.errors.length ? `<ul>${row.errors.map(error => `<li>${h(error)}</li>`).join('')}</ul>` : `<strong>${row.mode === 'update' ? '覆盖已有成绩' : '新增成绩'}</strong><small>校验通过</small>`}</td></tr>`).join('')}</tbody></table></div><form data-form="result-import-commit"><div class="import-commit-bar"><p>${preview.summary.invalid ? `${preview.summary.invalid} 行错误,修正 Excel 后请重新选择文件。` : `确认后将以一个事务写入 ${preview.summary.valid} 条成绩,失败时不会留下部分数据。`}</p><button class="solid-button" type="submit" ${preview.summary.invalid ? 'disabled' : ''}>确认提交到数据库</button></div></form></section>` : '';
const appealLedger = `<section class="panel data-panel"><div class="panel-title"><div><h2>本场成绩复议</h2><p>复议按考生所属班级和学校自动分配,可在流程中心办理。</p></div><button class="row-action" data-route="admin/flows">进入流程中心</button></div><div class="table-scroll"><table><thead><tr><th>考生 / 科目</th><th>考试</th><th>原成绩</th><th>复议理由</th><th>当前步骤</th><th>责任人</th><th>状态</th></tr></thead><tbody>${examAppeals.map(appeal => `<tr><td><strong>${h(appeal.result?.candidateName)} · ${h(appeal.result?.subjectName)}</strong></td><td>${h(appeal.result?.examName)}</td><td><strong>${h(appeal.result?.score)}</strong></td><td><small>${h(appeal.reason)}</small></td><td>${h(appeal.currentStepDetail?.name || '流程已结束')}</td><td>${h(appeal.assignee?.displayName || '—')}</td><td>${badge(appeal.status)}</td></tr>`).join('') || '<tr><td colspan="7" class="empty-state">本场暂无成绩复议申请</td></tr>'}</tbody></table></div></section>`; const appealLedger = `<section class="panel data-panel"><div class="panel-title"><div><h2>本场成绩复议</h2><p>复议按考生所属班级和学校自动分配,可在流程中心办理。</p></div><button class="row-action" data-route="admin/flows">进入流程中心</button></div><div class="table-scroll"><table><thead><tr><th>考生 / 科目</th><th>考试</th><th>原成绩</th><th>复议理由</th><th>当前步骤</th><th>责任人</th><th>状态</th></tr></thead><tbody>${examAppeals.map(appeal => `<tr><td><strong>${h(appeal.result?.candidateName)} · ${h(appeal.result?.subjectName)}</strong></td><td>${h(appeal.result?.examName)}</td><td><strong>${h(appeal.result?.score)}</strong></td><td><small>${h(appeal.reason)}</small></td><td>${h(appeal.currentStepDetail?.name || '流程已结束')}</td><td>${h(appeal.assignee?.displayName || '—')}</td><td>${badge(appeal.status)}</td></tr>`).join('') || '<tr><td colspan="7" class="empty-state">本场暂无成绩复议申请</td></tr>'}</tbody></table></div></section>`;
const examButton = exam => `<button class="${exam.id === activeExam?.id ? 'active' : ''} ${exam.archivedAt ? 'archived' : ''}" data-action="result-exam-filter" data-id="${h(exam.id)}"><span>${h(exam.code)}</span><strong>${h(exam.name)}</strong><small>${exam.archivedAt ? `归档于 ${formatDate(exam.archivedAt, true)} · 已锁定` : `${exam.registrationCount} 人 · 已录 ${exam.scored}/${exam.enrolledSubjects} 科次`}</small><i style="--progress:${exam.enrolledSubjects ? Math.round(exam.scored / exam.enrolledSubjects * 100) : 0}%"></i></button>`; const examButton = exam => `<button class="${exam.id === activeExam?.id ? 'active' : ''} ${exam.archivedAt ? 'archived' : ''}" data-action="result-exam-filter" data-id="${h(exam.id)}"><span>${h(exam.code)}</span><strong>${h(exam.name)}</strong><small>${exam.archivedAt ? `归档于 ${formatDate(exam.archivedAt, true)} · 已锁定` : `${exam.registrationCount} 人 · 已录 ${exam.scored}/${exam.enrolledSubjects} 科次`}</small><i style="--progress:${exam.enrolledSubjects ? Math.round(exam.scored / exam.enrolledSubjects * 100) : 0}%"></i></button>`;
@@ -255,7 +272,7 @@ export function createAdminViews(context) {
const cacheButton = state.user.adminLevel === 'super' const cacheButton = state.user.adminLevel === 'super'
? `<button class="row-action" data-action="refresh-results-cache" ${data.resultCache?.enabled ? '' : 'disabled'} title="${data.resultCache?.enabled ? '让所有考生成绩查询在下次访问时重新生成 Redis 缓存' : 'Redis 缓存当前未连接'}">${data.resultCache?.enabled ? '刷新成绩 Redis 缓存' : 'Redis 缓存未启用'}</button>` ? `<button class="row-action" data-action="refresh-results-cache" ${data.resultCache?.enabled ? '' : 'disabled'} title="${data.resultCache?.enabled ? '让所有考生成绩查询在下次访问时重新生成 Redis 缓存' : 'Redis 缓存当前未连接'}">${data.resultCache?.enabled ? '刷新成绩 Redis 缓存' : 'Redis 缓存未启用'}</button>`
: ''; : '';
const toolbar = `<div class="excel-toolbar result-excel-toolbar"><span><strong>${activeExam?.archivedAt ? '归档成绩只读区' : '成绩 Excel 工作区'}</strong><small>${activeExam?.archivedAt ? '本场成绩已永久锁定,仅保留导出与查阅能力' : '模板导入会先暂存预览,确认后才原子写入数据库'}</small></span><div>${cacheButton}${activeExam?.archivedAt ? '' : '<button class="row-action" data-action="excel-download" data-resource="results" data-template="1">下载模板</button>'}<button class="row-action" data-action="excel-download" data-resource="results" data-exam-id="${h(activeExam?.id || '')}">导出本场成绩</button>${state.user.adminLevel === 'super' && !activeExam?.archivedAt ? '<button class="row-action primary" data-action="excel-import" data-resource="results">选择 Excel 预览</button><input type="file" accept=".xlsx" hidden data-excel-file="results">' : ''}</div></div>`; const toolbar = `<div class="excel-toolbar result-excel-toolbar"><span><strong>${activeExam?.archivedAt ? '归档成绩只读区' : '成绩 Excel 工作区'}</strong><small>${activeExam?.archivedAt ? '本场成绩已永久锁定,仅保留导出与查阅能力' : '名单模板已预填本场全部考生;导入后先预览,确认写入数据库'}</small></span><div>${cacheButton}${activeExam?.archivedAt ? '' : '<button class="row-action" data-action="excel-download" data-resource="results" data-template="1">下载本场考生名单模板</button>'}<button class="row-action" data-action="excel-download" data-resource="results" data-exam-id="${h(activeExam?.id || '')}">导出本场成绩</button>${state.user.adminLevel === 'super' && !activeExam?.archivedAt ? '<button class="row-action primary" data-action="excel-import" data-resource="results">选择 Excel 预览</button><input type="file" accept=".xlsx" hidden data-excel-file="results">' : ''}</div></div>`;
const metrics = `<section class="result-metric-grid"><article><small>报名考生</small><strong>${activeExam?.registrationCount ?? 0}</strong><span>本场已通过报名</span></article><article><small>录入进度</small><strong>${activeExam?.scored ?? 0}<em> / ${activeExam?.enrolledSubjects ?? 0}</em></strong><span>剩余 ${activeExam?.missing ?? 0} 科次</span></article><article><small>已发布</small><strong>${activeExam?.published ?? 0}</strong><span>草稿 ${Math.max(0, (activeExam?.scored || 0) - (activeExam?.published || 0))} 条</span></article><article><small>成绩已出齐</small><strong>${activeExam?.complete ?? 0}</strong><span>人</span></article><article><small>整场合格率</small><strong>${passRate == null ? '—' : `${passRate}%`}</strong><span>按本场排名或所设规则判定</span></article><article><small>成绩复议</small><strong>${examAppeals.length}</strong><span>当前考试累计</span></article></section>`; const metrics = `<section class="result-metric-grid"><article><small>报名考生</small><strong>${activeExam?.registrationCount ?? 0}</strong><span>本场已通过报名</span></article><article><small>录入进度</small><strong>${activeExam?.scored ?? 0}<em> / ${activeExam?.enrolledSubjects ?? 0}</em></strong><span>剩余 ${activeExam?.missing ?? 0} 科次</span></article><article><small>已发布</small><strong>${activeExam?.published ?? 0}</strong><span>草稿 ${Math.max(0, (activeExam?.scored || 0) - (activeExam?.published || 0))} 条</span></article><article><small>成绩已出齐</small><strong>${activeExam?.complete ?? 0}</strong><span>人</span></article><article><small>整场合格率</small><strong>${passRate == null ? '—' : `${passRate}%`}</strong><span>按本场排名或所设规则判定</span></article><article><small>成绩复议</small><strong>${examAppeals.length}</strong><span>当前考试累计</span></article></section>`;
const ledger = `<section class="panel data-panel result-ledger"><div class="data-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="resultTable" placeholder="搜索报名号、姓名、学校或科目"></label><div class="filter-pills"><button class="active" data-action="status-filter" data-target="resultTable" data-status="all">全部</button><button data-action="status-filter" data-target="resultTable" data-status="published">已发布</button><button data-action="status-filter" data-target="resultTable" data-status="draft">草稿</button><button data-action="status-filter" data-target="resultTable" data-status="qualified">单科达线</button><button data-action="status-filter" data-target="resultTable" data-status="unqualified">单科未达线</button></div></div><div class="table-scroll"><table id="resultTable"><thead><tr><th>考生</th><th>考试 / 科目</th><th>成绩</th><th>排名 / 等级</th><th>单科及格规则</th><th>达线</th><th>发布</th><th>更新时间</th></tr></thead><tbody>${examResults.map(result => `<tr data-status="${result.published ? 'published' : 'draft'} ${result.qualified === true ? 'qualified' : result.qualified === false ? 'unqualified' : ''}"><td><div class="person-cell"><span>${h((result.candidateName || '?').slice(0,1))}</span><div><strong>${h(result.candidateName)}</strong><small class="mono">${h(result.candidateNumber)}</small><small>${h(result.schoolName)} · ${h(result.className)}</small></div></div></td><td><strong>${h(result.subjectName)}</strong><small>${h(result.examCode)}</small></td><td><strong class="result-score">${h(result.score)}<em> / ${h(result.fullScore)}</em></strong></td><td><strong>第 ${h(result.rank)} / ${h(result.cohortSize)} 名</strong><small>${h(result.grade)} · 前 ${h(result.rankPercent)}%</small></td><td><strong>${h(result.passText)}</strong></td><td>${result.qualified == null ? '<span class="result-neutral">不判定</span>' : result.qualified ? '<span class="result-qualified">达线</span>' : '<span class="result-unqualified">未达线</span>'}</td><td>${badge(result.published ? 'published' : 'draft')}</td><td>${formatDate(result.updatedAt || result.publishedAt, true)}</td></tr>`).join('') || '<tr><td colspan="8" class="empty-state">本场考试还没有成绩记录</td></tr>'}</tbody></table></div></section>`; const ledger = `<section class="panel data-panel result-ledger"><div class="data-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="resultTable" placeholder="搜索报名号、姓名、学校或科目"></label><div class="filter-pills"><button class="active" data-action="status-filter" data-target="resultTable" data-status="all">全部</button><button data-action="status-filter" data-target="resultTable" data-status="published">已发布</button><button data-action="status-filter" data-target="resultTable" data-status="draft">草稿</button><button data-action="status-filter" data-target="resultTable" data-status="qualified">单科达线</button><button data-action="status-filter" data-target="resultTable" data-status="unqualified">单科未达线</button></div></div><div class="table-scroll"><table id="resultTable"><thead><tr><th>考生</th><th>考试 / 科目</th><th>成绩</th><th>排名 / 等级</th><th>单科及格规则</th><th>达线</th><th>发布</th><th>更新时间</th></tr></thead><tbody>${examResults.map(result => `<tr data-status="${result.published ? 'published' : 'draft'} ${result.qualified === true ? 'qualified' : result.qualified === false ? 'unqualified' : ''}"><td><div class="person-cell"><span>${h((result.candidateName || '?').slice(0,1))}</span><div><strong>${h(result.candidateName)}</strong><small class="mono">${h(result.candidateNumber)}</small><small>${h(result.schoolName)} · ${h(result.className)}</small></div></div></td><td><strong>${h(result.subjectName)}</strong><small>${h(result.examCode)}</small></td><td><strong class="result-score">${h(result.score)}<em> / ${h(result.fullScore)}</em></strong></td><td><strong>第 ${h(result.rank)} / ${h(result.cohortSize)} 名</strong><small>${h(result.grade)} · 前 ${h(result.rankPercent)}%</small></td><td><strong>${h(result.passText)}</strong></td><td>${result.qualified == null ? '<span class="result-neutral">不判定</span>' : result.qualified ? '<span class="result-qualified">达线</span>' : '<span class="result-unqualified">未达线</span>'}</td><td>${badge(result.published ? 'published' : 'draft')}</td><td>${formatDate(result.updatedAt || result.publishedAt, true)}</td></tr>`).join('') || '<tr><td colspan="8" class="empty-state">本场考试还没有成绩记录</td></tr>'}</tbody></table></div></section>`;
const archiveLock = activeExam?.archivedAt ? `<section class="exam-lock-banner"><span>${icons.check}</span><div><strong>本场考试已归档</strong><small>${formatDate(activeExam.archivedAt, true)} 起,手工录入、Excel 导入和成绩复议改分均已永久关闭。</small></div></section>` : ''; const archiveLock = activeExam?.archivedAt ? `<section class="exam-lock-banner"><span>${icons.check}</span><div><strong>本场考试已归档</strong><small>${formatDate(activeExam.archivedAt, true)} 起,手工录入、Excel 导入和成绩复议改分均已永久关闭。</small></div></section>` : '';
+3 -1
View File
@@ -44,14 +44,16 @@ export function createPublicViews(context) {
function noticeDocuments(data = state.publicAnnouncements) { function noticeDocuments(data = state.publicAnnouncements) {
const ordinary = (state.publicData.notices || []).map(item => ({ ...item, documentId: item.id, documentType: 'notice', subtype: item.category || '通知公告', publishedAt: item.publishAt })); const ordinary = (state.publicData.notices || []).map(item => ({ ...item, documentId: item.id, documentType: 'notice', subtype: item.category || '通知公告', publishedAt: item.publishAt }));
const plans = (data.plans || []).map(item => ({ ...item, documentId: `plan-${item.id}`, documentType: 'plan', category: '招生公示', subtype: '招生计划', title: `${item.examName} · ${item.schoolName}招生计划公示`, summary: `${item.rows.reduce((sum, row) => sum + Number(row.quota || 0), 0)} 个招生名额,计划审核通过后由系统自动公示。` }));
const qualifications = (data.qualifications || []).map(item => ({ ...item, documentId: `qualification-${item.id}`, documentType: 'qualification', category: '录取公示', subtype: '指标资格', title: `${item.examName} · ${item.schoolName}指标分配资格公示`, summary: `本次公开 ${item.rows.length} 名考生的指标分配资格及特长类型。` })); const qualifications = (data.qualifications || []).map(item => ({ ...item, documentId: `qualification-${item.id}`, documentType: 'qualification', category: '录取公示', subtype: '指标资格', title: `${item.examName} · ${item.schoolName}指标分配资格公示`, summary: `本次公开 ${item.rows.length} 名考生的指标分配资格及特长类型。` }));
const admissions = (data.admissions || []).map(item => ({ ...item, documentId: `admission-${item.id}`, documentType: 'admission', category: '录取公示', subtype: '录取名单', title: `${item.examName}最终录取名单`, summary: `${item.rows.length} 名考生正式录取,公开报名号、姓名、总成绩和录取学校。` })); const admissions = (data.admissions || []).map(item => ({ ...item, documentId: `admission-${item.id}`, documentType: 'admission', category: '录取公示', subtype: '录取名单', title: `${item.examName}最终录取名单`, summary: `${item.rows.length} 名考生正式录取,公开报名号、姓名、总成绩和录取学校。` }));
const cutoffs = (data.cutoffs || []).map(item => ({ ...item, documentId: `cutoff-${item.id}`, documentType: 'cutoff', category: '录取公示', subtype: '录取分数线', title: `${item.examName}录取分数线`, summary: `按招生学校和招生类别公布 ${item.rows.length} 条最低录取分数线。` })); const cutoffs = (data.cutoffs || []).map(item => ({ ...item, documentId: `cutoff-${item.id}`, documentType: 'cutoff', category: '录取公示', subtype: '录取分数线', title: `${item.examName}录取分数线`, summary: `按招生学校和招生类别公布 ${item.rows.length} 条最低录取分数线。` }));
return [...ordinary, ...qualifications, ...admissions, ...cutoffs].sort((left, right) => new Date(right.publishedAt) - new Date(left.publishedAt)); return [...ordinary, ...plans, ...qualifications, ...admissions, ...cutoffs].sort((left, right) => new Date(right.publishedAt) - new Date(left.publishedAt));
} }
function renderDocumentBody(document) { function renderDocumentBody(document) {
if (document.documentType === 'notice') return `<article class="notice-document-content">${document.contentHtml || `<p>${h(document.content || '').replace(/\r?\n/g, '</p><p>')}</p>`}</article>`; if (document.documentType === 'notice') return `<article class="notice-document-content">${document.contentHtml || `<p>${h(document.content || '').replace(/\r?\n/g, '</p><p>')}</p>`}</article>`;
if (document.documentType === 'plan') return `<p class="document-lead">招生计划经考试中心审核通过后由系统自动公示。计划人数包含普通计划与定向指标,具体执行以本公示为准。</p><div class="table-scroll"><table><thead><tr><th>类别代码</th><th>招生类别</th><th>计划人数</th><th>其中定向指标</th><th>指标分配</th></tr></thead><tbody>${document.rows.map(row => `<tr><td class="mono">${h(row.code)}</td><td><strong>${h(row.name)}</strong><small>${h(row.specialtyLabel || '普通 / 政策类')}</small></td><td><strong>${h(row.quota)} 人</strong></td><td>${h(row.indicatorQuota || 0)} 人</td><td>${row.indicatorAllocations?.length ? row.indicatorAllocations.map(allocation => `<span>${h(allocation.sourceSchoolName)} ${h(allocation.quota)} 人</span>`).join('<br>') : '无定向指标'}</td></tr>`).join('')}</tbody></table></div>${document.note ? `<p class="document-note"><strong>计划说明:</strong>${h(document.note)}</p>` : ''}`;
if (document.documentType === 'qualification') return `<p class="document-lead">本公示由生源校完成全部考生资格确认后自动生成。</p><div class="table-scroll"><table><thead><tr><th>报名号</th><th>姓名</th><th>指标分配资格</th><th>特长类型</th></tr></thead><tbody>${document.rows.map(row => `<tr><td class="mono">${h(row.registrationNumber)}</td><td><strong>${h(row.name)}</strong></td><td><span class="qualification-result ${row.eligible ? 'eligible' : ''}">${row.eligible ? '有' : '无'}</span></td><td>${h(row.specialtyLabel || '普通生')}</td></tr>`).join('')}</tbody></table></div>`; if (document.documentType === 'qualification') return `<p class="document-lead">本公示由生源校完成全部考生资格确认后自动生成。</p><div class="table-scroll"><table><thead><tr><th>报名号</th><th>姓名</th><th>指标分配资格</th><th>特长类型</th></tr></thead><tbody>${document.rows.map(row => `<tr><td class="mono">${h(row.registrationNumber)}</td><td><strong>${h(row.name)}</strong></td><td><span class="qualification-result ${row.eligible ? 'eligible' : ''}">${row.eligible ? '有' : '无'}</span></td><td>${h(row.specialtyLabel || '普通生')}</td></tr>`).join('')}</tbody></table></div>`;
if (document.documentType === 'admission') return `<p class="document-lead">报名号、姓名、考生总成绩与录取学校公开透明;证件号和联系方式不在本页展示。</p><div class="table-scroll"><table><thead><tr><th>报名号</th><th>姓名</th><th>总成绩</th><th>录取学校</th><th>录取类别</th></tr></thead><tbody>${document.rows.map(row => `<tr><td class="mono">${h(row.registrationNumber)}</td><td><strong>${h(row.name)}</strong></td><td>${h(row.totalScore)}</td><td>${h(row.admittedSchool)}</td><td>${h(row.categoryName)}</td></tr>`).join('')}</tbody></table></div>`; if (document.documentType === 'admission') return `<p class="document-lead">报名号、姓名、考生总成绩与录取学校公开透明;证件号和联系方式不在本页展示。</p><div class="table-scroll"><table><thead><tr><th>报名号</th><th>姓名</th><th>总成绩</th><th>录取学校</th><th>录取类别</th></tr></thead><tbody>${document.rows.map(row => `<tr><td class="mono">${h(row.registrationNumber)}</td><td><strong>${h(row.name)}</strong></td><td>${h(row.totalScore)}</td><td>${h(row.admittedSchool)}</td><td>${h(row.categoryName)}</td></tr>`).join('')}</tbody></table></div>`;
return `<p class="document-lead">录取分数线为对应学校、招生类别最终录取考生的最低总成绩。</p><div class="table-scroll"><table><thead><tr><th>招生学校</th><th>招生类别</th><th>计划数</th><th>录取数</th><th>最高分</th><th>录取分数线</th></tr></thead><tbody>${document.rows.map(row => `<tr><td><strong>${h(row.schoolName)}</strong></td><td>${h(row.categoryName)}</td><td>${h(row.planQuota)}</td><td>${h(row.admittedCount)}</td><td>${h(row.highestScore)}</td><td><strong class="cutoff-score">${h(row.cutoffScore)}</strong></td></tr>`).join('')}</tbody></table></div>`; return `<p class="document-lead">录取分数线为对应学校、招生类别最终录取考生的最低总成绩。</p><div class="table-scroll"><table><thead><tr><th>招生学校</th><th>招生类别</th><th>计划数</th><th>录取数</th><th>最高分</th><th>录取分数线</th></tr></thead><tbody>${document.rows.map(row => `<tr><td><strong>${h(row.schoolName)}</strong></td><td>${h(row.categoryName)}</td><td>${h(row.planQuota)}</td><td>${h(row.admittedCount)}</td><td>${h(row.highestScore)}</td><td><strong class="cutoff-score">${h(row.cutoffScore)}</strong></td></tr>`).join('')}</tbody></table></div>`;
+2 -1
View File
@@ -2,13 +2,14 @@ export const state = {
user: null, user: null,
profile: null, profile: null,
publicData: { organization: {}, notices: [], exams: [], stats: {} }, publicData: { organization: {}, notices: [], exams: [], stats: {} },
publicAnnouncements: { qualifications: [], admissions: [], cutoffs: [] }, publicAnnouncements: { plans: [], qualifications: [], admissions: [], cutoffs: [] },
noticeCategory: '全部', noticeCategory: '全部',
noticePage: 1, noticePage: 1,
permissions: [], permissions: [],
scopeLabel: '', scopeLabel: '',
pageData: null, pageData: null,
resultExamFilter: '', resultExamFilter: '',
resultSubjectFilter: '',
resultImportPreview: null, resultImportPreview: null,
loading: false loading: false
}; };
+52 -2
View File
@@ -276,6 +276,7 @@ export function createAdminRoutes(context) {
const plan = existing || { id: uid('plan'), kind: 'plan', examId: exam.id, schoolId: school.id, createdAt: now }; const plan = existing || { id: uid('plan'), kind: 'plan', examId: exam.id, schoolId: school.id, createdAt: now };
Object.assign(plan, { userId: user.id, status: 'approved', updatedAt: now, payload: { categories, note: cleanText(body.note, 500), submittedBy: user.displayName, reviewedBy: user.displayName, reviewedAt: now, reviewNote: '超级管理员代上传并审核通过' } }); Object.assign(plan, { userId: user.id, status: 'approved', updatedAt: now, payload: { categories, note: cleanText(body.note, 500), submittedBy: user.displayName, reviewedBy: user.displayName, reviewedAt: now, reviewNote: '超级管理员代上传并审核通过' } });
await database.saveAdmissionRecord(plan, logAction(db, user, '代上传招生计划', `${school.name} · ${exam.name}`)); await database.saveAdmissionRecord(plan, logAction(db, user, '代上传招生计划', `${school.name} · ${exam.name}`));
await cache.invalidate('public');
return sendJson(response, existing ? 200 : 201, { ok: true, plan }); return sendJson(response, existing ? 200 : 201, { ok: true, plan });
} }
const planReviewMatch = pathname.match(/^\/api\/admin\/admission-plans\/([^/]+)$/); const planReviewMatch = pathname.match(/^\/api\/admin\/admission-plans\/([^/]+)$/);
@@ -289,6 +290,7 @@ export function createAdminRoutes(context) {
plan.updatedAt = nowIso(); plan.updatedAt = nowIso();
plan.payload = { ...plan.payload, reviewNote: cleanText(body.reviewNote, 500), reviewedBy: user.displayName, reviewedAt: plan.updatedAt }; plan.payload = { ...plan.payload, reviewNote: cleanText(body.reviewNote, 500), reviewedBy: user.displayName, reviewedAt: plan.updatedAt };
await database.saveAdmissionRecord(plan, logAction(db, user, body.status === 'approved' ? '审核通过招生计划' : '退回招生计划', plan.id)); await database.saveAdmissionRecord(plan, logAction(db, user, body.status === 'approved' ? '审核通过招生计划' : '退回招生计划', plan.id));
await cache.invalidate('public');
return sendJson(response, 200, { ok: true, plan }); return sendJson(response, 200, { ok: true, plan });
} }
const actionMatch = pathname.match(/^\/api\/admin\/admissions\/([^/]+)\/(match|finalize|supplementary)$/); const actionMatch = pathname.match(/^\/api\/admin\/admissions\/([^/]+)\/(match|finalize|supplementary)$/);
@@ -350,7 +352,7 @@ export function createAdminRoutes(context) {
if (resource === 'results' && !hasPermission(user, 'results.read')) return sendError(response, 403, '当前账号不能导出成绩'); if (resource === 'results' && !hasPermission(user, 'results.read')) return sendError(response, 403, '当前账号不能导出成绩');
const requestUrl = new URL(request.url, `http://${request.headers.host || '127.0.0.1'}`); const requestUrl = new URL(request.url, `http://${request.headers.host || '127.0.0.1'}`);
const template = requestUrl.searchParams.get('template') === '1'; const template = requestUrl.searchParams.get('template') === '1';
const rows = template ? [] : excelRowsForResource(db, user, resource, requestUrl.searchParams); const rows = template && resource !== 'results' ? [] : excelRowsForResource(db, user, resource, requestUrl.searchParams);
const subtitle = user.adminLevel === 'super' ? '全部数据范围' : adminScopeLabel(db, user); const subtitle = user.adminLevel === 'super' ? '全部数据范围' : adminScopeLabel(db, user);
const buffer = Buffer.from(await buildWorkbook(resource, rows, { template, subtitle })); const buffer = Buffer.from(await buildWorkbook(resource, rows, { template, subtitle }));
return sendWorkbook(response, buffer, `${excelResourceNames[resource]}-${template ? '导入模板' : '导出'}-${new Date().toISOString().slice(0, 10)}.xlsx`); return sendWorkbook(response, buffer, `${excelResourceNames[resource]}-${template ? '导入模板' : '导出'}-${new Date().toISOString().slice(0, 10)}.xlsx`);
@@ -1389,7 +1391,8 @@ export function createAdminRoutes(context) {
const view = examRegistrationView(db, item); const view = examRegistrationView(db, item);
const profile = db.candidateProfiles.find(profileItem => profileItem.userId === item.userId); const profile = db.candidateProfiles.find(profileItem => profileItem.userId === item.userId);
const account = db.users.find(accountItem => accountItem.id === item.userId); const account = db.users.find(accountItem => accountItem.id === item.userId);
return { ...view, candidateName: profile?.name || account?.displayName || '', candidateNumber: account?.candidateNumber || item.registrationNumber || '', schoolName: profile?.school || '', className: db.classes.find(classItem => classItem.id === profile?.classId)?.name || profile?.grade || '' }; const specialty = resolveProfileSpecialty(profile || {});
return { ...view, candidateName: profile?.name || account?.displayName || '', candidateNumber: account?.candidateNumber || item.registrationNumber || '', schoolName: profile?.school || '', className: db.classes.find(classItem => classItem.id === profile?.classId)?.name || profile?.grade || '', specialtyCategory: specialty.category, specialtyType: specialty.type, specialtyLabel: specialtyLabel(specialty.category, specialty.type) || '普通生' };
}) : []; }) : [];
return sendJson(response, 200, { ok: true, results, appeals, registrations, exams, resultCache: { enabled: cache.enabled, status: cache.status } }); return sendJson(response, 200, { ok: true, results, appeals, registrations, exams, resultCache: { enabled: cache.enabled, status: cache.status } });
} }
@@ -1399,6 +1402,53 @@ export function createAdminRoutes(context) {
const result = await commitResultImport(db, user, body.rows); const result = await commitResultImport(db, user, body.rows);
return sendJson(response, 200, { ok: true, ...result }); return sendJson(response, 200, { ok: true, ...result });
} }
if (request.method === 'POST' && pathname === '/api/admin/results/bulk') {
if (!requirePermission(user, response, '*')) return true;
const body = await readJson(request);
const exam = db.exams.find(item => item.id === cleanText(body.examId, 64) && !item.archivedAt);
const subject = exam?.subjects.find(item => item.id === cleanText(body.subjectId, 64));
if (!exam || !subject) return sendError(response, 400, '请选择有效且未归档的考试科目');
const sourceRows = [];
const seen = new Set();
for (const [index, row] of (Array.isArray(body.rows) ? body.rows : []).entries()) {
const registration = db.registrations.find(item => item.id === row.registrationId && item.examId === exam.id && item.status === 'approved' && item.subjectIds.includes(subject.id));
if (!registration || seen.has(registration.id)) return sendError(response, 400, `${index + 1} 条考生成绩无效或重复`);
seen.add(registration.id);
const account = db.users.find(item => item.id === registration.userId);
sourceRows.push({
__row: index + 3,
candidateNumber: account?.candidateNumber || registration.registrationNumber || '',
examCode: exam.code,
subjectName: subject.name,
score: row.score,
published: body.published === true
});
}
if (!sourceRows.length) return sendError(response, 400, '没有需要保存的成绩');
const result = await commitResultImport(db, user, sourceRows);
return sendJson(response, 200, { ok: true, published: body.published === true, ...result });
}
if (request.method === 'POST' && pathname === '/api/admin/feature-scores/bulk') {
if (!requirePermission(user, response, '*')) return true;
const body = await readJson(request);
const exam = db.exams.find(item => item.id === cleanText(body.examId, 64) && !item.archivedAt);
if (!exam) return sendError(response, 400, '请选择有效且未归档的考试');
const entries = [];
const seen = new Set();
for (const [index, row] of (Array.isArray(body.rows) ? body.rows : []).entries()) {
const registration = db.registrations.find(item => item.id === row.registrationId && item.examId === exam.id && item.status === 'approved');
const featureScore = Number(row.featureScore);
if (!registration || seen.has(registration.id)) return sendError(response, 400, `${index + 1} 条考生记录无效或重复`);
if (!Number.isFinite(featureScore) || featureScore < 0 || featureScore > 1000) return sendError(response, 400, `${index + 1} 条特征分必须在 0—1000 之间`);
seen.add(registration.id);
registration.featureScore = Number(featureScore.toFixed(2));
const profile = db.candidateProfiles.find(item => item.userId === registration.userId);
entries.push({ registration, log: logAction(db, user, '批量登记特征分', `${profile?.name || registration.userId} · ${exam.name} · ${registration.featureScore}`) });
}
if (!entries.length) return sendError(response, 400, '没有需要保存的特征分');
await database.updateFeatureScores(entries);
return sendJson(response, 200, { ok: true, count: entries.length });
}
const featureScoreMatch = pathname.match(/^\/api\/admin\/registrations\/([^/]+)\/feature-score$/); const featureScoreMatch = pathname.match(/^\/api\/admin\/registrations\/([^/]+)\/feature-score$/);
if (request.method === 'PATCH' && featureScoreMatch) { if (request.method === 'PATCH' && featureScoreMatch) {
if (!requirePermission(user, response, '*')) return true; if (!requirePermission(user, response, '*')) return true;
+23 -1
View File
@@ -1,5 +1,6 @@
import { noticeForClient } from '../security/notice-content.mjs'; import { noticeForClient } from '../security/notice-content.mjs';
import { admissionRecords, admissionSetting, publicAdmissionRows, sourceSchoolQualificationStatus } from '../services/volunteer-admission.mjs'; import { admissionRecords, admissionSetting, publicAdmissionRows, sourceSchoolQualificationStatus } from '../services/volunteer-admission.mjs';
import { specialtyLabel } from '../data/specialty-types.mjs';
export function createPublicRoutes(context) { export function createPublicRoutes(context) {
const { const {
@@ -65,13 +66,34 @@ export function createPublicRoutes(context) {
if (pathname === '/api/public/announcements') { if (pathname === '/api/public/announcements') {
const payload = await cache.remember('public', 'admission-announcements', async () => { const payload = await cache.remember('public', 'admission-announcements', async () => {
const db = await readDb(); const db = await readDb();
const plans = admissionRecords(db, 'plan').filter(item => item.status === 'approved').map(item => ({
id: item.id,
examId: item.examId,
examName: db.exams.find(exam => exam.id === item.examId)?.name || '',
schoolName: db.schools.find(school => school.id === item.schoolId)?.name || '',
publishedAt: item.payload?.reviewedAt || item.updatedAt,
note: item.payload?.note || '',
rows: (item.payload?.categories || []).map(category => ({
code: category.code,
name: category.name,
quota: Number(category.quota || 0),
specialtyCategory: category.specialtyCategory || '',
specialtyType: category.specialtyType || '',
specialtyLabel: specialtyLabel(category.specialtyCategory, category.specialtyType) || '普通 / 政策类',
indicatorQuota: (category.indicatorAllocations || []).reduce((sum, allocation) => sum + Number(allocation.quota || 0), 0),
indicatorAllocations: (category.indicatorAllocations || []).map(allocation => ({
sourceSchoolName: db.schools.find(school => school.id === allocation.sourceSchoolId)?.name || allocation.sourceSchoolId,
quota: Number(allocation.quota || 0)
}))
}))
})).sort((a, b) => new Date(b.publishedAt) - new Date(a.publishedAt));
const qualifications = admissionRecords(db, 'qualification_publication').filter(item => item.status === 'published' && sourceSchoolQualificationStatus(db, item.examId, item.schoolId).complete).map(item => ({ const qualifications = admissionRecords(db, 'qualification_publication').filter(item => item.status === 'published' && sourceSchoolQualificationStatus(db, item.examId, item.schoolId).complete).map(item => ({
id: item.id, examId: item.examId, examName: db.exams.find(exam => exam.id === item.examId)?.name || '', schoolName: db.schools.find(school => school.id === item.schoolId)?.name || '', publishedAt: item.payload?.publishedAt || item.updatedAt, id: item.id, examId: item.examId, examName: db.exams.find(exam => exam.id === item.examId)?.name || '', schoolName: db.schools.find(school => school.id === item.schoolId)?.name || '', publishedAt: item.payload?.publishedAt || item.updatedAt,
rows: (item.payload?.rows || []).map(row => ({ registrationNumber: row.registrationNumber, name: row.name, eligible: row.eligible === true, specialtyLabel: row.specialtyLabel || '普通生' })) rows: (item.payload?.rows || []).map(row => ({ registrationNumber: row.registrationNumber, name: row.name, eligible: row.eligible === true, specialtyLabel: row.specialtyLabel || '普通生' }))
})).sort((a, b) => new Date(b.publishedAt) - new Date(a.publishedAt)); })).sort((a, b) => new Date(b.publishedAt) - new Date(a.publishedAt));
const admissions = admissionRecords(db, 'setting').filter(item => item.status === 'completed' && item.payload?.autoPublish !== false).map(setting => ({ id: setting.id, examId: setting.examId, examName: db.exams.find(item => item.id === setting.examId)?.name || '', publishedAt: setting.payload?.completedAt || setting.updatedAt, rows: publicAdmissionRows(db, setting.examId) })).sort((a, b) => new Date(b.publishedAt) - new Date(a.publishedAt)); const admissions = admissionRecords(db, 'setting').filter(item => item.status === 'completed' && item.payload?.autoPublish !== false).map(setting => ({ id: setting.id, examId: setting.examId, examName: db.exams.find(item => item.id === setting.examId)?.name || '', publishedAt: setting.payload?.completedAt || setting.updatedAt, rows: publicAdmissionRows(db, setting.examId) })).sort((a, b) => new Date(b.publishedAt) - new Date(a.publishedAt));
const cutoffs = admissionRecords(db, 'cutoff_publication').filter(item => item.status === 'published' && admissionSetting(db, item.examId)?.payload?.autoPublish !== false).map(item => ({ id: item.id, examId: item.examId, examName: db.exams.find(exam => exam.id === item.examId)?.name || '', publishedAt: item.payload?.publishedAt || item.updatedAt, rows: item.payload?.rows || [] })).sort((a, b) => new Date(b.publishedAt) - new Date(a.publishedAt)); const cutoffs = admissionRecords(db, 'cutoff_publication').filter(item => item.status === 'published' && admissionSetting(db, item.examId)?.payload?.autoPublish !== false).map(item => ({ id: item.id, examId: item.examId, examName: db.exams.find(exam => exam.id === item.examId)?.name || '', publishedAt: item.payload?.publishedAt || item.updatedAt, rows: item.payload?.rows || [] })).sort((a, b) => new Date(b.publishedAt) - new Date(a.publishedAt));
return { ok: true, qualifications, admissions, cutoffs }; return { ok: true, plans, qualifications, admissions, cutoffs };
}); });
return sendJson(response, 200, payload); return sendJson(response, 200, payload);
} }
+57 -26
View File
@@ -16,6 +16,15 @@ export function candidateTotalScore(db, examId, userId) {
return Number(results.reduce((sum, item) => sum + Number(item.score || 0), 0).toFixed(2)); return Number(results.reduce((sum, item) => sum + Number(item.score || 0), 0).toFixed(2));
} }
export function candidateAdmissionScore(db, examId, userId, category = {}) {
const culturalScore = candidateTotalScore(db, examId, userId);
if (culturalScore == null) return null;
const registration = db.registrations.find(item => item.examId === examId && item.userId === userId && item.status === 'approved');
const featureScore = Number(registration?.featureScore || 0);
const usesFeatureScore = Boolean(category.specialtyCategory || category.specialtyType);
return Number((culturalScore + (usesFeatureScore ? featureScore : 0)).toFixed(2));
}
export function activePreference(db, examId, userId, round) { export function activePreference(db, examId, userId, round) {
return admissionRecords(db, 'preference', examId).find(item => item.userId === userId && Number(item.payload?.round || 1) === Number(round || 1)) || null; return admissionRecords(db, 'preference', examId).find(item => item.userId === userId && Number(item.payload?.round || 1) === Number(round || 1)) || null;
} }
@@ -114,12 +123,10 @@ export function buildVolunteerPlacements(db, setting, { uid, nowIso }) {
} }
const existing = admissionRecords(db, 'placement', examId).filter(item => item.status !== 'withdrawn'); const existing = admissionRecords(db, 'placement', examId).filter(item => item.status !== 'withdrawn');
const occupied = new Map();
const occupiedIndicators = new Map(); const occupiedIndicators = new Map();
const occupiedGeneral = new Map(); const occupiedGeneral = new Map();
for (const placement of existing) { for (const placement of existing) {
const key = categoryKey(placement.schoolId, placement.payload?.categoryCode); const key = categoryKey(placement.schoolId, placement.payload?.categoryCode);
occupied.set(key, (occupied.get(key) || 0) + 1);
if (placement.payload?.quotaBucket?.startsWith('indicator:')) { if (placement.payload?.quotaBucket?.startsWith('indicator:')) {
const indicatorKey = `${key}|${placement.payload.quotaBucket.slice(10)}`; const indicatorKey = `${key}|${placement.payload.quotaBucket.slice(10)}`;
occupiedIndicators.set(indicatorKey, (occupiedIndicators.get(indicatorKey) || 0) + 1); occupiedIndicators.set(indicatorKey, (occupiedIndicators.get(indicatorKey) || 0) + 1);
@@ -130,49 +137,73 @@ export function buildVolunteerPlacements(db, setting, { uid, nowIso }) {
const candidates = preferences.map(preference => { const candidates = preferences.map(preference => {
const profile = db.candidateProfiles.find(item => item.userId === preference.userId) || {}; const profile = db.candidateProfiles.find(item => item.userId === preference.userId) || {};
const account = db.users.find(item => item.id === preference.userId) || {}; const account = db.users.find(item => item.id === preference.userId) || {};
return { preference, profile, account, score: candidateTotalScore(db, examId, preference.userId) }; const registration = db.registrations.find(item => item.examId === examId && item.userId === preference.userId && item.status === 'approved');
}).filter(item => item.score != null && !existing.some(entry => entry.userId === item.preference.userId && ['school_review', 'admitted', 'final', 'withdrawal_pending'].includes(entry.status))) return { preference, profile, account, registration, culturalScore: candidateTotalScore(db, examId, preference.userId), featureScore: Number(registration?.featureScore || 0), nextChoiceIndex: 0 };
.sort((left, right) => right.score - left.score || String(left.account.candidateNumber || '').localeCompare(String(right.account.candidateNumber || ''))); }).filter(item => item.culturalScore != null && !existing.some(entry => entry.userId === item.preference.userId && ['school_review', 'admitted', 'final', 'withdrawal_pending'].includes(entry.status)));
const created = []; const compareProposals = (left, right) => right.totalScore - left.totalScore || String(left.candidate.account.candidateNumber || '').localeCompare(String(right.candidate.account.candidateNumber || ''));
for (const candidate of candidates) { const acceptedByBucket = new Map();
for (const [index, choice] of (candidate.preference.payload?.choices || []).entries()) { const queue = [...candidates].sort((left, right) => right.culturalScore - left.culturalScore || String(left.account.candidateNumber || '').localeCompare(String(right.account.candidateNumber || '')));
while (queue.length) {
const candidate = queue.shift();
const choices = candidate.preference.payload?.choices || [];
while (candidate.nextChoiceIndex < choices.length) {
const index = candidate.nextChoiceIndex;
const choice = choices[candidate.nextChoiceIndex++];
const target = categories.get(categoryKey(choice.schoolId, choice.categoryCode)); const target = categories.get(categoryKey(choice.schoolId, choice.categoryCode));
if (!target) continue; if (!target) continue;
const { category } = target; const { category } = target;
if (!candidateEligibleForCategory(candidate.profile, category)) continue; if (!candidateEligibleForCategory(candidate.profile, category)) continue;
const key = categoryKey(choice.schoolId, choice.categoryCode); const key = categoryKey(choice.schoolId, choice.categoryCode);
if ((occupied.get(key) || 0) >= Number(category.quota || 0)) continue;
let quotaBucket = null; let quotaBucket = null;
let bucketKey = '';
let capacity = 0;
let occupiedCount = 0;
if (choice.preferenceType === 'indicator') { if (choice.preferenceType === 'indicator') {
const qualification = indicatorQualification(db, examId, candidate.preference.userId); const qualification = indicatorQualification(db, examId, candidate.preference.userId);
const allocation = (category.indicatorAllocations || []).find(item => item.sourceSchoolId === candidate.profile.schoolId); const allocation = (category.indicatorAllocations || []).find(item => item.sourceSchoolId === candidate.profile.schoolId);
if (!qualification?.payload?.eligible || !allocation) continue; if (!qualification?.payload?.eligible || !allocation) continue;
const indicatorKey = `${key}|${candidate.profile.schoolId}`; const indicatorKey = `${key}|${candidate.profile.schoolId}`;
if ((occupiedIndicators.get(indicatorKey) || 0) < Number(allocation.quota || 0)) { quotaBucket = `indicator:${candidate.profile.schoolId}`;
quotaBucket = `indicator:${candidate.profile.schoolId}`; bucketKey = quotaBucket + '|' + key;
occupiedIndicators.set(indicatorKey, (occupiedIndicators.get(indicatorKey) || 0) + 1); capacity = Number(allocation.quota || 0);
} occupiedCount = occupiedIndicators.get(indicatorKey) || 0;
if (!quotaBucket) continue;
} else { } else {
const generalQuota = Math.max(0, Number(category.quota || 0) - (category.indicatorAllocations || []).reduce((sum, item) => sum + Number(item.quota || 0), 0)); const generalQuota = Math.max(0, Number(category.quota || 0) - (category.indicatorAllocations || []).reduce((sum, item) => sum + Number(item.quota || 0), 0));
if ((occupiedGeneral.get(key) || 0) >= generalQuota) continue;
quotaBucket = 'general'; quotaBucket = 'general';
occupiedGeneral.set(key, (occupiedGeneral.get(key) || 0) + 1); bucketKey = `general|${key}`;
capacity = generalQuota;
occupiedCount = occupiedGeneral.get(key) || 0;
} }
occupied.set(key, (occupied.get(key) || 0) + 1); const available = Math.max(0, capacity - occupiedCount);
created.push({ if (!available) continue;
id: uid('placement'), kind: 'placement', examId, userId: candidate.preference.userId, schoolId: choice.schoolId, const usesFeatureScore = Boolean(category.specialtyCategory || category.specialtyType);
status: 'school_review', createdAt: nowIso(), updatedAt: nowIso(), payload: { const proposal = {
round, categoryCode: category.code, categoryName: category.name, preferenceOrder: index + 1, candidate, choice, category, index, quotaBucket,
totalScore: candidate.score, featureScore: Number(db.registrations.find(item => item.examId === examId && item.userId === candidate.preference.userId)?.featureScore || 0), culturalScore: candidate.culturalScore,
specialtyQualification: resolveProfileSpecialty(candidate.profile), quotaBucket, schoolDecisionNote: '', withdrawalReason: '', withdrawalReviewNote: '' featureScore: candidate.featureScore,
} totalScore: Number((candidate.culturalScore + (usesFeatureScore ? candidate.featureScore : 0)).toFixed(2))
}); };
const accepted = acceptedByBucket.get(bucketKey) || [];
accepted.push(proposal);
accepted.sort(compareProposals);
const rejected = accepted.length > available ? accepted.pop() : null;
acceptedByBucket.set(bucketKey, accepted);
if (rejected && rejected !== proposal) queue.push(rejected.candidate);
if (rejected === proposal) continue;
break; break;
} }
} }
return created;
const accepted = [...acceptedByBucket.values()].flat().sort(compareProposals);
return accepted.map(({ candidate, choice, category, index, quotaBucket, culturalScore, featureScore, totalScore }) => ({
id: uid('placement'), kind: 'placement', examId, userId: candidate.preference.userId, schoolId: choice.schoolId,
status: 'school_review', createdAt: nowIso(), updatedAt: nowIso(), payload: {
round, categoryCode: category.code, categoryName: category.name, preferenceOrder: index + 1,
culturalScore, totalScore, featureScore,
specialtyQualification: resolveProfileSpecialty(candidate.profile), quotaBucket, schoolDecisionNote: '', withdrawalReason: '', withdrawalReviewNote: ''
}
}));
} }
export function remainingPlanQuota(db, plan) { export function remainingPlanQuota(db, plan) {
+51
View File
@@ -370,6 +370,51 @@ button:disabled { cursor: not-allowed; opacity: .5; }
.result-metric-grid strong { display:block; margin:7px 0 5px; color:#173f60; font:400 25px Georgia,serif; } .result-metric-grid strong { display:block; margin:7px 0 5px; color:#173f60; font:400 25px Georgia,serif; }
.result-metric-grid strong em { color:#8c96aa; font-size:13px; font-style:normal; } .result-metric-grid strong em { color:#8c96aa; font-size:13px; font-style:normal; }
.result-excel-toolbar { margin-bottom:14px; } .result-excel-toolbar { margin-bottom:14px; }
.result-entry-workbench { margin-bottom:14px; overflow:hidden; border-color:#cbd6e6; box-shadow:0 14px 34px rgba(20,36,81,.07); }
.result-workbench-head { display:grid; grid-template-columns:minmax(280px,1fr) minmax(430px,1.1fr); align-items:end; gap:28px; padding:22px 24px; color:#fff; background:linear-gradient(112deg,#12244d 0%,#1c4269 72%,#236b78 100%); }
.result-workbench-head > div:first-child > span { color:#8ed0d4; font:700 9px Consolas,monospace; letter-spacing:.18em; }
.result-workbench-head h2 { margin:6px 0 4px; font-family:"STKaiti","KaiTi",serif; font-size:25px; font-weight:400; }
.result-workbench-head p { margin:0; color:#c6d4e3; font-size:10px; line-height:1.7; }
.result-workbench-selectors { display:grid; grid-template-columns:1.25fr .75fr; gap:10px; }
.result-workbench-selectors label { display:grid; gap:6px; color:#dbe6f2; font-size:9px; font-weight:700; }
.result-workbench-selectors select { width:100%; min-height:42px; padding:8px 11px; border:1px solid rgba(255,255,255,.3); border-radius:8px; color:#15264b; background:#fff; font-size:11px; outline:0; }
.result-workbench-selectors select:focus { box-shadow:0 0 0 3px rgba(142,208,212,.24); }
.result-workbench-summary { display:grid; grid-template-columns:repeat(4,110px) 1fr; align-items:center; min-height:69px; padding:10px 24px; border-bottom:1px solid #e2e8f0; background:#f5f8fc; }
.result-workbench-summary > span { display:grid; gap:3px; border-right:1px solid #dce4ee; }
.result-workbench-summary small { color:#8a94a7; font-size:8px; }
.result-workbench-summary strong { color:#143a5b; font:400 19px Georgia,serif; }
.result-workbench-summary p { justify-self:end; margin:0; padding-left:18px; color:#60708a; font-size:10px; }
.result-entry-toolbar { min-height:62px; border-bottom:1px solid #e5eaf1; }
.result-entry-table-wrap { max-height:560px; overflow:auto; }
.result-entry-table-wrap table { min-width:1050px; }
.result-entry-table-wrap thead { position:sticky; top:0; z-index:3; }
.result-entry-table-wrap th { color:#56647c; background:#eef3f8; box-shadow:0 1px #dbe3ed; }
.result-entry-table-wrap tbody tr { transition:background .12s; }
.result-entry-table-wrap tbody tr:focus-within { background:#f1f7ff; }
.result-row-index { color:#97a1b1; text-align:center; }
.result-score-input-cell { min-width:170px; }
.result-score-input-cell input { width:126px; height:38px; padding:7px 11px; border:1px solid #cbd5e3; border-radius:7px; color:#102c4d; background:#fff; font:700 14px Consolas,monospace; outline:0; }
.result-score-input-cell input:focus { border-color:#315fba; box-shadow:0 0 0 3px rgba(49,95,186,.1); }
.result-score-input-cell input[data-dirty="true"] { border-color:#bd7d19; background:#fffaf0; }
.result-score-input-cell small { display:block; min-height:12px; margin-top:3px; color:#b23838; font-size:8px; }
.score-row-invalid { background:#fff6f5 !important; }
.score-row-invalid input { border-color:#c74646; }
.result-missing { display:inline-flex; padding:4px 8px; border-radius:99px; color:#7b8495; background:#eef1f5; font-size:8px; }
.result-workbench-actions { display:flex; align-items:center; justify-content:flex-end; gap:9px; min-height:74px; padding:13px 24px; border-top:1px solid #dfe6ef; background:#fff; box-shadow:0 -8px 20px rgba(20,36,81,.03); }
.result-workbench-actions > div { display:grid; gap:4px; margin-right:auto; }
.result-workbench-actions > div strong { color:#32435f; font-size:10px; }
.result-workbench-actions > div small { color:#8b95a7; font-size:8px; }
.feature-score-workbench { margin-bottom:14px; overflow:hidden; border-color:#bcd9dc; box-shadow:0 14px 34px rgba(25,86,95,.07); }
.feature-score-workbench > form > header { display:grid; grid-template-columns:minmax(300px,1fr) auto; align-items:center; gap:24px; padding:21px 24px; color:#133d48; background:linear-gradient(110deg,#eef9f8,#dceff0); border-left:4px solid #287486; }
.feature-score-workbench header > div:first-child > span { color:#287486; font:700 9px Consolas,monospace; letter-spacing:.18em; }
.feature-score-workbench h2 { margin:5px 0; font-family:"STKaiti","KaiTi",serif; font-size:24px; font-weight:400; }
.feature-score-workbench header p { max-width:720px; margin:0; color:#5b7780; font-size:10px; line-height:1.7; }
.feature-score-rule { display:grid; gap:5px; min-width:265px; padding:12px 15px; border:1px solid #b9d9da; border-radius:9px; background:rgba(255,255,255,.72); }
.feature-score-rule strong { color:#245b65; font-size:10px; }
.feature-score-rule span { color:#607a81; font-size:9px; }
.feature-score-input-cell input { width:140px; }
.feature-score-workbench td > small { display:block; margin-top:3px; color:#8a95a6; }
.feature-score-summary { grid-template-columns:repeat(4,125px) 1fr; }
.result-entry-console { margin-bottom:14px; overflow:hidden; } .result-entry-console { margin-bottom:14px; overflow:hidden; }
.result-entry-console .panel-title { height:auto; min-height:74px; } .result-entry-console .panel-title { height:auto; min-height:74px; }
.result-entry-console .panel-title p { margin:4px 0 0; color:#8791a4; font-size:10px; } .result-entry-console .panel-title p { margin:4px 0 0; color:#8791a4; font-size:10px; }
@@ -777,3 +822,9 @@ body.review-subpage-open { overflow:hidden; }
@media (max-width:650px) { @media (max-width:650px) {
.review-subpage-header { padding:24px 20px; }.review-subpage-layout { padding:20px 14px 45px; }.review-detail-grid,.review-exam-card dl { grid-template-columns:1fr; }.review-detail-grid > div.wide { grid-column:auto; }.review-exam-card dl > div:nth-child(2) { border-top:1px solid #e8edf0; }.review-subject-cards { grid-template-columns:1fr; padding:14px; }.review-subject-cards > article { grid-template-columns:32px minmax(0,1fr); }.review-subject-cards dl { grid-column:2; }.qualification-specialty-filter { flex-direction:column; }.table-filter-selects.five-columns { grid-template-columns:1fr; } .review-subpage-header { padding:24px 20px; }.review-subpage-layout { padding:20px 14px 45px; }.review-detail-grid,.review-exam-card dl { grid-template-columns:1fr; }.review-detail-grid > div.wide { grid-column:auto; }.review-exam-card dl > div:nth-child(2) { border-top:1px solid #e8edf0; }.review-subject-cards { grid-template-columns:1fr; padding:14px; }.review-subject-cards > article { grid-template-columns:32px minmax(0,1fr); }.review-subject-cards dl { grid-column:2; }.qualification-specialty-filter { flex-direction:column; }.table-filter-selects.five-columns { grid-template-columns:1fr; }
} }
@media (max-width:1000px) {
.result-workbench-head,.feature-score-workbench > form > header { grid-template-columns:1fr; }.result-workbench-summary { grid-template-columns:repeat(4,1fr); }.result-workbench-summary p { grid-column:1/-1; justify-self:start; padding:8px 0 0; }.feature-score-rule { min-width:0; }
}
@media (max-width:650px) {
.result-workbench-selectors,.result-workbench-summary { grid-template-columns:1fr; }.result-workbench-summary > span { padding:6px 0; border-right:0; border-bottom:1px solid #e2e8f0; }.result-workbench-actions { align-items:stretch; flex-direction:column; }.result-workbench-actions > div { margin:0 0 5px; }.result-workbench-actions button { width:100%; }
}
+25 -7
View File
@@ -1,5 +1,5 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { admissionCutoffRows, buildVolunteerPlacements, candidateTotalScore, publicAdmissionRows, remainingPlanQuota, sourceSchoolQualificationStatus } from '../src/services/volunteer-admission.mjs'; import { admissionCutoffRows, buildVolunteerPlacements, candidateAdmissionScore, candidateTotalScore, publicAdmissionRows, remainingPlanQuota, sourceSchoolQualificationStatus } from '../src/services/volunteer-admission.mjs';
import { candidateEligibleForCategory, specialtyLabel } from '../src/data/specialty-types.mjs'; import { candidateEligibleForCategory, specialtyLabel } from '../src/data/specialty-types.mjs';
const now = '2026-07-21T08:00:00.000Z'; const now = '2026-07-21T08:00:00.000Z';
@@ -46,24 +46,42 @@ const db = {
const setting = { examId: 'exam', payload: { round: 1 } }; const setting = { examId: 'exam', payload: { round: 1 } };
const placements = buildVolunteerPlacements(db, setting, { uid: prefix => `${prefix}-${++sequence}`, nowIso: () => now }); const placements = buildVolunteerPlacements(db, setting, { uid: prefix => `${prefix}-${++sequence}`, nowIso: () => now });
assert.equal(candidateTotalScore(db, 'exam', 'u-high'), 250, '投档总分应取当次全部已发布科目之和'); assert.equal(candidateTotalScore(db, 'exam', 'u-high'), 250, '投档总分应取当次全部已发布科目之和');
assert.equal(candidateAdmissionScore(db, 'exam', 'u-sport', { code: 'general' }), 215, '普通招生类别不得加入特征分');
assert.equal(candidateAdmissionScore(db, 'exam', 'u-sport', { specialtyCategory: 'sports', specialtyType: 'track_field' }), 303.5, '特长生招生类别应使用文化课总分加特征分');
assert.equal(placements.length, 3, '三个符合条件且计划充足的考生都应投档'); assert.equal(placements.length, 3, '三个符合条件且计划充足的考生都应投档');
assert.equal(placements.find(item => item.userId === 'u-high').schoolId, 'target-b', '最高分考生应优先满足第一志愿'); assert.equal(placements.find(item => item.userId === 'u-high').schoolId, 'target-b', '最高分考生应优先满足第一志愿');
assert.equal(placements.find(item => item.userId === 'u-low').schoolId, 'target-a', '第一志愿已满时应继续遵循下一志愿'); assert.equal(placements.find(item => item.userId === 'u-low').schoolId, 'target-a', '第一志愿已满时应继续遵循下一志愿');
assert.equal(placements.find(item => item.userId === 'u-sport').payload.quotaBucket, 'indicator:source-b', '特长生指标应使用对应生源学校指标名额'); assert.equal(placements.find(item => item.userId === 'u-sport').payload.quotaBucket, 'indicator:source-b', '特长生指标应使用对应生源学校指标名额');
assert.equal(placements.find(item => item.userId === 'u-sport').payload.featureScore, 88.5, '特征分应随投档材料发送,但不并入文化课总分'); assert.equal(placements.find(item => item.userId === 'u-sport').payload.featureScore, 88.5, '特征分应随投档材料发送');
assert.equal(placements.find(item => item.userId === 'u-sport').payload.culturalScore, 215, '特长生投档材料应保留文化课原始总分');
assert.equal(placements.find(item => item.userId === 'u-sport').payload.totalScore, 303.5, '特长生类别投档总分应加入特征分');
assert.equal(specialtyLabel('sports', 'track_field'), '体育·田径', '特长资格应显示大类和小类'); assert.equal(specialtyLabel('sports', 'track_field'), '体育·田径', '特长资格应显示大类和小类');
assert.equal(candidateEligibleForCategory(db.candidateProfiles[2], { specialtyCategory: 'arts', specialtyType: 'fine_arts' }), false, '体育资格考生不得填报艺术类计划'); assert.equal(candidateEligibleForCategory(db.candidateProfiles[2], { specialtyCategory: 'arts', specialtyType: 'fine_arts' }), false, '体育资格考生不得填报艺术类计划');
const specialtyPriorityDb = structuredClone(db);
specialtyPriorityDb.users.push({ id: 'u-sport-rival', role: 'candidate', active: true, candidateNumber: '20260004', displayName: '特长竞争考生' });
specialtyPriorityDb.candidateProfiles.push({ userId: 'u-sport-rival', name: '特长竞争考生', schoolId: 'source-b', profileCompleted: true, specialtyCategory: 'sports', specialtyType: 'track_field', specialtyTypes: ['track_field'] });
specialtyPriorityDb.registrations.push({ id: 'r-sport-rival', examId: 'exam', userId: 'u-sport-rival', status: 'approved', subjectIds: ['cn', 'math'], featureScore: 0 });
specialtyPriorityDb.results.push({ registrationId: 'r-sport-rival', subjectId: 'cn', score: 125, published: true }, { registrationId: 'r-sport-rival', subjectId: 'math', score: 125, published: true });
specialtyPriorityDb.admissionRecords.push(
{ id: 'qual-sport-rival', kind: 'indicator_qualification', examId: 'exam', userId: 'u-sport-rival', schoolId: 'source-b', status: 'confirmed', payload: { eligible: true } },
{ id: 'pref-sport-rival', kind: 'preference', examId: 'exam', userId: 'u-sport-rival', status: 'submitted', payload: { round: 1, choices: [{ schoolId: 'target-b', categoryCode: 'sport', preferenceType: 'indicator' }] } }
);
const specialtyPriorityPlacements = buildVolunteerPlacements(specialtyPriorityDb, setting, { uid: prefix => `${prefix}-priority-${++sequence}`, nowIso: () => now });
assert.equal(specialtyPriorityPlacements.find(item => item.payload.categoryCode === 'sport').userId, 'u-sport', '特长类别应按文化课加特征分排序,而不是只按文化课排序');
db.admissionRecords.push(...placements.map(item => ({ ...item, status: 'final' }))); db.admissionRecords.push(...placements.map(item => ({ ...item, status: 'final' })));
const remaining = remainingPlanQuota(db, db.admissionRecords.find(item => item.id === 'plan-b')); const remaining = remainingPlanQuota(db, db.admissionRecords.find(item => item.id === 'plan-b'));
assert.equal(remaining.find(item => item.code === 'general').remaining, 0, '普通生计划占用应准确统计'); assert.equal(remaining.find(item => item.code === 'general').remaining, 0, '普通生计划占用应准确统计');
assert.equal(remaining.find(item => item.code === 'sport').remaining, 0, '特长生计划占用应准确统计'); assert.equal(remaining.find(item => item.code === 'sport').remaining, 0, '特长生计划占用应准确统计');
const publicRows = publicAdmissionRows(db, 'exam'); const publicRows = publicAdmissionRows(db, 'exam');
assert.equal(publicRows[0].registrationNumber, '20260001', '公示必须公开报名号'); const highPublicRow = publicRows.find(item => item.registrationNumber === '20260001');
assert.equal(publicRows[0].name, '高分考生', '公示必须公开姓名'); assert.equal(highPublicRow.registrationNumber, '20260001', '公示必须公开报名号');
assert.equal(publicRows[0].totalScore, 250, '公示必须公开总成绩'); assert.equal(highPublicRow.name, '高分考生', '公示必须公开姓名');
assert.equal(publicRows[0].admittedSchool, '第二中学', '公示必须公开录取学校'); assert.equal(highPublicRow.totalScore, 250, '普通类别公示总成绩不得加入特征分');
assert.ok(publicRows[0].idNumberMasked.includes('*') && !publicRows[0].idNumberMasked.includes('20090101'), '重要身份信息必须脱敏'); assert.equal(highPublicRow.admittedSchool, '第二中学', '公示必须公开录取学校');
assert.ok(highPublicRow.idNumberMasked.includes('*') && !highPublicRow.idNumberMasked.includes('20090101'), '重要身份信息必须脱敏');
assert.equal(publicRows.find(item => item.registrationNumber === '20260003').totalScore, 303.5, '特长生类别公示总成绩应包含特征分');
const cutoffs = admissionCutoffRows(db, 'exam'); const cutoffs = admissionCutoffRows(db, 'exam');
assert.equal(cutoffs.find(item => item.schoolId === 'target-b' && item.categoryCode === 'general').cutoffScore, 250, '录取分数线应取学校招生类别最终录取最低总分'); assert.equal(cutoffs.find(item => item.schoolId === 'target-b' && item.categoryCode === 'general').cutoffScore, 250, '录取分数线应取学校招生类别最终录取最低总分');
const qualificationStatus = sourceSchoolQualificationStatus(db, 'exam', 'source-b'); const qualificationStatus = sourceSchoolQualificationStatus(db, 'exam', 'source-b');
+30 -5
View File
@@ -595,6 +595,11 @@ try {
assert.equal(structuredPlan.response.status, 201, '招生校应能提交结构化类别与生源校指标计划'); assert.equal(structuredPlan.response.status, 201, '招生校应能提交结构化类别与生源校指标计划');
assert.equal(structuredPlan.data.plan.payload.categories[1].specialtyType, 'fine_arts'); assert.equal(structuredPlan.data.plan.payload.categories[1].specialtyType, 'fine_arts');
assert.equal((await admin.request(`/api/admin/admission-plans/${structuredPlan.data.plan.id}`, { method: 'PATCH', body: { status: 'approved', reviewNote: '系统测试通过' } })).response.status, 200, '超级管理员应能审核结构化招生计划'); assert.equal((await admin.request(`/api/admin/admission-plans/${structuredPlan.data.plan.id}`, { method: 'PATCH', body: { status: 'approved', reviewNote: '系统测试通过' } })).response.status, 200, '超级管理员应能审核结构化招生计划');
const publicPlanAnnouncements = await anonymous.request('/api/public/announcements');
const publicPlan = publicPlanAnnouncements.data.plans.find(item => item.id === structuredPlan.data.plan.id);
assert.ok(publicPlan, '招生计划审核通过后应立即自动进入公开公示接口');
assert.equal(publicPlan.schoolName, '海州市招生实验学校', '招生计划公示应标明招生学校');
assert.deepEqual(publicPlan.rows.map(item => item.quota), [20, 4], '招生计划公示应保留各类别计划人数');
assert.equal((await classAdmin.request('/api/admin/indicator-qualifications')).response.status, 403, '班级管理员不得查看或确认指标分配资格'); assert.equal((await classAdmin.request('/api/admin/indicator-qualifications')).response.status, 403, '班级管理员不得查看或确认指标分配资格');
assert.equal((await admin.request('/api/admin/indicator-qualifications')).response.status, 403, '超级管理员不得代替生源校确认指标分配资格'); assert.equal((await admin.request('/api/admin/indicator-qualifications')).response.status, 403, '超级管理员不得代替生源校确认指标分配资格');
const qualificationLedger = await schoolAdmin.request('/api/admin/indicator-qualifications'); const qualificationLedger = await schoolAdmin.request('/api/admin/indicator-qualifications');
@@ -895,6 +900,20 @@ try {
assert.ok(publicRichNotice, '管理员发布通知后首页应可见'); assert.ok(publicRichNotice, '管理员发布通知后首页应可见');
assert.equal(publicRichNotice.contentHtml, publishNotice.data.notice.contentHtml, '公开接口应返回已清洗的富文本正文'); assert.equal(publicRichNotice.contentHtml, publishNotice.data.notice.contentHtml, '公开接口应返回已清洗的富文本正文');
const resultTemplate = await admin.request(`/api/admin/excel/results?template=1&examId=${encodeURIComponent(exam.id)}`);
assert.equal(resultTemplate.response.status, 200, '成绩模板应支持按考试下载完整录分名单');
const resultTemplateWorkbook = new ExcelJS.Workbook();
await resultTemplateWorkbook.xlsx.load(resultTemplate.data);
const resultTemplateSheet = resultTemplateWorkbook.getWorksheet('成绩');
const templateHeaders = resultTemplateSheet.getRow(2).values;
assert.ok(templateHeaders.includes('报名号*') && templateHeaders.includes('准考证号(只读参考)') && templateHeaders.includes('姓名(只读参考)'), '成绩模板应包含报名号、准考证号与姓名列');
const templateCandidateNumberColumn = templateHeaders.indexOf('报名号*');
const templateCandidateNameColumn = templateHeaders.indexOf('姓名(只读参考)');
const templateRows = resultTemplateSheet.getRows(3, Math.max(0, resultTemplateSheet.rowCount - 2)) || [];
const candidateTemplateRow = templateRows.find(row => row.getCell(templateCandidateNumberColumn).text === candidateNumber);
assert.ok(candidateTemplateRow, '成绩模板应预填已通过报名的真实考生,而不是空白示例行');
assert.ok(candidateTemplateRow.getCell(templateCandidateNameColumn).text, '成绩模板应预填考生姓名供核对');
const stagedResultFile = Buffer.from(await buildWorkbook('results', [{ candidateNumber, examCode: exam.code, subjectName: exam.subjects[0].name, score: 125, grade: 'A', published: '不发布' }])); const stagedResultFile = Buffer.from(await buildWorkbook('results', [{ candidateNumber, examCode: exam.code, subjectName: exam.subjects[0].name, score: 125, grade: 'A', published: '不发布' }]));
const stagedPreview = await admin.request('/api/admin/excel/results', { method: 'POST', headers: { 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }, body: stagedResultFile }); const stagedPreview = await admin.request('/api/admin/excel/results', { method: 'POST', headers: { 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }, body: stagedResultFile });
assert.equal(stagedPreview.response.status, 200, '成绩 Excel 应先解析为预览'); assert.equal(stagedPreview.response.status, 200, '成绩 Excel 应先解析为预览');
@@ -905,16 +924,22 @@ try {
assert.equal(stagedCommit.response.status, 200, '确认后应批量提交预览成绩'); assert.equal(stagedCommit.response.status, 200, '确认后应批量提交预览成绩');
assert.equal(stagedCommit.data.count, 1); assert.equal(stagedCommit.data.count, 1);
assert.ok((await admin.request('/api/admin/results')).data.results.some(item => item.registrationId === registrationId && item.subjectId === exam.subjects[0].id && item.score === 125 && !item.published), '批量提交后成绩应以预览状态写库'); assert.ok((await admin.request('/api/admin/results')).data.results.some(item => item.registrationId === registrationId && item.subjectId === exam.subjects[0].id && item.score === 125 && !item.published), '批量提交后成绩应以预览状态写库');
const publishResult = await admin.request('/api/admin/results', { method: 'POST', body: { registrationId, subjectId: exam.subjects[0].id, score: 126, grade: 'A', published: true } }); const bulkDraftResult = await admin.request('/api/admin/results/bulk', { method: 'POST', body: { examId: exam.id, subjectId: exam.subjects[0].id, published: false, rows: [{ registrationId, score: 125.5 }] } });
assert.equal(publishResult.response.status, 200); assert.equal(bulkDraftResult.response.status, 200, '名单式录分应支持按科目批量暂存');
assert.equal(bulkDraftResult.data.published, false);
const publishResult = await admin.request('/api/admin/results/bulk', { method: 'POST', body: { examId: exam.id, subjectId: exam.subjects[0].id, published: true, rows: [{ registrationId, score: 126 }] } });
assert.equal(publishResult.response.status, 200, '名单式录分应支持按科目批量发布');
assert.equal(publishResult.data.published, true);
assert.equal((await admin.request('/api/admin/results', { method: 'POST', body: { registrationId, subjectId: exam.subjects[0].id, score: 151, published: true } })).response.status, 400, '成绩不得超过该科配置的满分'); assert.equal((await admin.request('/api/admin/results', { method: 'POST', body: { registrationId, subjectId: exam.subjects[0].id, score: 151, published: true } })).response.status, 400, '成绩不得超过该科配置的满分');
assert.equal((await admin.request('/api/admin/results', { method: 'POST', body: { registrationId, subjectId: exam.subjects[2].id, score: 90, published: true } })).response.status, 200); assert.equal((await admin.request('/api/admin/results', { method: 'POST', body: { registrationId, subjectId: exam.subjects[2].id, score: 90, published: true } })).response.status, 200);
const adminResults = await admin.request('/api/admin/results'); const adminResults = await admin.request('/api/admin/results');
assert.equal(adminResults.data.registrations.find(item => item.id === registrationId).featureScore, 0, '所有考试的特征分默认应为 0'); assert.equal(adminResults.data.registrations.find(item => item.id === registrationId).featureScore, 0, '所有考试的特征分默认应为 0');
const featureScoreUpdate = await admin.request(`/api/admin/registrations/${registrationId}/feature-score`, { method: 'PATCH', body: { featureScore: 87.5 } }); const featureScoreUpdate = await admin.request('/api/admin/feature-scores/bulk', { method: 'POST', body: { examId: exam.id, rows: [{ registrationId, featureScore: 87.5 }] } });
assert.equal(featureScoreUpdate.response.status, 200, '超级管理员应能登记与科目无关的特征分'); assert.equal(featureScoreUpdate.response.status, 200, '超级管理员应能按考试名单批量登记特征分');
assert.equal(featureScoreUpdate.data.registration.featureScore, 87.5); assert.equal(featureScoreUpdate.data.count, 1);
assert.equal((await admin.request('/api/admin/results')).data.registrations.find(item => item.id === registrationId).featureScore, 87.5, '批量保存后应立即回填特征分');
assert.equal((await classAdmin.request(`/api/admin/registrations/${registrationId}/feature-score`, { method: 'PATCH', body: { featureScore: 10 } })).response.status, 403, '班级管理员不得登记特征分'); assert.equal((await classAdmin.request(`/api/admin/registrations/${registrationId}/feature-score`, { method: 'PATCH', body: { featureScore: 10 } })).response.status, 403, '班级管理员不得登记特征分');
assert.equal((await classAdmin.request('/api/admin/feature-scores/bulk', { method: 'POST', body: { examId: exam.id, rows: [{ registrationId, featureScore: 10 }] } })).response.status, 403, '班级管理员不得批量登记特征分');
assert.equal(adminResults.data.resultCache.status, 'disabled', '未配置 REDIS_URL 时成绩管理接口应报告缓存未启用'); assert.equal(adminResults.data.resultCache.status, 'disabled', '未配置 REDIS_URL 时成绩管理接口应报告缓存未启用');
assert.equal((await schoolAdmin.request('/api/admin/results/cache/refresh', { method: 'POST' })).response.status, 403, '仅超级管理员可以手动刷新成绩缓存'); assert.equal((await schoolAdmin.request('/api/admin/results/cache/refresh', { method: 'POST' })).response.status, 403, '仅超级管理员可以手动刷新成绩缓存');
const cacheRefresh = await admin.request('/api/admin/results/cache/refresh', { method: 'POST' }); const cacheRefresh = await admin.request('/api/admin/results/cache/refresh', { method: 'POST' });