feat(exam): 随机试卷支持难度与知识点筛选
- 后端新增 /knowledge-codes 接口,根据题库/题型/难度获取知识点列表 - ExamQuestionMapper.xml 添加 level 和 knowledge_code 过滤条件 - IKnowledgeService 新增 getByKnowledgeCodes 批量查询方法 - 前端 random-paper.tsx 各题型增加难度和知识点下拉选择 - 知识点动态加载,支持编辑时回显名称 - 更新前端 CLAUDE.md 文档,补充 httpClient 响应结构说明
This commit is contained in:
parent
f7e04de5e5
commit
f1881865de
@ -39,6 +39,8 @@ import xyz.playedu.common.util.StringUtil;
|
|||||||
import xyz.playedu.exam.constants.ExamConstant;
|
import xyz.playedu.exam.constants.ExamConstant;
|
||||||
import xyz.playedu.exam.domain.*;
|
import xyz.playedu.exam.domain.*;
|
||||||
import xyz.playedu.exam.service.*;
|
import xyz.playedu.exam.service.*;
|
||||||
|
import xyz.playedu.jc.domain.Knowledge;
|
||||||
|
import xyz.playedu.jc.service.IKnowledgeService;
|
||||||
import xyz.playedu.resource.service.ResourceService;
|
import xyz.playedu.resource.service.ResourceService;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@ -62,6 +64,8 @@ public class QuestionController {
|
|||||||
|
|
||||||
@Autowired private ResourceService resourceService;
|
@Autowired private ResourceService resourceService;
|
||||||
|
|
||||||
|
@Autowired private IKnowledgeService knowledgeService;
|
||||||
|
|
||||||
@GetMapping("/index")
|
@GetMapping("/index")
|
||||||
@BackendPermission(slug = BPermissionConstant.EXAM_QUESTION)
|
@BackendPermission(slug = BPermissionConstant.EXAM_QUESTION)
|
||||||
@Log(title = "试题-列表", businessType = BusinessTypeConstant.GET)
|
@Log(title = "试题-列表", businessType = BusinessTypeConstant.GET)
|
||||||
@ -1178,4 +1182,54 @@ public class QuestionController {
|
|||||||
.size());
|
.size());
|
||||||
examQuestionCategoryService.updateById(examQuestionCategory);
|
examQuestionCategoryService.updateById(examQuestionCategory);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据题库ID、题型、难度获取关联的知识点列表(去重)
|
||||||
|
* 用于随机试卷配置时的知识点筛选
|
||||||
|
* 返回格式: [{code: "MATH001", name: "导数与微分"}, ...]
|
||||||
|
*
|
||||||
|
* @author menft
|
||||||
|
*/
|
||||||
|
@GetMapping("/knowledge-codes")
|
||||||
|
@BackendPermission(slug = BPermissionConstant.EXAM_QUESTION)
|
||||||
|
public JsonResponse getKnowledgeCodes(@RequestParam HashMap<String, Object> params) {
|
||||||
|
String categoryIds = MapUtils.getString(params, "category_ids");
|
||||||
|
Integer type = MapUtils.getInteger(params, "type");
|
||||||
|
Integer level = MapUtils.getInteger(params, "level");
|
||||||
|
|
||||||
|
if (StringUtil.isEmpty(categoryIds)) {
|
||||||
|
return JsonResponse.data(List.of());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取去重的知识点编码
|
||||||
|
List<String> knowledgeCodes =
|
||||||
|
examQuestionService.getDistinctKnowledgeCodes(categoryIds, type, level);
|
||||||
|
if (knowledgeCodes.isEmpty()) {
|
||||||
|
return JsonResponse.data(List.of());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查询知识点名称
|
||||||
|
List<Knowledge> knowledgeList = knowledgeService.getByKnowledgeCodes(knowledgeCodes);
|
||||||
|
Map<String, String> codeToName =
|
||||||
|
knowledgeList.stream()
|
||||||
|
.collect(
|
||||||
|
Collectors.toMap(
|
||||||
|
Knowledge::getKnowledgeCode,
|
||||||
|
Knowledge::getName,
|
||||||
|
(v1, v2) -> v1));
|
||||||
|
|
||||||
|
// 构建返回结果
|
||||||
|
List<Map<String, String>> result =
|
||||||
|
knowledgeCodes.stream()
|
||||||
|
.map(
|
||||||
|
code -> {
|
||||||
|
Map<String, String> item = new HashMap<>();
|
||||||
|
item.put("code", code);
|
||||||
|
item.put("name", codeToName.getOrDefault(code, code));
|
||||||
|
return item;
|
||||||
|
})
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
|
return JsonResponse.data(result);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -961,41 +961,53 @@ public class PaperController {
|
|||||||
JSONObject source = randomRules.getJSONObject("source");
|
JSONObject source = randomRules.getJSONObject("source");
|
||||||
List<Integer> categoryIds = source.getBeanList("category_ids", Integer.class);
|
List<Integer> categoryIds = source.getBeanList("category_ids", Integer.class);
|
||||||
filter.setCategoryIds(categoryIds);
|
filter.setCategoryIds(categoryIds);
|
||||||
// 题型、数量
|
// 题型、数量、难度、知识点
|
||||||
JSONObject score = randomRules.getJSONObject("score");
|
JSONObject score = randomRules.getJSONObject("score");
|
||||||
JSONObject type1 = score.getJSONObject(ExamConstant.TYPE_1 + "");
|
JSONObject type1 = score.getJSONObject(ExamConstant.TYPE_1 + "");
|
||||||
if (StringUtil.isNotNull(type1)) {
|
if (StringUtil.isNotNull(type1)) {
|
||||||
filter.setType1Number(type1.getInt("number"));
|
filter.setType1Number(type1.getInt("number"));
|
||||||
|
filter.setType1Level(type1.getInt("level"));
|
||||||
|
filter.setType1KnowledgeCodes(type1.getStr("knowledge_codes"));
|
||||||
} else {
|
} else {
|
||||||
filter.setType1Number(0);
|
filter.setType1Number(0);
|
||||||
}
|
}
|
||||||
JSONObject type2 = score.getJSONObject(ExamConstant.TYPE_2 + "");
|
JSONObject type2 = score.getJSONObject(ExamConstant.TYPE_2 + "");
|
||||||
if (StringUtil.isNotNull(type2)) {
|
if (StringUtil.isNotNull(type2)) {
|
||||||
filter.setType2Number(type2.getInt("number"));
|
filter.setType2Number(type2.getInt("number"));
|
||||||
|
filter.setType2Level(type2.getInt("level"));
|
||||||
|
filter.setType2KnowledgeCodes(type2.getStr("knowledge_codes"));
|
||||||
} else {
|
} else {
|
||||||
filter.setType2Number(0);
|
filter.setType2Number(0);
|
||||||
}
|
}
|
||||||
JSONObject type3 = score.getJSONObject(ExamConstant.TYPE_3 + "");
|
JSONObject type3 = score.getJSONObject(ExamConstant.TYPE_3 + "");
|
||||||
if (StringUtil.isNotNull(type3)) {
|
if (StringUtil.isNotNull(type3)) {
|
||||||
filter.setType3Number(type3.getInt("number"));
|
filter.setType3Number(type3.getInt("number"));
|
||||||
|
filter.setType3Level(type3.getInt("level"));
|
||||||
|
filter.setType3KnowledgeCodes(type3.getStr("knowledge_codes"));
|
||||||
} else {
|
} else {
|
||||||
filter.setType3Number(0);
|
filter.setType3Number(0);
|
||||||
}
|
}
|
||||||
JSONObject type4 = score.getJSONObject(ExamConstant.TYPE_4 + "");
|
JSONObject type4 = score.getJSONObject(ExamConstant.TYPE_4 + "");
|
||||||
if (StringUtil.isNotNull(type4)) {
|
if (StringUtil.isNotNull(type4)) {
|
||||||
filter.setType4Number(type4.getInt("number"));
|
filter.setType4Number(type4.getInt("number"));
|
||||||
|
filter.setType4Level(type4.getInt("level"));
|
||||||
|
filter.setType4KnowledgeCodes(type4.getStr("knowledge_codes"));
|
||||||
} else {
|
} else {
|
||||||
filter.setType4Number(0);
|
filter.setType4Number(0);
|
||||||
}
|
}
|
||||||
JSONObject type5 = score.getJSONObject(ExamConstant.TYPE_5 + "");
|
JSONObject type5 = score.getJSONObject(ExamConstant.TYPE_5 + "");
|
||||||
if (StringUtil.isNotNull(type5)) {
|
if (StringUtil.isNotNull(type5)) {
|
||||||
filter.setType5Number(type5.getInt("number"));
|
filter.setType5Number(type5.getInt("number"));
|
||||||
|
filter.setType5Level(type5.getInt("level"));
|
||||||
|
filter.setType5KnowledgeCodes(type5.getStr("knowledge_codes"));
|
||||||
} else {
|
} else {
|
||||||
filter.setType5Number(0);
|
filter.setType5Number(0);
|
||||||
}
|
}
|
||||||
JSONObject type6 = score.getJSONObject(ExamConstant.TYPE_6 + "");
|
JSONObject type6 = score.getJSONObject(ExamConstant.TYPE_6 + "");
|
||||||
if (StringUtil.isNotNull(type6)) {
|
if (StringUtil.isNotNull(type6)) {
|
||||||
filter.setType6Number(type6.getInt("number"));
|
filter.setType6Number(type6.getInt("number"));
|
||||||
|
filter.setType6Level(type6.getInt("level"));
|
||||||
|
filter.setType6KnowledgeCodes(type6.getStr("knowledge_codes"));
|
||||||
} else {
|
} else {
|
||||||
filter.setType6Number(0);
|
filter.setType6Number(0);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,14 +12,26 @@ public class ExamQuestionFilter {
|
|||||||
private List<Integer> categoryIds;
|
private List<Integer> categoryIds;
|
||||||
|
|
||||||
private Integer type1Number;
|
private Integer type1Number;
|
||||||
|
private Integer type1Level;
|
||||||
|
private String type1KnowledgeCodes;
|
||||||
|
|
||||||
private Integer type2Number;
|
private Integer type2Number;
|
||||||
|
private Integer type2Level;
|
||||||
|
private String type2KnowledgeCodes;
|
||||||
|
|
||||||
private Integer type3Number;
|
private Integer type3Number;
|
||||||
|
private Integer type3Level;
|
||||||
|
private String type3KnowledgeCodes;
|
||||||
|
|
||||||
private Integer type4Number;
|
private Integer type4Number;
|
||||||
|
private Integer type4Level;
|
||||||
|
private String type4KnowledgeCodes;
|
||||||
|
|
||||||
private Integer type5Number;
|
private Integer type5Number;
|
||||||
|
private Integer type5Level;
|
||||||
|
private String type5KnowledgeCodes;
|
||||||
|
|
||||||
private Integer type6Number;
|
private Integer type6Number;
|
||||||
|
private Integer type6Level;
|
||||||
|
private String type6KnowledgeCodes;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -20,5 +20,12 @@ public interface IKnowledgeService extends IService<Knowledge> {
|
|||||||
JSONObject getByIdVo(Integer id);
|
JSONObject getByIdVo(Integer id);
|
||||||
JSONObject stuGetByIdVo(Integer id);
|
JSONObject stuGetByIdVo(Integer id);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据知识点编码列表批量查询知识点
|
||||||
|
*
|
||||||
|
* @param codes 知识点编码列表
|
||||||
|
* @return 知识点列表
|
||||||
|
* @author menft
|
||||||
|
*/
|
||||||
|
List<Knowledge> getByKnowledgeCodes(List<String> codes);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -217,6 +217,16 @@ public class KnowledgeServiceImpl extends ServiceImpl<KnowledgeMapper, Knowledge
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<Knowledge> getByKnowledgeCodes(List<String> codes) {
|
||||||
|
if (codes == null || codes.isEmpty()) {
|
||||||
|
return new ArrayList<>();
|
||||||
|
}
|
||||||
|
LambdaQueryWrapper<Knowledge> queryWrapper = new LambdaQueryWrapper<>();
|
||||||
|
queryWrapper.in(Knowledge::getKnowledgeCode, codes);
|
||||||
|
return list(queryWrapper);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 递增编码
|
* 递增编码
|
||||||
* @param code 当前编码
|
* @param code 当前编码
|
||||||
|
|||||||
@ -22,4 +22,6 @@ public interface ExamQuestionMapper extends BaseMapper<ExamQuestion> {
|
|||||||
Long paginateCount(ExamQuestionPaginateFilter filter);
|
Long paginateCount(ExamQuestionPaginateFilter filter);
|
||||||
|
|
||||||
List<ExamQuestion> chunksByCategoryIdAndLimit(ExamQuestionFilter filter);
|
List<ExamQuestion> chunksByCategoryIdAndLimit(ExamQuestionFilter filter);
|
||||||
|
|
||||||
|
List<String> getDistinctKnowledgeCodes(String categoryIds, Integer type, Integer level);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -43,4 +43,11 @@ public interface ExamQuestionService extends IService<ExamQuestion> {
|
|||||||
List<ExamQuestion> chunksByCategoryId(Integer categoryId);
|
List<ExamQuestion> chunksByCategoryId(Integer categoryId);
|
||||||
|
|
||||||
List<ExamQuestion> chunksByCategoryIdAndLimit(ExamQuestionFilter filter);
|
List<ExamQuestion> chunksByCategoryIdAndLimit(ExamQuestionFilter filter);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据题库ID、题型、难度获取去重的知识点编码列表
|
||||||
|
*
|
||||||
|
* @author menft
|
||||||
|
*/
|
||||||
|
List<String> getDistinctKnowledgeCodes(String categoryIds, Integer type, Integer level);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -116,4 +116,22 @@ public class ExamQuestionServiceImpl extends ServiceImpl<ExamQuestionMapper, Exa
|
|||||||
public List<ExamQuestion> chunksByCategoryIdAndLimit(ExamQuestionFilter filter) {
|
public List<ExamQuestion> chunksByCategoryIdAndLimit(ExamQuestionFilter filter) {
|
||||||
return getBaseMapper().chunksByCategoryIdAndLimit(filter);
|
return getBaseMapper().chunksByCategoryIdAndLimit(filter);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<String> getDistinctKnowledgeCodes(String categoryIds, Integer type, Integer level) {
|
||||||
|
// 获取所有 knowledge_code 字段(可能是逗号分隔的多个值)
|
||||||
|
List<String> rawCodes = getBaseMapper().getDistinctKnowledgeCodes(categoryIds, type, level);
|
||||||
|
if (rawCodes == null || rawCodes.isEmpty()) {
|
||||||
|
return new ArrayList<>();
|
||||||
|
}
|
||||||
|
// 拆分并去重
|
||||||
|
return rawCodes.stream()
|
||||||
|
.filter(code -> code != null && !code.isEmpty())
|
||||||
|
.flatMap(code -> java.util.Arrays.stream(code.split(",")))
|
||||||
|
.map(String::trim)
|
||||||
|
.filter(code -> !code.isEmpty())
|
||||||
|
.distinct()
|
||||||
|
.sorted()
|
||||||
|
.collect(java.util.stream.Collectors.toList());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -107,6 +107,16 @@
|
|||||||
#{categoryId}
|
#{categoryId}
|
||||||
</foreach>
|
</foreach>
|
||||||
</if>
|
</if>
|
||||||
|
<if test="type1Level != null">
|
||||||
|
and `exam_question`.`level` = #{type1Level}
|
||||||
|
</if>
|
||||||
|
<if test="type1KnowledgeCodes != null and type1KnowledgeCodes != ''">
|
||||||
|
and (
|
||||||
|
<foreach collection="type1KnowledgeCodes.split(',')" item="code" separator=" OR ">
|
||||||
|
FIND_IN_SET(#{code}, `exam_question`.`knowledge_code`)
|
||||||
|
</foreach>
|
||||||
|
)
|
||||||
|
</if>
|
||||||
</where>
|
</where>
|
||||||
ORDER BY RAND()
|
ORDER BY RAND()
|
||||||
LIMIT #{type1Number})
|
LIMIT #{type1Number})
|
||||||
@ -122,6 +132,16 @@
|
|||||||
#{categoryId}
|
#{categoryId}
|
||||||
</foreach>
|
</foreach>
|
||||||
</if>
|
</if>
|
||||||
|
<if test="type2Level != null">
|
||||||
|
and `exam_question`.`level` = #{type2Level}
|
||||||
|
</if>
|
||||||
|
<if test="type2KnowledgeCodes != null and type2KnowledgeCodes != ''">
|
||||||
|
and (
|
||||||
|
<foreach collection="type2KnowledgeCodes.split(',')" item="code" separator=" OR ">
|
||||||
|
FIND_IN_SET(#{code}, `exam_question`.`knowledge_code`)
|
||||||
|
</foreach>
|
||||||
|
)
|
||||||
|
</if>
|
||||||
</where>
|
</where>
|
||||||
ORDER BY RAND()
|
ORDER BY RAND()
|
||||||
LIMIT #{type2Number})
|
LIMIT #{type2Number})
|
||||||
@ -137,6 +157,16 @@
|
|||||||
#{categoryId}
|
#{categoryId}
|
||||||
</foreach>
|
</foreach>
|
||||||
</if>
|
</if>
|
||||||
|
<if test="type3Level != null">
|
||||||
|
and `exam_question`.`level` = #{type3Level}
|
||||||
|
</if>
|
||||||
|
<if test="type3KnowledgeCodes != null and type3KnowledgeCodes != ''">
|
||||||
|
and (
|
||||||
|
<foreach collection="type3KnowledgeCodes.split(',')" item="code" separator=" OR ">
|
||||||
|
FIND_IN_SET(#{code}, `exam_question`.`knowledge_code`)
|
||||||
|
</foreach>
|
||||||
|
)
|
||||||
|
</if>
|
||||||
</where>
|
</where>
|
||||||
ORDER BY RAND()
|
ORDER BY RAND()
|
||||||
LIMIT #{type3Number})
|
LIMIT #{type3Number})
|
||||||
@ -152,6 +182,16 @@
|
|||||||
#{categoryId}
|
#{categoryId}
|
||||||
</foreach>
|
</foreach>
|
||||||
</if>
|
</if>
|
||||||
|
<if test="type4Level != null">
|
||||||
|
and `exam_question`.`level` = #{type4Level}
|
||||||
|
</if>
|
||||||
|
<if test="type4KnowledgeCodes != null and type4KnowledgeCodes != ''">
|
||||||
|
and (
|
||||||
|
<foreach collection="type4KnowledgeCodes.split(',')" item="code" separator=" OR ">
|
||||||
|
FIND_IN_SET(#{code}, `exam_question`.`knowledge_code`)
|
||||||
|
</foreach>
|
||||||
|
)
|
||||||
|
</if>
|
||||||
</where>
|
</where>
|
||||||
ORDER BY RAND()
|
ORDER BY RAND()
|
||||||
LIMIT #{type4Number})
|
LIMIT #{type4Number})
|
||||||
@ -167,6 +207,16 @@
|
|||||||
#{categoryId}
|
#{categoryId}
|
||||||
</foreach>
|
</foreach>
|
||||||
</if>
|
</if>
|
||||||
|
<if test="type5Level != null">
|
||||||
|
and `exam_question`.`level` = #{type5Level}
|
||||||
|
</if>
|
||||||
|
<if test="type5KnowledgeCodes != null and type5KnowledgeCodes != ''">
|
||||||
|
and (
|
||||||
|
<foreach collection="type5KnowledgeCodes.split(',')" item="code" separator=" OR ">
|
||||||
|
FIND_IN_SET(#{code}, `exam_question`.`knowledge_code`)
|
||||||
|
</foreach>
|
||||||
|
)
|
||||||
|
</if>
|
||||||
</where>
|
</where>
|
||||||
ORDER BY RAND()
|
ORDER BY RAND()
|
||||||
LIMIT #{type5Number})
|
LIMIT #{type5Number})
|
||||||
@ -182,8 +232,36 @@
|
|||||||
#{categoryId}
|
#{categoryId}
|
||||||
</foreach>
|
</foreach>
|
||||||
</if>
|
</if>
|
||||||
|
<if test="type6Level != null">
|
||||||
|
and `exam_question`.`level` = #{type6Level}
|
||||||
|
</if>
|
||||||
|
<if test="type6KnowledgeCodes != null and type6KnowledgeCodes != ''">
|
||||||
|
and (
|
||||||
|
<foreach collection="type6KnowledgeCodes.split(',')" item="code" separator=" OR ">
|
||||||
|
FIND_IN_SET(#{code}, `exam_question`.`knowledge_code`)
|
||||||
|
</foreach>
|
||||||
|
)
|
||||||
|
</if>
|
||||||
</where>
|
</where>
|
||||||
ORDER BY RAND()
|
ORDER BY RAND()
|
||||||
LIMIT #{type6Number})
|
LIMIT #{type6Number})
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
|
<select id="getDistinctKnowledgeCodes" resultType="java.lang.String">
|
||||||
|
SELECT DISTINCT `knowledge_code`
|
||||||
|
FROM `exam_question`
|
||||||
|
<where>
|
||||||
|
AND `knowledge_code` IS NOT NULL
|
||||||
|
AND `knowledge_code` != ''
|
||||||
|
<if test="categoryIds != null and categoryIds != ''">
|
||||||
|
AND `category_id` IN (${categoryIds})
|
||||||
|
</if>
|
||||||
|
<if test="type != null">
|
||||||
|
AND `type` = #{type}
|
||||||
|
</if>
|
||||||
|
<if test="level != null">
|
||||||
|
AND `level` = #{level}
|
||||||
|
</if>
|
||||||
|
</where>
|
||||||
|
</select>
|
||||||
</mapper>
|
</mapper>
|
||||||
|
|||||||
207
app/backend/CLAUDE.md
Normal file
207
app/backend/CLAUDE.md
Normal file
@ -0,0 +1,207 @@
|
|||||||
|
[根目录](../../CLAUDE.md) > **app/backend**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 管理后台前端(PlayEdu Backend)
|
||||||
|
|
||||||
|
## 变更记录 (Changelog)
|
||||||
|
|
||||||
|
### 2025-11-24 22:45:26
|
||||||
|
- 初始化模块文档
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 模块职责
|
||||||
|
|
||||||
|
PlayEdu 管理后台是供平台管理员使用的 Web 应用,提供:
|
||||||
|
- 课程管理(线上课程、线下课程、实验课程、软件模块)
|
||||||
|
- 教材与知识点管理
|
||||||
|
- 考试与题库管理
|
||||||
|
- 学员与部门管理
|
||||||
|
- 资源库管理(视频、文档、音频、图片、附件)
|
||||||
|
- AI 知识库管理
|
||||||
|
- 系统配置与权限管理
|
||||||
|
- 数据统计与日志查看
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 入口与启动
|
||||||
|
|
||||||
|
**主入口文件**:`src/main.tsx`
|
||||||
|
|
||||||
|
**启动命令**:
|
||||||
|
```bash
|
||||||
|
cd app/backend
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
**构建命令**:
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
**开发服务器**:`http://localhost:5173`(Vite 默认端口)
|
||||||
|
|
||||||
|
**生产构建输出**:`dist/`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 对外接口
|
||||||
|
|
||||||
|
### API 通信
|
||||||
|
- **API Base URL**:配置在 `src/api/internal/httpClient.ts`
|
||||||
|
- **认证方式**:Bearer Token(存储在 localStorage)
|
||||||
|
|
||||||
|
### ⚠️ httpClient 响应数据结构(重要)
|
||||||
|
|
||||||
|
**httpClient 已经解包了一层**,调用 `client.get/post/put` 返回的是 `response.data`,即:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 后端原始响应
|
||||||
|
{ "code": 0, "msg": "", "data": [...] }
|
||||||
|
|
||||||
|
// httpClient.get() 返回的 res 就是上面这个对象
|
||||||
|
const res = await client.get('/api/xxx');
|
||||||
|
// res = { code: 0, msg: "", data: [...] }
|
||||||
|
// res.data = [...] ✅ 正确
|
||||||
|
// res.data.data ❌ 错误!多解了一层
|
||||||
|
```
|
||||||
|
|
||||||
|
**正确用法**:
|
||||||
|
```typescript
|
||||||
|
const res: any = await someApi.getData();
|
||||||
|
const items = res.data || []; // ✅ 直接用 res.data
|
||||||
|
```
|
||||||
|
|
||||||
|
**错误用法**:
|
||||||
|
```typescript
|
||||||
|
const res: any = await someApi.getData();
|
||||||
|
const items = res.data.data || []; // ❌ 多解了一层,永远是 undefined
|
||||||
|
```
|
||||||
|
|
||||||
|
### 主要 API 模块(位于 `src/api/`):
|
||||||
|
- `login.ts` - 管理员登录与认证
|
||||||
|
- `course.ts` - 课程管理
|
||||||
|
- `paper.ts` / `question.ts` - 考试与题库
|
||||||
|
- `user.ts` / `department.ts` - 学员与部门
|
||||||
|
- `resource.ts` / `resource-category.ts` - 资源管理
|
||||||
|
- `knowledge-*.ts` - AI 知识库
|
||||||
|
- `system.ts` / `app-config.ts` - 系统配置
|
||||||
|
- `upload.ts` - 文件上传(支持 S3/OSS)
|
||||||
|
|
||||||
|
### 路由结构
|
||||||
|
- **公开路由**:`/login` - 登录页
|
||||||
|
- **管理员路由**(需认证):
|
||||||
|
- `/dashboard` - 数据看板
|
||||||
|
- `/course/*` - 课程管理
|
||||||
|
- `/offline-course/*` - 线下课程
|
||||||
|
- `/lab-course/*` - 实验课程
|
||||||
|
- `/textbook/*` - 教材管理
|
||||||
|
- `/exam/*` - 考试管理
|
||||||
|
- `/question/*` - 题库管理
|
||||||
|
- `/resource/*` - 资源库
|
||||||
|
- `/repository/*` - 仓库管理
|
||||||
|
- `/knowledge/*` - AI 知识库
|
||||||
|
- `/user/*` - 学员管理
|
||||||
|
- `/department/*` - 部门管理
|
||||||
|
- `/system/*` - 系统配置
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 关键依赖与配置
|
||||||
|
|
||||||
|
### 技术栈
|
||||||
|
- **框架**:React 18.2 + TypeScript 4.9
|
||||||
|
- **构建工具**:Vite 7.1.3
|
||||||
|
- **UI 库**:Ant Design 5.12.2
|
||||||
|
- **状态管理**:Redux Toolkit + React-Redux
|
||||||
|
- **路由**:React Router DOM 6.9
|
||||||
|
- **HTTP 客户端**:Axios 1.3.4
|
||||||
|
- **富文本编辑器**:Quill 2.0.3 + Braft Editor
|
||||||
|
- **图表**:ECharts 5.4.2 + ECharts for React
|
||||||
|
- **视频播放器**:XGPlayer 3.0.13 + HLS 支持
|
||||||
|
- **文件上传**:Uppy 4.x(支持 AWS S3)
|
||||||
|
- **国际化**:i18next + react-i18next
|
||||||
|
- **工具库**:Day.js、Moment.js、Lodash、XLSX、FileSaver
|
||||||
|
|
||||||
|
### 配置文件
|
||||||
|
- `package.json` - 依赖与脚本定义
|
||||||
|
- `vite.config.ts` - Vite 构建配置(未找到,可能使用默认配置)
|
||||||
|
- `tsconfig.json` - TypeScript 配置
|
||||||
|
- `eslint.config.js` - ESLint 配置(使用 Prettier 集成)
|
||||||
|
|
||||||
|
### 代码规范工具
|
||||||
|
- ESLint 9.x(@typescript-eslint/eslint-plugin)
|
||||||
|
- Prettier 3.6.2
|
||||||
|
- 格式化命令:`npm run format`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 数据模型
|
||||||
|
|
||||||
|
### Redux Store 结构(`src/store/`)
|
||||||
|
- `user.ts` / `userSlice` - 当前管理员信息
|
||||||
|
- `system.ts` / `systemSlice` - 系统配置(API URL、logo、名称等)
|
||||||
|
- `resource.ts` / `resourceSlice` - 资源 URL 映射
|
||||||
|
|
||||||
|
### 本地存储(localStorage)
|
||||||
|
- `token` - 认证 Token
|
||||||
|
- `api_url` - API 服务器地址
|
||||||
|
- `system_name` - 系统名称
|
||||||
|
- `language` - 界面语言(zh-CN / zh-TC)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 测试与质量
|
||||||
|
|
||||||
|
- **代码检查**:ESLint(`npm run lint` / `npm run lint:quiet`)
|
||||||
|
- **代码格式化**:Prettier(`npm run format`)
|
||||||
|
- **类型检查**:TypeScript 严格模式
|
||||||
|
- **浏览器兼容性**:通过 Vite 自动处理 polyfills
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 常见问题 (FAQ)
|
||||||
|
|
||||||
|
### Q1:如何添加新的管理页面?
|
||||||
|
1. 在 `src/pages/` 下创建新页面组件
|
||||||
|
2. 在路由配置中添加路由(通常在 `App.tsx` 或独立的路由文件中)
|
||||||
|
3. 添加对应的 API 调用(在 `src/api/` 中)
|
||||||
|
4. 更新导航菜单(如果需要)
|
||||||
|
|
||||||
|
### Q2:如何配置 API 服务器地址?
|
||||||
|
- 开发环境:在 `src/api/internal/httpClient.ts` 中配置 `baseURL`
|
||||||
|
- 生产环境:通过环境变量或运行时配置
|
||||||
|
|
||||||
|
### Q3:文件上传如何工作?
|
||||||
|
- 使用 Uppy 组件(`@uppy/react`)
|
||||||
|
- 支持直传到 S3/OSS(通过 `@uppy/aws-s3`)
|
||||||
|
- 上传接口:`POST /backend/v1/upload/*`
|
||||||
|
|
||||||
|
### Q4:如何支持国际化?
|
||||||
|
- 配置文件:`src/i18n/config.ts`
|
||||||
|
- 语言包:`src/i18n/locales/` 目录
|
||||||
|
- 使用 `useTranslation()` Hook 进行翻译
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 相关文件清单
|
||||||
|
|
||||||
|
### 配置文件
|
||||||
|
- `app/backend/package.json` - NPM 依赖配置
|
||||||
|
- `app/backend/README.md` - 模块说明
|
||||||
|
|
||||||
|
### 核心代码
|
||||||
|
- `src/main.tsx` - 应用入口
|
||||||
|
- `src/App.tsx` - 根组件与路由配置
|
||||||
|
- `src/api/` - API 接口定义
|
||||||
|
- `src/store/` - Redux 状态管理
|
||||||
|
- `src/pages/` - 页面组件
|
||||||
|
- `src/compenents/` - 可复用组件
|
||||||
|
- `src/assets/` - 静态资源(图片、字体、样式)
|
||||||
|
|
||||||
|
### 样式文件
|
||||||
|
- `src/index.less` - 全局样式
|
||||||
|
- `src/App.module.less` - 根组件样式
|
||||||
|
- 组件样式:`*.module.less` / `*.module.scss`
|
||||||
@ -134,3 +134,17 @@ export function storeBatch(categoryId: number, startLine: number, questions: str
|
|||||||
export function uploadTxt(params: any) {
|
export function uploadTxt(params: any) {
|
||||||
return client.post('/backend/v1/exam/question/import', params);
|
return client.post('/backend/v1/exam/question/import', params);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据题库ID、题型、难度获取关联的知识点编码列表
|
||||||
|
* @param categoryIds 题库ID列表(逗号分隔)
|
||||||
|
* @param type 题型
|
||||||
|
* @param level 难度
|
||||||
|
*/
|
||||||
|
export function getKnowledgeCodes(categoryIds: string, type?: number, level?: number | null) {
|
||||||
|
return client.get('/backend/v1/exam/question/knowledge-codes', {
|
||||||
|
category_ids: categoryIds,
|
||||||
|
type: type,
|
||||||
|
level: level,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import styles from './paper.module.less';
|
import styles from './paper.module.less';
|
||||||
import { message, Button, Table, InputNumber, Spin, Modal } from 'antd';
|
import { message, Button, Table, InputNumber, Spin, Modal, Select } from 'antd';
|
||||||
import { paper } from '../../../../api';
|
import { paper, question } from '../../../../api';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import { PlusOutlined } from '@ant-design/icons';
|
import { PlusOutlined } from '@ant-design/icons';
|
||||||
import { useLocation, useNavigate } from 'react-router-dom';
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
@ -10,6 +10,14 @@ import { ExclamationCircleFilled } from '@ant-design/icons';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
const { confirm } = Modal;
|
const { confirm } = Modal;
|
||||||
|
|
||||||
|
// 难度选项
|
||||||
|
const levelOptions = [
|
||||||
|
{ value: null, label: '不限' },
|
||||||
|
{ value: 1, label: '简单' },
|
||||||
|
{ value: 2, label: '中等' },
|
||||||
|
{ value: 3, label: '困难' },
|
||||||
|
];
|
||||||
|
|
||||||
interface DataType {
|
interface DataType {
|
||||||
id: React.Key;
|
id: React.Key;
|
||||||
admin_id: number;
|
admin_id: number;
|
||||||
@ -61,28 +69,49 @@ export const RendomPaper: React.FC<PropInterface> = ({ type }) => {
|
|||||||
const [choice, setChoice] = useState({
|
const [choice, setChoice] = useState({
|
||||||
number: 0,
|
number: 0,
|
||||||
score: 0,
|
score: 0,
|
||||||
|
level: null as number | null,
|
||||||
|
knowledge_codes: '' as string,
|
||||||
});
|
});
|
||||||
const [select, setSelect] = useState({
|
const [select, setSelect] = useState({
|
||||||
number: 0,
|
number: 0,
|
||||||
score: 0,
|
score: 0,
|
||||||
missed_score: 0,
|
missed_score: 0,
|
||||||
|
level: null as number | null,
|
||||||
|
knowledge_codes: '' as string,
|
||||||
});
|
});
|
||||||
const [input, setInput] = useState({
|
const [input, setInput] = useState({
|
||||||
number: 0,
|
number: 0,
|
||||||
score: 0,
|
score: 0,
|
||||||
|
level: null as number | null,
|
||||||
|
knowledge_codes: '' as string,
|
||||||
});
|
});
|
||||||
const [judge, setJudge] = useState({
|
const [judge, setJudge] = useState({
|
||||||
number: 0,
|
number: 0,
|
||||||
score: 0,
|
score: 0,
|
||||||
|
level: null as number | null,
|
||||||
|
knowledge_codes: '' as string,
|
||||||
});
|
});
|
||||||
const [qa, setQa] = useState({
|
const [qa, setQa] = useState({
|
||||||
number: 0,
|
number: 0,
|
||||||
score: 0,
|
score: 0,
|
||||||
|
level: null as number | null,
|
||||||
|
knowledge_codes: '' as string,
|
||||||
});
|
});
|
||||||
const [cap, setCap] = useState({
|
const [cap, setCap] = useState({
|
||||||
number: 0,
|
number: 0,
|
||||||
score: 0,
|
score: 0,
|
||||||
missed_score: 0,
|
missed_score: 0,
|
||||||
|
level: null as number | null,
|
||||||
|
knowledge_codes: '' as string,
|
||||||
|
});
|
||||||
|
// 各题型的知识点选项
|
||||||
|
const [knowledgeOptions, setKnowledgeOptions] = useState<Record<number, any[]>>({
|
||||||
|
1: [],
|
||||||
|
2: [],
|
||||||
|
3: [],
|
||||||
|
4: [],
|
||||||
|
5: [],
|
||||||
|
6: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -91,6 +120,24 @@ export const RendomPaper: React.FC<PropInterface> = ({ type }) => {
|
|||||||
setId(Number(result.get('id')));
|
setId(Number(result.get('id')));
|
||||||
}, [result.get('cid'), result.get('title'), result.get('id')]);
|
}, [result.get('cid'), result.get('title'), result.get('id')]);
|
||||||
|
|
||||||
|
// 根据题库、题型、难度加载知识点列表
|
||||||
|
const loadKnowledgeCodes = async (type: number, level: number | null) => {
|
||||||
|
if (questions.length === 0) return;
|
||||||
|
try {
|
||||||
|
const res: any = await question.getKnowledgeCodes(questions.join(','), type, level);
|
||||||
|
// httpClient.get 已经 resolve(res.data),所以 res = {code, msg, data}
|
||||||
|
const items = res.data || [];
|
||||||
|
// 后端返回 [{code: "xxx", name: "yyy"}, ...]
|
||||||
|
const options = items.map((item: { code: string; name: string }) => ({
|
||||||
|
value: item.code,
|
||||||
|
label: item.name,
|
||||||
|
}));
|
||||||
|
setKnowledgeOptions((prev) => ({ ...prev, [type]: options }));
|
||||||
|
} catch (e) {
|
||||||
|
console.error('加载知识点失败', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (id === 0) {
|
if (id === 0) {
|
||||||
return;
|
return;
|
||||||
@ -187,6 +234,8 @@ export const RendomPaper: React.FC<PropInterface> = ({ type }) => {
|
|||||||
setChoice({
|
setChoice({
|
||||||
number: score[1].number,
|
number: score[1].number,
|
||||||
score: score[1].score,
|
score: score[1].score,
|
||||||
|
level: score[1].level || null,
|
||||||
|
knowledge_codes: score[1].knowledge_codes || '',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (score[2]) {
|
if (score[2]) {
|
||||||
@ -194,24 +243,32 @@ export const RendomPaper: React.FC<PropInterface> = ({ type }) => {
|
|||||||
number: score[2].number,
|
number: score[2].number,
|
||||||
score: score[2].score,
|
score: score[2].score,
|
||||||
missed_score: score[2].missed_score,
|
missed_score: score[2].missed_score,
|
||||||
|
level: score[2].level || null,
|
||||||
|
knowledge_codes: score[2].knowledge_codes || '',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (score[3]) {
|
if (score[3]) {
|
||||||
setInput({
|
setInput({
|
||||||
number: score[3].number,
|
number: score[3].number,
|
||||||
score: score[3].score,
|
score: score[3].score,
|
||||||
|
level: score[3].level || null,
|
||||||
|
knowledge_codes: score[3].knowledge_codes || '',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (score[4]) {
|
if (score[4]) {
|
||||||
setJudge({
|
setJudge({
|
||||||
number: score[4].number,
|
number: score[4].number,
|
||||||
score: score[4].score,
|
score: score[4].score,
|
||||||
|
level: score[4].level || null,
|
||||||
|
knowledge_codes: score[4].knowledge_codes || '',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (score[5]) {
|
if (score[5]) {
|
||||||
setQa({
|
setQa({
|
||||||
number: score[5].number,
|
number: score[5].number,
|
||||||
score: score[5].score,
|
score: score[5].score,
|
||||||
|
level: score[5].level || null,
|
||||||
|
knowledge_codes: score[5].knowledge_codes || '',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (score[6]) {
|
if (score[6]) {
|
||||||
@ -219,10 +276,33 @@ export const RendomPaper: React.FC<PropInterface> = ({ type }) => {
|
|||||||
number: score[6].number,
|
number: score[6].number,
|
||||||
score: score[6].score,
|
score: score[6].score,
|
||||||
missed_score: 0,
|
missed_score: 0,
|
||||||
|
level: score[6].level || null,
|
||||||
|
knowledge_codes: score[6].knowledge_codes || '',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
setSpinInit(false);
|
setSpinInit(false);
|
||||||
setInit(false);
|
setInit(false);
|
||||||
|
|
||||||
|
// 预加载各题型的知识点选项(用于回显)
|
||||||
|
if (arr.length > 0) {
|
||||||
|
const categoryIds = arr.join(',');
|
||||||
|
[1, 2, 3, 4, 5, 6].forEach((type) => {
|
||||||
|
const typeScore = score[type];
|
||||||
|
if (typeScore && typeScore.knowledge_codes) {
|
||||||
|
question
|
||||||
|
.getKnowledgeCodes(categoryIds, type, typeScore.level || null)
|
||||||
|
.then((res: any) => {
|
||||||
|
const items = res.data || [];
|
||||||
|
const options = items.map((item: { code: string; name: string }) => ({
|
||||||
|
value: item.code,
|
||||||
|
label: item.name,
|
||||||
|
}));
|
||||||
|
setKnowledgeOptions((prev) => ({ ...prev, [type]: options }));
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -609,6 +689,42 @@ export const RendomPaper: React.FC<PropInterface> = ({ type }) => {
|
|||||||
></InputNumber>
|
></InputNumber>
|
||||||
{t('exam.paper.compose.text4')}
|
{t('exam.paper.compose.text4')}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="d-flex ml-30">
|
||||||
|
难度
|
||||||
|
<Select
|
||||||
|
style={{ width: 100, marginLeft: 8 }}
|
||||||
|
size="large"
|
||||||
|
options={levelOptions}
|
||||||
|
value={choice.level}
|
||||||
|
onChange={(value) => {
|
||||||
|
const obj = { ...choice };
|
||||||
|
obj.level = value;
|
||||||
|
obj.knowledge_codes = '';
|
||||||
|
setChoice(obj);
|
||||||
|
loadKnowledgeCodes(1, value);
|
||||||
|
}}
|
||||||
|
allowClear
|
||||||
|
placeholder="不限"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="d-flex ml-30">
|
||||||
|
知识点
|
||||||
|
<Select
|
||||||
|
style={{ width: 200, marginLeft: 8 }}
|
||||||
|
size="large"
|
||||||
|
mode="multiple"
|
||||||
|
options={knowledgeOptions[1]}
|
||||||
|
value={choice.knowledge_codes ? choice.knowledge_codes.split(',') : []}
|
||||||
|
onChange={(values: string[]) => {
|
||||||
|
const obj = { ...choice };
|
||||||
|
obj.knowledge_codes = values.join(',');
|
||||||
|
setChoice(obj);
|
||||||
|
}}
|
||||||
|
maxTagCount="responsive"
|
||||||
|
placeholder="不限"
|
||||||
|
onFocus={() => loadKnowledgeCodes(1, choice.level)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="d-flex">
|
<div className="d-flex">
|
||||||
{t('exam.paper.compose.text1')}
|
{t('exam.paper.compose.text1')}
|
||||||
@ -619,7 +735,7 @@ export const RendomPaper: React.FC<PropInterface> = ({ type }) => {
|
|||||||
)}
|
)}
|
||||||
{q2 > 0 && (
|
{q2 > 0 && (
|
||||||
<div className={styles['config-item']}>
|
<div className={styles['config-item']}>
|
||||||
<div className="d-flex">
|
<div className="d-flex" style={{ flexWrap: 'wrap', gap: '8px 0' }}>
|
||||||
<div className={styles['label']}>
|
<div className={styles['label']}>
|
||||||
<span className="c-red">*</span>
|
<span className="c-red">*</span>
|
||||||
{t('exam.question.select.label')}({t('exam.paper.compose.text1')}
|
{t('exam.question.select.label')}({t('exam.paper.compose.text1')}
|
||||||
@ -679,6 +795,42 @@ export const RendomPaper: React.FC<PropInterface> = ({ type }) => {
|
|||||||
></InputNumber>
|
></InputNumber>
|
||||||
{t('exam.paper.compose.text5')}
|
{t('exam.paper.compose.text5')}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="d-flex ml-30">
|
||||||
|
难度
|
||||||
|
<Select
|
||||||
|
style={{ width: 100, marginLeft: 8 }}
|
||||||
|
size="large"
|
||||||
|
options={levelOptions}
|
||||||
|
value={select.level}
|
||||||
|
onChange={(value) => {
|
||||||
|
const obj = { ...select };
|
||||||
|
obj.level = value;
|
||||||
|
obj.knowledge_codes = '';
|
||||||
|
setSelect(obj);
|
||||||
|
loadKnowledgeCodes(2, value);
|
||||||
|
}}
|
||||||
|
allowClear
|
||||||
|
placeholder="不限"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="d-flex ml-30">
|
||||||
|
知识点
|
||||||
|
<Select
|
||||||
|
style={{ width: 200, marginLeft: 8 }}
|
||||||
|
size="large"
|
||||||
|
mode="multiple"
|
||||||
|
options={knowledgeOptions[2]}
|
||||||
|
value={select.knowledge_codes ? select.knowledge_codes.split(',') : []}
|
||||||
|
onChange={(values: string[]) => {
|
||||||
|
const obj = { ...select };
|
||||||
|
obj.knowledge_codes = values.join(',');
|
||||||
|
setSelect(obj);
|
||||||
|
}}
|
||||||
|
maxTagCount="responsive"
|
||||||
|
placeholder="不限"
|
||||||
|
onFocus={() => loadKnowledgeCodes(2, select.level)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="d-flex">
|
<div className="d-flex">
|
||||||
{t('exam.paper.compose.text1')}
|
{t('exam.paper.compose.text1')}
|
||||||
@ -689,7 +841,7 @@ export const RendomPaper: React.FC<PropInterface> = ({ type }) => {
|
|||||||
)}
|
)}
|
||||||
{q3 > 0 && (
|
{q3 > 0 && (
|
||||||
<div className={styles['config-item']}>
|
<div className={styles['config-item']}>
|
||||||
<div className="d-flex">
|
<div className="d-flex" style={{ flexWrap: 'wrap', gap: '8px 0' }}>
|
||||||
<div className={styles['label']}>
|
<div className={styles['label']}>
|
||||||
<span className="c-red">*</span>
|
<span className="c-red">*</span>
|
||||||
{t('exam.question.input.label')}({t('exam.paper.compose.text1')}
|
{t('exam.question.input.label')}({t('exam.paper.compose.text1')}
|
||||||
@ -730,6 +882,42 @@ export const RendomPaper: React.FC<PropInterface> = ({ type }) => {
|
|||||||
></InputNumber>
|
></InputNumber>
|
||||||
{t('exam.paper.compose.text4')}
|
{t('exam.paper.compose.text4')}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="d-flex ml-30">
|
||||||
|
难度
|
||||||
|
<Select
|
||||||
|
style={{ width: 100, marginLeft: 8 }}
|
||||||
|
size="large"
|
||||||
|
options={levelOptions}
|
||||||
|
value={input.level}
|
||||||
|
onChange={(value) => {
|
||||||
|
const obj = { ...input };
|
||||||
|
obj.level = value;
|
||||||
|
obj.knowledge_codes = '';
|
||||||
|
setInput(obj);
|
||||||
|
loadKnowledgeCodes(3, value);
|
||||||
|
}}
|
||||||
|
allowClear
|
||||||
|
placeholder="不限"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="d-flex ml-30">
|
||||||
|
知识点
|
||||||
|
<Select
|
||||||
|
style={{ width: 200, marginLeft: 8 }}
|
||||||
|
size="large"
|
||||||
|
mode="multiple"
|
||||||
|
options={knowledgeOptions[3]}
|
||||||
|
value={input.knowledge_codes ? input.knowledge_codes.split(',') : []}
|
||||||
|
onChange={(values: string[]) => {
|
||||||
|
const obj = { ...input };
|
||||||
|
obj.knowledge_codes = values.join(',');
|
||||||
|
setInput(obj);
|
||||||
|
}}
|
||||||
|
maxTagCount="responsive"
|
||||||
|
placeholder="不限"
|
||||||
|
onFocus={() => loadKnowledgeCodes(3, input.level)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="d-flex">
|
<div className="d-flex">
|
||||||
{t('exam.paper.compose.text1')}
|
{t('exam.paper.compose.text1')}
|
||||||
@ -740,7 +928,7 @@ export const RendomPaper: React.FC<PropInterface> = ({ type }) => {
|
|||||||
)}
|
)}
|
||||||
{q4 > 0 && (
|
{q4 > 0 && (
|
||||||
<div className={styles['config-item']}>
|
<div className={styles['config-item']}>
|
||||||
<div className="d-flex">
|
<div className="d-flex" style={{ flexWrap: 'wrap', gap: '8px 0' }}>
|
||||||
<div className={styles['label']}>
|
<div className={styles['label']}>
|
||||||
<span className="c-red">*</span>
|
<span className="c-red">*</span>
|
||||||
{t('exam.question.judge.label')}({t('exam.paper.compose.text1')}
|
{t('exam.question.judge.label')}({t('exam.paper.compose.text1')}
|
||||||
@ -781,6 +969,42 @@ export const RendomPaper: React.FC<PropInterface> = ({ type }) => {
|
|||||||
></InputNumber>
|
></InputNumber>
|
||||||
{t('exam.paper.compose.text4')}
|
{t('exam.paper.compose.text4')}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="d-flex ml-30">
|
||||||
|
难度
|
||||||
|
<Select
|
||||||
|
style={{ width: 100, marginLeft: 8 }}
|
||||||
|
size="large"
|
||||||
|
options={levelOptions}
|
||||||
|
value={judge.level}
|
||||||
|
onChange={(value) => {
|
||||||
|
const obj = { ...judge };
|
||||||
|
obj.level = value;
|
||||||
|
obj.knowledge_codes = '';
|
||||||
|
setJudge(obj);
|
||||||
|
loadKnowledgeCodes(4, value);
|
||||||
|
}}
|
||||||
|
allowClear
|
||||||
|
placeholder="不限"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="d-flex ml-30">
|
||||||
|
知识点
|
||||||
|
<Select
|
||||||
|
style={{ width: 200, marginLeft: 8 }}
|
||||||
|
size="large"
|
||||||
|
mode="multiple"
|
||||||
|
options={knowledgeOptions[4]}
|
||||||
|
value={judge.knowledge_codes ? judge.knowledge_codes.split(',') : []}
|
||||||
|
onChange={(values: string[]) => {
|
||||||
|
const obj = { ...judge };
|
||||||
|
obj.knowledge_codes = values.join(',');
|
||||||
|
setJudge(obj);
|
||||||
|
}}
|
||||||
|
maxTagCount="responsive"
|
||||||
|
placeholder="不限"
|
||||||
|
onFocus={() => loadKnowledgeCodes(4, judge.level)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="d-flex">
|
<div className="d-flex">
|
||||||
{t('exam.paper.compose.text1')}
|
{t('exam.paper.compose.text1')}
|
||||||
@ -791,7 +1015,7 @@ export const RendomPaper: React.FC<PropInterface> = ({ type }) => {
|
|||||||
)}
|
)}
|
||||||
{q5 > 0 && (
|
{q5 > 0 && (
|
||||||
<div className={styles['config-item']}>
|
<div className={styles['config-item']}>
|
||||||
<div className="d-flex">
|
<div className="d-flex" style={{ flexWrap: 'wrap', gap: '8px 0' }}>
|
||||||
<div className={styles['label']}>
|
<div className={styles['label']}>
|
||||||
<span className="c-red">*</span>
|
<span className="c-red">*</span>
|
||||||
{t('exam.question.qa.label')}({t('exam.paper.compose.text1')}
|
{t('exam.question.qa.label')}({t('exam.paper.compose.text1')}
|
||||||
@ -832,6 +1056,42 @@ export const RendomPaper: React.FC<PropInterface> = ({ type }) => {
|
|||||||
></InputNumber>
|
></InputNumber>
|
||||||
{t('exam.paper.compose.text4')}
|
{t('exam.paper.compose.text4')}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="d-flex ml-30">
|
||||||
|
难度
|
||||||
|
<Select
|
||||||
|
style={{ width: 100, marginLeft: 8 }}
|
||||||
|
size="large"
|
||||||
|
options={levelOptions}
|
||||||
|
value={qa.level}
|
||||||
|
onChange={(value) => {
|
||||||
|
const obj = { ...qa };
|
||||||
|
obj.level = value;
|
||||||
|
obj.knowledge_codes = '';
|
||||||
|
setQa(obj);
|
||||||
|
loadKnowledgeCodes(5, value);
|
||||||
|
}}
|
||||||
|
allowClear
|
||||||
|
placeholder="不限"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="d-flex ml-30">
|
||||||
|
知识点
|
||||||
|
<Select
|
||||||
|
style={{ width: 200, marginLeft: 8 }}
|
||||||
|
size="large"
|
||||||
|
mode="multiple"
|
||||||
|
options={knowledgeOptions[5]}
|
||||||
|
value={qa.knowledge_codes ? qa.knowledge_codes.split(',') : []}
|
||||||
|
onChange={(values: string[]) => {
|
||||||
|
const obj = { ...qa };
|
||||||
|
obj.knowledge_codes = values.join(',');
|
||||||
|
setQa(obj);
|
||||||
|
}}
|
||||||
|
maxTagCount="responsive"
|
||||||
|
placeholder="不限"
|
||||||
|
onFocus={() => loadKnowledgeCodes(5, qa.level)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="d-flex">
|
<div className="d-flex">
|
||||||
{t('exam.paper.compose.text1')}
|
{t('exam.paper.compose.text1')}
|
||||||
@ -842,7 +1102,7 @@ export const RendomPaper: React.FC<PropInterface> = ({ type }) => {
|
|||||||
)}
|
)}
|
||||||
{q6 > 0 && (
|
{q6 > 0 && (
|
||||||
<div className={styles['config-item']}>
|
<div className={styles['config-item']}>
|
||||||
<div className="d-flex">
|
<div className="d-flex" style={{ flexWrap: 'wrap', gap: '8px 0' }}>
|
||||||
<div className={styles['label']}>
|
<div className={styles['label']}>
|
||||||
<span className="c-red">*</span>
|
<span className="c-red">*</span>
|
||||||
{t('exam.question.cap.label')}({t('exam.paper.compose.text1')}
|
{t('exam.question.cap.label')}({t('exam.paper.compose.text1')}
|
||||||
@ -883,6 +1143,42 @@ export const RendomPaper: React.FC<PropInterface> = ({ type }) => {
|
|||||||
></InputNumber>
|
></InputNumber>
|
||||||
{t('exam.paper.compose.text4')}
|
{t('exam.paper.compose.text4')}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="d-flex ml-30">
|
||||||
|
难度
|
||||||
|
<Select
|
||||||
|
style={{ width: 100, marginLeft: 8 }}
|
||||||
|
size="large"
|
||||||
|
options={levelOptions}
|
||||||
|
value={cap.level}
|
||||||
|
onChange={(value) => {
|
||||||
|
const obj = { ...cap };
|
||||||
|
obj.level = value;
|
||||||
|
obj.knowledge_codes = '';
|
||||||
|
setCap(obj);
|
||||||
|
loadKnowledgeCodes(6, value);
|
||||||
|
}}
|
||||||
|
allowClear
|
||||||
|
placeholder="不限"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="d-flex ml-30">
|
||||||
|
知识点
|
||||||
|
<Select
|
||||||
|
style={{ width: 200, marginLeft: 8 }}
|
||||||
|
size="large"
|
||||||
|
mode="multiple"
|
||||||
|
options={knowledgeOptions[6]}
|
||||||
|
value={cap.knowledge_codes ? cap.knowledge_codes.split(',') : []}
|
||||||
|
onChange={(values: string[]) => {
|
||||||
|
const obj = { ...cap };
|
||||||
|
obj.knowledge_codes = values.join(',');
|
||||||
|
setCap(obj);
|
||||||
|
}}
|
||||||
|
maxTagCount="responsive"
|
||||||
|
placeholder="不限"
|
||||||
|
onFocus={() => loadKnowledgeCodes(6, cap.level)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="d-flex">
|
<div className="d-flex">
|
||||||
{t('exam.paper.compose.text1')}
|
{t('exam.paper.compose.text1')}
|
||||||
|
|||||||
@ -78,14 +78,18 @@ export const QuestionsDetailUpdate: React.FC<PropInterface> = ({ id, qid, open,
|
|||||||
try {
|
try {
|
||||||
// 获取知识点详情(包含bookId)
|
// 获取知识点详情(包含bookId)
|
||||||
const knowledgeRes: any = await textbook.getKnowledgeByCodesApi(data.knowledge_code);
|
const knowledgeRes: any = await textbook.getKnowledgeByCodesApi(data.knowledge_code);
|
||||||
if (knowledgeRes.data && Array.isArray(knowledgeRes.data) && knowledgeRes.data.length > 0) {
|
if (
|
||||||
|
knowledgeRes.data &&
|
||||||
|
Array.isArray(knowledgeRes.data) &&
|
||||||
|
knowledgeRes.data.length > 0
|
||||||
|
) {
|
||||||
// 获取所有需要预加载的教材ID(去重)
|
// 获取所有需要预加载的教材ID(去重)
|
||||||
const bookIds = [...new Set(knowledgeRes.data.map((k: any) => k.bookId))];
|
const bookIds = [...new Set(knowledgeRes.data.map((k: any) => k.bookId))];
|
||||||
|
|
||||||
// 预加载每个教材的知识点列表到级联选项中
|
// 预加载每个教材的知识点列表到级联选项中
|
||||||
for (const bookId of bookIds) {
|
for (const bookId of bookIds) {
|
||||||
const knowledgeListRes: any = await textbook.getKnowledgeListApi(bookId as number);
|
const knowledgeListRes: any = await textbook.getKnowledgeListApi(bookId as number);
|
||||||
const targetOption = options.find(opt => opt.value === bookId);
|
const targetOption = options.find((opt) => opt.value === bookId);
|
||||||
if (targetOption && knowledgeListRes.data && Array.isArray(knowledgeListRes.data)) {
|
if (targetOption && knowledgeListRes.data && Array.isArray(knowledgeListRes.data)) {
|
||||||
targetOption.children = knowledgeListRes.data.map((item: any) => ({
|
targetOption.children = knowledgeListRes.data.map((item: any) => ({
|
||||||
value: item.knowledgeCode || item.knowledge_code,
|
value: item.knowledgeCode || item.knowledge_code,
|
||||||
@ -97,7 +101,10 @@ export const QuestionsDetailUpdate: React.FC<PropInterface> = ({ id, qid, open,
|
|||||||
setCascaderOptions([...options]);
|
setCascaderOptions([...options]);
|
||||||
|
|
||||||
// 构建级联选择器的值 [[bookId, knowledgeCode], ...]
|
// 构建级联选择器的值 [[bookId, knowledgeCode], ...]
|
||||||
cascaderValue = knowledgeRes.data.map((k: any) => [k.bookId, k.knowledgeCode || k.knowledge_code]);
|
cascaderValue = knowledgeRes.data.map((k: any) => [
|
||||||
|
k.bookId,
|
||||||
|
k.knowledgeCode || k.knowledge_code,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('加载知识点详情失败:', err);
|
console.error('加载知识点详情失败:', err);
|
||||||
@ -124,24 +131,27 @@ export const QuestionsDetailUpdate: React.FC<PropInterface> = ({ id, qid, open,
|
|||||||
const targetOption = selectedOptions[selectedOptions.length - 1];
|
const targetOption = selectedOptions[selectedOptions.length - 1];
|
||||||
targetOption.loading = true;
|
targetOption.loading = true;
|
||||||
|
|
||||||
textbook.getKnowledgeListApi(targetOption.value as number).then((res: any) => {
|
textbook
|
||||||
targetOption.loading = false;
|
.getKnowledgeListApi(targetOption.value as number)
|
||||||
if (res.data && Array.isArray(res.data)) {
|
.then((res: any) => {
|
||||||
targetOption.children = res.data.map((item: any) => ({
|
targetOption.loading = false;
|
||||||
value: item.knowledgeCode || item.knowledge_code,
|
if (res.data && Array.isArray(res.data)) {
|
||||||
label: item.name,
|
targetOption.children = res.data.map((item: any) => ({
|
||||||
isLeaf: true,
|
value: item.knowledgeCode || item.knowledge_code,
|
||||||
}));
|
label: item.name,
|
||||||
} else {
|
isLeaf: true,
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
targetOption.children = [];
|
||||||
|
}
|
||||||
|
setCascaderOptions([...cascaderOptions]);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error('加载知识点失败:', err);
|
||||||
|
targetOption.loading = false;
|
||||||
targetOption.children = [];
|
targetOption.children = [];
|
||||||
}
|
setCascaderOptions([...cascaderOptions]);
|
||||||
setCascaderOptions([...cascaderOptions]);
|
});
|
||||||
}).catch((err) => {
|
|
||||||
console.error('加载知识点失败:', err);
|
|
||||||
targetOption.loading = false;
|
|
||||||
targetOption.children = [];
|
|
||||||
setCascaderOptions([...cascaderOptions]);
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const onFinish = (values: any) => {
|
const onFinish = (values: any) => {
|
||||||
@ -366,11 +376,13 @@ export const QuestionsDetailUpdate: React.FC<PropInterface> = ({ id, qid, open,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
question.questionUpdate(qid, id, params, values.level, Number(type), knowledgeCode).then((res: any) => {
|
question
|
||||||
setLoading(false);
|
.questionUpdate(qid, id, params, values.level, Number(type), knowledgeCode)
|
||||||
message.success(t('commen.saveSuccess'));
|
.then((res: any) => {
|
||||||
onCancel();
|
setLoading(false);
|
||||||
});
|
message.success(t('commen.saveSuccess'));
|
||||||
|
onCancel();
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const onFinishFailed = (errorInfo: any) => {
|
const onFinishFailed = (errorInfo: any) => {
|
||||||
@ -535,10 +547,7 @@ export const QuestionsDetailUpdate: React.FC<PropInterface> = ({ id, qid, open,
|
|||||||
</Radio>
|
</Radio>
|
||||||
</Radio.Group>
|
</Radio.Group>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item
|
<Form.Item label="关联知识点" name="knowledge_cascader">
|
||||||
label="关联知识点"
|
|
||||||
name="knowledge_cascader"
|
|
||||||
>
|
|
||||||
<Cascader
|
<Cascader
|
||||||
options={cascaderOptions}
|
options={cascaderOptions}
|
||||||
loadData={loadKnowledgeData as CascaderProps<CascaderOption>['loadData']}
|
loadData={loadKnowledgeData as CascaderProps<CascaderOption>['loadData']}
|
||||||
@ -551,7 +560,9 @@ export const QuestionsDetailUpdate: React.FC<PropInterface> = ({ id, qid, open,
|
|||||||
filter: (inputValue: string, path: CascaderOption[]) =>
|
filter: (inputValue: string, path: CascaderOption[]) =>
|
||||||
path.some(
|
path.some(
|
||||||
(option) =>
|
(option) =>
|
||||||
(option.label as string).toLowerCase().indexOf(inputValue.toLowerCase()) > -1
|
(option.label as string)
|
||||||
|
.toLowerCase()
|
||||||
|
.indexOf(inputValue.toLowerCase()) > -1
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user