feat(review-meeting): support active tablet meeting selection

pull/874/head
Codewoc 2026-04-07 09:16:11 +08:00
parent 823e50e52f
commit 25eef30456
6 changed files with 404 additions and 178 deletions

View File

@ -26,9 +26,6 @@ export interface ReviewMeetingSaveReqVO {
organizationUnit?: string
startTime?: string | number
endTime?: string | number
materialViewStartTime?: string | number
materialViewEndTime?: string | number
materialViewRemark?: string
location: string
host?: string
agendaAttachmentName?: string
@ -59,9 +56,6 @@ export interface ReviewMeetingRespVO {
organizationUnit?: string
startTime: string
endTime: string
materialViewStartTime?: string
materialViewEndTime?: string
materialViewRemark?: string
location: string
host?: string
agendaAttachmentName?: string
@ -81,6 +75,7 @@ export interface ReviewMeetingRespVO {
expertCount: number
projectCount: number
mailSent?: boolean
tabletActive?: boolean
createTime: string
}
@ -159,6 +154,14 @@ export const cancelReviewMeeting = (id: number) =>
export const finishReviewMeeting = (id: number) =>
request.put({ url: '/project/review-meeting/finish', params: { id } })
/** 设为当前平板评审会议(单选) */
export const setTabletActiveMeeting = (id: number) =>
request.put({ url: '/project/review-meeting/tablet-active', params: { id } })
/** 清空当前平板评审会议 */
export const clearTabletActiveMeeting = () =>
request.delete({ url: '/project/review-meeting/tablet-active' })
/** 复制会议(仅已结束/已取消) */
export const copyReviewMeeting = (id: number) =>
request.post({ url: '/project/review-meeting/copy', params: { id } })
@ -199,7 +202,10 @@ export const getMailLogList = (reviewMeetingId: number) =>
export const importProjectsFromExcel = async (file: File): Promise<ReviewProjectItemVO[]> => {
const formData = new FormData()
formData.append('file', file)
const res = await request.upload<any>({ 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 || []
}
@ -212,7 +218,10 @@ export const uploadAgendaAttachment = async (
/** 根据当前表单内容自动生成议程附件 */
export const generateAgendaAttachment = (data: ReviewMeetingAgendaGenerateReqVO) =>
request.post<ReviewMeetingAgendaAttachmentRespVO>({ url: '/project/review-meeting/generate-agenda', data })
request.post<ReviewMeetingAgendaAttachmentRespVO>({
url: '/project/review-meeting/generate-agenda',
data
})
/** 上传会议纪要附件Word/PDF/图片) */
export const uploadMinutesAttachment = (
@ -283,5 +292,7 @@ const uploadMeetingAttachmentByPresignedUrl = async (
}
const resolveAttachmentType = (fileName: string): string => {
return fileName.includes('.') ? fileName.substring(fileName.lastIndexOf('.') + 1).toLowerCase() : ''
return fileName.includes('.')
? fileName.substring(fileName.lastIndexOf('.') + 1).toLowerCase()
: ''
}

View File

@ -32,7 +32,7 @@ export interface ReviewTabletOpenUrlVO {
visitUrl: string
}
export const getTodayCatalog = () => request.get({ url: '/project/review-tablet/catalog/today' })
export const getActiveCatalog = () => request.get({ url: '/project/review-tablet/catalog/active' })
export const getProjectFiles = (reviewMeetingProjectId: number) =>
request.get({

View File

@ -22,7 +22,11 @@
</el-col>
<el-col :span="12">
<el-form-item label="组织单位" prop="organizationUnit">
<el-input v-model="formData.organizationUnit" placeholder="请输入组织单位" :disabled="isView" />
<el-input
v-model="formData.organizationUnit"
placeholder="请输入组织单位"
:disabled="isView"
/>
</el-form-item>
</el-col>
</el-row>
@ -54,7 +58,12 @@
:disabled="isView"
style="width: 100%"
>
<el-option v-for="item in meetingLocationOptions" :key="item" :label="item" :value="item" />
<el-option
v-for="item in meetingLocationOptions"
:key="item"
:label="item"
:value="item"
/>
</el-select>
</el-form-item>
</el-col>
@ -65,21 +74,6 @@
<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
v-model="formData.materialViewTimeRange"
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="agendaAttachmentUrl">
<div class="agenda-attachment-wrap">
@ -92,7 +86,12 @@
>
<button type="button" class="btn-upload">上传议程附件</button>
</el-upload>
<button type="button" class="btn-generate" :disabled="formLoading" @click="handleGenerateAgenda">
<button
type="button"
class="btn-generate"
:disabled="formLoading"
@click="handleGenerateAgenda"
>
自动生成议程
</button>
</div>
@ -101,9 +100,15 @@
<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>
<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>
@ -126,11 +131,20 @@
<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>
<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 v-if="formData.minutesAttachmentUrl && formData.minutesAiStatusName" class="minutes-ai-line">
<div
v-if="formData.minutesAttachmentUrl && formData.minutesAiStatusName"
class="minutes-ai-line"
>
<el-tag size="small" :type="getMinutesAiTagType(formData.minutesAiStatus)">
{{ formData.minutesAiStatusName }}
</el-tag>
@ -171,7 +185,9 @@
>
<button type="button" class="btn-default">导入验收申请 Excel</button>
</el-upload>
<button type="button" class="btn-default" @click="handleDownloadTemplate"></button>
<button type="button" class="btn-default" @click="handleDownloadTemplate"
>下载导入模板</button
>
<span class="import-hint">格式序号议程分类项目标题汇报人报告人单位</span>
</div>
@ -194,7 +210,13 @@
<!-- 底部操作区 -->
<div class="form-footer">
<button v-if="!isView" type="button" class="btn-primary" :disabled="formLoading" @click="submitForm">
<button
v-if="!isView"
type="button"
class="btn-primary"
:disabled="formLoading"
@click="submitForm"
>
{{ formLoading ? '保存中...' : '保存草稿' }}
</button>
<button type="button" class="btn-default" @click="handleBack"></button>
@ -271,7 +293,6 @@ const pageTitle = computed(() => {
type FormData = ReviewMeetingSaveReqVO & {
organizationUnit?: string
meetingTimeRange?: any[]
materialViewTimeRange?: any[]
minutesAiStatus?: number
minutesAiStatusName?: string
minutesAiErrorMessage?: string
@ -299,12 +320,8 @@ const formData = reactive<FormData>({
minutesAiStatusName: undefined,
minutesAiErrorMessage: undefined,
minutesAiUpdatedTime: undefined,
materialViewStartTime: undefined,
materialViewEndTime: undefined,
materialViewRemark: undefined,
expertIds: [],
meetingTimeRange: undefined,
materialViewTimeRange: undefined,
projects: []
})
@ -315,7 +332,9 @@ const rules: FormRules = {
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' }]
expertIds: [
{ required: true, type: 'array', min: 1, message: '至少选择一位专家', trigger: 'change' }
]
}
const formRef = ref()
@ -334,8 +353,13 @@ const resetProjectReviewDate = (projects: MeetingEditProjectItem[]): MeetingEdit
reviewDate: undefined
}))
const buildPreviewScheduledProjects = (projects: MeetingEditProjectItem[] = formData.projects): MeetingEditProjectItem[] => {
const projectsWithReviewDate = applyDefaultReviewDate(projects, formData.meetingTimeRange) as MeetingEditProjectItem[]
const buildPreviewScheduledProjects = (
projects: MeetingEditProjectItem[] = formData.projects
): MeetingEditProjectItem[] => {
const projectsWithReviewDate = applyDefaultReviewDate(
projects,
formData.meetingTimeRange
) as MeetingEditProjectItem[]
return (
buildScheduledProjectItems(
projectsWithReviewDate,
@ -363,12 +387,6 @@ const loadDetail = async (id: number) => {
new Date(detail.endTime.replace(' ', 'T')).getTime()
]
}
if (detail.materialViewStartTime && detail.materialViewEndTime) {
formData.materialViewTimeRange = [
new Date(detail.materialViewStartTime.replace(' ', 'T')).getTime(),
new Date(detail.materialViewEndTime.replace(' ', 'T')).getTime()
]
}
originalMeetingStart.value = formData.meetingTimeRange?.[0]
} finally {
formLoading.value = false
@ -393,9 +411,6 @@ const loadCopySource = async (id: number) => {
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
@ -422,17 +437,11 @@ const loadCopySource = async (id: number) => {
} 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
}
originalMeetingStart.value = formData.meetingTimeRange?.[0]
ElMessage.info('已带入会议信息和评审项目;保存草稿后将同步复制项目资料,议程附件与会议纪要不会复制')
ElMessage.info(
'已带入会议信息和评审项目;保存草稿后将同步复制项目资料,议程附件与会议纪要不会复制'
)
} finally {
formLoading.value = false
}
@ -462,12 +471,17 @@ watch(
const handleExcelChange = async (uploadFile: UploadFile) => {
if (!uploadFile.raw) return
if (formData.projects && formData.projects.length > 0) {
await ElMessageBox.confirm('重新导入将覆盖已有评审项目列表,是否继续?', '提示', { type: 'warning' })
await ElMessageBox.confirm('重新导入将覆盖已有评审项目列表,是否继续?', '提示', {
type: 'warning'
})
}
formLoading.value = true
try {
const result = await importProjectsFromExcel(uploadFile.raw)
const projects = applyDefaultReviewDate(result as ReviewProjectItemVO[], formData.meetingTimeRange)
const projects = applyDefaultReviewDate(
result as ReviewProjectItemVO[],
formData.meetingTimeRange
)
formData.projects = (buildScheduledProjectItems(projects, formData.meetingTimeRange?.[0]) ||
projects) as MeetingEditProjectItem[]
isProjectsModified.value = true
@ -584,7 +598,10 @@ const formatFileSize = (bytes?: number): string => {
}
const buildScheduledProjects = (): MeetingEditProjectItem[] => {
const projectsWithReviewDate = applyDefaultReviewDate(formData.projects, formData.meetingTimeRange) as MeetingEditProjectItem[]
const projectsWithReviewDate = applyDefaultReviewDate(
formData.projects,
formData.meetingTimeRange
) as MeetingEditProjectItem[]
const hasCompleteSchedule = projectsWithReviewDate.every((item) => item.startTime && item.endTime)
if (hasCompleteSchedule) {
return projectsWithReviewDate
@ -671,13 +688,6 @@ const submitForm = async () => {
formData.startTime = formData.meetingTimeRange[0]
formData.endTime = formData.meetingTimeRange[1]
}
if (formData.materialViewTimeRange?.length === 2) {
formData.materialViewStartTime = formData.materialViewTimeRange[0]
formData.materialViewEndTime = formData.materialViewTimeRange[1]
} else {
formData.materialViewStartTime = undefined
formData.materialViewEndTime = undefined
}
formLoading.value = true
try {
const projects = buildScheduledProjects()
@ -739,7 +749,9 @@ const handleBack = () => {
line-height: 1;
font-weight: 400;
}
.back-btn:hover { opacity: 0.75; }
.back-btn:hover {
opacity: 0.75;
}
.page-title {
font-size: 24px;
font-weight: 600;
@ -850,7 +862,9 @@ const handleBack = () => {
cursor: pointer;
transition: all 0.2s;
}
.btn-upload:hover { background-color: rgba(41, 90, 188, 0.08); }
.btn-upload:hover {
background-color: rgba(41, 90, 188, 0.08);
}
.btn-generate {
display: inline-flex;
@ -905,8 +919,13 @@ const handleBack = () => {
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; }
.btn-primary:hover {
background-color: rgba(41, 90, 188, 0.88);
}
.btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* ── 底部 ── */
.form-footer {

View File

@ -1,6 +1,18 @@
<template>
<el-dialog v-model="visible" :title="dialogTitle" width="860px" :close-on-click-modal="false" top="5vh">
<el-form ref="formRef" :model="formData" :rules="rules" label-width="90px" v-loading="formLoading">
<el-dialog
v-model="visible"
:title="dialogTitle"
width="860px"
:close-on-click-modal="false"
top="5vh"
>
<el-form
ref="formRef"
:model="formData"
:rules="rules"
label-width="90px"
v-loading="formLoading"
>
<!-- 基本信息 -->
<el-row :gutter="16">
<el-col :span="24">
@ -51,31 +63,20 @@
<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>
<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-col :span="24">
<el-form-item label="资料查看时限" prop="materialViewTimeRange">
<el-date-picker
v-model="formData.materialViewTimeRange"
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-row>
<el-row :gutter="16">
<el-col :span="24">
<el-form-item label="参会专家" prop="expertIds">
@ -110,7 +111,9 @@
<el-button type="primary" plain>导入验收申请 Excel</el-button>
</el-upload>
<el-button type="success" plain @click="handleDownloadTemplate"></el-button>
<el-text type="info" size="small" class="ml-10">格式序号议程分类项目标题汇报人报告人单位</el-text>
<el-text type="info" size="small" class="ml-10"
>格式序号议程分类项目标题汇报人报告人单位</el-text
>
</div>
<!-- 评审项目预览列表 -->
@ -168,12 +171,15 @@ const formLoading = ref(false)
const formType = ref<'create' | 'update' | 'view'>('create')
const isView = computed(() => formType.value === 'view')
const dialogTitle = computed(() =>
formType.value === 'create' ? '新增会议邀约' : formType.value === 'update' ? '编辑评审会议' : '查看评审会议'
formType.value === 'create'
? '新增会议邀约'
: formType.value === 'update'
? '编辑评审会议'
: '查看评审会议'
)
type FormData = ReviewMeetingSaveReqVO & {
meetingTimeRange?: any[]
materialViewTimeRange?: any[]
}
const formData = reactive<FormData>({
@ -186,12 +192,8 @@ const formData = reactive<FormData>({
agendaAttachmentUrl: undefined,
agendaAttachmentType: undefined,
agendaAttachmentSize: undefined,
materialViewStartTime: undefined,
materialViewEndTime: undefined,
materialViewRemark: undefined,
expertIds: [],
meetingTimeRange: undefined,
materialViewTimeRange: undefined,
projects: []
})
@ -199,13 +201,16 @@ const rules: FormRules = {
name: [{ required: true, message: '会议标题不能为空', trigger: 'blur' }],
meetingTimeRange: [{ required: true, message: '会议时间不能为空', trigger: 'change' }],
location: [{ required: true, message: '会议地点不能为空', trigger: 'blur' }],
expertIds: [{ required: true, type: 'array', min: 1, message: '至少选择一位专家', trigger: 'change' }]
expertIds: [
{ required: true, type: 'array', min: 1, message: '至少选择一位专家', trigger: 'change' }
]
}
const isProjectsModified = ref(false)
const formRef = ref()
const expertOptions = ref<any[]>([])
const expertLabel = (e: any) => `${e.nickname}${e.title ? `${e.title}` : ''}${e.deptName ? ` ${e.deptName}` : ''}`
const expertLabel = (e: any) =>
`${e.nickname}${e.title ? `${e.title}` : ''}${e.deptName ? ` ${e.deptName}` : ''}`
const open = async (type: 'create' | 'update' | 'view', id?: number) => {
formType.value = type
@ -225,12 +230,6 @@ const open = async (type: 'create' | 'update' | 'view', id?: number) => {
new Date(detail.endTime.replace(' ', 'T')).getTime()
]
}
if (detail.materialViewStartTime && detail.materialViewEndTime) {
formData.materialViewTimeRange = [
new Date(detail.materialViewStartTime.replace(' ', 'T')).getTime(),
new Date(detail.materialViewEndTime.replace(' ', 'T')).getTime()
]
}
} finally {
formLoading.value = false
}
@ -247,12 +246,8 @@ const resetForm = () => {
formData.agendaAttachmentUrl = undefined
formData.agendaAttachmentType = undefined
formData.agendaAttachmentSize = undefined
formData.materialViewStartTime = undefined
formData.materialViewEndTime = undefined
formData.materialViewRemark = undefined
formData.expertIds = []
formData.meetingTimeRange = undefined
formData.materialViewTimeRange = undefined
formData.projects = []
formRef.value?.resetFields()
}
@ -260,7 +255,9 @@ const resetForm = () => {
const handleExcelChange = async (uploadFile: UploadFile) => {
if (!uploadFile.raw) return
if (formData.projects && formData.projects.length > 0) {
await ElMessageBox.confirm('重新导入将覆盖已有评审项目列表,是否继续?', '提示', { type: 'warning' })
await ElMessageBox.confirm('重新导入将覆盖已有评审项目列表,是否继续?', '提示', {
type: 'warning'
})
}
formLoading.value = true
try {
@ -269,7 +266,8 @@ const handleExcelChange = async (uploadFile: UploadFile) => {
((result as any).data || result) as ReviewProjectItemVO[],
formData.meetingTimeRange
)
formData.projects = buildScheduledProjectItems(projects, formData.meetingTimeRange?.[0]) || projects
formData.projects =
buildScheduledProjectItems(projects, formData.meetingTimeRange?.[0]) || projects
isProjectsModified.value = true
ElMessage.success(`成功解析 ${formData.projects.length} 个评审项目`)
} catch (e) {
@ -339,20 +337,10 @@ const submitForm = async () => {
formData.startTime = formData.meetingTimeRange[0]
formData.endTime = formData.meetingTimeRange[1]
}
if (formData.materialViewTimeRange?.length === 2) {
formData.materialViewStartTime = formData.materialViewTimeRange[0]
formData.materialViewEndTime = formData.materialViewTimeRange[1]
} else if (formData.meetingTimeRange?.length === 2) {
// 便
formData.materialViewStartTime = formData.meetingTimeRange[0]
formData.materialViewEndTime = formData.meetingTimeRange[1]
} else {
formData.materialViewStartTime = undefined
formData.materialViewEndTime = undefined
}
formLoading.value = true
try {
const projects = buildScheduledProjectItems(
const projects =
buildScheduledProjectItems(
applyDefaultReviewDate(formData.projects, formData.meetingTimeRange),
formData.meetingTimeRange?.[0],
DEFAULT_REVIEW_MEETING_INTERVAL_MINUTES
@ -382,9 +370,26 @@ defineExpose({ open })
</script>
<style scoped>
.import-section { margin-top: 8px; display: flex; align-items: center; gap: 8px; }
.ml-10 { margin-left: 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; }
.import-section {
margin-top: 8px;
display: flex;
align-items: center;
gap: 8px;
}
.ml-10 {
margin-left: 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>

View File

@ -23,8 +23,19 @@
value-format="YYYY-MM-DD HH:mm:ss"
class="search-datepicker"
/>
<el-select v-model="queryParams.status" size="large" placeholder="会议状态" clearable class="search-select">
<el-option v-for="item in MEETING_STATUS_OPTIONS" :key="item.value" :label="item.label" :value="item.value" />
<el-select
v-model="queryParams.status"
size="large"
placeholder="会议状态"
clearable
class="search-select"
>
<el-option
v-for="item in MEETING_STATUS_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
<button class="btn-reset" @click="resetQuery"></button>
<button class="btn-search" @click="handleQuery"></button>
@ -35,6 +46,14 @@
<button v-hasPermi="['review:meeting:create']" class="btn-default" @click="goToEdit()">
<span class="btn-icon">+</span> 新建会议
</button>
<button
v-hasPermi="['review:meeting:update']"
class="btn-default"
:disabled="!selectedTabletMeetingId || settingTabletActive"
@click="handleClearTabletActive"
>
清空平板评审会议
</button>
</div>
<!-- 列表 -->
@ -54,27 +73,77 @@
<el-table-column label="会议地点" prop="location" width="184" show-overflow-tooltip />
<el-table-column label="参会专家数" prop="expertCount" width="112" align="center" />
<el-table-column label="项目数" prop="projectCount" width="96" align="center" />
<el-table-column label="平板评审会议" width="120" align="center">
<template #default="{ row }">
<el-radio
class="tablet-active-radio"
:model-value="selectedTabletMeetingId"
:label="row.id"
:disabled="row.status !== 1 || settingTabletActive"
@change="() => handleSetTabletActive(row)"
/>
</template>
</el-table-column>
<el-table-column label="状态" width="116" align="center">
<template #default="{ row }">
<span :class="`status-text status-${row.status}`">{{ STATUS_LABEL[row.status] }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="600" align="center" :fixed="useFixedActionColumn ? 'right' : false">
<el-table-column
label="操作"
width="600"
align="center"
:fixed="useFixedActionColumn ? 'right' : false"
>
<template #default="{ row }">
<template v-if="row.status === 0">
<div class="op-group">
<a v-hasPermi="['review:meeting:update']" class="op-link" @click="goToEdit(row.id)"></a>
<a v-hasPermi="['review:meeting:send-sms']" class="op-link" @click="handleSendSms(row)"></a>
<a v-hasPermi="['review:meeting:cancel']" class="op-link op-danger" @click="handleCancel(row)"></a>
<a v-hasPermi="['review:meeting:update']" class="op-link" @click="goToEdit(row.id)"
>编辑</a
>
<a
v-hasPermi="['review:meeting:send-sms']"
class="op-link"
@click="handleSendSms(row)"
>发送短信邀约</a
>
<a
v-hasPermi="['review:meeting:cancel']"
class="op-link op-danger"
@click="handleCancel(row)"
>取消</a
>
</div>
</template>
<template v-else-if="row.status === 1">
<div class="op-group">
<a class="op-link" @click="goToEdit(row.id, 'view')">查看</a>
<a v-hasPermi="['review:meeting:send-sms']" class="op-link" @click="openSmsStatus(row)"></a>
<a v-if="row.mailSent" v-hasPermi="['review:meeting:send-mail']" class="op-link" @click="openMailStatus(row)"></a>
<a v-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: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:export']"
class="op-link"
@ -83,14 +152,23 @@
>
{{ getExportText(row.id) }}
</a>
<a v-hasPermi="['review:meeting:finish']" class="op-link" @click="handleFinish(row)"></a>
<a v-hasPermi="['review:meeting:cancel']" class="op-link op-danger" @click="handleCancel(row)"></a>
<a v-hasPermi="['review:meeting:finish']" class="op-link" @click="handleFinish(row)"
>结束</a
>
<a
v-hasPermi="['review:meeting:cancel']"
class="op-link op-danger"
@click="handleCancel(row)"
>取消</a
>
</div>
</template>
<template v-else>
<div class="op-group">
<a class="op-link" @click="goToEdit(row.id, 'view')">查看</a>
<a v-hasPermi="['review:meeting:create']" class="op-link" @click="handleCopy(row)"></a>
<a v-hasPermi="['review:meeting:create']" class="op-link" @click="handleCopy(row)"
>复制新会议</a
>
</div>
</template>
</template>
@ -98,7 +176,12 @@
</el-table>
<!-- 分页 -->
<Pagination :total="total" v-model:page="queryParams.pageNo" v-model:limit="queryParams.pageSize" @pagination="getList" />
<Pagination
:total="total"
v-model:page="queryParams.pageNo"
v-model:limit="queryParams.pageSize"
@pagination="getList"
/>
<!-- 短信状态弹窗 -->
<SmsStatusDialog ref="smsStatusRef" />
@ -121,9 +204,11 @@ import { ref, reactive, onMounted, onBeforeUnmount } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessageBox, ElMessage } from 'element-plus'
import {
clearTabletActiveMeeting,
getReviewMeetingPage,
cancelReviewMeeting,
finishReviewMeeting,
setTabletActiveMeeting,
sendSmsInvitation,
sendMailInvitation,
uploadMinutesAttachment,
@ -146,6 +231,8 @@ const loading = ref(false)
const list = ref<ReviewMeetingRespVO[]>([])
const total = ref(0)
const useFixedActionColumn = ref(true)
const selectedTabletMeetingId = ref<number>()
const settingTabletActive = ref(false)
const MEETING_STATUS_OPTIONS = [
{ value: 0, label: '待召开' },
@ -153,7 +240,12 @@ const MEETING_STATUS_OPTIONS = [
{ value: 2, label: '已结束' },
{ value: 3, label: '已取消' }
]
const STATUS_LABEL: Record<number, string> = { 0: '待召开', 1: '正在召开', 2: '已结束', 3: '已取消' }
const STATUS_LABEL: Record<number, string> = {
0: '待召开',
1: '正在召开',
2: '已结束',
3: '已取消'
}
const queryParams = reactive<ReviewMeetingPageReqVO & { pageNo: number; pageSize: number }>({
pageNo: 1,
@ -167,7 +259,9 @@ const smsStatusRef = ref()
const mailStatusRef = ref()
const minutesUploadInputRef = ref<HTMLInputElement>()
const pendingMinutesMeeting = ref<ReviewMeetingRespVO>()
const exportTaskMap = reactive<Record<number, ReviewMeetingMaterialExportTaskRespVO | undefined>>({})
const exportTaskMap = reactive<Record<number, ReviewMeetingMaterialExportTaskRespVO | undefined>>(
{}
)
const exportPollingMap = new Map<number, ReturnType<typeof setTimeout>>()
const syncActionColumnMode = () => {
@ -180,12 +274,16 @@ const getList = async () => {
const data = await getReviewMeetingPage(queryParams)
list.value = data.list
total.value = data.total
selectedTabletMeetingId.value = data.list.find((item) => item.tabletActive)?.id
} finally {
loading.value = false
}
}
const handleQuery = () => { queryParams.pageNo = 1; getList() }
const handleQuery = () => {
queryParams.pageNo = 1
getList()
}
const resetQuery = () => {
queryParams.name = undefined
queryParams.status = undefined
@ -202,7 +300,11 @@ const goToEdit = (id?: number, mode?: string) => {
}
const goToProjectList = (row: ReviewMeetingRespVO) => {
router.push({ name: 'ReviewMeetingProject', params: { meetingId: row.id }, query: { meetingName: row.name } })
router.push({
name: 'ReviewMeetingProject',
params: { meetingId: row.id },
query: { meetingName: row.name }
})
}
const handleCancel = async (row: ReviewMeetingRespVO) => {
@ -219,6 +321,34 @@ const handleFinish = async (row: ReviewMeetingRespVO) => {
getList()
}
const handleSetTabletActive = async (row: ReviewMeetingRespVO) => {
if (settingTabletActive.value || row.status !== 1 || selectedTabletMeetingId.value === row.id) {
return
}
settingTabletActive.value = true
try {
await setTabletActiveMeeting(row.id)
ElMessage.success('已设置当前平板评审会议')
await getList()
} finally {
settingTabletActive.value = false
}
}
const handleClearTabletActive = async () => {
if (!selectedTabletMeetingId.value || settingTabletActive.value) {
return
}
settingTabletActive.value = true
try {
await clearTabletActiveMeeting()
ElMessage.success('已清空当前平板评审会议')
await getList()
} finally {
settingTabletActive.value = false
}
}
const handleCopy = async (row: ReviewMeetingRespVO) => {
router.push({
name: 'ReviewMeetingEdit',
@ -237,7 +367,9 @@ const handleMinutesFileChange = async (event: Event) => {
if (!file || !pendingMinutesMeeting.value) {
return
}
const ext = file.name.includes('.') ? file.name.substring(file.name.lastIndexOf('.') + 1).toLowerCase() : ''
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 或图片')
@ -517,10 +649,18 @@ onBeforeUnmount(() => {
font-size: 18px;
font-weight: 500;
}
.status-0 { color: #ecae4b; }
.status-1 { color: #73c047; }
.status-2 { color: #999; }
.status-3 { color: #999; }
.status-0 {
color: #ecae4b;
}
.status-1 {
color: #73c047;
}
.status-2 {
color: #999;
}
.status-3 {
color: #999;
}
/* ── 会议名称链接 ── */
.meeting-name-link {
@ -549,7 +689,11 @@ onBeforeUnmount(() => {
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s, border-color 0.2s, color 0.2s, opacity 0.2s;
transition:
background-color 0.2s,
border-color 0.2s,
color 0.2s,
opacity 0.2s;
}
.op-link:hover {
background: rgba(41, 90, 188, 0.1);
@ -643,4 +787,8 @@ onBeforeUnmount(() => {
:deep(.review-table .el-table__fixed::before) {
background-color: #e1e7f0;
}
:deep(.tablet-active-radio .el-radio__label) {
display: none;
}
</style>

View File

@ -15,7 +15,7 @@
<div class="left-title">专家评审资料</div>
<el-button text :icon="Refresh" @click="loadCatalog" />
</div>
<div class="left-sub">日项目编目{{ projectCount }}</div>
<div class="left-sub">前评审会议编目{{ projectCount }}</div>
<el-input
v-model="keyword"
@ -26,7 +26,10 @@
/>
<div class="left-tree-wrap">
<el-empty v-if="treeData.length === 0" description="当前时段暂无可查看资料" />
<el-empty
v-if="treeData.length === 0"
description="管理员未选择当前评审会议,请联系管理员设置"
/>
<el-tree
v-else
ref="treeRef"
@ -146,7 +149,15 @@
</el-result>
<template v-else-if="previewUrl">
<TabletPdfViewer
v-if="previewMode === 'pdf'"
:key="`pdf-${previewFrameKey}`"
:src="previewUrl"
@loading-change="handlePdfLoadingChange"
@error="handlePdfRenderError"
/>
<iframe
v-else
:key="previewFrameKey"
:src="previewUrl"
class="preview-iframe"
@ -155,7 +166,10 @@
@load="handlePreviewFrameLoad"
@error="handlePreviewFrameError"
></iframe>
<div v-if="previewFrameLoading && !previewError" class="preview-loading-mask">
<div
v-if="previewMode !== 'pdf' && previewFrameLoading && !previewError"
class="preview-loading-mask"
>
<div class="welcome-title">{{ previewLoadingTitle }}</div>
<div class="welcome-sub">{{ previewLoadingSubtitle }}</div>
</div>
@ -426,10 +440,12 @@ import {
import { ElMessage, ElMessageBox } from 'element-plus'
import MarkdownIt from 'markdown-it'
import { formatDate } from '@/utils/formatTime'
import TabletPdfViewer from './TabletPdfViewer.vue'
import { resolvePdfSourceUrl, resolveTabletPreviewMode } from './previewMode.mjs'
import {
getFileOpenUrl,
getProjectFiles,
getTodayCatalog,
getActiveCatalog,
type ReviewTabletCatalogVO,
type ReviewTabletOpenUrlVO
} from '@/api/review/tablet'
@ -598,13 +614,20 @@ const activeFileDesc = computed(() => {
return segs.join(' ')
})
const previewLoadingTitle = computed(() =>
isOfficeFile(activeFileNode.value?.fileType) ? '正在转换文档预览' : '正在加载预览'
previewMode.value === 'pdf'
? '正在加载 PDF 预览'
: isOfficeFile(activeFileNode.value?.fileType)
? '正在转换文档预览'
: '正在加载预览'
)
const previewLoadingSubtitle = computed(() =>
isOfficeFile(activeFileNode.value?.fileType)
previewMode.value === 'pdf'
? '正在渲染文档内容,请稍候'
: isOfficeFile(activeFileNode.value?.fileType)
? 'Office 文件首次打开可能需要一些时间,请稍候'
: '正在为您打开当前资料'
)
const previewMode = computed(() => resolveTabletPreviewMode(previewPayload.value))
const isTouchTablet = computed(() => coarsePointer.value)
const isCompactTablet = computed(() => coarsePointer.value && viewportWidth.value <= 1400)
const isDenseTablet = computed(() => coarsePointer.value && viewportWidth.value <= 1180)
@ -763,14 +786,22 @@ const loadPreview = async (fileId: number) => {
const payload = await getFileOpenUrl(fileId)
if (requestToken !== previewRequestToken.value) return
previewPayload.value = payload
if (!payload?.openUrl) {
const mode = resolveTabletPreviewMode(payload)
const sourceUrl = resolvePdfSourceUrl(payload, payload.openUrl)
if (!sourceUrl) {
previewUrl.value = ''
previewFrameLoading.value = false
previewError.value = '未获取到预览地址'
return
}
previewUrl.value = payload.openUrl
previewUrl.value = sourceUrl
if (mode === 'iframe') {
startPreviewFrameTimeout(requestToken)
} else {
clearPreviewFrameTimeout()
// PDF
previewFrameLoading.value = false
}
} catch {
if (requestToken !== previewRequestToken.value) return
previewUrl.value = ''
@ -791,7 +822,7 @@ const selectFileNode = async (fileNode: FileTreeNode) => {
}
const openInNewWindow = () => {
const url = previewPayload.value?.openUrl || previewUrl.value
const url = previewUrl.value || previewPayload.value?.openUrl
if (!url) {
ElMessage.warning('暂无可打开的预览地址')
return
@ -814,6 +845,18 @@ const handlePreviewFrameError = () => {
previewError.value = '预览内容加载失败,请稍后重试'
}
const handlePdfLoadingChange = (loading: boolean) => {
if (previewMode.value !== 'pdf') return
previewFrameLoading.value = loading
if (!loading) clearPreviewFrameTimeout()
}
const handlePdfRenderError = (message: string) => {
if (previewMode.value !== 'pdf') return
previewFrameLoading.value = false
previewError.value = message || ''
}
const handleNodeExpand = async (data: TreeNode) => {
if (data.type !== 'project') return
if (!expandedKeys.value.includes(data.id)) expandedKeys.value.push(data.id)
@ -876,7 +919,7 @@ const loadCatalog = async () => {
aiProjectCache.value = {}
resetProjectAiState()
try {
const catalog = await getTodayCatalog()
const catalog = await getActiveCatalog()
treeData.value = catalog
.sort((a, b) => (a.seqNo || 0) - (b.seqNo || 0))
.map((item) => buildProjectNode(item))