Add candidate account archiving and password reset
This commit is contained in:
1 parent
9f850fa045
commit
7023493f08
12 files changed
+207
-41
No files matched your search
@@ -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) {
|
||||
|
||||
Loaded 3 of 12 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user