feat(ai-render): add render workbench
This commit is contained in:
parent
c36d77fba8
commit
9dbb61cdd1
@ -0,0 +1,445 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { AlertCircle, Camera, Sparkles } from 'lucide-react'
|
||||
import { AssetWorkbenchEmptyBackdrop } from '@/components/platform/AssetWorkbenchEmptyBackdrop'
|
||||
import { ModelPreviewPanel } from '@/components/platform/ModelPreviewPanel'
|
||||
import {
|
||||
createAssistedAssemblyAiRender,
|
||||
optimizeAiPromptStream,
|
||||
} from '@/lib/open-product/platform-api'
|
||||
import { extractPreviewModelFromZip } from '@/lib/model-preview-zip'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { RenderInspector } from './RenderInspector'
|
||||
import { AI_RENDER_SAMPLE_SPUS } from './sampleData'
|
||||
import type {
|
||||
AiRenderSku,
|
||||
AiRenderSpu,
|
||||
LocalRenderRecord,
|
||||
} from './types'
|
||||
import {
|
||||
buildAiRenderAssetSnapshot,
|
||||
buildAiRenderWorkspaceSnapshot,
|
||||
buildSkuMaterialAssignments,
|
||||
createId,
|
||||
getDefaultSkuName,
|
||||
getDefaultSpuName,
|
||||
getProductStats,
|
||||
} from './utils'
|
||||
|
||||
function cloneSpus(spus: AiRenderSpu[]) {
|
||||
return spus.map((spu) => ({
|
||||
...spu,
|
||||
skus: spu.skus.map((sku) => ({ ...sku })),
|
||||
}))
|
||||
}
|
||||
|
||||
async function uploadSkuModelFile(file: File): Promise<string> {
|
||||
let uploadFile = file
|
||||
const lowerName = file.name.toLowerCase()
|
||||
|
||||
if (lowerName.endsWith('.zip')) {
|
||||
const parsed = await extractPreviewModelFromZip(file)
|
||||
if (!parsed.success) {
|
||||
throw new Error(parsed.error || 'ZIP 解析失败')
|
||||
}
|
||||
uploadFile = parsed.modelFile
|
||||
}
|
||||
|
||||
const uploadFileName = uploadFile.name.toLowerCase()
|
||||
if (
|
||||
!uploadFileName.endsWith('.glb') &&
|
||||
!uploadFileName.endsWith('.gltf') &&
|
||||
!uploadFileName.endsWith('.obj')
|
||||
) {
|
||||
throw new Error('当前仅支持 .zip / .glb / .gltf / .obj 格式的 SKU 模型')
|
||||
}
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('file', uploadFile)
|
||||
const response = await fetch('/api/upload/model', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
const payload = (await response.json().catch(() => null)) as {
|
||||
success?: boolean
|
||||
data?: { url?: string }
|
||||
error?: { message?: string }
|
||||
} | null
|
||||
if (!response.ok || !payload?.success || !payload.data?.url) {
|
||||
throw new Error(payload?.error?.message || 'SKU 模型上传失败')
|
||||
}
|
||||
return payload.data.url
|
||||
}
|
||||
|
||||
export default function AiRenderWorkbenchClient() {
|
||||
const [spus, setSpus] = useState<AiRenderSpu[]>(() => cloneSpus(AI_RENDER_SAMPLE_SPUS))
|
||||
const [selectedSpuId, setSelectedSpuId] = useState<string | null>(AI_RENDER_SAMPLE_SPUS[0]?.id ?? null)
|
||||
const [selectedSkuId, setSelectedSkuId] = useState<string | null>(AI_RENDER_SAMPLE_SPUS[0]?.skus[0]?.id ?? null)
|
||||
const [modelNodeNames, setModelNodeNames] = useState<string[]>([])
|
||||
const [capturePreview, setCapturePreview] = useState<(() => string | null) | null>(null)
|
||||
const [prompt, setPrompt] = useState('modern minimalist interior, product photography, natural daylight, realistic material, clean composition')
|
||||
const [negativePrompt, setNegativePrompt] = useState('deformed product, wrong structure, extra parts, watermark, text')
|
||||
const [aspectRatio, setAspectRatio] = useState('1:1')
|
||||
const [imageSize, setImageSize] = useState('2K')
|
||||
const [imageCount, setImageCount] = useState(1)
|
||||
const [records, setRecords] = useState<LocalRenderRecord[]>([])
|
||||
const [message, setMessage] = useState<string | null>(null)
|
||||
const [generating, setGenerating] = useState(false)
|
||||
const [optimizing, setOptimizing] = useState(false)
|
||||
const [optimExplanation, setOptimExplanation] = useState('')
|
||||
const [uploadingSkuId, setUploadingSkuId] = useState<string | null>(null)
|
||||
|
||||
const selectedSpu = useMemo(
|
||||
() => spus.find((spu) => spu.id === selectedSpuId) ?? spus[0] ?? null,
|
||||
[spus, selectedSpuId],
|
||||
)
|
||||
const selectedSku = useMemo(() => {
|
||||
const skus = selectedSpu?.skus ?? []
|
||||
return skus.find((sku) => sku.id === selectedSkuId) ?? skus[0] ?? null
|
||||
}, [selectedSpu, selectedSkuId])
|
||||
const stats = useMemo(() => getProductStats(spus), [spus])
|
||||
const materialAssignments = useMemo(
|
||||
() => buildSkuMaterialAssignments(selectedSku, modelNodeNames),
|
||||
[modelNodeNames, selectedSku],
|
||||
)
|
||||
|
||||
const handleCaptureReady = useCallback((capture: (() => string | null) | null) => {
|
||||
setCapturePreview(() => capture)
|
||||
}, [])
|
||||
|
||||
const updateSpu = useCallback((spuId: string, patch: Partial<AiRenderSpu>) => {
|
||||
setSpus((current) =>
|
||||
current.map((spu) => (spu.id === spuId ? { ...spu, ...patch } : spu)),
|
||||
)
|
||||
}, [])
|
||||
|
||||
const updateSku = useCallback((skuId: string, patch: Partial<AiRenderSku>) => {
|
||||
setSpus((current) =>
|
||||
current.map((spu) => ({
|
||||
...spu,
|
||||
skus: spu.skus.map((sku) =>
|
||||
sku.id === skuId ? { ...sku, ...patch } : sku,
|
||||
),
|
||||
})),
|
||||
)
|
||||
}, [])
|
||||
|
||||
const handleCreateSpu = useCallback(() => {
|
||||
const spuNo = spus.length + 1
|
||||
const newSpu: AiRenderSpu = {
|
||||
id: createId('spu'),
|
||||
name: getDefaultSpuName(spuNo),
|
||||
description: '',
|
||||
skus: [],
|
||||
}
|
||||
const firstSku: AiRenderSku = {
|
||||
id: createId('sku'),
|
||||
name: getDefaultSkuName(1),
|
||||
modelUrl: '',
|
||||
thumbnailUrl: null,
|
||||
materialColor: '#d8c6ad',
|
||||
}
|
||||
newSpu.skus = [firstSku]
|
||||
setSpus((current) => [...current, newSpu])
|
||||
setSelectedSpuId(newSpu.id)
|
||||
setSelectedSkuId(firstSku.id)
|
||||
setModelNodeNames([])
|
||||
}, [spus.length])
|
||||
|
||||
const handleDeleteSpu = useCallback((spuId: string) => {
|
||||
setSpus((current) => {
|
||||
const next = current.filter((spu) => spu.id !== spuId)
|
||||
const fallbackSpu = next[0] ?? null
|
||||
setSelectedSpuId(fallbackSpu?.id ?? null)
|
||||
setSelectedSkuId(fallbackSpu?.skus[0]?.id ?? null)
|
||||
setModelNodeNames([])
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleSelectSpu = useCallback((spuId: string) => {
|
||||
const spu = spus.find((item) => item.id === spuId)
|
||||
setSelectedSpuId(spuId)
|
||||
setSelectedSkuId(spu?.skus[0]?.id ?? null)
|
||||
setModelNodeNames([])
|
||||
}, [spus])
|
||||
|
||||
const handleCreateSku = useCallback(() => {
|
||||
if (!selectedSpu) return
|
||||
const newSku: AiRenderSku = {
|
||||
id: createId('sku'),
|
||||
name: getDefaultSkuName(selectedSpu.skus.length + 1),
|
||||
modelUrl: '',
|
||||
thumbnailUrl: null,
|
||||
materialColor: '#d8c6ad',
|
||||
}
|
||||
setSpus((current) =>
|
||||
current.map((spu) =>
|
||||
spu.id === selectedSpu.id
|
||||
? { ...spu, skus: [...spu.skus, newSku] }
|
||||
: spu,
|
||||
),
|
||||
)
|
||||
setSelectedSkuId(newSku.id)
|
||||
setModelNodeNames([])
|
||||
}, [selectedSpu])
|
||||
|
||||
const handleDeleteSku = useCallback((skuId: string) => {
|
||||
if (!selectedSpu) return
|
||||
setSpus((current) =>
|
||||
current.map((spu) => {
|
||||
if (spu.id !== selectedSpu.id) return spu
|
||||
const nextSkus = spu.skus.filter((sku) => sku.id !== skuId)
|
||||
const fallbackSku = nextSkus[0] ?? null
|
||||
setSelectedSkuId(fallbackSku?.id ?? null)
|
||||
setModelNodeNames([])
|
||||
return { ...spu, skus: nextSkus }
|
||||
}),
|
||||
)
|
||||
}, [selectedSpu])
|
||||
|
||||
const handleUploadSkuModel = useCallback(async (skuId: string, file: File) => {
|
||||
setUploadingSkuId(skuId)
|
||||
setMessage(null)
|
||||
try {
|
||||
const url = await uploadSkuModelFile(file)
|
||||
updateSku(skuId, { modelUrl: url })
|
||||
setSelectedSkuId(skuId)
|
||||
setMessage('SKU 模型已上传,可以在主视窗预览。')
|
||||
} catch (error) {
|
||||
setMessage(error instanceof Error ? error.message : 'SKU 模型上传失败')
|
||||
} finally {
|
||||
setUploadingSkuId(null)
|
||||
}
|
||||
}, [updateSku])
|
||||
|
||||
const createLocalRecord = useCallback((params: {
|
||||
mode: LocalRenderRecord['mode']
|
||||
imageUrl: string
|
||||
status?: LocalRenderRecord['status']
|
||||
}) => {
|
||||
if (!selectedSku) return
|
||||
setRecords((current) => [
|
||||
{
|
||||
id: createId('render'),
|
||||
skuName: selectedSku.name || '未命名 SKU',
|
||||
mode: params.mode,
|
||||
imageUrl: params.imageUrl,
|
||||
prompt,
|
||||
status: params.status ?? 'SUCCEEDED',
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
...current,
|
||||
].slice(0, 18))
|
||||
}, [prompt, selectedSku])
|
||||
|
||||
const handleCaptureWhiteBackground = useCallback(() => {
|
||||
const dataUrl = capturePreview?.()
|
||||
if (!dataUrl) {
|
||||
setMessage('当前 3D 预览还没准备好,稍等模型加载完成再试。')
|
||||
return
|
||||
}
|
||||
createLocalRecord({ mode: 'white-background', imageUrl: dataUrl })
|
||||
setMessage('白底图已从当前 3D 视角截取,先保存在右侧本地记录里。')
|
||||
}, [capturePreview, createLocalRecord])
|
||||
|
||||
const handleOptimizePrompt = useCallback(async () => {
|
||||
const normalizedPrompt = prompt.trim()
|
||||
if (!normalizedPrompt || optimizing) return
|
||||
|
||||
setOptimizing(true)
|
||||
setOptimExplanation('')
|
||||
setMessage(null)
|
||||
try {
|
||||
const referenceImageDataUrl = capturePreview?.() ?? null
|
||||
let accumulated = ''
|
||||
await optimizeAiPromptStream(
|
||||
normalizedPrompt,
|
||||
referenceImageDataUrl,
|
||||
(chunk) => {
|
||||
accumulated += chunk
|
||||
const sepIdx = accumulated.indexOf('---PROMPT---')
|
||||
if (sepIdx >= 0) {
|
||||
const explanation = accumulated.slice(0, sepIdx).trim()
|
||||
const promptPart = accumulated.slice(sepIdx + '---PROMPT---'.length).trim()
|
||||
if (explanation) setOptimExplanation(explanation)
|
||||
if (promptPart) setPrompt(promptPart)
|
||||
} else {
|
||||
setOptimExplanation(accumulated.trim())
|
||||
}
|
||||
},
|
||||
(_fullText, optimizedPrompt) => {
|
||||
setPrompt(optimizedPrompt)
|
||||
setMessage('提示词已优化,可继续编辑后生成。')
|
||||
},
|
||||
(errorMessage) => {
|
||||
setMessage(errorMessage)
|
||||
},
|
||||
)
|
||||
} catch (error) {
|
||||
setMessage(error instanceof Error ? error.message : '提示词优化失败')
|
||||
} finally {
|
||||
setOptimizing(false)
|
||||
}
|
||||
}, [capturePreview, optimizing, prompt])
|
||||
|
||||
const handleGenerateScene = useCallback(async () => {
|
||||
if (!selectedSku || !selectedSku.modelUrl) {
|
||||
setMessage('请先选择并配置一个 SKU 模型文件。')
|
||||
return
|
||||
}
|
||||
const referenceImageDataUrl = capturePreview?.()
|
||||
if (!referenceImageDataUrl) {
|
||||
setMessage('当前 3D 预览还没准备好,无法作为 AI 参考图。')
|
||||
return
|
||||
}
|
||||
const normalizedPrompt = prompt.trim()
|
||||
if (!normalizedPrompt) {
|
||||
setMessage('请先填写场景效果图提示词。')
|
||||
return
|
||||
}
|
||||
|
||||
setGenerating(true)
|
||||
setMessage('正在请求 AI 生图服务...')
|
||||
try {
|
||||
const render = await createAssistedAssemblyAiRender({
|
||||
prompt: normalizedPrompt,
|
||||
negativePrompt: negativePrompt.trim() || null,
|
||||
imageCount,
|
||||
aspectRatio,
|
||||
imageSize,
|
||||
referenceImageDataUrl,
|
||||
workspaceSnapshot: buildAiRenderWorkspaceSnapshot({
|
||||
spu: selectedSpu,
|
||||
sku: selectedSku,
|
||||
prompt: normalizedPrompt,
|
||||
mode: 'scene',
|
||||
}),
|
||||
assetSnapshot: buildAiRenderAssetSnapshot({
|
||||
spu: selectedSpu,
|
||||
sku: selectedSku,
|
||||
nodeNames: modelNodeNames,
|
||||
}),
|
||||
})
|
||||
const firstImage = render.resultImages[0]?.originalUrl || render.resultImages[0]?.thumbnailUrl
|
||||
if (render.status === 'SUCCEEDED' && firstImage) {
|
||||
createLocalRecord({ mode: 'scene', imageUrl: firstImage })
|
||||
setMessage('场景效果图已生成。')
|
||||
} else {
|
||||
setMessage(render.errorMessage || 'AI 生图请求已返回,但没有可用结果图。')
|
||||
}
|
||||
} catch (error) {
|
||||
setMessage(error instanceof Error ? error.message : 'AI 生图失败')
|
||||
} finally {
|
||||
setGenerating(false)
|
||||
}
|
||||
}, [
|
||||
aspectRatio,
|
||||
capturePreview,
|
||||
createLocalRecord,
|
||||
imageCount,
|
||||
imageSize,
|
||||
modelNodeNames,
|
||||
negativePrompt,
|
||||
prompt,
|
||||
selectedSku,
|
||||
selectedSpu,
|
||||
])
|
||||
|
||||
return (
|
||||
<div className="asset-center-obsidian flex h-full min-h-0 flex-col overflow-hidden bg-[#f8f9fa] text-[#2d3335] lg:flex-row">
|
||||
<section className="relative min-w-0 flex-1 bg-[#f1f4f5]">
|
||||
<ModelPreviewPanel
|
||||
key={selectedSku?.id ?? 'empty'}
|
||||
title="AI 出图工作台"
|
||||
modelUrl={selectedSku?.modelUrl || null}
|
||||
preserveDrawingBuffer
|
||||
showFpsBadge={false}
|
||||
className="h-full"
|
||||
defaultShowSceneGrid={false}
|
||||
showCameraActionButtons
|
||||
enableTranslateGizmo={false}
|
||||
materialAssignments={materialAssignments}
|
||||
emptyTitle="选择 SKU 后预览"
|
||||
emptyDescription="先在右侧产品信息里选择 SKU,上传 SKU 模型文件,或者粘贴模型 URL。"
|
||||
emptyIcon={<Sparkles size={30} strokeWidth={1.6} />}
|
||||
emptyBackdrop={<AssetWorkbenchEmptyBackdrop />}
|
||||
onCaptureReady={handleCaptureReady}
|
||||
onModelNodesChange={setModelNodeNames}
|
||||
overlayTopRight={
|
||||
<div className="flex items-center gap-2 rounded-[8px] bg-white/82 px-3 py-2 text-[11px] font-bold text-[#5a6062] shadow-sm ring-1 ring-black/[0.05] backdrop-blur-xl">
|
||||
<Camera size={14} strokeWidth={1.8} />
|
||||
{stats.spuCount} SPU / {stats.configuredSkuCount}/{stats.skuCount} SKU 已配置
|
||||
</div>
|
||||
}
|
||||
overlayBottomCenter={
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-[8px] bg-white/86 px-3 py-2 text-[11px] font-semibold shadow-sm ring-1 ring-black/[0.05] backdrop-blur-xl',
|
||||
selectedSku?.modelUrl ? 'text-[#5a6062]' : 'text-amber-700',
|
||||
)}
|
||||
>
|
||||
<AlertCircle size={14} strokeWidth={1.8} />
|
||||
{selectedSku?.modelUrl
|
||||
? '白底图基于当前 3D 视角截取,场景图会把当前视角作为 AI 参考图。'
|
||||
: '当前 SKU 还没有模型文件,先上传或粘贴模型 URL。'}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<div className="pointer-events-none absolute bottom-6 right-8 z-10 w-[280px] rounded-[12px] bg-white/82 px-5 py-4 shadow-[0_18px_48px_rgba(45,51,53,0.10)] backdrop-blur-xl">
|
||||
<p className="text-[10px] font-black uppercase tracking-[0.28em] text-[#5a6062]/50">
|
||||
WORKSPACE
|
||||
</p>
|
||||
<p className="mt-1 text-[11px] leading-5 text-[#5a6062]">
|
||||
先选 SKU · 改颜色贴图 · 再生成白底图或场景效果图
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<RenderInspector
|
||||
spus={spus}
|
||||
selectedSpuId={selectedSpu?.id ?? null}
|
||||
selectedSkuId={selectedSku?.id ?? null}
|
||||
uploadingSkuId={uploadingSkuId}
|
||||
spu={selectedSpu}
|
||||
sku={selectedSku}
|
||||
prompt={prompt}
|
||||
negativePrompt={negativePrompt}
|
||||
aspectRatio={aspectRatio}
|
||||
imageSize={imageSize}
|
||||
imageCount={imageCount}
|
||||
generating={generating}
|
||||
optimizing={optimizing}
|
||||
optimExplanation={optimExplanation}
|
||||
message={message}
|
||||
records={records}
|
||||
onSelectSpu={handleSelectSpu}
|
||||
onCreateSpu={handleCreateSpu}
|
||||
onRenameSpu={(spuId, name) => updateSpu(spuId, { name })}
|
||||
onDeleteSpu={handleDeleteSpu}
|
||||
onSelectSku={(skuId) => {
|
||||
setSelectedSkuId(skuId)
|
||||
setModelNodeNames([])
|
||||
}}
|
||||
onCreateSku={handleCreateSku}
|
||||
onUpdateSkuById={updateSku}
|
||||
onDeleteSku={handleDeleteSku}
|
||||
onUploadSkuModel={handleUploadSkuModel}
|
||||
onPromptChange={setPrompt}
|
||||
onNegativePromptChange={setNegativePrompt}
|
||||
onAspectRatioChange={setAspectRatio}
|
||||
onImageSizeChange={setImageSize}
|
||||
onImageCountChange={setImageCount}
|
||||
onCaptureWhiteBackground={handleCaptureWhiteBackground}
|
||||
onOptimizePrompt={handleOptimizePrompt}
|
||||
onGenerateScene={handleGenerateScene}
|
||||
onUpdateSku={(patch) => {
|
||||
if (selectedSku) {
|
||||
updateSku(selectedSku.id, patch)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,607 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import {
|
||||
AlertCircle,
|
||||
Box,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Cuboid,
|
||||
Download,
|
||||
Image as ImageIcon,
|
||||
ImagePlus,
|
||||
Loader2,
|
||||
Plus,
|
||||
RotateCcw,
|
||||
Sparkles,
|
||||
Trash2,
|
||||
UploadCloud,
|
||||
Wand2,
|
||||
} from 'lucide-react'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { AiRenderSku, AiRenderSpu, LocalRenderRecord } from './types'
|
||||
|
||||
interface RenderInspectorProps {
|
||||
spus: AiRenderSpu[]
|
||||
selectedSpuId: string | null
|
||||
selectedSkuId: string | null
|
||||
uploadingSkuId: string | null
|
||||
spu: AiRenderSpu | null
|
||||
sku: AiRenderSku | null
|
||||
prompt: string
|
||||
negativePrompt: string
|
||||
aspectRatio: string
|
||||
imageSize: string
|
||||
imageCount: number
|
||||
generating: boolean
|
||||
optimizing: boolean
|
||||
optimExplanation: string
|
||||
message: string | null
|
||||
records: LocalRenderRecord[]
|
||||
onSelectSpu: (spuId: string) => void
|
||||
onCreateSpu: () => void
|
||||
onRenameSpu: (spuId: string, name: string) => void
|
||||
onDeleteSpu: (spuId: string) => void
|
||||
onSelectSku: (skuId: string) => void
|
||||
onCreateSku: () => void
|
||||
onUpdateSkuById: (skuId: string, patch: Partial<AiRenderSku>) => void
|
||||
onDeleteSku: (skuId: string) => void
|
||||
onUploadSkuModel: (skuId: string, file: File) => void
|
||||
onPromptChange: (value: string) => void
|
||||
onNegativePromptChange: (value: string) => void
|
||||
onAspectRatioChange: (value: string) => void
|
||||
onImageSizeChange: (value: string) => void
|
||||
onImageCountChange: (value: number) => void
|
||||
onCaptureWhiteBackground: () => void
|
||||
onOptimizePrompt: () => void
|
||||
onGenerateScene: () => void
|
||||
onUpdateSku: (patch: Partial<AiRenderSku>) => void
|
||||
}
|
||||
|
||||
type InspectorTab = 'product' | 'ai'
|
||||
|
||||
function formatRecordTime(value: string) {
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return ''
|
||||
return date.toLocaleString('zh-CN', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
export function RenderInspector({
|
||||
spus,
|
||||
selectedSpuId,
|
||||
selectedSkuId,
|
||||
uploadingSkuId,
|
||||
spu,
|
||||
sku,
|
||||
prompt,
|
||||
negativePrompt,
|
||||
aspectRatio,
|
||||
imageSize,
|
||||
imageCount,
|
||||
generating,
|
||||
optimizing,
|
||||
optimExplanation,
|
||||
message,
|
||||
records,
|
||||
onSelectSpu,
|
||||
onCreateSpu,
|
||||
onRenameSpu,
|
||||
onDeleteSpu,
|
||||
onSelectSku,
|
||||
onCreateSku,
|
||||
onUpdateSkuById,
|
||||
onDeleteSku,
|
||||
onUploadSkuModel,
|
||||
onPromptChange,
|
||||
onNegativePromptChange,
|
||||
onAspectRatioChange,
|
||||
onImageSizeChange,
|
||||
onImageCountChange,
|
||||
onCaptureWhiteBackground,
|
||||
onOptimizePrompt,
|
||||
onGenerateScene,
|
||||
onUpdateSku,
|
||||
}: RenderInspectorProps) {
|
||||
const [activeTab, setActiveTab] = useState<InspectorTab>('product')
|
||||
const [promptCollapsed, setPromptCollapsed] = useState(false)
|
||||
const [negativeCollapsed, setNegativeCollapsed] = useState(true)
|
||||
const [optimExplanationCollapsed, setOptimExplanationCollapsed] = useState(false)
|
||||
|
||||
return (
|
||||
<aside className="flex h-full w-full shrink-0 flex-col bg-white shadow-[-10px_0_40px_rgba(0,0,0,0.03)] lg:w-[480px]">
|
||||
<div className="min-h-0 flex-1 overflow-y-auto border-b border-black/[0.03]">
|
||||
<div className="space-y-5 border-b border-black/[0.03] p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<Link
|
||||
href="/assisted-assembly"
|
||||
className="flex items-center gap-2 text-[10px] font-black uppercase tracking-[0.12em] text-[#47646b] transition-opacity hover:opacity-70"
|
||||
>
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
<span>返回</span>
|
||||
<span>辅助装配</span>
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
</Link>
|
||||
<span className="rounded-[4px] bg-black/[0.03] px-2 py-1 font-mono text-[9px] font-bold text-[#5a6062]/45">
|
||||
AI RENDER
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div />
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 rounded-[8px] bg-black/[0.025] p-1 ring-1 ring-black/[0.03]">
|
||||
{[
|
||||
{ key: 'product' as const, label: '产品信息' },
|
||||
{ key: 'ai' as const, label: 'AI 生图' },
|
||||
].map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
type="button"
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
className={cn(
|
||||
'h-8 rounded-[6px] text-[11px] font-black transition',
|
||||
activeTab === tab.key
|
||||
? 'bg-white text-[#2d3335] shadow-sm ring-1 ring-black/[0.04]'
|
||||
: 'text-[#5a6062]/55 hover:bg-white/70 hover:text-[#2d3335]',
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{activeTab === 'product' ? (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCreateSpu}
|
||||
className="flex h-10 items-center justify-center gap-1.5 rounded-[6px] bg-[#2d3335] text-[11px] font-black text-white transition hover:opacity-90"
|
||||
aria-label="新建 SPU"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
新建 SPU
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCreateSku}
|
||||
disabled={!spu}
|
||||
className="flex h-10 items-center justify-center gap-1.5 rounded-[6px] bg-white text-[11px] font-black text-[#2d3335] ring-1 ring-black/[0.06] transition hover:bg-black/[0.03] disabled:opacity-40"
|
||||
aria-label="新增 SKU"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
新增 SKU
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{spus.map((spuItem) => {
|
||||
const isSpuSelected = spuItem.id === selectedSpuId
|
||||
return (
|
||||
<article
|
||||
key={spuItem.id}
|
||||
className={cn(
|
||||
'overflow-hidden rounded-[7px] bg-white ring-1 transition',
|
||||
isSpuSelected ? 'ring-[#47646b]/18' : 'ring-black/[0.04] hover:ring-black/[0.08]',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 px-3 py-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelectSpu(spuItem.id)}
|
||||
className="flex h-6 w-6 shrink-0 items-center justify-center rounded-[5px] text-[#5a6062]/45 transition hover:bg-black/[0.035] hover:text-[#2d3335]"
|
||||
aria-label={`选择 SPU ${spuItem.name || '未命名 SPU'}`}
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn('h-3.5 w-3.5 transition-transform', isSpuSelected && 'rotate-90')}
|
||||
/>
|
||||
</button>
|
||||
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-[6px] bg-black/[0.035] text-[#5a6062]/65">
|
||||
<Box className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<input
|
||||
value={spuItem.name}
|
||||
onFocus={() => onSelectSpu(spuItem.id)}
|
||||
onChange={(event) => onRenameSpu(spuItem.id, event.target.value)}
|
||||
placeholder="填写 SPU 名称"
|
||||
className="h-6 w-full bg-transparent text-[12px] font-black text-[#2d3335] outline-none placeholder:text-[#5a6062]/35"
|
||||
aria-label="SPU 名称"
|
||||
/>
|
||||
<p className="truncate text-[9px] font-bold text-[#5a6062]/45">
|
||||
{spuItem.skus.length} 个 SKU
|
||||
</p>
|
||||
</div>
|
||||
{spus.length > 1 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDeleteSpu(spuItem.id)}
|
||||
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-[5px] text-[#9f4f4f]/65 transition hover:bg-[#b75b5b]/8 hover:text-[#9f4f4f]"
|
||||
aria-label="删除 SPU"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{isSpuSelected ? (
|
||||
<div className="space-y-2 border-t border-black/[0.04] px-3 pb-3 pt-2">
|
||||
<div className="space-y-1.5">
|
||||
{spuItem.skus.map((skuItem) => {
|
||||
const isSkuSelected = skuItem.id === selectedSkuId
|
||||
return (
|
||||
<div
|
||||
key={skuItem.id}
|
||||
className={cn(
|
||||
'rounded-[6px] bg-black/[0.018] px-2 py-2 ring-1 transition',
|
||||
isSkuSelected
|
||||
? 'ring-[#47646b]/18'
|
||||
: 'ring-transparent hover:bg-black/[0.026]',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelectSku(skuItem.id)}
|
||||
className="flex h-6 w-6 shrink-0 items-center justify-center rounded-[5px] text-[#5a6062]/45 transition hover:bg-white hover:text-[#2d3335]"
|
||||
aria-label={`选择 SKU ${skuItem.name || '未命名 SKU'}`}
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
'h-3.5 w-3.5 transition-transform',
|
||||
isSkuSelected && 'rotate-90',
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
<span
|
||||
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-[6px] text-white shadow-sm"
|
||||
style={{ backgroundColor: skuItem.materialColor || '#8b9092' }}
|
||||
>
|
||||
<Cuboid className="h-4 w-4" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<input
|
||||
value={skuItem.name}
|
||||
onFocus={() => onSelectSku(skuItem.id)}
|
||||
onChange={(event) => onUpdateSkuById(skuItem.id, { name: event.target.value })}
|
||||
placeholder="填写 SKU 名称"
|
||||
className="h-6 w-full bg-transparent text-[12px] font-black text-[#2d3335] outline-none placeholder:text-[#5a6062]/35"
|
||||
aria-label="SKU 名称"
|
||||
/>
|
||||
<p className="truncate text-[9px] font-bold text-[#5a6062]/45">
|
||||
{skuItem.modelUrl ? '已配置 SKU 模型' : '还没有 SKU 模型'}
|
||||
</p>
|
||||
</div>
|
||||
{spuItem.skus.length > 1 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDeleteSku(skuItem.id)}
|
||||
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-[5px] text-[#9f4f4f]/65 transition hover:bg-[#b75b5b]/8 hover:text-[#9f4f4f]"
|
||||
aria-label="删除 SKU"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{isSkuSelected ? (
|
||||
<div className="mt-2 grid grid-cols-[1fr_auto] gap-2 pl-8">
|
||||
<input
|
||||
value={skuItem.modelUrl}
|
||||
onChange={(event) => onUpdateSkuById(skuItem.id, { modelUrl: event.target.value })}
|
||||
placeholder="粘贴 SKU 模型 URL,或上传 ZIP / 模型"
|
||||
className="h-8 min-w-0 rounded-[5px] bg-white/70 px-2 text-[11px] font-medium text-[#5a6062] outline-none ring-1 ring-black/[0.04] transition placeholder:text-[#5a6062]/35 focus:bg-white focus:ring-[#47646b]/18"
|
||||
aria-label="SKU 模型 URL"
|
||||
/>
|
||||
<label className="flex h-8 cursor-pointer items-center justify-center gap-1.5 rounded-[5px] bg-[#2d3335] px-2.5 text-[10px] font-bold text-white transition hover:opacity-90">
|
||||
<UploadCloud className="h-3.5 w-3.5" />
|
||||
{uploadingSkuId === skuItem.id ? '上传中' : '上传 ZIP / 模型'}
|
||||
<input
|
||||
type="file"
|
||||
accept=".zip,.glb,.gltf,.obj,application/zip,model/gltf-binary,model/gltf+json,model/obj"
|
||||
className="hidden"
|
||||
disabled={uploadingSkuId === skuItem.id}
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0]
|
||||
event.target.value = ''
|
||||
if (file) {
|
||||
onUploadSkuModel(skuItem.id, file)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</article>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-3">
|
||||
<div className="space-y-2 rounded-[8px] bg-black/[0.025] p-3 ring-1 ring-black/[0.03]">
|
||||
{[
|
||||
{ label: 'SPU', value: spu?.name || '未选择' },
|
||||
{ label: '当前 SKU', value: sku?.name || '未选择' },
|
||||
{ label: 'SKU 模型', value: sku?.modelUrl ? '已配置' : '未配置' },
|
||||
].map((item) => (
|
||||
<div key={item.label} className="flex items-center justify-between gap-3 text-[11px]">
|
||||
<span className="font-bold text-[#5a6062]/50">{item.label}</span>
|
||||
<span className="truncate font-apple-medium text-[#2d3335]">{item.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="rounded-[6px] bg-white/50 p-3 ring-1 ring-black/[0.04]">
|
||||
<div className="mb-2 flex items-center gap-2 text-[12px] font-black text-[#2d3335]">
|
||||
<Wand2 className="h-3.5 w-3.5" />
|
||||
材质与贴图
|
||||
</div>
|
||||
<div className="grid grid-cols-[70px_1fr] items-center gap-2">
|
||||
<span className="text-[11px] font-semibold text-[#8b9092]">颜色</span>
|
||||
<input
|
||||
type="color"
|
||||
value={sku?.materialColor || '#d8c6ad'}
|
||||
disabled={!sku}
|
||||
onChange={(event) => onUpdateSku({ materialColor: event.target.value })}
|
||||
className="h-8 w-full rounded-[5px] border border-black/[0.06] bg-white p-1 disabled:opacity-40"
|
||||
aria-label="SKU 模型颜色"
|
||||
/>
|
||||
<span className="text-[11px] font-semibold text-[#8b9092]">贴图 URL</span>
|
||||
<input
|
||||
value={sku?.materialTextureUrl ?? ''}
|
||||
disabled={!sku}
|
||||
onChange={(event) => onUpdateSku({ materialTextureUrl: event.target.value })}
|
||||
placeholder="可选,粘贴贴图地址"
|
||||
className="h-8 rounded-[5px] bg-white/70 px-2 text-[11px] font-medium text-[#5a6062] outline-none ring-1 ring-black/[0.04] focus:bg-white focus:ring-[#47646b]/20 disabled:opacity-40"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{optimExplanation ? (
|
||||
<div className="rounded-[6px] bg-[#47646b]/5 ring-1 ring-[#47646b]/8">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOptimExplanationCollapsed((value) => !value)}
|
||||
className="flex w-full items-center justify-between px-2.5 py-1.5 text-[10px] font-bold text-[#47646b]"
|
||||
>
|
||||
<span>优化说明</span>
|
||||
<ChevronDown className={cn('h-3 w-3 transition-transform', optimExplanationCollapsed && '-rotate-90')} />
|
||||
</button>
|
||||
{!optimExplanationCollapsed ? (
|
||||
<div className="relative px-2.5 pb-2">
|
||||
<div className="max-h-[100px] overflow-y-auto text-[10px] leading-[16px] text-[#2d3335]/70">
|
||||
{optimExplanation}
|
||||
{optimizing ? <span className="ml-0.5 inline-block h-3 w-[2px] animate-pulse bg-[#47646b]" /> : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="rounded-[6px] bg-white/50 ring-1 ring-black/[0.04]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPromptCollapsed((value) => !value)}
|
||||
className="flex w-full items-center justify-between px-2.5 py-1.5 text-[10px] font-bold text-[#5a6062]/70"
|
||||
>
|
||||
<span>生图提示词</span>
|
||||
<ChevronDown className={cn('h-3 w-3 transition-transform', promptCollapsed && '-rotate-90')} />
|
||||
</button>
|
||||
{!promptCollapsed ? (
|
||||
<div className="px-2.5 pb-2">
|
||||
<textarea
|
||||
value={prompt}
|
||||
onChange={(event) => onPromptChange(event.target.value)}
|
||||
disabled={generating}
|
||||
placeholder="描述你想要的效果,如:保留产品结构,生成真实白底产品摄影或真实室内场景,材质自然,灯光柔和"
|
||||
className="min-h-[60px] w-full resize-none rounded-[4px] bg-white/70 px-2.5 py-1.5 text-[11px] font-medium leading-[18px] text-[#2d3335] outline-none ring-1 ring-black/[0.04] transition placeholder:text-[#5a6062]/35 focus:bg-white focus:ring-[#47646b]/20 disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
<select
|
||||
value={imageCount}
|
||||
onChange={(event) => onImageCountChange(Number(event.target.value))}
|
||||
disabled={generating}
|
||||
className="h-7 rounded-[5px] bg-white/70 px-2 text-[10px] font-bold text-[#2d3335] outline-none ring-1 ring-black/[0.04]"
|
||||
>
|
||||
{[1, 2, 3, 4].map((count) => (
|
||||
<option key={count} value={count}>
|
||||
{count} 张
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={aspectRatio}
|
||||
onChange={(event) => onAspectRatioChange(event.target.value)}
|
||||
disabled={generating}
|
||||
className="h-7 rounded-[5px] bg-white/70 px-2 text-[10px] font-bold text-[#2d3335] outline-none ring-1 ring-black/[0.04]"
|
||||
>
|
||||
{['16:9', '4:3', '1:1', '3:4'].map((ratio) => (
|
||||
<option key={ratio} value={ratio}>
|
||||
{ratio}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={imageSize}
|
||||
onChange={(event) => onImageSizeChange(event.target.value)}
|
||||
disabled={generating}
|
||||
className="h-7 rounded-[5px] bg-white/70 px-2 text-[10px] font-bold text-[#2d3335] outline-none ring-1 ring-black/[0.04]"
|
||||
>
|
||||
{['1K', '2K', '4K'].map((size) => (
|
||||
<option key={size} value={size}>
|
||||
{size}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="flex-1" />
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-7 shrink-0 items-center rounded-[5px] bg-white/70 px-1.5 text-[#5a6062]/55 ring-1 ring-black/[0.04] transition hover:text-[#2d3335]"
|
||||
title="本地记录会自动刷新"
|
||||
>
|
||||
<RotateCcw className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[6px] bg-white/50 ring-1 ring-black/[0.04]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setNegativeCollapsed((value) => !value)}
|
||||
className="flex w-full items-center justify-between px-2.5 py-1.5 text-[10px] font-bold text-[#5a6062]/70"
|
||||
>
|
||||
<span>反向提示词</span>
|
||||
<ChevronDown className={cn('h-3 w-3 transition-transform', negativeCollapsed && '-rotate-90')} />
|
||||
</button>
|
||||
{!negativeCollapsed ? (
|
||||
<div className="px-2.5 pb-2">
|
||||
<textarea
|
||||
value={negativePrompt}
|
||||
onChange={(event) => onNegativePromptChange(event.target.value)}
|
||||
rows={2}
|
||||
disabled={generating}
|
||||
placeholder="不想出现的内容,可选"
|
||||
className="w-full resize-none rounded-[4px] bg-white/70 px-2.5 py-1.5 text-[11px] font-medium leading-[18px] text-[#5a6062] outline-none ring-1 ring-black/[0.04] transition placeholder:text-[#5a6062]/35 focus:bg-white focus:ring-[#47646b]/20 disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{message ? (
|
||||
<div className="flex gap-1.5 rounded-[6px] bg-[#47646b]/6 px-2.5 py-1.5 text-[10px] font-medium text-[#47646b]">
|
||||
<AlertCircle className="mt-px h-3 w-3 shrink-0" />
|
||||
<span className="line-clamp-2">{message}</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOptimizePrompt}
|
||||
disabled={optimizing || generating || !prompt.trim()}
|
||||
className={cn(
|
||||
'flex h-9 shrink-0 items-center gap-1.5 rounded-[6px] px-3 text-[11px] font-bold transition ring-1',
|
||||
optimizing
|
||||
? 'bg-[#47646b]/10 text-[#47646b] ring-[#47646b]/15'
|
||||
: 'bg-gradient-to-b from-[#e8f4f0] to-[#d4ede6] text-[#2d6b5a] ring-[#47646b]/15 hover:from-[#d4ede6] hover:to-[#c0e5db]',
|
||||
)}
|
||||
>
|
||||
<Wand2 className={cn('h-3.5 w-3.5', optimizing && 'animate-pulse')} />
|
||||
{optimizing ? '优化中...' : '优化提示词'}
|
||||
</button>
|
||||
<Button
|
||||
variant="filled"
|
||||
size="small"
|
||||
onClick={onGenerateScene}
|
||||
disabled={generating || !sku?.modelUrl || !prompt.trim()}
|
||||
className="h-9 flex-1 rounded-[6px] text-[11px]"
|
||||
>
|
||||
{generating ? (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<ImagePlus className="mr-1.5 h-3.5 w-3.5" />
|
||||
)}
|
||||
{generating ? '生成中...' : '生成图片'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
<div className="mb-2 text-[12px] font-black text-[#2d3335]">本地生图记录</div>
|
||||
{records.length === 0 ? (
|
||||
<div className="py-8 text-center text-[10px] font-medium text-[#5a6062]/35">
|
||||
暂无记录
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2.5">
|
||||
{records.map((record) => (
|
||||
<a
|
||||
key={record.id}
|
||||
href={record.imageUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="group block overflow-hidden rounded-[7px] bg-white/60 ring-1 ring-black/[0.04]"
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={record.imageUrl} alt={record.skuName} className="aspect-[16/10] w-full object-cover" />
|
||||
<div className="px-2.5 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
'shrink-0 rounded-[4px] px-1.5 py-0.5 text-[8px] font-black',
|
||||
record.status === 'SUCCEEDED'
|
||||
? 'bg-[#286c34]/8 text-[#286c34]'
|
||||
: 'bg-[#b75b5b]/8 text-[#9f4f4f]',
|
||||
)}
|
||||
>
|
||||
{record.mode === 'white-background' ? '白底图' : '场景图'}
|
||||
</span>
|
||||
<span className="text-[8px] text-[#5a6062]/35">
|
||||
{formatRecordTime(record.createdAt)}
|
||||
</span>
|
||||
<div className="flex-1" />
|
||||
<Download className="h-3 w-3 text-[#5a6062]/30" />
|
||||
</div>
|
||||
<p className="mt-1 line-clamp-1 text-[10px] font-medium text-[#2d3335]/70">
|
||||
{record.skuName}
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 bg-white">
|
||||
<div className="space-y-4 px-5 py-5">
|
||||
{!sku?.modelUrl ? (
|
||||
<div className="flex gap-2 rounded-[8px] border border-[#9a7b3f]/12 bg-[#9a7b3f]/7 px-3 py-2 text-[11px] leading-5 text-[#7a612e]">
|
||||
<AlertCircle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||
<span>当前 SKU 还没有模型文件,先在产品信息里上传 ZIP / 模型,或粘贴 SKU 模型 URL。</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid grid-cols-[1fr_1.25fr] gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={!sku?.modelUrl || generating}
|
||||
onClick={onCaptureWhiteBackground}
|
||||
className="flex h-10 items-center justify-center gap-1.5 rounded-[6px] bg-black/[0.035] text-[11px] font-black text-[#2d3335] transition hover:bg-black/[0.06] disabled:opacity-40"
|
||||
>
|
||||
<ImageIcon className="h-3.5 w-3.5" />
|
||||
白底图
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!sku?.modelUrl || generating}
|
||||
onClick={onGenerateScene}
|
||||
className="flex h-10 items-center justify-center gap-1.5 rounded-[6px] bg-[#2d3335] text-[11px] font-black text-white transition hover:opacity-90 disabled:opacity-40"
|
||||
>
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
{generating ? '生成中...' : '场景效果图'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
12
furniture-3d-system/src/app/ai-render-workbench/page.tsx
Normal file
12
furniture-3d-system/src/app/ai-render-workbench/page.tsx
Normal file
@ -0,0 +1,12 @@
|
||||
import { requireSession } from '@/lib/server-session'
|
||||
import AiRenderWorkbenchClient from './AiRenderWorkbenchClient'
|
||||
|
||||
export const metadata = {
|
||||
title: 'AI 出图工作台',
|
||||
}
|
||||
|
||||
export default async function AiRenderWorkbenchPage() {
|
||||
await requireSession('/login')
|
||||
|
||||
return <AiRenderWorkbenchClient />
|
||||
}
|
||||
@ -0,0 +1,37 @@
|
||||
'use client'
|
||||
|
||||
import type { AiRenderSpu } from './types'
|
||||
|
||||
export const AI_RENDER_SAMPLE_SPUS: AiRenderSpu[] = [
|
||||
{
|
||||
id: 'spu-cj0001',
|
||||
name: 'CJ0001',
|
||||
description: 'SPU 名称可以直接改,下面的 SKU 名称也可以直接改。',
|
||||
skus: [
|
||||
{
|
||||
id: 'sku-cj000101',
|
||||
name: 'CJ000101',
|
||||
modelUrl: '',
|
||||
thumbnailUrl: null,
|
||||
materialColor: '#d8c6ad',
|
||||
note: '白底图主推款',
|
||||
},
|
||||
{
|
||||
id: 'sku-cj000102',
|
||||
name: 'CJ000102',
|
||||
modelUrl: '',
|
||||
thumbnailUrl: null,
|
||||
materialColor: '#9c6f55',
|
||||
note: '场景效果图备选款',
|
||||
},
|
||||
{
|
||||
id: 'sku-cj000103',
|
||||
name: 'CJ000103',
|
||||
modelUrl: '',
|
||||
thumbnailUrl: null,
|
||||
materialColor: '#64706c',
|
||||
note: '深色材质款',
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
37
furniture-3d-system/src/app/ai-render-workbench/types.ts
Normal file
37
furniture-3d-system/src/app/ai-render-workbench/types.ts
Normal file
@ -0,0 +1,37 @@
|
||||
'use client'
|
||||
|
||||
import type { MaterialConfig } from '@/lib/3d/material-manager'
|
||||
|
||||
export interface AiRenderSku {
|
||||
id: string
|
||||
name: string
|
||||
modelUrl: string
|
||||
thumbnailUrl?: string | null
|
||||
note?: string
|
||||
materialColor: string
|
||||
materialTextureUrl?: string
|
||||
}
|
||||
|
||||
export interface AiRenderSpu {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
skus: AiRenderSku[]
|
||||
}
|
||||
|
||||
export type AiRenderOutputMode = 'white-background' | 'scene'
|
||||
|
||||
export interface LocalRenderRecord {
|
||||
id: string
|
||||
skuName: string
|
||||
mode: AiRenderOutputMode
|
||||
imageUrl: string
|
||||
prompt: string
|
||||
status: 'SUCCEEDED' | 'FAILED'
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface AiRenderSkuMaterialAssignment {
|
||||
nodeName: string
|
||||
material: MaterialConfig
|
||||
}
|
||||
@ -0,0 +1,11 @@
|
||||
import { getDefaultSkuName, getDefaultSpuName } from './utils'
|
||||
|
||||
describe('AI 出图工作台默认名称', () => {
|
||||
it('新增 SPU 只生成可编辑临时名称,不按 CJ 编码', () => {
|
||||
expect(getDefaultSpuName(2)).toBe('新 SPU 2')
|
||||
})
|
||||
|
||||
it('新增 SKU 只生成可编辑临时名称,不根据 SPU 名称拼编号', () => {
|
||||
expect(getDefaultSkuName(4)).toBe('新 SKU 4')
|
||||
})
|
||||
})
|
||||
133
furniture-3d-system/src/app/ai-render-workbench/utils.ts
Normal file
133
furniture-3d-system/src/app/ai-render-workbench/utils.ts
Normal file
@ -0,0 +1,133 @@
|
||||
'use client'
|
||||
|
||||
import type { MaterialConfig } from '@/lib/3d/material-manager'
|
||||
import type {
|
||||
AiRenderSku,
|
||||
AiRenderSkuMaterialAssignment,
|
||||
AiRenderSpu,
|
||||
} from './types'
|
||||
|
||||
export function createId(prefix: string) {
|
||||
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
|
||||
return `${prefix}-${crypto.randomUUID()}`
|
||||
}
|
||||
return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
||||
}
|
||||
|
||||
export function getDefaultSpuName(index: number) {
|
||||
return `新 SPU ${index}`
|
||||
}
|
||||
|
||||
export function getDefaultSkuName(index: number) {
|
||||
return `新 SKU ${index}`
|
||||
}
|
||||
|
||||
export function getProductStats(spus: AiRenderSpu[]) {
|
||||
const skuCount = spus.reduce((sum, spu) => sum + spu.skus.length, 0)
|
||||
const configuredSkuCount = spus.reduce(
|
||||
(sum, spu) => sum + spu.skus.filter((sku) => Boolean(sku.modelUrl.trim())).length,
|
||||
0,
|
||||
)
|
||||
return {
|
||||
spuCount: spus.length,
|
||||
skuCount,
|
||||
configuredSkuCount,
|
||||
}
|
||||
}
|
||||
|
||||
export function createSkuMaterial(sku: AiRenderSku): MaterialConfig {
|
||||
return {
|
||||
id: `ai-render-${sku.id}`,
|
||||
name: sku.name,
|
||||
categoryId: 'ai-render-workbench',
|
||||
categoryName: 'AI 出图材质',
|
||||
thumbnailUrl: null,
|
||||
textureUrl: sku.materialTextureUrl?.trim() || null,
|
||||
normalMapUrl: null,
|
||||
color: sku.materialColor || '#d8c6ad',
|
||||
metalness: 0.05,
|
||||
roughness: 0.55,
|
||||
opacity: 1,
|
||||
textureRepeat: 1,
|
||||
textureRotation: 0,
|
||||
textureSizeMm: 600,
|
||||
}
|
||||
}
|
||||
|
||||
export function buildSkuMaterialAssignments(
|
||||
sku: AiRenderSku | null,
|
||||
nodeNames: string[],
|
||||
): AiRenderSkuMaterialAssignment[] {
|
||||
if (!sku || nodeNames.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const material = createSkuMaterial(sku)
|
||||
return nodeNames.map((nodeName) => ({
|
||||
nodeName,
|
||||
material,
|
||||
}))
|
||||
}
|
||||
|
||||
export function buildAiRenderAssetSnapshot(params: {
|
||||
spu: AiRenderSpu | null
|
||||
sku: AiRenderSku | null
|
||||
nodeNames: string[]
|
||||
}) {
|
||||
const { spu, sku, nodeNames } = params
|
||||
if (!sku) {
|
||||
return {
|
||||
type: 'ai-render-workbench',
|
||||
spu: spu ? { id: spu.id, name: spu.name } : null,
|
||||
nodes: [],
|
||||
}
|
||||
}
|
||||
|
||||
const materialConfig = createSkuMaterial(sku)
|
||||
return {
|
||||
type: 'ai-render-workbench',
|
||||
spu: spu ? { id: spu.id, name: spu.name } : null,
|
||||
sku: {
|
||||
id: sku.id,
|
||||
name: sku.name,
|
||||
modelUrl: sku.modelUrl,
|
||||
thumbnailUrl: sku.thumbnailUrl ?? null,
|
||||
},
|
||||
nodes:
|
||||
nodeNames.length > 0
|
||||
? nodeNames.map((nodeName) => ({
|
||||
name: nodeName,
|
||||
materialConfig,
|
||||
}))
|
||||
: [
|
||||
{
|
||||
name: sku.name,
|
||||
materialConfig,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
export function buildAiRenderWorkspaceSnapshot(params: {
|
||||
spu: AiRenderSpu | null
|
||||
sku: AiRenderSku | null
|
||||
prompt: string
|
||||
mode: string
|
||||
}) {
|
||||
const { spu, sku, prompt, mode } = params
|
||||
return {
|
||||
source: 'ai-render-workbench',
|
||||
mode,
|
||||
prompt,
|
||||
spu: spu ? { id: spu.id, name: spu.name, description: spu.description } : null,
|
||||
sku: sku
|
||||
? {
|
||||
id: sku.id,
|
||||
name: sku.name,
|
||||
modelUrl: sku.modelUrl,
|
||||
materialColor: sku.materialColor,
|
||||
materialTextureUrl: sku.materialTextureUrl ?? null,
|
||||
}
|
||||
: null,
|
||||
}
|
||||
}
|
||||
@ -26,6 +26,7 @@ export const APP_SHELL_ROOTS = [
|
||||
'/intake',
|
||||
'/warehouse',
|
||||
'/warehouse-quality',
|
||||
'/ai-render-workbench',
|
||||
] as const
|
||||
|
||||
export function shouldUseAppShell(pathname: string): boolean {
|
||||
@ -83,6 +84,8 @@ function AppShellContent({ children, user }: AppShellProps) {
|
||||
const isWarehouseRoute = pathname === '/warehouse' || pathname.startsWith('/warehouse/')
|
||||
const isWarehouseQualityRoute =
|
||||
pathname === '/warehouse-quality' || pathname.startsWith('/warehouse-quality/')
|
||||
const isAiRenderWorkbenchRoute =
|
||||
pathname === '/ai-render-workbench' || pathname.startsWith('/ai-render-workbench/')
|
||||
const isProfileRoute = pathname === '/profile' || pathname.startsWith('/profile/')
|
||||
const isUsersRoute = pathname === '/users' || pathname.startsWith('/users/')
|
||||
const isAdminRoute = pathname === '/admin' || pathname.startsWith('/admin/')
|
||||
@ -98,7 +101,8 @@ function AppShellContent({ children, user }: AppShellProps) {
|
||||
isDraftGroupsRoute ||
|
||||
isIntakeRoute ||
|
||||
isWarehouseRoute ||
|
||||
isWarehouseQualityRoute
|
||||
isWarehouseQualityRoute ||
|
||||
isAiRenderWorkbenchRoute
|
||||
const shouldLockMainViewport =
|
||||
isWorkbenchShellRoute
|
||||
|
||||
|
||||
@ -8,6 +8,7 @@ import {
|
||||
Layers,
|
||||
PackageOpen,
|
||||
ArrowDownToLine,
|
||||
Sparkles,
|
||||
Warehouse,
|
||||
} from 'lucide-react'
|
||||
import { SidebarUserCenter } from './SidebarUserCenter'
|
||||
@ -20,6 +21,7 @@ const NAV_ITEMS = [
|
||||
{ href: '/draft-groups', icon: PackageOpen, label: '草稿组' },
|
||||
{ href: '/intake', icon: ArrowDownToLine, label: '产品资料' },
|
||||
{ href: '/warehouse', icon: Warehouse, label: '仓库管理' },
|
||||
{ href: '/ai-render-workbench', icon: Sparkles, label: 'AI 出图工作台' },
|
||||
]
|
||||
|
||||
export function AppSideNav() {
|
||||
|
||||
@ -14,6 +14,7 @@ import {
|
||||
Menu,
|
||||
PackageOpen,
|
||||
Search,
|
||||
Sparkles,
|
||||
Warehouse,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
@ -35,6 +36,7 @@ const WORKBENCH_NAV_ITEMS = [
|
||||
{ href: '/intake/oa-board', icon: ClipboardList, label: 'OA 新品流程看板' },
|
||||
{ href: '/warehouse', icon: Warehouse, label: '仓库管理' },
|
||||
{ href: '/warehouse-quality', icon: ClipboardCheck, label: '仓库质检' },
|
||||
{ href: '/ai-render-workbench', icon: Sparkles, label: 'AI 出图工作台' },
|
||||
]
|
||||
|
||||
function matchesWorkbenchHref(pathname: string, href: string) {
|
||||
|
||||
@ -8,6 +8,7 @@ import {
|
||||
Home,
|
||||
Layers,
|
||||
PackageOpen,
|
||||
Sparkles,
|
||||
Warehouse,
|
||||
} from 'lucide-react'
|
||||
|
||||
@ -19,6 +20,7 @@ const MOBILE_TAB_ITEMS = [
|
||||
{ href: '/intake', icon: ArrowDownToLine, label: '产品资料' },
|
||||
{ href: '/warehouse', icon: Warehouse, label: '仓库管理' },
|
||||
{ href: '/warehouse-quality', icon: ClipboardCheck, label: '仓库质检' },
|
||||
{ href: '/ai-render-workbench', icon: Sparkles, label: 'AI 出图工作台' },
|
||||
] as const
|
||||
|
||||
function matchesHref(pathname: string, href: string): boolean {
|
||||
|
||||
@ -38,6 +38,7 @@ describe('AppShell', () => {
|
||||
expect(shouldUseAppShell('/intake')).toBe(true)
|
||||
expect(shouldUseAppShell('/warehouse')).toBe(true)
|
||||
expect(shouldUseAppShell('/warehouse-quality')).toBe(true)
|
||||
expect(shouldUseAppShell('/ai-render-workbench')).toBe(true)
|
||||
expect(shouldUseAppShell('/trash')).toBe(true)
|
||||
|
||||
expect(shouldUseAppShell('/products')).toBe(false)
|
||||
@ -120,7 +121,7 @@ describe('AppShell', () => {
|
||||
expect(screen.getByText('assisted assembly content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('产品资料、仓库管理和仓库质检使用顶部工作台导航', () => {
|
||||
it('产品资料、仓库管理、仓库质检和 AI 出图工作台使用顶部工作台导航', () => {
|
||||
usePathnameMock.mockReturnValue('/intake')
|
||||
|
||||
const { rerender } = render(
|
||||
@ -154,5 +155,16 @@ describe('AppShell', () => {
|
||||
expect(screen.getByTestId('app-top-nav')).toHaveAttribute('data-variant', 'workbench')
|
||||
expect(screen.queryByTestId('app-side-nav')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('warehouse quality content')).toBeInTheDocument()
|
||||
|
||||
usePathnameMock.mockReturnValue('/ai-render-workbench')
|
||||
rerender(
|
||||
<AppShell>
|
||||
<div>ai render workbench content</div>
|
||||
</AppShell>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('app-top-nav')).toHaveAttribute('data-variant', 'workbench')
|
||||
expect(screen.queryByTestId('app-side-nav')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('ai render workbench content')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -45,19 +45,26 @@ describe('AppTopNav', () => {
|
||||
expect(brandLink).not.toHaveTextContent('资产中心')
|
||||
})
|
||||
|
||||
it('在仓库管理右侧显示仓库质检入口', () => {
|
||||
usePathnameMock.mockReturnValue('/warehouse-quality')
|
||||
it('在仓库质检右侧显示 AI 出图工作台入口', () => {
|
||||
usePathnameMock.mockReturnValue('/ai-render-workbench')
|
||||
|
||||
render(<AppTopNav variant="workbench" />)
|
||||
|
||||
const warehouseLink = screen.getByRole('link', { name: '仓库管理' })
|
||||
const qualityLink = screen.getByRole('link', { name: '仓库质检' })
|
||||
const aiRenderLink = screen.getByRole('link', { name: 'AI 出图工作台' })
|
||||
|
||||
expect(qualityLink).toHaveAttribute('href', '/warehouse-quality')
|
||||
expect(aiRenderLink).toHaveAttribute('href', '/ai-render-workbench')
|
||||
expect(
|
||||
warehouseLink.compareDocumentPosition(qualityLink) &
|
||||
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy()
|
||||
expect(
|
||||
qualityLink.compareDocumentPosition(aiRenderLink) &
|
||||
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy()
|
||||
expect(screen.getAllByRole('link', { name: '仓库质检' })).toHaveLength(1)
|
||||
expect(screen.getAllByRole('link', { name: 'AI 出图工作台' })).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
@ -6,6 +6,7 @@ export type PlatformFlowKey =
|
||||
| 'draft-groups'
|
||||
| 'intake'
|
||||
| 'warehouse'
|
||||
| 'ai-render-workbench'
|
||||
|
||||
export interface PlatformFlowItem {
|
||||
key: PlatformFlowKey
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user