Add hierarchical region data to candidate and center records

This commit is contained in:
2026-07-20 11:50:51 +08:00 Unverified
parent 352f471ed0
commit c5446d5241
21 changed files with 387 additions and 64 deletions
+5
View File
@@ -1,3 +1,5 @@
import { formatRegionAddress } from './region-select.mjs';
export function createAdminViews(context) {
const {
state,
@@ -101,7 +103,10 @@ export function createAdminViews(context) {
function adminCenters(data) {
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('');
data.centers.forEach(center => { center.address = detailAddresses.get(center.id); });
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>`;
}
+4
View File
@@ -1,3 +1,5 @@
import { mountRegionSelects } from './region-select.mjs';
export function createCandidateViews(context) {
const {
state,
@@ -70,6 +72,7 @@ export function createCandidateViews(context) {
const data = await api('/api/candidate/profile');
state.pageData = data; state.profile = data.profile;
app.innerHTML = onboardingShell('profile', candidateProfile(data, true));
mountRegionSelects(app, data.profile, { className: 'region-selects wide-field' });
} catch (error) { renderError(error); }
return;
}
@@ -95,6 +98,7 @@ export function createCandidateViews(context) {
results: () => candidateResults(data), notices: () => candidateNotices(data.notices)
}[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); }
}
+42
View File
@@ -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
+25
View File
@@ -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
View File
@@ -6,17 +6,17 @@ export function createSeedDatabase({ nowIso, hashPassword }) {
const examId = 'exam_autumn_2026';
const registrationId = 'reg_demo_2026';
return {
meta: { version: 6, createdAt: nowIso() },
meta: { version: 7, createdAt: nowIso() },
settings: { selfRegistrationEnabled: false },
organization: {
name: '海州市教育考试中心',
code: 'HZ-EDU-032',
phone: '0518-8602 3158',
address: '海州市清河区文教路 18 号'
address: '江苏省连云港市海州区文教路 18 号'
},
schools: [
{ id: 'school_hz1', name: '海州市第一中学', code: 'HZ01', address: '海州市清河区学府路 8 号', active: true },
{ id: 'school_hz3', name: '海州市第三中学', code: 'HZ03', address: '海州市滨河区育才路 16 号', active: true }
{ id: 'school_hz1', name: '海州市第一中学', code: 'HZ01', address: '江苏省连云港市海州区学府路 8 号', active: true },
{ id: 'school_hz3', name: '海州市第三中学', code: 'HZ03', address: '江苏省连云港市连云区育才路 16 号', active: true }
],
classes: [
{ 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',
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,
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' }
],
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: '教学楼 A001、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_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: '教学楼 A001、002;实验楼:机考 01', 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: [
{ id: 'room_hz1_001', centerId: 'center_hz1', code: '001', name: '第 001 考场', building: '教学楼 A', floor: '1 层', capacity: 30, seatPlan: '按现场桌贴从前至后编排', roomType: 'standard', status: 'active', notes: '' },
+22 -1
View File
@@ -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_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 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 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'",
@@ -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 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 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 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"
@@ -246,6 +264,9 @@ export function createMysqlAdapter(context) {
if (Number(metadataRows[0]?.app_version || 1) < 6) {
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) {
const initialState = seed();
@@ -254,7 +275,7 @@ export function createMysqlAdapter(context) {
await connection.beginTransaction();
const [insert] = await connection.execute(`
INSERT IGNORE INTO schema_metadata (id, schema_version, app_version, self_registration_enabled, created_at)
VALUES (1, 6, ?, ?, ?)
VALUES (1, 7, ?, ?, ?)
`, [Number(initialState.meta?.version || 1), initialState.settings?.selfRegistrationEnabled ? 1 : 0, initialState.meta?.createdAt || new Date().toISOString()]);
if (insert.affectedRows === 1) {
for (const item of buildSeedOperations(initialState)) await connection.execute(item.sql, item.params);
+36
View File
@@ -61,6 +61,12 @@ export const sqliteSchema = `
grade TEXT,
school_id TEXT REFERENCES schools(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,
emergency_contact TEXT,
emergency_phone TEXT,
@@ -169,6 +175,12 @@ export const sqliteSchema = `
school_id TEXT NOT NULL REFERENCES schools(id) ON DELETE CASCADE,
code TEXT NOT NULL UNIQUE,
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,
contact TEXT,
manager_name TEXT,
@@ -207,6 +219,12 @@ export const sqliteSchema = `
request_type TEXT NOT NULL CHECK (request_type IN ('create', 'update')),
code 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,
contact TEXT,
manager_name TEXT,
@@ -420,6 +438,12 @@ export const mysqlSchema = [
grade VARCHAR(100) NULL,
school_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,
emergency_contact VARCHAR(100) NULL,
emergency_phone VARCHAR(60) NULL,
@@ -551,6 +575,12 @@ export const mysqlSchema = [
school_id VARCHAR(64) NOT NULL,
code VARCHAR(40) 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,
contact VARCHAR(100) NULL,
manager_name VARCHAR(100) NULL,
@@ -593,6 +623,12 @@ export const mysqlSchema = [
request_type ENUM('create', 'update') NOT NULL,
code VARCHAR(40) 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,
contact VARCHAR(100) NULL,
manager_name VARCHAR(100) NULL,
+14 -3
View File
@@ -34,7 +34,9 @@ export function createSqliteAdapter(context) {
ensureColumns('schema_metadata', [['self_registration_enabled', 'INTEGER NOT NULL DEFAULT 0']]);
ensureColumns('candidate_profiles', [
['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('exams', [
@@ -45,7 +47,13 @@ export function createSqliteAdapter(context) {
]);
ensureColumns('test_centers', [
['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('center_change_rooms', [['seat_plan', 'TEXT']]);
@@ -253,6 +261,9 @@ export function createSqliteAdapter(context) {
if (existingSystem && Number(existingSystem.app_version || 1) < 6) {
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()) {
const initialState = seed();
@@ -260,7 +271,7 @@ export function createSqliteAdapter(context) {
try {
connection.prepare(`
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());
for (const item of buildSeedOperations(initialState)) connection.prepare(item.sql).run(...item.params);
connection.exec('COMMIT');
+4 -1
View File
@@ -374,7 +374,10 @@ export function createAdminRoutes(context) {
const proposedRooms = db.centerChangeRooms.filter(item => item.requestId === change.id);
const rooms = proposedRooms.map(room => ({ ...room, id: room.roomId || uid('room'), centerId }));
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,
emergencyPhone: change.emergencyPhone, gateOpenTime: change.gateOpenTime, transport: change.transport,
status: change.centerStatus, notes: change.notes,
+6 -2
View File
@@ -45,7 +45,8 @@ export function createCandidateRoutes(context) {
buildWorkbook,
hasExcelResource,
parseWorkbook,
adminLevelNames
adminLevelNames,
resolveRegion
} = context;
async function handleCandidate(request, response, pathname) {
@@ -75,6 +76,9 @@ export function createCandidateRoutes(context) {
const body = await readJson(request);
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);
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 schoolClass = db.classes.find(item => item.id === cleanText(body.classId, 64) && item.schoolId === school?.id && item.active);
if (!school || !schoolClass) return sendError(response, 400, '请选择有效的学校和班级');
@@ -82,7 +86,7 @@ export function createCandidateRoutes(context) {
profile.classId = schoolClass.id;
profile.school = school.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, '证件号码已被其他考生使用');
profile.status = 'pending';
profile.profileCompleted = true;