feat(ai-render): add render workbench

This commit is contained in:
WuYongJie 2026-06-15 18:28:59 +08:00
parent c36d77fba8
commit 9dbb61cdd1
14 changed files with 1316 additions and 4 deletions

View File

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

View File

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

View 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 />
}

View File

@ -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: '深色材质款',
},
],
},
]

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

View File

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

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

View File

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

View File

@ -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() {

View File

@ -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) {

View File

@ -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 {

View File

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

View File

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

View File

@ -6,6 +6,7 @@ export type PlatformFlowKey =
| 'draft-groups'
| 'intake'
| 'warehouse'
| 'ai-render-workbench'
export interface PlatformFlowItem {
key: PlatformFlowKey