1140 lines
33 KiB
Vue
1140 lines
33 KiB
Vue
<template>
|
||
<ContentWrap>
|
||
<div class="page-title-bar">
|
||
<span class="back-btn" @click="handleBack">‹</span>
|
||
<span class="page-title">{{ pageTitle }}</span>
|
||
</div>
|
||
|
||
<el-form
|
||
ref="formRef"
|
||
:model="formData"
|
||
:rules="isView ? {} : rules"
|
||
label-width="100px"
|
||
v-loading="formLoading"
|
||
>
|
||
<!-- 基本信息 -->
|
||
<div class="section-header"><span class="section-title">基本信息</span></div>
|
||
<el-row :gutter="16">
|
||
<el-col :span="12">
|
||
<el-form-item label="会议名称" prop="name">
|
||
<el-input v-model="formData.name" placeholder="请输入会议名称" :disabled="isView" />
|
||
</el-form-item>
|
||
</el-col>
|
||
<el-col :span="12">
|
||
<el-form-item label="组织单位" prop="organizationUnit">
|
||
<el-input
|
||
v-model="formData.organizationUnit"
|
||
placeholder="请输入组织单位"
|
||
:disabled="isView"
|
||
/>
|
||
</el-form-item>
|
||
</el-col>
|
||
</el-row>
|
||
<el-row :gutter="16">
|
||
<el-col :span="12">
|
||
<el-form-item label="会议时间" prop="meetingTimeRange">
|
||
<el-date-picker
|
||
v-model="formData.meetingTimeRange"
|
||
type="datetimerange"
|
||
range-separator="至"
|
||
start-placeholder="开始时间"
|
||
end-placeholder="结束时间"
|
||
format="YYYY-MM-DD HH:mm"
|
||
value-format="x"
|
||
:disabled="isView"
|
||
style="width: 100%"
|
||
/>
|
||
</el-form-item>
|
||
</el-col>
|
||
<el-col :span="12">
|
||
<el-form-item label="会议地点" prop="location">
|
||
<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-row>
|
||
|
||
<!-- 评审项目 -->
|
||
<div class="section-header"><span class="section-title">评审项目</span></div>
|
||
<div v-if="!isView" class="import-section">
|
||
<el-upload
|
||
:auto-upload="false"
|
||
:on-change="handleExcelChange"
|
||
:show-file-list="false"
|
||
accept=".xls,.xlsx"
|
||
>
|
||
<button type="button" class="btn-default">导入验收申请 Excel</button>
|
||
</el-upload>
|
||
<button type="button" class="btn-default" @click="handleDownloadTemplate"
|
||
>下载导入模板</button
|
||
>
|
||
<span class="import-hint">格式:序号、议程分类、项目标题、汇报人、报告人单位</span>
|
||
</div>
|
||
|
||
<div v-if="formData.projects && formData.projects.length > 0" class="mt-10">
|
||
<el-table :data="formData.projects" border max-height="360" class="projects-preview-table">
|
||
<el-table-column label="序号" prop="seqNo" width="80" align="center" />
|
||
<el-table-column label="起止时间" width="180" align="center">
|
||
<template #default="{ row }">
|
||
{{ row.startTime || '--:--' }} - {{ row.endTime || '--:--' }}
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="议程分类" prop="agendaCategory" width="160" />
|
||
<el-table-column label="评审项目标题" prop="projectTitle" show-overflow-tooltip />
|
||
<el-table-column label="汇报人" prop="reporter" width="120" />
|
||
<el-table-column label="报告单位" prop="reporterUnit" show-overflow-tooltip width="220" />
|
||
</el-table>
|
||
</div>
|
||
<el-empty v-else-if="isView" description="暂无评审项目" :image-size="60" />
|
||
|
||
<!-- 评审专家 -->
|
||
<div class="section-header"><span class="section-title">评审专家</span></div>
|
||
<ExpertSelectTable
|
||
v-model="formData.expertIds"
|
||
:experts="expertOptions"
|
||
:disabled="isView"
|
||
/>
|
||
|
||
<!-- 会议附件 -->
|
||
<div class="section-header"><span class="section-title">会议附件</span></div>
|
||
<div class="attachment-cards">
|
||
<!-- 议程附件卡片 -->
|
||
<div class="attachment-card">
|
||
<div class="attachment-card-header">
|
||
<span class="attachment-card-title">议程附件</span>
|
||
</div>
|
||
<div class="attachment-card-body">
|
||
<div v-if="formData.agendaAttachmentUrl" class="attachment-file-info">
|
||
<el-link type="primary" :underline="false" @click="previewAgendaAttachment">
|
||
{{ formData.agendaAttachmentName }}
|
||
</el-link>
|
||
<el-tag size="small">{{
|
||
(formData.agendaAttachmentType || '').toUpperCase()
|
||
}}</el-tag>
|
||
<el-text type="info" size="small">{{
|
||
formatFileSize(formData.agendaAttachmentSize)
|
||
}}</el-text>
|
||
<el-button v-if="!isView" type="danger" link @click="clearAgendaAttachment"
|
||
>移除</el-button
|
||
>
|
||
</div>
|
||
<el-empty v-else description="暂无议程附件" :image-size="40" />
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 会议纪要卡片 -->
|
||
<div class="attachment-card">
|
||
<div class="attachment-card-header">
|
||
<span class="attachment-card-title">会议纪要</span>
|
||
</div>
|
||
<div class="attachment-card-body">
|
||
<div v-if="formData.minutesAttachmentUrl" class="attachment-file-info">
|
||
<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>
|
||
</div>
|
||
<el-empty v-else description="暂无会议纪要" :image-size="40" />
|
||
<div
|
||
v-if="formData.minutesAttachmentUrl && formData.minutesAiStatusName"
|
||
class="minutes-ai-line"
|
||
>
|
||
<el-tag size="small" :type="getMinutesAiTagType(formData.minutesAiStatus)">
|
||
{{ formData.minutesAiStatusName }}
|
||
</el-tag>
|
||
<el-text v-if="formData.minutesAiUpdatedTime" type="info" size="small">
|
||
最近生成:{{ formData.minutesAiUpdatedTime }}
|
||
</el-text>
|
||
<el-text v-if="formData.minutesAiErrorMessage" type="danger" size="small">
|
||
{{ formData.minutesAiErrorMessage }}
|
||
</el-text>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</el-form>
|
||
|
||
<!-- 底部操作区 -->
|
||
<div class="form-footer">
|
||
<button
|
||
v-if="!isView"
|
||
type="button"
|
||
class="btn-primary"
|
||
:disabled="formLoading || isSubmitting"
|
||
@click="submitForm"
|
||
>
|
||
{{ formLoading || isSubmitting ? '提交中...' : '提交' }}
|
||
</button>
|
||
<button type="button" class="btn-default" @click="handleBack">返回</button>
|
||
</div>
|
||
|
||
<!-- 上传议程附件弹窗 -->
|
||
<el-dialog
|
||
v-model="agendaDialogVisible"
|
||
title="上传议程附件"
|
||
width="500px"
|
||
:close-on-click-modal="false"
|
||
>
|
||
<div class="agenda-dialog-content">
|
||
<div class="agenda-dialog-buttons">
|
||
<div class="btn-wrapper">
|
||
<el-upload
|
||
:auto-upload="false"
|
||
:show-file-list="false"
|
||
accept=".docx,.pdf,.png,.jpg,.jpeg,.gif,.bmp,.webp"
|
||
:on-change="handleDialogAgendaUpload"
|
||
>
|
||
<button type="button" class="btn-dialog-action">上传议程附件</button>
|
||
</el-upload>
|
||
</div>
|
||
<div class="btn-wrapper">
|
||
<button
|
||
type="button"
|
||
class="btn-dialog-action"
|
||
:disabled="agendaDialogLoading"
|
||
@click="handleDialogGenerateAgenda"
|
||
>
|
||
AI生成议程
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<span class="upload-hint">支持 DOCX、PDF、图片;固定单附件</span>
|
||
<div v-if="formData.agendaAttachmentUrl" class="agenda-file-line mt-10">
|
||
<el-link type="primary" :underline="false" @click="previewAgendaAttachment">
|
||
{{ formData.agendaAttachmentName }}
|
||
</el-link>
|
||
<el-tag size="small">{{
|
||
(formData.agendaAttachmentType || '').toUpperCase()
|
||
}}</el-tag>
|
||
<el-text type="info" size="small">{{
|
||
formatFileSize(formData.agendaAttachmentSize)
|
||
}}</el-text>
|
||
</div>
|
||
</div>
|
||
<template #footer>
|
||
<el-button type="primary" :disabled="agendaDialogLoading || isSubmitting" @click="handleDialogConfirm">
|
||
{{ agendaDialogLoading || isSubmitting ? '提交中...' : '确定' }}
|
||
</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<!-- 文件预览对话框 -->
|
||
<FilePreviewDialog
|
||
v-if="formData.id"
|
||
v-model="previewDialogVisible"
|
||
:meeting-id="formData.id"
|
||
:attachment-type="previewAttachmentType"
|
||
:file-name="previewAttachmentType === 'agenda' ? formData.agendaAttachmentName : formData.minutesAttachmentName"
|
||
/>
|
||
</ContentWrap>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, reactive, computed, onMounted, watch } from 'vue'
|
||
import { useRouter, useRoute } from 'vue-router'
|
||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||
import type { FormRules } from 'element-plus'
|
||
import type { UploadFile } from 'element-plus'
|
||
import {
|
||
createReviewMeeting,
|
||
updateReviewMeeting,
|
||
getReviewMeeting,
|
||
importProjectsFromExcel,
|
||
getImportTemplate,
|
||
generateAgendaAttachment,
|
||
uploadAgendaAttachment,
|
||
type ReviewMeetingAgendaGenerateReqVO,
|
||
type ReviewMeetingSaveReqVO,
|
||
type ReviewProjectItemVO
|
||
} from '@/api/review/meeting'
|
||
import { getReviewProjectPage, updateReviewProjectTimeBatch } from '@/api/review/project'
|
||
import { getExpertUserList } from '@/api/system/user/index'
|
||
import download from '@/utils/download'
|
||
import ExpertSelectTable from './components/ExpertSelectTable.vue'
|
||
import FilePreviewDialog from './components/FilePreviewDialog.vue'
|
||
import { applyDefaultReviewDate } from './projectReviewDate'
|
||
import {
|
||
buildScheduledProjectItems,
|
||
DEFAULT_REVIEW_MEETING_INTERVAL_MINUTES
|
||
} from './projectSchedule'
|
||
import {
|
||
buildProjectTimeBatchPayload,
|
||
mapCopiedProjectItems,
|
||
mapPersistedProjectItems,
|
||
shouldSyncPersistedProjectTimes,
|
||
type MeetingEditProjectItem
|
||
} from './meetingEditHelpers'
|
||
|
||
defineOptions({ name: 'ReviewMeetingEdit' })
|
||
|
||
const router = useRouter()
|
||
const route = useRoute()
|
||
|
||
const formLoading = ref(false)
|
||
const expertOptions = ref<any[]>([])
|
||
const isProjectsModified = ref(false)
|
||
const meetingLocationOptions = ['东5楼326', '南6楼203', '南6楼207']
|
||
const isSubmitting = ref(false) // 防止重复提交
|
||
|
||
// 上传会议附件弹窗
|
||
const agendaDialogVisible = ref(false)
|
||
const agendaDialogLoading = ref(false)
|
||
|
||
// 预览对话框
|
||
const previewDialogVisible = ref(false)
|
||
const previewAttachmentType = ref<'agenda' | 'minutes'>('agenda')
|
||
|
||
// 从路由参数判断模式
|
||
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 ? '编辑会议' : '新建会议'
|
||
})
|
||
|
||
type FormData = ReviewMeetingSaveReqVO & {
|
||
organizationUnit?: string
|
||
meetingTimeRange?: any[]
|
||
minutesAiStatus?: number
|
||
minutesAiStatusName?: string
|
||
minutesAiErrorMessage?: string
|
||
minutesAiUpdatedTime?: string
|
||
projects: MeetingEditProjectItem[]
|
||
}
|
||
|
||
const formData = reactive<FormData>({
|
||
id: undefined,
|
||
name: '',
|
||
organizationUnit: undefined,
|
||
startTime: undefined,
|
||
endTime: undefined,
|
||
location: '东5楼326',
|
||
host: undefined,
|
||
agendaAttachmentName: undefined,
|
||
agendaAttachmentUrl: undefined,
|
||
agendaAttachmentType: undefined,
|
||
agendaAttachmentSize: undefined,
|
||
minutesAttachmentName: undefined,
|
||
minutesAttachmentUrl: undefined,
|
||
minutesAttachmentType: undefined,
|
||
minutesAttachmentSize: undefined,
|
||
minutesAiStatus: undefined,
|
||
minutesAiStatusName: undefined,
|
||
minutesAiErrorMessage: undefined,
|
||
minutesAiUpdatedTime: undefined,
|
||
expertIds: [],
|
||
meetingTimeRange: undefined,
|
||
projects: []
|
||
})
|
||
|
||
const rules: FormRules = {
|
||
name: [{ required: true, message: '会议名称不能为空', trigger: 'blur' }],
|
||
organizationUnit: [{ required: true, message: '组织单位不能为空', trigger: 'blur' }],
|
||
meetingTimeRange: [{ required: true, message: '会议时间不能为空', trigger: 'change' }],
|
||
location: [{ required: true, message: '会议地点不能为空', trigger: 'blur' }],
|
||
host: [{ required: true, message: '会议主持人不能为空', trigger: 'blur' }],
|
||
expertIds: [
|
||
{ required: true, type: 'array', min: 1, message: '至少选择一位专家', trigger: 'change' }
|
||
]
|
||
}
|
||
|
||
const formRef = ref()
|
||
const originalMeetingStart = ref<string | number | undefined>()
|
||
|
||
const getMinutesAiTagType = (status?: number) => {
|
||
if (status === 2) return 'success'
|
||
if (status === 3) return 'danger'
|
||
if (status === 1) return 'warning'
|
||
return 'info'
|
||
}
|
||
|
||
const resetProjectReviewDate = (projects: MeetingEditProjectItem[]): MeetingEditProjectItem[] =>
|
||
(projects || []).map((item) => ({
|
||
...item,
|
||
reviewDate: undefined
|
||
}))
|
||
|
||
const buildPreviewScheduledProjects = (
|
||
projects: MeetingEditProjectItem[] = formData.projects
|
||
): MeetingEditProjectItem[] => {
|
||
const projectsWithReviewDate = applyDefaultReviewDate(
|
||
projects,
|
||
formData.meetingTimeRange
|
||
) as MeetingEditProjectItem[]
|
||
return (
|
||
buildScheduledProjectItems(
|
||
projectsWithReviewDate,
|
||
formData.meetingTimeRange?.[0],
|
||
DEFAULT_REVIEW_MEETING_INTERVAL_MINUTES
|
||
) || projectsWithReviewDate
|
||
)
|
||
}
|
||
|
||
const stripProjectTransientFields = (projects: MeetingEditProjectItem[]): ReviewProjectItemVO[] =>
|
||
(projects || []).map(({ id: _id, ...item }) => item)
|
||
|
||
const loadDetail = async (id: number) => {
|
||
formLoading.value = true
|
||
try {
|
||
const [detail, projectData] = await Promise.all([
|
||
getReviewMeeting(id),
|
||
getReviewProjectPage({ reviewMeetingId: id, pageNo: 1, pageSize: 200 })
|
||
])
|
||
Object.assign(formData, detail)
|
||
formData.projects = mapPersistedProjectItems(projectData?.list ?? [])
|
||
if (detail.startTime && detail.endTime) {
|
||
formData.meetingTimeRange = [
|
||
new Date(detail.startTime.replace(' ', 'T')).getTime(),
|
||
new Date(detail.endTime.replace(' ', 'T')).getTime()
|
||
]
|
||
}
|
||
originalMeetingStart.value = formData.meetingTimeRange?.[0]
|
||
} finally {
|
||
formLoading.value = false
|
||
}
|
||
}
|
||
|
||
const loadCopySource = async (id: number) => {
|
||
formLoading.value = true
|
||
try {
|
||
const [detail, projectData] = await Promise.all([
|
||
getReviewMeeting(id),
|
||
getReviewProjectPage({ reviewMeetingId: id, pageNo: 1, pageSize: 200 })
|
||
])
|
||
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.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.minutesAiStatus = undefined
|
||
formData.minutesAiStatusName = undefined
|
||
formData.minutesAiErrorMessage = undefined
|
||
formData.minutesAiUpdatedTime = undefined
|
||
formData.expertIds = detail.expertIds || []
|
||
formData.projects = resetProjectReviewDate(mapCopiedProjectItems(projectData?.list ?? []))
|
||
isProjectsModified.value = false
|
||
|
||
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
|
||
}
|
||
originalMeetingStart.value = formData.meetingTimeRange?.[0]
|
||
|
||
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'
|
||
}
|
||
})
|
||
|
||
watch(
|
||
() => formData.meetingTimeRange?.[0],
|
||
(newValue, oldValue) => {
|
||
if (newValue === oldValue || oldValue === undefined || !formData.projects.length) {
|
||
return
|
||
}
|
||
formData.projects = buildPreviewScheduledProjects(formData.projects)
|
||
}
|
||
)
|
||
|
||
const handleExcelChange = async (uploadFile: UploadFile) => {
|
||
if (!uploadFile.raw) return
|
||
if (formData.projects && formData.projects.length > 0) {
|
||
await ElMessageBox.confirm('重新导入将覆盖已有评审项目列表,是否继续?', '提示', {
|
||
type: 'warning'
|
||
})
|
||
}
|
||
formLoading.value = true
|
||
try {
|
||
const result = await importProjectsFromExcel(uploadFile.raw)
|
||
const projects = applyDefaultReviewDate(
|
||
result as ReviewProjectItemVO[],
|
||
formData.meetingTimeRange
|
||
)
|
||
formData.projects = (buildScheduledProjectItems(projects, formData.meetingTimeRange?.[0]) ||
|
||
projects) as MeetingEditProjectItem[]
|
||
isProjectsModified.value = true
|
||
ElMessage.success(`成功解析 ${formData.projects.length} 个评审项目`)
|
||
} catch {
|
||
ElMessage.error('Excel 解析失败,请检查文件格式')
|
||
} finally {
|
||
formLoading.value = false
|
||
}
|
||
}
|
||
|
||
const handleDownloadTemplate = async () => {
|
||
try {
|
||
const data = await getImportTemplate()
|
||
download.excel(data, '评审项目导入模板.xls')
|
||
} catch {
|
||
ElMessage.error('下载模板失败')
|
||
}
|
||
}
|
||
|
||
const clearAgendaAttachment = () => {
|
||
formData.agendaAttachmentName = undefined
|
||
formData.agendaAttachmentUrl = undefined
|
||
formData.agendaAttachmentType = undefined
|
||
formData.agendaAttachmentSize = undefined
|
||
}
|
||
|
||
const previewAgendaAttachment = () => {
|
||
if (formData.agendaAttachmentUrl) {
|
||
previewAttachmentType.value = 'agenda'
|
||
previewDialogVisible.value = true
|
||
}
|
||
}
|
||
|
||
const previewMinutesAttachment = () => {
|
||
if (formData.minutesAttachmentUrl) {
|
||
previewAttachmentType.value = 'minutes'
|
||
previewDialogVisible.value = true
|
||
}
|
||
}
|
||
|
||
const formatFileSize = (bytes?: number): string => {
|
||
if (!bytes) return '-'
|
||
if (bytes < 1024) return `${bytes} B`
|
||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||
}
|
||
|
||
const buildScheduledProjects = (): MeetingEditProjectItem[] => {
|
||
const projectsWithReviewDate = applyDefaultReviewDate(
|
||
formData.projects,
|
||
formData.meetingTimeRange
|
||
) as MeetingEditProjectItem[]
|
||
const hasCompleteSchedule = projectsWithReviewDate.every((item) => item.startTime && item.endTime)
|
||
if (hasCompleteSchedule) {
|
||
return projectsWithReviewDate
|
||
}
|
||
return (
|
||
buildScheduledProjectItems(
|
||
projectsWithReviewDate,
|
||
formData.meetingTimeRange?.[0],
|
||
DEFAULT_REVIEW_MEETING_INTERVAL_MINUTES
|
||
) || projectsWithReviewDate
|
||
)
|
||
}
|
||
|
||
const buildAgendaGenerateData = (): ReviewMeetingAgendaGenerateReqVO | undefined => {
|
||
if (!formData.name?.trim()) {
|
||
ElMessage.warning('请先填写会议名称')
|
||
return
|
||
}
|
||
if (!formData.organizationUnit?.trim()) {
|
||
ElMessage.warning('请先填写组织单位')
|
||
return
|
||
}
|
||
if (!formData.location?.trim()) {
|
||
ElMessage.warning('请先填写会议地点')
|
||
return
|
||
}
|
||
if (!formData.host?.trim()) {
|
||
ElMessage.warning('请先填写会议主持人')
|
||
return
|
||
}
|
||
if (!formData.meetingTimeRange || formData.meetingTimeRange.length !== 2) {
|
||
ElMessage.warning('请先填写会议时间')
|
||
return
|
||
}
|
||
if (!formData.expertIds?.length) {
|
||
ElMessage.warning('请先选择参会专家')
|
||
return
|
||
}
|
||
if (!formData.projects?.length) {
|
||
ElMessage.warning('请先导入评审项目')
|
||
return
|
||
}
|
||
const projects = buildScheduledProjects()
|
||
const hasMissingSchedule = projects.some((item) => !item.startTime || !item.endTime)
|
||
if (hasMissingSchedule) {
|
||
ElMessage.warning('评审项目起止时间未生成完成,请检查会议时间和项目列表')
|
||
return
|
||
}
|
||
return {
|
||
name: formData.name,
|
||
organizationUnit: formData.organizationUnit,
|
||
startTime: formData.meetingTimeRange[0],
|
||
endTime: formData.meetingTimeRange[1],
|
||
location: formData.location,
|
||
host: formData.host,
|
||
expertIds: formData.expertIds,
|
||
projects
|
||
}
|
||
}
|
||
|
||
const submitForm = async () => {
|
||
if (formLoading.value || isSubmitting.value) return
|
||
const valid = await formRef.value?.validate().catch(() => false)
|
||
if (!valid) return
|
||
if (formData.meetingTimeRange?.length === 2) {
|
||
formData.startTime = formData.meetingTimeRange[0]
|
||
formData.endTime = formData.meetingTimeRange[1]
|
||
}
|
||
// 编辑模式且有议程附件:直接保存
|
||
if (isEdit.value && formData.agendaAttachmentUrl) {
|
||
isSubmitting.value = true
|
||
formLoading.value = true
|
||
try {
|
||
const projects = buildScheduledProjects()
|
||
const submitProjects = stripProjectTransientFields(projects)
|
||
const submitData: ReviewMeetingSaveReqVO & { projects?: ReviewProjectItemVO[] } = {
|
||
...formData,
|
||
projects: submitProjects
|
||
}
|
||
if (!isProjectsModified.value) {
|
||
delete submitData.projects
|
||
}
|
||
await updateReviewMeeting(submitData)
|
||
const shouldSyncProjectTimes = shouldSyncPersistedProjectTimes({
|
||
isEdit: isEdit.value,
|
||
isProjectsModified: isProjectsModified.value,
|
||
originalMeetingStart: originalMeetingStart.value,
|
||
currentMeetingStart: formData.meetingTimeRange?.[0],
|
||
projects
|
||
})
|
||
if (shouldSyncProjectTimes) {
|
||
const timeBatchPayload = buildProjectTimeBatchPayload(projects)
|
||
if (timeBatchPayload) {
|
||
await updateReviewProjectTimeBatch(timeBatchPayload)
|
||
}
|
||
}
|
||
originalMeetingStart.value = formData.meetingTimeRange?.[0]
|
||
ElMessage.success('更新成功')
|
||
router.push({ name: 'ReviewMeeting' })
|
||
} finally {
|
||
formLoading.value = false
|
||
isSubmitting.value = false
|
||
}
|
||
} else {
|
||
// 新建模式或编辑模式下议程附件为空:弹出上传议程附件弹窗
|
||
agendaDialogVisible.value = true
|
||
}
|
||
}
|
||
|
||
// 弹窗内上传议程附件
|
||
const handleDialogAgendaUpload = async (uploadFile: UploadFile) => {
|
||
if (!uploadFile.raw) return
|
||
const ext = uploadFile.raw.name.includes('.')
|
||
? uploadFile.raw.name.substring(uploadFile.raw.name.lastIndexOf('.') + 1).toLowerCase()
|
||
: ''
|
||
const isDocx = ext === 'docx'
|
||
const isPdf = ext === 'pdf' || uploadFile.raw.type === 'application/pdf'
|
||
const isImage = uploadFile.raw.type?.startsWith('image/')
|
||
if (!isDocx && !isPdf && !isImage) {
|
||
ElMessage.error('议程附件仅支持 DOCX、PDF 或图片')
|
||
return
|
||
}
|
||
agendaDialogLoading.value = true
|
||
try {
|
||
const attachment = await uploadAgendaAttachment(uploadFile.raw)
|
||
formData.agendaAttachmentName = attachment.name
|
||
formData.agendaAttachmentUrl = attachment.url
|
||
formData.agendaAttachmentType = attachment.type
|
||
formData.agendaAttachmentSize = attachment.size
|
||
ElMessage.success('议程附件上传成功')
|
||
} finally {
|
||
agendaDialogLoading.value = false
|
||
}
|
||
}
|
||
|
||
// 弹窗内自动生成议程
|
||
const handleDialogGenerateAgenda = async () => {
|
||
if (agendaDialogLoading.value) return
|
||
const generateData = buildAgendaGenerateData()
|
||
if (!generateData) return
|
||
agendaDialogLoading.value = true
|
||
try {
|
||
const attachment = await generateAgendaAttachment(generateData)
|
||
formData.agendaAttachmentName = attachment.name
|
||
formData.agendaAttachmentUrl = attachment.url
|
||
formData.agendaAttachmentType = attachment.type
|
||
formData.agendaAttachmentSize = attachment.size
|
||
ElMessage.success('议程附件已生成')
|
||
} finally {
|
||
agendaDialogLoading.value = false
|
||
}
|
||
}
|
||
|
||
// 弹窗确定按钮 - 真正保存草稿
|
||
const handleDialogConfirm = async () => {
|
||
if (agendaDialogLoading.value || isSubmitting.value) return
|
||
if (!formData.agendaAttachmentUrl) {
|
||
ElMessage.warning('请上传或生成议程附件')
|
||
return
|
||
}
|
||
isSubmitting.value = true
|
||
agendaDialogLoading.value = true
|
||
try {
|
||
const projects = buildScheduledProjects()
|
||
const submitProjects = stripProjectTransientFields(projects)
|
||
const submitData: ReviewMeetingSaveReqVO & { projects?: ReviewProjectItemVO[] } = {
|
||
...formData,
|
||
projects: submitProjects
|
||
}
|
||
if (isEdit.value) {
|
||
// 编辑模式:更新会议
|
||
await updateReviewMeeting(submitData)
|
||
ElMessage.success('更新成功')
|
||
} else {
|
||
// 新建模式:创建会议
|
||
await createReviewMeeting(submitData)
|
||
ElMessage.success('保存成功,会议已保存为草稿')
|
||
}
|
||
agendaDialogVisible.value = false
|
||
router.push({ name: 'ReviewMeeting' })
|
||
} finally {
|
||
agendaDialogLoading.value = false
|
||
isSubmitting.value = false
|
||
}
|
||
}
|
||
|
||
const handleBack = () => {
|
||
router.back()
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
/* ── 页面标题 ── */
|
||
.page-title-bar {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
margin-bottom: 16px;
|
||
padding-bottom: 12px;
|
||
border-bottom: 1px solid #e1e7f0;
|
||
}
|
||
.back-btn {
|
||
font-size: 30px;
|
||
color: #295abc;
|
||
cursor: pointer;
|
||
line-height: 1;
|
||
font-weight: 400;
|
||
}
|
||
.back-btn:hover {
|
||
opacity: 0.75;
|
||
}
|
||
.page-title {
|
||
font-size: 24px;
|
||
font-weight: 600;
|
||
color: #333;
|
||
}
|
||
|
||
/* ── 分区标题 ── */
|
||
.section-header {
|
||
margin: 18px 0 12px;
|
||
}
|
||
.section-title {
|
||
position: relative;
|
||
font-size: 18px;
|
||
font-weight: 600;
|
||
color: #333;
|
||
padding-left: 12px;
|
||
}
|
||
.section-title::before {
|
||
content: '';
|
||
position: absolute;
|
||
left: 0;
|
||
top: 2px;
|
||
bottom: 2px;
|
||
width: 3px;
|
||
border-radius: 2px;
|
||
background-color: #295abc;
|
||
}
|
||
|
||
/* ── 表单 item 字号 ── */
|
||
:deep(.el-form-item__label) {
|
||
font-size: 16px;
|
||
color: #555;
|
||
}
|
||
:deep(.el-form-item) {
|
||
margin-bottom: 20px;
|
||
}
|
||
:deep(.el-input__inner),
|
||
:deep(.el-textarea__inner),
|
||
:deep(.el-select__selected-item),
|
||
:deep(.el-select__placeholder),
|
||
:deep(.el-range-input) {
|
||
font-size: 16px;
|
||
}
|
||
:deep(.el-input__wrapper),
|
||
:deep(.el-select__wrapper),
|
||
:deep(.el-range-editor.el-input__wrapper) {
|
||
min-height: 42px;
|
||
}
|
||
:deep(.el-form-item__content) {
|
||
line-height: 1.5;
|
||
}
|
||
|
||
/* ── 议程附件 ── */
|
||
.agenda-attachment-wrap {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
}
|
||
.agenda-file-line {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
flex-wrap: wrap;
|
||
font-size: 16px;
|
||
}
|
||
.minutes-ai-line {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
flex-wrap: wrap;
|
||
}
|
||
.agenda-action-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
flex-wrap: wrap;
|
||
}
|
||
.upload-hint {
|
||
font-size: 14px;
|
||
color: #999;
|
||
}
|
||
|
||
/* ── 导入区 ── */
|
||
.import-section {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
flex-wrap: wrap;
|
||
margin-bottom: 12px;
|
||
}
|
||
.import-hint {
|
||
font-size: 14px;
|
||
color: #999;
|
||
}
|
||
.mt-10 {
|
||
margin-top: 10px;
|
||
}
|
||
|
||
/* ── 按钮 ── */
|
||
.btn-upload {
|
||
height: 38px;
|
||
padding: 0 16px;
|
||
background-color: #fff;
|
||
border: 1px solid #295abc;
|
||
border-radius: 6px;
|
||
color: #295abc;
|
||
font-size: 15px;
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
}
|
||
.btn-upload:hover {
|
||
background-color: rgba(41, 90, 188, 0.08);
|
||
}
|
||
|
||
.btn-generate {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
height: 38px;
|
||
padding: 0 16px;
|
||
background-color: #fff;
|
||
border: 1px solid #3aa76d;
|
||
border-radius: 6px;
|
||
color: #3aa76d;
|
||
font-size: 15px;
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
}
|
||
.btn-generate:hover {
|
||
background-color: rgba(58, 167, 109, 0.08);
|
||
}
|
||
.btn-generate:disabled {
|
||
opacity: 0.6;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
.btn-default {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
height: 40px;
|
||
padding: 0 18px;
|
||
background-color: #fff;
|
||
border: 1px solid #d5d5d5;
|
||
border-radius: 6px;
|
||
font-size: 15px;
|
||
color: #333;
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
}
|
||
.btn-default:hover {
|
||
background-color: rgba(41, 90, 188, 0.08);
|
||
border-color: #295abc;
|
||
color: #295abc;
|
||
}
|
||
|
||
.btn-primary {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
height: 40px;
|
||
padding: 0 20px;
|
||
background-color: #295abc;
|
||
border: none;
|
||
border-radius: 6px;
|
||
font-size: 15px;
|
||
color: #fff;
|
||
cursor: pointer;
|
||
transition: background-color 0.2s;
|
||
}
|
||
.btn-primary:hover {
|
||
background-color: rgba(41, 90, 188, 0.88);
|
||
}
|
||
.btn-primary:disabled {
|
||
opacity: 0.6;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
/* ── 底部 ── */
|
||
.form-footer {
|
||
margin-top: 20px;
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
gap: 12px;
|
||
}
|
||
|
||
/* ── 上传议程附件弹窗 ── */
|
||
.agenda-dialog-content {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
}
|
||
.agenda-dialog-buttons {
|
||
display: flex;
|
||
gap: 12px;
|
||
}
|
||
.agenda-dialog-buttons .btn-wrapper {
|
||
flex: 1;
|
||
}
|
||
.agenda-dialog-buttons .btn-wrapper :deep(.el-upload) {
|
||
width: 100%;
|
||
}
|
||
.agenda-dialog-buttons .btn-wrapper :deep(.el-upload .el-upload__input) {
|
||
display: none;
|
||
}
|
||
.agenda-dialog-buttons .btn-dialog-action {
|
||
width: 100%;
|
||
height: 40px;
|
||
padding: 0 16px;
|
||
background-color: #f5f7fa;
|
||
border: 1px solid #dcdfe6;
|
||
border-radius: 8px;
|
||
color: #606266;
|
||
font-size: 14px;
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
box-sizing: border-box;
|
||
}
|
||
.agenda-dialog-buttons .btn-dialog-action:hover {
|
||
background-color: #e6f0ff;
|
||
border-color: #295abc;
|
||
color: #295abc;
|
||
}
|
||
.agenda-dialog-buttons .btn-dialog-action:disabled {
|
||
opacity: 0.6;
|
||
cursor: not-allowed;
|
||
}
|
||
.agenda-dialog-buttons .btn-wrapper:last-child .btn-dialog-action {
|
||
background-color: #e6f7f0;
|
||
border-color: #67c23a;
|
||
color: #67c23a;
|
||
}
|
||
.agenda-dialog-buttons .btn-wrapper:last-child .btn-dialog-action:hover {
|
||
background-color: #d1f0e0;
|
||
border-color: #5daf34;
|
||
color: #5daf34;
|
||
}
|
||
|
||
/* ── 项目预览表格 ── */
|
||
:deep(.projects-preview-table .el-table__header-wrapper th) {
|
||
background-color: #eef2fb;
|
||
color: #333;
|
||
font-weight: 600;
|
||
font-size: 18px;
|
||
border-color: #e1e7f0;
|
||
}
|
||
:deep(.projects-preview-table .el-table__header-wrapper th.el-table__cell) {
|
||
padding-top: 12px;
|
||
padding-bottom: 12px;
|
||
}
|
||
:deep(.projects-preview-table .el-table__header-wrapper th .cell) {
|
||
justify-content: center;
|
||
text-align: center;
|
||
}
|
||
:deep(.projects-preview-table .el-table__body td) {
|
||
font-size: 18px;
|
||
color: #333;
|
||
border-color: #e1e7f0;
|
||
padding-top: 12px;
|
||
padding-bottom: 12px;
|
||
}
|
||
:deep(.projects-preview-table .el-table__body td .cell),
|
||
:deep(.projects-preview-table .el-table__header-wrapper th .cell) {
|
||
line-height: 1.5;
|
||
}
|
||
|
||
/* ── 会议附件卡片 ── */
|
||
.attachment-cards {
|
||
display: flex;
|
||
gap: 12px;
|
||
flex-wrap: wrap;
|
||
}
|
||
.attachment-card {
|
||
background: linear-gradient(135deg, #f8f9fa 0%, #ffffff 100%);
|
||
border: 1px solid #e8eaef;
|
||
border-radius: 8px;
|
||
padding: 12px 14px;
|
||
min-width: 320px;
|
||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
|
||
transition: all 0.2s ease;
|
||
}
|
||
.attachment-card:hover {
|
||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||
border-color: #d0d4dc;
|
||
}
|
||
.attachment-card-header {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
margin-bottom: 8px;
|
||
}
|
||
.attachment-card-title {
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
color: #6b7280;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.3px;
|
||
}
|
||
.attachment-card-body {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
}
|
||
.attachment-file-info {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
flex-wrap: wrap;
|
||
min-width: 0;
|
||
}
|
||
.attachment-file-info .el-link {
|
||
font-size: 13px;
|
||
color: #3b82f6;
|
||
font-weight: 500;
|
||
word-break: break-all;
|
||
flex: 1;
|
||
min-width: 100px;
|
||
}
|
||
.attachment-file-info .el-link:hover {
|
||
text-decoration: underline;
|
||
}
|
||
.attachment-file-info .el-tag {
|
||
background-color: #eef2ff;
|
||
color: #6366f1;
|
||
border-color: #e0e7ff;
|
||
font-weight: 500;
|
||
}
|
||
.attachment-file-info .el-text {
|
||
color: #9ca3af;
|
||
font-size: 12px;
|
||
}
|
||
.attachment-file-info .el-button {
|
||
color: #ef4444;
|
||
font-size: 12px;
|
||
}
|
||
.attachment-file-info .el-button:hover {
|
||
color: #dc2626;
|
||
}
|
||
.minutes-ai-line {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
flex-wrap: wrap;
|
||
padding-top: 4px;
|
||
border-top: 1px solid #f0f0f0;
|
||
}
|
||
.minutes-ai-line .el-tag {
|
||
font-weight: 500;
|
||
}
|
||
.minutes-ai-line .el-text {
|
||
color: #9ca3af;
|
||
font-size: 11px;
|
||
}
|
||
</style>
|