fix(installation-demo): allow customer service model upload with progress toast
- /api/upload/model 去掉 requireDesignWorkspaceWrite 硬性门槛:改为安装演示 canEdit 或设计工作区权限二者其一即可上传(客服等已配置安装演示编辑权限 的角色可上传模型,其他模块设计权限语义不变) - 上传进度右下角浮层按系统既有导入进度样式实现(文件名+百分比+进度条, 完成绿色停留 3 秒自动收起,失败红色保留可关闭) - installationModelUpload 改用 XHR 以获得真实上传字节进度,工具箱共用不受影响
This commit is contained in:
parent
9e9d92306e
commit
2b192afd86
@ -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 },
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user