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:
2026-01-04 09:20:04 +08:00
parent fbfd48e0ca
commit dbf9fdc01c
26 changed files with 1420 additions and 849 deletions

View File

@@ -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(() => {

View File

@@ -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">

View File

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

View File

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