Compare commits
10
Commits
@@ -159,7 +159,7 @@ git push origin v1.3.0-rc.1
|
||||
|
||||
首次成功推送后,容器镜像会出现在 `biss` 所有者的软件包列表。Gitea 的软件包归属于用户或组织,不会天然归属于某个仓库;打开该软件包的设置页面,将它关联到 `Exam-Information-System`,即可让它显示在此仓库的“软件包”页。之后可使用 `docker pull git.biss.click/biss/exam-information-system:latest` 拉取。
|
||||
|
||||
需要清空并重建空业务库时运行 `npm run reset-db`。需要测试数据时再手动运行 `npm run seed-test-data`;导入脚本会读取项目根目录的 `.env`,并根据 `DATABASE_CLIENT` 选择 SQLite 或 MySQL。它会生成 4 所学校、360 名批量考生及 360 条不同状态的报名数据,并明确不生成考场编排计划和准考证。省市区县下拉数据位于 `src/data/china-regions.mjs`,当前版本为国家地名信息库截至 2025-12-31 的三级快照,并补入和康县(653228)与和安县(653229);从新版 CSV 更新时可运行 `node scripts/build-regions.mjs <CSV路径> src/data/china-regions.mjs`。
|
||||
需要清空并重建空业务库时运行 `npm run reset-db`;该命令与 `npm run initialize-system` 使用同一套初始化流程,会读取项目根目录的 `.env`,并根据 `DATABASE_CLIENT` 选择 SQLite 或 MySQL。也可通过 `npm run reset-db -- --sqlite` 或 `npm run reset-db -- --mysql` 显式选择数据库;MySQL 中存在无法识别为样例数据的业务记录时仍会拒绝覆盖,只有确认目标可清空后才能追加 `--force`。需要测试数据时再手动运行 `npm run seed-test-data`;导入脚本会生成 5 所学校、1200 名批量考生及对应的不同状态报名数据。省市区县下拉数据位于 `src/data/china-regions.mjs`,当前版本为国家地名信息库截至 2025-12-31 的三级快照,并补入和康县(653228)与和安县(653229);从新版 CSV 更新时可运行 `node scripts/build-regions.mjs <CSV路径> src/data/china-regions.mjs`。
|
||||
|
||||
## 数据库配置
|
||||
|
||||
|
||||
@@ -24,14 +24,22 @@ function toast(title, message = '') {
|
||||
}
|
||||
|
||||
function setModal(content) {
|
||||
document.body.classList.remove('review-subpage-open');
|
||||
modalRoot.innerHTML = `<div class="modal-layer" data-modal-backdrop><div class="modal-card" role="dialog" aria-modal="true">${content}</div></div>`;
|
||||
setTimeout(() => modalRoot.querySelector('input,textarea,button')?.focus(), 30);
|
||||
}
|
||||
|
||||
function setReviewSubpage(content) {
|
||||
document.body.classList.add('review-subpage-open');
|
||||
modalRoot.innerHTML = `<div class="review-subpage-layer"><main class="review-subpage" role="dialog" aria-modal="true">${content}</main></div>`;
|
||||
setTimeout(() => modalRoot.querySelector('button,input,textarea,select')?.focus(), 30);
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
const editor = noticeEditor;
|
||||
noticeEditor = null;
|
||||
if (editor) editor.destroy().catch(error => console.error('CKEditor cleanup failed', error));
|
||||
document.body.classList.remove('review-subpage-open');
|
||||
modalRoot.innerHTML = '';
|
||||
}
|
||||
|
||||
@@ -55,10 +63,26 @@ function emptyState(title, description, route, action) {
|
||||
}
|
||||
|
||||
function renderError(error) {
|
||||
app.innerHTML = `<section class="fatal-error"><span>!</span><h1>页面暂时无法加载</h1><p>${h(error.message)}</p><button class="solid-button" data-action="retry">重新加载</button></section>`;
|
||||
if (error?.status === 401) return requireLogin();
|
||||
console.error('Page failed to render', error);
|
||||
const message = error?.status ? error.message : '请求未能完成,请稍后重试或返回首页。';
|
||||
app.innerHTML = `<section class="fatal-error"><span>!</span><h1>页面暂时无法加载</h1><p>${h(message)}</p><div class="fatal-error-actions"><button class="ghost-button" data-route="home">返回首页</button><button class="solid-button" data-action="retry">重试</button></div></section>`;
|
||||
}
|
||||
|
||||
const baseViewContext = { state, app, h, formatDate, dateRange, badge, money, passPolicyText, statusLabels, icons, api, renderError, emptyState };
|
||||
function requireLogin() {
|
||||
state.user = null;
|
||||
state.profile = null;
|
||||
state.permissions = [];
|
||||
state.scopeLabel = '';
|
||||
state.pageData = null;
|
||||
state.resultExamFilter = '';
|
||||
state.resultSubjectFilter = '';
|
||||
state.resultExamCatalog = null;
|
||||
state.authNotice = '登录状态已失效,请重新登录。';
|
||||
navigate('login');
|
||||
}
|
||||
|
||||
const baseViewContext = { state, app, h, formatDate, dateRange, badge, money, passPolicyText, statusLabels, icons, api, renderError, requireLogin, emptyState };
|
||||
const { brand, renderHome, renderNoticeCenter, renderAuth } = createPublicViews(baseViewContext);
|
||||
const { adminNavForUser, portalShell, loadingPanel, renderCandidate, accountSecurity } = createCandidateViews({ ...baseViewContext, brand });
|
||||
const { renderAdmin, workflowStepEditor } = createAdminViews({ ...baseViewContext, brand, portalShell, loadingPanel, adminNavForUser, accountSecurity });
|
||||
@@ -74,6 +98,7 @@ async function renderRoute() {
|
||||
window.scrollTo({ top: 0, behavior: 'instant' });
|
||||
const route = location.hash.slice(1) || 'home';
|
||||
const [section, page = 'dashboard'] = route.split('/');
|
||||
if (section !== 'login') state.authNotice = '';
|
||||
if (section === 'home') renderHome();
|
||||
else if (section === 'notices' || section === 'announcements') { state.publicAnnouncements = await api('/api/public/announcements'); renderNoticeCenter(state.publicAnnouncements); }
|
||||
else if (section === 'notice') { state.publicAnnouncements = await api('/api/public/announcements'); renderNoticeCenter(state.publicAnnouncements, page); }
|
||||
@@ -104,19 +129,67 @@ function updateRegistrationSelection() {
|
||||
document.querySelectorAll('[data-action="bulk-registration-review"]').forEach(button => { button.disabled = selected.length === 0; });
|
||||
}
|
||||
|
||||
function updateCandidateSelection() {
|
||||
const table = document.querySelector('#candidateTable');
|
||||
if (!table) return;
|
||||
const selectable = [...table.querySelectorAll('[data-candidate-select]:not(:disabled)')];
|
||||
const visible = selectable.filter(input => !input.closest('tr').hidden);
|
||||
const selected = selectable.filter(input => input.checked);
|
||||
const selectAll = table.querySelector('[data-candidate-select-all]');
|
||||
if (selectAll) {
|
||||
selectAll.checked = visible.length > 0 && visible.every(input => input.checked);
|
||||
selectAll.indeterminate = visible.some(input => input.checked) && !selectAll.checked;
|
||||
}
|
||||
const count = document.querySelector('[data-candidate-selection-count]');
|
||||
if (count) count.textContent = selected.length;
|
||||
document.querySelectorAll('[data-action="bulk-candidate-review"]').forEach(button => { button.disabled = selected.length === 0; });
|
||||
}
|
||||
|
||||
function updatePaymentSelection() {
|
||||
const table = document.querySelector('#paymentTable');
|
||||
if (!table) return;
|
||||
const selectable = [...table.querySelectorAll('[data-payment-select]:not(:disabled)')];
|
||||
const visible = selectable.filter(input => !input.closest('tr').hidden);
|
||||
const selected = selectable.filter(input => input.checked);
|
||||
const selectAll = table.querySelector('[data-payment-select-all]');
|
||||
if (selectAll) {
|
||||
selectAll.checked = visible.length > 0 && visible.every(input => input.checked);
|
||||
selectAll.indeterminate = visible.some(input => input.checked) && !selectAll.checked;
|
||||
}
|
||||
const count = document.querySelector('[data-payment-selection-count]');
|
||||
if (count) count.textContent = selected.length;
|
||||
document.querySelectorAll('[data-action="bulk-payment-update"]').forEach(button => { button.disabled = selected.length === 0; });
|
||||
}
|
||||
|
||||
function updateQualificationSelection(container) {
|
||||
if (!container) return;
|
||||
const rows = [...container.querySelectorAll('[data-qualification-select]')];
|
||||
const visible = rows.filter(input => !input.closest('tr').hidden);
|
||||
const selected = rows.filter(input => input.checked);
|
||||
const selectAll = container.querySelector('[data-action="qualification-select-all"]');
|
||||
if (selectAll) {
|
||||
selectAll.checked = rows.length > 0 && selected.length === rows.length;
|
||||
selectAll.indeterminate = selected.length > 0 && selected.length < rows.length;
|
||||
selectAll.checked = visible.length > 0 && visible.every(input => input.checked);
|
||||
selectAll.indeterminate = visible.some(input => input.checked) && !selectAll.checked;
|
||||
}
|
||||
const count = container.querySelector('[data-qualification-selected-count]');
|
||||
if (count) count.textContent = `已选 ${selected.length} 人`;
|
||||
}
|
||||
|
||||
function updatePlacementSelection() {
|
||||
const table = document.getElementById('placementReviewTable');
|
||||
if (!table) return;
|
||||
const selectable = [...table.querySelectorAll('[data-placement-select]:not(:disabled)')];
|
||||
const visible = selectable.filter(input => !input.closest('tr').hidden);
|
||||
const selected = selectable.filter(input => input.checked);
|
||||
const selectAll = document.querySelector('[data-placement-select-all][data-target="placementReviewTable"]');
|
||||
if (selectAll) {
|
||||
selectAll.checked = visible.length > 0 && visible.every(input => input.checked);
|
||||
selectAll.indeterminate = visible.some(input => input.checked) && !selectAll.checked;
|
||||
}
|
||||
const count = document.querySelector('[data-placement-selected-count]');
|
||||
if (count) count.textContent = `已选 ${selected.length} 人`;
|
||||
}
|
||||
|
||||
function applyTableFilters(tableId) {
|
||||
const table = document.getElementById(tableId);
|
||||
if (!table) return;
|
||||
@@ -124,13 +197,50 @@ function applyTableFilters(tableId) {
|
||||
const query = search?.value.trim().toLowerCase() || '';
|
||||
const status = [...document.querySelectorAll('[data-action="status-filter"]')].find(button => button.dataset.target === tableId && button.classList.contains('active'))?.dataset.status || 'all';
|
||||
const filters = [...document.querySelectorAll('[data-table-filter]')].filter(select => select.dataset.target === tableId && select.value);
|
||||
table.querySelectorAll('tbody tr[data-filter-row], tbody tr[data-status]').forEach(row => {
|
||||
table.querySelectorAll('[data-filter-row], tbody tr[data-status]').forEach(row => {
|
||||
const matchesSearch = !query || row.textContent.toLowerCase().includes(query);
|
||||
const matchesStatus = status === 'all' || String(row.dataset.status || '').split(/\s+/).includes(status);
|
||||
const matchesFilters = filters.every(select => String(row.dataset[select.dataset.tableFilter] || '') === select.value);
|
||||
const matchesFilters = filters.every(select => String(row.dataset[select.dataset.tableFilter] || '').split('|').includes(select.value));
|
||||
row.hidden = !(matchesSearch && matchesStatus && matchesFilters);
|
||||
});
|
||||
if (tableId === 'registrationTable') updateRegistrationSelection();
|
||||
if (tableId === 'candidateTable') updateCandidateSelection();
|
||||
if (tableId === 'paymentTable') updatePaymentSelection();
|
||||
if (tableId === 'placementReviewTable') updatePlacementSelection();
|
||||
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() {
|
||||
@@ -147,6 +257,7 @@ async function refreshSession() {
|
||||
|
||||
async function finishLogin(data) {
|
||||
state.user = data.user;
|
||||
state.authNotice = '';
|
||||
await refreshSession();
|
||||
closeModal();
|
||||
toast(data.usedRecoveryCode ? '已使用恢复码登录' : '登录成功', data.usedRecoveryCode ? '该恢复码已失效,请检查剩余恢复码' : `欢迎,${data.user.displayName}`);
|
||||
@@ -192,7 +303,7 @@ document.addEventListener('click', async event => {
|
||||
}
|
||||
if (action === 'logout') {
|
||||
await api('/api/auth/logout', { method: 'POST' });
|
||||
state.user = null; state.profile = null; state.pageData = null; state.permissions = []; state.scopeLabel = '';
|
||||
state.user = null; state.profile = null; state.pageData = null; state.permissions = []; state.scopeLabel = ''; state.resultExamFilter = ''; state.resultSubjectFilter = ''; state.resultExamCatalog = null;
|
||||
await refreshPublic(); navigate('home'); toast('已安全退出', '期待下次见面'); return;
|
||||
}
|
||||
if (action === 'open-notice') {
|
||||
@@ -222,7 +333,7 @@ document.addEventListener('click', async event => {
|
||||
const reviewNote = window.prompt(target.dataset.status === 'approved' ? '填写审核意见(可留空)' : '请填写退回原因', '') ?? null;
|
||||
if (reviewNote == null) return;
|
||||
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 (!window.confirm('确认按“分数优先、遵循志愿”执行本轮投档?填报顺序将锁定。')) return;
|
||||
@@ -257,10 +368,20 @@ document.addEventListener('click', async event => {
|
||||
}
|
||||
if (action === 'qualification-select-all') {
|
||||
const container = target.closest('[data-qualification-bulk]')?.closest('.qualification-ledger');
|
||||
container?.querySelectorAll('[data-qualification-select]').forEach(input => { input.checked = target.checked; });
|
||||
container?.querySelectorAll('[data-qualification-select]').forEach(input => {
|
||||
if (!input.closest('tr').hidden) input.checked = target.checked;
|
||||
});
|
||||
updateQualificationSelection(container);
|
||||
return;
|
||||
}
|
||||
if (action === 'clear-table-filters') {
|
||||
const tableId = target.dataset.target;
|
||||
document.querySelectorAll(`[data-table-filter][data-target="${tableId}"]`).forEach(select => { select.value = ''; });
|
||||
document.querySelectorAll(`[data-action="table-search"][data-target="${tableId}"]`).forEach(input => { input.value = ''; });
|
||||
document.querySelectorAll(`[data-action="status-filter"][data-target="${tableId}"]`).forEach((button, index) => button.classList.toggle('active', index === 0));
|
||||
applyTableFilters(tableId);
|
||||
return;
|
||||
}
|
||||
if (action === 'bulk-indicator-qualification') {
|
||||
const toolbar = target.closest('[data-qualification-bulk]');
|
||||
const ledger = toolbar?.closest('.qualification-ledger');
|
||||
@@ -273,6 +394,20 @@ document.addEventListener('click', async event => {
|
||||
toast(data.published ? '批量确认完成并自动公示' : '批量确认完成', `已更新 ${data.count} 名考生`);
|
||||
return renderRoute();
|
||||
}
|
||||
if (action === 'bulk-placement-review') {
|
||||
const ids = [...document.querySelectorAll('#placementReviewTable [data-placement-select]:checked')].map(input => input.value);
|
||||
const decision = target.dataset.decision;
|
||||
if (!ids.length) return toast('请先选择待审核考生', '可勾选单人,或选择当前筛选结果');
|
||||
let note = '';
|
||||
if (decision === 'withdraw') {
|
||||
note = window.prompt(`将为所选 ${ids.length} 名考生申请退档,请填写统一的特殊理由(至少 8 个字)`, '') ?? '';
|
||||
if (!note) return;
|
||||
if (note.trim().length < 8) return toast('退档理由至少需要 8 个字');
|
||||
} else if (!window.confirm(`确认批量接收所选 ${ids.length} 名投档考生吗?`)) return;
|
||||
const result = await api('/api/admission/placements/bulk', { method: 'POST', body: { ids, decision, note } });
|
||||
toast(decision === 'accept' ? '批量接收完成' : '批量退档申请已提交', `已处理 ${result.count} 名考生`);
|
||||
return renderRoute();
|
||||
}
|
||||
if (action === 'toggle-admin-account') {
|
||||
const active = target.dataset.active === 'true';
|
||||
if (!active && !window.confirm('确认停用该管理员账户?其现有登录会话会立即失效,历史审批记录将保留。')) return;
|
||||
@@ -286,6 +421,19 @@ document.addEventListener('click', async event => {
|
||||
setModal(`<div class="modal-head"><div><span>ADMIN PASSWORD RESET</span><h2>管理员临时密码已生成</h2><p>请通过线下安全渠道交给管理员,本页面关闭后不再展示。</p></div><button data-action="close-modal">×</button></div><div class="issued-number"><span>登录账号</span><strong>${h(data.username)}</strong><span>临时密码</span><strong>${h(data.temporaryPassword)}</strong><p>旧密码和现有登录会话均已失效。</p></div><div class="modal-foot"><button class="solid-button" data-action="close-modal">我已保存</button></div>`);
|
||||
return;
|
||||
}
|
||||
if (action === 'toggle-admission-account') {
|
||||
const active = target.dataset.active === 'true';
|
||||
if (!active && !window.confirm('确认停用该招生学校账户?现有登录会话会立即失效,历史审核记录继续保留。')) return;
|
||||
await api(`/api/admin/admission-school-accounts/${target.dataset.id}`, { method: 'PATCH', body: { active } });
|
||||
toast(active ? '招生学校账户已启用' : '招生学校账户已停用');
|
||||
return renderRoute();
|
||||
}
|
||||
if (action === 'reset-admission-account-password') {
|
||||
if (!window.confirm('确认重置该招生学校账户密码?旧密码和现有登录会话会立即失效。')) return;
|
||||
const data = await api(`/api/admin/admission-school-accounts/${target.dataset.id}/reset-password`, { method: 'POST' });
|
||||
setModal(`<div class="modal-head"><div><span>ADMISSION ACCOUNT RESET</span><h2>招生学校临时密码已生成</h2><p>请通过线下安全渠道交给招生学校,本页面关闭后不再展示。</p></div><button data-action="close-modal">×</button></div><div class="issued-number"><span>登录账号</span><strong>${h(data.username)}</strong><span>临时密码</span><strong>${h(data.temporaryPassword)}</strong><p>账户已同步启用,旧密码和现有会话均已失效。</p></div><div class="modal-foot"><button class="solid-button" data-action="close-modal">我已保存</button></div>`);
|
||||
return;
|
||||
}
|
||||
if (action === 'add-admission-category') {
|
||||
const sources = state.pageData?.sourceSchools || [];
|
||||
target.closest('form')?.querySelector('[data-admission-categories]')?.insertAdjacentHTML('beforeend', admissionCategoryEditor(h, sources));
|
||||
@@ -315,6 +463,7 @@ document.addEventListener('click', async event => {
|
||||
return;
|
||||
}
|
||||
if (action === 'new-notice') { await openNoticeForm(); return; }
|
||||
if (action === 'edit-notice') { await openNoticeForm(state.pageData.notices.find(item => item.id === target.dataset.id)); return; }
|
||||
if (action === 'new-exam') return openExamForm();
|
||||
if (action === 'new-admin') return openAdminForm();
|
||||
if (action === 'new-school') return openSchoolForm();
|
||||
@@ -405,6 +554,24 @@ document.addEventListener('click', async event => {
|
||||
toast(status === 'rejected' ? `已退回 ${completed} 条报名` : `已处理 ${completed} 条报名`, failures.length ? `${failures.length} 条未完成,请刷新后重试` : '报名状态与流程轨迹已同步更新');
|
||||
return renderRoute();
|
||||
}
|
||||
if (action === 'bulk-candidate-review') {
|
||||
const ids = [...document.querySelectorAll('#candidateTable [data-candidate-select]:checked')].map(input => input.value);
|
||||
if (!ids.length) return toast('请先选择考生', '仅当前步骤可由你处理的资料可以勾选');
|
||||
const status = target.dataset.status;
|
||||
const reviewNote = window.prompt(status === 'rejected' ? '请填写批量退回原因(必填)' : '填写批量审核意见(可留空)', '');
|
||||
if (reviewNote === null) return;
|
||||
if (status === 'rejected' && !reviewNote.trim()) return toast('请填写退回原因', '考生需要根据原因补充或修改资料');
|
||||
let completed = 0;
|
||||
const failures = [];
|
||||
for (const id of ids) {
|
||||
try {
|
||||
await api(`/api/admin/candidates/${id}`, { method: 'PATCH', body: { status, reviewNote } });
|
||||
completed += 1;
|
||||
} catch (error) { failures.push(error.message); }
|
||||
}
|
||||
toast(status === 'rejected' ? `已退回 ${completed} 名考生资料` : `已处理 ${completed} 名考生资料`, failures.length ? `${failures.length} 名未完成,请检查其流程责任人` : '资料状态与流程轨迹已同步更新');
|
||||
return renderRoute();
|
||||
}
|
||||
if (action === 'confirm-payment') {
|
||||
if (!window.confirm(`确认已收到 ${target.dataset.name} 的“${target.dataset.exam}”考试费用吗?`)) return;
|
||||
await api(`/api/admin/payments/${target.dataset.id}`, { method: 'PATCH' });
|
||||
@@ -421,11 +588,31 @@ document.addEventListener('click', async event => {
|
||||
toast(paid ? '已标记为已缴费' : '已改为待缴费', paid ? '确认人和确认时间已同步记录' : '原确认人和确认时间已清除');
|
||||
return renderRoute();
|
||||
}
|
||||
if (action === 'bulk-payment-update') {
|
||||
const selected = [...document.querySelectorAll('#paymentTable [data-payment-select]:checked')];
|
||||
if (!selected.length) return toast('请先选择缴费记录');
|
||||
const status = target.dataset.status;
|
||||
const changed = selected.filter(input => input.dataset.status !== status);
|
||||
const skipped = selected.length - changed.length;
|
||||
if (!changed.length) return toast('所选记录无需更改', status === 'paid' ? '所选考生都已缴费' : '所选考生都处于待缴费状态');
|
||||
if (!window.confirm(`确认将 ${changed.length} 条缴费记录批量改为“${status === 'paid' ? '已缴费' : '待缴费'}”吗?`)) return;
|
||||
let completed = 0;
|
||||
const failures = [];
|
||||
for (const input of changed) {
|
||||
try {
|
||||
await api(`/api/admin/payments/${input.value}`, { method: 'PATCH', body: { status } });
|
||||
completed += 1;
|
||||
} catch (error) { failures.push(error.message); }
|
||||
}
|
||||
toast(`已更新 ${completed} 条缴费记录`, [skipped ? `${skipped} 条状态相同已跳过` : '', failures.length ? `${failures.length} 条未完成` : ''].filter(Boolean).join(';'));
|
||||
return renderRoute();
|
||||
}
|
||||
if (action === 'excel-download') {
|
||||
const query = new URLSearchParams();
|
||||
if (target.dataset.template === '1') query.set('template', '1');
|
||||
if (target.dataset.batchId) query.set('batchId', target.dataset.batchId);
|
||||
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}`;
|
||||
return;
|
||||
}
|
||||
@@ -435,6 +622,16 @@ document.addEventListener('click', async event => {
|
||||
}
|
||||
if (action === 'result-exam-filter') {
|
||||
state.resultExamFilter = target.dataset.id;
|
||||
state.resultSubjectFilter = '';
|
||||
['resultEntryTable', 'featureScoreTable', 'resultTable'].forEach(key => {
|
||||
if (state.tablePages[key]) state.tablePages[key].page = 1;
|
||||
});
|
||||
return renderRoute();
|
||||
}
|
||||
if (action === 'table-page') {
|
||||
const key = target.dataset.tableKey;
|
||||
const current = state.tablePages[key] || { page: 1, pageSize: 50 };
|
||||
state.tablePages[key] = { ...current, page: Math.max(1, Number(target.dataset.page) || 1) };
|
||||
return renderRoute();
|
||||
}
|
||||
if (action === 'cancel-result-import') {
|
||||
@@ -481,7 +678,12 @@ document.addEventListener('click', async event => {
|
||||
}
|
||||
if (action === 'toggle-notice') {
|
||||
await api(`/api/admin/notices/${target.dataset.id}`, { method: 'PATCH', body: { status: target.dataset.status } });
|
||||
toast(target.dataset.status === 'published' ? '通知已发布' : '通知已撤回', '首页展示状态已更新'); return refreshPublic().then(renderRoute);
|
||||
toast(target.dataset.status === 'published' ? '通知已发布' : '通知已隐藏', '公开展示状态已更新'); return refreshPublic().then(renderRoute);
|
||||
}
|
||||
if (action === 'toggle-publication') {
|
||||
const visible = target.dataset.visible === 'true';
|
||||
await api(`/api/admin/publications/${target.dataset.sourceType}/${target.dataset.id}`, { method: 'PATCH', body: { visible } });
|
||||
toast(visible ? '系统公示已显示' : '系统公示已隐藏', '只调整公开目录展示,不修改公示内容'); return refreshPublic().then(renderRoute);
|
||||
}
|
||||
if (action === 'status-filter') {
|
||||
target.parentElement.querySelectorAll('button').forEach(button => button.classList.toggle('active', button === target));
|
||||
@@ -494,6 +696,8 @@ document.addEventListener('input', event => {
|
||||
if (event.target.matches('[data-action="table-search"]')) {
|
||||
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')) {
|
||||
const form = event.target.closest('form');
|
||||
const checked = [...form.querySelectorAll('.subject-options input:checked')];
|
||||
@@ -507,9 +711,38 @@ document.addEventListener('input', event => {
|
||||
});
|
||||
|
||||
document.addEventListener('change', event => {
|
||||
if (event.target.matches('[data-action="table-page-size"]')) {
|
||||
const key = event.target.dataset.tableKey;
|
||||
state.tablePages[key] = { page: 1, pageSize: Number(event.target.value) || 50 };
|
||||
renderRoute();
|
||||
return;
|
||||
}
|
||||
if (event.target.matches('[data-placement-select]')) updatePlacementSelection();
|
||||
if (event.target.matches('[data-placement-select-all]')) {
|
||||
const table = document.getElementById(event.target.dataset.target);
|
||||
table?.querySelectorAll('[data-placement-select]:not(:disabled)').forEach(input => {
|
||||
if (!input.closest('tr').hidden) input.checked = event.target.checked;
|
||||
});
|
||||
updatePlacementSelection();
|
||||
}
|
||||
if (event.target.matches('[data-action="result-bulk-exam"]')) {
|
||||
state.resultExamFilter = event.target.value;
|
||||
state.resultSubjectFilter = '';
|
||||
['resultEntryTable', 'featureScoreTable', 'resultTable'].forEach(key => {
|
||||
if (state.tablePages[key]) state.tablePages[key].page = 1;
|
||||
});
|
||||
renderRoute();
|
||||
}
|
||||
if (event.target.matches('[data-action="result-bulk-subject"]')) {
|
||||
state.resultSubjectFilter = event.target.value;
|
||||
if (state.tablePages.resultEntryTable) state.tablePages.resultEntryTable.page = 1;
|
||||
renderRoute();
|
||||
}
|
||||
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-registration-select]')) updateRegistrationSelection();
|
||||
if (event.target.matches('[data-candidate-select]')) updateCandidateSelection();
|
||||
if (event.target.matches('[data-payment-select]')) updatePaymentSelection();
|
||||
if (event.target.matches('[data-registration-select-all]')) {
|
||||
const table = document.getElementById(event.target.dataset.target);
|
||||
table?.querySelectorAll('[data-registration-select]:not(:disabled)').forEach(input => {
|
||||
@@ -517,6 +750,20 @@ document.addEventListener('change', event => {
|
||||
});
|
||||
updateRegistrationSelection();
|
||||
}
|
||||
if (event.target.matches('[data-candidate-select-all]')) {
|
||||
const table = document.getElementById(event.target.dataset.target);
|
||||
table?.querySelectorAll('[data-candidate-select]:not(:disabled)').forEach(input => {
|
||||
if (!input.closest('tr').hidden) input.checked = event.target.checked;
|
||||
});
|
||||
updateCandidateSelection();
|
||||
}
|
||||
if (event.target.matches('[data-payment-select-all]')) {
|
||||
const table = document.getElementById(event.target.dataset.target);
|
||||
table?.querySelectorAll('[data-payment-select]:not(:disabled)').forEach(input => {
|
||||
if (!input.closest('tr').hidden) input.checked = event.target.checked;
|
||||
});
|
||||
updatePaymentSelection();
|
||||
}
|
||||
if (event.target.matches('[data-excel-file]')) {
|
||||
const input = event.target;
|
||||
const file = input.files?.[0];
|
||||
@@ -635,7 +882,7 @@ document.addEventListener('submit', async event => {
|
||||
const form = event.target.closest('form[data-form]');
|
||||
if (!form) return;
|
||||
event.preventDefault();
|
||||
const submit = form.querySelector('button[type="submit"]');
|
||||
const submit = event.submitter || form.querySelector('button[type="submit"]');
|
||||
const original = submit?.innerHTML;
|
||||
if (submit) { submit.disabled = true; submit.textContent = '正在处理…'; }
|
||||
try {
|
||||
@@ -813,8 +1060,9 @@ document.addEventListener('submit', async event => {
|
||||
const body = formObject(form);
|
||||
if (noticeEditor) body.content = noticeEditor.getData();
|
||||
body.pinned = form.pinned.checked;
|
||||
await api('/api/admin/notices', { method: 'POST', body });
|
||||
closeModal(); await refreshPublic(); toast(body.status === 'published' ? '通知已发布' : '草稿已保存', '公开首页状态已同步'); renderRoute();
|
||||
const endpoint = body.id ? `/api/admin/notices/${body.id}` : '/api/admin/notices';
|
||||
await api(endpoint, { method: body.id ? 'PATCH' : 'POST', body });
|
||||
closeModal(); await refreshPublic(); toast(body.status === 'published' ? '通知已发布' : '草稿已保存', body.id ? '草稿内容已更新' : '公开首页状态已同步'); renderRoute();
|
||||
} else if (kind === 'exam-form') {
|
||||
const body = formObject(form);
|
||||
body.subjects = [...form.querySelectorAll('.exam-subject-editor')].map(row => ({
|
||||
@@ -836,10 +1084,42 @@ document.addEventListener('submit', async event => {
|
||||
const body = formObject(form); body.published = form.published.checked;
|
||||
await api('/api/admin/results', { method: 'POST', body });
|
||||
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') {
|
||||
const body = formObject(form); body.featureScore = Number(body.featureScore || 0);
|
||||
await api(`/api/admin/registrations/${body.registrationId}/feature-score`, { method: 'PATCH', body });
|
||||
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') {
|
||||
const rows = state.resultImportPreview?.rows || [];
|
||||
if (!rows.length) throw new Error('没有可提交的成绩预览');
|
||||
@@ -919,8 +1199,11 @@ function openFlowDetail(id) {
|
||||
function openCandidateReview(id) {
|
||||
const source = state.pageData.candidates.find(candidate => candidate.id === id);
|
||||
const item = { ...source, address: formatRegionAddress(source) };
|
||||
const canReview = item.status === 'pending' && item.workflow?.assignee?.id === state.user.id;
|
||||
setModal(`<div class="modal-head"><div><span>CANDIDATE REVIEW</span><h2>${canReview ? '处理' : '查看'} ${h(item.name)} 的资料</h2><p class="mono">报名号 ${h(item.candidateNumber)} · 更新于 ${formatDate(item.updatedAt,true)}</p></div><button data-action="close-modal">×</button></div><div class="review-profile"><dl><div><dt>证件号码</dt><dd class="mono">${h(item.idNumberMasked)}</dd></div><div><dt>性别 / 籍贯</dt><dd>${h(item.gender)} · ${h(item.nativePlace)}</dd></div><div><dt>联系电话</dt><dd>${h(item.phone)}</dd></div><div><dt>电子邮箱</dt><dd>${h(item.email)}</dd></div><div><dt>就读学校</dt><dd>${h(item.school)}</dd></div><div><dt>年级班级</dt><dd>${h(item.grade)}</dd></div><div><dt>家庭住址</dt><dd>${h(item.address)}</dd></div><div><dt>监护人</dt><dd>${h(item.guardianName || item.emergencyContact)} · ${h(item.guardianPhone || item.emergencyPhone)}</dd></div><div><dt>当前步骤</dt><dd>${h(item.workflow?.currentStepDetail?.name || '流程已结束')}</dd></div><div><dt>当前责任人</dt><dd>${h(item.workflow?.assignee?.displayName || '—')}</dd></div></dl></div>${canReview ? `<form class="modal-form" data-form="candidate-review"><input type="hidden" name="id" value="${h(item.id)}"><label><span>审核结论</span><select name="status"><option value="approved">通过当前步骤</option><option value="rejected">退回考生修改</option></select></label><label><span>审核意见</span><textarea name="reviewNote" rows="3" placeholder="填写核验说明或需要补充的资料">${h(item.reviewNote)}</textarea></label><div class="modal-foot"><button type="button" class="ghost-button" data-action="close-modal">取消</button><button type="submit" class="solid-button">确认处理</button></div></form>` : '<div class="modal-foot"><button class="solid-button" data-action="close-modal">关闭</button></div>'}`);
|
||||
const canReview = item.status === 'pending' && (item.workflow?.assignee?.id === state.user.id || state.user.adminLevel === 'super');
|
||||
const registrations = item.registrations || [];
|
||||
const examCards = registrations.map(registration => `<article class="review-exam-card"><header><div><span class="exam-code">${h(registration.exam?.code || '')}</span><h3>${h(registration.exam?.name || '考试信息异常')}</h3></div>${badge(registration.exam?.archivedAt ? 'archived' : registration.status)}</header><dl><div><dt>考试时间</dt><dd>${dateRange(registration.exam?.examStart, registration.exam?.examEnd)}</dd></div><div><dt>考试地点</dt><dd>${h(registration.exam?.location || '待公布')}</dd></div><div><dt>报名科目</dt><dd><div class="review-subject-list">${registration.subjects?.map(subject => `<span><strong>${h(subject.name)}</strong><small>${h(subject.date || '')} ${h(subject.start || '')} · 满分 ${h(subject.fullScore)} · ${money(subject.fee || 0)}</small></span>`).join('') || '<em>未选择科目</em>'}</div></dd></div><div><dt>报名 / 缴费</dt><dd>${badge(registration.status)} ${badge(registration.paymentStatus)} · 应缴 ${money(registration.amountDue || 0)}</dd></div></dl></article>`).join('');
|
||||
const decision = canReview ? `<form class="review-decision-form" data-form="candidate-review"><input type="hidden" name="id" value="${h(item.id)}"><div><span>当前审核步骤</span><strong>${h(item.workflow?.currentStepDetail?.name || '流程已结束')}</strong><small>责任人:${h(item.workflow?.assignee?.displayName || '—')}</small></div><label><span>审核结论</span><select name="status"><option value="approved">通过当前步骤</option><option value="rejected">退回考生修改</option></select></label><label><span>审核意见</span><textarea name="reviewNote" rows="5" placeholder="填写核验说明;退回时请明确指出需要补充或修改的资料">${h(item.reviewNote || '')}</textarea></label><button type="submit" class="solid-button">确认处理</button></form>` : `<aside class="review-readonly"><strong>当前为只读查看</strong><p>${item.status === 'pending' ? `本步骤由 ${h(item.workflow?.assignee?.displayName || '其他管理员')} 处理。` : `资料状态:${h(statusLabels[item.status] || item.status)}`}</p><button class="ghost-button" data-action="close-modal">返回列表</button></aside>`;
|
||||
setReviewSubpage(`<header class="review-subpage-header"><button class="review-back" data-action="close-modal">← 返回考生资料审核</button><div><span>CANDIDATE DOSSIER</span><h1>${h(item.name)} · 资料审核</h1><p><b class="mono">${h(item.candidateNumber)}</b><i></i>${h(item.school || '学校未填写')} · ${h(item.grade || '班级未填写')}<i></i>更新于 ${formatDate(item.updatedAt, true)}</p></div>${badge(item.status)}</header><div class="review-subpage-layout"><div class="review-subpage-main"><section class="review-section"><header><span>01</span><div><h2>身份与学籍信息</h2><p>核对实名、学籍范围以及联系方式。</p></div></header><dl class="review-detail-grid"><div><dt>证件号码</dt><dd class="mono">${h(item.idNumberMasked)}</dd></div><div><dt>性别 / 出生日期</dt><dd>${h(item.gender || '未填写')} · ${h(item.birthDate || '未填写')}</dd></div><div><dt>籍贯 / 民族</dt><dd>${h(item.nativePlace || '未填写')} · ${h(item.ethnicity || '未填写')}</dd></div><div><dt>就读学校 / 班级</dt><dd>${h(item.school || '未填写')} · ${h(item.grade || '未填写')}</dd></div><div><dt>联系电话</dt><dd>${h(item.phone || '未填写')}</dd></div><div><dt>电子邮箱</dt><dd>${h(item.email || '未填写')}</dd></div><div class="wide"><dt>家庭住址</dt><dd>${h(item.address || '未填写')}</dd></div><div><dt>监护人</dt><dd>${h(item.guardianName || '未填写')} · ${h(item.guardianPhone || '电话未填写')}</dd></div><div><dt>紧急联系人</dt><dd>${h(item.emergencyContact || '未填写')} · ${h(item.emergencyPhone || '电话未填写')}</dd></div></dl></section><section class="review-section"><header><span>02</span><div><h2>关联考试与报名科目</h2><p>审核资料时同时查看该考生历次报名上下文。</p></div><strong>${registrations.length} 场</strong></header><div class="review-exam-list">${examCards || '<div class="review-empty-context"><strong>暂无考试报名</strong><p>该考生当前尚未提交考试报名;资料审核通过后才能选择考试与科目。</p></div>'}</div></section></div>${decision}</div>`);
|
||||
}
|
||||
|
||||
function openCandidatePasswordReset(id) {
|
||||
@@ -932,11 +1215,18 @@ function openCandidatePasswordReset(id) {
|
||||
function openRegistrationReview(id) {
|
||||
const reg = state.pageData.registrations.find(item => item.id === id);
|
||||
const canReview = reg.status === 'pending' && (reg.workflow?.assignee?.id === state.user.id || state.user.adminLevel === 'super');
|
||||
setModal(`<div class="modal-head"><div><span>REGISTRATION REVIEW</span><h2>${canReview ? '处理' : '查看'}考试报名</h2><p>${h(reg.candidate?.name)} · ${h(reg.exam.name)}</p></div><button data-action="close-modal">×</button></div><div class="registration-review"><div><span>报考科目</span><p>${reg.subjects.map(subject => `<b>${h(subject.name)}</b>`).join('')}</p></div><dl><div><dt>账户报名号</dt><dd class="mono">${h(reg.registrationNumber || reg.candidate?.candidateNumber || '账户号码异常')}</dd></div><div><dt>当前步骤</dt><dd>${h(reg.workflow?.currentStepDetail?.name || '流程已结束')}</dd></div><div><dt>责任人</dt><dd>${h(reg.workflow?.assignee?.displayName || '—')}</dd></div><div><dt>缴费状态</dt><dd>${badge(reg.paymentStatus)}</dd></div></dl></div>${canReview ? `<form class="modal-form" data-form="registration-review"><input type="hidden" name="id" value="${h(reg.id)}"><label><span>审核结论</span><select name="status"><option value="approved">通过当前步骤</option><option value="rejected">退回报名</option></select></label><label><span>审核意见</span><textarea name="reviewNote" rows="3" placeholder="可填写审核说明">${h(reg.reviewNote || '')}</textarea></label><div class="modal-foot"><button type="button" class="ghost-button" data-action="close-modal">取消</button><button type="submit" class="solid-button">确认处理</button></div></form>` : '<div class="modal-foot"><button class="solid-button" data-action="close-modal">关闭</button></div>'}`);
|
||||
const subjects = reg.subjects || [];
|
||||
const subjectCards = subjects.map((subject, index) => `<article><i>${index + 1}</i><div><strong>${h(subject.name)}</strong><span>${h(subject.date || '日期待定')} ${h(subject.start || '')}${subject.end ? `—${h(subject.end)}` : ''}</span></div><dl><div><dt>满分</dt><dd>${h(subject.fullScore)} 分</dd></div><div><dt>报名费</dt><dd>${money(subject.fee || 0)}</dd></div></dl></article>`).join('');
|
||||
const decision = canReview ? `<form class="review-decision-form" data-form="registration-review"><input type="hidden" name="id" value="${h(reg.id)}"><div><span>当前审核步骤</span><strong>${h(reg.workflow?.currentStepDetail?.name || '流程已结束')}</strong><small>责任人:${h(reg.workflow?.assignee?.displayName || '—')}</small></div><label><span>审核结论</span><select name="status"><option value="approved">通过当前步骤</option><option value="rejected">退回本次报名</option></select></label><label><span>审核意见</span><textarea name="reviewNote" rows="5" placeholder="填写审核依据;退回时请说明考试或科目问题">${h(reg.reviewNote || '')}</textarea></label><button type="submit" class="solid-button">确认处理</button></form>` : `<aside class="review-readonly"><strong>当前为只读查看</strong><p>${reg.status === 'pending' ? `本步骤由 ${h(reg.workflow?.assignee?.displayName || '其他管理员')} 处理。` : `报名状态:${h(statusLabels[reg.status] || reg.status)}`}</p><button class="ghost-button" data-action="close-modal">返回列表</button></aside>`;
|
||||
setReviewSubpage(`<header class="review-subpage-header"><button class="review-back" data-action="close-modal">← 返回报名审核</button><div><span>REGISTRATION DOSSIER</span><h1>${h(reg.candidate?.name)} · 报名审核</h1><p><b class="mono">${h(reg.registrationNumber || reg.candidate?.candidateNumber || '号码待同步')}</b><i></i>${h(reg.schoolName || reg.candidate?.school || '')} · ${h([reg.gradeName, reg.className].filter(Boolean).join(' · '))}</p></div>${badge(reg.status)}</header><div class="review-subpage-layout"><div class="review-subpage-main"><section class="review-section exam-context-hero"><header><span>考试</span><div><p class="exam-code">${h(reg.exam.code)}</p><h2>${h(reg.exam.name)}</h2></div></header><dl class="review-detail-grid"><div><dt>报名时间</dt><dd>${dateRange(reg.exam.registrationStart, reg.exam.registrationEnd)}</dd></div><div><dt>考试时间</dt><dd>${dateRange(reg.exam.examStart, reg.exam.examEnd)}</dd></div><div><dt>考试地点</dt><dd>${h(reg.exam.location || '待公布')}</dd></div><div><dt>本次报名</dt><dd><strong>${subjects.length} 个科目 · 应缴 ${money(reg.amountDue || 0)}</strong></dd></div><div><dt>缴费状态</dt><dd>${badge(reg.paymentStatus)}</dd></div><div><dt>账户报名号</dt><dd class="mono">${h(reg.registrationNumber || reg.candidate?.candidateNumber || '待同步')}</dd></div></dl></section><section class="review-section subject-review-section"><header><span>科目</span><div><h2>本次所报科目</h2><p>逐科核对日期、时间、满分和报名费用。</p></div><strong>${subjects.length} 科</strong></header><div class="review-subject-cards">${subjectCards || '<div class="review-empty-context"><strong>未选择任何科目</strong><p>该报名记录数据异常,不应通过审核。</p></div>'}</div><footer><span>费用合计</span><strong>${money(reg.amountDue || 0)}</strong></footer></section><section class="review-section"><header><span>考生</span><div><h2>考生资料摘要</h2><p>报名审核同时核验身份与学校范围。</p></div></header><dl class="review-detail-grid"><div><dt>姓名 / 性别</dt><dd>${h(reg.candidate?.name || '未填写')} · ${h(reg.candidate?.gender || '未填写')}</dd></div><div><dt>证件号码</dt><dd class="mono">${h(reg.candidate?.idNumber || '未填写')}</dd></div><div><dt>学校 / 班级</dt><dd>${h(reg.schoolName || '')} · ${h([reg.gradeName, reg.className].filter(Boolean).join(' · '))}</dd></div><div><dt>联系电话</dt><dd>${h(reg.candidate?.phone || '未填写')}</dd></div></dl></section></div>${decision}</div>`);
|
||||
}
|
||||
|
||||
async function openNoticeForm() {
|
||||
setModal(`<div class="modal-head"><div><span>NEW NOTICE</span><h2>发布通知公告</h2><p>发布后将立即显示在公开首页和考生中心。</p></div><button data-action="close-modal">×</button></div><form class="modal-form notice-editor-form" data-form="notice-form"><div class="field-row"><label><span>通知分类</span><select name="category"><option>报名通知</option><option>考试须知</option><option>考点公告</option><option>成绩通知</option><option>系统公告</option></select></label><label><span>发布方式</span><select name="status"><option value="published">立即发布</option><option value="draft">保存草稿</option></select></label></div><label><span>通知标题 *</span><input name="title" required placeholder="清楚说明通知事项"></label><label><span>首页摘要</span><input name="summary" placeholder="用于首页列表展示;留空将从正文自动提取"></label><div class="notice-editor-field"><span>通知正文 *</span><textarea name="content" rows="10" data-notice-editor placeholder="请输入完整通知内容"></textarea><small>图片请点工具栏“通过 URL 插入图片”并填写专用服务器地址;普通文件仍使用超链接。</small></div><label class="agreement"><input type="checkbox" name="pinned"><span>在公开首页置顶展示</span></label><div class="modal-foot"><button type="button" class="ghost-button" data-action="close-modal">取消</button><button type="submit" class="solid-button">保存通知</button></div></form>`);
|
||||
async function openNoticeForm(notice = null) {
|
||||
if (notice === undefined) return toast('草稿不存在', '请刷新页面后重试');
|
||||
const editing = Boolean(notice);
|
||||
const categories = ['报名通知', '考试须知', '考点公告', '成绩通知', '系统公告'];
|
||||
if (notice?.category && !categories.includes(notice.category)) categories.push(notice.category);
|
||||
setModal(`<div class="modal-head"><div><span>${editing ? 'EDIT DRAFT' : 'NEW NOTICE'}</span><h2>${editing ? '编辑通知草稿' : '发布通知公告'}</h2><p>${editing ? '修改后可继续保存草稿,也可直接发布。' : '发布后将立即显示在公开首页和考生中心。'}</p></div><button data-action="close-modal">×</button></div><form class="modal-form notice-editor-form" data-form="notice-form">${editing ? `<input type="hidden" name="id" value="${h(notice.id)}">` : ''}<div class="field-row"><label><span>通知分类</span><select name="category">${categories.map(category => `<option ${category === notice?.category ? 'selected' : ''}>${h(category)}</option>`).join('')}</select></label><label><span>发布方式</span><select name="status"><option value="published" ${notice?.status === 'published' ? 'selected' : ''}>立即发布</option><option value="draft" ${!notice || notice.status === 'draft' ? 'selected' : ''}>保存草稿</option></select></label></div><label><span>通知标题 *</span><input name="title" required placeholder="清楚说明通知事项" value="${h(notice?.title || '')}"></label><label><span>首页摘要</span><input name="summary" placeholder="用于首页列表展示;留空将从正文自动提取" value="${h(notice?.summary || '')}"></label><div class="notice-editor-field"><span>通知正文 *</span><textarea name="content" rows="10" data-notice-editor placeholder="请输入完整通知内容">${h(notice?.content || '')}</textarea><small>图片请点工具栏“通过 URL 插入图片”并填写专用服务器地址;普通文件仍使用超链接。</small></div><label class="agreement"><input type="checkbox" name="pinned" ${notice?.pinned ? 'checked' : ''}><span>在公开首页置顶展示</span></label><div class="modal-foot"><button type="button" class="ghost-button" data-action="close-modal">取消</button><button type="submit" class="solid-button">${editing ? '保存修改' : '保存通知'}</button></div></form>`);
|
||||
const source = modalRoot.querySelector('[data-notice-editor]');
|
||||
try {
|
||||
const [ckeditor, translation] = await loadCKEditor();
|
||||
@@ -1071,7 +1361,16 @@ function openAdmitPreview(reg) {
|
||||
}
|
||||
|
||||
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 {
|
||||
await Promise.all([refreshPublic(), refreshSession()]);
|
||||
|
||||
+10
-1
@@ -3,6 +3,7 @@ import { dirname, join, resolve } from 'node:path';
|
||||
import { mysqlSchema, sqliteSchema } from './src/database/schema.mjs';
|
||||
import { createSqliteAdapter } from './src/database/sqlite-adapter.mjs';
|
||||
import { createMysqlAdapter } from './src/database/mysql-adapter.mjs';
|
||||
import { CURRENT_SCHEMA_VERSION } from './src/database/version.mjs';
|
||||
|
||||
export const relationalTables = [
|
||||
'schema_metadata',
|
||||
@@ -60,7 +61,7 @@ export function buildSeedOperations(state) {
|
||||
const nullable = value => value == null || value === '' ? null : value;
|
||||
|
||||
add(
|
||||
'UPDATE schema_metadata SET schema_version = 20, app_version = ?, self_registration_enabled = ?, created_at = ? WHERE id = 1',
|
||||
`UPDATE schema_metadata SET schema_version = ${CURRENT_SCHEMA_VERSION}, app_version = ?, self_registration_enabled = ?, created_at = ? WHERE id = 1`,
|
||||
Number(state.meta?.version || 1), state.settings?.selfRegistrationEnabled ? 1 : 0,
|
||||
state.meta?.createdAt || new Date().toISOString()
|
||||
);
|
||||
@@ -1246,6 +1247,14 @@ function createRepository({ client, location, read, transaction, close }) {
|
||||
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) {
|
||||
const operations = [operation('DELETE FROM admission_records WHERE id = ?', record.id), operation(
|
||||
`INSERT INTO admission_records (
|
||||
|
||||
@@ -85,7 +85,8 @@ const resourceSpecs = {
|
||||
results: {
|
||||
title: '考试成绩台账', sheet: '成绩',
|
||||
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, ''],
|
||||
['subjectName', '科目*', 16, '语文'], ['fullScore', '科目满分(只读参考)', 18, ''],
|
||||
['passRule', '单科及格规则(只读参考)', 26, ''], ['passScore', '实际及格分(只读参考)', 20, ''],
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node server.mjs",
|
||||
"test": "node tests/cache.test.mjs && node tests/admission.test.mjs && node tests/system.test.mjs",
|
||||
"test": "node tests/client-auth.test.mjs && node tests/cache.test.mjs && node tests/admission.test.mjs && node tests/seed.test.mjs && node tests/system.test.mjs",
|
||||
"test:cache": "node tests/cache.test.mjs",
|
||||
"reset-db": "node scripts/reset-dev-database.mjs",
|
||||
"seed-test-data": "node scripts/import-test-data.mjs",
|
||||
|
||||
@@ -6,6 +6,7 @@ import { loadEnvFile } from 'node:process';
|
||||
import { buildSeedOperations, createDatabase, relationalTables } from '../database.mjs';
|
||||
import { createBaseDatabase } from '../src/data/base.mjs';
|
||||
import { createSeedDatabase } from '../src/data/seed.mjs';
|
||||
import { CURRENT_SCHEMA_VERSION } from '../src/database/version.mjs';
|
||||
|
||||
const root = resolve(process.cwd());
|
||||
const envPath = join(root, '.env');
|
||||
@@ -101,8 +102,8 @@ async function prepareMysql() {
|
||||
}
|
||||
|
||||
const [metadataRows] = await connection.query('SELECT schema_version FROM schema_metadata WHERE id = 1');
|
||||
if (Number(metadataRows[0]?.schema_version) !== 16) {
|
||||
throw new Error(`MySQL 数据库结构版本不是 v16(当前 ${metadataRows[0]?.schema_version ?? '未知'}),请先完成结构初始化`);
|
||||
if (Number(metadataRows[0]?.schema_version) !== CURRENT_SCHEMA_VERSION) {
|
||||
throw new Error(`MySQL 数据库结构版本不是 v${CURRENT_SCHEMA_VERSION}(当前 ${metadataRows[0]?.schema_version ?? '未知'}),请先完成结构初始化`);
|
||||
}
|
||||
|
||||
const [examPartitionRows] = await connection.query(
|
||||
@@ -127,8 +128,8 @@ async function prepareMysql() {
|
||||
let recognizedTestData = false;
|
||||
if (initializeEmpty && nonEmpty.length) {
|
||||
const [[bulkUsers]] = await connection.query("SELECT COUNT(*) AS count FROM users WHERE id LIKE 'usr_bulk_%'");
|
||||
const [[testSchools]] = await connection.query("SELECT COUNT(*) AS count FROM schools WHERE id IN ('school_hz1', 'school_hz3', 'school_hz5', 'school_hz7')");
|
||||
recognizedTestData = Number(bulkUsers.count) >= 300 && Number(testSchools.count) === 4;
|
||||
const [[testSchools]] = await connection.query("SELECT COUNT(*) AS count FROM schools WHERE id IN ('school_hz1', 'school_hz3', 'school_hz5', 'school_hz7', 'school_hz9')");
|
||||
recognizedTestData = Number(bulkUsers.count) >= 1100 && Number(testSchools.count) === 5;
|
||||
}
|
||||
if (nonEmpty.length && !force && !recognizedTestData) {
|
||||
const forceCommand = initializeEmpty
|
||||
@@ -193,8 +194,9 @@ if (initializeEmpty) {
|
||||
console.log(`${state.users.length} initial administrator, ${state.schools.length} schools, ${state.candidateProfiles.length} candidates, ${state.registrations.length} registrations`);
|
||||
} else {
|
||||
console.log(`Imported test data into ${location}${mysqlAlreadyImported ? ' (transactional replace)' : ''}`);
|
||||
console.log(`${state.schools.length} schools, ${state.candidateProfiles.length} candidates, ${state.registrations.length} registrations`);
|
||||
console.log(`${state.schools.filter(item => item.isSourceSchool).length} source schools, ${state.schools.filter(item => item.isAdmissionSchool).length} admission schools, ${state.candidateProfiles.length} candidates, ${state.registrations.length} registrations`);
|
||||
console.log(`pending ${pending}, rejected ${rejected}, approved/unpaid ${unpaid}, approved/paid ${paid}`);
|
||||
console.log(`${state.results.length} published subject scores, ${state.admissionRecords.filter(item => item.kind === 'preference' && Number(item.payload?.round || 1) === 1).length} first-round preferences`);
|
||||
console.log(`arrangement plans ${state.arrangementPlans.length}, admit cards ${state.registrations.filter(item => item.admitCard).length}`);
|
||||
console.log('all predefined test account passwords: 12345678');
|
||||
}
|
||||
|
||||
@@ -1,35 +1,4 @@
|
||||
import { pbkdf2Sync, randomBytes } from 'node:crypto';
|
||||
import { rm } from 'node:fs/promises';
|
||||
import { isAbsolute, join, relative, resolve } from 'node:path';
|
||||
import { createDatabase } from '../database.mjs';
|
||||
import { createBaseDatabase } from '../src/data/base.mjs';
|
||||
|
||||
const root = resolve(process.cwd());
|
||||
const databasePath = join(root, 'data', 'exam.sqlite');
|
||||
const relativePath = relative(root, databasePath);
|
||||
if (!relativePath || relativePath.startsWith('..') || isAbsolute(relativePath)) throw new Error('拒绝重建工作区以外的数据库');
|
||||
|
||||
for (const path of [databasePath, `${databasePath}-shm`, `${databasePath}-wal`]) await rm(path, { force: true });
|
||||
|
||||
function hashPassword(password, salt = randomBytes(16).toString('hex')) {
|
||||
const hash = pbkdf2Sync(password, salt, 120000, 32, 'sha256').toString('hex');
|
||||
return `${salt}:${hash}`;
|
||||
}
|
||||
|
||||
process.env.DATABASE_CLIENT = 'sqlite';
|
||||
process.env.SQLITE_PATH = databasePath;
|
||||
const database = await createDatabase({
|
||||
root,
|
||||
seed: () => createBaseDatabase({
|
||||
nowIso: () => new Date().toISOString(),
|
||||
hashPassword,
|
||||
initialAdmin: {
|
||||
username: process.env.INITIAL_ADMIN_USERNAME,
|
||||
password: process.env.INITIAL_ADMIN_PASSWORD,
|
||||
displayName: process.env.INITIAL_ADMIN_DISPLAY_NAME
|
||||
}
|
||||
})
|
||||
});
|
||||
const state = await database.read();
|
||||
await database.close();
|
||||
console.log(`Rebuilt empty ${databasePath} (schema ${state.meta.version}, ${state.candidateProfiles.length} candidates)`);
|
||||
// Keep the historical reset-db entry point, but run the same guarded initializer used
|
||||
// by initialize-system so SQLite, MySQL, .env loading and schema checks stay in sync.
|
||||
if (!process.argv.slice(2).includes('--empty')) process.argv.push('--empty');
|
||||
await import('./import-test-data.mjs');
|
||||
|
||||
+93
-36
@@ -100,7 +100,7 @@ const initializeDatabase = () => createBaseDatabase({
|
||||
});
|
||||
const persistentDatabase = await createDatabase({ root, seed: initializeDatabase });
|
||||
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 namespaces = ['public'];
|
||||
const instance = args[0];
|
||||
@@ -357,12 +357,46 @@ function gradeForRank(rank, cohortSize) {
|
||||
return 'D';
|
||||
}
|
||||
|
||||
const resultIndexCache = new WeakMap();
|
||||
const examSummaryCache = new WeakMap();
|
||||
|
||||
function resultIndexes(db) {
|
||||
let indexes = resultIndexCache.get(db);
|
||||
if (indexes) return indexes;
|
||||
const publishedScoresBySubject = new Map();
|
||||
const publishedByRegistration = new Map();
|
||||
for (const result of db.results) {
|
||||
if (!result.published) continue;
|
||||
if (!publishedScoresBySubject.has(result.subjectId)) publishedScoresBySubject.set(result.subjectId, []);
|
||||
publishedScoresBySubject.get(result.subjectId).push(Number(result.score));
|
||||
if (!publishedByRegistration.has(result.registrationId)) publishedByRegistration.set(result.registrationId, []);
|
||||
publishedByRegistration.get(result.registrationId).push(result);
|
||||
}
|
||||
for (const scores of publishedScoresBySubject.values()) scores.sort((left, right) => left - right);
|
||||
indexes = { publishedScoresBySubject, publishedByRegistration };
|
||||
resultIndexCache.set(db, indexes);
|
||||
return indexes;
|
||||
}
|
||||
|
||||
function countScoresGreaterThan(sortedScores, score) {
|
||||
let low = 0;
|
||||
let high = sortedScores.length;
|
||||
while (low < high) {
|
||||
const middle = Math.floor((low + high) / 2);
|
||||
if (sortedScores[middle] <= score) low = middle + 1;
|
||||
else high = middle;
|
||||
}
|
||||
return sortedScores.length - low;
|
||||
}
|
||||
|
||||
function resultRankInfo(db, result, scoreOverride = result?.score) {
|
||||
if (!result?.subjectId || !Number.isFinite(Number(scoreOverride))) return { rank: null, cohortSize: 0, rankPercent: null, grade: '' };
|
||||
const score = Number(scoreOverride);
|
||||
const peers = db.results.filter(item => item.id !== result.id && item.subjectId === result.subjectId && item.published);
|
||||
const cohortSize = peers.length + 1;
|
||||
const rank = 1 + peers.filter(item => Number(item.score) > score).length;
|
||||
const scores = resultIndexes(db).publishedScoresBySubject.get(result.subjectId) || [];
|
||||
const cohortSize = scores.length + (result.published ? 0 : 1);
|
||||
let greater = countScoresGreaterThan(scores, score);
|
||||
if (result.published && Number(result.score) > score) greater -= 1;
|
||||
const rank = 1 + Math.max(0, greater);
|
||||
const rankPercent = Number((rank / cohortSize * 100).toFixed(2));
|
||||
return { rank, cohortSize, rankPercent, grade: gradeForRank(rank, cohortSize) };
|
||||
}
|
||||
@@ -379,15 +413,43 @@ function subjectPassEvaluation(db, result, subject, scoreOverride = result?.scor
|
||||
return { ...resultRankInfo(db, result, scoreOverride), qualified: Number(scoreOverride) >= passScore, passScore, cutoffRank: null };
|
||||
}
|
||||
|
||||
function examSummaryIndexes(db) {
|
||||
let indexes = examSummaryCache.get(db);
|
||||
if (indexes) return indexes;
|
||||
const examById = new Map(db.exams.map(exam => [exam.id, exam]));
|
||||
const publishedByRegistration = resultIndexes(db).publishedByRegistration;
|
||||
const baseByRegistration = new Map();
|
||||
const totalsByCohort = new Map();
|
||||
for (const registration of db.registrations) {
|
||||
if (registration.status !== 'approved') continue;
|
||||
const exam = examById.get(registration.examId);
|
||||
if (!exam) continue;
|
||||
const subjects = exam.subjects.filter(subject => registration.subjectIds.includes(subject.id));
|
||||
const published = publishedByRegistration.get(registration.id) || [];
|
||||
const resultsBySubject = new Map(published.map(result => [result.subjectId, result]));
|
||||
const complete = subjects.length > 0 && subjects.every(subject => resultsBySubject.has(subject.id));
|
||||
const total = subjects.reduce((sum, subject) => sum + Number(resultsBySubject.get(subject.id)?.score || 0), 0);
|
||||
const fullScore = subjects.reduce((sum, subject) => sum + Number(subject.fullScore || 0), 0);
|
||||
const subjectKey = [...registration.subjectIds].sort().join('|');
|
||||
const cohortKey = `${exam.id}\u0000${subjectKey}`;
|
||||
const base = { exam, subjects, published, resultsBySubject, complete, total, fullScore, cohortKey };
|
||||
baseByRegistration.set(registration.id, base);
|
||||
if (complete) {
|
||||
if (!totalsByCohort.has(cohortKey)) totalsByCohort.set(cohortKey, []);
|
||||
totalsByCohort.get(cohortKey).push(total);
|
||||
}
|
||||
}
|
||||
for (const totals of totalsByCohort.values()) totals.sort((left, right) => left - right);
|
||||
indexes = { baseByRegistration, totalsByCohort };
|
||||
examSummaryCache.set(db, indexes);
|
||||
return indexes;
|
||||
}
|
||||
|
||||
function examResultSummary(db, registration) {
|
||||
const exam = db.exams.find(item => item.id === registration.examId);
|
||||
if (!exam) return null;
|
||||
const subjects = exam.subjects.filter(subject => registration.subjectIds.includes(subject.id));
|
||||
const published = db.results.filter(result => result.registrationId === registration.id && result.published);
|
||||
const resultsBySubject = new Map(published.map(result => [result.subjectId, result]));
|
||||
const complete = subjects.length > 0 && subjects.every(subject => resultsBySubject.has(subject.id));
|
||||
const total = subjects.reduce((sum, subject) => sum + Number(resultsBySubject.get(subject.id)?.score || 0), 0);
|
||||
const fullScore = subjects.reduce((sum, subject) => sum + Number(subject.fullScore || 0), 0);
|
||||
const summaryIndexes = examSummaryIndexes(db);
|
||||
const base = summaryIndexes.baseByRegistration.get(registration.id);
|
||||
if (!base) return null;
|
||||
const { exam, subjects, published, resultsBySubject, complete, total, fullScore, cohortKey } = base;
|
||||
const scoreRatio = fullScore ? total / fullScore * 100 : 0;
|
||||
const policy = exam.passPolicy === 'score_ratio' ? 'rank_percent' : (exam.passPolicy || 'rank_percent');
|
||||
const value = Number(exam.passValue ?? 60);
|
||||
@@ -402,17 +464,9 @@ function examResultSummary(db, registration) {
|
||||
});
|
||||
if (complete && policy === 'none') qualified = null;
|
||||
if (complete && policy === 'rank_percent') {
|
||||
const subjectKey = [...registration.subjectIds].sort().join('|');
|
||||
const totals = db.registrations
|
||||
.filter(item => item.examId === exam.id && item.status === 'approved' && [...item.subjectIds].sort().join('|') === subjectKey)
|
||||
.map(item => {
|
||||
const itemResults = db.results.filter(result => result.registrationId === item.id && result.published);
|
||||
if (!item.subjectIds.every(id => itemResults.some(result => result.subjectId === id))) return null;
|
||||
return itemResults.filter(result => item.subjectIds.includes(result.subjectId)).reduce((sum, result) => sum + Number(result.score), 0);
|
||||
})
|
||||
.filter(item => item != null);
|
||||
const totals = summaryIndexes.totalsByCohort.get(cohortKey) || [];
|
||||
cohortSize = totals.length;
|
||||
rank = 1 + totals.filter(item => item > total).length;
|
||||
rank = 1 + countScoresGreaterThan(totals, total);
|
||||
qualified = rank <= Math.max(1, Math.ceil(cohortSize * value / 100));
|
||||
}
|
||||
|
||||
@@ -558,23 +612,26 @@ function excelRowsForResource(db, user, resource, searchParams) {
|
||||
});
|
||||
if (resource === 'results') {
|
||||
const examId = cleanText(searchParams.get('examId'), 64);
|
||||
const scopedRegistrations = db.registrations.filter(item => registrationInScope(db, user, item));
|
||||
return db.results.filter(result => scopedRegistrations.some(item => item.id === result.registrationId && (!examId || item.examId === examId))).map(result => {
|
||||
const registration = db.registrations.find(item => item.id === result.registrationId);
|
||||
const scopedRegistrations = db.registrations.filter(item => item.status === 'approved' && registrationInScope(db, user, item) && (!examId || item.examId === examId));
|
||||
return scopedRegistrations.flatMap(registration => {
|
||||
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 profile = db.candidateProfiles.find(item => item.userId === registration?.userId);
|
||||
const evaluation = subjectPassEvaluation(db, result, subject);
|
||||
const rank = resultRankInfo(db, result);
|
||||
return {
|
||||
candidateNumber: account?.candidateNumber || '', candidateName: profile?.name || account?.displayName || '',
|
||||
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: evaluation.qualified == null ? '不判定' : evaluation.qualified ? '达线' : '未达线',
|
||||
grade: result.published ? resultRankInfo(db, result).grade : '待发布', published: result.published ? '发布' : '不发布', updatedAt: result.updatedAt || result.publishedAt || ''
|
||||
};
|
||||
const schoolClass = db.classes.find(item => item.id === profile?.classId);
|
||||
return (exam?.subjects || []).filter(subject => registration.subjectIds.includes(subject.id)).map(subject => {
|
||||
const result = db.results.find(item => item.registrationId === registration.id && item.subjectId === subject.id);
|
||||
const evaluation = result ? subjectPassEvaluation(db, result, subject) : null;
|
||||
const rank = result ? resultRankInfo(db, result) : null;
|
||||
return {
|
||||
candidateNumber: account?.candidateNumber || registration.registrationNumber || '', cardNumber: registration.admitCard?.number || '',
|
||||
candidateName: profile?.name || account?.displayName || '', schoolName: profile?.school || '', className: schoolClass?.name || profile?.grade || '',
|
||||
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') {
|
||||
|
||||
+138
-43
@@ -21,6 +21,7 @@ export function createAdminViews(context) {
|
||||
icons,
|
||||
api,
|
||||
renderError,
|
||||
requireLogin,
|
||||
emptyState,
|
||||
brand,
|
||||
portalShell,
|
||||
@@ -30,14 +31,14 @@ export function createAdminViews(context) {
|
||||
} = context;
|
||||
|
||||
async function renderAdmin(page) {
|
||||
if (state.user?.role !== 'admin') return navigate('login');
|
||||
if (state.user?.role !== 'admin') return requireLogin();
|
||||
app.classList.add('admin-readable');
|
||||
const meta = {
|
||||
dashboard: ['考务工作台', '掌握当前报名、审核和发布任务。'], candidates: ['考生资料审核', '核验考生实名、学籍与联系信息。'],
|
||||
schools: ['学校管理', '创建和维护学校档案,控制学校在考生公开入口中的可选状态。'],
|
||||
registrations: ['考试报名审核', '确认考生所报考试、科目与缴费状态。'], exams: ['考试与科目', '创建考试、配置报名时间与考试科目。'],
|
||||
payments: [state.user.adminLevel === 'class' ? '考生缴费确认' : '缴费名单', '查看、导出并修改当前管理范围内考生的考试缴费状态。'],
|
||||
notices: ['通知发布', '发布后立即展示在公开首页和考生中心。'], admit: [state.user.adminLevel === 'super' ? '准考证编排' : state.user.adminLevel === 'school' ? '校内准考证' : '本班准考证', state.user.adminLevel === 'super' ? '按整场考试预检容量,并批量分配固定考点、分科考场与准考证号。' : '按当前管理范围批量下载准考证,并导出逐科准考证信息。'],
|
||||
notices: ['通知发布', '编辑通知草稿,并统一控制手动通知与系统公示是否公开显示。'], admit: [state.user.adminLevel === 'super' ? '准考证编排' : state.user.adminLevel === 'school' ? '校内准考证' : '本班准考证', state.user.adminLevel === 'super' ? '按整场考试预检容量,并批量分配固定考点、分科考场与准考证号。' : '按当前管理范围批量下载准考证,并导出逐科准考证信息。'],
|
||||
results: [state.user.adminLevel === 'super' ? '成绩管理中心' : '成绩分析', state.user.adminLevel === 'super' ? '按考试录入、预览导入、发布并跟踪各科达线情况。' : '按考试、科目和发布状态分析管理范围内成绩。'],
|
||||
admins: ['分级管理员', '同一级可以配置多名管理员,并分别绑定学校或班级。'],
|
||||
centers: ['考务场所档案', state.user.adminLevel === 'school' ? '查看本校考点与结构化考场,所有变更提交后进入审批。' : '管理各校考点、考场容量与变更审批台账。'],
|
||||
@@ -55,11 +56,16 @@ export function createAdminViews(context) {
|
||||
app.innerHTML = portalShell('admin', page, loadingPanel(), ...meta[page]);
|
||||
try {
|
||||
const endpoint = page === 'admit' ? 'admission-arrangements' : page === 'flows' ? 'workflow-instances' : page === 'flow-design' ? 'workflows' : page === 'account-batches' ? 'candidate-account-batches' : page === 'organization' ? 'school-organization' : page;
|
||||
const data = page === 'security' ? await api('/api/auth/totp') : await api(`/api/admin/${endpoint}`);
|
||||
const query = page === 'results' && state.resultExamFilter ? `?examId=${encodeURIComponent(state.resultExamFilter)}` : '';
|
||||
const data = page === 'results' && !state.resultExamFilter
|
||||
? { ok: true, selectedExamId: '', results: [], appeals: [], registrations: [], exams: state.resultExamCatalog || state.publicData.exams || [], resultCache: { enabled: false, status: 'not-loaded' } }
|
||||
: page === 'security' ? await api('/api/auth/totp') : await api(`/api/admin/${endpoint}${query}`);
|
||||
if (page === 'results' && data.selectedExamId) state.resultExamFilter = data.selectedExamId;
|
||||
if (page === 'results' && data.exams?.length) state.resultExamCatalog = data.exams;
|
||||
state.pageData = data;
|
||||
const content = {
|
||||
dashboard: () => adminDashboard(data), candidates: () => adminCandidates(data), registrations: () => adminRegistrations(data.registrations), payments: () => adminPayments(data),
|
||||
exams: () => adminExams(data.exams), notices: () => adminNotices(data.notices), admit: () => adminAdmit(data), results: () => adminResults(data),
|
||||
exams: () => adminExams(data.exams), notices: () => adminNotices(data.notices, data.publications), admit: () => adminAdmit(data), results: () => adminResults(data),
|
||||
schools: () => adminSchoolsV2(data), admins: () => adminUsers(data), centers: () => adminCenters(data), flows: () => adminFlows(data), organization: () => adminSchoolOrganization(data), 'account-batches': () => adminAccountBatches(data),
|
||||
'flow-design': () => adminFlowDesign(data.workflows), 'number-rules': () => adminNumberRules(data), admissions: () => adminAdmissionsV2(data), 'indicator-qualifications': () => adminIndicatorQualifications(data), security: () => accountSecurity(data)
|
||||
}[page]();
|
||||
@@ -71,7 +77,8 @@ export function createAdminViews(context) {
|
||||
function mountPreferenceLedger(data) {
|
||||
const container = app.querySelector('.portal-content');
|
||||
if (!container || !data.preferences?.length) return;
|
||||
container.insertAdjacentHTML('beforeend', `<section class="panel data-panel admission-preference-ledger"><div class="panel-title"><div><h2>考生志愿只读台账</h2><p>仅超级管理员可查看;系统不提供任何管理员修改入口。</p></div><span>${data.preferences.length} 份</span></div><div class="table-scroll"><table><thead><tr><th>报名号 / 考生</th><th>考试</th><th>轮次</th><th>志愿顺序</th><th>本人提交时间</th></tr></thead><tbody>${data.preferences.map(item => `<tr><td><strong>${h(item.candidate.name)}</strong><small class="mono">${h(item.candidate.registrationNumber)}</small></td><td>${h(data.exams.find(exam => exam.id === item.examId)?.name || item.examId)}</td><td>第 ${h(item.payload.round || 1)} 轮</td><td>${item.choices.map((choice, index) => `${index + 1}. ${h(choice.schoolName)} · ${h(choice.categoryCode)}`).join('<br>')}</td><td>${formatDate(item.payload.submittedAt, true)}</td></tr>`).join('')}</tbody></table></div></section>`);
|
||||
const preferencePage = paged(data.preferences, 'adminPreferenceTable');
|
||||
container.insertAdjacentHTML('beforeend', `<section class="panel data-panel admission-preference-ledger"><div class="panel-title"><div><h2>考生志愿只读台账</h2><p>仅超级管理员可查看;系统不提供任何管理员修改入口。</p></div><span>${data.preferences.length} 份</span></div><div class="table-scroll"><table id="adminPreferenceTable"><thead><tr><th>报名号 / 考生</th><th>考试</th><th>轮次</th><th>志愿顺序</th><th>本人提交时间</th></tr></thead><tbody>${preferencePage.items.map(item => `<tr><td><strong>${h(item.candidate.name)}</strong><small class="mono">${h(item.candidate.registrationNumber)}</small></td><td>${h(data.exams.find(exam => exam.id === item.examId)?.name || item.examId)}</td><td>第 ${h(item.payload.round || 1)} 轮</td><td>${item.choices.map((choice, index) => `${index + 1}. ${h(choice.schoolName)} · ${h(choice.categoryCode)}`).join('<br>')}</td><td>${formatDate(item.payload.submittedAt, true)}</td></tr>`).join('')}</tbody></table></div>${pagination(preferencePage)}</section>`);
|
||||
}
|
||||
|
||||
function adminDashboard(data) {
|
||||
@@ -105,44 +112,93 @@ export function createAdminViews(context) {
|
||||
}
|
||||
|
||||
function adminCandidates(data) {
|
||||
const candidates = data.candidates || [];
|
||||
const readOnly = false;
|
||||
const allCandidates = data.candidates || [];
|
||||
const candidatePage = paged(allCandidates, 'candidateTable');
|
||||
const candidates = candidatePage.items;
|
||||
const canReview = state.user.adminLevel === 'super' || state.permissions?.includes('candidates.review');
|
||||
const classById = new Map((data.classes || []).map(item => [item.id, item]));
|
||||
const optionList = getter => [...new Set(allCandidates.map(getter).filter(Boolean))]
|
||||
.sort((left, right) => left.localeCompare(right, 'zh-CN'))
|
||||
.map(value => `<option value="${h(value)}">${h(value)}</option>`).join('');
|
||||
const archiveConsole = state.user.adminLevel === 'school' ? (() => {
|
||||
const classes = data.classes || [];
|
||||
const grades = [...new Set(classes.map(item => item.grade))];
|
||||
return `<section class="panel candidate-archive-console"><div><span>SCHOOL ACCOUNT ARCHIVE</span><h2>按班级或年级归档账户</h2><p>归档只冻结登录,不删除考生、报名、准考证、成绩和审计记录;可随时按相同范围恢复。</p></div><div class="archive-controls"><select data-archive-scope><option value="">请选择范围</option><optgroup label="按班级">${classes.map(item => `<option value="class:${h(item.id)}">${h(item.grade)} · ${h(item.name)}</option>`).join('')}</optgroup><optgroup label="按年级">${grades.map(grade => `<option value="grade:${h(grade)}">${h(grade)}全部班级</option>`).join('')}</optgroup></select><button class="ghost-button" data-action="candidate-archive" data-archived="false">恢复账户</button><button class="solid-button archive-button" data-action="candidate-archive" data-archived="true">归档账户</button></div></section>`;
|
||||
})() : '';
|
||||
return `${archiveConsole}${excelToolbar('candidates', { importable: !readOnly, label: '考生资料' })}<section class="panel data-panel"><div class="data-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="candidateTable" placeholder="搜索报名号、姓名、证件号或学校"></label><div class="filter-pills"><button class="active" data-action="status-filter" data-target="candidateTable" data-status="all">全部</button><button data-action="status-filter" data-target="candidateTable" data-status="pending">待审核</button><button data-action="status-filter" data-target="candidateTable" data-status="approved">已通过</button><button data-action="status-filter" data-target="candidateTable" data-status="rejected">需修改</button><button data-action="status-filter" data-target="candidateTable" data-status="archived">已归档</button></div></div><div class="table-scroll"><table id="candidateTable"><thead><tr><th>报名号 / 考生</th><th>证件号码</th><th>学校 / 班级</th><th>账户状态</th><th>更新时间</th><th>资料状态</th><th>操作</th></tr></thead><tbody>${candidates.map(item => `<tr class="${item.accountArchived ? 'account-archived-row' : ''}" data-status="${h(item.status)} ${item.accountArchived ? 'archived' : 'active'}"><td><div class="person-cell"><span>${h(item.name.slice(0,1))}</span><div><strong>${h(item.name)}</strong><small class="mono">${h(item.candidateNumber || '待分配')}</small></div></div></td><td class="mono">${h(item.idNumberMasked)}</td><td><strong>${h(item.school || '未填写')}</strong><small>${h(item.grade || '')}</small></td><td><strong>${item.accountArchived ? '已归档' : item.mustChangePassword ? '待首次改密' : item.profileCompleted ? '正常' : '待补全资料'}</strong><small>${item.accountArchived ? `${formatDate(item.archivedAt, true)} · ${h(item.archivedByName || '校方')}` : h(item.workflow?.assignee?.displayName || '')}</small></td><td>${formatDate(item.updatedAt,true)}</td><td>${item.profileCompleted ? badge(item.status) : '<span class="onboarding-badge">未完成</span>'}</td><td><div class="candidate-account-actions"><button class="row-action" data-action="review-candidate" data-id="${h(item.id)}" ${item.profileCompleted && !item.accountArchived ? '' : 'disabled'}>${item.profileCompleted ? (readOnly ? '查看' : '查看流程') : '等待考生'}</button>${state.user.adminLevel === 'super' ? `<button class="row-action primary" data-action="reset-candidate-password" data-id="${h(item.id)}" ${item.accountArchived ? 'disabled' : ''}>重置密码</button>` : ''}</div></td></tr>`).join('')}</tbody></table></div></section>`;
|
||||
const rows = candidates.map(item => {
|
||||
const schoolClass = classById.get(item.classId);
|
||||
const grade = schoolClass?.grade || '';
|
||||
const className = schoolClass?.name || item.grade || '';
|
||||
const exams = item.registrations || [];
|
||||
const canSelect = canReview && item.profileCompleted && !item.accountArchived && item.status === 'pending' && item.workflow?.status === 'pending'
|
||||
&& (state.user.adminLevel === 'super' || item.workflow?.assignee?.id === state.user.id);
|
||||
const examNames = exams.map(registration => registration.exam?.name).filter(Boolean);
|
||||
return `<tr class="${item.accountArchived ? 'account-archived-row' : ''}" data-filter-row data-status="${h(item.status)} ${item.accountArchived ? 'archived' : 'active'}" data-school="${h(item.school || '')}" data-grade="${h(grade)}" data-class="${h(className)}" data-exam="${h(examNames.join('|'))}"><td class="selection-cell"><input type="checkbox" data-candidate-select value="${h(item.id)}" aria-label="选择 ${h(item.name)}" ${canSelect ? '' : 'disabled'}></td><td><div class="person-cell"><span>${h(item.name.slice(0,1))}</span><div><strong>${h(item.name)}</strong><small class="mono">${h(item.candidateNumber || '待分配')}</small></div></div></td><td class="mono">${h(item.idNumberMasked)}</td><td><strong>${h(item.school || '未填写')}</strong><small>${h([grade, className].filter(Boolean).join(' · '))}</small></td><td><div class="candidate-exam-summary">${exams.length ? `<strong>${h(examNames.slice(0, 2).join('、'))}${exams.length > 2 ? ` 等 ${exams.length} 场` : ''}</strong><small>${h(exams.flatMap(registration => registration.subjects || []).map(subject => subject.name).slice(0, 5).join('、') || '尚未选择科目')}</small>` : '<span>暂无考试报名</span>'}</div></td><td><strong>${item.accountArchived ? '已归档' : item.mustChangePassword ? '待首次改密' : item.profileCompleted ? '正常' : '待补全资料'}</strong><small>${item.accountArchived ? `${formatDate(item.archivedAt, true)} · ${h(item.archivedByName || '校方')}` : h(item.workflow?.assignee?.displayName || '')}</small></td><td>${item.profileCompleted ? badge(item.status) : '<span class="onboarding-badge">未完成</span>'}</td><td><div class="candidate-account-actions"><button class="row-action primary" data-action="review-candidate" data-id="${h(item.id)}" ${item.profileCompleted && !item.accountArchived ? '' : 'disabled'}>${item.profileCompleted ? '进入审核页' : '等待考生'}</button>${state.user.adminLevel === 'super' ? `<button class="row-action" data-action="reset-candidate-password" data-id="${h(item.id)}" ${item.accountArchived ? 'disabled' : ''}>重置密码</button>` : ''}</div></td></tr>`;
|
||||
}).join('');
|
||||
const bulkToolbar = canReview ? `<div class="registration-bulk-bar candidate-bulk-bar"><span>已选择 <strong data-candidate-selection-count>0</strong> 名当前可处理考生</span><div><button class="row-action" data-action="bulk-candidate-review" data-status="rejected" disabled>批量退回修改</button><button class="row-action primary" data-action="bulk-candidate-review" data-status="approved" disabled>批量通过当前步骤</button></div></div>` : '';
|
||||
const examOptions = [...new Set(allCandidates.flatMap(item => (item.registrations || []).map(registration => registration.exam?.name).filter(Boolean)))]
|
||||
.sort((left, right) => left.localeCompare(right, 'zh-CN'))
|
||||
.map(value => `<option value="${h(value)}">${h(value)}</option>`).join('');
|
||||
const filters = `<div class="table-filter-selects"><select data-table-filter="school" data-target="candidateTable"><option value="">全部学校</option>${optionList(item => item.school)}</select><select data-table-filter="grade" data-target="candidateTable"><option value="">全部年级</option>${optionList(item => classById.get(item.classId)?.grade)}</select><select data-table-filter="class" data-target="candidateTable"><option value="">全部班级</option>${optionList(item => classById.get(item.classId)?.name || item.grade)}</select><select data-table-filter="exam" data-target="candidateTable"><option value="">全部关联考试</option>${examOptions}</select></div>`;
|
||||
return `${archiveConsole}${excelToolbar('candidates', { importable: true, label: '考生资料' })}<section class="panel data-panel candidate-ledger">${bulkToolbar}<div class="data-toolbar candidate-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="candidateTable" placeholder="搜索当前页的报名号、姓名、证件号、考试或学校"></label><div class="filter-pills"><button class="active" data-action="status-filter" data-target="candidateTable" data-status="all">全部</button><button data-action="status-filter" data-target="candidateTable" data-status="pending">待审核</button><button data-action="status-filter" data-target="candidateTable" data-status="approved">已通过</button><button data-action="status-filter" data-target="candidateTable" data-status="rejected">需修改</button><button data-action="status-filter" data-target="candidateTable" data-status="archived">已归档</button></div>${filters}</div><div class="table-scroll"><table id="candidateTable"><thead><tr><th class="selection-cell"><input type="checkbox" data-candidate-select-all data-target="candidateTable" aria-label="全选当前筛选结果"></th><th>报名号 / 考生</th><th>证件号码</th><th>学校 / 年级 / 班级</th><th>关联考试 / 科目</th><th>账户状态</th><th>资料状态</th><th>操作</th></tr></thead><tbody>${rows || '<tr><td colspan="8" class="empty-state">当前范围暂无考生</td></tr>'}</tbody></table></div>${pagination(candidatePage)}</section>`;
|
||||
}
|
||||
|
||||
function paged(items, key, defaultPageSize = 50) {
|
||||
const current = state.tablePages[key] || {};
|
||||
const pageSize = [20, 50, 100].includes(Number(current.pageSize)) ? Number(current.pageSize) : defaultPageSize;
|
||||
const total = items.length;
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||
const page = Math.min(Math.max(1, Number(current.page) || 1), totalPages);
|
||||
state.tablePages[key] = { page, pageSize };
|
||||
return { items: items.slice((page - 1) * pageSize, page * pageSize), page, pageSize, total, totalPages, key };
|
||||
}
|
||||
|
||||
function pagination(meta) {
|
||||
if (!meta || meta.total <= meta.pageSize) return '';
|
||||
const start = (meta.page - 1) * meta.pageSize + 1;
|
||||
const end = Math.min(meta.total, meta.page * meta.pageSize);
|
||||
const pages = [...new Set([1, meta.page - 1, meta.page, meta.page + 1, meta.totalPages])].filter(page => page >= 1 && page <= meta.totalPages);
|
||||
return `<nav class="table-pagination" aria-label="列表分页"><span>第 ${start}—${end} 条,共 ${meta.total} 条</span><div><button type="button" data-action="table-page" data-table-key="${h(meta.key)}" data-page="${meta.page - 1}" ${meta.page === 1 ? 'disabled' : ''}>上一页</button>${pages.map((page, index) => `${index && page - pages[index - 1] > 1 ? '<i>…</i>' : ''}<button type="button" class="${page === meta.page ? 'active' : ''}" data-action="table-page" data-table-key="${h(meta.key)}" data-page="${page}">${page}</button>`).join('')}<button type="button" data-action="table-page" data-table-key="${h(meta.key)}" data-page="${meta.page + 1}" ${meta.page === meta.totalPages ? 'disabled' : ''}>下一页</button><label>每页 <select data-action="table-page-size" data-table-key="${h(meta.key)}">${[20, 50, 100].map(size => `<option value="${size}" ${size === meta.pageSize ? 'selected' : ''}>${size}</option>`).join('')}</select> 条</label></div></nav>`;
|
||||
}
|
||||
|
||||
function adminRegistrations(registrations) {
|
||||
const isSuper = state.user.adminLevel === 'super';
|
||||
const canReview = state.user.adminLevel === 'super' || state.permissions?.includes('registrations.review');
|
||||
const optionList = (getter) => [...new Set(registrations.map(getter).filter(Boolean))]
|
||||
.sort((left, right) => left.localeCompare(right, 'zh-CN'))
|
||||
.map(value => `<option value="${h(value)}">${h(value)}</option>`).join('');
|
||||
const rows = (items, selectable = false) => items.map(reg => {
|
||||
const canSelect = selectable && reg.status === 'pending' && reg.workflow?.status === 'pending';
|
||||
return `<tr data-filter-row data-status="${h(reg.status)}" data-exam="${h(reg.exam?.name || '')}" data-school="${h(reg.schoolName || '')}" data-grade="${h(reg.gradeName || '')}" data-class="${h(reg.className || '')}">${selectable ? `<td class="selection-cell"><input type="checkbox" data-registration-select value="${h(reg.id)}" aria-label="选择 ${h(reg.candidate?.name || '考生')}" ${canSelect ? '' : 'disabled'}></td>` : ''}<td><div class="person-cell"><span>${h((reg.candidate?.name || '?').slice(0,1))}</span><div><strong>${h(reg.candidate?.name)}</strong><small>${h([reg.schoolName, reg.gradeName, reg.className].filter(Boolean).join(' · '))}</small></div></div></td><td><strong>${h(reg.exam.name)}</strong><small>${reg.subjects.map(subject => h(subject.name)).join('、')}</small></td><td class="mono"><strong>${h(reg.registrationNumber || '待同步账户号码')}</strong><small>各次考试保持一致</small></td><td><strong>${h(reg.workflow?.currentStepDetail?.name || '流程已结束')}</strong><small>${h(reg.workflow?.assignee?.displayName || '')}</small></td><td>${badge(reg.paymentStatus)}</td><td>${badge(reg.exam.archivedAt ? 'archived' : reg.status)}</td><td>${reg.exam.archivedAt ? '<span class="archive-readonly-label">只读封存</span>' : `<button class="row-action" data-action="review-registration" data-id="${h(reg.id)}">查看流程</button>`}</td></tr>`;
|
||||
const canSelect = selectable && reg.status === 'pending' && reg.workflow?.status === 'pending'
|
||||
&& (isSuper || reg.workflow?.assignee?.id === state.user.id);
|
||||
return `<tr data-filter-row data-status="${h(reg.status)}" data-exam="${h(reg.exam?.name || '')}" data-subject="${h(reg.subjects.map(subject => subject.name).join('|'))}" data-school="${h(reg.schoolName || '')}" data-grade="${h(reg.gradeName || '')}" data-class="${h(reg.className || '')}">${selectable ? `<td class="selection-cell"><input type="checkbox" data-registration-select value="${h(reg.id)}" aria-label="选择 ${h(reg.candidate?.name || '考生')}" ${canSelect ? '' : 'disabled'}></td>` : ''}<td><div class="person-cell"><span>${h((reg.candidate?.name || '?').slice(0,1))}</span><div><strong>${h(reg.candidate?.name)}</strong><small>${h([reg.schoolName, reg.gradeName, reg.className].filter(Boolean).join(' · '))}</small></div></div></td><td><div class="registration-exam-cell"><strong>${h(reg.exam.name)}</strong><div class="subject-summary">${reg.subjects.map(subject => `<span>${h(subject.name)}</span>`).join('') || '<em>未选择科目</em>'}</div><small>${h(reg.subjects.length)} 科 · ${money(reg.amountDue || 0)}</small></div></td><td class="mono"><strong>${h(reg.registrationNumber || '待同步账户号码')}</strong><small>各次考试保持一致</small></td><td><strong>${h(reg.workflow?.currentStepDetail?.name || '流程已结束')}</strong><small>${h(reg.workflow?.assignee?.displayName || '')}</small></td><td>${badge(reg.paymentStatus)}</td><td>${badge(reg.exam.archivedAt ? 'archived' : reg.status)}</td><td>${reg.exam.archivedAt ? '<span class="archive-readonly-label">只读封存</span>' : `<button class="row-action primary" data-action="review-registration" data-id="${h(reg.id)}">进入审核页</button>`}</td></tr>`;
|
||||
}).join('');
|
||||
const table = (items, id, selectable = false) => `<div class="table-scroll"><table id="${id}"><thead><tr>${selectable ? '<th class="selection-cell"><input type="checkbox" data-registration-select-all data-target="registrationTable" aria-label="全选当前筛选结果"></th>' : ''}<th>考生</th><th>考试 / 科目</th><th>账户报名号</th><th>当前流程</th><th>缴费</th><th>状态</th><th>操作</th></tr></thead><tbody>${rows(items, selectable) || `<tr><td colspan="${selectable ? 8 : 7}" class="empty-state">当前范围暂无报名记录</td></tr>`}</tbody></table></div>`;
|
||||
const current = registrations.filter(reg => !reg.exam.archivedAt);
|
||||
const archived = registrations.filter(reg => reg.exam.archivedAt);
|
||||
const bulkToolbar = isSuper ? `<div class="registration-bulk-bar"><span>已选择 <strong data-registration-selection-count>0</strong> 条待审核报名</span><div><button class="row-action" data-action="bulk-registration-review" data-status="rejected" disabled>批量退回</button><button class="row-action primary" data-action="bulk-registration-review" data-status="approved" disabled>批量通过当前步骤</button></div></div>` : '';
|
||||
return `<section class="panel data-panel registration-ledger">${bulkToolbar}<div class="data-toolbar registration-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="registrationTable" placeholder="搜索考生、考试、固定报名号"></label><div class="filter-pills"><button class="active" data-action="status-filter" data-target="registrationTable" data-status="all">全部</button><button data-action="status-filter" data-target="registrationTable" data-status="pending">待审核</button><button data-action="status-filter" data-target="registrationTable" data-status="approved">已通过</button><button data-action="status-filter" data-target="registrationTable" data-status="rejected">已退回</button></div><div class="table-filter-selects"><select data-table-filter="exam" data-target="registrationTable"><option value="">全部考试</option>${optionList(item => item.exam?.name)}</select><select data-table-filter="school" data-target="registrationTable"><option value="">全部学校</option>${optionList(item => item.schoolName)}</select><select data-table-filter="grade" data-target="registrationTable"><option value="">全部年级</option>${optionList(item => item.gradeName)}</select><select data-table-filter="class" data-target="registrationTable"><option value="">全部班级</option>${optionList(item => item.className)}</select></div></div>${table(current, 'registrationTable', isSuper)}</section>${archived.length ? `<details class="candidate-archive-fold admin-registration-archive"><summary><span><strong>归档考试报名记录</strong><small>${archived.length} 条 · 流程与报名信息已冻结</small></span><b>${archived.length}</b></summary>${table(archived, 'archivedRegistrationTable')}</details>` : ''}`;
|
||||
const allCurrent = registrations.filter(reg => !reg.exam.archivedAt);
|
||||
const allArchived = registrations.filter(reg => reg.exam.archivedAt);
|
||||
const currentPage = paged(allCurrent, 'registrationTable');
|
||||
const archivedPage = paged(allArchived, 'archivedRegistrationTable');
|
||||
const current = currentPage.items;
|
||||
const archived = archivedPage.items;
|
||||
const subjectOptions = [...new Set(registrations.flatMap(item => item.subjects.map(subject => subject.name)))].sort((left, right) => left.localeCompare(right, 'zh-CN')).map(value => `<option value="${h(value)}">${h(value)}</option>`).join('');
|
||||
const bulkToolbar = canReview ? `<div class="registration-bulk-bar"><span>已选择 <strong data-registration-selection-count>0</strong> 条当前可处理报名</span><div><button class="row-action" data-action="bulk-registration-review" data-status="rejected" disabled>批量退回</button><button class="row-action primary" data-action="bulk-registration-review" data-status="approved" disabled>批量通过当前步骤</button></div></div>` : '';
|
||||
return `<section class="panel data-panel registration-ledger">${bulkToolbar}<div class="data-toolbar registration-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="registrationTable" placeholder="搜索当前页的考生、考试、科目、固定报名号"></label><div class="filter-pills"><button class="active" data-action="status-filter" data-target="registrationTable" data-status="all">全部</button><button data-action="status-filter" data-target="registrationTable" data-status="pending">待审核</button><button data-action="status-filter" data-target="registrationTable" data-status="approved">已通过</button><button data-action="status-filter" data-target="registrationTable" data-status="rejected">已退回</button></div><div class="table-filter-selects five-columns"><select data-table-filter="exam" data-target="registrationTable"><option value="">全部考试</option>${optionList(item => item.exam?.name)}</select><select data-table-filter="subject" data-target="registrationTable"><option value="">全部科目</option>${subjectOptions}</select><select data-table-filter="school" data-target="registrationTable"><option value="">全部学校</option>${optionList(item => item.schoolName)}</select><select data-table-filter="grade" data-target="registrationTable"><option value="">全部年级</option>${optionList(item => item.gradeName)}</select><select data-table-filter="class" data-target="registrationTable"><option value="">全部班级</option>${optionList(item => item.className)}</select></div></div>${table(current, 'registrationTable', Boolean(canReview))}${pagination(currentPage)}</section>${allArchived.length ? `<details class="candidate-archive-fold admin-registration-archive"><summary><span><strong>归档考试报名记录</strong><small>${allArchived.length} 条 · 流程与报名信息已冻结</small></span><b>${allArchived.length}</b></summary>${table(archived, 'archivedRegistrationTable')}${pagination(archivedPage)}</details>` : ''}`;
|
||||
}
|
||||
|
||||
function adminPayments(data) {
|
||||
const registrations = data.registrations || [];
|
||||
const paid = registrations.filter(item => item.paymentStatus === 'paid');
|
||||
const unpaid = registrations.filter(item => item.paymentStatus === 'unpaid');
|
||||
const totalDue = registrations.reduce((sum, item) => sum + Number(item.amountDue || 0), 0);
|
||||
const allRegistrations = data.registrations || [];
|
||||
const paymentPage = paged(allRegistrations, 'paymentTable');
|
||||
const registrations = paymentPage.items;
|
||||
const paid = allRegistrations.filter(item => item.paymentStatus === 'paid');
|
||||
const unpaid = allRegistrations.filter(item => item.paymentStatus === 'unpaid');
|
||||
const totalDue = allRegistrations.reduce((sum, item) => sum + Number(item.amountDue || 0), 0);
|
||||
const totalPaid = paid.reduce((sum, item) => sum + Number(item.amountDue || 0), 0);
|
||||
const optionList = (getter) => [...new Set(registrations.map(getter).filter(Boolean))]
|
||||
const optionList = (getter) => [...new Set(allRegistrations.map(getter).filter(Boolean))]
|
||||
.sort((left, right) => left.localeCompare(right, 'zh-CN'))
|
||||
.map(value => `<option value="${h(value)}">${h(value)}</option>`).join('');
|
||||
const rows = registrations.map(item => `<tr data-filter-row data-status="${h(item.paymentStatus)}" data-exam="${h(item.exam?.name || '')}" data-school="${h(item.schoolName || '')}" data-grade="${h(item.gradeName || '')}" data-class="${h(item.className || '')}"><td><div class="person-cell"><span>${h((item.candidate?.name || '?').slice(0, 1))}</span><div><strong>${h(item.candidate?.name || '未知考生')}</strong><small class="mono">${h(item.registrationNumber || '')}</small></div></div></td><td><strong>${h(item.schoolName)}</strong><small>${h([item.gradeName, item.className].filter(Boolean).join(' · '))}</small></td><td><strong>${h(item.exam?.name || '')}</strong><small>${item.subjects.map(subject => h(subject.name)).join('、')}</small></td><td><strong>${money(item.amountDue || 0)}</strong></td><td>${badge(item.paymentStatus)}</td><td>${item.paidAt ? `<strong>${formatDate(item.paidAt, true)}</strong><small>${h(item.paidByName || '管理员')}</small>` : '<span>—</span>'}</td><td>${data.canUpdatePayment && !item.exam?.archivedAt ? `<button class="row-action ${item.paymentStatus === 'unpaid' ? 'primary' : 'danger'}" data-action="update-payment" data-id="${h(item.id)}" data-name="${h(item.candidate?.name || '')}" data-exam="${h(item.exam?.name || '')}" data-status="${item.paymentStatus === 'paid' ? 'unpaid' : 'paid'}">${item.paymentStatus === 'paid' ? '改为待缴费' : '标记已缴费'}</button>` : item.exam?.archivedAt ? '<span class="archive-readonly-label">只读封存</span>' : '<span>—</span>'}</td></tr>`).join('');
|
||||
const rows = registrations.map(item => `<tr data-filter-row data-status="${h(item.paymentStatus)}" data-exam="${h(item.exam?.name || '')}" data-school="${h(item.schoolName || '')}" data-grade="${h(item.gradeName || '')}" data-class="${h(item.className || '')}">${data.canUpdatePayment ? `<td class="selection-cell"><input type="checkbox" data-payment-select value="${h(item.id)}" data-status="${h(item.paymentStatus)}" aria-label="选择 ${h(item.candidate?.name || '考生')}" ${item.exam?.archivedAt ? 'disabled' : ''}></td>` : ''}<td><div class="person-cell"><span>${h((item.candidate?.name || '?').slice(0, 1))}</span><div><strong>${h(item.candidate?.name || '未知考生')}</strong><small class="mono">${h(item.registrationNumber || '')}</small></div></div></td><td><strong>${h(item.schoolName)}</strong><small>${h([item.gradeName, item.className].filter(Boolean).join(' · '))}</small></td><td><strong>${h(item.exam?.name || '')}</strong><div class="subject-summary">${item.subjects.map(subject => `<span>${h(subject.name)}</span>`).join('')}</div></td><td><strong>${money(item.amountDue || 0)}</strong></td><td>${badge(item.paymentStatus)}</td><td>${item.paidAt ? `<strong>${formatDate(item.paidAt, true)}</strong><small>${h(item.paidByName || '管理员')}</small>` : '<span>—</span>'}</td><td>${data.canUpdatePayment && !item.exam?.archivedAt ? `<button class="row-action ${item.paymentStatus === 'unpaid' ? 'primary' : 'danger'}" data-action="update-payment" data-id="${h(item.id)}" data-name="${h(item.candidate?.name || '')}" data-exam="${h(item.exam?.name || '')}" data-status="${item.paymentStatus === 'paid' ? 'unpaid' : 'paid'}">${item.paymentStatus === 'paid' ? '改为待缴费' : '标记已缴费'}</button>` : item.exam?.archivedAt ? '<span class="archive-readonly-label">只读封存</span>' : '<span>—</span>'}</td></tr>`).join('');
|
||||
const filters = `<div class="table-filter-selects"><select data-table-filter="exam" data-target="paymentTable"><option value="">全部考试</option>${optionList(item => item.exam?.name)}</select><select data-table-filter="school" data-target="paymentTable"><option value="">全部学校</option>${optionList(item => item.schoolName)}</select><select data-table-filter="grade" data-target="paymentTable"><option value="">全部年级</option>${optionList(item => item.gradeName)}</select><select data-table-filter="class" data-target="paymentTable"><option value="">全部班级</option>${optionList(item => item.className)}</select></div>`;
|
||||
return `${excelToolbar('payments', { importable: false, template: false, label: '缴费名单' })}<section class="center-summary payment-summary"><div><span>报名人数</span><strong>${registrations.length}</strong></div><div><span>待确认</span><strong>${unpaid.length}</strong></div><div><span>已缴费</span><strong>${paid.length}</strong></div><div><span>应缴合计</span><strong>${money(totalDue)}</strong></div><div><span>已缴合计</span><strong>${money(totalPaid)}</strong></div></section><section class="panel data-panel"><div class="data-toolbar payment-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="paymentTable" placeholder="搜索考生、报名号、考试、学校或班级"></label><div class="filter-pills"><button class="active" data-action="status-filter" data-target="paymentTable" data-status="all">全部</button><button data-action="status-filter" data-target="paymentTable" data-status="unpaid">待缴费</button><button data-action="status-filter" data-target="paymentTable" data-status="paid">已缴费</button></div>${filters}</div><div class="table-scroll"><table id="paymentTable"><thead><tr><th>考生 / 报名号</th><th>学校 / 年级 / 班级</th><th>考试 / 科目</th><th>应缴金额</th><th>缴费状态</th><th>确认记录</th><th>操作</th></tr></thead><tbody>${rows || '<tr><td colspan="7" class="empty-state">当前范围暂无已审核通过的报名</td></tr>'}</tbody></table></div></section>`;
|
||||
const bulkToolbar = data.canUpdatePayment ? `<div class="registration-bulk-bar payment-bulk-bar"><span>已选择 <strong data-payment-selection-count>0</strong> 条缴费记录</span><div><button class="row-action" data-action="bulk-payment-update" data-status="unpaid" disabled>批量改为待缴费</button><button class="row-action primary" data-action="bulk-payment-update" data-status="paid" disabled>批量标记已缴费</button></div></div>` : '';
|
||||
return `${excelToolbar('payments', { importable: false, template: false, label: '缴费名单' })}<section class="center-summary payment-summary"><div><span>报名人数</span><strong>${allRegistrations.length}</strong></div><div><span>待确认</span><strong>${unpaid.length}</strong></div><div><span>已缴费</span><strong>${paid.length}</strong></div><div><span>应缴合计</span><strong>${money(totalDue)}</strong></div><div><span>已缴合计</span><strong>${money(totalPaid)}</strong></div></section><section class="panel data-panel payment-ledger">${bulkToolbar}<div class="data-toolbar payment-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="paymentTable" placeholder="搜索当前页的考生、报名号、考试、科目、学校或班级"></label><div class="filter-pills"><button class="active" data-action="status-filter" data-target="paymentTable" data-status="all">全部</button><button data-action="status-filter" data-target="paymentTable" data-status="unpaid">待缴费</button><button data-action="status-filter" data-target="paymentTable" data-status="paid">已缴费</button></div>${filters}</div><div class="table-scroll"><table id="paymentTable"><thead><tr>${data.canUpdatePayment ? '<th class="selection-cell"><input type="checkbox" data-payment-select-all data-target="paymentTable" aria-label="全选当前筛选结果"></th>' : ''}<th>考生 / 报名号</th><th>学校 / 年级 / 班级</th><th>考试 / 科目</th><th>应缴金额</th><th>缴费状态</th><th>确认记录</th><th>操作</th></tr></thead><tbody>${rows || `<tr><td colspan="${data.canUpdatePayment ? 8 : 7}" class="empty-state">当前范围暂无已审核通过的报名</td></tr>`}</tbody></table></div>${pagination(paymentPage)}</section>`;
|
||||
}
|
||||
|
||||
function adminExams(exams) {
|
||||
@@ -167,30 +223,41 @@ export function createAdminViews(context) {
|
||||
const pendingPlans = data.plans.filter(item => item.status === 'pending');
|
||||
const withdrawals = data.placements.filter(item => item.status === 'withdrawal_pending');
|
||||
const phaseLabels = { draft: '草稿', filling: '志愿填报中', closed: '填报已截止', matching: '投档中', school_review: '学校审核中', supplementary: '补录填报中', completed: '录取完成' };
|
||||
const accountPage = paged(data.schoolAccounts, 'admissionAccountTable', 20);
|
||||
const planPage = paged(data.plans, 'adminAdmissionPlanTable', 20);
|
||||
const placementPage = paged(data.placements, 'adminPlacementTable');
|
||||
const settings = `<section class="panel admission-settings-panel"><div class="panel-title"><div><h2>考试志愿设置</h2><p>成绩发布后按场次开放,并持续向考生展示录取进度。</p></div></div><form data-form="admission-setting"><label><span>考试 *</span><select name="examId" required>${data.exams.map(exam => `<option value="${h(exam.id)}" ${exam.id === selected?.examId ? 'selected' : ''}>${h(exam.name)}</option>`).join('')}</select></label><div class="field-row"><label><span>填报开始</span><input name="preferenceStart" type="datetime-local" value="${h(selected?.payload?.preferenceStart?.slice(0,16))}"></label><label><span>填报结束</span><input name="preferenceEnd" type="datetime-local" value="${h(selected?.payload?.preferenceEnd?.slice(0,16))}"></label></div><div class="field-row"><label><span>当前阶段</span><select name="status">${Object.entries(phaseLabels).map(([value,label]) => `<option value="${value}" ${value === (selected?.status || 'draft') ? 'selected' : ''}>${label}</option>`).join('')}</select></label><label><span>普通志愿数</span><input name="maxChoices" type="number" min="1" max="20" value="${h(selected?.payload?.maxChoices || 5)}"></label></div><div class="field-row"><label><span>最多提交次数</span><input name="maxSubmissions" type="number" min="1" max="50" value="${h(selected?.payload?.maxSubmissions || 3)}"><small>首次保存也计 1 次;达到上限自动锁定。</small></label><label><span>考生进度说明</span><input name="progress" value="${h(selected?.payload?.progress || '')}" placeholder="例如:招生学校正在审核投档名单"></label></div><label class="agreement"><input type="checkbox" name="enabled" ${selected?.payload?.enabled ? 'checked' : ''}><span>为本场考试启用志愿填报</span></label><label class="agreement"><input type="checkbox" name="autoPublish" ${selected?.payload?.autoPublish === false ? '' : 'checked'}><span>录取结束后自动脱敏公示录取结果与分数线</span></label><button class="solid-button" type="submit">保存志愿设置</button></form>${selected ? `<div class="admission-control-actions"><button class="solid-button" data-action="admission-match" data-exam-id="${h(selected.examId)}">按规则投档</button><button class="ghost-button" data-action="admission-finalize" data-exam-id="${h(selected.examId)}">结束录取并通知</button><button class="ghost-button" data-action="admission-supplementary" data-exam-id="${h(selected.examId)}">开启补录</button></div>` : ''}</section>`;
|
||||
const accounts = `<section class="panel admission-account-panel"><div class="panel-title"><div><h2>招生学校账号</h2><p>只可绑定已在学校管理中标记为“招生校”的学校。</p></div><span>${data.schoolAccounts.length} 个</span></div><form data-form="admission-account"><label><span>招生学校 *</span><select name="schoolId" required>${data.admissionSchools.map(school => `<option value="${h(school.id)}">${h(school.code)} · ${h(school.name)}</option>`).join('')}</select></label><div class="field-row"><label><span>登录账号 *</span><input name="username" required></label><label><span>初始密码 *</span><input name="password" type="password" minlength="8" required></label></div><label><span>显示名称</span><input name="displayName" placeholder="学校招生办公室"></label><button class="solid-button" type="submit">创建招生学校账号</button></form></section>`;
|
||||
const accountRows = accountPage.items.map(account => `<tr data-status="${account.active ? 'active' : 'disabled'}" data-school="${h(account.schoolId)}"><td><div class="person-cell"><span>${h((account.displayName || '招').slice(0, 1))}</span><div><strong>${h(account.displayName)}</strong><small>${h(account.id)}</small></div></div></td><td><strong class="mono">${h(account.username)}</strong></td><td><strong>${h(account.schoolName || '未绑定')}</strong><small class="mono">${h(account.schoolCode || '')}</small></td><td><span class="status status-${account.active ? 'approved' : 'closed'}">${account.active ? '已启用' : '已停用'}</span></td><td>${formatDate(account.createdAt, true)}</td><td><div class="admin-account-actions"><button class="row-action" data-action="reset-admission-account-password" data-id="${h(account.id)}">重置密码</button><button class="row-action ${account.active ? 'danger' : 'primary'}" data-action="toggle-admission-account" data-id="${h(account.id)}" data-active="${account.active ? 'false' : 'true'}">${account.active ? '停用账户' : '重新启用'}</button></div></td></tr>`).join('');
|
||||
const accounts = `<section class="panel admission-account-panel admission-account-management"><div class="panel-title"><div><h2>招生学校账户管理</h2><p>创建并维护各招生学校登录账户;停用会立即结束该账户现有会话。</p></div><span>${data.schoolAccounts.filter(item => item.active).length} 个启用 / 共 ${data.schoolAccounts.length} 个</span></div><form data-form="admission-account"><label><span>招生学校 *</span><select name="schoolId" required>${data.admissionSchools.map(school => `<option value="${h(school.id)}">${h(school.code)} · ${h(school.name)}</option>`).join('')}</select></label><div class="field-row"><label><span>登录账号 *</span><input name="username" required></label><label><span>初始密码 *</span><input name="password" type="password" minlength="8" required></label></div><label><span>显示名称</span><input name="displayName" placeholder="学校招生办公室"></label><button class="solid-button" type="submit">创建招生学校账号</button></form><div class="account-management-divider"><strong>已建账户</strong><span>保留历史记录,不提供物理删除</span></div><div class="data-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="admissionAccountTable" placeholder="搜索当前页的学校、账号或显示名称"></label><div class="table-filter-selects"><select data-table-filter="school" data-target="admissionAccountTable"><option value="">全部招生学校</option>${data.admissionSchools.map(school => `<option value="${h(school.id)}">${h(school.name)}</option>`).join('')}</select></div></div><div class="filter-pills account-status-pills"><button type="button" class="active" data-action="status-filter" data-target="admissionAccountTable" data-status="all">全部</button><button type="button" data-action="status-filter" data-target="admissionAccountTable" data-status="active">已启用</button><button type="button" data-action="status-filter" data-target="admissionAccountTable" data-status="disabled">已停用</button></div><div class="table-scroll"><table id="admissionAccountTable"><thead><tr><th>账户</th><th>登录账号</th><th>绑定招生学校</th><th>状态</th><th>创建时间</th><th>账户操作</th></tr></thead><tbody>${accountRows || '<tr><td colspan="6" class="empty-state">还没有招生学校账户</td></tr>'}</tbody></table></div>${pagination(accountPage)}</section>`;
|
||||
const planForm = `<section class="panel admission-plan-console structured"><div class="panel-title"><div><h2>代招生校上传计划</h2><p>每个类别独立设置人数、特长资格和生源校指标,保存后直接审核通过。</p></div></div><form data-form="admission-plan"><div class="field-row"><label><span>考试 *</span><select name="examId" required>${data.exams.map(exam => `<option value="${h(exam.id)}">${h(exam.name)}</option>`).join('')}</select></label><label><span>招生学校 *</span><select name="schoolId" required>${data.admissionSchools.map(school => `<option value="${h(school.id)}">${h(school.code)} · ${h(school.name)}</option>`).join('')}</select></label></div>${admissionCategoriesEditor(h, data.sourceSchools)}<label><span>计划说明</span><textarea name="note" rows="2" placeholder="可填写政策依据或补充说明"></textarea></label><button class="solid-button" type="submit">代上传并审核通过</button></form></section>`;
|
||||
const plans = `<section class="panel data-panel"><div class="panel-title"><div><h2>招生计划审核</h2><p>核对类别资格、计划总数和指标分配。</p></div><span>${pendingPlans.length} 份待审</span></div><div class="table-scroll"><table><thead><tr><th>考试 / 学校</th><th>计划构成</th><th>指标分配</th><th>状态</th><th>操作</th></tr></thead><tbody>${data.plans.map(plan => `<tr><td><strong>${h(plan.examName)}</strong><small>${h(plan.schoolName)}</small></td><td>${plan.payload.categories.map(category => `<strong>${h(category.name)} ${h(category.quota)} 人</strong><small>${h(specialtyLabel(category.specialtyCategory, category.specialtyType) || '普通 / 政策类')}</small>`).join('')}</td><td>${plan.payload.categories.flatMap(category => (category.indicatorAllocations || []).map(allocation => `${h(data.sourceSchools.find(item => item.id === allocation.sourceSchoolId)?.name || allocation.sourceSchoolId)} ${h(allocation.quota)} 人`)).join('<br>') || '无定向指标'}</td><td>${badge(plan.status)}</td><td>${plan.status === 'pending' ? `<button class="row-action primary" data-action="admission-plan-review" data-id="${h(plan.id)}" data-status="approved">通过</button><button class="row-action" data-action="admission-plan-review" data-id="${h(plan.id)}" data-status="rejected">退回</button>` : h(plan.payload.reviewNote || '')}</td></tr>`).join('') || '<tr><td colspan="5" class="empty-state">暂无招生计划</td></tr>'}</tbody></table></div></section>`;
|
||||
const placements = `<section class="panel data-panel"><div class="panel-title"><h2>投档与退档监督</h2><span>志愿只读,管理员均无修改入口</span></div><div class="table-scroll"><table><thead><tr><th>考生</th><th>成绩 / 志愿</th><th>投档学校</th><th>类别</th><th>状态</th><th>退档审核</th></tr></thead><tbody>${data.placements.map(item => `<tr><td><strong>${h(item.candidate.name)}</strong><small class="mono">${h(item.candidate.registrationNumber)}</small><small>${h(item.candidate.specialtyLabel || '普通生')}</small></td><td>${h(item.payload.totalScore)} 分 · 特征分 ${h(item.payload.featureScore || 0)} · 第 ${h(item.payload.preferenceOrder)} 志愿</td><td>${h(item.schoolName)}</td><td>${h(item.payload.categoryName)}</td><td>${badge(item.status)}</td><td>${item.status === 'withdrawal_pending' ? `<small>${h(item.payload.withdrawalReason)}</small><button class="row-action primary" data-action="withdrawal-review" data-id="${h(item.id)}" data-approved="true">批准</button><button class="row-action" data-action="withdrawal-review" data-id="${h(item.id)}" data-approved="false">驳回</button>` : '—'}</td></tr>`).join('') || '<tr><td colspan="6" class="empty-state">尚未产生投档记录</td></tr>'}</tbody></table></div></section>`;
|
||||
return `<section class="admission-command-banner"><div><span>ADMISSION COMMAND</span><h2>中考招生录取控制台</h2><p>学校代码、资格类别、招生计划和指标名额在一条可审计链路中完成。</p></div><dl><div><dt>待审计划</dt><dd>${pendingPlans.length}</dd></div><div><dt>学校审核中</dt><dd>${data.placements.filter(item => item.status === 'school_review').length}</dd></div><div><dt>退档待审</dt><dd>${withdrawals.length}</dd></div><div><dt>正式录取</dt><dd>${data.placements.filter(item => item.status === 'final').length}</dd></div></dl></section><div class="admission-admin-grid">${settings}${accounts}</div>${planForm}${plans}${placements}`;
|
||||
const plans = `<section class="panel data-panel"><div class="panel-title"><div><h2>招生计划审核</h2><p>核对类别资格、计划总数和指标分配。</p></div><span>${pendingPlans.length} 份待审</span></div><div class="table-scroll"><table id="adminAdmissionPlanTable"><thead><tr><th>考试 / 学校</th><th>计划构成</th><th>指标分配</th><th>状态</th><th>操作</th></tr></thead><tbody>${planPage.items.map(plan => `<tr><td><strong>${h(plan.examName)}</strong><small>${h(plan.schoolName)}</small></td><td>${plan.payload.categories.map(category => `<strong>${h(category.name)} ${h(category.quota)} 人</strong><small>${h(specialtyLabel(category.specialtyCategory, category.specialtyType) || '普通 / 政策类')}</small>`).join('')}</td><td>${plan.payload.categories.flatMap(category => (category.indicatorAllocations || []).map(allocation => `${h(data.sourceSchools.find(item => item.id === allocation.sourceSchoolId)?.name || allocation.sourceSchoolId)} ${h(allocation.quota)} 人`)).join('<br>') || '无定向指标'}</td><td>${badge(plan.status)}</td><td>${plan.status === 'pending' ? `<button class="row-action primary" data-action="admission-plan-review" data-id="${h(plan.id)}" data-status="approved">通过</button><button class="row-action" data-action="admission-plan-review" data-id="${h(plan.id)}" data-status="rejected">退回</button>` : h(plan.payload.reviewNote || '')}</td></tr>`).join('') || '<tr><td colspan="5" class="empty-state">暂无招生计划</td></tr>'}</tbody></table></div>${pagination(planPage)}</section>`;
|
||||
const placements = `<section class="panel data-panel"><div class="panel-title"><h2>投档与退档监督</h2><span>志愿只读,管理员均无修改入口</span></div><div class="table-scroll"><table id="adminPlacementTable"><thead><tr><th>考生</th><th>成绩 / 志愿</th><th>投档学校</th><th>类别</th><th>状态</th><th>退档审核</th></tr></thead><tbody>${placementPage.items.map(item => `<tr><td><strong>${h(item.candidate.name)}</strong><small class="mono">${h(item.candidate.registrationNumber)}</small><small>${h(item.candidate.specialtyLabel || '普通生')}</small></td><td>${h(item.payload.totalScore)} 分 · 特征分 ${h(item.payload.featureScore || 0)} · 第 ${h(item.payload.preferenceOrder)} 志愿</td><td>${h(item.schoolName)}</td><td>${h(item.payload.categoryName)}</td><td>${badge(item.status)}</td><td>${item.status === 'withdrawal_pending' ? `<small>${h(item.payload.withdrawalReason)}</small><button class="row-action primary" data-action="withdrawal-review" data-id="${h(item.id)}" data-approved="true">批准</button><button class="row-action" data-action="withdrawal-review" data-id="${h(item.id)}" data-approved="false">驳回</button>` : '—'}</td></tr>`).join('') || '<tr><td colspan="6" class="empty-state">尚未产生投档记录</td></tr>'}</tbody></table></div>${pagination(placementPage)}</section>`;
|
||||
return `<section class="admission-command-banner"><div><span>ADMISSION COMMAND</span><h2>中考招生录取控制台</h2><p>学校代码、资格类别、招生计划和指标名额在一条可审计链路中完成。</p></div><dl><div><dt>待审计划</dt><dd>${pendingPlans.length}</dd></div><div><dt>学校审核中</dt><dd>${data.placements.filter(item => item.status === 'school_review').length}</dd></div><div><dt>退档待审</dt><dd>${withdrawals.length}</dd></div><div><dt>正式录取</dt><dd>${data.placements.filter(item => item.status === 'final').length}</dd></div></dl></section><div class="admission-admin-grid single">${settings}</div>${accounts}${planForm}${plans}${placements}`;
|
||||
}
|
||||
|
||||
function adminIndicatorQualifications(data) {
|
||||
if (!data.exams?.length) return emptyState('暂无需要确认的考试', '超级管理员启用中考志愿填报后,本校资格名单会出现在这里。');
|
||||
return `<section class="qualification-ledger-intro"><div><span>SOURCE SCHOOL CERTIFICATION</span><h2>${h(data.school?.name)}资格确认簿</h2><p>逐人确认或多选批量设置。每场考试全部确认后立即自动公示,后续修改也会同步更新公示。</p></div></section>${data.exams.map(item => {
|
||||
return `<section class="qualification-ledger-intro"><div><span>SOURCE SCHOOL CERTIFICATION</span><h2>${h(data.school?.name)}资格确认簿</h2><p>先按姓名、报名号、确认状态或特长类型筛选,再逐人确认或多选批量设置。每场考试全部确认后立即自动公示。</p></div></section>${data.exams.map((item, index) => {
|
||||
const status = item.qualificationStatus;
|
||||
const bulk = `<div class="qualification-bulk-toolbar" data-qualification-bulk data-exam-id="${h(item.examId)}"><label><input type="checkbox" data-action="qualification-select-all"><span>全选本页 ${h(status.total)} 名考生</span></label><div><strong data-qualification-selected-count>已选 0 人</strong><select data-bulk-eligible><option value="">批量设置资格</option><option value="true">设为有指标分配资格</option><option value="false">设为无指标分配资格</option></select><button class="solid-button" data-action="bulk-indicator-qualification">应用到所选考生</button></div></div>`;
|
||||
const rows = status.rows.map(row => `<tr data-exam-id="${h(item.examId)}" data-user-id="${h(row.userId)}"><td><input type="checkbox" data-qualification-select aria-label="选择 ${h(row.name)}"></td><td><strong>${h(row.name)}</strong><small class="mono">${h(row.registrationNumber)}</small></td><td>${h(row.specialtyLabel)}</td><td><select data-indicator-eligible><option value="" ${row.confirmed ? '' : 'selected'}>请选择</option><option value="true" ${row.confirmed && row.eligible ? 'selected' : ''}>有指标分配资格</option><option value="false" ${row.confirmed && !row.eligible ? 'selected' : ''}>无指标分配资格</option></select></td><td>${row.confirmedAt ? formatDate(row.confirmedAt, true) : '待确认'}</td><td><button class="row-action primary" data-action="save-indicator-qualification">确认</button></td></tr>`).join('');
|
||||
return `<section class="panel qualification-ledger"><header><div><span>${h(item.exam.code)}</span><h2>${h(item.exam.name)}</h2></div><strong class="${status.complete ? 'complete' : ''}">${h(status.confirmed)} / ${h(status.total)} 已确认</strong></header><div class="qualification-progress"><i style="width:${status.total ? Math.round(status.confirmed / status.total * 100) : 0}%"></i></div>${status.complete ? '<div class="qualification-publication-state">✓ 本校资格已全部确认,公开公示已自动发布</div>' : '<div class="qualification-publication-state pending">未全部确认前不会公开,请逐项核对。</div>'}${bulk}<div class="table-scroll"><table><thead><tr><th>选择</th><th>报名号 / 姓名</th><th>特长类型</th><th>指标分配资格</th><th>确认时间</th><th>保存</th></tr></thead><tbody>${rows || '<tr><td colspan="6" class="empty-state">本校暂无资料已完善的在册考生</td></tr>'}</tbody></table></div></section>`;
|
||||
const tableId = `qualificationTable-${index}`;
|
||||
const qualificationPage = paged(status.rows, tableId);
|
||||
const specialties = [...new Set(status.rows.map(row => row.specialtyLabel).filter(Boolean))].sort((left, right) => left.localeCompare(right, 'zh-CN'));
|
||||
const filters = `<div class="data-toolbar qualification-filter-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="${tableId}" placeholder="搜索报名号、姓名或特长类型"></label><div class="filter-pills"><button class="active" data-action="status-filter" data-target="${tableId}" data-status="all">全部</button><button data-action="status-filter" data-target="${tableId}" data-status="unconfirmed">待确认</button><button data-action="status-filter" data-target="${tableId}" data-status="eligible">有资格</button><button data-action="status-filter" data-target="${tableId}" data-status="ineligible">无资格</button></div><div class="qualification-specialty-filter"><select data-table-filter="specialty" data-target="${tableId}"><option value="">全部特长类型</option>${specialties.map(value => `<option value="${h(value)}">${h(value)}</option>`).join('')}</select><button class="row-action" data-action="clear-table-filters" data-target="${tableId}">清除筛选</button></div></div>`;
|
||||
const bulk = `<div class="qualification-bulk-toolbar" data-qualification-bulk data-exam-id="${h(item.examId)}"><label><input type="checkbox" data-action="qualification-select-all"><span>全选当前筛选结果</span></label><div><strong data-qualification-selected-count>已选 0 人</strong><select data-bulk-eligible><option value="">批量设置资格</option><option value="true">设为有指标分配资格</option><option value="false">设为无指标分配资格</option></select><button class="solid-button" data-action="bulk-indicator-qualification">应用到所选考生</button></div></div>`;
|
||||
const rows = qualificationPage.items.map(row => `<tr data-filter-row data-status="${row.confirmed ? `${row.eligible ? 'eligible' : 'ineligible'} confirmed` : 'unconfirmed'}" data-specialty="${h(row.specialtyLabel || '')}" data-exam-id="${h(item.examId)}" data-user-id="${h(row.userId)}"><td><input type="checkbox" data-qualification-select aria-label="选择 ${h(row.name)}"></td><td><strong>${h(row.name)}</strong><small class="mono">${h(row.registrationNumber)}</small></td><td>${h(row.specialtyLabel)}</td><td><select data-indicator-eligible><option value="" ${row.confirmed ? '' : 'selected'}>请选择</option><option value="true" ${row.confirmed && row.eligible ? 'selected' : ''}>有指标分配资格</option><option value="false" ${row.confirmed && !row.eligible ? 'selected' : ''}>无指标分配资格</option></select></td><td>${row.confirmedAt ? formatDate(row.confirmedAt, true) : '待确认'}</td><td><button class="row-action primary" data-action="save-indicator-qualification">确认</button></td></tr>`).join('');
|
||||
return `<section class="panel qualification-ledger"><header><div><span>${h(item.exam.code)}</span><h2>${h(item.exam.name)}</h2></div><strong class="${status.complete ? 'complete' : ''}">${h(status.confirmed)} / ${h(status.total)} 已确认</strong></header><div class="qualification-progress"><i style="width:${status.total ? Math.round(status.confirmed / status.total * 100) : 0}%"></i></div>${status.complete ? '<div class="qualification-publication-state">✓ 本校资格已全部确认,公开公示已自动发布</div>' : '<div class="qualification-publication-state pending">未全部确认前不会公开,请逐项核对。</div>'}${filters}${bulk}<div class="table-scroll"><table id="${tableId}"><thead><tr><th>选择</th><th>报名号 / 姓名</th><th>特长类型</th><th>指标分配资格</th><th>确认时间</th><th>保存</th></tr></thead><tbody>${rows || '<tr><td colspan="6" class="empty-state">本校暂无资料已完善的在册考生</td></tr>'}</tbody></table></div>${pagination(qualificationPage)}</section>`;
|
||||
}).join('')}`;
|
||||
}
|
||||
|
||||
function adminNotices(notices) {
|
||||
return `<section class="panel data-panel"><div class="data-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="noticeTable" placeholder="搜索通知标题或分类"></label><p>发布状态会实时同步到公开首页和考生中心。</p></div><div class="table-scroll"><table id="noticeTable"><thead><tr><th>通知标题</th><th>分类</th><th>作者</th><th>发布时间</th><th>展示</th><th>状态</th><th>操作</th></tr></thead><tbody>${notices.map(notice => `<tr data-status="${h(notice.status)}"><td><strong>${h(notice.title)}</strong><small>${h(notice.summary)}</small></td><td>${h(notice.category)}</td><td>${h(notice.author)}</td><td>${formatDate(notice.publishAt || notice.createdAt,true)}</td><td>${notice.pinned ? '<span class="pin-label">首页置顶</span>' : '普通'}</td><td>${badge(notice.status)}</td><td><button class="row-action" data-action="toggle-notice" data-id="${h(notice.id)}" data-status="${notice.status === 'published' ? 'draft' : 'published'}">${notice.status === 'published' ? '撤回' : '发布'}</button></td></tr>`).join('')}</tbody></table></div></section>`;
|
||||
function adminNotices(notices, publications = []) {
|
||||
const noticeRows = notices.map(notice => `<tr data-filter-row data-status="ordinary ${h(notice.status)} ${notice.status === 'published' ? 'visible' : ''}"><td><strong>${h(notice.title)}</strong><small>${h(notice.summary)}</small></td><td><strong>手动通知</strong><small>${h(notice.category)}</small></td><td>${h(notice.author)}</td><td>${formatDate(notice.publishAt || notice.createdAt,true)}</td><td>${notice.pinned ? '<span class="pin-label">首页置顶</span>' : notice.status === 'published' ? '通知目录' : '尚未展示'}</td><td>${badge(notice.status)}</td><td><div class="notice-row-actions">${notice.status === 'draft' ? `<button class="row-action primary" data-action="edit-notice" data-id="${h(notice.id)}">编辑</button>` : ''}<button class="row-action" data-action="toggle-notice" data-id="${h(notice.id)}" data-status="${notice.status === 'published' ? 'draft' : 'published'}">${notice.status === 'published' ? '隐藏' : '发布'}</button></div></td></tr>`).join('');
|
||||
const publicationRows = publications.map(item => `<tr data-filter-row data-status="system ${h(item.status)}"><td><strong>${h(item.title)}</strong><small>${h(item.summary)}</small></td><td><strong>自动公示</strong><small>${h(item.category)}</small></td><td>${h(item.author)}</td><td>${formatDate(item.publishedAt,true)}</td><td>通知目录</td><td>${badge(item.status)}</td><td><button class="row-action ${item.visible ? '' : 'primary'}" data-action="toggle-publication" data-id="${h(item.id)}" data-source-type="${h(item.sourceType)}" data-visible="${item.visible ? 'false' : 'true'}">${item.visible ? '隐藏' : '显示'}</button></td></tr>`).join('');
|
||||
return `<section class="panel data-panel"><div class="data-toolbar notice-admin-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="noticeTable" placeholder="搜索通知、公示标题或分类"></label><div class="filter-pills"><button class="active" data-action="status-filter" data-target="noticeTable" data-status="all">全部</button><button data-action="status-filter" data-target="noticeTable" data-status="visible">正在显示</button><button data-action="status-filter" data-target="noticeTable" data-status="hidden">已隐藏</button><button data-action="status-filter" data-target="noticeTable" data-status="draft">草稿</button><button data-action="status-filter" data-target="noticeTable" data-status="system">自动公示</button></div><p>自动公示的内容由招生录取流程生成,这里只控制是否公开显示。</p></div><div class="table-scroll"><table id="noticeTable"><thead><tr><th>标题</th><th>来源 / 分类</th><th>发布人</th><th>发布时间</th><th>展示位置</th><th>状态</th><th>操作</th></tr></thead><tbody>${noticeRows}${publicationRows || (!noticeRows ? '<tr><td colspan="7" class="empty-state">暂无通知或系统公示</td></tr>' : '')}</tbody></table></div></section>`;
|
||||
}
|
||||
|
||||
function adminAdmit(data) {
|
||||
const approved = data.registrations || [];
|
||||
const admitPage = paged(approved, 'admitTable');
|
||||
const selectedExam = data.exams.find(exam => !exam.archivedAt && exam.approvedCount > 0) || data.exams.find(exam => !exam.archivedAt) || data.exams[0];
|
||||
const selectedPlan = selectedExam?.plan;
|
||||
const scopeName = code => data.mixingScopes.find(item => item.code === code)?.name || code;
|
||||
@@ -199,7 +266,7 @@ export function createAdminViews(context) {
|
||||
const planCards = data.canArrange && data.plans.length ? `<section class="arrangement-plan-grid">${data.plans.map(plan => `<article class="panel"><header><div><span>${h(scopeName(plan.mixingScope))}</span><h3>${h(plan.examName)}</h3></div><time>${formatDate(plan.generatedAt, true)}</time></header><div><strong>${plan.candidateCount}</strong><small>名考生</small><strong>${plan.centerCount}</strong><small>个考点</small><strong>${plan.subjectCombinationCount}</strong><small>种科目组合</small></div><p>${h(plan.ruleName)} · 本校考点率 ${h(plan.sameSchoolCenterRate)}%</p>${plan.warnings.length ? `<ul>${plan.warnings.map(item => `<li>${h(item)}</li>`).join('')}</ul>` : '<p class="arrangement-ok">容量、档案与时间冲突检查通过</p>'}</article>`).join('')}</section>` : '';
|
||||
const exportExam = data.exams.find(exam => exam.arrangedCount > 0) || data.exams[0];
|
||||
const exportPanel = exportExam ? `<section class="panel arrangement-config admission-export-panel"><div class="panel-title"><div><h2>${data.canArrange ? '准考证与考务材料' : h(data.scopeLabel)}</h2><p>批量准考证为 A4 横向打印文件;“准考证信息”是逐考生、逐科目的 Excel 台账。</p></div><span>${approved.filter(item => item.admitCard).length} 份范围内准考证</span></div><div class="arrangement-fields"><label><span>选择考试</span><select data-admission-export-exam>${data.exams.map(exam => `<option value="${h(exam.id)}" ${exam.id === exportExam.id ? 'selected' : ''}>${h(exam.name)}(已编排 ${exam.arrangedCount} 人)</option>`).join('')}</select></label></div><div class="arrangement-actions"><button class="solid-button" data-action="batch-admit-download">批量下载准考证</button><button class="ghost-button" data-action="admit-info-export">导出准考证信息</button>${data.canExportCenterMaterials ? '<button class="ghost-button" data-action="center-materials-export">导出本校考点桌贴 / 门贴 / 签名单</button>' : ''}</div></section>` : emptyState('当前范围还没有考试报名', '待报名审核通过并完成整场编排后,可在这里批量下载。');
|
||||
const rows = approved.map(reg => {
|
||||
const rows = admitPage.items.map(reg => {
|
||||
const assignments = new Map((reg.admitCard?.assignments || []).map(item => [item.subjectId, item]));
|
||||
const subjects = reg.subjects.map(subject => {
|
||||
const assignment = assignments.get(subject.id);
|
||||
@@ -207,19 +274,44 @@ export function createAdminViews(context) {
|
||||
}).join('');
|
||||
return `<tr><td><div class="person-cell"><span>${h((reg.candidate?.name || '?').slice(0,1))}</span><div><strong>${h(reg.candidate?.name)}</strong><small>${h(reg.candidate?.school || '')} · ${h(reg.candidate?.grade || '')}</small></div></div></td><td><strong>${h(reg.exam.name)}</strong><small>${h(reg.exam.code)}</small></td><td class="mono">${h(reg.admitCard?.number || '待编排')}</td><td><strong>${h(reg.admitCard?.testCenter || '—')}</strong><small>${h(reg.admitCard?.centerCode || '')} · ${h(reg.admitCard?.centerAddress || '')}</small></td><td><div class="subject-room-list">${subjects}</div></td><td>${reg.admitCard ? `<button class="row-action" data-action="generate-admit" data-id="${h(reg.id)}">查看</button>` : '—'}</td></tr>`;
|
||||
}).join('');
|
||||
return `${form}${ruleCards}${planCards}${exportPanel}<section class="panel data-panel"><div class="data-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="admitTable" placeholder="搜索考生、考试、准考证号或考点"></label><p>考试考场序号来自本次编排;通用名称和场地代码来自考点物理场地档案。</p></div><div class="table-scroll"><table id="admitTable"><thead><tr><th>考生</th><th>考试</th><th>准考证号</th><th>固定考点 / 地址</th><th>分科考试考场 / 物理场地 / 座位</th><th>操作</th></tr></thead><tbody>${rows || '<tr><td colspan="6" class="empty-state">暂无已通过的考试报名</td></tr>'}</tbody></table></div></section>`;
|
||||
return `${form}${ruleCards}${planCards}${exportPanel}<section class="panel data-panel"><div class="data-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="admitTable" placeholder="搜索当前页的考生、考试、准考证号或考点"></label><p>考试考场序号来自本次编排;通用名称和场地代码来自考点物理场地档案。</p></div><div class="table-scroll"><table id="admitTable"><thead><tr><th>考生</th><th>考试</th><th>准考证号</th><th>固定考点 / 地址</th><th>分科考试考场 / 物理场地 / 座位</th><th>操作</th></tr></thead><tbody>${rows || '<tr><td colspan="6" class="empty-state">暂无已通过的考试报名</td></tr>'}</tbody></table></div>${pagination(admitPage)}</section>`;
|
||||
}
|
||||
|
||||
function adminResults(data) {
|
||||
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];
|
||||
if (activeExam) state.resultExamFilter = activeExam.id;
|
||||
const activeExam = state.resultExamFilter ? data.exams.find(item => item.id === state.resultExamFilter) : null;
|
||||
if (!activeExam) {
|
||||
const examButton = exam => `<button class="${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 ? `已归档 · 点击按需加载` : '点击后按需加载本场成绩'}</small><i style="--progress:0%"></i></button>`;
|
||||
const currentExams = data.exams.filter(exam => !exam.archivedAt);
|
||||
const archivedExams = data.exams.filter(exam => exam.archivedAt);
|
||||
return `<section class="result-exam-strip">${currentExams.map(examButton).join('')}</section>${archivedExams.length ? `<details class="result-archive-switcher"><summary>历史归档考试 <span>${archivedExams.length} 场 · 选择后读取</span></summary><section class="result-exam-strip archived">${archivedExams.map(examButton).join('')}</section></details>` : ''}${emptyState('请先选择考试', '未选择考试时不会读取成绩、考生录分名单或成绩复议数据。')}`;
|
||||
}
|
||||
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 resultPage = paged(examResults, 'resultTable');
|
||||
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 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 resultEntryPage = paged(subjectRegistrations, 'resultEntryTable');
|
||||
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 = resultEntryPage.items.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 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 featurePage = paged(featureRegistrations, 'featureScoreTable');
|
||||
const featureModified = featureRegistrations.filter(item => Number(item.featureScore || 0) !== 0).length;
|
||||
const featureRows = featurePage.items.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 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>`;
|
||||
@@ -229,11 +321,11 @@ export function createAdminViews(context) {
|
||||
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>`
|
||||
: '';
|
||||
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 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>${resultPage.items.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>${pagination(resultPage)}</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>` : '';
|
||||
return `${examStrip}${archiveLock}${metrics}${toolbar}${activeExam?.archivedAt ? '' : importPreview}${entry}${featureEntry}${ledger}${appealLedger}`;
|
||||
return `${examStrip}${archiveLock}${metrics}${toolbar}${activeExam?.archivedAt ? '' : importPreview}${entry}${pagination(resultEntryPage)}${featureEntry}${pagination(featurePage)}${ledger}${appealLedger}`;
|
||||
}
|
||||
|
||||
function adminUsers(data) {
|
||||
@@ -280,14 +372,17 @@ export function createAdminViews(context) {
|
||||
function adminFlows(data) {
|
||||
const actionNames = { submit: '提交', approve: '通过', reject: '退回考生', transfer: '转交', return: '退回节点', supervise: '监督调整' };
|
||||
const typeNames = { profile_change: '考生信息修改', registration_review: '考试报名', center_change: '考点考场变更', candidate_account_batch: '批量报名号申领', score_appeal: '考生成绩复议' };
|
||||
return `<div class="workflow-board">${data.instances.map(instance => {
|
||||
const visibleTypes = [...new Set(data.instances.map(instance => instance.businessType))];
|
||||
const workflowPage = paged(data.instances, 'workflowBoard', 20);
|
||||
const toolbar = `<section class="panel data-toolbar workflow-filter-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="workflowBoard" placeholder="搜索考生、学校、考试、责任人或流程"></label><div class="filter-pills"><button class="active" data-action="status-filter" data-target="workflowBoard" data-status="all">全部</button><button data-action="status-filter" data-target="workflowBoard" data-status="pending">待处理</button><button data-action="status-filter" data-target="workflowBoard" data-status="approved">已通过</button><button data-action="status-filter" data-target="workflowBoard" data-status="rejected">已退回</button></div><div class="workflow-type-filter"><select data-table-filter="type" data-target="workflowBoard"><option value="">全部流程类型</option>${visibleTypes.map(type => `<option value="${h(type)}">${h(typeNames[type] || type)}</option>`).join('')}</select></div></section>`;
|
||||
return `${toolbar}<div class="workflow-board" id="workflowBoard">${workflowPage.items.map(instance => {
|
||||
const isCenter = instance.businessType === 'center_change';
|
||||
const isBatch = instance.businessType === 'candidate_account_batch';
|
||||
const isAppeal = instance.businessType === 'score_appeal';
|
||||
const title = isCenter ? instance.centerName : isBatch ? `${instance.schoolName} · ${instance.batchTotalCount} 个账户` : isAppeal ? `${instance.candidateName} · ${instance.appealResult?.subjectName || '成绩复议'}` : instance.candidateName;
|
||||
const sub = isCenter ? `${instance.requestType === 'create' ? '新增考点' : '修改档案'} · ${instance.schoolName}` : isBatch ? (instance.accountBatch?.quotas || []).map(item => `${item.className} ${item.count} 人`).join(' · ') : isAppeal ? `${instance.appealResult?.examName || ''} · 原成绩 ${instance.appealResult?.score ?? '—'}` : `${instance.examName ? `${instance.examName} · ` : ''}${instance.schoolName} · ${instance.className}`;
|
||||
return `<article class="panel workflow-card ${instance.status}"><header><div><span>${h(typeNames[instance.businessType] || instance.businessType)}</span><h2>${h(title)}</h2><p>${h(sub)}</p></div>${badge(instance.status)}</header><div class="workflow-track">${instance.steps.map(step => `<div class="${step.position < instance.currentStep || instance.status === 'approved' ? 'done' : step.position === instance.currentStep && instance.status === 'pending' ? 'current' : ''}"><i>${step.position < instance.currentStep || instance.status === 'approved' ? '✓' : step.position}</i><span><strong>${h(step.name)}</strong><small>${h(statusLabels[step.adminLevel])}</small></span></div>`).join('')}</div><div class="workflow-owner"><span>当前责任人</span><strong>${h(instance.assignee?.displayName || '流程已结束')}</strong><small>${h(instance.currentStepDetail?.name || statusLabels[instance.status])}</small></div><footer><span>${instance.actions.length ? `${h(actionNames[instance.actions.at(-1).action] || instance.actions.at(-1).action)} · ${h(instance.actions.at(-1).actorName)}` : '尚无操作记录'}</span><button class="row-action primary" data-action="open-flow" data-id="${h(instance.id)}">查看与处理</button></footer></article>`;
|
||||
}).join('') || emptyState('暂无审批流程', '考生资料、考试报名、考点档案或批量建号提交后,流程会显示在这里。')}</div>`;
|
||||
return `<article class="panel workflow-card ${instance.status}" data-filter-row data-status="${h(instance.status)}" data-type="${h(instance.businessType)}"><header><div><span>${h(typeNames[instance.businessType] || instance.businessType)}</span><h2>${h(title)}</h2><p>${h(sub)}</p></div>${badge(instance.status)}</header><div class="workflow-track">${instance.steps.map(step => `<div class="${step.position < instance.currentStep || instance.status === 'approved' ? 'done' : step.position === instance.currentStep && instance.status === 'pending' ? 'current' : ''}"><i>${step.position < instance.currentStep || instance.status === 'approved' ? '✓' : step.position}</i><span><strong>${h(step.name)}</strong><small>${h(statusLabels[step.adminLevel])}</small></span></div>`).join('')}</div><div class="workflow-owner"><span>当前责任人</span><strong>${h(instance.assignee?.displayName || '流程已结束')}</strong><small>${h(instance.currentStepDetail?.name || statusLabels[instance.status])}</small></div><footer><span>${instance.actions.length ? `${h(actionNames[instance.actions.at(-1).action] || instance.actions.at(-1).action)} · ${h(instance.actions.at(-1).actorName)}` : '尚无操作记录'}</span><button class="row-action primary" data-action="open-flow" data-id="${h(instance.id)}">查看与处理</button></footer></article>`;
|
||||
}).join('') || emptyState('暂无审批流程', '考生资料、考试报名、考点档案或批量建号提交后,流程会显示在这里。')}</div>${pagination(workflowPage)}`;
|
||||
}
|
||||
|
||||
return { renderAdmin, workflowStepEditor };
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export function createAdmissionViews(context) {
|
||||
const { state, app, h, formatDate, badge, icons, api, renderError, brand } = context;
|
||||
const { state, app, h, formatDate, badge, icons, api, renderError, requireLogin, brand } = context;
|
||||
const nav = [['dashboard','工作台','home'],['plans','招生计划','exam'],['placements','投档审核','check']];
|
||||
|
||||
function shell(page, content, title, description) {
|
||||
@@ -7,7 +7,7 @@ export function createAdmissionViews(context) {
|
||||
}
|
||||
|
||||
async function renderAdmission(page) {
|
||||
if (state.user?.role !== 'admission_school') return navigate('login');
|
||||
if (state.user?.role !== 'admission_school') return requireLogin();
|
||||
if (!nav.some(item => item[0] === page)) page = 'dashboard';
|
||||
const meta = { dashboard:['招生工作台','查看本校计划与待审核投档概况。'], plans:['本校招生计划','上传本年度普通生、特长生计划及指标分配,提交后由超级管理员审核。'], placements:['投档考生审核','查看投档考生资料和本场成绩;无特殊理由不得申请退档。'] };
|
||||
app.innerHTML = shell(page, '<div class="loading-panel"><i></i><span>正在读取数据</span></div>', ...meta[page]);
|
||||
@@ -23,13 +23,37 @@ export function createAdmissionViews(context) {
|
||||
return `<section class="admission-command-banner school"><div><span>ADMISSION OFFICE</span><h2>${h(data.school.name)}</h2><p>学校只接收超级管理员正式投档的数据,不可查看考生完整志愿表。</p></div></section><div class="admin-dashboard-grid"><section class="panel admin-todos"><div class="panel-title"><h2>本校工作入口</h2><span>${data.exams.length} 场启用志愿</span></div><button data-route="admission_school/plans"><i>计</i><span><strong>上传招生计划</strong><small>普通生、特长生与指标分配</small></span>${icons.arrow}</button><button data-route="admission_school/placements"><i>审</i><span><strong>审核投档考生</strong><small>接收或提交特殊退档理由</small></span>${icons.arrow}</button></section></div>`;
|
||||
}
|
||||
|
||||
function paged(items, key, defaultPageSize = 50) {
|
||||
const current = state.tablePages[key] || {};
|
||||
const pageSize = [20, 50, 100].includes(Number(current.pageSize)) ? Number(current.pageSize) : defaultPageSize;
|
||||
const total = items.length;
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||
const page = Math.min(Math.max(1, Number(current.page) || 1), totalPages);
|
||||
state.tablePages[key] = { page, pageSize };
|
||||
return { items: items.slice((page - 1) * pageSize, page * pageSize), page, pageSize, total, totalPages, key };
|
||||
}
|
||||
|
||||
function pagination(meta) {
|
||||
if (!meta || meta.total <= meta.pageSize) return '';
|
||||
const start = (meta.page - 1) * meta.pageSize + 1;
|
||||
const end = Math.min(meta.total, meta.page * meta.pageSize);
|
||||
const pages = [...new Set([1, meta.page - 1, meta.page, meta.page + 1, meta.totalPages])].filter(page => page >= 1 && page <= meta.totalPages);
|
||||
return `<nav class="table-pagination" aria-label="列表分页"><span>第 ${start}—${end} 条,共 ${meta.total} 条</span><div><button type="button" data-action="table-page" data-table-key="${h(meta.key)}" data-page="${meta.page - 1}" ${meta.page === 1 ? 'disabled' : ''}>上一页</button>${pages.map((page, index) => `${index && page - pages[index - 1] > 1 ? '<i>…</i>' : ''}<button type="button" class="${page === meta.page ? 'active' : ''}" data-action="table-page" data-table-key="${h(meta.key)}" data-page="${page}">${page}</button>`).join('')}<button type="button" data-action="table-page" data-table-key="${h(meta.key)}" data-page="${meta.page + 1}" ${meta.page === meta.totalPages ? 'disabled' : ''}>下一页</button><label>每页 <select data-action="table-page-size" data-table-key="${h(meta.key)}">${[20, 50, 100].map(size => `<option value="${size}" ${size === meta.pageSize ? 'selected' : ''}>${size}</option>`).join('')}</select> 条</label></div></nav>`;
|
||||
}
|
||||
|
||||
function plans(data) {
|
||||
return `<section class="panel admission-plan-console structured"><div class="panel-title"><div><h2>提交本校招生计划</h2><p>按招生类别设置计划人数、特长资格和各生源校指标,提交后由超级管理员审核。</p></div></div><form data-form="school-admission-plan"><label><span>招生考试 *</span><select name="examId" required>${data.exams.map(exam => `<option value="${h(exam.id)}">${h(exam.name)}</option>`).join('')}</select></label>${admissionCategoriesEditor(h, data.sourceSchools)}<label><span>计划说明</span><textarea name="note" rows="2" placeholder="填写政策依据或补充说明"></textarea></label><button class="solid-button" type="submit">提交超级管理员审核</button></form></section><section class="panel data-panel"><div class="table-scroll"><table><thead><tr><th>考试</th><th>类别计划</th><th>指标分配</th><th>状态</th><th>审核意见</th></tr></thead><tbody>${data.plans.map(plan => `<tr><td>${h(data.exams.find(exam => exam.id === plan.examId)?.name || plan.examId)}</td><td>${plan.payload.categories.map(item => `<strong>${h(item.name)} ${h(item.quota)} 人</strong><small>${h(specialtyLabel(item.specialtyCategory, item.specialtyType) || '普通 / 政策类')}</small>`).join('')}</td><td>${plan.payload.categories.flatMap(category => (category.indicatorAllocations || []).map(allocation => `${h(data.sourceSchools.find(item => item.id === allocation.sourceSchoolId)?.name || allocation.sourceSchoolId)} ${h(allocation.quota)} 人`)).join('<br>') || '无定向指标'}</td><td>${badge(plan.status)}</td><td>${h(plan.payload.reviewNote || '等待审核')}</td></tr>`).join('') || '<tr><td colspan="5" class="empty-state">尚未提交计划</td></tr>'}</tbody></table></div></section>`;
|
||||
const planPage = paged(data.plans, 'schoolAdmissionPlanTable', 20);
|
||||
return `<section class="panel admission-plan-console structured"><div class="panel-title"><div><h2>提交本校招生计划</h2><p>按招生类别设置计划人数、特长资格和各生源校指标,提交后由超级管理员审核。</p></div></div><form data-form="school-admission-plan"><label><span>招生考试 *</span><select name="examId" required>${data.exams.map(exam => `<option value="${h(exam.id)}">${h(exam.name)}</option>`).join('')}</select></label>${admissionCategoriesEditor(h, data.sourceSchools)}<label><span>计划说明</span><textarea name="note" rows="2" placeholder="填写政策依据或补充说明"></textarea></label><button class="solid-button" type="submit">提交超级管理员审核</button></form></section><section class="panel data-panel"><div class="table-scroll"><table id="schoolAdmissionPlanTable"><thead><tr><th>考试</th><th>类别计划</th><th>指标分配</th><th>状态</th><th>审核意见</th></tr></thead><tbody>${planPage.items.map(plan => `<tr><td>${h(data.exams.find(exam => exam.id === plan.examId)?.name || plan.examId)}</td><td>${plan.payload.categories.map(item => `<strong>${h(item.name)} ${h(item.quota)} 人</strong><small>${h(specialtyLabel(item.specialtyCategory, item.specialtyType) || '普通 / 政策类')}</small>`).join('')}</td><td>${plan.payload.categories.flatMap(category => (category.indicatorAllocations || []).map(allocation => `${h(data.sourceSchools.find(item => item.id === allocation.sourceSchoolId)?.name || allocation.sourceSchoolId)} ${h(allocation.quota)} 人`)).join('<br>') || '无定向指标'}</td><td>${badge(plan.status)}</td><td>${h(plan.payload.reviewNote || '等待审核')}</td></tr>`).join('') || '<tr><td colspan="5" class="empty-state">尚未提交计划</td></tr>'}</tbody></table></div>${pagination(planPage)}</section>`;
|
||||
}
|
||||
|
||||
function placements(data) {
|
||||
const exportBar = data.completedExams?.length ? `<section class="panel admission-export-bar"><div><span>FINAL ROSTER</span><strong>正式录取考生信息 Excel</strong><small>仅录取工作结束后开放,包含本校全部正式录取考生资料与当次成绩。</small></div><label><span>已完成考试</span><select name="exportExamId">${data.completedExams.map(exam => `<option value="${h(exam.id)}">${h(exam.name)}</option>`).join('')}</select></label><button class="solid-button" data-action="download-admitted-candidates">下载 Excel</button></section>` : '';
|
||||
return `${exportBar}<section class="panel data-panel"><div class="panel-title"><div><h2>本校投档名单</h2><p>显示投档所需的考生信息与当次成绩,不包含其余志愿。</p></div><span>${data.placements.length} 人</span></div><div class="table-scroll"><table><thead><tr><th>考生</th><th>资格</th><th>当次成绩</th><th>投档类别</th><th>状态</th><th>审核</th></tr></thead><tbody>${data.placements.map(item => `<tr><td><strong>${h(item.candidate.name)}</strong><small class="mono">${h(item.candidate.registrationNumber)} · ${h(item.candidate.idNumberMasked)}</small></td><td>${h(item.candidate.specialtyLabel || '普通生')}<small>${h(item.candidate.specialtyCertificate || '')}</small><small>${h(item.candidate.policyEligibility || '')}</small></td><td>${item.results.map(result => `${h(result.subjectName)} ${h(result.score)}`).join('<br>')}<strong>总分 ${h(item.payload.totalScore)} · 特征分 ${h(item.featureScore || 0)}</strong></td><td>${h(item.payload.categoryName)}<small>第 ${h(item.payload.preferenceOrder)} 志愿</small></td><td>${badge(item.status)}</td><td>${item.status === 'school_review' ? `<form class="placement-review-form" data-form="placement-review"><input type="hidden" name="id" value="${h(item.id)}"><select name="decision"><option value="accept">接收</option><option value="withdraw">申请退档</option></select><input name="note" placeholder="退档须填写特殊理由"><button class="row-action primary" type="submit">确认</button></form>` : `<small>${h(item.payload.schoolDecisionNote || '已处理')}</small>`}</td></tr>`).join('') || '<tr><td colspan="6" class="empty-state">暂无投档考生</td></tr>'}</tbody></table></div></section>`;
|
||||
const exams = [...new Map(data.placements.map(item => [item.examId, item.examName])).entries()];
|
||||
const categories = [...new Set(data.placements.map(item => item.payload.categoryName).filter(Boolean))];
|
||||
const pendingCount = data.placements.filter(item => item.status === 'school_review').length;
|
||||
const placementPage = paged(data.placements, 'placementReviewTable');
|
||||
const rows = placementPage.items.map(item => `<tr data-status="${h(item.status)}" data-exam="${h(item.examId)}" data-category="${h(item.payload.categoryName)}"><td><input type="checkbox" data-placement-select value="${h(item.id)}" ${item.status === 'school_review' ? '' : 'disabled'} aria-label="选择 ${h(item.candidate.name)}"></td><td><strong>${h(item.candidate.name)}</strong><small class="mono">${h(item.candidate.registrationNumber)} · ${h(item.candidate.idNumberMasked)}</small><small>${h(item.examName)}</small></td><td>${h(item.candidate.specialtyLabel || '普通生')}<small>${h(item.candidate.specialtyCertificate || '')}</small><small>${h(item.candidate.policyEligibility || '')}</small></td><td>${item.results.map(result => `${h(result.subjectName)} ${h(result.score)}`).join('<br>')}<strong>投档分 ${h(item.payload.totalScore)} · 特征分 ${h(item.featureScore || 0)}</strong></td><td>${h(item.payload.categoryName)}<small>第 ${h(item.payload.preferenceOrder)} 志愿</small></td><td>${badge(item.status)}</td><td>${item.status === 'school_review' ? `<form class="placement-review-form" data-form="placement-review"><input type="hidden" name="id" value="${h(item.id)}"><select name="decision"><option value="accept">接收</option><option value="withdraw">申请退档</option></select><input name="note" placeholder="退档须填写至少 8 字理由"><button class="row-action primary" type="submit">确认</button></form>` : `<small>${h(item.payload.schoolDecisionNote || '已处理')}</small>`}</td></tr>`).join('');
|
||||
return `${exportBar}<section class="panel data-panel placement-review-ledger"><div class="panel-title"><div><h2>本校投档审核台账</h2><p>可搜索、筛选和多选批量处理;仅待审核记录可被选中。</p></div><span>${pendingCount} 人待审 / 共 ${data.placements.length} 人</span></div><div class="data-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="placementReviewTable" placeholder="搜索当前页的姓名、报名号、考试、类别或资格"></label><div class="table-filter-selects"><select data-table-filter="exam" data-target="placementReviewTable"><option value="">全部考试</option>${exams.map(([id, name]) => `<option value="${h(id)}">${h(name)}</option>`).join('')}</select><select data-table-filter="category" data-target="placementReviewTable"><option value="">全部招生类别</option>${categories.map(category => `<option value="${h(category)}">${h(category)}</option>`).join('')}</select><button type="button" class="row-action" data-action="clear-table-filters" data-target="placementReviewTable">清除筛选</button></div></div><div class="filter-pills placement-status-pills"><button type="button" class="active" data-action="status-filter" data-target="placementReviewTable" data-status="all">全部</button><button type="button" data-action="status-filter" data-target="placementReviewTable" data-status="school_review">待审核</button><button type="button" data-action="status-filter" data-target="placementReviewTable" data-status="admitted">已接收</button><button type="button" data-action="status-filter" data-target="placementReviewTable" data-status="withdrawal_pending">退档待审</button><button type="button" data-action="status-filter" data-target="placementReviewTable" data-status="final">正式录取</button></div><div class="placement-bulk-toolbar"><label><input type="checkbox" data-placement-select-all data-target="placementReviewTable"><span>选择当前页筛选结果中的待审核考生</span></label><div><strong data-placement-selected-count>已选 0 人</strong><button type="button" class="ghost-button" data-action="bulk-placement-review" data-decision="withdraw">批量申请退档</button><button type="button" class="solid-button" data-action="bulk-placement-review" data-decision="accept">批量接收</button></div></div><div class="table-scroll"><table id="placementReviewTable"><thead><tr><th class="select-column">选择</th><th>考生 / 考试</th><th>资格</th><th>当次成绩</th><th>投档类别</th><th>状态</th><th>单人审核</th></tr></thead><tbody>${rows || '<tr><td colspan="7" class="empty-state">暂无投档考生</td></tr>'}</tbody></table></div>${pagination(placementPage)}</section>`;
|
||||
}
|
||||
return { renderAdmission };
|
||||
}
|
||||
|
||||
+5
-1
@@ -8,6 +8,10 @@ export async function api(path, options = {}) {
|
||||
});
|
||||
const type = response.headers.get('content-type') || '';
|
||||
const data = type.includes('application/json') ? await response.json() : await response.text();
|
||||
if (!response.ok) throw new Error(data?.message || '操作未完成,请稍后重试');
|
||||
if (!response.ok) {
|
||||
const error = new Error(data?.message || '操作未完成,请稍后重试');
|
||||
error.status = response.status;
|
||||
throw error;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ export function createCandidateViews(context) {
|
||||
icons,
|
||||
api,
|
||||
renderError,
|
||||
requireLogin,
|
||||
emptyState,
|
||||
brand
|
||||
} = context;
|
||||
@@ -73,7 +74,7 @@ export function createCandidateViews(context) {
|
||||
}
|
||||
|
||||
async function renderCandidate(page) {
|
||||
if (state.user?.role !== 'candidate') return navigate('login');
|
||||
if (state.user?.role !== 'candidate') return requireLogin();
|
||||
app.classList.remove('admin-readable');
|
||||
if (state.user.mustChangePassword) {
|
||||
app.innerHTML = onboardingShell('password', passwordOnboardingForm());
|
||||
|
||||
@@ -44,14 +44,16 @@ export function createPublicViews(context) {
|
||||
|
||||
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 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 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} 条最低录取分数线。` }));
|
||||
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) {
|
||||
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 === '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>`;
|
||||
@@ -94,7 +96,8 @@ export function createPublicViews(context) {
|
||||
app.classList.remove('admin-readable');
|
||||
const login = kind === 'login';
|
||||
const selfRegistration = state.publicData.selfRegistrationEnabled;
|
||||
app.innerHTML = `<main class="auth-page"><section class="auth-story"><div>${brand()}<p class="overline">CANDIDATE SERVICE</p><h1>${login ? '凭一个号码,' : '自主申请,'}<br><em>${login ? '办理每一次考试。' : '领取固定报名号。'}</em></h1><p>报名号就是考生账户,不因考试、科目或年度报名而改变。</p></div><div class="auth-quote"><span>首次登录顺序</span><p>修改初始密码 → 补全个人信息 → 等待资料审核。</p></div></section><section class="auth-panel"><button class="back-link" data-route="home">← 返回首页</button><div class="auth-card"><p class="overline">${login ? 'ACCOUNT LOGIN' : 'CANDIDATE NUMBER'}</p><h2>${login ? '报名号登录' : '自主申请报名号'}</h2><p>${login ? '考生填写报名号和密码;管理员继续使用管理账号。' : selfRegistration ? '提交基础学籍范围后,系统生成一个长期使用的报名号。' : '当前未开放自主注册,请联系学校领取报名号和初始密码。'}</p>${login ? loginForm() : selfRegistration ? registerForm() : '<div class="registration-closed"><strong>自主注册已关闭</strong><span>学校管理员会为考生创建账户并下发初始密码。</span><button class="solid-button" data-route="login">返回报名号登录</button></div>'}${login && selfRegistration ? `<div class="auth-switch">还没有报名号?<button data-route="register">自主申请</button></div>` : !login ? '<div class="auth-switch">已经有报名号?<button data-route="login">返回登录</button></div>' : ''}</div></section></main>`;
|
||||
const authNotice = login && state.authNotice ? `<div class="auth-session-notice" role="status"><strong>需要重新登录</strong><span>${h(state.authNotice)}</span></div>` : '';
|
||||
app.innerHTML = `<main class="auth-page"><section class="auth-story"><div>${brand()}<p class="overline">CANDIDATE SERVICE</p><h1>${login ? '凭一个号码,' : '自主申请,'}<br><em>${login ? '办理每一次考试。' : '领取固定报名号。'}</em></h1><p>报名号就是考生账户,不因考试、科目或年度报名而改变。</p></div><div class="auth-quote"><span>首次登录顺序</span><p>修改初始密码 → 补全个人信息 → 等待资料审核。</p></div></section><section class="auth-panel"><button class="back-link" data-route="home">← 返回首页</button><div class="auth-card"><p class="overline">${login ? 'ACCOUNT LOGIN' : 'CANDIDATE NUMBER'}</p><h2>${login ? '报名号登录' : '自主申请报名号'}</h2><p>${login ? '考生填写报名号和密码;管理员继续使用管理账号。' : selfRegistration ? '提交基础学籍范围后,系统生成一个长期使用的报名号。' : '当前未开放自主注册,请联系学校领取报名号和初始密码。'}</p>${authNotice}${login ? loginForm() : selfRegistration ? registerForm() : '<div class="registration-closed"><strong>自主注册已关闭</strong><span>学校管理员会为考生创建账户并下发初始密码。</span><button class="solid-button" data-route="login">返回报名号登录</button></div>'}${login && selfRegistration ? `<div class="auth-switch">还没有报名号?<button data-route="register">自主申请</button></div>` : !login ? '<div class="auth-switch">已经有报名号?<button data-route="login">返回登录</button></div>' : ''}</div></section></main>`;
|
||||
}
|
||||
|
||||
function loginForm() {
|
||||
|
||||
@@ -2,13 +2,17 @@ export const state = {
|
||||
user: null,
|
||||
profile: null,
|
||||
publicData: { organization: {}, notices: [], exams: [], stats: {} },
|
||||
publicAnnouncements: { qualifications: [], admissions: [], cutoffs: [] },
|
||||
publicAnnouncements: { plans: [], qualifications: [], admissions: [], cutoffs: [] },
|
||||
noticeCategory: '全部',
|
||||
noticePage: 1,
|
||||
permissions: [],
|
||||
scopeLabel: '',
|
||||
authNotice: '',
|
||||
pageData: null,
|
||||
resultExamFilter: '',
|
||||
resultSubjectFilter: '',
|
||||
resultExamCatalog: null,
|
||||
resultImportPreview: null,
|
||||
tablePages: {},
|
||||
loading: false
|
||||
};
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
export const statusLabels = {
|
||||
pending: '待审核', approved: '已通过', rejected: '需修改',
|
||||
published: '已发布', draft: '草稿', closed: '已结束', archived: '已归档',
|
||||
published: '已发布', visible: '已显示', hidden: '已隐藏', draft: '草稿', closed: '已结束', archived: '已归档',
|
||||
open: '报名中', upcoming: '即将开始', paid: '已缴费', unpaid: '待缴费',
|
||||
super: '超级管理员', school: '校级管理员', class: '班级管理员'
|
||||
, admission_school: '招生学校', filling: '志愿填报中', matching: '投档中', school_review: '学校审核中',
|
||||
|
||||
+3
-1
@@ -1,3 +1,5 @@
|
||||
import { CURRENT_SCHEMA_VERSION } from '../database/version.mjs';
|
||||
|
||||
const admissionNumberRules = (nowIso) => [
|
||||
{
|
||||
id: 'admit_rule_district_room_seat', code: 'district_room_seat', name: '县区编号 + 考场号 + 座位号',
|
||||
@@ -63,7 +65,7 @@ export function createBaseDatabase({ nowIso, hashPassword, initialAdmin = {} })
|
||||
const adminId = 'usr_admin';
|
||||
const createdAt = nowIso();
|
||||
return {
|
||||
meta: { version: 19, createdAt },
|
||||
meta: { version: CURRENT_SCHEMA_VERSION, createdAt },
|
||||
settings: { selfRegistrationEnabled: false },
|
||||
organization: { name: '考试服务平台', code: 'EXAM-SERVICE', phone: '', address: '' },
|
||||
schools: [], classes: [],
|
||||
|
||||
+159
-70
@@ -1,4 +1,4 @@
|
||||
export function createSeedDatabase({ nowIso, hashPassword }) {
|
||||
export function createSeedDatabase({ nowIso, hashPassword, candidateCount = 1200 }) {
|
||||
const adminId = 'usr_admin';
|
||||
const schoolAdminId = 'usr_school_admin';
|
||||
const schoolAdmin2Id = 'usr_school_admin_2';
|
||||
@@ -6,6 +6,39 @@ export function createSeedDatabase({ nowIso, hashPassword }) {
|
||||
const examId = 'exam_autumn_2026';
|
||||
const registrationId = 'reg_demo_2026';
|
||||
const testPasswordHash = hashPassword('12345678');
|
||||
const mainSubjectDefinitions = [
|
||||
{ id: 'sub_chinese', name: '语文', date: '2026-06-20', start: '09:00', end: '11:00', fullScore: 120 },
|
||||
{ id: 'sub_math', name: '数学', date: '2026-06-20', start: '14:30', end: '16:30', fullScore: 120 },
|
||||
{ id: 'sub_english', name: '外语', date: '2026-06-21', start: '09:00', end: '11:00', fullScore: 120 },
|
||||
{ id: 'sub_history', name: '历史', date: '2026-06-21', start: '14:30', end: '15:45', fullScore: 75 },
|
||||
{ id: 'sub_politics', name: '政治', date: '2026-06-21', start: '16:10', end: '17:25', fullScore: 75 },
|
||||
{ id: 'sub_physics', name: '物理', date: '2026-06-22', start: '09:00', end: '10:20', fullScore: 80 },
|
||||
{ id: 'sub_chemistry', name: '化学', date: '2026-06-22', start: '10:45', end: '12:00', fullScore: 70 },
|
||||
{ id: 'sub_experiment', name: '实验', date: '2026-06-22', start: '14:30', end: '15:00', fullScore: 20 },
|
||||
{ id: 'sub_it', name: '信息技术', date: '2026-06-22', start: '15:30', end: '16:00', fullScore: 10 }
|
||||
].map((subject, index) => ({
|
||||
...subject, fee: 0, passRule: 'fixed_score', passValue: subject.fullScore * 0.6,
|
||||
passScore: subject.fullScore * 0.6, order: index + 1
|
||||
}));
|
||||
const mainSubjectIds = mainSubjectDefinitions.map(subject => subject.id);
|
||||
const mainCandidateCount = Math.max(1, Math.trunc(Number(candidateCount) || 1200));
|
||||
const specialtyCandidateCount = Math.min(150, mainCandidateCount);
|
||||
|
||||
// 固定种子使每次导入得到相同的近似正态成绩,便于复现测试。
|
||||
let randomState = 0x20260620;
|
||||
const seededRandom = () => {
|
||||
randomState = (randomState + 0x6D2B79F5) >>> 0;
|
||||
let value = randomState;
|
||||
value = Math.imul(value ^ (value >>> 15), value | 1);
|
||||
value ^= value + Math.imul(value ^ (value >>> 7), value | 61);
|
||||
return ((value ^ (value >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
const normalRandom = () => {
|
||||
const first = Math.max(seededRandom(), Number.EPSILON);
|
||||
return Math.sqrt(-2 * Math.log(first)) * Math.cos(2 * Math.PI * seededRandom());
|
||||
};
|
||||
const normalScore = fullScore => Number(Math.min(fullScore, Math.max(0, fullScore * 0.72 + fullScore * 0.14 * normalRandom())).toFixed(1));
|
||||
const scoreGrade = (score, fullScore) => score >= fullScore * 0.9 ? 'A' : score >= fullScore * 0.75 ? 'B' : score >= fullScore * 0.6 ? 'C' : 'D';
|
||||
const database = {
|
||||
meta: { version: 15, createdAt: nowIso() },
|
||||
settings: { selfRegistrationEnabled: false },
|
||||
@@ -16,8 +49,8 @@ export function createSeedDatabase({ nowIso, hashPassword }) {
|
||||
address: '江苏省连云港市海州区文教路 18 号'
|
||||
},
|
||||
schools: [
|
||||
{ id: 'school_hz1', name: '海州市第一中学', code: 'HZ01', address: '江苏省连云港市海州区学府路 8 号', active: true },
|
||||
{ id: 'school_hz3', name: '海州市第三中学', code: 'HZ03', address: '江苏省连云港市连云区育才路 16 号', active: true }
|
||||
{ id: 'school_hz1', name: '海州市第一中学', code: 'HZ01', address: '江苏省连云港市海州区学府路 8 号', isSourceSchool: true, isAdmissionSchool: false, active: true },
|
||||
{ id: 'school_hz3', name: '海州市第三中学', code: 'HZ03', address: '江苏省连云港市连云区育才路 16 号', isSourceSchool: true, isAdmissionSchool: false, active: true }
|
||||
],
|
||||
classes: [
|
||||
{ id: 'class_hz1_301', schoolId: 'school_hz1', name: '高三(1)班', grade: '高三', active: true },
|
||||
@@ -39,7 +72,8 @@ export function createSeedDatabase({ nowIso, hashPassword }) {
|
||||
phone: '13800138000', email: 'zhou@example.com', school: '海州市第一中学', grade: '高三(2)班', schoolId: 'school_hz1', classId: 'class_hz1_302',
|
||||
provinceCode: '320000', provinceName: '江苏省', cityCode: '320700', cityName: '连云港市', districtCode: '320706', districtName: '海州区',
|
||||
address: '学府路 8 号', emergencyContact: '周建国', emergencyPhone: '13900139000',
|
||||
nativePlace: '江苏海州', birthDate: '2008-08-16', ethnicity: '汉族', postalCode: '222000', guardianName: '周建国', guardianPhone: '13900139000', profileCompleted: false,
|
||||
nativePlace: '江苏海州', birthDate: '2008-08-16', ethnicity: '汉族', postalCode: '222000', guardianName: '周建国', guardianPhone: '13900139000',
|
||||
specialtyCategory: 'arts', specialtyType: 'fine_arts', specialtyTypes: ['fine_arts'], specialtyCertificate: 'ART-DEMO-0001', policyEligibility: '特长生资格已核验', profileCompleted: true,
|
||||
status: 'approved', reviewNote: '身份信息与学籍信息核验一致', reviewedAt: '2026-07-18T08:30:00.000Z', updatedAt: '2026-07-17T09:20:00.000Z'
|
||||
}
|
||||
],
|
||||
@@ -50,33 +84,29 @@ export function createSeedDatabase({ nowIso, hashPassword }) {
|
||||
],
|
||||
exams: [
|
||||
{
|
||||
id: examId, code: 'EX-2026-AUT', name: '2026 年秋季统一考试', description: '面向全市普通高中高三在籍学生的统一学业考试。',
|
||||
registrationStart: '2026-07-01T00:00:00.000Z', registrationEnd: '2026-07-31T15:59:59.000Z',
|
||||
examStart: '2026-08-16T01:00:00.000Z', examEnd: '2026-08-18T09:00:00.000Z',
|
||||
admitDownloadStart: '2026-07-19T00:00:00.000Z', admitDownloadEnd: '2026-08-16T00:45:00.000Z',
|
||||
location: '海州市各指定考点', passPolicy: 'rank_percent', passValue: 60, status: 'published', createdAt: '2026-06-18T02:00:00.000Z',
|
||||
subjects: [
|
||||
{ id: 'sub_chinese', name: '语文', date: '2026-08-16', start: '09:00', end: '11:30', fee: 30, fullScore: 150, passRule: 'fixed_score', passValue: 90, passScore: 90 },
|
||||
{ id: 'sub_math', name: '数学', date: '2026-08-16', start: '15:00', end: '17:00', fee: 30, fullScore: 150, passRule: 'rank_percent', passValue: 60, passScore: null },
|
||||
{ id: 'sub_physics', name: '物理', date: '2026-08-17', start: '09:00', end: '10:30', fee: 25, fullScore: 100, passScore: 60 },
|
||||
{ id: 'sub_history', name: '历史', date: '2026-08-17', start: '09:00', end: '10:30', fee: 25, fullScore: 100, passScore: 60 },
|
||||
{ id: 'sub_english', name: '外语', date: '2026-08-17', start: '15:00', end: '16:30', fee: 30, fullScore: 150, passScore: 90 },
|
||||
{ id: 'sub_chemistry', name: '化学', date: '2026-08-18', start: '09:00', end: '10:15', fee: 25, fullScore: 100, passScore: 60 },
|
||||
{ id: 'sub_biology', name: '生物', date: '2026-08-18', start: '15:00', end: '16:15', fee: 25, fullScore: 100, passScore: 60 }
|
||||
]
|
||||
id: examId, code: 'EX-2026-ZK', name: '2026 年海州市初中学业水平考试', description: '演示数据主考试:覆盖成绩发布、特长生和第一轮志愿填报。',
|
||||
registrationStart: '2026-04-01T00:00:00.000Z', registrationEnd: '2026-04-30T15:59:59.000Z',
|
||||
examStart: '2026-06-20T01:00:00.000Z', examEnd: '2026-06-22T08:00:00.000Z',
|
||||
admitDownloadStart: '2026-06-10T00:00:00.000Z', admitDownloadEnd: '2026-06-20T00:45:00.000Z',
|
||||
location: '海州市各指定考点', passPolicy: 'rank_percent', passValue: 60, status: 'published', createdAt: '2026-03-18T02:00:00.000Z',
|
||||
subjects: mainSubjectDefinitions
|
||||
},
|
||||
{
|
||||
id: 'exam_mock_2026', code: 'EX-2026-MOCK-2', name: '第二次全市模拟考试', description: '秋季统一考试前的全流程模拟考试。',
|
||||
registrationStart: '2026-10-01T00:00:00.000Z', registrationEnd: '2026-10-20T15:59:59.000Z',
|
||||
examStart: '2026-11-08T01:00:00.000Z', examEnd: '2026-11-10T09:00:00.000Z',
|
||||
admitDownloadStart: '2026-11-01T00:00:00.000Z', admitDownloadEnd: '2026-11-08T00:45:00.000Z',
|
||||
location: '考点待公布', passPolicy: 'rank_percent', passValue: 60, status: 'draft', createdAt: nowIso(), subjects: []
|
||||
location: '考点待公布', passPolicy: 'rank_percent', passValue: 60, status: 'draft', createdAt: nowIso(), subjects: [
|
||||
{ id: 'mock_sub_chinese', name: '语文', date: '2026-11-08', start: '09:00', end: '11:00', fee: 0, fullScore: 120, passRule: 'fixed_score', passValue: 72, passScore: 72 },
|
||||
{ id: 'mock_sub_math', name: '数学', date: '2026-11-08', start: '14:30', end: '16:30', fee: 0, fullScore: 120, passRule: 'fixed_score', passValue: 72, passScore: 72 },
|
||||
{ id: 'mock_sub_english', name: '外语', date: '2026-11-09', start: '09:00', end: '11:00', fee: 0, fullScore: 120, passRule: 'fixed_score', passValue: 72, passScore: 72 }
|
||||
]
|
||||
}
|
||||
],
|
||||
registrations: [
|
||||
{
|
||||
id: registrationId, userId: candidateId, examId, subjectIds: ['sub_chinese', 'sub_math', 'sub_physics', 'sub_english', 'sub_chemistry'],
|
||||
status: 'approved', paymentStatus: 'paid', paidAt: '2026-07-18T09:00:00.000Z', paidBy: 'usr_class_admin', createdAt: '2026-07-08T05:18:00.000Z', reviewedAt: '2026-07-18T08:32:00.000Z', registrationNumber: '2026-HZ01-F-0001', numberRuleId: 'rule_default'
|
||||
id: registrationId, userId: candidateId, examId, subjectIds: mainSubjectIds,
|
||||
status: 'approved', paymentStatus: 'paid', paidAt: '2026-05-18T09:00:00.000Z', paidBy: 'usr_class_admin', createdAt: '2026-04-08T05:18:00.000Z', reviewedAt: '2026-04-18T08:32:00.000Z', reviewNote: '报名审核通过', registrationNumber: '2026-HZ01-F-0001', numberRuleId: 'rule_default', featureScore: 90
|
||||
}
|
||||
],
|
||||
results: [],
|
||||
@@ -171,15 +201,21 @@ export function createSeedDatabase({ nowIso, hashPassword }) {
|
||||
]
|
||||
};
|
||||
|
||||
const schoolDefinitions = [
|
||||
const sourceSchools = [
|
||||
{ key: 'hz1', id: 'school_hz1', code: 'HZ01', name: '海州市第一中学', districtCode: '320706', districtName: '海州区', address: '学府路 8 号' },
|
||||
{ key: 'hz3', id: 'school_hz3', code: 'HZ03', name: '海州市第三中学', districtCode: '320703', districtName: '连云区', address: '育才路 16 号' },
|
||||
{ key: 'hz5', id: 'school_hz5', code: 'HZ05', name: '海州市第五中学', districtCode: '320707', districtName: '赣榆区', address: '青口路 28 号' },
|
||||
{ key: 'hz7', id: 'school_hz7', code: 'HZ07', name: '海州市第七中学', districtCode: '320723', districtName: '灌云县', address: '胜利路 66 号' }
|
||||
{ key: 'hz7', id: 'school_hz7', code: 'HZ07', name: '海州市第七中学', districtCode: '320723', districtName: '灌云县', address: '胜利路 66 号' },
|
||||
{ key: 'hz9', id: 'school_hz9', code: 'HZ09', name: '海州市第九中学', districtCode: '320724', districtName: '灌南县', address: '新安路 39 号' }
|
||||
];
|
||||
for (const school of schoolDefinitions) {
|
||||
const admissionSchools = [
|
||||
{ key: 'admission_1', id: 'school_admission_1', code: 'AD01', name: '海州市高级中学', address: '江苏省连云港市海州区苍梧路 100 号' },
|
||||
{ key: 'admission_2', id: 'school_admission_2', code: 'AD02', name: '海州市实验高级中学', address: '江苏省连云港市连云区海棠路 88 号' },
|
||||
{ key: 'admission_3', id: 'school_admission_3', code: 'AD03', name: '海州市外国语高级中学', address: '江苏省连云港市赣榆区黄海路 66 号' }
|
||||
];
|
||||
for (const school of sourceSchools) {
|
||||
if (!database.schools.some(item => item.id === school.id)) {
|
||||
database.schools.push({ id: school.id, name: school.name, code: school.code, address: `江苏省连云港市${school.districtName}${school.address}`, active: true });
|
||||
database.schools.push({ id: school.id, name: school.name, code: school.code, address: `江苏省连云港市${school.districtName}${school.address}`, isSourceSchool: true, isAdmissionSchool: false, active: true });
|
||||
}
|
||||
for (let classIndex = 1; classIndex <= 3; classIndex += 1) {
|
||||
const classId = `class_${school.key}_30${classIndex}`;
|
||||
@@ -189,14 +225,25 @@ export function createSeedDatabase({ nowIso, hashPassword }) {
|
||||
}
|
||||
}
|
||||
|
||||
for (const school of schoolDefinitions) {
|
||||
const generatedSchoolAdminId = `usr_test_school_admin_${school.key}`;
|
||||
for (const school of admissionSchools) {
|
||||
database.schools.push({ id: school.id, name: school.name, code: school.code, address: school.address, isSourceSchool: false, isAdmissionSchool: true, active: true });
|
||||
database.users.push({
|
||||
id: generatedSchoolAdminId, username: `test_school_admin_${school.key}`, passwordHash: testPasswordHash,
|
||||
role: 'admin', adminLevel: 'school', schoolId: school.id, displayName: `${school.name}测试校管`, active: true, createdAt: nowIso()
|
||||
id: `usr_${school.key}`, username: `${school.key}_admin`, passwordHash: testPasswordHash, role: 'admission_school',
|
||||
schoolId: school.id, displayName: `${school.name}招生办`, active: true, mustChangePassword: false, createdAt: nowIso()
|
||||
});
|
||||
}
|
||||
|
||||
for (const school of sourceSchools) {
|
||||
if (school.id !== 'school_hz1') {
|
||||
const generatedSchoolAdminId = `usr_test_school_admin_${school.key}`;
|
||||
database.users.push({
|
||||
id: generatedSchoolAdminId, username: `test_school_admin_${school.key}`, passwordHash: testPasswordHash,
|
||||
role: 'admin', adminLevel: 'school', schoolId: school.id, displayName: `${school.name}测试校管`, active: true, createdAt: nowIso()
|
||||
});
|
||||
}
|
||||
for (let classIndex = 1; classIndex <= 3; classIndex += 1) {
|
||||
const classId = `class_${school.key}_30${classIndex}`;
|
||||
if (classId === 'class_hz1_302') continue;
|
||||
database.users.push({
|
||||
id: `usr_test_class_admin_${school.key}_${classIndex}`, username: `test_class_admin_${school.key}_${classIndex}`,
|
||||
passwordHash: testPasswordHash, role: 'admin', adminLevel: 'class', schoolId: school.id, classId,
|
||||
@@ -205,7 +252,7 @@ export function createSeedDatabase({ nowIso, hashPassword }) {
|
||||
}
|
||||
}
|
||||
|
||||
for (const school of schoolDefinitions) {
|
||||
for (const school of sourceSchools) {
|
||||
let center = database.testCenters.find(item => item.schoolId === school.id);
|
||||
if (!center) {
|
||||
center = {
|
||||
@@ -228,21 +275,20 @@ export function createSeedDatabase({ nowIso, hashPassword }) {
|
||||
}
|
||||
}
|
||||
|
||||
// 测试库停留在考场编排前:不预置编排计划、准考证或成绩。
|
||||
// 测试库停留在考场编排前:不预置编排计划或准考证;主考试成绩与志愿已完成。
|
||||
const familyNames = ['赵', '钱', '孙', '李', '周', '吴', '郑', '王', '冯', '陈', '褚', '卫'];
|
||||
const givenNames = ['子涵', '梓萱', '宇航', '雨欣', '浩然', '思远', '佳宁', '晨曦', '明轩', '若彤', '嘉诚', '欣怡'];
|
||||
const subjectSets = [
|
||||
['sub_chinese', 'sub_math', 'sub_english'],
|
||||
['sub_chinese', 'sub_math', 'sub_physics', 'sub_chemistry'],
|
||||
['sub_chinese', 'sub_math', 'sub_history', 'sub_biology'],
|
||||
['sub_chinese', 'sub_math', 'sub_english', 'sub_physics', 'sub_chemistry'],
|
||||
['sub_chinese', 'sub_math', 'sub_english', 'sub_history', 'sub_biology']
|
||||
const specialtyDefinitions = [
|
||||
{ category: 'sports', type: 'track_field', label: '田径' },
|
||||
{ category: 'sports', type: 'basketball', label: '篮球' },
|
||||
{ category: 'arts', type: 'fine_arts', label: '美术' },
|
||||
{ category: 'arts', type: 'vocal_music', label: '声乐' },
|
||||
{ category: 'arts', type: 'dance', label: '舞蹈' }
|
||||
];
|
||||
const registrationWorkflowId = 'workflow_registration';
|
||||
for (let index = 0; index < 360; index += 1) {
|
||||
for (let index = 0; index < mainCandidateCount - 1; index += 1) {
|
||||
const serial = index + 1001;
|
||||
const school = schoolDefinitions[index % schoolDefinitions.length];
|
||||
const classIndex = Math.floor(index / schoolDefinitions.length) % 3 + 1;
|
||||
const school = sourceSchools[index % sourceSchools.length];
|
||||
const classIndex = Math.floor(index / sourceSchools.length) % 3 + 1;
|
||||
const classId = `class_${school.key}_30${classIndex}`;
|
||||
const gender = index % 2 === 0 ? '男' : '女';
|
||||
const genderCode = gender === '男' ? 'M' : 'F';
|
||||
@@ -250,7 +296,7 @@ export function createSeedDatabase({ nowIso, hashPassword }) {
|
||||
const profileId = `profile_bulk_${String(index + 1).padStart(4, '0')}`;
|
||||
const registrationIdBulk = `reg_bulk_${String(index + 1).padStart(4, '0')}`;
|
||||
const candidateNumber = `2026-${school.code}-${genderCode}-${String(serial).padStart(4, '0')}`;
|
||||
const createdAt = new Date(Date.UTC(2026, 6, 2 + (index % 20), 1 + (index % 8), index % 60)).toISOString();
|
||||
const createdAt = new Date(Date.UTC(2026, 3, 2 + (index % 20), 1 + (index % 8), index % 60)).toISOString();
|
||||
const name = `${familyNames[index % familyNames.length]}${givenNames[Math.floor(index / familyNames.length) % givenNames.length]}${Math.floor(index / 144) + 1}`;
|
||||
const idNumber = `3207002008${String(index % 12 + 1).padStart(2, '0')}${String(index % 28 + 1).padStart(2, '0')}${String(index + 1).padStart(4, '0')}`;
|
||||
const phone = `138${String(10000000 + index).padStart(8, '0')}`;
|
||||
@@ -258,6 +304,8 @@ export function createSeedDatabase({ nowIso, hashPassword }) {
|
||||
id: userId, username: candidateNumber, candidateNumber, passwordHash: testPasswordHash, role: 'candidate',
|
||||
displayName: name, active: true, mustChangePassword: false, createdAt
|
||||
});
|
||||
const isSpecialtyCandidate = index < specialtyCandidateCount - 1;
|
||||
const specialty = isSpecialtyCandidate ? specialtyDefinitions[index % specialtyDefinitions.length] : null;
|
||||
database.candidateProfiles.push({
|
||||
id: profileId, userId, name, gender, idNumber, phone, email: `candidate${String(index + 1).padStart(4, '0')}@example.test`,
|
||||
school: school.name, grade: `高三(${classIndex})班`, schoolId: school.id, classId,
|
||||
@@ -266,41 +314,82 @@ export function createSeedDatabase({ nowIso, hashPassword }) {
|
||||
emergencyContact: `${familyNames[index % familyNames.length]}家长`, emergencyPhone: `139${String(10000000 + index).padStart(8, '0')}`,
|
||||
nativePlace: `江苏${school.districtName}`, birthDate: `2008-${String(index % 12 + 1).padStart(2, '0')}-${String(index % 28 + 1).padStart(2, '0')}`,
|
||||
ethnicity: index % 19 === 0 ? '回族' : '汉族', postalCode: '222000', guardianName: `${familyNames[index % familyNames.length]}家长`,
|
||||
guardianPhone: `139${String(10000000 + index).padStart(8, '0')}`, profileCompleted: true, status: 'approved',
|
||||
reviewNote: '批量测试数据:学籍核验通过', reviewedAt: '2026-06-30T08:00:00.000Z', reviewerId: adminId, updatedAt: createdAt
|
||||
guardianPhone: `139${String(10000000 + index).padStart(8, '0')}`,
|
||||
specialtyCategory: specialty?.category || '', specialtyType: specialty?.type || '', specialtyTypes: specialty ? [specialty.type] : [],
|
||||
specialtyCertificate: specialty ? `SPECIAL-2026-${String(index + 2).padStart(4, '0')}` : '', policyEligibility: specialty ? `${specialty.label}特长生资格已核验` : '',
|
||||
profileCompleted: true, status: 'approved', reviewNote: '批量演示数据:学籍核验通过',
|
||||
reviewedAt: '2026-04-30T08:00:00.000Z', reviewerId: adminId, updatedAt: createdAt
|
||||
});
|
||||
|
||||
const scenario = index % 4;
|
||||
const status = scenario === 0 ? 'pending' : scenario === 1 ? 'rejected' : 'approved';
|
||||
const paymentStatus = scenario === 3 ? 'paid' : 'unpaid';
|
||||
const classAdminId = `usr_test_class_admin_${school.key}_${classIndex}`;
|
||||
const schoolAdmin = `usr_test_school_admin_${school.key}`;
|
||||
const paymentStatus = index % 2 === 0 ? 'paid' : 'unpaid';
|
||||
const classAdminId = classId === 'class_hz1_302' ? 'usr_class_admin' : `usr_test_class_admin_${school.key}_${classIndex}`;
|
||||
database.registrations.push({
|
||||
id: registrationIdBulk, userId, examId, subjectIds: subjectSets[index % subjectSets.length], status, paymentStatus,
|
||||
paidAt: paymentStatus === 'paid' ? '2026-07-25T08:30:00.000Z' : null,
|
||||
id: registrationIdBulk, userId, examId, subjectIds: mainSubjectIds, status: 'approved', paymentStatus,
|
||||
paidAt: paymentStatus === 'paid' ? '2026-05-18T08:30:00.000Z' : null,
|
||||
paidBy: paymentStatus === 'paid' ? classAdminId : null,
|
||||
createdAt, reviewedAt: status === 'pending' ? null : '2026-07-22T08:00:00.000Z',
|
||||
reviewNote: status === 'rejected' ? '测试场景:报名资料被退回' : status === 'approved' ? '测试场景:报名审核通过' : '',
|
||||
registrationNumber: candidateNumber, numberRuleId: 'rule_default'
|
||||
createdAt, reviewedAt: '2026-04-30T08:00:00.000Z', reviewNote: '批量演示数据:报名审核通过',
|
||||
registrationNumber: candidateNumber, numberRuleId: 'rule_default',
|
||||
featureScore: specialty ? Number((80 + seededRandom() * 20).toFixed(1)) : 0
|
||||
});
|
||||
if (status === 'pending' || status === 'rejected') {
|
||||
const instanceId = `flow_reg_bulk_${String(index + 1).padStart(4, '0')}`;
|
||||
database.workflowInstances.push({
|
||||
id: instanceId, workflowId: registrationWorkflowId, businessType: 'registration_review', businessId: registrationIdBulk,
|
||||
status, currentStep: 1, assigneeId: status === 'pending' ? schoolAdmin : null, createdAt,
|
||||
completedAt: status === 'rejected' ? '2026-07-22T08:00:00.000Z' : null
|
||||
}
|
||||
|
||||
const admissionCreatedAt = '2026-07-01T00:00:00.000Z';
|
||||
database.admissionRecords.push({
|
||||
id: 'admission_setting_main_2026', kind: 'setting', examId, userId: adminId, schoolId: null, status: 'closed',
|
||||
payload: {
|
||||
enabled: true, preferenceStart: '2026-07-01T00:00:00.000Z', preferenceEnd: '2026-07-15T15:59:59.000Z',
|
||||
maxChoices: 3, maxSubmissions: 1, round: 1, autoPublish: true, progress: '第一轮志愿已全部填报完毕,等待投档'
|
||||
},
|
||||
createdAt: admissionCreatedAt, updatedAt: '2026-07-16T00:00:00.000Z'
|
||||
});
|
||||
for (const school of admissionSchools) {
|
||||
database.admissionRecords.push({
|
||||
id: `admission_plan_${school.key}`, kind: 'plan', examId, userId: `usr_${school.key}`, schoolId: school.id, status: 'approved',
|
||||
payload: {
|
||||
categories: [
|
||||
{ code: 'general', name: '普通生', quota: 350, specialtyCategory: '', specialtyType: '', indicatorAllocations: [] },
|
||||
{ code: 'sports', name: '体育特长生', quota: 1, specialtyCategory: 'sports', specialtyType: '', indicatorAllocations: [] },
|
||||
{ code: 'arts', name: '艺术特长生', quota: 1, specialtyCategory: 'arts', specialtyType: '', indicatorAllocations: [] }
|
||||
],
|
||||
note: '演示数据招生计划:普通类 350 人,特长生合计 2 人', submittedBy: `${school.name}招生办`, reviewedBy: '林老师', reviewedAt: admissionCreatedAt, publicVisible: true
|
||||
},
|
||||
createdAt: admissionCreatedAt, updatedAt: admissionCreatedAt
|
||||
});
|
||||
}
|
||||
|
||||
const mainRegistrations = database.registrations.filter(registration => registration.examId === examId);
|
||||
for (const [candidateIndex, registration] of mainRegistrations.entries()) {
|
||||
const profile = database.candidateProfiles.find(item => item.userId === registration.userId);
|
||||
const isSpecialtyCandidate = Boolean(profile?.specialtyCategory);
|
||||
for (const subject of mainSubjectDefinitions) {
|
||||
const score = normalScore(subject.fullScore);
|
||||
database.results.push({
|
||||
id: `result_main_${String(candidateIndex + 1).padStart(4, '0')}_${subject.id.slice(4)}`,
|
||||
registrationId: registration.id, subjectId: subject.id, score, grade: scoreGrade(score, subject.fullScore),
|
||||
published: true, updatedAt: '2026-06-30T08:00:00.000Z', publishedAt: '2026-06-30T08:00:00.000Z'
|
||||
});
|
||||
database.workflowActions.push({
|
||||
id: `flow_action_submit_bulk_${String(index + 1).padStart(4, '0')}`, instanceId, actorId: userId, action: 'submit',
|
||||
note: '提交考试报名', fromAssigneeId: null, toAssigneeId: schoolAdmin, createdAt
|
||||
});
|
||||
if (status === 'rejected') {
|
||||
database.workflowActions.push({
|
||||
id: `flow_action_reject_bulk_${String(index + 1).padStart(4, '0')}`, instanceId, actorId: schoolAdmin, action: 'reject',
|
||||
note: '测试场景:报名资料被退回', fromAssigneeId: schoolAdmin, toAssigneeId: null, createdAt: '2026-07-22T08:00:00.000Z'
|
||||
});
|
||||
}
|
||||
}
|
||||
const rotatedAdmissionSchools = admissionSchools.map((_, offset) => admissionSchools[(candidateIndex + offset) % admissionSchools.length]);
|
||||
const choices = rotatedAdmissionSchools.map((school, choiceIndex) => ({
|
||||
schoolId: school.id,
|
||||
categoryCode: isSpecialtyCandidate && choiceIndex === 0 ? profile.specialtyCategory : 'general',
|
||||
preferenceType: 'general'
|
||||
}));
|
||||
database.admissionRecords.push({
|
||||
id: `preference_main_${String(candidateIndex + 1).padStart(4, '0')}`, kind: 'preference', examId,
|
||||
userId: registration.userId, schoolId: null, status: 'submitted',
|
||||
payload: {
|
||||
round: 1, submissionCount: 1, submittedAt: new Date(Date.UTC(2026, 6, 5 + (candidateIndex % 10), 1 + (candidateIndex % 8), candidateIndex % 60)).toISOString(),
|
||||
choices
|
||||
},
|
||||
createdAt: admissionCreatedAt, updatedAt: '2026-07-15T08:00:00.000Z'
|
||||
});
|
||||
database.admissionRecords.push({
|
||||
id: `qualification_main_${String(candidateIndex + 1).padStart(4, '0')}`, kind: 'indicator_qualification', examId,
|
||||
userId: registration.userId, schoolId: profile.schoolId, status: 'confirmed',
|
||||
payload: { eligible: isSpecialtyCandidate, confirmedAt: '2026-06-28T08:00:00.000Z', note: isSpecialtyCandidate ? '特长资格核验通过' : '普通生' },
|
||||
createdAt: '2026-06-28T08:00:00.000Z', updatedAt: '2026-06-28T08:00:00.000Z'
|
||||
});
|
||||
}
|
||||
|
||||
return database;
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export const CURRENT_SCHEMA_VERSION = 20;
|
||||
+216
-43
@@ -120,11 +120,68 @@ export function createAdminRoutes(context) {
|
||||
})).filter(item => item.code && item.name && item.quota > 0);
|
||||
}
|
||||
|
||||
function systemPublications(db) {
|
||||
const examName = examId => db.exams.find(item => item.id === examId)?.name || '未知考试';
|
||||
const schoolName = schoolId => db.schools.find(item => item.id === schoolId)?.name || '未知学校';
|
||||
const view = (record, sourceType, category, title, publishedAt, summary) => ({
|
||||
id: record.id,
|
||||
sourceType,
|
||||
category,
|
||||
title,
|
||||
summary,
|
||||
author: '系统自动发布',
|
||||
publishedAt,
|
||||
visible: record.payload?.publicVisible !== false,
|
||||
status: record.payload?.publicVisible === false ? 'hidden' : 'visible'
|
||||
});
|
||||
const plans = admissionRecords(db, 'plan').filter(item => item.status === 'approved').map(item => view(
|
||||
item,
|
||||
'plan',
|
||||
'招生计划',
|
||||
`${examName(item.examId)} · ${schoolName(item.schoolId)}招生计划公示`,
|
||||
item.payload?.reviewedAt || item.updatedAt,
|
||||
'审核通过后由系统生成,当前页面仅控制是否在公开通知目录显示。'
|
||||
));
|
||||
const qualifications = admissionRecords(db, 'qualification_publication').filter(item => item.status === 'published').map(item => view(
|
||||
item,
|
||||
'qualification',
|
||||
'指标资格',
|
||||
`${examName(item.examId)} · ${schoolName(item.schoolId)}指标分配资格公示`,
|
||||
item.payload?.publishedAt || item.updatedAt,
|
||||
'资格确认完成后由系统生成,内容随资格确认结果更新。'
|
||||
));
|
||||
const admissions = admissionRecords(db, 'setting').filter(item => item.status === 'completed' && item.payload?.autoPublish !== false).map(item => view(
|
||||
item,
|
||||
'admission',
|
||||
'录取名单',
|
||||
`${examName(item.examId)}最终录取名单`,
|
||||
item.payload?.completedAt || item.updatedAt,
|
||||
'录取结束后由系统生成,内容取自最终录取结果。'
|
||||
));
|
||||
const cutoffs = admissionRecords(db, 'cutoff_publication').filter(item => item.status === 'published' && admissionSetting(db, item.examId)?.payload?.autoPublish !== false).map(item => view(
|
||||
item,
|
||||
'cutoff',
|
||||
'录取分数线',
|
||||
`${examName(item.examId)}录取分数线`,
|
||||
item.payload?.publishedAt || item.updatedAt,
|
||||
'录取结束后由系统生成,内容取自各招生类别最低录取分数。'
|
||||
));
|
||||
return [...plans, ...qualifications, ...admissions, ...cutoffs]
|
||||
.sort((left, right) => new Date(right.publishedAt) - new Date(left.publishedAt));
|
||||
}
|
||||
|
||||
async function handleAdmin(request, response, pathname) {
|
||||
if (!pathname.startsWith('/api/admin/')) return false;
|
||||
const user = await requireUser(request, response, 'admin');
|
||||
if (!user) return true;
|
||||
const db = await readDb();
|
||||
if (request.method === 'GET' && pathname === '/api/admin/results') {
|
||||
const requestedExamId = new URL(request.url, `http://${request.headers.host || '127.0.0.1'}`).searchParams.get('examId');
|
||||
if (!requestedExamId) {
|
||||
if (!requirePermission(user, response, 'results.read')) return true;
|
||||
return sendJson(response, 200, { ok: true, selectedExamId: '', results: [], appeals: [], registrations: [], exams: [], resultCache: { enabled: cache.enabled, status: cache.status } });
|
||||
}
|
||||
}
|
||||
const db = request.authDb || await readDb();
|
||||
if (request.method === 'DELETE' && /^\/api\/admin\/(?:admins|candidates|candidate-accounts)(?:\/|$)/.test(pathname)) {
|
||||
return sendError(response, 405, '账户不得删除;考生账户请由校方归档,管理员账户可停用');
|
||||
}
|
||||
@@ -176,7 +233,7 @@ export function createAdminRoutes(context) {
|
||||
if (status.complete) {
|
||||
const published = admissionRecords(nextDb, 'qualification_publication', setting.examId).find(item => item.schoolId === user.schoolId);
|
||||
const publication = published || { id: uid('qualification_publication'), kind: 'qualification_publication', examId: setting.examId, userId: user.id, schoolId: user.schoolId, createdAt: now };
|
||||
Object.assign(publication, { status: 'published', updatedAt: now, payload: { publishedAt: now, rows: status.rows } });
|
||||
Object.assign(publication, { status: 'published', updatedAt: now, payload: { ...publication.payload, publishedAt: now, rows: status.rows } });
|
||||
records.push(publication);
|
||||
}
|
||||
await database.saveAdmissionRecords(records, logAction(db, user, '批量确认指标分配资格', `${qualifications.length} 人 · ${body.eligible ? '有资格' : '无资格'}${status.complete ? ' · 全校已自动公示' : ''}`));
|
||||
@@ -203,7 +260,7 @@ export function createAdminRoutes(context) {
|
||||
if (status.complete) {
|
||||
const published = admissionRecords(nextDb, 'qualification_publication', setting.examId).find(item => item.schoolId === user.schoolId);
|
||||
const publication = published || { id: uid('qualification_publication'), kind: 'qualification_publication', examId: setting.examId, userId: user.id, schoolId: user.schoolId, createdAt: now };
|
||||
Object.assign(publication, { status: 'published', updatedAt: now, payload: { publishedAt: now, rows: status.rows } });
|
||||
Object.assign(publication, { status: 'published', updatedAt: now, payload: { ...publication.payload, publishedAt: now, rows: status.rows } });
|
||||
records.push(publication);
|
||||
}
|
||||
await database.saveAdmissionRecords(records, logAction(db, user, '确认指标分配资格', `${account.candidateNumber} · ${body.eligible ? '有资格' : '无资格'}${status.complete ? ' · 全校已自动公示' : ''}`));
|
||||
@@ -212,20 +269,27 @@ export function createAdminRoutes(context) {
|
||||
|
||||
if (pathname === '/api/admin/admissions' && request.method === 'GET') {
|
||||
if (user.adminLevel !== 'super') return sendError(response, 403, '只有超级管理员可以查看志愿与录取数据');
|
||||
const settings = admissionRecords(db, 'setting').map(setting => ({ ...setting, exam: db.exams.find(item => item.id === setting.examId), publicRows: setting.status === 'completed' ? publicAdmissionRows(db, setting.examId) : [] }));
|
||||
const plans = admissionRecords(db, 'plan').map(plan => ({ ...plan, schoolName: db.schools.find(item => item.id === plan.schoolId)?.name || '', examName: db.exams.find(item => item.id === plan.examId)?.name || '', remainingCategories: remainingPlanQuota(db, plan) }));
|
||||
const examById = new Map(db.exams.map(item => [item.id, item]));
|
||||
const schoolById = new Map(db.schools.map(item => [item.id, item]));
|
||||
const userById = new Map(db.users.map(item => [item.id, item]));
|
||||
const profileByUserId = new Map(db.candidateProfiles.map(item => [item.userId, item]));
|
||||
const settings = admissionRecords(db, 'setting').map(setting => ({ ...setting, exam: examById.get(setting.examId) }));
|
||||
const plans = admissionRecords(db, 'plan').map(plan => ({ ...plan, schoolName: schoolById.get(plan.schoolId)?.name || '', examName: examById.get(plan.examId)?.name || '', remainingCategories: remainingPlanQuota(db, plan) }));
|
||||
const placements = admissionRecords(db, 'placement').map(placement => {
|
||||
const account = db.users.find(item => item.id === placement.userId) || {};
|
||||
const profile = db.candidateProfiles.find(item => item.userId === placement.userId) || {};
|
||||
const account = userById.get(placement.userId) || {};
|
||||
const profile = profileByUserId.get(placement.userId) || {};
|
||||
const qualification = resolveProfileSpecialty(profile);
|
||||
return { ...placement, candidate: { registrationNumber: account.candidateNumber, name: profile.name, idNumberMasked: maskId(profile.idNumber), specialtyLabel: specialtyLabel(qualification.category, qualification.type) }, schoolName: db.schools.find(item => item.id === placement.schoolId)?.name || '' };
|
||||
return { ...placement, candidate: { registrationNumber: account.candidateNumber, name: profile.name, idNumberMasked: maskId(profile.idNumber), specialtyLabel: specialtyLabel(qualification.category, qualification.type) }, schoolName: schoolById.get(placement.schoolId)?.name || '' };
|
||||
});
|
||||
const preferences = admissionRecords(db, 'preference').map(preference => {
|
||||
const account = db.users.find(item => item.id === preference.userId) || {};
|
||||
const profile = db.candidateProfiles.find(item => item.userId === preference.userId) || {};
|
||||
return { ...preference, candidate: { registrationNumber: account.candidateNumber, name: profile.name }, choices: (preference.payload?.choices || []).map(choice => ({ ...choice, schoolName: db.schools.find(item => item.id === choice.schoolId)?.name || '' })) };
|
||||
const account = userById.get(preference.userId) || {};
|
||||
const profile = profileByUserId.get(preference.userId) || {};
|
||||
return { ...preference, candidate: { registrationNumber: account.candidateNumber, name: profile.name }, choices: (preference.payload?.choices || []).map(choice => ({ ...choice, schoolName: schoolById.get(choice.schoolId)?.name || '' })) };
|
||||
});
|
||||
const schoolAccounts = db.users.filter(item => item.role === 'admission_school').map(item => {
|
||||
const school = schoolById.get(item.schoolId);
|
||||
return { ...safeUser(item), active: item.active !== false, createdAt: item.createdAt, schoolName: school?.name || '', schoolCode: school?.code || '' };
|
||||
});
|
||||
const schoolAccounts = db.users.filter(item => item.role === 'admission_school').map(safeUser);
|
||||
return sendJson(response, 200, { ok: true, settings, plans, preferences, placements, schoolAccounts, schools: db.schools.filter(item => item.active), admissionSchools: db.schools.filter(item => item.active && item.isAdmissionSchool), sourceSchools: db.schools.filter(item => item.active && item.isSourceSchool), exams: db.exams.filter(item => !item.archivedAt) });
|
||||
}
|
||||
if (pathname === '/api/admin/admission-school-accounts' && request.method === 'POST') {
|
||||
@@ -240,6 +304,30 @@ export function createAdminRoutes(context) {
|
||||
await database.createAdmissionSchoolAccount(account, logAction(db, user, '创建招生学校账号', `${school.name} · ${username}`));
|
||||
return sendJson(response, 201, { ok: true, account: safeUser(account) });
|
||||
}
|
||||
const admissionAccountMatch = pathname.match(/^\/api\/admin\/admission-school-accounts\/([^/]+)$/);
|
||||
if (admissionAccountMatch && request.method === 'PATCH') {
|
||||
if (user.adminLevel !== 'super') return sendError(response, 403, '只有超级管理员可以维护招生学校账号');
|
||||
const target = db.users.find(item => item.id === admissionAccountMatch[1] && item.role === 'admission_school');
|
||||
if (!target) return sendError(response, 404, '招生学校账号不存在');
|
||||
const body = await readJson(request);
|
||||
target.active = body.active == null ? target.active : Boolean(body.active);
|
||||
target.displayName = cleanText(body.displayName || target.displayName, 80);
|
||||
await database.updateAdmin(target, false, logAction(db, user, target.active ? '启用招生学校账号' : '停用招生学校账号', `${target.displayName} · ${target.username}`));
|
||||
if (!target.active) for (const [token, session] of sessions) if (session.userId === target.id) sessions.delete(token);
|
||||
return sendJson(response, 200, { ok: true, account: { ...safeUser(target), active: target.active } });
|
||||
}
|
||||
const admissionAccountResetMatch = pathname.match(/^\/api\/admin\/admission-school-accounts\/([^/]+)\/reset-password$/);
|
||||
if (admissionAccountResetMatch && request.method === 'POST') {
|
||||
if (user.adminLevel !== 'super') return sendError(response, 403, '只有超级管理员可以重置招生学校账号密码');
|
||||
const target = db.users.find(item => item.id === admissionAccountResetMatch[1] && item.role === 'admission_school');
|
||||
if (!target) return sendError(response, 404, '招生学校账号不存在');
|
||||
const temporaryPassword = `Reset-${randomBytes(7).toString('base64url')}`;
|
||||
target.passwordHash = hashPassword(temporaryPassword);
|
||||
target.active = true;
|
||||
await database.updateAdmin(target, true, logAction(db, user, '重置招生学校账号密码', `${target.displayName} · ${target.username}`));
|
||||
for (const [token, session] of sessions) if (session.userId === target.id) sessions.delete(token);
|
||||
return sendJson(response, 200, { ok: true, username: target.username, temporaryPassword });
|
||||
}
|
||||
const settingMatch = pathname.match(/^\/api\/admin\/admissions\/([^/]+)\/setting$/);
|
||||
if (settingMatch && request.method === 'PUT') {
|
||||
if (user.adminLevel !== 'super') return sendError(response, 403, '只有超级管理员可以设置志愿填报');
|
||||
@@ -274,8 +362,9 @@ export function createAdminRoutes(context) {
|
||||
if (admissionRecords(db, 'placement', exam.id).some(item => item.schoolId === school.id && item.status !== 'withdrawn')) return sendError(response, 409, '已经产生投档记录,不能再修改该校本轮招生计划');
|
||||
const now = nowIso();
|
||||
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: { ...plan.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 cache.invalidate('public');
|
||||
return sendJson(response, existing ? 200 : 201, { ok: true, plan });
|
||||
}
|
||||
const planReviewMatch = pathname.match(/^\/api\/admin\/admission-plans\/([^/]+)$/);
|
||||
@@ -289,6 +378,7 @@ export function createAdminRoutes(context) {
|
||||
plan.updatedAt = nowIso();
|
||||
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 cache.invalidate('public');
|
||||
return sendJson(response, 200, { ok: true, plan });
|
||||
}
|
||||
const actionMatch = pathname.match(/^\/api\/admin\/admissions\/([^/]+)\/(match|finalize|supplementary)$/);
|
||||
@@ -315,7 +405,7 @@ export function createAdminRoutes(context) {
|
||||
const cutoffRows = admissionCutoffRows(completedDb, setting.examId);
|
||||
const existingCutoff = admissionRecords(db, 'cutoff_publication', setting.examId)[0];
|
||||
const cutoffPublication = existingCutoff || { id: uid('cutoff_publication'), kind: 'cutoff_publication', examId: setting.examId, userId: user.id, schoolId: null, createdAt: now };
|
||||
Object.assign(cutoffPublication, { status: 'published', updatedAt: now, payload: { publishedAt: now, rows: cutoffRows } });
|
||||
Object.assign(cutoffPublication, { status: 'published', updatedAt: now, payload: { ...cutoffPublication.payload, publishedAt: now, rows: cutoffRows } });
|
||||
await database.saveAdmissionRecords([setting, ...admitted, ...notifications, cutoffPublication], logAction(db, user, '结束录取并发布结果与分数线', `${setting.examId} · ${admitted.length} 人`));
|
||||
return sendJson(response, 200, { ok: true, admittedCount: admitted.length, publicRows: publicAdmissionRows(completedDb, setting.examId), cutoffRows });
|
||||
}
|
||||
@@ -350,7 +440,7 @@ export function createAdminRoutes(context) {
|
||||
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 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 buffer = Buffer.from(await buildWorkbook(resource, rows, { template, subtitle }));
|
||||
return sendWorkbook(response, buffer, `${excelResourceNames[resource]}-${template ? '导入模板' : '导出'}-${new Date().toISOString().slice(0, 10)}.xlsx`);
|
||||
@@ -861,6 +951,10 @@ export function createAdminRoutes(context) {
|
||||
const candidates = db.candidateProfiles.filter(profile => profileInScope(user, profile)).map(profile => {
|
||||
const instance = pendingWorkflow(db, 'profile_change', profile.id) || db.workflowInstances.filter(item => item.businessType === 'profile_change' && item.businessId === profile.id)[0];
|
||||
const account = db.users.find(item => item.id === profile.userId);
|
||||
const registrations = db.registrations
|
||||
.filter(item => item.userId === profile.userId)
|
||||
.map(item => examRegistrationView(db, item))
|
||||
.sort((left, right) => new Date(right.createdAt || 0) - new Date(left.createdAt || 0));
|
||||
return {
|
||||
...profile,
|
||||
idNumberMasked: profile.idNumber.startsWith('PENDING-') ? '待考生补充' : maskId(profile.idNumber),
|
||||
@@ -870,6 +964,7 @@ export function createAdminRoutes(context) {
|
||||
accountArchived: Boolean(account?.archivedAt),
|
||||
archivedAt: account?.archivedAt || null,
|
||||
archivedByName: db.users.find(item => item.id === account?.archivedBy)?.displayName || '',
|
||||
registrations,
|
||||
workflow: workflowView(db, instance)
|
||||
};
|
||||
});
|
||||
@@ -1293,7 +1388,7 @@ export function createAdminRoutes(context) {
|
||||
const notices = db.notices
|
||||
.sort((a, b) => new Date(b.publishAt || b.createdAt) - new Date(a.publishAt || a.createdAt))
|
||||
.map(noticeForClient);
|
||||
return sendJson(response, 200, { ok: true, notices });
|
||||
return sendJson(response, 200, { ok: true, notices, publications: systemPublications(db) });
|
||||
}
|
||||
if (request.method === 'POST' && pathname === '/api/admin/notices') {
|
||||
if (!requirePermission(user, response, '*')) return true;
|
||||
@@ -1305,8 +1400,24 @@ export function createAdminRoutes(context) {
|
||||
const notice = { id: uid('notice'), title, summary: cleanText(body.summary, 260) || contentText.slice(0, 80), content, category: cleanText(body.category, 30) || '通知公告', pinned: Boolean(body.pinned), status: body.status === 'draft' ? 'draft' : 'published', publishAt: body.status === 'draft' ? null : nowIso(), createdAt: nowIso(), author: user.displayName };
|
||||
const log = logAction(db, user, notice.status === 'published' ? '发布通知' : '保存通知草稿', notice.title);
|
||||
await database.createNotice(notice, log);
|
||||
await cache.invalidate('public');
|
||||
return sendJson(response, 201, { ok: true, notice: noticeForClient(notice) });
|
||||
}
|
||||
const publicationMatch = pathname.match(/^\/api\/admin\/publications\/(plan|qualification|admission|cutoff)\/([^/]+)$/);
|
||||
if (request.method === 'PATCH' && publicationMatch) {
|
||||
if (!requirePermission(user, response, '*')) return true;
|
||||
const kindByType = { plan: 'plan', qualification: 'qualification_publication', admission: 'setting', cutoff: 'cutoff_publication' };
|
||||
const sourceType = publicationMatch[1];
|
||||
const record = admissionRecords(db, kindByType[sourceType]).find(item => item.id === publicationMatch[2]);
|
||||
if (!record) return sendError(response, 404, '系统公示不存在');
|
||||
const body = await readJson(request);
|
||||
if (typeof body.visible !== 'boolean') return sendError(response, 400, '请明确设置是否显示');
|
||||
record.payload = { ...record.payload, publicVisible: body.visible };
|
||||
record.updatedAt = nowIso();
|
||||
await database.saveAdmissionRecord(record, logAction(db, user, body.visible ? '显示系统公示' : '隐藏系统公示', `${sourceType} · ${record.id}`));
|
||||
await cache.invalidate('public');
|
||||
return sendJson(response, 200, { ok: true, publication: systemPublications({ ...db, admissionRecords: db.admissionRecords.map(item => item.id === record.id ? record : item) }).find(item => item.id === record.id && item.sourceType === sourceType) });
|
||||
}
|
||||
const noticeMatch = pathname.match(/^\/api\/admin\/notices\/([^/]+)$/);
|
||||
if (request.method === 'PATCH' && noticeMatch) {
|
||||
if (!requirePermission(user, response, '*')) return true;
|
||||
@@ -1326,6 +1437,7 @@ export function createAdminRoutes(context) {
|
||||
}
|
||||
const log = logAction(db, user, '更新通知', `${notice.title} · ${notice.status}`);
|
||||
await database.updateNotice(notice, log);
|
||||
await cache.invalidate('public');
|
||||
return sendJson(response, 200, { ok: true, notice: noticeForClient(notice) });
|
||||
}
|
||||
if (request.method === 'POST' && pathname === '/api/admin/results/cache/refresh') {
|
||||
@@ -1340,53 +1452,67 @@ export function createAdminRoutes(context) {
|
||||
}
|
||||
if (request.method === 'GET' && pathname === '/api/admin/results') {
|
||||
if (!requirePermission(user, response, 'results.read')) return true;
|
||||
const requestedExamId = new URL(request.url, `http://${request.headers.host || '127.0.0.1'}`).searchParams.get('examId');
|
||||
const selectedExam = db.exams.find(item => item.id === requestedExamId);
|
||||
if (!selectedExam) return sendError(response, 404, '请选择有效的考试后再读取成绩');
|
||||
const scopedRegistrations = db.registrations.filter(item => registrationInScope(db, user, item));
|
||||
const results = db.results.filter(result => scopedRegistrations.some(item => item.id === result.registrationId)).map(result => {
|
||||
const registration = db.registrations.find(item => item.id === result.registrationId);
|
||||
const profile = db.candidateProfiles.find(item => item.userId === registration?.userId);
|
||||
const account = db.users.find(item => item.id === registration?.userId);
|
||||
const exam = db.exams.find(item => item.id === registration?.examId);
|
||||
const subject = exam?.subjects.find(item => item.id === result.subjectId);
|
||||
const pass = subjectPassEvaluation(db, result, subject);
|
||||
const rank = resultRankInfo(db, result);
|
||||
const selectedScopedRegistrations = scopedRegistrations.filter(item => item.examId === selectedExam.id);
|
||||
if (user.adminLevel !== 'super' && !selectedScopedRegistrations.length) return sendError(response, 404, '该考试不在当前管理范围内');
|
||||
const approved = selectedScopedRegistrations.filter(item => item.status === 'approved');
|
||||
const selectedRegistrationIds = new Set(selectedScopedRegistrations.map(item => item.id));
|
||||
const examRegistrations = db.registrations.filter(item => item.examId === selectedExam.id && item.status === 'approved');
|
||||
const examRegistrationIds = new Set(examRegistrations.map(item => item.id));
|
||||
const examResults = db.results.filter(result => examRegistrationIds.has(result.registrationId));
|
||||
const scoreDb = { ...db, registrations: examRegistrations, results: examResults };
|
||||
const registrationById = new Map(selectedScopedRegistrations.map(item => [item.id, item]));
|
||||
const profileByUserId = new Map(db.candidateProfiles.map(item => [item.userId, item]));
|
||||
const accountById = new Map(db.users.map(item => [item.id, item]));
|
||||
const classById = new Map(db.classes.map(item => [item.id, item]));
|
||||
const rawScopedResults = examResults.filter(result => selectedRegistrationIds.has(result.registrationId));
|
||||
const results = rawScopedResults.map(result => {
|
||||
const registration = registrationById.get(result.registrationId);
|
||||
const profile = profileByUserId.get(registration?.userId);
|
||||
const account = accountById.get(registration?.userId);
|
||||
const subject = selectedExam.subjects.find(item => item.id === result.subjectId);
|
||||
const pass = subjectPassEvaluation(scoreDb, result, subject);
|
||||
return {
|
||||
...result, grade: result.published ? rank.grade : result.grade, rank: rank.rank, cohortSize: rank.cohortSize, rankPercent: rank.rankPercent,
|
||||
...result, grade: result.published ? pass.grade : result.grade, rank: pass.rank, cohortSize: pass.cohortSize, rankPercent: pass.rankPercent,
|
||||
candidateName: profile?.name, candidateNumber: account?.candidateNumber || registration?.registrationNumber || '',
|
||||
schoolName: profile?.school || '', className: db.classes.find(item => item.id === profile?.classId)?.name || profile?.grade || '',
|
||||
examId: exam?.id, examCode: exam?.code, examName: exam?.name, subjectName: subject?.name,
|
||||
schoolName: profile?.school || '', className: classById.get(profile?.classId)?.name || profile?.grade || '',
|
||||
examId: selectedExam.id, examCode: selectedExam.code, examName: selectedExam.name, subjectName: subject?.name,
|
||||
fullScore: subject?.fullScore, passRule: subject?.passRule || 'fixed_score', passValue: subject?.passValue ?? subject?.passScore,
|
||||
passScore: pass.passScore, cutoffRank: pass.cutoffRank, passText: subjectPassText(subject),
|
||||
qualified: pass.qualified
|
||||
};
|
||||
});
|
||||
const appeals = db.workflowInstances.filter(instance => instance.businessType === 'score_appeal' && results.some(result => result.id === instance.businessId)).map(instance => {
|
||||
const result = results.find(item => item.id === instance.businessId);
|
||||
const resultById = new Map(results.map(result => [result.id, result]));
|
||||
const appeals = db.workflowInstances.filter(instance => instance.businessType === 'score_appeal' && resultById.has(instance.businessId)).map(instance => {
|
||||
const result = resultById.get(instance.businessId);
|
||||
const workflow = workflowView(db, instance);
|
||||
return { ...workflow, result, reason: workflow.actions.find(action => action.action === 'submit')?.note || '' };
|
||||
});
|
||||
const approved = scopedRegistrations.filter(item => item.status === 'approved');
|
||||
const exams = db.exams.map(exam => {
|
||||
const examRegistrations = approved.filter(item => item.examId === exam.id);
|
||||
const examResults = results.filter(item => item.examId === exam.id);
|
||||
const summaries = examRegistrations.map(item => examResultSummary(db, item)).filter(Boolean);
|
||||
const enrolledSubjects = examRegistrations.reduce((sum, item) => sum + item.subjectIds.length, 0);
|
||||
const scored = examResults.length;
|
||||
if (exam.id !== selectedExam.id) return publicExam(exam);
|
||||
const summaries = approved.map(item => examResultSummary(scoreDb, item)).filter(Boolean);
|
||||
const enrolledSubjects = approved.reduce((sum, item) => sum + item.subjectIds.length, 0);
|
||||
const scored = rawScopedResults.length;
|
||||
return {
|
||||
...publicExam(exam), registrationCount: examRegistrations.length, enrolledSubjects, scored,
|
||||
published: examResults.filter(item => item.published).length, missing: Math.max(0, enrolledSubjects - scored),
|
||||
...publicExam(exam), registrationCount: approved.length, enrolledSubjects, scored,
|
||||
published: rawScopedResults.filter(item => item.published).length, missing: Math.max(0, enrolledSubjects - scored),
|
||||
complete: summaries.filter(item => item.complete).length,
|
||||
qualified: summaries.filter(item => item.complete && item.qualified === true).length,
|
||||
unqualified: summaries.filter(item => item.complete && item.qualified === false).length,
|
||||
appeals: appeals.filter(item => item.result?.examId === exam.id).length
|
||||
appeals: appeals.length
|
||||
};
|
||||
}).filter(exam => exam.registrationCount || results.some(item => item.examId === exam.id) || user.adminLevel === 'super');
|
||||
});
|
||||
const registrations = user.adminLevel === 'super' ? approved.map(item => {
|
||||
const view = examRegistrationView(db, item);
|
||||
const profile = db.candidateProfiles.find(profileItem => profileItem.userId === 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 profile = profileByUserId.get(item.userId);
|
||||
const account = accountById.get(item.userId);
|
||||
const specialty = resolveProfileSpecialty(profile || {});
|
||||
return { ...view, candidateName: profile?.name || account?.displayName || '', candidateNumber: account?.candidateNumber || item.registrationNumber || '', schoolName: profile?.school || '', className: classById.get(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, selectedExamId: selectedExam.id, results, appeals, registrations, exams, resultCache: { enabled: cache.enabled, status: cache.status } });
|
||||
}
|
||||
if (request.method === 'POST' && pathname === '/api/admin/results/import') {
|
||||
if (!requirePermission(user, response, '*')) return true;
|
||||
@@ -1394,6 +1520,53 @@ export function createAdminRoutes(context) {
|
||||
const result = await commitResultImport(db, user, body.rows);
|
||||
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$/);
|
||||
if (request.method === 'PATCH' && featureScoreMatch) {
|
||||
if (!requirePermission(user, response, '*')) return true;
|
||||
|
||||
@@ -22,7 +22,7 @@ export function createAdmissionRoutes(context) {
|
||||
if (!pathname.startsWith('/api/admission/')) return false;
|
||||
const user = await requireUser(request, response, 'admission_school');
|
||||
if (!user) return true;
|
||||
const db = await readDb();
|
||||
const db = request.authDb || await readDb();
|
||||
const school = db.schools.find(item => item.id === user.schoolId && item.active && item.isAdmissionSchool);
|
||||
if (!school) return sendError(response, 403, '招生学校账号未绑定有效学校');
|
||||
|
||||
@@ -53,16 +53,25 @@ export function createAdmissionRoutes(context) {
|
||||
return sendJson(response, existing ? 200 : 201, { ok: true, plan });
|
||||
}
|
||||
if (request.method === 'GET' && pathname === '/api/admission/placements') {
|
||||
const accountById = new Map(db.users.map(item => [item.id, item]));
|
||||
const profileByUserId = new Map(db.candidateProfiles.map(item => [item.userId, item]));
|
||||
const examById = new Map(db.exams.map(item => [item.id, item]));
|
||||
const registrationByExamUser = new Map(db.registrations.map(item => [`${item.examId}\u0000${item.userId}`, item]));
|
||||
const publishedResultsByRegistration = new Map();
|
||||
for (const result of db.results) {
|
||||
if (!result.published) continue;
|
||||
const rows = publishedResultsByRegistration.get(result.registrationId) || [];
|
||||
rows.push(result);
|
||||
publishedResultsByRegistration.set(result.registrationId, rows);
|
||||
}
|
||||
const placements = admissionRecords(db, 'placement').filter(item => item.schoolId === school.id).map(item => {
|
||||
const account = db.users.find(entry => entry.id === item.userId) || {};
|
||||
const profile = db.candidateProfiles.find(entry => entry.userId === item.userId) || {};
|
||||
const registration = db.registrations.find(entry => entry.examId === item.examId && entry.userId === item.userId);
|
||||
const results = db.results.filter(entry => entry.registrationId === registration?.id && entry.published).map(result => {
|
||||
const exam = db.exams.find(entry => entry.id === item.examId);
|
||||
return { subjectName: exam?.subjects.find(subject => subject.id === result.subjectId)?.name || result.subjectId, score: result.score };
|
||||
});
|
||||
const account = accountById.get(item.userId) || {};
|
||||
const profile = profileByUserId.get(item.userId) || {};
|
||||
const exam = examById.get(item.examId);
|
||||
const registration = registrationByExamUser.get(`${item.examId}\u0000${item.userId}`);
|
||||
const results = (publishedResultsByRegistration.get(registration?.id) || []).map(result => ({ subjectName: exam?.subjects.find(subject => subject.id === result.subjectId)?.name || result.subjectId, score: result.score }));
|
||||
const qualification = resolveProfileSpecialty(profile);
|
||||
return { ...item, candidate: { registrationNumber: account.candidateNumber, name: profile.name, gender: profile.gender, idNumberMasked: maskId(profile.idNumber), specialtyCategory: qualification.category, specialtyType: qualification.type, specialtyLabel: specialtyLabel(qualification.category, qualification.type), specialtyCertificate: profile.specialtyCertificate || '', policyEligibility: profile.policyEligibility || '' }, featureScore: Number(registration?.featureScore || 0), results };
|
||||
return { ...item, examName: exam?.name || item.examId, candidate: { registrationNumber: account.candidateNumber, name: profile.name, gender: profile.gender, idNumberMasked: maskId(profile.idNumber), specialtyCategory: qualification.category, specialtyType: qualification.type, specialtyLabel: specialtyLabel(qualification.category, qualification.type), specialtyCertificate: profile.specialtyCertificate || '', policyEligibility: profile.policyEligibility || '' }, featureScore: Number(registration?.featureScore || 0), results };
|
||||
});
|
||||
const completedExams = db.exams.filter(exam => admissionRecords(db, 'setting', exam.id).some(setting => setting.status === 'completed') && placements.some(item => item.examId === exam.id && item.status === 'final'));
|
||||
return sendJson(response, 200, { ok: true, school, placements, completedExams });
|
||||
@@ -93,6 +102,26 @@ export function createAdmissionRoutes(context) {
|
||||
const buffer = Buffer.from(await buildWorkbook('admitted_candidates', rows, { subtitle: `${exam.name}|${school.name}` }));
|
||||
return sendWorkbook(response, buffer, `${exam.name}-${school.name}-录取考生信息.xlsx`);
|
||||
}
|
||||
if (request.method === 'POST' && pathname === '/api/admission/placements/bulk') {
|
||||
const body = await readJson(request);
|
||||
const ids = [...new Set((Array.isArray(body.ids) ? body.ids : []).map(id => cleanText(id, 64)).filter(Boolean))];
|
||||
const decision = cleanText(body.decision, 30);
|
||||
const note = cleanText(body.note, 500);
|
||||
if (!ids.length) return sendError(response, 400, '请至少选择一名待审核考生');
|
||||
if (!['accept', 'withdraw'].includes(decision)) return sendError(response, 400, '请选择接收或申请退档');
|
||||
if (decision === 'withdraw' && note.length < 8) return sendError(response, 400, '批量申请退档必须填写至少 8 个字的特殊理由');
|
||||
const placements = admissionRecords(db, 'placement').filter(item => ids.includes(item.id) && item.schoolId === school.id && item.status === 'school_review');
|
||||
if (placements.length !== ids.length) return sendError(response, 409, '所选记录中包含已处理或不属于本校的投档记录,请刷新后重试');
|
||||
const now = nowIso();
|
||||
for (const placement of placements) {
|
||||
placement.status = decision === 'accept' ? 'admitted' : 'withdrawal_pending';
|
||||
placement.payload.schoolDecisionNote = note;
|
||||
if (decision === 'withdraw') placement.payload.withdrawalReason = note;
|
||||
placement.updatedAt = now;
|
||||
}
|
||||
await database.saveAdmissionRecords(placements, logAction(db, user, decision === 'accept' ? '批量接收投档考生' : '批量申请退档', `${school.name} · ${placements.length} 人`));
|
||||
return sendJson(response, 200, { ok: true, count: placements.length, decision });
|
||||
}
|
||||
const placementMatch = pathname.match(/^\/api\/admission\/placements\/([^/]+)$/);
|
||||
if (request.method === 'PATCH' && placementMatch) {
|
||||
const placement = admissionRecords(db, 'placement').find(item => item.id === placementMatch[1] && item.schoolId === school.id);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { noticeForClient } from '../security/notice-content.mjs';
|
||||
import { admissionRecords, admissionSetting, publicAdmissionRows, sourceSchoolQualificationStatus } from '../services/volunteer-admission.mjs';
|
||||
import { specialtyLabel } from '../data/specialty-types.mjs';
|
||||
|
||||
export function createPublicRoutes(context) {
|
||||
const {
|
||||
@@ -65,13 +66,34 @@ export function createPublicRoutes(context) {
|
||||
if (pathname === '/api/public/announcements') {
|
||||
const payload = await cache.remember('public', 'admission-announcements', async () => {
|
||||
const db = await readDb();
|
||||
const qualifications = admissionRecords(db, 'qualification_publication').filter(item => item.status === 'published' && sourceSchoolQualificationStatus(db, item.examId, item.schoolId).complete).map(item => ({
|
||||
const plans = admissionRecords(db, 'plan').filter(item => item.status === 'approved' && item.payload?.publicVisible !== false).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' && item.payload?.publicVisible !== false && 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,
|
||||
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));
|
||||
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));
|
||||
return { ok: true, qualifications, admissions, cutoffs };
|
||||
const admissions = admissionRecords(db, 'setting').filter(item => item.status === 'completed' && item.payload?.autoPublish !== false && item.payload?.publicVisible !== 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' && item.payload?.publicVisible !== false && 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, plans, qualifications, admissions, cutoffs };
|
||||
});
|
||||
return sendJson(response, 200, payload);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ export function createSessionManager({ sessions, readDb, sendError }) {
|
||||
return null;
|
||||
}
|
||||
const db = await readDb();
|
||||
request.authDb = db;
|
||||
const user = db.users.find(item => item.id === session.userId) || null;
|
||||
return user?.active === false || user?.archivedAt ? null : normalizeUser(user);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,15 @@ export function candidateTotalScore(db, examId, userId) {
|
||||
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) {
|
||||
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 occupied = new Map();
|
||||
const occupiedIndicators = new Map();
|
||||
const occupiedGeneral = new Map();
|
||||
for (const placement of existing) {
|
||||
const key = categoryKey(placement.schoolId, placement.payload?.categoryCode);
|
||||
occupied.set(key, (occupied.get(key) || 0) + 1);
|
||||
if (placement.payload?.quotaBucket?.startsWith('indicator:')) {
|
||||
const indicatorKey = `${key}|${placement.payload.quotaBucket.slice(10)}`;
|
||||
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 profile = db.candidateProfiles.find(item => item.userId === preference.userId) || {};
|
||||
const account = db.users.find(item => item.id === preference.userId) || {};
|
||||
return { preference, profile, account, score: candidateTotalScore(db, examId, preference.userId) };
|
||||
}).filter(item => item.score != null && !existing.some(entry => entry.userId === item.preference.userId && ['school_review', 'admitted', 'final', 'withdrawal_pending'].includes(entry.status)))
|
||||
.sort((left, right) => right.score - left.score || String(left.account.candidateNumber || '').localeCompare(String(right.account.candidateNumber || '')));
|
||||
const registration = db.registrations.find(item => item.examId === examId && item.userId === preference.userId && item.status === 'approved');
|
||||
return { preference, profile, account, registration, culturalScore: candidateTotalScore(db, examId, preference.userId), featureScore: Number(registration?.featureScore || 0), nextChoiceIndex: 0 };
|
||||
}).filter(item => item.culturalScore != null && !existing.some(entry => entry.userId === item.preference.userId && ['school_review', 'admitted', 'final', 'withdrawal_pending'].includes(entry.status)));
|
||||
|
||||
const created = [];
|
||||
for (const candidate of candidates) {
|
||||
for (const [index, choice] of (candidate.preference.payload?.choices || []).entries()) {
|
||||
const compareProposals = (left, right) => right.totalScore - left.totalScore || String(left.candidate.account.candidateNumber || '').localeCompare(String(right.candidate.account.candidateNumber || ''));
|
||||
const acceptedByBucket = new Map();
|
||||
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));
|
||||
if (!target) continue;
|
||||
const { category } = target;
|
||||
if (!candidateEligibleForCategory(candidate.profile, category)) continue;
|
||||
const key = categoryKey(choice.schoolId, choice.categoryCode);
|
||||
if ((occupied.get(key) || 0) >= Number(category.quota || 0)) continue;
|
||||
let quotaBucket = null;
|
||||
let bucketKey = '';
|
||||
let capacity = 0;
|
||||
let occupiedCount = 0;
|
||||
if (choice.preferenceType === 'indicator') {
|
||||
const qualification = indicatorQualification(db, examId, candidate.preference.userId);
|
||||
const allocation = (category.indicatorAllocations || []).find(item => item.sourceSchoolId === candidate.profile.schoolId);
|
||||
if (!qualification?.payload?.eligible || !allocation) continue;
|
||||
const indicatorKey = `${key}|${candidate.profile.schoolId}`;
|
||||
if ((occupiedIndicators.get(indicatorKey) || 0) < Number(allocation.quota || 0)) {
|
||||
quotaBucket = `indicator:${candidate.profile.schoolId}`;
|
||||
occupiedIndicators.set(indicatorKey, (occupiedIndicators.get(indicatorKey) || 0) + 1);
|
||||
}
|
||||
if (!quotaBucket) continue;
|
||||
quotaBucket = `indicator:${candidate.profile.schoolId}`;
|
||||
bucketKey = quotaBucket + '|' + key;
|
||||
capacity = Number(allocation.quota || 0);
|
||||
occupiedCount = occupiedIndicators.get(indicatorKey) || 0;
|
||||
} else {
|
||||
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';
|
||||
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);
|
||||
created.push({
|
||||
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,
|
||||
totalScore: candidate.score, featureScore: Number(db.registrations.find(item => item.examId === examId && item.userId === candidate.preference.userId)?.featureScore || 0),
|
||||
specialtyQualification: resolveProfileSpecialty(candidate.profile), quotaBucket, schoolDecisionNote: '', withdrawalReason: '', withdrawalReviewNote: ''
|
||||
}
|
||||
});
|
||||
const available = Math.max(0, capacity - occupiedCount);
|
||||
if (!available) continue;
|
||||
const usesFeatureScore = Boolean(category.specialtyCategory || category.specialtyType);
|
||||
const proposal = {
|
||||
candidate, choice, category, index, quotaBucket,
|
||||
culturalScore: candidate.culturalScore,
|
||||
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;
|
||||
}
|
||||
}
|
||||
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) {
|
||||
|
||||
+200
-2
@@ -152,10 +152,10 @@ button:disabled { cursor: not-allowed; opacity: .5; }
|
||||
.overline { margin: 0; color: #8c96ae; font-family: Consolas, ui-monospace, monospace; font-size: 10px; font-weight: 700; letter-spacing: 2px; }
|
||||
.status { display: inline-flex; align-items: center; gap: 5px; padding: 4px 8px; border-radius: 6px; font-size: 10px; font-style: normal; font-weight: 700; white-space: nowrap; }
|
||||
.status::before { content: ""; width: 5px; height: 5px; border-radius: 50%; background: currentColor; }
|
||||
.status-open, .status-published, .status-approved, .status-paid { color: #247a5c; background: #e3f3ed; }
|
||||
.status-open, .status-published, .status-visible, .status-approved, .status-paid { color: #247a5c; background: #e3f3ed; }
|
||||
.status-pending, .status-upcoming, .status-unpaid { color: #9b6817; background: #fff1d3; }
|
||||
.status-rejected, .status-closed { color: #af443d; background: #fbe6e4; }
|
||||
.status-draft { color: #6d7589; background: #eef0f4; }
|
||||
.status-draft, .status-hidden { color: #6d7589; background: #eef0f4; }
|
||||
.exam-code { color: #6f7b98; font-family: Consolas, monospace; font-size: 10px; letter-spacing: .8px; }
|
||||
|
||||
/* Public site */
|
||||
@@ -237,6 +237,7 @@ button:disabled { cursor: not-allowed; opacity: .5; }
|
||||
.auth-story { position: relative; display: flex; flex-direction: column; justify-content: space-between; padding: 55px max(45px,8vw) 50px; color: #fff; background-color: var(--navy); background-image: radial-gradient(circle at 20% 85%,rgba(49,95,186,.3),transparent 35%), linear-gradient(rgba(255,255,255,.025) 1px,transparent 1px), linear-gradient(90deg,rgba(255,255,255,.025) 1px,transparent 1px); background-size: auto,26px 26px,26px 26px; overflow:hidden; }
|
||||
.auth-story::after { content:"准"; position:absolute; right:-55px; bottom:-105px; color:rgba(255,255,255,.03); font-family:"STKaiti"; font-size:420px; }.auth-story .brand { color:#fff; }.auth-story .brand small { color:#7e8db9; }.auth-story .overline { margin-top: 110px; color:#6f82b7; }.auth-story h1 { margin:16px 0 20px; font-family:"STKaiti",serif; font-size:clamp(42px,5vw,66px); font-weight:400; line-height:1.22; }.auth-story h1 em { color:#f07b70; font-style:normal; }.auth-story > div > p:last-child { max-width:470px; color:#aab5d4; font-size:12px; line-height:1.9; }.auth-quote { position:relative; z-index:1; padding-top:20px; border-top:1px solid rgba(255,255,255,.12); }.auth-quote span { color:#7484b1; font-size:9px; }.auth-quote p { margin:8px 0 0; color:#d4daeb; font-family:"STKaiti"; font-size:18px; }
|
||||
.auth-panel { display:grid; place-items:center; padding:60px 28px; position:relative; }.back-link { position:absolute; top:28px; right:34px; border:0; color:#778096; background:transparent; font-size:10px; }.auth-card { width:min(480px,100%); }.auth-card h2 { margin:8px 0 7px; font-family:"STKaiti"; font-size:34px; font-weight:400; }.auth-card > p:not(.overline) { margin:0 0 30px; color:#848c9d; font-size:11px; }.stack-form { display:grid; gap:15px; }.stack-form label,.modal-form label,.profile-form label,.result-entry label { display:grid; gap:7px; }.stack-form label > span,.modal-form label > span,.profile-form label > span,.result-entry label > span { color:#555f75; font-size:10px; font-weight:700; }.stack-form input,.stack-form select,.modal-form input,.modal-form select,.modal-form textarea,.profile-form input,.profile-form select,.result-entry input,.result-entry select { width:100%; min-height:44px; padding:10px 12px; border:1px solid #dce1ea; border-radius:8px; color:var(--ink); background:#fff; font-size:11px; outline:0; }.stack-form textarea,.modal-form textarea { resize:vertical; }.stack-form input:focus,.stack-form select:focus,.modal-form input:focus,.modal-form select:focus,.modal-form textarea:focus,.profile-form input:focus,.profile-form select:focus,.result-entry input:focus,.result-entry select:focus { border-color:#8999c0; box-shadow:0 0 0 3px rgba(49,95,186,.08); }.field-row { display:grid; grid-template-columns:1fr 1fr; gap:13px; }.region-selects { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:13px; }.agreement { display:flex !important; grid-template-columns:auto 1fr; align-items:center; gap:8px !important; color:#767f91; font-size:9px; }.agreement input { width:15px !important; min-height:auto !important; height:15px; }.agreement span { color:#767f91 !important; font-weight:400 !important; }.auth-switch { margin-top:20px; color:#858d9d; font-size:10px; text-align:center; }.auth-switch button { border:0; color:var(--blue); background:transparent; font-weight:700; }.demo-accounts { display:grid; gap:6px; margin-top:25px; padding:14px; border-radius:8px; background:#f5f7fa; }.demo-accounts strong { color:#70798d; font-size:9px; }.demo-accounts button { border:0; color:#6a748a; background:transparent; font-family:Consolas,monospace; font-size:9px; text-align:left; }
|
||||
.auth-session-notice { display:grid; gap:4px; margin:-12px 0 18px; padding:12px 14px; border-left:3px solid var(--red); border-radius:7px; color:#814b47; background:#fceceb; }.auth-session-notice strong { font-size:10px; }.auth-session-notice span { font-size:9px; line-height:1.7; }
|
||||
|
||||
/* Portal shell */
|
||||
.portal { min-height:100vh; }.portal-sidebar { position:fixed; inset:0 auto 0 0; z-index:35; width:238px; display:flex; flex-direction:column; padding:24px 17px 18px; color:#fff; background:var(--navy); overflow:hidden; }.portal-sidebar::after { content:""; position:absolute; width:260px; height:260px; left:-130px; bottom:-80px; border:1px solid rgba(255,255,255,.06); border-radius:50%; box-shadow:0 0 0 45px rgba(255,255,255,.02),0 0 0 90px rgba(255,255,255,.015); }.portal-brand { z-index:1; display:flex; align-items:center; justify-content:space-between; padding:0 9px 22px; border-bottom:1px solid rgba(255,255,255,.1); }.portal-brand .brand { color:#fff; }.portal-brand .brand small { color:#7e8db9; }.portal-brand > button { display:none; border:0; color:#fff; background:transparent; font-size:25px; }.portal-role { margin:20px 12px 9px; color:#6f7fae; font-family:Consolas,monospace; font-size:9px; letter-spacing:1.5px; }.portal-sidebar nav { z-index:1; display:grid; gap:4px; }.portal-sidebar nav button { width:100%; min-height:42px; display:flex; align-items:center; gap:12px; padding:0 13px; border:0; border-radius:8px; color:#aab5d4; background:transparent; font-size:11px; text-align:left; transition:.18s; }.portal-sidebar nav button > span { width:20px; display:grid; place-items:center; }.portal-sidebar nav button svg { width:17px; }.portal-sidebar nav button:hover { color:#fff; background:rgba(255,255,255,.05); }.portal-sidebar nav button.active { color:#fff; background:var(--navy-soft); box-shadow:inset 3px 0 var(--red); }.portal-sidebar nav button em { margin-left:auto; padding:2px 5px; border-radius:8px; color:#fff; background:var(--red); font-size:7px; font-style:normal; }.sidebar-help { z-index:1; display:grid; gap:4px; margin-top:auto; padding:15px 12px; border:1px solid rgba(255,255,255,.08); border-radius:9px; background:rgba(255,255,255,.03); }.sidebar-help span { color:#7484b1; font-size:8px; }.sidebar-help strong { font-family:Consolas,monospace; font-size:11px; }.sidebar-help small { color:#8897bd; font-size:8px; }.portal-main { min-height:100vh; margin-left:238px; }.portal-topbar { height:70px; display:flex; align-items:center; gap:20px; padding:0 30px; border-bottom:1px solid var(--line); background:rgba(255,255,255,.9); backdrop-filter:blur(16px); }.portal-topbar > div:first-of-type { display:flex; gap:8px; align-items:center; font-size:10px; }.portal-topbar > div:first-of-type span,.portal-topbar > div:first-of-type b { color:#9aa1b0; font-weight:400; }.portal-user { display:flex; align-items:center; gap:9px; margin-left:auto; }.portal-user > span:nth-of-type(2) { display:grid; }.portal-user > span strong { font-size:10px; }.portal-user > span small { color:#8c94a6; font-size:8px; }.user-avatar { width:32px; height:32px; display:grid; place-items:center; flex:0 0 auto; border-radius:9px; color:#43578b; background:#e4e9f5; font-size:11px; font-weight:700; }.notification-button,.logout-button,.sidebar-toggle { width:36px; height:36px; display:grid; place-items:center; border:1px solid var(--line); border-radius:8px; color:#687287; background:#fff; }.notification-button { position:relative; }.notification-button i { position:absolute; top:8px; right:8px; width:5px; height:5px; border-radius:50%; background:var(--red); }.notification-button svg,.logout-button svg,.sidebar-toggle svg { width:15px; }.logout-button { border:0; background:transparent; }.sidebar-toggle { display:none; }.portal-content { padding:31px; }.portal-heading { display:flex; align-items:flex-end; justify-content:space-between; gap:25px; margin-bottom:24px; }.portal-heading h1 { margin:5px 0 5px; font-family:"STKaiti"; font-size:31px; font-weight:400; }.portal-heading > div > p:last-child { margin:0; color:#81899a; font-size:11px; }.heading-status { color:#7f8798; font-size:10px; }.heading-status .status { margin-left:6px; }
|
||||
@@ -254,6 +255,7 @@ button:disabled { cursor: not-allowed; opacity: .5; }
|
||||
/* Admin */
|
||||
.admin-metrics { display:grid; grid-template-columns:repeat(4,1fr); gap:12px; margin-bottom:16px; }.admin-metrics article { display:flex; align-items:center; gap:13px; padding:20px; border:1px solid var(--line); border-radius:12px; background:#fff; }.admin-metrics article > span { width:40px; height:40px; display:grid; place-items:center; border-radius:9px; color:var(--blue); background:#ecf1fb; }.admin-metrics article:nth-child(2)>span { color:var(--amber); background:#fff3dd; }.admin-metrics article:nth-child(3)>span { color:var(--red); background:#fbe9e7; }.admin-metrics article:nth-child(4)>span { color:var(--jade); background:#e4f3ee; }.admin-metrics article > span svg { width:18px; }.admin-metrics div { display:grid; }.admin-metrics small { color:#8d95a6; font-size:8px; }.admin-metrics strong { margin:2px 0; font-family:Georgia,serif; font-size:23px; font-weight:400; }.admin-metrics em { color:#818a9c; font-size:7px; font-style:normal; }.admin-dashboard-grid { display:grid; grid-template-columns:1.05fr 1fr; gap:16px; }.admin-todos { overflow:hidden; }.admin-todos > button { width:100%; display:grid; grid-template-columns:36px 1fr 18px; align-items:center; gap:11px; padding:14px 18px; border:0; border-bottom:1px solid var(--line); color:var(--ink); background:#fff; text-align:left; }.admin-todos > button:last-child { border-bottom:0; }.admin-todos > button:hover { background:#fafbfc; }.admin-todos > button > i { width:34px; height:34px; display:grid; place-items:center; border-radius:9px; color:#65728d; background:#edf0f6; font-size:10px; font-style:normal; font-weight:700; }.admin-todos > button > i.urgent { color:#a8463f; background:#fbe8e6; }.admin-todos button > span { display:grid; gap:3px; }.admin-todos strong { font-size:9px; }.admin-todos small { color:#9299aa; font-size:7px; }.admin-todos button > svg { color:#9ba2b1; }.audit-feed > div { display:grid; grid-template-columns:32px 1fr auto; align-items:center; gap:10px; padding:14px 18px; border-bottom:1px solid var(--line); }.audit-feed > div:last-child { border-bottom:0; }.audit-feed p { display:grid; gap:3px; margin:0; }.audit-feed p strong { font-size:9px; }.audit-feed p small { color:#9098a8; font-size:7px; }.audit-feed time { color:#8d95a5; font-size:7px; }
|
||||
.data-panel { overflow:hidden; }.data-toolbar { min-height:64px; display:flex; align-items:center; justify-content:space-between; gap:15px; padding:13px 17px; border-bottom:1px solid var(--line); }.data-toolbar > p { margin:0; color:#878f9f; font-size:8px; }.search-box { width:min(330px,40%); min-height:36px; display:flex; align-items:center; gap:8px; padding:0 11px; border:1px solid var(--line); border-radius:8px; }.search-box svg { width:14px; color:#9098a9; }.search-box input { width:100%; border:0; outline:0; background:transparent; font-size:9px; }.filter-pills { display:flex; gap:4px; }.filter-pills button { min-height:31px; padding:0 11px; border:1px solid var(--line); border-radius:7px; color:#778094; background:#fff; font-size:8px; }.filter-pills button.active { border-color:var(--navy); color:#fff; background:var(--navy); }.table-scroll { overflow-x:auto; }table { width:100%; border-collapse:collapse; white-space:nowrap; }th { padding:11px 14px; color:#858d9f; background:#fafbfc; font-size:8px; font-weight:600; text-align:left; }td { padding:13px 14px; border-top:1px solid #edf0f5; color:#5b657a; font-size:9px; }tbody tr { transition:background .15s; }tbody tr:hover { background:#fafbfe; }td > strong,td > small { display:block; }td > strong { color:var(--ink); font-size:9px; }td > small { max-width:230px; margin-top:3px; overflow:hidden; color:#9299a9; font-size:7px; text-overflow:ellipsis; }.person-cell { display:flex; align-items:center; gap:9px; }.person-cell > span { width:31px; height:31px; display:grid; place-items:center; border-radius:8px; color:#536691; background:#e8edf7; font-size:10px; font-weight:700; }.person-cell > div { display:grid; gap:2px; }.person-cell strong { color:var(--ink); font-size:9px; }.person-cell small { color:#9299a9; font-size:7px; }.mono { font-family:Consolas,monospace; }.row-action { border:0; color:var(--blue); background:transparent; font-size:8px; font-weight:700; }.row-action.primary { padding:6px 9px; border-radius:6px; color:#fff; background:var(--navy); }.table-chips { display:flex; gap:3px; }.table-chips span { padding:3px 5px; border-radius:4px; color:#5e6980; background:#eef1f6; font-size:7px; }.pin-label { color:var(--red); font-size:8px; }
|
||||
.notice-admin-toolbar { flex-wrap:wrap; }.notice-admin-toolbar > p { flex:1 0 100%; }.notice-row-actions { display:flex; align-items:center; gap:8px; }
|
||||
.registration-toolbar,.payment-toolbar { flex-wrap:wrap; }.table-filter-selects { flex:1 0 100%; display:grid; grid-template-columns:repeat(4,minmax(130px,1fr)); gap:8px; }.table-filter-selects select { width:100%; min-height:35px; padding:7px 10px; border:1px solid var(--line); border-radius:7px; color:#59647a; background:#fff; font-size:8px; }.registration-bulk-bar { min-height:54px; display:flex; align-items:center; justify-content:space-between; gap:14px; padding:10px 17px; border-bottom:1px solid #dce4f1; background:#f4f7fc; }.registration-bulk-bar > span { color:#6e788d; font-size:8px; }.registration-bulk-bar > span strong { margin:0 3px; color:var(--navy); font-size:13px; }.registration-bulk-bar > div { display:flex; gap:7px; }.registration-bulk-bar .row-action { padding:7px 10px; border:1px solid #ccd6e7; border-radius:7px; background:#fff; }.registration-bulk-bar .row-action.primary { border-color:var(--navy); background:var(--navy); }.registration-bulk-bar .row-action:disabled { border-color:#e1e5ec; color:#aab1bf; background:#f8f9fb; }.selection-cell { width:42px; padding-right:8px; text-align:center; }.selection-cell input { width:15px; height:15px; accent-color:var(--navy); }.row-action.danger { color:#a64b48; }
|
||||
.admin-exam-grid { display:grid; grid-template-columns:repeat(2,1fr); gap:15px; }.admin-exam-card { position:relative; padding:22px; border:1px solid var(--line); border-radius:var(--radius); background:#fff; overflow:hidden; }.admin-exam-card.editable { cursor:pointer; transition:border-color .18s,box-shadow .18s,transform .18s; }.admin-exam-card.editable:hover { border-color:#bdc8df; box-shadow:var(--shadow); transform:translateY(-2px); }.admin-exam-card.published::before { content:""; position:absolute; top:0; bottom:0; left:0; width:4px; background:var(--jade); }.admin-exam-card header { display:flex; align-items:center; justify-content:space-between; }.admin-exam-card h2 { margin:16px 0 7px; font-family:"STKaiti"; font-size:21px; font-weight:400; }.admin-exam-card > p { min-height:34px; margin:0; color:#828a9a; font-size:9px; line-height:1.8; }.admin-exam-card dl { display:grid; grid-template-columns:1fr 1fr; gap:12px; margin:19px 0; }.admin-exam-card dl div:last-child { grid-column:1/-1; }.admin-exam-card dt { color:#999fac; font-size:7px; }.admin-exam-card dd { margin:3px 0 0; color:#5e687d; font-size:8px; }.admin-subjects { display:flex; flex-wrap:wrap; gap:5px; padding:12px; border-radius:8px; background:#f7f8fb; }.admin-subjects span { display:grid; gap:2px; padding:6px 8px; border:1px solid #e3e7ef; border-radius:5px; background:#fff; }.admin-subjects b { font-size:8px; }.admin-subjects small { color:#969dac; font-size:6px; }.admin-exam-card footer { display:flex; align-items:center; justify-content:space-between; gap:12px; margin-top:15px; padding-top:13px; border-top:1px solid var(--line); }.admin-exam-card footer > span { color:#858d9e; font-size:8px; }.exam-card-actions { display:flex; align-items:center; gap:7px; }.results-admin-grid { display:grid; grid-template-columns:.8fr 1.2fr; gap:16px; }.result-entry form { display:grid; gap:14px; padding:20px; }.publish-switch { justify-content:flex-start; }.published-results > div:not(.panel-title) { display:grid; grid-template-columns:32px 1fr auto auto; align-items:center; gap:10px; padding:12px 18px; border-bottom:1px solid var(--line); }.published-results > div:last-child { border-bottom:0; }.published-results p { display:grid; gap:3px; margin:0; }.published-results p strong { font-size:9px; }.published-results p small { color:#9098a9; font-size:7px; }.published-results b { font-family:Georgia,serif; font-size:17px; font-weight:400; }
|
||||
.exam-score-band { display:grid; grid-template-columns:140px 1fr; margin:17px -22px 0; color:#fff; background:var(--navy); }
|
||||
@@ -267,6 +269,7 @@ button:disabled { cursor: not-allowed; opacity: .5; }
|
||||
|
||||
/* Modals and feedback */
|
||||
.modal-layer { position:fixed; inset:0; z-index:100; display:grid; place-items:center; padding:22px; background:rgba(12,22,48,.55); backdrop-filter:blur(5px); animation:fadeIn .18s ease; }.modal-card { width:min(620px,100%); max-height:90vh; border-radius:16px; background:#fff; box-shadow:0 30px 90px rgba(13,23,51,.3); overflow-y:auto; animation:modalIn .22s ease; }.modal-head { display:flex; align-items:flex-start; justify-content:space-between; gap:20px; padding:22px 24px 18px; border-bottom:1px solid var(--line); }.modal-head span { color:#8b94a8; font-family:Consolas,monospace; font-size:8px; letter-spacing:1.5px; }.modal-head h2 { margin:5px 0; font-family:"STKaiti"; font-size:23px; font-weight:400; }.modal-head p { margin:0; color:#8c94a5; font-size:8px; }.modal-head > button { border:0; color:#8a92a2; background:transparent; font-size:23px; }.notice-content { padding:25px; color:#525d73; overflow-wrap:anywhere; }.notice-content p,.notice-content li { color:#525d73; font-size:11px; line-height:2; }.notice-content p { margin:0 0 13px; }.notice-content h2,.notice-content h3,.notice-content h4 { margin:22px 0 10px; color:var(--navy); font-family:"STKaiti"; font-weight:400; }.notice-content h2 { font-size:22px; }.notice-content h3 { font-size:18px; }.notice-content h4 { font-size:15px; }.notice-content ul,.notice-content ol { margin:0 0 14px; padding-left:24px; }.notice-content blockquote { margin:15px 0; padding:10px 14px; border-left:3px solid var(--blue); background:#f4f7fc; }.notice-content blockquote p { margin:0; }.notice-content a { color:var(--blue); text-decoration:underline; text-underline-offset:2px; }.modal-form { display:grid; gap:14px; padding:22px 24px 0; }.modal-foot { display:flex; justify-content:flex-end; gap:8px; margin:20px -24px 0; padding:15px 24px; border-top:1px solid var(--line); background:#fafbfc; }.modal-card > .modal-foot { margin:0; }.review-profile,.registration-review,.admit-preview { padding:22px 24px 0; }.review-profile dl { display:grid; grid-template-columns:1fr 1fr; gap:13px; margin:0; }.review-profile dl div,.registration-review dl div,.admit-preview dl div { display:grid; gap:4px; padding:10px; border-radius:7px; background:#f7f8fb; }.review-profile dt,.registration-review dt,.admit-preview dt { color:#969dac; font-size:7px; }.review-profile dd,.registration-review dd,.admit-preview dd { margin:0; color:#525d73; font-size:9px; }.registration-review > div > span { color:#9199a9; font-size:8px; }.registration-review > div p { display:flex; flex-wrap:wrap; gap:5px; }.registration-review > div b { padding:5px 8px; border-radius:5px; color:#54617a; background:#eef1f6; font-size:8px; }.registration-review dl,.admit-preview dl { display:grid; grid-template-columns:repeat(3,1fr); gap:9px; }.admit-preview > strong { display:block; margin:5px 0 18px; color:var(--navy); font-family:Consolas,monospace; font-size:26px; letter-spacing:2px; }.admit-preview > p { margin:16px 0 0; padding:11px; border-radius:7px; color:#7a5a24; background:#fff3dc; font-size:8px; }.toast { position:fixed; right:24px; bottom:24px; z-index:130; min-width:245px; display:flex; align-items:center; gap:11px; padding:13px 15px; border:1px solid #dfe7e3; border-radius:10px; background:#fff; box-shadow:0 17px 50px rgba(18,39,30,.17); opacity:0; transform:translateY(25px); pointer-events:none; transition:.25s; }.toast.show { opacity:1; transform:none; }.toast-icon { width:29px; height:29px; display:grid; place-items:center; border-radius:50%; color:#fff; background:var(--jade); font-size:11px; }.toast div { display:grid; gap:2px; }.toast strong { font-size:9px; }.toast small { color:#858d9e; font-size:8px; }.fatal-error { min-height:100vh; display:grid; place-content:center; justify-items:center; padding:25px; text-align:center; }.fatal-error > span { width:55px; height:55px; display:grid; place-items:center; border-radius:50%; color:#fff; background:var(--red); font-family:Georgia,serif; font-size:28px; }.fatal-error h1 { margin:18px 0 8px; font-family:"STKaiti"; font-size:28px; font-weight:400; }.fatal-error p { margin:0 0 18px; color:#7f8799; font-size:10px; }.empty-state { padding:35px; color:#8b93a4; font-size:9px; text-align:center; }
|
||||
.fatal-error-actions { display:flex; gap:9px; }
|
||||
.modal-card:has(.exam-config-form) { width:min(980px,100%); }
|
||||
.modal-card:has(.notice-editor-form) { width:min(820px,100%); }
|
||||
.notice-editor-field { display:grid; gap:7px; }
|
||||
@@ -370,6 +373,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 em { color:#8c96aa; font-size:13px; font-style:normal; }
|
||||
.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 .panel-title { height:auto; min-height:74px; }
|
||||
.result-entry-console .panel-title p { margin:4px 0 0; color:#8791a4; font-size:10px; }
|
||||
@@ -586,11 +634,13 @@ button:disabled { cursor: not-allowed; opacity: .5; }
|
||||
.admission-command-banner { display:flex; justify-content:space-between; gap:28px; margin-bottom:18px; padding:26px 30px; border-radius:14px; color:#fff; background:linear-gradient(118deg,#17375f 0%,#245783 62%,#2b7180 100%); box-shadow:0 16px 34px rgba(23,55,95,.18); }
|
||||
.admission-command-banner > div span { color:#9fcad5; font:700 10px/1.2 Consolas,monospace; letter-spacing:1.8px; }.admission-command-banner h2 { margin:8px 0 6px; font-size:24px; }.admission-command-banner p { max-width:650px; margin:0; color:#dceaf0; line-height:1.7; }.admission-command-banner dl { display:grid; grid-template-columns:repeat(4,minmax(70px,1fr)); gap:10px; margin:0; }.admission-command-banner dl div { padding:12px; border:1px solid rgba(255,255,255,.16); border-radius:9px; background:rgba(255,255,255,.07); }.admission-command-banner dt { color:#b8d6df; }.admission-command-banner dd { margin:4px 0 0; font-size:22px; font-weight:800; }
|
||||
.admission-admin-grid { display:grid; grid-template-columns:1.35fr .85fr; gap:16px; margin-bottom:16px; }.admission-settings-panel form,.admission-account-panel form,.admission-plan-console form { display:grid; gap:12px; }.admission-control-actions { display:flex; flex-wrap:wrap; gap:8px; margin-top:18px; padding-top:16px; border-top:1px solid var(--line); }
|
||||
.admission-admin-grid.single { grid-template-columns:1fr; }
|
||||
.admission-candidate-list { display:grid; gap:18px; }.admission-candidate-card { padding:24px; }.admission-candidate-card > header { display:flex; justify-content:space-between; gap:18px; }.admission-candidate-card > header span { color:var(--muted); font:700 10px Consolas,monospace; }.admission-candidate-card h2 { margin:5px 0 0; }
|
||||
.admission-progress-track { position:relative; display:grid; grid-template-columns:repeat(4,1fr); margin:26px 0; }.admission-progress-track::before { content:""; position:absolute; top:15px; left:10%; right:10%; height:2px; background:#dbe4ec; }.admission-progress-track div { position:relative; z-index:1; display:grid; justify-items:center; gap:7px; color:#8190a0; }.admission-progress-track i { display:grid; place-items:center; width:32px; height:32px; border:2px solid #dbe4ec; border-radius:50%; background:#fff; font-style:normal; font-weight:800; }.admission-progress-track .done i,.admission-progress-track .current i { border-color:#287486; color:#fff; background:#287486; }.admission-progress-track .current i { box-shadow:0 0 0 6px rgba(40,116,134,.12); }.admission-progress-track .done,.admission-progress-track .current { color:#214d5a; font-weight:700; }
|
||||
.admission-score-strip { display:flex; align-items:center; gap:12px; padding:14px 16px; border-radius:10px; background:#f2f7f8; }.admission-score-strip strong { margin-right:auto; font-size:18px; }.admission-score-strip em { color:#287486; font-style:normal; font-weight:700; }.admission-progress-copy { color:var(--muted); }.admission-result-banner { display:grid; gap:4px; margin:14px 0; padding:16px; border-left:4px solid #287486; border-radius:8px; background:#eef7f8; }.admission-result-banner strong { font-size:17px; }
|
||||
.preference-form { margin-top:18px; padding-top:18px; border-top:1px solid var(--line); }.preference-form-head { display:flex; justify-content:space-between; gap:12px; margin-bottom:12px; }.preference-form-head small { display:block; margin-top:4px; color:var(--muted); }.preference-choice-list { display:grid; gap:9px; margin-bottom:14px; }.preference-choice-list label { display:grid; grid-template-columns:34px 1fr; align-items:center; gap:9px; }.preference-choice-list b { display:grid; place-items:center; width:30px; height:30px; border-radius:50%; color:#fff; background:#244e72; }.preference-choice-list select,.placement-review-form select,.placement-review-form input { min-height:40px; padding:8px 10px; border:1px solid var(--line); border-radius:8px; background:#fff; }.locked-preferences { display:grid; gap:8px; margin-top:16px; }.locked-preferences span { display:flex; gap:10px; padding:10px 12px; border-radius:8px; background:#f5f7fa; }.locked-preferences b { color:#287486; }
|
||||
.placement-review-form { display:grid; min-width:210px; gap:7px; }.public-admission-board { margin-bottom:18px; overflow:hidden; }.public-admission-board > header { display:flex; justify-content:space-between; padding:20px 22px; color:#fff; background:#214d5a; }.public-admission-board h3 { margin:5px 0 0; font-size:19px; }.public-admission-board table { margin:0; }
|
||||
.placement-review-ledger { margin-bottom:16px; overflow:hidden; }.placement-review-ledger .data-toolbar { flex-wrap:wrap; }.placement-review-ledger table { min-width:1250px; }.placement-review-ledger td > small,.placement-review-ledger td > strong { display:block; margin-top:3px; }.placement-status-pills,.account-status-pills { margin:0 24px 12px; }.placement-bulk-toolbar { display:flex; align-items:center; justify-content:space-between; gap:18px; margin:0 24px 16px; padding:14px 16px; border:1px solid #d8e2e9; border-radius:10px; background:#f6f9fb; }.placement-bulk-toolbar > label,.placement-bulk-toolbar > div { display:flex; align-items:center; gap:10px; }.placement-bulk-toolbar input { width:17px; height:17px; }.placement-bulk-toolbar strong { margin-right:5px; color:#287486; }.placement-bulk-toolbar button { min-height:40px; }
|
||||
@media (max-width:1000px) { .admission-command-banner { flex-direction:column; }.admission-admin-grid { grid-template-columns:1fr; } }
|
||||
@media (max-width:620px) { .admission-command-banner { padding:20px; }.admission-command-banner dl { grid-template-columns:repeat(2,1fr); }.admission-progress-track span { font-size:9px; }.admission-score-strip,.preference-form-head { align-items:flex-start; flex-direction:column; }.admission-score-strip strong { margin-right:0; } }
|
||||
|
||||
@@ -643,6 +693,7 @@ button:disabled { cursor: not-allowed; opacity: .5; }
|
||||
|
||||
/* 招生控制台:用稳定的表单栅格替代浏览器默认控件排版 */
|
||||
.admission-settings-panel,.admission-account-panel,.admission-plan-console { overflow:hidden; }
|
||||
.admission-account-management { margin-bottom:16px; }.admission-account-management > form { grid-template-columns:minmax(240px,1fr) minmax(380px,1.4fr) minmax(220px,.8fr) auto; align-items:end; }.admission-account-management > form > .field-row { grid-template-columns:1fr 1fr; }.admission-account-management > form > .solid-button { min-width:170px; }.account-management-divider { display:flex; align-items:center; justify-content:space-between; padding:15px 24px; border-top:1px solid #e1e7ee; border-bottom:1px solid #e1e7ee; background:#f7f9fc; }.account-management-divider strong { color:#263a54; }.account-management-divider span { color:#8390a3; font-size:12px; }.admission-account-management .data-toolbar { padding-top:16px; padding-bottom:12px; }.admission-account-management table { min-width:980px; }
|
||||
.admission-settings-panel .panel-title,.admission-account-panel .panel-title,.admission-plan-console .panel-title { min-height:88px; height:auto; padding:20px 24px; background:linear-gradient(100deg,#fff,#f6f9fc); }
|
||||
.admission-settings-panel .panel-title h2,.admission-account-panel .panel-title h2,.admission-plan-console .panel-title h2 { font:400 23px/1.2 STKaiti,KaiTi,serif; }
|
||||
.admission-settings-panel .panel-title p,.admission-account-panel .panel-title p,.admission-plan-console .panel-title p { margin:7px 0 0; color:#69778c; font-size:13px; line-height:1.6; }
|
||||
@@ -682,3 +733,150 @@ button:disabled { cursor: not-allowed; opacity: .5; }
|
||||
.notice-pagination { display:flex; justify-content:center; gap:7px; padding:22px; }.notice-pagination button { min-width:36px; height:36px; padding:0 10px; border:1px solid #d7dee7; color:#526176; background:#fff; cursor:pointer; }.notice-pagination button.active { border-color:#17375f; color:#fff; background:#17375f; }.notice-pagination button:disabled { opacity:.42; cursor:not-allowed; }
|
||||
.notice-breadcrumb { width:min(980px,calc(100% - 48px)); display:flex; gap:10px; margin:0 auto; padding:38px 0 18px; color:#8792a1; }.notice-breadcrumb button { border:0; color:#245783; background:transparent; cursor:pointer; }.notice-document { width:min(980px,calc(100% - 48px)); margin:0 auto; border-top:5px solid #245783; background:#fff; box-shadow:0 14px 42px rgba(22,46,75,.08); }.notice-document > header { padding:46px 56px 34px; border-bottom:1px solid #dfe5eb; }.notice-document > header span { color:#287486; font-size:12px; font-weight:700; letter-spacing:.1em; }.notice-document > header h1 { margin:15px 0 18px; color:#1e3047; font:400 clamp(30px,4vw,44px)/1.25 STKaiti,KaiTi,serif; }.notice-document > header p { margin:0; color:#8994a2; }.notice-document > section { padding:40px 56px 50px; }.notice-document > footer { padding:20px 56px; border-top:1px solid #e1e6ec; background:#f8fafb; }.notice-document-content { color:#334257; font-size:15px; line-height:1.9; }.document-lead { margin:0 0 25px; padding:14px 17px; border-left:3px solid #287486; color:#526176; background:#f2f7f8; line-height:1.7; }.notice-document table { font-size:13px; }
|
||||
@media (max-width:800px) { .admission-settings-panel form,.admission-account-panel form,.admission-plan-console form { padding:18px; }.admission-settings-panel form > .solid-button,.admission-account-panel form > .solid-button,.admission-plan-console form > .solid-button { width:100%; }.qualification-bulk-toolbar { align-items:stretch; flex-direction:column; }.qualification-bulk-toolbar > div { align-items:stretch; flex-direction:column; }.notice-center-shell { width:calc(100% - 28px); grid-template-columns:1fr; }.notice-category-nav { display:flex; overflow-x:auto; padding:8px; border-right:0; border-bottom:1px solid #e1e6ec; scrollbar-width:none; }.notice-category-nav::-webkit-scrollbar { display:none; }.notice-category-nav button { width:auto; min-width:max-content; border-left:0; border-bottom:3px solid transparent; }.notice-category-nav button.active { border-bottom-color:#287486; }.notice-directory-list > button { grid-template-columns:58px minmax(0,1fr) 18px; gap:12px; padding:15px; }.notice-directory-copy small { white-space:normal; }.notice-center-hero { display:block; }.notice-center-hero > strong { display:none; }.notice-document > header,.notice-document > section,.notice-document > footer { padding-left:22px; padding-right:22px; } }
|
||||
|
||||
/* 审核工作台:以完整子页面承载资料、考试和处理结论 */
|
||||
body.review-subpage-open { overflow:hidden; }
|
||||
.review-subpage-layer { position:fixed; inset:0; z-index:120; overflow:auto; background:#eef2f6; animation:fadeIn .16s ease; }
|
||||
.review-subpage { min-height:100vh; color:#27364a; background:linear-gradient(90deg,#f8fafc 0 72%,#edf2f5 72%); }
|
||||
.review-subpage-header { min-height:190px; display:grid; grid-template-columns:minmax(190px,1fr) minmax(420px,2.2fr) minmax(140px,1fr); align-items:center; gap:28px; padding:34px clamp(28px,5vw,76px); color:#fff; background:linear-gradient(112deg,#132f52 0 62%,#216b79); box-shadow:0 12px 35px rgba(23,55,95,.16); }
|
||||
.review-back { justify-self:start; align-self:start; padding:10px 0; border:0; color:#c5dbe4; background:transparent; font-size:14px; font-weight:700; cursor:pointer; }
|
||||
.review-back:hover { color:#fff; }
|
||||
.review-subpage-header > div > span { color:#9bd0d6; font:700 12px/1.3 Consolas,monospace; letter-spacing:.16em; }
|
||||
.review-subpage-header h1 { margin:9px 0 12px; font:500 clamp(28px,3vw,42px)/1.18 STKaiti,KaiTi,serif; }
|
||||
.review-subpage-header p { display:flex; flex-wrap:wrap; align-items:center; gap:10px; margin:0; color:#d2dce7; font-size:14px; }
|
||||
.review-subpage-header p i { width:4px; height:4px; border-radius:50%; background:#7fa8b8; }
|
||||
.review-subpage-header > .status { justify-self:end; min-width:82px; justify-content:center; padding:9px 14px; font-size:13px; }
|
||||
.review-subpage-layout { width:min(1500px,100%); display:grid; grid-template-columns:minmax(0,1fr) 360px; gap:28px; margin:0 auto; padding:32px clamp(24px,4vw,58px) 70px; }
|
||||
.review-subpage-main { min-width:0; display:grid; align-content:start; gap:24px; }
|
||||
.review-section { overflow:hidden; border:1px solid #dbe3ea; border-radius:14px; background:#fff; box-shadow:0 9px 28px rgba(28,52,76,.05); }
|
||||
.review-section > header { min-height:86px; display:flex; align-items:center; gap:16px; padding:20px 24px; border-bottom:1px solid #e3e9ee; background:linear-gradient(90deg,#fff,#f6f9fa); }
|
||||
.review-section > header > span { min-width:45px; height:34px; display:grid; place-items:center; border-radius:6px; color:#fff; background:#245783; font:700 12px Consolas,monospace; }
|
||||
.review-section > header > div { min-width:0; margin-right:auto; }
|
||||
.review-section > header h2 { margin:0; color:#21364e; font-size:21px; }
|
||||
.review-section > header p { margin:5px 0 0; color:#748396; font-size:14px; }
|
||||
.review-section > header > strong { color:#287486; font-size:15px; }
|
||||
.review-detail-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:1px; margin:0; background:#e5eaef; }
|
||||
.review-detail-grid > div { min-width:0; padding:17px 20px; background:#fff; }
|
||||
.review-detail-grid > div.wide { grid-column:1/-1; }
|
||||
.review-detail-grid dt { margin-bottom:7px; color:#798799; font-size:13px; }
|
||||
.review-detail-grid dd { margin:0; overflow-wrap:anywhere; color:#293b51; font-size:15px; line-height:1.65; }
|
||||
.review-exam-list { display:grid; gap:14px; padding:20px 22px 24px; }
|
||||
.review-exam-card { overflow:hidden; border:1px solid #dfe6eb; border-radius:11px; background:#fbfcfd; }
|
||||
.review-exam-card > header { display:flex; align-items:center; justify-content:space-between; gap:20px; padding:16px 18px; border-bottom:1px solid #e4e9ed; background:#fff; }
|
||||
.review-exam-card h3 { margin:5px 0 0; font-size:18px; }
|
||||
.review-exam-card dl { display:grid; grid-template-columns:1fr 1fr; gap:0; margin:0; }
|
||||
.review-exam-card dl > div { padding:15px 18px; border-top:1px solid #e8edf0; }
|
||||
.review-exam-card dl > div:nth-child(-n+2) { border-top:0; }
|
||||
.review-exam-card dt { color:#7d8997; font-size:13px; }
|
||||
.review-exam-card dd { margin:6px 0 0; color:#33465a; font-size:14px; line-height:1.6; }
|
||||
.review-subject-list { display:flex; flex-wrap:wrap; gap:8px; }
|
||||
.review-subject-list > span { display:grid; gap:3px; padding:9px 11px; border:1px solid #d9e3e7; border-radius:8px; background:#fff; }
|
||||
.review-subject-list small { color:#738395; font-size:12px; }
|
||||
.review-empty-context { padding:25px; border:1px dashed #cdd8df; border-radius:10px; color:#67788b; background:#f7fafb; text-align:center; }
|
||||
.review-empty-context strong { color:#33485e; font-size:16px; }
|
||||
.review-empty-context p { margin:7px 0 0; font-size:14px; }
|
||||
.review-decision-form,.review-readonly { position:sticky; top:24px; align-self:start; display:grid; gap:18px; padding:24px; border-top:5px solid #287486; border-radius:12px; background:#fff; box-shadow:0 14px 38px rgba(26,52,76,.12); }
|
||||
.review-decision-form > div { display:grid; gap:5px; padding-bottom:18px; border-bottom:1px solid #e1e7eb; }
|
||||
.review-decision-form > div span,.review-decision-form label > span { color:#708093; font-size:13px; font-weight:700; }
|
||||
.review-decision-form > div strong { color:#1f3853; font-size:18px; }
|
||||
.review-decision-form > div small { color:#7e8a99; font-size:13px; }
|
||||
.review-decision-form label { display:grid; gap:7px; }
|
||||
.review-decision-form select,.review-decision-form textarea { width:100%; padding:11px 12px; border:1px solid #cbd6df; border-radius:8px; color:#293b50; background:#fff; font-size:14px; line-height:1.6; }
|
||||
.review-decision-form select:focus,.review-decision-form textarea:focus { border-color:#287486; outline:3px solid rgba(40,116,134,.12); }
|
||||
.review-decision-form .solid-button { min-height:46px; font-size:14px; }
|
||||
.review-readonly strong { color:#233c56; font-size:18px; }.review-readonly p { margin:0; color:#697b8e; font-size:14px; line-height:1.7; }
|
||||
.exam-context-hero { border-top:5px solid #245783; }
|
||||
.subject-review-section > footer { display:flex; align-items:baseline; justify-content:flex-end; gap:14px; padding:18px 24px; border-top:1px solid #e1e7eb; background:#f8fafb; }
|
||||
.subject-review-section > footer span { color:#6d7e91; font-size:14px; }.subject-review-section > footer strong { color:#17375f; font-size:24px; }
|
||||
.review-subject-cards { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:13px; padding:20px 22px; }
|
||||
.review-subject-cards > article { display:grid; grid-template-columns:34px minmax(0,1fr) auto; align-items:center; gap:12px; padding:16px; border:1px solid #dbe4ea; border-radius:10px; background:#fff; }
|
||||
.review-subject-cards > article > i { width:32px; height:32px; display:grid; place-items:center; border-radius:50%; color:#fff; background:#287486; font-size:13px; font-style:normal; }
|
||||
.review-subject-cards > article > div { display:grid; gap:4px; min-width:0; }.review-subject-cards > article > div strong { font-size:16px; }.review-subject-cards > article > div span { color:#718194; font-size:13px; }
|
||||
.review-subject-cards dl { display:flex; gap:16px; margin:0; }.review-subject-cards dl div { display:grid; gap:4px; }.review-subject-cards dt { color:#8290a0; font-size:12px; }.review-subject-cards dd { margin:0; font-size:14px; font-weight:700; }
|
||||
|
||||
/* 高频台账筛选与批量操作 */
|
||||
.candidate-toolbar { flex-wrap:wrap; }.table-filter-selects.five-columns { grid-template-columns:repeat(5,minmax(120px,1fr)); }
|
||||
.candidate-exam-summary { min-width:190px; }.candidate-exam-summary strong,.candidate-exam-summary small { display:block; }.candidate-exam-summary span { color:#8793a2; }
|
||||
.registration-exam-cell { min-width:220px; }.subject-summary { display:flex; flex-wrap:wrap; gap:5px; margin:7px 0 4px; }.subject-summary span { padding:4px 7px; border-radius:5px; color:#36576e; background:#e9f1f4; font-size:12px; font-weight:700; }.subject-summary em { color:#a14c43; font-style:normal; }
|
||||
.qualification-filter-toolbar { flex-wrap:wrap; margin-top:14px; border-top:1px solid #e4e9ed; }.qualification-specialty-filter { flex:1 0 100%; display:flex; gap:9px; }.qualification-specialty-filter select { flex:1; min-height:40px; padding:8px 11px; border:1px solid #ccd7df; border-radius:7px; color:#45576b; background:#fff; font-size:14px; }.qualification-specialty-filter .row-action { padding:0 12px; border:1px solid #d6dfe5; border-radius:7px; background:#fff; }
|
||||
.workflow-filter-toolbar { flex-wrap:wrap; margin-bottom:16px; }.workflow-type-filter { flex:1 0 100%; }.workflow-type-filter select { width:100%; min-height:40px; padding:8px 11px; border:1px solid #ccd7df; border-radius:7px; color:#45576b; background:#fff; font-size:14px; }
|
||||
|
||||
/* 全站可读性下限:正文 14px,辅助信息不低于 12px */
|
||||
.admin-readable .portal-sidebar nav button { font-size:14px; }.admin-readable .portal-sidebar nav button em { font-size:12px; }
|
||||
.admin-readable .portal-topbar > div:first-of-type,.admin-readable .portal-user > span strong { font-size:14px; }
|
||||
.admin-readable .portal-user > span small,.admin-readable .sidebar-help span,.admin-readable .sidebar-help small { font-size:12px; }
|
||||
.admin-readable .portal-content :where(p,time,dt,dd,label > span,th,td,button,input,select,textarea,em) { font-size:14px !important; line-height:1.55; }
|
||||
.admin-readable .portal-content small { font-size:12px !important; line-height:1.55; }
|
||||
.admin-readable .portal-content span:not(:has(svg)):not(.status):not(.user-avatar) { font-size:14px !important; }
|
||||
.admin-readable .portal-content :where(td strong,button strong,p strong) { font-size:14px !important; }
|
||||
.admin-readable .portal-content .status,.admin-readable .portal-content .overline,.admin-readable .portal-content .exam-code { font-size:12px !important; }
|
||||
.admin-readable .portal-content :where(.solid-button,.ghost-button,.row-action) { min-height:38px; }
|
||||
.portal-content :where(p,time,dt,dd,label > span,th,td,button,input,select,textarea,em) { font-size:14px !important; line-height:1.55; }
|
||||
.portal-content small { font-size:12px !important; line-height:1.55; }.portal-content span:not(:has(svg)):not(.status):not(.user-avatar) { font-size:14px !important; }.portal-content .status,.portal-content .overline,.portal-content .exam-code { font-size:12px !important; }
|
||||
.portal-sidebar nav button { font-size:14px; }.portal-sidebar nav button em,.portal-user small,.sidebar-help small { font-size:12px; }.portal-topbar button { font-size:13px; }
|
||||
.auth-page :where(p,label > span,button,input,select,textarea) { font-size:14px; line-height:1.65; }.auth-page span:not(:has(svg)) { font-size:14px; }.auth-page small { font-size:12px; }
|
||||
.onboarding-page :where(p,label > span,button,input,select,textarea) { font-size:14px; line-height:1.65; }.onboarding-page span:not(:has(svg)) { font-size:14px; }.onboarding-page small { font-size:12px; }
|
||||
.modal-card :where(p,dt,dd,label > span,button,input,select,textarea,li) { font-size:14px; line-height:1.65; }.modal-card span:not(:has(svg)) { font-size:14px; }.modal-card small,.modal-head span { font-size:12px; }
|
||||
.public-footer p,.public-footer > span { font-size:13px; line-height:1.65; }
|
||||
.notice-document table th,.notice-document table td { padding:15px 17px; font-size:15px; line-height:1.55; }
|
||||
.notice-document .document-lead { font-size:15px; }.notice-document .qualification-result { min-width:46px; padding:6px 11px; font-size:14px; }
|
||||
.public-main :where(button,input,select,textarea) { font-size:14px; }.public-main :where(p,dt,dd) { font-size:14px; line-height:1.7; }.public-main span:not(:has(svg)):not(.status) { font-size:14px; }.public-main small { font-size:12px; line-height:1.55; }
|
||||
|
||||
@media (max-width:1000px) {
|
||||
.review-subpage { background:#f4f7f9; }.review-subpage-header { grid-template-columns:1fr auto; min-height:0; }.review-subpage-header > div { grid-column:1/-1; grid-row:2; }.review-subpage-header > .status { grid-column:2; grid-row:1; }.review-subpage-layout { grid-template-columns:1fr; }.review-decision-form,.review-readonly { position:static; }.table-filter-selects.five-columns { grid-template-columns:repeat(2,minmax(130px,1fr)); }
|
||||
}
|
||||
@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; }
|
||||
}
|
||||
@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%; }
|
||||
}
|
||||
@media (max-width:1100px) {
|
||||
.admission-account-management > form { grid-template-columns:1fr 1fr; }.admission-account-management > form > .solid-button { width:100%; }
|
||||
}
|
||||
@media (max-width:800px) {
|
||||
.admission-account-management > form { grid-template-columns:1fr; }.placement-bulk-toolbar,.placement-bulk-toolbar > div { align-items:stretch; flex-direction:column; }.placement-bulk-toolbar button { width:100%; }.placement-status-pills,.account-status-pills { margin-right:18px; margin-left:18px; }
|
||||
}
|
||||
.table-pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 16px 18px;
|
||||
border-top: 1px solid var(--line, #e5e7eb);
|
||||
color: var(--muted, #64748b);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.table-pagination > div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.table-pagination button,
|
||||
.table-pagination select {
|
||||
min-width: 34px;
|
||||
height: 34px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--line, #dbe2ea);
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.table-pagination button:not(:disabled) { cursor: pointer; }
|
||||
.table-pagination button:hover:not(:disabled) { border-color: #1f6f5f; color: #1f6f5f; }
|
||||
.table-pagination button.active { border-color: #1f6f5f; background: #1f6f5f; color: #fff; }
|
||||
.table-pagination button:disabled { opacity: .45; }
|
||||
.table-pagination label { display: inline-flex; align-items: center; gap: 6px; margin-left: 6px; }
|
||||
.table-pagination i { font-style: normal; padding: 0 2px; }
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.table-pagination { align-items: flex-start; flex-direction: column; }
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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';
|
||||
|
||||
const now = '2026-07-21T08:00:00.000Z';
|
||||
@@ -46,24 +46,42 @@ const db = {
|
||||
const setting = { examId: 'exam', payload: { round: 1 } };
|
||||
const placements = buildVolunteerPlacements(db, setting, { uid: prefix => `${prefix}-${++sequence}`, nowIso: () => now });
|
||||
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.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-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(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' })));
|
||||
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 === 'sport').remaining, 0, '特长生计划占用应准确统计');
|
||||
const publicRows = publicAdmissionRows(db, 'exam');
|
||||
assert.equal(publicRows[0].registrationNumber, '20260001', '公示必须公开报名号');
|
||||
assert.equal(publicRows[0].name, '高分考生', '公示必须公开姓名');
|
||||
assert.equal(publicRows[0].totalScore, 250, '公示必须公开总成绩');
|
||||
assert.equal(publicRows[0].admittedSchool, '第二中学', '公示必须公开录取学校');
|
||||
assert.ok(publicRows[0].idNumberMasked.includes('*') && !publicRows[0].idNumberMasked.includes('20090101'), '重要身份信息必须脱敏');
|
||||
const highPublicRow = publicRows.find(item => item.registrationNumber === '20260001');
|
||||
assert.equal(highPublicRow.registrationNumber, '20260001', '公示必须公开报名号');
|
||||
assert.equal(highPublicRow.name, '高分考生', '公示必须公开姓名');
|
||||
assert.equal(highPublicRow.totalScore, 250, '普通类别公示总成绩不得加入特征分');
|
||||
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');
|
||||
assert.equal(cutoffs.find(item => item.schoolId === 'target-b' && item.categoryCode === 'general').cutoffScore, 250, '录取分数线应取学校招生类别最终录取最低总分');
|
||||
const qualificationStatus = sourceSchoolQualificationStatus(db, 'exam', 'source-b');
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createAdminViews } from '../src/client/admin-views.mjs';
|
||||
import { createAdmissionViews } from '../src/client/admission-views.mjs';
|
||||
import { api } from '../src/client/api.mjs';
|
||||
import { createCandidateViews } from '../src/client/candidate-views.mjs';
|
||||
import { createPublicViews } from '../src/client/public-views.mjs';
|
||||
|
||||
function protectedViewContext() {
|
||||
let loginRequests = 0;
|
||||
const context = {
|
||||
state: { user: null },
|
||||
app: { classList: { add() {}, remove() {} } },
|
||||
requireLogin() { loginRequests += 1; }
|
||||
};
|
||||
return { context, loginRequests: () => loginRequests };
|
||||
}
|
||||
|
||||
for (const createView of [createAdminViews, createCandidateViews, createAdmissionViews]) {
|
||||
const fixture = protectedViewContext();
|
||||
const views = createView(fixture.context);
|
||||
const render = views.renderAdmin || views.renderCandidate || views.renderAdmission;
|
||||
await render('dashboard');
|
||||
assert.equal(fixture.loginRequests(), 1, '未登录访问受保护视图时应交给统一登录处理');
|
||||
}
|
||||
|
||||
{
|
||||
const app = { classList: { remove() {} }, innerHTML: '' };
|
||||
const state = {
|
||||
user: null,
|
||||
authNotice: '登录状态已失效,请重新登录。',
|
||||
publicData: { selfRegistrationEnabled: false }
|
||||
};
|
||||
const { renderAuth } = createPublicViews({
|
||||
state,
|
||||
app,
|
||||
h: value => String(value ?? ''),
|
||||
icons: { arrow: '', menu: '' }
|
||||
});
|
||||
renderAuth('login');
|
||||
assert.match(app.innerHTML, /需要重新登录/);
|
||||
assert.match(app.innerHTML, /登录状态已失效,请重新登录/);
|
||||
}
|
||||
|
||||
{
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = async () => new Response(JSON.stringify({ message: '请先登录' }), {
|
||||
status: 401,
|
||||
headers: { 'content-type': 'application/json' }
|
||||
});
|
||||
try {
|
||||
await assert.rejects(() => api('/api/protected'), error => error.status === 401 && error.message === '请先登录');
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('客户端登录失效处理测试通过');
|
||||
@@ -0,0 +1,48 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createSeedDatabase } from '../src/data/seed.mjs';
|
||||
|
||||
const state = createSeedDatabase({ nowIso: () => '2026-07-21T00:00:00.000Z', hashPassword: password => `test-${password}` });
|
||||
const mainExam = state.exams.find(exam => exam.id === 'exam_autumn_2026');
|
||||
const mainRegistrations = state.registrations.filter(registration => registration.examId === mainExam.id);
|
||||
const mainRegistrationIds = new Set(mainRegistrations.map(registration => registration.id));
|
||||
const mainResults = state.results.filter(result => mainRegistrationIds.has(result.registrationId));
|
||||
const mainPreferences = state.admissionRecords.filter(record => record.kind === 'preference' && record.examId === mainExam.id && Number(record.payload?.round || 1) === 1);
|
||||
const mainPlans = state.admissionRecords.filter(record => record.kind === 'plan' && record.examId === mainExam.id && record.status === 'approved');
|
||||
const specialtyProfiles = state.candidateProfiles.filter(profile => profile.specialtyCategory && profile.specialtyType);
|
||||
const specialtyUserIds = new Set(specialtyProfiles.map(profile => profile.userId));
|
||||
const profilesByUserId = new Map(state.candidateProfiles.map(profile => [profile.userId, profile]));
|
||||
|
||||
assert.ok(state.exams.length >= 2, '演示数据至少包含两场考试');
|
||||
assert.equal(state.schools.filter(school => school.isSourceSchool).length, 5, '演示数据应包含 5 所生源校');
|
||||
assert.equal(state.schools.filter(school => school.isAdmissionSchool).length, 3, '演示数据应包含 3 所招生校');
|
||||
assert.deepEqual(Object.fromEntries(mainExam.subjects.map(subject => [subject.name, subject.fullScore])), {
|
||||
语文: 120, 数学: 120, 外语: 120, 历史: 75, 政治: 75, 物理: 80, 化学: 70, 实验: 20, 信息技术: 10
|
||||
});
|
||||
assert.equal(mainRegistrations.length, 1200, '主考试应有 1200 名考生');
|
||||
assert.ok(mainRegistrations.every(registration => registration.status === 'approved' && registration.subjectIds.length === 9), '主考试报名应全部审核通过并包含 9 科');
|
||||
assert.equal(mainResults.length, 1200 * 9, '每名主考试考生都应有完整的 9 科成绩');
|
||||
assert.ok(mainResults.every(result => result.published), '主考试成绩应全部发布');
|
||||
assert.equal(mainPreferences.length, 1200, '主考试每名考生都应完成第一轮志愿');
|
||||
assert.ok(mainPreferences.every(record => record.status === 'submitted' && record.payload.submissionCount === 1 && record.payload.choices.length === 3), '第一轮志愿应提交并填满 3 个招生校');
|
||||
assert.equal(mainPlans.length, 3, '三所招生校都应有已审核通过的招生计划');
|
||||
assert.ok(mainPlans.every(plan => plan.payload.categories.find(category => category.code === 'general')?.quota === 350), '每所招生校普通类计划应为 350 人');
|
||||
assert.ok(mainPlans.every(plan => plan.payload.categories.filter(category => category.specialtyCategory).reduce((sum, category) => sum + category.quota, 0) === 2), '每所招生校特长生计划合计应为 2 人');
|
||||
assert.equal(specialtyProfiles.length, 150, '应有 150 名特长生');
|
||||
assert.ok(mainRegistrations.filter(registration => specialtyUserIds.has(registration.userId)).every(registration => registration.featureScore >= 80 && registration.featureScore <= 100), '特长生特征分应分布在 80-100 分');
|
||||
assert.ok(mainPreferences.every(preference => {
|
||||
const profile = profilesByUserId.get(preference.userId);
|
||||
const categories = preference.payload.choices.map(choice => choice.categoryCode);
|
||||
return profile.specialtyCategory ? categories[0] === profile.specialtyCategory && categories.slice(1).every(code => code === 'general') : categories.every(code => code === 'general');
|
||||
}), '特长生第一志愿应匹配本人特长类别,其余志愿及普通考生志愿应填报普通类');
|
||||
assert.deepEqual(new Set(state.users.map(user => user.passwordHash)), new Set(['test-12345678']), '所有预置账号密码应统一为 12345678');
|
||||
|
||||
for (const subject of mainExam.subjects) {
|
||||
const scores = mainResults.filter(result => result.subjectId === subject.id).map(result => result.score);
|
||||
const mean = scores.reduce((sum, score) => sum + score, 0) / scores.length;
|
||||
const standardDeviation = Math.sqrt(scores.reduce((sum, score) => sum + (score - mean) ** 2, 0) / scores.length);
|
||||
assert.ok(mean > subject.fullScore * 0.66 && mean < subject.fullScore * 0.78, `${subject.name}平均分应符合正态样本预期`);
|
||||
assert.ok(standardDeviation > subject.fullScore * 0.09 && standardDeviation < subject.fullScore * 0.18, `${subject.name}标准差应符合正态样本预期`);
|
||||
assert.ok(scores.every(score => score >= 0 && score <= subject.fullScore), `${subject.name}成绩不得超出满分`);
|
||||
}
|
||||
|
||||
console.log('演示数据规模、学校角色、科目、志愿、特长生、成绩分布与密码校验通过');
|
||||
+118
-11
@@ -9,6 +9,7 @@ import { createDatabase, relationalTables } from '../database.mjs';
|
||||
import { createBaseDatabase } from '../src/data/base.mjs';
|
||||
import { createSeedDatabase } from '../src/data/seed.mjs';
|
||||
import { mysqlSchema } from '../src/database/schema.mjs';
|
||||
import { CURRENT_SCHEMA_VERSION } from '../src/database/version.mjs';
|
||||
import { totpAtStep } from '../src/security/totp.mjs';
|
||||
import { buildCenterMaterialsWorkbook, buildWorkbook } from '../excel.mjs';
|
||||
|
||||
@@ -60,7 +61,12 @@ assert.match(testDataImportSource, /SET FOREIGN_KEY_CHECKS = 0/, 'MySQL 样例
|
||||
assert.match(testDataImportSource, /beginTransaction\(\).*buildSeedOperations\(state\).*commit\(\)/s, 'MySQL 样例数据应在同一事务内清理并写入');
|
||||
assert.match(testDataImportSource, /initializeEmpty.*createBaseDatabase/s, '测试结束后应支持恢复空业务系统');
|
||||
assert.match(testDataImportSource, /recognizedTestData/, 'MySQL 初始化系统时应识别样例数据并保护非样例业务库');
|
||||
assert.match(testDataImportSource, /CURRENT_SCHEMA_VERSION/, '数据库导入脚本应复用统一的当前结构版本');
|
||||
const resetDatabaseSource = await readFile(resolve(root, 'scripts', 'reset-dev-database.mjs'), 'utf8');
|
||||
assert.match(resetDatabaseSource, /import\(['"]\.\/import-test-data\.mjs['"]\)/, '重置入口应复用统一的数据库初始化流程');
|
||||
assert.doesNotMatch(resetDatabaseSource, /DATABASE_CLIENT\s*=\s*['"]sqlite['"]/, '重置入口不得再强制使用 SQLite');
|
||||
const baseState = createBaseDatabase({ nowIso: () => new Date().toISOString(), hashPassword: password => `test-${password}` });
|
||||
assert.equal(baseState.meta.version, CURRENT_SCHEMA_VERSION, '空库初始状态必须使用当前结构版本');
|
||||
assert.equal(baseState.schools.length, 0, '正常首次建库不得预置学校');
|
||||
assert.equal(baseState.candidateProfiles.length, 0, '正常首次建库不得预置考生');
|
||||
assert.equal(baseState.exams.length, 0, '正常首次建库不得预置考试');
|
||||
@@ -83,7 +89,8 @@ process.env.SQLITE_PATH = testDb;
|
||||
const seededTestDatabase = await createDatabase({
|
||||
root,
|
||||
seed: () => {
|
||||
const state = createSeedDatabase({ nowIso: () => new Date().toISOString(), hashPassword });
|
||||
// 全量 1200 人规模由 seed.test.mjs 单独验证;端到端流程使用较小样本控制运行时间。
|
||||
const state = createSeedDatabase({ nowIso: () => new Date().toISOString(), hashPassword, candidateCount: 360 });
|
||||
const currentSuperAdmin = state.users.find(user => user.role === 'admin' && user.adminLevel === 'super');
|
||||
state.users.push({
|
||||
...currentSuperAdmin,
|
||||
@@ -156,6 +163,7 @@ try {
|
||||
const clientSources = await Promise.all([
|
||||
'app.js',
|
||||
'src/client/admin-views.mjs',
|
||||
'src/client/admission-views.mjs',
|
||||
'src/client/candidate-views.mjs',
|
||||
'src/client/public-views.mjs'
|
||||
].map(file => readFile(resolve(root, file), 'utf8')));
|
||||
@@ -170,6 +178,9 @@ try {
|
||||
assert.match(appSource, /data-action="edit-exam"/, '考试草稿应提供编辑入口');
|
||||
assert.match(appSource, /data-action="archive-exam"/, '超级管理员界面应提供不可逆考试归档入口');
|
||||
assert.match(appSource, /data-action="refresh-results-cache"/, '成绩管理中心应提供 Redis 成绩缓存刷新入口');
|
||||
assert.match(appSource, /data-action="bulk-placement-review"/, '招生学校投档审核应提供批量处理入口');
|
||||
assert.match(appSource, /data-action="toggle-admission-account"/, '超级管理员应有招生学校账户启停入口');
|
||||
assert.match(appSource, /data-action="reset-admission-account-password"/, '超级管理员应有招生学校账户密码重置入口');
|
||||
assert.match(appSource, /归档不可撤销/, '归档前应明确提示成绩将永久锁定');
|
||||
|
||||
const { DatabaseSync } = await import('node:sqlite');
|
||||
@@ -595,6 +606,20 @@ try {
|
||||
assert.equal(structuredPlan.response.status, 201, '招生校应能提交结构化类别与生源校指标计划');
|
||||
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, '超级管理员应能审核结构化招生计划');
|
||||
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], '招生计划公示应保留各类别计划人数');
|
||||
const noticeControlList = await admin.request('/api/admin/notices');
|
||||
const controlledPlan = noticeControlList.data.publications.find(item => item.sourceType === 'plan' && item.id === structuredPlan.data.plan.id);
|
||||
assert.ok(controlledPlan?.visible, '自动发布的招生计划应出现在通知发布管理页并默认显示');
|
||||
assert.equal((await admin.request(`/api/admin/publications/plan/${structuredPlan.data.plan.id}`, { method: 'PATCH', body: { visible: false } })).response.status, 200, '通知发布管理页应能隐藏系统招生计划公示');
|
||||
const hiddenPlanAnnouncements = await anonymous.request('/api/public/announcements');
|
||||
assert.equal(hiddenPlanAnnouncements.data.plans.some(item => item.id === structuredPlan.data.plan.id), false, '隐藏系统公示后公开通知目录不得继续返回该招生计划');
|
||||
assert.equal((await admin.request(`/api/admin/publications/plan/${structuredPlan.data.plan.id}`, { method: 'PATCH', body: { visible: true } })).response.status, 200, '已隐藏的系统招生计划公示应能重新显示');
|
||||
const restoredPlanAnnouncements = await anonymous.request('/api/public/announcements');
|
||||
assert.ok(restoredPlanAnnouncements.data.plans.some(item => item.id === structuredPlan.data.plan.id), '重新显示后招生计划应恢复到公开通知目录');
|
||||
assert.equal((await classAdmin.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');
|
||||
@@ -615,6 +640,53 @@ try {
|
||||
assert.equal(qualificationPublication.rows.find(item => item.registrationNumber === candidateNumber).eligible, true, '资格公示应公开考生有无指标分配资格');
|
||||
const invalidSpecialtyPlan = await admissionSchoolClient.request('/api/admission/plans', { method: 'POST', body: { examId: exam.id, categories: [{ code: 'bad', name: '错误特长类别', quota: 1, specialtyCategory: 'arts', specialtyType: 'track_field', indicatorAllocations: [] }] } });
|
||||
assert.equal(invalidSpecialtyPlan.response.status, 400, '招生计划不得把艺术大类与体育小类混用');
|
||||
|
||||
const placementCandidates = qualificationExam.qualificationStatus.rows.slice(0, 3);
|
||||
assert.equal(placementCandidates.length, 3, '批量投档测试至少需要三名候选考生');
|
||||
const placementIds = placementCandidates.map((row, index) => `placement_bulk_test_${index + 1}`);
|
||||
const placementCreatedAt = new Date().toISOString();
|
||||
const placementWriter = new DatabaseSync(testDb);
|
||||
const insertPlacement = placementWriter.prepare(`
|
||||
INSERT INTO admission_records (id, kind, exam_id, user_id, school_id, status, payload_json, created_at, updated_at)
|
||||
VALUES (?, 'placement', ?, ?, ?, 'school_review', ?, ?, ?)
|
||||
`);
|
||||
for (const [index, row] of placementCandidates.entries()) {
|
||||
insertPlacement.run(
|
||||
placementIds[index], exam.id, row.userId, admissionOnlySchool.data.school.id,
|
||||
JSON.stringify({ categoryCode: index === 2 ? 'arts' : 'general', categoryName: index === 2 ? '美术特长生' : '普通生', totalScore: 620 - index, preferenceOrder: index + 1 }),
|
||||
placementCreatedAt, placementCreatedAt
|
||||
);
|
||||
}
|
||||
placementWriter.close();
|
||||
|
||||
const placementReviewLedger = await admissionSchoolClient.request('/api/admission/placements');
|
||||
const seededPlacements = placementReviewLedger.data.placements.filter(item => placementIds.includes(item.id));
|
||||
assert.equal(seededPlacements.length, 3, '招生学校投档台账应返回可供筛选、搜索的完整候选记录');
|
||||
assert.ok(seededPlacements.every(item => item.examName === exam.name && item.candidate.registrationNumber), '投档台账应包含考试名称和考生报名号');
|
||||
const bulkAccept = await admissionSchoolClient.request('/api/admission/placements/bulk', { method: 'POST', body: { ids: placementIds.slice(0, 2), decision: 'accept', note: '批量核验通过' } });
|
||||
assert.equal(bulkAccept.response.status, 200, '招生学校应能批量接收多名投档考生');
|
||||
assert.equal(bulkAccept.data.count, 2, '批量接收应返回实际处理人数');
|
||||
const shortBulkWithdrawal = await admissionSchoolClient.request('/api/admission/placements/bulk', { method: 'POST', body: { ids: [placementIds[2]], decision: 'withdraw', note: '材料不符' } });
|
||||
assert.equal(shortBulkWithdrawal.response.status, 400, '批量退档必须填写充分的特殊理由');
|
||||
const bulkWithdrawal = await admissionSchoolClient.request('/api/admission/placements/bulk', { method: 'POST', body: { ids: [placementIds[2]], decision: 'withdraw', note: '专项资格证明材料复核不通过' } });
|
||||
assert.equal(bulkWithdrawal.response.status, 200, '招生学校应能批量申请退档');
|
||||
const processedPlacementLedger = await admissionSchoolClient.request('/api/admission/placements');
|
||||
assert.ok(processedPlacementLedger.data.placements.filter(item => placementIds.slice(0, 2).includes(item.id)).every(item => item.status === 'admitted'), '批量接收后所选记录应全部进入拟录取状态');
|
||||
assert.equal(processedPlacementLedger.data.placements.find(item => item.id === placementIds[2]).status, 'withdrawal_pending', '批量退档后记录应进入上级审核状态');
|
||||
assert.equal((await admissionSchoolClient.request('/api/admission/placements/bulk', { method: 'POST', body: { ids: placementIds.slice(0, 2), decision: 'accept' } })).response.status, 409, '已处理记录不得被重复批量审核');
|
||||
|
||||
const admissionAccountLedger = await admin.request('/api/admin/admissions');
|
||||
const managedAdmissionAccount = admissionAccountLedger.data.schoolAccounts.find(item => item.id === admissionAccount.data.account.id);
|
||||
assert.equal(managedAdmissionAccount.schoolName, '海州市招生实验学校', '招生学校账户台账应显示绑定学校');
|
||||
assert.equal(managedAdmissionAccount.active, true, '招生学校账户台账应返回真实启停状态');
|
||||
const disabledAdmissionAccount = await admin.request(`/api/admin/admission-school-accounts/${managedAdmissionAccount.id}`, { method: 'PATCH', body: { active: false } });
|
||||
assert.equal(disabledAdmissionAccount.data.account.active, false, '超级管理员应能停用招生学校账户');
|
||||
assert.equal((await admissionSchoolClient.request('/api/admission/context')).response.status, 401, '停用招生学校账户应立即使既有会话失效');
|
||||
assert.equal((await admin.request(`/api/admin/admission-school-accounts/${managedAdmissionAccount.id}`, { method: 'PATCH', body: { active: true } })).data.account.active, true, '超级管理员应能重新启用招生学校账户');
|
||||
const resetAdmissionPassword = await admin.request(`/api/admin/admission-school-accounts/${managedAdmissionAccount.id}/reset-password`, { method: 'POST' });
|
||||
assert.match(resetAdmissionPassword.data.temporaryPassword, /^Reset-/, '重置招生学校账户密码应返回一次性临时密码');
|
||||
assert.equal((await createClient().request('/api/auth/login', { method: 'POST', body: { username: managedAdmissionAccount.username, password: 'Admission123!' } })).response.status, 401, '招生学校账户重置后原密码应失效');
|
||||
assert.equal((await createClient().request('/api/auth/login', { method: 'POST', body: { username: managedAdmissionAccount.username, password: resetAdmissionPassword.data.temporaryPassword } })).response.status, 200, '招生学校账户应能使用临时密码重新登录');
|
||||
const createdExamInspector = new DatabaseSync(testDb, { readOnly: true });
|
||||
const createdExamPartition = createdExamInspector.prepare('SELECT * FROM exam_data_partitions WHERE exam_id = ?').get(exam.id);
|
||||
assert.ok(createdExamPartition, '创建考试时应同步登记该场考试的专属物理表');
|
||||
@@ -689,6 +761,10 @@ try {
|
||||
const adminRegistration = adminRegistrations.data.registrations.find(item => item.id === registrationId);
|
||||
assert.equal(adminRegistration.status, 'pending', '新报名应进入管理员审核队列');
|
||||
assert.ok(adminRegistration.exam?.name && adminRegistration.schoolName && adminRegistration.gradeName && adminRegistration.className, '报名审核列表应提供考试、学校、年级和班级筛选字段');
|
||||
assert.deepEqual(adminRegistration.subjects.map(item => item.id), [exam.subjects[0].id, exam.subjects[2].id], '报名审核详情应完整提供考生所报科目');
|
||||
const candidateReviewList = await admin.request('/api/admin/candidates');
|
||||
const candidateReviewItem = candidateReviewList.data.candidates.find(item => item.id === profile.id);
|
||||
assert.ok(candidateReviewItem.registrations.some(item => item.id === registrationId && item.exam?.id === exam.id && item.subjects.length === 2), '考生资料审核应同时提供关联考试和所报科目上下文');
|
||||
|
||||
const schoolFlows = await schoolAdmin.request('/api/admin/workflow-instances');
|
||||
const registrationFlow = schoolFlows.data.instances.find(item => item.businessId === registrationId && item.status === 'pending');
|
||||
@@ -886,31 +962,62 @@ try {
|
||||
assert.match(publishNotice.data.notice.summary, /成绩发布/, '首页摘要留空时应从富文本正文提取纯文本');
|
||||
const emptyRichNotice = await admin.request('/api/admin/notices', { method: 'POST', body: { title: '空富文本', content: '<script>alert(1)</script>', status: 'draft' } });
|
||||
assert.equal(emptyRichNotice.response.status, 400, '清洗后没有正文的通知必须拒绝保存');
|
||||
const draftNotice = await admin.request('/api/admin/notices', { method: 'POST', body: { title: '待编辑草稿', content: '<p>初始草稿内容</p>', status: 'draft' } });
|
||||
assert.equal(draftNotice.response.status, 201, '管理员应能保存通知草稿');
|
||||
const editedDraftNotice = await admin.request(`/api/admin/notices/${draftNotice.data.notice.id}`, { method: 'PATCH', body: { title: '已编辑草稿', content: '<p>修改后的草稿内容</p>', status: 'draft' } });
|
||||
assert.equal(editedDraftNotice.response.status, 200, '草稿编辑入口应通过原通知修改接口保存');
|
||||
assert.equal(editedDraftNotice.data.notice.title, '已编辑草稿');
|
||||
const noticeListAfterEdit = await admin.request('/api/admin/notices');
|
||||
assert.equal(noticeListAfterEdit.data.notices.filter(item => item.id === draftNotice.data.notice.id).length, 1, '编辑草稿不得重复新建通知');
|
||||
assert.match(noticeListAfterEdit.data.notices.find(item => item.id === draftNotice.data.notice.id).contentHtml, /修改后的草稿内容/, '草稿正文修改后应正确回填到管理接口');
|
||||
const refreshedHome = await anonymous.request('/api/public/home');
|
||||
const publicRichNotice = refreshedHome.data.notices.find(item => item.title === '系统测试成绩发布通知');
|
||||
assert.ok(publicRichNotice, '管理员发布通知后首页应可见');
|
||||
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 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.data.preview, true);
|
||||
assert.equal(stagedPreview.data.summary.valid, 1);
|
||||
assert.ok(!(await admin.request('/api/admin/results')).data.results.some(item => item.registrationId === registrationId && item.subjectId === exam.subjects[0].id), '预览阶段不得写入数据库');
|
||||
const unselectedResults = await admin.request('/api/admin/results');
|
||||
assert.deepEqual(unselectedResults.data.results, [], '未选择考试时不得读取成绩数据');
|
||||
assert.deepEqual(unselectedResults.data.registrations, [], '未选择考试时不得读取录分名单');
|
||||
assert.ok(!(await admin.request(`/api/admin/results?examId=${encodeURIComponent(exam.id)}`)).data.results.some(item => item.registrationId === registrationId && item.subjectId === exam.subjects[0].id), '预览阶段不得写入数据库');
|
||||
const stagedCommit = await admin.request('/api/admin/results/import', { method: 'POST', body: { rows: stagedPreview.data.rows } });
|
||||
assert.equal(stagedCommit.response.status, 200, '确认后应批量提交预览成绩');
|
||||
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), '批量提交后成绩应以预览状态写库');
|
||||
const publishResult = await admin.request('/api/admin/results', { method: 'POST', body: { registrationId, subjectId: exam.subjects[0].id, score: 126, grade: 'A', published: true } });
|
||||
assert.equal(publishResult.response.status, 200);
|
||||
assert.ok((await admin.request(`/api/admin/results?examId=${encodeURIComponent(exam.id)}`)).data.results.some(item => item.registrationId === registrationId && item.subjectId === exam.subjects[0].id && item.score === 125 && !item.published), '批量提交后成绩应以预览状态写库');
|
||||
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(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[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?examId=${encodeURIComponent(exam.id)}`);
|
||||
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 } });
|
||||
assert.equal(featureScoreUpdate.response.status, 200, '超级管理员应能登记与科目无关的特征分');
|
||||
assert.equal(featureScoreUpdate.data.registration.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.data.count, 1);
|
||||
assert.equal((await admin.request(`/api/admin/results?examId=${encodeURIComponent(exam.id)}`)).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/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((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' });
|
||||
@@ -936,7 +1043,7 @@ try {
|
||||
assert.equal(firstPreference.response.status, 200, '有资格考生应能分别填报一个指标志愿和普通志愿');
|
||||
assert.equal(firstPreference.data.locked, true, '达到管理员设置的提交次数后应自动锁定');
|
||||
assert.equal((await candidate.request(`/api/candidate/admissions/${exam.id}/preferences`, { method: 'PUT', body: { choices: [{ schoolId: structuredPlan.data.plan.schoolId, categoryCode: 'general', preferenceType: 'general' }] } })).response.status, 409, '超过填报次数后服务端必须拒绝继续修改');
|
||||
const classResults = await classAdmin.request('/api/admin/results');
|
||||
const classResults = await classAdmin.request(`/api/admin/results?examId=${encodeURIComponent(exam.id)}`);
|
||||
assert.ok(classResults.data.results.some(item => item.score === 126 && item.candidateName === '测试考生新名'), '班级管理员应可查看本班成绩');
|
||||
assert.equal((await classAdmin.request('/api/admin/results', { method: 'POST', body: { registrationId, subjectId: exam.subjects[0].id, score: 1 } })).response.status, 403, '班级管理员不得录入或发布成绩');
|
||||
|
||||
@@ -1017,7 +1124,7 @@ try {
|
||||
assert.match(archivedPreview.data.rows[0].errors.join(''), /永久锁定/, '归档场次的 Excel 行必须标记为不可提交');
|
||||
assert.equal((await admin.request('/api/admin/results/import', { method: 'POST', body: { rows: archivedPreview.data.rows } })).response.status, 400, '归档后 Excel 批量提交不得修改成绩');
|
||||
assert.equal((await candidate.request(`/api/candidate/results/${appealResults[0].id}/appeals`, { method: 'POST', body: { reason: '归档后再次申请复议' } })).response.status, 409, '归档后考生不得再发起成绩复议');
|
||||
const archivedAdminResults = await admin.request('/api/admin/results');
|
||||
const archivedAdminResults = await admin.request(`/api/admin/results?examId=${encodeURIComponent(exam.id)}`);
|
||||
assert.ok(archivedAdminResults.data.exams.find(item => item.id === exam.id)?.archivedAt, '管理端成绩中心应返回归档状态');
|
||||
const archivedCandidateResults = await candidate.request('/api/candidate/results');
|
||||
assert.ok(archivedCandidateResults.data.results.filter(item => item.examId === exam.id).every(item => item.archivedAt), '考生历史成绩应标记为归档并进入折叠区');
|
||||
|
||||
Reference in New Issue
Block a user