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 {
id?: number
name: string
organizationUnit?: string
startTime?: string | number
endTime?: string | number
materialViewStartTime?: string | number
materialViewEndTime?: string | number
materialViewRemark?: string
location: string
host?: string
agendaAttachmentName?: string
agendaAttachmentUrl?: string
agendaAttachmentType?: string
agendaAttachmentSize?: number
minutesAttachmentName?: string
minutesAttachmentUrl?: string
minutesAttachmentType?: string
minutesAttachmentSize?: number
expertIds: number[]
projects?: ReviewProjectItemVO[]
}
@ -46,16 +52,22 @@ export interface ReviewMeetingPageReqVO {
export interface ReviewMeetingRespVO {
id: number
name: string
organizationUnit?: string
startTime: string
endTime: string
materialViewStartTime?: string
materialViewEndTime?: string
materialViewRemark?: string
location: string
host?: string
agendaAttachmentName?: string
agendaAttachmentUrl?: string
agendaAttachmentType?: string
agendaAttachmentSize?: number
minutesAttachmentName?: string
minutesAttachmentUrl?: string
minutesAttachmentType?: string
minutesAttachmentSize?: number
status: number // 0-草稿 1-已邀约 2-已结束 3-已取消
expertIds: number[]
expertCount: number
@ -115,6 +127,10 @@ export const cancelReviewMeeting = (id: number) =>
export const finishReviewMeeting = (id: number) =>
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) =>
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)
}
/** 上传会议纪要附件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 = () =>
request.download({ url: '/project/review-meeting/get-import-template' })

View File

@ -16,7 +16,14 @@ export interface ReviewMeetingProjectRespVO {
projectTitle: string
reporter: string
reporterUnit: string
host: string
host?: string
reviewDate?: string
reviewResult?: 'PASS' | 'REJECT'
}
export interface ReviewMeetingProjectSeqUpdateReqVO {
id: number
seqNo: number
}
export interface ReviewMeetingProjectPageReqVO {
@ -79,6 +86,10 @@ export const createReviewProject = (data: Partial<ReviewMeetingProjectRespVO>) =
export const updateReviewProject = (data: Partial<ReviewMeetingProjectRespVO>) =>
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[]) =>
request.delete({ url: '/project/review-project/delete', params: { ids: ids.join(',') } })

View File

@ -72,14 +72,10 @@
</el-table-column>
<el-table-column label="报告人" prop="reporter" width="80" />
<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 }">
<el-input
v-model="row.host"
placeholder="填写主持人"
size="small"
@blur="handleHostBlur(row)"
/>
{{ REVIEW_RESULT_LABEL[row.reviewResult as keyof typeof REVIEW_RESULT_LABEL] || '-' }}
</template>
</el-table-column>
<el-table-column label="操作" width="180" align="center" fixed="right">
@ -121,8 +117,14 @@
<el-form-item label="报告单位" prop="reporterUnit">
<el-input v-model="formData.reporterUnit" placeholder="请输入报告单位" />
</el-form-item>
<el-form-item label="主持人" prop="host">
<el-input v-model="formData.host" placeholder="请输入主持人" />
<el-form-item label="评审日期" prop="reviewDate">
<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>
<template #footer>
@ -142,7 +144,6 @@ import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
getReviewProjectPageStandalone,
updateProjectHost,
updateReviewProject,
createReviewProject,
deleteReviewProject,
@ -158,7 +159,8 @@ const router = useRouter()
const loading = ref(false)
const list = ref<ReviewMeetingProjectRespVO[]>([])
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 }>({
pageNo: 1,
@ -183,7 +185,7 @@ const getList = async () => {
const loadMeetingOptions = async () => {
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() }
@ -217,11 +219,6 @@ const handleDeleteBatch = async () => {
getList()
}
const handleHostBlur = async (row: ReviewMeetingProjectRespVO) => {
await updateProjectHost(row.id, row.host || '')
ElMessage.success('主持人已更新')
}
const formVisible = ref(false)
const formType = ref<'create' | 'update'>('create')
const formLoading = ref(false)
@ -274,7 +271,7 @@ const goToDetail = (row: ReviewMeetingProjectRespVO) => {
agendaCategory: row.agendaCategory,
reporter: row.reporter,
reporterUnit: row.reporterUnit,
host: row.host,
meetingHost: meeting?.host,
meetingName: meeting?.name
}
})

View File

@ -44,11 +44,27 @@
</el-col>
<el-col :span="12">
<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-col>
</el-row>
<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-form-item label="资料查看时限" prop="materialViewTimeRange">
<el-date-picker
@ -88,6 +104,31 @@
</div>
</el-form-item>
</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>
<!-- 评审专家 -->
@ -156,6 +197,7 @@ import {
importProjectsFromExcel,
getImportTemplate,
uploadAgendaAttachment,
uploadMinutesAttachment,
type ReviewMeetingSaveReqVO,
type ReviewProjectItemVO
} from '@/api/review/meeting'
@ -172,16 +214,25 @@ const route = useRoute()
const formLoading = ref(false)
const expertOptions = ref<any[]>([])
const isProjectsModified = ref(false)
const meetingLocationOptions = ['东5楼326', '南6楼203', '南6楼207']
//
const meetingId = computed(() => {
const id = route.params.id
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 isEdit = computed(() => !!meetingId.value && !isView.value)
const isCopyMode = computed(() => !meetingId.value && !!copyFromId.value && !isView.value)
const pageTitle = computed(() => {
if (isView.value) return '查看会议'
if (isCopyMode.value) return '复制会议'
return isEdit.value ? '编辑会议' : '新建会议'
})
@ -197,11 +248,16 @@ const formData = reactive<FormData>({
organizationUnit: undefined,
startTime: undefined,
endTime: undefined,
location: '',
location: '东5楼326',
host: undefined,
agendaAttachmentName: undefined,
agendaAttachmentUrl: undefined,
agendaAttachmentType: undefined,
agendaAttachmentSize: undefined,
minutesAttachmentName: undefined,
minutesAttachmentUrl: undefined,
minutesAttachmentType: undefined,
minutesAttachmentSize: undefined,
materialViewStartTime: undefined,
materialViewEndTime: undefined,
materialViewRemark: undefined,
@ -216,12 +272,24 @@ const rules: FormRules = {
organizationUnit: [{ required: true, message: '组织单位不能为空', trigger: 'blur' }],
meetingTimeRange: [{ required: true, message: '会议时间不能为空', trigger: 'change' }],
location: [{ required: true, message: '会议地点不能为空', trigger: 'blur' }],
host: [{ required: true, message: '会议主持人不能为空', trigger: 'blur' }],
agendaAttachmentUrl: [{ required: true, message: '议程附件不能为空', trigger: 'change' }],
expertIds: [{ required: true, type: 'array', min: 1, message: '至少选择一位专家', trigger: 'change' }]
}
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) => {
formLoading.value = true
try {
@ -230,7 +298,7 @@ const loadDetail = async (id: number) => {
getReviewProjectPage({ reviewMeetingId: id, pageNo: 1, pageSize: 200 })
])
Object.assign(formData, detail)
formData.projects = (projectData?.list ?? []) as ReviewProjectItemVO[]
formData.projects = mapProjectItems(projectData?.list ?? [])
if (detail.startTime && detail.endTime) {
formData.meetingTimeRange = [
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 () => {
expertOptions.value = await getExpertUserList().catch(() => [])
if (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')
}
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 = () => {
if (formData.agendaAttachmentUrl) {
window.open(formData.agendaAttachmentUrl, '_blank')
}
}
const previewMinutesAttachment = () => {
if (formData.minutesAttachmentUrl) {
window.open(formData.minutesAttachmentUrl, '_blank')
}
}
const formatFileSize = (bytes?: number): string => {
if (!bytes) return '-'
if (bytes < 1024) return `${bytes} B`

View File

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

View File

@ -60,8 +60,13 @@
</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 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="seqNo" width="80" align="center" />
<el-table-column label="起止时间" width="110" align="center">
@ -78,14 +83,10 @@
</el-table-column>
<el-table-column label="报告人" prop="reporter" width="80" />
<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 }">
<el-input
v-model="row.host"
placeholder="填写主持人"
size="small"
@blur="handleHostBlur(row)"
/>
{{ REVIEW_RESULT_LABEL[row.reviewResult as keyof typeof REVIEW_RESULT_LABEL] || '-' }}
</template>
</el-table-column>
<el-table-column label="操作" width="180" align="center" fixed="right">
@ -122,8 +123,14 @@
<el-form-item label="报告单位" prop="reporterUnit">
<el-input v-model="formData.reporterUnit" placeholder="请输入报告单位" />
</el-form-item>
<el-form-item label="主持人" prop="host">
<el-input v-model="formData.host" placeholder="请输入主持人" />
<el-form-item label="评审日期" prop="reviewDate">
<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>
<template #footer>
@ -138,11 +145,12 @@
</template>
<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 { ElMessage, ElMessageBox } from 'element-plus'
import Sortable from 'sortablejs'
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'
defineOptions({ name: 'ReviewMeetingProject' })
@ -152,11 +160,15 @@ const router = useRouter()
const reviewMeetingId = Number(route.params.meetingId)
const loading = ref(false)
const sortLoading = ref(false)
const list = ref<ReviewMeetingProjectRespVO[]>([])
const total = ref(0)
const meetingInfo = ref<any>({})
const tableRef = ref()
let sortableInstance: Sortable | null = null
const STATUS_LABEL: Record<number, string> = { 0: '待召开', 1: '正在召开', 2: '已结束', 3: '已取消' }
const REVIEW_RESULT_LABEL = { PASS: '通过', REJECT: '不通过' }
const queryParams = reactive({
pageNo: 1,
@ -175,6 +187,75 @@ const getList = async () => {
total.value = data.total
} finally {
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()
}
const handleHostBlur = async (row: ReviewMeetingProjectRespVO) => {
await updateProjectHost(row.id, row.host || '')
ElMessage.success('主持人已更新')
}
const formVisible = ref(false)
const formType = ref<'create' | 'update'>('create')
const formLoading = ref(false)
@ -256,7 +332,7 @@ const goToDetail = (row: ReviewMeetingProjectRespVO) => {
agendaCategory: row.agendaCategory,
reporter: row.reporter,
reporterUnit: row.reporterUnit,
host: row.host,
meetingHost: meetingInfo.value?.host,
meetingName: meetingInfo.value?.name
}
})
@ -266,6 +342,11 @@ onMounted(async () => {
meetingInfo.value = await getReviewMeeting(reviewMeetingId)
await getList()
})
onBeforeUnmount(() => {
sortableInstance?.destroy()
sortableInstance = null
})
</script>
<style scoped>
@ -437,4 +518,22 @@ onMounted(async () => {
:deep(.review-table .el-table__body tr:hover > td) {
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>

View File

@ -71,12 +71,14 @@
<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-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:cancel']" class="op-link op-danger" @click="handleCancel(row)"></a>
</template>
<template v-else>
<a class="op-link" @click="goToEdit(row.id, 'view')">查看</a>
<a class="op-link" @click="goToProjectList(row)"></a>
<a v-hasPermi="['review:meeting:create']" class="op-link" @click="handleCopy(row)"></a>
</template>
</template>
</el-table-column>
@ -90,6 +92,14 @@
<!-- 邮件状态弹窗 -->
<MailStatusDialog ref="mailStatusRef" />
<input
ref="minutesUploadInputRef"
type="file"
style="display: none"
accept=".doc,.docx,.pdf,.png,.jpg,.jpeg,.gif,.bmp,.webp"
@change="handleMinutesFileChange"
/>
</ContentWrap>
</template>
@ -103,6 +113,7 @@ import {
finishReviewMeeting,
sendSmsInvitation,
sendMailInvitation,
uploadMinutesAttachment,
type ReviewMeetingRespVO,
type ReviewMeetingPageReqVO
} from '@/api/review/meeting'
@ -133,9 +144,10 @@ const queryParams = reactive<ReviewMeetingPageReqVO & { pageNo: number; pageSize
startTime: undefined
})
const queryFormRef = ref()
const smsStatusRef = ref()
const mailStatusRef = ref()
const minutesUploadInputRef = ref<HTMLInputElement>()
const pendingMinutesMeeting = ref<ReviewMeetingRespVO>()
const getList = async () => {
loading.value = true
@ -182,6 +194,41 @@ const handleFinish = async (row: ReviewMeetingRespVO) => {
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) => {
await ElMessageBox.confirm(
`确认向 ${row.expertCount} 位专家发送「${row.name}」会议邀约短信?`,