From 1ed3b6722f55ef569b562833dccbc1e4af3bf34d Mon Sep 17 00:00:00 2001 From: biss Date: Sun, 19 Jul 2026 21:58:35 +0800 Subject: [PATCH] Enable editing for draft exams --- app.js | 25 +++++++++++++++++++------ database.mjs | 21 ++++++++++++++++----- server.mjs | 19 +++++++++++++++++-- styles.css | 2 +- tests/system.test.mjs | 26 ++++++++++++++++++++++++++ 5 files changed, 79 insertions(+), 14 deletions(-) diff --git a/app.js b/app.js index 841ca71..c0e247f 100644 --- a/app.js +++ b/app.js @@ -77,7 +77,7 @@ function toast(title, message = '') { } function setModal(content) { - modalRoot.innerHTML = ``; + modalRoot.innerHTML = ``; setTimeout(() => modalRoot.querySelector('input,textarea,button')?.focus(), 30); } @@ -264,7 +264,7 @@ function adminRegistrations(registrations) { } function adminExams(exams) { - return `
${exams.map(exam => `
${h(exam.code)}${badge(exam.status)}

${h(exam.name)}

${h(exam.description)}

报名时间
${dateRange(exam.registrationStart, exam.registrationEnd)}
考试时间
${dateRange(exam.examStart, exam.examEnd)}
考点
${h(exam.location)}
${exam.subjects.map(subject => `${h(subject.name)}${h(subject.date)} ${h(subject.start)}`).join('') || '科目待配置'}
${exam.registrationCount} 人报名 · ${exam.subjects.length} 科
`).join('')}
`; + return `
${exams.map(exam => `
${h(exam.code)}${badge(exam.status)}

${h(exam.name)}

${h(exam.description)}

报名时间
${dateRange(exam.registrationStart, exam.registrationEnd)}
考试时间
${dateRange(exam.examStart, exam.examEnd)}
考点
${h(exam.location)}
${exam.subjects.map(subject => `${h(subject.name)}${h(subject.date)} ${h(subject.start)}`).join('') || '科目待配置'}
${exam.registrationCount} 人报名 · ${exam.subjects.length} 科
${exam.status === 'draft' ? `` : ''}
`).join('')}
`; } function adminNotices(notices) { @@ -325,6 +325,7 @@ document.addEventListener('click', async event => { event.preventDefault(); return navigate(routeTarget.dataset.route); } + if (event.target.matches('[data-modal-backdrop]')) return closeModal(); const target = event.target.closest('[data-action]'); if (!target) return; const action = target.dataset.action; @@ -358,6 +359,7 @@ document.addEventListener('click', async event => { if (action === 'download-admit') { window.location.href = `/api/candidate/registrations/${target.dataset.id}/admit-card`; return; } if (action === 'new-notice') return openNoticeForm(); if (action === 'new-exam') return openExamForm(); + if (action === 'edit-exam') return openExamForm(state.pageData.exams.find(exam => exam.id === target.dataset.id)); if (action === 'review-candidate') return openCandidateReview(target.dataset.id); if (action === 'review-registration') return openRegistrationReview(target.dataset.id); if (action === 'generate-admit') { @@ -446,8 +448,9 @@ document.addEventListener('submit', async event => { } else if (kind === 'exam-form') { const body = formObject(form); body.subjects = body.subjects.split(/[,,]/).map(item => item.trim()).filter(Boolean); ['registrationStart','registrationEnd','examStart','examEnd','admitDownloadStart','admitDownloadEnd'].forEach(field => body[field] = new Date(body[field]).toISOString()); - await api('/api/admin/exams', { method: 'POST', body }); - closeModal(); await refreshPublic(); toast('考试计划已创建', `${body.subjects.length} 个科目已加入`); renderRoute(); + const editing = Boolean(body.id); + await api(editing ? `/api/admin/exams/${body.id}` : '/api/admin/exams', { method: editing ? 'PATCH' : 'POST', body }); + closeModal(); await refreshPublic(); toast(editing ? '考试草稿已更新' : '考试计划已创建', `${body.subjects.length} 个科目已配置`); renderRoute(); } else if (kind === 'result-entry') { const body = formObject(form); body.published = form.published.checked; await api('/api/admin/results', { method: 'POST', body }); @@ -471,8 +474,18 @@ function openNoticeForm() { setModal(``); } -function openExamForm() { - setModal(``); +function dateTimeLocal(value) { + if (!value) return ''; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return ''; + const pad = number => String(number).padStart(2, '0'); + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`; +} + +function openExamForm(exam = null) { + const editing = Boolean(exam); + if (editing && exam.status !== 'draft') return toast('无法编辑', '请先将已发布考试撤回为草稿'); + setModal(``); } function openAdmitPreview(reg) { diff --git a/database.mjs b/database.mjs index 2f8a51d..44511ab 100644 --- a/database.mjs +++ b/database.mjs @@ -720,8 +720,8 @@ function createRepository({ client, location, read, transaction, close }) { operations.push(auditOperation(log)); await transaction(operations); }, - async updateExam(exam, log) { - await transaction([ + async updateExam(exam, log, replaceSubjects = false) { + const operations = [ operation( `UPDATE exams SET code = ?, name = ?, description = ?, registration_start = ?, registration_end = ?, @@ -730,9 +730,20 @@ function createRepository({ client, location, read, transaction, close }) { exam.code, exam.name, exam.description || '', exam.registrationStart, exam.registrationEnd, exam.examStart, exam.examEnd, exam.admitDownloadStart, exam.admitDownloadEnd, exam.location || '', exam.status, exam.id - ), - auditOperation(log) - ]); + ) + ]; + if (replaceSubjects) { + operations.push(operation('DELETE FROM exam_subjects WHERE exam_id = ?', exam.id)); + exam.subjects.forEach((subject, index) => operations.push(operation( + `INSERT INTO exam_subjects ( + id, exam_id, name, subject_date, start_time, end_time, fee, position + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + subject.id, exam.id, subject.name, subject.date || String(exam.examStart).slice(0, 10), + subject.start || '', subject.end || '', Number(subject.fee || 0), Number(subject.order || index + 1) + ))); + } + operations.push(auditOperation(log)); + await transaction(operations); }, async createNotice(notice, log) { await transaction([ diff --git a/server.mjs b/server.mjs index 11c4456..55bdce2 100644 --- a/server.mjs +++ b/server.mjs @@ -437,10 +437,25 @@ async function handleAdmin(request, response, pathname) { const body = await readJson(request); const exam = db.exams.find(item => item.id === examMatch[1]); if (!exam) return sendError(response, 404, '考试不存在'); + const originalStatus = exam.status; + const detailFields = ['code', 'name', 'description', 'location', 'registrationStart', 'registrationEnd', 'examStart', 'examEnd', 'admitDownloadStart', 'admitDownloadEnd']; + const editingDetails = detailFields.some(field => body[field] != null) || body.subjects != null; + if (editingDetails && originalStatus !== 'draft') return sendError(response, 409, '请先将考试撤回为草稿后再编辑'); if (body.status && ['draft', 'published', 'closed'].includes(body.status)) exam.status = body.status; - ['name', 'description', 'location', 'registrationStart', 'registrationEnd', 'examStart', 'examEnd', 'admitDownloadStart', 'admitDownloadEnd'].forEach(field => { if (body[field] != null) exam[field] = cleanText(body[field], 500); }); + detailFields.forEach(field => { if (body[field] != null) exam[field] = cleanText(body[field], field === 'description' ? 500 : 100); }); + let replaceSubjects = false; + if (body.subjects != null) { + if (db.registrations.some(registration => registration.examId === exam.id)) return sendError(response, 409, '已有报名记录,不能修改考试科目'); + const subjectNames = Array.isArray(body.subjects) ? body.subjects : String(body.subjects || '').split(/[,,]/); + const names = subjectNames.map(item => cleanText(typeof item === 'string' ? item : item.name, 30)).filter(Boolean); + if (!names.length) return sendError(response, 400, '请至少添加一个考试科目'); + exam.subjects = names.map((name, index) => ({ id: uid('sub'), name, date: String(exam.examStart).slice(0, 10), start: '09:00', end: '11:00', fee: 0, order: index + 1 })); + replaceSubjects = true; + } + if (!exam.name || !exam.registrationStart || !exam.registrationEnd || !exam.examStart || !exam.examEnd) return sendError(response, 400, '请完整填写考试名称和关键日期'); + if (exam.status === 'published' && !exam.subjects.length) return sendError(response, 400, '请先配置考试科目再发布'); const log = logAction(db, user, '更新考试', `${exam.name} · 状态 ${exam.status}`); - await database.updateExam(exam, log); + await database.updateExam(exam, log, replaceSubjects); return sendJson(response, 200, { ok: true, exam }); } if (request.method === 'GET' && pathname === '/api/admin/notices') return sendJson(response, 200, { ok: true, notices: db.notices.sort((a, b) => new Date(b.publishAt || b.createdAt) - new Date(a.publishAt || a.createdAt)) }); diff --git a/styles.css b/styles.css index 88b8b22..fb2c18d 100644 --- a/styles.css +++ b/styles.css @@ -146,7 +146,7 @@ button:disabled { cursor: not-allowed; opacity: .5; } /* Admin */ .admin-metrics { display:grid; grid-template-columns:repeat(4,1fr); gap:12px; margin-bottom:16px; }.admin-metrics article { display:flex; align-items:center; gap:13px; padding:20px; border:1px solid var(--line); border-radius:12px; background:#fff; }.admin-metrics article > span { width:40px; height:40px; display:grid; place-items:center; border-radius:9px; color:var(--blue); background:#ecf1fb; }.admin-metrics article:nth-child(2)>span { color:var(--amber); background:#fff3dd; }.admin-metrics article:nth-child(3)>span { color:var(--red); background:#fbe9e7; }.admin-metrics article:nth-child(4)>span { color:var(--jade); background:#e4f3ee; }.admin-metrics article > span svg { width:18px; }.admin-metrics div { display:grid; }.admin-metrics small { color:#8d95a6; font-size:8px; }.admin-metrics strong { margin:2px 0; font-family:Georgia,serif; font-size:23px; font-weight:400; }.admin-metrics em { color:#818a9c; font-size:7px; font-style:normal; }.admin-dashboard-grid { display:grid; grid-template-columns:1.05fr 1fr; gap:16px; }.admin-todos { overflow:hidden; }.admin-todos > button { width:100%; display:grid; grid-template-columns:36px 1fr 18px; align-items:center; gap:11px; padding:14px 18px; border:0; border-bottom:1px solid var(--line); color:var(--ink); background:#fff; text-align:left; }.admin-todos > button:last-child { border-bottom:0; }.admin-todos > button:hover { background:#fafbfc; }.admin-todos > button > i { width:34px; height:34px; display:grid; place-items:center; border-radius:9px; color:#65728d; background:#edf0f6; font-size:10px; font-style:normal; font-weight:700; }.admin-todos > button > i.urgent { color:#a8463f; background:#fbe8e6; }.admin-todos button > span { display:grid; gap:3px; }.admin-todos strong { font-size:9px; }.admin-todos small { color:#9299aa; font-size:7px; }.admin-todos button > svg { color:#9ba2b1; }.audit-feed > div { display:grid; grid-template-columns:32px 1fr auto; align-items:center; gap:10px; padding:14px 18px; border-bottom:1px solid var(--line); }.audit-feed > div:last-child { border-bottom:0; }.audit-feed p { display:grid; gap:3px; margin:0; }.audit-feed p strong { font-size:9px; }.audit-feed p small { color:#9098a8; font-size:7px; }.audit-feed time { color:#8d95a5; font-size:7px; } .data-panel { overflow:hidden; }.data-toolbar { min-height:64px; display:flex; align-items:center; justify-content:space-between; gap:15px; padding:13px 17px; border-bottom:1px solid var(--line); }.data-toolbar > p { margin:0; color:#878f9f; font-size:8px; }.search-box { width:min(330px,40%); min-height:36px; display:flex; align-items:center; gap:8px; padding:0 11px; border:1px solid var(--line); border-radius:8px; }.search-box svg { width:14px; color:#9098a9; }.search-box input { width:100%; border:0; outline:0; background:transparent; font-size:9px; }.filter-pills { display:flex; gap:4px; }.filter-pills button { min-height:31px; padding:0 11px; border:1px solid var(--line); border-radius:7px; color:#778094; background:#fff; font-size:8px; }.filter-pills button.active { border-color:var(--navy); color:#fff; background:var(--navy); }.table-scroll { overflow-x:auto; }table { width:100%; border-collapse:collapse; white-space:nowrap; }th { padding:11px 14px; color:#858d9f; background:#fafbfc; font-size:8px; font-weight:600; text-align:left; }td { padding:13px 14px; border-top:1px solid #edf0f5; color:#5b657a; font-size:9px; }tbody tr { transition:background .15s; }tbody tr:hover { background:#fafbfe; }td > strong,td > small { display:block; }td > strong { color:var(--ink); font-size:9px; }td > small { max-width:230px; margin-top:3px; overflow:hidden; color:#9299a9; font-size:7px; text-overflow:ellipsis; }.person-cell { display:flex; align-items:center; gap:9px; }.person-cell > span { width:31px; height:31px; display:grid; place-items:center; border-radius:8px; color:#536691; background:#e8edf7; font-size:10px; font-weight:700; }.person-cell > div { display:grid; gap:2px; }.person-cell strong { color:var(--ink); font-size:9px; }.person-cell small { color:#9299a9; font-size:7px; }.mono { font-family:Consolas,monospace; }.row-action { border:0; color:var(--blue); background:transparent; font-size:8px; font-weight:700; }.row-action.primary { padding:6px 9px; border-radius:6px; color:#fff; background:var(--navy); }.table-chips { display:flex; gap:3px; }.table-chips span { padding:3px 5px; border-radius:4px; color:#5e6980; background:#eef1f6; font-size:7px; }.pin-label { color:var(--red); font-size:8px; } -.admin-exam-grid { display:grid; grid-template-columns:repeat(2,1fr); gap:15px; }.admin-exam-card { position:relative; padding:22px; border:1px solid var(--line); border-radius:var(--radius); background:#fff; overflow:hidden; }.admin-exam-card.published::before { content:""; position:absolute; top:0; bottom:0; left:0; width:4px; background:var(--jade); }.admin-exam-card header { display:flex; align-items:center; justify-content:space-between; }.admin-exam-card h2 { margin:16px 0 7px; font-family:"STKaiti"; font-size:21px; font-weight:400; }.admin-exam-card > p { min-height:34px; margin:0; color:#828a9a; font-size:9px; line-height:1.8; }.admin-exam-card dl { display:grid; grid-template-columns:1fr 1fr; gap:12px; margin:19px 0; }.admin-exam-card dl div:last-child { grid-column:1/-1; }.admin-exam-card dt { color:#999fac; font-size:7px; }.admin-exam-card dd { margin:3px 0 0; color:#5e687d; font-size:8px; }.admin-subjects { display:flex; flex-wrap:wrap; gap:5px; padding:12px; border-radius:8px; background:#f7f8fb; }.admin-subjects span { display:grid; gap:2px; padding:6px 8px; border:1px solid #e3e7ef; border-radius:5px; background:#fff; }.admin-subjects b { font-size:8px; }.admin-subjects small { color:#969dac; font-size:6px; }.admin-exam-card footer { display:flex; align-items:center; justify-content:space-between; margin-top:15px; padding-top:13px; border-top:1px solid var(--line); }.admin-exam-card footer > span { color:#858d9e; font-size:8px; }.results-admin-grid { display:grid; grid-template-columns:.8fr 1.2fr; gap:16px; }.result-entry form { display:grid; gap:14px; padding:20px; }.publish-switch { justify-content:flex-start; }.published-results > div:not(.panel-title) { display:grid; grid-template-columns:32px 1fr auto auto; align-items:center; gap:10px; padding:12px 18px; border-bottom:1px solid var(--line); }.published-results > div:last-child { border-bottom:0; }.published-results p { display:grid; gap:3px; margin:0; }.published-results p strong { font-size:9px; }.published-results p small { color:#9098a9; font-size:7px; }.published-results b { font-family:Georgia,serif; font-size:17px; font-weight:400; } +.admin-exam-grid { display:grid; grid-template-columns:repeat(2,1fr); gap:15px; }.admin-exam-card { position:relative; padding:22px; border:1px solid var(--line); border-radius:var(--radius); background:#fff; overflow:hidden; }.admin-exam-card.editable { cursor:pointer; transition:border-color .18s,box-shadow .18s,transform .18s; }.admin-exam-card.editable:hover { border-color:#bdc8df; box-shadow:var(--shadow); transform:translateY(-2px); }.admin-exam-card.published::before { content:""; position:absolute; top:0; bottom:0; left:0; width:4px; background:var(--jade); }.admin-exam-card header { display:flex; align-items:center; justify-content:space-between; }.admin-exam-card h2 { margin:16px 0 7px; font-family:"STKaiti"; font-size:21px; font-weight:400; }.admin-exam-card > p { min-height:34px; margin:0; color:#828a9a; font-size:9px; line-height:1.8; }.admin-exam-card dl { display:grid; grid-template-columns:1fr 1fr; gap:12px; margin:19px 0; }.admin-exam-card dl div:last-child { grid-column:1/-1; }.admin-exam-card dt { color:#999fac; font-size:7px; }.admin-exam-card dd { margin:3px 0 0; color:#5e687d; font-size:8px; }.admin-subjects { display:flex; flex-wrap:wrap; gap:5px; padding:12px; border-radius:8px; background:#f7f8fb; }.admin-subjects span { display:grid; gap:2px; padding:6px 8px; border:1px solid #e3e7ef; border-radius:5px; background:#fff; }.admin-subjects b { font-size:8px; }.admin-subjects small { color:#969dac; font-size:6px; }.admin-exam-card footer { display:flex; align-items:center; justify-content:space-between; gap:12px; margin-top:15px; padding-top:13px; border-top:1px solid var(--line); }.admin-exam-card footer > span { color:#858d9e; font-size:8px; }.exam-card-actions { display:flex; align-items:center; gap:7px; }.results-admin-grid { display:grid; grid-template-columns:.8fr 1.2fr; gap:16px; }.result-entry form { display:grid; gap:14px; padding:20px; }.publish-switch { justify-content:flex-start; }.published-results > div:not(.panel-title) { display:grid; grid-template-columns:32px 1fr auto auto; align-items:center; gap:10px; padding:12px 18px; border-bottom:1px solid var(--line); }.published-results > div:last-child { border-bottom:0; }.published-results p { display:grid; gap:3px; margin:0; }.published-results p strong { font-size:9px; }.published-results p small { color:#9098a9; font-size:7px; }.published-results b { font-family:Georgia,serif; font-size:17px; font-weight:400; } /* Modals and feedback */ .modal-layer { position:fixed; inset:0; z-index:100; display:grid; place-items:center; padding:22px; background:rgba(12,22,48,.55); backdrop-filter:blur(5px); animation:fadeIn .18s ease; }.modal-card { width:min(620px,100%); max-height:90vh; border-radius:16px; background:#fff; box-shadow:0 30px 90px rgba(13,23,51,.3); overflow-y:auto; animation:modalIn .22s ease; }.modal-head { display:flex; align-items:flex-start; justify-content:space-between; gap:20px; padding:22px 24px 18px; border-bottom:1px solid var(--line); }.modal-head span { color:#8b94a8; font-family:Consolas,monospace; font-size:8px; letter-spacing:1.5px; }.modal-head h2 { margin:5px 0; font-family:"STKaiti"; font-size:23px; font-weight:400; }.modal-head p { margin:0; color:#8c94a5; font-size:8px; }.modal-head > button { border:0; color:#8a92a2; background:transparent; font-size:23px; }.notice-content { padding:25px; }.notice-content p { margin:0 0 13px; color:#525d73; font-size:11px; line-height:2; }.modal-form { display:grid; gap:14px; padding:22px 24px 0; }.modal-foot { display:flex; justify-content:flex-end; gap:8px; margin:20px -24px 0; padding:15px 24px; border-top:1px solid var(--line); background:#fafbfc; }.modal-card > .modal-foot { margin:0; }.review-profile,.registration-review,.admit-preview { padding:22px 24px 0; }.review-profile dl { display:grid; grid-template-columns:1fr 1fr; gap:13px; margin:0; }.review-profile dl div,.registration-review dl div,.admit-preview dl div { display:grid; gap:4px; padding:10px; border-radius:7px; background:#f7f8fb; }.review-profile dt,.registration-review dt,.admit-preview dt { color:#969dac; font-size:7px; }.review-profile dd,.registration-review dd,.admit-preview dd { margin:0; color:#525d73; font-size:9px; }.registration-review > div > span { color:#9199a9; font-size:8px; }.registration-review > div p { display:flex; flex-wrap:wrap; gap:5px; }.registration-review > div b { padding:5px 8px; border-radius:5px; color:#54617a; background:#eef1f6; font-size:8px; }.registration-review dl,.admit-preview dl { display:grid; grid-template-columns:repeat(3,1fr); gap:9px; }.admit-preview > strong { display:block; margin:5px 0 18px; color:var(--navy); font-family:Consolas,monospace; font-size:26px; letter-spacing:2px; }.admit-preview > p { margin:16px 0 0; padding:11px; border-radius:7px; color:#7a5a24; background:#fff3dc; font-size:8px; }.toast { position:fixed; right:24px; bottom:24px; z-index:130; min-width:245px; display:flex; align-items:center; gap:11px; padding:13px 15px; border:1px solid #dfe7e3; border-radius:10px; background:#fff; box-shadow:0 17px 50px rgba(18,39,30,.17); opacity:0; transform:translateY(25px); pointer-events:none; transition:.25s; }.toast.show { opacity:1; transform:none; }.toast-icon { width:29px; height:29px; display:grid; place-items:center; border-radius:50%; color:#fff; background:var(--jade); font-size:11px; }.toast div { display:grid; gap:2px; }.toast strong { font-size:9px; }.toast small { color:#858d9e; font-size:8px; }.fatal-error { min-height:100vh; display:grid; place-content:center; justify-items:center; padding:25px; text-align:center; }.fatal-error > span { width:55px; height:55px; display:grid; place-items:center; border-radius:50%; color:#fff; background:var(--red); font-family:Georgia,serif; font-size:28px; }.fatal-error h1 { margin:18px 0 8px; font-family:"STKaiti"; font-size:28px; font-weight:400; }.fatal-error p { margin:0 0 18px; color:#7f8799; font-size:10px; }.empty-state { padding:35px; color:#8b93a4; font-size:9px; text-align:center; } diff --git a/tests/system.test.mjs b/tests/system.test.mjs index a82f208..ae6f894 100644 --- a/tests/system.test.mjs +++ b/tests/system.test.mjs @@ -59,6 +59,11 @@ try { const sqliteFile = await readFile(testDb); assert.equal(sqliteFile.subarray(0, 16).toString(), 'SQLite format 3\0', '测试持久化文件必须是真实 SQLite 数据库'); + const appSource = await readFile(resolve(root, 'app.js'), 'utf8'); + assert.doesNotMatch(appSource, /onclick=["']event\.stopPropagation\(\)/, '弹窗不得截断内部按钮的委托点击事件'); + assert.match(appSource, /data-modal-backdrop/, '弹窗应仅在点击背景层本身时关闭'); + assert.match(appSource, /data-action="edit-exam"/, '考试草稿应提供编辑入口'); + const { DatabaseSync } = await import('node:sqlite'); const inspector = new DatabaseSync(testDb, { readOnly: true }); const tableNames = inspector.prepare(` @@ -115,6 +120,27 @@ try { assert.equal(createExam.data.exam.subjects.length, 3, '管理员应可创建多科目考试'); const exam = createExam.data.exam; + const draftResponse = await admin.request('/api/admin/exams', { + method: 'POST', + body: { + name: '待完善考试草稿', code: 'EX-TEST-DRAFT', description: '等待管理员继续配置', + registrationStart: new Date(now + 72 * hour).toISOString(), registrationEnd: new Date(now + 96 * hour).toISOString(), + examStart: new Date(now + 120 * hour).toISOString(), examEnd: new Date(now + 132 * hour).toISOString(), + admitDownloadStart: new Date(now + 100 * hour).toISOString(), admitDownloadEnd: new Date(now + 119 * hour).toISOString(), + location: '待定考点', status: 'draft', subjects: ['待定科目'] + } + }); + const editedDraft = await admin.request(`/api/admin/exams/${draftResponse.data.exam.id}`, { + method: 'PATCH', + body: { name: '已完善考试草稿', location: '测试中心', status: 'draft', subjects: ['语文', '数学'] } + }); + assert.equal(editedDraft.response.status, 200, '管理员应可继续编辑考试草稿'); + assert.deepEqual(editedDraft.data.exam.subjects.map(subject => subject.name), ['语文', '数学'], '草稿编辑应保存科目配置'); + const refreshedAdminExams = await admin.request('/api/admin/exams'); + const persistedDraft = refreshedAdminExams.data.exams.find(item => item.id === draftResponse.data.exam.id); + assert.equal(persistedDraft.name, '已完善考试草稿', '草稿修改应持久化'); + assert.equal(persistedDraft.subjects.length, 2, '草稿科目修改应持久化'); + const beforeApproval = await candidate.request('/api/candidate/registrations', { method: 'POST', body: { examId: exam.id, subjectIds: [exam.subjects[0].id] } }); assert.equal(beforeApproval.response.status, 403, '资料审核前不得报名考试');