20260604-个人基本信息模块开发记录.md 44 KB

个人基本信息模块开发记录

日期: 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<String> 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<PersonalInfo, IPersonalInfoService>
方法 HTTP 权限 功能描述
queryPageList() GET /list 分页查询,调用 queryPageListWithTags() 返回含标签名的 IPage<PersonalInfoVO>
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<String> tagIds,用于新增/编辑时接收前端提交的标签 ID 列表。

ResumeInfoDTO:独立的 DTO 类(非继承),包含主表字段 + 7 个子表 List:

  • List<ResumeWorkExp> resumeWorkExpList
  • List<ResumeEducation> resumeEducationList
  • List<ResumeTraining> resumeTrainingList
  • List<ResumeSkill> resumeSkillList
  • List<ResumeTitle> resumeTitleList
  • List<ResumeAward> resumeAwardList
  • List<ResumeStudyAbroad> resumeStudyAbroadList

必填校验注解:personalId@NotBlank), expectedPosition@NotBlank), positionName@NotBlank), expectedSalary@NotNull), workLocation@NotBlank), workNature@NotBlank

ImportResultItem:导入结果行载体。

  • int rowNum — Excel 行号
  • Map<String, String> 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. 逐行处理:捕获原始列值 → 字典文本反向翻译 → 必填字段校验 → 补充成对字段(householdLocationhouseholdAreaNamecurrentResidenceresidenceAreaName)→ 设置 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<XxxMapper, XxxEntity> 实现 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() 方法 + <PersonalInfoForm :key="formKey"> 实现每次打开弹窗强制重建子组件,解决"先编辑后详情表单未禁用"的问题
  • 通过 disableSubmit prop 控制确认按钮显隐(详情场景隐藏确认按钮)
  • 对外 expose 方法:addeditdisableSubmit

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 获取完整路径(如"广东省/湛江市/赤坎区"),分别存入 householdAreaNameresidenceAreaName
  • householdLocation / currentResidence 存储叶子节点的字典代码

个人标签处理:

  • a-select mode="multiple" 多选下拉
  • 编辑时后端返回逗号分隔的 tagIds 字符串,前端转为数组回显(空字符串转为 []
  • 提交时 tagIds 保持数组原样(不执行 .join(',')),因为后端 DTO 接收 List<String>

日期处理: 后端返回 "yyyy-MM-dd" 字符串,edit() 中转为 dayjs 对象赋值给 a-date-pickersubmitForm() 中通过 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 的 <a-modal> 默认 destroyOnClose=false,弹窗关闭时内部组件(PersonalInfoForm)仍然挂载在 DOM 中。先点击"编辑"时,PersonalInfoForm 以 formDisabled=false 的状态挂载;关闭弹窗后组件保持挂载;再点击"详情"时,虽然 disableSubmit 被设为 true,但由于组件实例未重新创建,props 的响应式更新未能正确触发 JFormContainer 的 <fieldset disabled> 状态变更。

最终修复方案: 使用 :key 方式强制组件重建。

  • 在 PersonalInfoModal 中引入 formKey ref 和 nextFormKey() 方法
  • <PersonalInfoForm> 上绑定 :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 收集 <a-form-item-rest> 包裹第二个 AInputNumber,告知 Form 不收集该字段
date.locale is not a function API 返回的日期字符串 "2000-01-15" 直接赋给 a-date-pickerv-model:value,但 a-date-picker 期望 dayjs 对象 PersonalInfoForm.edit() 方法中将 birthDategraduationDate 字符串转为 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 修改 年龄搜索字段用 <a-form-item-rest> 包裹第二个 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<String> tagIds,用于新增/编辑时接收前端提交的标签 ID 列表
  • 新增 PersonalInfoVO(extends PersonalInfo),含 String tagIds(逗号分隔,表单回显用),tagNames 继承自父类的虚拟字段
  • PersonalTagController 新增 /listForSelect 接口,返回所有已启用的标签 {id, tagName} 列表
  • PersonalInfoServiceImpl 注入 PersonalTagRelationMapperPersonalTagMapper,提供 saveWithTags/updateWithTags 方法,在保存人员基本信息后同步维护 personal_tag_relation 关联表
  • PersonalInfoController/add/edit 接收 PersonalInfoDTO/queryById 返回 PersonalInfoVO;分页列表 /list 返回带标签名的 VO 列表

前端:

  • PersonalInfo.api.ts 新增 queryByIdgetPersonalTagListForSelect API
  • PersonalInfoForm.vue 在"数据来源"前添加"个人标签"多选下拉(a-select mode="multiple"),编辑时自动将后端返回的逗号分隔字符串转为数组回显
  • PersonalInfoDetailModal.vue 新增"个人标签"展示行
  • PersonalInfoList.vuehandleEdit/handleDetail 改为先调用 queryById 获取含标签的完整数据再传给弹窗

Bug 修复(2026-06-05):

  1. tagIds 空字符串导致选中空选项: queryById 返回 tagIds: "" 时,edit 方法中转换条件用 tmpData.tagIds && ... 会因空字符串为 falsy 跳过转换,Object.assign"" 赋给 formData.tagIdsa-select mode="multiple" 收到非数组后误渲染空白选项。修复:改用 typeof tmpData.tagIds === 'string' 判断,空字符串转为 []

  2. tagIds 数组被误转为字符串导致 JSON 解析失败: submitForm 的循环中,getValueTypetagIds 误判为 string 类型,执行 .join(',') 转为逗号字符串,后端 DTO 接收 List<String> 时报 Cannot deserialize from String value。修复:在循环条件中排除 tagIdsdata !== 'tagIds'),保持数组原样提交。

涉及文件:

文件 操作 原因
jeecg-boot/../personal/dto/PersonalInfoDTO.java 新增 DTO 类,extends PersonalInfo,含 List<String> tagIds,用于 add/edit 接收前端标签数据
jeecg-boot/../personal/vo/PersonalInfoVO.java 新增 VO 类,extends PersonalInfo,含 String tagIds,用于 queryById 返回含标签的完整数据
jeecg-boot/../personal/service/IPersonalInfoService.java 修改 新增 saveWithTagsupdateWithTagsqueryPersonalInfoVOByIdqueryPageListWithTags 方法声明
jeecg-boot/../personal/service/impl/PersonalInfoServiceImpl.java 修改 注入 PersonalTagRelationMapperPersonalTagMapper;实现标签关联保存/更新/查询逻辑;分页查询批量填充标签名
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 修改 新增 queryByIdgetPersonalTagListForSelect 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 修改 新增 saveMainAndSubupdateMainAndSubdeleteMainWithSubs 方法
jeecg-boot/../personal/service/impl/ResumeInfoServiceImpl.java 修改 实现主表+子表联动逻辑(先删后插策略)
jeecg-boot/../personal/controller/ResumeInfoController.java 修改 新增主表+子表的 add/edit/delete/queryById + 21 个子表独立 CRUD 接口
7 个子表 Mapper 修改 各新增 deleteByMainIdselectByMainId 注解 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 实体中 birthDategraduationDate 使用 @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. 数据库保留字处理

ResumeWorkExpposition 字段和 ResumeTitlelevel 字段与数据库保留字同名,在实体类中使用 @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() 采用相同策略,用于导出时批量填充标签名称。