完成邮箱发送会议议程功能
parent
8f893c0afd
commit
167af744b9
|
|
@ -25,6 +25,10 @@ export interface ReviewMeetingSaveReqVO {
|
||||||
materialViewEndTime?: string | number
|
materialViewEndTime?: string | number
|
||||||
materialViewRemark?: string
|
materialViewRemark?: string
|
||||||
location: string
|
location: string
|
||||||
|
agendaAttachmentName?: string
|
||||||
|
agendaAttachmentUrl?: string
|
||||||
|
agendaAttachmentType?: string
|
||||||
|
agendaAttachmentSize?: number
|
||||||
expertIds: number[]
|
expertIds: number[]
|
||||||
projects?: ReviewProjectItemVO[]
|
projects?: ReviewProjectItemVO[]
|
||||||
}
|
}
|
||||||
|
|
@ -48,13 +52,25 @@ export interface ReviewMeetingRespVO {
|
||||||
materialViewEndTime?: string
|
materialViewEndTime?: string
|
||||||
materialViewRemark?: string
|
materialViewRemark?: string
|
||||||
location: string
|
location: string
|
||||||
|
agendaAttachmentName?: string
|
||||||
|
agendaAttachmentUrl?: string
|
||||||
|
agendaAttachmentType?: string
|
||||||
|
agendaAttachmentSize?: number
|
||||||
status: number // 0-草稿 1-已邀约 2-已结束 3-已取消
|
status: number // 0-草稿 1-已邀约 2-已结束 3-已取消
|
||||||
expertIds: number[]
|
expertIds: number[]
|
||||||
expertCount: number
|
expertCount: number
|
||||||
projectCount: number
|
projectCount: number
|
||||||
|
mailSent?: boolean
|
||||||
createTime: string
|
createTime: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ReviewMeetingAgendaAttachmentRespVO {
|
||||||
|
name: string
|
||||||
|
url: string
|
||||||
|
type: string
|
||||||
|
size: number
|
||||||
|
}
|
||||||
|
|
||||||
// 短信发送状态 VO
|
// 短信发送状态 VO
|
||||||
export interface ReviewMeetingSmsLogRespVO {
|
export interface ReviewMeetingSmsLogRespVO {
|
||||||
id: number
|
id: number
|
||||||
|
|
@ -67,6 +83,18 @@ export interface ReviewMeetingSmsLogRespVO {
|
||||||
sendTime: string
|
sendTime: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 邮件发送状态 VO
|
||||||
|
export interface ReviewMeetingMailLogRespVO {
|
||||||
|
id: number
|
||||||
|
expertId: number
|
||||||
|
expertName: string
|
||||||
|
mail: string
|
||||||
|
status: number // 0-待发送 1-成功 2-失败 3-已忽略
|
||||||
|
errorMsg: string
|
||||||
|
retryCount: number
|
||||||
|
sendTime: string
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// API 调用
|
// API 调用
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
@ -99,6 +127,10 @@ export const getReviewMeetingPage = (params: ReviewMeetingPageReqVO) =>
|
||||||
export const sendSmsInvitation = (id: number) =>
|
export const sendSmsInvitation = (id: number) =>
|
||||||
request.post({ url: '/project/review-meeting/send-sms', params: { id } })
|
request.post({ url: '/project/review-meeting/send-sms', params: { id } })
|
||||||
|
|
||||||
|
/** 手动发送邮件邀请函 */
|
||||||
|
export const sendMailInvitation = (id: number) =>
|
||||||
|
request.post({ url: '/project/review-meeting/send-mail', params: { id } })
|
||||||
|
|
||||||
/** 手动重发失败短信 */
|
/** 手动重发失败短信 */
|
||||||
export const retrySmsLog = (smsLogId: number) =>
|
export const retrySmsLog = (smsLogId: number) =>
|
||||||
request.post({ url: '/project/review-meeting/retry-sms', params: { smsLogId } })
|
request.post({ url: '/project/review-meeting/retry-sms', params: { smsLogId } })
|
||||||
|
|
@ -107,11 +139,25 @@ export const retrySmsLog = (smsLogId: number) =>
|
||||||
export const getSmsLogList = (reviewMeetingId: number) =>
|
export const getSmsLogList = (reviewMeetingId: number) =>
|
||||||
request.get({ url: '/project/review-meeting/sms-log-list', params: { reviewMeetingId } })
|
request.get({ url: '/project/review-meeting/sms-log-list', params: { reviewMeetingId } })
|
||||||
|
|
||||||
|
/** 获取邮件发送状态列表 */
|
||||||
|
export const getMailLogList = (reviewMeetingId: number) =>
|
||||||
|
request.get({ url: '/project/review-meeting/mail-log-list', params: { reviewMeetingId } })
|
||||||
|
|
||||||
/** 解析 Excel 导入评审项目(返回项目列表,不落库) */
|
/** 解析 Excel 导入评审项目(返回项目列表,不落库) */
|
||||||
export const importProjectsFromExcel = (file: File) => {
|
export const importProjectsFromExcel = async (file: File): Promise<ReviewProjectItemVO[]> => {
|
||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
formData.append('file', file)
|
formData.append('file', file)
|
||||||
return request.upload({ url: '/project/review-meeting/import-projects', data: formData })
|
const res = await request.upload<any>({ url: '/project/review-meeting/import-projects', data: formData })
|
||||||
|
return res?.data || []
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 上传固定议程附件(图片/PDF) */
|
||||||
|
export const uploadAgendaAttachment = (file: File): Promise<ReviewMeetingAgendaAttachmentRespVO> => {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', file)
|
||||||
|
return request
|
||||||
|
.upload<any>({ url: '/project/review-meeting/upload-agenda-attachment', data: formData })
|
||||||
|
.then((res) => res?.data as ReviewMeetingAgendaAttachmentRespVO)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 下载导入模板 */
|
/** 下载导入模板 */
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,50 @@
|
||||||
|
<template>
|
||||||
|
<el-dialog v-model="visible" :title="`邮件发送状态 - ${meetingName}`" width="760px">
|
||||||
|
<el-table :data="logList" v-loading="loading" border>
|
||||||
|
<el-table-column label="专家" prop="expertName" width="120" />
|
||||||
|
<el-table-column label="邮箱" prop="mail" min-width="200" show-overflow-tooltip />
|
||||||
|
<el-table-column label="状态" width="95" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="MAIL_STATUS_TYPE[row.status]">{{ MAIL_STATUS_LABEL[row.status] }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="重发次数" prop="retryCount" width="90" align="center" />
|
||||||
|
<el-table-column label="最后发送时间" prop="sendTime" width="170" />
|
||||||
|
<el-table-column label="失败原因" prop="errorMsg" min-width="150" show-overflow-tooltip />
|
||||||
|
</el-table>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { getMailLogList, type ReviewMeetingMailLogRespVO } from '@/api/review/meeting'
|
||||||
|
|
||||||
|
defineOptions({ name: 'MailStatusDialog' })
|
||||||
|
|
||||||
|
const visible = ref(false)
|
||||||
|
const loading = ref(false)
|
||||||
|
const meetingName = ref('')
|
||||||
|
const reviewMeetingId = ref<number>()
|
||||||
|
const logList = ref<ReviewMeetingMailLogRespVO[]>([])
|
||||||
|
|
||||||
|
const MAIL_STATUS_LABEL: Record<number, string> = { 0: '待发送', 1: '成功', 2: '失败', 3: '已忽略' }
|
||||||
|
const MAIL_STATUS_TYPE: Record<number, string> = { 0: 'info', 1: 'success', 2: 'danger', 3: 'warning' }
|
||||||
|
|
||||||
|
const open = async (id: number, name: string) => {
|
||||||
|
reviewMeetingId.value = id
|
||||||
|
meetingName.value = name
|
||||||
|
visible.value = true
|
||||||
|
await loadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadData = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
logList.value = await getMailLogList(reviewMeetingId.value!)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ open })
|
||||||
|
</script>
|
||||||
|
|
@ -33,6 +33,32 @@
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
</el-row>
|
</el-row>
|
||||||
|
<el-row :gutter="16">
|
||||||
|
<el-col :span="24">
|
||||||
|
<el-form-item label="议程附件">
|
||||||
|
<div class="agenda-attachment-wrap">
|
||||||
|
<el-upload
|
||||||
|
v-if="!isView"
|
||||||
|
:auto-upload="false"
|
||||||
|
:show-file-list="false"
|
||||||
|
accept=".pdf,.png,.jpg,.jpeg,.gif,.bmp,.webp"
|
||||||
|
:on-change="handleAgendaAttachmentChange"
|
||||||
|
>
|
||||||
|
<el-button type="primary" plain>上传议程附件</el-button>
|
||||||
|
</el-upload>
|
||||||
|
<el-text type="info" size="small">仅支持图片或 PDF,且固定单附件</el-text>
|
||||||
|
<div v-if="formData.agendaAttachmentUrl" class="agenda-file-line">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
<el-row :gutter="16">
|
<el-row :gutter="16">
|
||||||
<el-col :span="24">
|
<el-col :span="24">
|
||||||
<el-form-item label="资料查看时限" prop="materialViewTimeRange">
|
<el-form-item label="资料查看时限" prop="materialViewTimeRange">
|
||||||
|
|
@ -119,6 +145,7 @@ import {
|
||||||
getReviewMeeting,
|
getReviewMeeting,
|
||||||
importProjectsFromExcel,
|
importProjectsFromExcel,
|
||||||
getImportTemplate,
|
getImportTemplate,
|
||||||
|
uploadAgendaAttachment,
|
||||||
type ReviewMeetingSaveReqVO,
|
type ReviewMeetingSaveReqVO,
|
||||||
type ReviewProjectItemVO
|
type ReviewProjectItemVO
|
||||||
} from '@/api/review/meeting'
|
} from '@/api/review/meeting'
|
||||||
|
|
@ -147,6 +174,10 @@ const formData = reactive<FormData>({
|
||||||
startTime: undefined,
|
startTime: undefined,
|
||||||
endTime: undefined,
|
endTime: undefined,
|
||||||
location: '',
|
location: '',
|
||||||
|
agendaAttachmentName: undefined,
|
||||||
|
agendaAttachmentUrl: undefined,
|
||||||
|
agendaAttachmentType: undefined,
|
||||||
|
agendaAttachmentSize: undefined,
|
||||||
materialViewStartTime: undefined,
|
materialViewStartTime: undefined,
|
||||||
materialViewEndTime: undefined,
|
materialViewEndTime: undefined,
|
||||||
materialViewRemark: undefined,
|
materialViewRemark: undefined,
|
||||||
|
|
@ -204,6 +235,10 @@ const resetForm = () => {
|
||||||
formData.startTime = undefined
|
formData.startTime = undefined
|
||||||
formData.endTime = undefined
|
formData.endTime = undefined
|
||||||
formData.location = ''
|
formData.location = ''
|
||||||
|
formData.agendaAttachmentName = undefined
|
||||||
|
formData.agendaAttachmentUrl = undefined
|
||||||
|
formData.agendaAttachmentType = undefined
|
||||||
|
formData.agendaAttachmentSize = undefined
|
||||||
formData.materialViewStartTime = undefined
|
formData.materialViewStartTime = undefined
|
||||||
formData.materialViewEndTime = undefined
|
formData.materialViewEndTime = undefined
|
||||||
formData.materialViewRemark = undefined
|
formData.materialViewRemark = undefined
|
||||||
|
|
@ -241,6 +276,50 @@ const handleDownloadTemplate = async () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleAgendaAttachmentChange = async (uploadFile: UploadFile) => {
|
||||||
|
if (!uploadFile.raw) return
|
||||||
|
const ext = uploadFile.raw.name.includes('.')
|
||||||
|
? uploadFile.raw.name.substring(uploadFile.raw.name.lastIndexOf('.') + 1).toLowerCase()
|
||||||
|
: ''
|
||||||
|
const isPdf = ext === 'pdf' || uploadFile.raw.type === 'application/pdf'
|
||||||
|
const isImage = uploadFile.raw.type?.startsWith('image/')
|
||||||
|
if (!isPdf && !isImage) {
|
||||||
|
ElMessage.error('议程附件仅支持图片或 PDF')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
formLoading.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 {
|
||||||
|
formLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearAgendaAttachment = () => {
|
||||||
|
formData.agendaAttachmentName = undefined
|
||||||
|
formData.agendaAttachmentUrl = undefined
|
||||||
|
formData.agendaAttachmentType = undefined
|
||||||
|
formData.agendaAttachmentSize = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const previewAgendaAttachment = () => {
|
||||||
|
if (formData.agendaAttachmentUrl) {
|
||||||
|
window.open(formData.agendaAttachmentUrl, '_blank')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 submitForm = async () => {
|
const submitForm = async () => {
|
||||||
const valid = await formRef.value?.validate().catch(() => false)
|
const valid = await formRef.value?.validate().catch(() => false)
|
||||||
if (!valid) return
|
if (!valid) return
|
||||||
|
|
@ -286,4 +365,6 @@ defineExpose({ open })
|
||||||
.import-section { margin-top: 8px; display: flex; align-items: center; gap: 8px; }
|
.import-section { margin-top: 8px; display: flex; align-items: center; gap: 8px; }
|
||||||
.ml-10 { margin-left: 10px; }
|
.ml-10 { margin-left: 10px; }
|
||||||
.mt-10 { margin-top: 10px; }
|
.mt-10 { margin-top: 10px; }
|
||||||
|
.agenda-attachment-wrap { display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.agenda-file-line { display: flex; align-items: center; gap: 8px; }
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,7 @@
|
||||||
<el-button type="primary" link @click="goToProjectList(row)">进入项目列表</el-button>
|
<el-button type="primary" link @click="goToProjectList(row)">进入项目列表</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="260" align="center" fixed="right">
|
<el-table-column label="操作" width="430" align="center" fixed="right">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<!-- 草稿状态 -->
|
<!-- 草稿状态 -->
|
||||||
<template v-if="row.status === 0">
|
<template v-if="row.status === 0">
|
||||||
|
|
@ -77,7 +77,25 @@
|
||||||
<!-- 已邀约状态 -->
|
<!-- 已邀约状态 -->
|
||||||
<template v-else-if="row.status === 1">
|
<template v-else-if="row.status === 1">
|
||||||
<el-button type="info" link @click="openForm('view', row)">查看</el-button>
|
<el-button type="info" link @click="openForm('view', row)">查看</el-button>
|
||||||
<el-button v-hasPermi="['review:meeting:send-sms']" type="info" link @click="openSmsStatus(row)">发送状态</el-button>
|
<el-button v-hasPermi="['review:meeting:send-sms']" type="info" link @click="openSmsStatus(row)">短信状态</el-button>
|
||||||
|
<el-button
|
||||||
|
v-if="row.mailSent"
|
||||||
|
v-hasPermi="['review:meeting:send-mail']"
|
||||||
|
type="info"
|
||||||
|
link
|
||||||
|
@click="openMailStatus(row)"
|
||||||
|
>
|
||||||
|
邮件状态
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
v-else
|
||||||
|
v-hasPermi="['review:meeting:send-mail']"
|
||||||
|
type="primary"
|
||||||
|
link
|
||||||
|
@click="handleSendMail(row)"
|
||||||
|
>
|
||||||
|
发送邮件邀请函
|
||||||
|
</el-button>
|
||||||
<el-button v-hasPermi="['review:meeting:finish']" type="warning" link @click="handleFinish(row)">结束</el-button>
|
<el-button v-hasPermi="['review:meeting:finish']" type="warning" link @click="handleFinish(row)">结束</el-button>
|
||||||
<el-button v-hasPermi="['review:meeting:cancel']" type="danger" link @click="handleCancel(row)">取消</el-button>
|
<el-button v-hasPermi="['review:meeting:cancel']" type="danger" link @click="handleCancel(row)">取消</el-button>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -97,6 +115,9 @@
|
||||||
|
|
||||||
<!-- 短信状态弹窗 -->
|
<!-- 短信状态弹窗 -->
|
||||||
<SmsStatusDialog ref="smsStatusRef" />
|
<SmsStatusDialog ref="smsStatusRef" />
|
||||||
|
|
||||||
|
<!-- 邮件状态弹窗 -->
|
||||||
|
<MailStatusDialog ref="mailStatusRef" />
|
||||||
</ContentWrap>
|
</ContentWrap>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -110,11 +131,13 @@ import {
|
||||||
cancelReviewMeeting,
|
cancelReviewMeeting,
|
||||||
finishReviewMeeting,
|
finishReviewMeeting,
|
||||||
sendSmsInvitation,
|
sendSmsInvitation,
|
||||||
|
sendMailInvitation,
|
||||||
type ReviewMeetingRespVO,
|
type ReviewMeetingRespVO,
|
||||||
type ReviewMeetingPageReqVO
|
type ReviewMeetingPageReqVO
|
||||||
} from '@/api/review/meeting'
|
} from '@/api/review/meeting'
|
||||||
import MeetingForm from './MeetingForm.vue'
|
import MeetingForm from './MeetingForm.vue'
|
||||||
import SmsStatusDialog from './SmsStatusDialog.vue'
|
import SmsStatusDialog from './SmsStatusDialog.vue'
|
||||||
|
import MailStatusDialog from './MailStatusDialog.vue'
|
||||||
import { formatDate } from '@/utils/formatTime'
|
import { formatDate } from '@/utils/formatTime'
|
||||||
|
|
||||||
defineOptions({ name: 'ReviewMeeting' })
|
defineOptions({ name: 'ReviewMeeting' })
|
||||||
|
|
@ -144,6 +167,7 @@ const queryParams = reactive<ReviewMeetingPageReqVO & { pageNo: number; pageSize
|
||||||
const queryFormRef = ref()
|
const queryFormRef = ref()
|
||||||
const formRef = ref()
|
const formRef = ref()
|
||||||
const smsStatusRef = ref()
|
const smsStatusRef = ref()
|
||||||
|
const mailStatusRef = ref()
|
||||||
|
|
||||||
const getList = async () => {
|
const getList = async () => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
|
|
@ -192,9 +216,24 @@ const handleSendSms = async (row: ReviewMeetingRespVO) => {
|
||||||
getList()
|
getList()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleSendMail = async (row: ReviewMeetingRespVO) => {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
`确认向 ${row.expertCount} 位专家发送「${row.name}」邮件邀请函(含议程附件)?`,
|
||||||
|
'发送确认',
|
||||||
|
{ type: 'info', confirmButtonText: '确认发送' }
|
||||||
|
)
|
||||||
|
await sendMailInvitation(row.id)
|
||||||
|
row.mailSent = true
|
||||||
|
ElMessage.success('邮件邀请函发送任务已触发,请点击“邮件状态”查看结果')
|
||||||
|
}
|
||||||
|
|
||||||
const openSmsStatus = (row: ReviewMeetingRespVO) => {
|
const openSmsStatus = (row: ReviewMeetingRespVO) => {
|
||||||
smsStatusRef.value?.open(row.id, row.name)
|
smsStatusRef.value?.open(row.id, row.name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const openMailStatus = (row: ReviewMeetingRespVO) => {
|
||||||
|
mailStatusRef.value?.open(row.id, row.name)
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => getList())
|
onMounted(() => getList())
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue