admin-vue3/src/views/review/meeting/MeetingEdit.vue

1140 lines
33 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<template>
<ContentWrap>
<div class="page-title-bar">
<span class="back-btn" @click="handleBack">&#8249;</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>