- 后端:ExamQuestion 实体新增 knowledge_code 字段存储关联知识点 - 后端:TextbookController 新增教材下拉列表、知识点列表接口 - 后端:KnowledgeController 新增按编码列表查询知识点接口 - 前端:试题创建/编辑页面增加教材-知识点两级级联选择器 - 支持多选知识点,编辑时自动回显已关联知识点
649 lines
20 KiB
TypeScript
649 lines
20 KiB
TypeScript
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}
|
||
</>
|
||
);
|
||
};
|