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

809 lines
22 KiB
Vue

<template>
<ContentWrap>
<!-- 页面标题 -->
<div class="page-title">项目列表</div>
<!-- 会议摘要信息 -->
<div class="meeting-info-card">
<div class="meeting-info-head">
<div class="meeting-title-wrap">
<div class="meeting-caption">所属会议</div>
<div class="meeting-name">{{ meetingInfo.name || '-' }}</div>
<div class="meeting-time">
{{ meetingInfo.startTime ? formatDate(meetingInfo.startTime, 'YYYY-MM-DD HH:mm') : '-' }}
<span class="meeting-time-sep">~</span>
{{ meetingInfo.endTime ? formatDate(meetingInfo.endTime, 'YYYY-MM-DD HH:mm') : '-' }}
</div>
</div>
<span :class="`status-pill status-pill-${meetingInfo.status}`">{{ STATUS_LABEL[meetingInfo.status] || '-' }}</span>
</div>
<div class="meeting-meta-grid">
<div class="meta-item">
<span class="meta-label">会议地点</span>
<span class="meta-value">{{ meetingInfo.location || '-' }}</span>
</div>
<div class="meta-item">
<span class="meta-label">组织单位</span>
<span class="meta-value">{{ meetingInfo.organizationUnit || '-' }}</span>
</div>
<div class="meta-item">
<span class="meta-label">会议主持人</span>
<span class="meta-value">{{ meetingInfo.host || '-' }}</span>
</div>
<div class="meta-item">
<span class="meta-label">参会专家数</span>
<span class="meta-value">{{ meetingInfo.expertCount ?? 0 }}</span>
</div>
</div>
</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"
:data="list"
row-key="id"
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>
</template>
</el-table-column>
<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 }">
<div class="readonly-time-range">
<span class="readonly-time">{{ row.startTime || '--:--' }}</span>
<span class="inline-time-sep">-</span>
<span class="readonly-time">{{ row.endTime || '--:--' }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="议程分类" width="130">
<template #default="{ row }">
<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="projectTitle" min-width="220">
<template #default="{ row }">
<el-input
v-model="row.projectTitle"
class="inline-field"
size="small"
placeholder="项目名称"
@blur="saveInlineRow(row)"
/>
</template>
</el-table-column>
<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="AI状态" width="110" align="center">
<template #default="{ row }">
<span :class="`ai-status ai-status-${row.aiSummaryStatus ?? 0}`">
{{ AI_STATUS_LABEL[row.aiSummaryStatus ?? 0] }}
</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="goToDetail(row)">上传项目资料</a>
<a class="op-link op-danger" @click="handleDelete(row.id)">删除</a>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<Pagination :total="total" v-model:page="queryParams.pageNo" v-model:limit="queryParams.pageSize" @pagination="getList" />
</ContentWrap>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, onBeforeUnmount, nextTick } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import Sortable from 'sortablejs'
import { getReviewMeeting } from '@/api/review/meeting'
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: '已取消' }
const AI_STATUS_LABEL: Record<number, string> = { 0: '待构建', 1: '构建中', 2: '已完成', 3: '失败' }
const getMaterialCompleteClass = (complete?: boolean) => {
if (complete) return 'material-complete'
return 'material-incomplete'
}
type InlineEditableFields = {
seqNo?: number
agendaCategory?: string
projectTitle?: string
reporter?: string
reporterUnit?: string
reviewDate?: string
reviewResult?: 'PASS' | 'REJECT'
}
const inlineSnapshotMap = ref<Record<number, InlineEditableFields>>({})
const queryParams = reactive({
pageNo: 1,
pageSize: 10,
reviewMeetingId
})
const getList = async () => {
loading.value = true
try {
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,
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.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 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
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')
if (!tbody) return
sortableInstance?.destroy()
sortableInstance = Sortable.create(tbody, {
animation: 150,
handle: '.drag-handle',
ghostClass: 'drag-ghost',
chosenClass: 'drag-chosen',
onEnd: ({ oldIndex, newIndex }) => {
if (oldIndex === undefined || newIndex === undefined || oldIndex === newIndex) return
handleSortEnd(oldIndex, newIndex)
}
})
}
const handleSortEnd = async (oldIndex: number, newIndex: number) => {
const moved = list.value.splice(oldIndex, 1)[0]
if (!moved) return
list.value.splice(newIndex, 0, moved)
list.value = [...list.value]
sortLoading.value = true
try {
const pageStart = (queryParams.pageNo - 1) * queryParams.pageSize
const fullList = await getFullProjectList()
list.value.forEach((item, index) => {
fullList[pageStart + index] = item
})
const changedRows: ReviewMeetingProjectRespVO[] = []
fullList.forEach((item, index) => {
const nextSeq = index + 1
if (item.seqNo !== nextSeq) {
item.seqNo = nextSeq
changedRows.push(item)
}
})
if (changedRows.length === 0) return
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('排序保存失败,已恢复原列表')
await getList()
} finally {
sortLoading.value = false
}
}
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])
ElMessage.success('删除成功')
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',
params: { meetingId: reviewMeetingId, projectId: row.id },
state: {
projectTitle: row.projectTitle,
seqNo: row.seqNo,
startTime: row.startTime,
endTime: row.endTime,
agendaCategory: row.agendaCategory,
reporter: row.reporter,
reporterUnit: row.reporterUnit,
meetingHost: meetingInfo.value?.host,
meetingName: meetingInfo.value?.name
}
})
}
onMounted(async () => {
meetingInfo.value = await getReviewMeeting(reviewMeetingId)
await getList()
})
onBeforeUnmount(() => {
sortableInstance?.destroy()
sortableInstance = null
})
</script>
<style scoped>
/* ── 页面标题 ── */
.page-title {
font-size: 22px;
font-weight: 600;
color: #333;
margin-bottom: 16px;
padding-bottom: 14px;
border-bottom: 1px solid #e1e7f0;
}
/* ── 会议信息卡 ── */
.meeting-info-card {
padding: 16px;
background: linear-gradient(180deg, rgba(41, 90, 188, 0.1) 0%, rgba(41, 90, 188, 0.04) 100%);
border: 1px solid rgba(41, 90, 188, 0.16);
border-radius: 10px;
margin-bottom: 16px;
}
.meeting-info-head {
display: flex;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 12px;
}
.meeting-title-wrap {
display: flex;
flex-direction: column;
gap: 4px;
}
.meeting-caption {
color: #4f6d9f;
font-size: 12px;
margin: 0;
}
.meeting-name {
color: #295abc;
font-size: 20px;
line-height: 1.3;
font-weight: 600;
margin: 0;
}
.meeting-time {
margin: 0;
color: #2f3f5e;
font-size: 14px;
font-weight: 500;
}
.meeting-time-sep {
margin: 0 6px;
color: #7d91b4;
}
.status-pill {
align-self: flex-start;
padding: 4px 10px;
border-radius: 999px;
font-size: 13px;
font-weight: 600;
}
.status-pill-0 {
color: #b57616;
background: rgba(236, 174, 75, 0.2);
}
.status-pill-1 {
color: #267d1e;
background: rgba(115, 192, 71, 0.2);
}
.status-pill-2,
.status-pill-3 {
color: #596b89;
background: rgba(125, 145, 180, 0.16);
}
.meeting-meta-grid {
display: grid;
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);
border-radius: 8px;
padding: 10px 12px;
}
.meta-label {
display: block;
color: #6a7f9f;
font-size: 12px;
margin-bottom: 4px;
}
.meta-value {
display: block;
color: #20314f;
font-size: 14px;
font-weight: 600;
}
/* ── 状态文字 ── */
.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;
}
.ai-status {
display: inline-flex;
min-width: 64px;
justify-content: center;
padding: 3px 8px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
}
.ai-status-0 {
color: #8a6214;
background: #fff3cd;
}
.ai-status-1 {
color: #0f766e;
background: #ccfbf1;
}
.ai-status-2 {
color: #166534;
background: #dcfce7;
}
.ai-status-3 {
color: #b91c1c;
background: #fee2e2;
}
.inline-time-range {
display: flex;
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-field {
width: 100%;
}
:deep(.inline-field .el-input__wrapper),
:deep(.inline-field .el-select__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) {
border-color: #d8e0ec;
background-color: #fff;
}
:deep(.inline-field .el-input__wrapper.is-focus),
:deep(.inline-field .el-select__wrapper.is-focused),
:deep(.interval-select .el-select__wrapper.is-focused) {
border-color: #295abc;
background-color: #fff;
}
/* ── 操作链接 ── */
.op-link {
display: inline-block;
color: #295abc;
font-size: 13px;
cursor: pointer;
margin: 0 4px;
transition: opacity 0.2s;
}
.op-link:hover { opacity: 0.8; text-decoration: underline; }
.op-danger { color: #fc4f54; }
/* ── 表格 ── */
:deep(.review-table .el-table__header-wrapper th) {
background-color: #eef2fb;
color: #333;
font-weight: 600;
font-size: 14px;
border-color: #e1e7f0;
}
:deep(.review-table .el-table__body td) {
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);
}
.drag-handle {
display: inline-block;
color: #7a869a;
font-size: 16px;
line-height: 1;
cursor: grab;
user-select: none;
}
.drag-handle:active {
cursor: grabbing;
}
:deep(.drag-ghost > td) {
background-color: rgba(41, 90, 188, 0.1) !important;
}
:deep(.drag-chosen > td) {
background-color: rgba(41, 90, 188, 0.06);
}
@media (max-width: 1200px) {
.meeting-meta-grid {
grid-template-columns: repeat(2, minmax(140px, 1fr));
}
}
@media (max-width: 768px) {
.meeting-meta-grid {
grid-template-columns: 1fr;
}
}
</style>