feat(review-frontend): 直传链路与平板预览体验优化

comment:\n- 上传改为 MinIO 预签名直传 + register-file 业务登记,统一 500MB 限制与提示\n- 开发环境补齐 Vite 代理(/admin-api 与 /ncc-dev),对齐正式环境 nginx 代理策略\n- 平板预览新增加载遮罩、超时兜底与 Office 预热节流,修复一直停留在转换页问题
pull/874/head
Codewoc 2026-03-25 11:30:34 +08:00
parent 8fa5fa3388
commit 5ef10cfd6e
9 changed files with 210 additions and 37 deletions

View File

@ -6,8 +6,8 @@ VITE_DEV=true
# 请求路径 # 请求路径
VITE_BASE_URL='http://127.0.0.1:48080' VITE_BASE_URL='http://127.0.0.1:48080'
# 文件上传类型server - 后端上传, client - 前端直连上传仅支持S3服务 # 文件上传类型server - 后端上传, client - 前端直连上传,仅支持 S3 服务
VITE_UPLOAD_TYPE=server VITE_UPLOAD_TYPE=client
# 接口地址 # 接口地址
VITE_API_URL=/admin-api VITE_API_URL=/admin-api

View File

@ -7,7 +7,7 @@ VITE_DEV=true
VITE_BASE_URL='http://localhost:48080' VITE_BASE_URL='http://localhost:48080'
# 文件上传类型server - 后端上传, client - 前端直连上传,仅支持 S3 服务 # 文件上传类型server - 后端上传, client - 前端直连上传,仅支持 S3 服务
VITE_UPLOAD_TYPE=server VITE_UPLOAD_TYPE=client
# 接口地址 # 接口地址
VITE_API_URL=/admin-api VITE_API_URL=/admin-api

View File

@ -51,6 +51,28 @@ server {
client_max_body_size 100M; client_max_body_size 100M;
} }
# 对象存储代理配置
# 正式环境建议通过 Nginx 代理 MinIO保持前端页面与上传地址同源避免浏览器跨域限制
location /ncc-dev/ {
proxy_pass http://localhost:9000; # 修改为实际的 MinIO 地址和端口,保留原始 bucket 路径避免预签名失效
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# 大文件直传时关闭请求缓冲,避免 Nginx 先完整落盘再转发
proxy_request_buffering off;
proxy_buffering off;
proxy_connect_timeout 600;
proxy_send_timeout 600;
proxy_read_timeout 600;
send_timeout 600;
client_max_body_size 600M;
}
# 前端路由配置 - Vue Router history 模式支持 # 前端路由配置 - Vue Router history 模式支持
location / { location / {
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;

View File

@ -1,4 +1,6 @@
import request from '@/config/axios' import request from '@/config/axios'
import axios from 'axios'
import * as FileApi from '@/api/infra/file'
// ============================================================ // ============================================================
// 类型定义 // 类型定义
@ -48,6 +50,15 @@ export interface ReviewMeetingFileRespVO {
createTime: string createTime: string
} }
export interface ReviewMeetingFileRegisterReqVO {
reviewMeetingId: number
reviewMeetingProjectId: number
fileName: string
fileUrl: string
fileSize: number
fileType?: string
}
// ============================================================ // ============================================================
// API 调用 // API 调用
// ============================================================ // ============================================================
@ -82,13 +93,13 @@ export const uploadMeetingFile = (
reviewMeetingProjectId: number, reviewMeetingProjectId: number,
file: File file: File
) => { ) => {
const formData = new FormData() return uploadMeetingFileByPresignedUrl(reviewMeetingId, reviewMeetingProjectId, file)
formData.append('reviewMeetingId', String(reviewMeetingId))
formData.append('reviewMeetingProjectId', String(reviewMeetingProjectId))
formData.append('file', file)
return request.upload({ url: '/project/review-project/upload-file', data: formData })
} }
/** 登记会议文件 */
export const registerMeetingFile = (data: ReviewMeetingFileRegisterReqVO) =>
request.post<ReviewMeetingFileRespVO>({ url: '/project/review-project/register-file', data })
/** 获取项目文件列表 */ /** 获取项目文件列表 */
export const getMeetingFileList = (reviewMeetingProjectId: number) => export const getMeetingFileList = (reviewMeetingProjectId: number) =>
request.get({ request.get({
@ -99,3 +110,34 @@ 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 } })
const uploadMeetingFileByPresignedUrl = async (
reviewMeetingId: number,
reviewMeetingProjectId: number,
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 registerMeetingFile({
reviewMeetingId,
reviewMeetingProjectId,
fileName: file.name,
fileUrl: presignedInfo.url,
fileSize: file.size,
fileType: file.name.includes('.') ? file.name.split('.').pop()?.toLowerCase() : ''
})
}

View File

@ -114,9 +114,9 @@ defineExpose({ open }) // 提供 open 方法,用于打开弹窗
/** 上传前校验 */ /** 上传前校验 */
const beforeUpload = (file: File) => { const beforeUpload = (file: File) => {
const maxSize = 50 * 1024 * 1024 // 50MB const maxSize = 500 * 1024 * 1024 // 500MB
if (file.size > maxSize) { if (file.size > maxSize) {
message.error('文件大小不能超过50MB') message.error('文件大小不能超过500MB')
return false return false
} }
return true return true

View File

@ -11,7 +11,7 @@
> >
<el-button type="primary" plain>上传文件</el-button> <el-button type="primary" plain>上传文件</el-button>
<template #tip> <template #tip>
<div class="el-upload__tip">支持 docdocxxlsxlsxpdfpptpptx 格式单文件不超过 50MB</div> <div class="el-upload__tip">支持 docdocxxlsxlsxpdfpptpptx 格式单文件不超过 500MB</div>
</template> </template>
</el-upload> </el-upload>
@ -75,8 +75,8 @@ const loadFiles = async () => {
const handleFileChange = async (uploadFile: UploadFile) => { const handleFileChange = async (uploadFile: UploadFile) => {
if (!uploadFile.raw) return if (!uploadFile.raw) return
if (uploadFile.raw.size > 50 * 1024 * 1024) { if (uploadFile.raw.size > 500 * 1024 * 1024) {
ElMessage.error('文件大小不能超过 50MB') ElMessage.error('文件大小不能超过 500MB')
return return
} }
loading.value = true loading.value = true

View File

@ -49,7 +49,7 @@
> >
<button class="btn-upload">上传资料</button> <button class="btn-upload">上传资料</button>
</el-upload> </el-upload>
<span class="upload-hint">支持 docdocxxlsxlsxpdfpptpptx单文件不超过 50MB</span> <span class="upload-hint">支持 docdocxxlsxlsxpdfpptpptx单文件不超过 500MB</span>
</div> </div>
<el-table :data="fileList" v-loading="fileLoading" border class="file-table"> <el-table :data="fileList" v-loading="fileLoading" border class="file-table">
@ -123,8 +123,8 @@ const loadFiles = async () => {
const handleFileChange = async (uploadFile: UploadFile) => { const handleFileChange = async (uploadFile: UploadFile) => {
if (!uploadFile.raw) return if (!uploadFile.raw) return
if (uploadFile.raw.size > 50 * 1024 * 1024) { if (uploadFile.raw.size > 500 * 1024 * 1024) {
ElMessage.error('文件大小不能超过 50MB') ElMessage.error('文件大小不能超过 500MB')
return return
} }
fileLoading.value = true fileLoading.value = true

View File

@ -97,13 +97,21 @@
<el-button :disabled="!previewUrl" @click="openInNewWindow"></el-button> <el-button :disabled="!previewUrl" @click="openInNewWindow"></el-button>
</template> </template>
</el-result> </el-result>
<iframe <template v-else-if="previewUrl">
v-else-if="previewUrl" <iframe
:src="previewUrl" :key="previewFrameKey"
class="preview-iframe" :src="previewUrl"
frameborder="0" class="preview-iframe"
allowfullscreen frameborder="0"
></iframe> allowfullscreen
@load="handlePreviewFrameLoad"
@error="handlePreviewFrameError"
></iframe>
<div v-if="previewFrameLoading && !previewError" class="preview-loading-mask">
<div class="welcome-title">{{ previewLoadingTitle }}</div>
<div class="welcome-sub">{{ previewLoadingSubtitle }}</div>
</div>
</template>
</div> </div>
</section> </section>
@ -340,6 +348,9 @@ const activeFileId = ref<number>()
const activeFileNode = ref<FileTreeNode>() const activeFileNode = ref<FileTreeNode>()
const previewUrl = ref('') const previewUrl = ref('')
const previewPayload = ref<ReviewTabletOpenUrlVO>() const previewPayload = ref<ReviewTabletOpenUrlVO>()
const previewFrameLoading = ref(false)
const previewFrameKey = ref(0)
const previewRequestToken = ref(0)
const treeProps = { label: 'label', children: 'children' } const treeProps = { label: 'label', children: 'children' }
@ -354,6 +365,14 @@ const activeFileDesc = computed(() => {
segs.push(`上传于 ${formatDate(new Date(activeFileNode.value.createTime), 'YYYY-MM-DD HH:mm')}`) segs.push(`上传于 ${formatDate(new Date(activeFileNode.value.createTime), 'YYYY-MM-DD HH:mm')}`)
return segs.join(' · ') return segs.join(' · ')
}) })
const previewLoadingTitle = computed(() =>
isOfficeFile(activeFileNode.value?.fileType) ? '正在转换文档预览' : '正在加载预览'
)
const previewLoadingSubtitle = computed(() =>
isOfficeFile(activeFileNode.value?.fileType)
? 'Office 文件首次打开可能需要一些时间,请稍候'
: '正在为您打开当前资料'
)
// AI state // AI state
const aiPanelOpen = ref(false) const aiPanelOpen = ref(false)
@ -368,6 +387,8 @@ const streamingLoading = ref(false)
const aiMessagesRef = ref<HTMLElement>() const aiMessagesRef = ref<HTMLElement>()
let streamAbortCtrl: AbortController | null = null let streamAbortCtrl: AbortController | null = null
let summaryPollingTimer: ReturnType<typeof setTimeout> | null = null let summaryPollingTimer: ReturnType<typeof setTimeout> | null = null
let officeWarmupTimers: ReturnType<typeof setTimeout>[] = []
let previewFrameTimeoutTimer: ReturnType<typeof setTimeout> | null = null
// //
watch(keyword, (val) => treeRef.value?.filter(val.trim())) watch(keyword, (val) => treeRef.value?.filter(val.trim()))
@ -424,6 +445,7 @@ const loadProjectFiles = async (projectNode: ProjectTreeNode) => {
fileCache.value[projectNode.projectId] = files fileCache.value[projectNode.projectId] = files
projectNode.children = files.map((f) => toFileNode(f, projectNode.projectId)) projectNode.children = files.map((f) => toFileNode(f, projectNode.projectId))
projectNode.loaded = true projectNode.loaded = true
scheduleOfficeWarmup(files)
} catch { } catch {
ElMessage.error('加载项目资料失败,请稍后重试') ElMessage.error('加载项目资料失败,请稍后重试')
throw new Error('load-project-files-failed') throw new Error('load-project-files-failed')
@ -434,18 +456,30 @@ const loadProjectFiles = async (projectNode: ProjectTreeNode) => {
// //
const loadPreview = async (fileId: number) => { const loadPreview = async (fileId: number) => {
const requestToken = ++previewRequestToken.value
clearPreviewFrameTimeout()
previewLoading.value = true previewLoading.value = true
previewFrameLoading.value = true
previewError.value = '' previewError.value = ''
previewUrl.value = ''
previewPayload.value = undefined
previewFrameKey.value += 1
try { try {
const payload = await getFileOpenUrl(fileId) const payload = await getFileOpenUrl(fileId)
if (requestToken !== previewRequestToken.value) return
previewPayload.value = payload previewPayload.value = payload
if (!payload?.openUrl) { previewUrl.value = ''; previewError.value = '未获取到预览地址'; return } if (!payload?.openUrl) { previewUrl.value = ''; previewFrameLoading.value = false; previewError.value = '未获取到预览地址'; return }
previewUrl.value = payload.openUrl previewUrl.value = payload.openUrl
startPreviewFrameTimeout(requestToken)
} catch { } catch {
if (requestToken !== previewRequestToken.value) return
previewUrl.value = '' previewUrl.value = ''
previewFrameLoading.value = false
previewError.value = '预览服务暂不可用,请稍后重试' previewError.value = '预览服务暂不可用,请稍后重试'
} finally { } finally {
previewLoading.value = false if (requestToken === previewRequestToken.value) {
previewLoading.value = false
}
} }
} }
@ -464,6 +498,17 @@ const openInNewWindow = () => {
const refreshPreview = async () => { if (activeFileId.value) await loadPreview(activeFileId.value) } const refreshPreview = async () => { if (activeFileId.value) await loadPreview(activeFileId.value) }
const handlePreviewFrameLoad = () => {
clearPreviewFrameTimeout()
previewFrameLoading.value = false
}
const handlePreviewFrameError = () => {
clearPreviewFrameTimeout()
previewFrameLoading.value = false
previewError.value = '预览内容加载失败,请稍后重试'
}
// //
const handleNodeExpand = async (data: TreeNode) => { const handleNodeExpand = async (data: TreeNode) => {
if (data.type !== 'project') return if (data.type !== 'project') return
@ -491,6 +536,9 @@ const handleNodeClick = async (data: TreeNode) => {
// //
const loadCatalog = async () => { const loadCatalog = async () => {
pageLoading.value = true pageLoading.value = true
clearPreviewFrameTimeout()
clearOfficeWarmupTimers()
previewRequestToken.value += 1
previewError.value = '' previewError.value = ''
previewUrl.value = '' previewUrl.value = ''
previewPayload.value = undefined previewPayload.value = undefined
@ -691,6 +739,43 @@ const formatFileSize = (bytes: number): string => {
return `${(bytes / (1024 * 1024)).toFixed(1)} MB` return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
} }
const isOfficeFile = (fileType?: string) => ['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx'].includes((fileType || '').toLowerCase())
const clearOfficeWarmupTimers = () => {
officeWarmupTimers.forEach((timer) => clearTimeout(timer))
officeWarmupTimers = []
}
const clearPreviewFrameTimeout = () => {
if (previewFrameTimeoutTimer) {
clearTimeout(previewFrameTimeoutTimer)
previewFrameTimeoutTimer = null
}
}
const startPreviewFrameTimeout = (requestToken: number) => {
clearPreviewFrameTimeout()
previewFrameTimeoutTimer = setTimeout(() => {
if (requestToken !== previewRequestToken.value) return
if (!previewFrameLoading.value) return
previewFrameLoading.value = false
previewError.value = '预览服务响应超时,请稍后重试;若持续失败请检查 kkFileView 服务状态'
}, 30000)
}
const scheduleOfficeWarmup = (files: ReviewMeetingFileRespVO[]) => {
clearOfficeWarmupTimers()
files
.filter((file) => isOfficeFile(file.fileType))
.slice(0, 3)
.forEach((file, index) => {
const timer = setTimeout(() => {
getFileOpenUrl(file.id).catch(() => undefined)
}, index * 3000)
officeWarmupTimers.push(timer)
})
}
const renderMsgContent = (text: string) => const renderMsgContent = (text: string) =>
text.replace(/\n/g, '<br/>').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;') text.replace(/\n/g, '<br/>').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/&lt;br\/&gt;/g, '<br/>') .replace(/&lt;br\/&gt;/g, '<br/>')
@ -790,7 +875,7 @@ loadCatalog()
.preview-sub { margin-top: 4px; color: #64748b; font-size: 12px; } .preview-sub { margin-top: 4px; color: #64748b; font-size: 12px; }
.preview-actions { display: flex; gap: 8px; flex-shrink: 0; } .preview-actions { display: flex; gap: 8px; flex-shrink: 0; }
.preview-body { flex: 1; min-height: 0; background: #f8fafc; } .preview-body { flex: 1; min-height: 0; background: #f8fafc; position: relative; }
.welcome-panel { .welcome-panel {
height: 100%; height: 100%;
display: flex; flex-direction: column; align-items: center; justify-content: center; display: flex; flex-direction: column; align-items: center; justify-content: center;
@ -799,6 +884,18 @@ loadCatalog()
.welcome-title { font-size: 30px; font-weight: 700; letter-spacing: 1px; } .welcome-title { font-size: 30px; font-weight: 700; letter-spacing: 1px; }
.welcome-sub { margin-top: 12px; font-size: 15px; color: #64748b; } .welcome-sub { margin-top: 12px; font-size: 15px; color: #64748b; }
.preview-iframe { width: 100%; height: 100%; border: 0; display: block; background: #fff; } .preview-iframe { width: 100%; height: 100%; border: 0; display: block; background: #fff; }
.preview-loading-mask {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
background: rgba(248, 250, 252, 0.92);
z-index: 2;
pointer-events: none;
}
/* ── AI 面板 ── */ /* ── AI 面板 ── */
.ai-panel { .ai-panel {

View File

@ -26,17 +26,29 @@ export default ({command, mode}: ConfigEnv): UserConfig => {
// 服务端渲染 // 服务端渲染
server: { server: {
port: env.VITE_PORT, // 端口号 port: env.VITE_PORT, // 端口号
strictPort: true,
host: "0.0.0.0", host: "0.0.0.0",
open: env.VITE_OPEN === 'true', open: env.VITE_OPEN === 'true',
// 本地跨域代理. 目前注释的原因暂时没有用途server 端已经支持跨域 proxy: {
// proxy: { ['/admin-api']: {
// ['/admin-api']: { target: env.VITE_BASE_URL,
// target: env.VITE_BASE_URL, ws: false,
// ws: false, changeOrigin: true
// changeOrigin: true, },
// rewrite: (path) => path.replace(new RegExp(`^/admin-api`), ''), // 开发环境下,直接代理 bucket 根路径,避免预签名 URL 因路径重写导致签名失效
// }, ['/ncc-dev']: {
// }, target: 'http://127.0.0.1:9000',
ws: false,
changeOrigin: false,
configure: (proxy) => {
proxy.on('proxyReq', (proxyReq, req) => {
if (req.headers.host) {
proxyReq.setHeader('host', req.headers.host)
}
})
}
}
},
}, },
// 项目使用的vite插件。 单独提取到build/vite/plugin中管理 // 项目使用的vite插件。 单独提取到build/vite/plugin中管理
plugins: createVitePlugins(), plugins: createVitePlugins(),