Add candidate account archiving and password reset

This commit is contained in:
2026-07-20 16:30:06 +08:00 Unverified
parent 9f850fa045
commit 7023493f08
12 changed files with 207 additions and 41 deletions
+26 -2
View File
@@ -37,8 +37,8 @@ function renderError(error) {
const baseViewContext = { state, app, h, formatDate, dateRange, badge, money, passPolicyText, statusLabels, icons, api, renderError, emptyState }; const baseViewContext = { state, app, h, formatDate, dateRange, badge, money, passPolicyText, statusLabels, icons, api, renderError, emptyState };
const { brand, renderHome, renderAuth } = createPublicViews(baseViewContext); const { brand, renderHome, renderAuth } = createPublicViews(baseViewContext);
const { adminNavForUser, portalShell, loadingPanel, renderCandidate } = createCandidateViews({ ...baseViewContext, brand }); const { adminNavForUser, portalShell, loadingPanel, renderCandidate, accountSecurity } = createCandidateViews({ ...baseViewContext, brand });
const { renderAdmin, workflowStepEditor } = createAdminViews({ ...baseViewContext, brand, portalShell, loadingPanel, adminNavForUser }); const { renderAdmin, workflowStepEditor } = createAdminViews({ ...baseViewContext, brand, portalShell, loadingPanel, adminNavForUser, accountSecurity });
function navigate(route) { function navigate(route) {
location.hash = route; location.hash = route;
@@ -172,6 +172,15 @@ document.addEventListener('click', async event => {
} }
if (action === 'edit-exam') return openExamForm(state.pageData.exams.find(exam => exam.id === target.dataset.id)); if (action === 'edit-exam') return openExamForm(state.pageData.exams.find(exam => exam.id === target.dataset.id));
if (action === 'review-candidate') return openCandidateReview(target.dataset.id); if (action === 'review-candidate') return openCandidateReview(target.dataset.id);
if (action === 'reset-candidate-password') return openCandidatePasswordReset(target.dataset.id);
if (action === 'candidate-archive') {
const scope = document.querySelector('[data-archive-scope]')?.value || '';
const separator = scope.indexOf(':');
if (separator < 1) return toast('请选择归档范围', '可以选择一个班级或整个年级');
const archived = target.dataset.archived === 'true';
const data = await api('/api/admin/candidate-accounts/archive', { method: 'POST', body: { scopeType: scope.slice(0, separator), scopeValue: scope.slice(separator + 1), archived } });
toast(archived ? '账户已归档' : '账户已恢复', `${data.scopeLabel} · ${data.count} 个账户状态已更新`); return renderRoute();
}
if (action === 'review-registration') return openRegistrationReview(target.dataset.id); if (action === 'review-registration') return openRegistrationReview(target.dataset.id);
if (action === 'excel-download') { if (action === 'excel-download') {
const query = new URLSearchParams(); const query = new URLSearchParams();
@@ -352,6 +361,15 @@ document.addEventListener('submit', async event => {
if (body.newPassword !== body.confirmPassword) throw new Error('两次输入的新密码不一致'); if (body.newPassword !== body.confirmPassword) throw new Error('两次输入的新密码不一致');
await api('/api/auth/change-password', { method: 'POST', body }); await api('/api/auth/change-password', { method: 'POST', body });
await refreshSession(); toast('密码修改成功', '下一步请补全个人信息'); navigate('candidate/onboarding'); await refreshSession(); toast('密码修改成功', '下一步请补全个人信息'); navigate('candidate/onboarding');
} else if (kind === 'account-password') {
const body = formObject(form);
if (body.newPassword !== body.confirmPassword) throw new Error('两次输入的新密码不一致');
await api('/api/auth/change-password', { method: 'POST', body });
form.reset(); toast('密码修改成功', '下次登录请使用新密码');
} else if (kind === 'candidate-password-reset') {
const body = formObject(form);
const data = await api(`/api/admin/candidates/${body.id}/reset-password`, { method: 'POST' });
setModal(`<div class="modal-head"><div><span>PASSWORD RESET</span><h2>临时密码已生成</h2><p>请通过线下安全渠道交给考生,仅此处展示一次。</p></div><button data-action="close-modal">×</button></div><div class="issued-number"><span>报名号</span><strong>${h(data.candidateNumber)}</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>`);
} else if (kind === 'candidate-profile') { } else if (kind === 'candidate-profile') {
const data = await api('/api/candidate/profile', { method: 'PUT', body: formObject(form) }); const data = await api('/api/candidate/profile', { method: 'PUT', body: formObject(form) });
state.profile = data.profile; await refreshSession(); toast('资料已提交', '管理员审核后会更新状态'); navigate('candidate/dashboard'); state.profile = data.profile; await refreshSession(); toast('资料已提交', '管理员审核后会更新状态'); navigate('candidate/dashboard');
@@ -555,6 +573,12 @@ function openCandidateReview(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>'}`); 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>'}`);
} }
function openCandidatePasswordReset(id) {
const item = state.pageData?.candidates?.find(candidate => candidate.id === id);
if (!item) return toast('考生不存在', '请刷新页面后重试');
setModal(`<div class="modal-head"><div><span>RESET PASSWORD</span><h2>重置 ${h(item.name)} 的密码</h2><p class="mono">报名号 ${h(item.candidateNumber)}</p></div><button data-action="close-modal">×</button></div><form class="modal-form" data-form="candidate-password-reset"><input type="hidden" name="id" value="${h(item.id)}"><div class="reset-warning"><strong>重置后立即生效</strong><p>系统将生成临时密码,使原密码及该考生所有现有登录会话失效,并要求下次登录先改密。</p></div><div class="modal-foot"><button type="button" class="ghost-button" data-action="close-modal">取消</button><button type="submit" class="solid-button">确认重置</button></div></form>`);
}
function openRegistrationReview(id) { function openRegistrationReview(id) {
const reg = state.pageData.registrations.find(item => item.id === id); const reg = state.pageData.registrations.find(item => item.id === id);
const canReview = reg.status === 'pending' && reg.workflow?.assignee?.id === state.user.id; const canReview = reg.status === 'pending' && reg.workflow?.assignee?.id === state.user.id;
+22 -10
View File
@@ -57,7 +57,7 @@ function buildSeedOperations(state) {
const nullable = value => value == null || value === '' ? null : value; const nullable = value => value == null || value === '' ? null : value;
add( add(
'UPDATE schema_metadata SET schema_version = 12, app_version = ?, self_registration_enabled = ?, created_at = ? WHERE id = 1', 'UPDATE schema_metadata SET schema_version = 13, app_version = ?, self_registration_enabled = ?, created_at = ? WHERE id = 1',
Number(state.meta?.version || 1), state.settings?.selfRegistrationEnabled ? 1 : 0, Number(state.meta?.version || 1), state.settings?.selfRegistrationEnabled ? 1 : 0,
state.meta?.createdAt || new Date().toISOString() state.meta?.createdAt || new Date().toISOString()
); );
@@ -84,10 +84,11 @@ function buildSeedOperations(state) {
add( add(
`INSERT INTO users ( `INSERT INTO users (
id, username, candidate_number, password_hash, role, admin_level, school_id, class_id, active, id, username, candidate_number, password_hash, role, admin_level, school_id, class_id, active,
must_change_password, display_name, created_at must_change_password, archived_at, archived_by, display_name, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
user.id, user.username, nullable(user.candidateNumber), user.passwordHash, user.role, nullable(user.adminLevel), nullable(user.schoolId), user.id, user.username, nullable(user.candidateNumber), user.passwordHash, user.role, nullable(user.adminLevel), nullable(user.schoolId),
nullable(user.classId), user.active === false ? 0 : 1, user.mustChangePassword ? 1 : 0, user.displayName, user.createdAt nullable(user.classId), user.active === false ? 0 : 1, user.mustChangePassword ? 1 : 0, nullable(user.archivedAt),
nullable(user.archivedBy), user.displayName, user.createdAt
); );
} }
@@ -466,6 +467,8 @@ function stateFromRows(rows) {
classId: row.class_id || null, classId: row.class_id || null,
active: row.active == null ? true : Boolean(row.active), active: row.active == null ? true : Boolean(row.active),
mustChangePassword: Boolean(row.must_change_password), mustChangePassword: Boolean(row.must_change_password),
archivedAt: row.archived_at || null,
archivedBy: row.archived_by || null,
displayName: row.display_name, displayName: row.display_name,
createdAt: row.created_at createdAt: row.created_at
})), })),
@@ -854,10 +857,11 @@ function createRepository({ client, location, read, transaction, close }) {
operation( operation(
`INSERT INTO users ( `INSERT INTO users (
id, username, candidate_number, password_hash, role, admin_level, school_id, class_id, active, id, username, candidate_number, password_hash, role, admin_level, school_id, class_id, active,
must_change_password, display_name, created_at must_change_password, archived_at, archived_by, display_name, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
user.id, user.username, optional(user.candidateNumber), user.passwordHash, user.role, optional(user.adminLevel), optional(user.schoolId), user.id, user.username, optional(user.candidateNumber), user.passwordHash, user.role, optional(user.adminLevel), optional(user.schoolId),
optional(user.classId), user.active === false ? 0 : 1, user.mustChangePassword ? 1 : 0, user.displayName, user.createdAt optional(user.classId), user.active === false ? 0 : 1, user.mustChangePassword ? 1 : 0, optional(user.archivedAt),
optional(user.archivedBy), user.displayName, user.createdAt
), ),
operation( operation(
`INSERT INTO candidate_profiles ( `INSERT INTO candidate_profiles (
@@ -933,6 +937,14 @@ function createRepository({ client, location, read, transaction, close }) {
if (log) operations.push(auditOperation(log)); if (log) operations.push(auditOperation(log));
await transaction(operations); await transaction(operations);
}, },
async updateCandidateArchives(users, log) {
const operations = users.map(user => operation(
'UPDATE users SET archived_at = ?, archived_by = ? WHERE id = ?',
optional(user.archivedAt), optional(user.archivedBy), user.id
));
operations.push(auditOperation(log));
await transaction(operations);
},
async updateRegistrationSetting(enabled, log) { async updateRegistrationSetting(enabled, log) {
await transaction([ await transaction([
operation('UPDATE schema_metadata SET self_registration_enabled = ? WHERE id = 1', enabled ? 1 : 0), operation('UPDATE schema_metadata SET self_registration_enabled = ? WHERE id = 1', enabled ? 1 : 0),
@@ -1045,10 +1057,10 @@ function createRepository({ client, location, read, transaction, close }) {
operation( operation(
`INSERT INTO users ( `INSERT INTO users (
id, username, candidate_number, password_hash, role, admin_level, school_id, class_id, active, id, username, candidate_number, password_hash, role, admin_level, school_id, class_id, active,
must_change_password, display_name, created_at must_change_password, archived_at, archived_by, display_name, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
user.id, user.username, user.candidateNumber, user.passwordHash, user.role, optional(user.adminLevel), user.id, user.username, user.candidateNumber, user.passwordHash, user.role, optional(user.adminLevel),
user.schoolId, user.classId, 1, 1, user.displayName, user.createdAt user.schoolId, user.classId, 1, 1, null, null, user.displayName, user.createdAt
), ),
operation( operation(
`INSERT INTO candidate_profiles ( `INSERT INTO candidate_profiles (
+15 -7
View File
@@ -18,7 +18,8 @@ export function createAdminViews(context) {
brand, brand,
portalShell, portalShell,
loadingPanel, loadingPanel,
adminNavForUser adminNavForUser,
accountSecurity
} = context; } = context;
async function renderAdmin(page) { async function renderAdmin(page) {
@@ -34,20 +35,21 @@ export function createAdminViews(context) {
'account-batches': ['批量报名号申领', '按班级填写申领人数;审批通过后系统生成固定报名号和初始密码。'], 'account-batches': ['批量报名号申领', '按班级填写申领人数;审批通过后系统生成固定报名号和初始密码。'],
flows: [state.user.adminLevel === 'super' ? '流程监督' : '流程中心', state.user.adminLevel === 'super' ? '查看全部流程,监督转交、修改和退回节点。' : '处理分配给你的流程,并可转交给本校同级管理员。'], flows: [state.user.adminLevel === 'super' ? '流程监督' : '流程中心', state.user.adminLevel === 'super' ? '查看全部流程,监督转交、修改和退回节点。' : '处理分配给你的流程,并可转交给本校同级管理员。'],
'flow-design': ['流程设计', '配置考生信息、报名审核、考点考场变更与批量建号的审批步骤。'], 'flow-design': ['流程设计', '配置考生信息、报名审核、考点考场变更与批量建号的审批步骤。'],
'number-rules': ['报名号规则', '设计审批通过后生成的新账户号码组成。'] 'number-rules': ['报名号规则', '设计审批通过后生成的新账户号码组成。'],
security: ['账户安全', '使用当前密码设置新的登录密码。']
}; };
const allowedPages = adminNavForUser().map(item => item[0]); const allowedPages = adminNavForUser().map(item => item[0]);
if (!meta[page] || !allowedPages.includes(page)) page = 'dashboard'; if (!meta[page] || !allowedPages.includes(page)) page = 'dashboard';
app.innerHTML = portalShell('admin', page, loadingPanel(), ...meta[page]); app.innerHTML = portalShell('admin', page, loadingPanel(), ...meta[page]);
try { 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 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 = await api(`/api/admin/${endpoint}`); const data = page === 'security' ? {} : await api(`/api/admin/${endpoint}`);
state.pageData = data; state.pageData = data;
const content = { const content = {
dashboard: () => adminDashboard(data), candidates: () => adminCandidates(data.candidates), registrations: () => adminRegistrations(data.registrations), dashboard: () => adminDashboard(data), candidates: () => adminCandidates(data), registrations: () => adminRegistrations(data.registrations),
exams: () => adminExams(data.exams), notices: () => adminNotices(data.notices), admit: () => adminAdmit(data), results: () => adminResults(data), exams: () => adminExams(data.exams), notices: () => adminNotices(data.notices), admit: () => adminAdmit(data), results: () => adminResults(data),
admins: () => adminUsers(data), centers: () => adminCenters(data), flows: () => adminFlows(data), organization: () => adminSchoolOrganization(data), 'account-batches': () => adminAccountBatches(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) 'flow-design': () => adminFlowDesign(data.workflows), 'number-rules': () => adminNumberRules(data), security: () => accountSecurity()
}[page](); }[page]();
app.innerHTML = portalShell('admin', page, content, ...meta[page]); app.innerHTML = portalShell('admin', page, content, ...meta[page]);
} catch (error) { renderError(error); } } catch (error) { renderError(error); }
@@ -69,9 +71,15 @@ export function createAdminViews(context) {
return `<section class="school-org-banner"><div><span>SCHOOL ORGANIZATION</span><h2>${h(data.school?.name)}</h2><p>班级决定考生、报名与成绩的可见范围;一个班级可以配置多名班级管理员。</p></div><dl><div><dt>班级</dt><dd>${classes.length}</dd></div><div><dt>班级管理员</dt><dd>${activeAdmins}</dd></div><div><dt>在册考生</dt><dd>${classes.reduce((sum, item) => sum + item.candidateCount, 0)}</dd></div></dl></section>${excelToolbar('classes', { label: '班级台账' })}${excelToolbar('class_admins', { label: '班级管理员' })}<section class="org-class-grid">${classes.map(item => `<article class="panel org-class-card ${item.active ? '' : 'inactive'}"><header><div><span>${h(item.grade)}</span><h2>${h(item.name)}</h2></div>${badge(item.active ? 'approved' : 'closed')}</header><div class="org-class-metrics"><span><strong>${item.candidateCount}</strong><small>在册考生</small></span><span><strong>${item.admins.length}</strong><small>管理员</small></span></div><section><div class="org-admin-head"><strong>班级管理员</strong><button class="row-action" data-action="new-class-admin" data-class-id="${h(item.id)}">添加管理员</button></div>${item.admins.map(admin => `<button class="org-admin-row" data-action="edit-class-admin" data-id="${h(admin.id)}" data-class-id="${h(item.id)}"><span class="user-avatar">${h(admin.displayName.slice(0,1))}</span><span><strong>${h(admin.displayName)}</strong><small class="mono">${h(admin.username)}</small></span>${badge(admin.active ? 'approved' : 'closed')}</button>`).join('') || '<p class="org-empty">尚未配置班级管理员</p>'}</section><footer><button class="row-action" data-action="edit-school-class" data-id="${h(item.id)}">编辑班级</button><button class="row-action" data-action="toggle-school-class" data-id="${h(item.id)}" data-active="${item.active ? 'false' : 'true'}">${item.active ? '停用班级' : '重新启用'}</button></footer></article>`).join('') || emptyState('还没有班级', '点击“新增班级”建立本校组织范围。')}</section>`; return `<section class="school-org-banner"><div><span>SCHOOL ORGANIZATION</span><h2>${h(data.school?.name)}</h2><p>班级决定考生、报名与成绩的可见范围;一个班级可以配置多名班级管理员。</p></div><dl><div><dt>班级</dt><dd>${classes.length}</dd></div><div><dt>班级管理员</dt><dd>${activeAdmins}</dd></div><div><dt>在册考生</dt><dd>${classes.reduce((sum, item) => sum + item.candidateCount, 0)}</dd></div></dl></section>${excelToolbar('classes', { label: '班级台账' })}${excelToolbar('class_admins', { label: '班级管理员' })}<section class="org-class-grid">${classes.map(item => `<article class="panel org-class-card ${item.active ? '' : 'inactive'}"><header><div><span>${h(item.grade)}</span><h2>${h(item.name)}</h2></div>${badge(item.active ? 'approved' : 'closed')}</header><div class="org-class-metrics"><span><strong>${item.candidateCount}</strong><small>在册考生</small></span><span><strong>${item.admins.length}</strong><small>管理员</small></span></div><section><div class="org-admin-head"><strong>班级管理员</strong><button class="row-action" data-action="new-class-admin" data-class-id="${h(item.id)}">添加管理员</button></div>${item.admins.map(admin => `<button class="org-admin-row" data-action="edit-class-admin" data-id="${h(admin.id)}" data-class-id="${h(item.id)}"><span class="user-avatar">${h(admin.displayName.slice(0,1))}</span><span><strong>${h(admin.displayName)}</strong><small class="mono">${h(admin.username)}</small></span>${badge(admin.active ? 'approved' : 'closed')}</button>`).join('') || '<p class="org-empty">尚未配置班级管理员</p>'}</section><footer><button class="row-action" data-action="edit-school-class" data-id="${h(item.id)}">编辑班级</button><button class="row-action" data-action="toggle-school-class" data-id="${h(item.id)}" data-active="${item.active ? 'false' : 'true'}">${item.active ? '停用班级' : '重新启用'}</button></footer></article>`).join('') || emptyState('还没有班级', '点击“新增班级”建立本校组织范围。')}</section>`;
} }
function adminCandidates(candidates) { function adminCandidates(data) {
const candidates = data.candidates || [];
const readOnly = false; const readOnly = false;
return `${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></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 data-status="${h(item.status)}"><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.mustChangePassword ? '待首次改密' : item.profileCompleted ? h(item.workflow?.currentStepDetail?.name || '资料已提交') : '待补全资料'}</strong><small>${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><button class="row-action" data-action="review-candidate" data-id="${h(item.id)}" ${item.profileCompleted ? '' : 'disabled'}>${item.profileCompleted ? (readOnly ? '查看' : '查看流程') : '等待考生'}</button></td></tr>`).join('')}</tbody></table></div></section>`; 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>`;
} }
function adminRegistrations(registrations) { function adminRegistrations(registrations) {
+15 -8
View File
@@ -20,15 +20,17 @@ export function createCandidateViews(context) {
const candidateNav = [ const candidateNav = [
['dashboard', '总览', 'home'], ['profile', '个人资料', 'user'], ['exams', '考试报名', 'exam'], ['dashboard', '总览', 'home'], ['profile', '个人资料', 'user'], ['exams', '考试报名', 'exam'],
['registrations', '我的报名', 'check'], ['admit', '准考证', 'ticket'], ['results', '成绩查询', 'chart'], ['notices', '通知公告', 'bell'] ['registrations', '我的报名', 'check'], ['admit', '准考证', 'ticket'], ['results', '成绩查询', 'chart'], ['notices', '通知公告', 'bell'],
['security', '账户安全', 'user']
]; ];
function adminNavForUser() { function adminNavForUser() {
const level = state.user?.adminLevel || 'super'; const level = state.user?.adminLevel || 'super';
const core = [['dashboard', '工作台', 'home'], ['candidates', level === 'class' ? '本班考生' : '考生信息', 'users'], ['registrations', level === 'class' ? '报名状态' : '报名审核', 'check'], ['results', level === 'super' ? '成绩发布' : '成绩查看', 'chart']]; const core = [['dashboard', '工作台', 'home'], ['candidates', level === 'class' ? '本班考生' : '考生信息', 'users'], ['registrations', level === 'class' ? '报名状态' : '报名审核', 'check'], ['results', level === 'super' ? '成绩发布' : '成绩查看', 'chart']];
if (level === 'class') return [core[0], core[1], core[2], ['admit', '本班准考证', 'ticket'], ['flows', '流程中心', 'check'], core[3]]; const security = ['security', '账户安全', 'user'];
if (level === 'class') return [core[0], core[1], core[2], ['admit', '本班准考证', 'ticket'], ['flows', '流程中心', 'check'], core[3], security];
const operations = [['flows', '流程中心', 'check'], ['centers', '考场信息', 'exam']]; const operations = [['flows', '流程中心', 'check'], ['centers', '考场信息', 'exam']];
if (level === 'school') return [core[0], ['organization', '本校组织', 'users'], ['account-batches', '批量建号', 'ticket'], core[1], core[2], ['admit', '校内准考证', 'ticket'], ...operations, core[3]]; if (level === 'school') return [core[0], ['organization', '本校组织', 'users'], ['account-batches', '批量建号', 'ticket'], core[1], core[2], ['admit', '校内准考证', 'ticket'], ...operations, core[3], security];
return [core[0], ['admins', '管理员', 'users'], core[1], core[2], ['flows', '流程监督', 'check'], ['flow-design', '流程设计', 'exam'], ['number-rules', '报名号规则', 'ticket'], ['centers', '考场信息', 'exam'], ['exams', '考试与科目', 'exam'], ['notices', '通知发布', 'bell'], ['admit', '准考证编排', 'ticket'], core[3]]; return [core[0], ['admins', '管理员', 'users'], core[1], core[2], ['flows', '流程监督', 'check'], ['flow-design', '流程设计', 'exam'], ['number-rules', '报名号规则', 'ticket'], ['centers', '考场信息', 'exam'], ['exams', '考试与科目', 'exam'], ['notices', '通知发布', 'bell'], ['admit', '准考证编排', 'ticket'], core[3], security];
} }
function portalShell(role, page, content, title, description) { function portalShell(role, page, content, title, description) {
@@ -83,19 +85,20 @@ export function createCandidateViews(context) {
registrations: ['我的报名', '查看已提交的考试、科目与审核进度。'], registrations: ['我的报名', '查看已提交的考试、科目与审核进度。'],
admit: ['准考证', '管理员生成后,可在规定下载时间内保存准考证。'], admit: ['准考证', '管理员生成后,可在规定下载时间内保存准考证。'],
results: ['成绩查询', '仅显示考试中心已经正式发布的成绩。'], results: ['成绩查询', '仅显示考试中心已经正式发布的成绩。'],
notices: ['通知公告', '查看与报名、考试和成绩相关的最新消息。'] notices: ['通知公告', '查看与报名、考试和成绩相关的最新消息。'],
security: ['账户安全', '使用当前密码设置新的登录密码。']
}; };
if (!meta[page]) page = 'dashboard'; if (!meta[page]) page = 'dashboard';
app.innerHTML = portalShell('candidate', page, loadingPanel(), ...meta[page]); app.innerHTML = portalShell('candidate', page, loadingPanel(), ...meta[page]);
try { try {
const endpoint = page === 'dashboard' ? 'dashboard' : page === 'profile' ? 'profile' : page === 'exams' ? 'exams' : page === 'results' ? 'results' : 'registrations'; const endpoint = page === 'dashboard' ? 'dashboard' : page === 'profile' ? 'profile' : page === 'exams' ? 'exams' : page === 'results' ? 'results' : 'registrations';
const data = page === 'notices' ? { notices: state.publicData.notices } : await api(`/api/candidate/${endpoint}`); const data = page === 'notices' ? { notices: state.publicData.notices } : page === 'security' ? {} : await api(`/api/candidate/${endpoint}`);
state.pageData = data; state.pageData = data;
if (data.profile) state.profile = data.profile; if (data.profile) state.profile = data.profile;
const content = { const content = {
dashboard: () => candidateDashboard(data), profile: () => candidateProfile(data), exams: () => candidateExams(data), dashboard: () => candidateDashboard(data), profile: () => candidateProfile(data), exams: () => candidateExams(data),
registrations: () => candidateRegistrations(data.registrations), admit: () => candidateAdmit(data.registrations), registrations: () => candidateRegistrations(data.registrations), admit: () => candidateAdmit(data.registrations),
results: () => candidateResults(data), notices: () => candidateNotices(data.notices) results: () => candidateResults(data), notices: () => candidateNotices(data.notices), security: () => accountSecurity()
}[page](); }[page]();
app.innerHTML = portalShell('candidate', page, content, ...meta[page]); app.innerHTML = portalShell('candidate', page, content, ...meta[page]);
if (page === 'profile') mountRegionSelects(app, data.profile, { className: 'region-selects wide-field' }); if (page === 'profile') mountRegionSelects(app, data.profile, { className: 'region-selects wide-field' });
@@ -172,5 +175,9 @@ export function createCandidateViews(context) {
return `<section class="panel notice-center"><div class="notice-center-list">${notices.map(notice => `<button data-action="open-notice" data-id="${h(notice.id)}"><time><strong>${new Date(notice.publishAt).getDate()}</strong><span>${new Date(notice.publishAt).toLocaleString('zh-CN',{month:'short'})}</span></time><span><em>${h(notice.category)}</em><strong>${h(notice.title)}</strong><small>${h(notice.summary)}</small></span>${notice.pinned ? '<i>置顶</i>' : ''}${icons.arrow}</button>`).join('')}</div></section>`; return `<section class="panel notice-center"><div class="notice-center-list">${notices.map(notice => `<button data-action="open-notice" data-id="${h(notice.id)}"><time><strong>${new Date(notice.publishAt).getDate()}</strong><span>${new Date(notice.publishAt).toLocaleString('zh-CN',{month:'short'})}</span></time><span><em>${h(notice.category)}</em><strong>${h(notice.title)}</strong><small>${h(notice.summary)}</small></span>${notice.pinned ? '<i>置顶</i>' : ''}${icons.arrow}</button>`).join('')}</div></section>`;
} }
return { adminNavForUser, portalShell, loadingPanel, renderCandidate }; function accountSecurity() {
return `<section class="panel account-security-panel"><div class="account-security-copy"><span>LOGIN PASSWORD</span><h2>修改登录密码</h2><p>密码修改成功后立即生效。请使用至少 8 位、且与当前密码不同的新密码。</p><dl><div><dt>当前账号</dt><dd class="mono">${h(state.user?.candidateNumber || state.user?.username)}</dd></div><div><dt>账户类型</dt><dd>${state.user?.role === 'candidate' ? '考生账户' : statusLabels[state.user?.adminLevel] || '管理员账户'}</dd></div></dl></div><form class="stack-form account-password-form" data-form="account-password"><label><span>当前密码</span><input name="currentPassword" type="password" autocomplete="current-password" required></label><label><span>新密码</span><input name="newPassword" type="password" autocomplete="new-password" minlength="8" required></label><label><span>再次输入新密码</span><input name="confirmPassword" type="password" autocomplete="new-password" minlength="8" required></label><button class="solid-button large" type="submit">保存新密码</button></form></section>`;
}
return { adminNavForUser, portalShell, loadingPanel, renderCandidate, accountSecurity };
} }
+10 -3
View File
@@ -44,6 +44,8 @@ export function createMysqlAdapter(context) {
'ALTER TABLE users ADD COLUMN IF NOT EXISTS active BOOLEAN NOT NULL DEFAULT TRUE', 'ALTER TABLE users ADD COLUMN IF NOT EXISTS active BOOLEAN NOT NULL DEFAULT TRUE',
'ALTER TABLE users ADD COLUMN IF NOT EXISTS candidate_number VARCHAR(120) NULL', 'ALTER TABLE users ADD COLUMN IF NOT EXISTS candidate_number VARCHAR(120) NULL',
'ALTER TABLE users ADD COLUMN IF NOT EXISTS must_change_password BOOLEAN NOT NULL DEFAULT FALSE', 'ALTER TABLE users ADD COLUMN IF NOT EXISTS must_change_password BOOLEAN NOT NULL DEFAULT FALSE',
'ALTER TABLE users ADD COLUMN IF NOT EXISTS archived_at VARCHAR(35) NULL',
'ALTER TABLE users ADD COLUMN IF NOT EXISTS archived_by VARCHAR(64) NULL',
'ALTER TABLE schema_metadata ADD COLUMN IF NOT EXISTS self_registration_enabled BOOLEAN NOT NULL DEFAULT FALSE', 'ALTER TABLE schema_metadata ADD COLUMN IF NOT EXISTS self_registration_enabled BOOLEAN NOT NULL DEFAULT FALSE',
'ALTER TABLE candidate_profiles ADD COLUMN IF NOT EXISTS school_id VARCHAR(64) NULL', 'ALTER TABLE candidate_profiles ADD COLUMN IF NOT EXISTS school_id VARCHAR(64) NULL',
'ALTER TABLE candidate_profiles ADD COLUMN IF NOT EXISTS class_id VARCHAR(64) NULL', 'ALTER TABLE candidate_profiles ADD COLUMN IF NOT EXISTS class_id VARCHAR(64) NULL',
@@ -148,6 +150,11 @@ export function createMysqlAdapter(context) {
metadataRows[0].schema_version = 12; metadataRows[0].schema_version = 12;
metadataRows[0].app_version = 12; metadataRows[0].app_version = 12;
} }
if (Number(metadataRows[0]?.schema_version || 1) < 13) {
await pool.execute('UPDATE schema_metadata SET schema_version = 13, app_version = 13 WHERE id = 1');
metadataRows[0].schema_version = 13;
metadataRows[0].app_version = 13;
}
if (Number(metadataRows[0]?.app_version || 1) < 2) { if (Number(metadataRows[0]?.app_version || 1) < 2) {
const extension = seed(); const extension = seed();
const connection = await pool.getConnection(); const connection = await pool.getConnection();
@@ -319,8 +326,8 @@ export function createMysqlAdapter(context) {
if (Number(metadataRows[0]?.app_version || 1) < 7) { if (Number(metadataRows[0]?.app_version || 1) < 7) {
await pool.execute('UPDATE schema_metadata SET schema_version = 7, app_version = 7 WHERE id = 1'); await pool.execute('UPDATE schema_metadata SET schema_version = 7, app_version = 7 WHERE id = 1');
} }
if (Number(metadataRows[0]?.schema_version || 1) < 12) { if (Number(metadataRows[0]?.schema_version || 1) < 13) {
await pool.execute('UPDATE schema_metadata SET schema_version = 12, app_version = 12 WHERE id = 1'); await pool.execute('UPDATE schema_metadata SET schema_version = 13, app_version = 13 WHERE id = 1');
} }
} }
if (!existing.length) { if (!existing.length) {
@@ -330,7 +337,7 @@ export function createMysqlAdapter(context) {
await connection.beginTransaction(); await connection.beginTransaction();
const [insert] = await connection.execute(` const [insert] = await connection.execute(`
INSERT IGNORE INTO schema_metadata (id, schema_version, app_version, self_registration_enabled, created_at) INSERT IGNORE INTO schema_metadata (id, schema_version, app_version, self_registration_enabled, created_at)
VALUES (1, 12, ?, ?, ?) VALUES (1, 13, ?, ?, ?)
`, [Number(initialState.meta?.version || 1), initialState.settings?.selfRegistrationEnabled ? 1 : 0, initialState.meta?.createdAt || new Date().toISOString()]); `, [Number(initialState.meta?.version || 1), initialState.settings?.selfRegistrationEnabled ? 1 : 0, initialState.meta?.createdAt || new Date().toISOString()]);
if (insert.affectedRows === 1) { if (insert.affectedRows === 1) {
for (const item of buildSeedOperations(initialState)) await connection.execute(item.sql, item.params); for (const item of buildSeedOperations(initialState)) await connection.execute(item.sql, item.params);
+8 -1
View File
@@ -45,6 +45,8 @@ export const sqliteSchema = `
class_id TEXT REFERENCES school_classes(id) ON DELETE SET NULL, class_id TEXT REFERENCES school_classes(id) ON DELETE SET NULL,
active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0, 1)), active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0, 1)),
must_change_password INTEGER NOT NULL DEFAULT 0 CHECK (must_change_password IN (0, 1)), must_change_password INTEGER NOT NULL DEFAULT 0 CHECK (must_change_password IN (0, 1)),
archived_at TEXT,
archived_by TEXT REFERENCES users(id) ON DELETE RESTRICT,
display_name TEXT NOT NULL, display_name TEXT NOT NULL,
created_at TEXT NOT NULL created_at TEXT NOT NULL
) STRICT; ) STRICT;
@@ -398,6 +400,7 @@ export const sqliteSchema = `
) STRICT; ) STRICT;
CREATE INDEX IF NOT EXISTS idx_profiles_status ON candidate_profiles(status); CREATE INDEX IF NOT EXISTS idx_profiles_status ON candidate_profiles(status);
CREATE INDEX IF NOT EXISTS idx_users_archive_scope ON users(role, school_id, class_id, archived_at);
CREATE INDEX IF NOT EXISTS idx_profiles_scope ON candidate_profiles(school_id, class_id, status); CREATE INDEX IF NOT EXISTS idx_profiles_scope ON candidate_profiles(school_id, class_id, status);
CREATE INDEX IF NOT EXISTS idx_notices_status_publish ON notices(status, publish_at); CREATE INDEX IF NOT EXISTS idx_notices_status_publish ON notices(status, publish_at);
CREATE INDEX IF NOT EXISTS idx_exams_status_registration ON exams(status, registration_start, registration_end); CREATE INDEX IF NOT EXISTS idx_exams_status_registration ON exams(status, registration_start, registration_end);
@@ -466,14 +469,18 @@ export const mysqlSchema = [
class_id VARCHAR(64) NULL, class_id VARCHAR(64) NULL,
active BOOLEAN NOT NULL DEFAULT TRUE, active BOOLEAN NOT NULL DEFAULT TRUE,
must_change_password BOOLEAN NOT NULL DEFAULT FALSE, must_change_password BOOLEAN NOT NULL DEFAULT FALSE,
archived_at VARCHAR(35) NULL,
archived_by VARCHAR(64) NULL,
display_name VARCHAR(100) NOT NULL, display_name VARCHAR(100) NOT NULL,
created_at VARCHAR(35) NOT NULL, created_at VARCHAR(35) NOT NULL,
PRIMARY KEY (id), PRIMARY KEY (id),
UNIQUE KEY uq_users_username (username), UNIQUE KEY uq_users_username (username),
UNIQUE KEY uq_users_candidate_number (candidate_number), UNIQUE KEY uq_users_candidate_number (candidate_number),
KEY idx_users_admin_scope (role, admin_level, school_id, class_id), KEY idx_users_admin_scope (role, admin_level, school_id, class_id),
KEY idx_users_archive_scope (role, school_id, class_id, archived_at),
CONSTRAINT fk_users_school FOREIGN KEY (school_id) REFERENCES schools(id) ON DELETE SET NULL, CONSTRAINT fk_users_school FOREIGN KEY (school_id) REFERENCES schools(id) ON DELETE SET NULL,
CONSTRAINT fk_users_class FOREIGN KEY (class_id) REFERENCES school_classes(id) ON DELETE SET NULL CONSTRAINT fk_users_class FOREIGN KEY (class_id) REFERENCES school_classes(id) ON DELETE SET NULL,
CONSTRAINT fk_users_archiver FOREIGN KEY (archived_by) REFERENCES users(id) ON DELETE RESTRICT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS candidate_profiles ( `CREATE TABLE IF NOT EXISTS candidate_profiles (
id VARCHAR(64) NOT NULL, id VARCHAR(64) NOT NULL,
+8 -4
View File
@@ -29,7 +29,8 @@ export function createSqliteAdapter(context) {
}; };
ensureColumns('users', [ ensureColumns('users', [
['admin_level', 'TEXT'], ['school_id', 'TEXT'], ['class_id', 'TEXT'], ['active', 'INTEGER NOT NULL DEFAULT 1'], ['admin_level', 'TEXT'], ['school_id', 'TEXT'], ['class_id', 'TEXT'], ['active', 'INTEGER NOT NULL DEFAULT 1'],
['candidate_number', 'TEXT'], ['must_change_password', 'INTEGER NOT NULL DEFAULT 0'] ['candidate_number', 'TEXT'], ['must_change_password', 'INTEGER NOT NULL DEFAULT 0'],
['archived_at', 'TEXT'], ['archived_by', 'TEXT']
]); ]);
ensureColumns('schema_metadata', [['self_registration_enabled', 'INTEGER NOT NULL DEFAULT 0']]); ensureColumns('schema_metadata', [['self_registration_enabled', 'INTEGER NOT NULL DEFAULT 0']]);
ensureColumns('candidate_profiles', [ ensureColumns('candidate_profiles', [
@@ -232,6 +233,9 @@ export function createSqliteAdapter(context) {
connection.prepare("UPDATE exams SET pass_policy = 'rank_percent' WHERE pass_policy = 'score_ratio'").run(); connection.prepare("UPDATE exams SET pass_policy = 'rank_percent' WHERE pass_policy = 'score_ratio'").run();
connection.prepare('UPDATE schema_metadata SET schema_version = 12, app_version = 12 WHERE id = 1').run(); connection.prepare('UPDATE schema_metadata SET schema_version = 12, app_version = 12 WHERE id = 1').run();
} }
if (existingSystem && Number(existingSystem.schema_version || 1) < 13) {
connection.prepare('UPDATE schema_metadata SET schema_version = 13, app_version = 13 WHERE id = 1').run();
}
if (existingSystem && Number(existingSystem.app_version || 1) < 2) { if (existingSystem && Number(existingSystem.app_version || 1) < 2) {
const extension = seed(); const extension = seed();
connection.exec('BEGIN IMMEDIATE'); connection.exec('BEGIN IMMEDIATE');
@@ -382,8 +386,8 @@ export function createSqliteAdapter(context) {
if (existingSystem && Number(existingSystem.app_version || 1) < 7) { if (existingSystem && Number(existingSystem.app_version || 1) < 7) {
connection.prepare('UPDATE schema_metadata SET schema_version = 7, app_version = 7 WHERE id = 1').run(); connection.prepare('UPDATE schema_metadata SET schema_version = 7, app_version = 7 WHERE id = 1').run();
} }
if (existingSystem && Number(existingSystem.schema_version || 1) < 12) { if (existingSystem && Number(existingSystem.schema_version || 1) < 13) {
connection.prepare('UPDATE schema_metadata SET schema_version = 12, app_version = 12 WHERE id = 1').run(); connection.prepare('UPDATE schema_metadata SET schema_version = 13, app_version = 13 WHERE id = 1').run();
} }
if (!connection.prepare('SELECT id FROM schema_metadata WHERE id = 1').get()) { if (!connection.prepare('SELECT id FROM schema_metadata WHERE id = 1').get()) {
@@ -392,7 +396,7 @@ export function createSqliteAdapter(context) {
try { try {
connection.prepare(` connection.prepare(`
INSERT INTO schema_metadata (id, schema_version, app_version, self_registration_enabled, created_at) INSERT INTO schema_metadata (id, schema_version, app_version, self_registration_enabled, created_at)
VALUES (1, 12, ?, ?, ?) VALUES (1, 13, ?, ?, ?)
`).run(Number(initialState.meta?.version || 1), initialState.settings?.selfRegistrationEnabled ? 1 : 0, initialState.meta?.createdAt || new Date().toISOString()); `).run(Number(initialState.meta?.version || 1), initialState.settings?.selfRegistrationEnabled ? 1 : 0, initialState.meta?.createdAt || new Date().toISOString());
for (const item of buildSeedOperations(initialState)) connection.prepare(item.sql).run(...item.params); for (const item of buildSeedOperations(initialState)) connection.prepare(item.sql).run(...item.params);
connection.exec('COMMIT'); connection.exec('COMMIT');
+57 -1
View File
@@ -111,6 +111,9 @@ export function createAdminRoutes(context) {
const user = await requireUser(request, response, 'admin'); const user = await requireUser(request, response, 'admin');
if (!user) return true; if (!user) return true;
const db = await readDb(); const db = await readDb();
if (request.method === 'DELETE' && /^\/api\/admin\/(?:admins|candidates|candidate-accounts)(?:\/|$)/.test(pathname)) {
return sendError(response, 405, '账户不得删除;考生账户请由校方归档,管理员账户可停用');
}
if (request.method === 'GET' && pathname === '/api/admin/context') { if (request.method === 'GET' && pathname === '/api/admin/context') {
return sendJson(response, 200, { return sendJson(response, 200, {
@@ -579,10 +582,63 @@ export function createAdminRoutes(context) {
const candidates = db.candidateProfiles.filter(profile => profileInScope(user, profile)).map(profile => { 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 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 account = db.users.find(item => item.id === profile.userId);
return { ...profile, idNumberMasked: profile.idNumber.startsWith('PENDING-') ? '待考生补充' : maskId(profile.idNumber), username: account?.username, candidateNumber: account?.candidateNumber || '', mustChangePassword: Boolean(account?.mustChangePassword), workflow: workflowView(db, instance) }; return {
...profile,
idNumberMasked: profile.idNumber.startsWith('PENDING-') ? '待考生补充' : maskId(profile.idNumber),
username: account?.username,
candidateNumber: account?.candidateNumber || '',
mustChangePassword: Boolean(account?.mustChangePassword),
accountArchived: Boolean(account?.archivedAt),
archivedAt: account?.archivedAt || null,
archivedByName: db.users.find(item => item.id === account?.archivedBy)?.displayName || '',
workflow: workflowView(db, instance)
};
}); });
return sendJson(response, 200, { ok: true, candidates, schools: user.adminLevel === 'super' ? db.schools.filter(item => item.active) : db.schools.filter(item => item.id === user.schoolId && item.active), classes: db.classes.filter(item => item.active && (user.adminLevel === 'super' || item.schoolId === user.schoolId)) }); return sendJson(response, 200, { ok: true, candidates, schools: user.adminLevel === 'super' ? db.schools.filter(item => item.active) : db.schools.filter(item => item.id === user.schoolId && item.active), classes: db.classes.filter(item => item.active && (user.adminLevel === 'super' || item.schoolId === user.schoolId)) });
} }
if (request.method === 'POST' && pathname === '/api/admin/candidate-accounts/archive') {
if (user.adminLevel !== 'school') return sendError(response, 403, '考生账户归档由校级管理员负责');
const body = await readJson(request);
const scopeType = cleanText(body.scopeType, 20);
const scopeValue = cleanText(body.scopeValue, 100);
const archived = Boolean(body.archived);
if (!['class', 'grade'].includes(scopeType) || !scopeValue) return sendError(response, 400, '请选择要归档的班级或年级');
const scopedClasses = db.classes.filter(item => item.schoolId === user.schoolId);
const targetClassIds = scopeType === 'class'
? scopedClasses.filter(item => item.id === scopeValue).map(item => item.id)
: scopedClasses.filter(item => item.grade === scopeValue).map(item => item.id);
if (!targetClassIds.length) return sendError(response, 404, '所选班级或年级不在本校范围内');
const targetIds = new Set(db.candidateProfiles.filter(profile => profile.schoolId === user.schoolId && targetClassIds.includes(profile.classId)).map(profile => profile.userId));
const targets = db.users.filter(item => item.role === 'candidate' && targetIds.has(item.id) && Boolean(item.archivedAt) !== archived);
const changedAt = archived ? nowIso() : null;
for (const target of targets) {
target.archivedAt = changedAt;
target.archivedBy = archived ? user.id : null;
}
const scopeLabel = scopeType === 'class'
? db.classes.find(item => item.id === scopeValue)?.name
: scopeValue;
await database.updateCandidateArchives(targets, logAction(db, user, archived ? '批量归档考生账户' : '批量恢复考生账户', `${scopeLabel} · ${targets.length} 个账户`));
if (archived && targets.length) {
const targetUserIds = new Set(targets.map(item => item.id));
for (const [token, session] of sessions) if (targetUserIds.has(session.userId)) sessions.delete(token);
}
return sendJson(response, 200, { ok: true, archived, count: targets.length, scopeLabel });
}
const candidatePasswordResetMatch = pathname.match(/^\/api\/admin\/candidates\/([^/]+)\/reset-password$/);
if (request.method === 'POST' && candidatePasswordResetMatch) {
if (user.adminLevel !== 'super') return sendError(response, 403, '只有超级管理员可以重置考生密码');
const profile = db.candidateProfiles.find(item => item.id === candidatePasswordResetMatch[1]);
const target = db.users.find(item => item.id === profile?.userId && item.role === 'candidate');
if (!profile || !target) return sendError(response, 404, '考生账户不存在');
if (target.archivedAt) return sendError(response, 409, '归档账户需由校方恢复后才能重置密码');
const temporaryPassword = `Reset-${randomBytes(7).toString('base64url')}`;
target.passwordHash = hashPassword(temporaryPassword);
target.mustChangePassword = true;
await database.changePassword(target, logAction(db, user, '重置考生密码', `${target.candidateNumber} · ${profile.name}`));
for (const [token, session] of sessions) if (session.userId === target.id) sessions.delete(token);
return sendJson(response, 200, { ok: true, candidateNumber: target.candidateNumber, temporaryPassword });
}
const candidateMatch = pathname.match(/^\/api\/admin\/candidates\/([^/]+)$/); const candidateMatch = pathname.match(/^\/api\/admin\/candidates\/([^/]+)$/);
if (request.method === 'PATCH' && candidateMatch) { if (request.method === 'PATCH' && candidateMatch) {
if (!requirePermission(user, response, 'candidates.review')) return true; if (!requirePermission(user, response, 'candidates.review')) return true;
+2 -2
View File
@@ -85,7 +85,7 @@ export function createAuthRoutes(context) {
const db = await readDb(); const db = await readDb();
const account = cleanText(body.username, 120).toLowerCase(); const account = cleanText(body.username, 120).toLowerCase();
const user = db.users.find(item => item.username.toLowerCase() === account || String(item.candidateNumber || '').toLowerCase() === account); const user = db.users.find(item => item.username.toLowerCase() === account || String(item.candidateNumber || '').toLowerCase() === account);
if (!user || user.active === false || !verifyPassword(String(body.password || ''), user.passwordHash)) return sendError(response, 401, '账号或密码不正确'); if (!user || user.active === false || user.archivedAt || !verifyPassword(String(body.password || ''), user.passwordHash)) return sendError(response, 401, '账号或密码不正确');
const token = randomBytes(32).toString('hex'); const token = randomBytes(32).toString('hex');
sessions.set(token, { userId: user.id, expiresAt: Date.now() + 8 * 60 * 60 * 1000 }); sessions.set(token, { userId: user.id, expiresAt: Date.now() + 8 * 60 * 60 * 1000 });
return sendJson(response, 200, { ok: true, user: safeUser(user) }, { 'Set-Cookie': `hz_session=${token}; Path=/; HttpOnly; SameSite=Strict; Max-Age=28800` }); return sendJson(response, 200, { ok: true, user: safeUser(user) }, { 'Set-Cookie': `hz_session=${token}; Path=/; HttpOnly; SameSite=Strict; Max-Age=28800` });
@@ -98,7 +98,7 @@ export function createAuthRoutes(context) {
const newPassword = String(body.newPassword || ''); const newPassword = String(body.newPassword || '');
if (!verifyPassword(currentPassword, user.passwordHash)) return sendError(response, 400, '当前密码不正确'); if (!verifyPassword(currentPassword, user.passwordHash)) return sendError(response, 400, '当前密码不正确');
if (newPassword.length < 8) return sendError(response, 400, '新密码至少需要 8 位'); if (newPassword.length < 8) return sendError(response, 400, '新密码至少需要 8 位');
if (newPassword === currentPassword) return sendError(response, 400, '新密码不能与初始密码相同'); if (newPassword === currentPassword) return sendError(response, 400, '新密码不能与当前密码相同');
user.passwordHash = hashPassword(newPassword); user.passwordHash = hashPassword(newPassword);
user.mustChangePassword = false; user.mustChangePassword = false;
const db = await readDb(); const db = await readDb();
+3 -2
View File
@@ -15,7 +15,7 @@ export function createSessionManager({ sessions, readDb, sendError }) {
} }
const db = await readDb(); const db = await readDb();
const user = db.users.find(item => item.id === session.userId) || null; const user = db.users.find(item => item.id === session.userId) || null;
return user?.active === false ? null : user; return user?.active === false || user?.archivedAt ? null : user;
} }
function safeUser(user) { function safeUser(user) {
@@ -28,7 +28,8 @@ export function createSessionManager({ sessions, readDb, sendError }) {
classId: user.classId || null, classId: user.classId || null,
displayName: user.displayName, displayName: user.displayName,
candidateNumber: user.candidateNumber || null, candidateNumber: user.candidateNumber || null,
mustChangePassword: Boolean(user.mustChangePassword) mustChangePassword: Boolean(user.mustChangePassword),
archived: Boolean(user.archivedAt)
}; };
} }
+13
View File
@@ -459,6 +459,19 @@ button:disabled { cursor: not-allowed; opacity: .5; }
.onboarding-page { min-height:100vh; display:grid; grid-template-columns:minmax(330px,36%) 1fr; background:#f3f5f9; }.onboarding-identity { position:sticky; top:0; min-height:100vh; display:flex; flex-direction:column; align-items:flex-start; padding:42px clamp(30px,5vw,72px); color:#fff; background:var(--navy); }.onboarding-identity > span { margin-top:82px; color:#8593ba; font-size:8px; letter-spacing:1px; }.onboarding-identity > strong { max-width:100%; margin:10px 0 15px; overflow-wrap:anywhere; font-family:Consolas,monospace; font-size:clamp(22px,3vw,36px); letter-spacing:1px; }.onboarding-identity > p { max-width:390px; margin:0; color:#abb6d2; font-size:10px; line-height:1.9; }.onboarding-identity > button { margin-top:auto; border:0; color:#98a6ca; background:transparent; font-size:8px; }.onboarding-steps { width:100%; display:grid; gap:0; margin-top:55px; }.onboarding-steps > div { position:relative; display:grid; grid-template-columns:34px 1fr; gap:12px; min-height:72px; opacity:.45; }.onboarding-steps > div:not(:last-child)::after { content:""; position:absolute; top:34px; bottom:0; left:16px; width:1px; background:#53638d; }.onboarding-steps > div.current,.onboarding-steps > div.done { opacity:1; }.onboarding-steps i { z-index:1; width:34px; height:34px; display:grid; place-items:center; border:1px solid #63739d; border-radius:50%; color:#c1cae0; background:var(--navy); font-size:9px; font-style:normal; }.onboarding-steps .current i { border-color:#fff; color:var(--navy); background:#fff; box-shadow:0 0 0 6px rgba(255,255,255,.08); }.onboarding-steps .done i { border-color:var(--jade); color:#fff; background:var(--jade); }.onboarding-steps span { display:grid; align-content:start; gap:4px; padding-top:3px; }.onboarding-steps b { font-size:10px; }.onboarding-steps small { color:#96a3c5; font-size:7px; }.onboarding-work { width:min(900px,100%); padding:55px clamp(24px,5vw,70px) 70px; }.onboarding-work-head { margin-bottom:24px; }.onboarding-work-head > span { color:#7d89a2; font-family:Consolas,monospace; font-size:8px; letter-spacing:1.5px; }.onboarding-work-head h1 { margin:8px 0; font-family:"STKaiti"; font-size:34px; font-weight:400; }.onboarding-work-head p { margin:0; color:#828b9d; font-size:9px; }.password-onboarding { max-width:560px; padding:23px; }.password-rule { display:grid; gap:4px; margin-bottom:18px; padding:12px; border-left:3px solid var(--blue); background:#eef3fb; }.password-rule b { font-size:9px; }.password-rule span { color:#6e7990; font-size:8px; }.password-onboarding form { display:grid; gap:14px; }.onboarding-profile .profile-form { padding-top:0; }.profile-form .wide-field { grid-column:span 2; } .onboarding-page { min-height:100vh; display:grid; grid-template-columns:minmax(330px,36%) 1fr; background:#f3f5f9; }.onboarding-identity { position:sticky; top:0; min-height:100vh; display:flex; flex-direction:column; align-items:flex-start; padding:42px clamp(30px,5vw,72px); color:#fff; background:var(--navy); }.onboarding-identity > span { margin-top:82px; color:#8593ba; font-size:8px; letter-spacing:1px; }.onboarding-identity > strong { max-width:100%; margin:10px 0 15px; overflow-wrap:anywhere; font-family:Consolas,monospace; font-size:clamp(22px,3vw,36px); letter-spacing:1px; }.onboarding-identity > p { max-width:390px; margin:0; color:#abb6d2; font-size:10px; line-height:1.9; }.onboarding-identity > button { margin-top:auto; border:0; color:#98a6ca; background:transparent; font-size:8px; }.onboarding-steps { width:100%; display:grid; gap:0; margin-top:55px; }.onboarding-steps > div { position:relative; display:grid; grid-template-columns:34px 1fr; gap:12px; min-height:72px; opacity:.45; }.onboarding-steps > div:not(:last-child)::after { content:""; position:absolute; top:34px; bottom:0; left:16px; width:1px; background:#53638d; }.onboarding-steps > div.current,.onboarding-steps > div.done { opacity:1; }.onboarding-steps i { z-index:1; width:34px; height:34px; display:grid; place-items:center; border:1px solid #63739d; border-radius:50%; color:#c1cae0; background:var(--navy); font-size:9px; font-style:normal; }.onboarding-steps .current i { border-color:#fff; color:var(--navy); background:#fff; box-shadow:0 0 0 6px rgba(255,255,255,.08); }.onboarding-steps .done i { border-color:var(--jade); color:#fff; background:var(--jade); }.onboarding-steps span { display:grid; align-content:start; gap:4px; padding-top:3px; }.onboarding-steps b { font-size:10px; }.onboarding-steps small { color:#96a3c5; font-size:7px; }.onboarding-work { width:min(900px,100%); padding:55px clamp(24px,5vw,70px) 70px; }.onboarding-work-head { margin-bottom:24px; }.onboarding-work-head > span { color:#7d89a2; font-family:Consolas,monospace; font-size:8px; letter-spacing:1.5px; }.onboarding-work-head h1 { margin:8px 0; font-family:"STKaiti"; font-size:34px; font-weight:400; }.onboarding-work-head p { margin:0; color:#828b9d; font-size:9px; }.password-onboarding { max-width:560px; padding:23px; }.password-rule { display:grid; gap:4px; margin-bottom:18px; padding:12px; border-left:3px solid var(--blue); background:#eef3fb; }.password-rule b { font-size:9px; }.password-rule span { color:#6e7990; font-size:8px; }.password-onboarding form { display:grid; gap:14px; }.onboarding-profile .profile-form { padding-top:0; }.profile-form .wide-field { grid-column:span 2; }
.registration-policy { display:flex; align-items:center; justify-content:space-between; gap:25px; margin-bottom:15px; padding:20px 22px; background:linear-gradient(110deg,#fff,#f1f5fc); }.registration-policy > div > span { color:#7483a3; font-family:Consolas,monospace; font-size:7px; letter-spacing:1px; }.registration-policy h2 { margin:5px 0; font-family:"STKaiti"; font-size:21px; font-weight:400; }.registration-policy p { margin:0; color:#848d9f; font-size:8px; }.registration-policy form { display:flex; align-items:center; gap:11px; }.policy-state { padding:6px 9px; border-radius:99px; color:#8b5f22; background:#fff0d4; font-size:8px; font-weight:700; }.policy-state.open { color:#247157; background:#e2f2ec; }.onboarding-badge { display:inline-flex; padding:5px 8px; border-radius:6px; color:#8a6229; background:#fff0d6; font-size:8px; font-weight:700; }.account-issue-note { display:flex; align-items:center; gap:10px; padding:11px 13px; border-radius:8px; color:#6d5733; background:#fff5e3; }.account-issue-note strong { font-size:8px; }.account-issue-note span { font-size:7px; }.account-number-principle { display:grid; grid-template-columns:auto 1fr; gap:4px 18px; margin-bottom:15px; padding:17px 20px; border-left:4px solid var(--blue); border-radius:10px; color:#fff; background:var(--navy); }.account-number-principle span { grid-row:1/3; align-self:center; color:#8290b5; font-family:Consolas,monospace; font-size:7px; writing-mode:vertical-rl; letter-spacing:1px; }.account-number-principle strong { font-family:"STKaiti"; font-size:20px; font-weight:400; }.account-number-principle p { margin:0; color:#aeb8d2; font-size:8px; } .registration-policy { display:flex; align-items:center; justify-content:space-between; gap:25px; margin-bottom:15px; padding:20px 22px; background:linear-gradient(110deg,#fff,#f1f5fc); }.registration-policy > div > span { color:#7483a3; font-family:Consolas,monospace; font-size:7px; letter-spacing:1px; }.registration-policy h2 { margin:5px 0; font-family:"STKaiti"; font-size:21px; font-weight:400; }.registration-policy p { margin:0; color:#848d9f; font-size:8px; }.registration-policy form { display:flex; align-items:center; gap:11px; }.policy-state { padding:6px 9px; border-radius:99px; color:#8b5f22; background:#fff0d4; font-size:8px; font-weight:700; }.policy-state.open { color:#247157; background:#e2f2ec; }.onboarding-badge { display:inline-flex; padding:5px 8px; border-radius:6px; color:#8a6229; background:#fff0d6; font-size:8px; font-weight:700; }.account-issue-note { display:flex; align-items:center; gap:10px; padding:11px 13px; border-radius:8px; color:#6d5733; background:#fff5e3; }.account-issue-note strong { font-size:8px; }.account-issue-note span { font-size:7px; }.account-number-principle { display:grid; grid-template-columns:auto 1fr; gap:4px 18px; margin-bottom:15px; padding:17px 20px; border-left:4px solid var(--blue); border-radius:10px; color:#fff; background:var(--navy); }.account-number-principle span { grid-row:1/3; align-self:center; color:#8290b5; font-family:Consolas,monospace; font-size:7px; writing-mode:vertical-rl; letter-spacing:1px; }.account-number-principle strong { font-family:"STKaiti"; font-size:20px; font-weight:400; }.account-number-principle p { margin:0; color:#aeb8d2; font-size:8px; }
.account-security-panel { max-width:820px; display:grid; grid-template-columns:minmax(0,.9fr) minmax(320px,1.1fr); gap:34px; padding:28px; }
.account-security-copy > span,.candidate-archive-console > div > span { color:#7483a3; font-family:Consolas,monospace; font-size:7px; letter-spacing:1px; }
.account-security-copy h2,.candidate-archive-console h2 { margin:7px 0; font-family:"STKaiti"; font-size:24px; font-weight:400; }
.account-security-copy p,.candidate-archive-console p { margin:0; color:#818b9e; font-size:8px; line-height:1.8; }
.account-security-copy dl { display:grid; gap:9px; margin:24px 0 0; }.account-security-copy dl div { display:grid; grid-template-columns:80px 1fr; gap:9px; padding:9px 0; border-top:1px solid var(--line); }
.account-security-copy dt { color:#929bad; font-size:7px; }.account-security-copy dd { margin:0; font-size:9px; }
.account-password-form { display:grid; gap:14px; padding:20px; border:1px solid var(--line); border-radius:10px; background:#f8fafd; }.account-password-form label { display:grid; gap:6px; }.account-password-form label span { color:#5e6980; font-size:8px; font-weight:700; }.account-password-form input { min-height:42px; padding:9px 11px; border:1px solid #dce2ed; border-radius:8px; background:#fff; }
.candidate-archive-console { display:flex; align-items:center; justify-content:space-between; gap:26px; margin-bottom:15px; padding:20px 22px; background:linear-gradient(110deg,#fff,#f3f6fb); }.archive-controls { min-width:420px; display:grid; grid-template-columns:minmax(180px,1fr) auto auto; gap:9px; }.archive-controls select { min-height:40px; padding:8px 10px; border:1px solid var(--line); border-radius:8px; background:#fff; font-size:9px; }.archive-button { background:#9f463f; }.account-archived-row { opacity:.7; background:#f5f6f8; }.candidate-account-actions { display:flex; flex-wrap:wrap; gap:6px; }
.reset-warning { padding:14px; border-left:3px solid var(--red); border-radius:8px; background:#fceceb; }.reset-warning strong { font-size:9px; }.reset-warning p { margin:5px 0 0; color:#875b58; font-size:8px; line-height:1.7; }
@media (max-width: 1000px) { .candidate-archive-console { align-items:flex-start; flex-direction:column; }.archive-controls { width:100%; min-width:0; } }
@media (max-width: 620px) { .account-security-panel { grid-template-columns:1fr; padding:18px; }.archive-controls { grid-template-columns:1fr; } }
@media (max-width: 1000px) { @media (max-width: 1000px) {
.number-rule-layout,.workflow-design-grid,.workflow-board,.center-grid { grid-template-columns:1fr; }.batch-number-panel form { grid-template-columns:1fr 1fr; }.room-editor-grid { grid-template-columns:repeat(3,1fr); }.onboarding-page { grid-template-columns:290px 1fr; }.onboarding-identity { padding:32px 28px; }.onboarding-work { padding:42px 28px 60px; } .number-rule-layout,.workflow-design-grid,.workflow-board,.center-grid { grid-template-columns:1fr; }.batch-number-panel form { grid-template-columns:1fr 1fr; }.room-editor-grid { grid-template-columns:repeat(3,1fr); }.onboarding-page { grid-template-columns:290px 1fr; }.onboarding-identity { padding:32px 28px; }.onboarding-work { padding:42px 28px 60px; }
} }
+28 -1
View File
@@ -95,10 +95,12 @@ try {
const admitCardColumns = inspector.prepare('PRAGMA table_info(admit_cards)').all().map(row => row.name); const admitCardColumns = inspector.prepare('PRAGMA table_info(admit_cards)').all().map(row => row.name);
const admitSubjectColumns = inspector.prepare('PRAGMA table_info(admit_card_subjects)').all().map(row => row.name); const admitSubjectColumns = inspector.prepare('PRAGMA table_info(admit_card_subjects)').all().map(row => row.name);
const examSubjectColumns = inspector.prepare('PRAGMA table_info(exam_subjects)').all().map(row => row.name); const examSubjectColumns = inspector.prepare('PRAGMA table_info(exam_subjects)').all().map(row => row.name);
const userColumns = inspector.prepare('PRAGMA table_info(users)').all().map(row => row.name);
inspector.close(); inspector.close();
assert.deepEqual(tableNames, [...relationalTables].sort(), '业务数据必须按关系模型分表存储'); assert.deepEqual(tableNames, [...relationalTables].sort(), '业务数据必须按关系模型分表存储');
assert.ok(!tableNames.includes('app_state'), '不得使用单表 JSON 状态存储'); assert.ok(!tableNames.includes('app_state'), '不得使用单表 JSON 状态存储');
assert.equal(schemaVersion, 12, '排名比例、排名等级与复议改分应使用 v12 数据结构'); assert.equal(schemaVersion, 13, '账户归档应使用 v13 数据结构');
assert.ok(['archived_at', 'archived_by'].every(column => userColumns.includes(column)), '账户应保存独立归档状态和校方操作人');
assert.ok(['center_code', 'center_address'].every(column => admitCardColumns.includes(column)), '准考证应保存考点代码与详细地址快照'); assert.ok(['center_code', 'center_address'].every(column => admitCardColumns.includes(column)), '准考证应保存考点代码与详细地址快照');
assert.ok(['building', 'floor'].every(column => admitSubjectColumns.includes(column)), '分科考场应保存楼栋楼层快照'); assert.ok(['building', 'floor'].every(column => admitSubjectColumns.includes(column)), '分科考场应保存楼栋楼层快照');
assert.ok(['pass_rule', 'pass_value'].every(column => examSubjectColumns.includes(column)), '科目应保存独立及格线计算方式和规则数值'); assert.ok(['pass_rule', 'pass_value'].every(column => examSubjectColumns.includes(column)), '科目应保存独立及格线计算方式和规则数值');
@@ -122,6 +124,8 @@ try {
assert.equal((await schoolAdmin2.request('/api/auth/login', { method: 'POST', body: { username: 'school_admin_2', password: 'School123!' } })).data.user.adminLevel, 'school'); assert.equal((await schoolAdmin2.request('/api/auth/login', { method: 'POST', body: { username: 'school_admin_2', password: 'School123!' } })).data.user.adminLevel, 'school');
assert.equal((await classAdmin.request('/api/auth/login', { method: 'POST', body: { username: 'class_admin', password: 'Class123!' } })).data.user.adminLevel, 'class'); assert.equal((await classAdmin.request('/api/auth/login', { method: 'POST', body: { username: 'class_admin', password: 'Class123!' } })).data.user.adminLevel, 'class');
assert.equal((await classAdmin2.request('/api/auth/login', { method: 'POST', body: { username: 'class_admin_2', password: 'Class123!' } })).data.user.adminLevel, 'class'); assert.equal((await classAdmin2.request('/api/auth/login', { method: 'POST', body: { username: 'class_admin_2', password: 'Class123!' } })).data.user.adminLevel, 'class');
assert.equal((await classAdmin2.request('/api/auth/change-password', { method: 'POST', body: { currentPassword: 'Class123!', newPassword: 'ClassChanged123!' } })).response.status, 200, '管理员登录后应可修改自己的密码');
assert.equal((await createClient().request('/api/auth/login', { method: 'POST', body: { username: 'class_admin_2', password: 'Class123!' } })).response.status, 401, '管理员改密后旧密码应立即失效');
assert.equal((await admin.request('/api/admin/candidate-accounts', { method: 'POST', body: {} })).response.status, 404, '超级管理员不得再从旧入口直接生成报名号'); assert.equal((await admin.request('/api/admin/candidate-accounts', { method: 'POST', body: {} })).response.status, 404, '超级管理员不得再从旧入口直接生成报名号');
const singleAccountBatch = await schoolAdmin.request('/api/admin/candidate-account-batches', { method: 'POST', body: { quotas: [ const singleAccountBatch = await schoolAdmin.request('/api/admin/candidate-account-batches', { method: 'POST', body: { quotas: [
@@ -284,6 +288,29 @@ try {
const firstIssued = returnedBatch.items[0]; const firstIssued = returnedBatch.items[0];
const batchLogin = await batchCandidate.request('/api/auth/login', { method: 'POST', body: { username: firstIssued.candidateNumber, password: firstIssued.initialPassword } }); const batchLogin = await batchCandidate.request('/api/auth/login', { method: 'POST', body: { username: firstIssued.candidateNumber, password: firstIssued.initialPassword } });
assert.equal(batchLogin.data.user.mustChangePassword, true, '批量下发账户首次登录也必须强制修改密码'); assert.equal(batchLogin.data.user.mustChangePassword, true, '批量下发账户首次登录也必须强制修改密码');
const archiveTarget = returnedBatch.items.find(item => item.classId === 'class_hz1_301');
const archiveCandidate = createClient();
assert.equal((await archiveCandidate.request('/api/auth/login', { method: 'POST', body: { username: archiveTarget.candidateNumber, password: archiveTarget.initialPassword } })).response.status, 200);
assert.equal((await admin.request('/api/admin/candidate-accounts/archive', { method: 'POST', body: { scopeType: 'class', scopeValue: 'class_hz1_301', archived: true } })).response.status, 403, '超级管理员不得代替学校归档考生账户');
assert.equal((await classAdmin.request('/api/admin/candidate-accounts/archive', { method: 'POST', body: { scopeType: 'class', scopeValue: 'class_hz1_301', archived: true } })).response.status, 403, '班级管理员不得批量归档考生账户');
const archivedAccounts = await schoolAdmin.request('/api/admin/candidate-accounts/archive', { method: 'POST', body: { scopeType: 'class', scopeValue: 'class_hz1_301', archived: true } });
assert.equal(archivedAccounts.response.status, 200, '校级管理员应可按班级归档考生账户');
assert.ok(archivedAccounts.data.count >= 2, '按班级归档应批量更新该班考生');
const archivedDirectory = await schoolAdmin.request('/api/admin/candidates');
const archivedProfile = archivedDirectory.data.candidates.find(item => item.candidateNumber === archiveTarget.candidateNumber);
assert.equal(archivedProfile.accountArchived, true, '考生目录应显示归档状态');
assert.equal((await archiveCandidate.request('/api/auth/me')).data.user, null, '归档应立即使考生现有会话失效');
assert.equal((await createClient().request('/api/auth/login', { method: 'POST', body: { username: archiveTarget.candidateNumber, password: archiveTarget.initialPassword } })).response.status, 401, '归档账户不得登录');
assert.equal((await admin.request(`/api/admin/candidates/${archivedProfile.id}/reset-password`, { method: 'POST' })).response.status, 409, '归档账户恢复前不得重置密码');
const restoredAccounts = await schoolAdmin.request('/api/admin/candidate-accounts/archive', { method: 'POST', body: { scopeType: 'grade', scopeValue: '高三', archived: false } });
assert.equal(restoredAccounts.response.status, 200, '校级管理员应可按年级恢复考生账户');
const resetPassword = await admin.request(`/api/admin/candidates/${archivedProfile.id}/reset-password`, { method: 'POST' });
assert.equal(resetPassword.response.status, 200, '超级管理员应可为未归档考生重置密码');
assert.match(resetPassword.data.temporaryPassword, /^Reset-/, '重置服务应返回一次性临时密码');
assert.equal((await createClient().request('/api/auth/login', { method: 'POST', body: { username: archiveTarget.candidateNumber, password: archiveTarget.initialPassword } })).response.status, 401, '重置后原密码应失效');
const resetLogin = await createClient().request('/api/auth/login', { method: 'POST', body: { username: archiveTarget.candidateNumber, password: resetPassword.data.temporaryPassword } });
assert.equal(resetLogin.data.user.mustChangePassword, true, '使用重置临时密码登录后必须先改密');
assert.equal((await schoolAdmin.request(`/api/admin/candidates/${archivedProfile.id}`, { method: 'DELETE' })).response.status, 405, '任何管理员都不得删除考生账户');
const now = Date.now(); const now = Date.now();
const hour = 60 * 60 * 1000; const hour = 60 * 60 * 1000;