feat: 更新构建流程,添加 API 构建脚本和 SQL 文件复制脚本

- 修改 package.json,更新构建命令,添加 postbuild 脚本以复制 init.sql 文件。
- 新增 scripts/build-api.mjs,使用 esbuild 构建 API 代码。
- 新增 scripts/copy-init-sql.mjs,复制数据库初始化 SQL 文件到构建输出目录。
- 在 SubjectSelectionPage 组件中添加 totalScore 属性,增加历史最高分状态显示功能。
- 在 ExamSubjectPage 和 QuestionManagePage 中优化判断题答案处理逻辑。
- 在 OptionList 组件中将判断题选项文本从 'T' 和 'F' 改为 '对' 和 '错'。
- 在 QuizFooter 组件中调整样式,增加按钮和文本的可读性。
- 新增用户默认组测试用例,验证新用户创建后自动加入“全体用户”系统组。
- 新增 tsconfig.api.json,配置 API 相关 TypeScript 编译选项。
- 移除 vite.config.ts 中的 global 定义。
This commit is contained in:
2025-12-30 20:33:14 +08:00
parent 1822d8b4da
commit eb4504960e
31 changed files with 10221 additions and 150 deletions

View File

@@ -29,6 +29,7 @@ interface ExamTask {
usedAttempts?: number;
maxAttempts?: number;
bestScore?: number;
totalScore?: number;
}
export const SubjectSelectionPage: React.FC = () => {
@@ -88,6 +89,35 @@ export const SubjectSelectionPage: React.FC = () => {
return tasks.filter(task => getTaskStatus(task) === status);
};
const getScoreStatus = (pct: number): '优秀' | '合格' | '不及格' => {
if (!Number.isFinite(pct)) return '不及格';
if (pct >= 80) return '优秀';
if (pct >= 60) return '合格';
return '不及格';
};
const getScoreStatusTagColor = (status: '优秀' | '合格' | '不及格') => {
if (status === '优秀') return 'green';
if (status === '合格') return 'blue';
return 'red';
};
const renderBestHistoryTag = (task: ExamTask, subject?: ExamSubject) => {
const usedAttempts = Number(task.usedAttempts) || 0;
if (usedAttempts <= 0) return null;
if (typeof task.bestScore !== 'number') return null;
const totalScore = Number(subject?.totalScore ?? task.totalScore) || 0;
const pct = totalScore > 0 ? (task.bestScore / totalScore) * 100 : NaN;
const status = getScoreStatus(pct);
return (
<Tag color={getScoreStatusTagColor(status)} className="text-xs">
{status}
</Tag>
);
};
const startQuiz = async (taskId: string) => {
if (!taskId) {
message.warning('请选择考试任务');
@@ -224,9 +254,7 @@ export const SubjectSelectionPage: React.FC = () => {
<Tag color="green" className="text-xs">
{usedAttempts}/{maxAttempts}
</Tag>
{typeof task.bestScore === 'number' ? (
<Tag color="green" className="text-xs"> {task.bestScore} </Tag>
) : null}
{renderBestHistoryTag(task, subject)}
</div>
</div>
<div className="flex justify-between items-center mb-2">
@@ -300,9 +328,7 @@ export const SubjectSelectionPage: React.FC = () => {
<Tag color={attemptsExhausted ? 'red' : 'default'} className="text-xs">
{usedAttempts}/{maxAttempts}
</Tag>
{typeof task.bestScore === 'number' ? (
<Tag color="green" className="text-xs"> {task.bestScore} </Tag>
) : null}
{renderBestHistoryTag(task, subject)}
</div>
</div>
<div className="flex justify-between items-center mb-2">
@@ -377,9 +403,7 @@ export const SubjectSelectionPage: React.FC = () => {
<Tag color="blue" className="text-xs">
{usedAttempts}/{maxAttempts}
</Tag>
{typeof task.bestScore === 'number' ? (
<Tag color="green" className="text-xs"> {task.bestScore} </Tag>
) : null}
{renderBestHistoryTag(task, subject)}
</div>
</div>
<div className="flex justify-between items-center mb-2">
@@ -407,11 +431,7 @@ export const SubjectSelectionPage: React.FC = () => {
</div>
</div>
{tasks.length === 0 && (
<div className="text-center py-8 bg-gray-50 border-dashed border-2 border-gray-200 rounded">
<Text type="secondary" className="text-sm"></Text>
</div>
)}
</div>
</div>

View File

@@ -730,9 +730,12 @@ const ExamSubjectPage = () => {
{Array.isArray(question.answer) ?
// 多选题:直接拼接答案,不需要转换
question.answer.join(', ') :
question.type === 'judgment' ?
// 判断题A=正确B=错误
(question.answer === 'A' ? '正确' : '错误') :
question.type === 'judgment' ? (() => {
const v = String(question.answer ?? '').trim();
if (['A', 'T', 'true', 'True', 'TRUE', '1', '正确', '对', ''].includes(v)) return '正确';
if (['B', 'F', 'false', 'False', 'FALSE', '0', '错误', '错', '否', '不是'].includes(v)) return '错误';
return v;
})() :
// 单选题:直接显示答案,不需要转换
question.answer}
</span>

View File

@@ -116,11 +116,19 @@ const QuestionManagePage = () => {
};
const handleEdit = (question: Question) => {
const normalizeJudgmentAnswer = (raw: unknown) => {
const v = String(raw ?? '').trim();
if (['A', 'T', 'true', 'True', 'TRUE', '1', '正确', '对', '是'].includes(v)) return '正确';
if (['B', 'F', 'false', 'False', 'FALSE', '0', '错误', '错', '否', '不是'].includes(v)) return '错误';
return v;
};
setEditingQuestion(question);
form.setFieldsValue({
...question,
options: question.options?.join('\n'),
analysis: question.analysis || ''
analysis: question.analysis || '',
answer: question.type === 'judgment' ? normalizeJudgmentAnswer(question.answer) : question.answer,
});
setModalVisible(true);
};
@@ -137,9 +145,17 @@ const QuestionManagePage = () => {
const handleSubmit = async (values: any) => {
try {
const normalizeJudgmentAnswer = (raw: unknown) => {
const v = String(raw ?? '').trim();
if (['A', 'T', 'true', 'True', 'TRUE', '1', '正确', '对', '是'].includes(v)) return '正确';
if (['B', 'F', 'false', 'False', 'FALSE', '0', '错误', '错', '否', '不是'].includes(v)) return '错误';
return v;
};
const formData = {
...values,
options: values.options ? values.options.split('\n').filter((opt: string) => opt.trim()) : undefined
options: values.options ? values.options.split('\n').filter((opt: string) => opt.trim()) : undefined,
answer: values.type === 'judgment' ? normalizeJudgmentAnswer(values.answer) : values.answer,
};
if (editingQuestion) {

View File

@@ -78,7 +78,7 @@ export const OptionList = ({ type, options, value, onChange, disabled }: OptionL
? 'bg-[#00897B] border-[#00897B] text-white'
: 'bg-white border-gray-300 text-gray-500'}
`}>
{type === 'judgment' ? (index === 0 ? 'T' : 'F') : getOptionLabel(index)}
{type === 'judgment' ? (index === 0 ? '' : '') : getOptionLabel(index)}
</div>
{/* 选项内容 */}

View File

@@ -24,24 +24,24 @@ export const QuizFooter = ({
const isLast = current === total - 1;
return (
<div className="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-100 px-2 py-1.5 safe-area-bottom z-30 shadow-[0_-2px_10px_rgba(0,0,0,0.05)]">
<div className="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-100 px-2 py-2 safe-area-bottom z-30 shadow-[0_-2px_10px_rgba(0,0,0,0.05)]">
<div className="max-w-md mx-auto flex items-center justify-between">
<Button
type="text"
icon={<LeftOutlined />}
onClick={onPrev}
disabled={isFirst}
className={`flex items-center text-gray-600 hover:text-[#00897B] text-xs ${isFirst ? 'opacity-30' : ''}`}
className={`flex items-center text-gray-600 hover:text-[#00897B] text-sm ${isFirst ? 'opacity-30' : ''}`}
>
</Button>
<div
onClick={onOpenSheet}
className="flex flex-col items-center justify-center -mt-4 bg-white rounded-full h-11 w-11 shadow-lg border border-gray-100 cursor-pointer active:scale-95 transition-transform"
className="flex flex-col items-center justify-center -mt-5 bg-white rounded-full h-14 w-14 shadow-lg border border-gray-100 cursor-pointer active:scale-95 transition-transform"
>
<AppstoreOutlined className="text-sm text-[#00897B] mb-0.5" />
<span className="text-[10px] text-gray-500 scale-90">
<AppstoreOutlined className="text-base text-[#00897B] mb-0.5" />
<span className="text-[12px] text-gray-500">
{answeredCount}/{total}
</span>
</div>
@@ -50,7 +50,7 @@ export const QuizFooter = ({
<Button
type="text"
onClick={onSubmit}
className="flex items-center text-[#00897B] font-medium hover:bg-teal-50 text-xs"
className="flex items-center text-[#00897B] font-medium hover:bg-teal-50 text-sm"
>
<RightOutlined />
</Button>
@@ -58,7 +58,7 @@ export const QuizFooter = ({
<Button
type="text"
onClick={onNext}
className="flex items-center text-gray-600 hover:text-[#00897B] text-xs"
className="flex items-center text-gray-600 hover:text-[#00897B] text-sm"
>
<RightOutlined />
</Button>