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] =?UTF-8?q?=E4=BC=9A=E8=AE=AE=E7=BA=AA=E8=A6=81=E5=8A=9F?= =?UTF-8?q?=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;