feat: 修改部分导入文本的逻辑,添加部署脚本和样式文件,更新数据库迁移逻辑
- 新增部署脚本 `build-deploy-bundle.mjs`,用于构建和部署 web 和 server 目录。 - 新增样式文件 `index-acd65452.css`,包含基础样式和响应式设计。 - 新增脚本 `repro-import-text.mjs`,用于测试文本导入 API。 - 新增测试文件 `db-migration-score-zero.test.ts`,验证历史数据库中 questions.score 约束的迁移逻辑。 - 更新数据库初始化逻辑,允许插入 score=0 的问题。
This commit is contained in:
@@ -313,7 +313,7 @@ const QuizPage = () => {
|
||||
};
|
||||
|
||||
const handleJumpToFirstUnanswered = () => {
|
||||
const firstUnansweredIndex = questions.findIndex(q => !answers[q.id]);
|
||||
const firstUnansweredIndex = questions.findIndex(q => Number(q.score) > 0 && !answers[q.id]);
|
||||
if (firstUnansweredIndex !== -1) {
|
||||
handleJumpTo(firstUnansweredIndex);
|
||||
}
|
||||
@@ -325,7 +325,7 @@ const QuizPage = () => {
|
||||
setSubmitting(true);
|
||||
|
||||
if (!forceSubmit) {
|
||||
const unansweredQuestions = questions.filter(q => !answers[q.id]);
|
||||
const unansweredQuestions = questions.filter(q => Number(q.score) > 0 && !answers[q.id]);
|
||||
if (unansweredQuestions.length > 0) {
|
||||
message.warning(`还有 ${unansweredQuestions.length} 道题未作答`);
|
||||
return;
|
||||
@@ -333,10 +333,11 @@ const QuizPage = () => {
|
||||
}
|
||||
|
||||
const answersData = questions.map(question => {
|
||||
const isCorrect = checkAnswer(question, answers[question.id]);
|
||||
const userAnswer = (answers[question.id] ?? '') as any;
|
||||
const isCorrect = checkAnswer(question, userAnswer);
|
||||
return {
|
||||
questionId: question.id,
|
||||
userAnswer: answers[question.id],
|
||||
userAnswer,
|
||||
score: isCorrect ? question.score : 0,
|
||||
isCorrect
|
||||
};
|
||||
@@ -382,6 +383,7 @@ const QuizPage = () => {
|
||||
};
|
||||
|
||||
const checkAnswer = (question: Question, userAnswer: string | string[]): boolean => {
|
||||
if (Number(question.score) === 0) return true;
|
||||
if (!userAnswer) return false;
|
||||
|
||||
if (question.type === 'multiple') {
|
||||
@@ -395,7 +397,7 @@ const QuizPage = () => {
|
||||
};
|
||||
|
||||
const answeredCount = useMemo(() => {
|
||||
return questions.reduce((count, q) => (answers[q.id] ? count + 1 : count), 0);
|
||||
return questions.reduce((count, q) => (answers[q.id] || Number(q.score) === 0 ? count + 1 : count), 0);
|
||||
}, [questions, answers]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -58,7 +58,7 @@ const ExamSubjectPage = () => {
|
||||
text: 10
|
||||
});
|
||||
const [categoryRatios, setCategoryRatios] = useState<Record<string, number>>({
|
||||
通用: 100
|
||||
|
||||
});
|
||||
|
||||
const sumValues = (valuesMap: Record<string, number>) => Object.values(valuesMap).reduce((a, b) => a + b, 0);
|
||||
@@ -133,11 +133,20 @@ const ExamSubjectPage = () => {
|
||||
}, []);
|
||||
|
||||
const handleCreate = () => {
|
||||
if (categories.length === 0) {
|
||||
message.warning('题目类别加载中,请稍后再试');
|
||||
return;
|
||||
}
|
||||
setEditingSubject(null);
|
||||
form.resetFields();
|
||||
// 设置默认值
|
||||
const defaultTypeRatios = { single: 4, multiple: 3, judgment: 2, text: 1 };
|
||||
const defaultCategoryRatios: Record<string, number> = { 通用: 10 };
|
||||
const total = sumValues(defaultTypeRatios);
|
||||
const preferredCategory =
|
||||
categories.find((c) => c.name !== '通用')?.name ?? categories[0]?.name;
|
||||
const defaultCategoryRatios: Record<string, number> = preferredCategory
|
||||
? { [preferredCategory]: total }
|
||||
: {};
|
||||
|
||||
// 初始化状态
|
||||
setConfigMode('count');
|
||||
@@ -160,11 +169,7 @@ const ExamSubjectPage = () => {
|
||||
const initialTypeRatios = subject.typeRatios || { single: 40, multiple: 30, judgment: 20, text: 10 };
|
||||
|
||||
// 初始化类别比重,确保所有类别都有值
|
||||
const initialCategoryRatios: Record<string, number> = { 通用: 100 };
|
||||
// 合并现有类别比重
|
||||
if (subject.categoryRatios) {
|
||||
Object.assign(initialCategoryRatios, subject.categoryRatios);
|
||||
}
|
||||
const initialCategoryRatios: Record<string, number> = subject.categoryRatios || {};
|
||||
|
||||
// 确保状态与表单值正确同步
|
||||
const inferredMode = isRatioMode(initialTypeRatios) && isRatioMode(initialCategoryRatios) ? 'ratio' : 'count';
|
||||
@@ -380,11 +385,15 @@ const ExamSubjectPage = () => {
|
||||
key: 'categoryRatios',
|
||||
render: (ratios: Record<string, number>) => {
|
||||
const ratioMode = isRatioMode(ratios || {});
|
||||
const total = sumValues(ratios || {});
|
||||
const knownCategories = new Set(categories.map((c) => c.name));
|
||||
const entries = ratios
|
||||
? Object.entries(ratios).filter(([k]) => knownCategories.size === 0 || knownCategories.has(k))
|
||||
: [];
|
||||
const total = entries.reduce((s, [, v]) => s + (Number(v) || 0), 0);
|
||||
return (
|
||||
<div className="p-3 bg-white rounded-lg border border-gray-200 shadow-sm">
|
||||
<div className="w-full bg-gray-200 rounded-full h-4 flex mb-3 overflow-hidden">
|
||||
{ratios && Object.entries(ratios).map(([category, value]) => (
|
||||
{entries.map(([category, value]) => (
|
||||
<div
|
||||
key={category}
|
||||
className="h-full"
|
||||
@@ -396,7 +405,7 @@ const ExamSubjectPage = () => {
|
||||
))}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{ratios && Object.entries(ratios).map(([category, value]) => {
|
||||
{entries.map(([category, value]) => {
|
||||
const percent = ratioMode ? value : (total > 0 ? Math.round((value / total) * 1000) / 10 : 0);
|
||||
return (
|
||||
<div key={category} className="flex items-center text-sm">
|
||||
|
||||
@@ -610,7 +610,7 @@ const QuestionManagePage = () => {
|
||||
label="分值"
|
||||
rules={[{ required: true, message: '请输入分值' }]}
|
||||
>
|
||||
<InputNumber min={1} max={100} style={{ width: '100%' }} />
|
||||
<InputNumber min={0} max={100} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
@@ -649,16 +649,20 @@ const QuestionManagePage = () => {
|
||||
|
||||
<Form.Item
|
||||
noStyle
|
||||
shouldUpdate={(prevValues, currentValues) => prevValues.type !== currentValues.type}
|
||||
shouldUpdate={(prevValues, currentValues) =>
|
||||
prevValues.type !== currentValues.type || prevValues.score !== currentValues.score
|
||||
}
|
||||
>
|
||||
{({ getFieldValue }) => {
|
||||
const type = getFieldValue('type');
|
||||
const score = Number(getFieldValue('score') ?? 0);
|
||||
const requireAnswer = Number.isFinite(score) ? score > 0 : true;
|
||||
if (type === 'judgment') {
|
||||
return (
|
||||
<Form.Item
|
||||
name="answer"
|
||||
label="正确答案"
|
||||
rules={[{ required: true, message: '请选择正确答案' }]}
|
||||
rules={requireAnswer ? [{ required: true, message: '请选择正确答案' }] : []}
|
||||
>
|
||||
<Select placeholder="选择正确答案">
|
||||
<Option value="正确">正确</Option>
|
||||
@@ -671,7 +675,7 @@ const QuestionManagePage = () => {
|
||||
<Form.Item
|
||||
name="answer"
|
||||
label="正确答案"
|
||||
rules={[{ required: true, message: '请输入正确答案' }]}
|
||||
rules={requireAnswer ? [{ required: true, message: '请输入正确答案' }] : []}
|
||||
>
|
||||
<Input placeholder={type === 'multiple' ? '多个答案用逗号分隔' : '请输入正确答案'} />
|
||||
</Form.Item>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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 { Table, Button, Input, Space, message, Popconfirm, Modal, Form, Switch, Upload, Tabs, Select, Tag, Descriptions, Divider, Typography } from 'antd';
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined, ExportOutlined, ImportOutlined, EyeOutlined, EyeInvisibleOutlined } from '@ant-design/icons';
|
||||
import api, { userGroupAPI } from '../../services/api';
|
||||
import type { UploadProps } from 'antd';
|
||||
@@ -20,9 +20,7 @@ interface User {
|
||||
interface QuizRecord {
|
||||
id: string;
|
||||
userId: string;
|
||||
userName: string;
|
||||
totalScore: number;
|
||||
obtainedScore: number;
|
||||
scorePercentage: number;
|
||||
status: '不及格' | '合格' | '优秀';
|
||||
createdAt: string;
|
||||
@@ -30,6 +28,24 @@ interface QuizRecord {
|
||||
taskName?: string;
|
||||
}
|
||||
|
||||
interface RecordDetailAnswer {
|
||||
id: string;
|
||||
questionId: string;
|
||||
userAnswer: string | string[];
|
||||
score: number;
|
||||
isCorrect: boolean;
|
||||
questionContent?: string;
|
||||
questionType?: string;
|
||||
correctAnswer?: string | string[];
|
||||
questionScore?: number;
|
||||
questionAnalysis?: string;
|
||||
}
|
||||
|
||||
interface RecordDetailResponse {
|
||||
record: QuizRecord;
|
||||
answers: RecordDetailAnswer[];
|
||||
}
|
||||
|
||||
interface UserGroup {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -290,8 +306,9 @@ const UserManagePage = () => {
|
||||
setRecordDetailLoading(true);
|
||||
try {
|
||||
const res = await api.get(`/admin/quiz/records/detail/${recordId}`);
|
||||
setRecordDetail(res.data);
|
||||
return res.data;
|
||||
const data = res.data as RecordDetailResponse;
|
||||
setRecordDetail(data);
|
||||
return data;
|
||||
} catch (error) {
|
||||
message.error('获取记录详情失败');
|
||||
console.error('获取记录详情失败:', error);
|
||||
@@ -489,7 +506,7 @@ const UserManagePage = () => {
|
||||
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>;
|
||||
return <Tag color={color}>{status}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -497,7 +514,7 @@ const UserManagePage = () => {
|
||||
dataIndex: 'obtainedScore',
|
||||
key: 'obtainedScore',
|
||||
render: (score: number, record: QuizRecord) => {
|
||||
const actualScore = score || 0;
|
||||
const actualScore = (score ?? record.totalScore) || 0;
|
||||
const totalScore = record.totalScore || 0;
|
||||
return (
|
||||
<span className={`font-medium ${actualScore >= totalScore * 0.6 ? 'text-green-600' : 'text-red-600'}`}>
|
||||
@@ -511,9 +528,7 @@ const UserManagePage = () => {
|
||||
dataIndex: 'scoreRate',
|
||||
key: 'scoreRate',
|
||||
render: (_: any, record: QuizRecord) => {
|
||||
const obtainedScore = record.obtainedScore || 0;
|
||||
const totalScore = record.totalScore || 0;
|
||||
const rate = totalScore > 0 ? (obtainedScore / totalScore) * 100 : 0;
|
||||
const rate = typeof record.scorePercentage === 'number' ? record.scorePercentage : 0;
|
||||
return `${rate.toFixed(1)}%`;
|
||||
},
|
||||
},
|
||||
@@ -594,94 +609,107 @@ const UserManagePage = () => {
|
||||
open={recordDetailVisible}
|
||||
onCancel={handleCloseRecordDetail}
|
||||
footer={null}
|
||||
width={800}
|
||||
width="90vw"
|
||||
style={{ maxWidth: 1100 }}
|
||||
loading={recordDetailLoading}
|
||||
bodyStyle={{ maxHeight: '75vh', overflowY: 'auto' }}
|
||||
>
|
||||
{recordDetail && (
|
||||
<div>
|
||||
{/* 考试基本信息 */}
|
||||
<div className="mb-6 p-4 bg-gray-50 rounded">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-gray-600 font-medium">科目:</label>
|
||||
<span>{recordDetail.subjectName || '无科目'}</span>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-gray-600 font-medium">任务:</label>
|
||||
<span>{recordDetail.taskName || '无任务'}</span>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-gray-600 font-medium">总分:</label>
|
||||
<span>{recordDetail.totalScore || 0}</span>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-gray-600 font-medium">得分:</label>
|
||||
<span className="font-semibold text-blue-600">{recordDetail.obtainedScore || 0}</span>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="text-gray-600 font-medium">考试时间:</label>
|
||||
<span>{formatDateTime(recordDetail.createdAt, { includeSeconds: true })}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{recordDetail && (() => {
|
||||
const record = (recordDetail as RecordDetailResponse).record;
|
||||
const answers = (recordDetail as RecordDetailResponse).answers || [];
|
||||
const obtainedScore = record?.totalScore ?? 0;
|
||||
const scorePercentage = typeof record?.scorePercentage === 'number' ? record.scorePercentage : 0;
|
||||
|
||||
{/* 题目列表 */}
|
||||
<h3 className="text-lg font-semibold mb-4">题目详情</h3>
|
||||
<div className="space-y-6">
|
||||
{recordDetail.questions && recordDetail.questions.map((item: any, index: number) => (
|
||||
<div key={item.id} className="p-4 border rounded">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center">
|
||||
<span className="font-medium mr-2">第{index + 1}题</span>
|
||||
<span className="text-sm text-gray-600">
|
||||
{item.question.type === 'single' ? '单选题' :
|
||||
item.question.type === 'multiple' ? '多选题' :
|
||||
item.question.type === 'judgment' ? '判断题' : '文字题'}
|
||||
</span>
|
||||
<span className="ml-2 text-sm">{item.score}分</span>
|
||||
<span className={`ml-2 text-sm ${item.isCorrect ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{item.isCorrect ? '答对' : '答错'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<p className="font-medium">题目:{item.question.content}</p>
|
||||
</div>
|
||||
|
||||
{/* 显示选项(如果有) */}
|
||||
{item.question.options && item.question.options.length > 0 && (
|
||||
<div className="mb-3">
|
||||
<p className="text-sm font-medium text-gray-600 mb-2">选项:</p>
|
||||
<div className="space-y-2">
|
||||
{item.question.options.map((option: string, optIndex: number) => (
|
||||
<div key={optIndex} className="flex items-center">
|
||||
<span className="inline-block w-5 h-5 border rounded text-center text-xs mr-2">
|
||||
{String.fromCharCode(65 + optIndex)}
|
||||
</span>
|
||||
<span>{option}</span>
|
||||
const formatAnswer = (v: any) => {
|
||||
if (Array.isArray(v)) return v.join(', ');
|
||||
return String(v ?? '').trim();
|
||||
};
|
||||
|
||||
const typeLabel = (t?: string) => {
|
||||
if (t === 'single') return '单选题';
|
||||
if (t === 'multiple') return '多选题';
|
||||
if (t === 'judgment') return '判断题';
|
||||
if (t === 'text') return '文字题';
|
||||
return t || '';
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Descriptions
|
||||
size="small"
|
||||
bordered
|
||||
column={{ xs: 1, sm: 2, md: 3 }}
|
||||
items={[
|
||||
{ key: 'user', label: '用户', children: selectedUser?.name || record?.userId || '-' },
|
||||
{ key: 'subject', label: '科目', children: record?.subjectName || '无科目' },
|
||||
{ key: 'task', label: '任务', children: record?.taskName || '无任务' },
|
||||
{ key: 'totalScore', label: '得分', children: obtainedScore },
|
||||
{ key: 'scoreRate', label: '得分率', children: `${scorePercentage.toFixed(1)}%` },
|
||||
{ key: 'status', label: '状态', children: <Tag color={getStatusColor(record?.status)}>{record?.status || '-'}</Tag> },
|
||||
{ key: 'time', label: '考试时间', span: 3, children: record?.createdAt ? formatDateTime(record.createdAt, { includeSeconds: true }) : '-' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<Divider orientation="left" style={{ marginTop: 16 }}>题目详情</Divider>
|
||||
|
||||
<div className="space-y-4">
|
||||
{answers.map((a, index) => {
|
||||
const maxScore = Number(a.questionScore ?? 0);
|
||||
const isZeroScore = maxScore === 0;
|
||||
const correct = formatAnswer(a.correctAnswer);
|
||||
const userAns = formatAnswer(a.userAnswer);
|
||||
const showCorrectAnswer = a.questionType !== 'text' && correct !== '';
|
||||
const showAnalysis = String(a.questionAnalysis ?? '').trim() !== '';
|
||||
const isCorrect = Boolean(a.isCorrect);
|
||||
|
||||
return (
|
||||
<div key={a.id} className="p-4 border rounded bg-white">
|
||||
<div className="flex flex-wrap items-center gap-2 justify-between">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-medium">第 {index + 1} 题</span>
|
||||
<Tag color="blue">{typeLabel(a.questionType)}</Tag>
|
||||
<Tag>{maxScore} 分</Tag>
|
||||
<Tag color={isCorrect ? 'green' : 'red'}>{isCorrect ? '答对' : '答错'}</Tag>
|
||||
{isZeroScore ? <Tag color="default">0分题默认正确</Tag> : null}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">得分:{Number(a.score ?? 0)}</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3">
|
||||
<Typography.Paragraph style={{ marginBottom: 8 }}>
|
||||
<span className="font-medium">题目:</span>
|
||||
<span className="whitespace-pre-wrap">{a.questionContent || ''}</span>
|
||||
</Typography.Paragraph>
|
||||
|
||||
<div className="grid gap-2" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))' }}>
|
||||
<div>
|
||||
<div className="text-sm text-gray-600 mb-1">你的答案</div>
|
||||
<div className="whitespace-pre-wrap font-medium">{userAns || '未作答'}</div>
|
||||
</div>
|
||||
))}
|
||||
<div>
|
||||
<div className="text-sm text-gray-600 mb-1">正确答案</div>
|
||||
<div className="whitespace-pre-wrap">{showCorrectAnswer ? correct : (a.questionType === 'text' ? '(文字题无标准答案)' : (correct || ''))}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showAnalysis ? (
|
||||
<div className="mt-3">
|
||||
<div className="text-sm text-gray-600 mb-1">解析</div>
|
||||
<div className="whitespace-pre-wrap">{String(a.questionAnalysis ?? '')}</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 显示答案 */}
|
||||
<div className="space-y-1">
|
||||
<div className="flex">
|
||||
<span className="w-20 text-sm text-gray-600">正确答案:</span>
|
||||
<span className="text-sm">{Array.isArray(item.question.answer) ? item.question.answer.join(', ') : item.question.answer}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="w-20 text-sm text-gray-600">你的答案:</span>
|
||||
<span className="text-sm font-medium">{Array.isArray(item.userAnswer) ? item.userAnswer.join(', ') : item.userAnswer || '未作答'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
|
||||
{answers.length === 0 ? (
|
||||
<div className="text-center py-10 text-gray-500">暂无题目详情</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
);
|
||||
})()}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user