会议纪要功能修复与改进

后端:
- 待办同步改走 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 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 10:12:48 +08:00
parent e5bfa0c78c
commit a9c9b8a5ea
5 changed files with 330 additions and 174 deletions

View File

@@ -18,7 +18,8 @@
<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" type="primary" icon="el-icon-document-checked"
:loading="saving" @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>
@@ -37,22 +38,22 @@
<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="搜索主题 / 地点"
<el-input v-model="historyQuery.keyword" size="mini" placeholder="搜索编号 / 主题 / 地点"
clearable prefix-icon="el-icon-search"
@keyup.enter.native="loadHistory" @clear="loadHistory" />
@keyup.enter.native="searchHistory" @clear="searchHistory" />
</div>
<div class="filter-row">
<project-select v-model="historyQuery.projectId" placeholder="项目筛选" clearable
size="mini" style="flex:1" @input="loadHistory" />
size="mini" style="flex:1" @input="searchHistory" />
<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" />
style="width:90px;margin-left:4px" @change="searchHistory">
<el-option v-for="t in dict.type.oa_meeting_type" :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" />
style="width:100%" @change="searchHistory" />
</div>
<div v-loading="historyLoading" class="hist-list">
@@ -64,15 +65,13 @@
@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>
<dict-tag :options="dict.type.oa_meeting_type" :value="m.meetingType" />
<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>
<span v-if="m.projectName" class="hc-proj">· {{ m.projectName }}</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>
@@ -106,33 +105,35 @@
</el-col>
<el-col :span="8">
<el-form-item label="项目">
<project-select v-model="form.projectId" placeholder="选择项目" clearable style="width:100%" />
<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-option v-for="t in dict.type.oa_meeting_type" :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-input v-model="form.subject" maxlength="200" placeholder="输入会议主题" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="会议地点">
<el-input v-model="form.location" placeholder="会议室 / 线上" />
<el-input v-model="form.location" maxlength="100" placeholder="会议室 / 线上" />
</el-form-item>
</el-col>
<el-col :span="24">
<el-col :span="8">
<el-form-item label="主持人">
<user-select :value="hostCsv" @input="onHostInput" />
<div class="help-tip">仅取首位为主持人如要换人请先点 ×</div>
<el-tag v-if="form.hostUserId" closable @close="clearHost">
{{ form.hostUserName || ('#' + form.hostUserId) }}
</el-tag>
<el-button type="text" @click="pickHost">{{ form.hostUserId ? '更换' : '点击选择' }}</el-button>
</el-form-item>
</el-col>
<el-col :span="24">
<el-col :span="16">
<el-form-item label="参会人员">
<user-select v-model="form.attendeeUserIds" />
</el-form-item>
@@ -172,7 +173,7 @@
<div class="sec-block">
<div class="sec-hd">
<span class="sec-num"></span> 待办事项
<span class="sec-tip">保存时按上方开关自动同步 OA 任务</span>
<span class="sec-tip">填了负责人和内容的待办保存时按上方开关同步 OA 任务并通知负责人</span>
<el-button type="text" icon="el-icon-plus" class="add-task" @click="addTask">添加待办</el-button>
</div>
@@ -181,11 +182,18 @@
<div class="task-line">
<div class="tf tf-assignee">
<label>负责人</label>
<user-select :value="t._assigneeCsv" @input="onTaskAssigneeChange(t, $event)" />
<div>
<el-tag v-if="t.assigneeUserId" size="small" closable @close="clearAssignee(t)">
{{ t.assigneeName || ('#' + t.assigneeUserId) }}
</el-tag>
<el-button type="text" size="mini" @click="pickAssignee(i)">
{{ t.assigneeUserId ? '更换' : '选择' }}
</el-button>
</div>
</div>
<div class="tf tf-content">
<label>任务内容</label>
<el-input v-model="t.content" size="mini" placeholder="任务描述..." />
<el-input v-model="t.content" size="mini" maxlength="200" placeholder="任务描述..." />
</div>
<div class="tf tf-deadline">
<label>截止日期</label>
@@ -195,7 +203,8 @@
<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-option v-for="o in dict.type.oa_meeting_task_status" :key="o.value"
:value="o.value" :label="o.label" />
</el-select>
</div>
<div class="tf tf-act">
@@ -209,6 +218,9 @@
</el-card>
</el-col>
</el-row>
<!-- 人员单选弹窗主持人 / 待办负责人共用 -->
<user-single-select ref="userPicker" v-model="userPickerVisible" @onSelected="onUserPicked" />
</div>
</template>
@@ -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 =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[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>`
`<tr><td>${esc(t.assigneeName || '未指派')}</td><td>${esc(t.content)}</td>` +
`<td>${esc(t.deadline || '-')}</td><td>${esc(this.dictLabel('oa_meeting_task_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>`
@@ -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}}</style></head><body>
<h1>德睿福成套设备有限公司</h1><div class="sub">会 议 纪 要</div>
<h1>德睿福成套设备有限公司</h1><div class="sub">会 议 纪 要 ${esc(d.meetingCode || '')}</div>
<div class="meta"><span>📅 ${esc(d.meetingDate)}</span>
<span>📝 ${esc(d.subject)}</span></div>
<div class="meta"><span>📍 ${esc(d.location || '-')}</span></div>
<span>📝 ${esc(d.subject)}</span>
<span>🏷 ${esc(this.dictLabel('oa_meeting_type', d.meetingType))}</span></div>
<div class="meta"><span>📍 ${esc(d.location || '-')}</span>
<span>🎤 主持:${esc(d.hostUserName || '-')}</span></div>
<div class="meta"><span>👥 参会:${esc(d.attendeeUserNames || '-')}</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>
@@ -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; }