fix(installation-demo): allow customer service model upload with progress toast

- /api/upload/model 去掉 requireDesignWorkspaceWrite 硬性门槛:改为安装演示
  canEdit 或设计工作区权限二者其一即可上传(客服等已配置安装演示编辑权限
  的角色可上传模型,其他模块设计权限语义不变)
- 上传进度右下角浮层按系统既有导入进度样式实现(文件名+百分比+进度条,
  完成绿色停留 3 秒自动收起,失败红色保留可关闭)
- installationModelUpload 改用 XHR 以获得真实上传字节进度,工具箱共用不受影响
This commit is contained in:
WuYongJie 2026-09-12 15:50:47 +08:00
parent 9e9d92306e
commit 2b192afd86
5 changed files with 169 additions and 15 deletions

View File

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

View File

@ -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);
}}
/>
<InstallationUploadProgressOverlay
progress={upload.uploadProgress}
onDismiss={upload.dismissUploadProgress}
/>
</div>
);
}

View File

@ -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 (
<aside className={`installation-upload-progress is-${progress.status}`} aria-live="polite">
<header>
<span className="installation-upload-title">
<Icon size={16} />
{title}
</span>
{!active && (
<button type="button" onClick={onDismiss} title="关闭上传进度" aria-label="关闭上传进度">
<X size={15} />
</button>
)}
</header>
<div className="installation-upload-file" title={progress.fileName}>
{progress.fileName}
</div>
<div className="installation-upload-count">
<span>{active ? '正在上传到服务器...' : '处理完成'}</span>
<strong>{percent}%</strong>
</div>
<div
className={`installation-upload-track${active ? '' : ' is-settled'}`}
role="progressbar"
aria-label="模型上传进度"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={percent}
>
<span style={{ width: `${percent}%` }} />
</div>
{progress.message && progress.status === 'error' && <p>{progress.message}</p>}
<style jsx>{`
.installation-upload-progress{position:fixed;right:28px;bottom:24px;z-index:70;width:320px;box-sizing:border-box;padding:14px 15px;border:1px solid #d4d4d8;border-radius:7px;background:#fff;box-shadow:0 12px 28px rgba(24,24,27,.14);color:#27272a}
header,.installation-upload-title,.installation-upload-count{display:flex;align-items:center}
header{justify-content:space-between}.installation-upload-title{gap:7px;font-size:13px;font-weight:650}.installation-upload-title :global(svg){color:#52525b}.is-complete .installation-upload-title :global(svg){color:#15803d}.is-error .installation-upload-title :global(svg){color:#b91c1c}
header button{display:inline-flex;width:26px;height:26px;align-items:center;justify-content:center;padding:0;border:0;border-radius:4px;background:transparent;color:#71717a;cursor:pointer}header button:hover{background:#f4f4f5;color:#27272a}
.installation-upload-file{margin-top:9px;overflow:hidden;color:#71717a;font-size:12px;text-overflow:ellipsis;white-space:nowrap}.installation-upload-count{justify-content:space-between;margin-top:12px;color:#52525b;font-size:12px}.installation-upload-count strong{color:#27272a;font-size:12px;font-weight:650}
.installation-upload-track{height:6px;margin-top:7px;overflow:hidden;border-radius:999px;background:#e4e4e7}.installation-upload-track span{display:block;height:100%;border-radius:inherit;background:#27272a;transition:width .16s ease}.is-complete .installation-upload-track span{background:#15803d}.is-error .installation-upload-track span{background:#b91c1c}
.installation-upload-progress p{margin:9px 0 0;color:#b91c1c;font-size:12px;line-height:1.5}
@media(max-width:760px){.installation-upload-progress{right:12px;bottom:76px;width:calc(100vw - 24px)}}
`}</style>
</aside>
)
}

View File

@ -28,6 +28,13 @@ export function useInstallationModelUploadController(drawingId: string | null, c
useState<Record<string, InstallationUploadedModel>>(readStoredUploadedModels)
const [uploadingDrawingId, setUploadingDrawingId] = useState<string | null>(null)
const [uploadMessage, setUploadMessage] = useState<string | null>(null)
// 右下角进度浮层数据:文件名、0-100 百分比与状态(上传中/完成/失败)。
const [uploadProgress, setUploadProgress] = useState<{
fileName: string
percent: number
status: 'uploading' | 'complete' | 'error'
message?: string
} | null>(null)
const loadedDrawingIdsRef = useRef<Set<string>>(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<string, unknown>,
})
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,
}
}

View File

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