ai-course/app/backend/src/pages/exam/questions/compenents/detail-update.tsx
menft f7e04de5e5 feat(exam): 试题关联知识点功能
- 后端:ExamQuestion 实体新增 knowledge_code 字段存储关联知识点
- 后端:TextbookController 新增教材下拉列表、知识点列表接口
- 后端:KnowledgeController 新增按编码列表查询知识点接口
- 前端:试题创建/编辑页面增加教材-知识点两级级联选择器
- 支持多选知识点,编辑时自动回显已关联知识点
2025-11-29 22:31:42 +08:00

649 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useState, useEffect } from 'react';
import { Modal, Form, Radio, Spin, message, Cascader } from 'antd';
import type { CascaderProps } from 'antd';
import { question, textbook } from '../../../../api/index';
import { QuestionInput } from '../../../../compenents';
import { QChoice } from './choice';
import { QSelect } from './select';
import { QInput } from './input';
import { QJudge } from './judge';
import { QQa } from './qa';
import { QCap } from './cap';
import { useTranslation } from 'react-i18next';
// 级联选择器选项类型
interface CascaderOption {
value: string | number;
label: string;
children?: CascaderOption[];
isLeaf?: boolean;
loading?: boolean;
}
interface PropInterface {
id: number;
qid: number;
open: boolean;
onCancel: () => void;
}
export const QuestionsDetailUpdate: React.FC<PropInterface> = ({ id, qid, open, onCancel }) => {
const { t } = useTranslation();
const [form] = Form.useForm();
const [init, setInit] = useState(true);
const [loading, setLoading] = useState(false);
const [refresh, setRefresh] = useState(false);
const [type, setType] = useState('1');
const [resourceUrl, setResourceUrl] = useState<ResourceUrlModel>({});
const [cascaderOptions, setCascaderOptions] = useState<CascaderOption[]>([]); // 级联选择器选项
const [formParams, setFormParams] = useState({
v: 'v1',
d: {
content: null,
remark: null,
},
});
useEffect(() => {
setInit(true);
if (open && qid > 0) {
getDetail();
}
}, [form, open, id, qid]);
const getDetail = async () => {
try {
// 1. 加载教材列表作为级联选择器第一级
const textbookRes = await textbook.getTextbookSelectListApi();
let options: CascaderOption[] = [];
if (textbookRes.data && Array.isArray(textbookRes.data)) {
options = textbookRes.data.map((item: any) => ({
value: item.id,
label: item.title,
isLeaf: false,
}));
setCascaderOptions(options);
}
// 2. 加载试题详情
const res: any = await question.questionDetail(qid);
setResourceUrl(res.data.resource_url);
const data = res.data.question;
setType(String(data.type));
const params = JSON.parse(res.data.question.content);
// 3. 处理知识点回显
let cascaderValue: (string | number)[][] = [];
if (data.knowledge_code) {
try {
// 获取知识点详情包含bookId
const knowledgeRes: any = await textbook.getKnowledgeByCodesApi(data.knowledge_code);
if (knowledgeRes.data && Array.isArray(knowledgeRes.data) && knowledgeRes.data.length > 0) {
// 获取所有需要预加载的教材ID去重
const bookIds = [...new Set(knowledgeRes.data.map((k: any) => k.bookId))];
// 预加载每个教材的知识点列表到级联选项中
for (const bookId of bookIds) {
const knowledgeListRes: any = await textbook.getKnowledgeListApi(bookId as number);
const targetOption = options.find(opt => opt.value === bookId);
if (targetOption && knowledgeListRes.data && Array.isArray(knowledgeListRes.data)) {
targetOption.children = knowledgeListRes.data.map((item: any) => ({
value: item.knowledgeCode || item.knowledge_code,
label: item.name,
isLeaf: true,
}));
}
}
setCascaderOptions([...options]);
// 构建级联选择器的值 [[bookId, knowledgeCode], ...]
cascaderValue = knowledgeRes.data.map((k: any) => [k.bookId, k.knowledgeCode || k.knowledge_code]);
}
} catch (err) {
console.error('加载知识点详情失败:', err);
}
}
form.setFieldsValue({
level: data.level,
type: String(data.type),
content: params.d.content,
remark: params.d.remark,
knowledge_cascader: cascaderValue,
});
setFormParams(params);
setInit(false);
} catch (err) {
console.error('加载数据失败:', err);
setInit(false);
}
};
// 级联选择器动态加载知识点
const loadKnowledgeData = (selectedOptions: CascaderOption[]) => {
const targetOption = selectedOptions[selectedOptions.length - 1];
targetOption.loading = true;
textbook.getKnowledgeListApi(targetOption.value as number).then((res: any) => {
targetOption.loading = false;
if (res.data && Array.isArray(res.data)) {
targetOption.children = res.data.map((item: any) => ({
value: item.knowledgeCode || item.knowledge_code,
label: item.name,
isLeaf: true,
}));
} else {
targetOption.children = [];
}
setCascaderOptions([...cascaderOptions]);
}).catch((err) => {
console.error('加载知识点失败:', err);
targetOption.loading = false;
targetOption.children = [];
setCascaderOptions([...cascaderOptions]);
});
};
const onFinish = (values: any) => {
if (loading) {
return;
}
const obj: any = formParams;
if (!obj.d.content) {
message.error(t('exam.question.detail.edit.text1'));
return;
}
if (!obj.d.content.text) {
message.error(t('exam.question.detail.edit.text2'));
return;
}
if ((type === '2' || type === '1') && !obj.d.answer) {
message.error(t('exam.question.detail.edit.text3'));
return;
}
if (type === '2' || type === '1') {
let isOk = true;
for (const key in obj.d.options) {
// 判断属性值是否为null
if (obj.d.options[key] === null) {
message.error(
t('exam.question.detail.edit.text4') + key + t('exam.question.detail.edit.text5')
);
isOk = false;
break;
}
if (
obj.d.options[key] &&
!obj.d.options[key].text &&
obj.d.options[key].image.length === 0 &&
obj.d.options[key].audio.length === 0 &&
obj.d.options[key].video.length === 0
) {
message.error(
t('exam.question.detail.edit.text4') + key + t('exam.question.detail.edit.text5')
);
isOk = false;
break;
}
}
if (!isOk) {
return;
}
}
if (type === '2' && obj.d.answer && obj.d.answer.length === 0) {
message.error(t('exam.question.detail.edit.text6'));
return;
}
if (type === '3' && !obj.d.answer) {
message.error(t('exam.question.detail.edit.text7'));
return;
}
if (type === '3' && obj.d.answer && obj.d.answer.items && obj.d.answer.items.length === 0) {
message.error(t('exam.question.detail.edit.text7'));
return;
}
if (type === '3' && obj.d.answer && obj.d.answer.items && obj.d.answer.items.length > 0) {
let isOk = true;
obj.d.answer.items.map((item: any) => {
if (item.items.length === 0) {
isOk = false;
} else {
item.items.map((it: any) => {
if (it === '') {
isOk = false;
}
});
}
});
if (!isOk) {
message.error(t('exam.question.detail.edit.text8'));
return;
}
}
if (type === '4' && obj.d.answer === undefined) {
message.error(t('exam.question.detail.edit.text9'));
return;
}
if (type === '4' && obj.d.answer && obj.d.answer.length === 0) {
message.error(t('exam.question.detail.edit.text3'));
return;
}
if (type === '5' && !obj.d.answer) {
message.error(t('exam.question.detail.edit.text10'));
return;
}
if (type === '5' && obj.d.answer && obj.d.answer.length === 0) {
message.error(t('exam.question.detail.edit.text10'));
return;
}
if (type === '5' && obj.d.answer && obj.d.answer.length > 0) {
let isOk = true;
obj.d.answer.map((item: any) => {
if (item.keywords.length === 0) {
isOk = false;
} else {
item.keywords.map((it: any) => {
if (it === '') {
isOk = false;
}
});
}
});
if (!isOk) {
message.error(t('exam.question.detail.edit.text11'));
return;
}
}
if (type === '6' && !obj.d.children) {
message.error(t('exam.question.detail.edit.text12'));
return;
}
if (type === '6' && obj.d.children === '[]') {
message.error(t('exam.question.detail.edit.text12'));
return;
}
if (type === '6' && obj.d.children) {
const result = JSON.parse(obj.d.children);
let isOk = true;
result.map((item: any) => {
if (!item.raw.d.content) {
isOk = false;
}
if (item.raw.d.content && !item.raw.d.content.text) {
isOk = false;
}
if ((item.type === 1 || item.type === 2 || item.type === 3) && !item.raw.d.answer) {
isOk = false;
}
if (item.type === 2 || item.type === 1) {
for (const key in item.raw.d.options) {
// 判断属性值是否为null
if (item.raw.d.options[key] === null) {
isOk = false;
break;
}
if (
item.raw.d.options[key] &&
!item.raw.d.options[key].text &&
item.raw.d.options[key].image.length === 0 &&
item.raw.d.options[key].audio.length === 0 &&
item.raw.d.options[key].video.length === 0
) {
isOk = false;
break;
}
}
}
if (item.type === 2 && item.raw.d.answer && item.raw.d.answer.length === 0) {
isOk = false;
}
if (
item.type === 3 &&
item.raw.d.answer &&
item.raw.d.answer.items &&
item.raw.d.answer.items.length === 0
) {
isOk = false;
}
if (
item.type === 3 &&
item.raw.d.answer &&
item.raw.d.answer.items &&
item.raw.d.answer.items.length > 0
) {
item.raw.d.answer.items.map((item: any) => {
if (item.items.length === 0) {
isOk = false;
} else {
item.items.map((it: any) => {
if (it === '') {
isOk = false;
}
});
}
});
}
if (item.type === 4 && item.raw.d.answer === undefined) {
isOk = false;
}
if (item.type === 4 && item.raw.d.answer && item.raw.d.answer.length === 0) {
isOk = false;
}
if (item.type === 5 && !item.raw.d.answer) {
isOk = false;
}
if (item.type === 5 && item.raw.d.answer && item.raw.d.answer.length === 0) {
isOk = false;
}
if (item.type === 5 && item.raw.d.answer && item.raw.d.answer.length > 0) {
item.raw.d.answer.map((item: any) => {
if (item.keywords.length === 0) {
isOk = false;
} else {
item.keywords.map((it: any) => {
if (it === '') {
isOk = false;
}
});
}
});
}
});
if (!isOk) {
message.error(t('exam.question.detail.edit.text13'));
return;
}
}
const params = JSON.stringify(formParams);
// 处理知识点:从级联选择器值中提取知识点编码(第二级的值)
let knowledgeCode: string | undefined = undefined;
if (values.knowledge_cascader && values.knowledge_cascader.length > 0) {
const codes = values.knowledge_cascader
.filter((item: any[]) => item && item.length === 2)
.map((item: any[]) => item[1]);
if (codes.length > 0) {
knowledgeCode = codes.join(',');
}
}
setLoading(true);
question.questionUpdate(qid, id, params, values.level, Number(type), knowledgeCode).then((res: any) => {
setLoading(false);
message.success(t('commen.saveSuccess'));
onCancel();
});
};
const onFinishFailed = (errorInfo: any) => {
console.log('Failed:', errorInfo);
};
const changeQuestionContent = (
text: string,
image: number[],
video: number[],
audio: number[]
) => {
const params = {
text: text,
image: image,
video: video,
audio: audio,
};
const obj: any = { ...formParams };
obj.d.content = params;
setFormParams(obj);
form.setFieldsValue({
content: params,
});
};
const changeQuestionRemark = (
text: string,
image: number[],
video: number[],
audio: number[]
) => {
const params = {
text: text,
image: image,
video: video,
audio: audio,
};
const obj: any = { ...formParams };
obj.d.remark = params;
setFormParams(obj);
};
const change = (question: any, list: any) => {
const obj = { ...formParams };
Object.assign(obj.d, question);
setFormParams(obj);
};
const choiceRender = (data: any) => {
if (data.options && data.answer) {
return {
options: data.options,
answer: data.answer,
};
} else {
return null;
}
};
const selectRender = (data: any) => {
if (data.options && data.answer) {
return {
options: data.options,
answer: data.answer,
};
} else {
return null;
}
};
const inputRender = (data: any) => {
if (data.answer) {
return {
answer: data.answer,
};
} else {
return null;
}
};
const judgeRender = (data: any) => {
if (data) {
return {
answer: data.answer,
};
} else {
return null;
}
};
const qaRender = (data: any) => {
if (data.answer) {
return {
answer: data.answer,
};
} else {
return null;
}
};
const capRender = (data: any) => {
if (data.children) {
return {
children: data.children,
};
} else {
return null;
}
};
return (
<>
{open ? (
<Modal
title={t('exam.question.detail.update')}
centered
forceRender
open={true}
width={1000}
onOk={() => form.submit()}
onCancel={() => onCancel()}
maskClosable={false}
okButtonProps={{ loading: loading }}
>
{init && (
<div className="float-left text-center mt-30">
<Spin></Spin>
</div>
)}
{!init && (
<>
<div className="exam-tabs"></div>
<div className="float-left mt-10">
<Form
form={form}
name="question-detail-create"
labelCol={{ span: 2 }}
wrapperCol={{ span: 22 }}
initialValues={{ remember: true }}
onFinish={onFinish}
onFinishFailed={onFinishFailed}
autoComplete="off"
>
<Form.Item
label={t('exam.question.detail.edit.level')}
name="level"
rules={[
{
required: true,
message: t('exam.question.detail.edit.levelPlaceholder'),
},
]}
>
<Radio.Group>
<Radio value={1}>{t('exam.question.level1')}</Radio>
<Radio value={2} style={{ marginLeft: 8 }}>
{t('exam.question.level2')}
</Radio>
<Radio value={3} style={{ marginLeft: 8 }}>
{t('exam.question.level3')}
</Radio>
</Radio.Group>
</Form.Item>
<Form.Item
label="关联知识点"
name="knowledge_cascader"
>
<Cascader
options={cascaderOptions}
loadData={loadKnowledgeData as CascaderProps<CascaderOption>['loadData']}
multiple
maxTagCount="responsive"
placeholder="请选择教材和知识点(可多选)"
style={{ width: '100%' }}
showCheckedStrategy={Cascader.SHOW_CHILD}
showSearch={{
filter: (inputValue: string, path: CascaderOption[]) =>
path.some(
(option) =>
(option.label as string).toLowerCase().indexOf(inputValue.toLowerCase()) > -1
),
}}
/>
</Form.Item>
<Form.Item
label={t('exam.question.detail.edit.name')}
name="content"
labelCol={{ style: { marginTop: 4, marginLeft: 28 } }}
rules={[
{
required: true,
message: t('exam.question.detail.edit.namePlaceholder'),
},
]}
>
<QuestionInput
defautValue={formParams.d.content}
resourceUrl={resourceUrl}
height={40}
placeholder={t('exam.question.questionPlaceholder')}
setContent={(text, image, video, audio) => {
changeQuestionContent(text, image, video, audio);
}}
></QuestionInput>
</Form.Item>
{type === '1' && (
<QChoice
question={choiceRender(formParams.d)}
resourceUrl={resourceUrl}
index={null}
onChange={(question: any, list: any) => change(question, list)}
></QChoice>
)}
{type === '2' && (
<QSelect
question={selectRender(formParams.d)}
index={null}
resourceUrl={resourceUrl}
onChange={(question: any, list: any) => change(question, list)}
></QSelect>
)}
{type === '3' && (
<QInput
question={inputRender(formParams.d)}
index={null}
onChange={(question: any, list: any) => change(question, list)}
></QInput>
)}
{type === '4' && (
<QJudge
question={judgeRender(formParams.d)}
index={null}
onChange={(question: any, list: any) => change(question, list)}
></QJudge>
)}
{type === '5' && (
<QQa
question={qaRender(formParams.d)}
index={null}
onChange={(question: any, list: any) => change(question, list)}
></QQa>
)}
{type === '6' && (
<QCap
question={capRender(formParams.d)}
resourceUrl={resourceUrl}
index={null}
onChange={(question: any, list: any) => change(question, list)}
></QCap>
)}
<Form.Item
label={t('exam.question.detail.edit.remark')}
name="remark"
labelCol={{ style: { marginTop: 4, marginLeft: 39 } }}
>
<QuestionInput
defautValue={formParams.d.remark}
resourceUrl={resourceUrl}
height={120}
placeholder={t('exam.question.questionPlaceholder4')}
setContent={(text, image, video, audio) => {
changeQuestionRemark(text, image, video, audio);
}}
></QuestionInput>
</Form.Item>
</Form>
</div>
</>
)}
</Modal>
) : null}
</>
);
};