feat: 更新考试相关页面,优化任务状态处理,添加用户任务接口测试

This commit is contained in:
2025-12-30 11:10:03 +08:00
parent 57101fac37
commit 7fff13afb7
9 changed files with 234 additions and 50 deletions

View File

@@ -582,7 +582,6 @@ export class ExamTaskModel {
}
static async getUserTasks(userId: string): Promise<UserExamTask[]> {
const now = new Date().toISOString();
const rows = await all(`
SELECT
t.id,
@@ -606,9 +605,9 @@ export class ExamTaskModel {
WHERE user_id = ?
GROUP BY task_id
) q ON q.task_id = t.id
WHERE tu.user_id = ? AND t.start_at <= ? AND t.end_at >= ?
WHERE tu.user_id = ?
ORDER BY t.start_at ASC, t.end_at ASC
`, [userId, userId, now, now]);
`, [userId, userId]);
return rows.map(row => ({
id: row.id,

Binary file not shown.

View File

@@ -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": "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",
"check": "tsc --noEmit"
},
"dependencies": {

View File

@@ -347,11 +347,17 @@ const QuizPage = () => {
subjectId: subjectId || undefined,
taskId: taskId || undefined,
answers: answersData
});
}) as any;
const payload = response?.data ?? response;
const recordId = payload?.recordId;
if (!recordId) {
throw new Error('提交成功但未返回记录ID');
}
message.success('答题提交成功!');
clearActiveProgress(user!.id);
navigate(`/result/${response.data.recordId}`);
navigate(`/result/${recordId}`);
} catch (error: any) {
message.error(error.message || '提交失败');
} finally {

View File

@@ -32,6 +32,55 @@ interface QuizAnswer {
questionAnalysis?: string;
}
const StatusIcon = ({ status }: { status: QuizRecord['status'] }) => {
const colorClass =
status === '优秀' ? 'text-green-500' : status === '合格' ? 'text-blue-500' : 'text-red-500';
const renderInner = () => {
if (status === '优秀') {
// 五角星(实心)
return (
<path
d="M12 3.6l2.63 5.33 5.89.86-4.26 4.15 1.01 5.86L12 17.69 6.73 19.8l1.01-5.86-4.26-4.15 5.89-.86L12 3.6z"
fill="currentColor"
stroke="none"
/>
);
}
if (status === '合格') {
// 对号(空心)
return <path d="M7.5 12.2l3 3L16.8 9" />;
}
// 感叹号(空心)
return (
<>
<path d="M12 7v6" />
<path d="M12 16.8h.01" />
</>
);
};
return (
<svg
viewBox="0 0 24 24"
width="32"
height="32"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
className={colorClass}
>
<circle cx="12" cy="12" r="10" />
{renderInner()}
</svg>
);
};
const ResultPage = () => {
const { id: recordId } = useParams<{ id: string }>();
const navigate = useNavigate();
@@ -53,12 +102,14 @@ const ResultPage = () => {
const fetchResultDetail = async () => {
try {
setLoading(true);
const response = await quizAPI.getRecordDetail(recordId!);
setRecord(response.record);
setAnswers(response.answers);
const response = await quizAPI.getRecordDetail(recordId!) as any;
const payload = response?.data ?? response;
setRecord(payload?.record ?? null);
setAnswers(Array.isArray(payload?.answers) ? payload.answers : []);
} catch (error: any) {
message.error(error.message || '获取答题结果失败');
navigate('/');
setRecord(null);
setAnswers([]);
} finally {
setLoading(false);
}
@@ -203,19 +254,7 @@ const ResultPage = () => {
<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>
)}
<StatusIcon status={record.status} />
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-semibold text-gray-800 mb-0.5">

View File

@@ -66,19 +66,21 @@ export const SubjectSelectionPage: React.FC = () => {
};
const getTaskStatus = (task: ExamTask) => {
const now = new Date();
const startAt = new Date(task.startAt);
const endAt = new Date(task.endAt);
const nowMs = Date.now();
const startMs = new Date(task.startAt).getTime();
const endMs = new Date(task.endAt).getTime();
const usedAttempts = Number(task.usedAttempts) || 0;
const maxAttempts = Number(task.maxAttempts) || 3;
if (now < startAt) {
return 'notStarted';
} else if (now >= startAt && now <= endAt && usedAttempts < maxAttempts) {
return 'ongoing';
} else {
// 日期解析失败时,按“已完成/不可开始”处理,避免误分组
if (!Number.isFinite(startMs) || !Number.isFinite(endMs)) {
return 'completed';
}
if (nowMs < startMs) return 'notStarted';
if (nowMs > endMs) return 'completed';
if (usedAttempts >= maxAttempts) return 'completed';
return 'ongoing';
};
const getTasksByStatus = (status: 'ongoing' | 'completed' | 'notStarted') => {
@@ -163,7 +165,10 @@ export const SubjectSelectionPage: React.FC = () => {
<div>
<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>
<div>
<Title level={4} className="!mb-0 !text-gray-700 !text-base"></Title>
{user && <Text className="text-sm text-gray-500">{user.name}</Text>}
</div>
<Button
type="default"
size="small"
@@ -260,14 +265,17 @@ 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 isExpired = Number.isFinite(endMs) ? endMs < Date.now() : true;
const isDisabled = attemptsExhausted || isExpired;
return (
<Button
key={task.id}
type="default"
block
disabled={attemptsExhausted}
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)}
disabled={isDisabled}
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 ${isDisabled ? 'opacity-50 cursor-not-allowed' : ''}`}
onClick={() => !isDisabled && startQuiz(task.id)}
>
<div className="flex justify-between items-center w-full">
<div className="flex-1">
@@ -294,7 +302,6 @@ export const SubjectSelectionPage: React.FC = () => {
{typeof task.bestScore === 'number' ? (
<Tag color="green" className="text-xs"> {task.bestScore} </Tag>
) : null}
{attemptsExhausted ? <Tag color="red" className="text-xs"></Tag> : null}
</div>
</div>
<div className="flex justify-between items-center mb-2">
@@ -304,9 +311,13 @@ export const SubjectSelectionPage: React.FC = () => {
{new Date(task.startAt).toLocaleDateString()} - {new Date(task.endAt).toLocaleDateString()}
</Text>
</div>
{!attemptsExhausted && (
{attemptsExhausted ? (
<div className="text-red-600">
<Text className="text-xs px-2 py-1 rounded border border-red-200 bg-red-50"></Text>
</div>
) : (
<div className="text-gray-400">
<Text className="text-xs px-2 py-1 rounded border border-gray-200 bg-gray-50"></Text>
<Text className="text-xs px-2 py-1 rounded border border-gray-200 bg-gray-50"></Text>
</div>
)}
</div>

View File

@@ -1,14 +1,22 @@
import axios from 'axios';
import axios, { AxiosInstance, AxiosResponse } from 'axios';
const API_BASE_URL = '/api';
const api = axios.create({
// 创建自定义Axios实例类型考虑响应拦截器的行为
type CustomAxiosInstance = AxiosInstance & {
post<T = any>(url: string, data?: any, config?: any): Promise<T>;
get<T = any>(url: string, config?: any): Promise<T>;
put<T = any>(url: string, data?: any, config?: any): Promise<T>;
delete<T = any>(url: string, config?: any): Promise<T>;
};
const api: CustomAxiosInstance = axios.create({
baseURL: API_BASE_URL,
timeout: 30000, // 增加超时时间到30秒
headers: {
'Content-Type': 'application/json',
},
});
}) as unknown as CustomAxiosInstance;
// 请求拦截器
api.interceptors.request.use(
@@ -95,14 +103,15 @@ export const questionAPI = {
// 答题相关API
export const quizAPI = {
generateQuiz: (userId: string, subjectId?: string, taskId?: string) =>
api.post('/quiz/generate', { userId, subjectId, taskId }),
api.post<{ questions: any[]; totalScore: number; timeLimit: number }>('/quiz/generate', { userId, subjectId, taskId }),
submitQuiz: (data: { userId: string; subjectId?: string; taskId?: string; answers: any[] }) =>
api.post('/quiz/submit', data),
api.post<{ recordId: string; totalScore: number; correctCount: number; totalCount: number }>('/quiz/submit', data),
getUserRecords: (userId: string, params?: { page?: number; limit?: number }) =>
api.get(`/quiz/records/${userId}`, { params }),
getRecordDetail: (recordId: string) => api.get(`/quiz/records/detail/${recordId}`),
api.get<any[]>(`/quiz/records/${userId}`, { params }),
getRecordDetail: (recordId: string) =>
api.get<{ record: any; answers: any[] }>(`/quiz/records/detail/${recordId}`),
getAllRecords: (params?: { page?: number; limit?: number }) =>
api.get('/quiz/records', { params }),
api.get<any[]>(`/quiz/records`, { params }),
};
export const examSubjectAPI = {

View File

@@ -288,10 +288,11 @@ test('管理员任务分页统计接口返回结构正确', async () => {
assert.equal(userTasks.status, 200);
assert.equal(userTasks.json?.success, true);
assert.ok(Array.isArray(userTasks.json?.data));
assert.equal(userTasks.json?.data?.[0]?.id, activeTaskId);
assert.equal(userTasks.json?.data?.[0]?.usedAttempts, 3);
assert.equal(userTasks.json?.data?.[0]?.maxAttempts, 3);
assert.equal(userTasks.json?.data?.[0]?.bestScore, 30);
const activeTaskRow = (userTasks.json?.data as any[]).find((t) => t.id === activeTaskId);
assert.ok(activeTaskRow);
assert.equal(activeTaskRow.usedAttempts, 3);
assert.equal(activeTaskRow.maxAttempts, 3);
assert.equal(activeTaskRow.bestScore, 30);
const fourthGenerate = await jsonFetch(baseUrl, '/api/quiz/generate', {
method: 'POST',

119
test/user-tasks.test.ts Normal file
View File

@@ -0,0 +1,119 @@
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('用户任务接口返回未开始/进行中/已结束任务(由前端分组)', async () => {
const { initDatabase, run } = 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 now = Date.now();
const userId = randomUUID();
const subjectId = randomUUID();
const historyTaskId = randomUUID();
const upcomingTaskId = randomUUID();
const activeTaskId = randomUUID();
await run(`INSERT INTO users (id, name, phone, password) VALUES (?, ?, ?, ?)`, [
userId,
'测试用户',
'13800138099',
'',
]);
await run(
`INSERT INTO exam_subjects (id, name, type_ratios, category_ratios, total_score, duration_minutes)
VALUES (?, ?, ?, ?, ?, ?)`,
[
subjectId,
'测试科目',
JSON.stringify({ single: 100 }),
JSON.stringify({ 通用: 100 }),
100,
60,
],
);
const historyStartAt = new Date(now - 3 * 24 * 60 * 60 * 1000).toISOString();
const historyEndAt = new Date(now - 2 * 24 * 60 * 60 * 1000).toISOString();
const upcomingStartAt = new Date(now + 2 * 24 * 60 * 60 * 1000).toISOString();
const upcomingEndAt = new Date(now + 3 * 24 * 60 * 60 * 1000).toISOString();
const activeStartAt = new Date(now - 1 * 24 * 60 * 60 * 1000).toISOString();
const activeEndAt = new Date(now + 1 * 24 * 60 * 60 * 1000).toISOString();
await run(
`INSERT INTO exam_tasks (id, name, subject_id, start_at, end_at, selection_config)
VALUES (?, ?, ?, ?, ?, ?)`,
[historyTaskId, '历史任务', subjectId, historyStartAt, historyEndAt, null],
);
await run(
`INSERT INTO exam_tasks (id, name, subject_id, start_at, end_at, selection_config)
VALUES (?, ?, ?, ?, ?, ?)`,
[upcomingTaskId, '未开始任务', subjectId, upcomingStartAt, upcomingEndAt, null],
);
await run(
`INSERT INTO exam_tasks (id, name, subject_id, start_at, end_at, selection_config)
VALUES (?, ?, ?, ?, ?, ?)`,
[activeTaskId, '进行中任务', subjectId, activeStartAt, activeEndAt, null],
);
const linkUserToTask = async (taskId: string) => {
await run(`INSERT INTO exam_task_users (id, task_id, user_id) VALUES (?, ?, ?)`, [
randomUUID(),
taskId,
userId,
]);
};
await linkUserToTask(historyTaskId);
await linkUserToTask(upcomingTaskId);
await linkUserToTask(activeTaskId);
const res = await jsonFetch(baseUrl, `/api/exam-tasks/user/${userId}`);
assert.equal(res.status, 200);
assert.equal(res.json?.success, true);
assert.ok(Array.isArray(res.json?.data));
const names = (res.json?.data as any[]).map((t) => t.name).sort();
assert.deepEqual(names, ['历史任务', '未开始任务', '进行中任务'].sort());
const upcoming = (res.json?.data as any[]).find((t) => t.name === '未开始任务');
assert.ok(upcoming);
assert.equal(typeof upcoming.startAt, 'string');
assert.equal(typeof upcoming.endAt, 'string');
} finally {
server.close();
}
});