diff --git a/.env.dev b/.env.dev index 224c8d23a..f6aa6d99e 100644 --- a/.env.dev +++ b/.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' \ No newline at end of file +VITE_GOVIEW_URL='http://127.0.0.1:3000' diff --git a/.env.local b/.env.local index 35765584d..1a84e6942 100644 --- a/.env.local +++ b/.env.local @@ -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' \ No newline at end of file +VITE_GOVIEW_URL='http://127.0.0.1:3000' diff --git a/nginx.conf b/nginx.conf index fc1bf7473..31ffd9111 100644 --- a/nginx.conf +++ b/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 / { diff --git a/src/api/review/project.ts b/src/api/review/project.ts index 27280eeb9..10daaeeef 100644 --- a/src/api/review/project.ts +++ b/src/api/review/project.ts @@ -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({ 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() : '' + }) +} diff --git a/src/views/project/acceptanceMaterial/AcceptanceMaterialForm.vue b/src/views/project/acceptanceMaterial/AcceptanceMaterialForm.vue index 6fca74e37..6da3adb78 100644 --- a/src/views/project/acceptanceMaterial/AcceptanceMaterialForm.vue +++ b/src/views/project/acceptanceMaterial/AcceptanceMaterialForm.vue @@ -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 diff --git a/src/views/review/meeting/FileListDialog.vue b/src/views/review/meeting/FileListDialog.vue index b87cdfb2f..52c0d03cc 100644 --- a/src/views/review/meeting/FileListDialog.vue +++ b/src/views/review/meeting/FileListDialog.vue @@ -11,7 +11,7 @@ > 上传文件 @@ -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 diff --git a/src/views/review/meeting/ProjectDetail.vue b/src/views/review/meeting/ProjectDetail.vue index d15a37d67..19cd5cbc3 100644 --- a/src/views/review/meeting/ProjectDetail.vue +++ b/src/views/review/meeting/ProjectDetail.vue @@ -49,7 +49,7 @@ > - 支持 doc、docx、xls、xlsx、pdf、ppt、pptx,单文件不超过 50MB + 支持 doc、docx、xls、xlsx、pdf、ppt、pptx,单文件不超过 500MB @@ -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 diff --git a/src/views/review/tablet/index.vue b/src/views/review/tablet/index.vue index 78f703dc2..11c75f5e2 100644 --- a/src/views/review/tablet/index.vue +++ b/src/views/review/tablet/index.vue @@ -97,13 +97,21 @@ 新窗口打开 - + @@ -340,6 +348,9 @@ const activeFileId = ref() const activeFileNode = ref() const previewUrl = ref('') const previewPayload = ref() +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() let streamAbortCtrl: AbortController | null = null let summaryPollingTimer: ReturnType | null = null +let officeWarmupTimers: ReturnType[] = [] +let previewFrameTimeoutTimer: ReturnType | 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, '
').replace(/&/g, '&').replace(//g, '>') .replace(/<br\/>/g, '
') @@ -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 { diff --git a/vite.config.ts b/vite.config.ts index a10bcfb58..791d1bfe2 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -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(),