feat(review-ui): 优化项目列表行内编辑与复制会议带项目资料

pull/874/head
Codewoc 2026-03-26 10:46:21 +08:00
parent 1ba4d43de4
commit 0e0124872c
6 changed files with 326 additions and 52 deletions

View File

@ -6,6 +6,7 @@ import request from '@/config/axios'
// 评审项目条目Excel 导入 & 保存时使用)
export interface ReviewProjectItemVO {
sourceProjectId?: number
seqNo: number
startTime: string
endTime: string

View File

@ -19,6 +19,8 @@ export interface ReviewMeetingProjectRespVO {
host?: string
reviewDate?: string
reviewResult?: 'PASS' | 'REJECT'
preMeetingMaterialsComplete?: boolean
postMeetingMaterialsComplete?: boolean
}
export const REVIEW_AGENDA_CATEGORY_OPTIONS = ['项目立项', '预验收', '项目终验'] as const
@ -36,6 +38,7 @@ export interface ReviewMeetingProjectPageReqVO {
projectTitle?: string
agendaCategory?: string
reporter?: string
reviewDate?: string
}
/** 独立项目列表查询meetingId 可选,用于独立菜单页) */
@ -47,6 +50,7 @@ export interface ReviewProjectPageReqVO {
agendaCategory?: string
reporter?: string
reporterUnit?: string
reviewDate?: string
}
export interface ReviewMeetingFileRespVO {

View File

@ -43,6 +43,14 @@
<el-select v-model="queryParams.agendaCategory" placeholder="议程分类" clearable class="search-input">
<el-option v-for="item in REVIEW_AGENDA_CATEGORY_OPTIONS" :key="item" :label="item" :value="item" />
</el-select>
<el-date-picker
v-model="queryParams.reviewDate"
type="date"
value-format="YYYY-MM-DD"
placeholder="评审日期"
clearable
class="search-input"
/>
<button class="btn-reset" @click="resetQuery"></button>
<button class="btn-search" @click="handleQuery"></button>
</div>
@ -76,9 +84,25 @@
<el-table-column label="报告人" prop="reporter" width="80" />
<el-table-column label="报告单位" prop="reporterUnit" show-overflow-tooltip width="130" />
<el-table-column label="评审日期" prop="reviewDate" width="120" align="center" />
<el-table-column label="会前资料齐全" width="120" align="center">
<template #default="{ row }">
<span :class="getMaterialCompleteClass(row.preMeetingMaterialsComplete)">
{{ row.preMeetingMaterialsComplete ? '✓' : '✗' }}
</span>
</template>
</el-table-column>
<el-table-column label="会后资料齐全" width="120" align="center">
<template #default="{ row }">
<span :class="getMaterialCompleteClass(row.postMeetingMaterialsComplete)">
{{ row.postMeetingMaterialsComplete ? '✓' : '✗' }}
</span>
</template>
</el-table-column>
<el-table-column label="评审结果" width="110" align="center">
<template #default="{ row }">
{{ REVIEW_RESULT_LABEL[row.reviewResult as keyof typeof REVIEW_RESULT_LABEL] || '-' }}
<span :class="getReviewResultClass(row.reviewResult)">
{{ REVIEW_RESULT_LABEL[row.reviewResult as keyof typeof REVIEW_RESULT_LABEL] || '-' }}
</span>
</template>
</el-table-column>
<el-table-column label="操作" width="180" align="center" fixed="right">
@ -167,6 +191,15 @@ const list = ref<ReviewMeetingProjectRespVO[]>([])
const total = ref(0)
const meetingOptions = ref<{ id: number; name: string; host?: string }[]>([])
const REVIEW_RESULT_LABEL = { PASS: '通过', REJECT: '不通过' }
const getReviewResultClass = (result?: string) => {
if (result === 'PASS') return 'review-result-pass'
if (result === 'REJECT') return 'review-result-reject'
return ''
}
const getMaterialCompleteClass = (complete?: boolean) => {
if (complete) return 'material-complete'
return 'material-incomplete'
}
const queryParams = reactive<ReviewProjectPageReqVO & { pageNo: number; pageSize: number }>({
pageNo: 1,
@ -175,7 +208,8 @@ const queryParams = reactive<ReviewProjectPageReqVO & { pageNo: number; pageSize
projectTitle: undefined,
agendaCategory: undefined,
reporter: undefined,
reporterUnit: undefined
reporterUnit: undefined,
reviewDate: undefined
})
const getList = async () => {
@ -201,6 +235,7 @@ const resetQuery = () => {
queryParams.agendaCategory = undefined
queryParams.reporter = undefined
queryParams.reporterUnit = undefined
queryParams.reviewDate = undefined
handleQuery()
}
@ -398,6 +433,25 @@ onMounted(async () => {
}
.project-name-text:hover { text-decoration: underline; }
.review-result-pass {
color: #67c23a;
font-weight: 500;
}
.review-result-reject {
color: #f56c6c;
font-weight: 500;
}
.material-complete {
color: #67c23a !important;
font-weight: 600;
font-size: 16px;
}
.material-incomplete {
color: #f56c6c !important;
font-weight: 600;
font-size: 16px;
}
/* ── 操作链接 ── */
.op-link {
display: inline-block;

View File

@ -281,6 +281,7 @@ const formRef = ref()
const mapProjectItems = (projects: any[]): ReviewProjectItemVO[] =>
(projects || []).map((item: any) => ({
sourceProjectId: item.sourceProjectId ?? item.id,
seqNo: item.seqNo,
startTime: item.startTime,
endTime: item.endTime,
@ -319,7 +320,10 @@ const loadDetail = async (id: number) => {
const loadCopySource = async (id: number) => {
formLoading.value = true
try {
const detail = await getReviewMeeting(id)
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' })
@ -345,7 +349,8 @@ const loadCopySource = async (id: number) => {
formData.minutesAttachmentType = undefined
formData.minutesAttachmentSize = undefined
formData.expertIds = detail.expertIds || []
formData.projects = []
formData.projects = mapProjectItems(projectData?.list ?? [])
isProjectsModified.value = false
if (detail.startTime && detail.endTime) {
formData.meetingTimeRange = [
@ -364,7 +369,7 @@ const loadCopySource = async (id: number) => {
formData.materialViewTimeRange = undefined
}
ElMessage.info('已带入会议信息;议程附件和评审项目不会复制,请补充后再保存草稿')
ElMessage.info('已带入会议信息和评审项目;保存草稿后将同步复制项目资料,议程附件与会议纪要不会复制')
} finally {
formLoading.value = false
}

View File

@ -36,11 +36,6 @@
</div>
<div class="material-card" v-loading="loading">
<div class="material-toolbar">
<div class="toolbar-title">上传资料</div>
<el-button link type="primary" @click="handleDownloadTemplate"></el-button>
</div>
<div class="group-title">会前资料</div>
<el-table :data="beforeMeetingMaterials" border class="material-table" empty-text="">
<el-table-column label="资料类型" min-width="220">
@ -127,7 +122,6 @@ import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { getReviewMeeting } from '@/api/review/meeting'
import {
downloadProjectTemplateBundle,
getProjectMaterialHistory,
getProjectMaterialSummary,
uploadProjectMaterial,
@ -215,15 +209,6 @@ const loadSummary = async () => {
}
}
const handleDownloadTemplate = async () => {
const agendaType = materialSummary.value.agendaType || projectInfo.value.agendaCategory
if (!agendaType) {
ElMessage.warning('未获取到议程分类')
return
}
await downloadProjectTemplateBundle(agendaType)
}
const triggerUpload = (row: ReviewMeetingMaterialItemRespVO) => {
uploadTarget.value = row
if (!uploadInputRef.value) return
@ -403,19 +388,6 @@ onMounted(async () => {
padding: 16px;
}
.material-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 14px;
}
.toolbar-title {
font-size: 16px;
font-weight: 600;
color: #303133;
}
.group-title {
margin: 8px 0 10px;
font-size: 14px;

View File

@ -56,38 +56,139 @@
</div>
<!-- 列表 -->
<el-table ref="tableRef" v-loading="loading || sortLoading" :data="list" row-key="id" border class="review-table" @selection-change="handleSelectionChange">
<el-table-column type="selection" width="50" align="center" />
<el-table-column label="拖拽" width="60" align="center">
<el-table
ref="tableRef"
v-loading="loading || sortLoading"
:data="list"
row-key="id"
size="small"
border
class="review-table"
@selection-change="handleSelectionChange"
>
<el-table-column type="selection" width="46" align="center" />
<el-table-column label="拖拽" width="54" align="center">
<template #default>
<span class="drag-handle" title="拖拽排序"></span>
</template>
</el-table-column>
<el-table-column label="立项编号" prop="id" width="80" align="center" />
<el-table-column label="会中序号" prop="seqNo" width="80" align="center" />
<el-table-column label="起止时间" width="110" align="center">
<el-table-column label="立项编号" prop="id" width="72" align="center" />
<el-table-column label="会中序号" prop="seqNo" width="72" align="center" />
<el-table-column label="起止时间" width="236" align="center">
<template #default="{ row }">
<span v-if="row.startTime || row.endTime">{{ row.startTime || '' }} - {{ row.endTime || '' }}</span>
<span v-else>-</span>
<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)"
/>
<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)"
/>
</div>
</template>
</el-table-column>
<el-table-column label="议程分类" prop="agendaCategory" width="110" />
<el-table-column label="项目名称" prop="projectTitle" show-overflow-tooltip min-width="180">
<el-table-column label="议程分类" width="130">
<template #default="{ row }">
<span class="project-name-text">{{ row.projectTitle }}</span>
<el-select
v-model="row.agendaCategory"
class="inline-field"
size="small"
placeholder="议程分类"
@change="saveInlineRow(row, { reload: true })"
>
<el-option v-for="item in REVIEW_AGENDA_CATEGORY_OPTIONS" :key="item" :label="item" :value="item" />
</el-select>
</template>
</el-table-column>
<el-table-column label="报告人" prop="reporter" width="80" />
<el-table-column label="报告单位" prop="reporterUnit" show-overflow-tooltip width="130" />
<el-table-column label="评审日期" prop="reviewDate" width="120" align="center" />
<el-table-column label="评审结果" width="110" align="center">
<el-table-column label="项目名称" prop="projectTitle" min-width="220">
<template #default="{ row }">
{{ REVIEW_RESULT_LABEL[row.reviewResult as keyof typeof REVIEW_RESULT_LABEL] || '-' }}
<el-input
v-model="row.projectTitle"
class="inline-field"
size="small"
placeholder="项目名称"
@blur="saveInlineRow(row)"
/>
</template>
</el-table-column>
<el-table-column label="操作" width="180" align="center" fixed="right">
<el-table-column label="报告人" width="120">
<template #default="{ row }">
<el-input
v-model="row.reporter"
class="inline-field"
size="small"
placeholder="报告人"
@blur="saveInlineRow(row)"
/>
</template>
</el-table-column>
<el-table-column label="报告单位" min-width="180">
<template #default="{ row }">
<el-input
v-model="row.reporterUnit"
class="inline-field"
size="small"
placeholder="报告单位"
@blur="saveInlineRow(row)"
/>
</template>
</el-table-column>
<el-table-column label="评审日期" width="142" align="center">
<template #default="{ row }">
<el-date-picker
v-model="row.reviewDate"
class="inline-field"
size="small"
type="date"
value-format="YYYY-MM-DD"
placeholder="评审日期"
@change="saveInlineRow(row)"
/>
</template>
</el-table-column>
<el-table-column label="会前资料齐全" width="110" align="center">
<template #default="{ row }">
<span :class="getMaterialCompleteClass(row.preMeetingMaterialsComplete)">
{{ row.preMeetingMaterialsComplete ? '✓' : '✗' }}
</span>
</template>
</el-table-column>
<el-table-column label="会后资料齐全" width="110" align="center">
<template #default="{ row }">
<span :class="getMaterialCompleteClass(row.postMeetingMaterialsComplete)">
{{ row.postMeetingMaterialsComplete ? '✓' : '✗' }}
</span>
</template>
</el-table-column>
<el-table-column label="评审结果" width="120" align="center">
<template #default="{ row }">
<el-select
v-model="row.reviewResult"
class="inline-field"
size="small"
placeholder="评审结果"
clearable
@change="saveInlineRow(row)"
>
<el-option label="通过" value="PASS" />
<el-option label="不通过" value="REJECT" />
</el-select>
</template>
</el-table-column>
<el-table-column label="操作" width="154" align="center" fixed="right">
<template #default="{ row }">
<a class="op-link" @click="openForm('update', row)">编辑</a>
<a class="op-link" @click="goToDetail(row)"></a>
<a class="op-link op-danger" @click="handleDelete(row.id)"></a>
</template>
@ -174,7 +275,22 @@ const tableRef = ref()
let sortableInstance: Sortable | null = null
const STATUS_LABEL: Record<number, string> = { 0: '待召开', 1: '正在召开', 2: '已结束', 3: '已取消' }
const REVIEW_RESULT_LABEL = { PASS: '通过', REJECT: '不通过' }
const getMaterialCompleteClass = (complete?: boolean) => {
if (complete) return 'material-complete'
return 'material-incomplete'
}
type InlineEditableFields = {
seqNo?: number
startTime?: string
endTime?: string
agendaCategory?: string
projectTitle?: string
reporter?: string
reporterUnit?: string
reviewDate?: string
reviewResult?: 'PASS' | 'REJECT'
}
const inlineSnapshotMap = ref<Record<number, InlineEditableFields>>({})
const queryParams = reactive({
pageNo: 1,
@ -191,6 +307,7 @@ const getList = async () => {
const data = await getReviewProjectPage(queryParams)
list.value = data.list
total.value = data.total
syncInlineSnapshots(data.list || [])
} finally {
loading.value = false
await nextTick()
@ -198,6 +315,70 @@ const getList = async () => {
}
}
const buildInlineFields = (row: ReviewMeetingProjectRespVO): InlineEditableFields => ({
seqNo: row.seqNo,
startTime: row.startTime,
endTime: row.endTime,
agendaCategory: row.agendaCategory,
projectTitle: row.projectTitle,
reporter: row.reporter,
reporterUnit: row.reporterUnit,
reviewDate: row.reviewDate,
reviewResult: row.reviewResult
})
const syncInlineSnapshots = (rows: ReviewMeetingProjectRespVO[]) => {
const snapshot: Record<number, InlineEditableFields> = {}
rows.forEach((row) => {
snapshot[row.id] = buildInlineFields(row)
})
inlineSnapshotMap.value = snapshot
}
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)
|| normalize(current.reporterUnit) !== normalize(snapshot.reporterUnit)
|| normalize(current.reviewDate) !== normalize(snapshot.reviewDate)
|| normalize(current.reviewResult) !== normalize(snapshot.reviewResult)
}
const saveInlineRow = async (row: ReviewMeetingProjectRespVO, options: { reload?: boolean } = {}) => {
const snapshot = inlineSnapshotMap.value[row.id]
if (!snapshot) return
row.projectTitle = row.projectTitle?.trim()
if (!row.agendaCategory) {
ElMessage.warning('议程分类不能为空')
Object.assign(row, snapshot)
return
}
if (!row.projectTitle) {
ElMessage.warning('项目名称不能为空')
Object.assign(row, snapshot)
return
}
const current = buildInlineFields(row)
if (!isInlineChanged(current, snapshot)) return
try {
await updateReviewProject({
id: row.id,
...current
})
inlineSnapshotMap.value[row.id] = buildInlineFields(row)
if (options.reload) {
await getList()
}
} catch {
Object.assign(row, snapshot)
ElMessage.error('保存失败,已恢复')
}
}
const initSortable = () => {
if (!tableRef.value?.$el) return
const tbody = tableRef.value.$el.querySelector('.el-table__body-wrapper tbody')
@ -403,6 +584,25 @@ onBeforeUnmount(() => {
.status-2 { color: #999; }
.status-3 { color: #999; }
.review-result-pass {
color: #67c23a;
font-weight: 500;
}
.review-result-reject {
color: #f56c6c;
font-weight: 500;
}
.material-complete {
color: #67c23a !important;
font-weight: 600;
font-size: 16px;
}
.material-incomplete {
color: #f56c6c !important;
font-weight: 600;
font-size: 16px;
}
/* ── 搜索栏 ── */
.search-bar {
display: flex;
@ -497,6 +697,42 @@ onBeforeUnmount(() => {
font-size: 14px;
}
.inline-time-range {
display: flex;
align-items: center;
gap: 6px;
}
.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) {
border-radius: 6px;
border: 1px solid transparent;
box-shadow: none;
background-color: #f8fafc;
transition: border-color 0.2s, background-color 0.2s;
}
: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) {
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) {
border-color: #295abc;
background-color: #fff;
}
/* ── 操作链接 ── */
.op-link {
display: inline-block;
@ -521,6 +757,8 @@ onBeforeUnmount(() => {
font-size: 14px;
color: #333;
border-color: #e1e7f0;
padding-top: 10px;
padding-bottom: 10px;
}
:deep(.review-table .el-table__body tr:hover > td) {
background-color: rgba(41, 90, 188, 0.04);