# 数据字典类字段显示与导出实现方式 ## 1. 后端接口请求 ### 1.1 批量字典查询接口 项目采用"前端预加载字典 + 本地映射"方案,后端提供批量查询接口,前端一次性获取页面所需的所有字典数据。 **接口位置**: `jeecg-boot/.../dictionary/controller/DictionaryController.java` ```java @Operation(summary = "批量根据字典编码获取字典项") @PostMapping(value = "/getDictBatch") public Result>>> getDictBatch(@RequestBody List dictionaryCodes) { return Result.OK(dictionaryItemService.getDictItemsBatch(dictionaryCodes)); } ``` - Controller 只负责参数接收和结果返回,业务逻辑在 Service 层实现 - Service 层通过 QueryWrapper 查询 `DICTIONARY_ITEM` 表,按 `OrderNo` 排序,结果封装为 `{label, value}` 格式 **请求示例**: ``` POST /system/dictionary/getDictBatch Content-Type: application/json ["Gender", "MaritalStatus"] ``` **返回格式**(`Result` 包装后的 `result` 字段内容): ```json { "Gender": [ { "label": "男性", "value": 1 }, { "label": "女性", "value": 2 } ], "MaritalStatus": [ { "label": "未婚", "value": 1 }, { "label": "已婚", "value": 2 } ] } ``` ### 1.2 业务接口设计原则 业务接口(如人员列表查询)只返回字典 value(数字编码),不做 JOIN 查 label。例如人员信息接口返回 `{ gender: 1, maritalStatus: 2 }`,label 的转换由前端统一完成。这样业务接口与字典表解耦,字典变更不影响业务接口。 > **Excel 导出场景**:导出时采用"后端内存映射"方案(详见第 5 章),导出前批量查询字典,在内存中将字典值替换为中文标签,再由 > AutoPoi 写入 Excel。 --- ## 2. 前端数据缓存 ### 2.1 useDict 工具 **位置**: `jeecgboot-vue3/src/hooks/dictionary/useDict.ts` `useDict` 是一个 Vue 3 composable,负责从后端批量接口加载字典数据,并维护全局响应式缓存。 #### 核心设计 - **模块级单例缓存**:使用 `reactive()` 创建模块级 `dictCache` 对象,多个组件实例共享同一份缓存,只发起一次网络请求 - **防重复请求**:当已有请求在途中时,后续调用方等待同一请求完成,完成后再次检查是否仍缺失所需字典,缺则继续发起新请求 - **响应式更新**:缓存数据加载完成后,所有依赖 `dictCache` 的 `computed` 和模板自动刷新 - **类型统一**:后端 `DictionaryItem.value` 为 `Integer`,`PersonalInfo` 中字典字段为 `String`,缓存时通过 `String(item.value)` 统一转为字符串,确保 `` 的 `v-model` 能正确匹配选项 #### 导出方法 | 方法 | 用途 | |----------------------------|------------------------------------| | `getDictText(code, value)` | 根据字典编码和 value 获取对应 label,用于表格列显示 | | `getDictOptions(code)` | 获取 `{label, value}[]` 选项列表,用于下拉框绑定 | | `fetchDicts()` | 手动触发字典加载(在 `onMounted` 中自动调用) | `getDictText` 内部使用 `String(i.value) === String(value)` 做类型不敏感比较,兼容后端返回的数字和字符串类型混用场景。 #### 使用方式 ```typescript import { useDict } from '/@/hooks/dictionary/useDict'; const { getDictText, getDictOptions } = useDict([ 'Gender', 'MaritalStatus', 'JobSeekerCategory' ]); ``` --- ## 3. 页面 input 组件实现 ### 3.1 列表页搜索框 **位置**: `jeecgboot-vue3/src/views/recruitment/personal/PersonalInfoList.vue` 预加载字典后,用 `` 绑定 `getDictOptions()` 返回的选项列表,替代原来的 `` 或 ``。 ```vue ``` ### 3.2 表单页编辑字段 **位置**: `jeecgboot-vue3/src/views/recruitment/personal/components/PersonalInfoForm.vue` 将字典相关的字段从 `` 改为 `` 下拉选择。表单页需要加载所有涉及的字典,包括列表页可能没用到但表单编辑需要的字典。 ```vue ``` --- ## 4. 数据表格显示 ### 4.1 使用 bodyCell 插槽翻译字典值 **位置**: `jeecgboot-vue3/src/views/recruitment/personal/PersonalInfoList.vue` 表格列通过 `column.dataIndex` 配置为字典字段的 value 字段名,渲染时由 `bodyCell` 插槽拦截,调用 `getDictText()` 将 value 翻译为 label 显示。 ```vue ``` ### 4.2 处理逻辑 1. 表格从业务接口获取原始数据,字典字段只包含 value(如 `{ gender: 1 }`) 2. JeecgBoot 的 `BasicTable` 渲染时,`bodyCell` 插槽的 `text` 参数即为原始 value 3. 通过 `column.dataIndex` 识别哪个列是字典字段 4. 调用对应的 `getDictText('字典编码', text)` 从缓存中查找匹配项 5. 匹配成功显示 label,匹配失败回退显示原始 value --- ## 5. Excel 导出字典映射(后端内存映射方案) ### 5.1 方案说明 Excel 导出由后端直接生成文件,不经过前端映射。采用"后端内存映射"方案: - 导出前通过 `IDictionaryItemService.getDictItemsBatch()` 批量查询导出涉及的所有字典 - 构建 `Map<字典编码, Map>` 快速查找结构 - 遍历导出数据,将实体中字典字段的 value 值替换为对应的中文 label - 替换完成后交由 AutoPoi 框架写入 Excel ### 5.2 职责划分 字典翻译的业务逻辑不在 Controller 中实现,而是提取到 Service 层,遵循分层架构原则: | 层级 | 职责 | 文件 | |----------------|------------------------------------|--------------------------------| | **Controller** | 参数接收、查询条件组装、选中数据过滤、ModelAndView 构建 | `PersonalInfoController.java` | | **Service** | 批量查询字典、构建 value→label 映射、遍历替换实体字段值 | `PersonalInfoServiceImpl.java` | ### 5.3 Controller 层代码 **位置**: `jeecg-boot/.../personal/controller/PersonalInfoController.java`(`exportXls` 方法) Controller 只负责请求处理和视图构建,字典翻译逻辑委托给 Service: ```java @RequiresPermissions("personal:personal_info:exportXls") @RequestMapping(value = "/exportXls") public ModelAndView exportXls(HttpServletRequest request, PersonalInfo personalInfo) { // Step.1 组装查询条件 QueryWrapper queryWrapper = QueryGenerator.initQueryWrapper(personalInfo, request.getParameterMap()); LoginUser sysUser = (LoginUser) SecurityUtils.getSubject().getPrincipal(); // 过滤选中数据 String selections = request.getParameter("selections"); if (oConvertUtils.isNotEmpty(selections)) { List selectionList = Arrays.asList(selections.split(",")); queryWrapper.in("id", selectionList); } // Step.2 获取导出数据并进行字典值→标签内存映射(业务逻辑委托给 Service) List exportList = personalInfoService.translateDictFields( service.list(queryWrapper), EXPORT_DICT_FIELD_MAP); // Step.3 AutoPoi 导出Excel(此时字典字段已替换为中文标签) ModelAndView mv = new ModelAndView(new JeecgEntityExcelView()); String title = "个人基本信息表"; mv.addObject(NormalExcelConstants.FILE_NAME, title); mv.addObject(NormalExcelConstants.CLASS, PersonalInfo.class); ExportParams exportParams = new ExportParams(title + "报表", "导出人:" + sysUser.getRealname(), title, ExcelType.XSSF); mv.addObject(NormalExcelConstants.PARAMS, exportParams); mv.addObject(NormalExcelConstants.DATA_LIST, exportList); String exportFields = "fullName,gender,birthDate,education,householdLocation,currentResidence,jobSeekerCategory,contactPhone,jobSearchStatus,dataSource"; mv.addObject(NormalExcelConstants.EXPORT_FIELDS, exportFields); return mv; } ``` ### 5.4 Service 层实现 **位置**: `jeecg-boot/.../personal/service/IPersonalInfoService.java`(接口声明) ```java /** * 导出时对字典字段进行值→标签的内存映射翻译 * * @param list 待导出的数据列表 * @param fieldDictMap 字段名→字典编码 的映射关系 * @return 字典字段已替换为中文标签的数据列表 */ List translateDictFields(List list, Map fieldDictMap); ``` **位置**: `jeecg-boot/.../personal/service/impl/PersonalInfoServiceImpl.java`(实现) ```java @Service public class PersonalInfoServiceImpl extends ServiceImpl implements IPersonalInfoService { @Autowired private IDictionaryItemService dictionaryItemService; @Override public List translateDictFields(List list, Map fieldDictMap) { // 批量查询导出涉及的所有字典数据 List dictCodes = new ArrayList<>(fieldDictMap.values()); Map>> dictData = dictionaryItemService.getDictItemsBatch(dictCodes); // 构建 value→label 快速查找映射: Map<字典编码, Map> Map> valueLabelMap = new HashMap<>(); for (Map.Entry>> entry : dictData.entrySet()) { Map valueToLabel = new HashMap<>(); for (Map item : entry.getValue()) { valueToLabel.put(String.valueOf(item.get("value")), String.valueOf(item.get("label"))); } valueLabelMap.put(entry.getKey(), valueToLabel); } // 遍历导出数据,将字典字段的值替换为对应的中文标签 for (PersonalInfo info : list) { for (Map.Entry fieldEntry : fieldDictMap.entrySet()) { String fieldName = fieldEntry.getKey(); String dictCode = fieldEntry.getValue(); Map mapping = valueLabelMap.get(dictCode); if (mapping == null) continue; switch (fieldName) { case "gender": if (info.getGender() != null) info.setGender(mapping.getOrDefault(info.getGender(), info.getGender())); break; case "jobSeekerCategory": if (info.getJobSeekerCategory() != null) info.setJobSeekerCategory(mapping.getOrDefault(info.getJobSeekerCategory(), info.getJobSeekerCategory())); break; case "jobSearchStatus": if (info.getJobSearchStatus() != null) info.setJobSearchStatus(mapping.getOrDefault(info.getJobSearchStatus(), info.getJobSearchStatus())); break; case "dataSource": if (info.getDataSource() != null) info.setDataSource(mapping.getOrDefault(info.getDataSource(), info.getDataSource())); break; } } } return list; } } ``` ### 5.5 字段映射配置 **位置**: `PersonalInfoController.java`(类级别常量) ```java /** * 导出字段与字典编码的映射关系:entity字段名 → 字典编码 * 用于导出时自动将字典值翻译为中文标签 */ private static final Map EXPORT_DICT_FIELD_MAP = new LinkedHashMap<>(); static { EXPORT_DICT_FIELD_MAP.put("gender", "Gender"); EXPORT_DICT_FIELD_MAP.put("jobSeekerCategory", "JobSeekerCategory"); EXPORT_DICT_FIELD_MAP.put("jobSearchStatus", "JobSeekerStatus"); EXPORT_DICT_FIELD_MAP.put("dataSource", "DataSource"); } ``` 如需为其他字典字段添加导出翻译,只需在此映射中追加条目,并在 Service 的 `switch` 中添加对应 case 即可。 ### 5.6 数据流 ``` 业务库数据: { gender: "1", jobSeekerCategory: "2", jobSearchStatus: "1", dataSource: "1" } │ ▼ Service.translateDictFields() │ ├── ① getDictItemsBatch(["Gender", "JobSeekerCategory", "JobSeekerStatus", "DataSource"]) │ ├── ② 构建 value→label 映射 │ { Gender → {"1"→"男性", "2"→"女性"}, ... } │ ├── ③ 遍历替换实体字段值 │ { gender: "男性", jobSeekerCategory: "失业人员", jobSearchStatus: "待业中", dataSource: "系统录入" } │ └── ④ 返回替换后的列表给 Controller │ ▼ Controller → AutoPoi → Excel Excel 中直接显示中文标签 ``` ### 5.7 与前端映射方案对比 | 环节 | 前端映射方案 | 后端内存映射方案 | |--------|--------------------------------|----------------------------| | 适用场景 | 页面展示、查询表单、编辑表单 | Excel 导出 | | 字典查询时机 | 页面挂载时一次性预加载 | 导出时实时查询 | | 缓存方式 | `reactive()` 模块级全局缓存 | 临时 Map,导出完成后丢弃 | | 替换方式 | `bodyCell` 插槽中 `getDictText()` | Service 层 setter 直接修改实体字段值 | | 逻辑归属 | 前端 `useDict` composable | 后端 Service 层 | | 字典变更影响 | 页面刷新后生效 | 每次导出实时查询,立即生效 | --- ## 6. 树形字典(以行政区划 XZQH 为例) ### 6.1 方案概述 对于大规模层级数据(如行政区划 44K+ 节点、职业分类 2K+ 节点),不能使用第 1~5 章的扁平字典方案一次性全量加载。 本方案采用"懒加载树 + 双字段存储"策略: | 项目 | 说明 | |------|------| | **存储方案** | 实体存两个字段:叶子代码(已有列,如 `householdLocation`)+ 完整路径(新增列,如 `householdAreaName`) | | **后端加载** | 懒加载接口 `/getDictTreeChildren`,按需加载当前展开层级的子节点 | | **前端编辑** | `` + `loadData` 懒加载 + `labelInValue` 回显完整路径 | | **前端回显** | 编辑时直接用已存储的完整路径文本(`householdAreaName`),无需调 API | | **表格/详情/导出** | 直接显示 `householdAreaName` 字段,无需字典翻译 | ### 6.2 数据模型设计 数据库表中已有叶子代码列,新增完整路径文本列: **位置**: `.docs/sql/个人信息与简历信息.sql` ```sql ALTER TABLE PERSONAL_INFO ADD HOUSEHOLD_AREA_NAME VARCHAR(500); COMMENT ON COLUMN PERSONAL_INFO.HOUSEHOLD_AREA_NAME IS '户口所在地完整路径(如:广东省湛江市赤坎区)'; ALTER TABLE PERSONAL_INFO ADD RESIDENCE_AREA_NAME VARCHAR(500); COMMENT ON COLUMN PERSONAL_INFO.RESIDENCE_AREA_NAME IS '现居住地完整路径(如:广东省湛江市赤坎区)'; ``` **Entity 新增字段**(位置:`PersonalInfo.java`): ```java /** 户口所在地完整路径 */ @Excel(name = "户口所在地完整路径", width = 25) private java.lang.String householdAreaName; /** 现居住地完整路径 */ @Excel(name = "现居住地完整路径", width = 25) private java.lang.String residenceAreaName; ``` ### 6.3 后端接口 所有树形字典接口位于 `DictionaryController.java`,路径前缀 `/system/dictionary`。 #### 6.3.1 懒加载子节点(核心接口) ``` GET /system/dictionary/getDictTreeChildren?dictionaryCode=XZQH&parentCode= ``` - `parentCode` 为空时返回根节点(省级) - 返回格式 `[{key, title, leaf}]`,`leaf=true` 表示叶子节点(无展开箭头) - 通过一次批量 `SELECT DISTINCT ParentItemID` 查询判断每个子节点是否有后代,避免 N+1 **Service 实现位置**: `DictionaryItemServiceImpl.java` → `getDictTreeChildren()` #### 6.3.2 获取完整路径 ``` GET /system/dictionary/getItemFullPath?dictionaryCode=XZQH&code=440802 → "广东省湛江市赤坎区" ``` 沿 `parentItemId` 链从叶子节点向上回溯,将各层级名称拼接。 **Service 实现位置**: `DictionaryItemServiceImpl.java` → `getItemFullPath()` #### 6.3.3 根据 code 回显名称(value→label) ``` GET /system/dictionary/loadDictItem/XZQH?key=440100,440200 → ["广州市", "韶关市"] ``` 支持逗号分隔批量查询,按 key 的原始顺序返回。 #### 6.3.4 模糊搜索(用于搜索展开) ``` GET /system/dictionary/searchDictItem?dictionaryCode=XZQH&keyword=湛江&limit=20 → [{code, name, fullPath, ancestorCodes}, ...] ``` 按 `Name LIKE '%keyword%'` 模糊匹配,附带完整路径和祖先节点 code 链。 #### 6.3.5 获取全量树形结构(仅限小数据量) ``` GET /system/dictionary/getDictTree?dictionaryCode=EconomicIndustry ``` - 递归构建完整树形结构,返回 `[{label, value, children}, ...]` - 适用场景:树节点总数较少(几百条以内),前端可一次性加载整棵树 - **不适用大节点量场景**(如 XZQH 44K+、ZYFL 2K+),会占用大量内存和带宽 **Service 实现位置**: `DictionaryItemServiceImpl.java` → `getDictTree()` - 一次查询全部节点 → 按 `parentItemId` 分组 → 递归构建树 ### 6.4 前端编辑表单实现(以 PersonalInfoForm 的 XZQH 为例) **位置**: `jeecgboot-vue3/src/views/recruitment/personal/components/PersonalInfoForm.vue` #### 6.4.1 模板 使用 `` 替代 ``: ```vue ``` 关键属性: - `label-in-value`: v-model 绑定 `{value, label}` 对象,支持自定义显示文本 - `load-data`: 展开节点时触发懒加载 - `tree-expanded-keys`: 受控展开,编辑回显时可自动展开祖先层级 - 不使用 `show-search`(内置客户端搜索无法覆盖懒加载未加载的节点) #### 6.4.2 根节点加载 ```typescript async function loadAreaRootNodes() { const res = await defHttp.get({ url: '/system/dictionary/getDictTreeChildren', params: { dictionaryCode: 'XZQH', parentCode: '' }, }, { isTransformResponse: false }); if (res.success && res.result) { return res.result.map((item) => ({ key: item.key, title: item.title, value: item.key, isLeaf: item.leaf, })); } return []; } // onMounted 中调用 const rootNodes = await loadAreaRootNodes(); householdTreeData.value = rootNodes; residenceTreeData.value = rootNodes; ``` #### 6.4.3 懒加载子节点 ```typescript async function loadTreeChildren(treeNode, treeDataRefName) { // 避免重复加载 if (treeNode.dataRef.children) return Promise.resolve(); const res = await defHttp.get({ url: '/system/dictionary/getDictTreeChildren', params: { dictionaryCode: 'XZQH', parentCode: treeNode.value }, }, { isTransformResponse: false }); if (res.success && res.result) { const children = res.result.map((item) => ({ key: item.key, title: item.title, value: item.key, isLeaf: item.leaf, })); // 挂子节点到 a-tree-select 的 dataRef 上 treeNode.dataRef.children = children; // 强制触发 Vue 响应式更新 if (treeDataRefName === 'householdTreeData') { householdTreeData.value = [...householdTreeData.value]; } else { residenceTreeData.value = [...residenceTreeData.value]; } } return Promise.resolve(); } ``` > **注意**:`defHttp.get` 需传入 `{ isTransformResponse: false }`,否则框架会自动解包只返回 `data.result`,导致 `res.success` 无法判断。 #### 6.4.4 选择变更处理 ```typescript async function handleAreaChange(val, prefix) { if (val && val.value) { // 存叶子代码到已有列 formData[`${prefix}Location`] = val.value; // 调 API 获取完整路径,存到新增的 areaName 列 const res = await defHttp.get({ url: '/system/dictionary/getItemFullPath', params: { dictionaryCode: 'XZQH', code: val.value }, }, { isTransformResponse: false }); const fullPath = res.success ? res.result : val.label; formData[`${prefix}AreaName`] = fullPath; // 更新下拉框显示为完整路径 if (prefix === 'household') { householdValueObj.value = { value: val.value, label: fullPath }; } else { residenceValueObj.value = { value: val.value, label: fullPath }; } } else { // 清空 formData[`${prefix}Location`] = ''; formData[`${prefix}AreaName`] = ''; // ... } } ``` #### 6.4.5 编辑回显 编辑时直接从数据记录中取已存储的路径文本,**无需调 API**: ```typescript function edit(record) { nextTick(() => { // ...赋值 formData householdValueObj.value = tmpData.householdLocation ? { value: tmpData.householdLocation, label: tmpData.householdAreaName || tmpData.householdLocation } : undefined; residenceValueObj.value = tmpData.currentResidence ? { value: tmpData.currentResidence, label: tmpData.residenceAreaName || tmpData.currentResidence } : undefined; }); } ``` #### 6.4.6 ResumeInfoForm 中的全量加载模式(期望行业 EconomicIndustry) **位置**: `jeecgboot-vue3/src/views/recruitment/personal/components/ResumeInfoForm.vue` 期望行业(EconomicIndustry)数据量较小,采用 `getDictTree` 全量加载方案,一次性将整棵树加载到前端。编辑回显时直接在前端树数据中递归查找 label。 **模板**: ```vue ``` 与 XZQH 懒加载方案不同:**没有 `load-data`、`tree-expanded-keys`**,因为全量数据已在前端,ant-design-vue 组件自带搜索和展开能力。 **数据加载**: ```typescript // 树数据 ref const expectedIndustryTreeData = ref([]); const expectedIndustryValueObj = ref(undefined); /** * 从 getDictTree 接口加载树形字典完整数据 * 后端返回格式: { label, value, children } * 映射为 a-tree-select 识别的: { key, title, value, isLeaf, children } */ async function loadTreeData(dictionaryCode: string) { const res = await defHttp.get({ url: '/system/dictionary/getDictTree', params: { dictionaryCode } }, { isTransformResponse: false }); if (res.success && res.result) { return res.result.map((item) => treeNodeMapper(item)); } return []; } /** 递归映射树节点:{label, value, children} → {key, title, value, isLeaf, children} */ function treeNodeMapper(node: any): any { return { key: String(node.value), title: node.label, value: String(node.value), isLeaf: !node.children || node.children.length === 0, children: node.children?.length ? node.children.map((child) => treeNodeMapper(child)) : undefined, }; } /** 在树形字典数据中递归查找 value 对应的 label(用于编辑回显) */ function findTreeNodeLabel(tree: any[], value: string): string | undefined { for (const node of tree) { if (node.value === value) return node.title; if (node.children) { const found = findTreeNodeLabel(node.children, value); if (found) return found; } } return undefined; } ``` **编辑回显**——直接从已加载的树数据中查找 label,**零 API 调用**: ```typescript // 编辑时加载树数据,然后在前端内存中查找 label expectedIndustryTreeData.value = await loadTreeData('EconomicIndustry'); if (tmpData.expectedIndustry) { const label = findTreeNodeLabel(expectedIndustryTreeData.value, tmpData.expectedIndustry) || tmpData.expectedIndustry; expectedIndustryValueObj.value = { value: tmpData.expectedIndustry, label }; } ``` **适用条件**:树节点数少(EconomicIndustry 约几十条),全量加载不会造成性能问题。 #### 6.4.7 ResumeInfoForm 中的懒加载模式(期望职位 ZYFL) **位置**: `jeecgboot-vue3/src/views/recruitment/personal/components/ResumeInfoForm.vue` 期望职位(ZYFL)数据量较大(2000+ 节点),采用与 XZQH 相同的懒加载方案。关键区别:**不存储完整路径文本到实体**(ZYFL 实体只有 code 列,无 areaName 列),编辑回显时通过 `loadDictItem` API 获取名称。 **模板**: ```vue ``` **根节点加载 + 懒加载子节点**: ```typescript const positionTreeData = ref([]); const positionValueObj = ref(undefined); const positionExpandedKeys = ref([]); async function loadPositionRootNodes() { const res = await defHttp.get({ url: '/system/dictionary/getDictTreeChildren', params: { dictionaryCode: 'ZYFL', parentCode: '' }, }, { isTransformResponse: false }); if (res.success && res.result) { return res.result.map((item) => ({ key: item.key, title: item.title, value: item.key, isLeaf: item.leaf, })); } return []; } async function loadPositionChildren(treeNode) { if (treeNode.dataRef.children) return Promise.resolve(); const res = await defHttp.get({ url: '/system/dictionary/getDictTreeChildren', params: { dictionaryCode: 'ZYFL', parentCode: treeNode.value }, }, { isTransformResponse: false }); if (res.success && res.result) { const children = res.result.map((item) => ({ key: item.key, title: item.title, value: item.key, isLeaf: item.leaf, })); treeNode.dataRef.children = children; positionTreeData.value = [...positionTreeData.value]; } return Promise.resolve(); } ``` **编辑回显**——通过 `loadDictItem` API 获取名称: ```typescript // 编辑时加载根节点 positionTreeData.value = await loadPositionRootNodes(); // 调 API 获取字典项名称用于下拉框 label 显示 if (tmpData.expectedPosition) { const apiRes = await defHttp.get({ url: `/system/dictionary/loadDictItem/ZYFL`, params: { key: tmpData.expectedPosition }, }, { isTransformResponse: false }); const label = apiRes.success && apiRes.result?.length ? apiRes.result[0] : tmpData.expectedPosition; positionValueObj.value = { value: tmpData.expectedPosition, label }; } ``` > **与 XZQH 回显的差异**:XZQH 编辑回显直接用已存储的 `householdAreaName` 路径文本 — **零 API 调用**。ZYFL 没有存路径文本,编辑回显时需调用 `loadDictItem` API 获取名称。 ### 6.5 表格列显示 **位置**: `jeecgboot-vue3/src/views/recruitment/personal/PersonalInfo.data.ts` 树形字典字段的 `dataIndex` 直接指向 `areaName` 列,**无需 `bodyCell` 插槽字典翻译**: ```typescript { title: '户口所在地', align: 'center', dataIndex: 'householdAreaName', // ← 直接用路径文本列,不翻译 width: 200, }, { title: '现居住地', align: 'center', dataIndex: 'residenceAreaName', width: 200, }, ``` ### 6.6 详情弹窗显示 **位置**: `jeecgboot-vue3/src/views/recruitment/personal/components/PersonalInfoDetailModal.vue` ```vue {{ detailData.householdAreaName || detailData.householdLocation || '-' }} {{ detailData.residenceAreaName || detailData.currentResidence || '-' }} ``` 优先显示路径文本,无数据时回退显示叶子代码。 ### 6.7 Excel 导出 **位置**: `PersonalInfoController.java` → `exportXls()` 树形字典字段已在 `PersonalInfo` Entity 上有独立的 `householdAreaName`/`residenceAreaName` 列,且标注了 `@Excel` 注解。 导出字段列表中直接包含这两个列名即可,**无需走 `translateDictFields` 字典映射**: ```java String exportFields = "...", + "householdAreaName,residenceAreaName," + "jobSeekerCategory,contactPhone,jobSearchStatus,dataSource"; ``` `EXPORT_DICT_FIELD_MAP` 中无需添加树形字典的映射项。 ### 6.8 数据流总结 ``` 【新增/编辑】 用户展开树 → getDictTreeChildren 懒加载子节点 用户选中叶子节点 → handleAreaChange ├─ formData.householdLocation = "440802" (存 code) ├─ getItemFullPath API → "广东省湛江市赤坎区" ├─ formData.householdAreaName = "广东省湛江市赤坎区" (存路径) └─ householdValueObj.label = "广东省湛江市赤坎区" (下拉框显示) 保存 → 后端 code + 路径文本同时入库 【编辑回显】 编辑表单 → 直接用 householdAreaName 回填 valueObj → 下拉框显示完整路径,零 API 调用 【表格/详情/导出】 → 直接使用 householdAreaName 字段 → 无需字典翻译 ``` ### 6.9 三种树形字典模式对比 项目中存在三种树形字典使用模式,根据数据量和业务需求选择: | 模式 | 示例 | 数据量 | 后端接口 | 前端加载策略 | 编辑回显方式 | |------|------|--------|----------|-------------|-------------| | **全量加载** | EconomicIndustry(期望行业) | 几十条 | `getDictTree` | 一次性加载整棵树,组件自带搜索 | 前端 `findTreeNodeLabel` 递归查找,零 API | | **懒加载 + 双字段** | XZQH(行政区划) | 44K+ | `getDictTreeChildren` + `getItemFullPath` | 展开时按需加载子节点 | 直接用已存储的 `areaName` 字段,零 API | | **懒加载 + API 回显** | ZYFL(职业分类) | 2K+ | `getDictTreeChildren` + `loadDictItem` | 展开时按需加载子节点 | 调用 `loadDictItem` API 获取名称 | ### 6.10 树形字典与普通字典方案对比 | 环节 | 普通字典(如 Gender) | 树形字典(如 XZQH) | |------|----------------------|---------------------| | 数据量 | 小(几条~几十条) | 大(K 级~万级) | | 加载方式 | 前端预加载全部 | 懒加载,按需加载子节点(或小数据量全量加载) | | 编辑组件 | `` + `useDict` | `` + 自定义懒加载 | | 存储方式 | 只存 code(如 `1`) | 存 code(`440802`)+ 路径文本(`广东省湛江市赤坎区`) | | 表格显示 | `getDictText()` 翻译 code→label | 直接显示路径文本列 | | 详情显示 | `getDictText()` 翻译 | 直接显示路径文本列 | | Excel 导出 | `translateDictFields` 内存映射 | 直接导出路径文本列(`@Excel` 注解) | | 后端接口 | `/getDictBatch` 批量查询 | `/getDictTreeChildren` 懒加载 + `/getItemFullPath` 路径解析 | ### 6.11 关键源码文件索引 | 层级 | 文件 | 说明 | |------|------|------| | 后端 Controller | `jeecg-boot/.../dictionary/controller/DictionaryController.java` | 6 个接口:`getDictTree`/`getDictTreeChildren`/`loadDictItem`/`getItemFullPath`/`searchDictItem`/`getDict`/`getDictBatch` | | 后端 Service 接口 | `jeecg-boot/.../dictionary/service/IDictionaryItemService.java` | 树形字典核心方法声明 | | 后端 Service Impl | `jeecg-boot/.../dictionary/service/impl/DictionaryItemServiceImpl.java` | 全量树构建 `getDictTree`、懒加载 `getDictTreeChildren`、路径回溯 `getItemFullPath`、名称回显 `loadDictItem`、模糊搜索 `searchDictItem` | | 后端 Entity | `jeecg-boot/.../dictionary/entity/Dictionary.java` | 字典主表,`DictType` 字段区分普通/树形字典 | | 后端 Entity | `jeecg-boot/.../dictionary/entity/DictionaryItem.java` | 字典项,`ParentItemID` 字段实现树形层级 | | 后端 Entity | `jeecg-boot/.../personal/entity/PersonalInfo.java` | `householdAreaName`/`residenceAreaName` 完整路径字段 | | 后端 Controller | `jeecg-boot/.../personal/controller/PersonalInfoController.java` | `exportFields` 包含 areaName 列 | | 前端编辑表单 | `jeecgboot-vue3/src/views/recruitment/personal/components/PersonalInfoForm.vue` | XZQH 懒加载 + 双字段存储 + 路径回显 | | 前端编辑表单 | `jeecgboot-vue3/src/views/recruitment/personal/components/ResumeInfoForm.vue` | EconomicIndustry 全量加载 + ZYFL 懒加载 + loadDictItem 回显 | | 前端表格列配置 | `jeecgboot-vue3/src/views/recruitment/personal/PersonalInfo.data.ts` | `dataIndex: 'householdAreaName'` | | 前端详情弹窗 | `jeecgboot-vue3/src/views/recruitment/personal/components/PersonalInfoDetailModal.vue` | 直接显示 areaName 字段 | | SQL | `.docs/sql/个人信息与简历信息.sql` | ALTER TABLE 新增 areaName 列 | | SQL | `.docs/sql/数据字典-行政区划(XZQH).sql` | XZQH 字典数据插入脚本(44K+ 条) | | SQL | `.docs/sql/数据字典-职业分类(ZYFL).sql` | ZYFL 字典数据插入脚本(2K+ 条) | --- ## 7. 普通字典多选(以单位福利 UnitBenefits 为例) ### 7.1 方案概述 普通字典(扁平列表)也支持多选场景,存储方式为逗号分隔的 value 字符串(如 `"1,3,6"`),前端通过 `` 实现多选下拉,后端无需额外改动。 | 项目 | 说明 | |------|------| | **存储方案** | 实体字段存逗号分隔字符串,如 `"1,3,6"` | | **后端字典** | 同普通字典,`DICTIONARY_ITEM` 表,`getDictBatch` 批量加载 | | **前端编辑** | `` + `useDict` 预加载 + `getDictOptions` | | **编辑回显** | 将后端返回的逗号分隔字符串 `split(',')` 为数组 | | **提交保存** | 通用逻辑自动将数组 `join(',')` 为字符串 | | **详情展示** | `getDictTexts` 辅助函数,逐值翻译后用顿号拼接 | | **表格/导出** | 同普通字典,在 `translateDictFields` 中直接替换(字段值为逗号串) | ### 7.2 数据模型 实体字段存储逗号分隔的字典 value 字符串: ```typescript // Entity 字段定义(示例) private String companyBenefits; // 单位福利,如 "1,3,6" ``` ### 7.3 前端编辑表单实现 **位置**: `jeecgboot-vue3/src/views/recruitment/enterprise/components/EnterpriseInfoForm.vue` #### 7.3.1 预加载字典 在 `useDict` 中添加字典编码,计算属性获取选项列表: ```typescript const { getDictOptions } = useDict([ // ...其他字典 'UnitBenefits', ]); const companyBenefitsOptions = computed(() => getDictOptions('UnitBenefits')); ``` #### 7.3.2 模板 使用 ``,绑定选项列表: ```vue ``` `mode="multiple"` 使 `v-model` 绑定值为数组(如 `["1", "3", "6"]`)。 #### 7.3.3 编辑回显 后端返回的 `companyBenefits` 为逗号分隔字符串(如 `"1,3,6"`),需要在编辑时转为数组: ```typescript // 单位福利字段转换:将后端返回的逗号分隔字符串转为数组,适配 a-select mode="multiple" if (typeof tmpData.companyBenefits === 'string') { tmpData.companyBenefits = tmpData.companyBenefits ? tmpData.companyBenefits.split(',').filter((id) => id) : []; } else if (!tmpData.companyBenefits) { tmpData.companyBenefits = []; } ``` #### 7.3.4 提交保存 `submitForm()` 中已有通用逻辑自动处理数组→字符串转换(位置:`EnterpriseInfoForm.vue` 约第 697-703 行): ```typescript for (let data in model) { // 如果该数据是数组并且是字符串类型的字段,自动以逗号拼接 if (model[data] instanceof Array && data !== 'tagIds') { let valueType = getValueType(formRef.value.getProps, data); if (valueType === 'string') { model[data] = model[data].join(','); } } } ``` 提交到后端的 `companyBenefits` 值为 `"1,3,6"`。 ### 7.4 前端详情展示 **位置**: `jeecgboot-vue3/src/views/recruitment/enterprise/components/EnterpriseInfoDetailModal.vue` #### 7.4.1 预加载字典 ```typescript const { getDictText } = useDict([ // ...其他字典 'UnitBenefits', ]); ``` #### 7.4.2 getDictTexts 辅助函数 多选字典详情展示需要将逗号分隔的 value 逐一翻译为 label,然后用顿号拼接。新增 `getDictTexts` 方法: ```typescript /** * 翻译多选字典值:将逗号分隔的 value 串翻译为中文标签(用顿号分隔) * @param dictCode 字典编码 * @param valuesStr 逗号分隔的字典 value 字符串,如 "1,3,5" * @returns 中文标签字符串,如 "养老保险、失业保险、生育保险",无数据时返回 "-" */ function getDictTexts(dictCode: string, valuesStr: string | undefined | null): string { if (!valuesStr) return '-'; const values = valuesStr.split(',').filter((v) => v); return values.map((v) => getDictText(dictCode, v.trim())).join('、'); } ``` #### 7.4.3 模板使用 ```vue {{ getDictTexts('UnitBenefits', detailData.companyBenefits) }} ``` ### 7.5 Excel 导出 多选字典的导出与单值字典相同,在 `translateDictFields` 中处理。由于字段值本身就是逗号分隔字符串(如 `"1,3,6"`),在 Service 层的 `switch` 中直接整体替换即可: ```java case "companyBenefits": if (info.getCompanyBenefits() != null) { // 逗号分隔的多个 value 逐一替换为 label,再以逗号拼接 String[] values = info.getCompanyBenefits().split(","); List labels = new ArrayList<>(); for (String val : values) { labels.add(mapping.getOrDefault(val.trim(), val.trim())); } info.setCompanyBenefits(String.join("、", labels)); } break; ``` > **注意**:导出时拼接用中文顿号 `、`(与详情显示一致),而非逗号(逗号是存储格式)。 > 导出效果示例:`"养老保险、失业保险、生育保险"`。 ### 7.6 数据流 ``` 【新增/编辑】 用户多选 → formData.companyBenefits = ["1", "3", "6"](数组) 提交 → 通用逻辑 join(',') → "1,3,6"(字符串) 后端入库 → 存入字符串 "1,3,6" 【编辑回显】 后端返回 → "1,3,6" edit() 中 split(',') → ["1", "3", "6"] 渲染为已选项 【详情展示】 后端返回 → "1,3,6" getDictTexts() → 逐值翻译 → "养老保险、失业保险、生育保险" 【Excel 导出】 translateDictFields → 逐一替换 → "养老保险、失业保险、生育保险" ``` ### 7.7 与单值普通字典对比 | 环节 | 单值普通字典(如 Gender) | 多选普通字典(如 UnitBenefits) | |------|--------------------------|-------------------------------| | 存储方式 | 单个 value(如 `"1"`) | 逗号分隔的 values(如 `"1,3,6"`) | | 编辑组件 | ``(单选) | ``(多选) | | v-model 类型 | 字符串 | 字符串数组 | | 编辑回显 | 直接赋值 | `split(',')` 转为数组 | | 提交处理 | 直接提交 | 通用逻辑 `join(',')` 转为字符串 | | 详情显示 | `getDictText(code, value)` 单值翻译 | `getDictTexts(code, valuesStr)` 多值翻译 + 顿号拼接 | | Excel 导出 | 直接替换单值 | 逐值替换后以顿号拼接 |