feat(review-frontend): 完成评审资料清单页面与固定分类
parent
36b34d6695
commit
261173e76e
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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' }]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,10 @@
|
||||||
<template>
|
<template>
|
||||||
<ContentWrap>
|
<ContentWrap>
|
||||||
<!-- 页面标题带返回 -->
|
|
||||||
<div class="page-title">
|
<div class="page-title">
|
||||||
<span class="back-btn" @click="handleBack">‹</span>
|
<span class="back-btn" @click="handleBack">‹</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">支持 doc、docx、xls、xlsx、pdf、ppt、pptx,单文件不超过 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>
|
||||||
|
|
|
||||||
|
|
@ -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' }]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue