feat(review-meeting): 优化项目排程与导入展示

pull/874/head
Codewoc 2026-03-31 10:39:20 +08:00
parent 9113f446e6
commit 9251c64067
7 changed files with 348 additions and 53 deletions

View File

@ -10,12 +10,13 @@ import * as FileApi from '@/api/infra/file'
export interface ReviewProjectItemVO {
sourceProjectId?: number
seqNo: number
startTime: string
endTime: string
startTime?: string
endTime?: string
agendaCategory: string
projectTitle: string
reporter: string
reporterUnit: string
reviewDate?: string
}
// 会议保存 VO新增/编辑)

View File

@ -33,6 +33,19 @@ export interface ReviewMeetingProjectSeqUpdateReqVO {
seqNo: number
}
export interface ReviewMeetingProjectResultBatchUpdateReqVO {
ids: number[]
reviewResult: 'PASS' | 'REJECT'
}
export interface ReviewMeetingProjectTimeBatchUpdateReqVO {
items: Array<{
id: number
startTime: string
endTime: string
}>
}
export interface ReviewMeetingProjectPageReqVO {
pageNo?: number
pageSize?: number
@ -141,6 +154,14 @@ export const updateReviewProject = (data: Partial<ReviewMeetingProjectRespVO>) =
export const updateReviewProjectSeqBatch = (data: ReviewMeetingProjectSeqUpdateReqVO[]) =>
request.put({ url: '/project/review-project/update-seq-batch', data })
/** 批量更新评审结果 */
export const updateReviewProjectResultBatch = (data: ReviewMeetingProjectResultBatchUpdateReqVO) =>
request.put({ url: '/project/review-project/update-result-batch', data })
/** 批量更新评审项目起止时间 */
export const updateReviewProjectTimeBatch = (data: ReviewMeetingProjectTimeBatchUpdateReqVO) =>
request.put({ url: '/project/review-project/update-time-batch', data })
/** 删除评审项目 */
export const deleteReviewProject = (ids: number[]) =>
request.delete({ url: '/project/review-project/delete', params: { ids: ids.join(',') } })

View File

@ -157,14 +157,17 @@
<button type="button" class="btn-default">导入验收申请 Excel</button>
</el-upload>
<button type="button" class="btn-default" @click="handleDownloadTemplate"></button>
<span class="import-hint">格式序号开始时间结束时间议程分类项目标题汇报人报告人单位</span>
<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="60" align="center" />
<el-table-column label="开始时间" prop="startTime" width="80" align="center" />
<el-table-column label="结束时间" prop="endTime" width="80" align="center" />
<el-table-column label="起止时间" width="120" align="center">
<template #default="{ row }">
{{ row.startTime || '--:--' }} - {{ row.endTime || '--:--' }}
</template>
</el-table-column>
<el-table-column label="议程分类" prop="agendaCategory" width="110" />
<el-table-column label="评审项目标题" prop="projectTitle" show-overflow-tooltip />
<el-table-column label="汇报人" prop="reporter" width="80" />
@ -205,6 +208,11 @@ import { getReviewProjectPage } from '@/api/review/project'
import { getExpertUserList } from '@/api/system/user/index'
import download from '@/utils/download'
import ExpertSelectTable from './components/ExpertSelectTable.vue'
import { applyDefaultReviewDate } from './projectReviewDate'
import {
buildScheduledProjectItems,
DEFAULT_REVIEW_MEETING_INTERVAL_MINUTES
} from './projectSchedule'
defineOptions({ name: 'ReviewMeetingEdit' })
@ -288,7 +296,14 @@ const mapProjectItems = (projects: any[]): ReviewProjectItemVO[] =>
agendaCategory: item.agendaCategory,
projectTitle: item.projectTitle,
reporter: item.reporter,
reporterUnit: item.reporterUnit
reporterUnit: item.reporterUnit,
reviewDate: item.reviewDate
}))
const resetProjectReviewDate = (projects: ReviewProjectItemVO[]): ReviewProjectItemVO[] =>
(projects || []).map((item) => ({
...item,
reviewDate: undefined
}))
const loadDetail = async (id: number) => {
@ -349,7 +364,7 @@ const loadCopySource = async (id: number) => {
formData.minutesAttachmentType = undefined
formData.minutesAttachmentSize = undefined
formData.expertIds = detail.expertIds || []
formData.projects = mapProjectItems(projectData?.list ?? [])
formData.projects = resetProjectReviewDate(mapProjectItems(projectData?.list ?? []))
isProjectsModified.value = false
if (detail.startTime && detail.endTime) {
@ -394,7 +409,8 @@ const handleExcelChange = async (uploadFile: UploadFile) => {
formLoading.value = true
try {
const result = await importProjectsFromExcel(uploadFile.raw)
formData.projects = result as ReviewProjectItemVO[]
const projects = applyDefaultReviewDate(result as ReviewProjectItemVO[], formData.meetingTimeRange)
formData.projects = buildScheduledProjectItems(projects, formData.meetingTimeRange?.[0]) || projects
isProjectsModified.value = true
ElMessage.success(`成功解析 ${formData.projects.length} 个评审项目`)
} catch {
@ -516,7 +532,15 @@ const submitForm = async () => {
}
formLoading.value = true
try {
const submitData = { ...formData }
const projects = buildScheduledProjectItems(
applyDefaultReviewDate(formData.projects, formData.meetingTimeRange),
formData.meetingTimeRange?.[0],
DEFAULT_REVIEW_MEETING_INTERVAL_MINUTES
) || applyDefaultReviewDate(formData.projects, formData.meetingTimeRange)
const submitData = {
...formData,
projects
}
if (isEdit.value && !isProjectsModified.value) {
delete submitData.projects
}

View File

@ -110,15 +110,18 @@
<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>
<!-- 评审项目预览列表 -->
<div v-if="formData.projects && formData.projects.length > 0" class="mt-10">
<el-table :data="formData.projects" border size="small" max-height="300">
<el-table-column label="序号" prop="seqNo" width="60" align="center" />
<el-table-column label="开始时间" prop="startTime" width="80" align="center" />
<el-table-column label="结束时间" prop="endTime" width="80" align="center" />
<el-table-column label="起止时间" width="120" align="center">
<template #default="{ row }">
{{ row.startTime || '--:--' }} - {{ row.endTime || '--:--' }}
</template>
</el-table-column>
<el-table-column label="议程分类" prop="agendaCategory" width="100" />
<el-table-column label="评审项目标题" prop="projectTitle" show-overflow-tooltip />
<el-table-column label="汇报人" prop="reporter" width="80" />
@ -151,6 +154,11 @@ import {
} from '@/api/review/meeting'
import { getExpertUserList } from '@/api/system/user'
import download from '@/utils/download'
import { applyDefaultReviewDate } from './projectReviewDate'
import {
buildScheduledProjectItems,
DEFAULT_REVIEW_MEETING_INTERVAL_MINUTES
} from './projectSchedule'
defineOptions({ name: 'ReviewMeetingForm' })
const emit = defineEmits(['success'])
@ -257,7 +265,11 @@ const handleExcelChange = async (uploadFile: UploadFile) => {
formLoading.value = true
try {
const result = await importProjectsFromExcel(uploadFile.raw)
formData.projects = ((result as any).data || result) as ReviewProjectItemVO[]
const projects = applyDefaultReviewDate(
((result as any).data || result) as ReviewProjectItemVO[],
formData.meetingTimeRange
)
formData.projects = buildScheduledProjectItems(projects, formData.meetingTimeRange?.[0]) || projects
isProjectsModified.value = true
ElMessage.success(`成功解析 ${formData.projects.length} 个评审项目`)
} catch (e) {
@ -340,7 +352,15 @@ const submitForm = async () => {
}
formLoading.value = true
try {
const submitData = { ...formData }
const projects = buildScheduledProjectItems(
applyDefaultReviewDate(formData.projects, formData.meetingTimeRange),
formData.meetingTimeRange?.[0],
DEFAULT_REVIEW_MEETING_INTERVAL_MINUTES
) || applyDefaultReviewDate(formData.projects, formData.meetingTimeRange)
const submitData = {
...formData,
projects
}
if (formType.value === 'update' && !isProjectsModified.value) {
delete submitData.projects
}

View File

@ -38,6 +38,35 @@
</div>
<!-- 列表 -->
<div class="table-toolbar">
<div class="toolbar-left">
<div class="duration-card">
<span class="toolbar-label">项目汇报时长</span>
<el-select
v-model="scheduleInterval"
class="interval-select"
size="small"
@change="handleIntervalChange"
>
<el-option
v-for="item in REVIEW_MEETING_INTERVAL_OPTIONS"
:key="item"
:label="`${item}分钟`"
:value="item"
/>
</el-select>
</div>
<el-button
type="primary"
:disabled="selectedRows.length === 0"
:loading="batchPassLoading"
@click="handleBatchPass"
>
批量通过
</el-button>
</div>
<span class="selection-summary">已选 {{ selectedRows.length }} </span>
</div>
<el-table
ref="tableRef"
v-loading="loading || sortLoading"
@ -46,7 +75,9 @@
size="small"
border
class="review-table"
@selection-change="handleSelectionChange"
>
<el-table-column type="selection" width="52" align="center" />
<el-table-column label="拖拽" width="54" align="center">
<template #default>
<span class="drag-handle" title="拖拽排序"></span>
@ -56,26 +87,10 @@
<el-table-column label="会中序号" prop="seqNo" width="72" align="center" />
<el-table-column label="起止时间" width="236" align="center">
<template #default="{ row }">
<div class="inline-time-range">
<el-time-picker
v-model="row.startTime"
class="inline-time"
size="small"
format="HH:mm"
value-format="HH:mm"
placeholder="开始"
@change="saveInlineRow(row)"
/>
<div class="readonly-time-range">
<span class="readonly-time">{{ row.startTime || '--:--' }}</span>
<span class="inline-time-sep">-</span>
<el-time-picker
v-model="row.endTime"
class="inline-time"
size="small"
format="HH:mm"
value-format="HH:mm"
placeholder="结束"
@change="saveInlineRow(row)"
/>
<span class="readonly-time">{{ row.endTime || '--:--' }}</span>
</div>
</template>
</el-table-column>
@ -197,24 +212,34 @@ import {
REVIEW_AGENDA_CATEGORY_OPTIONS,
getReviewProjectPage,
updateReviewProject,
updateReviewProjectResultBatch,
updateReviewProjectSeqBatch,
updateReviewProjectTimeBatch,
deleteReviewProject,
type ReviewMeetingProjectRespVO
} from '@/api/review/project'
import { formatDate } from '@/utils/formatTime'
import {
buildScheduledProjects,
REVIEW_MEETING_INTERVAL_OPTIONS,
type ReviewMeetingIntervalMinutes
} from './projectSchedule'
defineOptions({ name: 'ReviewMeetingProject' })
const route = useRoute()
const router = useRouter()
const reviewMeetingId = Number(route.params.meetingId)
const scheduleInterval = ref<ReviewMeetingIntervalMinutes>(15)
const loading = ref(false)
const sortLoading = ref(false)
const batchPassLoading = ref(false)
const list = ref<ReviewMeetingProjectRespVO[]>([])
const total = ref(0)
const meetingInfo = ref<any>({})
const tableRef = ref()
const selectedRows = ref<ReviewMeetingProjectRespVO[]>([])
let sortableInstance: Sortable | null = null
const STATUS_LABEL: Record<number, string> = { 0: '待召开', 1: '正在召开', 2: '已结束', 3: '已取消' }
@ -225,8 +250,6 @@ const getMaterialCompleteClass = (complete?: boolean) => {
}
type InlineEditableFields = {
seqNo?: number
startTime?: string
endTime?: string
agendaCategory?: string
projectTitle?: string
reporter?: string
@ -248,18 +271,18 @@ const getList = async () => {
const data = await getReviewProjectPage(queryParams)
list.value = data.list
total.value = data.total
selectedRows.value = []
syncInlineSnapshots(data.list || [])
} finally {
loading.value = false
await nextTick()
tableRef.value?.clearSelection?.()
initSortable()
}
}
const buildInlineFields = (row: ReviewMeetingProjectRespVO): InlineEditableFields => ({
seqNo: row.seqNo,
startTime: row.startTime,
endTime: row.endTime,
agendaCategory: row.agendaCategory,
projectTitle: row.projectTitle,
reporter: row.reporter,
@ -279,8 +302,6 @@ const syncInlineSnapshots = (rows: ReviewMeetingProjectRespVO[]) => {
const isInlineChanged = (current: InlineEditableFields, snapshot: InlineEditableFields) => {
const normalize = (value: unknown) => (value ?? '') as string | number
return normalize(current.seqNo) !== normalize(snapshot.seqNo)
|| normalize(current.startTime) !== normalize(snapshot.startTime)
|| normalize(current.endTime) !== normalize(snapshot.endTime)
|| normalize(current.agendaCategory) !== normalize(snapshot.agendaCategory)
|| normalize(current.projectTitle) !== normalize(snapshot.projectTitle)
|| normalize(current.reporter) !== normalize(snapshot.reporter)
@ -289,6 +310,35 @@ const isInlineChanged = (current: InlineEditableFields, snapshot: InlineEditable
|| normalize(current.reviewResult) !== normalize(snapshot.reviewResult)
}
const getFullProjectList = async () => {
const data = await getReviewProjectPage({
reviewMeetingId,
pageNo: 1,
pageSize: Math.max(total.value || 0, queryParams.pageSize, 200)
})
return [...(data.list || [])]
}
const persistScheduledTime = async (projects: ReviewMeetingProjectRespVO[]) => {
if (!meetingInfo.value?.startTime) {
ElMessage.warning('会议开始时间为空,无法自动编排起止时间')
return null
}
const scheduledProjects = buildScheduledProjects(projects, meetingInfo.value.startTime, scheduleInterval.value)
if (!scheduledProjects) {
ElMessage.warning('会议开始时间格式无效,无法自动编排起止时间')
return null
}
await updateReviewProjectTimeBatch({
items: scheduledProjects.map((project) => ({
id: project.id,
startTime: project.startTime,
endTime: project.endTime
}))
})
return scheduledProjects
}
const saveInlineRow = async (row: ReviewMeetingProjectRespVO, options: { reload?: boolean } = {}) => {
const snapshot = inlineSnapshotMap.value[row.id]
if (!snapshot) return
@ -346,12 +396,7 @@ const handleSortEnd = async (oldIndex: number, newIndex: number) => {
sortLoading.value = true
try {
const pageStart = (queryParams.pageNo - 1) * queryParams.pageSize
const allData = await getReviewProjectPage({
reviewMeetingId,
pageNo: 1,
pageSize: Math.max(total.value || 0, queryParams.pageSize, 200)
})
const fullList = [...(allData.list || [])]
const fullList = await getFullProjectList()
list.value.forEach((item, index) => {
fullList[pageStart + index] = item
})
@ -366,13 +411,20 @@ const handleSortEnd = async (oldIndex: number, newIndex: number) => {
})
if (changedRows.length === 0) return
list.value = fullList.slice(pageStart, pageStart + queryParams.pageSize)
await updateReviewProjectSeqBatch(
changedRows.map((row) => ({
id: row.id,
seqNo: row.seqNo
}))
)
const scheduledProjects = await persistScheduledTime(fullList)
if (scheduledProjects) {
list.value = scheduledProjects.slice(pageStart, pageStart + queryParams.pageSize)
syncInlineSnapshots(list.value)
} else {
list.value = fullList.slice(pageStart, pageStart + queryParams.pageSize)
syncInlineSnapshots(list.value)
}
ElMessage.success('排序已更新')
} catch {
ElMessage.error('排序保存失败,已恢复原列表')
@ -382,6 +434,23 @@ const handleSortEnd = async (oldIndex: number, newIndex: number) => {
}
}
const handleIntervalChange = async () => {
sortLoading.value = true
try {
const fullList = await getFullProjectList()
const scheduledProjects = await persistScheduledTime(fullList)
if (!scheduledProjects) return
const pageStart = (queryParams.pageNo - 1) * queryParams.pageSize
list.value = scheduledProjects.slice(pageStart, pageStart + queryParams.pageSize)
syncInlineSnapshots(list.value)
ElMessage.success('起止时间已按新间隔重算')
} catch {
ElMessage.error('时间重算失败,请重试')
} finally {
sortLoading.value = false
}
}
const handleDelete = async (id: number) => {
await ElMessageBox.confirm('确认删除该项目吗?', '警告', { type: 'warning' })
await deleteReviewProject([id])
@ -389,6 +458,30 @@ const handleDelete = async (id: number) => {
getList()
}
const handleSelectionChange = (rows: ReviewMeetingProjectRespVO[]) => {
selectedRows.value = rows
}
const handleBatchPass = async () => {
if (selectedRows.value.length === 0) return
await ElMessageBox.confirm(
`确认将当前页勾选的 ${selectedRows.value.length} 个项目统一设为通过吗?`,
'批量通过确认',
{ type: 'warning' }
)
batchPassLoading.value = true
try {
await updateReviewProjectResultBatch({
ids: selectedRows.value.map((row) => row.id),
reviewResult: 'PASS'
})
ElMessage.success('已批量设为通过')
await getList()
} finally {
batchPassLoading.value = false
}
}
const goToDetail = (row: ReviewMeetingProjectRespVO) => {
router.push({
name: 'ReviewProjectDetail',
@ -496,6 +589,41 @@ onBeforeUnmount(() => {
gap: 10px 14px;
grid-template-columns: repeat(4, minmax(140px, 1fr));
}
.table-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.toolbar-left {
display: flex;
align-items: center;
gap: 12px;
}
.duration-card {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
background: linear-gradient(180deg, #f7faff 0%, #eef4ff 100%);
border: 1px solid #cfdcf8;
border-radius: 10px;
box-shadow: 0 4px 12px rgba(41, 90, 188, 0.08);
}
.toolbar-label {
color: #295abc;
font-size: 13px;
font-weight: 600;
white-space: nowrap;
}
.interval-select {
width: 128px;
}
.selection-summary {
color: #5c6f91;
font-size: 13px;
}
.meta-item {
background: #fff;
border: 1px solid rgba(41, 90, 188, 0.1);
@ -570,33 +698,50 @@ onBeforeUnmount(() => {
align-items: center;
gap: 6px;
}
.readonly-time-range {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
}
.readonly-time {
min-width: 44px;
color: #20314f;
font-weight: 600;
}
.inline-time-sep {
color: #999;
}
.inline-time {
width: 92px;
}
.inline-field {
width: 100%;
}
:deep(.inline-field .el-input__wrapper),
:deep(.inline-field .el-select__wrapper),
:deep(.inline-time .el-input__wrapper) {
:deep(.interval-select .el-select__wrapper) {
border-radius: 6px;
border: 1px solid transparent;
box-shadow: none;
background-color: #f8fafc;
transition: border-color 0.2s, background-color 0.2s;
}
:deep(.interval-select .el-select__wrapper) {
border-color: #b7c9f2;
background: #fff;
box-shadow: 0 0 0 1px rgba(41, 90, 188, 0.04);
}
:deep(.interval-select .el-select__placeholder),
:deep(.interval-select .el-select__selected-item) {
color: #20314f;
font-weight: 600;
}
:deep(.review-table .el-table__body tr:hover .inline-field .el-input__wrapper),
:deep(.review-table .el-table__body tr:hover .inline-field .el-select__wrapper),
:deep(.review-table .el-table__body tr:hover .inline-time .el-input__wrapper) {
:deep(.review-table .el-table__body tr:hover .inline-field .el-select__wrapper) {
border-color: #d8e0ec;
background-color: #fff;
}
:deep(.inline-field .el-input__wrapper.is-focus),
:deep(.inline-field .el-select__wrapper.is-focused),
:deep(.inline-time .el-input__wrapper.is-focus) {
:deep(.interval-select .el-select__wrapper.is-focused) {
border-color: #295abc;
background-color: #fff;
}

View File

@ -0,0 +1,29 @@
import dayjs from 'dayjs'
import type { ReviewProjectItemVO } from '@/api/review/meeting'
import { resolveMeetingStartTime } from './projectSchedule'
const resolveMeetingStartDate = (meetingTimeRange?: Array<string | number | undefined>) => {
const meetingStart = meetingTimeRange?.[0]
if (meetingStart === undefined || meetingStart === null || meetingStart === '') {
return undefined
}
const parsed = resolveMeetingStartTime(meetingStart)
if (!parsed.isValid()) {
return undefined
}
return parsed.format('YYYY-MM-DD')
}
export const applyDefaultReviewDate = (
projects: ReviewProjectItemVO[] = [],
meetingTimeRange?: Array<string | number | undefined>
): ReviewProjectItemVO[] => {
const defaultReviewDate = resolveMeetingStartDate(meetingTimeRange)
if (!defaultReviewDate) {
return projects
}
return projects.map((item) => ({
...item,
reviewDate: item.reviewDate || defaultReviewDate
}))
}

View File

@ -0,0 +1,55 @@
import dayjs from 'dayjs'
import type { ReviewProjectItemVO } from '@/api/review/meeting'
import type { ReviewMeetingProjectRespVO } from '@/api/review/project'
export const REVIEW_MEETING_INTERVAL_OPTIONS = [10, 15, 20, 30] as const
export type ReviewMeetingIntervalMinutes = (typeof REVIEW_MEETING_INTERVAL_OPTIONS)[number]
export const DEFAULT_REVIEW_MEETING_INTERVAL_MINUTES: ReviewMeetingIntervalMinutes = 15
type SchedulableProject = {
startTime?: string
endTime?: string
}
const parseMeetingStartTime = (meetingStartTime?: string | number) => {
if (!meetingStartTime) return null
const normalized = typeof meetingStartTime === 'string'
? (/^\d+$/.test(meetingStartTime) ? Number(meetingStartTime) : meetingStartTime.replace(' ', 'T'))
: meetingStartTime
const parsed = dayjs(normalized)
if (!parsed.isValid()) return null
return parsed
}
export const resolveMeetingStartTime = parseMeetingStartTime
const buildScheduledItems = <T extends SchedulableProject>(
projects: T[],
meetingStartTime: string | number | undefined,
intervalMinutes: ReviewMeetingIntervalMinutes
) => {
const meetingStart = parseMeetingStartTime(meetingStartTime)
if (!meetingStart) return null
return projects.map((project, index) => {
const start = meetingStart.add(index * intervalMinutes, 'minute')
const end = start.add(intervalMinutes, 'minute')
return {
...project,
startTime: start.format('HH:mm'),
endTime: end.format('HH:mm')
}
})
}
export const buildScheduledProjects = (
projects: ReviewMeetingProjectRespVO[],
meetingStartTime: string | undefined,
intervalMinutes: ReviewMeetingIntervalMinutes
) => buildScheduledItems(projects, meetingStartTime, intervalMinutes)
export const buildScheduledProjectItems = (
projects: ReviewProjectItemVO[],
meetingStartTime: string | number | undefined,
intervalMinutes: ReviewMeetingIntervalMinutes = DEFAULT_REVIEW_MEETING_INTERVAL_MINUTES
) => buildScheduledItems(projects, meetingStartTime, intervalMinutes)