feat(meeting): 会议详情页参会人 CRUD + BizProjectPlan audit 字段

- 后端: BizMeetingAttendee 增 list/add/update/delete 4 端点
  按手机号定位 sys_user (无则新建, 用户名=密码=phone, role_type='doctor')
  Mapper: insertWithProfile (一次写完整档案) + deleteByPrimaryKey
  Service: insertByPhoneWithProfile (重复参会人抛友好异常)
- #4 配套: BizProjectPlan.auditOpinion/auditBy/auditTime + 关联项目名
  BizProjectPlan.projectName (LEFT JOIN biz_project) 用于 #4 通知 content
- 前端 MeetingDetail.vue: 劳务材料 tab 内, 劳务明细表 file-row 下方
  直接出表 (无表题), 含姓名/手机/单位/科室/职称/劳务费/签字状态/操作
  新增/编辑 dialog 共用, 删除走 ElMessageBox.confirm 二次确认
- 触发 #5: 新参会人 POST 时也发会议邀请通知 (与 BizMeetingController.add 一致)
This commit is contained in:
郭庆泰
2026-08-22 15:53:06 +08:00
parent 86e85d02cb
commit 2cef296b1e
10 changed files with 522 additions and 13 deletions
+211 -1
View File
@@ -70,6 +70,46 @@
<div v-if="r.hint" class="file-hint">{{ r.hint }}</div>
</div>
</div>
<!-- 参会人管理 (按用户要求, 放在"劳务明细表"那行下面, 无表题直接出表) -->
<div class="attendee-section">
<div class="attendee-toolbar">
<el-button type="primary" size="small" :loading="attendeeLoading" @click="openAttendeeDialog()">+ 新增参会人</el-button>
<span class="toolbar-hint">按手机号定位; 手机号未注册将自动建 sys_user (用户名=密码=手机号)</span>
</div>
<el-table :data="attendeeRows" v-loading="attendeeLoading" border size="small" style="width:100%">
<el-table-column prop="name" label="姓名" min-width="100" />
<el-table-column prop="phone" label="手机号" width="120" />
<el-table-column prop="workUnit" label="工作单位" min-width="160" show-overflow-tooltip />
<el-table-column prop="department" label="科室" min-width="100" show-overflow-tooltip />
<el-table-column prop="title" label="职称" min-width="100" show-overflow-tooltip />
<el-table-column prop="laborForm" label="劳务形式" min-width="80" show-overflow-tooltip />
<el-table-column label="税前劳务费" width="110" align="right">
<template #default="{ row }">{{ row.feePreTax != null ? '¥ ' + Number(row.feePreTax).toFixed(2) : '-' }}</template>
</el-table-column>
<el-table-column label="税金" width="80" align="right">
<template #default="{ row }">{{ row.tax != null ? '¥ ' + Number(row.tax).toFixed(2) : '-' }}</template>
</el-table-column>
<el-table-column label="实发劳务费" width="110" align="right">
<template #default="{ row }">{{ row.fee != null ? '¥ ' + Number(row.fee).toFixed(2) : '-' }}</template>
</el-table-column>
<el-table-column label="签字" width="70" align="center">
<template #default="{ row }">
<el-tag v-if="row.handsign" type="success" size="small">已签</el-tag>
<el-tag v-else type="info" size="small">未签</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="140" align="center" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="openAttendeeDialog(row)">编辑</el-button>
<el-button link type="danger" size="small" @click="confirmDeleteAttendee(row)">删除</el-button>
</template>
</el-table-column>
<template #empty>
<span class="text-muted">暂无参会人, 点击右上"新增参会人"按手机号添加</span>
</template>
</el-table>
</div>
</el-tab-pane>
<el-tab-pane label="劳务凭证" name="laborVoucher">
<div class="file-list">
@@ -263,6 +303,44 @@
</template>
</el-dialog>
<!-- 参会人 dialog (新增/编辑通用) -->
<el-dialog v-model="attendeeDialog.show" :title="attendeeDialog.title" width="520px" @closed="resetAttendeeForm">
<el-form :model="attendeeDialog.form" label-width="100px">
<el-form-item label="手机号" required>
<el-input v-model="attendeeDialog.form.phone" :disabled="attendeeDialog.editing" placeholder="11位手机号" maxlength="11" />
</el-form-item>
<el-form-item label="姓名">
<el-input v-model="attendeeDialog.form.name" placeholder="未注册手机号将以此为昵称新建账号" />
</el-form-item>
<el-form-item label="工作单位">
<el-input v-model="attendeeDialog.form.workUnit" />
</el-form-item>
<el-form-item label="科室">
<el-input v-model="attendeeDialog.form.department" />
</el-form-item>
<el-form-item label="职称">
<el-input v-model="attendeeDialog.form.title" />
</el-form-item>
<el-form-item label="劳务形式">
<el-input v-model="attendeeDialog.form.laborForm" placeholder="如: 授课/主持/评审" />
</el-form-item>
<el-form-item label="税前劳务费">
<el-input-number v-model="attendeeDialog.form.feePreTax" :precision="2" :min="0" controls-position="right" style="width:100%" />
</el-form-item>
<el-form-item label="税金">
<el-input-number v-model="attendeeDialog.form.tax" :precision="2" :min="0" controls-position="right" style="width:100%" />
</el-form-item>
<el-form-item label="实发劳务费">
<el-input-number v-model="attendeeDialog.form.fee" :precision="2" :min="0" controls-position="right" style="width:100%" />
</el-form-item>
<div v-if="attendeeDialog.editing" class="form-hint">编辑时不能改手机号; 若需改手机号, 请删除后重新添加</div>
</el-form>
<template #footer>
<el-button @click="attendeeDialog.show = false">取消</el-button>
<el-button type="primary" :loading="attendeeDialog.saving" @click="confirmAttendee">确定</el-button>
</template>
</el-dialog>
<div class="form-actions">
<el-button @click="goBack">返回</el-button>
</div>
@@ -278,6 +356,7 @@ import { listSupporters, listExecutor } from '@/api/system'
import { useUserStore } from '@/store/user'
import { ElMessage } from 'element-plus'
import OssFileUploader from '@/components/OssFileUploader.vue'
import { ElMessageBox } from 'element-plus'
const route = useRoute()
const router = useRouter()
@@ -442,6 +521,131 @@ async function loadMaterials() {
})
} catch (e) { console.error('[meeting-detail] loadMaterials failed', e) }
}
// ===================== 参会人 CRUD =====================
/** 会议下的参会人列表 (放在 劳务材料 tab 表格) */
const attendeeRows = ref([])
const attendeeLoading = ref(false)
/** 参会人 dialog 状态 (新增/编辑共用) */
const attendeeDialog = ref({
show: false,
title: '',
editing: false, // true=编辑 / false=新增
form: emptyAttendeeForm(),
saving: false
})
function emptyAttendeeForm() {
return {
id: null,
meetingId: null,
phone: '',
name: '',
workUnit: '',
department: '',
title: '',
laborForm: '',
feePreTax: 0,
tax: 0,
fee: 0
}
}
function resetAttendeeForm() {
attendeeDialog.value = {
show: false, title: '', editing: false,
form: emptyAttendeeForm(), saving: false
}
}
async function loadAttendees() {
attendeeLoading.value = true
try {
const resp = await request.get(`/business/meetingAttendee/list/${meetingId.value}`)
const list = (resp && (resp.data || resp)) || []
attendeeRows.value = Array.isArray(list) ? list : []
} catch (e) {
console.error('[meeting-detail] loadAttendees failed', e)
attendeeRows.value = []
} finally {
attendeeLoading.value = false
}
}
/** 打开新增/编辑 dialog; row=null → 新增 */
function openAttendeeDialog(row) {
if (row) {
attendeeDialog.value = {
show: true,
title: '编辑参会人',
editing: true,
form: {
id: row.id,
meetingId: row.meetingId,
phone: row.phone || '',
name: row.name || '',
workUnit: row.workUnit || '',
department: row.department || '',
title: row.title || '',
laborForm: row.laborForm || '',
feePreTax: row.feePreTax ?? 0,
tax: row.tax ?? 0,
fee: row.fee ?? 0
},
saving: false
}
} else {
attendeeDialog.value = {
show: true,
title: '新增参会人',
editing: false,
form: { ...emptyAttendeeForm(), meetingId: Number(meetingId.value) },
saving: false
}
}
}
/** 提交 dialog (新增 → POST; 编辑 → PUT) */
async function confirmAttendee() {
const { editing, form } = attendeeDialog.value
if (!form.phone || !form.phone.trim()) {
ElMessage.warning('请输入手机号')
return
}
if (!form.phone.trim().match(/^1[3-9]\d{9}$/)) {
ElMessage.warning('手机号格式不正确')
return
}
attendeeDialog.value.saving = true
try {
if (editing) {
await request.put('/business/meetingAttendee', form, { __silentError: true })
ElMessage.success('已保存')
} else {
await request.post('/business/meetingAttendee', form, { __silentError: true })
ElMessage.success('已添加, 已向参会人发送会议邀请')
}
attendeeDialog.value.show = false
await loadAttendees()
} catch (e) {
ElMessage.error(e?.msg || e?.message || (editing ? '保存失败' : '添加失败'))
} finally {
attendeeDialog.value.saving = false
}
}
/** 删除参会人 (单条确认) */
async function confirmDeleteAttendee(row) {
try {
await ElMessageBox.confirm(`确认删除参会人「${row.name || row.phone || row.id}」?该操作仅删除参会关系, 不会删除其 sys_user 账号`, '删除确认', { type: 'warning' })
} catch { return /* 用户取消 */ }
try {
await request.delete(`/business/meetingAttendee/${row.id}`, { __silentError: true })
ElMessage.success('已删除')
await loadAttendees()
} catch (e) {
ElMessage.error(e?.msg || e?.message || '删除失败')
}
}
async function loadStaff() {
try {
const [sp, ex] = await Promise.all([
@@ -465,7 +669,7 @@ async function load() {
try {
const resp = await bizGet('meeting', meetingId.value)
row.value = (resp && (resp.data || resp)) || {}
await Promise.all([loadMaterials(), loadStaff(), loadTrail()])
await Promise.all([loadMaterials(), loadStaff(), loadTrail(), loadAttendees()])
} catch (e) {
console.error('[meeting-detail] load failed', e)
row.value = {}
@@ -757,4 +961,10 @@ onMounted(load)
.audit-column .section-title { font-size: 14px; margin-bottom: 12px; padding-left: 8px; }
.form-actions { display: flex; justify-content: flex-start; padding: 24px 0 0; }
/* 参会人管理 (放在 劳务材料 tab 底部, 无表题) */
.attendee-section { margin-top: 16px; padding-top: 16px; border-top: 1px dashed #e8e8e8; }
.attendee-toolbar { display: flex; align-items: center; gap: 12px; margin-bottom: 10px; flex-wrap: wrap; }
.toolbar-hint { font-size: 12px; color: #909399; line-height: 1.5; }
.form-hint { font-size: 12px; color: #909399; margin-top: -8px; margin-bottom: 8px; line-height: 1.5; }
</style>