diff --git a/furniture-3d-system/src/app/api/upload/model/route.ts b/furniture-3d-system/src/app/api/upload/model/route.ts index 367d8ca4..c84fc27a 100644 --- a/furniture-3d-system/src/app/api/upload/model/route.ts +++ b/furniture-3d-system/src/app/api/upload/model/route.ts @@ -3,13 +3,31 @@ import { handleRequest, jsonSuccess, jsonError } from '@/server/core/response' import { ErrorCode } from '@/server/core/errors' import { UPLOAD } from '@/server/core/constants' import { uploadObject } from '@/lib/storage' +import { canOperateDesignWorkspace } from '@/lib/role-scope' import { randomUUID } from 'crypto' import path from 'path' export async function POST(request: NextRequest) { return handleRequest( request, - async ({ requestId }) => { + async ({ requestId, user }) => { + // 安装演示工作台的上传走安装演示自身权限(客服等已配置编辑权限的角色 + // 也需要上传模型);其余调用方仍要求设计工作区写权限。 + // 该 service 与 storage 存在模块加载顺序耦合,这里用动态引入避免 route + // 模块初始化阶段被循环依赖拖挂(表现为 500 系统内部错误)。 + const { installationDemoAccessService } = await import( + '@/server/services/installation-demo/installation-demo-access.service' + ) + const installationDemoAccess = await installationDemoAccessService.getAccess(user) + if (!installationDemoAccess.canEdit && !canOperateDesignWorkspace(user)) { + return jsonError( + ErrorCode.AUTH_PERMISSION_DENIED, + '当前账号没有设计工作区操作权限', + 403, + requestId, + ) + } + const formData = await request.formData() const file = formData.get('file') as File | null @@ -58,6 +76,6 @@ export async function POST(request: NextRequest) { return jsonSuccess({ url }, 201, requestId) }, - { requireAuth: true, requireDesignWorkspaceWrite: true }, + { requireAuth: true }, ) } diff --git a/furniture-3d-system/src/app/installation-demo-workbench/InstallationDemoWorkbenchClient.tsx b/furniture-3d-system/src/app/installation-demo-workbench/InstallationDemoWorkbenchClient.tsx index 63e505a5..24db6309 100644 --- a/furniture-3d-system/src/app/installation-demo-workbench/InstallationDemoWorkbenchClient.tsx +++ b/furniture-3d-system/src/app/installation-demo-workbench/InstallationDemoWorkbenchClient.tsx @@ -119,6 +119,7 @@ import { useInstallationDrawingCapture } from "./hooks/useInstallationDrawingCap import { useInstallationDrawingAutoCapture } from "./hooks/useInstallationDrawingAutoCapture"; import { useInstallationDrawingConfirmation } from "./hooks/useInstallationDrawingConfirmation"; import { useInstallationDrawingTextAnnotations } from "./hooks/useInstallationDrawingTextAnnotations"; +import { InstallationUploadProgressOverlay } from "./components/InstallationUploadProgressOverlay"; import { buildInstallationDrawingCaptureFingerprint } from "./lib/installationDrawingCaptureFingerprint"; import { isInstallationDrawingActionCaptureTarget, @@ -3099,6 +3100,10 @@ export default function InstallationDemoWorkbenchClient({ setDrawingSheetOpen(true); }} /> + ); } diff --git a/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationUploadProgressOverlay.tsx b/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationUploadProgressOverlay.tsx new file mode 100644 index 00000000..0f18552e --- /dev/null +++ b/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationUploadProgressOverlay.tsx @@ -0,0 +1,79 @@ +'use client' + +import { CheckCircle2, CircleAlert, Upload, X } from 'lucide-react' + +type InstallationUploadProgressState = { + fileName: string + percent: number + status: 'uploading' | 'complete' | 'error' + message?: string +} + +type Props = { + progress: InstallationUploadProgressState | null + onDismiss: () => void +} + +/** + * 安装演示模型上传的右下角进度浮层。 + * 样式对齐系统既有的导入进度组件(StoreAccountImportProgress): + * 右下角固定卡片、图标+标题、文件名、百分比与进度条;完成绿色、失败红色。 + * 上传完成后由 controller 延时 3 秒自动收起,失败时保留给用户关闭。 + */ +export function InstallationUploadProgressOverlay({ progress, onDismiss }: Props) { + if (!progress) return null + + const active = progress.status === 'uploading' + const percent = Math.max(0, Math.min(100, Math.round(progress.percent))) + const title = + progress.status === 'complete' + ? '上传完成' + : progress.status === 'error' + ? '上传失败' + : '正在上传模型' + const Icon = progress.status === 'complete' ? CheckCircle2 : progress.status === 'error' ? CircleAlert : Upload + + return ( + + ) +} diff --git a/furniture-3d-system/src/app/installation-demo-workbench/hooks/useInstallationModelUploadController.ts b/furniture-3d-system/src/app/installation-demo-workbench/hooks/useInstallationModelUploadController.ts index deaec8bf..592ff18b 100644 --- a/furniture-3d-system/src/app/installation-demo-workbench/hooks/useInstallationModelUploadController.ts +++ b/furniture-3d-system/src/app/installation-demo-workbench/hooks/useInstallationModelUploadController.ts @@ -28,6 +28,13 @@ export function useInstallationModelUploadController(drawingId: string | null, c useState>(readStoredUploadedModels) const [uploadingDrawingId, setUploadingDrawingId] = useState(null) const [uploadMessage, setUploadMessage] = useState(null) + // 右下角进度浮层数据:文件名、0-100 百分比与状态(上传中/完成/失败)。 + const [uploadProgress, setUploadProgress] = useState<{ + fileName: string + percent: number + status: 'uploading' | 'complete' | 'error' + message?: string + } | null>(null) const loadedDrawingIdsRef = useRef>(new Set()) useEffect(() => { @@ -80,9 +87,20 @@ export function useInstallationModelUploadController(drawingId: string | null, c if (!canEdit) return false setUploadingDrawingId(drawingId) setUploadMessage(null) + setUploadProgress({ fileName: file.name, percent: 0, status: 'uploading' }) + + const finishWithDelay = (status: 'complete' | 'error', message?: string) => { + // 完成后进度浮层停留 3 秒再自动收起,让用户看清结果。 + setUploadProgress((current) => + current ? { ...current, percent: 100, status, message } : current, + ) + window.setTimeout(() => setUploadProgress(null), 3000) + } try { - const uploadedModel = await uploadInstallationModelFile(file) + const uploadedModel = await uploadInstallationModelFile(file, (percent) => { + setUploadProgress({ fileName: file.name, percent, status: 'uploading' }) + }) await onBeforeModelLinked?.() const modelRecord: InstallationUploadedModel = { drawingId, @@ -98,9 +116,12 @@ export function useInstallationModelUploadController(drawingId: string | null, c uploadedModel: modelRecord as unknown as Record, }) setUploadMessage('Model uploaded and linked to the current drawing') + finishWithDelay('complete') return true } catch (error) { - setUploadMessage(error instanceof Error ? error.message : 'Model upload failed') + const message = error instanceof Error ? error.message : 'Model upload failed' + setUploadMessage(message) + setUploadProgress({ fileName: file.name, percent: 100, status: 'error', message }) return false } finally { setUploadingDrawingId(null) @@ -109,10 +130,14 @@ export function useInstallationModelUploadController(drawingId: string | null, c [canEdit], ) + const dismissUploadProgress = useCallback(() => setUploadProgress(null), []) + return { uploadedModels, uploadingDrawingId, uploadMessage, + uploadProgress, + dismissUploadProgress, uploadModelForDrawing, } } diff --git a/furniture-3d-system/src/app/installation-demo-workbench/lib/installationModelUpload.ts b/furniture-3d-system/src/app/installation-demo-workbench/lib/installationModelUpload.ts index 9a64409e..c5cc1534 100644 --- a/furniture-3d-system/src/app/installation-demo-workbench/lib/installationModelUpload.ts +++ b/furniture-3d-system/src/app/installation-demo-workbench/lib/installationModelUpload.ts @@ -9,15 +9,21 @@ function isModelFile(fileName: string) { return MODEL_EXTENSIONS.some((extension) => lowerName.endsWith(extension)) } -export async function uploadInstallationModelFile(file: File) { +export async function uploadInstallationModelFile( + file: File, + onProgress?: (percent: number) => void, +) { let modelFile = file if (file.name.toLowerCase().endsWith('.zip')) { + // 解压属于本地准备阶段,进度条先推进到一个固定的前置刻度。 + onProgress?.(2) const parsed = await extractPreviewModelFromZip(file) if (!parsed.success) { throw new Error(parsed.error || 'ZIP 里没有可用的模型文件') } modelFile = parsed.modelFile + onProgress?.(5) } if (!isModelFile(modelFile.name)) { @@ -27,24 +33,45 @@ export async function uploadInstallationModelFile(file: File) { const formData = new FormData() formData.append('file', modelFile) - const response = await fetch('/api/upload/model', { - method: 'POST', - body: formData, - }) - const payload = (await response.json().catch(() => null)) as - | { + // fetch 不暴露上传进度,改用 XHR 把字节流进度回调出去。 + const payload = await new Promise<{ + success?: boolean + data?: { url?: string } + error?: { message?: string } + } | null>((resolve, reject) => { + const xhr = new XMLHttpRequest() + xhr.open('POST', '/api/upload/model') + xhr.responseType = 'json' + xhr.upload.onprogress = (event) => { + if (!event.lengthComputable) return + // 上传字节只占 5%–92% 区间,剩余留给服务端处理与落库。 + const scaled = 5 + (event.loaded / event.total) * 87 + onProgress?.(Math.min(92, Math.round(scaled))) + } + xhr.onload = () => { + onProgress?.(95) + resolve((xhr.response ?? null) as { success?: boolean data?: { url?: string } error?: { message?: string } - } - | null + } | null) + } + xhr.onerror = () => reject(new Error('模型上传失败')) + xhr.send(formData) + }).finally(() => { + onProgress?.(96) + }) - if (!response.ok || !payload?.success || !payload.data?.url) { + const ok = Boolean(payload?.success && payload.data?.url) + if (ok) onProgress?.(100) + + const modelUrl = payload?.data?.url + if (!ok || !modelUrl) { throw new Error(payload?.error?.message || '模型上传失败') } return { fileName: modelFile.name, - modelUrl: payload.data.url, + modelUrl, } }