merge: link intake products with AI render assets
This commit is contained in:
commit
f8fe78e2cb
@ -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";
|
||||
@ -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")
|
||||
|
||||
@ -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('')
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 模型上传失败')
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 生图权限',
|
||||
|
||||
@ -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 },
|
||||
)
|
||||
}
|
||||
@ -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 },
|
||||
)
|
||||
}
|
||||
@ -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 },
|
||||
)
|
||||
}
|
||||
@ -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:
|
||||
|
||||
@ -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} />
|
||||
),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
@ -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'
|
||||
}
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
|
||||
@ -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>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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()}
|
||||
/>
|
||||
);
|
||||
|
||||
@ -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[] }>(
|
||||
|
||||
@ -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()
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
@ -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 }))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
}
|
||||
@ -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),
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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),
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
Loading…
x
Reference in New Issue
Block a user