@@ -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<OaMeetingMinutesVo> list(OaMeetingMinutesBo bo, PageQuery pageQuery) {
|
||||
return service.queryPageList(bo, pageQuery);
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public R<OaMeetingMinutesVo> getInfo(@NotNull @PathVariable Long id) {
|
||||
return R.ok(service.queryById(id));
|
||||
}
|
||||
|
||||
@Log(title = "会议纪要", businessType = BusinessType.INSERT)
|
||||
@RepeatSubmit
|
||||
@PostMapping
|
||||
public R<Void> add(@RequestBody OaMeetingMinutesBo bo) {
|
||||
return toAjax(service.insertByBo(bo));
|
||||
}
|
||||
|
||||
@Log(title = "会议纪要", businessType = BusinessType.UPDATE)
|
||||
@RepeatSubmit
|
||||
@PutMapping
|
||||
public R<Void> edit(@RequestBody OaMeetingMinutesBo bo) {
|
||||
return toAjax(service.updateByBo(bo));
|
||||
}
|
||||
|
||||
@Log(title = "会议纪要", businessType = BusinessType.DELETE)
|
||||
@DeleteMapping("/{ids}")
|
||||
public R<Void> remove(@NotEmpty @PathVariable Long[] ids) {
|
||||
return toAjax(service.deleteWithValidByIds(Arrays.asList(ids), true));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<OaMeetingMinutesMapper, OaMeetingMinutes, OaMeetingMinutesVo> {
|
||||
}
|
||||
@@ -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<OaMeetingMinutesVo> queryPageList(OaMeetingMinutesBo bo, PageQuery pageQuery);
|
||||
|
||||
List<OaMeetingMinutesVo> queryList(OaMeetingMinutesBo bo);
|
||||
|
||||
Boolean insertByBo(OaMeetingMinutesBo bo);
|
||||
|
||||
Boolean updateByBo(OaMeetingMinutesBo bo);
|
||||
|
||||
Boolean deleteWithValidByIds(Collection<Long> ids, Boolean isValid);
|
||||
}
|
||||
@@ -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<OaMeetingMinutesVo> queryPageList(OaMeetingMinutesBo bo, PageQuery pageQuery) {
|
||||
LambdaQueryWrapper<OaMeetingMinutes> lqw = buildQueryWrapper(bo);
|
||||
Page<OaMeetingMinutesVo> result = baseMapper.selectVoPage(pageQuery.build(), lqw);
|
||||
enrich(result.getRecords());
|
||||
return TableDataInfo.build(result);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<OaMeetingMinutesVo> queryList(OaMeetingMinutesBo bo) {
|
||||
List<OaMeetingMinutesVo> list = baseMapper.selectVoList(buildQueryWrapper(bo));
|
||||
enrich(list);
|
||||
return list;
|
||||
}
|
||||
|
||||
private LambdaQueryWrapper<OaMeetingMinutes> buildQueryWrapper(OaMeetingMinutesBo bo) {
|
||||
LambdaQueryWrapper<OaMeetingMinutes> 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<OaMeetingMinutesVo> list) {
|
||||
if (list == null || list.isEmpty()) return;
|
||||
Set<Long> projectIds = new HashSet<>();
|
||||
Set<Long> 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<Long, SysOaProject> pMap = projectIds.isEmpty() ? Collections.emptyMap()
|
||||
: projectMapper.selectList(new QueryWrapper<SysOaProject>().in("project_id", projectIds))
|
||||
.stream().collect(Collectors.toMap(SysOaProject::getProjectId, p -> p, (a, b) -> a));
|
||||
Map<Long, SysUser> uMap = userIds.isEmpty() ? Collections.emptyMap()
|
||||
: userMapper.selectList(new QueryWrapper<SysUser>().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<Long> attIds = parseLongCsv(v.getAttendeeUserIds());
|
||||
if (!attIds.isEmpty()) {
|
||||
List<String> 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<Long> parseLongCsv(String csv) {
|
||||
if (StringUtils.isBlank(csv)) return Collections.emptyList();
|
||||
List<Long> 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<Long> ids, Boolean isValid) {
|
||||
return baseMapper.deleteBatchIds(ids) > 0;
|
||||
}
|
||||
}
|
||||
21
ruoyi-ui/src/api/oa/meetingMinutes.js
Normal file
21
ruoyi-ui/src/api/oa/meetingMinutes.js
Normal file
@@ -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' })
|
||||
}
|
||||
683
ruoyi-ui/src/views/oa/meeting/index.vue
Normal file
683
ruoyi-ui/src/views/oa/meeting/index.vue
Normal file
@@ -0,0 +1,683 @@
|
||||
<template>
|
||||
<div class="meeting-page">
|
||||
<!-- 顶部操作栏 -->
|
||||
<el-card shadow="never" class="topbar">
|
||||
<div class="topbar-inner">
|
||||
<div class="brand">
|
||||
<i class="el-icon-microphone" />
|
||||
<span class="brand-title">智能会议纪要</span>
|
||||
<el-tag size="mini" :type="statusType" effect="dark" class="brand-status">
|
||||
<span class="dot" :class="statusType" />{{ statusText }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<el-checkbox v-model="form.syncTask" :true-label="1" :false-label="0" class="sync-chk">
|
||||
<span style="font-size:12px">保存时同步生成 OA 任务</span>
|
||||
</el-checkbox>
|
||||
<el-button size="small" icon="el-icon-edit-outline" @click="cmdNew">新建</el-button>
|
||||
<el-button size="small" :type="isRecording ? 'danger' : 'warning'"
|
||||
:icon="isRecording ? 'el-icon-video-pause' : 'el-icon-microphone'"
|
||||
@click="cmdRecord">{{ isRecording ? '停止录音' : '语音录入' }}</el-button>
|
||||
<el-button size="small" type="primary" icon="el-icon-document-checked" @click="cmdSave">保存</el-button>
|
||||
<el-button size="small" icon="el-icon-download" @click="cmdExport">导出</el-button>
|
||||
<el-button size="small" icon="el-icon-printer" @click="cmdPrint">打印</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<el-alert v-if="protoWarn" type="warning" :closable="false" show-icon class="proto-warn">
|
||||
语音识别需通过 <b>https</b> 或 <b>localhost</b> 访问。当前协议不支持,录音按钮将无法工作。
|
||||
</el-alert>
|
||||
</el-card>
|
||||
|
||||
<el-row :gutter="10" class="body-row">
|
||||
<!-- 左:历史纪要列表 -->
|
||||
<el-col :span="7" :xs="24" class="left-col">
|
||||
<el-card shadow="never" class="side-card">
|
||||
<div slot="header" class="card-hd">
|
||||
<span><i class="el-icon-notebook-2" /> 历史纪要 <em class="hd-count">({{ historyTotal }})</em></span>
|
||||
<el-button type="text" icon="el-icon-plus" @click="cmdNew">新建</el-button>
|
||||
</div>
|
||||
<div class="filter-row">
|
||||
<el-input v-model="historyQuery.keyword" size="mini" placeholder="搜索主题 / 地点"
|
||||
clearable prefix-icon="el-icon-search"
|
||||
@keyup.enter.native="loadHistory" @clear="loadHistory" />
|
||||
</div>
|
||||
<div class="filter-row">
|
||||
<project-select v-model="historyQuery.projectId" placeholder="项目筛选" clearable
|
||||
size="mini" style="flex:1" @input="loadHistory" />
|
||||
<el-select v-model="historyQuery.meetingType" size="mini" clearable placeholder="类型"
|
||||
style="width:90px;margin-left:4px" @change="loadHistory">
|
||||
<el-option v-for="t in meetingTypes" :key="t.value" :value="t.value" :label="t.label" />
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="filter-row">
|
||||
<el-date-picker v-model="historyQuery.dateRange" type="daterange" size="mini" range-separator="~"
|
||||
start-placeholder="开始" end-placeholder="结束" value-format="yyyy-MM-dd"
|
||||
style="width:100%" @change="loadHistory" />
|
||||
</div>
|
||||
|
||||
<div v-loading="historyLoading" class="hist-list">
|
||||
<div v-if="historyList.length === 0 && !historyLoading" class="hist-empty">
|
||||
<i class="el-icon-document" /><div>暂无会议纪要</div>
|
||||
</div>
|
||||
<div v-for="m in historyList" :key="m.id"
|
||||
class="hist-card" :class="{ sel: form.id === m.id }"
|
||||
@click="loadMinutes(m.id)">
|
||||
<div class="hc-top">
|
||||
<span class="hc-code">{{ m.meetingCode }}</span>
|
||||
<el-tag size="mini" :type="typeTag(m.meetingType)" effect="plain">
|
||||
{{ typeLabel(m.meetingType) }}
|
||||
</el-tag>
|
||||
<el-button type="text" icon="el-icon-delete" class="hc-del"
|
||||
@click.stop="removeMinutes(m)" />
|
||||
</div>
|
||||
<div class="hc-subject">{{ m.subject }}</div>
|
||||
<div class="hc-line"><i class="el-icon-date" /> {{ m.meetingDate }}
|
||||
<span v-if="m.projectNum" class="hc-proj">· {{ m.projectNum }}</span>
|
||||
</div>
|
||||
<div v-if="m.hostUserName" class="hc-line"><i class="el-icon-s-custom" /> 主持:{{ m.hostUserName }}</div>
|
||||
<div v-if="m.location" class="hc-line"><i class="el-icon-location-outline" /> {{ m.location }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<pagination v-show="historyTotal > 0" :total="historyTotal"
|
||||
:page.sync="historyQuery.pageNum" :limit.sync="historyQuery.pageSize"
|
||||
:page-sizes="[10, 20, 50]" small
|
||||
@pagination="loadHistory" />
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<!-- 右:编辑器 -->
|
||||
<el-col :span="17" :xs="24" class="right-col">
|
||||
<el-card shadow="never" class="editor-card">
|
||||
<div slot="header" class="card-hd">
|
||||
<span><i class="el-icon-edit-outline" />
|
||||
{{ form.id ? ('编辑:' + (form.subject || '未命名')) : '新建会议纪要' }}
|
||||
</span>
|
||||
<span v-if="form.meetingCode" class="hd-tail-code">{{ form.meetingCode }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 元数据 -->
|
||||
<el-form :model="form" label-width="80px" size="small" class="meta-form">
|
||||
<el-row :gutter="10">
|
||||
<el-col :span="8">
|
||||
<el-form-item label="会议日期" required>
|
||||
<el-date-picker v-model="form.meetingDate" type="date" value-format="yyyy-MM-dd"
|
||||
style="width:100%" placeholder="选择日期" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="项目">
|
||||
<project-select v-model="form.projectId" placeholder="选择项目" clearable style="width:100%" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="会议类型">
|
||||
<el-select v-model="form.meetingType" style="width:100%">
|
||||
<el-option v-for="t in meetingTypes" :key="t.value" :value="t.value" :label="t.label" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="16">
|
||||
<el-form-item label="会议主题" required>
|
||||
<el-input v-model="form.subject" placeholder="输入会议主题" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="会议地点">
|
||||
<el-input v-model="form.location" placeholder="会议室 / 线上" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="24">
|
||||
<el-form-item label="主持人">
|
||||
<user-select :value="hostCsv" @input="onHostInput" />
|
||||
<div class="help-tip">仅取首位为主持人;如要换人请先点 ×。</div>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="24">
|
||||
<el-form-item label="参会人员">
|
||||
<user-select v-model="form.attendeeUserIds" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-form>
|
||||
|
||||
<!-- 语音面板 -->
|
||||
<el-card v-if="isRecording || voiceFinalText" class="voice-card" shadow="never">
|
||||
<div class="voice-hd">
|
||||
<span class="live-dot" :class="{ blink: isRecording }" />
|
||||
<span class="voice-title">{{ isRecording ? '实时语音识别中 — 请对着麦克风讲话' : '语音识别结果' }}</span>
|
||||
</div>
|
||||
<div class="voice-interim">{{ voiceInterim }}</div>
|
||||
<div class="voice-final">{{ voiceFinalText || '(无内容)' }}</div>
|
||||
<div class="voice-actions">
|
||||
<el-button size="mini" icon="el-icon-plus" @click="insertVoiceTo('topic')">插入到 议题</el-button>
|
||||
<el-button size="mini" icon="el-icon-plus" @click="insertVoiceTo('discussion')">插入到 讨论</el-button>
|
||||
<el-button size="mini" icon="el-icon-plus" @click="insertVoiceTo('decision')">插入到 决议</el-button>
|
||||
<el-button size="mini" type="danger" icon="el-icon-delete" plain @click="clearVoice">清空</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 4 结构化区块 -->
|
||||
<div class="sec-block">
|
||||
<div class="sec-hd"><span class="sec-num">一</span> 会议议题</div>
|
||||
<el-input type="textarea" :rows="4" v-model="form.topic" placeholder="1. 2. 3." />
|
||||
</div>
|
||||
<div class="sec-block">
|
||||
<div class="sec-hd"><span class="sec-num">二</span> 讨论内容</div>
|
||||
<el-input type="textarea" :rows="5" v-model="form.discussion" placeholder="记录讨论要点和各方意见..." />
|
||||
</div>
|
||||
<div class="sec-block">
|
||||
<div class="sec-hd"><span class="sec-num">三</span> 决议事项</div>
|
||||
<el-input type="textarea" :rows="4" v-model="form.decision" placeholder="记录会议达成的决议和结论..." />
|
||||
</div>
|
||||
<div class="sec-block">
|
||||
<div class="sec-hd">
|
||||
<span class="sec-num">四</span> 待办事项
|
||||
<span class="sec-tip">保存时按上方开关自动同步到 OA 任务</span>
|
||||
<el-button type="text" icon="el-icon-plus" class="add-task" @click="addTask">添加待办</el-button>
|
||||
</div>
|
||||
|
||||
<div v-if="form.tasks.length === 0" class="task-empty">尚未添加待办</div>
|
||||
<div v-for="(t, i) in form.tasks" :key="i" class="task-row">
|
||||
<div class="task-line">
|
||||
<div class="tf tf-assignee">
|
||||
<label>负责人</label>
|
||||
<user-select :value="t._assigneeCsv" @input="onTaskAssigneeChange(t, $event)" />
|
||||
</div>
|
||||
<div class="tf tf-content">
|
||||
<label>任务内容</label>
|
||||
<el-input v-model="t.content" size="mini" placeholder="任务描述..." />
|
||||
</div>
|
||||
<div class="tf tf-deadline">
|
||||
<label>截止日期</label>
|
||||
<el-date-picker v-model="t.deadline" type="date" size="mini"
|
||||
value-format="yyyy-MM-dd" style="width:100%" placeholder="日期" />
|
||||
</div>
|
||||
<div class="tf tf-status">
|
||||
<label>状态</label>
|
||||
<el-select v-model="t.status" size="mini" style="width:100%">
|
||||
<el-option v-for="o in taskStatusOpts" :key="o.value" :value="o.value" :label="o.label" />
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="tf tf-act">
|
||||
<el-tag v-if="t.taskId" size="mini" type="success">已同步</el-tag>
|
||||
<el-button type="text" icon="el-icon-delete" style="color:#f56c6c"
|
||||
@click="removeTask(i)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {
|
||||
listMeetingMinutes, getMeetingMinutes, addMeetingMinutes,
|
||||
updateMeetingMinutes, delMeetingMinutes
|
||||
} from '@/api/oa/meetingMinutes'
|
||||
import UserSelect from '@/components/UserSelect'
|
||||
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 emptyForm () {
|
||||
return {
|
||||
id: null,
|
||||
meetingCode: '',
|
||||
meetingDate: new Date().toISOString().slice(0, 10),
|
||||
projectId: null,
|
||||
meetingType: 'tech',
|
||||
subject: '',
|
||||
location: '',
|
||||
hostUserId: null,
|
||||
attendeeUserIds: '',
|
||||
topic: '',
|
||||
discussion: '',
|
||||
decision: '',
|
||||
tasks: [],
|
||||
syncTask: 1
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
name: 'OaMeeting',
|
||||
components: { UserSelect, ProjectSelect },
|
||||
data () {
|
||||
return {
|
||||
meetingTypes: MEETING_TYPES,
|
||||
taskStatusOpts: TASK_STATUS,
|
||||
|
||||
statusType: 'success',
|
||||
statusText: '就绪',
|
||||
protoWarn: false,
|
||||
|
||||
form: emptyForm(),
|
||||
|
||||
historyList: [],
|
||||
historyTotal: 0,
|
||||
historyLoading: false,
|
||||
historyQuery: {
|
||||
pageNum: 1, pageSize: 20,
|
||||
keyword: '', meetingType: '', projectId: null, dateRange: []
|
||||
},
|
||||
|
||||
recognition: null,
|
||||
isRecording: false,
|
||||
voiceFinalText: '',
|
||||
voiceInterim: '',
|
||||
|
||||
/** 主持人 CSV — 直接绑 UserSelect。@input 单向同步到 form.hostUserId(取首位) */
|
||||
hostCsv: ''
|
||||
}
|
||||
},
|
||||
created () {
|
||||
this.checkProtocol()
|
||||
this.loadHistory()
|
||||
},
|
||||
beforeDestroy () { this.stopRecording() },
|
||||
methods: {
|
||||
setStatus (text, type = 'success') { this.statusText = text; this.statusType = type },
|
||||
checkProtocol () {
|
||||
const host = location.hostname
|
||||
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] || '' },
|
||||
|
||||
/** 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
|
||||
},
|
||||
|
||||
// ============ 待办 ============
|
||||
addTask () {
|
||||
this.form.tasks.push({
|
||||
assigneeUserId: null, _assigneeCsv: '',
|
||||
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]) : ''
|
||||
},
|
||||
|
||||
// ============ 新建 / 保存 ============
|
||||
cmdNew () {
|
||||
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,
|
||||
content: t.content,
|
||||
deadline: t.deadline,
|
||||
status: t.status,
|
||||
taskId: t.taskId || null
|
||||
}))
|
||||
const payload = { ...this.form, tasksJson: JSON.stringify(cleanTasks) }
|
||||
delete payload.tasks
|
||||
try {
|
||||
if (this.form.id) await updateMeetingMinutes(payload)
|
||||
else await addMeetingMinutes(payload)
|
||||
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)
|
||||
} catch (err) {
|
||||
this.$modal.msgError(err.msg || '保存失败')
|
||||
}
|
||||
},
|
||||
|
||||
// ============ 历史 ============
|
||||
async loadHistory () {
|
||||
this.historyLoading = true
|
||||
try {
|
||||
const q = {
|
||||
pageNum: this.historyQuery.pageNum,
|
||||
pageSize: this.historyQuery.pageSize,
|
||||
keyword: this.historyQuery.keyword,
|
||||
meetingType: this.historyQuery.meetingType,
|
||||
projectId: this.historyQuery.projectId
|
||||
}
|
||||
const r = this.historyQuery.dateRange
|
||||
if (r && r.length === 2) { q.dateFrom = r[0]; q.dateTo = r[1] }
|
||||
const res = await listMeetingMinutes(q)
|
||||
this.historyList = res.rows || []
|
||||
this.historyTotal = res.total || 0
|
||||
} finally { this.historyLoading = false }
|
||||
},
|
||||
async loadMinutes (id, silent) {
|
||||
const res = await getMeetingMinutes(id)
|
||||
const m = res.data
|
||||
if (!m) return
|
||||
this.form = {
|
||||
id: m.id,
|
||||
meetingCode: m.meetingCode,
|
||||
meetingDate: m.meetingDate,
|
||||
projectId: m.projectId,
|
||||
meetingType: m.meetingType || 'other',
|
||||
subject: m.subject || '',
|
||||
location: m.location || '',
|
||||
hostUserId: m.hostUserId || null,
|
||||
attendeeUserIds: m.attendeeUserIds || '',
|
||||
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) {
|
||||
if (!s) return []
|
||||
try {
|
||||
const a = JSON.parse(s)
|
||||
if (!Array.isArray(a)) return []
|
||||
return a.map(t => ({
|
||||
assigneeUserId: t.assigneeUserId || null,
|
||||
_assigneeCsv: t.assigneeUserId ? String(t.assigneeUserId) : '',
|
||||
content: t.content || '',
|
||||
deadline: t.deadline || '',
|
||||
status: t.status || 'pending',
|
||||
taskId: t.taskId || null
|
||||
}))
|
||||
} catch (e) { return [] }
|
||||
},
|
||||
removeMinutes (m) {
|
||||
this.$modal.confirm(`确认删除「${m.subject}」?此操作不可恢复。`).then(async () => {
|
||||
await delMeetingMinutes(m.id)
|
||||
this.$modal.msgSuccess('已删除')
|
||||
if (this.form.id === m.id) this.form = emptyForm()
|
||||
await this.loadHistory()
|
||||
}).catch(() => {})
|
||||
},
|
||||
|
||||
// ============ 语音识别 ============
|
||||
cmdRecord () {
|
||||
if (this.isRecording) return this.stopRecording()
|
||||
this.checkProtocol()
|
||||
if (this.protoWarn) return this.$modal.msgError('需 https / localhost 才能录音')
|
||||
const SR = window.SpeechRecognition || window.webkitSpeechRecognition
|
||||
if (!SR) return this.$modal.msgError('浏览器不支持语音 API,请用 Chrome / Edge')
|
||||
if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
|
||||
navigator.mediaDevices.getUserMedia({ audio: true })
|
||||
.then(() => this.startRecording(SR))
|
||||
.catch(e => this.$modal.msgError('麦克风权限被拒绝:' + e.message))
|
||||
} else { this.startRecording(SR) }
|
||||
},
|
||||
startRecording (SR) {
|
||||
const r = new SR()
|
||||
r.lang = 'zh-CN'; r.interimResults = true; r.continuous = true; r.maxAlternatives = 1
|
||||
r.onstart = () => {
|
||||
this.isRecording = true; this.voiceFinalText = ''; this.voiceInterim = ''
|
||||
this.setStatus('录音中...', 'danger')
|
||||
}
|
||||
r.onresult = (event) => {
|
||||
let interim = ''; let final = ''
|
||||
for (let i = event.resultIndex; i < event.results.length; i++) {
|
||||
const rr = event.results[i]
|
||||
if (rr.isFinal) final += rr[0].transcript; else interim += rr[0].transcript
|
||||
}
|
||||
if (final) this.voiceFinalText += final
|
||||
this.voiceInterim = interim
|
||||
}
|
||||
r.onerror = (event) => {
|
||||
const msgMap = {
|
||||
'not-allowed': '麦克风权限被拒绝', 'audio-capture': '未检测到麦克风',
|
||||
'network': '网络错误', 'service-not-allowed': '语音识别服务不可用',
|
||||
'no-speech': '', 'aborted': ''
|
||||
}
|
||||
const msg = msgMap[event.error] || ('语音识别错误:' + event.error)
|
||||
if (msg) this.$modal.msgError(msg)
|
||||
if (event.error !== 'no-speech' && event.error !== 'aborted') this.stopRecording()
|
||||
}
|
||||
r.onend = () => {
|
||||
if (this.isRecording) {
|
||||
setTimeout(() => {
|
||||
if (this.isRecording && this.recognition) {
|
||||
try { this.recognition.start() } catch (e) {}
|
||||
}
|
||||
}, 400)
|
||||
} else { this.setStatus('就绪', 'success') }
|
||||
}
|
||||
this.recognition = r
|
||||
try { r.start() } catch (e) {
|
||||
this.$modal.msgError('启动失败:' + e.message); this.stopRecording()
|
||||
}
|
||||
},
|
||||
stopRecording () {
|
||||
this.isRecording = false
|
||||
if (this.recognition) {
|
||||
try { this.recognition.stop() } catch (e) {}
|
||||
this.recognition = null
|
||||
}
|
||||
},
|
||||
insertVoiceTo (sec) {
|
||||
const txt = this.voiceFinalText.trim()
|
||||
if (!txt) return this.$modal.msgError('没有可插入的语音内容')
|
||||
this.form[sec] = (this.form[sec] ? this.form[sec] + '\n' : '') + txt
|
||||
this.clearVoice()
|
||||
this.$modal.msgSuccess('已插入')
|
||||
},
|
||||
clearVoice () { this.voiceFinalText = ''; this.voiceInterim = '' },
|
||||
|
||||
// ============ 导出 / 打印 ============
|
||||
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.subject)
|
||||
lines.push('地点: ' + (d.location || '-'))
|
||||
lines.push('='.repeat(50))
|
||||
lines.push('')
|
||||
lines.push('一、会议议题'); lines.push('-'.repeat(30)); lines.push(d.topic || '(无)'); lines.push('')
|
||||
lines.push('二、讨论内容'); lines.push('-'.repeat(30)); lines.push(d.discussion || '(无)'); lines.push('')
|
||||
lines.push('三、决议事项'); lines.push('-'.repeat(30)); lines.push(d.decision || '(无)'); lines.push('')
|
||||
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))
|
||||
})
|
||||
} else { lines.push('(无)') }
|
||||
lines.push(''); lines.push('='.repeat(50))
|
||||
lines.push('导出时间: ' + new Date().toLocaleString('zh-CN'))
|
||||
const blob = new Blob(['' + lines.join('\n')], { type: 'text/plain;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url; a.download = '会议纪要_' + d.meetingDate + '_' + d.subject + '.txt'; a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
this.$modal.msgSuccess('已导出')
|
||||
},
|
||||
cmdPrint () {
|
||||
if (!this.form.subject) return this.$modal.msgError('无内容')
|
||||
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 =>
|
||||
`<tr><td>#${esc(t.assigneeUserId || '-')}</td><td>${esc(t.content)}</td>` +
|
||||
`<td>${esc(t.deadline || '-')}</td><td>${esc(statusMap[t.status] || t.status)}</td></tr>`
|
||||
).join('')
|
||||
taskHtml = `<table border="1" cellpadding="6" cellspacing="0" style="border-collapse:collapse;width:100%">
|
||||
<tr style="background:#eee"><th>负责人</th><th>任务内容</th><th>截止</th><th>状态</th></tr>${rows}</table>`
|
||||
}
|
||||
const w = window.open('', '', 'width=800,height=700')
|
||||
w.document.write(
|
||||
`<!DOCTYPE html><html><head><meta charset="UTF-8"><title>会议纪要</title>
|
||||
<style>body{font-family:"Microsoft YaHei",sans-serif;padding:40px;max-width:760px;margin:0 auto;line-height:1.8}
|
||||
h1{text-align:center;font-size:20px}.sub{text-align:center;color:#888;margin-bottom:20px}
|
||||
.meta{font-size:13px;margin-bottom:18px;border-bottom:1px solid #ddd;padding-bottom:10px}
|
||||
.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}}</style></head><body>
|
||||
<h1>德睿福成套设备有限公司</h1><div class="sub">会 议 纪 要</div>
|
||||
<div class="meta"><span>📅 ${esc(d.meetingDate)}</span>
|
||||
<span>📝 ${esc(d.subject)}</span></div>
|
||||
<div class="meta"><span>📍 ${esc(d.location || '-')}</span></div>
|
||||
<div class="sect">一、会议议题</div><div class="body">${esc(d.topic || '(无)')}</div>
|
||||
<div class="sect">二、讨论内容</div><div class="body">${esc(d.discussion || '(无)')}</div>
|
||||
<div class="sect">三、决议事项</div><div class="body">${esc(d.decision || '(无)')}</div>
|
||||
<div class="sect">四、待办事项</div><div class="body">${taskHtml}</div>
|
||||
</body></html>`
|
||||
)
|
||||
w.document.close()
|
||||
setTimeout(() => w.print(), 400)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.meeting-page { padding: 8px; }
|
||||
.topbar { margin-bottom: 8px; ::v-deep .el-card__body { padding: 10px 14px; } }
|
||||
.topbar-inner { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||
.brand { display: flex; align-items: center; gap: 8px;
|
||||
.el-icon-microphone { color: #409eff; font-size: 18px; }
|
||||
.brand-title { font-weight: 600; font-size: 15px; color: #303133; }
|
||||
}
|
||||
.brand-status .dot {
|
||||
display: inline-block; width: 6px; height: 6px; border-radius: 50%;
|
||||
background: #67c23a; margin-right: 5px; vertical-align: middle;
|
||||
&.danger { background: #f56c6c; animation: blink 1s infinite; }
|
||||
&.warning { background: #e6a23c; }
|
||||
}
|
||||
.actions { display: flex; align-items: center; gap: 6px; }
|
||||
.sync-chk { margin-right: 6px; }
|
||||
.proto-warn { margin-top: 8px; }
|
||||
|
||||
.body-row { ::v-deep .el-card__body { padding: 8px 12px; } }
|
||||
|
||||
.side-card {
|
||||
::v-deep .el-card__header { padding: 8px 12px; }
|
||||
}
|
||||
.card-hd {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
font-size: 13px; color: #303133; font-weight: 600;
|
||||
i { margin-right: 4px; color: #409eff; }
|
||||
.hd-count { font-style: normal; color: #909399; font-weight: normal; }
|
||||
.hd-tail-code { font-family: monospace; font-size: 12px; color: #909399; font-weight: normal; }
|
||||
}
|
||||
.filter-row { margin-bottom: 6px; display: flex; align-items: center; }
|
||||
|
||||
.hist-list {
|
||||
margin-top: 4px; max-height: calc(100vh - 360px); overflow-y: auto;
|
||||
display: flex; flex-direction: column; gap: 4px;
|
||||
}
|
||||
.hist-empty {
|
||||
text-align: center; color: #c0c4cc; padding: 30px 0; font-size: 12px;
|
||||
i { font-size: 32px; opacity: 0.4; display: block; margin-bottom: 6px; }
|
||||
}
|
||||
.hist-card {
|
||||
background: #fafbfc; border: 1px solid #ebeef5; border-radius: 4px;
|
||||
padding: 6px 10px; cursor: pointer; transition: 0.15s; font-size: 12px;
|
||||
&:hover { border-color: #409eff; background: #fff; }
|
||||
&.sel { border-color: #409eff; background: #ecf5ff; }
|
||||
.hc-top { display: flex; align-items: center; gap: 6px; margin-bottom: 2px;
|
||||
.hc-code { font-family: monospace; color: #409eff; font-weight: 600; font-size: 11px; }
|
||||
.hc-del { padding: 0; color: #c0c4cc; margin-left: auto;
|
||||
&:hover { color: #f56c6c; }
|
||||
}
|
||||
}
|
||||
.hc-subject { font-weight: 600; font-size: 13px; color: #303133; margin: 2px 0;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.hc-line { font-size: 11px; color: #909399; line-height: 1.6;
|
||||
i { margin-right: 3px; }
|
||||
.hc-proj { color: #4db8c9; margin-left: 4px; }
|
||||
}
|
||||
}
|
||||
|
||||
.editor-card { ::v-deep .el-card__header { padding: 8px 14px; } }
|
||||
|
||||
.meta-form {
|
||||
::v-deep .el-form-item { margin-bottom: 8px; }
|
||||
.help-tip { color: #909399; font-size: 11px; line-height: 1.4; }
|
||||
}
|
||||
|
||||
.voice-card {
|
||||
border-left: 3px solid #f56c6c; margin: 6px 0 10px;
|
||||
::v-deep .el-card__body { padding: 8px 12px; }
|
||||
.voice-hd { display: flex; align-items: center; gap: 8px; margin-bottom: 4px;
|
||||
.live-dot { width: 8px; height: 8px; border-radius: 50%; background: #f56c6c;
|
||||
&.blink { animation: blink 0.7s infinite; }
|
||||
}
|
||||
.voice-title { font-size: 12px; font-weight: 600; color: #e6a23c; }
|
||||
}
|
||||
.voice-interim { font-size: 12px; color: #909399; font-style: italic; min-height: 16px; }
|
||||
.voice-final { font-size: 13px; color: #303133; white-space: pre-wrap;
|
||||
max-height: 100px; overflow-y: auto; padding-top: 4px; margin-top: 4px;
|
||||
border-top: 1px solid #ebeef5;
|
||||
}
|
||||
.voice-actions { display: flex; gap: 6px; margin-top: 6px; flex-wrap: wrap; }
|
||||
}
|
||||
|
||||
.sec-block { margin-top: 10px; }
|
||||
.sec-hd {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
padding: 6px 10px; background: #f4f7fc; border-radius: 4px 4px 0 0;
|
||||
font-size: 13px; font-weight: 600; color: #409eff;
|
||||
.sec-num { display: inline-block; min-width: 18px; height: 18px; line-height: 18px;
|
||||
text-align: center; background: #409eff; color: #fff; border-radius: 3px; font-size: 11px; }
|
||||
.sec-tip { color: #909399; font-weight: normal; font-size: 11px; margin-left: 8px; }
|
||||
.add-task { margin-left: auto; }
|
||||
}
|
||||
|
||||
.task-empty {
|
||||
border: 1px dashed #dcdfe6; border-top: none;
|
||||
padding: 14px; text-align: center; color: #c0c4cc; font-size: 12px;
|
||||
border-radius: 0 0 4px 4px;
|
||||
}
|
||||
.task-row {
|
||||
border: 1px solid #ebeef5; border-top: none; padding: 8px 10px;
|
||||
background: #fff;
|
||||
&:last-child { border-radius: 0 0 4px 4px; }
|
||||
}
|
||||
.task-line {
|
||||
display: grid;
|
||||
grid-template-columns: 200px 1fr 140px 110px 100px;
|
||||
gap: 8px; align-items: start;
|
||||
.tf {
|
||||
label { display: block; font-size: 11px; color: #909399; margin-bottom: 2px; }
|
||||
&.tf-act { display: flex; flex-direction: column; align-items: center; gap: 4px;
|
||||
padding-top: 16px; }
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
|
||||
</style>
|
||||
62
sql/oa_meeting.sql
Normal file
62
sql/oa_meeting.sql
Normal file
@@ -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(), '');
|
||||
Reference in New Issue
Block a user