From 57101fac37f79ec772f92262442dede63771cd30 Mon Sep 17 00:00:00 2001 From: XuJiacheng Date: Mon, 29 Dec 2025 20:28:33 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=9B=B4=E6=96=B0=E8=80=83=E8=AF=95?= =?UTF-8?q?=E7=9B=B8=E5=85=B3=E9=A1=B5=E9=9D=A2=EF=BC=8C=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E5=88=B7=E6=96=B0=E5=8A=9F=E8=83=BD=EF=BC=8C=E4=BF=AE=E6=94=B9?= =?UTF-8?q?=E8=80=83=E8=AF=95=E7=8A=B6=E6=80=81=E6=98=BE=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 注:当前代码还存在bug:查看结果暂出现错误。 - 在 SubjectSelectionPage 页面中添加刷新按钮,允许用户手动刷新考试任务列表。 - 修改 UserTaskPage 页面,重构考试任务为答题记录,更新数据结构和状态显示。 - 在 AdminDashboardPage、UserManagePage、UserRecordsPage 等管理页面中添加考试状态显示,使用不同颜色区分状态(不及格、合格、优秀)。 - 在 ResultPage 中显示考试状态,确保用户能够清晰了解考试结果。 - 添加约束,确保单次考试试卷中题目不可重复出现,并记录相关规范。 - 添加评分状态约束,根据得分占比自动计算考试状态,并在结果页面显示。 --- api/database/index.ts | 5 + api/database/init.sql | 2 + api/models/examSubject.ts | 35 ++-- api/models/quiz.ts | 38 +++- data/survey.db | Bin 647168 -> 647168 bytes .../proposal.md | 17 ++ .../specs/quiz-integrity/spec.md | 22 +++ .../tasks.md | 6 + .../add-scoring-status-constraint/proposal.md | 21 ++ .../specs/quiz-scoring/spec.md | 29 +++ .../add-scoring-status-constraint/tasks.md | 7 + src/layouts/AdminLayout.tsx | 8 +- src/layouts/UserLayout.tsx | 6 +- src/main.tsx | 3 - src/pages/HomePage.tsx | 6 +- src/pages/ResultPage.tsx | 76 +++++-- src/pages/SubjectSelectionPage.tsx | 76 ++++--- src/pages/UserTaskPage.tsx | 187 ++++++++---------- src/pages/admin/AdminDashboardPage.tsx | 24 +++ src/pages/admin/AdminLoginPage.tsx | 6 +- src/pages/admin/QuestionTextImportPage.tsx | 1 + src/pages/admin/RecordDetailPage.tsx | 30 ++- src/pages/admin/StatisticsPage.tsx | 32 ++- src/pages/admin/UserManagePage.tsx | 27 ++- src/pages/admin/UserRecordsPage.tsx | 28 +++ src/pages/quiz/components/OptionList.tsx | 4 +- 26 files changed, 480 insertions(+), 216 deletions(-) create mode 100644 openspec/changes/add-no-duplicate-questions-constraint/proposal.md create mode 100644 openspec/changes/add-no-duplicate-questions-constraint/specs/quiz-integrity/spec.md create mode 100644 openspec/changes/add-no-duplicate-questions-constraint/tasks.md create mode 100644 openspec/changes/add-scoring-status-constraint/proposal.md create mode 100644 openspec/changes/add-scoring-status-constraint/specs/quiz-scoring/spec.md create mode 100644 openspec/changes/add-scoring-status-constraint/tasks.md diff --git a/api/database/index.ts b/api/database/index.ts index 9dfc5dc..02ab82d 100644 --- a/api/database/index.ts +++ b/api/database/index.ts @@ -83,7 +83,10 @@ const columnExists = async (tableName: string, columnName: string): Promise { if (!(await columnExists(tableName, columnName))) { + console.log(`添加列 ${tableName}.${columnName}`); await exec(`ALTER TABLE ${tableName} ADD COLUMN ${columnDefSql}`); + } else { + console.log(`列 ${tableName}.${columnName} 已存在`); } }; @@ -119,6 +122,8 @@ export const initDatabase = async () => { } else { console.log('数据库表已存在,跳过初始化'); await ensureColumn('questions', "analysis TEXT NOT NULL DEFAULT ''", 'analysis'); + await ensureColumn('quiz_records', "score_percentage REAL", 'score_percentage'); + await ensureColumn('quiz_records', "status TEXT", 'status'); } } catch (error) { console.error('数据库初始化失败:', error); diff --git a/api/database/init.sql b/api/database/init.sql index b6c6879..9c711ca 100644 --- a/api/database/init.sql +++ b/api/database/init.sql @@ -87,6 +87,8 @@ CREATE TABLE quiz_records ( total_score INTEGER NOT NULL, correct_count INTEGER NOT NULL, total_count INTEGER NOT NULL, + score_percentage REAL, + status TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id), FOREIGN KEY (subject_id) REFERENCES exam_subjects(id), diff --git a/api/models/examSubject.ts b/api/models/examSubject.ts index ab4c5ef..09f7575 100644 --- a/api/models/examSubject.ts +++ b/api/models/examSubject.ts @@ -96,11 +96,10 @@ export class ExamSubjectModel { const targetTypeScore = Math.round((typeRatio / 100) * subject.totalScore); let currentTypeScore = 0; - let typeQuestions: Awaited> = []; while (currentTypeScore < targetTypeScore) { const randomCategory = weightedCategories[Math.floor(Math.random() * weightedCategories.length)]; - const availableQuestions = await QuestionModel.getRandomQuestions(type as any, 10, [randomCategory]); + const availableQuestions = await QuestionModel.getRandomQuestions(type as any, 100, [randomCategory]); if (availableQuestions.length === 0) break; @@ -114,13 +113,11 @@ export class ExamSubjectModel { return currDiff < prevDiff ? curr : prev; }); - typeQuestions.push(selectedQuestion); + questions.push(selectedQuestion); currentTypeScore += selectedQuestion.score; - if (typeQuestions.length > 100) break; + if (questions.length > 200) break; } - - questions.push(...typeQuestions); } let totalScore = questions.reduce((sum, q) => sum + q.score, 0); while (totalScore < subject.totalScore) { @@ -128,7 +125,7 @@ export class ExamSubjectModel { if (allTypes.length === 0) break; const randomType = allTypes[Math.floor(Math.random() * allTypes.length)]; - const availableQuestions = await QuestionModel.getRandomQuestions(randomType as any, 10, categories); + const availableQuestions = await QuestionModel.getRandomQuestions(randomType as any, 100, categories); if (availableQuestions.length === 0) break; const availableUnselected = availableQuestions.filter((q) => !questions.some((selected) => selected.id === q.id)); @@ -165,9 +162,13 @@ export class ExamSubjectModel { questions.splice(closestIndex, 1); } + const uniqueQuestions = questions.filter((q, index, self) => + index === self.findIndex((t) => t.id === q.id) + ); + return { - questions, - totalScore, + questions: uniqueQuestions, + totalScore: uniqueQuestions.reduce((sum, q) => sum + q.score, 0), timeLimitMinutes: subject.timeLimitMinutes, }; } @@ -213,7 +214,7 @@ export class ExamSubjectModel { tried.add(category); const desiredAvg = remainingSlots > 0 ? (subject.totalScore - currentTotal) / remainingSlots : 0; - const fetched = await QuestionModel.getRandomQuestions(type as any, 30, [category]); + const fetched = await QuestionModel.getRandomQuestions(type as any, 100, [category]); const candidates = fetched.filter((q) => !questions.some((selectedQ) => selectedQ.id === q.id)); if (candidates.length === 0) continue; @@ -227,7 +228,7 @@ export class ExamSubjectModel { } if (!selected || !selectedCategory) { - throw new Error('题库中缺少满足当前配置的题目'); + continue; } questions.push(selected); @@ -245,8 +246,8 @@ export class ExamSubjectModel { const idx = Math.floor(Math.random() * questions.length); const base = questions[idx]; - const fetched = await QuestionModel.getRandomQuestions(base.type as any, 30, [base.category]); - const candidates = fetched.filter((q) => !questions.some((selectedQ) => selectedQ.id === q.id)); + const fetched = await QuestionModel.getRandomQuestions(base.type as any, 100, [base.category]); + const candidates = fetched.filter((q) => !questions.some((selectedQ) => selectedQ.id === q.id) && q.id !== base.id); if (candidates.length === 0) continue; const currentBest = Math.abs(diff); @@ -267,9 +268,13 @@ export class ExamSubjectModel { totalScore = totalScore - base.score + best.score; } + const uniqueQuestions = questions.filter((q, index, self) => + index === self.findIndex((t) => t.id === q.id) + ); + return { - questions, - totalScore, + questions: uniqueQuestions, + totalScore: uniqueQuestions.reduce((sum, q) => sum + q.score, 0), timeLimitMinutes: subject.timeLimitMinutes, }; } diff --git a/api/models/quiz.ts b/api/models/quiz.ts index 9deb640..d925e3e 100644 --- a/api/models/quiz.ts +++ b/api/models/quiz.ts @@ -8,7 +8,13 @@ export interface QuizRecord { totalScore: number; correctCount: number; totalCount: number; + scorePercentage: number; + status: '不及格' | '合格' | '优秀'; createdAt: string; + subjectId?: string; + subjectName?: string; + taskId?: string; + taskName?: string; } export interface QuizAnswer { @@ -39,15 +45,25 @@ export interface SubmitQuizData { } export class QuizModel { + // 计算考试状态 + private static calculateStatus(scorePercentage: number): '不及格' | '合格' | '优秀' { + if (scorePercentage < 60) return '不及格'; + if (scorePercentage < 80) return '合格'; + return '优秀'; + } + // 创建答题记录 static async createRecord(data: { userId: string; totalScore: number; correctCount: number; totalCount: number }): Promise { const id = uuidv4(); + const scorePercentage = data.totalCount > 0 ? (data.totalScore / data.totalCount) * 100 : 0; + const status = this.calculateStatus(scorePercentage); + const sql = ` - INSERT INTO quiz_records (id, user_id, total_score, correct_count, total_count) - VALUES (?, ?, ?, ?, ?) + INSERT INTO quiz_records (id, user_id, total_score, correct_count, total_count, score_percentage, status) + VALUES (?, ?, ?, ?, ?, ?, ?) `; - await run(sql, [id, data.userId, data.totalScore, data.correctCount, data.totalCount]); + await run(sql, [id, data.userId, data.totalScore, data.correctCount, data.totalCount, scorePercentage, status]); return this.findRecordById(id) as Promise; } @@ -114,7 +130,7 @@ 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, created_at as createdAt FROM quiz_records WHERE id = ?`; + 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 record = await get(sql, [id]); return record || null; } @@ -122,10 +138,15 @@ export class QuizModel { // 获取用户的答题记录 static async findRecordsByUserId(userId: string, limit = 10, offset = 0): Promise<{ records: QuizRecord[]; total: number }> { const recordsSql = ` - SELECT id, user_id as userId, total_score as totalScore, correct_count as correctCount, total_count as totalCount, created_at as createdAt - FROM quiz_records - WHERE user_id = ? - ORDER BY created_at DESC + 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, + r.subject_id as subjectId, s.name as subjectName, + r.task_id as taskId, t.name as taskName + FROM quiz_records r + LEFT JOIN exam_subjects s ON r.subject_id = s.id + LEFT JOIN exam_tasks t ON r.task_id = t.id + WHERE r.user_id = ? + ORDER BY r.created_at DESC LIMIT ? OFFSET ? `; @@ -147,6 +168,7 @@ 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, r.created_at as createdAt, r.subject_id as subjectId, s.name as subjectName, r.task_id as taskId FROM quiz_records r diff --git a/data/survey.db b/data/survey.db index 81e460bcf0a64347afee2f00676c996cf99d95a5..0a93c873b54453a82d29bf239064bfae3d9846e5 100644 GIT binary patch delta 4726 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 delta 3807 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 P9mlol@ { {children} -