From e5bfa0c78ce493e38461b40ac1191de2455a2567 Mon Sep 17 00:00:00 2001 From: wangyu <823267011@qq.com> Date: Thu, 11 Jun 2026 10:12:12 +0800 Subject: [PATCH 01/11] =?UTF-8?q?=E4=BC=9A=E8=AE=AE=E7=BA=AA=E8=A6=81?= =?UTF-8?q?=EF=BC=9A=E5=8E=9F=E5=A7=8BWIP=E7=89=88=E6=9C=AC=E7=95=99?= =?UTF-8?q?=E5=BA=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .../OaMeetingMinutesController.java | 61 ++ .../com/ruoyi/oa/domain/OaMeetingMinutes.java | 58 ++ .../oa/domain/bo/OaMeetingMinutesBo.java | 47 ++ .../oa/domain/vo/OaMeetingMinutesVo.java | 51 ++ .../oa/mapper/OaMeetingMinutesMapper.java | 8 + .../oa/service/IOaMeetingMinutesService.java | 24 + .../impl/OaMeetingMinutesServiceImpl.java | 260 +++++++ ruoyi-ui/src/api/oa/meetingMinutes.js | 21 + ruoyi-ui/src/views/oa/meeting/index.vue | 683 ++++++++++++++++++ sql/oa_meeting.sql | 62 ++ 10 files changed, 1275 insertions(+) create mode 100644 ruoyi-oa/src/main/java/com/ruoyi/oa/controller/OaMeetingMinutesController.java create mode 100644 ruoyi-oa/src/main/java/com/ruoyi/oa/domain/OaMeetingMinutes.java create mode 100644 ruoyi-oa/src/main/java/com/ruoyi/oa/domain/bo/OaMeetingMinutesBo.java create mode 100644 ruoyi-oa/src/main/java/com/ruoyi/oa/domain/vo/OaMeetingMinutesVo.java create mode 100644 ruoyi-oa/src/main/java/com/ruoyi/oa/mapper/OaMeetingMinutesMapper.java create mode 100644 ruoyi-oa/src/main/java/com/ruoyi/oa/service/IOaMeetingMinutesService.java create mode 100644 ruoyi-oa/src/main/java/com/ruoyi/oa/service/impl/OaMeetingMinutesServiceImpl.java create mode 100644 ruoyi-ui/src/api/oa/meetingMinutes.js create mode 100644 ruoyi-ui/src/views/oa/meeting/index.vue create mode 100644 sql/oa_meeting.sql diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/controller/OaMeetingMinutesController.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/controller/OaMeetingMinutesController.java new file mode 100644 index 0000000..546bf27 --- /dev/null +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/controller/OaMeetingMinutesController.java @@ -0,0 +1,61 @@ +package com.ruoyi.oa.controller; + +import com.ruoyi.common.annotation.Log; +import com.ruoyi.common.annotation.RepeatSubmit; +import com.ruoyi.common.core.controller.BaseController; +import com.ruoyi.common.core.domain.PageQuery; +import com.ruoyi.common.core.domain.R; +import com.ruoyi.common.core.page.TableDataInfo; +import com.ruoyi.common.enums.BusinessType; +import com.ruoyi.oa.domain.bo.OaMeetingMinutesBo; +import com.ruoyi.oa.domain.vo.OaMeetingMinutesVo; +import com.ruoyi.oa.service.IOaMeetingMinutesService; +import lombok.RequiredArgsConstructor; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import javax.validation.constraints.NotEmpty; +import javax.validation.constraints.NotNull; +import java.util.Arrays; + +/** + * 会议纪要 + */ +@Validated +@RequiredArgsConstructor +@RestController +@RequestMapping("/oa/meetingMinutes") +public class OaMeetingMinutesController extends BaseController { + + private final IOaMeetingMinutesService service; + + @GetMapping("/list") + public TableDataInfo list(OaMeetingMinutesBo bo, PageQuery pageQuery) { + return service.queryPageList(bo, pageQuery); + } + + @GetMapping("/{id}") + public R getInfo(@NotNull @PathVariable Long id) { + return R.ok(service.queryById(id)); + } + + @Log(title = "会议纪要", businessType = BusinessType.INSERT) + @RepeatSubmit + @PostMapping + public R add(@RequestBody OaMeetingMinutesBo bo) { + return toAjax(service.insertByBo(bo)); + } + + @Log(title = "会议纪要", businessType = BusinessType.UPDATE) + @RepeatSubmit + @PutMapping + public R edit(@RequestBody OaMeetingMinutesBo bo) { + return toAjax(service.updateByBo(bo)); + } + + @Log(title = "会议纪要", businessType = BusinessType.DELETE) + @DeleteMapping("/{ids}") + public R remove(@NotEmpty @PathVariable Long[] ids) { + return toAjax(service.deleteWithValidByIds(Arrays.asList(ids), true)); + } +} diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/domain/OaMeetingMinutes.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/domain/OaMeetingMinutes.java new file mode 100644 index 0000000..0faa7c1 --- /dev/null +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/domain/OaMeetingMinutes.java @@ -0,0 +1,58 @@ +package com.ruoyi.oa.domain; + +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableLogic; +import com.baomidou.mybatisplus.annotation.TableName; +import com.fasterxml.jackson.annotation.JsonFormat; +import com.ruoyi.common.core.domain.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.springframework.format.annotation.DateTimeFormat; + +import java.util.Date; + +/** + * 会议纪要 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("oa_meeting_minutes") +public class OaMeetingMinutes extends BaseEntity { + + private static final long serialVersionUID = 1L; + + @TableId(value = "id") + private Long id; + + private String meetingCode; + + @JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8") + @DateTimeFormat(pattern = "yyyy-MM-dd") + private Date meetingDate; + + /** sys_oa_project.project_id */ + private Long projectId; + + private String meetingType; + private String subject; + private String location; + + /** sys_user.user_id */ + private Long hostUserId; + + /** 参会人员 user_id CSV */ + private String attendeeUserIds; + + private String topic; + private String discussion; + private String decision; + + /** 待办 JSON:[{assigneeUserId, content, deadline, status, taskId}] */ + private String tasksJson; + + /** 1=保存时自动同步生成 sys_oa_task */ + private Integer syncTask; + + @TableLogic + private String delFlag; +} diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/domain/bo/OaMeetingMinutesBo.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/domain/bo/OaMeetingMinutesBo.java new file mode 100644 index 0000000..4b158e4 --- /dev/null +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/domain/bo/OaMeetingMinutesBo.java @@ -0,0 +1,47 @@ +package com.ruoyi.oa.domain.bo; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.ruoyi.common.core.domain.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.springframework.format.annotation.DateTimeFormat; + +import java.util.Date; + +/** + * 会议纪要 BO + */ +@Data +@EqualsAndHashCode(callSuper = true) +public class OaMeetingMinutesBo extends BaseEntity { + + private Long id; + private String meetingCode; + + @JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8") + @DateTimeFormat(pattern = "yyyy-MM-dd") + private Date meetingDate; + + private Long projectId; + private String meetingType; + private String subject; + private String location; + private Long hostUserId; + private String attendeeUserIds; + private String topic; + private String discussion; + private String decision; + private String tasksJson; + private Integer syncTask; + + /** 关键字模糊(主题/地点/项目名) */ + private String keyword; + + @JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8") + @DateTimeFormat(pattern = "yyyy-MM-dd") + private Date dateFrom; + + @JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8") + @DateTimeFormat(pattern = "yyyy-MM-dd") + private Date dateTo; +} diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/domain/vo/OaMeetingMinutesVo.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/domain/vo/OaMeetingMinutesVo.java new file mode 100644 index 0000000..779e99f --- /dev/null +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/domain/vo/OaMeetingMinutesVo.java @@ -0,0 +1,51 @@ +package com.ruoyi.oa.domain.vo; + +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Data; + +import java.io.Serializable; +import java.util.Date; + +/** + * 会议纪要 VO + */ +@Data +public class OaMeetingMinutesVo implements Serializable { + + private static final long serialVersionUID = 1L; + + private Long id; + private String meetingCode; + + @JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8") + private Date meetingDate; + + private Long projectId; + /** 冗余:项目编号 / 名称(列表联表带出) */ + private String projectNum; + private String projectName; + + private String meetingType; + private String subject; + private String location; + private Long hostUserId; + /** 冗余:主持人昵称 */ + private String hostUserName; + + private String attendeeUserIds; + /** 冗余:参会人员昵称(逗号分隔) */ + private String attendeeUserNames; + + private String topic; + private String discussion; + private String decision; + private String tasksJson; + private Integer syncTask; + + private String createBy; + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date createTime; + private String updateBy; + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date updateTime; +} diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/mapper/OaMeetingMinutesMapper.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/mapper/OaMeetingMinutesMapper.java new file mode 100644 index 0000000..234d98a --- /dev/null +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/mapper/OaMeetingMinutesMapper.java @@ -0,0 +1,8 @@ +package com.ruoyi.oa.mapper; + +import com.ruoyi.common.core.mapper.BaseMapperPlus; +import com.ruoyi.oa.domain.OaMeetingMinutes; +import com.ruoyi.oa.domain.vo.OaMeetingMinutesVo; + +public interface OaMeetingMinutesMapper extends BaseMapperPlus { +} diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/service/IOaMeetingMinutesService.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/service/IOaMeetingMinutesService.java new file mode 100644 index 0000000..9cb6685 --- /dev/null +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/service/IOaMeetingMinutesService.java @@ -0,0 +1,24 @@ +package com.ruoyi.oa.service; + +import com.ruoyi.common.core.domain.PageQuery; +import com.ruoyi.common.core.page.TableDataInfo; +import com.ruoyi.oa.domain.bo.OaMeetingMinutesBo; +import com.ruoyi.oa.domain.vo.OaMeetingMinutesVo; + +import java.util.Collection; +import java.util.List; + +public interface IOaMeetingMinutesService { + + OaMeetingMinutesVo queryById(Long id); + + TableDataInfo queryPageList(OaMeetingMinutesBo bo, PageQuery pageQuery); + + List queryList(OaMeetingMinutesBo bo); + + Boolean insertByBo(OaMeetingMinutesBo bo); + + Boolean updateByBo(OaMeetingMinutesBo bo); + + Boolean deleteWithValidByIds(Collection ids, Boolean isValid); +} diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/service/impl/OaMeetingMinutesServiceImpl.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/service/impl/OaMeetingMinutesServiceImpl.java new file mode 100644 index 0000000..bd2112c --- /dev/null +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/service/impl/OaMeetingMinutesServiceImpl.java @@ -0,0 +1,260 @@ +package com.ruoyi.oa.service.impl; + +import cn.hutool.core.bean.BeanUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.ruoyi.common.core.domain.PageQuery; +import com.ruoyi.common.core.domain.entity.SysUser; +import com.ruoyi.common.core.page.TableDataInfo; +import com.ruoyi.common.helper.LoginHelper; +import com.ruoyi.common.utils.StringUtils; +import com.ruoyi.oa.domain.OaMeetingMinutes; +import com.ruoyi.oa.domain.SysOaProject; +import com.ruoyi.oa.domain.SysOaTask; +import com.ruoyi.oa.domain.bo.OaMeetingMinutesBo; +import com.ruoyi.oa.domain.vo.OaMeetingMinutesVo; +import com.ruoyi.oa.mapper.OaMeetingMinutesMapper; +import com.ruoyi.oa.mapper.SysOaProjectMapper; +import com.ruoyi.oa.mapper.SysOaTaskMapper; +import com.ruoyi.oa.service.IOaMeetingMinutesService; +import com.ruoyi.system.mapper.SysUserMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.text.SimpleDateFormat; +import java.util.*; +import java.util.stream.Collectors; + +@RequiredArgsConstructor +@Service +public class OaMeetingMinutesServiceImpl implements IOaMeetingMinutesService { + + private final OaMeetingMinutesMapper baseMapper; + private final SysOaProjectMapper projectMapper; + private final SysOaTaskMapper taskMapper; + private final SysUserMapper userMapper; + private final ObjectMapper json = new ObjectMapper(); + + @Override + public OaMeetingMinutesVo queryById(Long id) { + OaMeetingMinutesVo vo = baseMapper.selectVoById(id); + enrich(Collections.singletonList(vo)); + return vo; + } + + @Override + public TableDataInfo queryPageList(OaMeetingMinutesBo bo, PageQuery pageQuery) { + LambdaQueryWrapper lqw = buildQueryWrapper(bo); + Page result = baseMapper.selectVoPage(pageQuery.build(), lqw); + enrich(result.getRecords()); + return TableDataInfo.build(result); + } + + @Override + public List queryList(OaMeetingMinutesBo bo) { + List list = baseMapper.selectVoList(buildQueryWrapper(bo)); + enrich(list); + return list; + } + + private LambdaQueryWrapper buildQueryWrapper(OaMeetingMinutesBo bo) { + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + if (bo != null) { + lqw.eq(bo.getProjectId() != null, OaMeetingMinutes::getProjectId, bo.getProjectId()); + lqw.eq(StringUtils.isNotBlank(bo.getMeetingType()), OaMeetingMinutes::getMeetingType, bo.getMeetingType()); + if (StringUtils.isNotBlank(bo.getKeyword())) { + String kw = bo.getKeyword().trim(); + lqw.and(w -> w.like(OaMeetingMinutes::getSubject, kw) + .or().like(OaMeetingMinutes::getLocation, kw)); + } + lqw.ge(bo.getDateFrom() != null, OaMeetingMinutes::getMeetingDate, bo.getDateFrom()); + lqw.le(bo.getDateTo() != null, OaMeetingMinutes::getMeetingDate, bo.getDateTo()); + } + lqw.orderByDesc(OaMeetingMinutes::getMeetingDate) + .orderByDesc(OaMeetingMinutes::getCreateTime); + return lqw; + } + + /** 给列表填上 项目名/编号、主持人/参会人员 昵称 */ + private void enrich(List list) { + if (list == null || list.isEmpty()) return; + Set projectIds = new HashSet<>(); + Set userIds = new HashSet<>(); + for (OaMeetingMinutesVo v : list) { + if (v == null) continue; + if (v.getProjectId() != null) projectIds.add(v.getProjectId()); + if (v.getHostUserId() != null) userIds.add(v.getHostUserId()); + for (Long uid : parseLongCsv(v.getAttendeeUserIds())) userIds.add(uid); + } + Map pMap = projectIds.isEmpty() ? Collections.emptyMap() + : projectMapper.selectList(new QueryWrapper().in("project_id", projectIds)) + .stream().collect(Collectors.toMap(SysOaProject::getProjectId, p -> p, (a, b) -> a)); + Map uMap = userIds.isEmpty() ? Collections.emptyMap() + : userMapper.selectList(new QueryWrapper().in("user_id", userIds)) + .stream().collect(Collectors.toMap(SysUser::getUserId, u -> u, (a, b) -> a)); + + for (OaMeetingMinutesVo v : list) { + if (v == null) continue; + if (v.getProjectId() != null) { + SysOaProject p = pMap.get(v.getProjectId()); + if (p != null) { + v.setProjectName(p.getProjectName()); + v.setProjectNum(p.getProjectNum()); + } + } + if (v.getHostUserId() != null) { + SysUser u = uMap.get(v.getHostUserId()); + if (u != null) v.setHostUserName(u.getNickName()); + } + List attIds = parseLongCsv(v.getAttendeeUserIds()); + if (!attIds.isEmpty()) { + List names = new ArrayList<>(); + for (Long uid : attIds) { + SysUser u = uMap.get(uid); + if (u != null) names.add(u.getNickName()); + } + v.setAttendeeUserNames(String.join(",", names)); + } + } + } + + @Override + @Transactional(rollbackFor = Exception.class) + public Boolean insertByBo(OaMeetingMinutesBo bo) { + OaMeetingMinutes add = BeanUtil.toBean(bo, OaMeetingMinutes.class); + if (StringUtils.isBlank(add.getMeetingCode())) { + add.setMeetingCode("MT-" + new SimpleDateFormat("yyyyMMddHHmmss").format(new Date())); + } + if (StringUtils.isBlank(add.getMeetingType())) add.setMeetingType("other"); + if (add.getSyncTask() == null) add.setSyncTask(1); + validBeforeSave(add); + boolean ok = baseMapper.insert(add) > 0; + if (ok) { + bo.setId(add.getId()); + if (Integer.valueOf(1).equals(add.getSyncTask())) { + String updated = syncTasks(add); + if (updated != null && !updated.equals(add.getTasksJson())) { + add.setTasksJson(updated); + baseMapper.updateById(add); + } + } + } + return ok; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public Boolean updateByBo(OaMeetingMinutesBo bo) { + OaMeetingMinutes upd = BeanUtil.toBean(bo, OaMeetingMinutes.class); + validBeforeSave(upd); + boolean ok = baseMapper.updateById(upd) > 0; + if (ok && Integer.valueOf(1).equals(upd.getSyncTask())) { + String updated = syncTasks(upd); + if (updated != null && !updated.equals(upd.getTasksJson())) { + upd.setTasksJson(updated); + baseMapper.updateById(upd); + } + } + return ok; + } + + private void validBeforeSave(OaMeetingMinutes e) { + if (e.getMeetingDate() == null) throw new IllegalArgumentException("请选择会议日期"); + if (StringUtils.isBlank(e.getSubject())) throw new IllegalArgumentException("请输入会议主题"); + } + + /** + * 将 tasks_json 中的每个待办同步生成 sys_oa_task: + * - 若条目已有 taskId 且仍存在 → 更新内容/截止/完成状态 + * - 否则新建并把 taskId 写回 JSON + */ + private String syncTasks(OaMeetingMinutes meeting) { + if (StringUtils.isBlank(meeting.getTasksJson())) return meeting.getTasksJson(); + try { + JsonNode root = json.readTree(meeting.getTasksJson()); + if (!root.isArray()) return meeting.getTasksJson(); + ArrayNode arr = (ArrayNode) root; + Long currentUser = LoginHelper.getUserId(); + for (int i = 0; i < arr.size(); i++) { + JsonNode n = arr.get(i); + if (!n.isObject()) continue; + ObjectNode o = (ObjectNode) n; + String content = textOf(o, "content"); + if (StringUtils.isBlank(content)) continue; + + Long assignee = longOf(o, "assigneeUserId"); + String deadline = textOf(o, "deadline"); + String status = textOf(o, "status"); + Long existTaskId = longOf(o, "taskId"); + + SysOaTask t = null; + if (existTaskId != null) { + t = taskMapper.selectById(existTaskId); + } + boolean isNew = (t == null); + if (isNew) { + t = new SysOaTask(); + t.setCreateUserId(currentUser); + t.setBeginTime(meeting.getMeetingDate()); + } + t.setProjectId(meeting.getProjectId()); + t.setTaskTitle(content.length() > 200 ? content.substring(0, 200) : content); + t.setContent("来自会议纪要:" + meeting.getSubject()); + t.setWorkerId(assignee); + if (StringUtils.isNotBlank(deadline)) { + try { t.setFinishTime(new SimpleDateFormat("yyyy-MM-dd").parse(deadline)); } catch (Exception ignored) {} + } + // 任务状态:done=2 已完成;progress=1 进行中;pending=0 待办 + if ("done".equals(status)) t.setState(2L); + else if ("progress".equals(status)) t.setState(1L); + else t.setState(0L); + + if (isNew) { + taskMapper.insert(t); + o.put("taskId", t.getTaskId()); + } else { + taskMapper.updateById(t); + } + } + return json.writeValueAsString(arr); + } catch (Exception e) { + // 同步失败不影响主流程,仅日志 + return meeting.getTasksJson(); + } + } + + private String textOf(ObjectNode n, String k) { + JsonNode v = n.get(k); + return v == null || v.isNull() ? null : v.asText(); + } + + private Long longOf(ObjectNode n, String k) { + JsonNode v = n.get(k); + if (v == null || v.isNull()) return null; + try { return v.isNumber() ? v.asLong() : Long.parseLong(v.asText()); } + catch (NumberFormatException e) { return null; } + } + + private List parseLongCsv(String csv) { + if (StringUtils.isBlank(csv)) return Collections.emptyList(); + List r = new ArrayList<>(); + for (String s : csv.split(",")) { + String t = s.trim(); + if (t.isEmpty()) continue; + try { r.add(Long.parseLong(t)); } catch (NumberFormatException ignored) {} + } + return r; + } + + @Override + public Boolean deleteWithValidByIds(Collection ids, Boolean isValid) { + return baseMapper.deleteBatchIds(ids) > 0; + } +} diff --git a/ruoyi-ui/src/api/oa/meetingMinutes.js b/ruoyi-ui/src/api/oa/meetingMinutes.js new file mode 100644 index 0000000..63f1a2b --- /dev/null +++ b/ruoyi-ui/src/api/oa/meetingMinutes.js @@ -0,0 +1,21 @@ +import request from '@/utils/request' + +export function listMeetingMinutes (query) { + return request({ url: '/oa/meetingMinutes/list', method: 'get', params: query }) +} + +export function getMeetingMinutes (id) { + return request({ url: '/oa/meetingMinutes/' + id, method: 'get' }) +} + +export function addMeetingMinutes (data) { + return request({ url: '/oa/meetingMinutes', method: 'post', data }) +} + +export function updateMeetingMinutes (data) { + return request({ url: '/oa/meetingMinutes', method: 'put', data }) +} + +export function delMeetingMinutes (ids) { + return request({ url: '/oa/meetingMinutes/' + ids, method: 'delete' }) +} diff --git a/ruoyi-ui/src/views/oa/meeting/index.vue b/ruoyi-ui/src/views/oa/meeting/index.vue new file mode 100644 index 0000000..a53ebdd --- /dev/null +++ b/ruoyi-ui/src/views/oa/meeting/index.vue @@ -0,0 +1,683 @@ + + + + + diff --git a/sql/oa_meeting.sql b/sql/oa_meeting.sql new file mode 100644 index 0000000..7136cba --- /dev/null +++ b/sql/oa_meeting.sql @@ -0,0 +1,62 @@ +-- ===================================================== +-- 智能会议纪要 (Smart Meeting Minutes) +-- - 弃用 oa_meeting_project,改为绑定 sys_oa_project.project_id +-- - 主持人/参会/待办负责人 统一存 user_id +-- - 待办在保存时可同步生成 sys_oa_task +-- ===================================================== + +-- 清理上一版(如有) +DROP TABLE IF EXISTS `oa_meeting_project`; + +-- ---------------- 会议纪要 ---------------- +DROP TABLE IF EXISTS `oa_meeting_minutes`; +CREATE TABLE `oa_meeting_minutes` ( + `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `meeting_code` varchar(32) NOT NULL COMMENT '会议编号 MT-yyyyMMddHHmmss', + `meeting_date` date NOT NULL COMMENT '会议日期', + `project_id` bigint(20) DEFAULT NULL COMMENT '关联 sys_oa_project.project_id', + `meeting_type` varchar(20) DEFAULT 'other' COMMENT '类型 tech/project/client/weekly/other', + `subject` varchar(500) NOT NULL COMMENT '会议主题', + `location` varchar(255) DEFAULT NULL COMMENT '会议地点', + `host_user_id` bigint(20) DEFAULT NULL COMMENT '主持人 sys_user.user_id', + `attendee_user_ids` varchar(1000) DEFAULT NULL COMMENT '参会人员 user_id 列表(逗号分隔)', + `topic` text COMMENT '会议议题', + `discussion` text COMMENT '讨论内容', + `decision` text COMMENT '决议事项', + `tasks_json` text COMMENT '待办 JSON:[{assigneeUserId,content,deadline,status,taskId}]', + `sync_task` tinyint(1) DEFAULT 1 COMMENT '是否将待办同步为 OA 任务', + `create_by` varchar(64) DEFAULT NULL, + `create_time` datetime DEFAULT CURRENT_TIMESTAMP, + `update_by` varchar(64) DEFAULT NULL, + `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `del_flag` char(1) DEFAULT '0' COMMENT '删除标志:0正常 1删除', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_meeting_code` (`meeting_code`, `del_flag`), + KEY `idx_date` (`meeting_date`), + KEY `idx_project` (`project_id`), + KEY `idx_type` (`meeting_type`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='会议纪要'; + +-- ---------------- 字典:会议类型 ---------------- +INSERT IGNORE INTO `sys_dict_type` (`dict_name`, `dict_type`, `status`, `create_by`, `create_time`, `remark`) +VALUES ('会议类型', 'oa_meeting_type', '0', 'admin', NOW(), '智能会议纪要-会议类型'); + +INSERT IGNORE INTO `sys_dict_data` + (`dict_sort`, `dict_label`, `dict_value`, `dict_type`, `css_class`, `list_class`, `is_default`, `status`, `create_by`, `create_time`, `remark`) +VALUES + (1, '技术评审', 'tech', 'oa_meeting_type', '', 'info', 'N', '0', 'admin', NOW(), ''), + (2, '项目推进', 'project', 'oa_meeting_type', '', 'primary', 'N', '0', 'admin', NOW(), ''), + (3, '客户沟通', 'client', 'oa_meeting_type', '', 'warning', 'N', '0', 'admin', NOW(), ''), + (4, '周例会', 'weekly', 'oa_meeting_type', '', 'success', 'N', '0', 'admin', NOW(), ''), + (5, '其他', 'other', 'oa_meeting_type', '', '', 'Y', '0', 'admin', NOW(), ''); + +-- ---------------- 字典:待办状态 ---------------- +INSERT IGNORE INTO `sys_dict_type` (`dict_name`, `dict_type`, `status`, `create_by`, `create_time`, `remark`) +VALUES ('会议待办状态', 'oa_meeting_task_status', '0', 'admin', NOW(), '智能会议纪要-待办状态'); + +INSERT IGNORE INTO `sys_dict_data` + (`dict_sort`, `dict_label`, `dict_value`, `dict_type`, `css_class`, `list_class`, `is_default`, `status`, `create_by`, `create_time`, `remark`) +VALUES + (1, '待办', 'pending', 'oa_meeting_task_status', '', 'info', 'Y', '0', 'admin', NOW(), ''), + (2, '进行中', 'progress', 'oa_meeting_task_status', '', 'primary', 'N', '0', 'admin', NOW(), ''), + (3, '已完成', 'done', 'oa_meeting_task_status', '', 'success', 'N', '0', 'admin', NOW(), ''); From a9c9b8a5eaf1c95f98f7f12ddcb6e5751ad76ec9 Mon Sep 17 00:00:00 2001 From: wangyu <823267011@qq.com> Date: Thu, 11 Jun 2026 10:12:48 +0800 Subject: [PATCH 02/11] =?UTF-8?q?=E4=BC=9A=E8=AE=AE=E7=BA=AA=E8=A6=81?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=E4=BF=AE=E5=A4=8D=E4=B8=8E=E6=94=B9=E8=BF=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端: - 待办同步改走 ISysOaTaskService.insertByBo/updateByBo,新任务带操作日志和IM通知 - 任务状态映射修正:done→2执行完成,其余→0执行中(原 progress→1 会显示成"等待验收") - 无负责人/无内容的待办仅作纪要记录,不再生成无主任务 - 更新时可清空字段改用显式 set(原来解绑项目、清空内容不生效) - 新增接口返回纪要ID,前端据此进入编辑态,避免重复保存生成多条 - 会议编号加3位随机数防同秒撞唯一键;异常改 ServiceException;同步失败记日志 - enrich 为待办条目注入 assigneeName,列表/详情/导出可显示负责人姓名 - SysOaTaskServiceImpl.insertByBo 回填 taskId 供调用方关联 前端: - 主持人/待办负责人改用人员单选弹窗(原多选组件取首位的方式易误操作) - 会议类型、待办状态接入 sys_dict 字典(oa_meeting_type / oa_meeting_task_status) - 新建保存后切换为编辑态;默认日期用本地时区(原 UTC 凌晨会差一天) - 导出/打印带主持人、参会人、待办负责人姓名(原来只有用户ID) - 删除已同步待办时提示任务不会被删除 SQL(已直接应用到生产库): - 字典数据补全并修复 dict_id=0 脏数据(sys_dict_* 主键为雪花ID须显式指定) - 菜单 2063809716454174722 icon 修为 documentation,授权10个角色 - 脚本改为幂等,去掉 DROP TABLE,del_flag 注释修正为逻辑删除值2 Co-Authored-By: Claude Fable 5 --- .../OaMeetingMinutesController.java | 10 +- .../impl/OaMeetingMinutesServiceImpl.java | 193 ++++++++++------ .../oa/service/impl/SysOaTaskServiceImpl.java | 2 + ruoyi-ui/src/views/oa/meeting/index.vue | 213 +++++++++++------- sql/oa_meeting.sql | 86 ++++--- 5 files changed, 330 insertions(+), 174 deletions(-) diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/controller/OaMeetingMinutesController.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/controller/OaMeetingMinutesController.java index 546bf27..2500975 100644 --- a/ruoyi-oa/src/main/java/com/ruoyi/oa/controller/OaMeetingMinutesController.java +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/controller/OaMeetingMinutesController.java @@ -39,11 +39,17 @@ public class OaMeetingMinutesController extends BaseController { return R.ok(service.queryById(id)); } + /** + * 新增,返回新纪要ID(前端据此切换为编辑态,避免重复保存生成多条) + */ @Log(title = "会议纪要", businessType = BusinessType.INSERT) @RepeatSubmit @PostMapping - public R add(@RequestBody OaMeetingMinutesBo bo) { - return toAjax(service.insertByBo(bo)); + public R add(@RequestBody OaMeetingMinutesBo bo) { + if (!service.insertByBo(bo)) { + return R.fail("保存失败"); + } + return R.ok(bo.getId()); } @Log(title = "会议纪要", businessType = BusinessType.UPDATE) diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/service/impl/OaMeetingMinutesServiceImpl.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/service/impl/OaMeetingMinutesServiceImpl.java index bd2112c..a3be2b2 100644 --- a/ruoyi-oa/src/main/java/com/ruoyi/oa/service/impl/OaMeetingMinutesServiceImpl.java +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/service/impl/OaMeetingMinutesServiceImpl.java @@ -1,8 +1,10 @@ package com.ruoyi.oa.service.impl; import cn.hutool.core.bean.BeanUtil; +import cn.hutool.core.util.RandomUtil; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import com.baomidou.mybatisplus.core.toolkit.Wrappers; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.fasterxml.jackson.databind.JsonNode; @@ -12,19 +14,22 @@ import com.fasterxml.jackson.databind.node.ObjectNode; import com.ruoyi.common.core.domain.PageQuery; import com.ruoyi.common.core.domain.entity.SysUser; import com.ruoyi.common.core.page.TableDataInfo; -import com.ruoyi.common.helper.LoginHelper; +import com.ruoyi.common.exception.ServiceException; import com.ruoyi.common.utils.StringUtils; import com.ruoyi.oa.domain.OaMeetingMinutes; import com.ruoyi.oa.domain.SysOaProject; import com.ruoyi.oa.domain.SysOaTask; import com.ruoyi.oa.domain.bo.OaMeetingMinutesBo; +import com.ruoyi.oa.domain.bo.SysOaTaskBo; import com.ruoyi.oa.domain.vo.OaMeetingMinutesVo; import com.ruoyi.oa.mapper.OaMeetingMinutesMapper; import com.ruoyi.oa.mapper.SysOaProjectMapper; import com.ruoyi.oa.mapper.SysOaTaskMapper; import com.ruoyi.oa.service.IOaMeetingMinutesService; +import com.ruoyi.oa.service.ISysOaTaskService; import com.ruoyi.system.mapper.SysUserMapper; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -32,6 +37,13 @@ import java.text.SimpleDateFormat; import java.util.*; import java.util.stream.Collectors; +/** + * 会议纪要 + * + * 待办同步说明:tasks_json 中"有负责人且有内容"的条目会通过 ISysOaTaskService + * 生成/更新 sys_oa_task(复用任务模块的操作日志、IM 通知逻辑),任务ID回写进 JSON。 + */ +@Slf4j @RequiredArgsConstructor @Service public class OaMeetingMinutesServiceImpl implements IOaMeetingMinutesService { @@ -39,12 +51,20 @@ public class OaMeetingMinutesServiceImpl implements IOaMeetingMinutesService { private final OaMeetingMinutesMapper baseMapper; private final SysOaProjectMapper projectMapper; private final SysOaTaskMapper taskMapper; + private final ISysOaTaskService taskService; private final SysUserMapper userMapper; private final ObjectMapper json = new ObjectMapper(); + /** sys_oa_task.state:0执行中 2执行完成(1等待验收/15延期申请由任务模块流转,会议页不使用) */ + private static final Long TASK_STATE_DOING = 0L; + private static final Long TASK_STATE_DONE = 2L; + @Override public OaMeetingMinutesVo queryById(Long id) { OaMeetingMinutesVo vo = baseMapper.selectVoById(id); + if (vo == null) { + throw new ServiceException("会议纪要不存在或已删除"); + } enrich(Collections.singletonList(vo)); return vo; } @@ -72,7 +92,8 @@ public class OaMeetingMinutesServiceImpl implements IOaMeetingMinutesService { if (StringUtils.isNotBlank(bo.getKeyword())) { String kw = bo.getKeyword().trim(); lqw.and(w -> w.like(OaMeetingMinutes::getSubject, kw) - .or().like(OaMeetingMinutes::getLocation, kw)); + .or().like(OaMeetingMinutes::getLocation, kw) + .or().like(OaMeetingMinutes::getMeetingCode, kw)); } lqw.ge(bo.getDateFrom() != null, OaMeetingMinutes::getMeetingDate, bo.getDateFrom()); lqw.le(bo.getDateTo() != null, OaMeetingMinutes::getMeetingDate, bo.getDateTo()); @@ -82,16 +103,25 @@ public class OaMeetingMinutesServiceImpl implements IOaMeetingMinutesService { return lqw; } - /** 给列表填上 项目名/编号、主持人/参会人员 昵称 */ + /** 填充 项目名/编号、主持人/参会人/待办负责人 昵称 */ private void enrich(List list) { if (list == null || list.isEmpty()) return; Set projectIds = new HashSet<>(); Set userIds = new HashSet<>(); + Map taskNodes = new HashMap<>(); for (OaMeetingMinutesVo v : list) { if (v == null) continue; if (v.getProjectId() != null) projectIds.add(v.getProjectId()); if (v.getHostUserId() != null) userIds.add(v.getHostUserId()); - for (Long uid : parseLongCsv(v.getAttendeeUserIds())) userIds.add(uid); + userIds.addAll(parseLongCsv(v.getAttendeeUserIds())); + ArrayNode arr = parseTaskArray(v.getTasksJson()); + if (arr != null) { + taskNodes.put(v.getId(), arr); + for (JsonNode n : arr) { + Long uid = longOf(n, "assigneeUserId"); + if (uid != null) userIds.add(uid); + } + } } Map pMap = projectIds.isEmpty() ? Collections.emptyMap() : projectMapper.selectList(new QueryWrapper().in("project_id", projectIds)) @@ -113,14 +143,26 @@ public class OaMeetingMinutesServiceImpl implements IOaMeetingMinutesService { SysUser u = uMap.get(v.getHostUserId()); if (u != null) v.setHostUserName(u.getNickName()); } - List attIds = parseLongCsv(v.getAttendeeUserIds()); - if (!attIds.isEmpty()) { - List names = new ArrayList<>(); - for (Long uid : attIds) { - SysUser u = uMap.get(uid); - if (u != null) names.add(u.getNickName()); + List names = new ArrayList<>(); + for (Long uid : parseLongCsv(v.getAttendeeUserIds())) { + SysUser u = uMap.get(uid); + if (u != null) names.add(u.getNickName()); + } + if (!names.isEmpty()) v.setAttendeeUserNames(String.join(",", names)); + // 待办条目写入 assigneeName,前端展示/导出用 + ArrayNode arr = taskNodes.get(v.getId()); + if (arr != null) { + for (JsonNode n : arr) { + if (!n.isObject()) continue; + Long uid = longOf(n, "assigneeUserId"); + SysUser u = uid == null ? null : uMap.get(uid); + ((ObjectNode) n).put("assigneeName", u == null ? null : u.getNickName()); + } + try { + v.setTasksJson(json.writeValueAsString(arr)); + } catch (Exception e) { + log.warn("会议纪要[{}] tasksJson 序列化失败", v.getId(), e); } - v.setAttendeeUserNames(String.join(",", names)); } } } @@ -129,9 +171,10 @@ public class OaMeetingMinutesServiceImpl implements IOaMeetingMinutesService { @Transactional(rollbackFor = Exception.class) public Boolean insertByBo(OaMeetingMinutesBo bo) { OaMeetingMinutes add = BeanUtil.toBean(bo, OaMeetingMinutes.class); - if (StringUtils.isBlank(add.getMeetingCode())) { - add.setMeetingCode("MT-" + new SimpleDateFormat("yyyyMMddHHmmss").format(new Date())); - } + add.setId(null); + // 时间戳+3位随机数,避免同一秒并发保存撞唯一键 + add.setMeetingCode("MT-" + new SimpleDateFormat("yyyyMMddHHmmss").format(new Date()) + + RandomUtil.randomNumbers(3)); if (StringUtils.isBlank(add.getMeetingType())) add.setMeetingType("other"); if (add.getSyncTask() == null) add.setSyncTask(1); validBeforeSave(add); @@ -141,8 +184,7 @@ public class OaMeetingMinutesServiceImpl implements IOaMeetingMinutesService { if (Integer.valueOf(1).equals(add.getSyncTask())) { String updated = syncTasks(add); if (updated != null && !updated.equals(add.getTasksJson())) { - add.setTasksJson(updated); - baseMapper.updateById(add); + patchTasksJson(add.getId(), updated); } } } @@ -152,28 +194,50 @@ public class OaMeetingMinutesServiceImpl implements IOaMeetingMinutesService { @Override @Transactional(rollbackFor = Exception.class) public Boolean updateByBo(OaMeetingMinutesBo bo) { + if (bo.getId() == null) throw new ServiceException("缺少纪要ID"); + OaMeetingMinutes old = baseMapper.selectById(bo.getId()); + if (old == null) throw new ServiceException("会议纪要不存在或已删除"); OaMeetingMinutes upd = BeanUtil.toBean(bo, OaMeetingMinutes.class); + if (StringUtils.isBlank(upd.getMeetingType())) upd.setMeetingType("other"); validBeforeSave(upd); - boolean ok = baseMapper.updateById(upd) > 0; - if (ok && Integer.valueOf(1).equals(upd.getSyncTask())) { - String updated = syncTasks(upd); - if (updated != null && !updated.equals(upd.getTasksJson())) { - upd.setTasksJson(updated); - baseMapper.updateById(upd); - } + if (Integer.valueOf(1).equals(upd.getSyncTask())) { + upd.setTasksJson(syncTasks(upd)); } - return ok; + // 可清空字段(解绑项目、清空人员/内容等)显式 set,避免 MyBatis-Plus 忽略 null 导致清空不生效 + LambdaUpdateWrapper luw = Wrappers.lambdaUpdate() + .set(OaMeetingMinutes::getProjectId, upd.getProjectId()) + .set(OaMeetingMinutes::getHostUserId, upd.getHostUserId()) + .set(OaMeetingMinutes::getAttendeeUserIds, upd.getAttendeeUserIds()) + .set(OaMeetingMinutes::getLocation, upd.getLocation()) + .set(OaMeetingMinutes::getTopic, upd.getTopic()) + .set(OaMeetingMinutes::getDiscussion, upd.getDiscussion()) + .set(OaMeetingMinutes::getDecision, upd.getDecision()) + .set(OaMeetingMinutes::getTasksJson, upd.getTasksJson()) + .eq(OaMeetingMinutes::getId, upd.getId()); + OaMeetingMinutes entity = new OaMeetingMinutes(); + entity.setMeetingDate(upd.getMeetingDate()); + entity.setMeetingType(upd.getMeetingType()); + entity.setSubject(upd.getSubject()); + entity.setSyncTask(upd.getSyncTask()); + return baseMapper.update(entity, luw) > 0; + } + + private void patchTasksJson(Long id, String tasksJson) { + baseMapper.update(null, Wrappers.lambdaUpdate() + .set(OaMeetingMinutes::getTasksJson, tasksJson) + .eq(OaMeetingMinutes::getId, id)); } private void validBeforeSave(OaMeetingMinutes e) { - if (e.getMeetingDate() == null) throw new IllegalArgumentException("请选择会议日期"); - if (StringUtils.isBlank(e.getSubject())) throw new IllegalArgumentException("请输入会议主题"); + if (e.getMeetingDate() == null) throw new ServiceException("请选择会议日期"); + if (StringUtils.isBlank(e.getSubject())) throw new ServiceException("请输入会议主题"); } /** - * 将 tasks_json 中的每个待办同步生成 sys_oa_task: - * - 若条目已有 taskId 且仍存在 → 更新内容/截止/完成状态 - * - 否则新建并把 taskId 写回 JSON + * 将待办同步到 sys_oa_task(通过任务服务,带操作日志和 IM 通知): + * - 无内容或无负责人的条目仅作纪要记录,不生成任务 + * - 已有 taskId 且任务仍存在 → 更新;否则新建并把 taskId 写回 JSON + * - 同步失败不阻塞纪要保存,仅记录日志 */ private String syncTasks(OaMeetingMinutes meeting) { if (StringUtils.isBlank(meeting.getTasksJson())) return meeting.getTasksJson(); @@ -181,61 +245,64 @@ public class OaMeetingMinutesServiceImpl implements IOaMeetingMinutesService { JsonNode root = json.readTree(meeting.getTasksJson()); if (!root.isArray()) return meeting.getTasksJson(); ArrayNode arr = (ArrayNode) root; - Long currentUser = LoginHelper.getUserId(); - for (int i = 0; i < arr.size(); i++) { - JsonNode n = arr.get(i); + for (JsonNode n : arr) { if (!n.isObject()) continue; ObjectNode o = (ObjectNode) n; String content = textOf(o, "content"); - if (StringUtils.isBlank(content)) continue; - Long assignee = longOf(o, "assigneeUserId"); - String deadline = textOf(o, "deadline"); + if (StringUtils.isBlank(content) || assignee == null) continue; + String status = textOf(o, "status"); + boolean done = "done".equals(status); Long existTaskId = longOf(o, "taskId"); + SysOaTask exist = existTaskId == null ? null : taskMapper.selectById(existTaskId); - SysOaTask t = null; - if (existTaskId != null) { - t = taskMapper.selectById(existTaskId); - } - boolean isNew = (t == null); - if (isNew) { - t = new SysOaTask(); - t.setCreateUserId(currentUser); - t.setBeginTime(meeting.getMeetingDate()); - } + SysOaTaskBo t = new SysOaTaskBo(); t.setProjectId(meeting.getProjectId()); - t.setTaskTitle(content.length() > 200 ? content.substring(0, 200) : content); - t.setContent("来自会议纪要:" + meeting.getSubject()); - t.setWorkerId(assignee); - if (StringUtils.isNotBlank(deadline)) { - try { t.setFinishTime(new SimpleDateFormat("yyyy-MM-dd").parse(deadline)); } catch (Exception ignored) {} - } - // 任务状态:done=2 已完成;progress=1 进行中;pending=0 待办 - if ("done".equals(status)) t.setState(2L); - else if ("progress".equals(status)) t.setState(1L); - else t.setState(0L); - - if (isNew) { - taskMapper.insert(t); - o.put("taskId", t.getTaskId()); + t.setTaskTitle(StringUtils.substring(content, 0, 200)); + t.setContent("来自会议纪要「" + meeting.getSubject() + "」"); + t.setFinishTime(parseDay(textOf(o, "deadline"))); + t.setState(done ? TASK_STATE_DONE : TASK_STATE_DOING); + if (done) t.setCompletedTime(new Date()); + if (exist == null) { + t.setBeginTime(meeting.getMeetingDate()); + t.setWorkerIds(String.valueOf(assignee)); + t.setStatus(0L); + taskService.insertByBo(t); + if (t.getTaskId() != null) o.put("taskId", t.getTaskId()); } else { - taskMapper.updateById(t); + t.setTaskId(existTaskId); + t.setWorkerId(assignee); + taskService.updateByBo(t); } } return json.writeValueAsString(arr); } catch (Exception e) { - // 同步失败不影响主流程,仅日志 + log.warn("会议纪要[{}]待办同步OA任务失败", meeting.getMeetingCode(), e); return meeting.getTasksJson(); } } - private String textOf(ObjectNode n, String k) { + private Date parseDay(String s) { + if (StringUtils.isBlank(s)) return null; + try { return new SimpleDateFormat("yyyy-MM-dd").parse(s); } + catch (Exception e) { return null; } + } + + private ArrayNode parseTaskArray(String s) { + if (StringUtils.isBlank(s)) return null; + try { + JsonNode root = json.readTree(s); + return root.isArray() ? (ArrayNode) root : null; + } catch (Exception e) { return null; } + } + + private String textOf(JsonNode n, String k) { JsonNode v = n.get(k); return v == null || v.isNull() ? null : v.asText(); } - private Long longOf(ObjectNode n, String k) { + private Long longOf(JsonNode n, String k) { JsonNode v = n.get(k); if (v == null || v.isNull()) return null; try { return v.isNumber() ? v.asLong() : Long.parseLong(v.asText()); } diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/service/impl/SysOaTaskServiceImpl.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/service/impl/SysOaTaskServiceImpl.java index 6fed139..f4197a1 100644 --- a/ruoyi-oa/src/main/java/com/ruoyi/oa/service/impl/SysOaTaskServiceImpl.java +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/service/impl/SysOaTaskServiceImpl.java @@ -217,6 +217,8 @@ public class SysOaTaskServiceImpl implements ISysOaTaskService { add.setOriginFinishTime(add.getFinishTime()); add.setWorkerId(workerId); flag = baseMapper.insert(add) > 0; + // 回填任务ID,供调用方(如会议纪要待办同步)建立关联 + bo.setTaskId(add.getTaskId()); if (flag) { operationLogService.recordLog(add.getProjectId(), 3, add.getTaskId(), add.getTaskTitle(), 1, "新增任务: " + add.getTaskTitle(), null, null); diff --git a/ruoyi-ui/src/views/oa/meeting/index.vue b/ruoyi-ui/src/views/oa/meeting/index.vue index a53ebdd..f1fe7fc 100644 --- a/ruoyi-ui/src/views/oa/meeting/index.vue +++ b/ruoyi-ui/src/views/oa/meeting/index.vue @@ -18,7 +18,8 @@ {{ isRecording ? '停止录音' : '语音录入' }} - 保存 + 保存 导出 打印 @@ -37,22 +38,22 @@ 新建
- + @keyup.enter.native="searchHistory" @clear="searchHistory" />
+ size="mini" style="flex:1" @input="searchHistory" /> - + style="width:90px;margin-left:4px" @change="searchHistory"> +
+ style="width:100%" @change="searchHistory" />
@@ -64,15 +65,13 @@ @click="loadMinutes(m.id)">
{{ m.meetingCode }} - - {{ typeLabel(m.meetingType) }} - +
{{ m.subject }}
{{ m.meetingDate }} - · {{ m.projectNum }} + · {{ m.projectName }}
主持:{{ m.hostUserName }}
{{ m.location }}
@@ -106,33 +105,35 @@ - + - + - + - + - + - -
仅取首位为主持人;如要换人请先点 ×。
+ + {{ form.hostUserName || ('#' + form.hostUserId) }} + + {{ form.hostUserId ? '更换' : '点击选择' }}
- + @@ -172,7 +173,7 @@
待办事项 - 保存时按上方开关自动同步到 OA 任务 + 填了负责人和内容的待办,保存时按上方开关同步为 OA 任务并通知负责人 添加待办
@@ -181,11 +182,18 @@
- +
+ + {{ t.assigneeName || ('#' + t.assigneeUserId) }} + + + {{ t.assigneeUserId ? '更换' : '选择' }} + +
- +
@@ -195,7 +203,8 @@
- +
@@ -209,6 +218,9 @@ + + +
@@ -218,33 +230,28 @@ import { updateMeetingMinutes, delMeetingMinutes } from '@/api/oa/meetingMinutes' import UserSelect from '@/components/UserSelect' +import UserSingleSelect from '@/components/UserSelect/single' import ProjectSelect from '@/components/fad-service/ProjectSelect' -const MEETING_TYPES = [ - { value: 'tech', label: '技术评审' }, - { value: 'project', label: '项目推进' }, - { value: 'client', label: '客户沟通' }, - { value: 'weekly', label: '周例会' }, - { value: 'other', label: '其他' } -] -const TASK_STATUS = [ - { value: 'pending', label: '待办' }, - { value: 'progress', label: '进行中' }, - { value: 'done', label: '已完成' } -] -const TYPE_TAG = { tech: 'info', project: 'primary', client: 'warning', weekly: 'success', other: '' } +function localToday () { + const d = new Date() + const p = n => String(n).padStart(2, '0') + return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}` +} function emptyForm () { return { id: null, meetingCode: '', - meetingDate: new Date().toISOString().slice(0, 10), + meetingDate: localToday(), projectId: null, - meetingType: 'tech', + meetingType: 'other', subject: '', location: '', hostUserId: null, + hostUserName: '', attendeeUserIds: '', + attendeeUserNames: '', topic: '', discussion: '', decision: '', @@ -255,15 +262,14 @@ function emptyForm () { export default { name: 'OaMeeting', - components: { UserSelect, ProjectSelect }, + components: { UserSelect, UserSingleSelect, ProjectSelect }, + dicts: ['oa_meeting_type', 'oa_meeting_task_status'], data () { return { - meetingTypes: MEETING_TYPES, - taskStatusOpts: TASK_STATUS, - statusType: 'success', statusText: '就绪', protoWarn: false, + saving: false, form: emptyForm(), @@ -280,8 +286,9 @@ export default { voiceFinalText: '', voiceInterim: '', - /** 主持人 CSV — 直接绑 UserSelect。@input 单向同步到 form.hostUserId(取首位) */ - hostCsv: '' + // 人员单选弹窗当前服务对象:'host' 或待办行下标 + userPickerVisible: false, + userPickerTarget: 'host' } }, created () { @@ -296,29 +303,58 @@ export default { const local = host === 'localhost' || host === '127.0.0.1' this.protoWarn = !(location.protocol === 'https:' || local) }, - typeLabel (v) { return (MEETING_TYPES.find(t => t.value === v) || {}).label || '其他' }, - typeTag (v) { return TYPE_TAG[v] || '' }, + dictLabel (dictKey, v) { + const hit = (this.dict.type[dictKey] || []).find(t => t.value === v) + return hit ? hit.label : (v || '-') + }, - /** UserSelect 是多选;主持人只保留首位 */ - onHostInput (val) { - const arr = typeof val === 'string' ? val.split(',').filter(Boolean) : (val || []) - this.hostCsv = arr.length ? String(arr[0]) : '' - this.form.hostUserId = arr.length ? Number(arr[0]) : null + // ============ 人员选择 ============ + pickHost () { + this.userPickerTarget = 'host' + this.userPickerVisible = true + }, + clearHost () { + this.form.hostUserId = null + this.form.hostUserName = '' + }, + pickAssignee (i) { + this.userPickerTarget = i + this.userPickerVisible = true + }, + clearAssignee (t) { + t.assigneeUserId = null + t.assigneeName = '' + }, + onUserPicked (row) { + if (!row) return + if (this.userPickerTarget === 'host') { + this.form.hostUserId = row.userId + this.form.hostUserName = row.nickName + } else { + const t = this.form.tasks[this.userPickerTarget] + if (t) { + t.assigneeUserId = row.userId + t.assigneeName = row.nickName + } + } }, // ============ 待办 ============ addTask () { this.form.tasks.push({ - assigneeUserId: null, _assigneeCsv: '', + assigneeUserId: null, assigneeName: '', content: '', deadline: '', status: 'pending', taskId: null }) }, - removeTask (i) { this.form.tasks.splice(i, 1) }, - /** 待办负责人 UserSelect 多选 → 取首位 */ - onTaskAssigneeChange (task, val) { - const arr = typeof val === 'string' ? val.split(',').filter(Boolean) : (val || []) - task.assigneeUserId = arr.length ? Number(arr[0]) : null - task._assigneeCsv = arr.length ? String(arr[0]) : '' + removeTask (i) { + const t = this.form.tasks[i] + if (t.taskId) { + this.$modal.confirm('该待办已同步为 OA 任务,移除后任务本身不会删除,仅与纪要解除关联。继续?') + .then(() => this.form.tasks.splice(i, 1)) + .catch(() => {}) + } else { + this.form.tasks.splice(i, 1) + } }, // ============ 新建 / 保存 ============ @@ -326,16 +362,14 @@ export default { const keepProject = this.form.projectId this.form = emptyForm() if (keepProject) this.form.projectId = keepProject - this.hostCsv = '' this.clearVoice() - this.$modal.msgSuccess('已新建纪要') }, async cmdSave () { if (!this.form.meetingDate) return this.$modal.msgError('请选择会议日期') if (!this.form.subject) return this.$modal.msgError('请输入会议主题') - // 序列化时清掉 _assigneeCsv 临时字段 const cleanTasks = (this.form.tasks || []).map(t => ({ assigneeUserId: t.assigneeUserId, + assigneeName: t.assigneeName, content: t.content, deadline: t.deadline, status: t.status, @@ -343,20 +377,32 @@ export default { })) const payload = { ...this.form, tasksJson: JSON.stringify(cleanTasks) } delete payload.tasks + this.saving = true try { - if (this.form.id) await updateMeetingMinutes(payload) - else await addMeetingMinutes(payload) + let id = this.form.id + if (id) { + await updateMeetingMinutes(payload) + } else { + const res = await addMeetingMinutes(payload) + id = res.data + } this.setStatus('已保存', 'success') setTimeout(() => this.setStatus('就绪', 'success'), 2000) this.$modal.msgSuccess('纪要已保存' + (this.form.syncTask ? ',待办已同步到 OA 任务' : '')) await this.loadHistory() - if (this.form.id) await this.loadMinutes(this.form.id, true) + if (id) await this.loadMinutes(id, true) } catch (err) { - this.$modal.msgError(err.msg || '保存失败') + this.setStatus('保存失败', 'warning') + } finally { + this.saving = false } }, // ============ 历史 ============ + searchHistory () { + this.historyQuery.pageNum = 1 + this.loadHistory() + }, async loadHistory () { this.historyLoading = true try { @@ -387,14 +433,15 @@ export default { subject: m.subject || '', location: m.location || '', hostUserId: m.hostUserId || null, + hostUserName: m.hostUserName || '', attendeeUserIds: m.attendeeUserIds || '', + attendeeUserNames: m.attendeeUserNames || '', topic: m.topic || '', discussion: m.discussion || '', decision: m.decision || '', tasks: this.parseTasks(m.tasksJson), syncTask: m.syncTask == null ? 1 : m.syncTask } - this.hostCsv = m.hostUserId ? String(m.hostUserId) : '' if (!silent) this.$modal.msgSuccess('已加载:' + m.subject) }, parseTasks (s) { @@ -404,7 +451,7 @@ export default { if (!Array.isArray(a)) return [] return a.map(t => ({ assigneeUserId: t.assigneeUserId || null, - _assigneeCsv: t.assigneeUserId ? String(t.assigneeUserId) : '', + assigneeName: t.assigneeName || '', content: t.content || '', deadline: t.deadline || '', status: t.status || 'pending', @@ -413,10 +460,10 @@ export default { } catch (e) { return [] } }, removeMinutes (m) { - this.$modal.confirm(`确认删除「${m.subject}」?此操作不可恢复。`).then(async () => { + this.$modal.confirm(`确认删除「${m.subject}」?已同步的 OA 任务不受影响。`).then(async () => { await delMeetingMinutes(m.id) this.$modal.msgSuccess('已删除') - if (this.form.id === m.id) this.form = emptyForm() + if (this.form.id === m.id) this.cmdNew() await this.loadHistory() }).catch(() => {}) }, @@ -494,14 +541,15 @@ export default { cmdExport () { if (!this.form.subject) return this.$modal.msgError('无内容可导出') const d = this.form - const typeLabel = this.typeLabel(d.meetingType) - const statusMap = { pending: '待办', progress: '进行中', done: '已完成' } const lines = [] lines.push('德睿福成套设备有限公司 · 会议纪要') lines.push('='.repeat(50)) - lines.push('日期: ' + d.meetingDate + ' 类型: ' + typeLabel) + lines.push('编号: ' + (d.meetingCode || '-')) + lines.push('日期: ' + d.meetingDate + ' 类型: ' + this.dictLabel('oa_meeting_type', d.meetingType)) lines.push('主题: ' + d.subject) lines.push('地点: ' + (d.location || '-')) + lines.push('主持: ' + (d.hostUserName || '-')) + lines.push('参会: ' + (d.attendeeUserNames || '-')) lines.push('='.repeat(50)) lines.push('') lines.push('一、会议议题'); lines.push('-'.repeat(30)); lines.push(d.topic || '(无)'); lines.push('') @@ -510,8 +558,8 @@ export default { lines.push('四、待办事项'); lines.push('-'.repeat(30)) if (d.tasks && d.tasks.length) { d.tasks.forEach(t => { - lines.push(' • [#' + (t.assigneeUserId || '-') + '] ' + (t.content || '') + - ' | 截止:' + (t.deadline || '-') + ' | 状态:' + (statusMap[t.status] || t.status)) + lines.push(' • [' + (t.assigneeName || '未指派') + '] ' + (t.content || '') + + ' | 截止:' + (t.deadline || '-') + ' | 状态:' + this.dictLabel('oa_meeting_task_status', t.status)) }) } else { lines.push('(无)') } lines.push(''); lines.push('='.repeat(50)) @@ -528,12 +576,11 @@ export default { const d = this.form const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])) - const statusMap = { pending: '待办', progress: '进行中', done: '已完成' } let taskHtml = '(无)' if (d.tasks && d.tasks.length) { const rows = d.tasks.map(t => - `#${esc(t.assigneeUserId || '-')}${esc(t.content)}` + - `${esc(t.deadline || '-')}${esc(statusMap[t.status] || t.status)}` + `${esc(t.assigneeName || '未指派')}${esc(t.content)}` + + `${esc(t.deadline || '-')}${esc(this.dictLabel('oa_meeting_task_status', t.status))}` ).join('') taskHtml = `${rows}
负责人任务内容截止状态
` @@ -547,10 +594,13 @@ export default { .meta span{margin-right:18px}.sect{font-size:14px;margin:14px 0 6px;font-weight:700} .body{white-space:pre-wrap;font-size:13px;margin-bottom:16px} @media print{body{padding:20px}} -

德睿福成套设备有限公司

会 议 纪 要
+

德睿福成套设备有限公司

会 议 纪 要 ${esc(d.meetingCode || '')}
📅 ${esc(d.meetingDate)} - 📝 ${esc(d.subject)}
-
📍 ${esc(d.location || '-')}
+ 📝 ${esc(d.subject)} + 🏷 ${esc(this.dictLabel('oa_meeting_type', d.meetingType))}
+
📍 ${esc(d.location || '-')} + 🎤 主持:${esc(d.hostUserName || '-')}
+
👥 参会:${esc(d.attendeeUserNames || '-')}
一、会议议题
${esc(d.topic || '(无)')}
二、讨论内容
${esc(d.discussion || '(无)')}
三、决议事项
${esc(d.decision || '(无)')}
@@ -627,7 +677,6 @@ export default { .meta-form { ::v-deep .el-form-item { margin-bottom: 8px; } - .help-tip { color: #909399; font-size: 11px; line-height: 1.4; } } .voice-card { @@ -670,7 +719,7 @@ export default { } .task-line { display: grid; - grid-template-columns: 200px 1fr 140px 110px 100px; + grid-template-columns: 170px 1fr 140px 110px 100px; gap: 8px; align-items: start; .tf { label { display: block; font-size: 11px; color: #909399; margin-bottom: 2px; } diff --git a/sql/oa_meeting.sql b/sql/oa_meeting.sql index 7136cba..c05e8ff 100644 --- a/sql/oa_meeting.sql +++ b/sql/oa_meeting.sql @@ -1,21 +1,19 @@ -- ===================================================== -- 智能会议纪要 (Smart Meeting Minutes) --- - 弃用 oa_meeting_project,改为绑定 sys_oa_project.project_id +-- - 会议可绑定 sys_oa_project.project_id,也可不绑定(非项目会议) -- - 主持人/参会/待办负责人 统一存 user_id --- - 待办在保存时可同步生成 sys_oa_task +-- - 待办在保存时可同步生成 sys_oa_task(带操作日志和 IM 通知) +-- 本脚本可重复执行(幂等)。 +-- 注意:sys_dict_type/sys_dict_data/sys_menu 主键为雪花ID(非自增),必须显式指定。 -- ===================================================== --- 清理上一版(如有) -DROP TABLE IF EXISTS `oa_meeting_project`; - --- ---------------- 会议纪要 ---------------- -DROP TABLE IF EXISTS `oa_meeting_minutes`; -CREATE TABLE `oa_meeting_minutes` ( +-- ---------------- 会议纪要表 ---------------- +CREATE TABLE IF NOT EXISTS `oa_meeting_minutes` ( `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID', - `meeting_code` varchar(32) NOT NULL COMMENT '会议编号 MT-yyyyMMddHHmmss', + `meeting_code` varchar(32) NOT NULL COMMENT '会议编号 MT-yyyyMMddHHmmss+3位随机', `meeting_date` date NOT NULL COMMENT '会议日期', - `project_id` bigint(20) DEFAULT NULL COMMENT '关联 sys_oa_project.project_id', - `meeting_type` varchar(20) DEFAULT 'other' COMMENT '类型 tech/project/client/weekly/other', + `project_id` bigint(20) DEFAULT NULL COMMENT '关联 sys_oa_project.project_id(可空=非项目会议)', + `meeting_type` varchar(20) DEFAULT 'other' COMMENT '类型 字典 oa_meeting_type', `subject` varchar(500) NOT NULL COMMENT '会议主题', `location` varchar(255) DEFAULT NULL COMMENT '会议地点', `host_user_id` bigint(20) DEFAULT NULL COMMENT '主持人 sys_user.user_id', @@ -23,13 +21,13 @@ CREATE TABLE `oa_meeting_minutes` ( `topic` text COMMENT '会议议题', `discussion` text COMMENT '讨论内容', `decision` text COMMENT '决议事项', - `tasks_json` text COMMENT '待办 JSON:[{assigneeUserId,content,deadline,status,taskId}]', + `tasks_json` text COMMENT '待办 JSON:[{assigneeUserId,assigneeName,content,deadline,status,taskId}]', `sync_task` tinyint(1) DEFAULT 1 COMMENT '是否将待办同步为 OA 任务', `create_by` varchar(64) DEFAULT NULL, `create_time` datetime DEFAULT CURRENT_TIMESTAMP, `update_by` varchar(64) DEFAULT NULL, `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - `del_flag` char(1) DEFAULT '0' COMMENT '删除标志:0正常 1删除', + `del_flag` char(1) DEFAULT '0' COMMENT '删除标志:0正常 2删除(mybatis-plus logicDeleteValue=2)', PRIMARY KEY (`id`), UNIQUE KEY `uk_meeting_code` (`meeting_code`, `del_flag`), KEY `idx_date` (`meeting_date`), @@ -37,26 +35,60 @@ CREATE TABLE `oa_meeting_minutes` ( KEY `idx_type` (`meeting_type`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='会议纪要'; +-- ---------------- 清理早期错误数据(dict_id/dict_code 误插为 0) ---------------- +DELETE FROM `sys_dict_type` WHERE `dict_id` = 0 AND `dict_type` IN ('oa_meeting_type', 'oa_meeting_task_status'); +DELETE FROM `sys_dict_data` WHERE `dict_code` = 0 AND `dict_type` IN ('oa_meeting_type', 'oa_meeting_task_status'); + -- ---------------- 字典:会议类型 ---------------- -INSERT IGNORE INTO `sys_dict_type` (`dict_name`, `dict_type`, `status`, `create_by`, `create_time`, `remark`) -VALUES ('会议类型', 'oa_meeting_type', '0', 'admin', NOW(), '智能会议纪要-会议类型'); +INSERT IGNORE INTO `sys_dict_type` (`dict_id`, `dict_name`, `dict_type`, `status`, `create_by`, `create_time`, `remark`) +VALUES (2063900000000000001, '会议类型', 'oa_meeting_type', '0', 'admin', NOW(), '智能会议纪要-会议类型'); INSERT IGNORE INTO `sys_dict_data` - (`dict_sort`, `dict_label`, `dict_value`, `dict_type`, `css_class`, `list_class`, `is_default`, `status`, `create_by`, `create_time`, `remark`) + (`dict_code`, `dict_sort`, `dict_label`, `dict_value`, `dict_type`, `css_class`, `list_class`, `is_default`, `status`, `create_by`, `create_time`, `remark`) VALUES - (1, '技术评审', 'tech', 'oa_meeting_type', '', 'info', 'N', '0', 'admin', NOW(), ''), - (2, '项目推进', 'project', 'oa_meeting_type', '', 'primary', 'N', '0', 'admin', NOW(), ''), - (3, '客户沟通', 'client', 'oa_meeting_type', '', 'warning', 'N', '0', 'admin', NOW(), ''), - (4, '周例会', 'weekly', 'oa_meeting_type', '', 'success', 'N', '0', 'admin', NOW(), ''), - (5, '其他', 'other', 'oa_meeting_type', '', '', 'Y', '0', 'admin', NOW(), ''); + (2063900000000000011, 1, '技术评审', 'tech', 'oa_meeting_type', '', 'info', 'N', '0', 'admin', NOW(), ''), + (2063900000000000012, 2, '项目推进', 'project', 'oa_meeting_type', '', 'primary', 'N', '0', 'admin', NOW(), ''), + (2063900000000000013, 3, '客户沟通', 'client', 'oa_meeting_type', '', 'warning', 'N', '0', 'admin', NOW(), ''), + (2063900000000000014, 4, '周例会', 'weekly', 'oa_meeting_type', '', 'success', 'N', '0', 'admin', NOW(), ''), + (2063900000000000015, 5, '其他', 'other', 'oa_meeting_type', '', 'default', 'Y', '0', 'admin', NOW(), ''); -- ---------------- 字典:待办状态 ---------------- -INSERT IGNORE INTO `sys_dict_type` (`dict_name`, `dict_type`, `status`, `create_by`, `create_time`, `remark`) -VALUES ('会议待办状态', 'oa_meeting_task_status', '0', 'admin', NOW(), '智能会议纪要-待办状态'); +INSERT IGNORE INTO `sys_dict_type` (`dict_id`, `dict_name`, `dict_type`, `status`, `create_by`, `create_time`, `remark`) +VALUES (2063900000000000002, '会议待办状态', 'oa_meeting_task_status', '0', 'admin', NOW(), '智能会议纪要-待办状态'); INSERT IGNORE INTO `sys_dict_data` - (`dict_sort`, `dict_label`, `dict_value`, `dict_type`, `css_class`, `list_class`, `is_default`, `status`, `create_by`, `create_time`, `remark`) + (`dict_code`, `dict_sort`, `dict_label`, `dict_value`, `dict_type`, `css_class`, `list_class`, `is_default`, `status`, `create_by`, `create_time`, `remark`) VALUES - (1, '待办', 'pending', 'oa_meeting_task_status', '', 'info', 'Y', '0', 'admin', NOW(), ''), - (2, '进行中', 'progress', 'oa_meeting_task_status', '', 'primary', 'N', '0', 'admin', NOW(), ''), - (3, '已完成', 'done', 'oa_meeting_task_status', '', 'success', 'N', '0', 'admin', NOW(), ''); + (2063900000000000021, 1, '待办', 'pending', 'oa_meeting_task_status', '', 'info', 'Y', '0', 'admin', NOW(), ''), + (2063900000000000022, 2, '进行中', 'progress', 'oa_meeting_task_status', '', 'primary', 'N', '0', 'admin', NOW(), ''), + (2063900000000000023, 3, '已完成', 'done', 'oa_meeting_task_status', '', 'success', 'N', '0', 'admin', NOW(), ''); + +-- ---------------- 菜单:信息 > 会议纪要 ---------------- +-- 父菜单 1774989374680858626 = 「信息」 +INSERT IGNORE INTO `sys_menu` + (`menu_id`, `menu_name`, `parent_id`, `order_num`, `path`, `component`, `menu_type`, `visible`, `status`, `perms`, `icon`, `create_by`, `create_time`) +VALUES + (2063809716454174722, '会议纪要', 1774989374680858626, 3, + 'meeting', 'oa/meeting/index', 'C', '0', '0', + NULL, 'documentation', 'admin', NOW()); + +UPDATE `sys_menu` SET `icon` = 'documentation' WHERE `menu_id` = 2063809716454174722 AND (`icon` = '#' OR `icon` IS NULL); + +-- ---------------- 角色授权(与「信息」下兄弟菜单一致的角色集) ---------------- +INSERT IGNORE INTO `sys_role_menu` (`role_id`, `menu_id`) +VALUES + (1743186990678077442, 2063809716454174722), -- 总经理 + (1743204526291349506, 2063809716454174722), -- 技术总监 + (1743205028123045890, 2063809716454174722), -- 信息化部 + (1852970465740505090, 2063809716454174722), -- 普通员工 + (1859257980152692738, 2063809716454174722), -- 职工 + (1859548445766717441, 2063809716454174722), -- 后勤 + (1893987128812761089, 2063809716454174722), -- 新员工临时身份 + (1914212623781187585, 2063809716454174722), -- 技术总工 + (1914213026883162113, 2063809716454174722), -- 设计主任 + (1925062159919448065, 2063809716454174722); -- 外贸专责 + +-- ---------------- 校验 ---------------- +SELECT menu_id, menu_name, path, component, icon FROM sys_menu WHERE menu_id = 2063809716454174722; +SELECT dict_type, dict_label, dict_value FROM sys_dict_data + WHERE dict_type IN ('oa_meeting_type', 'oa_meeting_task_status') ORDER BY dict_type, dict_sort; From db7cbf815754b0061356cab6a62b1ddd82859bb6 Mon Sep 17 00:00:00 2001 From: wangyu <823267011@qq.com> Date: Thu, 11 Jun 2026 10:29:58 +0800 Subject: [PATCH 03/11] =?UTF-8?q?=E4=BC=9A=E8=AE=AE=E7=BA=AA=E8=A6=81?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=20=E5=88=97=E8=A1=A8=E9=A1=B5+=E7=8B=AC?= =?UTF-8?q?=E7=AB=8B=E7=BC=96=E8=BE=91=E9=A1=B5=20=E7=BB=93=E6=9E=84?= =?UTF-8?q?=EF=BC=8C=E7=A7=BB=E9=99=A4=E8=AF=AD=E9=9F=B3=E5=BD=95=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - index.vue 重写为标准列表页:搜索(关键字/项目/类型/日期)+ 表格 + 分页, 显示待办同步进度(已同步/总数),双击行或点编辑进入编辑页 - 新增 edit.vue 独立编辑页:新增 /hint/meeting/add、编辑 /hint/meeting/edit/:id, 新建保存后自动切换为编辑路由,防止重复新增;路由复用时通过 $route watch 重置/加载 - router/index.js 增加 /hint 静态隐藏路由(与 /people、/claim 同惯例,activeMenu 高亮列表菜单) - 按要求删除语音录入功能(SpeechRecognition 相关全部移除) Co-Authored-By: Claude Fable 5 --- ruoyi-ui/src/router/index.js | 19 + ruoyi-ui/src/views/oa/meeting/edit.vue | 468 ++++++++++++++ ruoyi-ui/src/views/oa/meeting/index.vue | 821 ++++-------------------- 3 files changed, 610 insertions(+), 698 deletions(-) create mode 100644 ruoyi-ui/src/views/oa/meeting/edit.vue diff --git a/ruoyi-ui/src/router/index.js b/ruoyi-ui/src/router/index.js index 21008e7..620375f 100644 --- a/ruoyi-ui/src/router/index.js +++ b/ruoyi-ui/src/router/index.js @@ -160,6 +160,25 @@ export const constantRoutes = [ }, ], }, + { + path: "/hint", + component: Layout, + hidden: true, + children: [ + { + path: "meeting/add", + component: () => import("@/views/oa/meeting/edit"), + name: "addMeetingMinutes", + meta: { title: "新增会议纪要", activeMenu: "/hint/meeting" }, + }, + { + path: "meeting/edit/:id(\\d+)", + component: () => import("@/views/oa/meeting/edit"), + name: "editMeetingMinutes", + meta: { title: "编辑会议纪要", activeMenu: "/hint/meeting" }, + }, + ], + }, { path: "/claim", component: Layout, diff --git a/ruoyi-ui/src/views/oa/meeting/edit.vue b/ruoyi-ui/src/views/oa/meeting/edit.vue new file mode 100644 index 0000000..ce0c900 --- /dev/null +++ b/ruoyi-ui/src/views/oa/meeting/edit.vue @@ -0,0 +1,468 @@ + + + + + diff --git a/ruoyi-ui/src/views/oa/meeting/index.vue b/ruoyi-ui/src/views/oa/meeting/index.vue index f1fe7fc..2464f59 100644 --- a/ruoyi-ui/src/views/oa/meeting/index.vue +++ b/ruoyi-ui/src/views/oa/meeting/index.vue @@ -1,732 +1,157 @@ - - From 6f64c3d4af0b3012e9b27912b56c3cfe75ad97ef Mon Sep 17 00:00:00 2001 From: wangyu <823267011@qq.com> Date: Thu, 11 Jun 2026 10:33:18 +0800 Subject: [PATCH 04/11] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=85=A8=E5=B1=80?= =?UTF-8?q?=E6=A0=B7=E5=BC=8F=E5=AF=BC=E8=87=B4=E8=BE=93=E5=85=A5=E6=A1=86?= =?UTF-8?q?=E6=96=87=E5=AD=97=E4=B8=8E=E5=9B=BE=E6=A0=87=E9=87=8D=E5=8F=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit index.scss 全局压高度规则把 .el-input__inner 的 padding 写死为 0 8px !important, 覆盖了 element-ui 给带前/后缀图标输入框预留的 30px 内边距,导致日期选择器、 带搜索图标输入框、下拉选择等的占位文字/内容压在图标上。 在该规则后补回更高优先级的图标位 padding(prefix/suffix 各 28px), 并修正 mini 尺寸输入框图标行高(22px,原 26px 垂直偏移)。 Co-Authored-By: Claude Fable 5 --- ruoyi-ui/src/assets/styles/index.scss | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/ruoyi-ui/src/assets/styles/index.scss b/ruoyi-ui/src/assets/styles/index.scss index 341ad29..2f078b1 100644 --- a/ruoyi-ui/src/assets/styles/index.scss +++ b/ruoyi-ui/src/assets/styles/index.scss @@ -65,6 +65,11 @@ body { .el-range-editor .el-range-separator { line-height: 24px !important; font-size: 12px !important; } .el-input__icon { line-height: 26px !important; } .el-input__suffix-inner .el-input__icon { line-height: 26px !important; } +/* 上面的 padding: 0 8px !important 会盖掉 element 给带图标输入框预留的 30px, + 导致占位文字/内容压在前后缀图标上(如日期选择器、带搜索图标的输入框),这里补回图标位 */ +.el-input--prefix .el-input__inner { padding-left: 28px !important; } +.el-input--suffix .el-input__inner { padding-right: 28px !important; } +.el-input--mini .el-input__icon { line-height: 22px !important; } .el-form-item__content { line-height: 26px; } /* 按钮:默认 26px 高,mini 22px,medium 28px */ From 005cf474247f85f2fb8328ed7a9f24af4c16ad3f Mon Sep 17 00:00:00 2001 From: Joshi <3040996759@qq.com> Date: Fri, 12 Jun 2026 09:48:46 +0800 Subject: [PATCH 05/11] =?UTF-8?q?feat(oa):=20=E6=B7=BB=E5=8A=A0=E5=88=B0?= =?UTF-8?q?=E8=B4=A7=E6=98=8E=E7=BB=86=E7=AE=A1=E7=90=86=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 创建到货明细实体类 OaArrivalDetail 包含基本信息字段 - 实现到货明细业务对象 OaArrivalDetailBo 和视图对象 OaArrivalDetailVo - 开发到货明细服务接口 IOaArrivalDetailService 及其实现类 - 构建到货明细数据访问层 OaArrivalDetailMapper 及对应的 XML 映射文件 - 设计到货明细控制器 OaArrivalDetailController 提供完整的 CRUD 操作 - 集成分页查询、导出 Excel、新增修改删除等完整业务功能 --- .../controller/OaArrivalDetailController.java | 101 +++++++++++++++ .../com/ruoyi/oa/domain/OaArrivalDetail.java | 95 ++++++++++++++ .../ruoyi/oa/domain/bo/OaArrivalDetailBo.java | 103 +++++++++++++++ .../ruoyi/oa/domain/vo/OaArrivalDetailVo.java | 119 +++++++++++++++++ .../oa/mapper/OaArrivalDetailMapper.java | 15 +++ .../oa/service/IOaArrivalDetailService.java | 49 +++++++ .../impl/OaArrivalDetailServiceImpl.java | 122 ++++++++++++++++++ .../mapper/oa/OaArrivalDetailMapper.xml | 31 +++++ 8 files changed, 635 insertions(+) create mode 100644 ruoyi-oa/src/main/java/com/ruoyi/oa/controller/OaArrivalDetailController.java create mode 100644 ruoyi-oa/src/main/java/com/ruoyi/oa/domain/OaArrivalDetail.java create mode 100644 ruoyi-oa/src/main/java/com/ruoyi/oa/domain/bo/OaArrivalDetailBo.java create mode 100644 ruoyi-oa/src/main/java/com/ruoyi/oa/domain/vo/OaArrivalDetailVo.java create mode 100644 ruoyi-oa/src/main/java/com/ruoyi/oa/mapper/OaArrivalDetailMapper.java create mode 100644 ruoyi-oa/src/main/java/com/ruoyi/oa/service/IOaArrivalDetailService.java create mode 100644 ruoyi-oa/src/main/java/com/ruoyi/oa/service/impl/OaArrivalDetailServiceImpl.java create mode 100644 ruoyi-oa/src/main/resources/mapper/oa/OaArrivalDetailMapper.xml diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/controller/OaArrivalDetailController.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/controller/OaArrivalDetailController.java new file mode 100644 index 0000000..cecd139 --- /dev/null +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/controller/OaArrivalDetailController.java @@ -0,0 +1,101 @@ +package com.ruoyi.oa.controller; + +import java.util.List; +import java.util.Arrays; +import java.util.concurrent.TimeUnit; + +import lombok.RequiredArgsConstructor; +import javax.servlet.http.HttpServletResponse; +import javax.validation.constraints.*; +import org.springframework.web.bind.annotation.*; +import org.springframework.validation.annotation.Validated; +import com.ruoyi.common.annotation.RepeatSubmit; +import com.ruoyi.common.annotation.Log; +import com.ruoyi.common.core.controller.BaseController; +import com.ruoyi.common.core.domain.PageQuery; +import com.ruoyi.common.core.domain.R; +import com.ruoyi.common.core.validate.AddGroup; +import com.ruoyi.common.core.validate.EditGroup; +import com.ruoyi.common.core.validate.QueryGroup; +import com.ruoyi.common.enums.BusinessType; +import com.ruoyi.common.utils.poi.ExcelUtil; +import com.ruoyi.oa.domain.vo.OaArrivalDetailVo; +import com.ruoyi.oa.domain.bo.OaArrivalDetailBo; +import com.ruoyi.oa.service.IOaArrivalDetailService; +import com.ruoyi.common.core.page.TableDataInfo; + +/** + * 到货明细 + * + * @author ruoyi + * @date 2026-06-12 + */ +@Validated +@RequiredArgsConstructor +@RestController +@RequestMapping("/oa/arrivalDetail") +public class OaArrivalDetailController extends BaseController { + + private final IOaArrivalDetailService iOaArrivalDetailService; + + /** + * 查询到货明细列表 + */ + @GetMapping("/list") + public TableDataInfo list(OaArrivalDetailBo bo, PageQuery pageQuery) { + return iOaArrivalDetailService.queryPageList(bo, pageQuery); + } + + /** + * 导出到货明细列表 + */ + @Log(title = "到货明细", businessType = BusinessType.EXPORT) + @PostMapping("/export") + public void export(OaArrivalDetailBo bo, HttpServletResponse response) { + List list = iOaArrivalDetailService.queryList(bo); + ExcelUtil.exportExcel(list, "到货明细", OaArrivalDetailVo.class, response); + } + + /** + * 获取到货明细详细信息 + * + * @param detailId 主键 + */ + @GetMapping("/{detailId}") + public R getInfo(@NotNull(message = "主键不能为空") + @PathVariable Long detailId) { + return R.ok(iOaArrivalDetailService.queryById(detailId)); + } + + /** + * 新增到货明细 + */ + @Log(title = "到货明细", businessType = BusinessType.INSERT) + @RepeatSubmit() + @PostMapping() + public R add(@Validated(AddGroup.class) @RequestBody OaArrivalDetailBo bo) { + return toAjax(iOaArrivalDetailService.insertByBo(bo)); + } + + /** + * 修改到货明细 + */ + @Log(title = "到货明细", businessType = BusinessType.UPDATE) + @RepeatSubmit() + @PutMapping() + public R edit(@Validated(EditGroup.class) @RequestBody OaArrivalDetailBo bo) { + return toAjax(iOaArrivalDetailService.updateByBo(bo)); + } + + /** + * 删除到货明细 + * + * @param detailIds 主键串 + */ + @Log(title = "到货明细", businessType = BusinessType.DELETE) + @DeleteMapping("/{detailIds}") + public R remove(@NotEmpty(message = "主键不能为空") + @PathVariable Long[] detailIds) { + return toAjax(iOaArrivalDetailService.deleteWithValidByIds(Arrays.asList(detailIds), true)); + } +} diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/domain/OaArrivalDetail.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/domain/OaArrivalDetail.java new file mode 100644 index 0000000..0e29f52 --- /dev/null +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/domain/OaArrivalDetail.java @@ -0,0 +1,95 @@ +package com.ruoyi.oa.domain; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; +import lombok.EqualsAndHashCode; +import java.io.Serializable; +import java.util.Date; +import java.math.BigDecimal; + +import java.math.BigDecimal; +import java.util.Date; +import com.fasterxml.jackson.annotation.JsonFormat; +import com.ruoyi.common.core.domain.BaseEntity; + +/** + * 到货明细对象 oa_arrival_detail + * + * @author ruoyi + * @date 2026-06-12 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("oa_arrival_detail") +public class OaArrivalDetail extends BaseEntity { + + private static final long serialVersionUID=1L; + + /** + * 明细主键 + */ + @TableId(value = "detail_id") + private Long detailId; + /** + * 关联采购需求ID + */ + private Long requirementId; + /** + * 关联项目ID + */ + private Long projectId; + /** + * 项目类型:0=内贸 1=外贸 + */ + private Integer tradeType; + /** + * 合同编号 + */ + private String contractNo; + /** + * 物料名称 + */ + private String goodsName; + /** + * 数量 + */ + private BigDecimal quantity; + /** + * 单价 + */ + private BigDecimal unitPrice; + /** + * 到货类型 (0 收,1 发) + */ + private Integer arrivalType; + /** + * 到货截止日期 + */ + private Date deadline; + /** + * 发货地点 + */ + private String sourceAddress; + /** + * 规划目的地 + */ + private String targetAddress; + /** + * 状态(0 = 待发货,1 = 运输中,2 = 已到货,3 = 异常 / 拒收,4 = 取消) + */ + private Integer detailStatus; + /** + * 描述 + */ + private String description; + /** + * 手动备注 + */ + private String remark; + /** + * 删除标志:0正常 1删除 + */ + @TableLogic + private Integer delFlag; + +} diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/domain/bo/OaArrivalDetailBo.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/domain/bo/OaArrivalDetailBo.java new file mode 100644 index 0000000..3b26b1e --- /dev/null +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/domain/bo/OaArrivalDetailBo.java @@ -0,0 +1,103 @@ +package com.ruoyi.oa.domain.bo; + +import com.ruoyi.common.core.validate.AddGroup; +import com.ruoyi.common.core.validate.EditGroup; +import lombok.Data; +import lombok.EqualsAndHashCode; +import javax.validation.constraints.*; + +import java.util.Date; + +import java.math.BigDecimal; +import java.util.Date; +import com.fasterxml.jackson.annotation.JsonFormat; +import com.ruoyi.common.core.domain.BaseEntity; + +/** + * 到货明细业务对象 oa_arrival_detail + * + * @author ruoyi + * @date 2026-06-12 + */ + +@Data +@EqualsAndHashCode(callSuper = true) +public class OaArrivalDetailBo extends BaseEntity { + + /** + * 明细主键 + */ + private Long detailId; + + /** + * 关联采购需求ID + */ + private Long requirementId; + + /** + * 关联项目ID + */ + private Long projectId; + + /** + * 项目类型:0=内贸 1=外贸 + */ + private Integer tradeType; + + /** + * 合同编号 + */ + private String contractNo; + + /** + * 物料名称 + */ + private String goodsName; + + /** + * 数量 + */ + private BigDecimal quantity; + + /** + * 单价 + */ + private BigDecimal unitPrice; + + /** + * 到货类型 (0 收,1 发) + */ + private Integer arrivalType; + + /** + * 到货截止日期 + */ + private Date deadline; + + /** + * 发货地点 + */ + private String sourceAddress; + + /** + * 规划目的地 + */ + private String targetAddress; + + /** + * 状态(0 = 待发货,1 = 运输中,2 = 已到货,3 = 异常 / 拒收,4 = 取消) + */ + private Integer detailStatus; + + /** + * 描述 + */ + private String description; + + /** + * 手动备注 + */ + private String remark; + + +} diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/domain/vo/OaArrivalDetailVo.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/domain/vo/OaArrivalDetailVo.java new file mode 100644 index 0000000..757ce09 --- /dev/null +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/domain/vo/OaArrivalDetailVo.java @@ -0,0 +1,119 @@ +package com.ruoyi.oa.domain.vo; + +import java.math.BigDecimal; +import java.util.Date; +import com.fasterxml.jackson.annotation.JsonFormat; +import com.alibaba.excel.annotation.ExcelIgnoreUnannotated; +import com.alibaba.excel.annotation.ExcelProperty; +import com.ruoyi.common.annotation.ExcelDictFormat; +import com.ruoyi.common.convert.ExcelDictConvert; +import com.ruoyi.common.core.domain.BaseEntity; +import lombok.Data; +import java.util.Date; + + + +/** + * 到货明细视图对象 oa_arrival_detail + * + * @author ruoyi + * @date 2026-06-12 + */ +@Data +@ExcelIgnoreUnannotated +public class OaArrivalDetailVo extends BaseEntity { + + private static final long serialVersionUID = 1L; + + /** + * 明细主键 + */ + @ExcelProperty(value = "明细主键") + private Long detailId; + + /** + * 关联采购需求ID + */ + @ExcelProperty(value = "关联采购需求ID") + private Long requirementId; + + /** + * 关联项目ID + */ + @ExcelProperty(value = "关联项目ID") + private Long projectId; + + /** + * 项目类型:0=内贸 1=外贸 + */ + @ExcelProperty(value = "项目类型:0=内贸 1=外贸") + private Integer tradeType; + + /** + * 合同编号 + */ + @ExcelProperty(value = "合同编号") + private String contractNo; + + /** + * 物料名称 + */ + @ExcelProperty(value = "物料名称") + private String goodsName; + + /** + * 数量 + */ + @ExcelProperty(value = "数量") + private BigDecimal quantity; + + /** + * 单价 + */ + @ExcelProperty(value = "单价") + private BigDecimal unitPrice; + + /** + * 到货类型 (0 收,1 发) + */ + @ExcelProperty(value = "到货类型 (0 收,1 发)") + private Integer arrivalType; + + /** + * 到货截止日期 + */ + @ExcelProperty(value = "到货截止日期") + private Date deadline; + + /** + * 发货地点 + */ + @ExcelProperty(value = "发货地点") + private String sourceAddress; + + /** + * 规划目的地 + */ + @ExcelProperty(value = "规划目的地") + private String targetAddress; + + /** + * 状态(0 = 待发货,1 = 运输中,2 = 已到货,3 = 异常 / 拒收,4 = 取消) + */ + @ExcelProperty(value = "状态(0 = 待发货,1 = 运输中,2 = 已到货,3 = 异常 / 拒收,4 = 取消)") + private Integer detailStatus; + + /** + * 描述 + */ + @ExcelProperty(value = "描述") + private String description; + + /** + * 手动备注 + */ + @ExcelProperty(value = "手动备注") + private String remark; + + +} diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/mapper/OaArrivalDetailMapper.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/mapper/OaArrivalDetailMapper.java new file mode 100644 index 0000000..04c7b1a --- /dev/null +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/mapper/OaArrivalDetailMapper.java @@ -0,0 +1,15 @@ +package com.ruoyi.oa.mapper; + +import com.ruoyi.oa.domain.OaArrivalDetail; +import com.ruoyi.oa.domain.vo.OaArrivalDetailVo; +import com.ruoyi.common.core.mapper.BaseMapperPlus; + +/** + * 到货明细Mapper接口 + * + * @author ruoyi + * @date 2026-06-12 + */ +public interface OaArrivalDetailMapper extends BaseMapperPlus { + +} diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/service/IOaArrivalDetailService.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/service/IOaArrivalDetailService.java new file mode 100644 index 0000000..a67b28d --- /dev/null +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/service/IOaArrivalDetailService.java @@ -0,0 +1,49 @@ +package com.ruoyi.oa.service; + +import com.ruoyi.oa.domain.OaArrivalDetail; +import com.ruoyi.oa.domain.vo.OaArrivalDetailVo; +import com.ruoyi.oa.domain.bo.OaArrivalDetailBo; +import com.ruoyi.common.core.page.TableDataInfo; +import com.ruoyi.common.core.domain.PageQuery; + +import java.util.Collection; +import java.util.List; + +/** + * 到货明细Service接口 + * + * @author ruoyi + * @date 2026-06-12 + */ +public interface IOaArrivalDetailService { + + /** + * 查询到货明细 + */ + OaArrivalDetailVo queryById(Long detailId); + + /** + * 查询到货明细列表 + */ + TableDataInfo queryPageList(OaArrivalDetailBo bo, PageQuery pageQuery); + + /** + * 查询到货明细列表 + */ + List queryList(OaArrivalDetailBo bo); + + /** + * 新增到货明细 + */ + Boolean insertByBo(OaArrivalDetailBo bo); + + /** + * 修改到货明细 + */ + Boolean updateByBo(OaArrivalDetailBo bo); + + /** + * 校验并批量删除到货明细信息 + */ + Boolean deleteWithValidByIds(Collection ids, Boolean isValid); +} diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/service/impl/OaArrivalDetailServiceImpl.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/service/impl/OaArrivalDetailServiceImpl.java new file mode 100644 index 0000000..9ebeb04 --- /dev/null +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/service/impl/OaArrivalDetailServiceImpl.java @@ -0,0 +1,122 @@ +package com.ruoyi.oa.service.impl; + +import cn.hutool.core.bean.BeanUtil; +import com.ruoyi.common.utils.StringUtils; +import com.ruoyi.common.core.page.TableDataInfo; +import com.ruoyi.common.core.domain.PageQuery; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import com.ruoyi.oa.domain.bo.OaArrivalDetailBo; +import com.ruoyi.oa.domain.vo.OaArrivalDetailVo; +import com.ruoyi.oa.domain.OaArrivalDetail; +import com.ruoyi.oa.mapper.OaArrivalDetailMapper; +import com.ruoyi.oa.service.IOaArrivalDetailService; + +import java.util.List; +import java.util.Map; +import java.util.Collection; + +/** + * 到货明细Service业务层处理 + * + * @author ruoyi + * @date 2026-06-12 + */ +@RequiredArgsConstructor +@Service +public class OaArrivalDetailServiceImpl implements IOaArrivalDetailService { + + private final OaArrivalDetailMapper baseMapper; + + /** + * 查询到货明细 + */ + @Override + public OaArrivalDetailVo queryById(Long detailId){ + return baseMapper.selectVoById(detailId); + } + + /** + * 查询到货明细列表 + */ + @Override + public TableDataInfo queryPageList(OaArrivalDetailBo bo, PageQuery pageQuery) { + LambdaQueryWrapper lqw = buildQueryWrapper(bo); + Page result = baseMapper.selectVoPage(pageQuery.build(), lqw); + return TableDataInfo.build(result); + } + + /** + * 查询到货明细列表 + */ + @Override + public List queryList(OaArrivalDetailBo bo) { + LambdaQueryWrapper lqw = buildQueryWrapper(bo); + return baseMapper.selectVoList(lqw); + } + + private LambdaQueryWrapper buildQueryWrapper(OaArrivalDetailBo bo) { + Map params = bo.getParams(); + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.eq(bo.getRequirementId() != null, OaArrivalDetail::getRequirementId, bo.getRequirementId()); + lqw.eq(bo.getProjectId() != null, OaArrivalDetail::getProjectId, bo.getProjectId()); + lqw.eq(bo.getTradeType() != null, OaArrivalDetail::getTradeType, bo.getTradeType()); + lqw.eq(StringUtils.isNotBlank(bo.getContractNo()), OaArrivalDetail::getContractNo, bo.getContractNo()); + lqw.like(StringUtils.isNotBlank(bo.getGoodsName()), OaArrivalDetail::getGoodsName, bo.getGoodsName()); + lqw.eq(bo.getQuantity() != null, OaArrivalDetail::getQuantity, bo.getQuantity()); + lqw.eq(bo.getUnitPrice() != null, OaArrivalDetail::getUnitPrice, bo.getUnitPrice()); + lqw.eq(bo.getArrivalType() != null, OaArrivalDetail::getArrivalType, bo.getArrivalType()); + lqw.eq(bo.getDeadline() != null, OaArrivalDetail::getDeadline, bo.getDeadline()); + lqw.eq(StringUtils.isNotBlank(bo.getSourceAddress()), OaArrivalDetail::getSourceAddress, bo.getSourceAddress()); + lqw.eq(StringUtils.isNotBlank(bo.getTargetAddress()), OaArrivalDetail::getTargetAddress, bo.getTargetAddress()); + lqw.eq(bo.getDetailStatus() != null, OaArrivalDetail::getDetailStatus, bo.getDetailStatus()); + lqw.eq(StringUtils.isNotBlank(bo.getDescription()), OaArrivalDetail::getDescription, bo.getDescription()); + lqw.orderByDesc(OaArrivalDetail::getCreateTime); + return lqw; + } + + /** + * 新增到货明细 + */ + @Override + public Boolean insertByBo(OaArrivalDetailBo bo) { + OaArrivalDetail add = BeanUtil.toBean(bo, OaArrivalDetail.class); + validEntityBeforeSave(add); + boolean flag = baseMapper.insert(add) > 0; + if (flag) { + bo.setDetailId(add.getDetailId()); + } + return flag; + } + + /** + * 修改到货明细 + */ + @Override + public Boolean updateByBo(OaArrivalDetailBo bo) { + OaArrivalDetail update = BeanUtil.toBean(bo, OaArrivalDetail.class); + validEntityBeforeSave(update); + return baseMapper.updateById(update) > 0; + } + + /** + * 保存前的数据校验 + */ + private void validEntityBeforeSave(OaArrivalDetail entity){ + //TODO 做一些数据校验,如唯一约束 + } + + /** + * 批量删除到货明细 + */ + @Override + public Boolean deleteWithValidByIds(Collection ids, Boolean isValid) { + if(isValid){ + //TODO 做一些业务上的校验,判断是否需要校验 + } + return baseMapper.deleteBatchIds(ids) > 0; + } +} diff --git a/ruoyi-oa/src/main/resources/mapper/oa/OaArrivalDetailMapper.xml b/ruoyi-oa/src/main/resources/mapper/oa/OaArrivalDetailMapper.xml new file mode 100644 index 0000000..0fda017 --- /dev/null +++ b/ruoyi-oa/src/main/resources/mapper/oa/OaArrivalDetailMapper.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From faca2f85eb7b91a12a91d50cd9ef83ece58ee49b Mon Sep 17 00:00:00 2001 From: wangyu <823267011@qq.com> Date: Fri, 12 Jun 2026 10:00:09 +0800 Subject: [PATCH 06/11] =?UTF-8?q?=E6=96=B0=E5=A2=9E=20AI=20=E5=90=88?= =?UTF-8?q?=E5=90=8C/=E7=AE=80=E5=8E=86=E5=AE=A1=E6=A0=B8=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=EF=BC=88=E5=B0=8F=E7=B1=B3=20MiMo=20=E5=A4=9A?= =?UTF-8?q?=E6=A8=A1=E6=80=81=E5=A4=A7=E6=A8=A1=E5=9E=8B=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 核心诉求:合同审核站在我方(德睿福)立场,找出不利条款并给出利好我方的 修改/补充建议;简历审核评估候选人与目标岗位的匹配度。 后端(ruoyi-oa): - 接入小米 MiMo(OpenAI 兼容 /chat/completions),mimo-v2.5 多模态模型 · MiMoProperties 绑定 application.yml mimo: 配置(base-url/api-key/model/...) · MiMoClient:text + multimodal(image_url base64) 两种调用,独立长超时 RestTemplate;mimo-v2.5 是推理模型,max-tokens 配 8192 留足思考额度 - DocumentParseUtil:PDF 文字(PDFBox)、Word(POI: docx XWPF / doc HWPF), 扫描版 PDF(提取文字过短)用 PDFRenderer 转 PNG 走多模态 - OaAiReview 实体 + BO/VO/Mapper/Service/Controller(/oa/aiReview) · analyze 上传解析→构建提示词→调用大模型→留存原件(OSS)→落库 · 合同/简历两套提示词;正则解析风险评级:高/中/低与匹配度评分:NN入库 · 提供 list/detail/delete - ruoyi-oa/pom.xml 增加 poi-ooxml、poi-scratchpad(Word 解析) - application.yml 增加 mimo: 配置块 前端(ruoyi-ui): - views/oa/aiReview/index.vue:类型切换(合同/简历)、拖拽上传(pdf/word)、 简历目标岗位输入、审核(loading)、Markdown 结果渲染、历史记录列表 - api/oa/aiReview.js:analyze 用 FormData,超时放宽到 5 分钟 SQL(已应用到生产库): - oa_ai_review 表;菜单挂信息下(menu_id 2063910000000000001),授权10个角色 已用真实接口端到端验证:合同审核输出利好我方意见、风险评级可正确解析。 Co-Authored-By: Claude Fable 5 --- .../src/main/resources/application.yml | 16 + ruoyi-oa/pom.xml | 11 + .../ruoyi/oa/aireview/DocumentParseUtil.java | 92 ++++++ .../com/ruoyi/oa/aireview/MiMoClient.java | 131 ++++++++ .../com/ruoyi/oa/aireview/MiMoProperties.java | 37 +++ .../oa/controller/OaAiReviewController.java | 58 ++++ .../java/com/ruoyi/oa/domain/OaAiReview.java | 55 ++++ .../com/ruoyi/oa/domain/bo/OaAiReviewBo.java | 21 ++ .../com/ruoyi/oa/domain/vo/OaAiReviewVo.java | 32 ++ .../com/ruoyi/oa/mapper/OaAiReviewMapper.java | 8 + .../ruoyi/oa/service/IOaAiReviewService.java | 27 ++ .../service/impl/OaAiReviewServiceImpl.java | 211 +++++++++++++ ruoyi-ui/src/api/oa/aiReview.js | 28 ++ ruoyi-ui/src/views/oa/aiReview/index.vue | 284 ++++++++++++++++++ sql/oa_ai_review.sql | 57 ++++ 15 files changed, 1068 insertions(+) create mode 100644 ruoyi-oa/src/main/java/com/ruoyi/oa/aireview/DocumentParseUtil.java create mode 100644 ruoyi-oa/src/main/java/com/ruoyi/oa/aireview/MiMoClient.java create mode 100644 ruoyi-oa/src/main/java/com/ruoyi/oa/aireview/MiMoProperties.java create mode 100644 ruoyi-oa/src/main/java/com/ruoyi/oa/controller/OaAiReviewController.java create mode 100644 ruoyi-oa/src/main/java/com/ruoyi/oa/domain/OaAiReview.java create mode 100644 ruoyi-oa/src/main/java/com/ruoyi/oa/domain/bo/OaAiReviewBo.java create mode 100644 ruoyi-oa/src/main/java/com/ruoyi/oa/domain/vo/OaAiReviewVo.java create mode 100644 ruoyi-oa/src/main/java/com/ruoyi/oa/mapper/OaAiReviewMapper.java create mode 100644 ruoyi-oa/src/main/java/com/ruoyi/oa/service/IOaAiReviewService.java create mode 100644 ruoyi-oa/src/main/java/com/ruoyi/oa/service/impl/OaAiReviewServiceImpl.java create mode 100644 ruoyi-ui/src/api/oa/aiReview.js create mode 100644 ruoyi-ui/src/views/oa/aiReview/index.vue create mode 100644 sql/oa_ai_review.sql diff --git a/ruoyi-admin/src/main/resources/application.yml b/ruoyi-admin/src/main/resources/application.yml index bd87293..7a76333 100644 --- a/ruoyi-admin/src/main/resources/application.yml +++ b/ruoyi-admin/src/main/resources/application.yml @@ -328,3 +328,19 @@ fad: webKey: 34bf20d1db5b183558b9bb85d6eed783 securityKey: 6f9171724396deb5f8c42ef256b3cbc5 +--- # 小米 MiMo 大模型(AI 合同/简历审核) +mimo: + # OpenAI 兼容接口地址(不含 /chat/completions) + base-url: https://api.xiaomimimo.com/v1 + # API Key + api-key: sk-cgdkhgch2w1cg37dl12scuckyzbnrkj37ih3b6f0k13dcgwp + # 多模态模型 + model: mimo-v2.5 + # 最大生成 token(mimo-v2.5 为推理模型,会先消耗 token 思考,需留足额度) + max-tokens: 8192 + temperature: 0.3 + # 单次请求读超时(秒)。推理+长文档审核较慢,给足时间 + timeout: 180 + # PDF 无法提取文字时(扫描件)转图片走多模态,最多渲染页数 + max-image-pages: 8 + diff --git a/ruoyi-oa/pom.xml b/ruoyi-oa/pom.xml index 6b7611c..6985e8a 100644 --- a/ruoyi-oa/pom.xml +++ b/ruoyi-oa/pom.xml @@ -83,6 +83,17 @@ 2.0.29 + + + org.apache.poi + poi-ooxml + + + org.apache.poi + poi-scratchpad + ${poi.version} + + diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/aireview/DocumentParseUtil.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/aireview/DocumentParseUtil.java new file mode 100644 index 0000000..f38bddf --- /dev/null +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/aireview/DocumentParseUtil.java @@ -0,0 +1,92 @@ +package com.ruoyi.oa.aireview; + +import com.ruoyi.common.exception.ServiceException; +import com.ruoyi.common.utils.StringUtils; +import lombok.extern.slf4j.Slf4j; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.rendering.ImageType; +import org.apache.pdfbox.rendering.PDFRenderer; +import org.apache.pdfbox.text.PDFTextStripper; +import org.apache.poi.hwpf.extractor.WordExtractor; +import org.apache.poi.xwpf.extractor.XWPFWordExtractor; +import org.apache.poi.xwpf.usermodel.XWPFDocument; + +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; + +/** + * 文档解析:从 PDF / Word 提取文字;扫描版 PDF 渲染为图片走多模态。 + * + * @author wangyu + */ +@Slf4j +public final class DocumentParseUtil { + + private DocumentParseUtil() {} + + /** 提取文字内容(PDF / docx / doc)。提取不到返回空串,不抛异常。 */ + public static String extractText(String fileName, byte[] bytes) { + String lower = fileName == null ? "" : fileName.toLowerCase(); + try { + if (lower.endsWith(".pdf")) { + return extractPdfText(bytes); + } else if (lower.endsWith(".docx")) { + try (XWPFDocument doc = new XWPFDocument(new ByteArrayInputStream(bytes)); + XWPFWordExtractor ex = new XWPFWordExtractor(doc)) { + return StringUtils.trimToEmpty(ex.getText()); + } + } else if (lower.endsWith(".doc")) { + try (WordExtractor ex = new WordExtractor(new ByteArrayInputStream(bytes))) { + return StringUtils.trimToEmpty(ex.getText()); + } + } + throw new ServiceException("仅支持 PDF / Word(.doc/.docx) 文件"); + } catch (ServiceException e) { + throw e; + } catch (Exception e) { + log.error("解析文档失败:{}", fileName, e); + return ""; + } + } + + private static String extractPdfText(byte[] bytes) throws Exception { + try (PDDocument doc = PDDocument.load(bytes)) { + PDFTextStripper stripper = new PDFTextStripper(); + return StringUtils.trimToEmpty(stripper.getText(doc)); + } + } + + /** 是否为 PDF */ + public static boolean isPdf(String fileName) { + return fileName != null && fileName.toLowerCase().endsWith(".pdf"); + } + + /** + * 将 PDF 渲染为 PNG 图片的 data URI 列表(用于扫描件走多模态)。 + * + * @param maxPages 最多渲染页数 + */ + public static List renderPdfImages(byte[] bytes, int maxPages) { + List images = new ArrayList<>(); + try (PDDocument doc = PDDocument.load(bytes)) { + PDFRenderer renderer = new PDFRenderer(doc); + int pages = Math.min(doc.getNumberOfPages(), Math.max(1, maxPages)); + for (int i = 0; i < pages; i++) { + BufferedImage img = renderer.renderImageWithDPI(i, 120, ImageType.RGB); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + ImageIO.write(img, "png", baos); + String b64 = Base64.getEncoder().encodeToString(baos.toByteArray()); + images.add("data:image/png;base64," + b64); + } + } catch (Exception e) { + log.error("PDF 转图片失败", e); + throw new ServiceException("PDF 转图片失败:" + e.getMessage()); + } + return images; + } +} diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/aireview/MiMoClient.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/aireview/MiMoClient.java new file mode 100644 index 0000000..d5bf6cc --- /dev/null +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/aireview/MiMoClient.java @@ -0,0 +1,131 @@ +package com.ruoyi.oa.aireview; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.ruoyi.common.exception.ServiceException; +import com.ruoyi.common.utils.StringUtils; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.*; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; + +import javax.annotation.PostConstruct; +import java.util.List; + +/** + * 小米 MiMo 大模型调用客户端(OpenAI 兼容 /chat/completions)。 + * + * 注意:mimo-v2.5 是推理模型,响应里 message.content 是最终答案, + * message.reasoning_content 是思考过程;max_completion_tokens 要给足, + * 否则 token 被思考耗尽会出现 content 为空、finish_reason=length。 + * + * @author wangyu + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class MiMoClient { + + private final MiMoProperties props; + private final ObjectMapper json = new ObjectMapper(); + + private RestTemplate restTemplate; + + @PostConstruct + public void init() { + SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); + factory.setConnectTimeout(10_000); + factory.setReadTimeout((props.getTimeout() == null ? 180 : props.getTimeout()) * 1000); + this.restTemplate = new RestTemplate(factory); + } + + /** + * 纯文本对话 + */ + public String chatText(String systemPrompt, String userText) { + ObjectNode body = baseBody(); + ArrayNode messages = body.putArray("messages"); + if (StringUtils.isNotBlank(systemPrompt)) { + messages.addObject().put("role", "system").put("content", systemPrompt); + } + messages.addObject().put("role", "user").put("content", userText); + return send(body); + } + + /** + * 多模态对话:一段文字 + 若干图片(data URI,形如 data:image/png;base64,xxx) + */ + public String chatMultimodal(String systemPrompt, String userText, List imageDataUris) { + ObjectNode body = baseBody(); + ArrayNode messages = body.putArray("messages"); + if (StringUtils.isNotBlank(systemPrompt)) { + messages.addObject().put("role", "system").put("content", systemPrompt); + } + ObjectNode userMsg = messages.addObject(); + userMsg.put("role", "user"); + ArrayNode content = userMsg.putArray("content"); + content.addObject().put("type", "text").put("text", userText); + if (imageDataUris != null) { + for (String uri : imageDataUris) { + if (StringUtils.isBlank(uri)) continue; + ObjectNode img = content.addObject(); + img.put("type", "image_url"); + img.putObject("image_url").put("url", uri); + } + } + return send(body); + } + + private ObjectNode baseBody() { + ObjectNode body = json.createObjectNode(); + body.put("model", props.getModel()); + // OpenAI 兼容新参数名;MiMo 同时也接受 max_tokens,这里两者都给以防万一 + body.put("max_completion_tokens", props.getMaxTokens()); + body.put("max_tokens", props.getMaxTokens()); + body.put("temperature", props.getTemperature()); + body.put("stream", false); + return body; + } + + private String send(ObjectNode body) { + if (StringUtils.isBlank(props.getApiKey())) { + throw new ServiceException("未配置 MiMo API Key(application.yml: mimo.api-key)"); + } + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + // 文档要求 api-key 头;同时带 Bearer 以兼容 OpenAI 风格 + headers.set("api-key", props.getApiKey()); + headers.set("Authorization", "Bearer " + props.getApiKey()); + + String url = props.getBaseUrl() + "/chat/completions"; + try { + HttpEntity entity = new HttpEntity<>(json.writeValueAsString(body), headers); + ResponseEntity resp = restTemplate.exchange(url, HttpMethod.POST, entity, String.class); + if (resp.getStatusCode() != HttpStatus.OK || resp.getBody() == null) { + throw new ServiceException("AI 服务返回异常:" + resp.getStatusCode()); + } + JsonNode root = json.readTree(resp.getBody()); + JsonNode message = root.path("choices").path(0).path("message"); + String content = message.path("content").asText(""); + String finish = root.path("choices").path(0).path("finish_reason").asText(""); + if (StringUtils.isBlank(content)) { + if ("length".equals(finish)) { + throw new ServiceException("文档过长,AI 输出被截断,请精简文档或提高 mimo.max-tokens 后重试"); + } + throw new ServiceException("AI 未返回有效内容"); + } + int total = root.path("usage").path("total_tokens").asInt(0); + log.info("MiMo 审核完成,model={}, 消耗 token={}", props.getModel(), total); + return content; + } catch (ServiceException e) { + throw e; + } catch (Exception e) { + log.error("调用 MiMo 失败", e); + throw new ServiceException("AI 服务调用失败:" + e.getMessage()); + } + } +} diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/aireview/MiMoProperties.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/aireview/MiMoProperties.java new file mode 100644 index 0000000..92de805 --- /dev/null +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/aireview/MiMoProperties.java @@ -0,0 +1,37 @@ +package com.ruoyi.oa.aireview; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +/** + * 小米 MiMo 大模型配置(AI 合同/简历审核) + * + * @author wangyu + */ +@Data +@Component +@ConfigurationProperties(prefix = "mimo") +public class MiMoProperties { + + /** OpenAI 兼容接口地址(不含 /chat/completions) */ + private String baseUrl = "https://api.xiaomimimo.com/v1"; + + /** API Key */ + private String apiKey; + + /** 多模态模型名 */ + private String model = "mimo-v2.5"; + + /** 最大生成 token(mimo-v2.5 为推理模型,需留足额度给思考+输出) */ + private Integer maxTokens = 8192; + + /** 采样温度 */ + private Double temperature = 0.3; + + /** 单次请求读超时(秒) */ + private Integer timeout = 180; + + /** PDF 转图片走多模态时最多渲染页数 */ + private Integer maxImagePages = 8; +} diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/controller/OaAiReviewController.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/controller/OaAiReviewController.java new file mode 100644 index 0000000..bb501d6 --- /dev/null +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/controller/OaAiReviewController.java @@ -0,0 +1,58 @@ +package com.ruoyi.oa.controller; + +import com.ruoyi.common.annotation.Log; +import com.ruoyi.common.core.controller.BaseController; +import com.ruoyi.common.core.domain.PageQuery; +import com.ruoyi.common.core.domain.R; +import com.ruoyi.common.core.page.TableDataInfo; +import com.ruoyi.common.enums.BusinessType; +import com.ruoyi.oa.domain.bo.OaAiReviewBo; +import com.ruoyi.oa.domain.vo.OaAiReviewVo; +import com.ruoyi.oa.service.IOaAiReviewService; +import lombok.RequiredArgsConstructor; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import javax.validation.constraints.NotEmpty; +import javax.validation.constraints.NotNull; +import java.util.Arrays; + +/** + * AI 审核(合同 / 简历) + */ +@Validated +@RequiredArgsConstructor +@RestController +@RequestMapping("/oa/aiReview") +public class OaAiReviewController extends BaseController { + + private final IOaAiReviewService service; + + /** + * 上传并审核 + */ + @Log(title = "AI审核", businessType = BusinessType.OTHER) + @PostMapping(value = "/analyze", consumes = "multipart/form-data") + public R analyze(@RequestParam("file") MultipartFile file, + @RequestParam("reviewType") String reviewType, + @RequestParam(value = "position", required = false) String position) { + return R.ok("审核完成", service.analyze(file, reviewType, position)); + } + + @GetMapping("/list") + public TableDataInfo list(OaAiReviewBo bo, PageQuery pageQuery) { + return service.queryPageList(bo, pageQuery); + } + + @GetMapping("/{id}") + public R getInfo(@NotNull @PathVariable Long id) { + return R.ok(service.queryById(id)); + } + + @Log(title = "AI审核", businessType = BusinessType.DELETE) + @DeleteMapping("/{ids}") + public R remove(@NotEmpty @PathVariable Long[] ids) { + return toAjax(service.deleteByIds(Arrays.asList(ids))); + } +} diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/domain/OaAiReview.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/domain/OaAiReview.java new file mode 100644 index 0000000..ef071fd --- /dev/null +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/domain/OaAiReview.java @@ -0,0 +1,55 @@ +package com.ruoyi.oa.domain; + +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableLogic; +import com.baomidou.mybatisplus.annotation.TableName; +import com.ruoyi.common.core.domain.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * AI 审核记录(合同 / 简历) + */ +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("oa_ai_review") +public class OaAiReview extends BaseEntity { + + private static final long serialVersionUID = 1L; + + @TableId(value = "id") + private Long id; + + /** 审核类型:contract 合同 / resume 简历 */ + private String reviewType; + + /** 原始文件名 */ + private String fileName; + + /** OSS 文件ID(原件留存,可空) */ + private Long ossId; + + /** OSS 文件地址 */ + private String fileUrl; + + /** 简历审核的目标岗位 */ + private String position; + + /** 简历匹配度评分 0-100(合同为空) */ + private Integer matchScore; + + /** 合同总体风险评级:高/中/低(简历为空) */ + private String riskLevel; + + /** AI 审核结果(Markdown) */ + private String resultMd; + + /** 使用的模型 */ + private String model; + + /** 消耗 token */ + private Integer tokens; + + @TableLogic + private String delFlag; +} diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/domain/bo/OaAiReviewBo.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/domain/bo/OaAiReviewBo.java new file mode 100644 index 0000000..f28da36 --- /dev/null +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/domain/bo/OaAiReviewBo.java @@ -0,0 +1,21 @@ +package com.ruoyi.oa.domain.bo; + +import com.ruoyi.common.core.domain.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * AI 审核记录 查询 BO + */ +@Data +@EqualsAndHashCode(callSuper = true) +public class OaAiReviewBo extends BaseEntity { + + private Long id; + + /** 审核类型:contract / resume */ + private String reviewType; + + /** 关键字(文件名 / 岗位) */ + private String keyword; +} diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/domain/vo/OaAiReviewVo.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/domain/vo/OaAiReviewVo.java new file mode 100644 index 0000000..d0b8e6f --- /dev/null +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/domain/vo/OaAiReviewVo.java @@ -0,0 +1,32 @@ +package com.ruoyi.oa.domain.vo; + +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Data; + +import java.io.Serializable; +import java.util.Date; + +/** + * AI 审核记录 VO + */ +@Data +public class OaAiReviewVo implements Serializable { + + private static final long serialVersionUID = 1L; + + private Long id; + private String reviewType; + private String fileName; + private Long ossId; + private String fileUrl; + private String position; + private Integer matchScore; + private String riskLevel; + private String resultMd; + private String model; + private Integer tokens; + + private String createBy; + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date createTime; +} diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/mapper/OaAiReviewMapper.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/mapper/OaAiReviewMapper.java new file mode 100644 index 0000000..03dd99a --- /dev/null +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/mapper/OaAiReviewMapper.java @@ -0,0 +1,8 @@ +package com.ruoyi.oa.mapper; + +import com.ruoyi.common.core.mapper.BaseMapperPlus; +import com.ruoyi.oa.domain.OaAiReview; +import com.ruoyi.oa.domain.vo.OaAiReviewVo; + +public interface OaAiReviewMapper extends BaseMapperPlus { +} diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/service/IOaAiReviewService.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/service/IOaAiReviewService.java new file mode 100644 index 0000000..cd31fde --- /dev/null +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/service/IOaAiReviewService.java @@ -0,0 +1,27 @@ +package com.ruoyi.oa.service; + +import com.ruoyi.common.core.domain.PageQuery; +import com.ruoyi.common.core.page.TableDataInfo; +import com.ruoyi.oa.domain.bo.OaAiReviewBo; +import com.ruoyi.oa.domain.vo.OaAiReviewVo; +import org.springframework.web.multipart.MultipartFile; + +import java.util.Collection; + +public interface IOaAiReviewService { + + /** + * 上传合同/简历并进行 AI 审核,落库并返回结果 + * + * @param file PDF / Word 文件 + * @param reviewType contract / resume + * @param position 简历审核的目标岗位(合同可空) + */ + OaAiReviewVo analyze(MultipartFile file, String reviewType, String position); + + TableDataInfo queryPageList(OaAiReviewBo bo, PageQuery pageQuery); + + OaAiReviewVo queryById(Long id); + + Boolean deleteByIds(Collection ids); +} diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/service/impl/OaAiReviewServiceImpl.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/service/impl/OaAiReviewServiceImpl.java new file mode 100644 index 0000000..fa9d229 --- /dev/null +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/service/impl/OaAiReviewServiceImpl.java @@ -0,0 +1,211 @@ +package com.ruoyi.oa.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.ruoyi.common.core.domain.PageQuery; +import com.ruoyi.common.core.page.TableDataInfo; +import com.ruoyi.common.exception.ServiceException; +import com.ruoyi.common.utils.StringUtils; +import com.ruoyi.oa.aireview.DocumentParseUtil; +import com.ruoyi.oa.aireview.MiMoClient; +import com.ruoyi.oa.aireview.MiMoProperties; +import com.ruoyi.oa.domain.OaAiReview; +import com.ruoyi.oa.domain.bo.OaAiReviewBo; +import com.ruoyi.oa.domain.vo.OaAiReviewVo; +import com.ruoyi.oa.mapper.OaAiReviewMapper; +import com.ruoyi.oa.service.IOaAiReviewService; +import com.ruoyi.system.domain.vo.SysOssVo; +import com.ruoyi.system.service.ISysOssService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import java.util.Collection; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * AI 审核(合同 / 简历) + * + * @author wangyu + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class OaAiReviewServiceImpl implements IOaAiReviewService { + + private final OaAiReviewMapper baseMapper; + private final MiMoClient miMoClient; + private final MiMoProperties miMoProps; + private final ISysOssService ossService; + + /** 文字内容少于该长度,认为是扫描件/图片版,PDF 走多模态 */ + private static final int MIN_TEXT_LEN = 30; + /** 文档过长时截断,避免 prompt 过大 */ + private static final int MAX_TEXT_LEN = 24000; + + private static final Pattern SCORE_PATTERN = Pattern.compile("匹配度评分[::\\s]*([0-9]{1,3})"); + private static final Pattern RISK_PATTERN = Pattern.compile("风险评级[::\\s]*([高中低])"); + + @Override + public OaAiReviewVo analyze(MultipartFile file, String reviewType, String position) { + if (file == null || file.isEmpty()) { + throw new ServiceException("请上传文件"); + } + if (!"contract".equals(reviewType) && !"resume".equals(reviewType)) { + throw new ServiceException("审核类型不正确"); + } + String fileName = file.getOriginalFilename(); + if (StringUtils.isBlank(fileName)) { + throw new ServiceException("文件名为空"); + } + String lower = fileName.toLowerCase(); + if (!(lower.endsWith(".pdf") || lower.endsWith(".doc") || lower.endsWith(".docx"))) { + throw new ServiceException("仅支持 PDF / Word(.doc/.docx) 文件"); + } + + byte[] bytes; + try { + bytes = file.getBytes(); + } catch (Exception e) { + throw new ServiceException("读取文件失败"); + } + + // 1. 提取文字 + String text = DocumentParseUtil.extractText(fileName, bytes); + text = truncate(text); + + // 2. 构建提示词 + String system = "contract".equals(reviewType) ? contractSystemPrompt() : resumeSystemPrompt(position); + + // 3. 调用大模型(文字优先;扫描版 PDF 走多模态) + String result; + if (StringUtils.length(text) >= MIN_TEXT_LEN) { + String userText = "contract".equals(reviewType) + ? "以下是待审核的合同全文:\n\n" + text + : "以下是待评估的简历内容:" + + (StringUtils.isNotBlank(position) ? "(目标岗位:" + position + ")" : "") + "\n\n" + text; + result = miMoClient.chatText(system, userText); + } else if (DocumentParseUtil.isPdf(fileName)) { + List images = DocumentParseUtil.renderPdfImages(bytes, miMoProps.getMaxImagePages()); + String userText = "contract".equals(reviewType) + ? "请审核以下图片中的合同(扫描件),逐页通读后给出审核意见。" + : "请评估以下图片中的简历(扫描件)。" + + (StringUtils.isNotBlank(position) ? "目标岗位:" + position + "。" : ""); + result = miMoClient.chatMultimodal(system, userText, images); + } else { + throw new ServiceException("未能从该 Word 文件中提取到文字内容,请确认文件未加密或改用 PDF"); + } + + // 4. 留存原件(失败不阻塞主流程) + Long ossId = null; + String fileUrl = null; + try { + SysOssVo oss = ossService.upload(file, 0L); + if (oss != null) { + ossId = oss.getOssId(); + fileUrl = oss.getUrl(); + } + } catch (Exception e) { + log.warn("AI审核原件留存失败:{}", fileName, e); + } + + // 5. 落库 + OaAiReview entity = new OaAiReview(); + entity.setReviewType(reviewType); + entity.setFileName(fileName); + entity.setOssId(ossId); + entity.setFileUrl(fileUrl); + entity.setPosition(position); + entity.setResultMd(result); + entity.setModel(miMoProps.getModel()); + if ("resume".equals(reviewType)) { + entity.setMatchScore(parseInt(SCORE_PATTERN, result, 100)); + } else { + entity.setRiskLevel(parseStr(RISK_PATTERN, result)); + } + baseMapper.insert(entity); + + return baseMapper.selectVoById(entity.getId()); + } + + private String truncate(String text) { + if (text == null) return ""; + return text.length() > MAX_TEXT_LEN ? text.substring(0, MAX_TEXT_LEN) : text; + } + + private Integer parseInt(Pattern p, String text, int max) { + Matcher m = p.matcher(text == null ? "" : text); + if (m.find()) { + try { + int v = Integer.parseInt(m.group(1)); + if (v >= 0 && v <= max) return v; + } catch (NumberFormatException ignored) {} + } + return null; + } + + private String parseStr(Pattern p, String text) { + Matcher m = p.matcher(text == null ? "" : text); + return m.find() ? m.group(1) : null; + } + + private String contractSystemPrompt() { + return "你是德睿福成套设备有限公司聘请的资深合同法务与商务谈判顾问。" + + "你的立场是【最大化保护并争取“我方”(德睿福)的利益】——审查时始终站在我方角度," + + "识别对我方不利的安排,并提出利好我方的修改与补充建议。\n" + + "请用简体中文、Markdown 格式输出审核报告,包含以下小节(用二级标题):\n" + + "## 一、合同概要\n(合同类型、双方主体、标的、金额、期限等关键信息)\n" + + "## 二、总体风险评级\n(必须单独一行明确写出:`风险评级:高` 或 `风险评级:中` 或 `风险评级:低`,再附简要理由)\n" + + "## 三、对我方不利/存在风险的条款\n(逐条列出:① 原文摘录 ② 风险说明 ③ 利好我方的修改建议)\n" + + "## 四、建议补充的利好我方条款\n(如违约金、付款节点与比例、质保与验收、知识产权归属、保密、不可抗力、争议解决地与管辖等)\n" + + "## 五、关键条款审查\n(付款、交付、验收、违约责任、责任限制/赔偿上限、解除权等)\n" + + "## 六、谈判要点与优先级\n(按重要性排序,给出可直接用于谈判的要点)\n" + + "注意:若合同中我方实为乙方/供方,请据实判断我方身份后仍以争取我方利益为目标。"; + } + + private String resumeSystemPrompt(String position) { + String posLine = StringUtils.isNotBlank(position) + ? "目标岗位为【" + position + "】,请重点评估候选人与该岗位的匹配度。\n" + : "未指定目标岗位,请综合判断其最适合的岗位方向并据此评估。\n"; + return "你是德睿福成套设备有限公司的资深招聘官与技术面试官。" + posLine + + "请用简体中文、Markdown 格式输出评估报告,包含以下小节(用二级标题):\n" + + "## 一、候选人概要\n(姓名、工作年限、学历、当前/最近岗位、期望等,能提取则填)\n" + + "## 二、岗位匹配度\n(必须单独一行明确写出:`匹配度评分:NN`,NN 为 0-100 的整数;随后给出评分理由," + + "结合岗位所需的技能、经验、行业背景逐点对照)\n" + + "## 三、核心优势\n" + + "## 四、短板与风险点\n(经历空窗、跳槽频繁、技能缺口等)\n" + + "## 五、建议重点考察的面试问题\n(针对其经历与岗位要求,给出 5 个左右有针对性的问题)\n" + + "## 六、录用建议\n(明确给出:建议录用 / 谨慎考虑 / 不建议,并说明理由)"; + } + + @Override + public TableDataInfo queryPageList(OaAiReviewBo bo, PageQuery pageQuery) { + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + if (bo != null) { + lqw.eq(StringUtils.isNotBlank(bo.getReviewType()), OaAiReview::getReviewType, bo.getReviewType()); + if (StringUtils.isNotBlank(bo.getKeyword())) { + String kw = bo.getKeyword().trim(); + lqw.and(w -> w.like(OaAiReview::getFileName, kw).or().like(OaAiReview::getPosition, kw)); + } + } + lqw.orderByDesc(OaAiReview::getCreateTime); + Page page = baseMapper.selectVoPage(pageQuery.build(), lqw); + return TableDataInfo.build(page); + } + + @Override + public OaAiReviewVo queryById(Long id) { + OaAiReviewVo vo = baseMapper.selectVoById(id); + if (vo == null) throw new ServiceException("记录不存在或已删除"); + return vo; + } + + @Override + public Boolean deleteByIds(Collection ids) { + return baseMapper.deleteBatchIds(ids) > 0; + } +} diff --git a/ruoyi-ui/src/api/oa/aiReview.js b/ruoyi-ui/src/api/oa/aiReview.js new file mode 100644 index 0000000..f3cf709 --- /dev/null +++ b/ruoyi-ui/src/api/oa/aiReview.js @@ -0,0 +1,28 @@ +import request from '@/utils/request' + +/** + * 上传合同/简历进行 AI 审核 + * @param {FormData} data 包含 file, reviewType(contract|resume), position(可选) + */ +export function analyzeAiReview (data) { + return request({ + url: '/oa/aiReview/analyze', + method: 'post', + data, + headers: { 'Content-Type': 'multipart/form-data' }, + // 推理模型 + 长文档,单次可能较慢,放宽到 5 分钟 + timeout: 300000 + }) +} + +export function listAiReview (query) { + return request({ url: '/oa/aiReview/list', method: 'get', params: query }) +} + +export function getAiReview (id) { + return request({ url: '/oa/aiReview/' + id, method: 'get' }) +} + +export function delAiReview (ids) { + return request({ url: '/oa/aiReview/' + ids, method: 'delete' }) +} diff --git a/ruoyi-ui/src/views/oa/aiReview/index.vue b/ruoyi-ui/src/views/oa/aiReview/index.vue new file mode 100644 index 0000000..45bd9be --- /dev/null +++ b/ruoyi-ui/src/views/oa/aiReview/index.vue @@ -0,0 +1,284 @@ + + + + + diff --git a/sql/oa_ai_review.sql b/sql/oa_ai_review.sql new file mode 100644 index 0000000..8e2c747 --- /dev/null +++ b/sql/oa_ai_review.sql @@ -0,0 +1,57 @@ +-- ===================================================== +-- AI 智能审核(合同 / 简历) +-- - 使用小米 MiMo 多模态大模型(mimo-v2.5) +-- - 合同:站在“我方”立场审查,找出不利条款 + 利好我方的修改建议 +-- - 简历:评估候选人与目标岗位匹配度 +-- 本脚本可重复执行(幂等)。 +-- 注意:sys_menu 主键为雪花ID(非自增),必须显式指定。 +-- ===================================================== + +-- ---------------- 审核记录表 ---------------- +CREATE TABLE IF NOT EXISTS `oa_ai_review` ( + `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `review_type` varchar(20) NOT NULL COMMENT '类型 contract合同 / resume简历', + `file_name` varchar(255) DEFAULT NULL COMMENT '原始文件名', + `oss_id` bigint(20) DEFAULT NULL COMMENT 'OSS文件ID(原件留存)', + `file_url` varchar(500) DEFAULT NULL COMMENT 'OSS文件地址', + `position` varchar(255) DEFAULT NULL COMMENT '简历目标岗位', + `match_score` int(11) DEFAULT NULL COMMENT '简历匹配度评分 0-100', + `risk_level` varchar(10) DEFAULT NULL COMMENT '合同风险评级 高/中/低', + `result_md` longtext COMMENT 'AI 审核结果(Markdown)', + `model` varchar(50) DEFAULT NULL COMMENT '使用的模型', + `tokens` int(11) DEFAULT NULL COMMENT '消耗 token', + `create_by` varchar(64) DEFAULT NULL, + `create_time` datetime DEFAULT CURRENT_TIMESTAMP, + `update_by` varchar(64) DEFAULT NULL, + `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `del_flag` char(1) DEFAULT '0' COMMENT '删除标志:0正常 2删除(mybatis-plus logicDeleteValue=2)', + PRIMARY KEY (`id`), + KEY `idx_type` (`review_type`), + KEY `idx_create_time` (`create_time`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI 审核记录(合同/简历)'; + +-- ---------------- 菜单:信息 > AI审核 ---------------- +-- 父菜单 1774989374680858626 = 「信息」 +INSERT IGNORE INTO `sys_menu` + (`menu_id`, `menu_name`, `parent_id`, `order_num`, `path`, `component`, `menu_type`, `visible`, `status`, `perms`, `icon`, `create_by`, `create_time`) +VALUES + (2063910000000000001, 'AI审核', 1774989374680858626, 4, + 'aiReview', 'oa/aiReview/index', 'C', '0', '0', + NULL, 'eye-open', 'admin', NOW()); + +-- ---------------- 角色授权(与「信息」下兄弟菜单一致的角色集) ---------------- +INSERT IGNORE INTO `sys_role_menu` (`role_id`, `menu_id`) +VALUES + (1743186990678077442, 2063910000000000001), -- 总经理 + (1743204526291349506, 2063910000000000001), -- 技术总监 + (1743205028123045890, 2063910000000000001), -- 信息化部 + (1852970465740505090, 2063910000000000001), -- 普通员工 + (1859257980152692738, 2063910000000000001), -- 职工 + (1859548445766717441, 2063910000000000001), -- 后勤 + (1893987128812761089, 2063910000000000001), -- 新员工临时身份 + (1914212623781187585, 2063910000000000001), -- 技术总工 + (1914213026883162113, 2063910000000000001), -- 设计主任 + (1925062159919448065, 2063910000000000001); -- 外贸专责 + +-- ---------------- 校验 ---------------- +SELECT menu_id, menu_name, path, component, icon FROM sys_menu WHERE menu_id = 2063910000000000001; From d46754ede8c1afd0ccb1988ea649abdbfa3e9b63 Mon Sep 17 00:00:00 2001 From: wangyu <823267011@qq.com> Date: Fri, 12 Jun 2026 10:04:16 +0800 Subject: [PATCH 07/11] =?UTF-8?q?AI=E5=AE=A1=E6=A0=B8=E6=94=B9=E4=B8=BA=20?= =?UTF-8?q?=E5=88=97=E8=A1=A8=E9=A1=B5+=E8=AF=A6=E6=83=85=E9=A1=B5=20?= =?UTF-8?q?=E7=BB=93=E6=9E=84=EF=BC=8C=E5=88=97=E8=A1=A8=E5=B8=A6=E5=AE=A1?= =?UTF-8?q?=E6=A0=B8=E6=91=98=E8=A6=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 表 oa_ai_review 增加 summary 列(审核结论摘要,纯文本,列表展示用), 已应用到生产库;分析时由结果 Markdown 提炼前160字纯文本写入 - 列表查询清空大字段 result_md 减小响应体,详情接口仍返回完整结果 - 前端拆分: · index.vue 重写为列表页:搜索(类型/关键字)+表格(类型/文件名/岗位/结论标签/ 审核摘要/时间)+分页,「新增审核」改为弹窗上传(类型/岗位/文件), 审核完成后跳转详情;行可删除 · 新增 detail.vue 详情页:元信息(文件名+下载原件/岗位/模型/时间/审核人) + 结论标签 + 完整 Markdown 结果,返回列表按钮 · router 增加 /hint/aiReview/detail/:id 隐藏路由 - 原件已通过 OSS 留存,详情页可下载,下次可直接查看 Co-Authored-By: Claude Fable 5 --- .../java/com/ruoyi/oa/domain/OaAiReview.java | 3 + .../com/ruoyi/oa/domain/vo/OaAiReviewVo.java | 1 + .../service/impl/OaAiReviewServiceImpl.java | 15 + ruoyi-ui/src/router/index.js | 6 + ruoyi-ui/src/views/oa/aiReview/detail.vue | 110 ++++++ ruoyi-ui/src/views/oa/aiReview/index.vue | 362 ++++++++---------- sql/oa_ai_review.sql | 4 + 7 files changed, 293 insertions(+), 208 deletions(-) create mode 100644 ruoyi-ui/src/views/oa/aiReview/detail.vue diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/domain/OaAiReview.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/domain/OaAiReview.java index ef071fd..a11215b 100644 --- a/ruoyi-oa/src/main/java/com/ruoyi/oa/domain/OaAiReview.java +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/domain/OaAiReview.java @@ -41,6 +41,9 @@ public class OaAiReview extends BaseEntity { /** 合同总体风险评级:高/中/低(简历为空) */ private String riskLevel; + /** AI 审核结论摘要(列表展示,纯文本) */ + private String summary; + /** AI 审核结果(Markdown) */ private String resultMd; diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/domain/vo/OaAiReviewVo.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/domain/vo/OaAiReviewVo.java index d0b8e6f..6c81d32 100644 --- a/ruoyi-oa/src/main/java/com/ruoyi/oa/domain/vo/OaAiReviewVo.java +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/domain/vo/OaAiReviewVo.java @@ -22,6 +22,7 @@ public class OaAiReviewVo implements Serializable { private String position; private Integer matchScore; private String riskLevel; + private String summary; private String resultMd; private String model; private Integer tokens; diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/service/impl/OaAiReviewServiceImpl.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/service/impl/OaAiReviewServiceImpl.java index fa9d229..14c2005 100644 --- a/ruoyi-oa/src/main/java/com/ruoyi/oa/service/impl/OaAiReviewServiceImpl.java +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/service/impl/OaAiReviewServiceImpl.java @@ -121,6 +121,7 @@ public class OaAiReviewServiceImpl implements IOaAiReviewService { entity.setFileUrl(fileUrl); entity.setPosition(position); entity.setResultMd(result); + entity.setSummary(buildSummary(result)); entity.setModel(miMoProps.getModel()); if ("resume".equals(reviewType)) { entity.setMatchScore(parseInt(SCORE_PATTERN, result, 100)); @@ -137,6 +138,16 @@ public class OaAiReviewServiceImpl implements IOaAiReviewService { return text.length() > MAX_TEXT_LEN ? text.substring(0, MAX_TEXT_LEN) : text; } + /** 从 Markdown 结果里提炼一段纯文本摘要,供列表展示 */ + private String buildSummary(String md) { + if (StringUtils.isBlank(md)) return null; + String text = md.replaceAll("(?m)^#+\\s*", "") // 标题符号 + .replaceAll("[*`>#\\-]", " ") // markdown 符号 + .replaceAll("\\s+", " ") // 折叠空白 + .trim(); + return text.length() > 160 ? text.substring(0, 160) : text; + } + private Integer parseInt(Pattern p, String text, int max) { Matcher m = p.matcher(text == null ? "" : text); if (m.find()) { @@ -194,6 +205,10 @@ public class OaAiReviewServiceImpl implements IOaAiReviewService { } lqw.orderByDesc(OaAiReview::getCreateTime); Page page = baseMapper.selectVoPage(pageQuery.build(), lqw); + // 列表只需 summary,清空大字段 resultMd 减小响应体 + if (page.getRecords() != null) { + page.getRecords().forEach(v -> v.setResultMd(null)); + } return TableDataInfo.build(page); } diff --git a/ruoyi-ui/src/router/index.js b/ruoyi-ui/src/router/index.js index 620375f..a72563d 100644 --- a/ruoyi-ui/src/router/index.js +++ b/ruoyi-ui/src/router/index.js @@ -177,6 +177,12 @@ export const constantRoutes = [ name: "editMeetingMinutes", meta: { title: "编辑会议纪要", activeMenu: "/hint/meeting" }, }, + { + path: "aiReview/detail/:id(\\d+)", + component: () => import("@/views/oa/aiReview/detail"), + name: "aiReviewDetail", + meta: { title: "审核详情", activeMenu: "/hint/aiReview" }, + }, ], }, { diff --git a/ruoyi-ui/src/views/oa/aiReview/detail.vue b/ruoyi-ui/src/views/oa/aiReview/detail.vue new file mode 100644 index 0000000..b5d0a70 --- /dev/null +++ b/ruoyi-ui/src/views/oa/aiReview/detail.vue @@ -0,0 +1,110 @@ + + + + + diff --git a/ruoyi-ui/src/views/oa/aiReview/index.vue b/ruoyi-ui/src/views/oa/aiReview/index.vue index 45bd9be..13d8bff 100644 --- a/ruoyi-ui/src/views/oa/aiReview/index.vue +++ b/ruoyi-ui/src/views/oa/aiReview/index.vue @@ -1,155 +1,189 @@ diff --git a/sql/oa_ai_review.sql b/sql/oa_ai_review.sql index 8e2c747..e06f76e 100644 --- a/sql/oa_ai_review.sql +++ b/sql/oa_ai_review.sql @@ -17,6 +17,7 @@ CREATE TABLE IF NOT EXISTS `oa_ai_review` ( `position` varchar(255) DEFAULT NULL COMMENT '简历目标岗位', `match_score` int(11) DEFAULT NULL COMMENT '简历匹配度评分 0-100', `risk_level` varchar(10) DEFAULT NULL COMMENT '合同风险评级 高/中/低', + `summary` varchar(500) DEFAULT NULL COMMENT 'AI审核结论摘要(列表展示)', `result_md` longtext COMMENT 'AI 审核结果(Markdown)', `model` varchar(50) DEFAULT NULL COMMENT '使用的模型', `tokens` int(11) DEFAULT NULL COMMENT '消耗 token', @@ -30,6 +31,9 @@ CREATE TABLE IF NOT EXISTS `oa_ai_review` ( KEY `idx_create_time` (`create_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI 审核记录(合同/简历)'; +-- 若表已存在(旧版本),补加 summary 列(MySQL 不支持 ADD COLUMN IF NOT EXISTS,重复执行报错可忽略): +-- ALTER TABLE `oa_ai_review` ADD COLUMN `summary` varchar(500) DEFAULT NULL COMMENT 'AI审核结论摘要(列表展示)' AFTER `risk_level`; + -- ---------------- 菜单:信息 > AI审核 ---------------- -- 父菜单 1774989374680858626 = 「信息」 INSERT IGNORE INTO `sys_menu` From 23f65c738d9ef12e6d16cc239b6c6812b204c360 Mon Sep 17 00:00:00 2001 From: Joshi <3040996759@qq.com> Date: Fri, 12 Jun 2026 10:05:45 +0800 Subject: [PATCH 08/11] =?UTF-8?q?refactor(oa):=20=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E5=88=B0=E8=B4=A7=E6=98=8E=E7=BB=86=E6=9C=8D=E5=8A=A1=E7=9A=84?= =?UTF-8?q?=E5=85=B3=E8=81=94=E6=9F=A5=E8=AF=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加批量填充关联VO的方法,避免N+1查询问题 - 新增需求和项目关联字段到到货明细VO中 - 实现根据需求ID自动填充项目ID和贸易类型的逻辑 - 重构查询方法以支持关联数据的批量加载 - 添加Excel导出忽略注解以防止关联对象被导出 --- .../ruoyi/oa/domain/vo/OaArrivalDetailVo.java | 12 ++ .../impl/OaArrivalDetailServiceImpl.java | 125 +++++++++++++++++- 2 files changed, 133 insertions(+), 4 deletions(-) diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/domain/vo/OaArrivalDetailVo.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/domain/vo/OaArrivalDetailVo.java index 757ce09..892f4ab 100644 --- a/ruoyi-oa/src/main/java/com/ruoyi/oa/domain/vo/OaArrivalDetailVo.java +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/domain/vo/OaArrivalDetailVo.java @@ -8,6 +8,7 @@ import com.alibaba.excel.annotation.ExcelProperty; import com.ruoyi.common.annotation.ExcelDictFormat; import com.ruoyi.common.convert.ExcelDictConvert; import com.ruoyi.common.core.domain.BaseEntity; +import com.alibaba.excel.annotation.ExcelIgnore; import lombok.Data; import java.util.Date; @@ -115,5 +116,16 @@ public class OaArrivalDetailVo extends BaseEntity { @ExcelProperty(value = "手动备注") private String remark; + /** + * 关联需求完整信息(列表展示用,非数据库字段) + */ + @ExcelIgnore + private OaRequirementsVo requirement; + + /** + * 关联项目完整信息(列表展示用,非数据库字段) + */ + @ExcelIgnore + private SysOaProjectVo project; } diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/service/impl/OaArrivalDetailServiceImpl.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/service/impl/OaArrivalDetailServiceImpl.java index 9ebeb04..541b342 100644 --- a/ruoyi-oa/src/main/java/com/ruoyi/oa/service/impl/OaArrivalDetailServiceImpl.java +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/service/impl/OaArrivalDetailServiceImpl.java @@ -11,13 +11,24 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import com.ruoyi.oa.domain.bo.OaArrivalDetailBo; import com.ruoyi.oa.domain.vo.OaArrivalDetailVo; +import com.ruoyi.oa.domain.vo.OaRequirementsVo; +import com.ruoyi.oa.domain.vo.SysOaProjectVo; import com.ruoyi.oa.domain.OaArrivalDetail; +import com.ruoyi.oa.domain.OaRequirements; +import com.ruoyi.oa.domain.SysOaProject; import com.ruoyi.oa.mapper.OaArrivalDetailMapper; +import com.ruoyi.oa.mapper.OaRequirementsMapper; +import com.ruoyi.oa.mapper.SysOaProjectMapper; import com.ruoyi.oa.service.IOaArrivalDetailService; import java.util.List; import java.util.Map; import java.util.Collection; +import java.util.Set; +import java.util.Collections; +import java.util.Objects; +import java.util.function.Function; +import java.util.stream.Collectors; /** * 到货明细Service业务层处理 @@ -31,31 +42,44 @@ public class OaArrivalDetailServiceImpl implements IOaArrivalDetailService { private final OaArrivalDetailMapper baseMapper; + private final OaRequirementsMapper requirementsMapper; + + private final SysOaProjectMapper projectMapper; + /** * 查询到货明细 */ @Override public OaArrivalDetailVo queryById(Long detailId){ - return baseMapper.selectVoById(detailId); + OaArrivalDetailVo vo = baseMapper.selectVoById(detailId); + if (vo != null) { + fillRelatedVo(vo); + } + return vo; } /** - * 查询到货明细列表 + * 查询到货明细列表(分页) */ @Override public TableDataInfo queryPageList(OaArrivalDetailBo bo, PageQuery pageQuery) { LambdaQueryWrapper lqw = buildQueryWrapper(bo); Page result = baseMapper.selectVoPage(pageQuery.build(), lqw); + // 批量填充关联名称,避免 N+1 + batchFillRelatedVos(result.getRecords()); return TableDataInfo.build(result); } /** - * 查询到货明细列表 + * 查询到货明细列表(不分页) */ @Override public List queryList(OaArrivalDetailBo bo) { LambdaQueryWrapper lqw = buildQueryWrapper(bo); - return baseMapper.selectVoList(lqw); + List list = baseMapper.selectVoList(lqw); + // 批量填充关联名称 + batchFillRelatedVos(list); + return list; } private LambdaQueryWrapper buildQueryWrapper(OaArrivalDetailBo bo) { @@ -78,11 +102,79 @@ public class OaArrivalDetailServiceImpl implements IOaArrivalDetailService { return lqw; } + /** + * 批量填充关联的需求VO和项目VO + */ + private void batchFillRelatedVos(List list) { + if (list == null || list.isEmpty()) { + return; + } + + // 收集所有需求ID + Set requirementIds = list.stream() + .map(OaArrivalDetailVo::getRequirementId) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + + // 收集所有项目ID + Set projectIds = list.stream() + .map(OaArrivalDetailVo::getProjectId) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + + // 批量查询需求完整VO + Map requirementMap = Collections.emptyMap(); + if (!requirementIds.isEmpty()) { + List reqVoList = requirementsMapper.selectVoBatchIds(requirementIds); + if (reqVoList != null) { + requirementMap = reqVoList.stream() + .collect(Collectors.toMap(OaRequirementsVo::getRequirementId, + Function.identity(), (a, b) -> a)); + } + } + + // 批量查询项目完整VO + Map projectMap = Collections.emptyMap(); + if (!projectIds.isEmpty()) { + List projVoList = projectMapper.selectVoBatchIds(projectIds); + if (projVoList != null) { + projectMap = projVoList.stream() + .collect(Collectors.toMap(SysOaProjectVo::getProjectId, + Function.identity(), (a, b) -> a)); + } + } + + // 回填完整 VO 对象 + for (OaArrivalDetailVo vo : list) { + if (vo.getRequirementId() != null) { + vo.setRequirement(requirementMap.get(vo.getRequirementId())); + } + if (vo.getProjectId() != null) { + vo.setProject(projectMap.get(vo.getProjectId())); + } + } + } + + /** + * 单条填充关联VO + */ + private void fillRelatedVo(OaArrivalDetailVo vo) { + if (vo.getRequirementId() != null) { + vo.setRequirement(requirementsMapper.selectVoById(vo.getRequirementId())); + } + if (vo.getProjectId() != null) { + vo.setProject(projectMapper.selectVoById(vo.getProjectId())); + } + } + /** * 新增到货明细 */ @Override public Boolean insertByBo(OaArrivalDetailBo bo) { + // 如果前端传了需求ID,自动查询需求表获取项目ID,再查项目表获取贸易类型 + autoFillByRequirement(bo); + OaArrivalDetail add = BeanUtil.toBean(bo, OaArrivalDetail.class); validEntityBeforeSave(add); boolean flag = baseMapper.insert(add) > 0; @@ -92,6 +184,31 @@ public class OaArrivalDetailServiceImpl implements IOaArrivalDetailService { return flag; } + /** + * 根据需求ID自动填充项目ID和贸易类型 + */ + private void autoFillByRequirement(OaArrivalDetailBo bo) { + if (bo.getRequirementId() == null) { + return; + } + // 查询需求表获取项目ID + OaRequirements requirement = requirementsMapper.selectById(bo.getRequirementId()); + if (requirement == null) { + return; + } + Long projectId = requirement.getProjectId(); + if (projectId != null && bo.getProjectId() == null) { + bo.setProjectId(projectId); + } + // 查询项目表获取贸易类型(仅当未设置时) + if (bo.getTradeType() == null && projectId != null) { + SysOaProject project = projectMapper.selectById(projectId); + if (project != null && project.getTradeType() != null) { + bo.setTradeType(project.getTradeType().intValue()); + } + } + } + /** * 修改到货明细 */ From 7a2603e1f998f7ff19d561f00698eff26cdb17b2 Mon Sep 17 00:00:00 2001 From: wangyu <823267011@qq.com> Date: Fri, 12 Jun 2026 10:19:52 +0800 Subject: [PATCH 09/11] =?UTF-8?q?AI=E5=AE=A1=E6=A0=B8=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=E7=8B=AC=E7=AB=8B=E6=B5=81=E5=BC=8F=E9=A1=B5?= =?UTF-8?q?=E9=9D=A2=EF=BC=9A=E5=B7=A6=E4=BE=A7=E5=AE=9E=E6=97=B6=E8=BE=93?= =?UTF-8?q?=E5=87=BA+=E5=8F=B3=E4=BE=A7=E6=96=87=E6=A1=A3=E9=A2=84?= =?UTF-8?q?=E8=A7=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 解决上传后长时间无反馈的问题——改为流式(SSE)边生成边展示。 后端: - MiMoClient.chatStream:HttpURLConnection 读 SSE,分别回调 reasoning(思考) 与 content(正文) 增量;支持多模态(扫描件PDF) - IOaAiReviewService.analyzeStream + 实现:同步校验/解析文档后,异步线程调用 流式接口,通过 SseEmitter 推送 {type:reasoning|content|done|error}, 结束后落库(含结论解析、摘要、原件OSS留存);createBy 显式回填(异步线程无登录上下文) · 抽出 prepare()/persist() 复用,analyze() 与 analyzeStream() 共用 - Controller 新增 POST /oa/aiReview/analyzeStream(multipart→text/event-stream) 前端: - 新增独立二级页面 views/oa/aiReview/add.vue(路由 /hint/aiReview/add): · 顶部:类型/岗位/选文件/开始审核 · 左侧:用原生 fetch 读流,实时渲染——思考过程(可折叠)+正文 Markdown 实时输出 · 右侧:选中文件即用本地 objectURL 预览(PDF 内嵌 iframe,Word 占位提示) · 完成后显示匹配度/风险标签 + 查看详情 - 列表页「新增审核」由弹窗改为跳转该页面,移除弹窗相关逻辑 - router 增加 /hint/aiReview/add 隐藏路由 Co-Authored-By: Claude Fable 5 --- .../com/ruoyi/oa/aireview/MiMoClient.java | 107 +++++++ .../oa/controller/OaAiReviewController.java | 12 + .../ruoyi/oa/service/IOaAiReviewService.java | 6 + .../service/impl/OaAiReviewServiceImpl.java | 172 +++++++--- ruoyi-ui/src/router/index.js | 6 + ruoyi-ui/src/views/oa/aiReview/add.vue | 297 ++++++++++++++++++ ruoyi-ui/src/views/oa/aiReview/index.vue | 100 +----- 7 files changed, 568 insertions(+), 132 deletions(-) create mode 100644 ruoyi-ui/src/views/oa/aiReview/add.vue diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/aireview/MiMoClient.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/aireview/MiMoClient.java index d5bf6cc..616dcf1 100644 --- a/ruoyi-oa/src/main/java/com/ruoyi/oa/aireview/MiMoClient.java +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/aireview/MiMoClient.java @@ -14,7 +14,15 @@ import org.springframework.stereotype.Component; import org.springframework.web.client.RestTemplate; import javax.annotation.PostConstruct; +import java.io.BufferedReader; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; import java.util.List; +import java.util.function.BiConsumer; /** * 小米 MiMo 大模型调用客户端(OpenAI 兼容 /chat/completions)。 @@ -80,6 +88,105 @@ public class MiMoClient { return send(body); } + /** + * 流式对话(SSE)。一边接收一边回调,返回拼接好的最终答案 content。 + * + * @param imageDataUris 多模态图片(可空) + * @param onDelta 回调:kind = "reasoning"(思考) / "content"(正文),text = 增量片段 + */ + public String chatStream(String systemPrompt, String userText, List imageDataUris, + BiConsumer onDelta) { + if (StringUtils.isBlank(props.getApiKey())) { + throw new ServiceException("未配置 MiMo API Key(application.yml: mimo.api-key)"); + } + ObjectNode body = baseBody(); + body.put("stream", true); + ArrayNode messages = body.putArray("messages"); + if (StringUtils.isNotBlank(systemPrompt)) { + messages.addObject().put("role", "system").put("content", systemPrompt); + } + boolean multimodal = imageDataUris != null && !imageDataUris.isEmpty(); + if (multimodal) { + ObjectNode userMsg = messages.addObject(); + userMsg.put("role", "user"); + ArrayNode content = userMsg.putArray("content"); + content.addObject().put("type", "text").put("text", userText); + for (String uri : imageDataUris) { + if (StringUtils.isBlank(uri)) continue; + ObjectNode img = content.addObject(); + img.put("type", "image_url"); + img.putObject("image_url").put("url", uri); + } + } else { + messages.addObject().put("role", "user").put("content", userText); + } + + HttpURLConnection conn = null; + try { + URL url = new URL(props.getBaseUrl() + "/chat/completions"); + conn = (HttpURLConnection) url.openConnection(); + conn.setRequestMethod("POST"); + conn.setDoOutput(true); + conn.setConnectTimeout(10_000); + conn.setReadTimeout((props.getTimeout() == null ? 180 : props.getTimeout()) * 1000); + conn.setRequestProperty("Content-Type", "application/json"); + conn.setRequestProperty("Accept", "text/event-stream"); + conn.setRequestProperty("api-key", props.getApiKey()); + conn.setRequestProperty("Authorization", "Bearer " + props.getApiKey()); + try (OutputStream os = conn.getOutputStream()) { + os.write(json.writeValueAsString(body).getBytes(StandardCharsets.UTF_8)); + } + int code = conn.getResponseCode(); + if (code >= 400) { + String err = readAll(conn.getErrorStream()); + throw new ServiceException("AI 服务返回 " + code + ":" + StringUtils.substring(err, 0, 300)); + } + StringBuilder full = new StringBuilder(); + try (BufferedReader r = new BufferedReader( + new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8))) { + String line; + while ((line = r.readLine()) != null) { + if (line.isEmpty() || !line.startsWith("data:")) continue; + String payload = line.substring(5).trim(); + if ("[DONE]".equals(payload)) break; + JsonNode delta = json.readTree(payload).path("choices").path(0).path("delta"); + JsonNode rc = delta.get("reasoning_content"); + if (rc != null && !rc.isNull() && !rc.asText().isEmpty()) { + onDelta.accept("reasoning", rc.asText()); + } + JsonNode c = delta.get("content"); + if (c != null && !c.isNull() && !c.asText().isEmpty()) { + full.append(c.asText()); + onDelta.accept("content", c.asText()); + } + } + } + if (full.length() == 0) { + throw new ServiceException("AI 未返回有效内容(文档过长或额度不足)"); + } + return full.toString(); + } catch (ServiceException e) { + throw e; + } catch (Exception e) { + log.error("调用 MiMo 流式接口失败", e); + throw new ServiceException("AI 服务调用失败:" + e.getMessage()); + } finally { + if (conn != null) conn.disconnect(); + } + } + + private String readAll(InputStream in) { + if (in == null) return ""; + try (BufferedReader r = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))) { + StringBuilder sb = new StringBuilder(); + String l; + while ((l = r.readLine()) != null) sb.append(l); + return sb.toString(); + } catch (Exception e) { + return ""; + } + } + private ObjectNode baseBody() { ObjectNode body = json.createObjectNode(); body.put("model", props.getModel()); diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/controller/OaAiReviewController.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/controller/OaAiReviewController.java index bb501d6..eb53e6b 100644 --- a/ruoyi-oa/src/main/java/com/ruoyi/oa/controller/OaAiReviewController.java +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/controller/OaAiReviewController.java @@ -13,6 +13,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; import javax.validation.constraints.NotEmpty; import javax.validation.constraints.NotNull; @@ -40,6 +41,17 @@ public class OaAiReviewController extends BaseController { return R.ok("审核完成", service.analyze(file, reviewType, position)); } + /** + * 上传并流式审核(SSE:边生成边推送,结束后落库) + */ + @Log(title = "AI审核", businessType = BusinessType.OTHER) + @PostMapping(value = "/analyzeStream", consumes = "multipart/form-data", produces = "text/event-stream;charset=UTF-8") + public SseEmitter analyzeStream(@RequestParam("file") MultipartFile file, + @RequestParam("reviewType") String reviewType, + @RequestParam(value = "position", required = false) String position) { + return service.analyzeStream(file, reviewType, position); + } + @GetMapping("/list") public TableDataInfo list(OaAiReviewBo bo, PageQuery pageQuery) { return service.queryPageList(bo, pageQuery); diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/service/IOaAiReviewService.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/service/IOaAiReviewService.java index cd31fde..dbd02bc 100644 --- a/ruoyi-oa/src/main/java/com/ruoyi/oa/service/IOaAiReviewService.java +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/service/IOaAiReviewService.java @@ -5,6 +5,7 @@ import com.ruoyi.common.core.page.TableDataInfo; import com.ruoyi.oa.domain.bo.OaAiReviewBo; import com.ruoyi.oa.domain.vo.OaAiReviewVo; import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; import java.util.Collection; @@ -19,6 +20,11 @@ public interface IOaAiReviewService { */ OaAiReviewVo analyze(MultipartFile file, String reviewType, String position); + /** + * 流式审核:边生成边推送(SSE),结束后落库 + */ + SseEmitter analyzeStream(MultipartFile file, String reviewType, String position); + TableDataInfo queryPageList(OaAiReviewBo bo, PageQuery pageQuery); OaAiReviewVo queryById(Long id); diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/service/impl/OaAiReviewServiceImpl.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/service/impl/OaAiReviewServiceImpl.java index 14c2005..808d897 100644 --- a/ruoyi-oa/src/main/java/com/ruoyi/oa/service/impl/OaAiReviewServiceImpl.java +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/service/impl/OaAiReviewServiceImpl.java @@ -6,6 +6,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ruoyi.common.core.domain.PageQuery; import com.ruoyi.common.core.page.TableDataInfo; import com.ruoyi.common.exception.ServiceException; +import com.ruoyi.common.helper.LoginHelper; import com.ruoyi.common.utils.StringUtils; import com.ruoyi.oa.aireview.DocumentParseUtil; import com.ruoyi.oa.aireview.MiMoClient; @@ -21,9 +22,14 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; +import java.io.File; +import java.nio.file.Files; import java.util.Collection; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -52,6 +58,66 @@ public class OaAiReviewServiceImpl implements IOaAiReviewService { @Override public OaAiReviewVo analyze(MultipartFile file, String reviewType, String position) { + Prepared p = prepare(file, reviewType, position); + String result = p.images != null + ? miMoClient.chatMultimodal(p.system, p.userText, p.images) + : miMoClient.chatText(p.system, p.userText); + // 留存原件 + SysOssVo oss = uploadQuietly(file); + OaAiReview entity = persist(p, result, oss, currentUsername()); + return baseMapper.selectVoById(entity.getId()); + } + + @Override + public SseEmitter analyzeStream(MultipartFile file, String reviewType, String position) { + // 同步校验 + 解析(出错可立即以普通异常返回),耗时的大模型调用放到异步线程 + Prepared p = prepare(file, reviewType, position); + String username = currentUsername(); + long timeoutMs = ((miMoProps.getTimeout() == null ? 180 : miMoProps.getTimeout()) + 60) * 1000L; + SseEmitter emitter = new SseEmitter(timeoutMs); + + Thread worker = new Thread(() -> { + try { + String result = miMoClient.chatStream(p.system, p.userText, p.images, (kind, chunk) -> { + try { + Map ev = new HashMap<>(); + ev.put("type", kind); // reasoning / content + ev.put("c", chunk); + emitter.send(ev); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + SysOssVo oss = uploadQuietly(p.fileName, p.bytes); + OaAiReview entity = persist(p, result, oss, username); + + Map done = new HashMap<>(); + done.put("type", "done"); + done.put("id", entity.getId()); + done.put("matchScore", entity.getMatchScore()); + done.put("riskLevel", entity.getRiskLevel()); + emitter.send(done); + emitter.complete(); + } catch (Exception e) { + log.error("流式审核失败", e); + try { + Map err = new HashMap<>(); + err.put("type", "error"); + Throwable cause = e instanceof RuntimeException && e.getCause() != null ? e.getCause() : e; + err.put("msg", cause.getMessage()); + emitter.send(err); + } catch (Exception ignored) {} + emitter.complete(); + } + }); + worker.setDaemon(true); + worker.setName("ai-review-stream"); + worker.start(); + return emitter; + } + + /** 校验 + 读取 + 解析 + 构建提示词 */ + private Prepared prepare(MultipartFile file, String reviewType, String position) { if (file == null || file.isEmpty()) { throw new ServiceException("请上传文件"); } @@ -66,7 +132,6 @@ public class OaAiReviewServiceImpl implements IOaAiReviewService { if (!(lower.endsWith(".pdf") || lower.endsWith(".doc") || lower.endsWith(".docx"))) { throw new ServiceException("仅支持 PDF / Word(.doc/.docx) 文件"); } - byte[] bytes; try { bytes = file.getBytes(); @@ -74,63 +139,100 @@ public class OaAiReviewServiceImpl implements IOaAiReviewService { throw new ServiceException("读取文件失败"); } - // 1. 提取文字 - String text = DocumentParseUtil.extractText(fileName, bytes); - text = truncate(text); + Prepared p = new Prepared(); + p.reviewType = reviewType; + p.position = position; + p.fileName = fileName; + p.bytes = bytes; + p.system = "contract".equals(reviewType) ? contractSystemPrompt() : resumeSystemPrompt(position); - // 2. 构建提示词 - String system = "contract".equals(reviewType) ? contractSystemPrompt() : resumeSystemPrompt(position); - - // 3. 调用大模型(文字优先;扫描版 PDF 走多模态) - String result; + String text = truncate(DocumentParseUtil.extractText(fileName, bytes)); if (StringUtils.length(text) >= MIN_TEXT_LEN) { - String userText = "contract".equals(reviewType) + p.userText = "contract".equals(reviewType) ? "以下是待审核的合同全文:\n\n" + text : "以下是待评估的简历内容:" + (StringUtils.isNotBlank(position) ? "(目标岗位:" + position + ")" : "") + "\n\n" + text; - result = miMoClient.chatText(system, userText); + p.images = null; } else if (DocumentParseUtil.isPdf(fileName)) { - List images = DocumentParseUtil.renderPdfImages(bytes, miMoProps.getMaxImagePages()); - String userText = "contract".equals(reviewType) + p.images = DocumentParseUtil.renderPdfImages(bytes, miMoProps.getMaxImagePages()); + p.userText = "contract".equals(reviewType) ? "请审核以下图片中的合同(扫描件),逐页通读后给出审核意见。" : "请评估以下图片中的简历(扫描件)。" + (StringUtils.isNotBlank(position) ? "目标岗位:" + position + "。" : ""); - result = miMoClient.chatMultimodal(system, userText, images); } else { throw new ServiceException("未能从该 Word 文件中提取到文字内容,请确认文件未加密或改用 PDF"); } + return p; + } - // 4. 留存原件(失败不阻塞主流程) - Long ossId = null; - String fileUrl = null; - try { - SysOssVo oss = ossService.upload(file, 0L); - if (oss != null) { - ossId = oss.getOssId(); - fileUrl = oss.getUrl(); - } - } catch (Exception e) { - log.warn("AI审核原件留存失败:{}", fileName, e); - } - - // 5. 落库 + /** 落库(结论解析、摘要、创建人) */ + private OaAiReview persist(Prepared p, String result, SysOssVo oss, String username) { OaAiReview entity = new OaAiReview(); - entity.setReviewType(reviewType); - entity.setFileName(fileName); - entity.setOssId(ossId); - entity.setFileUrl(fileUrl); - entity.setPosition(position); + entity.setReviewType(p.reviewType); + entity.setFileName(p.fileName); + if (oss != null) { + entity.setOssId(oss.getOssId()); + entity.setFileUrl(oss.getUrl()); + } + entity.setPosition(p.position); entity.setResultMd(result); entity.setSummary(buildSummary(result)); entity.setModel(miMoProps.getModel()); - if ("resume".equals(reviewType)) { + if ("resume".equals(p.reviewType)) { entity.setMatchScore(parseInt(SCORE_PATTERN, result, 100)); } else { entity.setRiskLevel(parseStr(RISK_PATTERN, result)); } + if (StringUtils.isNotBlank(username)) { + entity.setCreateBy(username); + } baseMapper.insert(entity); + return entity; + } - return baseMapper.selectVoById(entity.getId()); + private String currentUsername() { + try { + return LoginHelper.getLoginUser() != null ? LoginHelper.getLoginUser().getUsername() : null; + } catch (Exception e) { + return null; + } + } + + private SysOssVo uploadQuietly(MultipartFile file) { + try { + return ossService.upload(file, 0L); + } catch (Exception e) { + log.warn("AI审核原件留存失败:{}", file.getOriginalFilename(), e); + return null; + } + } + + private SysOssVo uploadQuietly(String fileName, byte[] bytes) { + File tmp = null; + try { + String suffix = fileName.contains(".") ? fileName.substring(fileName.lastIndexOf('.')) : ""; + tmp = File.createTempFile("aireview", suffix); + Files.write(tmp.toPath(), bytes); + return ossService.upload(tmp, fileName, 0L); + } catch (Exception e) { + log.warn("AI审核原件留存失败:{}", fileName, e); + return null; + } finally { + if (tmp != null && tmp.exists()) { + try { tmp.delete(); } catch (Exception ignored) {} + } + } + } + + /** 解析后的待审核数据 */ + private static class Prepared { + String reviewType; + String position; + String fileName; + byte[] bytes; + String system; + String userText; + List images; } private String truncate(String text) { diff --git a/ruoyi-ui/src/router/index.js b/ruoyi-ui/src/router/index.js index a72563d..d4bee6f 100644 --- a/ruoyi-ui/src/router/index.js +++ b/ruoyi-ui/src/router/index.js @@ -177,6 +177,12 @@ export const constantRoutes = [ name: "editMeetingMinutes", meta: { title: "编辑会议纪要", activeMenu: "/hint/meeting" }, }, + { + path: "aiReview/add", + component: () => import("@/views/oa/aiReview/add"), + name: "aiReviewAdd", + meta: { title: "新增审核", activeMenu: "/hint/aiReview" }, + }, { path: "aiReview/detail/:id(\\d+)", component: () => import("@/views/oa/aiReview/detail"), diff --git a/ruoyi-ui/src/views/oa/aiReview/add.vue b/ruoyi-ui/src/views/oa/aiReview/add.vue new file mode 100644 index 0000000..d1745d4 --- /dev/null +++ b/ruoyi-ui/src/views/oa/aiReview/add.vue @@ -0,0 +1,297 @@ +