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

785 lines
23 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<template>
<ContentWrap>
<!-- 页面标题 -->
<div class="page-title">项目列表</div>
<!-- 会议摘要信息 -->
<div class="meeting-summary-bar">
<span class="summary-label">所属会议</span>
<span class="summary-meeting-name">{{ meetingInfo.name || '-' }}</span>
<span class="summary-sep">|</span>
<span class="summary-label">会议时间</span>
<span class="summary-value">
{{ meetingInfo.startTime ? formatDate(meetingInfo.startTime, 'YYYY-MM-DD HH:mm') : '-' }}
&nbsp;~&nbsp;
{{ meetingInfo.endTime ? formatDate(meetingInfo.endTime, 'YYYY-MM-DD HH:mm') : '-' }}
</span>
<span class="summary-sep">|</span>
<span class="summary-label">会议地点</span>
<span class="summary-value">{{ meetingInfo.location || '-' }}</span>
<span class="summary-sep">|</span>
<span class="summary-label">状态</span>
<span :class="`status-text status-${meetingInfo.status}`">{{ STATUS_LABEL[meetingInfo.status] }}</span>
</div>
<!-- 搜索栏 -->
<div class="search-bar">
<el-input
v-model="queryParams.projectTitle"
placeholder="项目标题"
clearable
class="search-input"
@keyup.enter="handleQuery"
/>
<el-input
v-model="queryParams.reporter"
placeholder="报告人"
clearable
class="search-input"
@keyup.enter="handleQuery"
/>
<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>
<button class="btn-reset" @click="resetQuery">重置</button>
<button class="btn-search" @click="handleQuery">查询</button>
</div>
<!-- 操作按钮 -->
<div class="toolbar">
<button class="btn-default" @click="openForm('create')">
<span class="btn-icon">+</span> 新增评审项目
</button>
<button class="btn-default btn-danger" :disabled="selectedIds.length === 0" @click="handleDelete()">
批量删除
</button>
</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="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="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="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="议程分类" 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="评审结果" 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>
<!-- 编辑/新增项目弹窗 -->
<el-dialog v-model="formVisible" :title="formType === 'create' ? '新增评审项目' : '编辑评审项目'" width="550px">
<el-form ref="formRef" :model="formData" :rules="formRules" label-width="100px">
<el-form-item label="序号" prop="seqNo">
<el-input-number v-model="formData.seqNo" :min="1" />
</el-form-item>
<el-form-item label="时间范围">
<div style="display: flex; gap: 10px; width: 100%;">
<el-time-picker v-model="formData.startTime" format="HH:mm" value-format="HH:mm" placeholder="开始时间" style="flex: 1" />
<span>-</span>
<el-time-picker v-model="formData.endTime" format="HH:mm" value-format="HH:mm" placeholder="结束时间" style="flex: 1" />
</div>
</el-form-item>
<el-form-item label="议程分类" prop="agendaCategory">
<el-select v-model="formData.agendaCategory" placeholder="请选择议程分类" style="width: 100%">
<el-option v-for="item in REVIEW_AGENDA_CATEGORY_OPTIONS" :key="item" :label="item" :value="item" />
</el-select>
</el-form-item>
<el-form-item label="项目标题" prop="projectTitle">
<el-input v-model="formData.projectTitle" placeholder="请输入项目标题" />
</el-form-item>
<el-form-item label="汇报人" prop="reporter">
<el-input v-model="formData.reporter" placeholder="请输入汇报人" />
</el-form-item>
<el-form-item label="报告单位" prop="reporterUnit">
<el-input v-model="formData.reporterUnit" placeholder="请输入报告单位" />
</el-form-item>
<el-form-item label="评审日期" prop="reviewDate">
<el-date-picker v-model="formData.reviewDate" type="date" value-format="YYYY-MM-DD" placeholder="请选择评审日期" style="width: 100%" />
</el-form-item>
<el-form-item label="评审结果" prop="reviewResult">
<el-select v-model="formData.reviewResult" placeholder="请选择评审结果" clearable style="width: 100%">
<el-option label="通过" value="PASS" />
<el-option label="不通过" value="REJECT" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="formVisible = false">取 消</el-button>
<el-button type="primary" :loading="formLoading" @click="submitForm">确 定</el-button>
</template>
</el-dialog>
<!-- 分页 -->
<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,
updateReviewProjectSeqBatch,
createReviewProject,
deleteReviewProject,
type ReviewMeetingProjectRespVO
} from '@/api/review/project'
import { formatDate } from '@/utils/formatTime'
defineOptions({ name: 'ReviewMeetingProject' })
const route = useRoute()
const router = useRouter()
const reviewMeetingId = Number(route.params.meetingId)
const loading = ref(false)
const sortLoading = ref(false)
const list = ref<ReviewMeetingProjectRespVO[]>([])
const total = ref(0)
const meetingInfo = ref<any>({})
const tableRef = ref()
let sortableInstance: Sortable | null = null
const 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
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,
pageSize: 10,
reviewMeetingId,
projectTitle: undefined as string | undefined,
agendaCategory: undefined as string | undefined,
reporter: undefined as string | undefined
})
const getList = async () => {
loading.value = true
try {
const data = await getReviewProjectPage(queryParams)
list.value = data.list
total.value = data.total
syncInlineSnapshots(data.list || [])
} finally {
loading.value = false
await nextTick()
initSortable()
}
}
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')
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) => {
if (queryParams.projectTitle || queryParams.agendaCategory || queryParams.reporter) {
ElMessage.warning('请先清空筛选条件后再拖拽排序')
await getList()
return
}
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 allData = await getReviewProjectPage({
reviewMeetingId,
pageNo: 1,
pageSize: Math.max(total.value || 0, queryParams.pageSize, 200)
})
const fullList = [...(allData.list || [])]
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
list.value = fullList.slice(pageStart, pageStart + queryParams.pageSize)
await updateReviewProjectSeqBatch(
changedRows.map((row) => ({
id: row.id,
seqNo: row.seqNo
}))
)
ElMessage.success('排序已更新')
} catch {
ElMessage.error('排序保存失败,已恢复原列表')
await getList()
} finally {
sortLoading.value = false
}
}
const handleQuery = () => { queryParams.pageNo = 1; getList() }
const resetQuery = () => {
queryParams.projectTitle = undefined
queryParams.agendaCategory = undefined
queryParams.reporter = undefined
handleQuery()
}
const selectedIds = ref<number[]>([])
const handleSelectionChange = (val: ReviewMeetingProjectRespVO[]) => {
selectedIds.value = val.map(v => v.id)
}
const handleDelete = async (id?: number) => {
const ids = id ? [id] : selectedIds.value
if (ids.length === 0) return
await ElMessageBox.confirm(`确认删除选中的 ${ids.length} 个项目吗?`, '警告', { type: 'warning' })
await deleteReviewProject(ids)
ElMessage.success('删除成功')
getList()
}
const formVisible = ref(false)
const formType = ref<'create' | 'update'>('create')
const formLoading = ref(false)
const formRef = ref()
const formData = reactive<Partial<ReviewMeetingProjectRespVO>>({})
const formRules = {
agendaCategory: [{ required: true, message: '请选择议程分类', trigger: 'change' }],
projectTitle: [{ required: true, message: '项目标题不能为空', trigger: 'blur' }]
}
const openForm = (type: 'create' | 'update', row?: ReviewMeetingProjectRespVO) => {
formType.value = type
if (type === 'create') {
Object.keys(formData).forEach(key => delete formData[key as keyof ReviewMeetingProjectRespVO])
formData.reviewMeetingId = reviewMeetingId
} else if (row) {
Object.assign(formData, row)
}
formVisible.value = true
}
const submitForm = async () => {
const valid = await formRef.value?.validate()
if (!valid) return
formLoading.value = true
try {
if (formType.value === 'create') {
await createReviewProject(formData)
ElMessage.success('新增成功')
} else {
await updateReviewProject(formData)
ElMessage.success('修改成功')
}
formVisible.value = false
getList()
} finally {
formLoading.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-summary-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
padding: 10px 14px;
background-color: rgba(41, 90, 188, 0.05);
border: 1px solid rgba(41, 90, 188, 0.12);
border-radius: 6px;
margin-bottom: 16px;
font-size: 14px;
color: #333;
}
.summary-label {
color: #666;
}
.summary-meeting-name {
color: #295abc;
font-weight: 500;
}
.summary-value {
color: #333;
}
.summary-sep {
color: #ddd;
margin: 0 4px;
}
/* ── 状态文字 ── */
.status-text { font-size: 14px; font-weight: 500; }
.status-0 { color: #ecae4b; }
.status-1 { color: #73c047; }
.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;
flex-wrap: wrap;
align-items: center;
gap: 12px;
margin-bottom: 14px;
}
.search-input {
width: 180px;
}
:deep(.search-input .el-input__wrapper) {
height: 40px;
border-radius: 6px;
border-color: #dcdedf;
}
:deep(.search-input .el-input__inner) {
font-size: 15px;
color: #333;
}
.btn-search {
height: 40px;
padding: 0 22px;
background-color: #295abc;
color: #fff;
border: none;
border-radius: 6px;
font-size: 15px;
cursor: pointer;
transition: background-color 0.2s;
}
.btn-search:hover { background-color: rgba(41, 90, 188, 0.88); }
.btn-reset {
height: 40px;
padding: 0 8px;
background: none;
border: none;
color: #295abc;
font-size: 15px;
cursor: pointer;
}
.btn-reset:hover { opacity: 0.8; }
/* ── 工具栏 ── */
.toolbar {
display: flex;
gap: 10px;
margin-bottom: 12px;
}
.btn-default {
display: inline-flex;
align-items: center;
gap: 6px;
height: 36px;
padding: 0 16px;
background-color: #fff;
border: 1px solid #d5d5d5;
border-radius: 6px;
font-size: 14px;
color: #333;
cursor: pointer;
transition: all 0.2s;
}
.btn-default:hover {
background-color: rgba(41, 90, 188, 0.08);
border-color: #295abc;
color: #295abc;
}
.btn-default:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.btn-danger {
border-color: #fc4f54;
color: #fc4f54;
}
.btn-danger:hover {
background-color: rgba(252, 79, 84, 0.08);
border-color: #fc4f54;
color: #fc4f54;
}
.btn-icon {
font-size: 16px;
line-height: 1;
}
/* ── 项目名称 ── */
.project-name-text {
color: #295abc;
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;
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);
}
</style>