From 1822d8b4da2f13e32de254a743e09ef60afe69fe Mon Sep 17 00:00:00 2001 From: XuJiacheng Date: Tue, 30 Dec 2025 15:26:53 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=BE=97=E5=88=86?= =?UTF-8?q?=E5=8D=A0=E6=AF=94=E8=AE=A1=E7=AE=97=E5=8A=9F=E8=83=BD=EF=BC=9B?= =?UTF-8?q?=E4=BC=98=E5=8C=96=E6=97=B6=E9=97=B4=E6=A0=BC=E5=BC=8F=E5=8C=96?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=E5=87=BD=E6=95=B0=EF=BC=9B=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E8=80=83=E8=AF=95=E8=AE=B0=E5=BD=95=E5=92=8C=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1=E9=A1=B5=E9=9D=A2=E4=BB=A5=E6=98=BE=E7=A4=BA?= =?UTF-8?q?=E5=BE=97=E5=88=86=E5=8D=A0=E6=AF=94=EF=BC=9B=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E5=BE=97=E5=88=86=E5=8D=A0=E6=AF=94=E6=B5=8B=E8=AF=95=E7=94=A8?= =?UTF-8?q?=E4=BE=8B=EF=BC=9B=E4=BF=AE=E5=A4=8D=E6=97=B6=E9=97=B4=E8=A7=A3?= =?UTF-8?q?=E6=9E=90=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/controllers/quizController.ts | 5 +- api/models/examTask.ts | 63 +++++++++---- api/models/quiz.ts | 82 +++++++++++++++-- data/survey.db | Bin 647168 -> 647168 bytes package.json | 2 +- src/pages/SubjectSelectionPage.tsx | 13 +-- src/pages/UserTaskPage.tsx | 3 +- src/pages/admin/AdminDashboardPage.tsx | 6 +- src/pages/admin/ExamSubjectPage.tsx | 4 +- src/pages/admin/ExamTaskPage.tsx | 26 ++++-- src/pages/admin/QuestionCategoryPage.tsx | 3 +- src/pages/admin/QuestionManagePage.tsx | 4 +- src/pages/admin/RecordDetailPage.tsx | 4 +- src/pages/admin/UserGroupManage.tsx | 4 +- src/pages/admin/UserManagePage.tsx | 19 ++-- src/pages/admin/UserRecordsPage.tsx | 4 +- src/utils/validation.ts | 62 ++++++++++++- test/score-percentage.test.ts | 111 +++++++++++++++++++++++ 18 files changed, 347 insertions(+), 68 deletions(-) create mode 100644 test/score-percentage.test.ts diff --git a/api/controllers/quizController.ts b/api/controllers/quizController.ts index cb823fc..5eb991e 100644 --- a/api/controllers/quizController.ts +++ b/api/controllers/quizController.ts @@ -86,6 +86,7 @@ export class QuizController { } const processedAnswers = []; + let totalPossibleScore = 0; for (const answer of answers) { const question = await QuestionModel.findById(answer.questionId); if (!question) { @@ -93,6 +94,8 @@ export class QuizController { continue; } + totalPossibleScore += Number(question.score) || 0; + if (question.type === 'multiple') { const optionCount = question.options ? question.options.length : 0; const unitScore = optionCount > 0 ? question.score / optionCount : 0; @@ -160,7 +163,7 @@ export class QuizController { processedAnswers.push(answer); } - const result = await QuizModel.submitQuiz({ userId, answers: processedAnswers }); + const result = await QuizModel.submitQuiz({ userId, answers: processedAnswers, totalPossibleScore }); if (subjectId || taskId) { let finalSubjectId: string | null = subjectId || null; diff --git a/api/models/examTask.ts b/api/models/examTask.ts index 58a2367..b67426b 100644 --- a/api/models/examTask.ts +++ b/api/models/examTask.ts @@ -46,6 +46,7 @@ export interface TaskReport { userName: string; userPhone: string; score: number | null; + scorePercentage: number | null; completedAt: string | null; }>; } @@ -81,16 +82,16 @@ export class ExamTaskModel { : 0; const passingUsers = report.details.filter((d) => { - if (d.score === null) return false; - return d.score / input.totalScore >= 0.6; + if (d.scorePercentage === null) return false; + return d.scorePercentage >= 60; }).length; const passRate = report.totalUsers > 0 ? Math.round((passingUsers / report.totalUsers) * 100) : 0; const excellentUsers = report.details.filter((d) => { - if (d.score === null) return false; - return d.score / input.totalScore >= 0.8; + if (d.scorePercentage === null) return false; + return d.scorePercentage >= 80; }).length; const excellentRate = @@ -142,20 +143,20 @@ export class ExamTaskModel { // 获取该任务的详细报表数据 const report = await this.getReport(task.id); - // 计算合格率(得分率60%以上) + // 计算合格率(得分占比60%以上) const passingUsers = report.details.filter((d: any) => { - if (d.score === null) return false; - return (d.score / task.totalScore) >= 0.6; + if (d.scorePercentage === null) return false; + return d.scorePercentage >= 60; }).length; const passRate = report.totalUsers > 0 ? Math.round((passingUsers / report.totalUsers) * 100) : 0; - // 计算优秀率(得分率80%以上) + // 计算优秀率(得分占比80%以上) const excellentUsers = report.details.filter((d: any) => { - if (d.score === null) return false; - return (d.score / task.totalScore) >= 0.8; + if (d.scorePercentage === null) return false; + return d.scorePercentage >= 80; }).length; const excellentRate = report.totalUsers > 0 @@ -198,20 +199,20 @@ export class ExamTaskModel { ? Math.round((report.completedUsers / report.totalUsers) * 100) : 0; - // 4. 计算合格率(得分率60%以上) + // 4. 计算合格率(得分占比60%以上) const passingUsers = report.details.filter(d => { - if (d.score === null) return false; - return (d.score / task.totalScore) >= 0.6; + if (d.scorePercentage === null) return false; + return d.scorePercentage >= 60; }).length; const passRate = report.totalUsers > 0 ? Math.round((passingUsers / report.totalUsers) * 100) : 0; - // 5. 计算优秀率(得分率80%以上) + // 5. 计算优秀率(得分占比80%以上) const excellentUsers = report.details.filter(d => { - if (d.score === null) return false; - return (d.score / task.totalScore) >= 0.8; + if (d.scorePercentage === null) return false; + return d.scorePercentage >= 80; }).length; const excellentRate = report.totalUsers > 0 @@ -509,16 +510,39 @@ export class ExamTaskModel { const subject = await import('./examSubject').then(({ ExamSubjectModel }) => ExamSubjectModel.findById(task.subjectId)); if (!subject) throw new Error('科目不存在'); + // 注意:同一用户可能多次参加同一任务考试,这里取“得分占比最高,其次完成时间最新”的那一次。 + // 得分占比优先用 quiz_answers/题目分值重算(兼容旧记录),否则回退到 quiz_records.score_percentage。 const sqlUsers = ` + WITH best_records AS ( + SELECT + r.*, + ROW_NUMBER() OVER ( + PARTITION BY r.user_id, r.task_id + ORDER BY COALESCE(r.score_percentage, -1) DESC, r.created_at DESC + ) as rn + FROM quiz_records r + WHERE r.task_id = ? + ), + totals AS ( + SELECT qa.record_id as recordId, SUM(q.score) as totalPossibleScore + FROM quiz_answers qa + JOIN questions q ON q.id = qa.question_id + GROUP BY qa.record_id + ) SELECT u.id as userId, u.name as userName, u.phone as userPhone, - qr.total_score as score, - qr.created_at as completedAt + br.total_score as score, + br.created_at as completedAt, + CASE + WHEN totals.totalPossibleScore > 0 THEN (br.total_score * 1.0 / totals.totalPossibleScore) * 100 + ELSE br.score_percentage + END as scorePercentage FROM exam_task_users etu JOIN users u ON etu.user_id = u.id - LEFT JOIN quiz_records qr ON u.id = qr.user_id AND qr.task_id = ? + LEFT JOIN best_records br ON br.user_id = u.id AND br.rn = 1 + LEFT JOIN totals ON totals.recordId = br.id WHERE etu.task_id = ? `; @@ -529,6 +553,7 @@ export class ExamTaskModel { userName: r.userName, userPhone: r.userPhone, score: r.score !== null ? r.score : null, + scorePercentage: r.scorePercentage !== null ? Number(r.scorePercentage) : null, completedAt: r.completedAt || null })); diff --git a/api/models/quiz.ts b/api/models/quiz.ts index 07571b9..2990971 100644 --- a/api/models/quiz.ts +++ b/api/models/quiz.ts @@ -42,6 +42,8 @@ export interface SubmitAnswerData { export interface SubmitQuizData { userId: string; answers: SubmitAnswerData[]; + // 试卷总分(题目满分之和)。用于计算得分占比 = totalScore/totalPossibleScore * 100 + totalPossibleScore?: number; } export class QuizModel { @@ -53,9 +55,11 @@ export class QuizModel { } // 创建答题记录 - static async createRecord(data: { userId: string; totalScore: number; correctCount: number; totalCount: number }): Promise { + static async createRecord(data: { userId: string; totalScore: number; correctCount: number; totalCount: number; totalPossibleScore?: number }): Promise { const id = uuidv4(); - const scorePercentage = data.totalCount > 0 ? (data.totalScore / data.totalCount) * 100 : 0; + const denomRaw = typeof data.totalPossibleScore === 'number' ? data.totalPossibleScore : data.totalCount; + const denom = denomRaw > 0 ? denomRaw : 0; + const scorePercentage = denom > 0 ? (data.totalScore / denom) * 100 : 0; const status = this.calculateStatus(scorePercentage); const sql = ` @@ -119,7 +123,8 @@ export class QuizModel { userId: data.userId, totalScore, correctCount, - totalCount + totalCount, + totalPossibleScore: data.totalPossibleScore }); // 创建答题答案 @@ -130,7 +135,35 @@ export class QuizModel { // 根据ID查找答题记录 static async findRecordById(id: string): Promise { - const sql = `SELECT id, user_id as userId, total_score as totalScore, correct_count as correctCount, total_count as totalCount, score_percentage as scorePercentage, status, created_at as createdAt FROM quiz_records WHERE id = ?`; + const sql = ` + SELECT r.id, + r.user_id as userId, + r.total_score as totalScore, + r.correct_count as correctCount, + r.total_count as totalCount, + CASE + WHEN totals.totalPossibleScore > 0 THEN (r.total_score * 1.0 / totals.totalPossibleScore) * 100 + ELSE r.score_percentage + END as scorePercentage, + CASE + WHEN totals.totalPossibleScore > 0 THEN + CASE + WHEN (r.total_score * 1.0 / totals.totalPossibleScore) * 100 < 60 THEN '不及格' + WHEN (r.total_score * 1.0 / totals.totalPossibleScore) * 100 < 80 THEN '合格' + ELSE '优秀' + END + ELSE r.status + END as status, + r.created_at as createdAt + FROM quiz_records r + LEFT JOIN ( + SELECT a.record_id as recordId, SUM(q.score) as totalPossibleScore + FROM quiz_answers a + JOIN questions q ON a.question_id = q.id + GROUP BY a.record_id + ) totals ON totals.recordId = r.id + WHERE r.id = ? + `; const record = await get(sql, [id]); return record || null; } @@ -139,11 +172,30 @@ export class QuizModel { static async findRecordsByUserId(userId: string, limit = 10, offset = 0): Promise<{ records: QuizRecord[]; total: number }> { const recordsSql = ` SELECT r.id, r.user_id as userId, r.total_score as totalScore, r.correct_count as correctCount, r.total_count as totalCount, - r.score_percentage as scorePercentage, r.status, r.created_at as createdAt, + CASE + WHEN totals.totalPossibleScore > 0 THEN (r.total_score * 1.0 / totals.totalPossibleScore) * 100 + ELSE r.score_percentage + END as scorePercentage, + CASE + WHEN totals.totalPossibleScore > 0 THEN + CASE + WHEN (r.total_score * 1.0 / totals.totalPossibleScore) * 100 < 60 THEN '不及格' + WHEN (r.total_score * 1.0 / totals.totalPossibleScore) * 100 < 80 THEN '合格' + ELSE '优秀' + END + ELSE r.status + END as status, + r.created_at as createdAt, COALESCE(r.subject_id, t.subject_id) as subjectId, COALESCE(s.name, ts.name) as subjectName, r.task_id as taskId, t.name as taskName FROM quiz_records r + LEFT JOIN ( + SELECT a.record_id as recordId, SUM(q.score) as totalPossibleScore + FROM quiz_answers a + JOIN questions q ON a.question_id = q.id + GROUP BY a.record_id + ) totals ON totals.recordId = r.id LEFT JOIN exam_tasks t ON r.task_id = t.id LEFT JOIN exam_subjects s ON r.subject_id = s.id LEFT JOIN exam_subjects ts ON t.subject_id = ts.id @@ -170,7 +222,19 @@ export class QuizModel { const recordsSql = ` SELECT r.id, r.user_id as userId, u.name as userName, u.phone as userPhone, r.total_score as totalScore, r.correct_count as correctCount, r.total_count as totalCount, - r.score_percentage as scorePercentage, r.status, + CASE + WHEN totals.totalPossibleScore > 0 THEN (r.total_score * 1.0 / totals.totalPossibleScore) * 100 + ELSE r.score_percentage + END as scorePercentage, + CASE + WHEN totals.totalPossibleScore > 0 THEN + CASE + WHEN (r.total_score * 1.0 / totals.totalPossibleScore) * 100 < 60 THEN '不及格' + WHEN (r.total_score * 1.0 / totals.totalPossibleScore) * 100 < 80 THEN '合格' + ELSE '优秀' + END + ELSE r.status + END as status, r.created_at as createdAt, COALESCE(r.subject_id, t.subject_id) as subjectId, COALESCE(s.name, ts.name) as subjectName, @@ -178,6 +242,12 @@ export class QuizModel { t.name as taskName FROM quiz_records r JOIN users u ON r.user_id = u.id + LEFT JOIN ( + SELECT a.record_id as recordId, SUM(q.score) as totalPossibleScore + FROM quiz_answers a + JOIN questions q ON a.question_id = q.id + GROUP BY a.record_id + ) totals ON totals.recordId = r.id LEFT JOIN exam_tasks t ON r.task_id = t.id LEFT JOIN exam_subjects s ON r.subject_id = s.id LEFT JOIN exam_subjects ts ON t.subject_id = ts.id diff --git a/data/survey.db b/data/survey.db index b43fe6dda1c1eb357e5be77e6cbb054f925a8759..b69abd425f844db8706593b29d1a0a06db24d492 100644 GIT binary patch delta 2750 zcma);Z-^9S9LINNZf<99Z{`v459--nvFZ{&^~^KR{5$AWw0A*Zt%PVg&oeWZ1Q{V= zy=Yz12@K?HvJn#UMV2VJYfBsl2?zHk=tWsrzL5*@u+YHXNA}FD8(xL?%)HoT`R;u8 z_k4ce-~NuwZaOl%X=!aGEEN>LK&n^z+f1YIK*y%81>VW7iz2445%1k8=4qeXbd^y*d4b}795mo z*=u*C+SaWvUTg4c*R8Kl-~Rka%a{Lgk=I0#Ns-Nbh&;=I#E(rFcwPt@_A#?rgb)kU z%NIAUSma@k6EtpmJ&&L%*R;|%y{D%K+8+-oASr?CAOY>J(K!ru)s==MQjU@fMShXI zNoiEF07$hDW(WFqZIE=0*~1{4Iw993rFc;bB`;uuUMiIoFjE4*fvaGqYkYqZJiS`6 zr2jcO<=>w_C^!9eeq_v0kCwnoM#s?feZyCV)#{F+bCtE)+vU0P=D}yxqutss`_&g( z6Nbi*{4J?sIU%Di^F!#6fO8D37}6MH$O3E!E<&~wV3ET~zdKa>=*`NOeGfryg?feC zw6=2}FL02J;{aL|F-YRbf^T4fl&}rYP!B@#ze^OkA+(H+|Tl357T>BtK{^K>b$PN zacpYxLa2ubBv$A_ijWUICypXJW{ff{l8l00cH+AHWp+N+mZyK+UYE&}+GzUz!tm~1 zAN#Elt*DRN5*mkQfIJ?0pL6qk1_Oi;G`THyESotIzm^^m8RxdGK-Tw|DCZt|0KCAY zAu}nY7Umwj4`9G27_?%Kg#q`t zcgMY^-puEw$eb~9p~#@nbD>D6hjU?5B$d@%DUzq5Tqu%~17VZReb9@Przp8MzP1=Q<+|3z`MlN{+v{jNR-2nTx8m@%f*)4lr?l6IfkDh+&f zT=IKZMW#!ERYX`|6%qEZiYN=LBEkZzNG=Mjxv;s^Vp3ogd1QfAL|9<$3)eqtDN?gG zkm}hnAT`H&bd3qlcB+W#4394-%L%suF($KR%cOYv_%hBk{q|5@EqG;@S4%auAd*kd znlzm5ZPuk~)*hAWYH?aUNpT)MiB^2QsHPZG(qN%}Z_lVSy=PQD|2p4=E|kEZ;74#_ z+L)5hfGjyE?_91ZrOHsT0QDEMY2&P%uS)O8_3YAp>8WBSpAbnK`NSvXfjHSlDIkaG2~o*n`=}Yos#2C0P;VuOo%iyD~%93Zrha7 zGMm6)h*}WoVwNw+G@!}fg2J{P;Ixd`aCeS*LB_eQemNfKur-IFIYlhrX&N2Cy^};F z%`7u3Zn~{PBZRc6+PV+%lB%Gy0-tR_L{O`DQu`xiayLbqq*5e8qKg$`QoZhMtY^Z_1P`EOumK*=@Npz# z1g^~T-hD_JwrwBhyv%v^&4MEd-iXWm2HZ~YVWhx&C`t0Tq-6g?7Icp0>6XH+I8qzP$|A?C|l$M80ag-z;YRQ>W2kC7kNXxZhe@Y z&_pc}wPIAnMADy$^PV>YrGHPSc=JH~!e%stJFs#H&teZv#Qm@&F@y)c2JHz%dG@a% zFNugd(|%~glhuQWEg`3$@PQlb7vz#kCBVi?(RFbv& z{r}ItVp`KzLA95i^h`=q-n^8iLQhknrzvk;N>ibysnF9@=xHkSG`BwOX-cFzj0zft z4nd=7DcxG^=_nMZ(;BEqr+E{d>3dd{hxV+tX83ONrf0CGXEk;Z161>zNOZS%YTc7k zS)PW)IaRuU79hC|^(^!%tZL z=VkbO#K2yV)SFW&L#$Om(FP656C}+Li8AXLT9Au!|4O;@Ssg#8@rsaARk1<6#%pWT Zin%Q2`eLpYbM=_(kGX-E%b&VN@h|C!EfxR( diff --git a/package.json b/package.json index 56ae8d3..9d06ee5 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "build": "tsc && vite build", "preview": "vite preview", "start": "node dist/api/server.js", - "test": "node --import tsx --test test/admin-task-stats.test.ts test/question-text-import.test.ts test/swipe-detect.test.ts test/user-tasks.test.ts test/user-records-subjectname.test.ts", + "test": "node --import tsx --test test/admin-task-stats.test.ts test/question-text-import.test.ts test/swipe-detect.test.ts test/user-tasks.test.ts test/user-records-subjectname.test.ts test/score-percentage.test.ts", "check": "tsc --noEmit" }, "dependencies": { diff --git a/src/pages/SubjectSelectionPage.tsx b/src/pages/SubjectSelectionPage.tsx index 12c715d..1aec65f 100644 --- a/src/pages/SubjectSelectionPage.tsx +++ b/src/pages/SubjectSelectionPage.tsx @@ -5,6 +5,7 @@ import { useNavigate } from 'react-router-dom'; import { useUser } from '../contexts'; import { examSubjectAPI, examTaskAPI, quizAPI } from '../services/api'; import { UserLayout } from '../layouts/UserLayout'; +import { formatDate, parseUtcDateTime } from '../utils/validation'; const { Title, Text } = Typography; @@ -67,8 +68,8 @@ export const SubjectSelectionPage: React.FC = () => { const getTaskStatus = (task: ExamTask) => { const nowMs = Date.now(); - const startMs = new Date(task.startAt).getTime(); - const endMs = new Date(task.endAt).getTime(); + const startMs = parseUtcDateTime(task.startAt)?.getTime() ?? NaN; + const endMs = parseUtcDateTime(task.endAt)?.getTime() ?? NaN; const usedAttempts = Number(task.usedAttempts) || 0; const maxAttempts = Number(task.maxAttempts) || 3; @@ -232,7 +233,7 @@ export const SubjectSelectionPage: React.FC = () => {
- {new Date(task.startAt).toLocaleDateString()} - {new Date(task.endAt).toLocaleDateString()} + {formatDate(task.startAt)} - {formatDate(task.endAt)}
@@ -265,7 +266,7 @@ export const SubjectSelectionPage: React.FC = () => { const usedAttempts = Number(task.usedAttempts) || 0; const maxAttempts = Number(task.maxAttempts) || 3; const attemptsExhausted = usedAttempts >= maxAttempts; - const endMs = new Date(task.endAt).getTime(); + const endMs = parseUtcDateTime(task.endAt)?.getTime() ?? NaN; const isExpired = Number.isFinite(endMs) ? endMs < Date.now() : true; const isDisabled = attemptsExhausted || isExpired; return ( @@ -308,7 +309,7 @@ export const SubjectSelectionPage: React.FC = () => {
- {new Date(task.startAt).toLocaleDateString()} - {new Date(task.endAt).toLocaleDateString()} + {formatDate(task.startAt)} - {formatDate(task.endAt)}
{attemptsExhausted ? ( @@ -385,7 +386,7 @@ export const SubjectSelectionPage: React.FC = () => {
- {new Date(task.startAt).toLocaleDateString()} - {new Date(task.endAt).toLocaleDateString()} + {formatDate(task.startAt)} - {formatDate(task.endAt)}
diff --git a/src/pages/UserTaskPage.tsx b/src/pages/UserTaskPage.tsx index 7765eb8..755f97d 100644 --- a/src/pages/UserTaskPage.tsx +++ b/src/pages/UserTaskPage.tsx @@ -5,6 +5,7 @@ import { useNavigate } from 'react-router-dom'; import { useUser } from '../contexts'; import { quizAPI } from '../services/api'; import { UserLayout } from '../layouts/UserLayout'; +import { formatDateTime } from '../utils/validation'; const { Title, Text } = Typography; @@ -138,7 +139,7 @@ export const UserTaskPage: React.FC = () => { - {new Date(date).toLocaleString('zh-CN')} + {formatDateTime(date)} ) diff --git a/src/pages/admin/AdminDashboardPage.tsx b/src/pages/admin/AdminDashboardPage.tsx index 16006a8..385732a 100644 --- a/src/pages/admin/AdminDashboardPage.tsx +++ b/src/pages/admin/AdminDashboardPage.tsx @@ -9,7 +9,7 @@ import { ReloadOutlined, } from '@ant-design/icons'; import { adminAPI, quizAPI } from '../../services/api'; -import { formatDateTime } from '../../utils/validation'; +import { formatDateTime, parseUtcDateTime } from '../../utils/validation'; import type { Dayjs } from 'dayjs'; import { PieChart, @@ -258,8 +258,8 @@ const AdminDashboardPage = () => { key: 'progress', render: (_: any, record: ActiveTaskStat) => { const now = new Date(); - const start = new Date(record.startAt); - const end = new Date(record.endAt); + const start = parseUtcDateTime(record.startAt) ?? new Date(record.startAt); + const end = parseUtcDateTime(record.endAt) ?? new Date(record.endAt); const totalDuration = end.getTime() - start.getTime(); const elapsedDuration = now.getTime() - start.getTime(); diff --git a/src/pages/admin/ExamSubjectPage.tsx b/src/pages/admin/ExamSubjectPage.tsx index af3bc6a..125ae2a 100644 --- a/src/pages/admin/ExamSubjectPage.tsx +++ b/src/pages/admin/ExamSubjectPage.tsx @@ -3,6 +3,7 @@ import { Table, Button, Input, Space, message, Popconfirm, Modal, Form, InputNum import { PlusOutlined, EditOutlined, DeleteOutlined, EyeOutlined } from '@ant-design/icons'; import api from '../../services/api'; import { getCategoryColorHex } from '../../lib/categoryColors'; +import { parseUtcDateTime } from '../../utils/validation'; interface Question { id: string; @@ -417,7 +418,8 @@ const ExamSubjectPage = () => { dataIndex: 'createdAt', key: 'createdAt', render: (text: string) => { - const date = new Date(text); + const date = parseUtcDateTime(text) ?? new Date(text); + if (Number.isNaN(date.getTime())) return text; return (
{date.toLocaleDateString()}
diff --git a/src/pages/admin/ExamTaskPage.tsx b/src/pages/admin/ExamTaskPage.tsx index a0612a6..cb127ce 100644 --- a/src/pages/admin/ExamTaskPage.tsx +++ b/src/pages/admin/ExamTaskPage.tsx @@ -3,6 +3,7 @@ import { Table, Button, Input, Space, message, Popconfirm, Modal, Form, DatePick import { PlusOutlined, EditOutlined, DeleteOutlined, FileTextOutlined } from '@ant-design/icons'; import api, { userGroupAPI } from '../../services/api'; import dayjs from 'dayjs'; +import { formatDateTime, parseUtcDateTime } from '../../utils/validation'; interface ExamTask { id: string; @@ -148,8 +149,8 @@ const ExamTaskPage = () => { form.setFieldsValue({ name: task.name, subjectId: task.subjectId, - startAt: dayjs(task.startAt), - endAt: dayjs(task.endAt), + startAt: dayjs(parseUtcDateTime(task.startAt) ?? task.startAt), + endAt: dayjs(parseUtcDateTime(task.endAt) ?? task.endAt), userIds: userIds, groupIds: groupIds, }); @@ -228,14 +229,14 @@ const ExamTaskPage = () => { dataIndex: 'startAt', key: 'startAt', width: 110, - render: (text: string) => dayjs(text).format('YYYY-MM-DD HH:mm'), + render: (text: string) => formatDateTime(text), }, { title: '结束时间', dataIndex: 'endAt', key: 'endAt', width: 110, - render: (text: string) => dayjs(text).format('YYYY-MM-DD HH:mm'), + render: (text: string) => formatDateTime(text), }, { title: '考试进程', @@ -244,8 +245,8 @@ const ExamTaskPage = () => { width: 160, render: (_: any, record: ExamTask) => { const now = dayjs(); - const start = dayjs(record.startAt); - const end = dayjs(record.endAt); + const start = dayjs(parseUtcDateTime(record.startAt) ?? record.startAt); + const end = dayjs(parseUtcDateTime(record.endAt) ?? record.endAt); let progress = 0; if (now < start) { @@ -306,7 +307,7 @@ const ExamTaskPage = () => { dataIndex: 'createdAt', key: 'createdAt', width: 110, - render: (text: string) => dayjs(text).format('YYYY-MM-DD HH:mm'), + render: (text: string) => formatDateTime(text), }, { title:
操作
, @@ -523,11 +524,20 @@ const ExamTaskPage = () => { key: 'score', render: (score: number | null) => score !== null ? `${score} 分` : '未答题', }, + { + title: '得分占比', + dataIndex: 'scorePercentage', + key: 'scorePercentage', + render: (pct: number | null) => { + if (pct === null || pct === undefined || Number.isNaN(Number(pct))) return '未答题'; + return `${Math.round(Number(pct) * 10) / 10}%`; + }, + }, { title: '完成时间', dataIndex: 'completedAt', key: 'completedAt', - render: (date: string | null) => date ? dayjs(date).format('YYYY-MM-DD HH:mm') : '未答题', + render: (date: string | null) => (date ? formatDateTime(date) : '未答题'), }, ]} dataSource={reportData.details} diff --git a/src/pages/admin/QuestionCategoryPage.tsx b/src/pages/admin/QuestionCategoryPage.tsx index 7803a99..d531d02 100644 --- a/src/pages/admin/QuestionCategoryPage.tsx +++ b/src/pages/admin/QuestionCategoryPage.tsx @@ -4,6 +4,7 @@ import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons'; import { useLocation } from 'react-router-dom'; import api from '../../services/api'; import { getCategoryColorHex } from '../../lib/categoryColors'; +import { formatDateTime } from '../../utils/validation'; interface QuestionCategory { id: string; @@ -118,7 +119,7 @@ const QuestionCategoryPage = () => { width: 200, dataIndex: 'createdAt', key: 'createdAt', - render: (text: string) => new Date(text).toLocaleString(), + render: (text: string) => formatDateTime(text, { includeSeconds: true }), }, { title:
操作
, diff --git a/src/pages/admin/QuestionManagePage.tsx b/src/pages/admin/QuestionManagePage.tsx index 0b1f025..f15cdc7 100644 --- a/src/pages/admin/QuestionManagePage.tsx +++ b/src/pages/admin/QuestionManagePage.tsx @@ -33,7 +33,7 @@ import { import * as XLSX from 'xlsx'; import { useNavigate } from 'react-router-dom'; import { questionAPI } from '../../services/api'; -import { questionTypeMap, questionTypeColors } from '../../utils/validation'; +import { formatDate, questionTypeMap, questionTypeColors } from '../../utils/validation'; import { getCategoryColorHex } from '../../lib/categoryColors'; const { Option } = Select; @@ -428,7 +428,7 @@ const QuestionManagePage = () => { dataIndex: 'createdAt', key: 'createdAt', width: 160, - render: (date: string) => new Date(date).toLocaleDateString(), + render: (date: string) => formatDate(date), }, { title:
操作
, diff --git a/src/pages/admin/RecordDetailPage.tsx b/src/pages/admin/RecordDetailPage.tsx index 7d66829..1845154 100644 --- a/src/pages/admin/RecordDetailPage.tsx +++ b/src/pages/admin/RecordDetailPage.tsx @@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react'; import { Table, Card, Row, Col, Statistic, Button, message } from 'antd'; import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell } from 'recharts'; import api from '../../services/api'; -import dayjs from 'dayjs'; +import { formatDateTime } from '../../utils/validation'; interface Answer { id: string; @@ -154,7 +154,7 @@ const RecordDetailPage = ({ recordId }: { recordId: string }) => {

答题记录详情

- 答题时间:{dayjs(record.createdAt).format('YYYY-MM-DD HH:mm:ss')} + 答题时间:{formatDateTime(record.createdAt, { includeSeconds: true })}

diff --git a/src/pages/admin/UserGroupManage.tsx b/src/pages/admin/UserGroupManage.tsx index da24eef..060cf82 100644 --- a/src/pages/admin/UserGroupManage.tsx +++ b/src/pages/admin/UserGroupManage.tsx @@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react'; import { Table, Button, Input, Space, message, Popconfirm, Modal, Form, Tag } from 'antd'; import { PlusOutlined, EditOutlined, DeleteOutlined, TeamOutlined } from '@ant-design/icons'; import { userGroupAPI } from '../../services/api'; -import dayjs from 'dayjs'; +import { formatDateTime } from '../../utils/validation'; interface UserGroup { id: string; @@ -116,7 +116,7 @@ const UserGroupManage = () => { title: '创建时间', dataIndex: 'createdAt', key: 'createdAt', - render: (text: string) => dayjs(text).format('YYYY-MM-DD HH:mm'), + render: (text: string) => formatDateTime(text), }, { title:
操作
, diff --git a/src/pages/admin/UserManagePage.tsx b/src/pages/admin/UserManagePage.tsx index cbfbfdb..ceb9978 100644 --- a/src/pages/admin/UserManagePage.tsx +++ b/src/pages/admin/UserManagePage.tsx @@ -1,3 +1,4 @@ +import { formatDateTime } from '../../utils/validation'; import React, { useState, useEffect } from 'react'; import { Table, Button, Input, Space, message, Popconfirm, Modal, Form, Switch, Upload, Tabs, Select, Tag } from 'antd'; import { PlusOutlined, EditOutlined, DeleteOutlined, ExportOutlined, ImportOutlined, EyeOutlined, EyeInvisibleOutlined } from '@ant-design/icons'; @@ -127,24 +128,22 @@ const UserManagePage = () => { fetchUsers(newPagination.current, newPagination.pageSize, searchKeyword); }; - // 处理搜索 const handleSearch = () => { fetchUsers(1, pagination.pageSize, searchKeyword); }; - // 处理搜索框变化 const handleSearchChange = (e: React.ChangeEvent) => { setSearchKeyword(e.target.value); }; const handleCreate = async () => { + const groups = await fetchUserGroups(); setEditingUser(null); form.resetFields(); - - const groups = await fetchUserGroups(); const systemGroups = groups.filter((g) => g.isSystem).map((g) => g.id); - form.setFieldsValue({ groupIds: systemGroups }); - + if (systemGroups.length) { + form.setFieldsValue({ groupIds: systemGroups }); + } setModalVisible(true); }; @@ -367,13 +366,13 @@ const UserManagePage = () => { title: '最后一次考试时间', dataIndex: 'lastExamTime', key: 'lastExamTime', - render: (time: string) => time ? new Date(time).toLocaleString() : '无', + render: (time: string) => (time ? formatDateTime(time, { includeSeconds: true }) : '无'), }, { title: '注册时间', dataIndex: 'createdAt', key: 'createdAt', - render: (text: string) => new Date(text).toLocaleString(), + render: (text: string) => formatDateTime(text, { includeSeconds: true }), }, { title:
操作
, @@ -522,7 +521,7 @@ const UserManagePage = () => { title: '考试时间', dataIndex: 'createdAt', key: 'createdAt', - render: (time: string) => new Date(time).toLocaleString(), + render: (time: string) => formatDateTime(time, { includeSeconds: true }), }, ]} dataSource={userRecords} @@ -621,7 +620,7 @@ const UserManagePage = () => {
- {new Date(recordDetail.createdAt).toLocaleString()} + {formatDateTime(recordDetail.createdAt, { includeSeconds: true })}
diff --git a/src/pages/admin/UserRecordsPage.tsx b/src/pages/admin/UserRecordsPage.tsx index ad726cb..00b1aed 100644 --- a/src/pages/admin/UserRecordsPage.tsx +++ b/src/pages/admin/UserRecordsPage.tsx @@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react'; import { Table, Card, Row, Col, Statistic, Button, message } from 'antd'; import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell } from 'recharts'; import api from '../../services/api'; -import dayjs from 'dayjs'; +import { formatDateTime } from '../../utils/validation'; interface Record { id: string; @@ -77,7 +77,7 @@ const UserRecordsPage = ({ userId }: { userId: string }) => { title: '答题时间', dataIndex: 'createdAt', key: 'createdAt', - render: (text: string) => dayjs(text).format('YYYY-MM-DD HH:mm:ss'), + render: (text: string) => formatDateTime(text, { includeSeconds: true }), }, { title: '状态', diff --git a/src/utils/validation.ts b/src/utils/validation.ts index 0bbce0d..eb12ac8 100644 --- a/src/utils/validation.ts +++ b/src/utils/validation.ts @@ -54,17 +54,73 @@ export const generateId = (): string => { }; // 格式化时间 -export const formatDateTime = (dateString: string): string => { - const date = new Date(dateString); +const hasExplicitTimeZone = (value: string) => { + // ISO with Z or numeric offset, e.g. 2025-01-01T00:00:00Z / 2025-01-01T00:00:00+08:00 + return /([zZ]|[+-]\d{2}:?\d{2})$/.test(value.trim()); +}; + +const normalizeUtcStringToIso = (value: string) => { + const v = value.trim(); + + // Already ISO with timezone + if (hasExplicitTimeZone(v)) return v; + + // SQLite CURRENT_TIMESTAMP: "YYYY-MM-DD HH:mm:ss" (UTC) + if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}(:\d{2})?$/.test(v)) { + const iso = v.replace(' ', 'T'); + return iso.length === 16 ? `${iso}:00Z` : `${iso}Z`; + } + + // ISO without timezone: treat as UTC + if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d{1,3})?)?$/.test(v)) { + return `${v}Z`; + } + + // Date only: treat as UTC midnight + if (/^\d{4}-\d{2}-\d{2}$/.test(v)) { + return `${v}T00:00:00Z`; + } + + return v; +}; + +export const parseUtcDateTime = (value: string | null | undefined): Date | null => { + if (!value) return null; + const normalized = normalizeUtcStringToIso(String(value)); + const date = new Date(normalized); + if (Number.isNaN(date.getTime())) return null; + return date; +}; + +export const formatDateTime = ( + dateString: string, + options?: { includeSeconds?: boolean; dateOnly?: boolean }, +): string => { + const date = parseUtcDateTime(dateString); + if (!date) return dateString; + + if (options?.dateOnly) { + return date.toLocaleDateString('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + }); + } + return date.toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', - minute: '2-digit' + minute: '2-digit', + ...(options?.includeSeconds ? { second: '2-digit' as const } : null), }); }; +export const formatDate = (dateString: string): string => { + return formatDateTime(dateString, { dateOnly: true }); +}; + // 计算正确率 export const calculateCorrectRate = (correct: number, total: number): string => { if (total === 0) return '0%'; diff --git a/test/score-percentage.test.ts b/test/score-percentage.test.ts new file mode 100644 index 0000000..013ce3f --- /dev/null +++ b/test/score-percentage.test.ts @@ -0,0 +1,111 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { randomUUID } from 'node:crypto'; + +process.env.NODE_ENV = 'test'; +process.env.DB_PATH = ':memory:'; + +const jsonFetch = async ( + baseUrl: string, + path: string, + options?: { method?: string; body?: unknown }, +) => { + const res = await fetch(`${baseUrl}${path}`, { + method: options?.method ?? 'GET', + headers: options?.body ? { 'Content-Type': 'application/json' } : undefined, + body: options?.body ? JSON.stringify(options.body) : undefined, + }); + + const text = await res.text(); + let json: any = null; + try { + json = text ? JSON.parse(text) : null; + } catch { + json = null; + } + return { status: res.status, json, text }; +}; + +test('得分占比=总得分/试卷总分;40%应判定为不及格', async () => { + const { initDatabase, run, get } = await import('../api/database'); + await initDatabase(); + + const { app } = await import('../api/server'); + const server = app.listen(0); + + try { + const addr = server.address(); + assert.ok(addr && typeof addr === 'object'); + const baseUrl = `http://127.0.0.1:${addr.port}`; + + const userId = randomUUID(); + + await run(`INSERT INTO users (id, name, phone, password) VALUES (?, ?, ?, ?)`, [ + userId, + '测试用户', + '13900139001', + '', + ]); + + // 5道单选题,每题5分,总分25;答对2题得10分,得分占比=40% + const questionIds: string[] = []; + for (let i = 0; i < 5; i++) { + const qid = randomUUID(); + questionIds.push(qid); + await run( + `INSERT INTO questions (id, content, type, options, answer, analysis, score, category) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + [ + qid, + `题目${i + 1}`, + 'single', + JSON.stringify(['A', 'B', 'C', 'D']), + 'A', + '', + 5, + '通用', + ], + ); + } + + const answers = questionIds.map((questionId, idx) => ({ + questionId, + userAnswer: idx < 2 ? 'A' : 'B', + score: 0, + isCorrect: false, + })); + + const submitRes = await jsonFetch(baseUrl, '/api/quiz/submit', { + method: 'POST', + body: { userId, answers }, + }); + + assert.equal(submitRes.status, 200, submitRes.text); + assert.equal(submitRes.json?.success, true); + const recordId = submitRes.json?.data?.recordId; + assert.ok(recordId); + + const detailRes = await jsonFetch(baseUrl, `/api/quiz/records/detail/${recordId}`); + assert.equal(detailRes.status, 200, detailRes.text); + assert.equal(detailRes.json?.success, true); + + const record = detailRes.json?.data?.record; + assert.ok(record); + assert.equal(record.totalScore, 10); + assert.equal(record.correctCount, 2); + assert.equal(record.totalCount, 5); + + assert.ok(typeof record.scorePercentage === 'number'); + assert.ok(Math.abs(record.scorePercentage - 40) < 0.0001); + assert.equal(record.status, '不及格'); + + // 数据库里也应写入合理的score_percentage(避免旧逻辑200%) + const dbRow = await get(`SELECT score_percentage as scorePercentage, status FROM quiz_records WHERE id = ?`, [recordId]); + assert.ok(dbRow); + assert.ok(typeof dbRow.scorePercentage === 'number'); + assert.ok(Math.abs(dbRow.scorePercentage - 40) < 0.0001); + assert.equal(dbRow.status, '不及格'); + } finally { + server.close(); + } +});