# 个人基本信息模块开发记录 **日期:** 2026-06-04 ~ 2026-06-05 **最后更新:** 2026-06-15 --- ## 一、模块概述 个人基本信息模块(personal)是招聘管理系统中的核心基础模块,负责管理求职者的个人信息和简历信息。该模块采用 JeecgBoot 的前后端分离架构,前端基于 Vue3 + Ant Design Vue,后端基于 Spring Boot + MyBatis-Plus。 ### 业务数据关系 ``` PersonalInfo(个人信息) 1 ── N ResumeInfo(简历信息) │ │ 1 ── N ├── ResumeWorkExp(工作经历) ├── ResumeEducation(教育经历) ├── ResumeTraining(培训经历) ├── ResumeSkill(职业技能) ├── ResumeTitle(职称情况) ├── ResumeAward(获奖情况) └── ResumeStudyAbroad(留学经历) PersonalInfo(个人信息)── M:N ── PersonalTag(个人标签) 通过中间表 personal_tag_relation 关联 ``` - **PersonalInfo(个人信息)**:主表,记录求职者的基本资料(证件、姓名、联系方式、学历、求职状态等共 27 个业务字段,另有 4 个审计字段 + tagNames 虚拟字段) - **ResumeInfo(简历信息)**:关联表,与个人信息 N:1 关联,记录简历概要(简历名称、有效期、是否公开、是否默认、求职意向、自我优势等) - **7 个简历子表**:教育经历、工作经历、培训经历、职业技能、职称情况、获奖情况、留学经历 --- ## 二、模块文件结构 ### 后端 ``` jeecg-boot/jeecg-boot-module/jeecg-module-zjrs/src/main/java/org/jeecg/modules/zjrs/personal/ ├── controller/ │ ├── PersonalInfoController.java # 个人信息控制器 — 列表/增删改查/导入导出/标签关联 │ └── ResumeInfoController.java # 简历控制器 — 主表 CRUD + 7个子表独立 CRUD(21个接口) ├── dto/ │ ├── ImportResultItem.java # 导入结果行数据载体(行号 + 原始列值 + 错误信息) │ ├── PersonalInfoDTO.java # 个人信息 DTO(extends PersonalInfo,新增 List tagIds) │ └── ResumeInfoDTO.java # 简历 DTO(主表字段 + 7个子表List) ├── entity/ │ ├── PersonalInfo.java # 个人信息实体(27个业务字段 + tagNames虚拟字段) │ ├── ResumeInfo.java # 简历主表实体(14个业务字段) │ ├── ResumeWorkExp.java # 工作经历实体 │ ├── ResumeEducation.java # 教育经历实体 │ ├── ResumeTraining.java # 培训经历实体 │ ├── ResumeSkill.java # 职业技能实体 │ ├── ResumeTitle.java # 职称情况实体 │ ├── ResumeAward.java # 获奖情况实体 │ └── ResumeStudyAbroad.java # 留学经历实体 ├── mapper/ │ ├── PersonalInfoMapper.java # 个人信息 Mapper(无自定义方法) │ ├── ResumeInfoMapper.java # 简历 Mapper(无自定义方法) │ ├── ResumeWorkExpMapper.java # 工作经历 Mapper(含 deleteByMainId、selectByMainId) │ ├── ResumeEducationMapper.java # 教育经历 Mapper(同上) │ ├── ResumeTrainingMapper.java # 培训经历 Mapper(同上) │ ├── ResumeSkillMapper.java # 职业技能 Mapper(同上) │ ├── ResumeTitleMapper.java # 职称情况 Mapper(同上) │ ├── ResumeAwardMapper.java # 获奖情况 Mapper(同上) │ ├── ResumeStudyAbroadMapper.java # 留学经历 Mapper(同上) │ └── xml/ # 9个 XML 映射文件(均为空 namespace 声明,SQL 全部在 Mapper 接口注解中) ├── service/ │ ├── IPersonalInfoService.java # 个人信息 Service 接口(6个自定义方法) │ ├── IResumeInfoService.java # 简历 Service 接口(3个自定义方法) │ ├── IResumeWorkExpService.java 等7个 # 子表 Service 接口(无自定义方法,纯 IService 继承) │ └── impl/ │ ├── PersonalInfoServiceImpl.java # 个人信息 Service 实现(核心:标签保存/查询/导入导出/字典翻译/校验) │ ├── ResumeInfoServiceImpl.java # 简历 Service 实现(主表+子表联动:先删后插策略) │ ├── ResumeWorkExpServiceImpl.java 等7个 # 子表 Service 空实现 ├── vo/ │ ├── PersonalInfoVO.java # 个人信息 VO(extends PersonalInfo,新增 String tagIds) │ └── ResumeInfoPage.java # 简历详情 VO(主表字段 + 7个子表List) ``` ### 前端 ``` jeecgboot-vue3/src/views/recruitment/personal/ ├── PersonalInfo.api.ts # 个人信息 API(9个接口) ├── PersonalInfo.data.ts # 列表页表格列配置(14列,含年龄动态计算) ├── PersonalInfoList.vue # 个人信息列表主页面(搜索 + 表格 + 增删改查入口) ├── ResumeInfo.api.ts # 简历 API(6个接口) ├── ResumeInfo.data.ts # 子表列类型定义(7个子表的 JVxeTypes 列配置,供各模块复用) └── components/ ├── PersonalInfoModal.vue # 个人信息弹窗容器(管理 visible/title + :key 强制重建子组件) ├── PersonalInfoForm.vue # 个人信息编辑表单(30+字段 + 头像上传 + 行政区划懒加载 + 标签多选) ├── PersonalInfoDetailModal.vue # 个人信息只读详情弹窗(a-descriptions + a-image 头像) ├── PersonalInfoSelector.vue # 人员选择器输入框(可嵌入其他表单,单选/多选) ├── PersonalInfoSelectorModal.vue # 人员选择弹窗(带查询表格 + 跨页多选) ├── ResumeListModal.vue # 某人简历列表弹窗(支持快速新增) ├── ResumeInfoModal.vue # 简历弹窗容器 ├── ResumeInfoForm.vue # 简历编辑表单(主表 + 7个子表本地 CRUD 操作) ├── ResumeInfoDetailModal.vue # 简历只读详情弹窗 ├── ResumeWorkExpModal.vue # 工作经历弹窗(树形字典:所属行业/经济类型) ├── ResumeEducationModal.vue # 教育经历弹窗 ├── ResumeTrainingModal.vue # 培训经历弹窗 ├── ResumeSkillModal.vue # 职业技能弹窗 ├── ResumeTitleModal.vue # 职称情况弹窗 ├── ResumeAwardModal.vue # 获奖情况弹窗 └── ResumeStudyAbroadModal.vue # 留学经历弹窗(国家过滤掉中国) ``` --- ## 三、后端架构详情 ### 3.1 Controller 层 #### PersonalInfoController | 属性 | 说明 | |------|------| | 路径映射 | `@RequestMapping("/personal/personalInfo")` | | 继承 | `JeecgController` | | 方法 | HTTP | 权限 | 功能描述 | |------|------|------|---------| | `queryPageList()` | GET `/list` | 无 | 分页查询,调用 `queryPageListWithTags()` 返回含标签名的 `IPage` | | `add(PersonalInfoDTO)` | POST `/add` | `personal:personal_info:add` | 校验必填字段 → 调用 `saveWithTags()` 保存人员+标签关联 | | `edit(PersonalInfoDTO)` | PUT `/edit` | `personal:personal_info:edit` | 校验必填字段 → 调用 `updateWithTags()` 更新人员+标签关联 | | `delete(String id)` | DELETE `/delete` | `personal:personal_info:delete` | 通过 ID 删除 | | `deleteBatch(String ids)` | DELETE `/deleteBatch` | `personal:personal_info:deleteBatch` | 批量删除 | | `queryById(String id)` | GET `/queryById` | 无 | 返回 `PersonalInfoVO`(含标签ID列表) | | `exportXls(...)` | GET `/exportXls` | `personal:personal_info:exportXls` | 三步导出:组装查询条件 → 查数据并填充标签+字典翻译 → AutoPoi 导出(13列) | | `importExcel(...)` | POST `/importExcel` | `personal:personal_info:importExcel` | 解析 Excel → `importExcelData()` 字典反向翻译+校验+保存 → 错误行返回 | | `importTemplate(...)` | GET `/importTemplate` | `personal:personal_info:importExcel` | 生成含 27 字段表头 + 示例数据的导入模板 | **导出字典翻译映射(EXPORT_DICT_FIELD_MAP):** `gender→Gender, nation→Ethnicity, nationality→Nationality, education→Education, jobSeekerCategory→JobSeekerCategory, jobSearchStatus→JobSeekerStatus, dataSource→DataSource` #### ResumeInfoController | 属性 | 说明 | |------|------| | 路径映射 | `@RequestMapping("/personal/resumeInfo")` | | 注入依赖 | `IResumeInfoService` + 7 个子表 Mapper | **主表接口:** | 方法 | HTTP | 功能描述 | |------|------|---------| | `queryPageList()` | GET `/list` | 分页查询 | | `add(ResumeInfoDTO)` | POST `/add` | 调用 `saveMainAndSub()` 保存主表+7个子表 | | `addBasic(ResumeInfo)` | POST `/addBasic` | 快速新增简历(仅基本信息) | | `edit(ResumeInfoDTO)` | PUT `/edit` | 调用 `updateMainAndSub()` 更新主表+子表(先删后插) | | `editMain(ResumeInfo)` | PUT `/editMain` | 仅更新主表,不影响子表 | | `delete(String id)` | DELETE `/delete` | 调用 `deleteMainWithSubs()` 级联删除 | | `queryById(String id)` | GET `/queryById` | 组装 `ResumeInfoPage`(主表+7个子表列表) | **7 个子表独立查询接口:** 每个子表提供 `/queryXxxListByMainId` 接口(共 7 个) **7 个子表独立 CRUD 接口(21 个,供小程序端使用):** 每个子表提供 `addXxx`/`editXxx`/`deleteXxx` 三个接口(如 `addWorkExp`/`editWorkExp`/`deleteWorkExp`) ### 3.2 Entity 层 所有 Entity 共同特征: - `@Data`, `@Accessors(chain = true)`, `@EqualsAndHashCode(callSuper = false)`, `implements Serializable` - 主键使用 `@TableId(type = IdType.ASSIGN_ID)` - 通用审计字段:`createBy`, `createTime`, `updateBy`, `updateTime`, `sysOrgCode` **数据库保留字特殊处理:** - `ResumeWorkExp` 中的 `position` 字段使用 `@TableField("\"POSITION\"")` 转义 - `ResumeTitle` 中的 `level` 字段使用 `@TableField("\"LEVEL\"")` 转义 **PersonalInfo 关键字段(共 27 个业务字段):** `idType`(证件类型), `idNumber`(证件号码), `fullName`(姓名), `gender`(性别), `birthDate`(出生日期), `nation`(民族), `nationality`(国籍), `maritalStatus`(婚姻状况), `education`(学历), `graduationDate`(毕业日期), `graduateSchool`(毕业院校), `major`(专业), `politicalStatus`(政治面貌), `workExperience`(工作经验), `householdType`(户口性质), `householdLocation`(户口所在地代码), `currentResidence`(现居住地代码), `currentAddress`(现居住地址), `householdAreaName`(户口所在地完整路径), `residenceAreaName`(现居住地完整路径), `jobSeekerCategory`(求职人员类别), `contactPhone`(联系电话), `email`(邮箱), `qqNumber`(QQ号), `wechatId`(微信号), `isOverseasTalent`(是否留学人才, 1/0), `skillLevel`(职业技能等级), `jobSearchStatus`(求职状态), `acceptRecommend`(是否接受推荐, 1/0), `avatarPath`(头像路径), `dataSource`(数据来源, 默认 '2') **虚拟字段:** - `tagNames`:通过 `@TableField(exist = false)` 标注为非数据库字段,由 Service 层的 `populateTagNames()` / `queryPageListWithTags()` 方法动态填充 ### 3.3 DTO 层 **PersonalInfoDTO**:继承 `PersonalInfo`,新增 `List tagIds`,用于新增/编辑时接收前端提交的标签 ID 列表。 **ResumeInfoDTO**:独立的 DTO 类(非继承),包含主表字段 + 7 个子表 List: - `List resumeWorkExpList` - `List resumeEducationList` - `List resumeTrainingList` - `List resumeSkillList` - `List resumeTitleList` - `List resumeAwardList` - `List resumeStudyAbroadList` 必填校验注解:`personalId`(`@NotBlank`), `expectedPosition`(`@NotBlank`), `positionName`(`@NotBlank`), `expectedSalary`(`@NotNull`), `workLocation`(`@NotBlank`), `workNature`(`@NotBlank`) **ImportResultItem**:导入结果行载体。 - `int rowNum` — Excel 行号 - `Map rowData` — 原始列值(列名→值) - `String errorMsg` — 错误信息(null 表示校验通过) ### 3.4 VO 层 **PersonalInfoVO**:继承 `PersonalInfo`,新增 `String tagIds`(逗号分隔的标签 ID 列表,用于表单回显及新增/编辑数据传递)。注意:tagNames 字段在 PersonalInfo 父类的 `@TableField(exist = false)` 中,VO 直接继承使用。 **ResumeInfoPage**:独立 VO 类,包含主表字段 + 7 个 `@ExcelCollection` 注解的子表 List,用于 `queryById` 返回完整简历信息。 ### 3.5 Mapper 层 | Mapper | 自定义方法 | |--------|----------| | `PersonalInfoMapper` | 无,纯 `BaseMapper` 继承 | | `ResumeInfoMapper` | 无,纯 `BaseMapper` 继承 | | 7 个子表 Mapper | 各含 2 个注解 SQL:`deleteByMainId(mainId)` 按简历ID删除、`selectByMainId(mainId)` 按简历ID查询列表 | 所有 XML 文件均为空 namespace 声明文件,所有 SQL 通过 Mapper 接口的 `@Select`/`@Delete` 注解实现。 ### 3.6 Service 层 #### PersonalInfoServiceImpl(核心,约 400 行) | 依赖注入 | 用途 | |---------|------| | `IDictionaryItemService` | 字典数据查询(翻译) | | `PersonalTagRelationMapper` | 标签关联表的 Mapper | | `PersonalTagMapper` | 标签表的 Mapper | | `DictionaryItemMapper` | 字典项 Mapper(批量查询用) | **核心业务方法:** | 方法 | 事务 | 功能 | |------|------|------| | `saveWithTags(DTO)` | `@Transactional` | 校验证件号重复 → 保存人员 → 保存标签关联 | | `updateWithTags(DTO)` | `@Transactional` | 校验证件号重复(排除自身)→ 更新人员 → 删旧关联 → 插入新关联 | | `deleteTagRelations(personalId)` | — | 按 personalId 删除所有旧的标签关联 | | `saveTagRelations(personalId, tagIds)` | — | 遍历 tagIds 逐个创建 `PersonalTagRelation` 并插入 | | `queryPersonalInfoVOById(id)` | — | 查询 Person → 复制到 VO → 查标签关联 → 查标签名 → 设置 tagIds | | `queryPageListWithTags(page, params)` | — | 分页查询 → 批量查标签关联(按 personIds 收集分组)→ 批量查标签名 → 构建 VO 分页结果(两次批量查询避免 N+1) | | `populateTagNames(list)` | — | 批量填充导出数据的标签名称 | | `translateDictFields(list)` | — | 导出字典翻译:批量查字典 → 构建 value→label 映射 → switch 替换字段值 | | `importExcelData(list)` | — | 导入核心逻辑(见下方"导入流程") | | `validateRequiredFields(entity)` | — | 校验 18 个必填字段,校验失败抛异常 | | `checkIdNumberDuplicate(idNumber, excludeId)` | — | 查询同证件号数量,重复抛 `JeecgBootBizTipException` | **导入流程(importExcelData):** 1. 批量查询字典构建 文本→字典码 反向映射(用于将 Excel 中的中文标签转为字典代码) 2. 收集证件号码,检查 Excel 内部重复(同一证件号多行) 3. 检查数据库已存证件号是否重复 4. 逐行处理:捕获原始列值 → 字典文本反向翻译 → 必填字段校验 → 补充成对字段(`householdLocation`↔`householdAreaName`、`currentResidence`↔`residenceAreaName`)→ 设置 `dataSource="2"` 5. 有错误返回全部行(含原始值+错误信息),无错误批量保存 **导入字典反向翻译映射(IMPORT_DICT_FIELD_MAP):** `idType→IDType, gender→Gender, nation→Ethnicity, nationality→Nationality, maritalStatus→MaritalStatus, education→Education, politicalStatus→PoliticalStatus, workExperience→WorkExperience, householdType→HouseholdType, jobSeekerCategory→JobSeekerCategory, skillLevel→SkillLevel, jobSearchStatus→JobSeekerStatus` #### ResumeInfoServiceImpl | 依赖注入 | 用途 | |---------|------| | 7 个子表 Mapper | 子表 CRUD 操作 | **核心业务方法:** | 方法 | 事务 | 功能 | |------|------|------| | `saveMainAndSub(DTO)` | `@Transactional` | 保存主表 → 如果 `isDefault="1"` 则清除该人员其他简历的默认状态 → 遍历 7 个子表分别设置 resumeId 后批量插入 | | `updateMainAndSub(DTO)` | `@Transactional` | 更新主表 → 处理默认简历逻辑 → 删除 7 个子表旧数据(`deleteByMainId`)→ 遍历插入 7 个子表新数据(先删后插策略) | | `deleteMainWithSubs(id)` | `@Transactional` | 删除 7 个子表数据 → 删除主表数据 | | `clearOtherDefaultResumes(personalId, resumeId)` | — | 通过 `lambdaUpdate()` 将同一人员下其他简历的 `isDefault` 设为 "0" | #### 子表 Service(7 个) 全部为纯模板实现:继承 `ServiceImpl` 实现 `IXxxService`,无自定义方法。 --- ## 四、前端架构详情 ### 4.1 页面组件层级关系 ``` PersonalInfoList.vue(列表主页) ├── PersonalInfoModal.vue(新增/编辑弹窗容器) │ └── PersonalInfoForm.vue(编辑表单) ├── PersonalInfoDetailModal.vue(只读详情弹窗) └── ResumeListModal.vue(简历列表弹窗) ├── ResumeInfoModal.vue(简历编辑弹窗容器) │ └── ResumeInfoForm.vue(简历编辑表单) │ ├── ResumeWorkExpModal.vue │ ├── ResumeEducationModal.vue │ ├── ResumeTrainingModal.vue │ ├── ResumeSkillModal.vue │ ├── ResumeTitleModal.vue │ ├── ResumeAwardModal.vue │ └── ResumeStudyAbroadModal.vue └── ResumeInfoDetailModal.vue(简历详情弹窗) PersonalInfoSelector.vue(人员选择器输入框) └── PersonalInfoSelectorModal.vue(人员选择弹窗) ``` ### 4.2 API 层 #### PersonalInfo.api.ts | 函数 | HTTP | 接口路径 | 说明 | |------|------|------|------| | `list(params)` | GET | `/personal/personalInfo/list` | 分页查询 | | `queryById(id)` | GET | `/personal/personalInfo/queryById` | 查询详情(含标签) | | `saveOrUpdate(params, isUpdate)` | POST | `/personal/personalInfo/add` 或 `/edit` | 新增/编辑 | | `deleteOne(params, onSuccess)` | DELETE | `/personal/personalInfo/delete` | 单个删除 | | `batchDelete(params, onSuccess)` | DELETE | `/personal/personalInfo/deleteBatch` | 批量删除(带确认框) | | `getPersonalTagListForSelect()` | GET | `/tag/personalTag/listForSelect` | 获取标签下拉列表 | #### ResumeInfo.api.ts | 函数 | HTTP | 接口路径 | 说明 | |------|------|------|------| | `resumeList(params)` | GET | `/personal/resumeInfo/list` | 分页查询(含 personalId 过滤) | | `queryResumeById(id)` | GET | `/personal/resumeInfo/queryById` | 查询简历详情(含子表) | | `saveResume(params)` | POST | `/personal/resumeInfo/add` | 新增简历 | | `editResume(params)` | PUT | `/personal/resumeInfo/edit` | 编辑简历 | | `deleteResume(params, onSuccess)` | DELETE | `/personal/resumeInfo/delete` | 删除简历 | | `addBasicResume(params)` | POST | `/personal/resumeInfo/addBasic` | 快速新增简历 | ### 4.3 数据定义 #### PersonalInfo.data.ts 导出 14 列表格配置,包含: - 序号、姓名、性别(字典 Gender)、**年龄**(根据 `birthDate` 通过 `customRender` 动态计算周岁)、学历(字典 Education)、民族(字典 Ethnicity)、国籍(字典 Nationality)、户口所在地(`householdAreaName`)、现居住地(`residenceAreaName`)、求职人员类别(字典 JobSeekerCategory)、联系电话、个人标签(`tagNames`,长文本省略号)、求职状态(字典 JobSeekerStatus)、数据来源(字典 DataSource) #### ResumeInfo.data.ts 导出 8 个列配置数组(供 JVxeTable 等组件复用),每个数组使用 `JVxeTypes` 定义列类型和校验规则: | 导出变量 | 对应子表 | 列数 | 特殊列类型 | |----------|---------|------|-----------| | `workExpColumns` | 工作经历 | 10 | slot(单位类型/人员规模), textarea(工作职责/工作业绩) | | `educationColumns` | 教育经历 | 8 | slot(学历/学位/学制) | | `trainingColumns` | 培训经历 | 5 | — | | `skillColumns` | 职业技能 | 7 | slot(技能等级) | | `titleColumns` | 职称情况 | 7 | slot(级别) | | `awardColumns` | 获奖情况 | 8 | textarea(成果说明/其他说明) | | `studyAbroadColumns` | 留学经历 | 8 | — | ### 4.4 列表主页面 — PersonalInfoList.vue **功能概述:** 个人信息管理主页面,包含搜索区、操作工具栏、数据表格、导入导出。 **查询条件:** 姓名、性别、学历、户口所在地(XZQH 树形懒加载)、现居住地点(XZQH 树形懒加载)、年龄范围(`ageBegin`/`ageEnd` → 实际查询时转为 `birthDate_begin`/`birthDate_end`)、求职人员类别、求职状态、数据来源 **行政区划树加载:** 使用 XZQH 字典,通过 `loadAreaRootNodes()` 加载根节点,`loadSearchTreeChildren()` 实现子节点懒加载 **关键操作流程:** | 操作 | 流程 | |------|------| | 新增 | `handleAdd()` → `registerModal.add()` → 打开 PersonalInfoModal | | 编辑 | `handleEdit(record)` → 先调 `queryById` 获取含标签完整数据 → `registerModal.edit(fullRecord)` | | 详情 | `handleDetail(record)` → 先调 `queryById` 获取含标签完整数据 → `personalInfoDetailModal.open(fullRecord)` | | 个人简历 | `handleResume(record)` → `resumeListModal.open(record)` | | 单个删除 | `handleDelete(record)` → `deleteOne()` | | 批量删除 | `batchHandleDelete()` → `batchDelete()` | | 导入 | `CustomImportModal` 组件 | | 导出 | `getExportUrl()` → 直接下载 | **表格操作按钮:** "编辑"、"个人简历"为直接操作按钮;"详情"、"删除"在下拉菜单中 ### 4.5 个人信息弹窗容器 — PersonalInfoModal.vue **设计要点:** - 使用 `formKey` ref + `nextFormKey()` 方法 + `` 实现每次打开弹窗强制重建子组件,解决"先编辑后详情表单未禁用"的问题 - 通过 `disableSubmit` prop 控制确认按钮显隐(详情场景隐藏确认按钮) - 对外 expose 方法:`add`、`edit`、`disableSubmit` ### 4.6 个人信息编辑表单 — PersonalInfoForm.vue **表单字段(30+):** | 区域 | 字段 | |------|------| | 基本信息 | 证件类型、证件号码、姓名、性别、出生日期 | | 身份信息 | 民族、国籍、婚姻状况、政治面貌、是否留学人才 | | 教育信息 | 学历、毕业日期、毕业院校、专业 | | 工作/技能 | 工作经验、职业技能等级 | | 户籍/居住 | 户口性质、户口所在地(XZQH 树形懒加载)、现居住地(XZQH 树形懒加载)、现居住地址 | | 联系方式 | 联系电话、邮箱、QQ号码、微信号 | | 求职信息 | 求职人员类别、求职状态、是否接受推荐 | | 标签/头像 | 个人标签(多选下拉)、个人头像(`JImageUpload`)、数据来源(默认"2") | **必填校验(18 个字段):** idType, idNumber, fullName, gender, birthDate, nation, nationality, education, graduationDate, graduateSchool, workExperience, householdLocation, householdAreaName, currentResidence, residenceAreaName, jobSeekerCategory, contactPhone, isOverseasTalent, jobSearchStatus, acceptRecommend **行政区划处理:** - 使用 `a-tree-select` + 懒加载展示行政区划树 - 选择节点时调用后端 API 获取完整路径(如"广东省/湛江市/赤坎区"),分别存入 `householdAreaName` 和 `residenceAreaName` - `householdLocation` / `currentResidence` 存储叶子节点的字典代码 **个人标签处理:** - `a-select mode="multiple"` 多选下拉 - 编辑时后端返回逗号分隔的 `tagIds` 字符串,前端转为数组回显(空字符串转为 `[]`) - 提交时 `tagIds` 保持数组原样(不执行 `.join(',')`),因为后端 DTO 接收 `List` **日期处理:** 后端返回 `"yyyy-MM-dd"` 字符串,`edit()` 中转为 `dayjs` 对象赋值给 `a-date-picker`;`submitForm()` 中通过 `dayjs().format('YYYY-MM-DD')` 还原为字符串提交 ### 4.7 个人信息详情弹窗 — PersonalInfoDetailModal.vue **设计特点:** - 全屏弹窗,1000px 宽度 - 左侧 a-image 展示头像(150x150px),使用 `getFileAccessHttpUrl` 获取文件 URL - 右侧 a-descriptions(`:column="3" bordered`)展示 30+ 字段,与编辑表单布局对应 - 所有字典字段通过 `useDict` + `getDictText` 自动转为中文显示(使用 12 个字典) - 1/0 字段(是否留学人才、是否接受推荐)通过 `formatYesNo()` 转为"是/否" - 底部仅保留"关闭"按钮 ### 4.8 人员选择器 — PersonalInfoSelector / PersonalInfoSelectorModal **PersonalInfoSelector.vue:** 可嵌入其他表单的人员选择输入框组件 - 支持 `selectionType='single'/'multiple'` - 显示已选人员姓名(多选用顿号分隔),只读输入框 + 搜索图标 - 支持清除 **PersonalInfoSelectorModal.vue:** 选择弹窗 - 与 PersonalInfoList 类似的搜索区域 - 表格行选择(`selectionType='radio'/'checkbox'`) - 支持跨页选择(切换分页不会丢失已选记录) - 确认后 emit `select(records)` 回传已选人员列表 ### 4.9 简历列表弹窗 — ResumeListModal.vue **功能:** 查看指定人员的简历列表,支持查看/编辑/删除简历 + 快速新增简历 - 快速新增简历内置简化表单(简历名称、有效期 默认60天后、是否公开 默认是、是否默认简历 默认否) - 调用 `addBasicResume` API 快速新增,不触发完整的 ResumeInfoModal - 编辑/查看简历分别调用 ResumeInfoModal / ResumeInfoDetailModal ### 4.10 简历编辑表单 — ResumeInfoForm.vue **核心设计模式:** - 主表表单(8 个字段)+ 7 个子表区域 - 每个子表:分隔线标题 + 新增按钮 + a-table + 操作列(编辑/删除) - 子表操作全部在本地 `reactive dataSource` 数组中完成(增删改),临时 ID 使用 `__temp_N` 格式 - 最终提交时收集全部 7 个子表数据,组装 `ResumeInfoDTO` 统一提交 **期望行业/职位:** - 期望行业:从 EconomicIndustry 字典全量加载为树形下拉 - 期望职位:从 ZYFL 字典懒加载为树形下拉 **子表弹窗通用模式:** 各子表弹窗(如 ResumeWorkExpModal)通过 `defineProps/defineEmits` 对外开放: - `open(record?)` — 打开弹窗,带数据回填 - `emit('success', record)` — 编辑/新增确认后回传数据给父表单 - 日期回填:字符串 → dayjs;提交:dayjs → 'YYYY-MM-DD' ### 4.11 简历详情弹窗 — ResumeInfoDetailModal.vue - 基本信息区(a-descriptions)+ 7 个子表分区(各用 a-table 展示) - 子表字典字段通过 `getDictText` 转换 - 期望职位(ZYFL)因数据量大采用懒加载,无法直接用 `getDictText`,改为调用 API 获取完整名称 ### 4.12 各子表弹窗组件特性 | 组件 | 特殊处理 | |------|---------| | ResumeWorkExpModal | 所属行业(EconomicIndustry)和经济类型(EconomicType)使用 `getDictTree` 全量加载(因为这些字典 Code 列为空,`getDictTreeChildren` 会返回全为空 key,导致选中 bug) | | ResumeEducationModal | 字典:Education、Degree、EduSystem | | ResumeTrainingModal | 纯文本框表单,无字典依赖 | | ResumeSkillModal | 字典:SkillLevel | | ResumeTitleModal | 字典:TitleLevel | | ResumeAwardModal | 纯文本框/文本区域表单,无字典依赖 | | ResumeStudyAbroadModal | 国家/地区下拉从 Nationality 字典过滤掉"中国" | --- ## 五、开发记录 ### 功能开发 | 日期 | 内容 | 说明 | |------|------|------| | 2026-06-04 | 个人基本信息模块初始构建 | 完成个人信息与简历管理的基础 CRUD 功能,前后端共 50+ 文件 | | 2026-06-04 | 详情查看时表单未禁用修复(先编辑后详情场景) | 详见下方 Bug 修复记录 | | 2026-06-04 | 打开编辑弹窗多个控制台错误修复 | 详见下方 Bug 修复记录 | | 2026-06-05 | 详情查看改用独立的 a-descriptions 详情弹窗 | 详见下方功能变更记录 | | 2026-06-05 | 人员编辑表单增加个人标签字段 | 详见下方功能变更记录 | | 2026-06-05 | 导入/导出功能实现 | 模板下载 + 导入校验 + 导出(含字典翻译和标签填充) | ### Bug 修复 #### 2026-06-04:详情查看时表单未禁用修复(先编辑后详情场景) **问题描述:** 在个人信息列表页,先点击"编辑"后关闭弹窗,再点击"详情"时,表单字段和图片上传组件未处于禁用/只读状态。 **复现条件:** 页面刷新后先点编辑再点详情才会出现;页面刷新后直接点详情则正常禁用。 **原因分析:** Ant Design Vue 的 `` 默认 `destroyOnClose=false`,弹窗关闭时内部组件(PersonalInfoForm)仍然挂载在 DOM 中。先点击"编辑"时,PersonalInfoForm 以 `formDisabled=false` 的状态挂载;关闭弹窗后组件保持挂载;再点击"详情"时,虽然 `disableSubmit` 被设为 `true`,但由于组件实例未重新创建,props 的响应式更新未能正确触发 JFormContainer 的 `
` 状态变更。 **最终修复方案:** 使用 `:key` 方式强制组件重建。 - 在 PersonalInfoModal 中引入 `formKey` ref 和 `nextFormKey()` 方法 - 在 `` 上绑定 `:key="formKey"` - 每次打开弹窗(add/edit)时先调用 `nextFormKey()` 递增 key,触发 Vue 重建组件实例 - 由于组件始终挂载在 DOM 中(非 `v-if` 控制),ref 在重建完成后自然可用 **涉及文件:** | 文件 | 操作 | 原因 | |------|------|------| | `jeecgboot-vue3/src/views/recruitment/personal/components/PersonalInfoModal.vue` | **修改** | 引入 `formKey` + `nextFormKey()`,`:key` 绑定强制重建子组件 | --- #### 2026-06-04:打开编辑弹窗多个控制台错误修复 **问题描述:** 打开编辑弹窗时控制台出现 5 类错误: 1. `FormItem can only collect one field item` — 年龄搜索字段两个 `AInputNumber` 在同一 `a-form-item` 中 2. `date.locale is not a function` — 日期字符串未转为 dayjs 对象 3. `Cannot read properties of null (reading 'type')` — 日期错误的级联报错 4. `Cannot read properties of null (reading 'submitForm')` — registerForm.value 为空 5. `Cannot read properties of undefined (reading '__asyncLoader')` — 组件异步加载异常 **原因分析与修复方案:** | 错误 | 原因 | 修复 | |------|------|------| | FormItem 警告 | 年龄范围搜索字段中两个 `AInputNumber` 被同一个 `a-form-item` 收集 | 用 `` 包裹第二个 `AInputNumber`,告知 Form 不收集该字段 | | `date.locale is not a function` | API 返回的日期字符串 `"2000-01-15"` 直接赋给 `a-date-picker` 的 `v-model:value`,但 `a-date-picker` 期望 dayjs 对象 | 在 `PersonalInfoForm.edit()` 方法中将 `birthDate` 和 `graduationDate` 字符串转为 `dayjs()` 对象 | | 级联错误 | 日期转换错误导致 `a-date-picker` 内部操作失败 | 修复日期转换后自动解决 | | `submitForm` 报错 | `v-if="visible"` 导致组件未挂载时 ref 为 null | 改用 `:key` 方式重建组件(同修复1) | | `__asyncLoader` 报错 | 组件重建时序问题导致异步组件加载异常 | 改用 `:key` 重建后解决 | **涉及文件:** | 文件 | 操作 | 原因 | |------|------|------| | `jeecgboot-vue3/src/views/recruitment/personal/components/PersonalInfoForm.vue` | **修改** | edit 方法增加日期字符串转 dayjs 对象逻辑;引入 dayjs 依赖 | | `jeecgboot-vue3/src/views/recruitment/personal/components/PersonalInfoModal.vue` | **修改** | 改用 `:key` 替代 `v-if` | | `jeecgboot-vue3/src/views/recruitment/personal/PersonalInfoList.vue` | **修改** | 年龄搜索字段用 `` 包裹第二个 `AInputNumber` | --- ### 功能变更记录 #### 2026-06-05:详情查看改用独立的 a-descriptions 详情弹窗 **问题描述:** 原详情查看功能复用 `PersonalInfoModal` + `PersonalInfoForm`,打开的是禁用状态的编辑表单,布局与编辑界面相同但字段不可编辑,用户体验不佳。 **解决方案:** 新建独立的 `PersonalInfoDetailModal` 组件,使用 `a-descriptions` 描述列表以只读方式展示数据。 **实现要点:** - 使用 `a-descriptions :column="3" bordered` 呈现三列带边框的字段列表,布局与编辑表单完全一致(10 行字段) - 使用 `a-image` 正方形组件展示头像(150x150px),与描述列表形成左右布局(左侧 span=2,右侧 span=22) - 字典字段(证件类型、性别、婚姻状况、政治面貌、工作经验、户口性质、求职人员类别、求职状态、数据来源、学历、民族、国籍)通过 `getDictText` 自动转为中文显示 - 弹窗默认为全屏模式(`:fullscreen="true"`),底部仅保留「关闭」按钮 - 修复 `a-image` 预览按钮铺满父容器的问题:外层包裹 `display: inline-block` 的 div 约束宽度 **涉及文件:** | 文件 | 操作 | 原因 | |------|------|------| | `jeecgboot-vue3/src/views/recruitment/personal/components/PersonalInfoDetailModal.vue` | **新增** | 独立的只读详情弹窗,使用 a-descriptions 展示 | | `jeecgboot-vue3/src/views/recruitment/personal/PersonalInfoList.vue` | **修改** | 导入新组件,修改 `handleDetail` 方法调用 `personalInfoDetailModal.open()` | --- #### 2026-06-05:人员编辑表单增加个人标签字段 **需求描述:** 在人员编辑表单(PersonalInfoForm)中增加"个人标签"字段,支持多选,数据来源于个人标签库(PersonalTag)。新增/编辑人员时同步保存标签关联关系,详情弹窗展示标签名称。 **实现方案:** **后端:** - 新增 `PersonalInfoDTO`(extends PersonalInfo),含 `List tagIds`,用于新增/编辑时接收前端提交的标签 ID 列表 - 新增 `PersonalInfoVO`(extends PersonalInfo),含 `String tagIds`(逗号分隔,表单回显用),tagNames 继承自父类的虚拟字段 - `PersonalTagController` 新增 `/listForSelect` 接口,返回所有已启用的标签 `{id, tagName}` 列表 - `PersonalInfoServiceImpl` 注入 `PersonalTagRelationMapper` 和 `PersonalTagMapper`,提供 `saveWithTags`/`updateWithTags` 方法,在保存人员基本信息后同步维护 `personal_tag_relation` 关联表 - `PersonalInfoController` 的 `/add` 和 `/edit` 接收 `PersonalInfoDTO`,`/queryById` 返回 `PersonalInfoVO`;分页列表 `/list` 返回带标签名的 VO 列表 **前端:** - `PersonalInfo.api.ts` 新增 `queryById` 和 `getPersonalTagListForSelect` API - `PersonalInfoForm.vue` 在"数据来源"前添加"个人标签"多选下拉(`a-select mode="multiple"`),编辑时自动将后端返回的逗号分隔字符串转为数组回显 - `PersonalInfoDetailModal.vue` 新增"个人标签"展示行 - `PersonalInfoList.vue` 的 `handleEdit`/`handleDetail` 改为先调用 `queryById` 获取含标签的完整数据再传给弹窗 **Bug 修复(2026-06-05):** 1. **tagIds 空字符串导致选中空选项:** `queryById` 返回 `tagIds: ""` 时,`edit` 方法中转换条件用 `tmpData.tagIds && ...` 会因空字符串为 falsy 跳过转换,`Object.assign` 把 `""` 赋给 `formData.tagIds`,`a-select mode="multiple"` 收到非数组后误渲染空白选项。修复:改用 `typeof tmpData.tagIds === 'string'` 判断,空字符串转为 `[]`。 2. **tagIds 数组被误转为字符串导致 JSON 解析失败:** `submitForm` 的循环中,`getValueType` 将 `tagIds` 误判为 `string` 类型,执行 `.join(',')` 转为逗号字符串,后端 DTO 接收 `List` 时报 `Cannot deserialize from String value`。修复:在循环条件中排除 `tagIds`(`data !== 'tagIds'`),保持数组原样提交。 **涉及文件:** | 文件 | 操作 | 原因 | |------|------|------| | `jeecg-boot/../personal/dto/PersonalInfoDTO.java` | **新增** | DTO 类,extends PersonalInfo,含 `List tagIds`,用于 add/edit 接收前端标签数据 | | `jeecg-boot/../personal/vo/PersonalInfoVO.java` | **新增** | VO 类,extends PersonalInfo,含 `String tagIds`,用于 queryById 返回含标签的完整数据 | | `jeecg-boot/../personal/service/IPersonalInfoService.java` | **修改** | 新增 `saveWithTags`、`updateWithTags`、`queryPersonalInfoVOById`、`queryPageListWithTags` 方法声明 | | `jeecg-boot/../personal/service/impl/PersonalInfoServiceImpl.java` | **修改** | 注入 `PersonalTagRelationMapper` 和 `PersonalTagMapper`;实现标签关联保存/更新/查询逻辑;分页查询批量填充标签名 | | `jeecg-boot/../personal/controller/PersonalInfoController.java` | **修改** | add/edit 接收 `PersonalInfoDTO`;queryById 返回 `PersonalInfoVO`;页面列表返回含标签名的 VO | | `jeecg-boot/../tag/controller/PersonalTagController.java` | **修改** | 新增 `/listForSelect` 接口,返回已启用的标签列表供前端下拉选择 | | `jeecgboot-vue3/../personal/PersonalInfo.api.ts` | **修改** | 新增 `queryById` 和 `getPersonalTagListForSelect` API | | `jeecgboot-vue3/../personal/PersonalInfoList.vue` | **修改** | handleEdit/handleDetail 改为调用 queryById 获取含标签的完整数据 | | `jeecgboot-vue3/../personal/components/PersonalInfoForm.vue` | **修改** | 添加个人标签多选下拉;编辑时字符串→数组回显;提交时排除 tagIds 的数组→字符串转换 | | `jeecgboot-vue3/../personal/components/PersonalInfoDetailModal.vue` | **修改** | 新增个人标签展示行 | --- #### 2026-06-05:简历主表+子表联动编辑功能 **功能描述:** 简历编辑支持主表字段 + 7 个子表(工作经历、教育经历、培训经历、职业技能、职称情况、获奖情况、留学经历)的新增/编辑。 **实现方案:** **后端:** - `ResumeInfoDTO` 包含主表字段 + 7 个子表 List - `ResumeInfoServiceImpl.saveMainAndSub()` 事务方法:保存主表 → 处理默认简历逻辑 → 遍历插入 7 个子表 - `ResumeInfoServiceImpl.updateMainAndSub()` 事务方法:更新主表 → 先删后插 7 个子表 - `ResumeInfoController` 额外提供 21 个子表独立 CRUD 接口(小程序端使用) **前端:** - `ResumeInfoForm.vue` 主表单 + 7 个子表 area,子表操作在本地 dataSource 执行,最终统一提交 - 7 个子表弹窗组件(ResumeWorkExpModal 等)各自处理编辑逻辑,通过 emit 回传数据 - `ResumeInfoDetailModal.vue` 只读展示主表+7个子表 **涉及文件:** | 文件 | 操作 | 原因 | |------|------|------| | `jeecg-boot/../personal/dto/ResumeInfoDTO.java` | **新增** | 简历 DTO,含主表字段 + 7 个子表 List | | `jeecg-boot/../personal/vo/ResumeInfoPage.java` | **新增** | 简历 VO,用于 queryById 组装完整信息 | | `jeecg-boot/../personal/service/IResumeInfoService.java` | **修改** | 新增 `saveMainAndSub`、`updateMainAndSub`、`deleteMainWithSubs` 方法 | | `jeecg-boot/../personal/service/impl/ResumeInfoServiceImpl.java` | **修改** | 实现主表+子表联动逻辑(先删后插策略) | | `jeecg-boot/../personal/controller/ResumeInfoController.java` | **修改** | 新增主表+子表的 add/edit/delete/queryById + 21 个子表独立 CRUD 接口 | | 7 个子表 Mapper | **修改** | 各新增 `deleteByMainId` 和 `selectByMainId` 注解 SQL 方法 | | `jeecgboot-vue3/../personal/ResumeInfo.api.ts` | **新增** | 简历 API 封装(6个接口) | | `jeecgboot-vue3/../personal/ResumeInfo.data.ts` | **新增** | 7 个子表列类型定义(JVxeTypes) | | `jeecgboot-vue3/../personal/components/ResumeInfoForm.vue` | **新增** | 简历主表+子表编辑表单 | | `jeecgboot-vue3/../personal/components/ResumeInfoModal.vue` | **新增** | 简历编辑弹窗容器,类似 PersonalInfoModal 模式 | | `jeecgboot-vue3/../personal/components/ResumeInfoDetailModal.vue` | **新增** | 简历只读详情弹窗 | | `jeecgboot-vue3/../personal/components/ResumeListModal.vue` | **新增** | 简历列表弹窗(含快速新增) | | 7 个子表 Modal(ResumeWorkExpModal 等) | **新增** | 各子表的新增/编辑弹窗 | --- #### 2026-06-05:人员选择器组件 **功能描述:** 提供可嵌入其他表单的人员选择器组件,支持单选和多选,支持跨页选择。 **前端组件:** - `PersonalInfoSelector.vue` — 只读输入框,展示已选人员姓名,点击打开选择弹窗 - `PersonalInfoSelectorModal.vue` — 带查询功能的弹窗表格,支持 radio/checkbox 行选择 **涉及文件:** | 文件 | 操作 | 原因 | |------|------|------| | `jeecgboot-vue3/../personal/components/PersonalInfoSelector.vue` | **新增** | 人员选择器输入框,可嵌入其他业务表单 | | `jeecgboot-vue3/../personal/components/PersonalInfoSelectorModal.vue` | **新增** | 人员选择弹窗,含查询表格 + 跨页多选 | --- ### 重要技术细节记录 #### 1. 简历子表编辑策略(先删后插) `ResumeInfoServiceImpl.updateMainAndSub()` 采用"先删后插"策略处理子表编辑:先通过各子表 Mapper 的 `deleteByMainId(resumeId)` 删除所有旧数据,再遍历 DTO 中的子表列表逐一插入新数据。这样避免了逐行判断增删改的复杂逻辑,简化了前端与后端的协同。 #### 2. 日期处理 **前端:** 所有日期字段在组件 `open()` 回填时将后端返回的 `"yyyy-MM-dd"` 字符串转为 `dayjs` 对象赋给 `a-date-picker`;在 `handleOk()` 提交时通过 `dayjs().format('YYYY-MM-DD')` 还原为字符串提交给后端。 **后端:** PersonalInfo 实体中 `birthDate`、`graduationDate` 使用 `@JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd")` 和 `@DateTimeFormat(pattern = "yyyy-MM-dd")` 注解,统一为 `Date` 类型。格式化为字符串在前端完成。 #### 3. 行政区划处理 PersonalInfo 中户口所在地和现居住地各有两个字段: - `householdLocation` / `currentResidence`:存储行政区划字典的叶子节点代码 - `householdAreaName` / `residenceAreaName`:存储完整路径(如"广东省/湛江市/赤坎区") 前端通过 `a-tree-select` 懒加载 XZQH 字典树,选择节点后调用后端 API 获取完整路径存入 areaName。导入时两者互为补充——Excel 通常填写完整路径,导入逻辑会反向查找对应的字典代码。 #### 4. 数据库保留字处理 `ResumeWorkExp` 的 `position` 字段和 `ResumeTitle` 的 `level` 字段与数据库保留字同名,在实体类中使用 `@TableField("\"COLUMN_NAME\"")` 注解进行转义。 #### 5. 树形字典加载差异 - **行政区划(XZQH)和期望职位(ZYFL)**:数据量大,使用 `getDictTreeChildren` 懒加载 - **经济行业(EconomicIndustry)和经济类型(EconomicType)**:这些字典的 Code 列为空,导致 `getDictTreeChildren` 返回的 key 全为空字符串,造成"选中一个全部选中"的 bug,因此改用 `getDictTree` 全量加载(使用 Value 列做标识) #### 6. 标签名查询性能优化 `PersonalInfoServiceImpl.queryPageListWithTags()` 中通过两次批量查询避免 N+1 问题: 1. 批量查标签关联(`personal_tag_relation`),按 `personId` 分组 2. 批量查标签名(`personal_tag`) 3. 组装 VO 结果集 `populateTagNames()` 采用相同策略,用于导出时批量填充标签名称。