增加专家在平板上查看评审资料功能,前端页面为单独入口url:project/review-tablet

pull/874/head
Codewoc 2026-03-19 13:54:22 +08:00
parent ca0cbe2ac9
commit 8f893c0afd
5 changed files with 692 additions and 9 deletions

View File

@ -19,8 +19,11 @@ export interface ReviewProjectItemVO {
export interface ReviewMeetingSaveReqVO {
id?: number
name: string
startTime: string | number
endTime: string | number
startTime?: string | number
endTime?: string | number
materialViewStartTime?: string | number
materialViewEndTime?: string | number
materialViewRemark?: string
location: string
expertIds: number[]
projects?: ReviewProjectItemVO[]
@ -41,6 +44,9 @@ export interface ReviewMeetingRespVO {
name: string
startTime: string
endTime: string
materialViewStartTime?: string
materialViewEndTime?: string
materialViewRemark?: string
location: string
status: number // 0-草稿 1-已邀约 2-已结束 3-已取消
expertIds: number[]
@ -111,4 +117,3 @@ export const importProjectsFromExcel = (file: File) => {
/** 下载导入模板 */
export const getImportTemplate = () =>
request.download({ url: '/project/review-meeting/get-import-template' })

38
src/api/review/tablet.ts Normal file
View File

@ -0,0 +1,38 @@
import request from '@/config/axios'
export interface ReviewTabletCatalogVO {
reviewMeetingId: number
meetingName: string
meetingStartTime: string
meetingEndTime: string
reviewMeetingProjectId: number
seqNo: number
agendaCategory: string
projectTitle: string
reporter: string
reporterUnit: string
fileCount: number
}
export interface ReviewTabletOpenUrlVO {
fileId: number
fileName: string
fileType: string
openUrl: string
visitUrl: string
}
export const getTodayCatalog = () =>
request.get({ url: '/project/review-tablet/catalog/today' })
export const getProjectFiles = (reviewMeetingProjectId: number) =>
request.get({
url: '/project/review-tablet/project-files',
params: { reviewMeetingProjectId }
})
export const getFileOpenUrl = (fileId: number): Promise<ReviewTabletOpenUrlVO> =>
request.get({
url: '/project/review-tablet/file-open-url',
params: { fileId }
})

View File

@ -694,6 +694,18 @@ const remainingRouter: AppRouteRecordRaw[] = [
}
]
},
{
path: '/project/review-tablet',
component: () => import('@/views/review/tablet/index.vue'),
name: 'ReviewTablet',
meta: {
title: '专家评审资料',
noCache: true,
hidden: true,
canTo: true,
noTagsView: true
}
},
{
path: '/:pathMatch(.*)*',
component: () => import('@/views/Error/404.vue'),

View File

@ -33,6 +33,23 @@
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="24">
<el-form-item label="资料查看时限" prop="materialViewTimeRange">
<el-date-picker
v-model="formData.materialViewTimeRange"
type="datetimerange"
range-separator="至"
start-placeholder="开始时间"
end-placeholder="结束时间"
format="YYYY-MM-DD HH:mm"
value-format="x"
:disabled="isView"
style="width: 100%"
/>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="24">
<el-form-item label="参会专家" prop="expertIds">
@ -94,6 +111,7 @@
<script setup lang="ts">
import { ref, reactive, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { FormRules } from 'element-plus'
import type { UploadFile } from 'element-plus'
import {
createReviewMeeting,
@ -118,20 +136,27 @@ const dialogTitle = computed(() =>
formType.value === 'create' ? '新增会议邀约' : formType.value === 'update' ? '编辑评审会议' : '查看评审会议'
)
type FormData = ReviewMeetingSaveReqVO & { meetingTimeRange?: string[] }
type FormData = ReviewMeetingSaveReqVO & {
meetingTimeRange?: any[]
materialViewTimeRange?: any[]
}
const formData = reactive<FormData>({
id: undefined,
name: '',
startTime: '',
endTime: '',
startTime: undefined,
endTime: undefined,
location: '',
materialViewStartTime: undefined,
materialViewEndTime: undefined,
materialViewRemark: undefined,
expertIds: [],
meetingTimeRange: undefined,
materialViewTimeRange: undefined,
projects: []
})
const rules = {
const rules: FormRules = {
name: [{ required: true, message: '会议标题不能为空', trigger: 'blur' }],
meetingTimeRange: [{ required: true, message: '会议时间不能为空', trigger: 'change' }],
location: [{ required: true, message: '会议地点不能为空', trigger: 'blur' }],
@ -161,6 +186,12 @@ const open = async (type: 'create' | 'update' | 'view', id?: number) => {
new Date(detail.endTime.replace(' ', 'T')).getTime()
]
}
if (detail.materialViewStartTime && detail.materialViewEndTime) {
formData.materialViewTimeRange = [
new Date(detail.materialViewStartTime.replace(' ', 'T')).getTime(),
new Date(detail.materialViewEndTime.replace(' ', 'T')).getTime()
]
}
} finally {
formLoading.value = false
}
@ -170,11 +201,15 @@ const open = async (type: 'create' | 'update' | 'view', id?: number) => {
const resetForm = () => {
formData.id = undefined
formData.name = ''
formData.startTime = ''
formData.endTime = ''
formData.startTime = undefined
formData.endTime = undefined
formData.location = ''
formData.materialViewStartTime = undefined
formData.materialViewEndTime = undefined
formData.materialViewRemark = undefined
formData.expertIds = []
formData.meetingTimeRange = undefined
formData.materialViewTimeRange = undefined
formData.projects = []
formRef.value?.resetFields()
}
@ -213,6 +248,17 @@ const submitForm = async () => {
formData.startTime = formData.meetingTimeRange[0]
formData.endTime = formData.meetingTimeRange[1]
}
if (formData.materialViewTimeRange?.length === 2) {
formData.materialViewStartTime = formData.materialViewTimeRange[0]
formData.materialViewEndTime = formData.materialViewTimeRange[1]
} else if (formData.meetingTimeRange?.length === 2) {
// 便
formData.materialViewStartTime = formData.meetingTimeRange[0]
formData.materialViewEndTime = formData.meetingTimeRange[1]
} else {
formData.materialViewStartTime = undefined
formData.materialViewEndTime = undefined
}
formLoading.value = true
try {
const submitData = { ...formData }

View File

@ -0,0 +1,582 @@
<template>
<div class="tablet-full-page" v-loading="pageLoading">
<header class="page-toolbar">
<div class="title-wrap">
<h2>专家评审资料</h2>
<span>当日项目编目 · {{ projectCount }} </span>
</div>
<div class="tool-wrap">
<el-input
v-model="keyword"
clearable
placeholder="搜索项目/资料"
class="search-input"
/>
<el-button :icon="Refresh" @click="loadCatalog"></el-button>
</div>
</header>
<div class="page-body">
<aside class="directory-panel">
<div class="panel-title">项目目录</div>
<el-empty v-if="treeData.length === 0" description="当前时段暂无可查看资料" />
<el-tree
v-else
ref="treeRef"
class="catalog-tree"
:data="treeData"
node-key="id"
highlight-current
:expand-on-click-node="false"
:default-expanded-keys="expandedKeys"
:props="treeProps"
:filter-node-method="filterNode"
@node-expand="handleNodeExpand"
@node-collapse="handleNodeCollapse"
@node-click="handleNodeClick"
>
<template #default="{ data }">
<div v-if="data.type === 'project'" class="tree-project">
<div class="node-left">
<el-icon class="node-icon folder-icon"><Folder /></el-icon>
<span class="node-name">{{ data.label }}</span>
</div>
<el-tag size="small" type="info">{{ data.fileCount }} </el-tag>
</div>
<div v-else class="tree-file">
<div class="node-left">
<el-icon class="node-icon file-icon"><Document /></el-icon>
<span class="node-name">{{ data.label }}</span>
</div>
<span class="file-type">{{ data.fileType?.toUpperCase() }}</span>
</div>
</template>
</el-tree>
</aside>
<section class="preview-panel">
<div class="preview-header">
<div class="preview-meta">
<div class="preview-title">{{ activeFileName || '请选择左侧资料进行预览' }}</div>
<div class="preview-sub">{{ activeFileDesc }}</div>
</div>
<div class="preview-actions">
<el-button :disabled="!activeFileId" @click="refreshPreview"></el-button>
<el-button
type="primary"
:icon="Link"
:disabled="!previewUrl"
@click="openInNewWindow"
>
新窗口打开
</el-button>
</div>
</div>
<div class="preview-body" v-loading="previewLoading">
<div v-if="!activeFileId && !previewLoading" class="welcome-panel">
<div class="welcome-title">欢迎各位专家莅临指导工作</div>
<div class="welcome-sub">请从左侧目录中选择评审资料开始查阅</div>
</div>
<el-result v-else-if="previewError" icon="error" title="预览加载失败" :sub-title="previewError">
<template #extra>
<el-button type="primary" :disabled="!activeFileId" @click="refreshPreview"></el-button>
<el-button :disabled="!previewUrl" @click="openInNewWindow"></el-button>
</template>
</el-result>
<iframe
v-else-if="previewUrl"
:src="previewUrl"
class="preview-iframe"
frameborder="0"
allowfullscreen
></iframe>
</div>
</section>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue'
import { Document, Folder, Link, Refresh } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { formatDate } from '@/utils/formatTime'
import {
getFileOpenUrl,
getProjectFiles,
getTodayCatalog,
type ReviewTabletCatalogVO,
type ReviewTabletOpenUrlVO
} from '@/api/review/tablet'
import type { ReviewMeetingFileRespVO } from '@/api/review/project'
defineOptions({ name: 'ReviewTablet' })
type TreeNode = ProjectTreeNode | FileTreeNode
interface ProjectTreeNode {
id: string
type: 'project'
label: string
projectId: number
meetingName: string
fileCount: number
seqNo: number
loaded: boolean
loading: boolean
source: ReviewTabletCatalogVO
children: FileTreeNode[]
}
interface FileTreeNode {
id: string
type: 'file'
label: string
projectId: number
fileId: number
fileType: string
fileSize: number
createTime: string
source: ReviewMeetingFileRespVO
}
const pageLoading = ref(false)
const previewLoading = ref(false)
const previewError = ref('')
const keyword = ref('')
const treeRef = ref<any>()
const treeData = ref<ProjectTreeNode[]>([])
const expandedKeys = ref<string[]>([])
const fileCache = ref<Record<number, ReviewMeetingFileRespVO[]>>({})
const activeProjectId = ref<number>()
const activeFileId = ref<number>()
const activeFileNode = ref<FileTreeNode>()
const previewUrl = ref('')
const previewPayload = ref<ReviewTabletOpenUrlVO>()
const treeProps = {
label: 'label',
children: 'children'
}
const projectCount = computed(() => treeData.value.length)
const activeFileName = computed(() => activeFileNode.value?.label || previewPayload.value?.fileName || '')
const activeFileDesc = computed(() => {
if (!activeFileNode.value) return ''
const segments: string[] = []
if (activeFileNode.value.fileType) {
segments.push(activeFileNode.value.fileType.toUpperCase())
}
if (activeFileNode.value.fileSize) {
segments.push(formatFileSize(activeFileNode.value.fileSize))
}
if (activeFileNode.value.createTime) {
segments.push(`上传于 ${formatDate(new Date(activeFileNode.value.createTime), 'YYYY-MM-DD HH:mm')}`)
}
return segments.join(' · ')
})
watch(keyword, (value) => {
treeRef.value?.filter(value.trim())
})
const filterNode = (value: string, data: TreeNode): boolean => {
if (!value) return true
const keywordValue = value.toLowerCase()
if (data.type === 'project') {
const projectMatched = [data.label, data.meetingName].join('|').toLowerCase().includes(keywordValue)
const childMatched = data.children.some((item) => item.label.toLowerCase().includes(keywordValue))
return projectMatched || childMatched
}
return [data.label, data.fileType].join('|').toLowerCase().includes(keywordValue)
}
const buildProjectNode = (item: ReviewTabletCatalogVO): ProjectTreeNode => ({
id: `project-${item.reviewMeetingProjectId}`,
type: 'project',
label: item.projectTitle,
projectId: item.reviewMeetingProjectId,
meetingName: item.meetingName,
fileCount: item.fileCount,
seqNo: item.seqNo,
loaded: false,
loading: false,
source: item,
children: []
})
const toFileNode = (file: ReviewMeetingFileRespVO, projectId: number): FileTreeNode => ({
id: `file-${projectId}-${file.id}`,
type: 'file',
label: file.fileName,
projectId,
fileId: file.id,
fileType: file.fileType,
fileSize: file.fileSize,
createTime: file.createTime,
source: file
})
const loadProjectFiles = async (projectNode: ProjectTreeNode) => {
if (projectNode.loaded || projectNode.loading) return
const cachedFiles = fileCache.value[projectNode.projectId]
if (cachedFiles) {
projectNode.children = cachedFiles.map((item) => toFileNode(item, projectNode.projectId))
projectNode.loaded = true
return
}
projectNode.loading = true
try {
const files = await getProjectFiles(projectNode.projectId)
fileCache.value[projectNode.projectId] = files
projectNode.children = files.map((item) => toFileNode(item, projectNode.projectId))
projectNode.loaded = true
} catch {
ElMessage.error('加载项目资料失败,请稍后重试')
throw new Error('load-project-files-failed')
} finally {
projectNode.loading = false
}
}
const loadPreview = async (fileId: number) => {
previewLoading.value = true
previewError.value = ''
try {
const payload = await getFileOpenUrl(fileId)
previewPayload.value = payload
if (!payload?.openUrl) {
previewUrl.value = ''
previewError.value = '未获取到预览地址'
return
}
previewUrl.value = payload.openUrl
} catch {
previewUrl.value = ''
previewError.value = '预览服务暂不可用,请稍后重试'
} finally {
previewLoading.value = false
}
}
const selectFileNode = async (fileNode: FileTreeNode) => {
activeProjectId.value = fileNode.projectId
activeFileId.value = fileNode.fileId
activeFileNode.value = fileNode
await loadPreview(fileNode.fileId)
}
const openInNewWindow = () => {
const url = previewPayload.value?.openUrl || previewUrl.value
if (!url) {
ElMessage.warning('暂无可打开的预览地址')
return
}
window.open(url, '_blank', 'noopener,noreferrer')
}
const refreshPreview = async () => {
if (!activeFileId.value) return
await loadPreview(activeFileId.value)
}
const handleNodeExpand = async (data: TreeNode) => {
if (data.type !== 'project') return
if (!expandedKeys.value.includes(data.id)) {
expandedKeys.value.push(data.id)
}
await loadProjectFiles(data)
}
const handleNodeCollapse = (data: TreeNode) => {
if (data.type !== 'project') return
expandedKeys.value = expandedKeys.value.filter((id) => id !== data.id)
if (expandedKeys.value.length > 0) return
activeProjectId.value = undefined
activeFileId.value = undefined
activeFileNode.value = undefined
previewUrl.value = ''
previewPayload.value = undefined
previewError.value = ''
}
const handleNodeClick = async (data: TreeNode) => {
if (data.type === 'project') {
activeProjectId.value = data.projectId
await loadProjectFiles(data)
return
}
await selectFileNode(data)
}
const loadCatalog = async () => {
pageLoading.value = true
previewError.value = ''
previewUrl.value = ''
previewPayload.value = undefined
activeFileId.value = undefined
activeFileNode.value = undefined
activeProjectId.value = undefined
try {
const catalog = await getTodayCatalog()
treeData.value = catalog
.sort((a, b) => (a.seqNo || 0) - (b.seqNo || 0))
.map((item) => buildProjectNode(item))
fileCache.value = {}
expandedKeys.value = []
if (treeData.value.length === 0) return
expandedKeys.value = []
await nextTick()
treeRef.value?.setCurrentKey(undefined)
} finally {
pageLoading.value = false
}
}
const formatFileSize = (bytes: number): string => {
if (!bytes) return '-'
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
loadCatalog()
</script>
<style scoped>
.tablet-full-page {
height: 100vh;
min-height: 100vh;
padding: 12px;
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 12px;
background: #eef2f7;
}
.page-toolbar {
background: #fff;
border-radius: 12px;
padding: 12px 16px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
border: 1px solid #e5e7eb;
}
.title-wrap h2 {
margin: 0;
font-size: 22px;
font-weight: 700;
}
.title-wrap span {
display: block;
margin-top: 4px;
font-size: 13px;
color: #6b7280;
}
.tool-wrap {
display: flex;
align-items: center;
gap: 8px;
}
.search-input {
width: 320px;
}
.page-body {
flex: 1;
min-height: 0;
display: grid;
grid-template-columns: 360px minmax(0, 1fr);
gap: 12px;
}
.directory-panel,
.preview-panel {
border: 1px solid #e5e7eb;
border-radius: 12px;
background: #fff;
}
.directory-panel {
display: flex;
flex-direction: column;
min-height: 0;
padding: 10px;
}
.panel-title {
padding: 6px 8px 10px;
font-size: 15px;
font-weight: 600;
}
.catalog-tree {
flex: 1;
min-height: 0;
overflow: auto;
}
.tree-project,
.tree-file {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.node-left {
display: inline-flex;
align-items: center;
min-width: 0;
gap: 6px;
}
.node-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.node-icon {
font-size: 16px;
}
.folder-icon {
color: #2563eb;
}
.file-icon {
color: #475569;
}
.file-type {
font-size: 11px;
color: #64748b;
}
.preview-panel {
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
}
.preview-header {
padding: 12px 14px;
border-bottom: 1px solid #e5e7eb;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.preview-meta {
min-width: 0;
}
.preview-title {
font-size: 16px;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.preview-sub {
margin-top: 4px;
color: #64748b;
font-size: 12px;
}
.preview-actions {
display: flex;
gap: 8px;
}
.preview-body {
flex: 1;
min-height: 0;
background: #f8fafc;
}
.welcome-panel {
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #1f2937;
text-align: center;
padding: 24px;
}
.welcome-title {
font-size: 30px;
font-weight: 700;
letter-spacing: 1px;
}
.welcome-sub {
margin-top: 12px;
font-size: 15px;
color: #64748b;
}
.preview-iframe {
width: 100%;
height: 100%;
border: 0;
display: block;
background: #fff;
}
:deep(.el-tree-node__content) {
height: 42px;
padding-right: 8px;
}
:deep(.el-tree-node__content:hover) {
background: #eff6ff;
}
@media (max-width: 1024px) {
.tablet-full-page {
height: auto;
min-height: 100vh;
}
.page-toolbar {
flex-direction: column;
align-items: stretch;
}
.search-input {
width: 100%;
}
.page-body {
grid-template-columns: 1fr;
}
.directory-panel {
max-height: 42vh;
}
.preview-panel {
min-height: 56vh;
}
}
</style>