merge: link intake products with AI render assets

This commit is contained in:
WuYongJie 2026-07-29 17:42:56 +08:00
commit f8fe78e2cb
41 changed files with 2069 additions and 309 deletions

View File

@ -0,0 +1,188 @@
CREATE TYPE "IntakeAiRenderOutputMode" AS ENUM ('WHITE_BACKGROUND', 'EIGHT_VIEW', 'SCENE');
CREATE TYPE "IntakeAiRenderStorageClass" AS ENUM ('TEMPORARY', 'PERMANENT');
CREATE TABLE "IntakeProductModelAsset" (
"id" TEXT NOT NULL,
"productId" TEXT NOT NULL,
"modelKey" TEXT NOT NULL,
"displayName" TEXT NOT NULL DEFAULT '',
"createdBy" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "IntakeProductModelAsset_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "IntakeProductModelVersion" (
"id" TEXT NOT NULL,
"assetId" TEXT NOT NULL,
"versionNo" INTEGER NOT NULL,
"originalFileName" TEXT NOT NULL,
"mimeType" TEXT NOT NULL,
"fileSize" INTEGER NOT NULL,
"checksumSha256" TEXT NOT NULL DEFAULT '',
"sourceStorageKey" TEXT NOT NULL,
"sourceUrl" TEXT NOT NULL,
"previewStorageKey" TEXT,
"previewUrl" TEXT,
"createdBy" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "IntakeProductModelVersion_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "IntakeSkuAiProfile" (
"id" TEXT NOT NULL,
"skuItemId" TEXT NOT NULL,
"currentModelVersionId" TEXT,
"currentMaterialVersionId" TEXT,
"createdBy" TEXT NOT NULL,
"updatedBy" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "IntakeSkuAiProfile_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "IntakeSkuAiMaterialVersion" (
"id" TEXT NOT NULL,
"profileId" TEXT NOT NULL,
"versionNo" INTEGER NOT NULL,
"materialColor" TEXT NOT NULL DEFAULT '#d8c6ad',
"textureStorageKey" TEXT,
"textureUrl" TEXT,
"nodeMaterials" JSONB,
"uvConfig" JSONB,
"createdBy" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "IntakeSkuAiMaterialVersion_pkey" PRIMARY KEY ("id")
);
ALTER TABLE "AssistedAssemblyAiRender"
ADD COLUMN "intakeSkuItemId" TEXT,
ADD COLUMN "modelVersionId" TEXT,
ADD COLUMN "materialVersionId" TEXT,
ADD COLUMN "outputMode" "IntakeAiRenderOutputMode",
ADD COLUMN "batchId" TEXT,
ADD COLUMN "viewName" TEXT,
ADD COLUMN "viewIndex" INTEGER,
ADD COLUMN "storageClass" "IntakeAiRenderStorageClass" NOT NULL DEFAULT 'TEMPORARY';
CREATE UNIQUE INDEX "IntakeProductModelAsset_productId_modelKey_key"
ON "IntakeProductModelAsset"("productId", "modelKey");
CREATE INDEX "IntakeProductModelAsset_productId_createdAt_idx"
ON "IntakeProductModelAsset"("productId", "createdAt");
CREATE INDEX "IntakeProductModelAsset_createdBy_idx"
ON "IntakeProductModelAsset"("createdBy");
CREATE UNIQUE INDEX "IntakeProductModelVersion_assetId_versionNo_key"
ON "IntakeProductModelVersion"("assetId", "versionNo");
CREATE INDEX "IntakeProductModelVersion_assetId_createdAt_idx"
ON "IntakeProductModelVersion"("assetId", "createdAt");
CREATE INDEX "IntakeProductModelVersion_checksumSha256_idx"
ON "IntakeProductModelVersion"("checksumSha256");
CREATE INDEX "IntakeProductModelVersion_createdBy_idx"
ON "IntakeProductModelVersion"("createdBy");
CREATE UNIQUE INDEX "IntakeSkuAiProfile_skuItemId_key"
ON "IntakeSkuAiProfile"("skuItemId");
CREATE UNIQUE INDEX "IntakeSkuAiProfile_currentMaterialVersionId_key"
ON "IntakeSkuAiProfile"("currentMaterialVersionId");
CREATE INDEX "IntakeSkuAiProfile_currentModelVersionId_idx"
ON "IntakeSkuAiProfile"("currentModelVersionId");
CREATE INDEX "IntakeSkuAiProfile_createdBy_idx"
ON "IntakeSkuAiProfile"("createdBy");
CREATE UNIQUE INDEX "IntakeSkuAiMaterialVersion_profileId_versionNo_key"
ON "IntakeSkuAiMaterialVersion"("profileId", "versionNo");
CREATE INDEX "IntakeSkuAiMaterialVersion_profileId_createdAt_idx"
ON "IntakeSkuAiMaterialVersion"("profileId", "createdAt");
CREATE INDEX "IntakeSkuAiMaterialVersion_createdBy_idx"
ON "IntakeSkuAiMaterialVersion"("createdBy");
CREATE INDEX "AssistedAssemblyAiRender_intakeSkuItemId_createdAt_idx"
ON "AssistedAssemblyAiRender"("intakeSkuItemId", "createdAt");
CREATE INDEX "AssistedAssemblyAiRender_intakeSkuItemId_outputMode_createdAt_idx"
ON "AssistedAssemblyAiRender"("intakeSkuItemId", "outputMode", "createdAt");
CREATE INDEX "AssistedAssemblyAiRender_modelVersionId_idx"
ON "AssistedAssemblyAiRender"("modelVersionId");
CREATE INDEX "AssistedAssemblyAiRender_materialVersionId_idx"
ON "AssistedAssemblyAiRender"("materialVersionId");
CREATE INDEX "AssistedAssemblyAiRender_storageClass_createdAt_idx"
ON "AssistedAssemblyAiRender"("storageClass", "createdAt");
ALTER TABLE "IntakeProductModelAsset"
ADD CONSTRAINT "IntakeProductModelAsset_productId_fkey"
FOREIGN KEY ("productId") REFERENCES "IntakeNonIntegratedProduct"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "IntakeProductModelAsset"
ADD CONSTRAINT "IntakeProductModelAsset_createdBy_fkey"
FOREIGN KEY ("createdBy") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "IntakeProductModelVersion"
ADD CONSTRAINT "IntakeProductModelVersion_assetId_fkey"
FOREIGN KEY ("assetId") REFERENCES "IntakeProductModelAsset"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "IntakeProductModelVersion"
ADD CONSTRAINT "IntakeProductModelVersion_createdBy_fkey"
FOREIGN KEY ("createdBy") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "IntakeSkuAiProfile"
ADD CONSTRAINT "IntakeSkuAiProfile_skuItemId_fkey"
FOREIGN KEY ("skuItemId") REFERENCES "IntakeNonIntegratedSkuItem"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "IntakeSkuAiProfile"
ADD CONSTRAINT "IntakeSkuAiProfile_currentModelVersionId_fkey"
FOREIGN KEY ("currentModelVersionId") REFERENCES "IntakeProductModelVersion"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "IntakeSkuAiProfile"
ADD CONSTRAINT "IntakeSkuAiProfile_createdBy_fkey"
FOREIGN KEY ("createdBy") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "IntakeSkuAiMaterialVersion"
ADD CONSTRAINT "IntakeSkuAiMaterialVersion_profileId_fkey"
FOREIGN KEY ("profileId") REFERENCES "IntakeSkuAiProfile"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "IntakeSkuAiMaterialVersion"
ADD CONSTRAINT "IntakeSkuAiMaterialVersion_createdBy_fkey"
FOREIGN KEY ("createdBy") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "IntakeSkuAiProfile"
ADD CONSTRAINT "IntakeSkuAiProfile_currentMaterialVersionId_fkey"
FOREIGN KEY ("currentMaterialVersionId") REFERENCES "IntakeSkuAiMaterialVersion"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "AssistedAssemblyAiRender"
ADD CONSTRAINT "AssistedAssemblyAiRender_intakeSkuItemId_fkey"
FOREIGN KEY ("intakeSkuItemId") REFERENCES "IntakeNonIntegratedSkuItem"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "AssistedAssemblyAiRender"
ADD CONSTRAINT "AssistedAssemblyAiRender_modelVersionId_fkey"
FOREIGN KEY ("modelVersionId") REFERENCES "IntakeProductModelVersion"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "AssistedAssemblyAiRender"
ADD CONSTRAINT "AssistedAssemblyAiRender_materialVersionId_fkey"
FOREIGN KEY ("materialVersionId") REFERENCES "IntakeSkuAiMaterialVersion"("id") ON DELETE SET NULL ON UPDATE CASCADE;
UPDATE "AssistedAssemblyAiRender"
SET
"outputMode" = CASE
WHEN "params"->>'mode' = 'white-background' THEN 'WHITE_BACKGROUND'::"IntakeAiRenderOutputMode"
WHEN "params"->>'mode' = 'eight-view' THEN 'EIGHT_VIEW'::"IntakeAiRenderOutputMode"
WHEN "params"->>'mode' = 'scene' THEN 'SCENE'::"IntakeAiRenderOutputMode"
ELSE NULL
END,
"batchId" = NULLIF("params"->>'batchId', ''),
"viewName" = NULLIF("params"->>'viewName', ''),
"viewIndex" = CASE
WHEN ("params"->>'viewIndex') ~ '^[0-9]+$' THEN ("params"->>'viewIndex')::INTEGER
ELSE NULL
END;
WITH exact_matches AS (
SELECT render."id" AS "renderId", MIN(item."id") AS "skuItemId"
FROM "AssistedAssemblyAiRender" render
JOIN "AiRenderWorkbenchSku" workbench_sku ON workbench_sku."id" = render."workbenchSkuId"
JOIN "AiRenderWorkbenchSpu" workbench_spu ON workbench_spu."id" = workbench_sku."spuId"
JOIN "IntakeNonIntegratedSkuItem" item
ON UPPER(BTRIM(item."barcode")) = UPPER(BTRIM(workbench_sku."name"))
JOIN "IntakeNonIntegratedProduct" product
ON product."id" = item."productId"
AND UPPER(BTRIM(product."spu")) = UPPER(BTRIM(workbench_spu."name"))
WHERE BTRIM(item."barcode") <> ''
GROUP BY render."id"
HAVING COUNT(*) = 1
)
UPDATE "AssistedAssemblyAiRender" render
SET "intakeSkuItemId" = exact_matches."skuItemId"
FROM exact_matches
WHERE render."id" = exact_matches."renderId";

View File

@ -257,6 +257,7 @@ model IntakeNonIntegratedProduct {
pricingRows IntakeNonIntegratedPricingRow[]
productLimitRows IntakeNonIntegratedProductLimitRow[]
profile IntakeNonIntegratedProductProfile?
modelAssets IntakeProductModelAsset[]
@@index([spu])
@@index([spuBase])
@ -394,6 +395,8 @@ model IntakeNonIntegratedSkuItem {
contentLocked Boolean @default(false)
dataQualityStatus IntakeNonIntegratedSkuDataQualityStatus @default(INCOMPLETE)
missingRequiredFields String[] @default([])
aiProfile IntakeSkuAiProfile?
aiRenders AssistedAssemblyAiRender[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@ -408,6 +411,88 @@ model IntakeNonIntegratedSkuItem {
@@index([sourceTable, sourceSkuId])
}
model IntakeProductModelAsset {
id String @id @default(cuid())
productId String
product IntakeNonIntegratedProduct @relation(fields: [productId], references: [id], onDelete: Cascade)
modelKey String
displayName String @default("")
versions IntakeProductModelVersion[]
createdBy String
creator User @relation("IntakeProductModelAssetCreator", fields: [createdBy], references: [id], onDelete: Restrict)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([productId, modelKey])
@@index([productId, createdAt])
@@index([createdBy])
}
model IntakeProductModelVersion {
id String @id @default(cuid())
assetId String
asset IntakeProductModelAsset @relation(fields: [assetId], references: [id], onDelete: Cascade)
versionNo Int
originalFileName String
mimeType String
fileSize Int
checksumSha256 String @default("")
sourceStorageKey String @db.Text
sourceUrl String @db.Text
previewStorageKey String? @db.Text
previewUrl String? @db.Text
createdBy String
creator User @relation("IntakeProductModelVersionCreator", fields: [createdBy], references: [id], onDelete: Restrict)
skuProfiles IntakeSkuAiProfile[]
aiRenders AssistedAssemblyAiRender[]
createdAt DateTime @default(now())
@@unique([assetId, versionNo])
@@index([assetId, createdAt])
@@index([checksumSha256])
@@index([createdBy])
}
model IntakeSkuAiProfile {
id String @id @default(cuid())
skuItemId String @unique
skuItem IntakeNonIntegratedSkuItem @relation(fields: [skuItemId], references: [id], onDelete: Cascade)
currentModelVersionId String?
currentModelVersion IntakeProductModelVersion? @relation(fields: [currentModelVersionId], references: [id], onDelete: SetNull)
currentMaterialVersionId String? @unique
currentMaterialVersion IntakeSkuAiMaterialVersion? @relation("CurrentIntakeSkuAiMaterialVersion", fields: [currentMaterialVersionId], references: [id], onDelete: SetNull)
materialVersions IntakeSkuAiMaterialVersion[] @relation("IntakeSkuAiMaterialVersions")
createdBy String
updatedBy String?
creator User @relation("IntakeSkuAiProfileCreator", fields: [createdBy], references: [id], onDelete: Restrict)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([currentModelVersionId])
@@index([createdBy])
}
model IntakeSkuAiMaterialVersion {
id String @id @default(cuid())
profileId String
profile IntakeSkuAiProfile @relation("IntakeSkuAiMaterialVersions", fields: [profileId], references: [id], onDelete: Cascade)
currentForProfile IntakeSkuAiProfile? @relation("CurrentIntakeSkuAiMaterialVersion")
versionNo Int
materialColor String @default("#d8c6ad")
textureStorageKey String? @db.Text
textureUrl String? @db.Text
nodeMaterials Json?
uvConfig Json?
createdBy String
creator User @relation("IntakeSkuAiMaterialVersionCreator", fields: [createdBy], references: [id], onDelete: Restrict)
aiRenders AssistedAssemblyAiRender[]
createdAt DateTime @default(now())
@@unique([profileId, versionNo])
@@index([profileId, createdAt])
@@index([createdBy])
}
model IntakeNonIntegratedPricingRow {
id String @id
productId String
@ -1366,6 +1451,18 @@ model AssistedAssemblyAiRender {
workbenchSkuId String?
workbenchSku AiRenderWorkbenchSku? @relation(fields: [workbenchSkuId], references: [id], onDelete: SetNull)
intakeSkuItemId String?
intakeSkuItem IntakeNonIntegratedSkuItem? @relation(fields: [intakeSkuItemId], references: [id], onDelete: SetNull)
modelVersionId String?
modelVersion IntakeProductModelVersion? @relation(fields: [modelVersionId], references: [id], onDelete: SetNull)
materialVersionId String?
materialVersion IntakeSkuAiMaterialVersion? @relation(fields: [materialVersionId], references: [id], onDelete: SetNull)
outputMode IntakeAiRenderOutputMode?
batchId String?
viewName String?
viewIndex Int?
storageClass IntakeAiRenderStorageClass @default(TEMPORARY)
prompt String @db.Text
negativePrompt String? @db.Text
params Json
@ -1388,6 +1485,11 @@ model AssistedAssemblyAiRender {
@@index([createdBy, createdAt])
@@index([workbenchSpuId, createdAt])
@@index([workbenchSkuId, createdAt])
@@index([intakeSkuItemId, createdAt])
@@index([intakeSkuItemId, outputMode, createdAt])
@@index([modelVersionId])
@@index([materialVersionId])
@@index([storageClass, createdAt])
@@index([status, createdAt])
}
@ -1788,6 +1890,17 @@ enum AssistedAssemblyAiRenderStatus {
FAILED
}
enum IntakeAiRenderOutputMode {
WHITE_BACKGROUND
EIGHT_VIEW
SCENE
}
enum IntakeAiRenderStorageClass {
TEMPORARY
PERMANENT
}
enum AssetSkuSyncEventStatus {
PENDING
PROCESSING
@ -1879,6 +1992,10 @@ model User {
createdAiRenderWorkbenchSpus AiRenderWorkbenchSpu[] @relation("AiRenderWorkbenchSpuCreator")
createdAiRenderWorkbenchSkus AiRenderWorkbenchSku[] @relation("AiRenderWorkbenchSkuCreator")
assistedAssemblyAiRenders AssistedAssemblyAiRender[] @relation("AssistedAssemblyAiRenderCreator")
createdIntakeProductModelAssets IntakeProductModelAsset[] @relation("IntakeProductModelAssetCreator")
createdIntakeProductModelVersions IntakeProductModelVersion[] @relation("IntakeProductModelVersionCreator")
createdIntakeSkuAiProfiles IntakeSkuAiProfile[] @relation("IntakeSkuAiProfileCreator")
createdIntakeSkuAiMaterialVersions IntakeSkuAiMaterialVersion[] @relation("IntakeSkuAiMaterialVersionCreator")
collaboratedProducts ProductCollaborator[] @relation("ProductCollaboratorUser")
addedProductCollaborators ProductCollaborator[] @relation("ProductCollaboratorAddedBy")
feedbacks Feedback[] @relation("FeedbackSubmitter")

View File

@ -135,7 +135,7 @@ export default function AiRenderWorkbenchClient({
const [materialPartAnnotationsBySkuId, setMaterialPartAnnotationsBySkuId] = useState<
Record<string, AiRenderMaterialPartAnnotation[]>
>(() => storedUiState?.materialPartAnnotationsBySkuId ?? {})
const [materialPartAnnotationInput, setMaterialPartAnnotationInput] = useState('金色金属拉手')
const [materialPartAnnotationInput, setMaterialPartAnnotationInput] = useState('')
const [message, setMessage] = useState<string | null>(null)
const [optimizing, setOptimizing] = useState(false)
const [optimExplanation, setOptimExplanation] = useState('')

View File

@ -1,7 +1,7 @@
'use client'
import { useMemo, useState } from 'react'
import { Box, ChevronRight, Cuboid, Plus, Search, Trash2, X } from 'lucide-react'
import { Box, ChevronRight, Cuboid, Search, X } from 'lucide-react'
import { cn } from '@/lib/utils'
import { SkuModelUploadControl } from './SkuModelUploadControl'
import type { AiRenderSku, AiRenderSpu } from './types'
@ -31,21 +31,15 @@ export function RenderInspectorProductPanel(props: RenderInspectorProductPanelPr
selectedSpuId,
selectedSkuId,
uploadingSkuId,
spu,
loading,
canWriteConfig,
onSelectSpu,
onCreateSpu,
onRenameSpu,
onDeleteSpu,
onSelectSku,
onCreateSku,
onUpdateSkuById,
onDeleteSku,
onUploadSkuModel,
} = props
const [productQuery, setProductQuery] = useState('')
const [collapsedSpuIds, setCollapsedSpuIds] = useState<Set<string>>(() => new Set())
const normalizedProductQuery = productQuery.trim().toLowerCase()
const filteredSpus = useMemo(() => {
@ -74,9 +68,9 @@ export function RenderInspectorProductPanel(props: RenderInspectorProductPanelPr
<input
value={productQuery}
onChange={(event) => setProductQuery(event.target.value)}
placeholder="查询 SPU / SKU"
placeholder="查询 SPU / 条码"
className="h-full min-w-0 flex-1 bg-transparent text-[11px] font-bold text-[#2d3335] outline-none placeholder:text-[#5a6062]/35"
aria-label="查询 SPU 或 SKU"
aria-label="查询 SPU 或条码"
/>
{productQuery ? (
<button
@ -91,56 +85,22 @@ export function RenderInspectorProductPanel(props: RenderInspectorProductPanelPr
</div>
{normalizedProductQuery ? (
<p className="px-1 text-[10px] font-bold text-[#5a6062]/50">
{filteredSpus.length} 个 SPU / {filteredSkuCount} 个 SKU
{filteredSpus.length} 个 SPU / {filteredSkuCount} 个条码
</p>
) : null}
</div>
<div className="grid grid-cols-2 gap-2">
{canWriteConfig ? (
<>
<button
type="button"
onClick={() => {
setProductQuery('')
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={() => {
setProductQuery('')
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 className="col-span-2 rounded-[6px] bg-black/[0.03] px-3 py-2 text-[10px] font-bold text-[#5a6062]/50">
当前账号可查看和生成,但不能修改产品配置
</div>
)}
</div>
<div className="space-y-2">
{filteredSpus.length === 0 ? (
<div className="rounded-[7px] bg-white px-3 py-5 text-center text-[11px] font-bold text-[#5a6062]/45 ring-1 ring-black/[0.04]">
{loading ? '正在加载共享工作台...' : '没有匹配的 SPU / SKU'}
{loading ? '正在加载产品资料...' : '没有匹配的 SPU / 条码'}
</div>
) : (
filteredSpus.map(({ spu: spuItem, skus: visibleSkus }) => {
const isSpuSelected = spuItem.id === selectedSpuId
const showSkuList = isSpuSelected || Boolean(normalizedProductQuery)
const isSpuCollapsed = collapsedSpuIds.has(spuItem.id)
const showSkuList =
Boolean(normalizedProductQuery) || (isSpuSelected && !isSpuCollapsed)
return (
<article
@ -153,12 +113,33 @@ export function RenderInspectorProductPanel(props: RenderInspectorProductPanelPr
<div className="flex items-center gap-2 px-3 py-2">
<button
type="button"
onClick={() => onSelectSpu(spuItem.id)}
onClick={() => {
if (!isSpuSelected) {
setCollapsedSpuIds((current) => {
if (!current.has(spuItem.id)) return current
const next = new Set(current)
next.delete(spuItem.id)
return next
})
onSelectSpu(spuItem.id)
return
}
setCollapsedSpuIds((current) => {
const next = new Set(current)
if (next.has(spuItem.id)) {
next.delete(spuItem.id)
} else {
next.add(spuItem.id)
}
return next
})
}}
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'}`}
aria-label={`${showSkuList ? '折叠' : '展开'} SPU ${spuItem.name || '未命名 SPU'}`}
>
<ChevronRight
className={cn('h-3.5 w-3.5 transition-transform', isSpuSelected && 'rotate-90')}
className={cn('h-3.5 w-3.5 transition-transform', showSkuList && '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">
@ -168,28 +149,16 @@ export function RenderInspectorProductPanel(props: RenderInspectorProductPanelPr
<input
value={spuItem.name}
onFocus={() => onSelectSpu(spuItem.id)}
onChange={(event) => onRenameSpu(spuItem.id, event.target.value)}
readOnly={!canWriteConfig}
placeholder="填写 SPU 名称"
readOnly
className="h-6 w-full bg-transparent text-[12px] font-black text-[#2d3335] outline-none placeholder:text-[#5a6062]/35 read-only:cursor-default"
aria-label="SPU 名称"
/>
<p className="truncate text-[9px] font-bold text-[#5a6062]/45">
{normalizedProductQuery
? `${visibleSkus.length}/${spuItem.skus.length} 个 SKU`
: `${spuItem.skus.length} 个 SKU`}
? `${visibleSkus.length}/${spuItem.skus.length} 个条码`
: `${spuItem.skus.length} 个条码`}
</p>
</div>
{canWriteConfig ? (
<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>
{showSkuList ? (
@ -211,7 +180,7 @@ export function RenderInspectorProductPanel(props: RenderInspectorProductPanelPr
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'}`}
aria-label={`选择条码 ${skuItem.name || '未命名条码'}`}
>
<ChevronRight
className={cn(
@ -230,41 +199,18 @@ export function RenderInspectorProductPanel(props: RenderInspectorProductPanelPr
<input
value={skuItem.name}
onFocus={() => onSelectSku(skuItem.id)}
onChange={(event) =>
onUpdateSkuById(skuItem.id, { name: event.target.value })
}
readOnly={!canWriteConfig}
placeholder="填写 SKU 名称"
readOnly
className="h-6 w-full bg-transparent text-[12px] font-black text-[#2d3335] outline-none placeholder:text-[#5a6062]/35 read-only:cursor-default"
aria-label="SKU 名称"
aria-label="条码"
/>
<p className="truncate text-[9px] font-bold text-[#5a6062]/45">
{skuItem.modelUrl ? '已配置 SKU 模型' : '还没有 SKU 模型'}
</p>
</div>
{canWriteConfig ? (
<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 && canWriteConfig ? (
<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"
/>
<div className="mt-2 flex justify-end pl-8">
<SkuModelUploadControl
skuId={skuItem.id}
uploading={uploadingSkuId === skuItem.id}

View File

@ -241,7 +241,7 @@ async function createEightViewRender(params: {
batchId,
viewName: preset.label,
viewIndex,
workbenchSkuId: taskSku.id,
intakeSkuItemId: taskSku.id,
referenceImageDataUrl,
structureReferenceImageDataUrls: structureReferences.map((item) => item.referenceImageDataUrl),
workspaceSnapshot: buildAiRenderWorkspaceSnapshot({

View File

@ -143,7 +143,7 @@ export async function runSceneGeneration(params: SceneGenerationParams): Promise
aspectRatio: taskAspectRatio,
imageSize: taskImageSize,
mode: 'scene',
workbenchSkuId: taskSku.id,
intakeSkuItemId: taskSku.id,
referenceImageDataUrl,
workspaceSnapshot: buildAiRenderWorkspaceSnapshot({
spu: taskSpu,

View File

@ -5,6 +5,9 @@ import type { AssetVariantUvProjectionAxis } from '@/lib/asset-variant-render-co
export interface AiRenderSku {
id: string
productId?: string
skuItemId?: string
barcode?: string
name: string
modelUrl: string
thumbnailUrl?: string | null
@ -12,6 +15,10 @@ export interface AiRenderSku {
materialColor: string
materialTextureUrl?: string
nodeMaterials?: Record<string, AiRenderNodeMaterial>
uvConfig?: Record<string, unknown>
modelVersionId?: string | null
modelFileName?: string | null
materialVersionId?: string | null
}
export interface AiRenderNodeMaterial {

View File

@ -2,20 +2,12 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import {
createAiRenderWorkbenchSku,
createAiRenderWorkbenchSpu,
deleteAiRenderWorkbenchSku,
deleteAiRenderWorkbenchSpu,
fetchAiRenderWorkbench,
importAiRenderWorkbenchLocalDraft,
updateAiRenderWorkbenchSku,
updateAiRenderWorkbenchSpu,
saveIntakeAiRenderMaterial,
uploadIntakeAiRenderModel,
} from '@/lib/open-product/platform-api'
import { extractPreviewModelFromZip } from '@/lib/model-preview-zip'
import type { AiRenderWorkbenchAccess } from '@/lib/ai-render-workbench-permissions'
import {
AI_RENDER_WORKBENCH_STORAGE_KEY,
isLegacySampleOnly,
normalizeSpu,
readStoredWorkbenchState,
readStoredWorkbenchUiState,
@ -34,48 +26,6 @@ interface UseAiRenderWorkbenchCatalogControllerParams {
type WorkbenchResponse = Awaited<ReturnType<typeof fetchAiRenderWorkbench>>
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 function useAiRenderWorkbenchCatalogController(
params: UseAiRenderWorkbenchCatalogControllerParams,
) {
@ -131,30 +81,16 @@ export function useAiRenderWorkbenchCatalogController(
void (async () => {
setWorkbenchLoading(true)
try {
let workbench = await fetchAiRenderWorkbench()
if (
workbench.access.canWriteConfig &&
workbench.spus.length === 0 &&
legacyStoredWorkbenchState &&
legacyStoredWorkbenchState.spus.length > 0 &&
!isLegacySampleOnly(legacyStoredWorkbenchState.spus)
) {
workbench = await importAiRenderWorkbenchLocalDraft({
spus: legacyStoredWorkbenchState.spus,
records: legacyStoredWorkbenchState.records,
})
if (typeof window !== 'undefined') {
window.localStorage.removeItem(AI_RENDER_WORKBENCH_STORAGE_KEY)
}
if (active) {
setMessage('已把本机旧草稿导入服务器共享工作台。')
}
}
const workbench = await fetchAiRenderWorkbench()
const urlParams =
typeof window === 'undefined' ? null : new URLSearchParams(window.location.search)
const urlProductId = urlParams?.get('productId')?.trim() || null
const urlSkuId = urlParams?.get('skuId')?.trim() || null
if (!active) return
applyWorkbenchData(workbench, {
selectedSpuId,
selectedSkuId,
selectedSpuId: urlProductId ?? selectedSpuId,
selectedSkuId: urlSkuId ?? selectedSkuId,
})
} catch (error) {
if (active) {
@ -174,17 +110,8 @@ export function useAiRenderWorkbenchCatalogController(
}, [])
const updateSpu = useCallback(
(spuId: string, patch: Partial<AiRenderSpu>) => {
if (!canWriteConfig) return
setSpus((current) => current.map((spu) => (spu.id === spuId ? { ...spu, ...patch } : spu)))
void updateAiRenderWorkbenchSpu(spuId, {
name: patch.name,
description: patch.description,
}).catch((error) => {
setMessage(error instanceof Error ? error.message : 'SPU 更新失败')
})
},
[canWriteConfig, setMessage],
(_spuId: string, _patch: Partial<AiRenderSpu>) => undefined,
[],
)
const updateSku = useCallback(
@ -196,46 +123,32 @@ export function useAiRenderWorkbenchCatalogController(
skus: spu.skus.map((sku) => (sku.id === skuId ? { ...sku, ...patch } : sku)),
})),
)
void updateAiRenderWorkbenchSku(skuId, patch).catch((error) => {
setMessage(error instanceof Error ? error.message : 'SKU 更新失败')
const sku = spus.flatMap((spu) => spu.skus).find((item) => item.id === skuId)
if (!sku?.productId) return
void saveIntakeAiRenderMaterial({
productId: sku.productId,
skuDatabaseId: sku.id,
materialColor: patch.materialColor ?? sku.materialColor,
materialTextureUrl: patch.materialTextureUrl ?? sku.materialTextureUrl,
nodeMaterials: patch.nodeMaterials ?? sku.nodeMaterials,
uvConfig: patch.uvConfig ?? sku.uvConfig,
}).catch((error) => {
setMessage(error instanceof Error ? error.message : '条码材质保存失败')
})
},
[canWriteConfig, setMessage],
[canWriteConfig, setMessage, spus],
)
const createSpu = useCallback(async () => {
if (!canWriteConfig) return false
setMessage(null)
try {
const workbench = await createAiRenderWorkbenchSpu({
name: `新 SPU ${spus.length + 1}`,
})
const newSpu = workbench.spus[workbench.spus.length - 1]
applyWorkbenchData(workbench, {
selectedSpuId: newSpu?.id ?? null,
selectedSkuId: newSpu?.skus[0]?.id ?? null,
})
return true
} catch (error) {
setMessage(error instanceof Error ? error.message : 'SPU 创建失败')
return false
}
}, [applyWorkbenchData, canWriteConfig, setMessage, spus.length])
return false
}, [])
const deleteSpu = useCallback(
async (spuId: string) => {
if (!canWriteConfig) return false
try {
const workbench = await deleteAiRenderWorkbenchSpu(spuId)
applyWorkbenchData(workbench)
setMessage('SPU 已删除')
return true
} catch (error) {
setMessage(error instanceof Error ? error.message : 'SPU 删除失败')
return false
}
void spuId
return false
},
[applyWorkbenchData, canWriteConfig, setMessage],
[],
)
const selectSpu = useCallback(
@ -248,46 +161,15 @@ export function useAiRenderWorkbenchCatalogController(
)
const createSku = useCallback(async () => {
if (!canWriteConfig || !selectedSpu) return false
try {
const workbench = await createAiRenderWorkbenchSku({
spuId: selectedSpu.id,
name: `新 SKU ${selectedSpu.skus.length + 1}`,
})
const nextSpu = workbench.spus.find((spu) => spu.id === selectedSpu.id)
const newSku = nextSpu?.skus[nextSpu.skus.length - 1]
applyWorkbenchData(workbench, {
selectedSpuId: selectedSpu.id,
selectedSkuId: newSku?.id ?? null,
})
return true
} catch (error) {
setMessage(error instanceof Error ? error.message : 'SKU 创建失败')
return false
}
}, [applyWorkbenchData, canWriteConfig, selectedSpu, setMessage])
return false
}, [])
const deleteSku = useCallback(
async (skuId: string) => {
if (!canWriteConfig) return false
try {
const parentSpu = spus.find((spuItem) => spuItem.skus.some((skuItem) => skuItem.id === skuId))
const workbench = await deleteAiRenderWorkbenchSku(skuId)
const nextSpu = parentSpu
? workbench.spus.find((spu) => spu.id === parentSpu.id)
: workbench.spus[0]
applyWorkbenchData(workbench, {
selectedSpuId: nextSpu?.id ?? null,
selectedSkuId: nextSpu?.skus[0]?.id ?? null,
})
setMessage('SKU 已删除')
return true
} catch (error) {
setMessage(error instanceof Error ? error.message : 'SKU 删除失败')
return false
}
void skuId
return false
},
[applyWorkbenchData, canWriteConfig, setMessage, spus],
[],
)
const uploadSkuModel = useCallback(
@ -295,19 +177,25 @@ export function useAiRenderWorkbenchCatalogController(
if (!canWriteConfig) return false
const parentSpu = spus.find((spuItem) => spuItem.skus.some((skuItem) => skuItem.id === skuId))
const sku = parentSpu?.skus.find((item) => item.id === skuId)
if (!parentSpu || !sku?.productId || !sku.skuItemId) {
setMessage('当前条码缺少产品归属,不能上传模型')
return false
}
setUploadingSkuId(skuId)
setMessage(null)
try {
const url = await uploadSkuModelFile(file)
const workbench = await updateAiRenderWorkbenchSku(skuId, { modelUrl: url })
const nextParentSpu = workbench.spus.find((spuItem) =>
spuItem.skus.some((skuItem) => skuItem.id === skuId),
)
await uploadIntakeAiRenderModel({
productId: sku.productId,
skuItemId: sku.skuItemId,
file,
})
const workbench = await fetchAiRenderWorkbench()
applyWorkbenchData(workbench, {
selectedSpuId: nextParentSpu?.id ?? parentSpu?.id ?? null,
selectedSpuId: parentSpu.id,
selectedSkuId: skuId,
})
setMessage('SKU 模型已上传,可以在主视窗预览。')
setMessage('共享模型已上传,同型号未单独调整的条码已同步带出。')
return true
} catch (error) {
setMessage(error instanceof Error ? error.message : 'SKU 模型上传失败')

View File

@ -16,12 +16,12 @@ type UseDetailSelectionControllerOptions = {
exitMessage: string
captureCurrentReferenceImage: () => string | null
onCreateDetailReference: (params: {
workbenchSkuId: string
intakeSkuItemId: string
referenceImageDataUrl: string
}) => Promise<AiRenderTemporaryReferenceImage>
onDeleteDetailReference: (params: {
storageKey: string
workbenchSkuId: string | null
intakeSkuItemId: string | null
}) => Promise<unknown>
onCommitDetailReference: (image: AiRenderTemporaryReferenceImage | null) => void
onMessage: (message: string) => void
@ -96,7 +96,7 @@ export function useDetailSelectionController(options: UseDetailSelectionControll
if (selectedSkuDetailReference?.storageKey) {
await onDeleteDetailReference({
storageKey: selectedSkuDetailReference.storageKey,
workbenchSkuId: selectedSku?.id ?? null,
intakeSkuItemId: selectedSku?.id ?? null,
})
}
onCommitDetailReference(null)
@ -221,7 +221,7 @@ export function useDetailSelectionController(options: UseDetailSelectionControll
}
const savedDetailReference = await onCreateDetailReference({
workbenchSkuId: taskSku.id,
intakeSkuItemId: taskSku.id,
referenceImageDataUrl: croppedReferenceImageDataUrl,
})
onCommitDetailReference(savedDetailReference)
@ -233,7 +233,7 @@ export function useDetailSelectionController(options: UseDetailSelectionControll
) {
void onDeleteDetailReference({
storageKey: previousDetailReference.storageKey,
workbenchSkuId: taskSku.id,
intakeSkuItemId: taskSku.id,
}).catch(() => undefined)
}
} catch (error) {

View File

@ -271,7 +271,7 @@ export async function runWhiteBackgroundGeneration(
aspectRatio: taskAspectRatio,
imageSize: taskImageSize,
mode: 'white-background',
workbenchSkuId: taskSku.id,
intakeSkuItemId: taskSku.id,
referenceImageDataUrl: dataUrl,
structureReferenceImageDataUrls:
structureReferenceImageDataUrls.length > 0

View File

@ -151,7 +151,10 @@ export function mapServerRenderToLocalRecords(
const renderParams = toRecord(render.params)
const workspaceSnapshot = toRecord(render.workspaceSnapshot)
const snapshotSku = toRecord(workspaceSnapshot.sku)
const skuId = render.workbenchSkuId ?? (typeof snapshotSku.id === 'string' ? snapshotSku.id : '')
const skuId =
render.intakeSkuItemId ??
render.workbenchSkuId ??
(typeof snapshotSku.id === 'string' ? snapshotSku.id : '')
const skuName =
typeof snapshotSku.name === 'string' && snapshotSku.name.trim()
? snapshotSku.name

View File

@ -272,6 +272,9 @@ export function normalizeSku(value: unknown, index: number): AiRenderSku {
const nodeMaterials = normalizeNodeMaterials(sku.nodeMaterials)
return {
id: typeof sku.id === 'string' && sku.id.trim() ? sku.id : createId('sku'),
productId: typeof sku.productId === 'string' ? sku.productId : undefined,
skuItemId: typeof sku.skuItemId === 'string' ? sku.skuItemId : undefined,
barcode: typeof sku.barcode === 'string' ? sku.barcode : undefined,
name: typeof sku.name === 'string' ? sku.name : getDefaultSkuName(index + 1),
modelUrl: typeof sku.modelUrl === 'string' ? sku.modelUrl : '',
thumbnailUrl: typeof sku.thumbnailUrl === 'string' ? sku.thumbnailUrl : null,
@ -283,6 +286,11 @@ export function normalizeSku(value: unknown, index: number): AiRenderSku {
materialTextureUrl:
typeof sku.materialTextureUrl === 'string' ? sku.materialTextureUrl : undefined,
nodeMaterials,
uvConfig: isRecord(sku.uvConfig) ? sku.uvConfig : undefined,
modelVersionId: typeof sku.modelVersionId === 'string' ? sku.modelVersionId : null,
modelFileName: typeof sku.modelFileName === 'string' ? sku.modelFileName : null,
materialVersionId:
typeof sku.materialVersionId === 'string' ? sku.materialVersionId : null,
}
}

View File

@ -5,13 +5,17 @@ import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
import { assistedAssemblyAiRenderService } from '@/server/services'
const createDetailReferenceSchema = z.object({
workbenchSkuId: z.string().trim().min(1, '缺少 SKU ID'),
workbenchSkuId: z.string().trim().min(1).optional().nullable(),
intakeSkuItemId: z.string().trim().min(1).optional().nullable(),
referenceImageDataUrl: z.string().min(1, '缺少特写参考图'),
}).refine((value) => Boolean(value.workbenchSkuId || value.intakeSkuItemId), {
message: '缺少条码 ID',
})
const deleteDetailReferenceSchema = z.object({
storageKey: z.string().trim().min(1, '缺少特写参考图存储键'),
workbenchSkuId: z.string().trim().optional().nullable(),
intakeSkuItemId: z.string().trim().optional().nullable(),
})
export async function POST(request: NextRequest) {
@ -30,7 +34,21 @@ export async function POST(request: NextRequest) {
}
try {
const image = await assistedAssemblyAiRenderService.createWorkbenchDetailReference(parsed.data, ctx.user)
const image = parsed.data.intakeSkuItemId
? await assistedAssemblyAiRenderService.createIntakeDetailReference(
{
intakeSkuItemId: parsed.data.intakeSkuItemId,
referenceImageDataUrl: parsed.data.referenceImageDataUrl,
},
ctx.user,
)
: await assistedAssemblyAiRenderService.createWorkbenchDetailReference(
{
workbenchSkuId: parsed.data.workbenchSkuId!,
referenceImageDataUrl: parsed.data.referenceImageDataUrl,
},
ctx.user,
)
return jsonSuccess({ image }, 201, ctx.requestId)
} catch (error) {
if (error instanceof AppError) {

View File

@ -2,14 +2,17 @@ import { NextRequest } from 'next/server'
import { AppError } from '@/server/core/errors'
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
import { aiRenderWorkbenchService } from '@/server/services'
import { intakeAiAssetService } from '@/server/services/intake-ai-asset.service'
export async function GET(request: NextRequest) {
return handleRequest(
request,
async (ctx) => {
try {
const workbench = await aiRenderWorkbenchService.list(ctx.user)
return jsonSuccess(workbench, 200, ctx.requestId)
const query = request.nextUrl.searchParams.get('query')?.trim() ?? ''
const access = await aiRenderWorkbenchService.getAccess(ctx.user)
const spus = await intakeAiAssetService.listCatalog(query, ctx.user)
return jsonSuccess({ access, spus }, 200, ctx.requestId)
} catch (error) {
if (error instanceof AppError) {
return jsonError(error.code, error.message, error.statusCode, ctx.requestId)

View File

@ -31,6 +31,7 @@ const createAiRenderSchema = z.object({
viewName: z.string().trim().max(32).optional().nullable(),
viewIndex: z.number().int().min(0).max(31).optional().nullable(),
workbenchSkuId: z.string().trim().max(160).optional().nullable(),
intakeSkuItemId: z.string().trim().max(160).optional().nullable(),
referenceImageDataUrl: z.string().min(1, '缺少当前 3D 预览截图'),
structureReferenceImageDataUrls: z
.array(z.string().min(1))
@ -64,6 +65,15 @@ export async function GET(request: NextRequest) {
request,
async (ctx) => {
const workbenchSkuId = request.nextUrl.searchParams.get('workbenchSkuId')?.trim()
const intakeSkuItemId = request.nextUrl.searchParams.get('intakeSkuItemId')?.trim()
if (intakeSkuItemId) {
const renders = await assistedAssemblyAiRenderService.listForIntakeSku(
intakeSkuItemId,
ctx.user,
{ limit: readLimit(request) },
)
return jsonSuccess({ renders }, 200, ctx.requestId)
}
if (workbenchSkuId) {
const renders = await assistedAssemblyAiRenderService.listForWorkbenchSku(
workbenchSkuId,
@ -97,7 +107,7 @@ export async function POST(request: NextRequest) {
)
}
if (!parsed.data.workbenchSkuId && !canOperateDesignWorkspace(ctx.user)) {
if (!parsed.data.workbenchSkuId && !parsed.data.intakeSkuItemId && !canOperateDesignWorkspace(ctx.user)) {
return jsonError(
ErrorCode.AUTH_PERMISSION_DENIED,
'当前账号没有辅助装配 AI 生图权限',

View File

@ -0,0 +1,48 @@
import { NextRequest } from 'next/server'
import { z } from 'zod'
import { AppError, ErrorCode } from '@/server/core/errors'
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
import { intakeAiAssetService } from '@/server/services/intake-ai-asset.service'
const materialSchema = z.object({
skuDatabaseId: z.string().trim().min(1).max(180),
materialColor: z.string().trim().max(32).optional().nullable(),
materialTextureUrl: z.string().trim().max(4000).optional().nullable(),
nodeMaterials: z.unknown().optional(),
uvConfig: z.unknown().optional(),
})
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
return handleRequest(
request,
async (ctx) => {
const { id } = await params
const parsed = materialSchema.safeParse(await request.json().catch(() => null))
if (!parsed.success) {
return jsonError(
ErrorCode.VALIDATION_FAILED,
parsed.error.issues[0]?.message ?? '材质参数不正确',
400,
ctx.requestId,
)
}
try {
const sku = await intakeAiAssetService.saveMaterial({
...parsed.data,
productId: id,
user: ctx.user,
})
return jsonSuccess({ sku }, 201, ctx.requestId)
} catch (error) {
if (error instanceof AppError) {
return jsonError(error.code, error.message, error.statusCode, ctx.requestId)
}
throw error
}
},
{ requireAuth: true },
)
}

View File

@ -0,0 +1,50 @@
import { NextRequest } from 'next/server'
import { AppError, ErrorCode } from '@/server/core/errors'
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
import { intakeAiAssetService } from '@/server/services/intake-ai-asset.service'
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
return handleRequest(
request,
async (ctx) => {
try {
const { id } = await params
const formData = await request.formData()
const file = formData.get('file')
const skuItemId = formData.get('skuItemId')
if (!(file instanceof File)) {
return jsonError(
ErrorCode.VALIDATION_REQUIRED_FIELD,
'请选择模型文件',
400,
ctx.requestId,
)
}
if (typeof skuItemId !== 'string' || !skuItemId.trim()) {
return jsonError(
ErrorCode.VALIDATION_REQUIRED_FIELD,
'缺少条码记录',
400,
ctx.requestId,
)
}
const sku = await intakeAiAssetService.uploadModel({
productId: id,
skuItemId: skuItemId.trim(),
file,
user: ctx.user,
})
return jsonSuccess({ sku }, 201, ctx.requestId)
} catch (error) {
if (error instanceof AppError) {
return jsonError(error.code, error.message, error.statusCode, ctx.requestId)
}
throw error
}
},
{ requireAuth: true },
)
}

View File

@ -0,0 +1,26 @@
import { NextRequest } from 'next/server'
import { AppError } from '@/server/core/errors'
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
import { intakeAiAssetService } from '@/server/services/intake-ai-asset.service'
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
return handleRequest(
request,
async (ctx) => {
try {
const { id } = await params
const product = await intakeAiAssetService.getProductWorkspace(id, ctx.user)
return jsonSuccess({ product }, 200, ctx.requestId)
} catch (error) {
if (error instanceof AppError) {
return jsonError(error.code, error.message, error.statusCode, ctx.requestId)
}
throw error
}
},
{ requireAuth: true },
)
}

View File

@ -54,6 +54,18 @@ export function buildNonIntegratedDocumentWorkspaceRendererState({
workspaceNavigation,
}: DocumentWorkspaceRendererStateProps) {
return buildNonIntegratedDocumentWorkspaceRenderers({
aiRender: {
productId: productId ?? null,
selectedBarcode: documentState.selectedProductPhotoBarcode,
isReadOnly: editability.isReadOnly,
onNotify: (message, type) => {
if (type === "error") {
detailToast.error(message);
return;
}
detailToast.success(message);
},
},
aiWhiteBackground: {
productId: productId ?? null,
isActive:

View File

@ -1,6 +1,7 @@
import type { ComponentProps } from "react";
import { NonIntegratedAiWhiteBackgroundWorkspace } from "../workspaces/ai-white-background/NonIntegratedAiWhiteBackgroundWorkspace";
import { NonIntegratedAiRenderWorkspace } from "../workspaces/ai-render/NonIntegratedAiRenderWorkspace";
import { buildDocumentWorkspaceRenderers } from "../workspaces/documents/documentWorkspaceRenderers";
type DocumentWorkspaceRendererOptions = Parameters<
@ -16,14 +17,16 @@ type NonIntegratedDocumentWorkspaceRendererOptions = Omit<
aiWhiteBackground: ComponentProps<
typeof NonIntegratedAiWhiteBackgroundWorkspace
>;
aiRender: ComponentProps<typeof NonIntegratedAiRenderWorkspace>;
productPhoto: Omit<
ProductPhotoRendererOptions,
"renderAiWhiteBackgroundContent"
"renderAiWhiteBackgroundContent" | "renderAiRenderContent"
>;
};
export function buildNonIntegratedDocumentWorkspaceRenderers({
aiWhiteBackground,
aiRender,
productPhoto,
...documentWorkspaceOptions
}: NonIntegratedDocumentWorkspaceRendererOptions) {
@ -34,6 +37,9 @@ export function buildNonIntegratedDocumentWorkspaceRenderers({
renderAiWhiteBackgroundContent: () => (
<NonIntegratedAiWhiteBackgroundWorkspace {...aiWhiteBackground} />
),
renderAiRenderContent: () => (
<NonIntegratedAiRenderWorkspace {...aiRender} />
),
},
});
}

View File

@ -0,0 +1,57 @@
import { AI_RENDER_EIGHT_VIEW_PRESETS } from '@/app/ai-render-workbench/types'
import type { IntakeAiRenderGalleryImage } from './IntakeAiRenderGallery'
function findSlotImage(images: IntakeAiRenderGalleryImage[], slotIndex: number, label: string) {
const matchedImage = images.find(
(image) => image.viewIndex === slotIndex || image.label === label,
)
if (matchedImage) return matchedImage
const hasViewMetadata = images.some(
(image) => image.viewIndex !== null || image.hasExplicitViewName,
)
return hasViewMetadata ? undefined : images[slotIndex]
}
export function IntakeAiEightViewGrid(props: { images: IntakeAiRenderGalleryImage[] }) {
return (
<div
data-testid="intake-ai-render-eight-view-grid"
className="grid flex-none grid-cols-2 gap-2 xl:min-h-[220px] xl:flex-1 xl:auto-rows-auto xl:grid-cols-3 xl:grid-rows-3"
>
{AI_RENDER_EIGHT_VIEW_PRESETS.map((preset, index) => {
const image = findSlotImage(props.images, index, preset.label)
return (
<div
key={preset.label}
data-testid={`intake-ai-render-eight-view-slot-${index}`}
className="grid grid-rows-[auto_auto_auto] rounded-apple-lg border border-apple-separator bg-apple-bg-secondary/20 p-1.5 text-left xl:h-full xl:min-h-0"
>
<p className="mb-0.5 text-[10px] font-apple-semibold leading-4 text-apple-label-primary sm:mb-1 sm:text-[11px]">
{preset.label}
</p>
{image ? (
<a
href={image.url}
target="_blank"
rel="noreferrer"
className="flex aspect-square min-h-0 items-center justify-center overflow-hidden rounded-apple-lg border border-apple-separator bg-white"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={image.url} alt={preset.label} className="h-full w-full object-cover" />
</a>
) : (
<div className="flex aspect-square min-h-0 items-center justify-center overflow-hidden rounded-apple-lg border border-apple-separator bg-white">
<span className="px-2 text-xs text-apple-label-secondary">暂无图片</span>
</div>
)}
<p className="mt-0.5 truncate text-[9px] leading-4 text-apple-label-secondary sm:mt-1 sm:text-[10px]">
{image ? '点击查看大图' : '暂无图片'}
</p>
</div>
)
})}
</div>
)
}

View File

@ -0,0 +1,91 @@
import { ImageIcon } from 'lucide-react'
import type {
IntakeAiRenderCategory,
IntakeAiRenderRecord,
} from './intakeAiRenderWorkspaceTypes'
import { getIntakeAiRenderCategory } from './intakeAiRenderWorkspaceTypes'
import { IntakeAiEightViewGrid } from './IntakeAiEightViewGrid'
export const INTAKE_AI_RENDER_CATEGORY_LABELS: Record<IntakeAiRenderCategory, string> = {
'white-background': '白底图',
'eight-view': '八视图',
scene: '效果图',
}
export interface IntakeAiRenderGalleryImage {
key: string
url: string
label: string
viewIndex: number | null
hasExplicitViewName: boolean
}
export function getIntakeAiRenderGalleryImages(
records: IntakeAiRenderRecord[],
category: IntakeAiRenderCategory,
) {
return records
.filter((record) => getIntakeAiRenderCategory(record) === category)
.flatMap((record) =>
record.resultImages.map((image, index) => ({
key: `${record.id}-${index}`,
url: image.thumbnailUrl || image.originalUrl,
label:
record.viewName?.trim() ||
(category === 'scene'
? `效果图 ${index + 1}`
: INTAKE_AI_RENDER_CATEGORY_LABELS[category]),
viewIndex: record.viewIndex,
hasExplicitViewName: Boolean(record.viewName?.trim()),
})),
)
}
export function IntakeAiRenderGallery(props: {
category: IntakeAiRenderCategory
records: IntakeAiRenderRecord[]
}) {
const categoryLabel = INTAKE_AI_RENDER_CATEGORY_LABELS[props.category]
const images = getIntakeAiRenderGalleryImages(props.records, props.category)
return (
<div className="flex min-h-0 flex-1 flex-col overflow-hidden p-2.5">
{props.category === 'eight-view' ? (
<IntakeAiEightViewGrid images={images} />
) : images.length > 0 ? (
<div
data-testid={`intake-ai-render-gallery-${props.category}`}
className="grid min-h-0 flex-1 grid-cols-2 gap-2 overflow-y-auto pr-1 xl:grid-cols-3"
>
{images.map((image) => (
<a
key={image.key}
href={image.url}
target="_blank"
rel="noreferrer"
className="grid content-start rounded-apple-lg border border-apple-separator bg-apple-bg-secondary/20 p-1.5 text-left transition hover:bg-apple-bg-secondary/40"
>
<p className="mb-1 truncate text-[11px] font-apple-semibold leading-4 text-apple-label-primary">
{image.label}
</p>
<div className="aspect-square overflow-hidden rounded-apple-lg border border-apple-separator bg-white">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={image.url} alt={image.label} className="h-full w-full object-cover" />
</div>
<p className="mt-1 truncate text-[10px] leading-4 text-apple-label-secondary">
点击查看大图
</p>
</a>
))}
</div>
) : (
<div className="flex min-h-[220px] flex-1 flex-col items-center justify-center rounded-apple-xl border border-dashed border-apple-separator px-5 text-center">
<ImageIcon className="h-7 w-7 text-apple-label-secondary/35" />
<p className="mt-3 text-sm font-apple-medium text-apple-label-primary">
当前还没有{categoryLabel}
</p>
</div>
)}
</div>
)
}

View File

@ -0,0 +1,67 @@
'use client'
import { useState } from 'react'
import {
getIntakeAiRenderGalleryImages,
INTAKE_AI_RENDER_CATEGORY_LABELS,
IntakeAiRenderGallery,
} from './IntakeAiRenderGallery'
import type {
IntakeAiRenderCategory,
IntakeAiRenderRecord,
} from './intakeAiRenderWorkspaceTypes'
const CATEGORY_ORDER: IntakeAiRenderCategory[] = [
'white-background',
'eight-view',
'scene',
]
export function IntakeAiRenderResultPanel(props: { records: IntakeAiRenderRecord[] }) {
const [activeCategory, setActiveCategory] =
useState<IntakeAiRenderCategory>('white-background')
return (
<section className="flex min-h-[360px] min-w-0 flex-col overflow-hidden rounded-apple-xl border border-apple-separator bg-white">
<div className="border-b border-apple-separator px-2 py-1.5">
<div className="grid grid-cols-3 gap-1">
{CATEGORY_ORDER.map((category) => {
const count = getIntakeAiRenderGalleryImages(props.records, category).length
const isActive = category === activeCategory
return (
<button
key={category}
type="button"
data-testid={`intake-ai-render-category-${category}`}
onClick={() => setActiveCategory(category)}
className={`min-h-[34px] rounded-apple-lg border px-2 py-1 text-left transition ${
isActive
? 'border-apple-label-primary bg-apple-label-primary text-white'
: 'border-apple-separator bg-apple-bg-secondary/20 text-apple-label-primary hover:bg-apple-bg-secondary/40'
}`}
>
<div className="flex h-full min-w-0 flex-col justify-center gap-0.5">
<span className="truncate text-[11px] font-apple-medium leading-4">
{INTAKE_AI_RENDER_CATEGORY_LABELS[category]}
</span>
<span
className={`w-fit rounded-full px-1.5 py-0 text-[10px] leading-3 ${
isActive
? 'bg-white/20 text-white'
: 'bg-apple-bg-primary text-apple-label-secondary'
}`}
>
{count} 张
</span>
</div>
</button>
)
})}
</div>
</div>
<IntakeAiRenderGallery category={activeCategory} records={props.records} />
</section>
)
}

View File

@ -0,0 +1,93 @@
'use client'
import { useRef } from 'react'
import { ArrowUpRight, Upload } from 'lucide-react'
import { ModelPreviewPanel } from '@/components/platform/ModelPreviewPanel'
import { IntakeAiRenderResultPanel } from './IntakeAiRenderResultPanel'
import { useIntakeAiRenderWorkspace } from './useIntakeAiRenderWorkspace'
export function NonIntegratedAiRenderWorkspace(props: {
productId: string | null
selectedBarcode: string
isReadOnly: boolean
onNotify: (message: string, type: 'success' | 'error') => void
}) {
const inputRef = useRef<HTMLInputElement | null>(null)
const state = useIntakeAiRenderWorkspace({
productId: props.productId,
selectedBarcode: props.selectedBarcode,
onNotify: props.onNotify,
})
const openWorkbench = () => {
if (!props.productId || !state.selectedSku) return
const returnTo = `${window.location.pathname}${window.location.search}`
const params = new URLSearchParams({
productId: props.productId,
skuId: state.selectedSku.id,
returnTo,
})
window.location.href = `/ai-render-workbench?${params.toString()}`
}
return (
<div className="grid min-h-0 flex-1 gap-2 xl:grid-cols-[minmax(540px,580px)_minmax(0,1fr)]">
<IntakeAiRenderResultPanel records={state.records} />
<section className="flex min-h-[360px] flex-col overflow-hidden rounded-[7px] border border-[#adb3b5]/16 bg-white">
<div className="flex min-h-12 items-center gap-2 border-b border-[#adb3b5]/12 px-3 py-2">
<div className="min-w-0 flex-1">
<p className="truncate text-[12px] font-black text-[#2d3335]">
{state.selectedSku?.barcode || state.selectedSku?.name || '未选择条码'}
</p>
<p className="truncate text-[10px] font-semibold text-[#5a6062]/55">
{state.selectedSku?.modelFileName || (state.loading ? '加载中' : '3D 模型')}
</p>
</div>
{!props.isReadOnly ? (
<>
<input
ref={inputRef}
type="file"
accept=".zip,.glb,.gltf,.obj,application/zip,model/gltf-binary,model/gltf+json,model/obj"
className="hidden"
onChange={(event) => {
const file = event.target.files?.[0]
if (file) void state.uploadModel(file)
event.currentTarget.value = ''
}}
/>
<button
type="button"
onClick={() => inputRef.current?.click()}
disabled={!state.selectedSku || state.uploading}
className="flex h-8 items-center gap-1.5 rounded-[5px] border border-[#adb3b5]/18 bg-white px-2.5 text-[11px] font-black text-[#2d3335] disabled:opacity-40"
>
<Upload className="h-3.5 w-3.5" />
{state.selectedSku?.modelUrl ? '替换模型' : '上传模型'}
</button>
</>
) : null}
<button
type="button"
onClick={openWorkbench}
disabled={!state.selectedSku?.modelUrl}
className="flex h-8 items-center gap-1.5 rounded-[5px] bg-[#2d3335] px-3 text-[11px] font-black text-white disabled:opacity-35"
>
AI 出图
<ArrowUpRight className="h-3.5 w-3.5" />
</button>
</div>
<ModelPreviewPanel
modelUrl={state.selectedSku?.modelUrl || null}
title="模型预览"
className="min-h-0 flex-1"
showFpsBadge={false}
showCameraActionButtons
emptyTitle="暂无模型"
emptyDescription=""
/>
</section>
</div>
)
}

View File

@ -0,0 +1,47 @@
import { getIntakeAiRenderCategory } from './intakeAiRenderWorkspaceTypes'
function createRecord(overrides: Record<string, unknown>) {
return {
id: 'render-1',
workbenchSpuId: null,
workbenchSkuId: null,
intakeSkuItemId: 'sku-1',
outputMode: null,
storageClass: 'PERMANENT',
prompt: '',
negativePrompt: null,
params: {},
referenceImageUrl: null,
resultImages: [],
workspaceSnapshot: {},
assetSnapshot: {},
externalRequestId: null,
attempts: null,
status: 'SUCCEEDED',
errorMessage: null,
durationMs: 10,
restoreUrl: '',
creatorName: null,
createdAt: '2026-07-29T00:00:00.000Z',
updatedAt: '2026-07-29T00:00:00.000Z',
...overrides,
} as Parameters<typeof getIntakeAiRenderCategory>[0]
}
describe('getIntakeAiRenderCategory', () => {
it('uses the persisted output mode for permanent records', () => {
expect(
getIntakeAiRenderCategory(createRecord({ outputMode: 'WHITE_BACKGROUND' })),
).toBe('white-background')
expect(getIntakeAiRenderCategory(createRecord({ outputMode: 'EIGHT_VIEW' }))).toBe(
'eight-view',
)
expect(getIntakeAiRenderCategory(createRecord({ outputMode: 'SCENE' }))).toBe('scene')
})
it('falls back to params for migrated records', () => {
expect(
getIntakeAiRenderCategory(createRecord({ params: { mode: 'eight-view' } })),
).toBe('eight-view')
})
})

View File

@ -0,0 +1,27 @@
import type {
AiRenderWorkbenchSkuDTO,
AiRenderWorkbenchSpuDTO,
AssistedAssemblyAiRenderDTO,
} from '@/server/services'
export type IntakeAiRenderProduct = AiRenderWorkbenchSpuDTO
export type IntakeAiRenderSku = AiRenderWorkbenchSkuDTO
export type IntakeAiRenderRecord = AssistedAssemblyAiRenderDTO
export type IntakeAiRenderCategory = 'white-background' | 'eight-view' | 'scene'
export function getIntakeAiRenderCategory(
record: IntakeAiRenderRecord,
): IntakeAiRenderCategory {
if (record.outputMode === 'WHITE_BACKGROUND') return 'white-background'
if (record.outputMode === 'EIGHT_VIEW') return 'eight-view'
if (record.outputMode === 'SCENE') return 'scene'
const params =
record.params && typeof record.params === 'object' && !Array.isArray(record.params)
? record.params
: {}
if (params.mode === 'white-background') return 'white-background'
if (params.mode === 'eight-view') return 'eight-view'
return 'scene'
}

View File

@ -0,0 +1,122 @@
'use client'
import { useCallback, useEffect, useEffectEvent, useMemo, useState } from 'react'
import {
fetchIntakeAiRenderWorkspace,
listAiRenderWorkbenchSkuRenders,
uploadIntakeAiRenderModel,
} from '@/lib/open-product/platform-api'
import type {
IntakeAiRenderProduct,
IntakeAiRenderRecord,
} from './intakeAiRenderWorkspaceTypes'
export function useIntakeAiRenderWorkspace(input: {
productId: string | null
selectedBarcode: string
onNotify: (message: string, type: 'success' | 'error') => void
}) {
const { productId, selectedBarcode, onNotify } = input
const [product, setProduct] = useState<IntakeAiRenderProduct | null>(null)
const [recordState, setRecordState] = useState<{
skuId: string
records: IntakeAiRenderRecord[]
}>({ skuId: '', records: [] })
const [loading, setLoading] = useState(false)
const [uploading, setUploading] = useState(false)
const notifyFromEffect = useEffectEvent(onNotify)
const currentProduct = product?.id === productId ? product : null
const selectedSku = useMemo(
() =>
currentProduct?.skus.find((sku) => sku.barcode === selectedBarcode) ??
currentProduct?.skus[0] ??
null,
[currentProduct, selectedBarcode],
)
const records = recordState.skuId === selectedSku?.id ? recordState.records : []
const loadProduct = useCallback(async () => {
if (!productId) return null
const nextProduct = await fetchIntakeAiRenderWorkspace(productId)
setProduct(nextProduct)
return nextProduct
}, [productId])
useEffect(() => {
if (!productId) {
return
}
let active = true
void (async () => {
await Promise.resolve()
if (active) setLoading(true)
try {
await loadProduct()
} catch (error) {
if (active) {
notifyFromEffect(error instanceof Error ? error.message : 'AI 出图资料加载失败', 'error')
}
} finally {
if (active) setLoading(false)
}
})()
return () => {
active = false
}
}, [loadProduct, productId])
const refreshRecords = useCallback(async (skuId: string) => {
const nextRecords = await listAiRenderWorkbenchSkuRenders(skuId, 60)
setRecordState({ skuId, records: nextRecords })
}, [])
useEffect(() => {
if (!selectedSku?.id) {
return
}
let active = true
void (async () => {
try {
await refreshRecords(selectedSku.id)
} catch (error) {
if (active) {
notifyFromEffect(error instanceof Error ? error.message : '生成图片加载失败', 'error')
}
}
})()
return () => {
active = false
}
}, [refreshRecords, selectedSku?.id])
const uploadModel = useCallback(
async (file: File) => {
if (!productId || !selectedSku?.skuItemId) return
setUploading(true)
try {
await uploadIntakeAiRenderModel({
productId,
skuItemId: selectedSku.skuItemId,
file,
})
await loadProduct()
onNotify('共享模型已更新', 'success')
} catch (error) {
onNotify(error instanceof Error ? error.message : '模型上传失败', 'error')
} finally {
setUploading(false)
}
},
[loadProduct, onNotify, productId, selectedSku],
)
return {
product: currentProduct,
selectedSku,
records,
loading,
uploading,
refreshRecords,
uploadModel,
}
}

View File

@ -61,6 +61,7 @@ export function ProductPhotoHeaderActions({
const modeOptions: { key: ProductPhotoWorkspaceMode; label: string }[] = [
{ key: "source", label: "实拍资料" },
{ key: "ai-white-background", label: "AI 白底图" },
{ key: "ai-render", label: "AI 出图" },
];
return (
@ -88,7 +89,7 @@ export function ProductPhotoHeaderActions({
</div>
<div className={actionGroupClassName}>
{isReadOnly || activeMode === "ai-white-background" ? null : (
{isReadOnly || activeMode !== "source" ? null : (
<>
<Button size="small" onClick={() => onOpenPanelUpload(activePanel)}>
{groupUploadLabel}
@ -142,7 +143,7 @@ export function ProductPhotoHeaderActions({
</Button>
</>
) : null}
{isReadOnly || activeMode === "ai-white-background" ? null : (
{isReadOnly || activeMode !== "source" ? null : (
<Button
variant="plain"
size="small"

View File

@ -33,6 +33,7 @@ type ProductPhotoWorkspaceProps = {
previewDescription: string;
previewContent: ReactNode;
aiWhiteBackgroundContent: ReactNode;
aiRenderContent: ReactNode;
onSelectStyle: (styleKey: string) => void;
onSelectBarcode: (barcode: string) => void;
onSwitchPanel: (panel: ProductPhotoGroup) => void;
@ -67,6 +68,7 @@ export function ProductPhotoWorkspace({
previewDescription,
previewContent,
aiWhiteBackgroundContent,
aiRenderContent,
onSelectStyle,
onSelectBarcode,
onSwitchPanel,
@ -76,35 +78,35 @@ export function ProductPhotoWorkspace({
onSelectGroupedFile,
}: ProductPhotoWorkspaceProps) {
return (
<div className="grid gap-2 overflow-visible text-[#2d3335] xl:min-h-0 xl:flex-1 xl:overflow-hidden xl:grid-cols-[220px_minmax(540px,580px)_minmax(0,1fr)]">
<div className="flex flex-col overflow-visible rounded-[8px] border border-[#adb3b5]/16 bg-white shadow-[0_2px_10px_rgba(45,51,53,0.035)] xl:min-h-0 xl:overflow-hidden">
<div className="grid gap-2 overflow-visible text-[#2d3335] xl:min-h-0 xl:flex-1 xl:overflow-hidden xl:grid-cols-[250px_minmax(540px,580px)_minmax(0,1fr)]">
<div className="flex min-w-0 flex-col overflow-visible rounded-[8px] border border-[#adb3b5]/16 bg-white shadow-[0_2px_10px_rgba(45,51,53,0.035)] xl:min-h-0 xl:overflow-hidden">
<div className="border-b border-[#adb3b5]/12 bg-[#f8f9fa] px-3 py-1.5">
<p className="text-[13px] font-black text-[#2d3335] sm:text-sm">
款式 / 条码
</p>
</div>
<div className="flex-1 bg-[#f1f4f5] p-2 xl:min-h-0 xl:overflow-y-auto">
<div className="grid gap-2">
<div className="min-w-0 flex-1 overflow-x-hidden bg-[#f1f4f5] p-2 xl:min-h-0 xl:overflow-y-auto">
<div className="grid min-w-0 gap-2">
{styleEntries.map((entry) => {
const isActive = activeStyleEntry?.key === entry.key;
const totalCount = entry.nineViewCount + entry.groupedCount;
return (
<div key={entry.key} className="grid gap-1.5">
<div key={entry.key} className="grid min-w-0 gap-1.5">
<button
type="button"
data-testid={`product-photo-style-${entry.key}`}
onClick={() => onSelectStyle(entry.key)}
className={`rounded-[7px] border px-2.5 py-2 text-left transition ${
className={`w-full min-w-0 rounded-[7px] border px-2.5 py-2 text-left transition ${
isActive
? "border-[#2d3335] bg-[#2d3335] text-white shadow-[0_6px_18px_rgba(45,51,53,0.14)]"
: "border-[#adb3b5]/16 bg-white hover:bg-[#f8f9fa]"
}`}
>
<div className="flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center justify-between gap-2">
<p
className={`truncate text-xs font-black sm:text-[13px] ${
className={`min-w-0 truncate text-xs font-black sm:text-[13px] ${
isActive ? "text-white" : "text-[#2d3335]"
}`}
>
@ -123,7 +125,7 @@ export function ProductPhotoWorkspace({
</button>
{isActive ? (
<div className="ml-2 grid gap-1.5 border-l border-[#adb3b5]/26 pl-2">
<div className="ml-2 grid min-w-0 gap-1.5 border-l border-[#adb3b5]/26 pl-2">
{entry.barcodeEntries.map((barcodeEntry) => {
const isBarcodeActive =
activeBarcodeEntry?.barcode === barcodeEntry.barcode;
@ -138,13 +140,13 @@ export function ProductPhotoWorkspace({
onClick={() =>
onSelectBarcode(barcodeEntry.barcode)
}
className={`rounded-[7px] border px-2.5 py-2 text-left transition ${
className={`w-full min-w-0 rounded-[7px] border px-2.5 py-2 text-left transition ${
isBarcodeActive
? "border-[#47646b] bg-white shadow-[0_6px_18px_rgba(45,51,53,0.08)] ring-1 ring-[#47646b]/16"
: "border-[#adb3b5]/16 bg-white/82 hover:bg-white"
}`}
>
<div className="flex items-start justify-between gap-2">
<div className="flex min-w-0 items-start justify-between gap-2">
<div className="min-w-0">
<p className="truncate text-[11px] font-black text-[#2d3335] sm:text-[12px]">
{barcodeEntry.barcode}
@ -185,12 +187,12 @@ export function ProductPhotoWorkspace({
</div>
</div>
{activeMode === "ai-white-background" ? (
{activeMode === "ai-white-background" || activeMode === "ai-render" ? (
<div
data-testid="product-photo-ai-white-background-workspace"
data-testid={`product-photo-${activeMode}-workspace`}
className="flex min-w-0 flex-col overflow-visible rounded-[8px] bg-[#f1f4f5] xl:col-span-2 xl:min-h-0 xl:overflow-hidden"
>
{aiWhiteBackgroundContent}
{activeMode === "ai-white-background" ? aiWhiteBackgroundContent : aiRenderContent}
</div>
) : (
<>

View File

@ -52,7 +52,7 @@ export type ProductPhotoFileGroup =
| "competitor-sample"
| "competitor-disassembly";
export type ProductPhotoGroup = "nine-view" | ProductPhotoFileGroup;
export type ProductPhotoWorkspaceMode = "source" | "ai-white-background";
export type ProductPhotoWorkspaceMode = "source" | "ai-white-background" | "ai-render";
export type ProductPhotoNineViewSlot =
| "perspective"
| "top"

View File

@ -28,9 +28,10 @@ type ReviewSheetWorkspaceProps = ComponentProps<typeof ReviewSheetWorkspace>;
type ProductPhotoWorkspaceRendererProps = Omit<
ProductPhotoWorkspaceProps,
"aiWhiteBackgroundContent" | "previewContent"
"aiWhiteBackgroundContent" | "aiRenderContent" | "previewContent"
> & {
renderAiWhiteBackgroundContent: () => ReactNode;
renderAiRenderContent: () => ReactNode;
renderPreviewContent: () => ReactNode;
};
@ -68,6 +69,7 @@ export function buildDocumentWorkspaceRenderers({
}: DocumentWorkspaceRendererOptions) {
const {
renderAiWhiteBackgroundContent,
renderAiRenderContent,
renderPreviewContent,
...productPhotoProps
} = productPhoto;
@ -85,6 +87,7 @@ export function buildDocumentWorkspaceRenderers({
<ProductPhotoWorkspace
{...productPhotoProps}
aiWhiteBackgroundContent={renderAiWhiteBackgroundContent()}
aiRenderContent={renderAiRenderContent()}
previewContent={renderPreviewContent()}
/>
);

View File

@ -71,12 +71,15 @@ interface ApiEnvelope<T> {
export type AssetReviewAction = 'SUBMIT' | 'APPROVE' | 'REJECT'
async function requestPlatform<T>(input: RequestInfo, init?: RequestInit): Promise<T> {
const isFormDataBody = typeof FormData !== 'undefined' && init?.body instanceof FormData
const response = await fetch(input, {
...init,
headers: {
'Content-Type': 'application/json',
...(init?.headers ?? {}),
},
headers: isFormDataBody
? init?.headers
: {
'Content-Type': 'application/json',
...(init?.headers ?? {}),
},
})
const payload = (await response.json().catch(() => null)) as ApiEnvelope<T> | null
@ -943,6 +946,7 @@ export interface CreateAssistedAssemblyAiRenderPayload {
viewName?: string | null
viewIndex?: number | null
workbenchSkuId?: string | null
intakeSkuItemId?: string | null
referenceImageDataUrl: string
structureReferenceImageDataUrls?: string[] | null
materialPartAnnotations?: Array<{
@ -972,6 +976,54 @@ export async function fetchAiRenderWorkbench(): Promise<AiRenderWorkbenchDTO> {
return requestPlatform<AiRenderWorkbenchDTO>('/api/ai-render-workbench')
}
export async function fetchIntakeAiRenderWorkspace(
productId: string,
): Promise<AiRenderWorkbenchSpuDTO> {
const data = await requestPlatform<{ product: AiRenderWorkbenchSpuDTO }>(
`/api/intake/non-integrated/${encodeURIComponent(productId)}/ai-render`,
)
return data.product
}
export async function uploadIntakeAiRenderModel(input: {
productId: string
skuItemId: string
file: File
}): Promise<AiRenderWorkbenchSkuDTO> {
const formData = new FormData()
formData.append('skuItemId', input.skuItemId)
formData.append('file', input.file)
const data = await requestPlatform<{ sku: AiRenderWorkbenchSkuDTO }>(
`/api/intake/non-integrated/${encodeURIComponent(input.productId)}/ai-render/model`,
{ method: 'POST', body: formData },
)
return data.sku
}
export async function saveIntakeAiRenderMaterial(input: {
productId: string
skuDatabaseId: string
materialColor?: string | null
materialTextureUrl?: string | null
nodeMaterials?: unknown
uvConfig?: unknown
}): Promise<AiRenderWorkbenchSkuDTO> {
const data = await requestPlatform<{ sku: AiRenderWorkbenchSkuDTO }>(
`/api/intake/non-integrated/${encodeURIComponent(input.productId)}/ai-render/material`,
{
method: 'POST',
body: JSON.stringify({
skuDatabaseId: input.skuDatabaseId,
materialColor: input.materialColor,
materialTextureUrl: input.materialTextureUrl,
nodeMaterials: input.nodeMaterials,
uvConfig: input.uvConfig,
}),
},
)
return data.sku
}
export async function fetchInstallationDemoWorkbenchCatalog(): Promise<InstallationDemoWorkbenchCatalogDTO> {
return requestPlatform<InstallationDemoWorkbenchCatalogDTO>('/api/installation-demo-workbench/catalog')
}
@ -1012,7 +1064,7 @@ export async function createInstallationDemoShareLink(
}
export async function createAiRenderWorkbenchDetailReference(input: {
workbenchSkuId: string
intakeSkuItemId: string
referenceImageDataUrl: string
}): Promise<AiRenderWorkbenchTemporaryImageDTO> {
const data = await requestPlatform<{ image: AiRenderWorkbenchTemporaryImageDTO }>(
@ -1027,7 +1079,7 @@ export async function createAiRenderWorkbenchDetailReference(input: {
export async function deleteAiRenderWorkbenchDetailReference(input: {
storageKey: string
workbenchSkuId?: string | null
intakeSkuItemId?: string | null
}): Promise<void> {
await requestPlatform<{ ok: boolean }>('/api/ai-render-workbench/detail-reference', {
method: 'DELETE',
@ -1102,7 +1154,7 @@ export async function listAiRenderWorkbenchSkuRenders(
limit = 24,
): Promise<AssistedAssemblyAiRenderDTO[]> {
const searchParams = new URLSearchParams({
workbenchSkuId: skuId,
intakeSkuItemId: skuId,
limit: String(limit),
})
const data = await requestPlatform<{ renders: AssistedAssemblyAiRenderDTO[] }>(

View File

@ -0,0 +1,250 @@
import { Prisma } from '@prisma/client'
import { prisma } from '@/lib/prisma'
const skuAiProfileInclude = {
currentModelVersion: {
include: {
asset: true,
},
},
currentMaterialVersion: true,
} satisfies Prisma.IntakeSkuAiProfileInclude
const catalogProductInclude = {
profile: true,
skuItems: {
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }],
include: {
aiProfile: {
include: skuAiProfileInclude,
},
},
},
} satisfies Prisma.IntakeNonIntegratedProductInclude
export type IntakeAiCatalogProductRecord = Prisma.IntakeNonIntegratedProductGetPayload<{
include: typeof catalogProductInclude
}>
export type IntakeAiSkuRecord = IntakeAiCatalogProductRecord['skuItems'][number]
export interface CreateModelVersionInput {
id: string
assetId: string
versionNo: number
originalFileName: string
mimeType: string
fileSize: number
checksumSha256: string
sourceStorageKey: string
sourceUrl: string
previewStorageKey: string | null
previewUrl: string | null
createdBy: string
}
export class IntakeAiAssetRepository {
findCatalogProducts(query: string, take = 200) {
const normalizedQuery = query.trim()
return prisma.intakeNonIntegratedProduct.findMany({
where: {
deletedAt: null,
...(normalizedQuery
? {
OR: [
{ spu: { contains: normalizedQuery, mode: 'insensitive' as const } },
{ spuBase: { contains: normalizedQuery, mode: 'insensitive' as const } },
{
profile: {
productName: { contains: normalizedQuery, mode: 'insensitive' as const },
},
},
{
skuItems: {
some: {
OR: [
{ barcode: { contains: normalizedQuery, mode: 'insensitive' as const } },
{ skuCode: { contains: normalizedQuery, mode: 'insensitive' as const } },
{ model: { contains: normalizedQuery, mode: 'insensitive' as const } },
],
},
},
},
],
}
: {}),
},
include: catalogProductInclude,
orderBy: [{ updatedAt: 'desc' }, { createdAt: 'desc' }],
take,
})
}
findProductById(productId: string) {
return prisma.intakeNonIntegratedProduct.findFirst({
where: { id: productId, deletedAt: null },
include: catalogProductInclude,
})
}
findSkuByDatabaseId(skuItemId: string) {
return prisma.intakeNonIntegratedSkuItem.findUnique({
where: { id: skuItemId },
include: {
product: { include: { profile: true } },
aiProfile: { include: skuAiProfileInclude },
},
})
}
findSkuByProductKey(productId: string, skuItemId: string) {
return prisma.intakeNonIntegratedSkuItem.findUnique({
where: {
productId_skuItemId: { productId, skuItemId },
},
include: {
product: { include: { profile: true } },
aiProfile: { include: skuAiProfileInclude },
},
})
}
async getOrCreateModelAsset(input: {
productId: string
modelKey: string
displayName: string
createdBy: string
}) {
return prisma.intakeProductModelAsset.upsert({
where: {
productId_modelKey: {
productId: input.productId,
modelKey: input.modelKey,
},
},
update: {
displayName: input.displayName,
},
create: input,
})
}
async getNextModelVersionNo(assetId: string) {
const latest = await prisma.intakeProductModelVersion.findFirst({
where: { assetId },
orderBy: { versionNo: 'desc' },
select: { versionNo: true },
})
return (latest?.versionNo ?? 0) + 1
}
async createModelVersionAndBind(input: {
version: CreateModelVersionInput
selectedSkuDatabaseId: string
productId: string
modelValue: string
previewUrl: string
createdBy: string
initializeModelGroup: boolean
}) {
return prisma.$transaction(async (tx) => {
const version = await tx.intakeProductModelVersion.create({
data: input.version,
include: { asset: true },
})
const candidates = input.initializeModelGroup
? await tx.intakeNonIntegratedSkuItem.findMany({
where: {
productId: input.productId,
model: { equals: input.modelValue, mode: 'insensitive' },
},
select: {
id: true,
model3dDataUrl: true,
aiProfile: { select: { id: true, currentModelVersionId: true } },
},
})
: []
const targetIds = new Set<string>([input.selectedSkuDatabaseId])
candidates.forEach((candidate) => {
if (!candidate.aiProfile?.currentModelVersionId) {
targetIds.add(candidate.id)
}
})
for (const targetId of targetIds) {
await tx.intakeSkuAiProfile.upsert({
where: { skuItemId: targetId },
update: {
currentModelVersionId: version.id,
updatedBy: input.createdBy,
},
create: {
skuItemId: targetId,
currentModelVersionId: version.id,
createdBy: input.createdBy,
updatedBy: input.createdBy,
},
})
await tx.intakeNonIntegratedSkuItem.update({
where: { id: targetId },
data: { model3dDataUrl: input.previewUrl },
})
}
return version
})
}
async createMaterialVersion(input: {
skuDatabaseId: string
createdBy: string
materialColor: string
textureStorageKey: string | null
textureUrl: string | null
nodeMaterials: Prisma.InputJsonValue | typeof Prisma.JsonNull
uvConfig: Prisma.InputJsonValue | typeof Prisma.JsonNull
}) {
return prisma.$transaction(async (tx) => {
const profile = await tx.intakeSkuAiProfile.upsert({
where: { skuItemId: input.skuDatabaseId },
update: { updatedBy: input.createdBy },
create: {
skuItemId: input.skuDatabaseId,
createdBy: input.createdBy,
updatedBy: input.createdBy,
},
})
const latest = await tx.intakeSkuAiMaterialVersion.findFirst({
where: { profileId: profile.id },
orderBy: { versionNo: 'desc' },
select: { versionNo: true },
})
const materialVersion = await tx.intakeSkuAiMaterialVersion.create({
data: {
profileId: profile.id,
versionNo: (latest?.versionNo ?? 0) + 1,
materialColor: input.materialColor,
textureStorageKey: input.textureStorageKey,
textureUrl: input.textureUrl,
nodeMaterials: input.nodeMaterials,
uvConfig: input.uvConfig,
createdBy: input.createdBy,
},
})
await tx.intakeSkuAiProfile.update({
where: { id: profile.id },
data: {
currentMaterialVersionId: materialVersion.id,
updatedBy: input.createdBy,
},
})
return materialVersion
})
}
}
export const intakeAiAssetRepository = new IntakeAiAssetRepository()

View File

@ -0,0 +1,33 @@
import { buildPermanentIntakeRenderStorageKey } from '../assisted-assembly-ai-render.permanent-storage'
describe('permanent intake AI render storage', () => {
it('keeps barcode records and categories in separate product-development folders', () => {
expect(
buildPermanentIntakeRenderStorageKey({
skuItemId: 'sku-a',
renderId: 'render-1',
mode: 'eight-view',
kind: 'results',
extension: 'webp',
index: 2,
}),
).toBe(
'product-development/ai-renders/sku-a/eight-view/render-1/results/image-3.webp',
)
})
it('stores references beside their scene render without using the temporary prefix', () => {
const key = buildPermanentIntakeRenderStorageKey({
skuItemId: 'sku-b',
renderId: 'render-2',
mode: 'scene',
kind: 'references',
extension: 'png',
})
expect(key).toBe(
'product-development/ai-renders/sku-b/scene/render-2/references/image.png',
)
expect(key).not.toContain('ai-render-workbench/temp')
})
})

View File

@ -18,6 +18,9 @@ const IMPORT_RECORD_LIMIT = 200
export interface AiRenderWorkbenchSkuDTO {
id: string
productId?: string
skuItemId?: string
barcode?: string
name: string
modelUrl: string
thumbnailUrl: string | null
@ -25,6 +28,10 @@ export interface AiRenderWorkbenchSkuDTO {
materialColor: string
materialTextureUrl?: string
nodeMaterials?: Record<string, unknown>
uvConfig?: Record<string, unknown>
modelVersionId?: string | null
modelFileName?: string | null
materialVersionId?: string | null
}
export interface AiRenderWorkbenchSpuDTO {
@ -401,13 +408,13 @@ export class AiRenderWorkbenchService {
createdBy: user.id,
updatedBy: user.id,
}
const sku = skuId
? await tx.aiRenderWorkbenchSku.upsert({
await (skuId
? tx.aiRenderWorkbenchSku.upsert({
where: { id: skuId },
update: skuData,
create: { id: skuId, ...skuData },
})
: await tx.aiRenderWorkbenchSku.create({ data: skuData })
: tx.aiRenderWorkbenchSku.create({ data: skuData }))
}
}

View File

@ -1,5 +1,9 @@
import type { Prisma } from '@prisma/client'
import { AssistedAssemblyAiRenderStatus } from '@prisma/client'
import {
AssistedAssemblyAiRenderStatus,
IntakeAiRenderOutputMode,
IntakeAiRenderStorageClass,
} from '@prisma/client'
import { nanoid } from 'nanoid'
import { prisma } from '@/lib/prisma'
import {
@ -8,6 +12,7 @@ import {
} from '@/lib/storage'
import type { AuthUser } from '@/server/core/types'
import { aiRenderWorkbenchService } from './ai-render-workbench.service'
import { intakeAiAssetService } from './intake-ai-asset.service'
import {
cleanWorkbenchResultBackground,
shouldCleanWorkbenchResultBackground,
@ -30,6 +35,10 @@ import {
uploadTemporaryWorkbenchReferenceImage,
uploadTemporaryWorkbenchResultImage,
} from './assisted-assembly-ai-render.temp-storage'
import {
persistPermanentIntakeResultImages,
uploadPermanentIntakeReferenceImage,
} from './assisted-assembly-ai-render.permanent-storage'
import {
decodeReferenceImageDataUrl,
decodeReferenceImageDataUrls,
@ -63,6 +72,7 @@ export interface CreateAssistedAssemblyAiRenderInput {
viewName?: string | null
viewIndex?: number | null
workbenchSkuId?: string | null
intakeSkuItemId?: string | null
referenceImageDataUrl: string
structureReferenceImageDataUrls?: string[] | null
materialPartAnnotations?: Array<{
@ -73,6 +83,12 @@ export interface CreateAssistedAssemblyAiRenderInput {
assetSnapshot: unknown
}
function resolveOutputMode(mode: string): IntakeAiRenderOutputMode {
if (mode === 'white-background') return IntakeAiRenderOutputMode.WHITE_BACKGROUND
if (mode === 'eight-view') return IntakeAiRenderOutputMode.EIGHT_VIEW
return IntakeAiRenderOutputMode.SCENE
}
function isPrivateIpv4Hostname(hostname: string): boolean {
const parts = hostname.split('.').map((part) => Number(part))
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
@ -303,16 +319,22 @@ export async function createAssistedAssemblyAiRender(
const batchId = normalizeOptionalText(input.batchId, '图集批次', 160)
const viewName = normalizeOptionalText(input.viewName, '视图名称', 32)
const viewIndex = normalizeOptionalViewIndex(input.viewIndex)
const workbenchSkuId = trimToNull(input.workbenchSkuId)
const workbenchSku = workbenchSkuId
? await aiRenderWorkbenchService.getSkuForRender(workbenchSkuId, user)
const intakeSkuItemId = trimToNull(input.intakeSkuItemId)
const intakeSku = intakeSkuItemId
? await intakeAiAssetService.getSkuForRender(intakeSkuItemId, user)
: null
imageSize = normalizeWorkbenchRequestImageSize(imageSize, Boolean(workbenchSku))
const legacyWorkbenchSkuId = intakeSku ? null : trimToNull(input.workbenchSkuId)
const workbenchSku = legacyWorkbenchSkuId
? await aiRenderWorkbenchService.getSkuForRender(legacyWorkbenchSkuId, user)
: null
const isCatalogRender = Boolean(intakeSku || workbenchSku)
const renderId = nanoid(24)
imageSize = normalizeWorkbenchRequestImageSize(imageSize, isCatalogRender)
const referenceImage = decodeReferenceImageDataUrl(input.referenceImageDataUrl)
const structureReferenceImages = workbenchSku
const structureReferenceImages = isCatalogRender
? decodeReferenceImageDataUrls(input.structureReferenceImageDataUrls)
: []
const materialPartAnnotations = workbenchSku
const materialPartAnnotations = isCatalogRender
? normalizeMaterialPartAnnotations(input.materialPartAnnotations)
: []
const materialEntries = extractEnhancedMaterialEntriesFromSnapshot(input.assetSnapshot)
@ -355,6 +377,7 @@ export async function createAssistedAssemblyAiRender(
})
const buildBaseCreateData = (referenceImageUrl: string) => ({
id: renderId,
prompt,
negativePrompt,
params: buildCurrentParams(),
@ -363,13 +386,32 @@ export async function createAssistedAssemblyAiRender(
createdBy,
workbenchSpuId: workbenchSku?.spuId ?? null,
workbenchSkuId: workbenchSku?.id ?? null,
intakeSkuItemId: intakeSku?.id ?? null,
modelVersionId: intakeSku?.modelVersionId ?? null,
materialVersionId: intakeSku?.materialVersionId ?? null,
outputMode: intakeSku ? resolveOutputMode(mode) : null,
batchId,
viewName,
viewIndex,
storageClass: intakeSku
? IntakeAiRenderStorageClass.PERMANENT
: IntakeAiRenderStorageClass.TEMPORARY,
workspaceSnapshot: ensureJsonObject(input.workspaceSnapshot, '缺少辅助装配工作台快照'),
assetSnapshot: ensureJsonObject(input.assetSnapshot, '缺少辅助装配物料快照'),
status: AssistedAssemblyAiRenderStatus.PENDING,
})
try {
referenceUpload = workbenchSku
referenceUpload = intakeSku
? await uploadPermanentIntakeReferenceImage({
buffer: referenceImage.buffer,
contentType: referenceImage.contentType,
extension: referenceImage.extension,
skuItemId: intakeSku.id,
renderId,
mode,
})
: workbenchSku
? await uploadTemporaryWorkbenchReferenceImage({
buffer: referenceImage.buffer,
contentType: referenceImage.contentType,
@ -458,7 +500,14 @@ export async function createAssistedAssemblyAiRender(
disabledMessage: '辅助装配 AI 生图服务未启用或未配置',
})
const resultImages = workbenchSku
const resultImages = intakeSku
? await persistPermanentIntakeResultImages({
images: normalizeResultImages(result.images),
skuItemId: intakeSku.id,
renderId,
mode,
})
: workbenchSku
? await persistTemporaryWorkbenchResultImages({
images: normalizeResultImages(result.images),
createdBy,

View File

@ -0,0 +1,93 @@
import type { AssistedAssemblyAiRenderImageDTO } from './assisted-assembly-ai-render.records'
import {
cleanWorkbenchResultBackground,
shouldCleanWorkbenchResultBackground,
} from './assisted-assembly-ai-render.background'
import {
fetchImageBuffer,
normalizeWorkbenchResultOutputSize,
} from './assisted-assembly-ai-render.temp-storage'
import { uploadBufferToObjectStorageWithMetadata } from '@/lib/storage'
const PERMANENT_RENDER_STORAGE_PREFIX = 'product-development/ai-renders'
function normalizeModeSegment(mode?: string | null) {
if (mode === 'white-background') return 'white-background'
if (mode === 'eight-view') return 'eight-view'
return 'scene'
}
export function buildPermanentIntakeRenderStorageKey(input: {
skuItemId: string
renderId: string
mode?: string | null
kind: 'references' | 'results'
extension: string
index?: number
}) {
const indexPart = typeof input.index === 'number' ? `-${input.index + 1}` : ''
return `${PERMANENT_RENDER_STORAGE_PREFIX}/${input.skuItemId}/${normalizeModeSegment(input.mode)}/${input.renderId}/${input.kind}/image${indexPart}.${input.extension}`
}
export async function uploadPermanentIntakeReferenceImage(input: {
buffer: Buffer
contentType: string
extension: string
skuItemId: string
renderId: string
mode?: string | null
}) {
return uploadBufferToObjectStorageWithMetadata(
input.buffer,
buildPermanentIntakeRenderStorageKey({ ...input, kind: 'references' }),
input.contentType,
)
}
export async function persistPermanentIntakeResultImages(input: {
images: AssistedAssemblyAiRenderImageDTO[]
skuItemId: string
renderId: string
mode?: string | null
}): Promise<AssistedAssemblyAiRenderImageDTO[]> {
const storedImages: AssistedAssemblyAiRenderImageDTO[] = []
for (let index = 0; index < input.images.length; index += 1) {
const image = input.images[index]!
const fetchedImage = await fetchImageBuffer(image.originalUrl)
const cleanedImage = shouldCleanWorkbenchResultBackground(input.mode)
? await cleanWorkbenchResultBackground(fetchedImage)
: fetchedImage
const normalizedImage = await normalizeWorkbenchResultOutputSize({
buffer: cleanedImage.buffer,
extension: cleanedImage.extension,
})
const upload = await uploadBufferToObjectStorageWithMetadata(
normalizedImage.buffer,
buildPermanentIntakeRenderStorageKey({
skuItemId: input.skuItemId,
renderId: input.renderId,
mode: input.mode,
kind: 'results',
extension: normalizedImage.extension,
index,
}),
normalizedImage.contentType,
)
storedImages.push({
...(image.id !== undefined ? { id: image.id } : {}),
originalUrl: upload.url,
thumbnailUrl: upload.url,
imageSize:
normalizedImage.width && normalizedImage.height
? `${normalizedImage.width}x${normalizedImage.height}`
: image.imageSize ?? null,
storageKey: upload.storageKey,
expiresAt: null,
temporary: false,
})
}
return storedImages
}

View File

@ -1,4 +1,8 @@
import type { Prisma } from '@prisma/client'
import type {
IntakeAiRenderOutputMode,
IntakeAiRenderStorageClass,
Prisma,
} from '@prisma/client'
import { AssistedAssemblyAiRenderStatus } from '@prisma/client'
import {
deleteWorkbenchRenderStorageKey,
@ -13,6 +17,11 @@ export interface AssistedAssemblyAiRenderDTO {
id: string
workbenchSpuId: string | null
workbenchSkuId: string | null
intakeSkuItemId: string | null
outputMode: IntakeAiRenderOutputMode | null
viewName: string | null
viewIndex: number | null
storageClass: IntakeAiRenderStorageClass
prompt: string
negativePrompt: string | null
params: Record<string, unknown>
@ -165,6 +174,11 @@ export function toAiRenderDTO(
id: string
workbenchSpuId: string | null
workbenchSkuId: string | null
intakeSkuItemId: string | null
outputMode: IntakeAiRenderOutputMode | null
viewName?: string | null
viewIndex?: number | null
storageClass: IntakeAiRenderStorageClass
prompt: string
negativePrompt: string | null
params: Prisma.JsonValue
@ -187,6 +201,11 @@ export function toAiRenderDTO(
id: record.id,
workbenchSpuId: record.workbenchSpuId,
workbenchSkuId: record.workbenchSkuId,
intakeSkuItemId: record.intakeSkuItemId,
outputMode: record.outputMode,
viewName: record.viewName ?? null,
viewIndex: record.viewIndex ?? null,
storageClass: record.storageClass,
prompt: record.prompt,
negativePrompt: record.negativePrompt,
params: toRecord(record.params),

View File

@ -6,6 +6,7 @@ import {
aiRenderWorkbenchService,
assertCanDeleteAiRenderWorkbenchImage,
} from './ai-render-workbench.service'
import { intakeAiAssetService } from './intake-ai-asset.service'
import { decodeReferenceImageDataUrl, normalizeRequiredText, trimToNull } from './assisted-assembly-ai-render.validation'
import { createAssistedAssemblyAiRender } from './assisted-assembly-ai-render.create'
import type { CreateAssistedAssemblyAiRenderInput } from './assisted-assembly-ai-render.create'
@ -140,6 +141,24 @@ export class AssistedAssemblyAiRenderService {
return records.map((record) => toAiRenderDTO(record))
}
async listForIntakeSku(
skuId: string,
user: AuthUser,
options?: { limit?: number },
): Promise<AssistedAssemblyAiRenderDTO[]> {
await cleanupExpiredWorkbenchRenderFilesSafely()
const sku = await intakeAiAssetService.getSku(skuId, user)
const limit = Math.min(Math.max(options?.limit ?? 24, 1), 60)
const records = await prisma.assistedAssemblyAiRender.findMany({
where: { intakeSkuItemId: sku.id },
orderBy: { createdAt: 'desc' },
take: limit,
include: { creator: { select: { name: true } } },
})
return records.map((record) => toAiRenderDTO(record))
}
async getForUser(id: string, user: AuthUser): Promise<AssistedAssemblyAiRenderDTO> {
const normalizedId = normalizeRequiredText(id, 'AI 闁汇垻鍠庡ù妯兼媼閺夎法绉?ID')
const createdBy = normalizeRequiredText(user.id, 'User ID')
@ -148,13 +167,16 @@ export class AssistedAssemblyAiRenderService {
include: { creator: { select: { name: true } } },
})
if (!record || (!record.workbenchSkuId && record.createdBy !== createdBy)) {
if (!record || (!record.workbenchSkuId && !record.intakeSkuItemId && record.createdBy !== createdBy)) {
throw new AppError(ErrorCode.FILE_NOT_FOUND, 'AI render record not found')
}
if (record.workbenchSkuId) {
await aiRenderWorkbenchService.getSkuForRender(record.workbenchSkuId, user)
}
if (record.intakeSkuItemId) {
await intakeAiAssetService.getSku(record.intakeSkuItemId, user)
}
return toAiRenderDTO(record, user.name)
}
@ -168,12 +190,13 @@ export class AssistedAssemblyAiRenderService {
id: true,
createdBy: true,
workbenchSkuId: true,
intakeSkuItemId: true,
referenceImageUrl: true,
resultImages: true,
params: true,
},
})
if (!record || (!record.workbenchSkuId && record.createdBy !== createdBy)) {
if (!record || (!record.workbenchSkuId && !record.intakeSkuItemId && record.createdBy !== createdBy)) {
throw new AppError(ErrorCode.FILE_NOT_FOUND, 'AI render record not found')
}
@ -200,6 +223,29 @@ export class AssistedAssemblyAiRenderService {
}
}
}
if (record.intakeSkuItemId) {
assertCanDeleteAiRenderWorkbenchImage(user)
await intakeAiAssetService.getSku(record.intakeSkuItemId, user)
const imageIndex = options?.imageIndex
const images = normalizeResultImages(record.resultImages)
if (typeof imageIndex === 'number' && imageIndex >= 0 && images.length > 1) {
const nextImages = images.filter((_, index) => index !== imageIndex)
if (nextImages.length > 0 && nextImages.length !== images.length) {
const updateResult = await prisma.assistedAssemblyAiRender.updateMany({
where: { id: record.id },
data: { resultImages: nextImages as unknown as Prisma.InputJsonValue },
})
if (updateResult.count > 0) {
const deleteResults = await deleteRenderImageStorageFile(images[imageIndex]!)
logWorkbenchRenderStorageDeleteFailures('delete-intake-single-image', deleteResults, {
recordId: record.id,
imageIndex,
})
}
return
}
}
}
const deleteResults = await deleteRenderStorageFiles(record)
logWorkbenchRenderStorageDeleteFailures('delete-record', deleteResults, {
@ -223,7 +269,27 @@ export class AssistedAssemblyAiRenderService {
const createdBy = normalizeRequiredText(user.id, 'User ID')
await cleanupExpiredWorkbenchRenderFilesSafely()
await cleanupExpiredLocalWorkbenchDetailReferenceFilesSafely()
await aiRenderWorkbenchService.getSkuForRender(input.workbenchSkuId, user)
await intakeAiAssetService.getSkuForRender(input.workbenchSkuId, user)
const referenceImage = decodeReferenceImageDataUrl(input.referenceImageDataUrl)
return persistTemporaryWorkbenchDetailReferenceImage({
buffer: referenceImage.buffer,
contentType: referenceImage.contentType,
extension: referenceImage.extension,
createdBy,
})
}
async createIntakeDetailReference(
input: {
intakeSkuItemId: string
referenceImageDataUrl: string
},
user: AuthUser,
) {
const createdBy = normalizeRequiredText(user.id, 'User ID')
await cleanupExpiredLocalWorkbenchDetailReferenceFilesSafely()
await intakeAiAssetService.getSkuForRender(input.intakeSkuItemId, user)
const referenceImage = decodeReferenceImageDataUrl(input.referenceImageDataUrl)
return persistTemporaryWorkbenchDetailReferenceImage({
@ -238,6 +304,7 @@ export class AssistedAssemblyAiRenderService {
input: {
storageKey: string
workbenchSkuId?: string | null
intakeSkuItemId?: string | null
},
user: AuthUser,
): Promise<void> {
@ -252,6 +319,9 @@ export class AssistedAssemblyAiRenderService {
if (trimToNull(input.workbenchSkuId)) {
await aiRenderWorkbenchService.getSkuForRender(input.workbenchSkuId!, user)
}
if (trimToNull(input.intakeSkuItemId)) {
await intakeAiAssetService.getSkuForRender(input.intakeSkuItemId!, user)
}
await deleteWorkbenchRenderStorageKey(storageKey)
}

View File

@ -0,0 +1,95 @@
import type { Prisma } from '@prisma/client'
import type {
IntakeAiCatalogProductRecord,
IntakeAiSkuRecord,
} from '@/server/repositories/intake-ai-asset.repository'
export const DEFAULT_INTAKE_AI_MATERIAL_COLOR = '#d8c6ad'
export interface IntakeAiSkuDTO {
id: string
productId: string
skuItemId: string
barcode: string
name: string
modelName: string
color: string
material: string
modelUrl: string
modelVersionId: string | null
modelFileName: string | null
materialVersionId: string | null
materialColor: string
materialTextureUrl?: string
nodeMaterials?: Record<string, unknown>
uvConfig?: Record<string, unknown>
}
export interface IntakeAiSpuDTO {
id: string
name: string
description: string
skus: IntakeAiSkuDTO[]
}
export function trimIntakeAiText(value: unknown) {
return typeof value === 'string' ? value.trim() : ''
}
function toObject(value: Prisma.JsonValue | null | undefined) {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined
}
export function toIntakeAiSkuDTO(sku: IntakeAiSkuRecord): IntakeAiSkuDTO {
const modelVersion = sku.aiProfile?.currentModelVersion ?? null
const materialVersion = sku.aiProfile?.currentMaterialVersion ?? null
const barcode = trimIntakeAiText(sku.barcode)
const canonicalModelUrl = trimIntakeAiText(sku.model3dDataUrl)
const versionModelUrl =
trimIntakeAiText(modelVersion?.previewUrl) || trimIntakeAiText(modelVersion?.sourceUrl)
const modelVersionMatchesCanonical =
!canonicalModelUrl || canonicalModelUrl === versionModelUrl
return {
id: sku.id,
productId: sku.productId,
skuItemId: sku.skuItemId,
barcode,
name:
barcode ||
trimIntakeAiText(sku.skuCode) ||
trimIntakeAiText(sku.specName) ||
'未命名条码',
modelName: trimIntakeAiText(sku.model),
color: trimIntakeAiText(sku.color),
material: trimIntakeAiText(sku.material),
modelUrl: canonicalModelUrl || versionModelUrl,
modelVersionId: modelVersionMatchesCanonical ? modelVersion?.id ?? null : null,
modelFileName: modelVersionMatchesCanonical ? modelVersion?.originalFileName ?? null : null,
materialVersionId: materialVersion?.id ?? null,
materialColor: materialVersion?.materialColor || DEFAULT_INTAKE_AI_MATERIAL_COLOR,
...(materialVersion?.textureUrl ? { materialTextureUrl: materialVersion.textureUrl } : {}),
...(toObject(materialVersion?.nodeMaterials) ? {
nodeMaterials: toObject(materialVersion?.nodeMaterials),
} : {}),
...(toObject(materialVersion?.uvConfig) ? {
uvConfig: toObject(materialVersion?.uvConfig),
} : {}),
}
}
export function toIntakeAiSpuDTO(product: IntakeAiCatalogProductRecord): IntakeAiSpuDTO {
return {
id: product.id,
name:
trimIntakeAiText(product.spu) ||
trimIntakeAiText(product.spuBase) ||
'未命名 SPU',
description: trimIntakeAiText(product.profile?.productName),
skus: product.skuItems
.filter((sku) => trimIntakeAiText(sku.barcode))
.map(toIntakeAiSkuDTO),
}
}

View File

@ -0,0 +1,252 @@
import { createHash, randomUUID } from 'node:crypto'
import path from 'node:path'
import { Prisma } from '@prisma/client'
import { extractPreviewModelFromZip } from '@/lib/model-preview-zip'
import { getAiRenderWorkbenchAccess } from '@/lib/ai-render-workbench-permissions'
import {
deleteObjectWithResult,
readObjectStorageKeyFromUrl,
uploadBufferToObjectStorageWithMetadata,
} from '@/lib/storage'
import { UPLOAD } from '@/server/core/constants'
import { AppError, ErrorCode } from '@/server/core/errors'
import type { AuthUser } from '@/server/core/types'
import {
intakeAiAssetRepository,
type IntakeAiSkuRecord,
} from '@/server/repositories/intake-ai-asset.repository'
import { intakeNonIntegratedProductService } from './intake-non-integrated-product.service'
import { intakeNonIntegratedScopeService } from './intake-non-integrated-scope.service'
import {
DEFAULT_INTAKE_AI_MATERIAL_COLOR,
toIntakeAiSkuDTO,
toIntakeAiSpuDTO,
trimIntakeAiText,
type IntakeAiSkuDTO,
type IntakeAiSpuDTO,
} from './intake-ai-asset.mapper'
const PRODUCT_PHOTO_WORKSPACE_CATEGORY = '产品实拍'
const MODEL_STORAGE_PREFIX = 'product-development/models'
export type { IntakeAiSkuDTO, IntakeAiSpuDTO } from './intake-ai-asset.mapper'
function assertCanGenerate(user: AuthUser) {
if (!getAiRenderWorkbenchAccess(user).canGenerate) {
throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '当前账号不能在 AI 出图工作台生成图片')
}
}
function assertCanView(user: AuthUser) {
if (!getAiRenderWorkbenchAccess(user).canView) {
throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '当前账号不能访问 AI 出图资料')
}
}
function normalizeModelKey(sku: Pick<IntakeAiSkuRecord, 'model' | 'skuCode' | 'barcode' | 'id'>) {
const source =
trimIntakeAiText(sku.model) ||
trimIntakeAiText(sku.skuCode) ||
trimIntakeAiText(sku.barcode) ||
sku.id
return source.toUpperCase().replace(/\s+/g, ' ').slice(0, 160)
}
function sanitizeExtension(fileName: string) {
return path.extname(fileName).toLowerCase().replace(/[^a-z0-9.]/g, '')
}
function assertSupportedModelFile(file: File) {
const extension = sanitizeExtension(file.name)
if (!['.zip', '.glb', '.gltf', '.obj'].includes(extension)) {
throw new AppError(
ErrorCode.FILE_TYPE_NOT_ALLOWED,
'仅支持 ZIP、GLB、GLTF 或 OBJ 模型文件',
)
}
if (file.size <= 0) {
throw new AppError(ErrorCode.VALIDATION_FAILED, '模型文件为空')
}
if (file.size > UPLOAD.MAX_FILE_SIZE) {
throw new AppError(ErrorCode.FILE_TOO_LARGE, '模型文件超过上传大小限制')
}
}
function jsonObjectOrNull(value: unknown) {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return Prisma.JsonNull
}
return JSON.parse(JSON.stringify(value)) as Prisma.InputJsonValue
}
export class IntakeAiAssetService {
async listCatalog(query: string, user: AuthUser): Promise<IntakeAiSpuDTO[]> {
assertCanView(user)
const products = await intakeAiAssetRepository.findCatalogProducts(query)
return products
.filter((product) => intakeNonIntegratedScopeService.canReadRecord(product, user))
.map(toIntakeAiSpuDTO)
.filter((product) => product.skus.length > 0)
}
async getProductWorkspace(productId: string, user: AuthUser): Promise<IntakeAiSpuDTO> {
assertCanView(user)
const product = await intakeAiAssetRepository.findProductById(productId)
if (!product || !intakeNonIntegratedScopeService.canReadRecord(product, user)) {
throw new AppError(ErrorCode.FILE_NOT_FOUND, '没有找到这条产品资料')
}
return toIntakeAiSpuDTO(product)
}
async getSkuForRender(skuDatabaseId: string, user: AuthUser): Promise<IntakeAiSkuDTO> {
assertCanGenerate(user)
const dto = await this.getSku(skuDatabaseId, user)
if (!dto.modelUrl) {
throw new AppError(ErrorCode.VALIDATION_FAILED, '当前条码还没有模型')
}
return dto
}
async getSku(skuDatabaseId: string, user: AuthUser): Promise<IntakeAiSkuDTO> {
assertCanView(user)
const sku = await intakeAiAssetRepository.findSkuByDatabaseId(skuDatabaseId)
if (!sku || !intakeNonIntegratedScopeService.canReadRecord(sku.product, user)) {
throw new AppError(ErrorCode.SKU_NOT_FOUND, '没有找到这个条码')
}
return toIntakeAiSkuDTO(sku as unknown as IntakeAiSkuRecord)
}
async uploadModel(input: {
productId: string
skuItemId: string
file: File
user: AuthUser
}): Promise<IntakeAiSkuDTO> {
assertSupportedModelFile(input.file)
await intakeNonIntegratedProductService.assertCanWrite(
input.productId,
input.user,
PRODUCT_PHOTO_WORKSPACE_CATEGORY,
)
const sku = await intakeAiAssetRepository.findSkuByProductKey(
input.productId,
input.skuItemId,
)
if (!sku) {
throw new AppError(ErrorCode.SKU_NOT_FOUND, '没有找到这个条码')
}
const sourceBuffer = Buffer.from(await input.file.arrayBuffer())
const checksumSha256 = createHash('sha256').update(sourceBuffer).digest('hex')
const modelKey = normalizeModelKey(sku as unknown as IntakeAiSkuRecord)
const asset = await intakeAiAssetRepository.getOrCreateModelAsset({
productId: input.productId,
modelKey,
displayName: trimIntakeAiText(sku.model) || trimIntakeAiText(sku.barcode),
createdBy: input.user.id,
})
const versionNo = await intakeAiAssetRepository.getNextModelVersionNo(asset.id)
const versionId = randomUUID()
const sourceExtension = sanitizeExtension(input.file.name) || '.bin'
const sourceUpload = await uploadBufferToObjectStorageWithMetadata(
sourceBuffer,
`${MODEL_STORAGE_PREFIX}/${asset.id}/versions/${versionId}/source/original${sourceExtension}`,
input.file.type || 'application/octet-stream',
)
const uploadedKeys = new Set([sourceUpload.storageKey])
try {
let previewUpload = sourceUpload
let previewFile = input.file
if (sourceExtension === '.zip') {
const extracted = await extractPreviewModelFromZip(input.file)
if (!extracted.success) {
throw new AppError(ErrorCode.VALIDATION_INVALID_FORMAT, extracted.error)
}
previewFile = extracted.modelFile
const previewExtension = sanitizeExtension(previewFile.name) || '.glb'
previewUpload = await uploadBufferToObjectStorageWithMetadata(
Buffer.from(await previewFile.arrayBuffer()),
`${MODEL_STORAGE_PREFIX}/${asset.id}/versions/${versionId}/preview/model${previewExtension}`,
previewFile.type || 'application/octet-stream',
)
uploadedKeys.add(previewUpload.storageKey)
}
const initializeModelGroup = !sku.aiProfile?.currentModelVersionId
await intakeAiAssetRepository.createModelVersionAndBind({
version: {
id: versionId,
assetId: asset.id,
versionNo,
originalFileName: input.file.name,
mimeType: input.file.type || 'application/octet-stream',
fileSize: input.file.size,
checksumSha256,
sourceStorageKey: sourceUpload.storageKey,
sourceUrl: sourceUpload.url,
previewStorageKey: previewUpload.storageKey,
previewUrl: previewUpload.url,
createdBy: input.user.id,
},
selectedSkuDatabaseId: sku.id,
productId: input.productId,
modelValue: sku.model,
previewUrl: previewUpload.url,
createdBy: input.user.id,
initializeModelGroup:
initializeModelGroup && Boolean(trimIntakeAiText(sku.model)),
})
} catch (error) {
await Promise.all(Array.from(uploadedKeys).map((key) => deleteObjectWithResult(key)))
throw error
}
const updatedSku = await intakeAiAssetRepository.findSkuByDatabaseId(sku.id)
if (!updatedSku) {
throw new AppError(ErrorCode.SKU_NOT_FOUND, '模型已上传,但条码刷新失败')
}
return toIntakeAiSkuDTO(updatedSku as unknown as IntakeAiSkuRecord)
}
async saveMaterial(input: {
productId: string
skuDatabaseId: string
materialColor?: string | null
materialTextureUrl?: string | null
nodeMaterials?: unknown
uvConfig?: unknown
user: AuthUser
}): Promise<IntakeAiSkuDTO> {
const sku = await intakeAiAssetRepository.findSkuByDatabaseId(input.skuDatabaseId)
if (!sku) {
throw new AppError(ErrorCode.SKU_NOT_FOUND, '没有找到这个条码')
}
if (sku.productId !== input.productId) {
throw new AppError(ErrorCode.VALIDATION_FAILED, '条码不属于当前产品')
}
await intakeNonIntegratedProductService.assertCanWrite(
input.productId,
input.user,
PRODUCT_PHOTO_WORKSPACE_CATEGORY,
)
const textureUrl = trimIntakeAiText(input.materialTextureUrl) || null
await intakeAiAssetRepository.createMaterialVersion({
skuDatabaseId: sku.id,
createdBy: input.user.id,
materialColor:
trimIntakeAiText(input.materialColor) || DEFAULT_INTAKE_AI_MATERIAL_COLOR,
textureStorageKey: textureUrl ? readObjectStorageKeyFromUrl(textureUrl) : null,
textureUrl,
nodeMaterials: jsonObjectOrNull(input.nodeMaterials),
uvConfig: jsonObjectOrNull(input.uvConfig),
})
const updatedSku = await intakeAiAssetRepository.findSkuByDatabaseId(sku.id)
if (!updatedSku) {
throw new AppError(ErrorCode.SKU_NOT_FOUND, '材质已保存,但条码刷新失败')
}
return toIntakeAiSkuDTO(updatedSku as unknown as IntakeAiSkuRecord)
}
}
export const intakeAiAssetService = new IntakeAiAssetService()