feat: 更新考试相关页面,添加刷新功能,修改考试状态显示

- 注:当前代码还存在bug:查看结果暂出现错误。
- 在 SubjectSelectionPage 页面中添加刷新按钮,允许用户手动刷新考试任务列表。
- 修改 UserTaskPage 页面,重构考试任务为答题记录,更新数据结构和状态显示。
- 在 AdminDashboardPage、UserManagePage、UserRecordsPage 等管理页面中添加考试状态显示,使用不同颜色区分状态(不及格、合格、优秀)。
- 在 ResultPage 中显示考试状态,确保用户能够清晰了解考试结果。
- 添加约束,确保单次考试试卷中题目不可重复出现,并记录相关规范。
- 添加评分状态约束,根据得分占比自动计算考试状态,并在结果页面显示。
This commit is contained in:
2025-12-29 20:28:33 +08:00
parent 03eb858749
commit 57101fac37
26 changed files with 480 additions and 216 deletions

View File

@@ -83,7 +83,10 @@ const columnExists = async (tableName: string, columnName: string): Promise<bool
const ensureColumn = async (tableName: string, columnDefSql: string, columnName: string) => {
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);

View File

@@ -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),

View File

@@ -96,11 +96,10 @@ export class ExamSubjectModel {
const targetTypeScore = Math.round((typeRatio / 100) * subject.totalScore);
let currentTypeScore = 0;
let typeQuestions: Awaited<ReturnType<typeof QuestionModel.getRandomQuestions>> = [];
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,
};
}

View File

@@ -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<QuizRecord> {
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<QuizRecord>;
}
@@ -114,7 +130,7 @@ export class QuizModel {
// 根据ID查找答题记录
static async findRecordById(id: string): Promise<QuizRecord | null> {
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

Binary file not shown.

View File

@@ -0,0 +1,17 @@
# Proposal: Add No Duplicate Questions Constraint
## Problem
用户发现在单次考试试卷中会出现重复的题目,这违反了考试的基本原则。
## Solution
在 openspec 中添加约束,确保单次考试试卷中题目不可重复出现。
## Impact
- 需要修改 `api/models/examSubject.ts` 中的 `generateQuizQuestions` 方法
- 确保在生成试卷时,已选择的题目不会被重复选择
- 需要在 openspec 中记录此约束
## Tasks
- [ ] 在 openspec 中添加约束规范
- [ ] 修复 examSubject.ts 中数量模式下的题目重复问题
- [ ] 验证修复后的代码逻辑

View File

@@ -0,0 +1,22 @@
## ADDED Requirements
### Requirement: No Duplicate Questions in Single Exam
系统 MUST 确保单次考试试卷中不可重复出现同一道题目。
#### Scenario: Generate exam paper
- **GIVEN** 考试科目配置了题型比例和题目类别比例
- **WHEN** 系统生成考试试卷时
- **THEN** 系统 MUST 确保每道题目在试卷中只出现一次
- **AND** 系统 MUST 在选择题目时过滤掉已选择的题目
#### Scenario: Random question selection
- **GIVEN** 系统需要从题库中随机选择题目
- **WHEN** 已选择题目集合中已包含某道题目
- **THEN** 系统 MUST 不再选择该题目
- **AND** 系统 MUST 继续选择其他未选中的题目
#### Scenario: Insufficient unique questions
- **GIVEN** 题库中满足条件的题目数量少于试卷需要的题目数量
- **WHEN** 系统尝试生成试卷
- **THEN** 系统 MUST 抛出错误提示题库题目不足
- **AND** 系统 MUST 不生成包含重复题目的试卷

View File

@@ -0,0 +1,6 @@
# Tasks: Add No Duplicate Questions Constraint
## Tasks
- [x] 在 openspec 中添加约束规范
- [ ] 修复 examSubject.ts 中数量模式下的题目重复问题
- [ ] 验证修复后的代码逻辑

View File

@@ -0,0 +1,21 @@
# Proposal: Add Scoring Status Constraint
## Problem
用户完成考试后,系统只显示得分,但没有显示考试状态的等级评定。用户需要根据得分占比了解考试是否及格、合格或优秀。
## Solution
在 openspec 中添加评分状态约束,根据得分占比自动计算考试状态:
- 得分占比 < 60%:不及格
- 得分占比 ≥ 60% 且 < 80%:合格
- 得分占比 ≥ 80%:优秀
## Impact
- 需要修改 `api/models/quiz.ts` 添加状态字段和计算逻辑
- 需要修改 `src/pages/ResultPage.tsx` 显示考试状态
- 需要在 openspec 中记录此约束
## Tasks
- [ ] 在 openspec 中添加评分状态约束规范
- [ ] 修改 quiz.ts 模型添加状态字段和计算逻辑
- [ ] 修改 ResultPage.tsx 显示考试状态
- [ ] 修改其他相关页面以显示考试状态

View File

@@ -0,0 +1,29 @@
## ADDED Requirements
### Requirement: Scoring Status Based on Score Percentage
系统 MUST 根据得分占比自动计算考试状态等级。
#### Scenario: Calculate scoring status for failed exam
- **GIVEN** 用户完成考试并获得得分
- **WHEN** 得分占比小于 60%
- **THEN** 系统 MUST 将考试状态标记为"不及格"
- **AND** 系统 MUST 在结果页面显示"不及格"状态
#### Scenario: Calculate scoring status for qualified exam
- **GIVEN** 用户完成考试并获得得分
- **WHEN** 得分占比大于等于 60% 且小于 80%
- **THEN** 系统 MUST 将考试状态标记为"合格"
- **AND** 系统 MUST 在结果页面显示"合格"状态
#### Scenario: Calculate scoring status for excellent exam
- **GIVEN** 用户完成考试并获得得分
- **WHEN** 得分占比大于等于 80%
- **THEN** 系统 MUST 将考试状态标记为"优秀"
- **AND** 系统 MUST 在结果页面显示"优秀"状态
#### Scenario: Display scoring status in result page
- **GIVEN** 用户查看考试结果页面
- **WHEN** 页面加载完成
- **THEN** 系统 MUST 显示考试得分
- **AND** 系统 MUST 显示考试状态(不及格/合格/优秀)
- **AND** 系统 MUST 根据状态使用不同的颜色或样式进行区分

View File

@@ -0,0 +1,7 @@
# Tasks: Add Scoring Status Constraint
## Tasks
- [x] 在 openspec 中添加评分状态约束规范
- [ ] 修改 quiz.ts 模型添加状态字段和计算逻辑
- [ ] 修改 ResultPage.tsx 显示考试状态
- [ ] 修改其他相关页面以显示考试状态

View File

@@ -17,7 +17,6 @@ import {
} from '@ant-design/icons';
import { useAdmin } from '../contexts';
import LOGO from '../assets/主要LOGO.svg';
import LOGO from '../assets/纯字母LOGO.svg';
import LOGO from '../assets/正方形LOGO.svg';
const { Header, Sider, Content, Footer } = Layout;
@@ -144,13 +143,10 @@ const AdminLayout = ({ children }: { children: React.ReactNode }) => {
{children}
</Content>
<Footer className="bg-white text-center py-3 px-8 text-gray-400 text-xs flex flex-col md:flex-row justify-between items-center fixed bottom-0 left-0 right-0 shadow-sm" style={{ marginLeft: collapsed ? 80 : 240, transition: 'margin-left 0.3s' }}>
<div>
<Footer className="bg-white text-center py-1.5 px-2 text-gray-400 text-xs flex flex-col md:flex-row justify-center items-center fixed bottom-0 left-0 right-0 shadow-sm" style={{ marginLeft: collapsed ? 80 : 240, transition: 'margin-left 0.3s' }}>
<div className="whitespace-nowrap">
&copy; {new Date().getFullYear()} Boonlive OA System. All Rights Reserved.
</div>
<div className="mt-2 md:mt-0">
<img src={LOGO} alt="纯字母LOGO" style={{ width: '100px', height: '38px' }} />
</div>
</Footer>
</Layout>
</Layout>

View File

@@ -1,7 +1,6 @@
import React from 'react';
import { Layout } from 'antd';
import LOGO from '../assets/主要LOGO.svg';
import LOGO from '../assets/纯字母LOGO.svg';
const { Header, Content, Footer } = Layout;
@@ -18,11 +17,10 @@ export const UserLayout: React.FC<{ children: React.ReactNode }> = ({ children }
</div>
</Content>
<Footer className="bg-white border-t border-gray-100 py-6 px-8 flex flex-col md:flex-row justify-between items-center text-gray-400 text-sm">
<div className="mb-4 md:mb-0">
<Footer className="bg-white border-t border-gray-100 py-3 px-2 flex flex-col md:flex-row justify-center items-center text-gray-400 text-sm">
<div className="md:mb-0 whitespace-nowrap">
&copy; {new Date().getFullYear()} Boonlive OA System. All Rights Reserved.
</div>
<img src={LOGO} alt="纯字母LOGO" style={{ width: '136px', height: '51px' }} />
</Footer>
</Layout>
);

View File

@@ -50,9 +50,6 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
headerBg: '#ffffff',
siderBg: '#ffffff',
},
Pagination: {
size: 'small',
},
}
}}
>

View File

@@ -5,7 +5,6 @@ import { useUser } from '../contexts';
import { userAPI } from '../services/api';
import { validateUserForm } from '../utils/validation';
import LOGO from '../assets/主要LOGO.svg';
import LOGO from '../assets/纯字母LOGO.svg';
const { Header, Content, Footer } = Layout;
const { Title } = Typography;
@@ -218,11 +217,10 @@ const HomePage = () => {
</div>
</Content>
<Footer className="bg-white border-t border-gray-100 py-3 px-4 flex flex-col md:flex-row justify-between items-center text-gray-400 text-xs">
<div className="mb-2 md:mb-0">
<Footer className="bg-white border-t border-gray-100 py-1.5 px-2 flex flex-col md:flex-row justify-center items-center text-gray-400 text-xs">
<div className="mb-2 md:mb-0 whitespace-nowrap">
&copy; {new Date().getFullYear()} Boonlive OA System. All Rights Reserved.
</div>
<img src={LOGO} alt="纯字母LOGO" style={{ width: '100px', height: '38px' }} />
</Footer>
</Layout>
);

View File

@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react';
import { Card, Result, Button, Descriptions, message } from 'antd';
import { Card, Button, Descriptions, message } from 'antd';
import { useParams, useNavigate } from 'react-router-dom';
import { useUser } from '../contexts';
import { quizAPI } from '../services/api';
@@ -14,6 +14,8 @@ interface QuizRecord {
totalScore: number;
correctCount: number;
totalCount: number;
scorePercentage: number;
status: '不及格' | '合格' | '优秀';
createdAt: string;
}
@@ -52,8 +54,8 @@ const ResultPage = () => {
try {
setLoading(true);
const response = await quizAPI.getRecordDetail(recordId!);
setRecord(response.data.record);
setAnswers(response.data.answers);
setRecord(response.record);
setAnswers(response.answers);
} catch (error: any) {
message.error(error.message || '获取答题结果失败');
navigate('/');
@@ -63,7 +65,7 @@ const ResultPage = () => {
};
const handleBackToHome = () => {
navigate('/');
navigate('/tasks');
};
const getTagColor = (type: string) => {
@@ -181,35 +183,69 @@ const ResultPage = () => {
}
const correctRate = ((record.correctCount / record.totalCount) * 100).toFixed(1);
const status = record.totalScore >= record.totalCount * 0.6 ? 'success' : 'warning';
const getStatusColor = (status: string) => {
switch (status) {
case '不及格':
return 'bg-red-100 text-red-800 border-red-200';
case '合格':
return 'bg-blue-100 text-blue-800 border-blue-200';
case '优秀':
return 'bg-green-100 text-green-800 border-green-200';
default:
return 'bg-gray-100 text-gray-800 border-gray-200';
}
};
return (
<UserLayout>
<div className="max-w-md mx-auto px-4">
{/* 结果概览 */}
<Card className="shadow-lg mb-6 rounded-xl border-t-4 border-t-mars-500">
<Result
status={status as any}
title={`答题完成!您的得分是 ${record.totalScore}`}
subTitle={`正确率 ${correctRate}% (${record.correctCount}/${record.totalCount})`}
extra={[
<Button key="back" type="primary" onClick={handleBackToHome} className="bg-mars-500 hover:bg-mars-600 border-none px-6 h-9 text-sm">
<Card className="shadow-lg mb-6 rounded-xl border-t-4 border-t-mars-500" bodyStyle={{ padding: '12px' }}>
<div className="flex items-start gap-3">
<div className="flex-shrink-0 mt-0.5">
{record.status === '优秀' ? (
<svg viewBox="64 64 896 896" focusable="false" data-icon="check-circle" width="32" height="32" fill="currentColor" aria-hidden="true" className="text-green-500">
<path d="M512 64C264.6 64 64 264.6 64 512s200.6 448 448 448 448-200.6 448-448S759.4 64 512 64zm193.5 301.7l-210.6 292a31.8 31.8 0 01-51.7 0L318.5 484.9c-3.8-5.3 0-12.7 6.5-12.7h46.9c10.2 0 19.9 4.9 25.9 13.3l71.2 98.8 157.2-218c6-8.3 15.6-13.3 25.9-13.3H699c6.5 0 10.3 7.4 6.5 12.7z"></path>
</svg>
) : record.status === '合格' ? (
<svg viewBox="64 64 896 896" focusable="false" data-icon="check-circle" width="32" height="32" fill="currentColor" aria-hidden="true" className="text-blue-500">
<path d="M512 64C264.6 64 64 264.6 64 512s200.6 448 448 448 448-200.6 448-448S759.4 64 512 64zm193.5 301.7l-210.6 292a31.8 31.8 0 01-51.7 0L318.5 484.9c-3.8-5.3 0-12.7 6.5-12.7h46.9c10.2 0 19.9 4.9 25.9 13.3l71.2 98.8 157.2-218c6-8.3 15.6-13.3 25.9-13.3H699c6.5 0 10.3 7.4 6.5 12.7z"></path>
</svg>
) : (
<svg viewBox="64 64 896 896" focusable="false" data-icon="close-circle" width="32" height="32" fill="currentColor" aria-hidden="true" className="text-red-500">
<path d="M512 64C264.6 64 64 264.6 64 512s200.6 448 448 448 448-200.6 448-448S759.4 64 512 64zm193.5 301.7l-210.6 292a31.8 31.8 0 01-51.7 0L318.5 484.9c-3.8-5.3 0-12.7 6.5-12.7h46.9c10.2 0 19.9 4.9 25.9 13.3l71.2 98.8 157.2-218c6-8.3 15.6-13.3 25.9-13.3H699c6.5 0 10.3 7.4 6.5 12.7z"></path>
</svg>
)}
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-semibold text-gray-800 mb-0.5">
{record.totalScore}
</div>
<div className="flex items-center gap-2 mb-2">
<span className={`px-2 py-0.5 rounded text-xs font-medium border ${getStatusColor(record.status)}`}>
{record.status}
</span>
<span className="text-xs text-gray-600">
{correctRate}% ({record.correctCount}/{record.totalCount})
</span>
</div>
<Button type="primary" onClick={handleBackToHome} className="bg-mars-500 hover:bg-mars-600 border-none px-4 h-7 text-xs">
</Button>
]}
/>
</div>
</div>
</Card>
{/* 基本信息 */}
<Card className="shadow-lg mb-6 rounded-xl">
<h3 className="text-base font-semibold mb-3 text-gray-800 border-l-4 border-mars-500 pl-3"></h3>
<Descriptions bordered column={1} size="small">
<Item label="姓名">{user?.name}</Item>
<Item label="手机号">{user?.phone}</Item>
<Card className="shadow-lg mb-6 rounded-xl" bodyStyle={{ padding: '12px' }}>
<h3 className="text-sm font-semibold mb-2 text-gray-800 border-l-4 border-mars-500 pl-3"></h3>
<Descriptions bordered column={1} size="small" className="text-xs">
<Item label="答题时间">{formatDateTime(record.createdAt)}</Item>
<Item label="总题数">{record.totalCount} </Item>
<Item label="正确数">{record.correctCount} </Item>
<Item label="总得分">{record.totalScore} </Item>
<Item label="得分占比">{record.scorePercentage.toFixed(1)}%</Item>
<Item label="考试状态">{record.status}</Item>
</Descriptions>
</Card>

View File

@@ -1,6 +1,6 @@
import React, { useState, useEffect } from 'react';
import { Button, Typography, Tag, Space, Spin, message, Modal } from 'antd';
import { ClockCircleOutlined, BookOutlined, RightOutlined } from '@ant-design/icons';
import { ClockCircleOutlined, BookOutlined, RightOutlined, ReloadOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import { useUser } from '../contexts';
import { examSubjectAPI, examTaskAPI, quizAPI } from '../services/api';
@@ -164,6 +164,15 @@ export const SubjectSelectionPage: React.FC = () => {
<div className="flex items-center mb-3 border-b border-gray-200 pb-1">
<BookOutlined className="text-lg mr-2 text-mars-400" />
<Title level={4} className="!mb-0 !text-gray-700 !text-base"></Title>
<Button
type="default"
size="small"
icon={<ReloadOutlined />}
className="ml-auto"
onClick={fetchData}
>
</Button>
</div>
<div className="space-y-4">
@@ -187,15 +196,18 @@ export const SubjectSelectionPage: React.FC = () => {
className="h-auto py-3 px-4 text-left border-l-4 border-l-green-500 hover:border-l-green-600 hover:shadow-md transition-all duration-300"
onClick={() => startQuiz(task.id)}
>
<div className="flex justify-between items-start w-full">
<div className="flex justify-between items-center w-full">
<div className="flex-1">
<div className="flex justify-between items-center mb-1">
<div className="flex items-center mb-1">
<Title level={5} className="mb-0 text-gray-800 !text-sm">
{task.name}
</Title>
<div className="text-green-600">
<Text className="text-xs"></Text>
</div>
{subject && (
<div className="flex items-center ml-auto">
<span className="mr-1 text-gray-400 text-xs">:</span>
<Text className="text-gray-600 text-xs">{subject.timeLimitMinutes}</Text>
</div>
)}
</div>
<div className="flex justify-between items-center mb-2">
<div className="flex items-center">
@@ -218,12 +230,9 @@ export const SubjectSelectionPage: React.FC = () => {
{new Date(task.startAt).toLocaleDateString()} - {new Date(task.endAt).toLocaleDateString()}
</Text>
</div>
{subject && (
<div className="flex items-center">
<span className="mr-1 text-gray-400 text-xs">:</span>
<Text className="text-gray-600 text-xs">{subject.timeLimitMinutes}</Text>
</div>
)}
<div className="text-green-600">
<Text className="text-xs px-2 py-1 rounded border border-green-200 bg-green-50"></Text>
</div>
</div>
</div>
</div>
@@ -260,15 +269,16 @@ export const SubjectSelectionPage: React.FC = () => {
className={`h-auto py-3 px-4 text-left border-l-4 border-l-gray-400 hover:border-l-gray-500 hover:shadow-md transition-all duration-300 ${attemptsExhausted ? 'opacity-50 cursor-not-allowed' : ''}`}
onClick={() => !attemptsExhausted && startQuiz(task.id)}
>
<div className="flex justify-between items-start w-full">
<div className="flex justify-between items-center w-full">
<div className="flex-1">
<div className="flex justify-between items-center mb-1">
<Title level={5} className="mb-0 text-gray-800 !text-sm">
{task.name}
</Title>
{!attemptsExhausted && (
<div className="text-gray-400">
<Text className="text-xs"></Text>
{subject && (
<div className="flex items-center ml-auto">
<span className="mr-1 text-gray-400 text-xs">:</span>
<Text className="text-gray-600 text-xs">{subject.timeLimitMinutes}</Text>
</div>
)}
</div>
@@ -294,10 +304,9 @@ export const SubjectSelectionPage: React.FC = () => {
{new Date(task.startAt).toLocaleDateString()} - {new Date(task.endAt).toLocaleDateString()}
</Text>
</div>
{subject && (
<div className="flex items-center">
<span className="mr-1 text-gray-400 text-xs">:</span>
<Text className="text-gray-600 text-xs">{subject.timeLimitMinutes}</Text>
{!attemptsExhausted && (
<div className="text-gray-400">
<Text className="text-xs px-2 py-1 rounded border border-gray-200 bg-gray-50"></Text>
</div>
)}
</div>
@@ -334,11 +343,19 @@ export const SubjectSelectionPage: React.FC = () => {
disabled
className="h-auto py-3 px-4 text-left border-l-4 border-l-blue-400 opacity-75 cursor-not-allowed"
>
<div className="flex justify-between items-start w-full">
<div className="flex justify-between items-center w-full">
<div className="flex-1">
<Title level={5} className="mb-1 text-gray-800 !text-sm">
{task.name}
</Title>
<div className="flex justify-between items-center mb-1">
<Title level={5} className="mb-0 text-gray-800 !text-sm">
{task.name}
</Title>
{subject && (
<div className="flex items-center ml-auto">
<span className="mr-1 text-gray-400 text-xs">:</span>
<Text className="text-gray-600 text-xs">{subject.timeLimitMinutes}</Text>
</div>
)}
</div>
<div className="flex justify-between items-center mb-2">
<div className="flex items-center">
<BookOutlined className="mr-1 text-gray-400 text-xs" />
@@ -360,12 +377,9 @@ export const SubjectSelectionPage: React.FC = () => {
{new Date(task.startAt).toLocaleDateString()} - {new Date(task.endAt).toLocaleDateString()}
</Text>
</div>
{subject && (
<div className="flex items-center">
<span className="mr-1 text-gray-400 text-xs">:</span>
<Text className="text-gray-600 text-xs">{subject.timeLimitMinutes}</Text>
</div>
)}
<div className="text-blue-600">
<Text className="text-xs px-2 py-1 rounded border border-blue-200 bg-blue-50"></Text>
</div>
</div>
</div>
</div>
@@ -396,7 +410,7 @@ export const SubjectSelectionPage: React.FC = () => {
className="px-4 h-10 text-sm hover:border-mars-500 hover:text-mars-500"
onClick={() => navigate('/tasks')}
>
</Button>
</div>
</div>

View File

@@ -1,98 +1,93 @@
import React, { useState, useEffect } from 'react';
import { Card, Table, Tag, Button, Space, Spin, message, Typography } from 'antd';
import { ClockCircleOutlined, BookOutlined, CalendarOutlined, CheckCircleOutlined } from '@ant-design/icons';
import { EyeOutlined, BookOutlined, CalendarOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import { useUser } from '../contexts';
import { examTaskAPI } from '../services/api';
import { quizAPI } from '../services/api';
import { UserLayout } from '../layouts/UserLayout';
const { Title, Text } = Typography;
interface ExamTask {
interface QuizRecord {
id: string;
name: string;
subjectId: string;
subjectName: string;
startAt: string;
endAt: string;
userId: string;
subjectId?: string;
taskId?: string;
subjectName?: string;
taskName?: string;
totalScore: number;
timeLimitMinutes: number;
usedAttempts: number;
maxAttempts: number;
bestScore: number;
correctCount: number;
totalCount: number;
scorePercentage: number;
status: '不及格' | '合格' | '优秀';
createdAt: string;
}
export const UserTaskPage: React.FC = () => {
const [tasks, setTasks] = useState<ExamTask[]>([]);
const [records, setRecords] = useState<QuizRecord[]>([]);
const [loading, setLoading] = useState(true);
const navigate = useNavigate();
const { user } = useUser();
useEffect(() => {
if (user) {
fetchUserTasks();
fetchUserRecords();
}
}, [user]);
const fetchUserTasks = async () => {
const fetchUserRecords = async () => {
try {
setLoading(true);
if (!user?.id) return;
const response = await examTaskAPI.getUserTasks(user.id) as any;
setTasks(response.data);
const response = await quizAPI.getUserRecords(user.id) as any;
setRecords(response.data);
} catch (error) {
message.error('获取考试任务失败');
message.error('获取答题记录失败');
} finally {
setLoading(false);
}
};
const startTask = (task: ExamTask) => {
const now = new Date();
const startAt = new Date(task.startAt);
const endAt = new Date(task.endAt);
if (now < startAt) {
message.warning('考试任务尚未开始');
return;
}
if (now > endAt) {
message.warning('考试任务已结束');
return;
}
// 跳转到科目选择页面带上任务ID
navigate('/subjects', { state: { selectedTask: task.id } });
const viewResult = (recordId: string) => {
navigate(`/result/${recordId}`);
};
const getStatusColor = (task: ExamTask) => {
const now = new Date();
const startAt = new Date(task.startAt);
const endAt = new Date(task.endAt);
if (now < startAt) return 'blue';
if (now > endAt) return 'red';
return 'cyan'; // Using cyan to match Mars Green family better than pure green
};
const getStatusText = (task: ExamTask) => {
const now = new Date();
const startAt = new Date(task.startAt);
const endAt = new Date(task.endAt);
if (now < startAt) return '未开始';
if (now > endAt) return '已结束';
return '进行中';
};
const getStatusColor = (status: string) => {
switch (status) {
case '不及格':
return 'red';
case '合格':
return 'blue';
case '优秀':
return 'green';
default:
return 'default';
}
};
const columns = [
{
title: '操作',
key: 'action',
width: 80,
render: (record: QuizRecord) => (
<Button
type="link"
size="small"
icon={<EyeOutlined />}
onClick={() => viewResult(record.id)}
style={{ fontSize: '12px', padding: 0 }}
>
</Button>
)
},
{
title: '任务名称',
dataIndex: 'name',
key: 'name',
dataIndex: 'taskName',
key: 'taskName',
width: 100,
render: (text: string) => <Text strong style={{ fontSize: '12px' }}>{text}</Text>
render: (text: string) => <Text strong style={{ fontSize: '12px' }}>{text || '-'}</Text>
},
{
title: '考试科目',
@@ -102,76 +97,51 @@ export const UserTaskPage: React.FC = () => {
render: (text: string) => (
<Space size={4}>
<BookOutlined style={{ fontSize: '12px' }} className="text-mars-600" />
<Text style={{ fontSize: '12px' }}>{text}</Text>
<Text style={{ fontSize: '12px' }}>{text || '-'}</Text>
</Space>
)
},
{
title: '分',
title: '分',
dataIndex: 'totalScore',
key: 'totalScore',
width: 60,
render: (score: number) => <Text strong style={{ fontSize: '12px' }}>{score}</Text>
},
{
title: '时长',
dataIndex: 'timeLimitMinutes',
key: 'timeLimitMinutes',
width: 70,
render: (minutes: number) => (
<Space size={4}>
<ClockCircleOutlined style={{ fontSize: '12px' }} className="text-gray-500" />
<Text style={{ fontSize: '12px' }}>{minutes}</Text>
</Space>
)
},
{
title: '时间范围',
key: 'timeRange',
width: 110,
render: (record: ExamTask) => (
<Space direction="vertical" size={0}>
<Space size={4}>
<CalendarOutlined style={{ fontSize: '11px' }} className="text-gray-500" />
<Text type="secondary" style={{ fontSize: '11px' }}>
{new Date(record.startAt).toLocaleDateString()}
</Text>
</Space>
<Space size={4}>
<CalendarOutlined style={{ fontSize: '11px' }} className="text-gray-500" />
<Text type="secondary" style={{ fontSize: '11px' }}>
{new Date(record.endAt).toLocaleDateString()}
</Text>
</Space>
</Space>
)
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 70,
render: (record: ExamTask) => (
<Tag color={getStatusColor(record)} className="rounded-full px-2" style={{ fontSize: '11px' }}>
{getStatusText(record)}
width: 60,
render: (status: string) => (
<Tag color={getStatusColor(status)} style={{ fontSize: '11px' }}>
{status}
</Tag>
)
},
{
title: '数',
key: 'attempts',
width: 50,
render: (record: ExamTask) => (
title: '正确题数',
key: 'correctCount',
width: 80,
render: (record: QuizRecord) => (
<Text style={{ fontSize: '12px' }}>
{record.usedAttempts}/{record.maxAttempts}
{record.correctCount}/{record.totalCount}
</Text>
),
},
{
title: '最高分',
dataIndex: 'bestScore',
key: 'bestScore',
width: 60,
render: (score: number) => <Text strong style={{ fontSize: '12px' }}>{score}</Text>,
title: '答题时间',
dataIndex: 'createdAt',
key: 'createdAt',
width: 110,
render: (date: string) => (
<Space size={4}>
<CalendarOutlined style={{ fontSize: '11px' }} className="text-gray-500" />
<Text type="secondary" style={{ fontSize: '11px' }}>
{new Date(date).toLocaleString('zh-CN')}
</Text>
</Space>
)
}
];
@@ -191,7 +161,7 @@ export const UserTaskPage: React.FC = () => {
<Table
columns={columns}
dataSource={tasks}
dataSource={records}
rowKey="id"
size="small"
pagination={{
@@ -201,9 +171,8 @@ export const UserTaskPage: React.FC = () => {
size: 'small'
}}
locale={{
emptyText: '暂无考试任务'
emptyText: '暂无答题记录'
}}
size="small"
scroll={{ x: 'max-content' }}
className="mobile-table"
style={{

View File

@@ -39,6 +39,8 @@ interface RecentRecord {
totalScore: number;
correctCount: number;
totalCount: number;
scorePercentage: number;
status: '不及格' | '合格' | '优秀';
createdAt: string;
subjectName?: string;
examCount?: number;
@@ -161,6 +163,19 @@ const AdminDashboardPage = () => {
Number(overview.taskStatusDistribution.notStarted || 0)
: 0;
const getStatusColor = (status: string) => {
switch (status) {
case '不及格':
return 'red';
case '合格':
return 'blue';
case '优秀':
return 'green';
default:
return 'default';
}
};
const columns = [
{
title: '姓名',
@@ -178,6 +193,15 @@ const AdminDashboardPage = () => {
key: 'totalScore',
render: (score: number) => <span className="font-semibold text-mars-600">{score} </span>,
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
render: (status: string) => {
const color = getStatusColor(status);
return <span className={`px-2 py-1 rounded text-xs font-medium bg-${color}-100 text-${color}-800`}>{status}</span>;
},
},
{
title: '正确率',
key: 'correctRate',

View File

@@ -4,7 +4,6 @@ import { useNavigate } from 'react-router-dom';
import { useAdmin } from '../../contexts';
import { adminAPI } from '../../services/api';
import LOGO from '../../assets/主要LOGO.svg';
import LOGO from '../../assets/纯字母LOGO.svg';
const { Header, Content, Footer } = Layout;
@@ -197,11 +196,10 @@ const AdminLoginPage = () => {
</div>
</Content>
<Footer className="bg-white border-t border-gray-100 py-3 px-4 flex flex-col md:flex-row justify-between items-center text-gray-400 text-xs">
<div className="mb-2 md:mb-0">
<Footer className="bg-white border-t border-gray-100 py-1.5 px-2 flex flex-col md:flex-row justify-center items-center text-gray-400 text-xs">
<div className="mb-2 md:mb-0 whitespace-nowrap">
&copy; {new Date().getFullYear()} Boonlive OA System. All Rights Reserved.
</div>
<img src={LOGO} alt="纯字母LOGO" style={{ width: '100px', height: '38px' }} />
</Footer>
</Layout>
);

View File

@@ -234,6 +234,7 @@ const QuestionTextImportPage = () => {
setTablePagination({
current: pagination.current ?? 1,
pageSize: pagination.pageSize ?? 10,
size: 'small' as const,
})
}
/>

View File

@@ -23,10 +23,27 @@ interface Record {
totalScore: number;
correctCount: number;
totalCount: number;
scorePercentage: number;
status: '不及格' | '合格' | '优秀';
createdAt: string;
answers: Answer[];
}
const COLORS = ['#1890ff', '#52c41a', '#faad14', '#f5222d', '#722ed1', '#13c2c2'];
const getStatusColor = (status: string) => {
switch (status) {
case '不及格':
return 'red';
case '合格':
return 'blue';
case '优秀':
return 'green';
default:
return 'default';
}
};
const RecordDetailPage = ({ recordId }: { recordId: string }) => {
const [record, setRecord] = useState<Record | null>(null);
const [loading, setLoading] = useState(false);
@@ -142,22 +159,27 @@ const RecordDetailPage = ({ recordId }: { recordId: string }) => {
</div>
<Row gutter={16} className="mb-6">
<Col span={6}>
<Col span={5}>
<Card>
<Statistic title="考试状态" value={record.status} valueStyle={{ color: getStatusColor(record.status) === 'red' ? '#f5222d' : getStatusColor(record.status) === 'green' ? '#52c41a' : '#1890ff' }} />
</Card>
</Col>
<Col span={5}>
<Card>
<Statistic title="总得分" value={record.totalScore} suffix="分" />
</Card>
</Col>
<Col span={6}>
<Col span={5}>
<Card>
<Statistic title="正确题数" value={record.correctCount} />
</Card>
</Col>
<Col span={6}>
<Col span={5}>
<Card>
<Statistic title="总题数" value={record.totalCount} />
</Card>
</Col>
<Col span={6}>
<Col span={4}>
<Card>
<Statistic
title="正确率"

View File

@@ -24,6 +24,8 @@ interface QuizRecord {
totalScore: number;
correctCount: number;
totalCount: number;
scorePercentage: number;
status: '不及格' | '合格' | '优秀';
createdAt: string;
}
@@ -54,6 +56,19 @@ interface TaskStats {
const COLORS = ['#1890ff', '#52c41a', '#faad14', '#f5222d', '#722ed1', '#13c2c2'];
const getStatusColor = (status: string) => {
switch (status) {
case '不及格':
return 'red';
case '合格':
return 'blue';
case '优秀':
return 'green';
default:
return 'default';
}
};
const StatisticsPage = () => {
const [statistics, setStatistics] = useState<Statistics | null>(null);
const [records, setRecords] = useState<QuizRecord[]>([]);
@@ -181,11 +196,9 @@ const StatisticsPage = () => {
};
const scoreDistribution = [
{ range: '0-59分', count: records.filter(r => r.totalScore < 60).length },
{ range: '60-69分', count: records.filter(r => r.totalScore >= 60 && r.totalScore < 70).length },
{ range: '70-79分', count: records.filter(r => r.totalScore >= 70 && r.totalScore < 80).length },
{ range: '80-89分', count: records.filter(r => r.totalScore >= 80 && r.totalScore < 90).length },
{ range: '90-100分', count: records.filter(r => r.totalScore >= 90).length },
{ range: '不及格', count: records.filter(r => r.status === '不及格').length },
{ range: '合格', count: records.filter(r => r.status === '合格').length },
{ range: '优秀', count: records.filter(r => r.status === '优秀').length },
].filter(item => item.count > 0);
const overviewColumns = [
@@ -193,6 +206,15 @@ const StatisticsPage = () => {
{ title: '手机号', dataIndex: 'userPhone', key: 'userPhone' },
{ title: '科目', dataIndex: 'subjectName', key: 'subjectName' },
{ title: '任务', dataIndex: 'taskName', key: 'taskName' },
{
title: '状态',
dataIndex: 'status',
key: 'status',
render: (status: string) => {
const color = getStatusColor(status);
return <span className={`px-2 py-1 rounded text-xs font-medium bg-${color}-100 text-${color}-800`}>{status}</span>;
},
},
{ title: '得分', dataIndex: 'totalScore', key: 'totalScore', render: (score: number) => <span className="font-semibold text-blue-600">{score} </span> },
{ title: '正确率', key: 'correctRate', render: (record: QuizRecord) => {
const rate = ((record.correctCount / record.totalCount) * 100).toFixed(1);

View File

@@ -22,6 +22,8 @@ interface QuizRecord {
userName: string;
totalScore: number;
obtainedScore: number;
scorePercentage: number;
status: '不及格' | '合格' | '优秀';
createdAt: string;
subjectName?: string;
taskName?: string;
@@ -33,6 +35,19 @@ interface UserGroup {
isSystem: boolean;
}
const getStatusColor = (status: string) => {
switch (status) {
case '不及格':
return 'red';
case '合格':
return 'blue';
case '优秀':
return 'green';
default:
return 'default';
}
};
const UserManagePage = () => {
const [users, setUsers] = useState<User[]>([]);
const [userGroups, setUserGroups] = useState<UserGroup[]>([]);
@@ -81,6 +96,7 @@ const UserManagePage = () => {
current: page,
pageSize,
total: (res as any).pagination?.total || res.data.length,
size: 'small' as const,
});
} catch (error) {
message.error('获取用户列表失败');
@@ -246,6 +262,7 @@ const UserManagePage = () => {
current: page,
pageSize,
total: (res as any).pagination?.total || res.data.length,
size: 'small' as const,
});
} catch (error) {
message.error('获取答题记录失败');
@@ -467,12 +484,20 @@ const UserManagePage = () => {
key: 'totalScore',
render: (score: number) => score || 0,
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
render: (status: string) => {
const color = getStatusColor(status);
return <span className={`px-2 py-1 rounded text-xs font-medium bg-${color}-100 text-${color}-800`}>{status}</span>;
},
},
{
title: '得分',
dataIndex: 'obtainedScore',
key: 'obtainedScore',
render: (score: number, record: QuizRecord) => {
// 确保score有默认值
const actualScore = score || 0;
const totalScore = record.totalScore || 0;
return (

View File

@@ -12,9 +12,26 @@ interface Record {
totalScore: number;
correctCount: number;
totalCount: number;
scorePercentage: number;
status: '不及格' | '合格' | '优秀';
createdAt: string;
}
const COLORS = ['#1890ff', '#52c41a', '#faad14', '#f5222d', '#722ed1', '#13c2c2'];
const getStatusColor = (status: string) => {
switch (status) {
case '不及格':
return 'red';
case '合格':
return 'blue';
case '优秀':
return 'green';
default:
return 'default';
}
};
const UserRecordsPage = ({ userId }: { userId: string }) => {
const [records, setRecords] = useState<Record[]>([]);
const [loading, setLoading] = useState(false);
@@ -34,6 +51,7 @@ const UserRecordsPage = ({ userId }: { userId: string }) => {
current: page,
pageSize,
total: res.data.pagination.total,
size: 'small' as const,
});
} catch (error) {
message.error('获取答题记录失败');
@@ -61,6 +79,16 @@ const UserRecordsPage = ({ userId }: { userId: string }) => {
key: 'createdAt',
render: (text: string) => dayjs(text).format('YYYY-MM-DD HH:mm:ss'),
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
render: (status: string) => (
<span className={`px-2 py-1 rounded text-xs font-medium bg-${getStatusColor(status)}-100 text-${getStatusColor(status)}-800`}>
{status}
</span>
),
},
{
title: '总得分',
dataIndex: 'totalScore',

View File

@@ -65,7 +65,7 @@ export const OptionList = ({ type, options, value, onChange, disabled }: OptionL
key={opt.key}
onClick={() => handleSelect(opt.value)}
className={`
relative flex items-start p-1.5 rounded-xl border-2 transition-all duration-200 active:scale-[0.99] cursor-pointer
relative flex items-center p-1.5 rounded-xl border-2 transition-all duration-200 active:scale-[0.99] cursor-pointer
${selected
? 'border-[#00897B] bg-[#E0F2F1]'
: 'border-transparent bg-white shadow-sm hover:border-gray-200'}
@@ -73,7 +73,7 @@ export const OptionList = ({ type, options, value, onChange, disabled }: OptionL
>
{/* 选项圆圈 */}
<div className={`
flex-shrink-0 w-5 h-5 rounded-full flex items-center justify-center text-xs font-medium border mr-1.5 mt-0.5 transition-colors
flex-shrink-0 w-5 h-5 rounded-full flex items-center justify-center text-xs font-medium border mr-1.5 transition-colors
${selected
? 'bg-[#00897B] border-[#00897B] text-white'
: 'bg-white border-gray-300 text-gray-500'}