Add hierarchical region data to candidate and center records
This commit is contained in:
@@ -76,7 +76,9 @@ npm start
|
|||||||
|
|
||||||
打开 <http://127.0.0.1:4173>。
|
打开 <http://127.0.0.1:4173>。
|
||||||
|
|
||||||
本地开发无需额外配置,首次运行会自动创建 `data/exam.sqlite` 和完整关系型数据库结构。当前处于开发阶段,不兼容旧版 JSON/单表数据库;已有关系型数据库会在启动时自动升级到 v6,补充分级权限、固定考生报名号、首次改密、注册开关、完整资料、学校组织管理、结构化考场、座位编排说明、批量建号申请与审批结果结构。
|
本地开发无需额外配置,首次运行会自动创建 `data/exam.sqlite` 和完整关系型数据库结构。当前处于开发阶段,不兼容旧版 JSON/单表数据库;已有关系型数据库会在启动时自动升级到 v7,补充分级权限、固定考生报名号、首次改密、注册开关、完整资料、学校组织管理、结构化考场、座位编排说明、批量建号申请与审批结果,以及考生/考点省市区县代码结构。
|
||||||
|
|
||||||
|
需要清空并重建本地演示库时运行 `npm run reset-db`。省市区县下拉数据位于 `src/data/china-regions.mjs`,当前版本为国家地名信息库截至 2025-12-31 的三级快照,并补入和康县(653228)与和安县(653229);从新版 CSV 更新时可运行 `node scripts/build-regions.mjs <CSV路径> src/data/china-regions.mjs`。
|
||||||
|
|
||||||
## 数据库配置
|
## 数据库配置
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { createCandidateViews } from './src/client/candidate-views.mjs';
|
|||||||
import { createPublicViews } from './src/client/public-views.mjs';
|
import { createPublicViews } from './src/client/public-views.mjs';
|
||||||
import { state } from './src/client/state.mjs';
|
import { state } from './src/client/state.mjs';
|
||||||
import { badge, dateRange, formatDate, h, icons, money, passPolicyText, statusLabels } from './src/client/ui.mjs';
|
import { badge, dateRange, formatDate, h, icons, money, passPolicyText, statusLabels } from './src/client/ui.mjs';
|
||||||
|
import { formatRegionAddress, mountRegionSelects, updateRegionSelects } from './src/client/region-select.mjs';
|
||||||
|
|
||||||
const app = document.querySelector('#app');
|
const app = document.querySelector('#app');
|
||||||
const modalRoot = document.querySelector('#modalRoot');
|
const modalRoot = document.querySelector('#modalRoot');
|
||||||
@@ -247,6 +248,7 @@ document.addEventListener('change', event => {
|
|||||||
classSelect.innerHTML = `<option value="">请选择班级</option>${classes.filter(item => item.schoolId === event.target.value).map(item => `<option value="${h(item.id)}">${h(item.name)}</option>`).join('')}`;
|
classSelect.innerHTML = `<option value="">请选择班级</option>${classes.filter(item => item.schoolId === event.target.value).map(item => `<option value="${h(item.id)}">${h(item.name)}</option>`).join('')}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (event.target.matches('[data-region-level]')) updateRegionSelects(event.target);
|
||||||
if (event.target.matches('[data-action="admin-level"]')) {
|
if (event.target.matches('[data-action="admin-level"]')) {
|
||||||
const form = event.target.closest('form');
|
const form = event.target.closest('form');
|
||||||
form?.querySelector('[data-admin-school]')?.classList.toggle('hidden', event.target.value === 'super');
|
form?.querySelector('[data-admin-school]')?.classList.toggle('hidden', event.target.value === 'super');
|
||||||
@@ -437,6 +439,7 @@ function openCenterForm(center = null) {
|
|||||||
const schools = state.pageData.schools || [];
|
const schools = state.pageData.schools || [];
|
||||||
const rooms = center?.rooms?.length ? center.rooms : [{}];
|
const rooms = center?.rooms?.length ? center.rooms : [{}];
|
||||||
setModal(`<div class="modal-head"><div><span>CONTROLLED DOSSIER</span><h2>${center ? '提交考点档案变更' : '提交新考点档案'}</h2><p>表单将进入“考点考场变更审批”,通过前不会改动正式数据。</p></div><button data-action="close-modal">×</button></div><form class="modal-form center-dossier-form" data-form="center-form">${center ? `<input type="hidden" name="id" value="${h(center.id)}">` : ''}<section class="center-form-section"><h3>考点基本档案</h3>${state.user.adminLevel === 'super' ? `<label><span>所属学校 *</span><select name="schoolId" required>${schools.map(item => `<option value="${h(item.id)}" ${center?.schoolId === item.id ? 'selected' : ''}>${h(item.name)}</option>`).join('')}</select></label>` : ''}<div class="field-row"><label><span>考点代码 *</span><input name="code" required value="${h(center?.code || '')}" placeholder="如 HZ01-C01"></label><label><span>考点名称 *</span><input name="name" required value="${h(center?.name || '')}"></label></div><label><span>详细地址 *</span><input name="address" required value="${h(center?.address || '')}"></label><div class="field-row"><label><span>考点负责人</span><input name="managerName" value="${h(center?.managerName || '')}"></label><label><span>负责人手机</span><input name="managerPhone" value="${h(center?.managerPhone || '')}"></label></div><div class="field-row"><label><span>值班电话</span><input name="contact" value="${h(center?.contact || '')}"></label><label><span>应急电话</span><input name="emergencyPhone" value="${h(center?.emergencyPhone || '')}"></label></div><div class="field-row"><label><span>开放时间</span><input name="gateOpenTime" type="time" value="${h(center?.gateOpenTime || '')}"></label><label><span>档案状态</span><select name="status"><option value="active" ${center?.status !== 'inactive' ? 'selected' : ''}>启用</option><option value="inactive" ${center?.status === 'inactive' ? 'selected' : ''}>停用</option></select></label></div><label><span>交通与入场提示</span><textarea name="transport" rows="3">${h(center?.transport || '')}</textarea></label><label><span>考务备注</span><textarea name="notes" rows="2">${h(center?.notes || '')}</textarea></label></section><section class="center-form-section rooms-section"><header><div><h3>考场明细</h3><p>每个考场单独维护位置、容量、座位编排说明与类型。</p></div><button type="button" class="row-action" data-action="add-center-room">添加考场</button></header><div data-center-rooms>${rooms.map(room => centerRoomEditor(room)).join('')}</div></section><div class="approval-callout"><strong>提交即进入审批</strong><span>审批通过后,正式考点档案和全部考场明细会作为一个版本整体生效。</span></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>`);
|
setModal(`<div class="modal-head"><div><span>CONTROLLED DOSSIER</span><h2>${center ? '提交考点档案变更' : '提交新考点档案'}</h2><p>表单将进入“考点考场变更审批”,通过前不会改动正式数据。</p></div><button data-action="close-modal">×</button></div><form class="modal-form center-dossier-form" data-form="center-form">${center ? `<input type="hidden" name="id" value="${h(center.id)}">` : ''}<section class="center-form-section"><h3>考点基本档案</h3>${state.user.adminLevel === 'super' ? `<label><span>所属学校 *</span><select name="schoolId" required>${schools.map(item => `<option value="${h(item.id)}" ${center?.schoolId === item.id ? 'selected' : ''}>${h(item.name)}</option>`).join('')}</select></label>` : ''}<div class="field-row"><label><span>考点代码 *</span><input name="code" required value="${h(center?.code || '')}" placeholder="如 HZ01-C01"></label><label><span>考点名称 *</span><input name="name" required value="${h(center?.name || '')}"></label></div><label><span>详细地址 *</span><input name="address" required value="${h(center?.address || '')}"></label><div class="field-row"><label><span>考点负责人</span><input name="managerName" value="${h(center?.managerName || '')}"></label><label><span>负责人手机</span><input name="managerPhone" value="${h(center?.managerPhone || '')}"></label></div><div class="field-row"><label><span>值班电话</span><input name="contact" value="${h(center?.contact || '')}"></label><label><span>应急电话</span><input name="emergencyPhone" value="${h(center?.emergencyPhone || '')}"></label></div><div class="field-row"><label><span>开放时间</span><input name="gateOpenTime" type="time" value="${h(center?.gateOpenTime || '')}"></label><label><span>档案状态</span><select name="status"><option value="active" ${center?.status !== 'inactive' ? 'selected' : ''}>启用</option><option value="inactive" ${center?.status === 'inactive' ? 'selected' : ''}>停用</option></select></label></div><label><span>交通与入场提示</span><textarea name="transport" rows="3">${h(center?.transport || '')}</textarea></label><label><span>考务备注</span><textarea name="notes" rows="2">${h(center?.notes || '')}</textarea></label></section><section class="center-form-section rooms-section"><header><div><h3>考场明细</h3><p>每个考场单独维护位置、容量、座位编排说明与类型。</p></div><button type="button" class="row-action" data-action="add-center-room">添加考场</button></header><div data-center-rooms>${rooms.map(room => centerRoomEditor(room)).join('')}</div></section><div class="approval-callout"><strong>提交即进入审批</strong><span>审批通过后,正式考点档案和全部考场明细会作为一个版本整体生效。</span></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>`);
|
||||||
|
mountRegionSelects(modalRoot, center);
|
||||||
}
|
}
|
||||||
|
|
||||||
function openFlowDetail(id) {
|
function openFlowDetail(id) {
|
||||||
@@ -450,7 +453,7 @@ function openFlowDetail(id) {
|
|||||||
const finalBatchStep = isBatch && instance.currentStep >= instance.steps.length;
|
const finalBatchStep = isBatch && instance.currentStep >= instance.steps.length;
|
||||||
const subject = isCenter ? instance.centerName : isBatch ? `${instance.schoolName} · ${instance.batchTotalCount} 个报名号` : instance.candidateName;
|
const subject = isCenter ? instance.centerName : isBatch ? `${instance.schoolName} · ${instance.batchTotalCount} 个报名号` : instance.candidateName;
|
||||||
const subjectDetail = isCenter ? `${instance.requestType === 'create' ? '新增考点' : '修改档案'} · ${instance.schoolName}` : isBatch ? '按班级批量申领 · 批准后生成账号' : `${instance.examName ? `${instance.examName} · ` : ''}${instance.schoolName}`;
|
const subjectDetail = isCenter ? `${instance.requestType === 'create' ? '新增考点' : '修改档案'} · ${instance.schoolName}` : isBatch ? '按班级批量申领 · 批准后生成账号' : `${instance.examName ? `${instance.examName} · ` : ''}${instance.schoolName}`;
|
||||||
const change = instance.centerChange;
|
const change = instance.centerChange ? { ...instance.centerChange, address: formatRegionAddress(instance.centerChange) } : null;
|
||||||
const changeSnapshot = change ? `<section class="flow-center-snapshot"><header><div><span>申请快照</span><h3>${h(change.name)} · ${h(change.code)}</h3></div><b>${change.rooms.length} 个考场</b></header><dl><div><dt>地址</dt><dd>${h(change.address)}</dd></div><div><dt>负责人</dt><dd>${h(change.managerName || '未填写')} · ${h(change.managerPhone || '未填写')}</dd></div><div><dt>开放时间</dt><dd>${h(change.gateOpenTime || '未填写')}</dd></div><div><dt>档案状态</dt><dd>${change.centerStatus === 'active' ? '启用' : '停用'}</dd></div></dl><div>${change.rooms.map(room => `<span><strong>${h(room.name)}</strong><small>${h(room.building)} · ${h(room.capacity)} 席 · ${h(room.seatPlan || '按现场座次表编排')}</small></span>`).join('')}</div></section>` : '';
|
const changeSnapshot = change ? `<section class="flow-center-snapshot"><header><div><span>申请快照</span><h3>${h(change.name)} · ${h(change.code)}</h3></div><b>${change.rooms.length} 个考场</b></header><dl><div><dt>地址</dt><dd>${h(change.address)}</dd></div><div><dt>负责人</dt><dd>${h(change.managerName || '未填写')} · ${h(change.managerPhone || '未填写')}</dd></div><div><dt>开放时间</dt><dd>${h(change.gateOpenTime || '未填写')}</dd></div><div><dt>档案状态</dt><dd>${change.centerStatus === 'active' ? '启用' : '停用'}</dd></div></dl><div>${change.rooms.map(room => `<span><strong>${h(room.name)}</strong><small>${h(room.building)} · ${h(room.capacity)} 席 · ${h(room.seatPlan || '按现场座次表编排')}</small></span>`).join('')}</div></section>` : '';
|
||||||
const batch = instance.accountBatch;
|
const batch = instance.accountBatch;
|
||||||
const batchSnapshot = batch ? `<section class="flow-batch-snapshot"><header><div><span>班级配额</span><h3>${batch.totalCount} 个待建账户</h3></div><b>${batch.quotas.length} 个班级</b></header><div>${batch.quotas.map(item => `<span><strong>${h(item.className)}</strong><small>${item.count} 人</small></span>`).join('')}</div><p>最终批准时才生成固定报名号和随机初始密码。</p></section>` : '';
|
const batchSnapshot = batch ? `<section class="flow-batch-snapshot"><header><div><span>班级配额</span><h3>${batch.totalCount} 个待建账户</h3></div><b>${batch.quotas.length} 个班级</b></header><div>${batch.quotas.map(item => `<span><strong>${h(item.className)}</strong><small>${item.count} 人</small></span>`).join('')}</div><p>最终批准时才生成固定报名号和随机初始密码。</p></section>` : '';
|
||||||
@@ -458,7 +461,8 @@ function openFlowDetail(id) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openCandidateReview(id) {
|
function openCandidateReview(id) {
|
||||||
const item = state.pageData.candidates.find(candidate => candidate.id === id);
|
const source = state.pageData.candidates.find(candidate => candidate.id === id);
|
||||||
|
const item = { ...source, address: formatRegionAddress(source) };
|
||||||
const canReview = state.user.adminLevel !== 'class' && item.status === 'pending';
|
const canReview = state.user.adminLevel !== 'class' && item.status === 'pending';
|
||||||
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>'}`);
|
||||||
}
|
}
|
||||||
|
|||||||
+78
-29
@@ -53,7 +53,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 = 6, app_version = ?, self_registration_enabled = ?, created_at = ? WHERE id = 1',
|
'UPDATE schema_metadata SET schema_version = 7, 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()
|
||||||
);
|
);
|
||||||
@@ -90,13 +90,16 @@ function buildSeedOperations(state) {
|
|||||||
for (const profile of state.candidateProfiles) {
|
for (const profile of state.candidateProfiles) {
|
||||||
add(
|
add(
|
||||||
`INSERT INTO candidate_profiles (
|
`INSERT INTO candidate_profiles (
|
||||||
id, user_id, name, gender, id_number, phone, email, school, grade, school_id, class_id, address,
|
id, user_id, name, gender, id_number, phone, email, school, grade, school_id, class_id,
|
||||||
|
province_code, province_name, city_code, city_name, district_code, district_name, address,
|
||||||
emergency_contact, emergency_phone, native_place, birth_date, ethnicity, postal_code, guardian_name,
|
emergency_contact, emergency_phone, native_place, birth_date, ethnicity, postal_code, guardian_name,
|
||||||
guardian_phone, profile_completed, status, review_note, reviewed_at, reviewer_id, updated_at
|
guardian_phone, profile_completed, status, review_note, reviewed_at, reviewer_id, updated_at
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
profile.id, profile.userId, profile.name, nullable(profile.gender), profile.idNumber, profile.phone,
|
profile.id, profile.userId, profile.name, nullable(profile.gender), profile.idNumber, profile.phone,
|
||||||
nullable(profile.email), nullable(profile.school), nullable(profile.grade), nullable(profile.schoolId),
|
nullable(profile.email), nullable(profile.school), nullable(profile.grade), nullable(profile.schoolId),
|
||||||
nullable(profile.classId), nullable(profile.address),
|
nullable(profile.classId), nullable(profile.provinceCode), nullable(profile.provinceName),
|
||||||
|
nullable(profile.cityCode), nullable(profile.cityName), nullable(profile.districtCode), nullable(profile.districtName),
|
||||||
|
nullable(profile.address),
|
||||||
nullable(profile.emergencyContact), nullable(profile.emergencyPhone), nullable(profile.nativePlace), nullable(profile.birthDate),
|
nullable(profile.emergencyContact), nullable(profile.emergencyPhone), nullable(profile.nativePlace), nullable(profile.birthDate),
|
||||||
nullable(profile.ethnicity), nullable(profile.postalCode), nullable(profile.guardianName), nullable(profile.guardianPhone),
|
nullable(profile.ethnicity), nullable(profile.postalCode), nullable(profile.guardianName), nullable(profile.guardianPhone),
|
||||||
profile.profileCompleted ? 1 : 0, profile.status, nullable(profile.reviewNote),
|
profile.profileCompleted ? 1 : 0, profile.status, nullable(profile.reviewNote),
|
||||||
@@ -171,10 +174,13 @@ function buildSeedOperations(state) {
|
|||||||
for (const center of state.testCenters) {
|
for (const center of state.testCenters) {
|
||||||
add(
|
add(
|
||||||
`INSERT INTO test_centers (
|
`INSERT INTO test_centers (
|
||||||
id, school_id, code, name, address, contact, manager_name, manager_phone, emergency_phone,
|
id, school_id, code, name, province_code, province_name, city_code, city_name, district_code, district_name,
|
||||||
|
address, contact, manager_name, manager_phone, emergency_phone,
|
||||||
gate_open_time, transport, status, notes, rooms, updated_at
|
gate_open_time, transport, status, notes, rooms, updated_at
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
center.id, center.schoolId, center.code, center.name, center.address, nullable(center.contact),
|
center.id, center.schoolId, center.code, center.name,
|
||||||
|
center.provinceCode, center.provinceName, center.cityCode, center.cityName, center.districtCode, center.districtName,
|
||||||
|
center.address, nullable(center.contact),
|
||||||
nullable(center.managerName), nullable(center.managerPhone), nullable(center.emergencyPhone),
|
nullable(center.managerName), nullable(center.managerPhone), nullable(center.emergencyPhone),
|
||||||
nullable(center.gateOpenTime), nullable(center.transport), center.status || 'active', nullable(center.notes),
|
nullable(center.gateOpenTime), nullable(center.transport), center.status || 'active', nullable(center.notes),
|
||||||
center.rooms || '', center.updatedAt
|
center.rooms || '', center.updatedAt
|
||||||
@@ -194,11 +200,14 @@ function buildSeedOperations(state) {
|
|||||||
for (const request of state.centerChangeRequests) {
|
for (const request of state.centerChangeRequests) {
|
||||||
add(
|
add(
|
||||||
`INSERT INTO center_change_requests (
|
`INSERT INTO center_change_requests (
|
||||||
id, center_id, school_id, request_type, code, name, address, contact, manager_name, manager_phone,
|
id, center_id, school_id, request_type, code, name,
|
||||||
|
province_code, province_name, city_code, city_name, district_code, district_name,
|
||||||
|
address, contact, manager_name, manager_phone,
|
||||||
emergency_phone, gate_open_time, transport, center_status, notes, status, review_note,
|
emergency_phone, gate_open_time, transport, center_status, notes, status, review_note,
|
||||||
requested_by, created_at, reviewed_at
|
requested_by, created_at, reviewed_at
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
request.id, nullable(request.centerId), request.schoolId, request.requestType, request.code, request.name,
|
request.id, nullable(request.centerId), request.schoolId, request.requestType, request.code, request.name,
|
||||||
|
request.provinceCode, request.provinceName, request.cityCode, request.cityName, request.districtCode, request.districtName,
|
||||||
request.address, nullable(request.contact), nullable(request.managerName), nullable(request.managerPhone),
|
request.address, nullable(request.contact), nullable(request.managerName), nullable(request.managerPhone),
|
||||||
nullable(request.emergencyPhone), nullable(request.gateOpenTime), nullable(request.transport),
|
nullable(request.emergencyPhone), nullable(request.gateOpenTime), nullable(request.transport),
|
||||||
request.centerStatus || 'active', nullable(request.notes), request.status, nullable(request.reviewNote),
|
request.centerStatus || 'active', nullable(request.notes), request.status, nullable(request.reviewNote),
|
||||||
@@ -400,6 +409,12 @@ function stateFromRows(rows) {
|
|||||||
grade: row.grade || '',
|
grade: row.grade || '',
|
||||||
schoolId: row.school_id || null,
|
schoolId: row.school_id || null,
|
||||||
classId: row.class_id || null,
|
classId: row.class_id || null,
|
||||||
|
provinceCode: row.province_code || '',
|
||||||
|
provinceName: row.province_name || '',
|
||||||
|
cityCode: row.city_code || '',
|
||||||
|
cityName: row.city_name || '',
|
||||||
|
districtCode: row.district_code || '',
|
||||||
|
districtName: row.district_name || '',
|
||||||
address: row.address || '',
|
address: row.address || '',
|
||||||
emergencyContact: row.emergency_contact || '',
|
emergencyContact: row.emergency_contact || '',
|
||||||
emergencyPhone: row.emergency_phone || '',
|
emergencyPhone: row.emergency_phone || '',
|
||||||
@@ -475,6 +490,12 @@ function stateFromRows(rows) {
|
|||||||
schoolId: row.school_id,
|
schoolId: row.school_id,
|
||||||
code: row.code || '',
|
code: row.code || '',
|
||||||
name: row.name,
|
name: row.name,
|
||||||
|
provinceCode: row.province_code || '',
|
||||||
|
provinceName: row.province_name || '',
|
||||||
|
cityCode: row.city_code || '',
|
||||||
|
cityName: row.city_name || '',
|
||||||
|
districtCode: row.district_code || '',
|
||||||
|
districtName: row.district_name || '',
|
||||||
address: row.address,
|
address: row.address,
|
||||||
contact: row.contact || '',
|
contact: row.contact || '',
|
||||||
managerName: row.manager_name || '',
|
managerName: row.manager_name || '',
|
||||||
@@ -509,6 +530,12 @@ function stateFromRows(rows) {
|
|||||||
requestType: row.request_type,
|
requestType: row.request_type,
|
||||||
code: row.code,
|
code: row.code,
|
||||||
name: row.name,
|
name: row.name,
|
||||||
|
provinceCode: row.province_code || '',
|
||||||
|
provinceName: row.province_name || '',
|
||||||
|
cityCode: row.city_code || '',
|
||||||
|
cityName: row.city_name || '',
|
||||||
|
districtCode: row.district_code || '',
|
||||||
|
districtName: row.district_name || '',
|
||||||
address: row.address,
|
address: row.address,
|
||||||
contact: row.contact || '',
|
contact: row.contact || '',
|
||||||
managerName: row.manager_name || '',
|
managerName: row.manager_name || '',
|
||||||
@@ -730,13 +757,16 @@ function createRepository({ client, location, read, transaction, close }) {
|
|||||||
),
|
),
|
||||||
operation(
|
operation(
|
||||||
`INSERT INTO candidate_profiles (
|
`INSERT INTO candidate_profiles (
|
||||||
id, user_id, name, gender, id_number, phone, email, school, grade, school_id, class_id, address,
|
id, user_id, name, gender, id_number, phone, email, school, grade, school_id, class_id,
|
||||||
|
province_code, province_name, city_code, city_name, district_code, district_name, address,
|
||||||
emergency_contact, emergency_phone, native_place, birth_date, ethnicity, postal_code, guardian_name,
|
emergency_contact, emergency_phone, native_place, birth_date, ethnicity, postal_code, guardian_name,
|
||||||
guardian_phone, profile_completed, status, review_note, reviewed_at, reviewer_id, updated_at
|
guardian_phone, profile_completed, status, review_note, reviewed_at, reviewer_id, updated_at
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
profile.id, profile.userId, profile.name, optional(profile.gender), profile.idNumber, profile.phone,
|
profile.id, profile.userId, profile.name, optional(profile.gender), profile.idNumber, profile.phone,
|
||||||
optional(profile.email), optional(profile.school), optional(profile.grade), optional(profile.schoolId),
|
optional(profile.email), optional(profile.school), optional(profile.grade), optional(profile.schoolId),
|
||||||
optional(profile.classId), optional(profile.address),
|
optional(profile.classId), optional(profile.provinceCode), optional(profile.provinceName),
|
||||||
|
optional(profile.cityCode), optional(profile.cityName), optional(profile.districtCode), optional(profile.districtName),
|
||||||
|
optional(profile.address),
|
||||||
optional(profile.emergencyContact), optional(profile.emergencyPhone), optional(profile.nativePlace), optional(profile.birthDate),
|
optional(profile.emergencyContact), optional(profile.emergencyPhone), optional(profile.nativePlace), optional(profile.birthDate),
|
||||||
optional(profile.ethnicity), optional(profile.postalCode), optional(profile.guardianName), optional(profile.guardianPhone),
|
optional(profile.ethnicity), optional(profile.postalCode), optional(profile.guardianName), optional(profile.guardianPhone),
|
||||||
profile.profileCompleted ? 1 : 0, profile.status, optional(profile.reviewNote),
|
profile.profileCompleted ? 1 : 0, profile.status, optional(profile.reviewNote),
|
||||||
@@ -771,13 +801,16 @@ function createRepository({ client, location, read, transaction, close }) {
|
|||||||
const operations = [
|
const operations = [
|
||||||
operation(
|
operation(
|
||||||
`UPDATE candidate_profiles SET
|
`UPDATE candidate_profiles SET
|
||||||
name = ?, gender = ?, id_number = ?, phone = ?, email = ?, school = ?, grade = ?, address = ?,
|
name = ?, gender = ?, id_number = ?, phone = ?, email = ?, school = ?, grade = ?,
|
||||||
|
province_code = ?, province_name = ?, city_code = ?, city_name = ?, district_code = ?, district_name = ?, address = ?,
|
||||||
school_id = ?, class_id = ?, emergency_contact = ?, emergency_phone = ?, status = ?, review_note = ?,
|
school_id = ?, class_id = ?, emergency_contact = ?, emergency_phone = ?, status = ?, review_note = ?,
|
||||||
native_place = ?, birth_date = ?, ethnicity = ?, postal_code = ?, guardian_name = ?, guardian_phone = ?,
|
native_place = ?, birth_date = ?, ethnicity = ?, postal_code = ?, guardian_name = ?, guardian_phone = ?,
|
||||||
profile_completed = ?, reviewed_at = ?, reviewer_id = ?, updated_at = ?
|
profile_completed = ?, reviewed_at = ?, reviewer_id = ?, updated_at = ?
|
||||||
WHERE id = ?`,
|
WHERE id = ?`,
|
||||||
profile.name, optional(profile.gender), profile.idNumber, profile.phone, optional(profile.email),
|
profile.name, optional(profile.gender), profile.idNumber, profile.phone, optional(profile.email),
|
||||||
optional(profile.school), optional(profile.grade), optional(profile.address), optional(profile.schoolId),
|
optional(profile.school), optional(profile.grade), optional(profile.provinceCode), optional(profile.provinceName),
|
||||||
|
optional(profile.cityCode), optional(profile.cityName), optional(profile.districtCode), optional(profile.districtName),
|
||||||
|
optional(profile.address), optional(profile.schoolId),
|
||||||
optional(profile.classId), optional(profile.emergencyContact), optional(profile.emergencyPhone), profile.status,
|
optional(profile.classId), optional(profile.emergencyContact), optional(profile.emergencyPhone), profile.status,
|
||||||
optional(profile.reviewNote), optional(profile.nativePlace), optional(profile.birthDate), optional(profile.ethnicity),
|
optional(profile.reviewNote), optional(profile.nativePlace), optional(profile.birthDate), optional(profile.ethnicity),
|
||||||
optional(profile.postalCode), optional(profile.guardianName), optional(profile.guardianPhone), profile.profileCompleted ? 1 : 0, optional(profile.reviewedAt),
|
optional(profile.postalCode), optional(profile.guardianName), optional(profile.guardianPhone), profile.profileCompleted ? 1 : 0, optional(profile.reviewedAt),
|
||||||
@@ -909,13 +942,16 @@ function createRepository({ client, location, read, transaction, close }) {
|
|||||||
),
|
),
|
||||||
operation(
|
operation(
|
||||||
`INSERT INTO candidate_profiles (
|
`INSERT INTO candidate_profiles (
|
||||||
id, user_id, name, gender, id_number, phone, email, school, grade, school_id, class_id, address,
|
id, user_id, name, gender, id_number, phone, email, school, grade, school_id, class_id,
|
||||||
|
province_code, province_name, city_code, city_name, district_code, district_name, address,
|
||||||
emergency_contact, emergency_phone, native_place, birth_date, ethnicity, postal_code, guardian_name,
|
emergency_contact, emergency_phone, native_place, birth_date, ethnicity, postal_code, guardian_name,
|
||||||
guardian_phone, profile_completed, status, review_note, reviewed_at, reviewer_id, updated_at
|
guardian_phone, profile_completed, status, review_note, reviewed_at, reviewer_id, updated_at
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
profile.id, profile.userId, profile.name, optional(profile.gender), profile.idNumber, profile.phone,
|
profile.id, profile.userId, profile.name, optional(profile.gender), profile.idNumber, profile.phone,
|
||||||
optional(profile.email), profile.school, profile.grade, profile.schoolId, profile.classId,
|
optional(profile.email), profile.school, profile.grade, profile.schoolId, profile.classId,
|
||||||
optional(profile.address), optional(profile.emergencyContact), optional(profile.emergencyPhone),
|
optional(profile.provinceCode), optional(profile.provinceName), optional(profile.cityCode), optional(profile.cityName),
|
||||||
|
optional(profile.districtCode), optional(profile.districtName), optional(profile.address),
|
||||||
|
optional(profile.emergencyContact), optional(profile.emergencyPhone),
|
||||||
optional(profile.nativePlace), optional(profile.birthDate), optional(profile.ethnicity), optional(profile.postalCode),
|
optional(profile.nativePlace), optional(profile.birthDate), optional(profile.ethnicity), optional(profile.postalCode),
|
||||||
optional(profile.guardianName), optional(profile.guardianPhone), 0, profile.status, optional(profile.reviewNote),
|
optional(profile.guardianName), optional(profile.guardianPhone), 0, profile.status, optional(profile.reviewNote),
|
||||||
optional(profile.reviewedAt), optional(profile.reviewerId), profile.updatedAt
|
optional(profile.reviewedAt), optional(profile.reviewerId), profile.updatedAt
|
||||||
@@ -1014,14 +1050,19 @@ function createRepository({ client, location, read, transaction, close }) {
|
|||||||
const centerOperation = isNew
|
const centerOperation = isNew
|
||||||
? operation(
|
? operation(
|
||||||
`INSERT INTO test_centers (
|
`INSERT INTO test_centers (
|
||||||
id, school_id, name, address, contact, rooms, updated_at
|
id, school_id, code, name, province_code, province_name, city_code, city_name, district_code, district_name,
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
address, contact, rooms, updated_at
|
||||||
center.id, center.schoolId, center.name, center.address, optional(center.contact), center.rooms, center.updatedAt
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
center.id, center.schoolId, center.code, center.name,
|
||||||
|
center.provinceCode, center.provinceName, center.cityCode, center.cityName, center.districtCode, center.districtName,
|
||||||
|
center.address, optional(center.contact), center.rooms, center.updatedAt
|
||||||
)
|
)
|
||||||
: operation(
|
: operation(
|
||||||
`UPDATE test_centers SET
|
`UPDATE test_centers SET
|
||||||
name = ?, address = ?, contact = ?, rooms = ?, updated_at = ? WHERE id = ?`,
|
code = ?, name = ?, province_code = ?, province_name = ?, city_code = ?, city_name = ?,
|
||||||
center.name, center.address, optional(center.contact), center.rooms, center.updatedAt, center.id
|
district_code = ?, district_name = ?, address = ?, contact = ?, rooms = ?, updated_at = ? WHERE id = ?`,
|
||||||
|
center.code, center.name, center.provinceCode, center.provinceName, center.cityCode, center.cityName,
|
||||||
|
center.districtCode, center.districtName, center.address, optional(center.contact), center.rooms, center.updatedAt, center.id
|
||||||
);
|
);
|
||||||
await transaction([centerOperation, auditOperation(log)]);
|
await transaction([centerOperation, auditOperation(log)]);
|
||||||
},
|
},
|
||||||
@@ -1029,11 +1070,14 @@ function createRepository({ client, location, read, transaction, close }) {
|
|||||||
const operations = [
|
const operations = [
|
||||||
operation(
|
operation(
|
||||||
`INSERT INTO center_change_requests (
|
`INSERT INTO center_change_requests (
|
||||||
id, center_id, school_id, request_type, code, name, address, contact, manager_name, manager_phone,
|
id, center_id, school_id, request_type, code, name,
|
||||||
|
province_code, province_name, city_code, city_name, district_code, district_name,
|
||||||
|
address, contact, manager_name, manager_phone,
|
||||||
emergency_phone, gate_open_time, transport, center_status, notes, status, review_note,
|
emergency_phone, gate_open_time, transport, center_status, notes, status, review_note,
|
||||||
requested_by, created_at, reviewed_at
|
requested_by, created_at, reviewed_at
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
request.id, optional(request.centerId), request.schoolId, request.requestType, request.code, request.name,
|
request.id, optional(request.centerId), request.schoolId, request.requestType, request.code, request.name,
|
||||||
|
request.provinceCode, request.provinceName, request.cityCode, request.cityName, request.districtCode, request.districtName,
|
||||||
request.address, optional(request.contact), optional(request.managerName), optional(request.managerPhone),
|
request.address, optional(request.contact), optional(request.managerName), optional(request.managerPhone),
|
||||||
optional(request.emergencyPhone), optional(request.gateOpenTime), optional(request.transport),
|
optional(request.emergencyPhone), optional(request.gateOpenTime), optional(request.transport),
|
||||||
request.centerStatus, optional(request.notes), request.status, optional(request.reviewNote),
|
request.centerStatus, optional(request.notes), request.status, optional(request.reviewNote),
|
||||||
@@ -1069,10 +1113,13 @@ function createRepository({ client, location, read, transaction, close }) {
|
|||||||
if (request.requestType === 'create') {
|
if (request.requestType === 'create') {
|
||||||
operations.push(operation(
|
operations.push(operation(
|
||||||
`INSERT INTO test_centers (
|
`INSERT INTO test_centers (
|
||||||
id, school_id, code, name, address, contact, manager_name, manager_phone, emergency_phone,
|
id, school_id, code, name, province_code, province_name, city_code, city_name, district_code, district_name,
|
||||||
|
address, contact, manager_name, manager_phone, emergency_phone,
|
||||||
gate_open_time, transport, status, notes, rooms, updated_at
|
gate_open_time, transport, status, notes, rooms, updated_at
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
center.id, center.schoolId, center.code, center.name, center.address, optional(center.contact),
|
center.id, center.schoolId, center.code, center.name,
|
||||||
|
center.provinceCode, center.provinceName, center.cityCode, center.cityName, center.districtCode, center.districtName,
|
||||||
|
center.address, optional(center.contact),
|
||||||
optional(center.managerName), optional(center.managerPhone), optional(center.emergencyPhone),
|
optional(center.managerName), optional(center.managerPhone), optional(center.emergencyPhone),
|
||||||
optional(center.gateOpenTime), optional(center.transport), center.status, optional(center.notes),
|
optional(center.gateOpenTime), optional(center.transport), center.status, optional(center.notes),
|
||||||
center.rooms || '', center.updatedAt
|
center.rooms || '', center.updatedAt
|
||||||
@@ -1080,10 +1127,12 @@ function createRepository({ client, location, read, transaction, close }) {
|
|||||||
} else {
|
} else {
|
||||||
operations.push(operation(
|
operations.push(operation(
|
||||||
`UPDATE test_centers SET
|
`UPDATE test_centers SET
|
||||||
code = ?, name = ?, address = ?, contact = ?, manager_name = ?, manager_phone = ?,
|
code = ?, name = ?, province_code = ?, province_name = ?, city_code = ?, city_name = ?,
|
||||||
|
district_code = ?, district_name = ?, address = ?, contact = ?, manager_name = ?, manager_phone = ?,
|
||||||
emergency_phone = ?, gate_open_time = ?, transport = ?, status = ?, notes = ?, rooms = ?, updated_at = ?
|
emergency_phone = ?, gate_open_time = ?, transport = ?, status = ?, notes = ?, rooms = ?, updated_at = ?
|
||||||
WHERE id = ?`,
|
WHERE id = ?`,
|
||||||
center.code, center.name, center.address, optional(center.contact), optional(center.managerName),
|
center.code, center.name, center.provinceCode, center.provinceName, center.cityCode, center.cityName,
|
||||||
|
center.districtCode, center.districtName, center.address, optional(center.contact), optional(center.managerName),
|
||||||
optional(center.managerPhone), optional(center.emergencyPhone), optional(center.gateOpenTime),
|
optional(center.managerPhone), optional(center.emergencyPhone), optional(center.gateOpenTime),
|
||||||
optional(center.transport), center.status, optional(center.notes), center.rooms || '', center.updatedAt, center.id
|
optional(center.transport), center.status, optional(center.notes), center.rooms || '', center.updatedAt, center.id
|
||||||
));
|
));
|
||||||
|
|||||||
@@ -36,7 +36,11 @@ const resourceSpecs = {
|
|||||||
['candidateNumber', '报名号*', 26, '2026-HZ01-X-0001'], ['name', '姓名*', 16, '李明'],
|
['candidateNumber', '报名号*', 26, '2026-HZ01-X-0001'], ['name', '姓名*', 16, '李明'],
|
||||||
['gender', '性别*', 10, '男'], ['idNumber', '证件号码*', 24, '320101200801011234'],
|
['gender', '性别*', 10, '男'], ['idNumber', '证件号码*', 24, '320101200801011234'],
|
||||||
['phone', '手机号*', 18, '13800138000'], ['email', '邮箱', 24, 'student@example.com'],
|
['phone', '手机号*', 18, '13800138000'], ['email', '邮箱', 24, 'student@example.com'],
|
||||||
['nativePlace', '籍贯', 18, '江苏海州'], ['address', '家庭住址', 32, '海州市清河区示例路 1 号'],
|
['nativePlace', '籍贯', 18, '江苏海州'],
|
||||||
|
['provinceCode', '省级代码*', 14, '320000'], ['provinceName', '省份', 18, '江苏省'],
|
||||||
|
['cityCode', '市级代码*', 14, '320700'], ['cityName', '城市', 18, '连云港市'],
|
||||||
|
['districtCode', '区县代码*', 14, '320706'], ['districtName', '区县', 18, '海州区'],
|
||||||
|
['address', '详细住址*', 32, '示例路 1 号'],
|
||||||
['className', '班级*', 22, '高三(1)班'], ['ethnicity', '民族', 12, '汉族'],
|
['className', '班级*', 22, '高三(1)班'], ['ethnicity', '民族', 12, '汉族'],
|
||||||
['birthDate', '出生日期', 16, '2008-01-01'], ['postalCode', '邮编', 14, '222000'],
|
['birthDate', '出生日期', 16, '2008-01-01'], ['postalCode', '邮编', 14, '222000'],
|
||||||
['guardianName', '监护人', 16, '李家长'], ['guardianPhone', '监护人电话', 18, '13900139000']
|
['guardianName', '监护人', 16, '李家长'], ['guardianPhone', '监护人电话', 18, '13900139000']
|
||||||
@@ -47,7 +51,11 @@ const resourceSpecs = {
|
|||||||
title: '考点考场档案', sheet: '考点考场',
|
title: '考点考场档案', sheet: '考点考场',
|
||||||
columns: [
|
columns: [
|
||||||
['schoolCode', '学校代码*', 14, 'HZ01'], ['centerCode', '考点代码*', 18, 'HZ01-C02'],
|
['schoolCode', '学校代码*', 14, 'HZ01'], ['centerCode', '考点代码*', 18, 'HZ01-C02'],
|
||||||
['centerName', '考点名称*', 24, '第一中学东区考点'], ['address', '详细地址*', 30, '海州市示例路 8 号'],
|
['centerName', '考点名称*', 24, '第一中学东区考点'],
|
||||||
|
['provinceCode', '省级代码*', 14, '320000'], ['provinceName', '省份', 18, '江苏省'],
|
||||||
|
['cityCode', '市级代码*', 14, '320700'], ['cityName', '城市', 18, '连云港市'],
|
||||||
|
['districtCode', '区县代码*', 14, '320706'], ['districtName', '区县', 18, '海州区'],
|
||||||
|
['address', '详细地址*', 30, '示例路 8 号'],
|
||||||
['managerName', '负责人', 16, '王老师'], ['managerPhone', '负责人手机', 18, '13800138000'],
|
['managerName', '负责人', 16, '王老师'], ['managerPhone', '负责人手机', 18, '13800138000'],
|
||||||
['contact', '值班电话', 18, '0518-86020000'], ['emergencyPhone', '应急电话', 18, '0518-120'],
|
['contact', '值班电话', 18, '0518-86020000'], ['emergencyPhone', '应急电话', 18, '0518-120'],
|
||||||
['gateOpenTime', '开放时间', 14, '07:00'], ['transport', '交通提示', 30, '东门入场'],
|
['gateOpenTime', '开放时间', 14, '07:00'], ['transport', '交通提示', 30, '东门入场'],
|
||||||
|
|||||||
+2
-1
@@ -5,7 +5,8 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node server.mjs",
|
"start": "node server.mjs",
|
||||||
"test": "node tests/system.test.mjs"
|
"test": "node tests/system.test.mjs",
|
||||||
|
"reset-db": "node scripts/reset-dev-database.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"exceljs": "^4.4.0",
|
"exceljs": "^4.4.0",
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { readFile, writeFile } from 'node:fs/promises';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
|
||||||
|
const input = resolve(process.argv[2] || 'ok_data_level3.csv');
|
||||||
|
const output = resolve(process.argv[3] || 'src/data/china-regions.mjs');
|
||||||
|
|
||||||
|
function parseCsvLine(line) {
|
||||||
|
const values = [];
|
||||||
|
let value = '';
|
||||||
|
let quoted = false;
|
||||||
|
for (let index = 0; index < line.length; index += 1) {
|
||||||
|
const char = line[index];
|
||||||
|
if (char === '"') {
|
||||||
|
if (quoted && line[index + 1] === '"') { value += '"'; index += 1; }
|
||||||
|
else quoted = !quoted;
|
||||||
|
} else if (char === ',' && !quoted) {
|
||||||
|
values.push(value);
|
||||||
|
value = '';
|
||||||
|
} else value += char;
|
||||||
|
}
|
||||||
|
values.push(value);
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
const source = await readFile(input, 'utf8');
|
||||||
|
const rows = source.replace(/^\uFEFF/, '').trim().split(/\r?\n/).slice(1).map(line => {
|
||||||
|
const [id, pid, deep, , , , extId, extName] = parseCsvLine(line);
|
||||||
|
return { id, pid, deep: Number(deep), code: extId.slice(0, 6), name: extName };
|
||||||
|
});
|
||||||
|
|
||||||
|
const provinces = rows.filter(item => item.deep === 0 && item.code !== '0').map(province => ({
|
||||||
|
code: province.code,
|
||||||
|
name: province.name,
|
||||||
|
cities: rows.filter(city => city.deep === 1 && city.pid === province.id).map(city => ({
|
||||||
|
code: city.code,
|
||||||
|
name: city.name,
|
||||||
|
districts: rows.filter(district => district.deep === 2 && district.pid === city.id).map(district => ({
|
||||||
|
code: district.code,
|
||||||
|
name: district.name
|
||||||
|
}))
|
||||||
|
}))
|
||||||
|
}));
|
||||||
|
|
||||||
|
const hotan = provinces.find(item => item.code === '650000')?.cities.find(item => item.code === '653200');
|
||||||
|
for (const district of [
|
||||||
|
{ code: '653228', name: '和康县' },
|
||||||
|
{ code: '653229', name: '和安县' }
|
||||||
|
]) {
|
||||||
|
if (hotan && !hotan.districts.some(item => item.code === district.code)) hotan.districts.push(district);
|
||||||
|
}
|
||||||
|
hotan?.districts.sort((a, b) => a.code.localeCompare(b.code));
|
||||||
|
|
||||||
|
const banner = `// Generated from AreaCity-JsSpider-StatsGov release 2025.251231.260403.\n// Source snapshot: 国家地名信息库 2025-12-31; generated 2026-07-20.\n// Manual official additions: 和康县 653228, 和安县 653229.\n`;
|
||||||
|
await writeFile(output, `${banner}export const chinaRegionsVersion = '2025-12-31';\nexport const chinaRegions = ${JSON.stringify(provinces)};\n`, 'utf8');
|
||||||
|
console.log(`Generated ${provinces.length} provinces at ${output}`);
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { pbkdf2Sync, randomBytes } from 'node:crypto';
|
||||||
|
import { rm } from 'node:fs/promises';
|
||||||
|
import { isAbsolute, join, relative, resolve } from 'node:path';
|
||||||
|
import { createDatabase } from '../database.mjs';
|
||||||
|
import { createSeedDatabase } from '../src/data/seed.mjs';
|
||||||
|
|
||||||
|
const root = resolve(process.cwd());
|
||||||
|
const databasePath = join(root, 'data', 'exam.sqlite');
|
||||||
|
const relativePath = relative(root, databasePath);
|
||||||
|
if (!relativePath || relativePath.startsWith('..') || isAbsolute(relativePath)) throw new Error('拒绝重建工作区以外的数据库');
|
||||||
|
|
||||||
|
for (const path of [databasePath, `${databasePath}-shm`, `${databasePath}-wal`]) await rm(path, { force: true });
|
||||||
|
|
||||||
|
function hashPassword(password, salt = randomBytes(16).toString('hex')) {
|
||||||
|
const hash = pbkdf2Sync(password, salt, 120000, 32, 'sha256').toString('hex');
|
||||||
|
return `${salt}:${hash}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
process.env.DATABASE_CLIENT = 'sqlite';
|
||||||
|
process.env.SQLITE_PATH = databasePath;
|
||||||
|
const database = await createDatabase({
|
||||||
|
root,
|
||||||
|
seed: () => createSeedDatabase({ nowIso: () => new Date().toISOString(), hashPassword })
|
||||||
|
});
|
||||||
|
const state = await database.read();
|
||||||
|
await database.close();
|
||||||
|
console.log(`Rebuilt ${databasePath} (schema ${state.meta.version}, ${state.candidateProfiles.length} candidates, ${state.testCenters.length} centers)`);
|
||||||
+22
-8
@@ -12,6 +12,7 @@ import { adminLevelNames, adminScopeLabel, createPermissionGuard, hasPermission,
|
|||||||
import { createSessionManager } from './src/security/session.mjs';
|
import { createSessionManager } from './src/security/session.mjs';
|
||||||
import { readBodyBuffer, readJson, sendError, sendJson, sendWorkbook } from './src/http/responses.mjs';
|
import { readBodyBuffer, readJson, sendError, sendJson, sendWorkbook } from './src/http/responses.mjs';
|
||||||
import { createSeedDatabase } from './src/data/seed.mjs';
|
import { createSeedDatabase } from './src/data/seed.mjs';
|
||||||
|
import { resolveRegion } from './src/data/region-service.mjs';
|
||||||
|
|
||||||
const root = resolve(process.cwd());
|
const root = resolve(process.cwd());
|
||||||
const port = Number(process.env.PORT || 4173);
|
const port = Number(process.env.PORT || 4173);
|
||||||
@@ -26,7 +27,9 @@ const staticFiles = new Set([
|
|||||||
'/src/client/candidate-views.mjs',
|
'/src/client/candidate-views.mjs',
|
||||||
'/src/client/public-views.mjs',
|
'/src/client/public-views.mjs',
|
||||||
'/src/client/state.mjs',
|
'/src/client/state.mjs',
|
||||||
'/src/client/ui.mjs'
|
'/src/client/ui.mjs',
|
||||||
|
'/src/client/region-select.mjs',
|
||||||
|
'/src/data/china-regions.mjs'
|
||||||
]);
|
]);
|
||||||
const mimeTypes = {
|
const mimeTypes = {
|
||||||
'.html': 'text/html; charset=utf-8',
|
'.html': 'text/html; charset=utf-8',
|
||||||
@@ -201,8 +204,9 @@ function parseCenterChange(db, body, schoolId, center = null) {
|
|||||||
const code = cleanText(body.code, 30).toUpperCase();
|
const code = cleanText(body.code, 30).toUpperCase();
|
||||||
const name = cleanText(body.name, 100);
|
const name = cleanText(body.name, 100);
|
||||||
const address = cleanText(body.address, 200);
|
const address = cleanText(body.address, 200);
|
||||||
|
const region = resolveRegion(body);
|
||||||
const rooms = Array.isArray(body.rooms) ? body.rooms : [];
|
const rooms = Array.isArray(body.rooms) ? body.rooms : [];
|
||||||
if (!code || !name || !address) throw Object.assign(new Error('请填写考点代码、名称和详细地址'), { status: 400 });
|
if (!code || !name || !address || !region) throw Object.assign(new Error('请填写考点代码、名称、省市区县和详细地址'), { status: 400 });
|
||||||
if (!rooms.length) throw Object.assign(new Error('请至少配置一个结构化考场'), { status: 400 });
|
if (!rooms.length) throw Object.assign(new Error('请至少配置一个结构化考场'), { status: 400 });
|
||||||
const duplicateCenter = db.testCenters.some(item => item.code.toUpperCase() === code && item.id !== center?.id)
|
const duplicateCenter = db.testCenters.some(item => item.code.toUpperCase() === code && item.id !== center?.id)
|
||||||
|| db.centerChangeRequests.some(item => item.status === 'pending' && item.code.toUpperCase() === code && item.centerId !== center?.id);
|
|| db.centerChangeRequests.some(item => item.status === 'pending' && item.code.toUpperCase() === code && item.centerId !== center?.id);
|
||||||
@@ -227,7 +231,7 @@ function parseCenterChange(db, body, schoolId, center = null) {
|
|||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
center: {
|
center: {
|
||||||
schoolId, code, name, address, contact: cleanText(body.contact, 80), managerName: cleanText(body.managerName, 50),
|
schoolId, code, name, ...region, address, contact: cleanText(body.contact, 80), managerName: cleanText(body.managerName, 50),
|
||||||
managerPhone: cleanText(body.managerPhone, 30), emergencyPhone: cleanText(body.emergencyPhone, 30),
|
managerPhone: cleanText(body.managerPhone, 30), emergencyPhone: cleanText(body.emergencyPhone, 30),
|
||||||
gateOpenTime: cleanText(body.gateOpenTime, 20), transport: cleanText(body.transport, 500),
|
gateOpenTime: cleanText(body.gateOpenTime, 20), transport: cleanText(body.transport, 500),
|
||||||
centerStatus: body.status === 'inactive' ? 'inactive' : 'active', notes: cleanText(body.notes, 1000)
|
centerStatus: body.status === 'inactive' ? 'inactive' : 'active', notes: cleanText(body.notes, 1000)
|
||||||
@@ -345,14 +349,18 @@ function excelRowsForResource(db, user, resource, searchParams) {
|
|||||||
if (resource === 'candidates') return db.candidateProfiles.filter(profile => profileInScope(user, profile)).map(profile => ({
|
if (resource === 'candidates') return db.candidateProfiles.filter(profile => profileInScope(user, profile)).map(profile => ({
|
||||||
candidateNumber: db.users.find(item => item.id === profile.userId)?.candidateNumber || '', name: profile.name, gender: profile.gender,
|
candidateNumber: db.users.find(item => item.id === profile.userId)?.candidateNumber || '', name: profile.name, gender: profile.gender,
|
||||||
idNumber: profile.idNumber.startsWith('PENDING-') ? '' : profile.idNumber, phone: profile.phone, email: profile.email,
|
idNumber: profile.idNumber.startsWith('PENDING-') ? '' : profile.idNumber, phone: profile.phone, email: profile.email,
|
||||||
nativePlace: profile.nativePlace, address: profile.address, className: db.classes.find(item => item.id === profile.classId)?.name || profile.grade,
|
nativePlace: profile.nativePlace, provinceCode: profile.provinceCode, provinceName: profile.provinceName,
|
||||||
|
cityCode: profile.cityCode, cityName: profile.cityName, districtCode: profile.districtCode, districtName: profile.districtName,
|
||||||
|
address: profile.address, className: db.classes.find(item => item.id === profile.classId)?.name || profile.grade,
|
||||||
ethnicity: profile.ethnicity, birthDate: profile.birthDate, postalCode: profile.postalCode, guardianName: profile.guardianName, guardianPhone: profile.guardianPhone
|
ethnicity: profile.ethnicity, birthDate: profile.birthDate, postalCode: profile.postalCode, guardianName: profile.guardianName, guardianPhone: profile.guardianPhone
|
||||||
}));
|
}));
|
||||||
if (resource === 'centers') return db.testCenters.filter(center => schools.some(school => school.id === center.schoolId)).flatMap(center => {
|
if (resource === 'centers') return db.testCenters.filter(center => schools.some(school => school.id === center.schoolId)).flatMap(center => {
|
||||||
const rooms = db.testRooms.filter(room => room.centerId === center.id);
|
const rooms = db.testRooms.filter(room => room.centerId === center.id);
|
||||||
return (rooms.length ? rooms : [{}]).map(room => ({
|
return (rooms.length ? rooms : [{}]).map(room => ({
|
||||||
schoolCode: db.schools.find(school => school.id === center.schoolId)?.code || '', centerCode: center.code, centerName: center.name,
|
schoolCode: db.schools.find(school => school.id === center.schoolId)?.code || '', centerCode: center.code, centerName: center.name,
|
||||||
address: center.address, managerName: center.managerName, managerPhone: center.managerPhone, contact: center.contact,
|
provinceCode: center.provinceCode, provinceName: center.provinceName, cityCode: center.cityCode, cityName: center.cityName,
|
||||||
|
districtCode: center.districtCode, districtName: center.districtName, address: center.address,
|
||||||
|
managerName: center.managerName, managerPhone: center.managerPhone, contact: center.contact,
|
||||||
emergencyPhone: center.emergencyPhone, gateOpenTime: center.gateOpenTime, transport: center.transport,
|
emergencyPhone: center.emergencyPhone, gateOpenTime: center.gateOpenTime, transport: center.transport,
|
||||||
centerStatus: center.status === 'inactive' ? '停用' : '启用', centerNotes: center.notes,
|
centerStatus: center.status === 'inactive' ? '停用' : '启用', centerNotes: center.notes,
|
||||||
roomCode: room.code || '', roomName: room.name || '', building: room.building || '', floor: room.floor || '', capacity: room.capacity || '',
|
roomCode: room.code || '', roomName: room.name || '', building: room.building || '', floor: room.floor || '', capacity: room.capacity || '',
|
||||||
@@ -436,9 +444,12 @@ async function importExcelResource(db, user, resource, rows) {
|
|||||||
if (!schoolClass) throw excelImportError(row, '班级名称无效');
|
if (!schoolClass) throw excelImportError(row, '班级名称无效');
|
||||||
const required = ['name', 'gender', 'idNumber', 'phone'];
|
const required = ['name', 'gender', 'idNumber', 'phone'];
|
||||||
if (required.some(key => !cleanText(row[key], 200))) throw excelImportError(row, '姓名、性别、证件号码和手机号必填');
|
if (required.some(key => !cleanText(row[key], 200))) throw excelImportError(row, '姓名、性别、证件号码和手机号必填');
|
||||||
|
const region = resolveRegion(row);
|
||||||
|
if (!region) throw excelImportError(row, '省、市或区县代码无效,或上下级不匹配');
|
||||||
Object.assign(profile, {
|
Object.assign(profile, {
|
||||||
name: cleanText(row.name, 50), gender: cleanText(row.gender, 10), idNumber: cleanText(row.idNumber, 40), phone: cleanText(row.phone, 30),
|
name: cleanText(row.name, 50), gender: cleanText(row.gender, 10), idNumber: cleanText(row.idNumber, 40), phone: cleanText(row.phone, 30),
|
||||||
email: cleanText(row.email, 100), nativePlace: cleanText(row.nativePlace, 100), address: cleanText(row.address, 200), classId: schoolClass.id,
|
email: cleanText(row.email, 100), nativePlace: cleanText(row.nativePlace, 100), ...region,
|
||||||
|
address: cleanText(row.address, 200), classId: schoolClass.id,
|
||||||
grade: schoolClass.name, ethnicity: cleanText(row.ethnicity, 30), birthDate: cleanText(row.birthDate, 20), postalCode: cleanText(row.postalCode, 20),
|
grade: schoolClass.name, ethnicity: cleanText(row.ethnicity, 30), birthDate: cleanText(row.birthDate, 20), postalCode: cleanText(row.postalCode, 20),
|
||||||
guardianName: cleanText(row.guardianName, 50), guardianPhone: cleanText(row.guardianPhone, 30), profileCompleted: true, status: 'pending', reviewNote: '', reviewedAt: null, reviewerId: null, updatedAt: nowIso()
|
guardianName: cleanText(row.guardianName, 50), guardianPhone: cleanText(row.guardianPhone, 30), profileCompleted: true, status: 'pending', reviewNote: '', reviewedAt: null, reviewerId: null, updatedAt: nowIso()
|
||||||
});
|
});
|
||||||
@@ -458,7 +469,9 @@ async function importExcelResource(db, user, resource, rows) {
|
|||||||
if (existing && existing.schoolId !== school.id) throw excelImportError(first, '考点代码已属于其他学校');
|
if (existing && existing.schoolId !== school.id) throw excelImportError(first, '考点代码已属于其他学校');
|
||||||
if (existing && db.centerChangeRequests.some(item => item.centerId === existing.id && item.status === 'pending')) throw excelImportError(first, '该考点已有待审批变更');
|
if (existing && db.centerChangeRequests.some(item => item.centerId === existing.id && item.status === 'pending')) throw excelImportError(first, '该考点已有待审批变更');
|
||||||
const body = {
|
const body = {
|
||||||
schoolId: school.id, code: centerCode, name: first.centerName, address: first.address, managerName: first.managerName,
|
schoolId: school.id, code: centerCode, name: first.centerName,
|
||||||
|
provinceCode: first.provinceCode, cityCode: first.cityCode, districtCode: first.districtCode,
|
||||||
|
address: first.address, managerName: first.managerName,
|
||||||
managerPhone: first.managerPhone, contact: first.contact, emergencyPhone: first.emergencyPhone, gateOpenTime: first.gateOpenTime,
|
managerPhone: first.managerPhone, contact: first.contact, emergencyPhone: first.emergencyPhone, gateOpenTime: first.gateOpenTime,
|
||||||
transport: first.transport, status: first.centerStatus === '停用' ? 'inactive' : 'active', notes: first.centerNotes,
|
transport: first.transport, status: first.centerStatus === '停用' ? 'inactive' : 'active', notes: first.centerNotes,
|
||||||
rooms: centerRows.map(row => ({ code: row.roomCode, name: row.roomName, building: row.building, floor: row.floor, capacity: Number(row.capacity), seatPlan: row.seatPlan,
|
rooms: centerRows.map(row => ({ code: row.roomCode, name: row.roomName, building: row.building, floor: row.floor, capacity: Number(row.capacity), seatPlan: row.seatPlan,
|
||||||
@@ -554,7 +567,8 @@ const routeContext = {
|
|||||||
hasExcelResource,
|
hasExcelResource,
|
||||||
parseWorkbook,
|
parseWorkbook,
|
||||||
adminLevelNames,
|
adminLevelNames,
|
||||||
permissionsByLevel
|
permissionsByLevel,
|
||||||
|
resolveRegion
|
||||||
};
|
};
|
||||||
const handlePublic = createPublicRoutes(routeContext);
|
const handlePublic = createPublicRoutes(routeContext);
|
||||||
const handleAuth = createAuthRoutes(routeContext);
|
const handleAuth = createAuthRoutes(routeContext);
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { formatRegionAddress } from './region-select.mjs';
|
||||||
|
|
||||||
export function createAdminViews(context) {
|
export function createAdminViews(context) {
|
||||||
const {
|
const {
|
||||||
state,
|
state,
|
||||||
@@ -101,7 +103,10 @@ export function createAdminViews(context) {
|
|||||||
|
|
||||||
function adminCenters(data) {
|
function adminCenters(data) {
|
||||||
const roomTypeNames = { standard: '标准考场', computer: '机考考场', accessible: '无障碍考场', spare: '备用考场' };
|
const roomTypeNames = { standard: '标准考场', computer: '机考考场', accessible: '无障碍考场', spare: '备用考场' };
|
||||||
|
const detailAddresses = new Map(data.centers.map(center => [center.id, center.address]));
|
||||||
|
data.centers.forEach(center => { center.address = formatRegionAddress(center); });
|
||||||
const cards = data.centers.map(center => `<article class="panel center-dossier"><header><div><span>${h(center.schoolName)} · <b class="mono">${h(center.code)}</b></span><h2>${h(center.name)}</h2></div><div>${center.pendingChange ? '<span class="pending-mark">变更审批中</span>' : ''}${badge(center.status === 'active' ? 'approved' : 'closed')}<button class="row-action" data-action="edit-center" data-id="${h(center.id)}" ${center.pendingChange ? 'disabled title="已有待审批变更"' : ''}>提交变更</button></div></header><div class="center-metrics"><div><small>结构化考场</small><strong>${center.rooms.length}</strong><span>个</span></div><div><small>启用席位</small><strong>${center.totalCapacity}</strong><span>席</span></div><div><small>开放时间</small><strong>${h(center.gateOpenTime || '未设')}</strong></div></div><dl class="center-profile"><div><dt>详细地址</dt><dd>${h(center.address)}</dd></div><div><dt>考点负责人</dt><dd>${h(center.managerName || '未填写')} · ${h(center.managerPhone || center.contact || '未填写')}</dd></div><div><dt>应急电话</dt><dd>${h(center.emergencyPhone || '未填写')}</dd></div><div><dt>交通提示</dt><dd>${h(center.transport || '未填写')}</dd></div></dl><div class="room-table-wrap"><table class="room-table"><thead><tr><th>考场</th><th>位置</th><th>类型</th><th>容量</th><th>座位编排</th><th>状态</th></tr></thead><tbody>${center.rooms.map(room => `<tr><td><strong>${h(room.name)}</strong><small class="mono">${h(room.code)}</small></td><td>${h(room.building)} · ${h(room.floor || '楼层未填')}</td><td>${h(roomTypeNames[room.roomType] || room.roomType)}</td><td>${h(room.capacity)} 席</td><td>${h(room.seatPlan || '按现场座次表编排')}</td><td>${badge(room.status === 'active' ? 'approved' : 'closed')}</td></tr>`).join('')}</tbody></table></div><footer><span>${h(center.notes || '无补充说明')}</span><time>更新于 ${formatDate(center.updatedAt, true)}</time></footer></article>`).join('');
|
const cards = data.centers.map(center => `<article class="panel center-dossier"><header><div><span>${h(center.schoolName)} · <b class="mono">${h(center.code)}</b></span><h2>${h(center.name)}</h2></div><div>${center.pendingChange ? '<span class="pending-mark">变更审批中</span>' : ''}${badge(center.status === 'active' ? 'approved' : 'closed')}<button class="row-action" data-action="edit-center" data-id="${h(center.id)}" ${center.pendingChange ? 'disabled title="已有待审批变更"' : ''}>提交变更</button></div></header><div class="center-metrics"><div><small>结构化考场</small><strong>${center.rooms.length}</strong><span>个</span></div><div><small>启用席位</small><strong>${center.totalCapacity}</strong><span>席</span></div><div><small>开放时间</small><strong>${h(center.gateOpenTime || '未设')}</strong></div></div><dl class="center-profile"><div><dt>详细地址</dt><dd>${h(center.address)}</dd></div><div><dt>考点负责人</dt><dd>${h(center.managerName || '未填写')} · ${h(center.managerPhone || center.contact || '未填写')}</dd></div><div><dt>应急电话</dt><dd>${h(center.emergencyPhone || '未填写')}</dd></div><div><dt>交通提示</dt><dd>${h(center.transport || '未填写')}</dd></div></dl><div class="room-table-wrap"><table class="room-table"><thead><tr><th>考场</th><th>位置</th><th>类型</th><th>容量</th><th>座位编排</th><th>状态</th></tr></thead><tbody>${center.rooms.map(room => `<tr><td><strong>${h(room.name)}</strong><small class="mono">${h(room.code)}</small></td><td>${h(room.building)} · ${h(room.floor || '楼层未填')}</td><td>${h(roomTypeNames[room.roomType] || room.roomType)}</td><td>${h(room.capacity)} 席</td><td>${h(room.seatPlan || '按现场座次表编排')}</td><td>${badge(room.status === 'active' ? 'approved' : 'closed')}</td></tr>`).join('')}</tbody></table></div><footer><span>${h(center.notes || '无补充说明')}</span><time>更新于 ${formatDate(center.updatedAt, true)}</time></footer></article>`).join('');
|
||||||
|
data.centers.forEach(center => { center.address = detailAddresses.get(center.id); });
|
||||||
const requests = data.changeRequests || [];
|
const requests = data.changeRequests || [];
|
||||||
return `${excelToolbar('centers', { label: '考点考场档案' })}<section class="center-summary"><div><span>正式考点</span><strong>${data.centers.length}</strong></div><div><span>结构化考场</span><strong>${data.centers.reduce((sum, item) => sum + item.rooms.length, 0)}</strong></div><div><span>待审批变更</span><strong>${requests.filter(item => item.status === 'pending').length}</strong></div></section><div class="center-dossier-grid">${cards || emptyState('还没有正式考点', '提交考点和考场档案,经流程审批后会显示在这里。')}</div><section class="panel center-change-ledger"><div class="panel-title"><div><h2>考点变更台账</h2><p>新增和修改均保留申请快照,审批通过后才更新正式档案。</p></div><button class="row-action" data-route="admin/flows">进入流程中心</button></div><div class="table-scroll"><table><thead><tr><th>申请类型</th><th>考点</th><th>学校</th><th>考场数</th><th>提交时间</th><th>当前状态</th><th>责任人</th></tr></thead><tbody>${requests.map(item => `<tr><td>${item.requestType === 'create' ? '新增考点' : '修改档案'}</td><td><strong>${h(item.name)}</strong><small class="mono">${h(item.code)}</small></td><td>${h(item.schoolName)}</td><td>${item.rooms.length} 个</td><td>${formatDate(item.createdAt, true)}</td><td>${badge(item.status)}</td><td>${h(item.workflow?.assignee?.displayName || '流程已结束')}</td></tr>`).join('') || '<tr><td colspan="7" class="empty-state">暂无考点变更申请</td></tr>'}</tbody></table></div></section>`;
|
return `${excelToolbar('centers', { label: '考点考场档案' })}<section class="center-summary"><div><span>正式考点</span><strong>${data.centers.length}</strong></div><div><span>结构化考场</span><strong>${data.centers.reduce((sum, item) => sum + item.rooms.length, 0)}</strong></div><div><span>待审批变更</span><strong>${requests.filter(item => item.status === 'pending').length}</strong></div></section><div class="center-dossier-grid">${cards || emptyState('还没有正式考点', '提交考点和考场档案,经流程审批后会显示在这里。')}</div><section class="panel center-change-ledger"><div class="panel-title"><div><h2>考点变更台账</h2><p>新增和修改均保留申请快照,审批通过后才更新正式档案。</p></div><button class="row-action" data-route="admin/flows">进入流程中心</button></div><div class="table-scroll"><table><thead><tr><th>申请类型</th><th>考点</th><th>学校</th><th>考场数</th><th>提交时间</th><th>当前状态</th><th>责任人</th></tr></thead><tbody>${requests.map(item => `<tr><td>${item.requestType === 'create' ? '新增考点' : '修改档案'}</td><td><strong>${h(item.name)}</strong><small class="mono">${h(item.code)}</small></td><td>${h(item.schoolName)}</td><td>${item.rooms.length} 个</td><td>${formatDate(item.createdAt, true)}</td><td>${badge(item.status)}</td><td>${h(item.workflow?.assignee?.displayName || '流程已结束')}</td></tr>`).join('') || '<tr><td colspan="7" class="empty-state">暂无考点变更申请</td></tr>'}</tbody></table></div></section>`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { mountRegionSelects } from './region-select.mjs';
|
||||||
|
|
||||||
export function createCandidateViews(context) {
|
export function createCandidateViews(context) {
|
||||||
const {
|
const {
|
||||||
state,
|
state,
|
||||||
@@ -70,6 +72,7 @@ export function createCandidateViews(context) {
|
|||||||
const data = await api('/api/candidate/profile');
|
const data = await api('/api/candidate/profile');
|
||||||
state.pageData = data; state.profile = data.profile;
|
state.pageData = data; state.profile = data.profile;
|
||||||
app.innerHTML = onboardingShell('profile', candidateProfile(data, true));
|
app.innerHTML = onboardingShell('profile', candidateProfile(data, true));
|
||||||
|
mountRegionSelects(app, data.profile, { className: 'region-selects wide-field' });
|
||||||
} catch (error) { renderError(error); }
|
} catch (error) { renderError(error); }
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -95,6 +98,7 @@ export function createCandidateViews(context) {
|
|||||||
results: () => candidateResults(data), notices: () => candidateNotices(data.notices)
|
results: () => candidateResults(data), notices: () => candidateNotices(data.notices)
|
||||||
}[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' });
|
||||||
} catch (error) { renderError(error); }
|
} catch (error) { renderError(error); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { chinaRegions } from '../data/china-regions.mjs';
|
||||||
|
|
||||||
|
const option = (value, label, selected = false) => `<option value="${value}" ${selected ? 'selected' : ''}>${label}</option>`;
|
||||||
|
|
||||||
|
export function regionSelects(region = {}, { required = true, className = 'region-selects' } = {}) {
|
||||||
|
const province = chinaRegions.find(item => item.code === region.provinceCode);
|
||||||
|
const city = province?.cities.find(item => item.code === region.cityCode);
|
||||||
|
const requiredText = required ? 'required' : '';
|
||||||
|
return `<div class="${className}" data-region-group>
|
||||||
|
<label><span>省 / 自治区 / 直辖市${required ? ' *' : ''}</span><select name="provinceCode" data-region-level="province" ${requiredText}><option value="">请选择省份</option>${chinaRegions.map(item => option(item.code, item.name, item.code === region.provinceCode)).join('')}</select></label>
|
||||||
|
<label><span>市 / 州${required ? ' *' : ''}</span><select name="cityCode" data-region-level="city" ${requiredText}><option value="">请先选择省份</option>${(province?.cities || []).map(item => option(item.code, item.name, item.code === region.cityCode)).join('')}</select></label>
|
||||||
|
<label><span>区 / 县${required ? ' *' : ''}</span><select name="districtCode" data-region-level="district" ${requiredText}><option value="">请先选择城市</option>${(city?.districts || []).map(item => option(item.code, item.name, item.code === region.districtCode)).join('')}</select></label>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateRegionSelects(select) {
|
||||||
|
const group = select.closest('[data-region-group]');
|
||||||
|
if (!group) return;
|
||||||
|
const provinceSelect = group.querySelector('[name="provinceCode"]');
|
||||||
|
const citySelect = group.querySelector('[name="cityCode"]');
|
||||||
|
const districtSelect = group.querySelector('[name="districtCode"]');
|
||||||
|
const province = chinaRegions.find(item => item.code === provinceSelect?.value);
|
||||||
|
if (select.dataset.regionLevel === 'province') {
|
||||||
|
citySelect.innerHTML = `<option value="">请选择城市</option>${(province?.cities || []).map(item => option(item.code, item.name)).join('')}`;
|
||||||
|
districtSelect.innerHTML = '<option value="">请先选择城市</option>';
|
||||||
|
} else if (select.dataset.regionLevel === 'city') {
|
||||||
|
const city = province?.cities.find(item => item.code === citySelect?.value);
|
||||||
|
districtSelect.innerHTML = `<option value="">请选择区县</option>${(city?.districts || []).map(item => option(item.code, item.name)).join('')}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mountRegionSelects(root, region = {}, options = {}) {
|
||||||
|
const address = root?.querySelector('[name="address"]');
|
||||||
|
if (!address || root.querySelector('[data-region-group]')) return;
|
||||||
|
address.closest('label')?.insertAdjacentHTML('beforebegin', regionSelects(region, options));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatRegionAddress(region = {}) {
|
||||||
|
const parts = [region.provinceName, region.cityName, region.districtName]
|
||||||
|
.filter((value, index, values) => value && value !== values[index - 1]);
|
||||||
|
return [...parts, region.address].filter(Boolean).join('');
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,25 @@
|
|||||||
|
import { chinaRegions } from './china-regions.mjs';
|
||||||
|
|
||||||
|
export function resolveRegion(input = {}) {
|
||||||
|
const provinceCode = String(input.provinceCode || '').trim();
|
||||||
|
const cityCode = String(input.cityCode || '').trim();
|
||||||
|
const districtCode = String(input.districtCode || '').trim();
|
||||||
|
const province = chinaRegions.find(item => item.code === provinceCode);
|
||||||
|
const city = province?.cities.find(item => item.code === cityCode);
|
||||||
|
const district = city?.districts.find(item => item.code === districtCode);
|
||||||
|
if (!province || !city || !district) return null;
|
||||||
|
return {
|
||||||
|
provinceCode: province.code,
|
||||||
|
provinceName: province.name,
|
||||||
|
cityCode: city.code,
|
||||||
|
cityName: city.name,
|
||||||
|
districtCode: district.code,
|
||||||
|
districtName: district.name
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function regionLabel(region = {}) {
|
||||||
|
return [region.provinceName, region.cityName, region.districtName]
|
||||||
|
.filter((value, index, values) => value && value !== values[index - 1])
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
+8
-7
@@ -6,17 +6,17 @@ export function createSeedDatabase({ nowIso, hashPassword }) {
|
|||||||
const examId = 'exam_autumn_2026';
|
const examId = 'exam_autumn_2026';
|
||||||
const registrationId = 'reg_demo_2026';
|
const registrationId = 'reg_demo_2026';
|
||||||
return {
|
return {
|
||||||
meta: { version: 6, createdAt: nowIso() },
|
meta: { version: 7, createdAt: nowIso() },
|
||||||
settings: { selfRegistrationEnabled: false },
|
settings: { selfRegistrationEnabled: false },
|
||||||
organization: {
|
organization: {
|
||||||
name: '海州市教育考试中心',
|
name: '海州市教育考试中心',
|
||||||
code: 'HZ-EDU-032',
|
code: 'HZ-EDU-032',
|
||||||
phone: '0518-8602 3158',
|
phone: '0518-8602 3158',
|
||||||
address: '海州市清河区文教路 18 号'
|
address: '江苏省连云港市海州区文教路 18 号'
|
||||||
},
|
},
|
||||||
schools: [
|
schools: [
|
||||||
{ id: 'school_hz1', name: '海州市第一中学', code: 'HZ01', address: '海州市清河区学府路 8 号', active: true },
|
{ id: 'school_hz1', name: '海州市第一中学', code: 'HZ01', address: '江苏省连云港市海州区学府路 8 号', active: true },
|
||||||
{ id: 'school_hz3', name: '海州市第三中学', code: 'HZ03', address: '海州市滨河区育才路 16 号', active: true }
|
{ id: 'school_hz3', name: '海州市第三中学', code: 'HZ03', address: '江苏省连云港市连云区育才路 16 号', active: true }
|
||||||
],
|
],
|
||||||
classes: [
|
classes: [
|
||||||
{ id: 'class_hz1_301', schoolId: 'school_hz1', name: '高三(1)班', grade: '高三', active: true },
|
{ id: 'class_hz1_301', schoolId: 'school_hz1', name: '高三(1)班', grade: '高三', active: true },
|
||||||
@@ -35,7 +35,8 @@ export function createSeedDatabase({ nowIso, hashPassword }) {
|
|||||||
{
|
{
|
||||||
id: 'profile_demo', userId: candidateId, name: '周雨桐', gender: '女', idNumber: '320101200808164821',
|
id: 'profile_demo', userId: candidateId, name: '周雨桐', gender: '女', idNumber: '320101200808164821',
|
||||||
phone: '13800138000', email: 'zhou@example.com', school: '海州市第一中学', grade: '高三(2)班', schoolId: 'school_hz1', classId: 'class_hz1_302',
|
phone: '13800138000', email: 'zhou@example.com', school: '海州市第一中学', grade: '高三(2)班', schoolId: 'school_hz1', classId: 'class_hz1_302',
|
||||||
address: '海州市清河区', emergencyContact: '周建国', emergencyPhone: '13900139000',
|
provinceCode: '320000', provinceName: '江苏省', cityCode: '320700', cityName: '连云港市', districtCode: '320706', districtName: '海州区',
|
||||||
|
address: '学府路 8 号', emergencyContact: '周建国', emergencyPhone: '13900139000',
|
||||||
nativePlace: '江苏海州', birthDate: '2008-08-16', ethnicity: '汉族', postalCode: '222000', guardianName: '周建国', guardianPhone: '13900139000', profileCompleted: false,
|
nativePlace: '江苏海州', birthDate: '2008-08-16', ethnicity: '汉族', postalCode: '222000', guardianName: '周建国', guardianPhone: '13900139000', profileCompleted: false,
|
||||||
status: 'approved', reviewNote: '身份信息与学籍信息核验一致', reviewedAt: '2026-07-18T08:30:00.000Z', updatedAt: '2026-07-17T09:20:00.000Z'
|
status: 'approved', reviewNote: '身份信息与学籍信息核验一致', reviewedAt: '2026-07-18T08:30:00.000Z', updatedAt: '2026-07-17T09:20:00.000Z'
|
||||||
}
|
}
|
||||||
@@ -82,8 +83,8 @@ export function createSeedDatabase({ nowIso, hashPassword }) {
|
|||||||
{ id: 'result_demo_2', registrationId, subjectId: 'sub_math', score: 132, grade: 'A', published: true, publishedAt: '2026-07-19T03:00:00.000Z' }
|
{ id: 'result_demo_2', registrationId, subjectId: 'sub_math', score: 132, grade: 'A', published: true, publishedAt: '2026-07-19T03:00:00.000Z' }
|
||||||
],
|
],
|
||||||
testCenters: [
|
testCenters: [
|
||||||
{ id: 'center_hz1', schoolId: 'school_hz1', code: 'HZ01-C01', name: '海州市第一中学考点', address: '海州市清河区学府路 8 号', contact: '0518-8602 1101', managerName: '王立新', managerPhone: '13800001101', emergencyPhone: '0518-8602 1190', gateOpenTime: '07:00', transport: '地铁 2 号线学府路站 2 号口,步行约 600 米', status: 'active', notes: '南门为考生唯一入口,无障碍通道位于东侧。', rooms: '教学楼 A:001、002;实验楼:机考 01', updatedAt: nowIso() },
|
{ id: 'center_hz1', schoolId: 'school_hz1', code: 'HZ01-C01', name: '海州市第一中学考点', provinceCode: '320000', provinceName: '江苏省', cityCode: '320700', cityName: '连云港市', districtCode: '320706', districtName: '海州区', address: '学府路 8 号', contact: '0518-8602 1101', managerName: '王立新', managerPhone: '13800001101', emergencyPhone: '0518-8602 1190', gateOpenTime: '07:00', transport: '地铁 2 号线学府路站 2 号口,步行约 600 米', status: 'active', notes: '南门为考生唯一入口,无障碍通道位于东侧。', rooms: '教学楼 A:001、002;实验楼:机考 01', updatedAt: nowIso() },
|
||||||
{ id: 'center_hz3', schoolId: 'school_hz3', code: 'HZ03-C01', name: '海州市第三中学考点', address: '海州市滨河区育才路 16 号', contact: '0518-8602 3301', managerName: '李文峰', managerPhone: '13800003301', emergencyPhone: '0518-8602 3390', gateOpenTime: '07:10', transport: '公交 18、32 路育才路站,考点不提供社会车辆停车位', status: 'active', notes: '西门设置临时物品存放区。', rooms: '笃学楼:001、002', updatedAt: nowIso() }
|
{ id: 'center_hz3', schoolId: 'school_hz3', code: 'HZ03-C01', name: '海州市第三中学考点', provinceCode: '320000', provinceName: '江苏省', cityCode: '320700', cityName: '连云港市', districtCode: '320703', districtName: '连云区', address: '育才路 16 号', contact: '0518-8602 3301', managerName: '李文峰', managerPhone: '13800003301', emergencyPhone: '0518-8602 3390', gateOpenTime: '07:10', transport: '公交 18、32 路育才路站,考点不提供社会车辆停车位', status: 'active', notes: '西门设置临时物品存放区。', rooms: '笃学楼:001、002', updatedAt: nowIso() }
|
||||||
],
|
],
|
||||||
testRooms: [
|
testRooms: [
|
||||||
{ id: 'room_hz1_001', centerId: 'center_hz1', code: '001', name: '第 001 考场', building: '教学楼 A', floor: '1 层', capacity: 30, seatPlan: '按现场桌贴从前至后编排', roomType: 'standard', status: 'active', notes: '' },
|
{ id: 'room_hz1_001', centerId: 'center_hz1', code: '001', name: '第 001 考场', building: '教学楼 A', floor: '1 层', capacity: 30, seatPlan: '按现场桌贴从前至后编排', roomType: 'standard', status: 'active', notes: '' },
|
||||||
|
|||||||
@@ -54,6 +54,12 @@ export function createMysqlAdapter(context) {
|
|||||||
'ALTER TABLE candidate_profiles ADD COLUMN IF NOT EXISTS guardian_name VARCHAR(100) NULL',
|
'ALTER TABLE candidate_profiles ADD COLUMN IF NOT EXISTS guardian_name VARCHAR(100) NULL',
|
||||||
'ALTER TABLE candidate_profiles ADD COLUMN IF NOT EXISTS guardian_phone VARCHAR(60) NULL',
|
'ALTER TABLE candidate_profiles ADD COLUMN IF NOT EXISTS guardian_phone VARCHAR(60) NULL',
|
||||||
'ALTER TABLE candidate_profiles ADD COLUMN IF NOT EXISTS profile_completed BOOLEAN NOT NULL DEFAULT FALSE',
|
'ALTER TABLE candidate_profiles ADD COLUMN IF NOT EXISTS profile_completed BOOLEAN NOT NULL DEFAULT FALSE',
|
||||||
|
'ALTER TABLE candidate_profiles ADD COLUMN IF NOT EXISTS province_code VARCHAR(6) NULL',
|
||||||
|
'ALTER TABLE candidate_profiles ADD COLUMN IF NOT EXISTS province_name VARCHAR(80) NULL',
|
||||||
|
'ALTER TABLE candidate_profiles ADD COLUMN IF NOT EXISTS city_code VARCHAR(6) NULL',
|
||||||
|
'ALTER TABLE candidate_profiles ADD COLUMN IF NOT EXISTS city_name VARCHAR(100) NULL',
|
||||||
|
'ALTER TABLE candidate_profiles ADD COLUMN IF NOT EXISTS district_code VARCHAR(6) NULL',
|
||||||
|
'ALTER TABLE candidate_profiles ADD COLUMN IF NOT EXISTS district_name VARCHAR(100) NULL',
|
||||||
'ALTER TABLE registrations ADD COLUMN IF NOT EXISTS registration_number VARCHAR(120) NULL',
|
'ALTER TABLE registrations ADD COLUMN IF NOT EXISTS registration_number VARCHAR(120) NULL',
|
||||||
'ALTER TABLE registrations ADD COLUMN IF NOT EXISTS number_rule_id VARCHAR(64) NULL',
|
'ALTER TABLE registrations ADD COLUMN IF NOT EXISTS number_rule_id VARCHAR(64) NULL',
|
||||||
"ALTER TABLE exams ADD COLUMN IF NOT EXISTS pass_policy ENUM('fixed_score', 'score_ratio', 'rank_percent', 'subject_scores', 'none') NOT NULL DEFAULT 'score_ratio'",
|
"ALTER TABLE exams ADD COLUMN IF NOT EXISTS pass_policy ENUM('fixed_score', 'score_ratio', 'rank_percent', 'subject_scores', 'none') NOT NULL DEFAULT 'score_ratio'",
|
||||||
@@ -68,6 +74,18 @@ export function createMysqlAdapter(context) {
|
|||||||
'ALTER TABLE test_centers ADD COLUMN IF NOT EXISTS transport VARCHAR(500) NULL',
|
'ALTER TABLE test_centers ADD COLUMN IF NOT EXISTS transport VARCHAR(500) NULL',
|
||||||
"ALTER TABLE test_centers ADD COLUMN IF NOT EXISTS status ENUM('active', 'inactive') NOT NULL DEFAULT 'active'",
|
"ALTER TABLE test_centers ADD COLUMN IF NOT EXISTS status ENUM('active', 'inactive') NOT NULL DEFAULT 'active'",
|
||||||
'ALTER TABLE test_centers ADD COLUMN IF NOT EXISTS notes VARCHAR(1000) NULL',
|
'ALTER TABLE test_centers ADD COLUMN IF NOT EXISTS notes VARCHAR(1000) NULL',
|
||||||
|
'ALTER TABLE test_centers ADD COLUMN IF NOT EXISTS province_code VARCHAR(6) NULL',
|
||||||
|
'ALTER TABLE test_centers ADD COLUMN IF NOT EXISTS province_name VARCHAR(80) NULL',
|
||||||
|
'ALTER TABLE test_centers ADD COLUMN IF NOT EXISTS city_code VARCHAR(6) NULL',
|
||||||
|
'ALTER TABLE test_centers ADD COLUMN IF NOT EXISTS city_name VARCHAR(100) NULL',
|
||||||
|
'ALTER TABLE test_centers ADD COLUMN IF NOT EXISTS district_code VARCHAR(6) NULL',
|
||||||
|
'ALTER TABLE test_centers ADD COLUMN IF NOT EXISTS district_name VARCHAR(100) NULL',
|
||||||
|
'ALTER TABLE center_change_requests ADD COLUMN IF NOT EXISTS province_code VARCHAR(6) NULL',
|
||||||
|
'ALTER TABLE center_change_requests ADD COLUMN IF NOT EXISTS province_name VARCHAR(80) NULL',
|
||||||
|
'ALTER TABLE center_change_requests ADD COLUMN IF NOT EXISTS city_code VARCHAR(6) NULL',
|
||||||
|
'ALTER TABLE center_change_requests ADD COLUMN IF NOT EXISTS city_name VARCHAR(100) NULL',
|
||||||
|
'ALTER TABLE center_change_requests ADD COLUMN IF NOT EXISTS district_code VARCHAR(6) NULL',
|
||||||
|
'ALTER TABLE center_change_requests ADD COLUMN IF NOT EXISTS district_name VARCHAR(100) NULL',
|
||||||
'ALTER TABLE test_rooms ADD COLUMN IF NOT EXISTS seat_plan VARCHAR(500) NULL',
|
'ALTER TABLE test_rooms ADD COLUMN IF NOT EXISTS seat_plan VARCHAR(500) NULL',
|
||||||
'ALTER TABLE center_change_rooms ADD COLUMN IF NOT EXISTS seat_plan VARCHAR(500) NULL',
|
'ALTER TABLE center_change_rooms ADD COLUMN IF NOT EXISTS seat_plan VARCHAR(500) NULL',
|
||||||
"ALTER TABLE workflow_definitions MODIFY COLUMN business_type ENUM('profile_change', 'registration_review', 'center_change', 'candidate_account_batch') NOT NULL"
|
"ALTER TABLE workflow_definitions MODIFY COLUMN business_type ENUM('profile_change', 'registration_review', 'center_change', 'candidate_account_batch') NOT NULL"
|
||||||
@@ -246,6 +264,9 @@ export function createMysqlAdapter(context) {
|
|||||||
if (Number(metadataRows[0]?.app_version || 1) < 6) {
|
if (Number(metadataRows[0]?.app_version || 1) < 6) {
|
||||||
await pool.execute('UPDATE schema_metadata SET schema_version = 6, app_version = 6 WHERE id = 1');
|
await pool.execute('UPDATE schema_metadata SET schema_version = 6, app_version = 6 WHERE id = 1');
|
||||||
}
|
}
|
||||||
|
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 (!existing.length) {
|
if (!existing.length) {
|
||||||
const initialState = seed();
|
const initialState = seed();
|
||||||
@@ -254,7 +275,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, 6, ?, ?, ?)
|
VALUES (1, 7, ?, ?, ?)
|
||||||
`, [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);
|
||||||
|
|||||||
@@ -61,6 +61,12 @@ export const sqliteSchema = `
|
|||||||
grade TEXT,
|
grade TEXT,
|
||||||
school_id TEXT REFERENCES schools(id) ON DELETE SET NULL,
|
school_id TEXT REFERENCES schools(id) ON DELETE SET NULL,
|
||||||
class_id TEXT REFERENCES school_classes(id) ON DELETE SET NULL,
|
class_id TEXT REFERENCES school_classes(id) ON DELETE SET NULL,
|
||||||
|
province_code TEXT,
|
||||||
|
province_name TEXT,
|
||||||
|
city_code TEXT,
|
||||||
|
city_name TEXT,
|
||||||
|
district_code TEXT,
|
||||||
|
district_name TEXT,
|
||||||
address TEXT,
|
address TEXT,
|
||||||
emergency_contact TEXT,
|
emergency_contact TEXT,
|
||||||
emergency_phone TEXT,
|
emergency_phone TEXT,
|
||||||
@@ -169,6 +175,12 @@ export const sqliteSchema = `
|
|||||||
school_id TEXT NOT NULL REFERENCES schools(id) ON DELETE CASCADE,
|
school_id TEXT NOT NULL REFERENCES schools(id) ON DELETE CASCADE,
|
||||||
code TEXT NOT NULL UNIQUE,
|
code TEXT NOT NULL UNIQUE,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
|
province_code TEXT NOT NULL,
|
||||||
|
province_name TEXT NOT NULL,
|
||||||
|
city_code TEXT NOT NULL,
|
||||||
|
city_name TEXT NOT NULL,
|
||||||
|
district_code TEXT NOT NULL,
|
||||||
|
district_name TEXT NOT NULL,
|
||||||
address TEXT NOT NULL,
|
address TEXT NOT NULL,
|
||||||
contact TEXT,
|
contact TEXT,
|
||||||
manager_name TEXT,
|
manager_name TEXT,
|
||||||
@@ -207,6 +219,12 @@ export const sqliteSchema = `
|
|||||||
request_type TEXT NOT NULL CHECK (request_type IN ('create', 'update')),
|
request_type TEXT NOT NULL CHECK (request_type IN ('create', 'update')),
|
||||||
code TEXT NOT NULL,
|
code TEXT NOT NULL,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
|
province_code TEXT NOT NULL,
|
||||||
|
province_name TEXT NOT NULL,
|
||||||
|
city_code TEXT NOT NULL,
|
||||||
|
city_name TEXT NOT NULL,
|
||||||
|
district_code TEXT NOT NULL,
|
||||||
|
district_name TEXT NOT NULL,
|
||||||
address TEXT NOT NULL,
|
address TEXT NOT NULL,
|
||||||
contact TEXT,
|
contact TEXT,
|
||||||
manager_name TEXT,
|
manager_name TEXT,
|
||||||
@@ -420,6 +438,12 @@ export const mysqlSchema = [
|
|||||||
grade VARCHAR(100) NULL,
|
grade VARCHAR(100) NULL,
|
||||||
school_id VARCHAR(64) NULL,
|
school_id VARCHAR(64) NULL,
|
||||||
class_id VARCHAR(64) NULL,
|
class_id VARCHAR(64) NULL,
|
||||||
|
province_code VARCHAR(6) NULL,
|
||||||
|
province_name VARCHAR(80) NULL,
|
||||||
|
city_code VARCHAR(6) NULL,
|
||||||
|
city_name VARCHAR(100) NULL,
|
||||||
|
district_code VARCHAR(6) NULL,
|
||||||
|
district_name VARCHAR(100) NULL,
|
||||||
address VARCHAR(255) NULL,
|
address VARCHAR(255) NULL,
|
||||||
emergency_contact VARCHAR(100) NULL,
|
emergency_contact VARCHAR(100) NULL,
|
||||||
emergency_phone VARCHAR(60) NULL,
|
emergency_phone VARCHAR(60) NULL,
|
||||||
@@ -551,6 +575,12 @@ export const mysqlSchema = [
|
|||||||
school_id VARCHAR(64) NOT NULL,
|
school_id VARCHAR(64) NOT NULL,
|
||||||
code VARCHAR(40) NOT NULL,
|
code VARCHAR(40) NOT NULL,
|
||||||
name VARCHAR(200) NOT NULL,
|
name VARCHAR(200) NOT NULL,
|
||||||
|
province_code VARCHAR(6) NOT NULL,
|
||||||
|
province_name VARCHAR(80) NOT NULL,
|
||||||
|
city_code VARCHAR(6) NOT NULL,
|
||||||
|
city_name VARCHAR(100) NOT NULL,
|
||||||
|
district_code VARCHAR(6) NOT NULL,
|
||||||
|
district_name VARCHAR(100) NOT NULL,
|
||||||
address VARCHAR(255) NOT NULL,
|
address VARCHAR(255) NOT NULL,
|
||||||
contact VARCHAR(100) NULL,
|
contact VARCHAR(100) NULL,
|
||||||
manager_name VARCHAR(100) NULL,
|
manager_name VARCHAR(100) NULL,
|
||||||
@@ -593,6 +623,12 @@ export const mysqlSchema = [
|
|||||||
request_type ENUM('create', 'update') NOT NULL,
|
request_type ENUM('create', 'update') NOT NULL,
|
||||||
code VARCHAR(40) NOT NULL,
|
code VARCHAR(40) NOT NULL,
|
||||||
name VARCHAR(200) NOT NULL,
|
name VARCHAR(200) NOT NULL,
|
||||||
|
province_code VARCHAR(6) NOT NULL,
|
||||||
|
province_name VARCHAR(80) NOT NULL,
|
||||||
|
city_code VARCHAR(6) NOT NULL,
|
||||||
|
city_name VARCHAR(100) NOT NULL,
|
||||||
|
district_code VARCHAR(6) NOT NULL,
|
||||||
|
district_name VARCHAR(100) NOT NULL,
|
||||||
address VARCHAR(255) NOT NULL,
|
address VARCHAR(255) NOT NULL,
|
||||||
contact VARCHAR(100) NULL,
|
contact VARCHAR(100) NULL,
|
||||||
manager_name VARCHAR(100) NULL,
|
manager_name VARCHAR(100) NULL,
|
||||||
|
|||||||
@@ -34,7 +34,9 @@ export function createSqliteAdapter(context) {
|
|||||||
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', [
|
||||||
['school_id', 'TEXT'], ['class_id', 'TEXT'], ['native_place', 'TEXT'], ['birth_date', 'TEXT'], ['ethnicity', 'TEXT'],
|
['school_id', 'TEXT'], ['class_id', 'TEXT'], ['native_place', 'TEXT'], ['birth_date', 'TEXT'], ['ethnicity', 'TEXT'],
|
||||||
['postal_code', 'TEXT'], ['guardian_name', 'TEXT'], ['guardian_phone', 'TEXT'], ['profile_completed', 'INTEGER NOT NULL DEFAULT 0']
|
['postal_code', 'TEXT'], ['guardian_name', 'TEXT'], ['guardian_phone', 'TEXT'], ['profile_completed', 'INTEGER NOT NULL DEFAULT 0'],
|
||||||
|
['province_code', 'TEXT'], ['province_name', 'TEXT'], ['city_code', 'TEXT'], ['city_name', 'TEXT'],
|
||||||
|
['district_code', 'TEXT'], ['district_name', 'TEXT']
|
||||||
]);
|
]);
|
||||||
ensureColumns('registrations', [['registration_number', 'TEXT'], ['number_rule_id', 'TEXT']]);
|
ensureColumns('registrations', [['registration_number', 'TEXT'], ['number_rule_id', 'TEXT']]);
|
||||||
ensureColumns('exams', [
|
ensureColumns('exams', [
|
||||||
@@ -45,7 +47,13 @@ export function createSqliteAdapter(context) {
|
|||||||
]);
|
]);
|
||||||
ensureColumns('test_centers', [
|
ensureColumns('test_centers', [
|
||||||
['code', 'TEXT'], ['manager_name', 'TEXT'], ['manager_phone', 'TEXT'], ['emergency_phone', 'TEXT'],
|
['code', 'TEXT'], ['manager_name', 'TEXT'], ['manager_phone', 'TEXT'], ['emergency_phone', 'TEXT'],
|
||||||
['gate_open_time', 'TEXT'], ['transport', 'TEXT'], ['status', "TEXT NOT NULL DEFAULT 'active'"], ['notes', 'TEXT']
|
['gate_open_time', 'TEXT'], ['transport', 'TEXT'], ['status', "TEXT NOT NULL DEFAULT 'active'"], ['notes', 'TEXT'],
|
||||||
|
['province_code', 'TEXT'], ['province_name', 'TEXT'], ['city_code', 'TEXT'], ['city_name', 'TEXT'],
|
||||||
|
['district_code', 'TEXT'], ['district_name', 'TEXT']
|
||||||
|
]);
|
||||||
|
ensureColumns('center_change_requests', [
|
||||||
|
['province_code', 'TEXT'], ['province_name', 'TEXT'], ['city_code', 'TEXT'], ['city_name', 'TEXT'],
|
||||||
|
['district_code', 'TEXT'], ['district_name', 'TEXT']
|
||||||
]);
|
]);
|
||||||
ensureColumns('test_rooms', [['seat_plan', 'TEXT']]);
|
ensureColumns('test_rooms', [['seat_plan', 'TEXT']]);
|
||||||
ensureColumns('center_change_rooms', [['seat_plan', 'TEXT']]);
|
ensureColumns('center_change_rooms', [['seat_plan', 'TEXT']]);
|
||||||
@@ -253,6 +261,9 @@ export function createSqliteAdapter(context) {
|
|||||||
if (existingSystem && Number(existingSystem.app_version || 1) < 6) {
|
if (existingSystem && Number(existingSystem.app_version || 1) < 6) {
|
||||||
connection.prepare('UPDATE schema_metadata SET schema_version = 6, app_version = 6 WHERE id = 1').run();
|
connection.prepare('UPDATE schema_metadata SET schema_version = 6, app_version = 6 WHERE id = 1').run();
|
||||||
}
|
}
|
||||||
|
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 (!connection.prepare('SELECT id FROM schema_metadata WHERE id = 1').get()) {
|
if (!connection.prepare('SELECT id FROM schema_metadata WHERE id = 1').get()) {
|
||||||
const initialState = seed();
|
const initialState = seed();
|
||||||
@@ -260,7 +271,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, 6, ?, ?, ?)
|
VALUES (1, 7, ?, ?, ?)
|
||||||
`).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');
|
||||||
|
|||||||
@@ -374,7 +374,10 @@ export function createAdminRoutes(context) {
|
|||||||
const proposedRooms = db.centerChangeRooms.filter(item => item.requestId === change.id);
|
const proposedRooms = db.centerChangeRooms.filter(item => item.requestId === change.id);
|
||||||
const rooms = proposedRooms.map(room => ({ ...room, id: room.roomId || uid('room'), centerId }));
|
const rooms = proposedRooms.map(room => ({ ...room, id: room.roomId || uid('room'), centerId }));
|
||||||
const center = {
|
const center = {
|
||||||
id: centerId, schoolId: change.schoolId, code: change.code, name: change.name, address: change.address,
|
id: centerId, schoolId: change.schoolId, code: change.code, name: change.name,
|
||||||
|
provinceCode: change.provinceCode, provinceName: change.provinceName, cityCode: change.cityCode,
|
||||||
|
cityName: change.cityName, districtCode: change.districtCode, districtName: change.districtName,
|
||||||
|
address: change.address,
|
||||||
contact: change.contact, managerName: change.managerName, managerPhone: change.managerPhone,
|
contact: change.contact, managerName: change.managerName, managerPhone: change.managerPhone,
|
||||||
emergencyPhone: change.emergencyPhone, gateOpenTime: change.gateOpenTime, transport: change.transport,
|
emergencyPhone: change.emergencyPhone, gateOpenTime: change.gateOpenTime, transport: change.transport,
|
||||||
status: change.centerStatus, notes: change.notes,
|
status: change.centerStatus, notes: change.notes,
|
||||||
|
|||||||
@@ -45,7 +45,8 @@ export function createCandidateRoutes(context) {
|
|||||||
buildWorkbook,
|
buildWorkbook,
|
||||||
hasExcelResource,
|
hasExcelResource,
|
||||||
parseWorkbook,
|
parseWorkbook,
|
||||||
adminLevelNames
|
adminLevelNames,
|
||||||
|
resolveRegion
|
||||||
} = context;
|
} = context;
|
||||||
|
|
||||||
async function handleCandidate(request, response, pathname) {
|
async function handleCandidate(request, response, pathname) {
|
||||||
@@ -75,6 +76,9 @@ export function createCandidateRoutes(context) {
|
|||||||
const body = await readJson(request);
|
const body = await readJson(request);
|
||||||
const fields = ['name', 'gender', 'idNumber', 'phone', 'email', 'address', 'emergencyContact', 'emergencyPhone', 'nativePlace', 'birthDate', 'ethnicity', 'postalCode', 'guardianName', 'guardianPhone'];
|
const fields = ['name', 'gender', 'idNumber', 'phone', 'email', 'address', 'emergencyContact', 'emergencyPhone', 'nativePlace', 'birthDate', 'ethnicity', 'postalCode', 'guardianName', 'guardianPhone'];
|
||||||
for (const field of fields) profile[field] = cleanText(body[field], field === 'address' ? 160 : 80);
|
for (const field of fields) profile[field] = cleanText(body[field], field === 'address' ? 160 : 80);
|
||||||
|
const region = resolveRegion(body);
|
||||||
|
if (!region) return sendError(response, 400, '请选择有效的省、市和区县');
|
||||||
|
Object.assign(profile, region);
|
||||||
const school = db.schools.find(item => item.id === cleanText(body.schoolId, 64) && item.active);
|
const school = db.schools.find(item => item.id === cleanText(body.schoolId, 64) && item.active);
|
||||||
const schoolClass = db.classes.find(item => item.id === cleanText(body.classId, 64) && item.schoolId === school?.id && item.active);
|
const schoolClass = db.classes.find(item => item.id === cleanText(body.classId, 64) && item.schoolId === school?.id && item.active);
|
||||||
if (!school || !schoolClass) return sendError(response, 400, '请选择有效的学校和班级');
|
if (!school || !schoolClass) return sendError(response, 400, '请选择有效的学校和班级');
|
||||||
@@ -82,7 +86,7 @@ export function createCandidateRoutes(context) {
|
|||||||
profile.classId = schoolClass.id;
|
profile.classId = schoolClass.id;
|
||||||
profile.school = school.name;
|
profile.school = school.name;
|
||||||
profile.grade = schoolClass.name;
|
profile.grade = schoolClass.name;
|
||||||
if (!profile.name || !['男', '女'].includes(profile.gender) || !profile.idNumber || profile.idNumber.startsWith('PENDING-') || !profile.nativePlace || !profile.address || !profile.phone || !profile.email || !profile.school || !profile.classId) return sendError(response, 400, '请完整填写姓名、性别、证件号码、籍贯、家庭住址、手机号、邮箱、学校和班级');
|
if (!profile.name || !['男', '女'].includes(profile.gender) || !profile.idNumber || profile.idNumber.startsWith('PENDING-') || !profile.nativePlace || !profile.address || !profile.phone || !profile.email || !profile.school || !profile.classId) return sendError(response, 400, '请完整填写姓名、性别、证件号码、籍贯、省市区县、家庭住址、手机号、邮箱、学校和班级');
|
||||||
if (db.candidateProfiles.some(item => item.id !== profile.id && item.idNumber === profile.idNumber)) return sendError(response, 409, '证件号码已被其他考生使用');
|
if (db.candidateProfiles.some(item => item.id !== profile.id && item.idNumber === profile.idNumber)) return sendError(response, 409, '证件号码已被其他考生使用');
|
||||||
profile.status = 'pending';
|
profile.status = 'pending';
|
||||||
profile.profileCompleted = true;
|
profile.profileCompleted = true;
|
||||||
|
|||||||
+2
-1
@@ -236,7 +236,7 @@ button:disabled { cursor: not-allowed; opacity: .5; }
|
|||||||
.auth-page { min-height: 100vh; display: grid; grid-template-columns: .9fr 1.1fr; background: #fff; }
|
.auth-page { min-height: 100vh; display: grid; grid-template-columns: .9fr 1.1fr; background: #fff; }
|
||||||
.auth-story { position: relative; display: flex; flex-direction: column; justify-content: space-between; padding: 55px max(45px,8vw) 50px; color: #fff; background-color: var(--navy); background-image: radial-gradient(circle at 20% 85%,rgba(49,95,186,.3),transparent 35%), linear-gradient(rgba(255,255,255,.025) 1px,transparent 1px), linear-gradient(90deg,rgba(255,255,255,.025) 1px,transparent 1px); background-size: auto,26px 26px,26px 26px; overflow:hidden; }
|
.auth-story { position: relative; display: flex; flex-direction: column; justify-content: space-between; padding: 55px max(45px,8vw) 50px; color: #fff; background-color: var(--navy); background-image: radial-gradient(circle at 20% 85%,rgba(49,95,186,.3),transparent 35%), linear-gradient(rgba(255,255,255,.025) 1px,transparent 1px), linear-gradient(90deg,rgba(255,255,255,.025) 1px,transparent 1px); background-size: auto,26px 26px,26px 26px; overflow:hidden; }
|
||||||
.auth-story::after { content:"准"; position:absolute; right:-55px; bottom:-105px; color:rgba(255,255,255,.03); font-family:"STKaiti"; font-size:420px; }.auth-story .brand { color:#fff; }.auth-story .brand small { color:#7e8db9; }.auth-story .overline { margin-top: 110px; color:#6f82b7; }.auth-story h1 { margin:16px 0 20px; font-family:"STKaiti",serif; font-size:clamp(42px,5vw,66px); font-weight:400; line-height:1.22; }.auth-story h1 em { color:#f07b70; font-style:normal; }.auth-story > div > p:last-child { max-width:470px; color:#aab5d4; font-size:12px; line-height:1.9; }.auth-quote { position:relative; z-index:1; padding-top:20px; border-top:1px solid rgba(255,255,255,.12); }.auth-quote span { color:#7484b1; font-size:9px; }.auth-quote p { margin:8px 0 0; color:#d4daeb; font-family:"STKaiti"; font-size:18px; }
|
.auth-story::after { content:"准"; position:absolute; right:-55px; bottom:-105px; color:rgba(255,255,255,.03); font-family:"STKaiti"; font-size:420px; }.auth-story .brand { color:#fff; }.auth-story .brand small { color:#7e8db9; }.auth-story .overline { margin-top: 110px; color:#6f82b7; }.auth-story h1 { margin:16px 0 20px; font-family:"STKaiti",serif; font-size:clamp(42px,5vw,66px); font-weight:400; line-height:1.22; }.auth-story h1 em { color:#f07b70; font-style:normal; }.auth-story > div > p:last-child { max-width:470px; color:#aab5d4; font-size:12px; line-height:1.9; }.auth-quote { position:relative; z-index:1; padding-top:20px; border-top:1px solid rgba(255,255,255,.12); }.auth-quote span { color:#7484b1; font-size:9px; }.auth-quote p { margin:8px 0 0; color:#d4daeb; font-family:"STKaiti"; font-size:18px; }
|
||||||
.auth-panel { display:grid; place-items:center; padding:60px 28px; position:relative; }.back-link { position:absolute; top:28px; right:34px; border:0; color:#778096; background:transparent; font-size:10px; }.auth-card { width:min(480px,100%); }.auth-card h2 { margin:8px 0 7px; font-family:"STKaiti"; font-size:34px; font-weight:400; }.auth-card > p:not(.overline) { margin:0 0 30px; color:#848c9d; font-size:11px; }.stack-form { display:grid; gap:15px; }.stack-form label,.modal-form label,.profile-form label,.result-entry label { display:grid; gap:7px; }.stack-form label > span,.modal-form label > span,.profile-form label > span,.result-entry label > span { color:#555f75; font-size:10px; font-weight:700; }.stack-form input,.stack-form select,.modal-form input,.modal-form select,.modal-form textarea,.profile-form input,.profile-form select,.result-entry input,.result-entry select { width:100%; min-height:44px; padding:10px 12px; border:1px solid #dce1ea; border-radius:8px; color:var(--ink); background:#fff; font-size:11px; outline:0; }.stack-form textarea,.modal-form textarea { resize:vertical; }.stack-form input:focus,.stack-form select:focus,.modal-form input:focus,.modal-form select:focus,.modal-form textarea:focus,.profile-form input:focus,.profile-form select:focus,.result-entry input:focus,.result-entry select:focus { border-color:#8999c0; box-shadow:0 0 0 3px rgba(49,95,186,.08); }.field-row { display:grid; grid-template-columns:1fr 1fr; gap:13px; }.agreement { display:flex !important; grid-template-columns:auto 1fr; align-items:center; gap:8px !important; color:#767f91; font-size:9px; }.agreement input { width:15px !important; min-height:auto !important; height:15px; }.agreement span { color:#767f91 !important; font-weight:400 !important; }.auth-switch { margin-top:20px; color:#858d9d; font-size:10px; text-align:center; }.auth-switch button { border:0; color:var(--blue); background:transparent; font-weight:700; }.demo-accounts { display:grid; gap:6px; margin-top:25px; padding:14px; border-radius:8px; background:#f5f7fa; }.demo-accounts strong { color:#70798d; font-size:9px; }.demo-accounts button { border:0; color:#6a748a; background:transparent; font-family:Consolas,monospace; font-size:9px; text-align:left; }
|
.auth-panel { display:grid; place-items:center; padding:60px 28px; position:relative; }.back-link { position:absolute; top:28px; right:34px; border:0; color:#778096; background:transparent; font-size:10px; }.auth-card { width:min(480px,100%); }.auth-card h2 { margin:8px 0 7px; font-family:"STKaiti"; font-size:34px; font-weight:400; }.auth-card > p:not(.overline) { margin:0 0 30px; color:#848c9d; font-size:11px; }.stack-form { display:grid; gap:15px; }.stack-form label,.modal-form label,.profile-form label,.result-entry label { display:grid; gap:7px; }.stack-form label > span,.modal-form label > span,.profile-form label > span,.result-entry label > span { color:#555f75; font-size:10px; font-weight:700; }.stack-form input,.stack-form select,.modal-form input,.modal-form select,.modal-form textarea,.profile-form input,.profile-form select,.result-entry input,.result-entry select { width:100%; min-height:44px; padding:10px 12px; border:1px solid #dce1ea; border-radius:8px; color:var(--ink); background:#fff; font-size:11px; outline:0; }.stack-form textarea,.modal-form textarea { resize:vertical; }.stack-form input:focus,.stack-form select:focus,.modal-form input:focus,.modal-form select:focus,.modal-form textarea:focus,.profile-form input:focus,.profile-form select:focus,.result-entry input:focus,.result-entry select:focus { border-color:#8999c0; box-shadow:0 0 0 3px rgba(49,95,186,.08); }.field-row { display:grid; grid-template-columns:1fr 1fr; gap:13px; }.region-selects { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:13px; }.agreement { display:flex !important; grid-template-columns:auto 1fr; align-items:center; gap:8px !important; color:#767f91; font-size:9px; }.agreement input { width:15px !important; min-height:auto !important; height:15px; }.agreement span { color:#767f91 !important; font-weight:400 !important; }.auth-switch { margin-top:20px; color:#858d9d; font-size:10px; text-align:center; }.auth-switch button { border:0; color:var(--blue); background:transparent; font-weight:700; }.demo-accounts { display:grid; gap:6px; margin-top:25px; padding:14px; border-radius:8px; background:#f5f7fa; }.demo-accounts strong { color:#70798d; font-size:9px; }.demo-accounts button { border:0; color:#6a748a; background:transparent; font-family:Consolas,monospace; font-size:9px; text-align:left; }
|
||||||
|
|
||||||
/* Portal shell */
|
/* Portal shell */
|
||||||
.portal { min-height:100vh; }.portal-sidebar { position:fixed; inset:0 auto 0 0; z-index:35; width:238px; display:flex; flex-direction:column; padding:24px 17px 18px; color:#fff; background:var(--navy); overflow:hidden; }.portal-sidebar::after { content:""; position:absolute; width:260px; height:260px; left:-130px; bottom:-80px; border:1px solid rgba(255,255,255,.06); border-radius:50%; box-shadow:0 0 0 45px rgba(255,255,255,.02),0 0 0 90px rgba(255,255,255,.015); }.portal-brand { z-index:1; display:flex; align-items:center; justify-content:space-between; padding:0 9px 22px; border-bottom:1px solid rgba(255,255,255,.1); }.portal-brand .brand { color:#fff; }.portal-brand .brand small { color:#7e8db9; }.portal-brand > button { display:none; border:0; color:#fff; background:transparent; font-size:25px; }.portal-role { margin:20px 12px 9px; color:#6f7fae; font-family:Consolas,monospace; font-size:9px; letter-spacing:1.5px; }.portal-sidebar nav { z-index:1; display:grid; gap:4px; }.portal-sidebar nav button { width:100%; min-height:42px; display:flex; align-items:center; gap:12px; padding:0 13px; border:0; border-radius:8px; color:#aab5d4; background:transparent; font-size:11px; text-align:left; transition:.18s; }.portal-sidebar nav button > span { width:20px; display:grid; place-items:center; }.portal-sidebar nav button svg { width:17px; }.portal-sidebar nav button:hover { color:#fff; background:rgba(255,255,255,.05); }.portal-sidebar nav button.active { color:#fff; background:var(--navy-soft); box-shadow:inset 3px 0 var(--red); }.portal-sidebar nav button em { margin-left:auto; padding:2px 5px; border-radius:8px; color:#fff; background:var(--red); font-size:7px; font-style:normal; }.sidebar-help { z-index:1; display:grid; gap:4px; margin-top:auto; padding:15px 12px; border:1px solid rgba(255,255,255,.08); border-radius:9px; background:rgba(255,255,255,.03); }.sidebar-help span { color:#7484b1; font-size:8px; }.sidebar-help strong { font-family:Consolas,monospace; font-size:11px; }.sidebar-help small { color:#8897bd; font-size:8px; }.portal-main { min-height:100vh; margin-left:238px; }.portal-topbar { height:70px; display:flex; align-items:center; gap:20px; padding:0 30px; border-bottom:1px solid var(--line); background:rgba(255,255,255,.9); backdrop-filter:blur(16px); }.portal-topbar > div:first-of-type { display:flex; gap:8px; align-items:center; font-size:10px; }.portal-topbar > div:first-of-type span,.portal-topbar > div:first-of-type b { color:#9aa1b0; font-weight:400; }.portal-user { display:flex; align-items:center; gap:9px; margin-left:auto; }.portal-user > span:nth-of-type(2) { display:grid; }.portal-user > span strong { font-size:10px; }.portal-user > span small { color:#8c94a6; font-size:8px; }.user-avatar { width:32px; height:32px; display:grid; place-items:center; flex:0 0 auto; border-radius:9px; color:#43578b; background:#e4e9f5; font-size:11px; font-weight:700; }.notification-button,.logout-button,.sidebar-toggle { width:36px; height:36px; display:grid; place-items:center; border:1px solid var(--line); border-radius:8px; color:#687287; background:#fff; }.notification-button { position:relative; }.notification-button i { position:absolute; top:8px; right:8px; width:5px; height:5px; border-radius:50%; background:var(--red); }.notification-button svg,.logout-button svg,.sidebar-toggle svg { width:15px; }.logout-button { border:0; background:transparent; }.sidebar-toggle { display:none; }.portal-content { padding:31px; }.portal-heading { display:flex; align-items:flex-end; justify-content:space-between; gap:25px; margin-bottom:24px; }.portal-heading h1 { margin:5px 0 5px; font-family:"STKaiti"; font-size:31px; font-weight:400; }.portal-heading > div > p:last-child { margin:0; color:#81899a; font-size:11px; }.heading-status { color:#7f8798; font-size:10px; }.heading-status .status { margin-left:6px; }
|
.portal { min-height:100vh; }.portal-sidebar { position:fixed; inset:0 auto 0 0; z-index:35; width:238px; display:flex; flex-direction:column; padding:24px 17px 18px; color:#fff; background:var(--navy); overflow:hidden; }.portal-sidebar::after { content:""; position:absolute; width:260px; height:260px; left:-130px; bottom:-80px; border:1px solid rgba(255,255,255,.06); border-radius:50%; box-shadow:0 0 0 45px rgba(255,255,255,.02),0 0 0 90px rgba(255,255,255,.015); }.portal-brand { z-index:1; display:flex; align-items:center; justify-content:space-between; padding:0 9px 22px; border-bottom:1px solid rgba(255,255,255,.1); }.portal-brand .brand { color:#fff; }.portal-brand .brand small { color:#7e8db9; }.portal-brand > button { display:none; border:0; color:#fff; background:transparent; font-size:25px; }.portal-role { margin:20px 12px 9px; color:#6f7fae; font-family:Consolas,monospace; font-size:9px; letter-spacing:1.5px; }.portal-sidebar nav { z-index:1; display:grid; gap:4px; }.portal-sidebar nav button { width:100%; min-height:42px; display:flex; align-items:center; gap:12px; padding:0 13px; border:0; border-radius:8px; color:#aab5d4; background:transparent; font-size:11px; text-align:left; transition:.18s; }.portal-sidebar nav button > span { width:20px; display:grid; place-items:center; }.portal-sidebar nav button svg { width:17px; }.portal-sidebar nav button:hover { color:#fff; background:rgba(255,255,255,.05); }.portal-sidebar nav button.active { color:#fff; background:var(--navy-soft); box-shadow:inset 3px 0 var(--red); }.portal-sidebar nav button em { margin-left:auto; padding:2px 5px; border-radius:8px; color:#fff; background:var(--red); font-size:7px; font-style:normal; }.sidebar-help { z-index:1; display:grid; gap:4px; margin-top:auto; padding:15px 12px; border:1px solid rgba(255,255,255,.08); border-radius:9px; background:rgba(255,255,255,.03); }.sidebar-help span { color:#7484b1; font-size:8px; }.sidebar-help strong { font-family:Consolas,monospace; font-size:11px; }.sidebar-help small { color:#8897bd; font-size:8px; }.portal-main { min-height:100vh; margin-left:238px; }.portal-topbar { height:70px; display:flex; align-items:center; gap:20px; padding:0 30px; border-bottom:1px solid var(--line); background:rgba(255,255,255,.9); backdrop-filter:blur(16px); }.portal-topbar > div:first-of-type { display:flex; gap:8px; align-items:center; font-size:10px; }.portal-topbar > div:first-of-type span,.portal-topbar > div:first-of-type b { color:#9aa1b0; font-weight:400; }.portal-user { display:flex; align-items:center; gap:9px; margin-left:auto; }.portal-user > span:nth-of-type(2) { display:grid; }.portal-user > span strong { font-size:10px; }.portal-user > span small { color:#8c94a6; font-size:8px; }.user-avatar { width:32px; height:32px; display:grid; place-items:center; flex:0 0 auto; border-radius:9px; color:#43578b; background:#e4e9f5; font-size:11px; font-weight:700; }.notification-button,.logout-button,.sidebar-toggle { width:36px; height:36px; display:grid; place-items:center; border:1px solid var(--line); border-radius:8px; color:#687287; background:#fff; }.notification-button { position:relative; }.notification-button i { position:absolute; top:8px; right:8px; width:5px; height:5px; border-radius:50%; background:var(--red); }.notification-button svg,.logout-button svg,.sidebar-toggle svg { width:15px; }.logout-button { border:0; background:transparent; }.sidebar-toggle { display:none; }.portal-content { padding:31px; }.portal-heading { display:flex; align-items:flex-end; justify-content:space-between; gap:25px; margin-bottom:24px; }.portal-heading h1 { margin:5px 0 5px; font-family:"STKaiti"; font-size:31px; font-weight:400; }.portal-heading > div > p:last-child { margin:0; color:#81899a; font-size:11px; }.heading-status { color:#7f8798; font-size:10px; }.heading-status .status { margin-left:6px; }
|
||||||
@@ -311,6 +311,7 @@ button:disabled { cursor: not-allowed; opacity: .5; }
|
|||||||
.public-nav { width:calc(100% - 28px); }.public-nav nav { position:absolute; top:75px; left:14px; right:14px; display:none; flex-direction:column; gap:0; margin:0; padding:9px; border:1px solid var(--line); border-radius:10px; background:#fff; box-shadow:var(--shadow); }.public-nav nav.open { display:flex; }.public-nav nav a { padding:12px; }.mobile-menu { display:grid; }.nav-actions > .text-button { display:none; }.hero { min-height:auto; }.hero-grid { grid-template-columns:1fr; gap:55px; padding:60px 0 75px; }.hero-ticket { width:min(540px,100%); }.notice-layout,.public-exam-grid { grid-template-columns:1fr; }.flow-track { grid-template-columns:1fr 1fr; gap:30px; }.flow-track::before { display:none; }.auth-page { grid-template-columns:1fr; }.auth-story { min-height:360px; padding:35px 35px 40px; }.auth-story .overline { margin-top:55px; }.auth-story h1 { font-size:42px; }.auth-quote { display:none; }.portal-sidebar { transform:translateX(-100%); transition:.25s; box-shadow:18px 0 55px rgba(12,22,48,.2); }.portal-sidebar.open { transform:none; }.portal-brand > button { display:block; }.portal-main { margin-left:0; }.sidebar-toggle { display:grid; }.portal-topbar { padding:0 18px; }.portal-topbar > div:first-of-type span,.portal-topbar > div:first-of-type b { display:none; }.portal-content { padding:22px 18px; }.apply-card-main,.registration-info { grid-template-columns:1fr; }.apply-copy,.registration-info > dl { border-right:0; border-bottom:1px solid var(--line); }.admit-ticket { grid-template-columns:1fr 170px; }.admit-ticket::before,.admit-ticket::after { right:158px; }.admin-exam-grid { grid-template-columns:1fr; }
|
.public-nav { width:calc(100% - 28px); }.public-nav nav { position:absolute; top:75px; left:14px; right:14px; display:none; flex-direction:column; gap:0; margin:0; padding:9px; border:1px solid var(--line); border-radius:10px; background:#fff; box-shadow:var(--shadow); }.public-nav nav.open { display:flex; }.public-nav nav a { padding:12px; }.mobile-menu { display:grid; }.nav-actions > .text-button { display:none; }.hero { min-height:auto; }.hero-grid { grid-template-columns:1fr; gap:55px; padding:60px 0 75px; }.hero-ticket { width:min(540px,100%); }.notice-layout,.public-exam-grid { grid-template-columns:1fr; }.flow-track { grid-template-columns:1fr 1fr; gap:30px; }.flow-track::before { display:none; }.auth-page { grid-template-columns:1fr; }.auth-story { min-height:360px; padding:35px 35px 40px; }.auth-story .overline { margin-top:55px; }.auth-story h1 { font-size:42px; }.auth-quote { display:none; }.portal-sidebar { transform:translateX(-100%); transition:.25s; box-shadow:18px 0 55px rgba(12,22,48,.2); }.portal-sidebar.open { transform:none; }.portal-brand > button { display:block; }.portal-main { margin-left:0; }.sidebar-toggle { display:grid; }.portal-topbar { padding:0 18px; }.portal-topbar > div:first-of-type span,.portal-topbar > div:first-of-type b { display:none; }.portal-content { padding:22px 18px; }.apply-card-main,.registration-info { grid-template-columns:1fr; }.apply-copy,.registration-info > dl { border-right:0; border-bottom:1px solid var(--line); }.admit-ticket { grid-template-columns:1fr 170px; }.admit-ticket::before,.admit-ticket::after { right:158px; }.admin-exam-grid { grid-template-columns:1fr; }
|
||||||
}
|
}
|
||||||
@media (max-width: 620px) {
|
@media (max-width: 620px) {
|
||||||
|
.region-selects { grid-template-columns:1fr; }
|
||||||
.public-header { height:68px; }.public-nav nav { top:67px; }.public-nav .solid-button { display:none; }.brand strong { font-size:22px; }.brand-symbol { width:32px; height:32px; }.hero-grid,.content-section { width:calc(100% - 32px); }.notice-ticker { margin-bottom:28px; }.hero-copy h1 { font-size:40px; }.hero-copy h1 em::after { width:35px; }.hero-lead { font-size:12px; }.hero-actions { align-items:stretch; flex-direction:column; }.hero-stats { justify-content:space-between; gap:10px; }.hero-ticket { grid-template-columns:1fr 82px; transform:none; }.hero-ticket::before,.hero-ticket::after { right:70px; }.ticket-main { padding:23px; }.ticket-main h2 { font-size:22px; }.ticket-main dl div { grid-template-columns:62px 1fr; }.ticket-stub strong { font-size:25px; }.content-section { padding:65px 0; }.section-heading { align-items:flex-start; flex-direction:column; gap:10px; }.section-heading > p { text-align:left; }.section-heading h2 { font-size:30px; }.notice-row { grid-template-columns:55px 1fr 18px; padding:11px 13px; }.featured-notice { min-height:280px; }.exam-meta { grid-template-columns:1fr; }.public-exam-card footer { align-items:flex-start; flex-direction:column; gap:12px; }.flow-track { grid-template-columns:1fr; }.public-footer { align-items:flex-start; flex-direction:column; gap:25px; }.auth-story { min-height:315px; padding:27px 24px; }.auth-story h1 { font-size:35px; }.auth-panel { padding:70px 20px 35px; }.back-link { top:22px; right:20px; }.field-row,.form-grid { grid-template-columns:1fr; }.portal-topbar { height:62px; }.portal-user > span:nth-of-type(2) { display:none; }.portal-user .notification-button { display:none; }.portal-content { padding:20px 14px; }.portal-heading { align-items:flex-start; flex-direction:column; }.portal-heading .solid-button { width:100%; }.portal-heading h1 { font-size:28px; }.candidate-welcome { padding:24px; }.welcome-seal { display:none; }.candidate-welcome h2 { font-size:22px; }.summary-grid,.admin-metrics { grid-template-columns:1fr; }.candidate-progress { grid-template-columns:1fr; gap:0; padding:18px; }.progress-step { min-height:58px; grid-template-columns:30px 1fr; justify-items:start; align-items:center; text-align:left; }.progress-step::before { top:-50%; bottom:50%; left:14px; width:2px; height:auto; right:auto; }.progress-step div { justify-items:start; }.subject-options { grid-template-columns:1fr; }.registration-card > footer,.form-actions { align-items:flex-start; flex-direction:column; gap:10px; }.admit-ticket { grid-template-columns:1fr; }.admit-ticket::before,.admit-ticket::after { display:none; }.admit-stub { border-top:1px dashed rgba(255,255,255,.18); border-left:0; }.admit-main dl { grid-template-columns:1fr; }.score-grid { grid-template-columns:1fr 1fr; }.score-grid article,.score-grid article:nth-child(3) { border-right:1px solid var(--line); border-top:1px solid var(--line); }.score-grid article:nth-child(2n) { border-right:0; }.result-panel > header,.result-panel > footer { align-items:flex-start; flex-direction:column; gap:8px; }.notice-center-list > button { grid-template-columns:45px 1fr 18px; gap:10px; padding:12px; }.notice-center button > i { display:none; }.data-toolbar { align-items:stretch; flex-direction:column; }.search-box { width:100%; }.filter-pills { overflow-x:auto; }.filter-pills button { white-space:nowrap; }.admin-exam-card dl { grid-template-columns:1fr; }.admin-exam-card dl div:last-child { grid-column:auto; }.review-profile dl,.registration-review dl,.admit-preview dl { grid-template-columns:1fr; }.modal-layer { padding:10px; }.modal-card { max-height:94vh; }.modal-head,.modal-form { padding-left:18px; padding-right:18px; }.modal-foot { margin-left:-18px; margin-right:-18px; padding-left:18px; padding-right:18px; }.toast { right:14px; bottom:14px; left:14px; min-width:0; }
|
.public-header { height:68px; }.public-nav nav { top:67px; }.public-nav .solid-button { display:none; }.brand strong { font-size:22px; }.brand-symbol { width:32px; height:32px; }.hero-grid,.content-section { width:calc(100% - 32px); }.notice-ticker { margin-bottom:28px; }.hero-copy h1 { font-size:40px; }.hero-copy h1 em::after { width:35px; }.hero-lead { font-size:12px; }.hero-actions { align-items:stretch; flex-direction:column; }.hero-stats { justify-content:space-between; gap:10px; }.hero-ticket { grid-template-columns:1fr 82px; transform:none; }.hero-ticket::before,.hero-ticket::after { right:70px; }.ticket-main { padding:23px; }.ticket-main h2 { font-size:22px; }.ticket-main dl div { grid-template-columns:62px 1fr; }.ticket-stub strong { font-size:25px; }.content-section { padding:65px 0; }.section-heading { align-items:flex-start; flex-direction:column; gap:10px; }.section-heading > p { text-align:left; }.section-heading h2 { font-size:30px; }.notice-row { grid-template-columns:55px 1fr 18px; padding:11px 13px; }.featured-notice { min-height:280px; }.exam-meta { grid-template-columns:1fr; }.public-exam-card footer { align-items:flex-start; flex-direction:column; gap:12px; }.flow-track { grid-template-columns:1fr; }.public-footer { align-items:flex-start; flex-direction:column; gap:25px; }.auth-story { min-height:315px; padding:27px 24px; }.auth-story h1 { font-size:35px; }.auth-panel { padding:70px 20px 35px; }.back-link { top:22px; right:20px; }.field-row,.form-grid { grid-template-columns:1fr; }.portal-topbar { height:62px; }.portal-user > span:nth-of-type(2) { display:none; }.portal-user .notification-button { display:none; }.portal-content { padding:20px 14px; }.portal-heading { align-items:flex-start; flex-direction:column; }.portal-heading .solid-button { width:100%; }.portal-heading h1 { font-size:28px; }.candidate-welcome { padding:24px; }.welcome-seal { display:none; }.candidate-welcome h2 { font-size:22px; }.summary-grid,.admin-metrics { grid-template-columns:1fr; }.candidate-progress { grid-template-columns:1fr; gap:0; padding:18px; }.progress-step { min-height:58px; grid-template-columns:30px 1fr; justify-items:start; align-items:center; text-align:left; }.progress-step::before { top:-50%; bottom:50%; left:14px; width:2px; height:auto; right:auto; }.progress-step div { justify-items:start; }.subject-options { grid-template-columns:1fr; }.registration-card > footer,.form-actions { align-items:flex-start; flex-direction:column; gap:10px; }.admit-ticket { grid-template-columns:1fr; }.admit-ticket::before,.admit-ticket::after { display:none; }.admit-stub { border-top:1px dashed rgba(255,255,255,.18); border-left:0; }.admit-main dl { grid-template-columns:1fr; }.score-grid { grid-template-columns:1fr 1fr; }.score-grid article,.score-grid article:nth-child(3) { border-right:1px solid var(--line); border-top:1px solid var(--line); }.score-grid article:nth-child(2n) { border-right:0; }.result-panel > header,.result-panel > footer { align-items:flex-start; flex-direction:column; gap:8px; }.notice-center-list > button { grid-template-columns:45px 1fr 18px; gap:10px; padding:12px; }.notice-center button > i { display:none; }.data-toolbar { align-items:stretch; flex-direction:column; }.search-box { width:100%; }.filter-pills { overflow-x:auto; }.filter-pills button { white-space:nowrap; }.admin-exam-card dl { grid-template-columns:1fr; }.admin-exam-card dl div:last-child { grid-column:auto; }.review-profile dl,.registration-review dl,.admit-preview dl { grid-template-columns:1fr; }.modal-layer { padding:10px; }.modal-card { max-height:94vh; }.modal-head,.modal-form { padding-left:18px; padding-right:18px; }.modal-foot { margin-left:-18px; margin-right:-18px; padding-left:18px; padding-right:18px; }.toast { right:14px; bottom:14px; left:14px; min-width:0; }
|
||||||
}
|
}
|
||||||
@media (max-width: 620px) {
|
@media (max-width: 620px) {
|
||||||
|
|||||||
+11
-6
@@ -132,7 +132,7 @@ try {
|
|||||||
assert.equal((await candidate.request('/api/candidate/dashboard')).response.status, 428, '未补全个人信息前仍不得进入考试业务');
|
assert.equal((await candidate.request('/api/candidate/dashboard')).response.status, 428, '未补全个人信息前仍不得进入考试业务');
|
||||||
const updateProfile = await candidate.request('/api/candidate/profile', {
|
const updateProfile = await candidate.request('/api/candidate/profile', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: { name: '测试考生新名', gender: '男', idNumber: '320101200801019999', nativePlace: '江苏海州', birthDate: '2008-01-01', ethnicity: '汉族', phone: '13900009999', email: 'test@example.com', schoolId: 'school_hz1', classId: 'class_hz1_302', address: '海州市测试区 1 号', postalCode: '222000', guardianName: '测试家长', guardianPhone: '13800008888', emergencyContact: '测试家长', emergencyPhone: '13800008888' }
|
body: { name: '测试考生新名', gender: '男', idNumber: '320101200801019999', nativePlace: '江苏海州', birthDate: '2008-01-01', ethnicity: '汉族', phone: '13900009999', email: 'test@example.com', schoolId: 'school_hz1', classId: 'class_hz1_302', provinceCode: '320000', cityCode: '320700', districtCode: '320706', address: '测试路 1 号', postalCode: '222000', guardianName: '测试家长', guardianPhone: '13800008888', emergencyContact: '测试家长', emergencyPhone: '13800008888' }
|
||||||
});
|
});
|
||||||
assert.equal(updateProfile.response.status, 200, '考生应补全包含籍贯、住址、手机、邮箱和班级的完整资料');
|
assert.equal(updateProfile.response.status, 200, '考生应补全包含籍贯、住址、手机、邮箱和班级的完整资料');
|
||||||
assert.equal(updateProfile.data.profile.profileCompleted, true, '完整资料提交后应标记完成');
|
assert.equal(updateProfile.data.profile.profileCompleted, true, '完整资料提交后应标记完成');
|
||||||
@@ -155,7 +155,7 @@ try {
|
|||||||
const schoolCenters = await schoolAdmin.request('/api/admin/centers');
|
const schoolCenters = await schoolAdmin.request('/api/admin/centers');
|
||||||
assert.ok(schoolCenters.data.centers.every(item => item.schoolId === 'school_hz1'), '校级管理员只能读取本校考点');
|
assert.ok(schoolCenters.data.centers.every(item => item.schoolId === 'school_hz1'), '校级管理员只能读取本校考点');
|
||||||
const newCenter = await schoolAdmin.request('/api/admin/centers', { method: 'POST', body: {
|
const newCenter = await schoolAdmin.request('/api/admin/centers', { method: 'POST', body: {
|
||||||
code: 'HZ01-EAST', name: '海州市第一中学东区考点', address: '海州市测试路 8 号', contact: '0518-12345678',
|
code: 'HZ01-EAST', name: '海州市第一中学东区考点', provinceCode: '320000', cityCode: '320700', districtCode: '320706', address: '测试路 8 号', contact: '0518-12345678',
|
||||||
managerName: '测试负责人', managerPhone: '13800001234', emergencyPhone: '0518-120', gateOpenTime: '07:00', transport: '东门入场', status: 'active', notes: '自动化测试档案',
|
managerName: '测试负责人', managerPhone: '13800001234', emergencyPhone: '0518-120', gateOpenTime: '07:00', transport: '东门入场', status: 'active', notes: '自动化测试档案',
|
||||||
rooms: [{ code: 'E001', name: '东区第 001 考场', building: '东教学楼', floor: '1 层', capacity: 30, seatPlan: '按现场座次表编排', roomType: 'standard', status: 'active', notes: '' }]
|
rooms: [{ code: 'E001', name: '东区第 001 考场', building: '东教学楼', floor: '1 层', capacity: 30, seatPlan: '按现场座次表编排', roomType: 'standard', status: 'active', notes: '' }]
|
||||||
} });
|
} });
|
||||||
@@ -214,13 +214,13 @@ try {
|
|||||||
const centerHeaders = centerWorkbook.worksheets[0].getRow(2).values.map(String);
|
const centerHeaders = centerWorkbook.worksheets[0].getRow(2).values.map(String);
|
||||||
assert.ok(centerHeaders.includes('座位编排说明'), '考场 Excel 应使用座位编排说明');
|
assert.ok(centerHeaders.includes('座位编排说明'), '考场 Excel 应使用座位编排说明');
|
||||||
assert.ok(!centerHeaders.some(item => item.includes('座位起号') || item.includes('座位止号')), '考场 Excel 不应再出现座位起止号');
|
assert.ok(!centerHeaders.some(item => item.includes('座位起号') || item.includes('座位止号')), '考场 Excel 不应再出现座位起止号');
|
||||||
assert.equal(centerWorkbook.worksheets[0].getCell('M3').value, '001', '考场代码应保留前导零');
|
assert.equal(centerWorkbook.worksheets[0].getCell('S3').value, '001', '考场代码应保留前导零');
|
||||||
assert.equal(centerWorkbook.worksheets[0].getCell('M3').numFmt, '@', '标识号列应固定为文本格式');
|
assert.equal(centerWorkbook.worksheets[0].getCell('S3').numFmt, '@', '标识号列应固定为文本格式');
|
||||||
const classAdminImportFile = Buffer.from(await buildWorkbook('class_admins', [{ schoolCode: 'HZ01', className: '高一(8)班', displayName: 'Excel 班管', username: 'excel_class_admin', initialPassword: 'ExcelClass123!', status: '启用' }]));
|
const classAdminImportFile = Buffer.from(await buildWorkbook('class_admins', [{ schoolCode: 'HZ01', className: '高一(8)班', displayName: 'Excel 班管', username: 'excel_class_admin', initialPassword: 'ExcelClass123!', status: '启用' }]));
|
||||||
const classAdminImport = await schoolAdmin.request('/api/admin/excel/class_admins', { method: 'POST', headers: { 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }, body: classAdminImportFile });
|
const classAdminImport = await schoolAdmin.request('/api/admin/excel/class_admins', { method: 'POST', headers: { 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }, body: classAdminImportFile });
|
||||||
assert.equal(classAdminImport.data.count, 1, '班级管理员应支持 Excel 批量导入');
|
assert.equal(classAdminImport.data.count, 1, '班级管理员应支持 Excel 批量导入');
|
||||||
const centerImportFile = Buffer.from(await buildWorkbook('centers', [{
|
const centerImportFile = Buffer.from(await buildWorkbook('centers', [{
|
||||||
schoolCode: 'HZ01', centerCode: 'HZ01-XLSX', centerName: 'Excel 导入考点', address: '海州市表格路 1 号', managerName: '表格负责人', managerPhone: '13800138001', contact: '0518-88000000', emergencyPhone: '0518-120', gateOpenTime: '07:10', transport: '北门入场', centerStatus: '启用', centerNotes: 'Excel 自动化测试',
|
schoolCode: 'HZ01', centerCode: 'HZ01-XLSX', centerName: 'Excel 导入考点', provinceCode: '320000', provinceName: '江苏省', cityCode: '320700', cityName: '连云港市', districtCode: '320706', districtName: '海州区', address: '表格路 1 号', managerName: '表格负责人', managerPhone: '13800138001', contact: '0518-88000000', emergencyPhone: '0518-120', gateOpenTime: '07:10', transport: '北门入场', centerStatus: '启用', centerNotes: 'Excel 自动化测试',
|
||||||
roomCode: 'X001', roomName: 'Excel 第 001 考场', building: '综合楼', floor: '2 层', capacity: 32, seatPlan: '按现场桌贴编排', roomType: '标准考场', roomStatus: '启用', roomNotes: ''
|
roomCode: 'X001', roomName: 'Excel 第 001 考场', building: '综合楼', floor: '2 层', capacity: 32, seatPlan: '按现场桌贴编排', roomType: '标准考场', roomStatus: '启用', roomNotes: ''
|
||||||
}]));
|
}]));
|
||||||
const centerImport = await schoolAdmin.request('/api/admin/excel/centers', { method: 'POST', headers: { 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }, body: centerImportFile });
|
const centerImport = await schoolAdmin.request('/api/admin/excel/centers', { method: 'POST', headers: { 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }, body: centerImportFile });
|
||||||
@@ -327,7 +327,12 @@ try {
|
|||||||
const candidates = await admin.request('/api/admin/candidates');
|
const candidates = await admin.request('/api/admin/candidates');
|
||||||
const profile = candidates.data.candidates.find(item => item.candidateNumber === candidateNumber);
|
const profile = candidates.data.candidates.find(item => item.candidateNumber === candidateNumber);
|
||||||
assert.ok(profile, '管理员应能看到新注册考生');
|
assert.ok(profile, '管理员应能看到新注册考生');
|
||||||
assert.equal(profile.address, '海州市测试区 1 号', '管理员应能审核考生自主填写的完整资料');
|
assert.equal(profile.address, '测试路 1 号', '详细门牌地址应独立保存');
|
||||||
|
assert.deepEqual(
|
||||||
|
[profile.provinceCode, profile.cityCode, profile.districtCode, profile.provinceName, profile.cityName, profile.districtName],
|
||||||
|
['320000', '320700', '320706', '江苏省', '连云港市', '海州区'],
|
||||||
|
'管理员应能审核考生自主填写且经过服务端规范化的省市区县信息'
|
||||||
|
);
|
||||||
const schoolCandidates = await schoolAdmin.request('/api/admin/candidates');
|
const schoolCandidates = await schoolAdmin.request('/api/admin/candidates');
|
||||||
assert.ok(schoolCandidates.data.candidates.some(item => item.id === profile.id), '校级管理员应看到本校考生');
|
assert.ok(schoolCandidates.data.candidates.some(item => item.id === profile.id), '校级管理员应看到本校考生');
|
||||||
const classCandidates = await classAdmin.request('/api/admin/candidates');
|
const classCandidates = await classAdmin.request('/api/admin/candidates');
|
||||||
|
|||||||
Reference in New Issue
Block a user