feat(review-frontend): 完成评审资料清单页面与固定分类

pull/874/head
Codewoc 2026-03-26 08:27:20 +08:00
parent 36b34d6695
commit 261173e76e
4 changed files with 338 additions and 168 deletions

View File

@ -21,6 +21,9 @@ export interface ReviewMeetingProjectRespVO {
reviewResult?: 'PASS' | 'REJECT' reviewResult?: 'PASS' | 'REJECT'
} }
export const REVIEW_AGENDA_CATEGORY_OPTIONS = ['项目立项', '预验收', '项目终验'] as const
export type ReviewAgendaCategory = (typeof REVIEW_AGENDA_CATEGORY_OPTIONS)[number]
export interface ReviewMeetingProjectSeqUpdateReqVO { export interface ReviewMeetingProjectSeqUpdateReqVO {
id: number id: number
seqNo: number seqNo: number
@ -48,7 +51,12 @@ export interface ReviewProjectPageReqVO {
export interface ReviewMeetingFileRespVO { export interface ReviewMeetingFileRespVO {
id: number id: number
reviewMeetingId: number
reviewMeetingProjectId: number reviewMeetingProjectId: number
materialCode?: string
materialNameSnapshot?: string
agendaTypeSnapshot?: string
version?: number
fileName: string fileName: string
fileUrl: string fileUrl: string
fileSize: number fileSize: number
@ -66,6 +74,37 @@ export interface ReviewMeetingFileRegisterReqVO {
fileType?: string fileType?: string
} }
export interface ReviewMeetingMaterialItemRespVO {
materialCode: string
materialName: string
required: boolean
acceptExts: string
tabletVisible: boolean
latestFile?: ReviewMeetingFileRespVO
}
export interface ReviewMeetingMaterialSummaryRespVO {
agendaType: string
templateAvailable: boolean
materials: ReviewMeetingMaterialItemRespVO[]
}
export interface ReviewMeetingMaterialHistoryRespVO {
materialCode: string
materialName: string
file: ReviewMeetingFileRespVO
}
export interface ReviewMeetingMaterialUploadReqVO {
reviewMeetingId: number
reviewMeetingProjectId: number
materialCode: string
fileName: string
fileUrl: string
fileSize: number
fileType?: string
}
// ============================================================ // ============================================================
// API 调用 // API 调用
// ============================================================ // ============================================================
@ -122,6 +161,64 @@ export const getMeetingFileList = (reviewMeetingProjectId: number) =>
export const deleteMeetingFile = (id: number) => export const deleteMeetingFile = (id: number) =>
request.delete({ url: '/project/review-project/delete-file', params: { id } }) request.delete({ url: '/project/review-project/delete-file', params: { id } })
/** 获取结构化材料汇总 */
export const getProjectMaterialSummary = (reviewMeetingProjectId: number) =>
request.get<ReviewMeetingMaterialSummaryRespVO>({
url: '/project/review-project/material-summary',
params: { reviewMeetingProjectId }
})
/** 获取材料历史版本 */
export const getProjectMaterialHistory = (reviewMeetingProjectId: number, materialCode: string) =>
request.get<ReviewMeetingMaterialHistoryRespVO[]>({
url: '/project/review-project/material-history',
params: { reviewMeetingProjectId, materialCode }
})
/** 下载当前议程分类模板包 */
export const downloadProjectTemplateBundle = (agendaType: string) =>
request.download({
url: '/project/review-project/download-template-bundle',
params: { agendaType }
})
/** 上传结构化材料(预签名直传) */
export const uploadProjectMaterial = async (
reviewMeetingId: number,
reviewMeetingProjectId: number,
materialCode: string,
file: File
) => {
const presignedInfo = await FileApi.getFilePresignedUrl(file.name, 'review-meeting')
await axios.put(presignedInfo.uploadUrl, file, {
headers: {
'Content-Type': file.type || 'application/octet-stream'
}
})
await FileApi.createFile({
configId: presignedInfo.configId,
url: presignedInfo.url,
path: presignedInfo.path,
name: file.name,
type: file.type || 'application/octet-stream',
size: file.size
})
return request.post<ReviewMeetingFileRespVO>({
url: '/project/review-project/upload-material',
data: {
reviewMeetingId,
reviewMeetingProjectId,
materialCode,
fileName: file.name,
fileUrl: presignedInfo.url,
fileSize: file.size,
fileType: file.name.includes('.') ? file.name.split('.').pop()?.toLowerCase() : ''
} satisfies ReviewMeetingMaterialUploadReqVO
})
}
const uploadMeetingFileByPresignedUrl = async ( const uploadMeetingFileByPresignedUrl = async (
reviewMeetingId: number, reviewMeetingId: number,
reviewMeetingProjectId: number, reviewMeetingProjectId: number,

View File

@ -33,13 +33,16 @@
class="search-input-sm" class="search-input-sm"
@keyup.enter="handleQuery" @keyup.enter="handleQuery"
/> />
<el-input <el-input
v-model="queryParams.reporterUnit" v-model="queryParams.reporterUnit"
placeholder="报告单位" placeholder="报告单位"
clearable clearable
class="search-input" class="search-input"
@keyup.enter="handleQuery" @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-reset" @click="resetQuery"></button>
<button class="btn-search" @click="handleQuery"></button> <button class="btn-search" @click="handleQuery"></button>
</div> </div>
@ -106,7 +109,9 @@
</div> </div>
</el-form-item> </el-form-item>
<el-form-item label="议程分类" prop="agendaCategory"> <el-form-item label="议程分类" prop="agendaCategory">
<el-input v-model="formData.agendaCategory" placeholder="请输入议程分类" /> <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>
<el-form-item label="项目标题" prop="projectTitle"> <el-form-item label="项目标题" prop="projectTitle">
<el-input v-model="formData.projectTitle" placeholder="请输入项目标题" /> <el-input v-model="formData.projectTitle" placeholder="请输入项目标题" />
@ -143,6 +148,7 @@ import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { import {
REVIEW_AGENDA_CATEGORY_OPTIONS,
getReviewProjectPageStandalone, getReviewProjectPageStandalone,
updateReviewProject, updateReviewProject,
createReviewProject, createReviewProject,
@ -226,6 +232,7 @@ const formRef = ref()
const formData = reactive<Partial<ReviewMeetingProjectRespVO>>({}) const formData = reactive<Partial<ReviewMeetingProjectRespVO>>({})
const formRules = { const formRules = {
reviewMeetingId: [{ required: true, message: '请选择所属会议', trigger: 'change' }], reviewMeetingId: [{ required: true, message: '请选择所属会议', trigger: 'change' }],
agendaCategory: [{ required: true, message: '请选择议程分类', trigger: 'change' }],
projectTitle: [{ required: true, message: '项目标题不能为空', trigger: 'blur' }] projectTitle: [{ required: true, message: '项目标题不能为空', trigger: 'blur' }]
} }

View File

@ -1,12 +1,10 @@
<template> <template>
<ContentWrap> <ContentWrap>
<!-- 页面标题带返回 -->
<div class="page-title"> <div class="page-title">
<span class="back-btn" @click="handleBack">&#8249;</span> <span class="back-btn" @click="handleBack">&#8249;</span>
项目详情 项目资料
</div> </div>
<!-- 蓝色信息面板 -->
<div class="info-panel"> <div class="info-panel">
<div class="info-panel-title">{{ projectInfo.projectTitle || '-' }}</div> <div class="info-panel-title">{{ projectInfo.projectTitle || '-' }}</div>
<div class="info-panel-meta"> <div class="info-panel-meta">
@ -21,7 +19,7 @@
<div class="info-panel-footer"> <div class="info-panel-footer">
<div class="info-footer-item"> <div class="info-footer-item">
<span class="footer-label">议程分类</span> <span class="footer-label">议程分类</span>
<span class="footer-value">{{ projectInfo.agendaCategory || '-' }}</span> <span class="footer-value">{{ materialSummary.agendaType || projectInfo.agendaCategory || '-' }}</span>
</div> </div>
<div class="info-footer-item"> <div class="info-footer-item">
<span class="footer-label">报告人</span> <span class="footer-label">报告人</span>
@ -37,39 +35,70 @@
</div> </div>
</div> </div>
<!-- 上传资料区 --> <div class="material-card" v-loading="loading">
<div class="section-header"> <div class="material-toolbar">
<span class="section-title">上传资料</span> <div class="material-type-wrap">
<el-upload <span class="label">类型</span>
:auto-upload="false" <el-select :model-value="materialSummary.agendaType || projectInfo.agendaCategory" disabled class="type-select">
:on-change="handleFileChange" <el-option
:show-file-list="false" v-for="option in REVIEW_AGENDA_CATEGORY_OPTIONS"
multiple :key="option"
accept=".doc,.docx,.xls,.xlsx,.pdf,.ppt,.pptx" :label="option"
> :value="option"
<button class="btn-upload">上传资料</button> />
</el-upload> </el-select>
<span class="upload-hint">支持 docdocxxlsxlsxpdfpptpptx单文件不超过 500MB</span> </div>
<el-button link type="primary" @click="handleDownloadTemplate"></el-button>
</div>
<el-table :data="materialSummary.materials" border class="material-table" empty-text="">
<el-table-column label="资料类型" min-width="220">
<template #default="{ row }">
<span>
{{ row.materialName }}
<span v-if="row.required" class="required-flag">*</span>
</span>
</template>
</el-table-column>
<el-table-column label="资料文件" min-width="300">
<template #default="{ row }">
<span v-if="row.latestFile" class="file-name">{{ row.latestFile.fileName }}</span>
<span v-else class="file-empty">未上传</span>
</template>
</el-table-column>
<el-table-column label="操作" width="260" align="center">
<template #default="{ row }">
<a class="op-link" @click="triggerUpload(row)">{{ row.latestFile ? '' : '' }}</a>
<a class="op-link" :class="{ disabled: !row.latestFile }" @click="row.latestFile && handleDownload(row.latestFile)">下载</a>
<a class="op-link" @click="openHistory(row)"></a>
</template>
</el-table-column>
</el-table>
</div> </div>
<el-table :data="fileList" v-loading="fileLoading" border class="file-table"> <input
<el-table-column label="资料文件" prop="fileName" show-overflow-tooltip min-width="220" /> ref="uploadInputRef"
<el-table-column label="大小" width="90" align="right"> type="file"
<template #default="{ row }">{{ formatFileSize(row.fileSize) }}</template> class="hidden-upload-input"
</el-table-column> @change="handleFileSelected"
<el-table-column label="类型" prop="fileType" width="70" align="center" /> />
<el-table-column label="上传人" prop="creator" width="90" />
<el-table-column label="上传时间" prop="createTime" width="165" /> <el-dialog v-model="historyVisible" title="历史版本" width="760px">
<el-table-column label="操作" width="120" align="center"> <el-table :data="historyList" border>
<template #default="{ row }"> <el-table-column label="版本" width="80" align="center">
<a class="op-link" @click="handleDownload(row)"></a> <template #default="{ row }">v{{ row.file.version || 1 }}</template>
<a class="op-link op-danger" @click="handleDelete(row)"></a> </el-table-column>
</template> <el-table-column label="文件名" prop="file.fileName" min-width="280" show-overflow-tooltip />
</el-table-column> <el-table-column label="上传人" prop="file.creator" width="120" align="center" />
</el-table> <el-table-column label="上传时间" prop="file.createTime" width="180" align="center" />
<el-empty v-if="!fileLoading && fileList.length === 0" description="暂无资料文件" :image-size="60" /> <el-table-column label="操作" width="90" align="center">
<template #default="{ row }">
<a class="op-link" @click="handleDownload(row.file)"></a>
</template>
</el-table-column>
</el-table>
</el-dialog>
<!-- 底部操作 -->
<div class="form-footer"> <div class="form-footer">
<button class="btn-default" @click="handleBack"></button> <button class="btn-default" @click="handleBack"></button>
</div> </div>
@ -77,16 +106,20 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage } from 'element-plus'
import type { UploadFile } from 'element-plus'
import { getReviewMeeting } from '@/api/review/meeting' import { getReviewMeeting } from '@/api/review/meeting'
import { import {
getMeetingFileList, REVIEW_AGENDA_CATEGORY_OPTIONS,
uploadMeetingFile, downloadProjectTemplateBundle,
deleteMeetingFile, getProjectMaterialHistory,
type ReviewMeetingFileRespVO getProjectMaterialSummary,
uploadProjectMaterial,
type ReviewMeetingFileRespVO,
type ReviewMeetingMaterialHistoryRespVO,
type ReviewMeetingMaterialItemRespVO,
type ReviewMeetingMaterialSummaryRespVO
} from '@/api/review/project' } from '@/api/review/project'
defineOptions({ name: 'ReviewProjectDetail' }) defineOptions({ name: 'ReviewProjectDetail' })
@ -110,60 +143,103 @@ const projectInfo = ref({
meetingName: state.meetingName as string | undefined meetingName: state.meetingName as string | undefined
}) })
const fileList = ref<ReviewMeetingFileRespVO[]>([]) const loading = ref(false)
const fileLoading = ref(false) const materialSummary = ref<ReviewMeetingMaterialSummaryRespVO>({
agendaType: '',
templateAvailable: false,
materials: []
})
const loadFiles = async () => { const uploadInputRef = ref<HTMLInputElement>()
fileLoading.value = true const uploadTarget = ref<ReviewMeetingMaterialItemRespVO>()
const historyVisible = ref(false)
const historyList = ref<ReviewMeetingMaterialHistoryRespVO[]>([])
const loadSummary = async () => {
loading.value = true
try { try {
fileList.value = await getMeetingFileList(reviewMeetingProjectId) materialSummary.value = await getProjectMaterialSummary(reviewMeetingProjectId)
} finally { } finally {
fileLoading.value = false loading.value = false
} }
} }
const handleFileChange = async (uploadFile: UploadFile) => { const handleDownloadTemplate = async () => {
if (!uploadFile.raw) return const agendaType = materialSummary.value.agendaType || projectInfo.value.agendaCategory
if (uploadFile.raw.size > 500 * 1024 * 1024) { if (!agendaType) {
ElMessage.warning('未获取到议程分类')
return
}
await downloadProjectTemplateBundle(agendaType)
}
const triggerUpload = (row: ReviewMeetingMaterialItemRespVO) => {
uploadTarget.value = row
if (!uploadInputRef.value) return
uploadInputRef.value.value = ''
uploadInputRef.value.accept = toAcceptAttr(row.acceptExts)
uploadInputRef.value.click()
}
const handleFileSelected = async (event: Event) => {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
const target = uploadTarget.value
if (!file || !target) return
if (file.size > 500 * 1024 * 1024) {
ElMessage.error('文件大小不能超过 500MB') ElMessage.error('文件大小不能超过 500MB')
return return
} }
fileLoading.value = true if (!checkFileExt(file.name, target.acceptExts)) {
ElMessage.error(`文件类型不合法,仅支持:${target.acceptExts}`)
return
}
loading.value = true
try { try {
await uploadMeetingFile(reviewMeetingId, reviewMeetingProjectId, uploadFile.raw) await uploadProjectMaterial(reviewMeetingId, reviewMeetingProjectId, target.materialCode, file)
ElMessage.success('上传成功') ElMessage.success('上传成功')
await loadFiles() await loadSummary()
} catch { } catch {
ElMessage.error('上传失败') ElMessage.error('上传失败')
} finally { } finally {
fileLoading.value = false loading.value = false
} }
} }
const handleDownload = (row: ReviewMeetingFileRespVO) => { const openHistory = async (row: ReviewMeetingMaterialItemRespVO) => {
window.open(row.fileUrl, '_blank') historyVisible.value = true
historyList.value = []
historyList.value = await getProjectMaterialHistory(reviewMeetingProjectId, row.materialCode)
} }
const handleDelete = async (row: ReviewMeetingFileRespVO) => { const handleDownload = (file: ReviewMeetingFileRespVO) => {
await ElMessageBox.confirm(`确定要删除文件「${row.fileName}」吗?`, '提示', { type: 'warning' }) window.open(file.fileUrl, '_blank')
await deleteMeetingFile(row.id)
ElMessage.success('删除成功')
await loadFiles()
} }
const formatFileSize = (bytes: number): string => { const toAcceptAttr = (exts: string) =>
if (!bytes) return '-' exts
if (bytes < 1024) return bytes + ' B' .split(',')
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB' .map((item) => item.trim())
return (bytes / (1024 * 1024)).toFixed(1) + ' MB' .filter(Boolean)
.map((item) => `.${item}`)
.join(',')
const checkFileExt = (name: string, exts: string) => {
if (!name.includes('.')) return false
const ext = name.split('.').pop()?.toLowerCase() || ''
return exts
.split(',')
.map((item) => item.trim().toLowerCase())
.includes(ext)
} }
const handleBack = () => { const handleBack = () => {
router.back() router.back()
} }
onMounted(() => { onMounted(async () => {
loadFiles() await loadSummary()
getReviewMeeting(reviewMeetingId) getReviewMeeting(reviewMeetingId)
.then((meeting) => { .then((meeting) => {
if (!projectInfo.value.meetingName) { if (!projectInfo.value.meetingName) {
@ -176,7 +252,6 @@ onMounted(() => {
</script> </script>
<style scoped> <style scoped>
/* ── 页面标题 ── */
.page-title { .page-title {
display: flex; display: flex;
align-items: center; align-items: center;
@ -188,6 +263,7 @@ onMounted(() => {
padding-bottom: 14px; padding-bottom: 14px;
border-bottom: 1px solid #e1e7f0; border-bottom: 1px solid #e1e7f0;
} }
.back-btn { .back-btn {
font-size: 28px; font-size: 28px;
color: #295abc; color: #295abc;
@ -196,33 +272,35 @@ onMounted(() => {
margin-right: 2px; margin-right: 2px;
font-weight: 400; font-weight: 400;
} }
.back-btn:hover { opacity: 0.75; }
/* ── 蓝色信息面板 ── */
.info-panel { .info-panel {
background: linear-gradient(135deg, rgba(41, 90, 188, 0.06) 0%, rgba(41, 90, 188, 0.03) 100%); background: linear-gradient(135deg, rgba(41, 90, 188, 0.06) 0%, rgba(41, 90, 188, 0.03) 100%);
border: 1px solid rgba(41, 90, 188, 0.15); border: 1px solid rgba(41, 90, 188, 0.15);
border-radius: 8px; border-radius: 8px;
padding: 18px 20px; padding: 18px 20px;
margin-bottom: 24px; margin-bottom: 20px;
} }
.info-panel-title { .info-panel-title {
font-size: 18px; font-size: 18px;
font-weight: 600; font-weight: 600;
color: #295abc; color: #295abc;
margin-bottom: 10px; margin-bottom: 10px;
} }
.info-panel-meta { .info-panel-meta {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
margin-bottom: 14px; margin-bottom: 14px;
} }
.meta-left { .meta-left {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 14px; gap: 14px;
} }
.meta-tag { .meta-tag {
display: inline-block; display: inline-block;
padding: 2px 10px; padding: 2px 10px;
@ -231,135 +309,116 @@ onMounted(() => {
border-radius: 4px; border-radius: 4px;
font-size: 13px; font-size: 13px;
} }
.meta-item {
font-size: 13px; .meta-item,
color: #666;
}
.meta-right { .meta-right {
font-size: 13px; font-size: 13px;
color: #666; color: #666;
} }
.info-panel-footer { .info-panel-footer {
display: flex; display: flex;
gap: 32px; gap: 32px;
padding-top: 12px; padding-top: 12px;
border-top: 1px solid rgba(41, 90, 188, 0.12); border-top: 1px solid rgba(41, 90, 188, 0.12);
} }
.info-footer-item { .info-footer-item {
display: flex; display: flex;
align-items: baseline; align-items: baseline;
gap: 8px; gap: 8px;
} }
.footer-label { .footer-label {
font-size: 13px; font-size: 13px;
color: #999; color: #999;
white-space: nowrap; white-space: nowrap;
} }
.footer-value { .footer-value {
font-size: 14px; font-size: 14px;
color: #333; color: #333;
} }
.footer-value em { .footer-value em {
color: #666;
font-style: normal; font-style: normal;
color: #999;
font-size: 12px;
margin-left: 6px; margin-left: 6px;
} }
/* ── 资料区标题 ── */ .material-card {
.section-header { background-color: #fff;
border: 1px solid #e6eaf2;
border-radius: 8px;
padding: 16px;
}
.material-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 14px;
}
.material-type-wrap {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 8px;
margin-bottom: 12px;
} }
.section-title {
position: relative; .label {
font-size: 16px; color: #606266;
font-weight: 600; }
.type-select {
width: 260px;
}
.required-flag {
color: #fc4f54;
margin-left: 4px;
}
.file-name {
color: #333; color: #333;
padding-left: 10px;
}
.section-title::before {
content: '';
position: absolute;
left: 0;
top: 2px;
bottom: 2px;
width: 3px;
border-radius: 2px;
background-color: #295abc;
} }
.btn-upload { .file-empty {
height: 34px; color: #909399;
padding: 0 14px;
background-color: #fff;
border: 1px solid #295abc;
border-radius: 6px;
color: #295abc;
font-size: 14px;
cursor: pointer;
transition: all 0.2s;
}
.btn-upload:hover {
background-color: rgba(41, 90, 188, 0.08);
} }
.upload-hint {
font-size: 12px;
color: #999;
}
/* ── 操作链接 ── */
.op-link { .op-link {
display: inline-block; display: inline-block;
color: #295abc; color: #295abc;
font-size: 13px; font-size: 13px;
cursor: pointer; cursor: pointer;
margin: 0 4px; margin: 0 8px;
transition: opacity 0.2s; }
}
.op-link:hover { opacity: 0.8; text-decoration: underline; } .op-link.disabled {
.op-danger { color: #fc4f54; } color: #c0c4cc;
cursor: not-allowed;
/* ── 文件表格 ── */ }
:deep(.file-table .el-table__header-wrapper th) {
background-color: #eef2fb; .hidden-upload-input {
color: #333; display: none;
font-weight: 600;
font-size: 14px;
border-color: #e1e7f0;
}
:deep(.file-table .el-table__body td) {
font-size: 14px;
color: #333;
border-color: #e1e7f0;
}
:deep(.file-table .el-table__body tr:hover > td) {
background-color: rgba(41, 90, 188, 0.04);
} }
/* ── 底部按钮 ── */
.form-footer { .form-footer {
margin-top: 24px; margin-top: 16px;
} }
.btn-default { .btn-default {
display: inline-flex; min-width: 90px;
align-items: center;
height: 36px; height: 36px;
padding: 0 20px; border: 1px solid #dcdfe6;
background-color: #fff; background-color: #fff;
border: 1px solid #d5d5d5; border-radius: 4px;
border-radius: 6px; color: #606266;
font-size: 14px;
color: #333;
cursor: pointer; cursor: pointer;
transition: all 0.2s;
} }
.btn-default:hover {
background-color: rgba(41, 90, 188, 0.08); :deep(.material-table .el-table__header-wrapper th) {
border-color: #295abc; background-color: #f5f7fa;
color: #295abc;
} }
</style> </style>

View File

@ -38,13 +38,9 @@
class="search-input" class="search-input"
@keyup.enter="handleQuery" @keyup.enter="handleQuery"
/> />
<el-input <el-select v-model="queryParams.agendaCategory" placeholder="议程分类" clearable class="search-input">
v-model="queryParams.agendaCategory" <el-option v-for="item in REVIEW_AGENDA_CATEGORY_OPTIONS" :key="item" :label="item" :value="item" />
placeholder="议程分类" </el-select>
clearable
class="search-input"
@keyup.enter="handleQuery"
/>
<button class="btn-reset" @click="resetQuery"></button> <button class="btn-reset" @click="resetQuery"></button>
<button class="btn-search" @click="handleQuery"></button> <button class="btn-search" @click="handleQuery"></button>
</div> </div>
@ -112,7 +108,9 @@
</div> </div>
</el-form-item> </el-form-item>
<el-form-item label="议程分类" prop="agendaCategory"> <el-form-item label="议程分类" prop="agendaCategory">
<el-input v-model="formData.agendaCategory" placeholder="请输入议程分类" /> <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>
<el-form-item label="项目标题" prop="projectTitle"> <el-form-item label="项目标题" prop="projectTitle">
<el-input v-model="formData.projectTitle" placeholder="请输入项目标题" /> <el-input v-model="formData.projectTitle" placeholder="请输入项目标题" />
@ -150,7 +148,15 @@ import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import Sortable from 'sortablejs' import Sortable from 'sortablejs'
import { getReviewMeeting } from '@/api/review/meeting' import { getReviewMeeting } from '@/api/review/meeting'
import { getReviewProjectPage, updateReviewProject, updateReviewProjectSeqBatch, createReviewProject, deleteReviewProject, type ReviewMeetingProjectRespVO } from '@/api/review/project' import {
REVIEW_AGENDA_CATEGORY_OPTIONS,
getReviewProjectPage,
updateReviewProject,
updateReviewProjectSeqBatch,
createReviewProject,
deleteReviewProject,
type ReviewMeetingProjectRespVO
} from '@/api/review/project'
import { formatDate } from '@/utils/formatTime' import { formatDate } from '@/utils/formatTime'
defineOptions({ name: 'ReviewMeetingProject' }) defineOptions({ name: 'ReviewMeetingProject' })
@ -287,6 +293,7 @@ const formLoading = ref(false)
const formRef = ref() const formRef = ref()
const formData = reactive<Partial<ReviewMeetingProjectRespVO>>({}) const formData = reactive<Partial<ReviewMeetingProjectRespVO>>({})
const formRules = { const formRules = {
agendaCategory: [{ required: true, message: '请选择议程分类', trigger: 'change' }],
projectTitle: [{ required: true, message: '项目标题不能为空', trigger: 'blur' }] projectTitle: [{ required: true, message: '项目标题不能为空', trigger: 'blur' }]
} }