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'
# 文件上传类型server - 后端上传, client - 前端直连上传仅支持S3服务
VITE_UPLOAD_TYPE=server
# 文件上传类型server - 后端上传, client - 前端直连上传,仅支持 S3 服务
VITE_UPLOAD_TYPE=client
# 接口地址
VITE_API_URL=/admin-api
@ -34,4 +34,4 @@ VITE_MALL_H5_DOMAIN='http://mall.yudao.iocoder.cn'
VITE_APP_CAPTCHA_ENABLE=true
# GoView域名
VITE_GOVIEW_URL='http://127.0.0.1:3000'
VITE_GOVIEW_URL='http://127.0.0.1:3000'

View File

@ -7,7 +7,7 @@ VITE_DEV=true
VITE_BASE_URL='http://localhost:48080'
# 文件上传类型server - 后端上传, client - 前端直连上传,仅支持 S3 服务
VITE_UPLOAD_TYPE=server
VITE_UPLOAD_TYPE=client
# 接口地址
VITE_API_URL=/admin-api
@ -31,4 +31,4 @@ VITE_MALL_H5_DOMAIN='http://localhost:3000'
VITE_APP_CAPTCHA_ENABLE=false
# GoView域名
VITE_GOVIEW_URL='http://127.0.0.1:3000'
VITE_GOVIEW_URL='http://127.0.0.1:3000'

View File

@ -50,6 +50,28 @@ server {
# 请求体大小限制(根据需要调整,例如文件上传)
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 模式支持
location / {

View File

@ -1,4 +1,6 @@
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
}
export interface ReviewMeetingFileRegisterReqVO {
reviewMeetingId: number
reviewMeetingProjectId: number
fileName: string
fileUrl: string
fileSize: number
fileType?: string
}
// ============================================================
// API 调用
// ============================================================
@ -82,13 +93,13 @@ export const uploadMeetingFile = (
reviewMeetingProjectId: number,
file: File
) => {
const formData = new FormData()
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 })
return uploadMeetingFileByPresignedUrl(reviewMeetingId, reviewMeetingProjectId, file)
}
/** 登记会议文件 */
export const registerMeetingFile = (data: ReviewMeetingFileRegisterReqVO) =>
request.post<ReviewMeetingFileRespVO>({ url: '/project/review-project/register-file', data })
/** 获取项目文件列表 */
export const getMeetingFileList = (reviewMeetingProjectId: number) =>
request.get({
@ -99,3 +110,34 @@ export const getMeetingFileList = (reviewMeetingProjectId: number) =>
/** 删除会议文件 */
export const deleteMeetingFile = (id: number) =>
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 maxSize = 50 * 1024 * 1024 // 50MB
const maxSize = 500 * 1024 * 1024 // 500MB
if (file.size > maxSize) {
message.error('文件大小不能超过50MB')
message.error('文件大小不能超过500MB')
return false
}
return true

View File

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

View File

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

View File

@ -97,13 +97,21 @@
<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>
<template v-else-if="previewUrl">
<iframe
:key="previewFrameKey"
:src="previewUrl"
class="preview-iframe"
frameborder="0"
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>
</section>
@ -340,6 +348,9 @@ const activeFileId = ref<number>()
const activeFileNode = ref<FileTreeNode>()
const previewUrl = ref('')
const previewPayload = ref<ReviewTabletOpenUrlVO>()
const previewFrameLoading = ref(false)
const previewFrameKey = ref(0)
const previewRequestToken = ref(0)
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')}`)
return segs.join(' · ')
})
const previewLoadingTitle = computed(() =>
isOfficeFile(activeFileNode.value?.fileType) ? '正在转换文档预览' : '正在加载预览'
)
const previewLoadingSubtitle = computed(() =>
isOfficeFile(activeFileNode.value?.fileType)
? 'Office 文件首次打开可能需要一些时间,请稍候'
: '正在为您打开当前资料'
)
// AI state
const aiPanelOpen = ref(false)
@ -368,6 +387,8 @@ const streamingLoading = ref(false)
const aiMessagesRef = ref<HTMLElement>()
let streamAbortCtrl: AbortController | 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()))
@ -424,6 +445,7 @@ const loadProjectFiles = async (projectNode: ProjectTreeNode) => {
fileCache.value[projectNode.projectId] = files
projectNode.children = files.map((f) => toFileNode(f, projectNode.projectId))
projectNode.loaded = true
scheduleOfficeWarmup(files)
} catch {
ElMessage.error('加载项目资料失败,请稍后重试')
throw new Error('load-project-files-failed')
@ -434,18 +456,30 @@ const loadProjectFiles = async (projectNode: ProjectTreeNode) => {
//
const loadPreview = async (fileId: number) => {
const requestToken = ++previewRequestToken.value
clearPreviewFrameTimeout()
previewLoading.value = true
previewFrameLoading.value = true
previewError.value = ''
previewUrl.value = ''
previewPayload.value = undefined
previewFrameKey.value += 1
try {
const payload = await getFileOpenUrl(fileId)
if (requestToken !== previewRequestToken.value) return
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
startPreviewFrameTimeout(requestToken)
} catch {
if (requestToken !== previewRequestToken.value) return
previewUrl.value = ''
previewFrameLoading.value = false
previewError.value = '预览服务暂不可用,请稍后重试'
} 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 handlePreviewFrameLoad = () => {
clearPreviewFrameTimeout()
previewFrameLoading.value = false
}
const handlePreviewFrameError = () => {
clearPreviewFrameTimeout()
previewFrameLoading.value = false
previewError.value = '预览内容加载失败,请稍后重试'
}
//
const handleNodeExpand = async (data: TreeNode) => {
if (data.type !== 'project') return
@ -491,6 +536,9 @@ const handleNodeClick = async (data: TreeNode) => {
//
const loadCatalog = async () => {
pageLoading.value = true
clearPreviewFrameTimeout()
clearOfficeWarmupTimers()
previewRequestToken.value += 1
previewError.value = ''
previewUrl.value = ''
previewPayload.value = undefined
@ -691,6 +739,43 @@ const formatFileSize = (bytes: number): string => {
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) =>
text.replace(/\n/g, '<br/>').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/&lt;br\/&gt;/g, '<br/>')
@ -790,7 +875,7 @@ loadCatalog()
.preview-sub { margin-top: 4px; color: #64748b; font-size: 12px; }
.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 {
height: 100%;
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-sub { margin-top: 12px; font-size: 15px; color: #64748b; }
.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-panel {

View File

@ -26,17 +26,29 @@ export default ({command, mode}: ConfigEnv): UserConfig => {
// 服务端渲染
server: {
port: env.VITE_PORT, // 端口号
strictPort: true,
host: "0.0.0.0",
open: env.VITE_OPEN === 'true',
// 本地跨域代理. 目前注释的原因暂时没有用途server 端已经支持跨域
// proxy: {
// ['/admin-api']: {
// target: env.VITE_BASE_URL,
// ws: false,
// changeOrigin: true,
// rewrite: (path) => path.replace(new RegExp(`^/admin-api`), ''),
// },
// },
proxy: {
['/admin-api']: {
target: env.VITE_BASE_URL,
ws: false,
changeOrigin: true
},
// 开发环境下,直接代理 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中管理
plugins: createVitePlugins(),