feat(exam): 随机试卷支持难度与知识点筛选

- 后端新增 /knowledge-codes 接口,根据题库/题型/难度获取知识点列表
- ExamQuestionMapper.xml 添加 level 和 knowledge_code 过滤条件
- IKnowledgeService 新增 getByKnowledgeCodes 批量查询方法
- 前端 random-paper.tsx 各题型增加难度和知识点下拉选择
- 知识点动态加载,支持编辑时回显名称
- 更新前端 CLAUDE.md 文档,补充 httpClient 响应结构说明
This commit is contained in:
menft 2025-11-30 10:37:31 +08:00
parent f7e04de5e5
commit f1881865de
13 changed files with 767 additions and 39 deletions

View File

@ -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);
}
} }

View File

@ -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);
} }

View File

@ -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;
} }

View File

@ -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);
} }

View File

@ -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 当前编码

View File

@ -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);
} }

View File

@ -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);
} }

View File

@ -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());
}
} }

View File

@ -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
View 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`

View File

@ -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,
});
}

View File

@ -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')}

View File

@ -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,7 +131,9 @@ 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
.getKnowledgeListApi(targetOption.value as number)
.then((res: any) => {
targetOption.loading = false; targetOption.loading = false;
if (res.data && Array.isArray(res.data)) { if (res.data && Array.isArray(res.data)) {
targetOption.children = res.data.map((item: any) => ({ targetOption.children = res.data.map((item: any) => ({
@ -136,7 +145,8 @@ export const QuestionsDetailUpdate: React.FC<PropInterface> = ({ id, qid, open,
targetOption.children = []; targetOption.children = [];
} }
setCascaderOptions([...cascaderOptions]); setCascaderOptions([...cascaderOptions]);
}).catch((err) => { })
.catch((err) => {
console.error('加载知识点失败:', err); console.error('加载知识点失败:', err);
targetOption.loading = false; targetOption.loading = false;
targetOption.children = []; targetOption.children = [];
@ -366,7 +376,9 @@ 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
.questionUpdate(qid, id, params, values.level, Number(type), knowledgeCode)
.then((res: any) => {
setLoading(false); setLoading(false);
message.success(t('commen.saveSuccess')); message.success(t('commen.saveSuccess'));
onCancel(); onCancel();
@ -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
), ),
}} }}
/> />