Add candidate account archiving and password reset
This commit is contained in:
@@ -37,8 +37,8 @@ function renderError(error) {
|
||||
|
||||
const baseViewContext = { state, app, h, formatDate, dateRange, badge, money, passPolicyText, statusLabels, icons, api, renderError, emptyState };
|
||||
const { brand, renderHome, renderAuth } = createPublicViews(baseViewContext);
|
||||
const { adminNavForUser, portalShell, loadingPanel, renderCandidate } = createCandidateViews({ ...baseViewContext, brand });
|
||||
const { renderAdmin, workflowStepEditor } = createAdminViews({ ...baseViewContext, brand, portalShell, loadingPanel, adminNavForUser });
|
||||
const { adminNavForUser, portalShell, loadingPanel, renderCandidate, accountSecurity } = createCandidateViews({ ...baseViewContext, brand });
|
||||
const { renderAdmin, workflowStepEditor } = createAdminViews({ ...baseViewContext, brand, portalShell, loadingPanel, adminNavForUser, accountSecurity });
|
||||
|
||||
function navigate(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 === '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 === 'excel-download') {
|
||||
const query = new URLSearchParams();
|
||||
@@ -352,6 +361,15 @@ document.addEventListener('submit', async event => {
|
||||
if (body.newPassword !== body.confirmPassword) throw new Error('两次输入的新密码不一致');
|
||||
await api('/api/auth/change-password', { method: 'POST', body });
|
||||
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') {
|
||||
const data = await api('/api/candidate/profile', { method: 'PUT', body: formObject(form) });
|
||||
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>'}`);
|
||||
}
|
||||
|
||||
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) {
|
||||
const reg = state.pageData.registrations.find(item => item.id === id);
|
||||
const canReview = reg.status === 'pending' && reg.workflow?.assignee?.id === state.user.id;
|
||||
|
||||
+22
-10
@@ -57,7 +57,7 @@ function buildSeedOperations(state) {
|
||||
const nullable = value => value == null || value === '' ? null : value;
|
||||
|
||||
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,
|
||||
state.meta?.createdAt || new Date().toISOString()
|
||||
);
|
||||
@@ -84,10 +84,11 @@ function buildSeedOperations(state) {
|
||||
add(
|
||||
`INSERT INTO users (
|
||||
id, username, candidate_number, password_hash, role, admin_level, school_id, class_id, active,
|
||||
must_change_password, display_name, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
must_change_password, archived_at, archived_by, display_name, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
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,
|
||||
active: row.active == null ? true : Boolean(row.active),
|
||||
mustChangePassword: Boolean(row.must_change_password),
|
||||
archivedAt: row.archived_at || null,
|
||||
archivedBy: row.archived_by || null,
|
||||
displayName: row.display_name,
|
||||
createdAt: row.created_at
|
||||
})),
|
||||
@@ -854,10 +857,11 @@ function createRepository({ client, location, read, transaction, close }) {
|
||||
operation(
|
||||
`INSERT INTO users (
|
||||
id, username, candidate_number, password_hash, role, admin_level, school_id, class_id, active,
|
||||
must_change_password, display_name, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
must_change_password, archived_at, archived_by, display_name, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
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(
|
||||
`INSERT INTO candidate_profiles (
|
||||
@@ -933,6 +937,14 @@ function createRepository({ client, location, read, transaction, close }) {
|
||||
if (log) operations.push(auditOperation(log));
|
||||
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) {
|
||||
await transaction([
|
||||
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(
|
||||
`INSERT INTO users (
|
||||
id, username, candidate_number, password_hash, role, admin_level, school_id, class_id, active,
|
||||
must_change_password, display_name, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
must_change_password, archived_at, archived_by, display_name, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
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(
|
||||
`INSERT INTO candidate_profiles (
|
||||
|
||||
@@ -18,7 +18,8 @@ export function createAdminViews(context) {
|
||||
brand,
|
||||
portalShell,
|
||||
loadingPanel,
|
||||
adminNavForUser
|
||||
adminNavForUser,
|
||||
accountSecurity
|
||||
} = context;
|
||||
|
||||
async function renderAdmin(page) {
|
||||
@@ -34,20 +35,21 @@ export function createAdminViews(context) {
|
||||
'account-batches': ['批量报名号申领', '按班级填写申领人数;审批通过后系统生成固定报名号和初始密码。'],
|
||||
flows: [state.user.adminLevel === 'super' ? '流程监督' : '流程中心', state.user.adminLevel === 'super' ? '查看全部流程,监督转交、修改和退回节点。' : '处理分配给你的流程,并可转交给本校同级管理员。'],
|
||||
'flow-design': ['流程设计', '配置考生信息、报名审核、考点考场变更与批量建号的审批步骤。'],
|
||||
'number-rules': ['报名号规则', '设计审批通过后生成的新账户号码组成。']
|
||||
'number-rules': ['报名号规则', '设计审批通过后生成的新账户号码组成。'],
|
||||
security: ['账户安全', '使用当前密码设置新的登录密码。']
|
||||
};
|
||||
const allowedPages = adminNavForUser().map(item => item[0]);
|
||||
if (!meta[page] || !allowedPages.includes(page)) page = 'dashboard';
|
||||
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 = await api(`/api/admin/${endpoint}`);
|
||||
const data = page === 'security' ? {} : await api(`/api/admin/${endpoint}`);
|
||||
state.pageData = data;
|
||||
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),
|
||||
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]();
|
||||
app.innerHTML = portalShell('admin', page, content, ...meta[page]);
|
||||
} 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>`;
|
||||
}
|
||||
|
||||
function adminCandidates(candidates) {
|
||||
function adminCandidates(data) {
|
||||
const candidates = data.candidates || [];
|
||||
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) {
|
||||
|
||||
@@ -20,15 +20,17 @@ export function createCandidateViews(context) {
|
||||
|
||||
const candidateNav = [
|
||||
['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() {
|
||||
const level = state.user?.adminLevel || 'super';
|
||||
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']];
|
||||
if (level === 'school') return [core[0], ['organization', '本校组织', 'users'], ['account-batches', '批量建号', 'ticket'], core[1], core[2], ['admit', '校内准考证', 'ticket'], ...operations, 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]];
|
||||
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], security];
|
||||
}
|
||||
|
||||
function portalShell(role, page, content, title, description) {
|
||||
@@ -83,19 +85,20 @@ export function createCandidateViews(context) {
|
||||
registrations: ['我的报名', '查看已提交的考试、科目与审核进度。'],
|
||||
admit: ['准考证', '管理员生成后,可在规定下载时间内保存准考证。'],
|
||||
results: ['成绩查询', '仅显示考试中心已经正式发布的成绩。'],
|
||||
notices: ['通知公告', '查看与报名、考试和成绩相关的最新消息。']
|
||||
notices: ['通知公告', '查看与报名、考试和成绩相关的最新消息。'],
|
||||
security: ['账户安全', '使用当前密码设置新的登录密码。']
|
||||
};
|
||||
if (!meta[page]) page = 'dashboard';
|
||||
app.innerHTML = portalShell('candidate', page, loadingPanel(), ...meta[page]);
|
||||
try {
|
||||
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;
|
||||
if (data.profile) state.profile = data.profile;
|
||||
const content = {
|
||||
dashboard: () => candidateDashboard(data), profile: () => candidateProfile(data), exams: () => candidateExams(data),
|
||||
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]();
|
||||
app.innerHTML = portalShell('candidate', page, content, ...meta[page]);
|
||||
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 { 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 };
|
||||
}
|
||||
|
||||
@@ -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 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 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 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',
|
||||
@@ -148,6 +150,11 @@ export function createMysqlAdapter(context) {
|
||||
metadataRows[0].schema_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) {
|
||||
const extension = seed();
|
||||
const connection = await pool.getConnection();
|
||||
@@ -319,8 +326,8 @@ export function createMysqlAdapter(context) {
|
||||
if (Number(metadataRows[0]?.app_version || 1) < 7) {
|
||||
await pool.execute('UPDATE schema_metadata SET schema_version = 7, app_version = 7 WHERE id = 1');
|
||||
}
|
||||
if (Number(metadataRows[0]?.schema_version || 1) < 12) {
|
||||
await pool.execute('UPDATE schema_metadata SET schema_version = 12, app_version = 12 WHERE id = 1');
|
||||
if (Number(metadataRows[0]?.schema_version || 1) < 13) {
|
||||
await pool.execute('UPDATE schema_metadata SET schema_version = 13, app_version = 13 WHERE id = 1');
|
||||
}
|
||||
}
|
||||
if (!existing.length) {
|
||||
@@ -330,7 +337,7 @@ export function createMysqlAdapter(context) {
|
||||
await connection.beginTransaction();
|
||||
const [insert] = await connection.execute(`
|
||||
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()]);
|
||||
if (insert.affectedRows === 1) {
|
||||
for (const item of buildSeedOperations(initialState)) await connection.execute(item.sql, item.params);
|
||||
|
||||
@@ -45,6 +45,8 @@ export const sqliteSchema = `
|
||||
class_id TEXT REFERENCES school_classes(id) ON DELETE SET NULL,
|
||||
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)),
|
||||
archived_at TEXT,
|
||||
archived_by TEXT REFERENCES users(id) ON DELETE RESTRICT,
|
||||
display_name TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
) STRICT;
|
||||
@@ -398,6 +400,7 @@ export const sqliteSchema = `
|
||||
) STRICT;
|
||||
|
||||
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_notices_status_publish ON notices(status, publish_at);
|
||||
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,
|
||||
active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
must_change_password BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
archived_at VARCHAR(35) NULL,
|
||||
archived_by VARCHAR(64) NULL,
|
||||
display_name VARCHAR(100) NOT NULL,
|
||||
created_at VARCHAR(35) NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uq_users_username (username),
|
||||
UNIQUE KEY uq_users_candidate_number (candidate_number),
|
||||
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_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`,
|
||||
`CREATE TABLE IF NOT EXISTS candidate_profiles (
|
||||
id VARCHAR(64) NOT NULL,
|
||||
|
||||
@@ -29,7 +29,8 @@ export function createSqliteAdapter(context) {
|
||||
};
|
||||
ensureColumns('users', [
|
||||
['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('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 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) {
|
||||
const extension = seed();
|
||||
connection.exec('BEGIN IMMEDIATE');
|
||||
@@ -382,8 +386,8 @@ export function createSqliteAdapter(context) {
|
||||
if (existingSystem && Number(existingSystem.app_version || 1) < 7) {
|
||||
connection.prepare('UPDATE schema_metadata SET schema_version = 7, app_version = 7 WHERE id = 1').run();
|
||||
}
|
||||
if (existingSystem && Number(existingSystem.schema_version || 1) < 12) {
|
||||
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 (!connection.prepare('SELECT id FROM schema_metadata WHERE id = 1').get()) {
|
||||
@@ -392,7 +396,7 @@ export function createSqliteAdapter(context) {
|
||||
try {
|
||||
connection.prepare(`
|
||||
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());
|
||||
for (const item of buildSeedOperations(initialState)) connection.prepare(item.sql).run(...item.params);
|
||||
connection.exec('COMMIT');
|
||||
|
||||
@@ -111,6 +111,9 @@ export function createAdminRoutes(context) {
|
||||
const user = await requireUser(request, response, 'admin');
|
||||
if (!user) return true;
|
||||
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') {
|
||||
return sendJson(response, 200, {
|
||||
@@ -579,10 +582,63 @@ 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);
|
||||
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)) });
|
||||
}
|
||||
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\/([^/]+)$/);
|
||||
if (request.method === 'PATCH' && candidateMatch) {
|
||||
if (!requirePermission(user, response, 'candidates.review')) return true;
|
||||
|
||||
@@ -85,7 +85,7 @@ export function createAuthRoutes(context) {
|
||||
const db = await readDb();
|
||||
const account = cleanText(body.username, 120).toLowerCase();
|
||||
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');
|
||||
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` });
|
||||
@@ -98,7 +98,7 @@ export function createAuthRoutes(context) {
|
||||
const newPassword = String(body.newPassword || '');
|
||||
if (!verifyPassword(currentPassword, user.passwordHash)) return sendError(response, 400, '当前密码不正确');
|
||||
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.mustChangePassword = false;
|
||||
const db = await readDb();
|
||||
|
||||
@@ -15,7 +15,7 @@ export function createSessionManager({ sessions, readDb, sendError }) {
|
||||
}
|
||||
const db = await readDb();
|
||||
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) {
|
||||
@@ -28,7 +28,8 @@ export function createSessionManager({ sessions, readDb, sendError }) {
|
||||
classId: user.classId || null,
|
||||
displayName: user.displayName,
|
||||
candidateNumber: user.candidateNumber || null,
|
||||
mustChangePassword: Boolean(user.mustChangePassword)
|
||||
mustChangePassword: Boolean(user.mustChangePassword),
|
||||
archived: Boolean(user.archivedAt)
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+13
@@ -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; }
|
||||
.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) {
|
||||
.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
@@ -95,10 +95,12 @@ try {
|
||||
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 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();
|
||||
assert.deepEqual(tableNames, [...relationalTables].sort(), '业务数据必须按关系模型分表存储');
|
||||
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(['building', 'floor'].every(column => admitSubjectColumns.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 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/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, '超级管理员不得再从旧入口直接生成报名号');
|
||||
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 batchLogin = await batchCandidate.request('/api/auth/login', { method: 'POST', body: { username: firstIssued.candidateNumber, password: firstIssued.initialPassword } });
|
||||
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 hour = 60 * 60 * 1000;
|
||||
|
||||
Reference in New Issue
Block a user