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,
}
}