feat(review-frontend): 优化会议评审页面交互

pull/874/head
Codewoc 2026-03-25 17:07:15 +08:00
parent 5ef10cfd6e
commit 36b34d6695
7 changed files with 401 additions and 43 deletions

View File

@ -19,16 +19,22 @@ export interface ReviewProjectItemVO {
export interface ReviewMeetingSaveReqVO { export interface ReviewMeetingSaveReqVO {
id?: number id?: number
name: string name: string
organizationUnit?: string
startTime?: string | number startTime?: string | number
endTime?: string | number endTime?: string | number
materialViewStartTime?: string | number materialViewStartTime?: string | number
materialViewEndTime?: string | number materialViewEndTime?: string | number
materialViewRemark?: string materialViewRemark?: string
location: string location: string
host?: string
agendaAttachmentName?: string agendaAttachmentName?: string
agendaAttachmentUrl?: string agendaAttachmentUrl?: string
agendaAttachmentType?: string agendaAttachmentType?: string
agendaAttachmentSize?: number agendaAttachmentSize?: number
minutesAttachmentName?: string
minutesAttachmentUrl?: string
minutesAttachmentType?: string
minutesAttachmentSize?: number
expertIds: number[] expertIds: number[]
projects?: ReviewProjectItemVO[] projects?: ReviewProjectItemVO[]
} }
@ -46,16 +52,22 @@ export interface ReviewMeetingPageReqVO {
export interface ReviewMeetingRespVO { export interface ReviewMeetingRespVO {
id: number id: number
name: string name: string
organizationUnit?: string
startTime: string startTime: string
endTime: string endTime: string
materialViewStartTime?: string materialViewStartTime?: string
materialViewEndTime?: string materialViewEndTime?: string
materialViewRemark?: string materialViewRemark?: string
location: string location: string
host?: string
agendaAttachmentName?: string agendaAttachmentName?: string
agendaAttachmentUrl?: string agendaAttachmentUrl?: string
agendaAttachmentType?: string agendaAttachmentType?: string
agendaAttachmentSize?: number agendaAttachmentSize?: number
minutesAttachmentName?: string
minutesAttachmentUrl?: string
minutesAttachmentType?: string
minutesAttachmentSize?: number
status: number // 0-草稿 1-已邀约 2-已结束 3-已取消 status: number // 0-草稿 1-已邀约 2-已结束 3-已取消
expertIds: number[] expertIds: number[]
expertCount: number expertCount: number
@ -115,6 +127,10 @@ export const cancelReviewMeeting = (id: number) =>
export const finishReviewMeeting = (id: number) => export const finishReviewMeeting = (id: number) =>
request.put({ url: '/project/review-meeting/finish', params: { id } }) request.put({ url: '/project/review-meeting/finish', params: { id } })
/** 复制会议(仅已结束/已取消) */
export const copyReviewMeeting = (id: number) =>
request.post({ url: '/project/review-meeting/copy', params: { id } })
/** 获取会议详情 */ /** 获取会议详情 */
export const getReviewMeeting = (id: number) => export const getReviewMeeting = (id: number) =>
request.get({ url: '/project/review-meeting/get', params: { id } }) request.get({ url: '/project/review-meeting/get', params: { id } })
@ -160,6 +176,19 @@ export const uploadAgendaAttachment = (file: File): Promise<ReviewMeetingAgendaA
.then((res) => res?.data as ReviewMeetingAgendaAttachmentRespVO) .then((res) => res?.data as ReviewMeetingAgendaAttachmentRespVO)
} }
/** 上传会议纪要附件Word/PDF/图片) */
export const uploadMinutesAttachment = (
id: number,
file: File
): Promise<ReviewMeetingAgendaAttachmentRespVO> => {
const formData = new FormData()
formData.append('id', String(id))
formData.append('file', file)
return request
.upload<any>({ url: '/project/review-meeting/upload-minutes-attachment', data: formData })
.then((res) => res?.data as ReviewMeetingAgendaAttachmentRespVO)
}
/** 下载导入模板 */ /** 下载导入模板 */
export const getImportTemplate = () => export const getImportTemplate = () =>
request.download({ url: '/project/review-meeting/get-import-template' }) request.download({ url: '/project/review-meeting/get-import-template' })

View File

@ -16,7 +16,14 @@ export interface ReviewMeetingProjectRespVO {
projectTitle: string projectTitle: string
reporter: string reporter: string
reporterUnit: string reporterUnit: string
host: string host?: string
reviewDate?: string
reviewResult?: 'PASS' | 'REJECT'
}
export interface ReviewMeetingProjectSeqUpdateReqVO {
id: number
seqNo: number
} }
export interface ReviewMeetingProjectPageReqVO { export interface ReviewMeetingProjectPageReqVO {
@ -79,6 +86,10 @@ export const createReviewProject = (data: Partial<ReviewMeetingProjectRespVO>) =
export const updateReviewProject = (data: Partial<ReviewMeetingProjectRespVO>) => export const updateReviewProject = (data: Partial<ReviewMeetingProjectRespVO>) =>
request.put({ url: '/project/review-project/update', data }) request.put({ url: '/project/review-project/update', data })
/** 批量更新会中序号 */
export const updateReviewProjectSeqBatch = (data: ReviewMeetingProjectSeqUpdateReqVO[]) =>
request.put({ url: '/project/review-project/update-seq-batch', data })
/** 删除评审项目 */ /** 删除评审项目 */
export const deleteReviewProject = (ids: number[]) => export const deleteReviewProject = (ids: number[]) =>
request.delete({ url: '/project/review-project/delete', params: { ids: ids.join(',') } }) request.delete({ url: '/project/review-project/delete', params: { ids: ids.join(',') } })

View File

@ -72,14 +72,10 @@
</el-table-column> </el-table-column>
<el-table-column label="报告人" prop="reporter" width="80" /> <el-table-column label="报告人" prop="reporter" width="80" />
<el-table-column label="报告单位" prop="reporterUnit" show-overflow-tooltip width="130" /> <el-table-column label="报告单位" prop="reporterUnit" show-overflow-tooltip width="130" />
<el-table-column label="主持人" width="150"> <el-table-column label="评审日期" prop="reviewDate" width="120" align="center" />
<el-table-column label="评审结果" width="110" align="center">
<template #default="{ row }"> <template #default="{ row }">
<el-input {{ REVIEW_RESULT_LABEL[row.reviewResult as keyof typeof REVIEW_RESULT_LABEL] || '-' }}
v-model="row.host"
placeholder="填写主持人"
size="small"
@blur="handleHostBlur(row)"
/>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="180" align="center" fixed="right"> <el-table-column label="操作" width="180" align="center" fixed="right">
@ -121,8 +117,14 @@
<el-form-item label="报告单位" prop="reporterUnit"> <el-form-item label="报告单位" prop="reporterUnit">
<el-input v-model="formData.reporterUnit" placeholder="请输入报告单位" /> <el-input v-model="formData.reporterUnit" placeholder="请输入报告单位" />
</el-form-item> </el-form-item>
<el-form-item label="主持人" prop="host"> <el-form-item label="评审日期" prop="reviewDate">
<el-input v-model="formData.host" placeholder="请输入主持人" /> <el-date-picker v-model="formData.reviewDate" type="date" value-format="YYYY-MM-DD" placeholder="请选择评审日期" style="width: 100%" />
</el-form-item>
<el-form-item label="评审结果" prop="reviewResult">
<el-select v-model="formData.reviewResult" placeholder="请选择评审结果" clearable style="width: 100%">
<el-option label="通过" value="PASS" />
<el-option label="不通过" value="REJECT" />
</el-select>
</el-form-item> </el-form-item>
</el-form> </el-form>
<template #footer> <template #footer>
@ -142,7 +144,6 @@ import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { import {
getReviewProjectPageStandalone, getReviewProjectPageStandalone,
updateProjectHost,
updateReviewProject, updateReviewProject,
createReviewProject, createReviewProject,
deleteReviewProject, deleteReviewProject,
@ -158,7 +159,8 @@ const router = useRouter()
const loading = ref(false) const loading = ref(false)
const list = ref<ReviewMeetingProjectRespVO[]>([]) const list = ref<ReviewMeetingProjectRespVO[]>([])
const total = ref(0) const total = ref(0)
const meetingOptions = ref<{ id: number; name: string }[]>([]) const meetingOptions = ref<{ id: number; name: string; host?: string }[]>([])
const REVIEW_RESULT_LABEL = { PASS: '通过', REJECT: '不通过' }
const queryParams = reactive<ReviewProjectPageReqVO & { pageNo: number; pageSize: number }>({ const queryParams = reactive<ReviewProjectPageReqVO & { pageNo: number; pageSize: number }>({
pageNo: 1, pageNo: 1,
@ -183,7 +185,7 @@ const getList = async () => {
const loadMeetingOptions = async () => { const loadMeetingOptions = async () => {
const data = await getReviewMeetingPage({ pageNo: 1, pageSize: 200 }).catch(() => ({ list: [] })) const data = await getReviewMeetingPage({ pageNo: 1, pageSize: 200 }).catch(() => ({ list: [] }))
meetingOptions.value = (data.list || []).map((m: any) => ({ id: m.id, name: m.name })) meetingOptions.value = (data.list || []).map((m: any) => ({ id: m.id, name: m.name, host: m.host }))
} }
const handleQuery = () => { queryParams.pageNo = 1; getList() } const handleQuery = () => { queryParams.pageNo = 1; getList() }
@ -217,11 +219,6 @@ const handleDeleteBatch = async () => {
getList() getList()
} }
const handleHostBlur = async (row: ReviewMeetingProjectRespVO) => {
await updateProjectHost(row.id, row.host || '')
ElMessage.success('主持人已更新')
}
const formVisible = ref(false) const formVisible = ref(false)
const formType = ref<'create' | 'update'>('create') const formType = ref<'create' | 'update'>('create')
const formLoading = ref(false) const formLoading = ref(false)
@ -274,7 +271,7 @@ const goToDetail = (row: ReviewMeetingProjectRespVO) => {
agendaCategory: row.agendaCategory, agendaCategory: row.agendaCategory,
reporter: row.reporter, reporter: row.reporter,
reporterUnit: row.reporterUnit, reporterUnit: row.reporterUnit,
host: row.host, meetingHost: meeting?.host,
meetingName: meeting?.name meetingName: meeting?.name
} }
}) })

View File

@ -44,11 +44,27 @@
</el-col> </el-col>
<el-col :span="12"> <el-col :span="12">
<el-form-item label="会议地点" prop="location"> <el-form-item label="会议地点" prop="location">
<el-input v-model="formData.location" placeholder="请输入会议地点" :disabled="isView" /> <el-select
v-model="formData.location"
placeholder="请选择或输入会议地点"
filterable
allow-create
default-first-option
clearable
:disabled="isView"
style="width: 100%"
>
<el-option v-for="item in meetingLocationOptions" :key="item" :label="item" :value="item" />
</el-select>
</el-form-item> </el-form-item>
</el-col> </el-col>
</el-row> </el-row>
<el-row :gutter="16"> <el-row :gutter="16">
<el-col :span="12">
<el-form-item label="会议主持人" prop="host">
<el-input v-model="formData.host" placeholder="请输入会议主持人" :disabled="isView" />
</el-form-item>
</el-col>
<el-col :span="12"> <el-col :span="12">
<el-form-item label="资料查看时限" prop="materialViewTimeRange"> <el-form-item label="资料查看时限" prop="materialViewTimeRange">
<el-date-picker <el-date-picker
@ -88,6 +104,31 @@
</div> </div>
</el-form-item> </el-form-item>
</el-col> </el-col>
<el-col :span="12">
<el-form-item label="会议纪要" prop="minutesAttachmentUrl">
<div class="agenda-attachment-wrap">
<el-upload
v-if="!isView"
:auto-upload="false"
:show-file-list="false"
accept=".doc,.docx,.pdf,.png,.jpg,.jpeg,.gif,.bmp,.webp"
:on-change="handleMinutesAttachmentChange"
>
<button type="button" class="btn-upload">上传会议纪要</button>
</el-upload>
<span class="upload-hint">支持 WordPDF图片结束会议前必须上传</span>
<span v-if="!meetingId && !isView" class="upload-hint"></span>
<div v-if="formData.minutesAttachmentUrl" class="agenda-file-line">
<el-link type="primary" :underline="false" @click="previewMinutesAttachment">
{{ formData.minutesAttachmentName }}
</el-link>
<el-tag size="small">{{ (formData.minutesAttachmentType || '').toUpperCase() }}</el-tag>
<el-text type="info" size="small">{{ formatFileSize(formData.minutesAttachmentSize) }}</el-text>
<el-button v-if="!isView" type="danger" link @click="clearMinutesAttachment"></el-button>
</div>
</div>
</el-form-item>
</el-col>
</el-row> </el-row>
<!-- 评审专家 --> <!-- 评审专家 -->
@ -156,6 +197,7 @@ import {
importProjectsFromExcel, importProjectsFromExcel,
getImportTemplate, getImportTemplate,
uploadAgendaAttachment, uploadAgendaAttachment,
uploadMinutesAttachment,
type ReviewMeetingSaveReqVO, type ReviewMeetingSaveReqVO,
type ReviewProjectItemVO type ReviewProjectItemVO
} from '@/api/review/meeting' } from '@/api/review/meeting'
@ -172,16 +214,25 @@ const route = useRoute()
const formLoading = ref(false) const formLoading = ref(false)
const expertOptions = ref<any[]>([]) const expertOptions = ref<any[]>([])
const isProjectsModified = ref(false) const isProjectsModified = ref(false)
const meetingLocationOptions = ['东5楼326', '南6楼203', '南6楼207']
// //
const meetingId = computed(() => { const meetingId = computed(() => {
const id = route.params.id const id = route.params.id
return id ? Number(id) : undefined return id ? Number(id) : undefined
}) })
const copyFromId = computed(() => {
const queryValue = route.query.copyFromId
const raw = Array.isArray(queryValue) ? queryValue[0] : queryValue
const parsed = raw ? Number(raw) : NaN
return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined
})
const isView = computed(() => route.query.mode === 'view') const isView = computed(() => route.query.mode === 'view')
const isEdit = computed(() => !!meetingId.value && !isView.value) const isEdit = computed(() => !!meetingId.value && !isView.value)
const isCopyMode = computed(() => !meetingId.value && !!copyFromId.value && !isView.value)
const pageTitle = computed(() => { const pageTitle = computed(() => {
if (isView.value) return '查看会议' if (isView.value) return '查看会议'
if (isCopyMode.value) return '复制会议'
return isEdit.value ? '编辑会议' : '新建会议' return isEdit.value ? '编辑会议' : '新建会议'
}) })
@ -197,11 +248,16 @@ const formData = reactive<FormData>({
organizationUnit: undefined, organizationUnit: undefined,
startTime: undefined, startTime: undefined,
endTime: undefined, endTime: undefined,
location: '', location: '东5楼326',
host: undefined,
agendaAttachmentName: undefined, agendaAttachmentName: undefined,
agendaAttachmentUrl: undefined, agendaAttachmentUrl: undefined,
agendaAttachmentType: undefined, agendaAttachmentType: undefined,
agendaAttachmentSize: undefined, agendaAttachmentSize: undefined,
minutesAttachmentName: undefined,
minutesAttachmentUrl: undefined,
minutesAttachmentType: undefined,
minutesAttachmentSize: undefined,
materialViewStartTime: undefined, materialViewStartTime: undefined,
materialViewEndTime: undefined, materialViewEndTime: undefined,
materialViewRemark: undefined, materialViewRemark: undefined,
@ -216,12 +272,24 @@ const rules: FormRules = {
organizationUnit: [{ required: true, message: '组织单位不能为空', trigger: 'blur' }], organizationUnit: [{ required: true, message: '组织单位不能为空', trigger: 'blur' }],
meetingTimeRange: [{ required: true, message: '会议时间不能为空', trigger: 'change' }], meetingTimeRange: [{ required: true, message: '会议时间不能为空', trigger: 'change' }],
location: [{ required: true, message: '会议地点不能为空', trigger: 'blur' }], location: [{ required: true, message: '会议地点不能为空', trigger: 'blur' }],
host: [{ required: true, message: '会议主持人不能为空', trigger: 'blur' }],
agendaAttachmentUrl: [{ required: true, message: '议程附件不能为空', trigger: 'change' }], agendaAttachmentUrl: [{ required: true, message: '议程附件不能为空', trigger: 'change' }],
expertIds: [{ required: true, type: 'array', min: 1, message: '至少选择一位专家', trigger: 'change' }] expertIds: [{ required: true, type: 'array', min: 1, message: '至少选择一位专家', trigger: 'change' }]
} }
const formRef = ref() const formRef = ref()
const mapProjectItems = (projects: any[]): ReviewProjectItemVO[] =>
(projects || []).map((item: any) => ({
seqNo: item.seqNo,
startTime: item.startTime,
endTime: item.endTime,
agendaCategory: item.agendaCategory,
projectTitle: item.projectTitle,
reporter: item.reporter,
reporterUnit: item.reporterUnit
}))
const loadDetail = async (id: number) => { const loadDetail = async (id: number) => {
formLoading.value = true formLoading.value = true
try { try {
@ -230,7 +298,7 @@ const loadDetail = async (id: number) => {
getReviewProjectPage({ reviewMeetingId: id, pageNo: 1, pageSize: 200 }) getReviewProjectPage({ reviewMeetingId: id, pageNo: 1, pageSize: 200 })
]) ])
Object.assign(formData, detail) Object.assign(formData, detail)
formData.projects = (projectData?.list ?? []) as ReviewProjectItemVO[] formData.projects = mapProjectItems(projectData?.list ?? [])
if (detail.startTime && detail.endTime) { if (detail.startTime && detail.endTime) {
formData.meetingTimeRange = [ formData.meetingTimeRange = [
new Date(detail.startTime.replace(' ', 'T')).getTime(), new Date(detail.startTime.replace(' ', 'T')).getTime(),
@ -248,10 +316,68 @@ const loadDetail = async (id: number) => {
} }
} }
const loadCopySource = async (id: number) => {
formLoading.value = true
try {
const detail = await getReviewMeeting(id)
if (![2, 3].includes(detail.status)) {
ElMessage.warning('仅支持从已结束或已取消会议复制')
router.push({ name: 'ReviewMeeting' })
return
}
formData.id = undefined
formData.name = detail.name
formData.organizationUnit = detail.organizationUnit
formData.startTime = detail.startTime
formData.endTime = detail.endTime
formData.materialViewStartTime = detail.materialViewStartTime
formData.materialViewEndTime = detail.materialViewEndTime
formData.materialViewRemark = detail.materialViewRemark
formData.location = detail.location || '东5楼326'
formData.host = detail.host
formData.agendaAttachmentName = undefined
formData.agendaAttachmentUrl = undefined
formData.agendaAttachmentType = undefined
formData.agendaAttachmentSize = undefined
formData.minutesAttachmentName = undefined
formData.minutesAttachmentUrl = undefined
formData.minutesAttachmentType = undefined
formData.minutesAttachmentSize = undefined
formData.expertIds = detail.expertIds || []
formData.projects = []
if (detail.startTime && detail.endTime) {
formData.meetingTimeRange = [
new Date(detail.startTime.replace(' ', 'T')).getTime(),
new Date(detail.endTime.replace(' ', 'T')).getTime()
]
} else {
formData.meetingTimeRange = undefined
}
if (detail.materialViewStartTime && detail.materialViewEndTime) {
formData.materialViewTimeRange = [
new Date(detail.materialViewStartTime.replace(' ', 'T')).getTime(),
new Date(detail.materialViewEndTime.replace(' ', 'T')).getTime()
]
} else {
formData.materialViewTimeRange = undefined
}
ElMessage.info('已带入会议信息;议程附件和评审项目不会复制,请补充后再保存草稿')
} finally {
formLoading.value = false
}
}
onMounted(async () => { onMounted(async () => {
expertOptions.value = await getExpertUserList().catch(() => []) expertOptions.value = await getExpertUserList().catch(() => [])
if (meetingId.value) { if (meetingId.value) {
await loadDetail(meetingId.value) await loadDetail(meetingId.value)
} else if (copyFromId.value) {
await loadCopySource(copyFromId.value)
} else if (!formData.location) {
formData.location = '东5楼326'
} }
}) })
@ -315,12 +441,52 @@ const clearAgendaAttachment = () => {
formRef.value?.validateField('agendaAttachmentUrl') formRef.value?.validateField('agendaAttachmentUrl')
} }
const handleMinutesAttachmentChange = async (uploadFile: UploadFile) => {
if (!uploadFile.raw) return
if (!meetingId.value) {
ElMessage.warning('请先保存会议后再上传纪要')
return
}
const ext = uploadFile.raw.name.includes('.')
? uploadFile.raw.name.substring(uploadFile.raw.name.lastIndexOf('.') + 1).toLowerCase()
: ''
const allowed = ['doc', 'docx', 'pdf', 'png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp']
if (!allowed.includes(ext)) {
ElMessage.error('会议纪要仅支持 Word、PDF 或图片')
return
}
formLoading.value = true
try {
const attachment = await uploadMinutesAttachment(meetingId.value, uploadFile.raw)
formData.minutesAttachmentName = attachment.name
formData.minutesAttachmentUrl = attachment.url
formData.minutesAttachmentType = attachment.type
formData.minutesAttachmentSize = attachment.size
ElMessage.success('会议纪要上传成功')
} finally {
formLoading.value = false
}
}
const clearMinutesAttachment = () => {
formData.minutesAttachmentName = undefined
formData.minutesAttachmentUrl = undefined
formData.minutesAttachmentType = undefined
formData.minutesAttachmentSize = undefined
}
const previewAgendaAttachment = () => { const previewAgendaAttachment = () => {
if (formData.agendaAttachmentUrl) { if (formData.agendaAttachmentUrl) {
window.open(formData.agendaAttachmentUrl, '_blank') window.open(formData.agendaAttachmentUrl, '_blank')
} }
} }
const previewMinutesAttachment = () => {
if (formData.minutesAttachmentUrl) {
window.open(formData.minutesAttachmentUrl, '_blank')
}
}
const formatFileSize = (bytes?: number): string => { const formatFileSize = (bytes?: number): string => {
if (!bytes) return '-' if (!bytes) return '-'
if (bytes < 1024) return `${bytes} B` if (bytes < 1024) return `${bytes} B`

View File

@ -32,7 +32,7 @@
</div> </div>
<div class="info-footer-item"> <div class="info-footer-item">
<span class="footer-label">主持人</span> <span class="footer-label">主持人</span>
<span class="footer-value">{{ projectInfo.host || '-' }}</span> <span class="footer-value">{{ projectInfo.meetingHost || '-' }}</span>
</div> </div>
</div> </div>
</div> </div>
@ -81,6 +81,7 @@ import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import type { UploadFile } from 'element-plus' import type { UploadFile } from 'element-plus'
import { getReviewMeeting } from '@/api/review/meeting'
import { import {
getMeetingFileList, getMeetingFileList,
uploadMeetingFile, uploadMeetingFile,
@ -105,7 +106,7 @@ const projectInfo = ref({
agendaCategory: state.agendaCategory as string | undefined, agendaCategory: state.agendaCategory as string | undefined,
reporter: state.reporter as string | undefined, reporter: state.reporter as string | undefined,
reporterUnit: state.reporterUnit as string | undefined, reporterUnit: state.reporterUnit as string | undefined,
host: state.host as string | undefined, meetingHost: state.meetingHost as string | undefined,
meetingName: state.meetingName as string | undefined meetingName: state.meetingName as string | undefined
}) })
@ -163,6 +164,14 @@ const handleBack = () => {
onMounted(() => { onMounted(() => {
loadFiles() loadFiles()
getReviewMeeting(reviewMeetingId)
.then((meeting) => {
if (!projectInfo.value.meetingName) {
projectInfo.value.meetingName = meeting.name
}
projectInfo.value.meetingHost = meeting.host
})
.catch(() => {})
}) })
</script> </script>

View File

@ -60,8 +60,13 @@
</div> </div>
<!-- 列表 --> <!-- 列表 -->
<el-table v-loading="loading" :data="list" border class="review-table" @selection-change="handleSelectionChange"> <el-table ref="tableRef" v-loading="loading || sortLoading" :data="list" row-key="id" border class="review-table" @selection-change="handleSelectionChange">
<el-table-column type="selection" width="50" align="center" /> <el-table-column type="selection" width="50" align="center" />
<el-table-column label="拖拽" width="60" align="center">
<template #default>
<span class="drag-handle" title="拖拽排序"></span>
</template>
</el-table-column>
<el-table-column label="立项编号" prop="id" width="80" align="center" /> <el-table-column label="立项编号" prop="id" width="80" align="center" />
<el-table-column label="会中序号" prop="seqNo" width="80" align="center" /> <el-table-column label="会中序号" prop="seqNo" width="80" align="center" />
<el-table-column label="起止时间" width="110" align="center"> <el-table-column label="起止时间" width="110" align="center">
@ -78,14 +83,10 @@
</el-table-column> </el-table-column>
<el-table-column label="报告人" prop="reporter" width="80" /> <el-table-column label="报告人" prop="reporter" width="80" />
<el-table-column label="报告单位" prop="reporterUnit" show-overflow-tooltip width="130" /> <el-table-column label="报告单位" prop="reporterUnit" show-overflow-tooltip width="130" />
<el-table-column label="主持人" width="150"> <el-table-column label="评审日期" prop="reviewDate" width="120" align="center" />
<el-table-column label="评审结果" width="110" align="center">
<template #default="{ row }"> <template #default="{ row }">
<el-input {{ REVIEW_RESULT_LABEL[row.reviewResult as keyof typeof REVIEW_RESULT_LABEL] || '-' }}
v-model="row.host"
placeholder="填写主持人"
size="small"
@blur="handleHostBlur(row)"
/>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="180" align="center" fixed="right"> <el-table-column label="操作" width="180" align="center" fixed="right">
@ -122,8 +123,14 @@
<el-form-item label="报告单位" prop="reporterUnit"> <el-form-item label="报告单位" prop="reporterUnit">
<el-input v-model="formData.reporterUnit" placeholder="请输入报告单位" /> <el-input v-model="formData.reporterUnit" placeholder="请输入报告单位" />
</el-form-item> </el-form-item>
<el-form-item label="主持人" prop="host"> <el-form-item label="评审日期" prop="reviewDate">
<el-input v-model="formData.host" placeholder="请输入主持人" /> <el-date-picker v-model="formData.reviewDate" type="date" value-format="YYYY-MM-DD" placeholder="请选择评审日期" style="width: 100%" />
</el-form-item>
<el-form-item label="评审结果" prop="reviewResult">
<el-select v-model="formData.reviewResult" placeholder="请选择评审结果" clearable style="width: 100%">
<el-option label="通过" value="PASS" />
<el-option label="不通过" value="REJECT" />
</el-select>
</el-form-item> </el-form-item>
</el-form> </el-form>
<template #footer> <template #footer>
@ -138,11 +145,12 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, onMounted } from 'vue' import { ref, reactive, onMounted, onBeforeUnmount, nextTick } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import Sortable from 'sortablejs'
import { getReviewMeeting } from '@/api/review/meeting' import { getReviewMeeting } from '@/api/review/meeting'
import { getReviewProjectPage, updateProjectHost, updateReviewProject, createReviewProject, deleteReviewProject, type ReviewMeetingProjectRespVO } from '@/api/review/project' import { getReviewProjectPage, updateReviewProject, updateReviewProjectSeqBatch, createReviewProject, deleteReviewProject, type ReviewMeetingProjectRespVO } from '@/api/review/project'
import { formatDate } from '@/utils/formatTime' import { formatDate } from '@/utils/formatTime'
defineOptions({ name: 'ReviewMeetingProject' }) defineOptions({ name: 'ReviewMeetingProject' })
@ -152,11 +160,15 @@ const router = useRouter()
const reviewMeetingId = Number(route.params.meetingId) const reviewMeetingId = Number(route.params.meetingId)
const loading = ref(false) const loading = ref(false)
const sortLoading = ref(false)
const list = ref<ReviewMeetingProjectRespVO[]>([]) const list = ref<ReviewMeetingProjectRespVO[]>([])
const total = ref(0) const total = ref(0)
const meetingInfo = ref<any>({}) const meetingInfo = ref<any>({})
const tableRef = ref()
let sortableInstance: Sortable | null = null
const STATUS_LABEL: Record<number, string> = { 0: '待召开', 1: '正在召开', 2: '已结束', 3: '已取消' } const STATUS_LABEL: Record<number, string> = { 0: '待召开', 1: '正在召开', 2: '已结束', 3: '已取消' }
const REVIEW_RESULT_LABEL = { PASS: '通过', REJECT: '不通过' }
const queryParams = reactive({ const queryParams = reactive({
pageNo: 1, pageNo: 1,
@ -175,6 +187,75 @@ const getList = async () => {
total.value = data.total total.value = data.total
} finally { } finally {
loading.value = false loading.value = false
await nextTick()
initSortable()
}
}
const initSortable = () => {
if (!tableRef.value?.$el) return
const tbody = tableRef.value.$el.querySelector('.el-table__body-wrapper tbody')
if (!tbody) return
sortableInstance?.destroy()
sortableInstance = Sortable.create(tbody, {
animation: 150,
handle: '.drag-handle',
ghostClass: 'drag-ghost',
chosenClass: 'drag-chosen',
onEnd: ({ oldIndex, newIndex }) => {
if (oldIndex === undefined || newIndex === undefined || oldIndex === newIndex) return
handleSortEnd(oldIndex, newIndex)
}
})
}
const handleSortEnd = async (oldIndex: number, newIndex: number) => {
if (queryParams.projectTitle || queryParams.agendaCategory || queryParams.reporter) {
ElMessage.warning('请先清空筛选条件后再拖拽排序')
await getList()
return
}
const moved = list.value.splice(oldIndex, 1)[0]
if (!moved) return
list.value.splice(newIndex, 0, moved)
list.value = [...list.value]
sortLoading.value = true
try {
const pageStart = (queryParams.pageNo - 1) * queryParams.pageSize
const allData = await getReviewProjectPage({
reviewMeetingId,
pageNo: 1,
pageSize: Math.max(total.value || 0, queryParams.pageSize, 200)
})
const fullList = [...(allData.list || [])]
list.value.forEach((item, index) => {
fullList[pageStart + index] = item
})
const changedRows: ReviewMeetingProjectRespVO[] = []
fullList.forEach((item, index) => {
const nextSeq = index + 1
if (item.seqNo !== nextSeq) {
item.seqNo = nextSeq
changedRows.push(item)
}
})
if (changedRows.length === 0) return
list.value = fullList.slice(pageStart, pageStart + queryParams.pageSize)
await updateReviewProjectSeqBatch(
changedRows.map((row) => ({
id: row.id,
seqNo: row.seqNo
}))
)
ElMessage.success('排序已更新')
} catch {
ElMessage.error('排序保存失败,已恢复原列表')
await getList()
} finally {
sortLoading.value = false
} }
} }
@ -200,11 +281,6 @@ const handleDelete = async (id?: number) => {
getList() getList()
} }
const handleHostBlur = async (row: ReviewMeetingProjectRespVO) => {
await updateProjectHost(row.id, row.host || '')
ElMessage.success('主持人已更新')
}
const formVisible = ref(false) const formVisible = ref(false)
const formType = ref<'create' | 'update'>('create') const formType = ref<'create' | 'update'>('create')
const formLoading = ref(false) const formLoading = ref(false)
@ -256,7 +332,7 @@ const goToDetail = (row: ReviewMeetingProjectRespVO) => {
agendaCategory: row.agendaCategory, agendaCategory: row.agendaCategory,
reporter: row.reporter, reporter: row.reporter,
reporterUnit: row.reporterUnit, reporterUnit: row.reporterUnit,
host: row.host, meetingHost: meetingInfo.value?.host,
meetingName: meetingInfo.value?.name meetingName: meetingInfo.value?.name
} }
}) })
@ -266,6 +342,11 @@ onMounted(async () => {
meetingInfo.value = await getReviewMeeting(reviewMeetingId) meetingInfo.value = await getReviewMeeting(reviewMeetingId)
await getList() await getList()
}) })
onBeforeUnmount(() => {
sortableInstance?.destroy()
sortableInstance = null
})
</script> </script>
<style scoped> <style scoped>
@ -437,4 +518,22 @@ onMounted(async () => {
:deep(.review-table .el-table__body tr:hover > td) { :deep(.review-table .el-table__body tr:hover > td) {
background-color: rgba(41, 90, 188, 0.04); background-color: rgba(41, 90, 188, 0.04);
} }
.drag-handle {
display: inline-block;
color: #7a869a;
font-size: 16px;
line-height: 1;
cursor: grab;
user-select: none;
}
.drag-handle:active {
cursor: grabbing;
}
:deep(.drag-ghost > td) {
background-color: rgba(41, 90, 188, 0.1) !important;
}
:deep(.drag-chosen > td) {
background-color: rgba(41, 90, 188, 0.06);
}
</style> </style>

View File

@ -71,12 +71,14 @@
<a v-hasPermi="['review:meeting:send-sms']" class="op-link" @click="openSmsStatus(row)"></a> <a v-hasPermi="['review:meeting:send-sms']" class="op-link" @click="openSmsStatus(row)"></a>
<a v-if="row.mailSent" v-hasPermi="['review:meeting:send-mail']" class="op-link" @click="openMailStatus(row)"></a> <a v-if="row.mailSent" v-hasPermi="['review:meeting:send-mail']" class="op-link" @click="openMailStatus(row)"></a>
<a v-else v-hasPermi="['review:meeting:send-mail']" class="op-link" @click="handleSendMail(row)"></a> <a v-else v-hasPermi="['review:meeting:send-mail']" class="op-link" @click="handleSendMail(row)"></a>
<a v-hasPermi="['review:meeting:update']" class="op-link" @click="triggerUploadMinutes(row)"></a>
<a v-hasPermi="['review:meeting:finish']" class="op-link" @click="handleFinish(row)"></a> <a v-hasPermi="['review:meeting:finish']" class="op-link" @click="handleFinish(row)"></a>
<a v-hasPermi="['review:meeting:cancel']" class="op-link op-danger" @click="handleCancel(row)"></a> <a v-hasPermi="['review:meeting:cancel']" class="op-link op-danger" @click="handleCancel(row)"></a>
</template> </template>
<template v-else> <template v-else>
<a class="op-link" @click="goToEdit(row.id, 'view')">查看</a> <a class="op-link" @click="goToEdit(row.id, 'view')">查看</a>
<a class="op-link" @click="goToProjectList(row)"></a> <a class="op-link" @click="goToProjectList(row)"></a>
<a v-hasPermi="['review:meeting:create']" class="op-link" @click="handleCopy(row)"></a>
</template> </template>
</template> </template>
</el-table-column> </el-table-column>
@ -90,6 +92,14 @@
<!-- 邮件状态弹窗 --> <!-- 邮件状态弹窗 -->
<MailStatusDialog ref="mailStatusRef" /> <MailStatusDialog ref="mailStatusRef" />
<input
ref="minutesUploadInputRef"
type="file"
style="display: none"
accept=".doc,.docx,.pdf,.png,.jpg,.jpeg,.gif,.bmp,.webp"
@change="handleMinutesFileChange"
/>
</ContentWrap> </ContentWrap>
</template> </template>
@ -103,6 +113,7 @@ import {
finishReviewMeeting, finishReviewMeeting,
sendSmsInvitation, sendSmsInvitation,
sendMailInvitation, sendMailInvitation,
uploadMinutesAttachment,
type ReviewMeetingRespVO, type ReviewMeetingRespVO,
type ReviewMeetingPageReqVO type ReviewMeetingPageReqVO
} from '@/api/review/meeting' } from '@/api/review/meeting'
@ -133,9 +144,10 @@ const queryParams = reactive<ReviewMeetingPageReqVO & { pageNo: number; pageSize
startTime: undefined startTime: undefined
}) })
const queryFormRef = ref()
const smsStatusRef = ref() const smsStatusRef = ref()
const mailStatusRef = ref() const mailStatusRef = ref()
const minutesUploadInputRef = ref<HTMLInputElement>()
const pendingMinutesMeeting = ref<ReviewMeetingRespVO>()
const getList = async () => { const getList = async () => {
loading.value = true loading.value = true
@ -182,6 +194,41 @@ const handleFinish = async (row: ReviewMeetingRespVO) => {
getList() getList()
} }
const handleCopy = async (row: ReviewMeetingRespVO) => {
router.push({
name: 'ReviewMeetingEdit',
query: { copyFromId: String(row.id) }
})
}
const triggerUploadMinutes = (row: ReviewMeetingRespVO) => {
pendingMinutesMeeting.value = row
minutesUploadInputRef.value?.click()
}
const handleMinutesFileChange = async (event: Event) => {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
if (!file || !pendingMinutesMeeting.value) {
return
}
const ext = file.name.includes('.') ? file.name.substring(file.name.lastIndexOf('.') + 1).toLowerCase() : ''
const allowed = ['doc', 'docx', 'pdf', 'png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp']
if (!allowed.includes(ext)) {
ElMessage.error('会议纪要仅支持 Word、PDF 或图片')
input.value = ''
return
}
try {
await uploadMinutesAttachment(pendingMinutesMeeting.value.id, file)
ElMessage.success('会议纪要上传成功')
getList()
} finally {
input.value = ''
pendingMinutesMeeting.value = undefined
}
}
const handleSendSms = async (row: ReviewMeetingRespVO) => { const handleSendSms = async (row: ReviewMeetingRespVO) => {
await ElMessageBox.confirm( await ElMessageBox.confirm(
`确认向 ${row.expertCount} 位专家发送「${row.name}」会议邀约短信?`, `确认向 ${row.expertCount} 位专家发送「${row.name}」会议邀约短信?`,