feat(review-frontend): 直传链路与平板预览体验优化
comment:\n- 上传改为 MinIO 预签名直传 + register-file 业务登记,统一 500MB 限制与提示\n- 开发环境补齐 Vite 代理(/admin-api 与 /ncc-dev),对齐正式环境 nginx 代理策略\n- 平板预览新增加载遮罩、超时兜底与 Office 预热节流,修复一直停留在转换页问题pull/874/head
parent
8fa5fa3388
commit
5ef10cfd6e
6
.env.dev
6
.env.dev
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
22
nginx.conf
22
nginx.conf
|
|
@ -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 / {
|
||||
|
|
|
|||
|
|
@ -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() : ''
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
>
|
||||
<el-button type="primary" plain>上传文件</el-button>
|
||||
<template #tip>
|
||||
<div class="el-upload__tip">支持 doc、docx、xls、xlsx、pdf、ppt、pptx 格式,单文件不超过 50MB</div>
|
||||
<div class="el-upload__tip">支持 doc、docx、xls、xlsx、pdf、ppt、pptx 格式,单文件不超过 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
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@
|
|||
>
|
||||
<button class="btn-upload">上传资料</button>
|
||||
</el-upload>
|
||||
<span class="upload-hint">支持 doc、docx、xls、xlsx、pdf、ppt、pptx,单文件不超过 50MB</span>
|
||||
<span class="upload-hint">支持 doc、docx、xls、xlsx、pdf、ppt、pptx,单文件不超过 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
|
||||
|
|
|
|||
|
|
@ -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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/<br\/>/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 {
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
Loading…
Reference in New Issue