فهرست منبع

feat: 功能优化与消息、待办发送

zhangying 18 ساعت پیش
والد
کامیت
4849a6875a
29فایلهای تغییر یافته به همراه1423 افزوده شده و 64 حذف شده
  1. 186 0
      .docs/20260604-数据字典类字段显示与导出实现方式.md
  2. 251 0
      .docs/260624-消息与待办模块设计开发文档.md
  3. 20 0
      .docs/sql/字典数据/数据字典数据插入.sql
  4. 36 0
      .docs/sql/建表语句/待办记录表.sql
  5. 6 0
      .docs/sql/建表语句/消息通知记录表.sql
  6. 3 0
      .docs/系统需要的数据字典.txt
  7. 35 0
      jeecg-boot/jeecg-boot-module/jeecg-module-zjrs/src/main/java/org/jeecg/modules/zjrs/application/mapper/xml/JobApplicationMapper.xml
  8. 14 47
      jeecg-boot/jeecg-boot-module/jeecg-module-zjrs/src/main/java/org/jeecg/modules/zjrs/interview/controller/InterviewRecordController.java
  9. 31 0
      jeecg-boot/jeecg-boot-module/jeecg-module-zjrs/src/main/java/org/jeecg/modules/zjrs/interview/service/IInterviewRecordService.java
  10. 187 0
      jeecg-boot/jeecg-boot-module/jeecg-module-zjrs/src/main/java/org/jeecg/modules/zjrs/interview/service/impl/InterviewRecordServiceImpl.java
  11. 40 0
      jeecg-boot/jeecg-boot-module/jeecg-module-zjrs/src/main/java/org/jeecg/modules/zjrs/notification/controller/NotificationController.java
  12. 8 0
      jeecg-boot/jeecg-boot-module/jeecg-module-zjrs/src/main/java/org/jeecg/modules/zjrs/notification/entity/NotificationRecord.java
  13. 3 0
      jeecg-boot/jeecg-boot-module/jeecg-module-zjrs/src/main/java/org/jeecg/modules/zjrs/notification/entity/NotificationTarget.java
  14. 21 0
      jeecg-boot/jeecg-boot-module/jeecg-module-zjrs/src/main/java/org/jeecg/modules/zjrs/notification/service/INotificationRecordService.java
  15. 79 0
      jeecg-boot/jeecg-boot-module/jeecg-module-zjrs/src/main/java/org/jeecg/modules/zjrs/notification/service/impl/NotificationRecordServiceImpl.java
  16. 8 14
      jeecg-boot/jeecg-boot-module/jeecg-module-zjrs/src/main/java/org/jeecg/modules/zjrs/offer/controller/EmploymentOfferController.java
  17. 12 0
      jeecg-boot/jeecg-boot-module/jeecg-module-zjrs/src/main/java/org/jeecg/modules/zjrs/offer/service/IEmploymentOfferService.java
  18. 61 0
      jeecg-boot/jeecg-boot-module/jeecg-module-zjrs/src/main/java/org/jeecg/modules/zjrs/offer/service/impl/EmploymentOfferServiceImpl.java
  19. 3 1
      jeecg-boot/jeecg-boot-module/jeecg-module-zjrs/src/main/java/org/jeecg/modules/zjrs/post/mapper/xml/PostInfoMapper.xml
  20. 66 0
      jeecg-boot/jeecg-boot-module/jeecg-module-zjrs/src/main/java/org/jeecg/modules/zjrs/todo/controller/TodoController.java
  21. 77 0
      jeecg-boot/jeecg-boot-module/jeecg-module-zjrs/src/main/java/org/jeecg/modules/zjrs/todo/entity/TodoRecord.java
  22. 36 0
      jeecg-boot/jeecg-boot-module/jeecg-module-zjrs/src/main/java/org/jeecg/modules/zjrs/todo/entity/TodoTarget.java
  23. 7 0
      jeecg-boot/jeecg-boot-module/jeecg-module-zjrs/src/main/java/org/jeecg/modules/zjrs/todo/mapper/TodoRecordMapper.java
  24. 7 0
      jeecg-boot/jeecg-boot-module/jeecg-module-zjrs/src/main/java/org/jeecg/modules/zjrs/todo/mapper/TodoTargetMapper.java
  25. 4 0
      jeecg-boot/jeecg-boot-module/jeecg-module-zjrs/src/main/java/org/jeecg/modules/zjrs/todo/mapper/xml/TodoRecordMapper.xml
  26. 46 0
      jeecg-boot/jeecg-boot-module/jeecg-module-zjrs/src/main/java/org/jeecg/modules/zjrs/todo/service/ITodoRecordService.java
  27. 153 0
      jeecg-boot/jeecg-boot-module/jeecg-module-zjrs/src/main/java/org/jeecg/modules/zjrs/todo/service/impl/TodoRecordServiceImpl.java
  28. 14 1
      jeecgboot-vue3/src/views/recruitment/enterprise/components/EnterpriseInfoDetailModal.vue
  29. 9 1
      jeecgboot-vue3/src/views/recruitment/enterprise/components/EnterpriseInfoForm.vue

+ 186 - 0
.docs/20260604-数据字典类字段显示与导出实现方式.md

@@ -880,3 +880,189 @@ String exportFields = "...",
 | 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"`),前端通过 `<a-select mode="multiple">` 实现多选下拉,后端无需额外改动。
+
+| 项目 | 说明 |
+|------|------|
+| **存储方案** | 实体字段存逗号分隔字符串,如 `"1,3,6"` |
+| **后端字典** | 同普通字典,`DICTIONARY_ITEM` 表,`getDictBatch` 批量加载 |
+| **前端编辑** | `<a-select mode="multiple">` + `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 模板
+
+使用 `<a-select mode="multiple">`,绑定选项列表:
+
+```vue
+<a-select
+  v-model:value="formData.companyBenefits"
+  mode="multiple"
+  allow-clear
+  placeholder="请选择单位福利"
+  :options="companyBenefitsOptions"
+/>
+```
+
+`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
+<a-descriptions-item label="单位福利" :span="1">
+  {{ getDictTexts('UnitBenefits', detailData.companyBenefits) }}
+</a-descriptions-item>
+```
+
+### 7.5 Excel 导出
+
+多选字典的导出与单值字典相同,在 `translateDictFields` 中处理。由于字段值本身就是逗号分隔字符串(如 `"1,3,6"`),在 Service 层的 `switch` 中直接整体替换即可:
+
+```java
+case "companyBenefits":
+    if (info.getCompanyBenefits() != null) {
+        // 逗号分隔的多个 value 逐一替换为 label,再以逗号拼接
+        String[] values = info.getCompanyBenefits().split(",");
+        List<String> 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"]
+  <a-select mode="multiple"> 渲染为已选项
+
+【详情展示】
+  后端返回 → "1,3,6"
+  getDictTexts() → 逐值翻译 → "养老保险、失业保险、生育保险"
+
+【Excel 导出】
+  translateDictFields → 逐一替换 → "养老保险、失业保险、生育保险"
+```
+
+### 7.7 与单值普通字典对比
+
+| 环节 | 单值普通字典(如 Gender) | 多选普通字典(如 UnitBenefits) |
+|------|--------------------------|-------------------------------|
+| 存储方式 | 单个 value(如 `"1"`) | 逗号分隔的 values(如 `"1,3,6"`) |
+| 编辑组件 | `<a-select>`(单选) | `<a-select mode="multiple">`(多选) |
+| v-model 类型 | 字符串 | 字符串数组 |
+| 编辑回显 | 直接赋值 | `split(',')` 转为数组 |
+| 提交处理 | 直接提交 | 通用逻辑 `join(',')` 转为字符串 |
+| 详情显示 | `getDictText(code, value)` 单值翻译 | `getDictTexts(code, valuesStr)` 多值翻译 + 顿号拼接 |
+| Excel 导出 | 直接替换单值 | 逐值替换后以顿号拼接 |

+ 251 - 0
.docs/260624-消息与待办模块设计开发文档.md

@@ -0,0 +1,251 @@
+# 消息通知与待办模块设计开发文档
+
+## 一、概述
+
+本文档记录了消息通知模块和待办模块的数据库设计、后端接口与实现,以及其他业务模块(面试、录用)对这两个模块的调用示例。
+
+- **消息通知模块**:为系统提供统一的消息发送能力,支持给企业/人员发送通知消息,记录接收人关联并更新最后通知时间。
+- **待办模块**:为系统提供待办事项的创建与查询能力,支持给企业/人员生成待办,前端可通过目标页面路径进行跳转。
+
+---
+
+## 二、数据库设计
+
+### 2.1 消息通知记录表
+
+**文件**:`.docs/sql/建表语句/消息通知记录表.sql`
+
+```sql
+CREATE TABLE notification_record (
+    ID            VARCHAR(36)  NOT NULL,
+    MODULE_TYPE   VARCHAR(50)           COMMENT '来源模块',
+    SUBJECT       VARCHAR(200) NOT NULL COMMENT '消息主题',
+    CONTENT       VARCHAR(2000)         COMMENT '消息内容',
+    SENDER_ID     VARCHAR(36)           COMMENT '推送人用户ID(关联sys_user)',
+    SENDER        VARCHAR(50)  NOT NULL COMMENT '推送人姓名',
+    SEND_TIME     TIMESTAMP    NOT NULL COMMENT '推送时间',
+    CREATE_BY     VARCHAR(50),
+    CREATE_TIME   TIMESTAMP    DEFAULT CURRENT_TIMESTAMP NOT NULL,
+    SYS_ORG_CODE  VARCHAR(50),
+    PRIMARY KEY (ID)
+);
+
+-- 后增字段(ALTER 方式)
+ALTER TABLE notification_record ADD EXPIRE_TIME TIMESTAMP;
+COMMENT ON COLUMN notification_record.EXPIRE_TIME IS '过期时间';
+ALTER TABLE notification_record ADD STATUS VARCHAR(20) DEFAULT '0';
+COMMENT ON COLUMN notification_record.STATUS IS '状态: 0未读 1已读';
+```
+
+**接收人关联表**:
+
+```sql
+CREATE TABLE notification_target (
+    NOTIFICATION_ID VARCHAR(36) NOT NULL COMMENT '消息ID',
+    TARGET_TYPE     VARCHAR(20) NOT NULL COMMENT '类型: personal/enterprise',
+    TARGET_ID       VARCHAR(36) NOT NULL COMMENT '目标ID'
+);
+```
+
+### 2.2 待办记录表
+
+**文件**:`.docs/sql/建表语句/待办记录表.sql`
+
+```sql
+CREATE TABLE todo_record (
+    ID            VARCHAR(36)   NOT NULL,
+    MODULE_TYPE   VARCHAR(50)            COMMENT '来源模块',
+    SUBJECT       VARCHAR(200)  NOT NULL COMMENT '待办标题',
+    CONTENT       VARCHAR(2000)          COMMENT '待办内容',
+    CREATOR_ID    VARCHAR(36)            COMMENT '发起人用户ID(关联sys_user)',
+    CREATOR       VARCHAR(50)   NOT NULL COMMENT '发起人姓名',
+    CREATE_TIME   TIMESTAMP     DEFAULT CURRENT_TIMESTAMP NOT NULL COMMENT '创建时间',
+    STATUS        VARCHAR(20)   DEFAULT '0' NOT NULL COMMENT '状态: 0待处理 1已完成 2已取消',
+    COMPLETE_TIME TIMESTAMP              COMMENT '完成时间',
+    EXPIRE_TIME   TIMESTAMP              COMMENT '过期时间',
+    TARGET_PAGE   VARCHAR(500)           COMMENT '目标页面路径',
+    DATA_ID       VARCHAR(36)            COMMENT '数据ID',
+    PATH_PARAMS   VARCHAR(500)           COMMENT '路径参数(JSON格式)',
+    SYS_ORG_CODE  VARCHAR(50),
+    PRIMARY KEY (ID)
+);
+
+CREATE TABLE todo_target (
+    TODO_ID     VARCHAR(36) NOT NULL COMMENT '待办ID',
+    TARGET_TYPE VARCHAR(20) NOT NULL COMMENT '类型: personal/enterprise',
+    TARGET_ID   VARCHAR(36) NOT NULL COMMENT '目标ID'
+);
+```
+
+### 2.3 表结构对比
+
+| 特性 | notification_record | todo_record |
+|------|-------------------|-------------|
+| 主信息 | 主题、内容、发送人 | 标题、内容、发起人 |
+| 时间 | 发送时间、过期时间 | 创建时间、完成时间、过期时间 |
+| 状态 | 0未读/1已读 | 0待处理/1已完成/2已取消 |
+| 跳转字段 | 无 | TARGET_PAGE、DATA_ID、PATH_PARAMS |
+| 接收人关联 | notification_target | todo_target |
+
+---
+
+## 三、后端模块设计
+
+### 3.1 消息通知模块
+
+**包路径**:`org.jeecg.modules.zjrs.notification`
+
+| 文件 | 操作 | 说明 |
+|------|------|------|
+| `entity/NotificationRecord.java` | 修改 | 新增 `expireTime`、`status` 字段 |
+| `service/INotificationRecordService.java` | 修改 | 新增 `sendMessage()` 方法 |
+| `service/impl/NotificationRecordServiceImpl.java` | 修改 | 实现 `sendMessage()`,含事务写主表、关联表、更新状态表 |
+
+**`sendMessage()` 方法说明**:
+
+```
+入参:
+  - moduleType : 来源模块(如 "interview", "offer")
+  - subject    : 消息主题
+  - content    : 消息内容
+  - senderId   : 推送人用户ID
+  - sender     : 推送人姓名
+  - expireTime : 过期时间(可为null)
+  - targets    : 接收人列表(List<NotificationTarget>)
+
+执行流程:
+  1. 构建消息主记录,STATUS 默认 "0"(未读)
+  2. 保存主记录
+  3. 遍历接收人,逐条写入 notification_target
+  4. 根据 targetType 更新 enterprise_status_local 或 personal_status_local 的 last_notice_time
+```
+
+### 3.2 待办模块
+
+**包路径**:`org.jeecg.modules.zjrs.todo`
+
+| 文件 | 操作 | 说明 |
+|------|------|------|
+| `entity/TodoRecord.java` | 新建 | 待办主表实体,包含跳转字段 |
+| `entity/TodoTarget.java` | 新建 | 待办接收人关联实体 |
+| `mapper/TodoRecordMapper.java` | 新建 | MyBatis Plus Mapper |
+| `mapper/TodoTargetMapper.java` | 新建 | MyBatis Plus Mapper |
+| `mapper/xml/TodoRecordMapper.xml` | 新建 | Mapper XML(空) |
+| `service/ITodoRecordService.java` | 新建 | 接口包含 `createTodo()`、`queryMyList()` |
+| `service/impl/TodoRecordServiceImpl.java` | 新建 | 实现,含事务写主表和关联表 |
+| `controller/TodoController.java` | 新建 | `GET /todo/myList` 分页查询当前用户待办 |
+
+**`createTodo()` 方法说明**:
+
+```
+入参:
+  - moduleType : 来源模块
+  - subject    : 待办标题
+  - content    : 待办内容
+  - creatorId  : 发起人用户ID
+  - creator    : 发起人姓名
+  - targetPage : 前端路由路径(如 "pages/personal/my-jobs/index")
+  - dataId     : 关联的业务数据主键ID
+  - pathParams : 路径参数(如 "fromTab=2")
+  - targets    : 接收人列表
+
+执行流程:
+  1. 构建待办主记录,STATUS 默认 "0"(待处理)
+  2. 保存主记录
+  3. 遍历接收人,逐条写入 todo_target
+```
+
+**`queryMyList()` 方法说明**:
+
+```
+入参:
+  - targetId   : 用户ID
+  - targetType : 用户类型(personal/enterprise)
+  - keyword    : 关键字(模糊匹配标题)
+  - moduleType : 来源模块过滤
+  - status     : 状态过滤
+  - pageNo     : 页码
+  - pageSize   : 每页条数
+
+执行流程:
+  1. 从 todo_target 查出当前用户的所有待办ID
+  2. 根据 ID 列表分页查询 todo_record,支持 keyword/moduleType/status 过滤
+  3. 遍历结果,填充每条的 targets 列表及目标名称
+```
+
+---
+
+## 四、业务模块调用示例
+
+### 4.1 面试模块
+
+**文件变更**:
+
+| 文件 | 操作 | 说明 |
+|------|------|------|
+| `interview/service/IInterviewRecordService.java` | 修改 | 新增 attendInterview、notAttendInterview、passInterview、failInterview、offerJob 方法 |
+| `interview/service/impl/InterviewRecordServiceImpl.java` | 修改 | 实现上述方法,调用 sendMessage 和 createTodo |
+| `interview/controller/InterviewRecordController.java` | 修改 | 4个接口改为委托 service |
+
+**调用链路**:
+
+| 接口 | 触发 | 行为 |
+|------|------|------|
+| `PUT /interviewRecord/attend` | 求职者确认参加 | 状态改"参加" → 通知企业「求职者已确认参加面试」 |
+| `PUT /interviewRecord/notAttend` | 求职者不参加 | 状态改"不参加" → 通知企业「求职者已不参加面试」 |
+| `PUT /interviewRecord/pass` | 企业面试通过 | 状态改"通过" → 通知求职者「面试已通过」 |
+| `PUT /interviewRecord/fail` | 企业面试不通过 | 状态改"不通过" → 通知求职者「面试未通过」 |
+| `POST /interviewRecord/offer` | 企业发起录用 | 创建录用记录 → 生成待办「录用通知」给求职者 |
+
+**待办配置(发起录用时)**:
+
+```java
+todoRecordService.createTodo(
+    "offer",                              // moduleType
+    "录用通知",                           // subject
+    "您已被【XX公司】的【Java开发】岗位录用...",  // content
+    "",                                   // creatorId(系统发起)
+    record.getEnterpriseName(),           // creator
+    "pages/personal/my-jobs/index",       // targetPage
+    employmentOffer.getId(),              // dataId
+    "fromTab=2",                          // pathParams
+    Collections.singletonList(target)     // 接收人:求职者
+);
+```
+
+### 4.2 录用模块
+
+**文件变更**:
+
+| 文件 | 操作 | 说明 |
+|------|------|------|
+| `offer/service/IEmploymentOfferService.java` | 修改 | 新增 confirmOffer、rejectOffer 方法 |
+| `offer/service/impl/EmploymentOfferServiceImpl.java` | 修改 | 实现,调用 sendMessage |
+| `offer/controller/EmploymentOfferController.java` | 修改 | 2个接口改为委托 service |
+
+**调用链路**:
+
+| 接口 | 触发 | 行为 |
+|------|------|------|
+| `PUT /employmentOffer/confirm` | 求职者确认签约 | 状态改"已签约" → 通知企业「求职者已确认签约」 |
+| `PUT /employmentOffer/reject` | 求职者拒绝录用 | 状态改"已拒绝" → 通知企业「求职者已拒绝录用」 |
+
+---
+
+## 五、过期时间规则
+
+| 场景 | 过期时间计算 |
+|------|-------------|
+| 面试通知(求职者反馈) | 面试时间 + 1天 |
+| 面试结果通知 | 面试时间 + 1天 |
+| 录用反馈通知 | 无过期(null) |
+| 录用待办 | 无过期(null) |
+
+---
+
+## 六、模块分类(moduleType)
+
+| moduleType 值 | 说明 |
+|--------------|------|
+| `interview` | 面试相关通知/待办 |
+| `offer` | 录用相关通知/待办 |

+ 20 - 0
.docs/sql/字典数据/数据字典数据插入.sql

@@ -1792,3 +1792,23 @@ VALUES ('1174509082208396054', '', 'ForeignLanguage', 186, '祖鲁语', 186, 1,
 INSERT INTO "DICTIONARY_ITEM"
 VALUES ('1174509082208396055', '', 'ForeignLanguage', 187, '其他', 187, 1, 1, NULL);
 
+-- ----------------------------
+-- 38. 单位福利 (UnitBenefits)
+-- ----------------------------
+INSERT INTO "DICTIONARY"
+VALUES ('UnitBenefits', '单位福利', 38, 1, 0);
+INSERT INTO "DICTIONARY_ITEM"
+VALUES ('1174509082208396056', '', 'UnitBenefits', 1, '养老保险', 1, 1, 1, NULL);
+INSERT INTO "DICTIONARY_ITEM"
+VALUES ('1174509082208396057', '', 'UnitBenefits', 2, '医疗保险', 2, 1, 1, NULL);
+INSERT INTO "DICTIONARY_ITEM"
+VALUES ('1174509082208396058', '', 'UnitBenefits', 3, '失业保险', 3, 1, 1, NULL);
+INSERT INTO "DICTIONARY_ITEM"
+VALUES ('1174509082208396059', '', 'UnitBenefits', 4, '工伤保险', 4, 1, 1, NULL);
+INSERT INTO "DICTIONARY_ITEM"
+VALUES ('1174509082208396060', '', 'UnitBenefits', 5, '生育保险', 5, 1, 1, NULL);
+INSERT INTO "DICTIONARY_ITEM"
+VALUES ('1174509082208396061', '', 'UnitBenefits', 6, '住房公积金', 6, 1, 1, NULL);
+INSERT INTO "DICTIONARY_ITEM"
+VALUES ('1174509082208396062', '', 'UnitBenefits', 7, '带薪年假', 7, 1, 1, NULL);
+

+ 36 - 0
.docs/sql/建表语句/待办记录表.sql

@@ -0,0 +1,36 @@
+-- ============================================================
+-- 待办记录表 + 接收人关联表(DM8)
+-- 设计:待办主题/内容只存一份,接收人独立关联,批量发送无冗余
+-- 接收人名称不存储,查询时关联 PERSONAL_INFO/ENTERPRISE_INFO 实时获取
+-- 前端页面跳转所需的目标页面路径、数据ID、路径参数,直接存在待办主表
+-- ============================================================
+
+CREATE TABLE todo_record (
+    ID            VARCHAR(36)   NOT NULL,
+    MODULE_TYPE   VARCHAR(50)            COMMENT '来源模块',
+    SUBJECT       VARCHAR(200)  NOT NULL COMMENT '待办标题',
+    CONTENT       VARCHAR(2000)          COMMENT '待办内容',
+    CREATOR_ID    VARCHAR(36)            COMMENT '发起人用户ID(关联sys_user)',
+    CREATOR       VARCHAR(50)   NOT NULL COMMENT '发起人姓名',
+    CREATE_TIME   TIMESTAMP     DEFAULT CURRENT_TIMESTAMP NOT NULL COMMENT '创建时间',
+    STATUS        VARCHAR(20)   DEFAULT '0' NOT NULL COMMENT '状态: 0待处理 1已完成 2已取消',
+    COMPLETE_TIME TIMESTAMP              COMMENT '完成时间',
+    EXPIRE_TIME   TIMESTAMP              COMMENT '过期时间',
+    TARGET_PAGE   VARCHAR(500)           COMMENT '目标页面路径',
+    DATA_ID       VARCHAR(36)            COMMENT '数据ID',
+    PATH_PARAMS   VARCHAR(500)           COMMENT '路径参数',
+    SYS_ORG_CODE  VARCHAR(50),
+    PRIMARY KEY (ID)
+);
+COMMENT ON TABLE todo_record IS '待办记录表';
+
+CREATE TABLE todo_target (
+    TODO_ID     VARCHAR(36) NOT NULL COMMENT '待办ID',
+    TARGET_TYPE VARCHAR(20) NOT NULL COMMENT '类型: personal/enterprise',
+    TARGET_ID   VARCHAR(36) NOT NULL COMMENT '目标ID'
+);
+COMMENT ON TABLE todo_target IS '待办接收人关联表';
+CREATE INDEX idx_tt_target ON todo_target(TARGET_TYPE, TARGET_ID);
+CREATE INDEX idx_tt_todo ON todo_target(TODO_ID);
+ALTER TABLE todo_target ADD STATUS VARCHAR(20) DEFAULT '0';
+COMMENT ON COLUMN todo_target.STATUS IS '状态: 0待处理 1已完成 2已取消';

+ 6 - 0
.docs/sql/建表语句/消息通知记录表.sql

@@ -18,6 +18,10 @@ CREATE TABLE notification_record (
     PRIMARY KEY (ID)
 );
 COMMENT ON TABLE notification_record IS '消息通知记录表';
+ALTER TABLE notification_record ADD EXPIRE_TIME TIMESTAMP;
+COMMENT ON COLUMN notification_record.EXPIRE_TIME IS '过期时间';
+ALTER TABLE notification_record ADD STATUS VARCHAR(20) DEFAULT '0';
+COMMENT ON COLUMN notification_record.STATUS IS '状态: 0未读 1已读';
 
 CREATE TABLE notification_target (
     NOTIFICATION_ID VARCHAR(36) NOT NULL COMMENT '消息ID',
@@ -27,3 +31,5 @@ CREATE TABLE notification_target (
 COMMENT ON TABLE notification_target IS '消息接收人关联表';
 CREATE INDEX idx_nt_target ON notification_target(TARGET_TYPE, TARGET_ID);
 CREATE INDEX idx_nt_notify ON notification_target(NOTIFICATION_ID);
+ALTER TABLE notification_target ADD STATUS VARCHAR(20) DEFAULT '0';
+COMMENT ON COLUMN notification_target.STATUS IS '状态: 0未读 1已读';

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 3 - 0
.docs/系统需要的数据字典.txt


+ 35 - 0
jeecg-boot/jeecg-boot-module/jeecg-module-zjrs/src/main/java/org/jeecg/modules/zjrs/application/mapper/xml/JobApplicationMapper.xml

@@ -0,0 +1,35 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="org.jeecg.modules.zjrs.application.mapper.JobApplicationMapper">
+
+    <!-- 分页查询个人投递记录,关联 post_info 获取当前岗位名称 -->
+    <select id="queryMyApplications" resultType="org.jeecg.modules.zjrs.application.entity.JobApplication">
+        SELECT ja.id,
+               ja.personal_id,
+               ja.resume_id,
+               ja.enterprise_id,
+               ja.post_id,
+               pi.post_name AS post_name,
+               ja.enterprise_name,
+               ja.personal_name,
+               ja.apply_time,
+               ja.status,
+               ja.remark,
+               ja.create_by,
+               ja.create_time,
+               ja.update_by,
+               ja.update_time,
+               ja.sys_org_code
+        FROM job_application ja
+                 LEFT JOIN post_info pi ON ja.post_id = pi.id
+        WHERE ja.personal_id = #{personalId}
+        <if test="status != null and status != ''">
+            AND ja.status = #{status}
+        </if>
+        <if test="postName != null and postName != ''">
+            AND pi.post_name LIKE CONCAT('%', #{postName}, '%')
+        </if>
+        ORDER BY ja.apply_time DESC
+    </select>
+
+</mapper>

+ 14 - 47
jeecg-boot/jeecg-boot-module/jeecg-module-zjrs/src/main/java/org/jeecg/modules/zjrs/interview/controller/InterviewRecordController.java

@@ -114,6 +114,7 @@ public class InterviewRecordController extends JeecgController<InterviewRecord,
     public Result<IPage<InterviewRecord>> myInterviews(
             @RequestParam(name = "personalId", required = true) String personalId,
             @RequestParam(name = "interviewResult", required = false) String interviewResult,
+            @RequestParam(name = "postName", required = false) String postName,
             @RequestParam(name = "pageNo", defaultValue = "1") Integer pageNo,
             @RequestParam(name = "pageSize", defaultValue = "10") Integer pageSize) {
         Page<InterviewRecord> page = new Page<>(pageNo, pageSize);
@@ -122,6 +123,9 @@ public class InterviewRecordController extends JeecgController<InterviewRecord,
         if (interviewResult != null && !interviewResult.isEmpty()) {
             queryWrapper.eq("interview_result", interviewResult);
         }
+        if (postName != null && !postName.isEmpty()) {
+            queryWrapper.like("post_name", postName);
+        }
         queryWrapper.orderByAsc("interview_time");
         IPage<InterviewRecord> pageList = interviewRecordService.page(page, queryWrapper);
         return Result.OK(pageList);
@@ -129,78 +133,51 @@ public class InterviewRecordController extends JeecgController<InterviewRecord,
 
     /**
      * 企业端 - 面试通过
+     * 设置interviewResult='1',并给求职者发送通知
      */
     @AutoLog(value = "面试通过")
     @Operation(summary = "面试通过")
     @PutMapping(value = "/pass")
     public Result<?> pass(@RequestParam(name = "id", required = true) String id,
                           @RequestParam(name = "resultRemark", required = false) String resultRemark) {
-        InterviewRecord record = interviewRecordService.getById(id);
-        if (record == null) {
-            return Result.error("面试记录不存在");
-        }
-        record.setInterviewResult("1"); // 面试通过
-        if (resultRemark != null && !resultRemark.isEmpty()) {
-            record.setResultRemark(resultRemark);
-        }
-        interviewRecordService.updateById(record);
+        interviewRecordService.passInterview(id, resultRemark);
         return Result.OK("操作成功");
     }
 
     /**
      * 企业端 - 面试不通过
-     * 不通过时需填写面试说明(失败原因),保存到interviewComment字段
+     * 设置interviewResult='2',并给求职者发送通知
      */
     @AutoLog(value = "面试不通过")
     @Operation(summary = "面试不通过")
     @PutMapping(value = "/fail")
     public Result<?> fail(@RequestParam(name = "id", required = true) String id,
                           @RequestParam(name = "resultRemark", required = false) String resultRemark) {
-        InterviewRecord record = interviewRecordService.getById(id);
-        if (record == null) {
-            return Result.error("面试记录不存在");
-        }
-        record.setInterviewResult("2"); // 面试不通过
-        // 面试不通过时,resultRemark作为失败原因同时保存到interviewComment和resultRemark
-        if (resultRemark != null && !resultRemark.isEmpty()) {
-            record.setInterviewComment(resultRemark);
-            record.setResultRemark(resultRemark);
-        }
-        interviewRecordService.updateById(record);
+        interviewRecordService.failInterview(id, resultRemark);
         return Result.OK("操作成功");
     }
 
     /**
      * 个人端 - 确认参加面试
-     * 设置attendStatus='1'(参加)
+     * 设置attendStatus='1'(参加),并给企业发送通知
      */
     @AutoLog(value = "确认参加面试")
     @Operation(summary = "确认参加面试")
     @PutMapping(value = "/attend")
     public Result<?> attend(@RequestParam(name = "id", required = true) String id) {
-        InterviewRecord record = interviewRecordService.getById(id);
-        if (record == null) {
-            return Result.error("面试记录不存在");
-        }
-        record.setAttendStatus("1"); // 参加面试
-        interviewRecordService.updateById(record);
+        interviewRecordService.attendInterview(id);
         return Result.OK("操作成功");
     }
 
     /**
      * 个人端 - 不参加面试
-     * 设置attendStatus='2'(不参加)
+     * 设置attendStatus='2'(不参加),并给企业发送通知
      */
     @AutoLog(value = "不参加面试")
     @Operation(summary = "不参加面试")
     @PutMapping(value = "/notAttend")
     public Result<?> notAttend(@RequestParam(name = "id", required = true) String id) {
-        InterviewRecord record = interviewRecordService.getById(id);
-        if (record == null) {
-            return Result.error("面试记录不存在");
-        }
-        record.setAttendStatus("2"); // 不参加面试
-        interviewRecordService.updateById(record);
+        interviewRecordService.notAttendInterview(id);
         return Result.OK("操作成功");
     }
 
@@ -223,23 +200,13 @@ public class InterviewRecordController extends JeecgController<InterviewRecord,
 
     /**
      * 企业端 - 发起录用
-     * 校验面试已通过后,通过Mapper创建录用记录
+     * 校验面试已通过后创建录用记录,并给求职者生成待办
      */
     @AutoLog(value = "发起录用")
     @Operation(summary = "发起录用")
     @PostMapping(value = "/offer")
     public Result<?> offer(@RequestBody EmploymentOffer employmentOffer) {
-        // 校验面试记录存在且已通过
-        InterviewRecord record = interviewRecordService.getById(employmentOffer.getInterviewId());
-        if (record == null) {
-            return Result.error("面试记录不存在");
-        }
-        if (!"1".equals(record.getInterviewResult())) {
-            return Result.error("只能对面试通过的记录发起录用");
-        }
-        employmentOffer.setStatus("0"); // 待签约
-        // 通过Mapper创建录用记录
-        employmentOfferMapper.insert(employmentOffer);
+        interviewRecordService.offerJob(employmentOffer);
         return Result.OK("发起录用成功");
     }
 

+ 31 - 0
jeecg-boot/jeecg-boot-module/jeecg-module-zjrs/src/main/java/org/jeecg/modules/zjrs/interview/service/IInterviewRecordService.java

@@ -2,6 +2,7 @@ package org.jeecg.modules.zjrs.interview.service;
 
 import com.baomidou.mybatisplus.extension.service.IService;
 import org.jeecg.modules.zjrs.interview.entity.InterviewRecord;
+import org.jeecg.modules.zjrs.offer.entity.EmploymentOffer;
 
 /**
  * @Description: 面试记录表
@@ -10,4 +11,34 @@ import org.jeecg.modules.zjrs.interview.entity.InterviewRecord;
  * @Version: V2.0
  */
 public interface IInterviewRecordService extends IService<InterviewRecord> {
+
+    /**
+     * 个人端 - 确认参加面试
+     * 设置attendStatus='1',并给企业发送通知消息
+     */
+    void attendInterview(String id);
+
+    /**
+     * 个人端 - 不参加面试
+     * 设置attendStatus='2',并给企业发送通知消息
+     */
+    void notAttendInterview(String id);
+
+    /**
+     * 企业端 - 面试通过
+     * 设置interviewResult='1',并给求职者发送通知消息
+     */
+    void passInterview(String id, String resultRemark);
+
+    /**
+     * 企业端 - 面试不通过
+     * 设置interviewResult='2',并给求职者发送通知消息
+     */
+    void failInterview(String id, String resultRemark);
+
+    /**
+     * 企业端 - 发起录用
+     * 校验面试已通过后创建录用记录,并给求职者生成待办
+     */
+    void offerJob(EmploymentOffer employmentOffer);
 }

+ 187 - 0
jeecg-boot/jeecg-boot-module/jeecg-module-zjrs/src/main/java/org/jeecg/modules/zjrs/interview/service/impl/InterviewRecordServiceImpl.java

@@ -4,7 +4,19 @@ import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
 import org.jeecg.modules.zjrs.interview.entity.InterviewRecord;
 import org.jeecg.modules.zjrs.interview.mapper.InterviewRecordMapper;
 import org.jeecg.modules.zjrs.interview.service.IInterviewRecordService;
+import org.jeecg.modules.zjrs.notification.entity.NotificationTarget;
+import org.jeecg.modules.zjrs.notification.service.INotificationRecordService;
+import org.jeecg.modules.zjrs.offer.entity.EmploymentOffer;
+import org.jeecg.modules.zjrs.offer.mapper.EmploymentOfferMapper;
+import org.jeecg.modules.zjrs.todo.entity.TodoTarget;
+import org.jeecg.modules.zjrs.todo.service.ITodoRecordService;
+import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.Calendar;
+import java.util.Collections;
+import java.util.Date;
 
 /**
  * @Description: 面试记录表
@@ -14,4 +26,179 @@ import org.springframework.stereotype.Service;
  */
 @Service
 public class InterviewRecordServiceImpl extends ServiceImpl<InterviewRecordMapper, InterviewRecord> implements IInterviewRecordService {
+
+    @Autowired
+    private INotificationRecordService notificationRecordService;
+
+    @Autowired
+    private EmploymentOfferMapper employmentOfferMapper;
+
+    @Autowired
+    private ITodoRecordService todoRecordService;
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void attendInterview(String id) {
+        // 更新面试记录状态为"参加"
+        InterviewRecord record = this.getById(id);
+        if (record == null) {
+            throw new RuntimeException("面试记录不存在");
+        }
+        record.setAttendStatus("1"); // 参加面试
+        this.updateById(record);
+
+        // 给企业发送通知,告知求职者确认参加,过期时间 = 面试时间 + 1天
+        sendAttendNotification(record, "确认参加");
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void notAttendInterview(String id) {
+        // 更新面试记录状态为"不参加"
+        InterviewRecord record = this.getById(id);
+        if (record == null) {
+            throw new RuntimeException("面试记录不存在");
+        }
+        record.setAttendStatus("2"); // 不参加面试
+        this.updateById(record);
+
+        // 给企业发送通知,告知求职者不参加,过期时间 = 面试时间 + 1天
+        sendAttendNotification(record, "不参加");
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void passInterview(String id, String resultRemark) {
+        // 更新面试记录结果为"面试通过"
+        InterviewRecord record = this.getById(id);
+        if (record == null) {
+            throw new RuntimeException("面试记录不存在");
+        }
+        record.setInterviewResult("1"); // 面试通过
+        if (resultRemark != null && !resultRemark.isEmpty()) {
+            record.setResultRemark(resultRemark);
+        }
+        this.updateById(record);
+
+        // 给求职者发送通知,告知面试通过,过期时间 = 面试时间 + 1天
+        sendResultNotification(record, "通过");
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void failInterview(String id, String resultRemark) {
+        // 更新面试记录结果为"面试不通过"
+        InterviewRecord record = this.getById(id);
+        if (record == null) {
+            throw new RuntimeException("面试记录不存在");
+        }
+        record.setInterviewResult("2"); // 面试不通过
+        // 不通过时,resultRemark作为失败原因同时保存到interviewComment和resultRemark
+        if (resultRemark != null && !resultRemark.isEmpty()) {
+            record.setInterviewComment(resultRemark);
+            record.setResultRemark(resultRemark);
+        }
+        this.updateById(record);
+
+        // 给求职者发送通知,告知面试未通过,过期时间 = 面试时间 + 1天
+        sendResultNotification(record, "未通过");
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void offerJob(EmploymentOffer employmentOffer) {
+        // 校验面试记录存在且已通过
+        InterviewRecord record = this.getById(employmentOffer.getInterviewId());
+        if (record == null) {
+            throw new RuntimeException("面试记录不存在");
+        }
+        if (!"1".equals(record.getInterviewResult())) {
+            throw new RuntimeException("只能对面试通过的记录发起录用");
+        }
+
+        // 创建录用记录
+        employmentOffer.setStatus("0"); // 待签约
+        employmentOfferMapper.insert(employmentOffer);
+
+        // 给求职者生成待办,告知已被录用
+        TodoTarget target = new TodoTarget();
+        target.setTargetType("personal");
+        target.setTargetId(record.getPersonalId());
+
+        todoRecordService.createTodo(
+                "offer",
+                "录用通知",
+                String.format("您已被【%s】的【%s】岗位录用,请前往查看并确认签约",
+                        record.getEnterpriseName(), record.getPostName()),
+                "",
+                record.getEnterpriseName(),
+                "pages/personal/my-jobs/index",
+                employmentOffer.getId(),
+                "fromTab=2",
+                Collections.singletonList(target)
+        );
+    }
+
+    /**
+     * 发送面试反馈通知给企业(求职者确认/不确认参加时调用)
+     */
+    private void sendAttendNotification(InterviewRecord record, String action) {
+        // 计算过期时间:面试时间 + 1天
+        Date expireTime = calcExpireTime(record.getInterviewTime());
+
+        // 构建接收人
+        NotificationTarget target = new NotificationTarget();
+        target.setTargetType("enterprise");
+        target.setTargetId(record.getEnterpriseId());
+
+        // 发送通知
+        notificationRecordService.sendMessage(
+                "interview",
+                "面试反馈通知",
+                String.format("求职者【%s】已%s面试【%s】",
+                        record.getPersonalName(), action, record.getPostName()),
+                "",
+                record.getPersonalName(),
+                expireTime,
+                Collections.singletonList(target)
+        );
+    }
+
+    /**
+     * 发送面试结果通知给求职者(企业判定通过/不通过时调用)
+     */
+    private void sendResultNotification(InterviewRecord record, String result) {
+        // 计算过期时间:面试时间 + 1天
+        Date expireTime = calcExpireTime(record.getInterviewTime());
+
+        // 构建接收人
+        NotificationTarget target = new NotificationTarget();
+        target.setTargetType("personal");
+        target.setTargetId(record.getPersonalId());
+
+        // 发送通知
+        notificationRecordService.sendMessage(
+                "interview",
+                "面试结果通知",
+                String.format("您参加的【%s】面试为%s,结果由【%s】评定",
+                        record.getPostName(), result, record.getEnterpriseName()),
+                "",
+                record.getEnterpriseName(),
+                expireTime,
+                Collections.singletonList(target)
+        );
+    }
+
+    /**
+     * 计算过期时间:给定时间 + 1天
+     */
+    private Date calcExpireTime(Date interviewTime) {
+        if (interviewTime == null) {
+            return null;
+        }
+        Calendar cal = Calendar.getInstance();
+        cal.setTime(interviewTime);
+        cal.add(Calendar.DAY_OF_MONTH, 1);
+        return cal.getTime();
+    }
 }

+ 40 - 0
jeecg-boot/jeecg-boot-module/jeecg-module-zjrs/src/main/java/org/jeecg/modules/zjrs/notification/controller/NotificationController.java

@@ -324,6 +324,46 @@ public class NotificationController {
         return Result.OK(page);
     }
 
+    @Operation(summary = "修改消息状态为已读")
+    @PutMapping(value = "/updateStatus")
+    public Result<String> updateStatus(@RequestBody Map<String, List<String>> body) {
+        List<String> notificationIds = body.get("notificationIds");
+        if (notificationIds == null || notificationIds.isEmpty()) {
+            return Result.error("消息ID列表不能为空");
+        }
+
+        LoginUser sysUser = (LoginUser) SecurityUtils.getSubject().getPrincipal();
+        String username = sysUser.getUsername();
+
+        // 从username解析用户ID和类型(与receivedList逻辑一致)
+        String targetId;
+        String targetType;
+
+        if (username != null && username.startsWith("personal_")) {
+            targetId = username.substring("personal_".length());
+            targetType = "personal";
+        } else if (username != null && username.startsWith("enterprise_")) {
+            targetId = username.substring("enterprise_".length());
+            targetType = "enterprise";
+        } else {
+            return Result.error("当前用户类型不支持修改消息状态");
+        }
+
+        // 批量更新匹配的消息接收记录状态为已读
+        // 通过notification_id + target_type + target_id精确定位
+        QueryWrapper<NotificationTarget> query = new QueryWrapper<>();
+        query.in("notification_id", notificationIds);
+        query.eq("target_type", targetType);
+        query.eq("target_id", targetId);
+        query.eq("status", "0"); // 只更新未读的记录
+
+        NotificationTarget update = new NotificationTarget();
+        update.setStatus("1");
+        notificationTargetMapper.update(update, query);
+
+        return Result.OK("修改成功");
+    }
+
     @Operation(summary = "查询当前用户收到的消息")
     @GetMapping(value = "/receivedList")
     public Result<Page<NotificationRecord>> receivedList(

+ 8 - 0
jeecg-boot/jeecg-boot-module/jeecg-module-zjrs/src/main/java/org/jeecg/modules/zjrs/notification/entity/NotificationRecord.java

@@ -46,6 +46,14 @@ public class NotificationRecord implements Serializable {
     @Schema(description = "推送时间")
     private Date sendTime;
 
+    @JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss")
+    @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    @Schema(description = "过期时间")
+    private Date expireTime;
+
+    @Schema(description = "状态: 0未读 1已读")
+    private String status;
+
     @Schema(description = "创建人")
     private String createBy;
 

+ 3 - 0
jeecg-boot/jeecg-boot-module/jeecg-module-zjrs/src/main/java/org/jeecg/modules/zjrs/notification/entity/NotificationTarget.java

@@ -26,6 +26,9 @@ public class NotificationTarget implements Serializable {
     @Schema(description = "目标ID")
     private String targetId;
 
+    @Schema(description = "状态: 0=未读 1=已读")
+    private String status;
+
     // 非表字段 — 查询时从 PERSONAL_INFO / ENTERPRISE_INFO 关联填充
     @TableField(exist = false)
     @Schema(description = "目标名称(关联查询)")

+ 21 - 0
jeecg-boot/jeecg-boot-module/jeecg-module-zjrs/src/main/java/org/jeecg/modules/zjrs/notification/service/INotificationRecordService.java

@@ -2,6 +2,27 @@ package org.jeecg.modules.zjrs.notification.service;
 
 import com.baomidou.mybatisplus.extension.service.IService;
 import org.jeecg.modules.zjrs.notification.entity.NotificationRecord;
+import org.jeecg.modules.zjrs.notification.entity.NotificationTarget;
+
+import java.util.Date;
+import java.util.List;
 
 public interface INotificationRecordService extends IService<NotificationRecord> {
+
+    /**
+     * 发送消息(提供给其他业务模块调用)
+     *
+     * @param moduleType 来源模块
+     * @param subject    消息主题
+     * @param content    消息内容
+     * @param senderId   推送人用户ID
+     * @param sender     推送人姓名
+     * @param expireTime 过期时间
+     * @param targets    接收人列表
+     * @return 创建的消息记录
+     */
+    NotificationRecord sendMessage(String moduleType, String subject, String content,
+                                   String senderId, String sender,
+                                   Date expireTime,
+                                   List<NotificationTarget> targets);
 }

+ 79 - 0
jeecg-boot/jeecg-boot-module/jeecg-module-zjrs/src/main/java/org/jeecg/modules/zjrs/notification/service/impl/NotificationRecordServiceImpl.java

@@ -1,11 +1,90 @@
 package org.jeecg.modules.zjrs.notification.service.impl;
 
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
 import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import org.jeecg.modules.zjrs.enterprisestatus.entity.EnterpriseStatusLocal;
+import org.jeecg.modules.zjrs.enterprisestatus.service.IEnterpriseStatusService;
 import org.jeecg.modules.zjrs.notification.entity.NotificationRecord;
+import org.jeecg.modules.zjrs.notification.entity.NotificationTarget;
 import org.jeecg.modules.zjrs.notification.mapper.NotificationRecordMapper;
+import org.jeecg.modules.zjrs.notification.mapper.NotificationTargetMapper;
 import org.jeecg.modules.zjrs.notification.service.INotificationRecordService;
+import org.jeecg.modules.zjrs.personalstatus.entity.PersonalStatusLocal;
+import org.jeecg.modules.zjrs.personalstatus.service.IPersonalStatusService;
+import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.Date;
+import java.util.List;
 
 @Service
 public class NotificationRecordServiceImpl extends ServiceImpl<NotificationRecordMapper, NotificationRecord> implements INotificationRecordService {
+
+    @Autowired
+    private NotificationTargetMapper notificationTargetMapper;
+
+    @Autowired
+    private IEnterpriseStatusService enterpriseStatusService;
+
+    @Autowired
+    private IPersonalStatusService personalStatusService;
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public NotificationRecord sendMessage(String moduleType, String subject, String content,
+                                           String senderId, String sender,
+                                           Date expireTime,
+                                           List<NotificationTarget> targets) {
+        // 构建消息主记录,默认状态为未读
+        Date now = new Date();
+        NotificationRecord record = new NotificationRecord();
+        record.setModuleType(moduleType);
+        record.setSubject(subject);
+        record.setContent(content);
+        record.setSenderId(senderId);
+        record.setSender(sender);
+        record.setSendTime(now);
+        record.setExpireTime(expireTime);
+        record.setStatus("0"); // 默认未读
+        this.save(record);
+
+        // 写入接收人关联,并更新企业/人员的最后通知时间,用于首页红点提醒
+        for (NotificationTarget target : targets) {
+            target.setNotificationId(record.getId());
+            notificationTargetMapper.insert(target);
+
+            if ("enterprise".equals(target.getTargetType())) {
+                // 更新企业状态表中的最后通知时间
+                QueryWrapper<EnterpriseStatusLocal> q = new QueryWrapper<>();
+                q.eq("enterprise_id", target.getTargetId());
+                EnterpriseStatusLocal ext = enterpriseStatusService.getOne(q);
+                if (ext != null) {
+                    ext.setLastNoticeTime(now);
+                    enterpriseStatusService.updateById(ext);
+                } else {
+                    EnterpriseStatusLocal local = new EnterpriseStatusLocal();
+                    local.setEnterpriseId(target.getTargetId());
+                    local.setLastNoticeTime(now);
+                    enterpriseStatusService.save(local);
+                }
+            } else if ("personal".equals(target.getTargetType())) {
+                // 更新个人状态表中的最后通知时间
+                QueryWrapper<PersonalStatusLocal> q = new QueryWrapper<>();
+                q.eq("personal_id", target.getTargetId());
+                PersonalStatusLocal ext = personalStatusService.getOne(q);
+                if (ext != null) {
+                    ext.setLastNoticeTime(now);
+                    personalStatusService.updateById(ext);
+                } else {
+                    PersonalStatusLocal local = new PersonalStatusLocal();
+                    local.setPersonalId(target.getTargetId());
+                    local.setLastNoticeTime(now);
+                    personalStatusService.save(local);
+                }
+            }
+        }
+
+        return record;
+    }
 }

+ 8 - 14
jeecg-boot/jeecg-boot-module/jeecg-module-zjrs/src/main/java/org/jeecg/modules/zjrs/offer/controller/EmploymentOfferController.java

@@ -109,6 +109,7 @@ public class EmploymentOfferController extends JeecgController<EmploymentOffer,
     public Result<IPage<EmploymentOffer>> myOffers(
             @RequestParam(name = "personalId", required = true) String personalId,
             @RequestParam(name = "status", required = false) String status,
+            @RequestParam(name = "postName", required = false) String postName,
             @RequestParam(name = "pageNo", defaultValue = "1") Integer pageNo,
             @RequestParam(name = "pageSize", defaultValue = "10") Integer pageSize) {
         Page<EmploymentOffer> page = new Page<>(pageNo, pageSize);
@@ -117,6 +118,9 @@ public class EmploymentOfferController extends JeecgController<EmploymentOffer,
         if (status != null && !status.isEmpty()) {
             queryWrapper.eq("status", status);
         }
+        if (postName != null && !postName.isEmpty()) {
+            queryWrapper.like("post_name", postName);
+        }
         queryWrapper.orderByDesc("create_time");
         IPage<EmploymentOffer> pageList = employmentOfferService.page(page, queryWrapper);
         return Result.OK(pageList);
@@ -141,35 +145,25 @@ public class EmploymentOfferController extends JeecgController<EmploymentOffer,
 
     /**
      * 个人端 - 确认签约
-     * 设置录用状态为已签约(status='2')
+     * 设置录用状态为已签约(status='2'),并给企业发送通知
      */
     @AutoLog(value = "确认签约")
     @Operation(summary = "确认签约")
     @PutMapping(value = "/confirm")
     public Result<?> confirm(@RequestParam(name = "id", required = true) String id) {
-        EmploymentOffer offer = employmentOfferService.getById(id);
-        if (offer == null) {
-            return Result.error("录用记录不存在");
-        }
-        offer.setStatus("2"); // 已签约
-        employmentOfferService.updateById(offer);
+        employmentOfferService.confirmOffer(id);
         return Result.OK("签约成功");
     }
 
     /**
      * 个人端 - 拒绝录用
-     * 设置录用状态为已拒绝(status='4')
+     * 设置录用状态为已拒绝(status='4'),并给企业发送通知
      */
     @AutoLog(value = "拒绝录用")
     @Operation(summary = "拒绝录用")
     @PutMapping(value = "/reject")
     public Result<?> reject(@RequestParam(name = "id", required = true) String id) {
-        EmploymentOffer offer = employmentOfferService.getById(id);
-        if (offer == null) {
-            return Result.error("录用记录不存在");
-        }
-        offer.setStatus("4"); // 已拒绝
-        employmentOfferService.updateById(offer);
+        employmentOfferService.rejectOffer(id);
         return Result.OK("已拒绝");
     }
 

+ 12 - 0
jeecg-boot/jeecg-boot-module/jeecg-module-zjrs/src/main/java/org/jeecg/modules/zjrs/offer/service/IEmploymentOfferService.java

@@ -10,4 +10,16 @@ import org.jeecg.modules.zjrs.offer.entity.EmploymentOffer;
  * @Version: V2.0
  */
 public interface IEmploymentOfferService extends IService<EmploymentOffer> {
+
+    /**
+     * 个人端 - 确认签约
+     * 设置status='2',并给企业发送通知消息
+     */
+    void confirmOffer(String id);
+
+    /**
+     * 个人端 - 拒绝录用
+     * 设置status='4',并给企业发送通知消息
+     */
+    void rejectOffer(String id);
 }

+ 61 - 0
jeecg-boot/jeecg-boot-module/jeecg-module-zjrs/src/main/java/org/jeecg/modules/zjrs/offer/service/impl/EmploymentOfferServiceImpl.java

@@ -1,10 +1,16 @@
 package org.jeecg.modules.zjrs.offer.service.impl;
 
 import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import org.jeecg.modules.zjrs.notification.entity.NotificationTarget;
+import org.jeecg.modules.zjrs.notification.service.INotificationRecordService;
 import org.jeecg.modules.zjrs.offer.entity.EmploymentOffer;
 import org.jeecg.modules.zjrs.offer.mapper.EmploymentOfferMapper;
 import org.jeecg.modules.zjrs.offer.service.IEmploymentOfferService;
+import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.Collections;
 
 /**
  * @Description: 录用记录表
@@ -14,4 +20,59 @@ import org.springframework.stereotype.Service;
  */
 @Service
 public class EmploymentOfferServiceImpl extends ServiceImpl<EmploymentOfferMapper, EmploymentOffer> implements IEmploymentOfferService {
+
+    @Autowired
+    private INotificationRecordService notificationRecordService;
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void confirmOffer(String id) {
+        // 更新录用状态为"已签约"
+        EmploymentOffer offer = this.getById(id);
+        if (offer == null) {
+            throw new RuntimeException("录用记录不存在");
+        }
+        offer.setStatus("2"); // 已签约
+        this.updateById(offer);
+
+        // 给企业发送通知,告知求职者已确认签约
+        sendOfferNotification(offer, "确认签约", "确认签约");
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void rejectOffer(String id) {
+        // 更新录用状态为"已拒绝"
+        EmploymentOffer offer = this.getById(id);
+        if (offer == null) {
+            throw new RuntimeException("录用记录不存在");
+        }
+        offer.setStatus("4"); // 已拒绝
+        this.updateById(offer);
+
+        // 给企业发送通知,告知求职者已拒绝录用
+        sendOfferNotification(offer, "拒绝录用", "拒绝");
+    }
+
+    /**
+     * 发送录用反馈通知给企业(求职者确认/拒绝时调用)
+     */
+    private void sendOfferNotification(EmploymentOffer offer, String action, String result) {
+        // 构建接收人
+        NotificationTarget target = new NotificationTarget();
+        target.setTargetType("enterprise");
+        target.setTargetId(offer.getEnterpriseId());
+
+        // 发送通知
+        notificationRecordService.sendMessage(
+                "offer",
+                "录用反馈通知",
+                String.format("求职者【%s】已%s【%s】岗位的录用",
+                        offer.getPersonalName(), result, offer.getPostName()),
+                "",
+                offer.getPersonalName(),
+                null,
+                Collections.singletonList(target)
+        );
+    }
 }

+ 3 - 1
jeecg-boot/jeecg-boot-module/jeecg-module-zjrs/src/main/java/org/jeecg/modules/zjrs/post/mapper/xml/PostInfoMapper.xml

@@ -70,7 +70,9 @@
                p.update_by,
                p.update_time,
                p.sys_org_code,
-               e.company_name AS company_name
+               e.company_name AS company_name,
+               e.staff_size AS staff_size,
+               e.industry AS industry
         FROM post_info p
                  LEFT JOIN enterprise_info e ON p.enterprise_id = e.id
         WHERE p.id = #{id}

+ 66 - 0
jeecg-boot/jeecg-boot-module/jeecg-module-zjrs/src/main/java/org/jeecg/modules/zjrs/todo/controller/TodoController.java

@@ -0,0 +1,66 @@
+package org.jeecg.modules.zjrs.todo.controller;
+
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.shiro.SecurityUtils;
+import org.jeecg.common.api.vo.Result;
+import org.jeecg.common.system.vo.LoginUser;
+import org.jeecg.modules.zjrs.todo.entity.TodoRecord;
+import org.jeecg.modules.zjrs.todo.service.ITodoRecordService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.Collections;
+
+@Slf4j
+@Tag(name = "待办管理")
+@RestController
+@RequestMapping("/todo")
+public class TodoController {
+
+    @Autowired
+    private ITodoRecordService todoRecordService;
+
+    @Operation(summary = "分页查询当前用户的待办列表(企业/人员自动识别)")
+    @GetMapping(value = "/myList")
+    public Result<Page<TodoRecord>> myList(
+            @RequestParam(name = "keyword", required = false) String keyword,
+            @RequestParam(name = "moduleType", required = false) String moduleType,
+            @RequestParam(name = "status", required = false) String status,
+            @RequestParam(name = "pageNo", defaultValue = "1") Integer pageNo,
+            @RequestParam(name = "pageSize", defaultValue = "10") Integer pageSize) {
+        LoginUser sysUser = (LoginUser) SecurityUtils.getSubject().getPrincipal();
+        String username = sysUser.getUsername();
+
+        // 从username解析用户类型和ID
+        // 小程序登录格式:personal_{personalInfoId} 或 enterprise_{enterpriseInfoId}
+        // 管理端登录格式:普通username,默认按个人查询
+        String targetId;
+        String targetType;
+
+        if (username != null && username.startsWith("personal_")) {
+            targetId = username.substring("personal_".length());
+            targetType = "personal";
+        } else if (username != null && username.startsWith("enterprise_")) {
+            targetId = username.substring("enterprise_".length());
+            targetType = "enterprise";
+        } else {
+            targetId = sysUser.getId();
+            targetType = "personal";
+        }
+
+        if (targetId == null || targetId.isEmpty()) {
+            Page<TodoRecord> emptyPage = new Page<>(pageNo, pageSize);
+            emptyPage.setTotal(0);
+            emptyPage.setRecords(Collections.emptyList());
+            return Result.OK(emptyPage);
+        }
+
+        Page<TodoRecord> page = todoRecordService.queryMyList(
+                targetId, targetType, keyword, moduleType, status, pageNo, pageSize);
+
+        return Result.OK(page);
+    }
+}

+ 77 - 0
jeecg-boot/jeecg-boot-module/jeecg-module-zjrs/src/main/java/org/jeecg/modules/zjrs/todo/entity/TodoRecord.java

@@ -0,0 +1,77 @@
+package org.jeecg.modules.zjrs.todo.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.fasterxml.jackson.annotation.JsonFormat;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import lombok.experimental.Accessors;
+import org.springframework.format.annotation.DateTimeFormat;
+
+import java.io.Serializable;
+import java.util.Date;
+import java.util.List;
+
+@Data
+@TableName("todo_record")
+@Accessors(chain = true)
+@EqualsAndHashCode(callSuper = false)
+@Schema(description = "待办记录表")
+public class TodoRecord implements Serializable {
+    private static final long serialVersionUID = 1L;
+
+    @TableId(type = IdType.ASSIGN_ID)
+    @Schema(description = "主键ID")
+    private String id;
+
+    @Schema(description = "来源模块")
+    private String moduleType;
+
+    @Schema(description = "待办标题")
+    private String subject;
+
+    @Schema(description = "待办内容")
+    private String content;
+
+    @Schema(description = "发起人用户ID")
+    private String creatorId;
+
+    @Schema(description = "发起人姓名")
+    private String creator;
+
+    @JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss")
+    @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    @Schema(description = "创建时间")
+    private Date createTime;
+
+    @Schema(description = "状态: 0待处理 1已完成 2已取消")
+    private String status;
+
+    @JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss")
+    @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    @Schema(description = "完成时间")
+    private Date completeTime;
+
+    @JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss")
+    @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    @Schema(description = "过期时间")
+    private Date expireTime;
+
+    @Schema(description = "目标页面路径")
+    private String targetPage;
+
+    @Schema(description = "数据ID")
+    private String dataId;
+
+    @Schema(description = "路径参数(JSON格式)")
+    private String pathParams;
+
+    @Schema(description = "组织机构编号")
+    private String sysOrgCode;
+
+    // 非表字段 — 接收人列表(用于查询时关联填充)
+    @Schema(description = "接收人列表")
+    private transient List<TodoTarget> targets;
+}

+ 36 - 0
jeecg-boot/jeecg-boot-module/jeecg-module-zjrs/src/main/java/org/jeecg/modules/zjrs/todo/entity/TodoTarget.java

@@ -0,0 +1,36 @@
+package org.jeecg.modules.zjrs.todo.entity;
+
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableName;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import lombok.experimental.Accessors;
+
+import java.io.Serializable;
+
+@Data
+@TableName("todo_target")
+@Accessors(chain = true)
+@EqualsAndHashCode(callSuper = false)
+@Schema(description = "待办接收人关联表")
+public class TodoTarget implements Serializable {
+    private static final long serialVersionUID = 1L;
+
+    @Schema(description = "待办ID")
+    private String todoId;
+
+    @Schema(description = "类型: personal/enterprise")
+    private String targetType;
+
+    @Schema(description = "目标ID")
+    private String targetId;
+
+    // 非表字段 — 查询时从 PERSONAL_INFO / ENTERPRISE_INFO 关联填充
+    @TableField(exist = false)
+    @Schema(description = "目标名称(关联查询)")
+    private String targetName;
+
+    @Schema(description = "状态: 0待处理 1已完成 2已取消")
+    private String status;
+}

+ 7 - 0
jeecg-boot/jeecg-boot-module/jeecg-module-zjrs/src/main/java/org/jeecg/modules/zjrs/todo/mapper/TodoRecordMapper.java

@@ -0,0 +1,7 @@
+package org.jeecg.modules.zjrs.todo.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import org.jeecg.modules.zjrs.todo.entity.TodoRecord;
+
+public interface TodoRecordMapper extends BaseMapper<TodoRecord> {
+}

+ 7 - 0
jeecg-boot/jeecg-boot-module/jeecg-module-zjrs/src/main/java/org/jeecg/modules/zjrs/todo/mapper/TodoTargetMapper.java

@@ -0,0 +1,7 @@
+package org.jeecg.modules.zjrs.todo.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import org.jeecg.modules.zjrs.todo.entity.TodoTarget;
+
+public interface TodoTargetMapper extends BaseMapper<TodoTarget> {
+}

+ 4 - 0
jeecg-boot/jeecg-boot-module/jeecg-module-zjrs/src/main/java/org/jeecg/modules/zjrs/todo/mapper/xml/TodoRecordMapper.xml

@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="org.jeecg.modules.zjrs.todo.mapper.TodoRecordMapper">
+</mapper>

+ 46 - 0
jeecg-boot/jeecg-boot-module/jeecg-module-zjrs/src/main/java/org/jeecg/modules/zjrs/todo/service/ITodoRecordService.java

@@ -0,0 +1,46 @@
+package org.jeecg.modules.zjrs.todo.service;
+
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.baomidou.mybatisplus.extension.service.IService;
+import org.jeecg.modules.zjrs.todo.entity.TodoRecord;
+import org.jeecg.modules.zjrs.todo.entity.TodoTarget;
+
+import java.util.List;
+
+public interface ITodoRecordService extends IService<TodoRecord> {
+
+    /**
+     * 新增待办(提供给其他业务模块调用)
+     *
+     * @param moduleType  来源模块
+     * @param subject     待办标题
+     * @param content     待办内容
+     * @param creatorId   发起人用户ID
+     * @param creator     发起人姓名
+     * @param targetPage  目标页面路径
+     * @param dataId      数据ID
+     * @param pathParams  路径参数
+     * @param targets     接收人列表
+     * @return 创建的待办记录
+     */
+    TodoRecord createTodo(String moduleType, String subject, String content,
+                          String creatorId, String creator,
+                          String targetPage, String dataId, String pathParams,
+                          List<TodoTarget> targets);
+
+    /**
+     * 分页查询当前用户的待办列表
+     *
+     * @param targetId   用户ID
+     * @param targetType 用户类型 personal/enterprise
+     * @param keyword    关键字(模糊匹配标题)
+     * @param moduleType 来源模块过滤
+     * @param status     状态过滤
+     * @param pageNo     页码
+     * @param pageSize   每页条数
+     * @return 分页待办列表(已填充接收人信息)
+     */
+    Page<TodoRecord> queryMyList(String targetId, String targetType,
+                                  String keyword, String moduleType, String status,
+                                  Integer pageNo, Integer pageSize);
+}

+ 153 - 0
jeecg-boot/jeecg-boot-module/jeecg-module-zjrs/src/main/java/org/jeecg/modules/zjrs/todo/service/impl/TodoRecordServiceImpl.java

@@ -0,0 +1,153 @@
+package org.jeecg.modules.zjrs.todo.service.impl;
+
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import org.jeecg.modules.zjrs.enterprise.entity.EnterpriseInfo;
+import org.jeecg.modules.zjrs.enterprise.mapper.EnterpriseInfoMapper;
+import org.jeecg.modules.zjrs.personal.entity.PersonalInfo;
+import org.jeecg.modules.zjrs.personal.mapper.PersonalInfoMapper;
+import org.jeecg.modules.zjrs.todo.entity.TodoRecord;
+import org.jeecg.modules.zjrs.todo.entity.TodoTarget;
+import org.jeecg.modules.zjrs.todo.mapper.TodoRecordMapper;
+import org.jeecg.modules.zjrs.todo.mapper.TodoTargetMapper;
+import org.jeecg.modules.zjrs.todo.service.ITodoRecordService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.*;
+import java.util.stream.Collectors;
+
+@Service
+public class TodoRecordServiceImpl extends ServiceImpl<TodoRecordMapper, TodoRecord> implements ITodoRecordService {
+
+    @Autowired
+    private TodoTargetMapper todoTargetMapper;
+
+    @Autowired
+    private PersonalInfoMapper personalInfoMapper;
+
+    @Autowired
+    private EnterpriseInfoMapper enterpriseInfoMapper;
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public TodoRecord createTodo(String moduleType, String subject, String content,
+                                  String creatorId, String creator,
+                                  String targetPage, String dataId, String pathParams,
+                                  List<TodoTarget> targets) {
+        // 构建待办主记录
+        Date now = new Date();
+        TodoRecord record = new TodoRecord();
+        record.setModuleType(moduleType);
+        record.setSubject(subject);
+        record.setContent(content);
+        record.setCreatorId(creatorId);
+        record.setCreator(creator);
+        record.setCreateTime(now);
+        record.setStatus("0"); // 默认待处理
+        record.setTargetPage(targetPage);
+        record.setDataId(dataId);
+        record.setPathParams(pathParams);
+        this.save(record);
+
+        // 批量写入接收人关联
+        if (targets != null && !targets.isEmpty()) {
+            for (TodoTarget target : targets) {
+                target.setTodoId(record.getId());
+                todoTargetMapper.insert(target);
+            }
+        }
+
+        return record;
+    }
+
+    @Override
+    public Page<TodoRecord> queryMyList(String targetId, String targetType,
+                                         String keyword, String moduleType, String status,
+                                         Integer pageNo, Integer pageSize) {
+        // 查询当前用户作为接收人的待办ID列表
+        QueryWrapper<TodoTarget> tq = new QueryWrapper<>();
+        tq.eq("target_id", targetId);
+        tq.eq("target_type", targetType);
+        tq.select("todo_id");
+        List<TodoTarget> targets = todoTargetMapper.selectList(tq);
+
+        if (targets.isEmpty()) {
+            Page<TodoRecord> emptyPage = new Page<>(pageNo, pageSize);
+            emptyPage.setTotal(0);
+            emptyPage.setRecords(Collections.emptyList());
+            return emptyPage;
+        }
+
+        Set<String> todoIds = targets.stream()
+                .map(TodoTarget::getTodoId)
+                .collect(Collectors.toSet());
+
+        // 查询待办主记录
+        QueryWrapper<TodoRecord> query = new QueryWrapper<>();
+        query.in("id", todoIds);
+        if (keyword != null && !keyword.isEmpty()) {
+            query.like("subject", keyword);
+        }
+        if (moduleType != null && !moduleType.isEmpty()) {
+            query.eq("module_type", moduleType);
+        }
+        if (status != null && !status.isEmpty()) {
+            query.eq("status", status);
+        }
+        query.orderByDesc("create_time");
+
+        Page<TodoRecord> page = new Page<>(pageNo, pageSize);
+        page = this.page(page, query);
+
+        // 填充接收人信息
+        for (TodoRecord record : page.getRecords()) {
+            QueryWrapper<TodoTarget> ntq = new QueryWrapper<>();
+            ntq.eq("todo_id", record.getId());
+            List<TodoTarget> recordTargets = todoTargetMapper.selectList(ntq);
+            resolveTargetNames(recordTargets);
+            record.setTargets(recordTargets);
+        }
+
+        return page;
+    }
+
+    /**
+     * 批量关联查询目标名称
+     */
+    private void resolveTargetNames(List<TodoTarget> targets) {
+        if (targets == null || targets.isEmpty()) return;
+        List<String> personalIds = targets.stream()
+                .filter(t -> "personal".equals(t.getTargetType()))
+                .map(TodoTarget::getTargetId)
+                .collect(Collectors.toList());
+        List<String> enterpriseIds = targets.stream()
+                .filter(t -> "enterprise".equals(t.getTargetType()))
+                .map(TodoTarget::getTargetId)
+                .collect(Collectors.toList());
+
+        Map<String, String> nameMap = new HashMap<>();
+        if (!personalIds.isEmpty()) {
+            QueryWrapper<PersonalInfo> pq = new QueryWrapper<>();
+            pq.in("id", personalIds);
+            pq.select("id", "full_name");
+            for (PersonalInfo p : personalInfoMapper.selectList(pq)) {
+                nameMap.put(p.getId(), p.getFullName());
+            }
+        }
+        if (!enterpriseIds.isEmpty()) {
+            QueryWrapper<EnterpriseInfo> eq = new QueryWrapper<>();
+            eq.in("id", enterpriseIds);
+            eq.select("id", "company_name");
+            for (EnterpriseInfo e : enterpriseInfoMapper.selectList(eq)) {
+                nameMap.put(e.getId(), e.getCompanyName());
+            }
+        }
+
+        for (TodoTarget t : targets) {
+            t.setTargetName(nameMap.getOrDefault(t.getTargetId(), t.getTargetId()));
+        }
+    }
+}

+ 14 - 1
jeecgboot-vue3/src/views/recruitment/enterprise/components/EnterpriseInfoDetailModal.vue

@@ -53,7 +53,7 @@
           <a-descriptions-item label="单位网站" :span="1">{{ detailData.website }}</a-descriptions-item>
           
           <a-descriptions-item label="传真号码" :span="1">{{ detailData.faxNumber }}</a-descriptions-item>
-          <a-descriptions-item label="单位福利" :span="1">{{ detailData.companyBenefits }}</a-descriptions-item>
+          <a-descriptions-item label="单位福利" :span="1">{{ getDictTexts('UnitBenefits', detailData.companyBenefits) }}</a-descriptions-item>
           <a-descriptions-item label="办公地址行政区划" :span="1">{{ detailData.officeAddrDistrict || '-' }}</a-descriptions-item>
           
           <a-descriptions-item label="办公地址" :span="1">{{ detailData.officeAddress }}</a-descriptions-item>
@@ -119,6 +119,7 @@
     'CompanySize',
     'DataSource',
     'IndustryField',
+    'UnitBenefits',
   ]);
 
   // ---------- 树形字典数据:用于 label 翻译 ----------
@@ -200,6 +201,18 @@
     return '-';
   }
 
+  /**
+   * 翻译多选字典值:将逗号分隔的 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('、');
+  }
+
   defineExpose({
     open,
   });

+ 9 - 1
jeecgboot-vue3/src/views/recruitment/enterprise/components/EnterpriseInfoForm.vue

@@ -258,7 +258,7 @@
             </a-col>
             <a-col :span="8">
               <a-form-item id="EnterpriseInfoForm-companyBenefits" label="单位福利" name="companyBenefits" v-bind="validateInfos.companyBenefits">
-                <a-input v-model:value="formData.companyBenefits" allow-clear placeholder="请输入单位福利"></a-input>
+                <a-select v-model:value="formData.companyBenefits" mode="multiple" allow-clear placeholder="请选择单位福利" :options="companyBenefitsOptions" />
               </a-form-item>
             </a-col>
             <a-col :span="8">
@@ -401,6 +401,7 @@
     'CompanySize',
     'DataSource',
     'IndustryField',
+    'UnitBenefits',
   ]);
 
   // 字典选项
@@ -409,6 +410,7 @@
   const staffSizeOptions = computed(() => getDictOptions('CompanySize'));
   const dataSourceOptions = computed(() => getDictOptions('DataSource'));
   const industryFieldOptions = computed(() => getDictOptions('IndustryField'));
+  const companyBenefitsOptions = computed(() => getDictOptions('UnitBenefits'));
 
   // ---------- 树形字典:注册地址行政区划 (XZQH) ----------
   const regAddrTreeData = ref<any[]>([]);
@@ -645,6 +647,12 @@
         // 处理 null、undefined 等非数组情况
         tmpData.tagIds = [];
       }
+      // 单位福利字段转换:将后端返回的逗号分隔字符串转为数组,适配 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 = [];
+      }
       //赋值
       Object.assign(formData, tmpData);
       // 回填行政区划树形下拉的 label 显示(直接用已存储的完整路径文本,无需调 API)