Compare commits

...

2 Commits

Author SHA1 Message Date
d61cdd9126 Merge pull request 'feat(exam): 试题关联知识点功能' (#1) from menft into master
Reviewed-on: http://114.55.243.137:6688/dianliang/ai-course/pulls/1
2025-11-29 22:34:29 +08:00
menft
f7e04de5e5 feat(exam): 试题关联知识点功能
- 后端:ExamQuestion 实体新增 knowledge_code 字段存储关联知识点
- 后端:TextbookController 新增教材下拉列表、知识点列表接口
- 后端:KnowledgeController 新增按编码列表查询知识点接口
- 前端:试题创建/编辑页面增加教材-知识点两级级联选择器
- 支持多选知识点,编辑时自动回显已关联知识点
2025-11-29 22:31:42 +08:00
16 changed files with 410 additions and 25 deletions

View File

@ -74,6 +74,7 @@ public class QuestionController {
String content = MapUtils.getString(params, "content");
Integer level = MapUtils.getInteger(params, "level");
Integer type = MapUtils.getInteger(params, "type");
String knowledgeCode = MapUtils.getString(params, "knowledge_code");
ExamQuestionPaginateFilter filter = new ExamQuestionPaginateFilter();
filter.setSortAlgo(sortAlgo);
@ -82,6 +83,7 @@ public class QuestionController {
filter.setContent(content);
filter.setLevel(level);
filter.setType(type);
filter.setKnowledgeCode(knowledgeCode);
if (!backendBus.isSuperAdmin()) { // 非超管只能读取它自己的题库
filter.setAdminIds(backendBus.getSameDataPermissionAdminIds());
@ -125,6 +127,7 @@ public class QuestionController {
String content = MapUtils.getString(params, "content");
Integer level = MapUtils.getInteger(params, "level");
Integer type = MapUtils.getInteger(params, "type");
String knowledgeCode = MapUtils.getString(params, "knowledge_code");
List<ExamQuestionCategory> questionCategories = new ArrayList<>();
if (StringUtil.isNotNull(categoryId)) {
@ -147,6 +150,7 @@ public class QuestionController {
filter.setContent(content);
filter.setLevel(level);
filter.setType(type);
filter.setKnowledgeCode(knowledgeCode);
if (!backendBus.isSuperAdmin()) { // 非超管只能读取它自己的题库
filter.setAdminIds(backendBus.getSameDataPermissionAdminIds());
@ -169,6 +173,7 @@ public class QuestionController {
req.getCategoryId(),
req.getContent().replaceAll(" ", ""),
req.getLevel(),
req.getKnowledgeCode(),
req.getType(),
BCtx.getId());
@ -198,6 +203,7 @@ public class QuestionController {
examQuestion.getId(),
req.getContent().replaceAll(" ", ""),
req.getLevel(),
req.getKnowledgeCode(),
req.getType(),
req.getCategoryId(),
BCtx.getId());

View File

@ -72,4 +72,20 @@ public class KnowledgeController {
knowledgeService.remove(queryWrapper);
return JsonResponse.success();
}
/**
* 根据知识点编码列表获取知识点详情用于编辑回显
* @param codes 逗号分隔的知识点编码
*/
@GetMapping("/byCodes")
public JsonResponse getByCodes(@RequestParam("codes") String codes) {
if (codes == null || codes.trim().isEmpty()) {
return JsonResponse.data(List.of());
}
String[] codeArray = codes.split(",");
LambdaQueryWrapper<Knowledge> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.in(Knowledge::getKnowledgeCode, (Object[]) codeArray);
List<Knowledge> list = knowledgeService.list(queryWrapper);
return JsonResponse.data(list);
}
}

View File

@ -1,7 +1,6 @@
package xyz.playedu.api.controller.backend.jc;
import cn.hutool.core.util.ObjectUtil;
import org.apache.commons.collections4.MapUtils;
import org.apache.commons.lang3.ObjectUtils;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
@ -37,7 +36,10 @@ import xyz.playedu.jc.service.ITextbookService;
import xyz.playedu.jc.service.JCIResourceService;
import xyz.playedu.knowledge.domain.KnowledgeMessages;
import java.util.*;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
@ -273,6 +275,22 @@ public class TextbookController {
return JsonResponse.data(list);
}
/**
* 获取教材下拉选择列表轻量级仅返回id和title
*/
@GetMapping("/selectList")
public JsonResponse selectList() {
List<Textbook> list = textbookService.list();
List<Map<String, Object>> result = new ArrayList<>();
for (Textbook textbook : list) {
Map<String, Object> item = new HashMap<>();
item.put("id", textbook.getId());
item.put("title", textbook.getTitle());
result.add(item);
}
return JsonResponse.data(result);
}
// @GetMapping("/{id}")
// public JsonResponse detail(@PathVariable("id") Integer id) {
// Textbook one = textbookService.getById(id);
@ -347,7 +365,4 @@ public class TextbookController {
textbookService.updateById(textbook);
return JsonResponse.success();
}
}

View File

@ -24,6 +24,9 @@ public class ExamQuestionRequest implements Serializable {
@NotNull(message = "level参数为空")
private Integer level;
@JsonProperty("knowledge_code")
private String knowledgeCode;
@NotNull(message = "type参数为空")
private Integer type;
}

View File

@ -17,6 +17,8 @@ public class ExamQuestionPaginateFilter {
private String categoryId;
private String knowledgeCode;
private List<Integer> adminIds;
private String sortField;

View File

@ -6,7 +6,6 @@ import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import xyz.playedu.framework.tenant.core.db.TenantBaseDO;
import java.util.Date;
import java.util.List;
@ -38,7 +37,7 @@ public class Knowledge extends TenantBaseDO {
private String knowledgeCode;
/** 知识点介绍 */
@TableField("desc")
@TableField("`desc`")
private String desc;
/** 层级 */

View File

@ -47,7 +47,8 @@ public class KnowledgeServiceImpl extends ServiceImpl<KnowledgeMapper, Knowledge
public List<Knowledge> listVo(KnowledgeParam param) {
//获取知识点卡片
LambdaQueryWrapper<Knowledge> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(Knowledge::getBookId, param.getBookId())
// 当bookId不为空时才加条件否则查询所有知识点
queryWrapper.eq(param.getBookId() != null, Knowledge::getBookId, param.getBookId())
.eq(Knowledge::getIsReal,"1").orderByAsc(Knowledge::getOrderNum);
return list(queryWrapper);
}

View File

@ -36,6 +36,10 @@ public class ExamQuestion extends TenantBaseDO {
/** 难度等级:1-4 */
private Integer level;
/** 知识点CODE多个用逗号分隔 */
@JsonProperty("knowledge_code")
private String knowledgeCode;
/** 内容 */
private String content;
@ -78,6 +82,9 @@ public class ExamQuestion extends TenantBaseDO {
&& (this.getLevel() == null
? other.getLevel() == null
: this.getLevel().equals(other.getLevel()))
&& (this.getKnowledgeCode() == null
? other.getKnowledgeCode() == null
: this.getKnowledgeCode().equals(other.getKnowledgeCode()))
&& (this.getContent() == null
? other.getContent() == null
: this.getContent().equals(other.getContent()))
@ -101,6 +108,7 @@ public class ExamQuestion extends TenantBaseDO {
result = prime * result + ((getAdminId() == null) ? 0 : getAdminId().hashCode());
result = prime * result + ((getType() == null) ? 0 : getType().hashCode());
result = prime * result + ((getLevel() == null) ? 0 : getLevel().hashCode());
result = prime * result + ((getKnowledgeCode() == null) ? 0 : getKnowledgeCode().hashCode());
result = prime * result + ((getContent() == null) ? 0 : getContent().hashCode());
result = prime * result + ((getCreatedAt() == null) ? 0 : getCreatedAt().hashCode());
result = prime * result + ((getUpdatedAt() == null) ? 0 : getUpdatedAt().hashCode());
@ -119,6 +127,7 @@ public class ExamQuestion extends TenantBaseDO {
sb.append(", adminId=").append(adminId);
sb.append(", type=").append(type);
sb.append(", level=").append(level);
sb.append(", knowledgeCode=").append(knowledgeCode);
sb.append(", content=").append(content);
sb.append(", createdAt=").append(createdAt);
sb.append(", updatedAt=").append(updatedAt);

View File

@ -20,12 +20,18 @@ public interface ExamQuestionService extends IService<ExamQuestion> {
PaginationResult<ExamQuestion> paginate(int page, int size, ExamQuestionPaginateFilter filter);
Integer create(
Integer categoryId, String content, Integer level, Integer type, Integer adminId);
Integer categoryId,
String content,
Integer level,
String knowledgeCode,
Integer type,
Integer adminId);
void update(
Integer id,
String content,
Integer level,
String knowledgeCode,
Integer type,
Integer categoryId,
Integer adminId);

View File

@ -50,13 +50,19 @@ public class ExamQuestionServiceImpl extends ServiceImpl<ExamQuestionMapper, Exa
@Override
public Integer create(
Integer categoryId, String content, Integer level, Integer type, Integer adminId) {
Integer categoryId,
String content,
Integer level,
String knowledgeCode,
Integer type,
Integer adminId) {
ExamQuestion examQuestion =
new ExamQuestion() {
{
setCategoryId(categoryId);
setAdminId(adminId);
setLevel(level);
setKnowledgeCode(knowledgeCode);
setType(type);
setContent(content);
setCreatedAt(new Date());
@ -72,6 +78,7 @@ public class ExamQuestionServiceImpl extends ServiceImpl<ExamQuestionMapper, Exa
Integer id,
String content,
Integer level,
String knowledgeCode,
Integer type,
Integer categoryId,
Integer adminId) {
@ -82,6 +89,7 @@ public class ExamQuestionServiceImpl extends ServiceImpl<ExamQuestionMapper, Exa
question.setCategoryId(categoryId);
question.setContent(content);
question.setLevel(level);
question.setKnowledgeCode(knowledgeCode);
question.setType(type);
question.setUpdatedAt(new Date());

View File

@ -10,6 +10,7 @@
<result property="adminId" column="admin_id" jdbcType="INTEGER"/>
<result property="type" column="type" jdbcType="INTEGER"/>
<result property="level" column="level" jdbcType="TINYINT"/>
<result property="knowledgeCode" column="knowledge_code" jdbcType="VARCHAR"/>
<result property="content" column="content" jdbcType="VARCHAR"/>
<result property="createdAt" column="created_at" jdbcType="TIMESTAMP"/>
<result property="updatedAt" column="updated_at" jdbcType="TIMESTAMP"/>
@ -18,7 +19,7 @@
<sql id="Base_Column_List">
id,category_id,admin_id,type,
level,content,created_at,
level,knowledge_code,content,created_at,
updated_at,deleted
</sql>
@ -42,6 +43,9 @@
<if test="level != null">
AND `exam_question`.`level` = #{level}
</if>
<if test="knowledgeCode != null and knowledgeCode != ''">
AND FIND_IN_SET(#{knowledgeCode}, `exam_question`.`knowledge_code`)
</if>
</where>
<if test="sortAlgo == 'asc'">
@ -87,6 +91,9 @@
<if test="level != null">
AND `exam_question`.`level` = #{level}
</if>
<if test="knowledgeCode != null and knowledgeCode != ''">
AND FIND_IN_SET(#{knowledgeCode}, `exam_question`.`knowledge_code`)
</if>
</where>
</select>

View File

@ -54,7 +54,8 @@ export function questionList(
sortAlgo: string,
content: string,
level: any,
type: any
type: any,
knowledgeCode?: string
) {
return client.get('/backend/v1/exam/question/index', {
category_id,
@ -65,15 +66,23 @@ export function questionList(
content,
level,
type,
knowledge_code: knowledgeCode,
});
}
export function questionStore(category_id: number, content: string, level: any, type: any) {
export function questionStore(
category_id: number,
content: string,
level: any,
type: any,
knowledge_code?: string
) {
return client.post('/backend/v1/exam/question/create', {
category_id,
content,
level,
type,
knowledge_code,
});
}
@ -86,13 +95,15 @@ export function questionUpdate(
category_id: number,
content: string,
level: any,
type: any
type: any,
knowledge_code?: string
) {
return client.put(`/backend/v1/exam/question/${id}`, {
category_id,
content,
level,
type,
knowledge_code,
});
}

View File

@ -229,6 +229,25 @@ export function EditChapterApi(
});
}
/*
* Knowledge
* */
// 获取知识点列表
export function getKnowledgeListApi(bookId?: number) {
return client.get('/backend/v1/jc/knowledge/list', { bookId: bookId });
}
// 获取教材下拉列表(不分页,用于选择器)
export function getTextbookSelectListApi() {
return client.get('/backend/v1/jc/textbook/selectList', {});
}
// 根据知识点编码列表获取知识点详情(用于编辑回显)
export function getKnowledgeByCodesApi(codes: string) {
return client.get('/backend/v1/jc/knowledge/byCodes', { codes });
}
/*
* resource List
* */

View File

@ -13,7 +13,7 @@ import {
Spin,
message,
} from 'antd';
import { question, resourceCategory } from '../../api';
import { question, resourceCategory, textbook } from '../../api';
import type { ColumnsType } from 'antd/es/table';
import styles from './index.module.less';
import { TreeQuestion } from '../../compenents';
@ -63,6 +63,11 @@ export const AddQuestion = (props: PropsInterface) => {
const [selectedIds, setSelectedIds] = useState<any>([]);
const [selectVideos, setSelectVideos] = useState<any[]>([]);
const [categories, setCategories] = useState<Option[]>([]);
const [textbookList, setTextbookList] = useState<any[]>([]); // 教材列表
const [selectedTextbookId, setSelectedTextbookId] = useState<number | undefined>(undefined); // 选中的教材ID
const [knowledgeCode, setKnowledgeCode] = useState('');
const [knowledgeList, setKnowledgeList] = useState<any[]>([]);
const [knowledgeLoading, setKnowledgeLoading] = useState(false); // 知识点加载状态
const [resourceUrl, setResourceUrl] = useState<ResourceUrlModel>({});
const types = [
{ label: t('exam.question.choice.label2'), value: 1 },
@ -92,10 +97,43 @@ export const AddQuestion = (props: PropsInterface) => {
const initData = async () => {
await getCategory();
await getTextbookList();
await getList();
setInit(false);
};
// 加载教材列表
const getTextbookList = async () => {
try {
const res: any = await textbook.getTextbookSelectListApi();
if (res.data && Array.isArray(res.data)) {
setTextbookList(res.data);
}
} catch (err) {
console.error('加载教材列表失败:', err);
}
};
// 当选择教材时,加载对应的知识点列表
const handleTextbookChange = async (textbookId: number | undefined) => {
setSelectedTextbookId(textbookId);
setKnowledgeCode(''); // 清空已选知识点
setKnowledgeList([]);
if (textbookId) {
setKnowledgeLoading(true);
try {
const res: any = await textbook.getKnowledgeListApi(textbookId);
if (res.data && Array.isArray(res.data)) {
setKnowledgeList(res.data);
}
} catch (err) {
console.error('加载知识点列表失败:', err);
}
setKnowledgeLoading(false);
}
};
const getCategory = async () => {
const res: any = await resourceCategory.resourceCategoryList();
const categories = res.data.categories;
@ -122,7 +160,8 @@ export const AddQuestion = (props: PropsInterface) => {
'',
name,
level.length === 0 ? '' : level,
type.length === 0 ? '' : type
type.length === 0 ? '' : type,
knowledgeCode || undefined
);
setResourceUrl(res.data.resource_url);
const data = res.data.result.data;
@ -152,6 +191,9 @@ export const AddQuestion = (props: PropsInterface) => {
setName('');
setType([]);
setLevel([]);
setSelectedTextbookId(undefined);
setKnowledgeCode('');
setKnowledgeList([]);
setSelectedRowKeys([]);
setSelectedIds([]);
setRefresh(!refresh);
@ -380,6 +422,46 @@ export const AddQuestion = (props: PropsInterface) => {
placeholder={t('exam.question.detail.namePlaceholder2')}
/>
</div>
</div>
<div className="d-flex mb-24">
<div className="d-flex mr-16">
<Typography.Text></Typography.Text>
<Select
style={{ width: 150 }}
placeholder="请选择教材"
value={selectedTextbookId}
allowClear
showSearch
filterOption={(input: string, option: any) =>
(option?.label ?? '').toLowerCase().includes(input.toLowerCase())
}
onChange={handleTextbookChange}
options={textbookList.map((item: any) => ({
label: item.title,
value: item.id,
}))}
/>
</div>
<div className="d-flex mr-16">
<Typography.Text></Typography.Text>
<Select
style={{ width: 150 }}
placeholder={selectedTextbookId ? "请选择知识点" : "请先选择教材"}
value={knowledgeCode || undefined}
disabled={!selectedTextbookId}
loading={knowledgeLoading}
allowClear
showSearch
filterOption={(input: string, option: any) =>
(option?.label ?? '').toLowerCase().includes(input.toLowerCase())
}
onChange={(value: any) => setKnowledgeCode(value || '')}
options={knowledgeList.map((item: any) => ({
label: item.name,
value: item.knowledgeCode || item.knowledge_code,
}))}
/>
</div>
<Button className="mr-16" onClick={resetList}>
{t('commen.reset')}
</Button>

View File

@ -1,7 +1,8 @@
import React, { useState, useEffect } from 'react';
import { Modal, Form, Tabs, Radio, Spin, message } from 'antd';
import { Modal, Form, Tabs, Radio, Spin, message, Cascader } from 'antd';
import type { TabsProps } from 'antd';
import { question } from '../../../../api/index';
import type { CascaderProps } from 'antd';
import { question, textbook } from '../../../../api/index';
import { QuestionInput } from '../../../../compenents';
import { QChoice } from './choice';
import { QSelect } from './select';
@ -11,6 +12,15 @@ 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;
open: boolean;
@ -24,6 +34,7 @@ export const QuestionsDetailCreate: React.FC<PropInterface> = ({ id, open, onCan
const [loading, setLoading] = useState(false);
const [refresh, setRefresh] = useState(false);
const [type, setType] = useState('1');
const [cascaderOptions, setCascaderOptions] = useState<CascaderOption[]>([]); // 级联选择器选项
const [formParams, setFormParams] = useState({
v: 'v1',
d: {
@ -38,6 +49,7 @@ export const QuestionsDetailCreate: React.FC<PropInterface> = ({ id, open, onCan
setType('1');
form.setFieldsValue({
level: 1,
knowledge_cascader: [],
});
setFormParams({
v: 'v1',
@ -46,10 +58,49 @@ export const QuestionsDetailCreate: React.FC<PropInterface> = ({ id, open, onCan
remark: null,
},
});
// 加载教材列表作为级联选择器第一级
textbook.getTextbookSelectListApi().then((res: any) => {
if (res.data && Array.isArray(res.data)) {
const options: CascaderOption[] = res.data.map((item: any) => ({
value: item.id,
label: item.title,
isLeaf: false, // 表示有子节点
}));
setCascaderOptions(options);
}
}).catch((err) => {
console.error('加载教材列表失败:', err);
});
setInit(false);
}
}, [form, open, id]);
// 级联选择器动态加载知识点
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 items: TabsProps['items'] = [
{
key: '1',
@ -289,8 +340,19 @@ export const QuestionsDetailCreate: React.FC<PropInterface> = ({ id, open, onCan
}
}
const params = JSON.stringify(formParams);
// 处理知识点:从级联选择器值中提取知识点编码(第二级的值)
let knowledgeCode: string | undefined = undefined;
if (values.knowledge_cascader && values.knowledge_cascader.length > 0) {
// 级联选择器多选值格式: [[textbook_id, knowledge_code], ...]
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.questionStore(id, params, values.level, Number(type)).then((res: any) => {
question.questionStore(id, params, values.level, Number(type), knowledgeCode).then((res: any) => {
setLoading(false);
message.success(t('commen.saveSuccess'));
onCancel();
@ -413,6 +475,27 @@ export const QuestionsDetailCreate: React.FC<PropInterface> = ({ id, open, onCan
</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"

View File

@ -1,6 +1,7 @@
import React, { useState, useEffect } from 'react';
import { Modal, Form, Radio, Spin, message } from 'antd';
import { question } from '../../../../api/index';
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';
@ -10,6 +11,15 @@ 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;
@ -25,6 +35,7 @@ export const QuestionsDetailUpdate: React.FC<PropInterface> = ({ id, qid, open,
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: {
@ -40,20 +51,96 @@ export const QuestionsDetailUpdate: React.FC<PropInterface> = ({ id, qid, open,
}
}, [form, open, id, qid]);
const getDetail = () => {
question.questionDetail(qid).then((res: any) => {
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]);
});
};
@ -268,8 +355,18 @@ export const QuestionsDetailUpdate: React.FC<PropInterface> = ({ id, qid, open,
}
}
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)).then((res: any) => {
question.questionUpdate(qid, id, params, values.level, Number(type), knowledgeCode).then((res: any) => {
setLoading(false);
message.success(t('commen.saveSuccess'));
onCancel();
@ -438,6 +535,27 @@ export const QuestionsDetailUpdate: React.FC<PropInterface> = ({ id, qid, open,
</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"