From d706019d6a3f75b14a8e8d2aa7deb82153b3c6eb Mon Sep 17 00:00:00 2001 From: wecoding Date: Sun, 17 May 2026 11:02:25 +0800 Subject: [PATCH 1/3] Normalize admin module roadmap --- .tech/03-next-development-plan.md | 66 +++ apps/admin/config/routes.test.ts | 32 + apps/admin/config/routes.ts | 76 +-- .../src/components/RightContent/index.tsx | 8 +- apps/admin/src/locales/en-US/menu.ts | 8 - apps/admin/src/locales/en-US/pages.ts | 55 +- apps/admin/src/locales/zh-CN/menu.ts | 8 - apps/admin/src/locales/zh-CN/pages.ts | 53 +- .../Assessment/Results/JudgeTasks/index.tsx | 10 +- .../Assessment/Results/Submissions/index.tsx | 10 +- apps/admin/src/pages/ComingSoon/index.tsx | 102 ---- .../pages/Content/Papers/Detail/index.less | 27 + .../src/pages/Content/Papers/Detail/index.tsx | 90 ++- .../pages/Content/Papers/Detail/model.test.ts | 64 ++ .../src/pages/Content/Papers/Detail/model.ts | 49 ++ .../pages/Examination/ExamDetail/index.tsx | 46 +- .../src/pages/Examination/ExamDetail/model.ts | 26 - .../pages/Examination/ExamPublish/index.tsx | 2 +- .../Monitoring/Proctoring/Events/index.tsx | 257 +++++++- apps/admin/src/theme/preference.test.ts | 23 + apps/admin/src/theme/preference.ts | 16 +- .../2026-05-17-examora-module-batches.md | 557 ++++++++++++++++++ ...ora-industry-exam-system-roadmap-design.md | 150 +++++ 23 files changed, 1365 insertions(+), 370 deletions(-) create mode 100644 .tech/03-next-development-plan.md create mode 100644 apps/admin/config/routes.test.ts delete mode 100644 apps/admin/src/pages/ComingSoon/index.tsx create mode 100644 docs/superpowers/plans/2026-05-17-examora-module-batches.md create mode 100644 docs/superpowers/specs/2026-05-17-examora-industry-exam-system-roadmap-design.md diff --git a/.tech/03-next-development-plan.md b/.tech/03-next-development-plan.md new file mode 100644 index 0000000..8f14348 --- /dev/null +++ b/.tech/03-next-development-plan.md @@ -0,0 +1,66 @@ +# Examora Next Development Plan + +## Current Source of Truth + +The current high-level product and architecture roadmap is: + +- `docs/superpowers/specs/2026-05-17-examora-industry-exam-system-roadmap-design.md` + +The staged implementation index is: + +- `docs/superpowers/plans/2026-05-17-examora-module-batches.md` + +This file keeps the internal engineering route aligned with those documents and records the admin route baseline that future work should build on. + +## Admin Route Baseline + +Use the new admin information architecture only: + +- `/overview/dashboard` +- `/content/library/questions` +- `/content/library/programming` +- `/content/papers` +- `/examination/exams` +- `/examination/submissions` +- `/examination/judge-tasks` +- `/examination/events` +- `/system/settings/users` +- `/system/settings/user-groups` + +Legacy routes stay removed and should resolve through the 404 fallback: + +- `/admin/questions` -> `/content/library/questions` +- `/admin/papers` -> `/content/papers` +- `/admin/exams` -> `/examination/exams` +- `/assessment/results/submissions` -> `/examination/submissions` +- `/assessment/results/judge-tasks` -> `/examination/judge-tasks` +- `/monitoring/proctoring/events` -> `/examination/events` +- `/examination/candidates` has no replacement until assignment management is implemented. + +## Batch Execution Route + +1. Admin Baseline + - Stabilize route migration, paper pre-publish checks, real audit events page, and theme behavior. + - Verify admin tests, lint, build, Go tests, and Chrome route checks. +2. Publish Safety + - Enforce paper and exam publish readiness in backend services. + - Direct API calls must not publish empty papers, zero-score papers, draft-question papers, or invalid exams. +3. Content Asset Center + - Harden question lifecycle, dependency checks, programming test-case requirements, and paper preview. +4. Exam Operations Center + - Make exam detail the operational console for snapshots, assignments, sessions, submissions, judge tasks, and events. +5. Candidate Desktop Flow + - Complete assigned exam list, start/resume, safe snapshot loading, autosave, and final submission. +6. Scoring and Judge Visibility + - Complete objective scoring visibility and programming judge result state. +7. Audit and Risk Review + - Normalize client events into searchable admin audit data without treating audit signals as automatic cheating conclusions. +8. Reports, Operations, and Docs + - Add lightweight reports, operational scripts, and public documentation updates. + +## Guardrails + +- Keep admin DTOs and candidate DTOs separate. +- Candidate APIs must not expose answer keys, hidden test cases, or internal scoring data. +- Source questions and papers remain mutable library assets; published exams must use frozen snapshots. +- Every batch should leave a working vertical slice and pass its declared verification commands. diff --git a/apps/admin/config/routes.test.ts b/apps/admin/config/routes.test.ts new file mode 100644 index 0000000..2839cb0 --- /dev/null +++ b/apps/admin/config/routes.test.ts @@ -0,0 +1,32 @@ +import routes from './routes'; + +const flattenPaths = (items: any[]): string[] => + items.flatMap((item) => [ + item.path, + ...(item.routes ? flattenPaths(item.routes) : []), + ]); + +describe('admin routes', () => { + test('does not keep legacy redirect routes after full migration', () => { + const paths = flattenPaths(routes).filter(Boolean); + + expect(paths).not.toContain('/admin/exams'); + expect(paths).not.toContain('/admin/exam/:id/publish'); + expect(paths).not.toContain('/admin/exam/create'); + expect(paths).not.toContain('/monitoring'); + expect(paths).not.toContain('/monitoring/proctoring'); + expect(paths).not.toContain('/monitoring/proctoring/events'); + expect(paths).not.toContain('/assessment'); + expect(paths).not.toContain('/assessment/results'); + expect(paths).not.toContain('/assessment/results/submissions'); + expect(paths).not.toContain('/assessment/results/judge-tasks'); + expect(paths).not.toContain('/examination/candidates'); + }); + + test('keeps an explicit not found fallback route', () => { + const paths = flattenPaths(routes).filter(Boolean); + + expect(paths).toContain('*'); + expect(paths).not.toContain('./*'); + }); +}); diff --git a/apps/admin/config/routes.ts b/apps/admin/config/routes.ts index 9533a4b..731e393 100644 --- a/apps/admin/config/routes.ts +++ b/apps/admin/config/routes.ts @@ -168,43 +168,6 @@ export default [ component: './Examination/ExamPublish', access: 'canAdmin', }, - // Legacy monitoring redirects - { - path: '/monitoring', - hideInMenu: true, - redirect: '/examination/events', - }, - { - path: '/monitoring/proctoring', - hideInMenu: true, - redirect: '/examination/events', - }, - { - path: '/monitoring/proctoring/events', - hideInMenu: true, - redirect: '/examination/events', - }, - // Legacy assessment redirects - { - path: '/assessment', - hideInMenu: true, - redirect: '/examination/submissions', - }, - { - path: '/assessment/results', - hideInMenu: true, - redirect: '/examination/submissions', - }, - { - path: '/assessment/results/submissions', - hideInMenu: true, - redirect: '/examination/submissions', - }, - { - path: '/assessment/results/judge-tasks', - hideInMenu: true, - redirect: '/examination/judge-tasks', - }, // System section { path: '/system', @@ -242,46 +205,9 @@ export default [ }, ], }, - // Legacy redirects - { - path: '/examination/candidates', - redirect: '/system/settings/user-groups', - }, - { - path: '/admin/exams', - redirect: '/examination/exams', - }, - { - path: '/admin/exam/:id/publish', - redirect: '/examination/exams/:id/publish', - }, - { - path: '/admin/exam/create', - redirect: '/examination/exams/create', - }, - { - path: '/admin', - redirect: '/system/settings/users', - }, - { - path: '/welcome', - redirect: '/overview/dashboard', - }, - { - path: '/exams', - redirect: '/examination/exams', - }, - { - path: '/exams/create', - redirect: '/examination/exams/create', - }, - { - path: '/exams/:id/publish', - redirect: '/examination/exams/:id/publish', - }, { component: './404', layout: false, - path: './*', + path: '*', }, ]; diff --git a/apps/admin/src/components/RightContent/index.tsx b/apps/admin/src/components/RightContent/index.tsx index 5e941a1..8907187 100644 --- a/apps/admin/src/components/RightContent/index.tsx +++ b/apps/admin/src/components/RightContent/index.tsx @@ -16,6 +16,7 @@ import { createStyles } from 'antd-style'; import React, { useEffect, useState } from 'react'; import { getEffectiveThemeMode, + getSystemPrefersDark, loadThemePreference, SYSTEM_DARK_QUERY, saveThemePreference, @@ -66,11 +67,6 @@ export const SelectLang: React.FC = () => { ); }; -function getSystemPrefersDark(): boolean { - if (typeof window === 'undefined' || !window.matchMedia) return false; - return window.matchMedia(SYSTEM_DARK_QUERY).matches; -} - const themeModeIcons: Record = { light: , dark: , @@ -114,7 +110,7 @@ export const ThemeSwitcher: React.FC = () => { ...state, settings: { ...state?.settings, - ...toLayoutSettings(next), + ...toLayoutSettings(next, systemPrefersDark), }, })); }; diff --git a/apps/admin/src/locales/en-US/menu.ts b/apps/admin/src/locales/en-US/menu.ts index 6e379b7..f901381 100644 --- a/apps/admin/src/locales/en-US/menu.ts +++ b/apps/admin/src/locales/en-US/menu.ts @@ -4,8 +4,6 @@ export default { 'menu.overview.dashboard': 'Dashboard', 'menu.content': 'Library', 'menu.examination': 'Exams', - 'menu.monitoring': 'MONITORING', - 'menu.assessment': 'ASSESSMENT', 'menu.system': 'System', 'menu.home': 'Home', 'menu.login': 'Login', @@ -30,12 +28,6 @@ export default { 'menu.examEdit': 'Edit Exam', 'menu.examPublish': 'Publish', 'menu.examination.examDetail': 'Exam Detail', - 'menu.examination.candidates': 'User Groups', - 'menu.proctoring': 'Proctoring', - 'menu.monitoring.events': 'Events', - 'menu.results': 'Results', - 'menu.assessment.submissions': 'Submissions', - 'menu.assessment.judgeTasks': 'Judge Tasks', 'menu.settings': 'Settings', 'menu.system.users': 'Users', 'menu.system.userGroups': 'User Groups', diff --git a/apps/admin/src/locales/en-US/pages.ts b/apps/admin/src/locales/en-US/pages.ts index fda72d8..11f2289 100644 --- a/apps/admin/src/locales/en-US/pages.ts +++ b/apps/admin/src/locales/en-US/pages.ts @@ -343,8 +343,16 @@ export default { 'pages.papers.detail.saveSuccess': 'Paper saved', 'pages.papers.detail.saveError': 'Failed to save paper', 'pages.papers.detail.loadError': 'Failed to load paper', + 'pages.papers.detail.publishBlockedTitle': 'Paper cannot be published', + 'pages.papers.detail.publishBlockedContent': + 'A paper cannot be published without questions. Add at least 1 published question, then confirm the total score is greater than 0 and no question has 0 score. Current questions: {questions}; unpublished: {unpublished}; zero-score: {zeroScore}.', 'pages.papers.detail.basicInfo': 'Basic Info', 'pages.papers.detail.summary': 'Paper Summary', + 'pages.papers.detail.publishReadyShort': 'Ready', + 'pages.papers.detail.publishNoQuestionsShort': 'No questions', + 'pages.papers.detail.publishNotReadyShort': 'Needs work', + 'pages.papers.detail.publishInlineSummary': + '{questions} questions / {score} pts · {unpublished} unpublished · {zeroScore} zero-score', 'pages.papers.detail.paperStructure': 'Paper Content', 'pages.papers.detail.defaultSectionTitle': 'Section 1', 'pages.papers.detail.sectionTitleTemplate': 'Section {index}', @@ -544,41 +552,18 @@ export default { 'pages.dashboard.shortcuts.questions': 'Question Bank', 'pages.dashboard.shortcuts.programming': 'Programming Bank', 'pages.dashboard.shortcuts.submissions': 'Submissions', - // Coming soon - 'pages.comingSoon.title': 'Module Under Construction', - 'pages.comingSoon.description': - 'This admin module is reserved in the navigation and will be connected in a future iteration.', - 'pages.comingSoon.shortTitle': 'Coming Soon', - 'pages.comingSoon.shortDescription': - 'This feature is under development. Please check back later.', - 'pages.comingSoon.backDashboard': 'Back to Dashboard', - 'pages.comingSoon.viewExams': 'View Exam Management', - 'pages.comingSoon.note': - 'This entry keeps the admin information architecture stable while business pages are added.', - 'pages.comingSoon.questions.title': 'Question Bank', - 'pages.comingSoon.questions.description': - 'Question lists, type settings, answers, and explanations will be connected here.', - 'pages.comingSoon.programming.title': 'Programming Questions and Test Cases', - 'pages.comingSoon.programming.description': - 'Programming templates, sample cases, hidden cases, and execution limits will be managed here.', - 'pages.comingSoon.papers.title': 'Paper Management', - 'pages.comingSoon.papers.description': - 'Paper composition, question ordering, score settings, and status flow will be connected here.', - 'pages.comingSoon.examCreate.title': 'Create Exam', - 'pages.comingSoon.examCreate.description': - 'Exam details, paper binding, and pre-publish configuration will be connected here.', - 'pages.comingSoon.candidates.title': 'Candidate Management', - 'pages.comingSoon.candidates.description': - 'Candidate accounts, exam authorization, groups, and import/export will be connected here.', - 'pages.comingSoon.events.title': 'Proctoring Audit', - 'pages.comingSoon.events.description': - 'Desktop events, device binding, and abnormal behavior records will be viewed here.', - 'pages.comingSoon.submissions.title': 'Submission Records', - 'pages.comingSoon.submissions.description': - 'Candidate papers, programming submissions, and scoring status will be summarized here.', - 'pages.comingSoon.judgeTasks.title': 'Judge Tasks', - 'pages.comingSoon.judgeTasks.description': - 'Async judge tasks, retry status, and sandbox results will be tracked here.', + // Events + 'pages.events.description': + 'View desktop audit events and device information by exam.', + 'pages.events.examsLoadError': 'Failed to load exams', + 'pages.events.fetchError': 'Failed to load audit events', + 'pages.events.columns.user': 'User ID', + 'pages.events.columns.device': 'Device', + 'pages.events.columns.type': 'Event Type', + 'pages.events.columns.createdAt': 'Time', + 'pages.events.examPlaceholder': 'Select exam', + 'pages.events.emptyExam': 'Select an exam to view audit events', + 'pages.events.detailTitle': 'Event Detail', // Exam form 'pages.exams.createTitle': 'Create Exam', 'pages.exams.editTitle': 'Edit Exam', diff --git a/apps/admin/src/locales/zh-CN/menu.ts b/apps/admin/src/locales/zh-CN/menu.ts index 0034727..43c0d1f 100644 --- a/apps/admin/src/locales/zh-CN/menu.ts +++ b/apps/admin/src/locales/zh-CN/menu.ts @@ -4,8 +4,6 @@ export default { 'menu.overview.dashboard': '工作台', 'menu.content': '资源库', 'menu.examination': '考试', - 'menu.monitoring': 'MONITORING', - 'menu.assessment': 'ASSESSMENT', 'menu.system': '系统', 'menu.home': '首页', 'menu.login': '登录', @@ -30,12 +28,6 @@ export default { 'menu.examEdit': '编辑考试', 'menu.examPublish': '发布考试', 'menu.examination.examDetail': '考试详情', - 'menu.examination.candidates': '用户组', - 'menu.proctoring': '监考', - 'menu.monitoring.events': '事件', - 'menu.results': '评测', - 'menu.assessment.submissions': '答卷', - 'menu.assessment.judgeTasks': '判题', 'menu.settings': '设置', 'menu.system.users': '用户', 'menu.system.userGroups': '用户组', diff --git a/apps/admin/src/locales/zh-CN/pages.ts b/apps/admin/src/locales/zh-CN/pages.ts index c745b90..0c1bca7 100644 --- a/apps/admin/src/locales/zh-CN/pages.ts +++ b/apps/admin/src/locales/zh-CN/pages.ts @@ -317,8 +317,16 @@ export default { 'pages.papers.detail.saveSuccess': '试卷已保存', 'pages.papers.detail.saveError': '保存试卷失败', 'pages.papers.detail.loadError': '加载试卷失败', + 'pages.papers.detail.publishBlockedTitle': '试卷暂不能发布', + 'pages.papers.detail.publishBlockedContent': + '试卷没有题目时不能发布。请至少添加 1 道已发布题目,并确认总分大于 0、没有 0 分题目。当前题目 {questions} 道,未发布题 {unpublished} 道,0 分题 {zeroScore} 道。', 'pages.papers.detail.basicInfo': '基本信息', 'pages.papers.detail.summary': '试卷汇总', + 'pages.papers.detail.publishReadyShort': '可发布', + 'pages.papers.detail.publishNoQuestionsShort': '无题目', + 'pages.papers.detail.publishNotReadyShort': '待完善', + 'pages.papers.detail.publishInlineSummary': + '{questions} 题 / {score} 分 · 未发布 {unpublished} · 0 分 {zeroScore}', 'pages.papers.detail.paperStructure': '试卷内容', 'pages.papers.detail.defaultSectionTitle': '第一大题', 'pages.papers.detail.sectionTitleTemplate': '第 {index} 大题', @@ -510,40 +518,17 @@ export default { 'pages.dashboard.shortcuts.questions': '题库管理', 'pages.dashboard.shortcuts.programming': '编程题库', 'pages.dashboard.shortcuts.submissions': '提交记录', - // Coming soon - 'pages.comingSoon.title': '模块建设中', - 'pages.comingSoon.description': - '该管理模块已预留导航入口,业务页面会在后续迭代接入。', - 'pages.comingSoon.shortTitle': '即将上线', - 'pages.comingSoon.shortDescription': '该功能正在开发中,敬请期待。', - 'pages.comingSoon.backDashboard': '返回工作台', - 'pages.comingSoon.viewExams': '查看考试管理', - 'pages.comingSoon.note': - '当前入口用于固定后台信息架构,避免后续业务页面上线时反复调整主菜单。', - 'pages.comingSoon.questions.title': '题库管理', - 'pages.comingSoon.questions.description': - '题目列表、题型配置、答案与解析维护即将接入。', - 'pages.comingSoon.programming.title': '编程题与测试用例', - 'pages.comingSoon.programming.description': - '编程题模板、样例用例、隐藏用例和运行限制将在这里管理。', - 'pages.comingSoon.papers.title': '试卷管理', - 'pages.comingSoon.papers.description': - '组卷、题目排序、分值配置和试卷状态流转即将接入。', - 'pages.comingSoon.examCreate.title': '创建考试', - 'pages.comingSoon.examCreate.description': - '考试基本信息、试卷绑定和发布前配置将在这里接入。', - 'pages.comingSoon.candidates.title': '考生管理', - 'pages.comingSoon.candidates.description': - '考生账号、考试授权、分组和导入导出能力即将接入。', - 'pages.comingSoon.events.title': '监考审计', - 'pages.comingSoon.events.description': - '桌面端事件、设备绑定、异常行为记录将在这里查看。', - 'pages.comingSoon.submissions.title': '提交记录', - 'pages.comingSoon.submissions.description': - '候选人答卷、编程提交和判分状态将在这里汇总。', - 'pages.comingSoon.judgeTasks.title': '判题任务', - 'pages.comingSoon.judgeTasks.description': - '异步判题任务、重试状态和沙箱结果将在这里跟踪。', + // Events + 'pages.events.description': '按考试查看桌面端上报的审计事件和设备信息。', + 'pages.events.examsLoadError': '加载考试列表失败', + 'pages.events.fetchError': '加载审计事件失败', + 'pages.events.columns.user': '用户ID', + 'pages.events.columns.device': '设备', + 'pages.events.columns.type': '事件类型', + 'pages.events.columns.createdAt': '时间', + 'pages.events.examPlaceholder': '选择考试', + 'pages.events.emptyExam': '请选择考试查看审计事件', + 'pages.events.detailTitle': '事件详情', // Exam form 'pages.exams.createTitle': '创建考试', 'pages.exams.editTitle': '编辑考试', diff --git a/apps/admin/src/pages/Assessment/Results/JudgeTasks/index.tsx b/apps/admin/src/pages/Assessment/Results/JudgeTasks/index.tsx index 91ff79e..3d9e51a 100644 --- a/apps/admin/src/pages/Assessment/Results/JudgeTasks/index.tsx +++ b/apps/admin/src/pages/Assessment/Results/JudgeTasks/index.tsx @@ -8,6 +8,7 @@ import type { AdminJudgeTask, AdminJudgeTaskPageResponse, } from '@examora/types'; +import { API_PATHS } from '@examora/types'; import { useIntl } from '@umijs/max'; import { App as AntdApp, @@ -30,9 +31,6 @@ import { } from './model'; const { Paragraph } = Typography; -const JUDGE_TASKS_PATH = '/api/v1/judge/tasks'; -const judgeTaskPath = (taskID: number | string) => - `/api/v1/judge/tasks/${taskID}`; const JudgeTasksContent: React.FC = () => { const intl = useIntl(); @@ -48,7 +46,7 @@ const JudgeTasksContent: React.FC = () => { setLoading(true); try { const data = await fetchEnvelope( - `${JUDGE_TASKS_PATH}?page=1&page_size=100`, + `${API_PATHS.admin.judgeTasks}?page=1&page_size=100`, ); setTasks(data.items || []); setTaskTotal(data.total || 0); @@ -74,7 +72,7 @@ const JudgeTasksContent: React.FC = () => { setDetailLoading(true); try { const data = await fetchEnvelope( - judgeTaskPath(record.id), + API_PATHS.admin.judgeTask(record.id), ); setDetail(data); } catch (error) { @@ -188,7 +186,7 @@ const JudgeTasksContent: React.FC = () => { return ( - `/api/v1/exams/${examID}/results`; -const examResultPath = (resultID: number | string) => - `/api/v1/exam-results/${resultID}`; const SubmissionsContent: React.FC = () => { const intl = useIntl(); @@ -90,7 +86,7 @@ const SubmissionsContent: React.FC = () => { setResultsLoading(true); try { const data = await fetchEnvelope( - `${examResultsPath(examID)}?page=1&page_size=100`, + `${API_PATHS.admin.examResults(examID)}?page=1&page_size=100`, ); setResults(data.items || []); setResultTotal(data.total || 0); @@ -116,7 +112,7 @@ const SubmissionsContent: React.FC = () => { setDetailLoading(true); try { const data = await fetchEnvelope( - examResultPath(record.id), + API_PATHS.admin.examResult(record.id), ); setDetail(data); } catch (error) { @@ -226,7 +222,7 @@ const SubmissionsContent: React.FC = () => { return ( = { - '/content/library/questions': { - titleId: 'pages.comingSoon.questions.title', - descriptionId: 'pages.comingSoon.questions.description', - icon: , - }, - '/content/library/programming': { - titleId: 'pages.comingSoon.programming.title', - descriptionId: 'pages.comingSoon.programming.description', - icon: , - }, - '/content/papers': { - titleId: 'pages.comingSoon.papers.title', - descriptionId: 'pages.comingSoon.papers.description', - icon: , - }, - '/examination/exams/create': { - titleId: 'pages.comingSoon.examCreate.title', - descriptionId: 'pages.comingSoon.examCreate.description', - icon: , - }, - '/examination/candidates': { - titleId: 'pages.comingSoon.candidates.title', - descriptionId: 'pages.comingSoon.candidates.description', - icon: , - }, - '/monitoring/proctoring/events': { - titleId: 'pages.comingSoon.events.title', - descriptionId: 'pages.comingSoon.events.description', - icon: , - }, - '/assessment/results/submissions': { - titleId: 'pages.comingSoon.submissions.title', - descriptionId: 'pages.comingSoon.submissions.description', - icon: , - }, - '/assessment/results/judge-tasks': { - titleId: 'pages.comingSoon.judgeTasks.title', - descriptionId: 'pages.comingSoon.judgeTasks.description', - icon: , - }, -}; - -const fallbackMeta = { - titleId: 'pages.comingSoon.title', - descriptionId: 'pages.comingSoon.description', - icon: , -}; - -const ComingSoon: React.FC = () => { - const intl = useIntl(); - const meta = moduleMeta[window.location.pathname] ?? fallbackMeta; - const title = intl.formatMessage({ id: meta.titleId }); - const description = intl.formatMessage({ id: meta.descriptionId }); - - return ( - - - - - - - } - /> - - {intl.formatMessage({ id: 'pages.comingSoon.note' })} - - - - ); -}; - -export default ComingSoon; diff --git a/apps/admin/src/pages/Content/Papers/Detail/index.less b/apps/admin/src/pages/Content/Papers/Detail/index.less index 0ed340e..ef2b549 100644 --- a/apps/admin/src/pages/Content/Papers/Detail/index.less +++ b/apps/admin/src/pages/Content/Papers/Detail/index.less @@ -427,6 +427,24 @@ html.examora-dark .pdetail-sticky-footer { color: var(--examora-text-secondary, #525252); } +.pdetail-save-status { + display: flex; + min-width: 0; + align-items: center; + gap: 8px; + flex: 1; + flex-wrap: wrap; +} + +.pdetail-save-summary { + overflow: hidden; + color: var(--examora-text-secondary, #525252); + font-size: 12px; + line-height: 20px; + white-space: nowrap; + text-overflow: ellipsis; +} + .paper-question-title { display: flex; min-width: 0; @@ -547,6 +565,15 @@ html.examora-dark .pdetail-sticky-footer { padding: 12px; } + .pdetail-save-status { + width: 100%; + } + + .pdetail-save-summary { + width: 100%; + white-space: normal; + } + .pdetail-sticky-footer .ant-space { width: 100%; justify-content: flex-end; diff --git a/apps/admin/src/pages/Content/Papers/Detail/index.tsx b/apps/admin/src/pages/Content/Papers/Detail/index.tsx index 8b73f54..149c2e1 100644 --- a/apps/admin/src/pages/Content/Papers/Detail/index.tsx +++ b/apps/admin/src/pages/Content/Papers/Detail/index.tsx @@ -47,6 +47,7 @@ import { Popconfirm, Select, Space, + Tag, Tooltip, } from 'antd'; import dayjs from 'dayjs'; @@ -55,6 +56,7 @@ import { requestErrorMessage } from '@/utils/request'; import { SectionTitle } from './components/SectionTitle'; import { SortableQuestionWrapper } from './components/SortableQuestionWrapper'; import { + buildPaperPreviewSummary, moveQuestionWithinSection, normalizeQuestions, normalizeSections, @@ -135,7 +137,7 @@ const parseQuestionDragId = (id: string) => { const PaperDetailContent: React.FC = () => { const intl = useIntl(); - const { message } = AntdApp.useApp(); + const { message, modal } = AntdApp.useApp(); const [form] = Form.useForm(); const paperId = history.location.pathname.split('/').filter(Boolean).at(-1); const isCreate = !paperId || paperId === 'new'; @@ -266,6 +268,11 @@ const PaperDetailContent: React.FC = () => { [sections], ); + const previewSummary = useMemo( + () => buildPaperPreviewSummary(sections), + [sections], + ); + useEffect(() => { if (isCreate) { const initialSections = [createDefaultSection(intl)]; @@ -588,6 +595,27 @@ const PaperDetailContent: React.FC = () => { const save = async () => { try { const values = await form.validateFields(); + if (values.status === 'PUBLISHED' && !previewSummary.canPublish) { + modal.warning({ + title: intl.formatMessage({ + id: 'pages.papers.detail.publishBlockedTitle', + defaultMessage: '试卷暂不能发布', + }), + content: intl.formatMessage( + { + id: 'pages.papers.detail.publishBlockedContent', + defaultMessage: + '试卷没有题目时不能发布。请至少添加 1 道已发布题目,并确认总分大于 0、没有 0 分题目。当前题目 {questions} 道,未发布题 {unpublished} 道,0 分题 {zeroScore} 道。', + }, + { + questions: previewSummary.questionCount, + unpublished: previewSummary.unpublishedQuestions.length, + zeroScore: previewSummary.zeroScoreQuestions.length, + }, + ), + }); + return; + } setSaving(true); const paperResponse = await request<{ code: number; data: Paper }>( isCreate ? API_PATHS.admin.papers : API_PATHS.admin.paper(paperId), @@ -1257,17 +1285,55 @@ const PaperDetailContent: React.FC = () => { {isDirty && (
- - {isCreate - ? intl.formatMessage({ - id: 'pages.papers.unsavedNew', - defaultMessage: '试卷尚未保存', - }) - : intl.formatMessage({ - id: 'pages.papers.unsavedEdit', - defaultMessage: '有未保存修改', - })} - +
+ + {isCreate + ? intl.formatMessage({ + id: 'pages.papers.unsavedNew', + defaultMessage: '试卷尚未保存', + }) + : intl.formatMessage({ + id: 'pages.papers.unsavedEdit', + defaultMessage: '有未保存修改', + })} + + + {previewSummary.canPublish + ? intl.formatMessage({ + id: 'pages.papers.detail.publishReadyShort', + defaultMessage: '可发布', + }) + : intl.formatMessage( + previewSummary.hasNoQuestions + ? { + id: 'pages.papers.detail.publishNoQuestionsShort', + defaultMessage: '无题目', + } + : { + id: 'pages.papers.detail.publishNotReadyShort', + defaultMessage: '待完善', + }, + )} + + + {intl.formatMessage( + { + id: 'pages.papers.detail.publishInlineSummary', + defaultMessage: + '{questions} 题 / {score} 分 · 未发布 {unpublished} · 0 分 {zeroScore}', + }, + { + questions: previewSummary.questionCount, + score: previewSummary.totalScore.toFixed(1), + unpublished: previewSummary.unpublishedQuestions.length, + zeroScore: previewSummary.zeroScoreQuestions.length, + }, + )} + +
, + ], + }, + ]; return ( } onClick={fetchEvents}> + {intl.formatMessage({ id: 'common.refresh', defaultMessage: '刷新' })} + , + ]} > - - +