feat: 更新考试相关页面,优化任务状态处理,添加用户任务接口测试
This commit is contained in:
@@ -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,
|
||||
|
||||
BIN
data/survey.db
BIN
data/survey.db
Binary file not shown.
@@ -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": {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
119
test/user-tasks.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user