feat(diy): add shared assets, uploaded styles and AI rendering
This commit is contained in:
parent
e0c324ce06
commit
8e14aa5fb1
@ -0,0 +1,18 @@
|
||||
ALTER TABLE "DiyCabinetStyle"
|
||||
ADD COLUMN "modelFileId" TEXT,
|
||||
ADD COLUMN "mainNodeNames" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
|
||||
ADD COLUMN "shelfNodeNames" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
|
||||
ADD COLUMN "modelRotationY" DOUBLE PRECISION NOT NULL DEFAULT 0;
|
||||
ALTER TABLE "DiyCabinetStyle" ADD CONSTRAINT "DiyCabinetStyle_modelFileId_fkey"
|
||||
FOREIGN KEY ("modelFileId") REFERENCES "DiyAssetFile"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE "DiyCabinetVariant"
|
||||
ADD COLUMN "mainMaterialCode" TEXT,
|
||||
ADD COLUMN "shelfMaterialCode" TEXT;
|
||||
ALTER TABLE "DiyCabinetVariant" ADD CONSTRAINT "DiyCabinetVariant_mainMaterialCode_fkey"
|
||||
FOREIGN KEY ("mainMaterialCode") REFERENCES "DiyMaterial"("itemCode") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE "DiyCabinetVariant" ADD CONSTRAINT "DiyCabinetVariant_shelfMaterialCode_fkey"
|
||||
FOREIGN KEY ("shelfMaterialCode") REFERENCES "DiyMaterial"("itemCode") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE "DiyDoorPattern"
|
||||
ADD COLUMN "width" DOUBLE PRECISION,
|
||||
ADD COLUMN "height" DOUBLE PRECISION,
|
||||
ADD COLUMN "thickness" DOUBLE PRECISION;
|
||||
@ -0,0 +1,10 @@
|
||||
-- Uploaded models have a file reference rather than generated panel geometry.
|
||||
-- A cabinet without door openings is a valid model-only cabinet.
|
||||
ALTER TABLE "DiyCabinetStyle"
|
||||
DROP CONSTRAINT "DiyCabinetStyle_cellCount_check",
|
||||
ADD CONSTRAINT "DiyCabinetStyle_cellCount_check"
|
||||
CHECK ("cellCount" >= 0 AND jsonb_typeof(cells) = 'array' AND jsonb_array_length(cells) = "cellCount"),
|
||||
DROP CONSTRAINT "DiyCabinetStyle_panelCount_check",
|
||||
ADD CONSTRAINT "DiyCabinetStyle_panelCount_check"
|
||||
CHECK ("panelCount" >= 0 AND jsonb_typeof(panels) = 'array' AND jsonb_array_length(panels) = "panelCount"
|
||||
AND ("panelCount" > 0 OR "modelFileId" IS NOT NULL));
|
||||
@ -0,0 +1,10 @@
|
||||
ALTER TABLE "DiyMaterial"
|
||||
ADD COLUMN "deletedAt" TIMESTAMP(3),
|
||||
ADD COLUMN "deletedBy" TEXT;
|
||||
|
||||
ALTER TABLE "DiyDoorPattern"
|
||||
ADD COLUMN "deletedAt" TIMESTAMP(3),
|
||||
ADD COLUMN "deletedBy" TEXT;
|
||||
|
||||
CREATE INDEX "DiyMaterial_deletedAt_idx" ON "DiyMaterial"("deletedAt");
|
||||
CREATE INDEX "DiyDoorPattern_deletedAt_idx" ON "DiyDoorPattern"("deletedAt");
|
||||
@ -2782,6 +2782,11 @@ model DiyCabinetStyle {
|
||||
cells Json
|
||||
sortOrder Int
|
||||
variants DiyCabinetVariant[]
|
||||
modelFileId String?
|
||||
model DiyAssetFile? @relation("DiyStyleModel", fields: [modelFileId], references: [id], onDelete: Restrict)
|
||||
mainNodeNames String[] @default([])
|
||||
shelfNodeNames String[] @default([])
|
||||
modelRotationY Float @default(0)
|
||||
@@index([spu, sortOrder])
|
||||
}
|
||||
|
||||
@ -2797,6 +2802,10 @@ model DiyCabinetVariant {
|
||||
sortOrder Int
|
||||
thumbnailFileId String
|
||||
thumbnail DiyAssetFile @relation(fields: [thumbnailFileId], references: [id], onDelete: Restrict)
|
||||
mainMaterialCode String?
|
||||
mainMaterial DiyMaterial? @relation("DiyVariantMainMaterial", fields: [mainMaterialCode], references: [itemCode], onDelete: Restrict)
|
||||
shelfMaterialCode String?
|
||||
shelfMaterial DiyMaterial? @relation("DiyVariantShelfMaterial", fields: [shelfMaterialCode], references: [itemCode], onDelete: Restrict)
|
||||
@@unique([styleCode, name])
|
||||
}
|
||||
|
||||
@ -2812,7 +2821,13 @@ model DiyDoorPattern {
|
||||
sortOrder Int
|
||||
imageFileId String
|
||||
image DiyAssetFile @relation(fields: [imageFileId], references: [id], onDelete: Restrict)
|
||||
width Float?
|
||||
height Float?
|
||||
thickness Float?
|
||||
deletedAt DateTime?
|
||||
deletedBy String?
|
||||
@@index([spu, sortOrder])
|
||||
@@index([deletedAt])
|
||||
}
|
||||
|
||||
model DiyMaterial {
|
||||
@ -2828,7 +2843,12 @@ model DiyMaterial {
|
||||
sortOrder Int
|
||||
textureFileId String?
|
||||
texture DiyAssetFile? @relation(fields: [textureFileId], references: [id], onDelete: Restrict)
|
||||
mainVariants DiyCabinetVariant[] @relation("DiyVariantMainMaterial")
|
||||
shelfVariants DiyCabinetVariant[] @relation("DiyVariantShelfMaterial")
|
||||
deletedAt DateTime?
|
||||
deletedBy String?
|
||||
@@unique([spu, name])
|
||||
@@index([deletedAt])
|
||||
}
|
||||
|
||||
/// 文件索引;url 为平台 /api/storage/object,校验和用于幂等导入。
|
||||
@ -2847,6 +2867,7 @@ model DiyAssetFile {
|
||||
variants DiyCabinetVariant[]
|
||||
patterns DiyDoorPattern[]
|
||||
materials DiyMaterial[]
|
||||
models DiyCabinetStyle[] @relation("DiyStyleModel")
|
||||
@@index([spu])
|
||||
}
|
||||
|
||||
|
||||
@ -13,6 +13,7 @@ interface RenderInspectorPromptTagPickerProps {
|
||||
onImageCountChange: (value: number) => void
|
||||
onAspectRatioChange: (value: string) => void
|
||||
onRefreshRecords: () => void
|
||||
showOutputControls?: boolean
|
||||
}
|
||||
|
||||
interface AiTagOption {
|
||||
@ -96,6 +97,7 @@ export function RenderInspectorPromptTagPicker(props: RenderInspectorPromptTagPi
|
||||
onImageCountChange,
|
||||
onAspectRatioChange,
|
||||
onRefreshRecords,
|
||||
showOutputControls = true,
|
||||
} = props
|
||||
|
||||
const [selectedTags, setSelectedTags] = useState<Record<string, AiTagOption>>({})
|
||||
@ -151,7 +153,7 @@ export function RenderInspectorPromptTagPicker(props: RenderInspectorPromptTagPi
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<select
|
||||
{showOutputControls && <><select
|
||||
value={imageCount}
|
||||
onChange={(event) => onImageCountChange(Number(event.target.value))}
|
||||
className="h-7 rounded-[5px] bg-white/70 px-2 text-[10px] font-bold text-[#2d3335] outline-none ring-1 ring-black/[0.04]"
|
||||
@ -173,7 +175,7 @@ export function RenderInspectorPromptTagPicker(props: RenderInspectorPromptTagPi
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="relative mx-0.5 h-4 w-px bg-black/[0.06]" />
|
||||
<div className="relative mx-0.5 h-4 w-px bg-black/[0.06]" /></>}
|
||||
{AI_OPTIM_TAG_GROUPS.map((group) => {
|
||||
const selectedOption = selectedTags[group.group]
|
||||
const isHovered = hoveredTagGroup === group.group
|
||||
|
||||
@ -198,7 +198,7 @@ function isMaterialPartLocatorReferenceLabel(label: string): boolean {
|
||||
|
||||
export function buildWhiteBackgroundPrompt(params: {
|
||||
spu: AiRenderSpu | null
|
||||
sku: AiRenderSku
|
||||
sku: Pick<AiRenderSku, 'name' | 'nodeMaterials'>
|
||||
userPrompt: string
|
||||
aspectRatio: string
|
||||
cameraView?: AiRenderCameraViewSnapshot | null
|
||||
@ -366,7 +366,7 @@ export function buildEightViewPrompt(params: {
|
||||
|
||||
export function buildLockedScenePrompt(params: {
|
||||
spu: AiRenderSpu | null
|
||||
sku: AiRenderSku
|
||||
sku: Pick<AiRenderSku, 'name' | 'nodeMaterials'>
|
||||
userPrompt: string
|
||||
aspectRatio: string
|
||||
}): string {
|
||||
|
||||
@ -0,0 +1,19 @@
|
||||
import { NextRequest } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { handleRequest, jsonSuccess, jsonError } from '@/server/core/response'
|
||||
import { ErrorCode } from '@/server/core/errors'
|
||||
import { diyAiRenderService } from '@/server/services/diy-ai-render.service'
|
||||
|
||||
const schema = z.object({ prompt: z.string().trim().min(1).max(2000), mode: z.enum(['white-background','scene']),
|
||||
referenceImageDataUrl: z.string().max(17 * 1024 * 1024) })
|
||||
export async function POST(request: NextRequest) {
|
||||
return handleRequest(request, async ctx => {
|
||||
const text = await request.text()
|
||||
if (Buffer.byteLength(text) > 18 * 1024 * 1024) return jsonError(ErrorCode.FILE_TOO_LARGE, '参考图过大', 413, ctx.requestId)
|
||||
let input: unknown
|
||||
try { input = JSON.parse(text) } catch { return jsonError(ErrorCode.VALIDATION_FAILED, '优化参数格式无效', 400, ctx.requestId) }
|
||||
const parsed = schema.safeParse(input)
|
||||
if (!parsed.success) return jsonError(ErrorCode.VALIDATION_FAILED, parsed.error.issues[0]?.message || '优化参数无效', 400, ctx.requestId)
|
||||
return jsonSuccess(await diyAiRenderService.optimize(parsed.data, ctx.user), 200, ctx.requestId)
|
||||
}, { requireAuth: true })
|
||||
}
|
||||
19
furniture-3d-system/src/app/api/diy/ai-renders/route.ts
Normal file
19
furniture-3d-system/src/app/api/diy/ai-renders/route.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { NextRequest } from 'next/server'
|
||||
import { handleRequest, jsonSuccess, jsonError } from '@/server/core/response'
|
||||
import { ErrorCode } from '@/server/core/errors'
|
||||
import { diyAiRenderService } from '@/server/services/diy-ai-render.service'
|
||||
|
||||
export const maxDuration = 300
|
||||
export async function GET(request: NextRequest) {
|
||||
return handleRequest(request, async ctx => jsonSuccess(await diyAiRenderService.list(ctx.user,
|
||||
request.nextUrl.searchParams.get('compositionKey') || undefined, request.nextUrl.searchParams.get('schemeCode') || undefined), 200, ctx.requestId), { requireAuth: true })
|
||||
}
|
||||
export async function POST(request: NextRequest) {
|
||||
return handleRequest(request, async ctx => {
|
||||
const text = await request.text()
|
||||
if (Buffer.byteLength(text) > 35 * 1024 * 1024) return jsonError(ErrorCode.FILE_TOO_LARGE, '出图参考不能超过35MB', 413, ctx.requestId)
|
||||
let input: unknown
|
||||
try { input = JSON.parse(text) } catch { return jsonError(ErrorCode.VALIDATION_FAILED, '出图参数格式无效', 400, ctx.requestId) }
|
||||
return jsonSuccess(await diyAiRenderService.create(input, ctx.user), 201, ctx.requestId)
|
||||
}, { requireAuth: true })
|
||||
}
|
||||
13
furniture-3d-system/src/app/api/diy/catalog/assets/route.ts
Normal file
13
furniture-3d-system/src/app/api/diy/catalog/assets/route.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { NextRequest } from 'next/server'
|
||||
import { handleRequest, jsonSuccess, jsonError } from '@/server/core/response'
|
||||
import { ErrorCode } from '@/server/core/errors'
|
||||
import { diyAssetLifecycleSchema } from '@/lib/diy/management-schema'
|
||||
import { diyAssetLifecycleService } from '@/server/services/diy-asset-lifecycle.service'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
return handleRequest(request, async ctx => {
|
||||
const input = diyAssetLifecycleSchema.safeParse(await request.json().catch(() => null))
|
||||
if (!input.success) return jsonError(ErrorCode.VALIDATION_FAILED, '素材信息不完整,请刷新后重试', 422, ctx.requestId)
|
||||
return jsonSuccess(await diyAssetLifecycleService.change(input.data, ctx.user), 200, ctx.requestId)
|
||||
}, { requireAuth: true })
|
||||
}
|
||||
31
furniture-3d-system/src/app/api/diy/catalog/manage/route.ts
Normal file
31
furniture-3d-system/src/app/api/diy/catalog/manage/route.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import { NextRequest } from 'next/server'
|
||||
import { handleRequest,jsonSuccess,jsonError } from '@/server/core/response'
|
||||
import { ErrorCode } from '@/server/core/errors'
|
||||
import { diyManagementSchema,diyStyleCellsSchema,diyMaterialDeleteSchema } from '@/lib/diy/management-schema'
|
||||
import { diyCatalogManagementService } from '@/server/services/diy-catalog-management.service'
|
||||
import { diyStyleGeometryService } from '@/server/services/diy-style-geometry.service'
|
||||
import { diyMaterialService } from '@/server/services/diy-material.service'
|
||||
|
||||
export async function POST(request:NextRequest) {
|
||||
return handleRequest(request,async ctx=>{
|
||||
const input=diyManagementSchema.safeParse(await request.json().catch(()=>null))
|
||||
if(!input.success)return jsonError(ErrorCode.VALIDATION_FAILED,input.error.issues[0]?.message||'素材信息不完整',422,ctx.requestId)
|
||||
return jsonSuccess(await diyCatalogManagementService.create(input.data,ctx.user),201,ctx.requestId)
|
||||
},{requireAuth:true})
|
||||
}
|
||||
|
||||
export async function PATCH(request:NextRequest) {
|
||||
return handleRequest(request,async ctx=>{
|
||||
const input=diyStyleCellsSchema.safeParse(await request.json().catch(()=>null))
|
||||
if(!input.success)return jsonError(ErrorCode.VALIDATION_FAILED,input.error.issues[0]?.message||'格口信息不完整',422,ctx.requestId)
|
||||
return jsonSuccess(await diyStyleGeometryService.updateCells(input.data,ctx.user),200,ctx.requestId)
|
||||
},{requireAuth:true})
|
||||
}
|
||||
|
||||
export async function DELETE(request:NextRequest) {
|
||||
return handleRequest(request,async ctx=>{
|
||||
const input=diyMaterialDeleteSchema.safeParse(await request.json().catch(()=>null))
|
||||
if(!input.success)return jsonError(ErrorCode.VALIDATION_FAILED,'颜色信息不完整,请刷新后重试',422,ctx.requestId)
|
||||
return jsonSuccess(await diyMaterialService.remove(input.data,ctx.user),200,ctx.requestId)
|
||||
},{requireAuth:true})
|
||||
}
|
||||
@ -0,0 +1,14 @@
|
||||
import { NextRequest } from 'next/server'
|
||||
import { handleRequest, jsonSuccess, jsonError } from '@/server/core/response'
|
||||
import { ErrorCode } from '@/server/core/errors'
|
||||
import { diyMaterialDeleteSchema } from '@/lib/diy/management-schema'
|
||||
import { diyMaterialService } from '@/server/services/diy-material.service'
|
||||
|
||||
export async function GET(request: NextRequest, { params }: { params: Promise<{ itemCode: string }> }) {
|
||||
return handleRequest(request, async ctx => {
|
||||
const { itemCode } = await params
|
||||
const code = diyMaterialDeleteSchema.shape.itemCode.safeParse(itemCode)
|
||||
if (!code.success) return jsonError(ErrorCode.VALIDATION_FAILED, '颜色编码无效', 422, ctx.requestId)
|
||||
return jsonSuccess(await diyMaterialService.usage(code.data, ctx.user), 200, ctx.requestId)
|
||||
}, { requireAuth: true })
|
||||
}
|
||||
@ -0,0 +1,7 @@
|
||||
import { NextRequest } from 'next/server'
|
||||
import { handleRequest, jsonSuccess } from '@/server/core/response'
|
||||
import { diyAssetLifecycleService } from '@/server/services/diy-asset-lifecycle.service'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
return handleRequest(request, async ctx => jsonSuccess(await diyAssetLifecycleService.recycled(ctx.user), 200, ctx.requestId), { requireAuth: true })
|
||||
}
|
||||
@ -6,6 +6,6 @@ import { diySchemeService } from '@/server/services/diy-scheme.service'
|
||||
export async function GET(request: NextRequest) {
|
||||
return handleRequest(request, async ctx => {
|
||||
await diySchemeService.assertView(ctx.user)
|
||||
return jsonSuccess(await diyCatalogService.getCatalog(), 200, ctx.requestId)
|
||||
return jsonSuccess(await diyCatalogService.getWorkbenchCatalog(), 200, ctx.requestId)
|
||||
}, { requireAuth: true })
|
||||
}
|
||||
|
||||
12
furniture-3d-system/src/app/api/diy/catalog/upload/route.ts
Normal file
12
furniture-3d-system/src/app/api/diy/catalog/upload/route.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { NextRequest } from 'next/server'
|
||||
import { handleRequest,jsonSuccess,jsonError } from '@/server/core/response'
|
||||
import { ErrorCode } from '@/server/core/errors'
|
||||
import { diyCatalogUploadService } from '@/server/services/diy-catalog-upload.service'
|
||||
|
||||
export async function POST(request:NextRequest) {
|
||||
return handleRequest(request,async ctx=>{
|
||||
const form=await request.formData(),file=form.get('file'),kind=form.get('kind')
|
||||
if(!(file instanceof File)||(kind!=='image'&&kind!=='model'))return jsonError(ErrorCode.VALIDATION_FAILED,'请选择文件和素材类型',422,ctx.requestId)
|
||||
return jsonSuccess(await diyCatalogUploadService.upload(file,String(form.get('spu')||''),kind,ctx.user),201,ctx.requestId)
|
||||
},{requireAuth:true})
|
||||
}
|
||||
@ -2,10 +2,10 @@
|
||||
|
||||
import { useRef, useState } from 'react'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { History, Save, FileSpreadsheet, Camera, Plus, Move, PanelTop } from 'lucide-react'
|
||||
import { Move, PanelTop } from 'lucide-react'
|
||||
import Button from '@/components/ui/Button'
|
||||
import Modal from '@/components/ui/Modal'
|
||||
import type { DiyAccess, DiyCatalogDTO } from '@/lib/diy/types'
|
||||
import type { DiyAccess, DiyCatalogDTO, CabinetCell } from '@/lib/diy/types'
|
||||
import { useDiyWorkbench } from './hooks/useDiyWorkbench'
|
||||
import { saveDiyScheme } from './lib/api'
|
||||
import { downloadDiyFile } from './lib/download'
|
||||
@ -13,15 +13,34 @@ import DiyCabinetLibrary from './components/DiyCabinetLibrary'
|
||||
import DiyInspector from './components/DiyInspector'
|
||||
import DiyHistoryDialog from './components/DiyHistoryDialog'
|
||||
import DiyBomDialog from './components/DiyBomDialog'
|
||||
import DiyManagementDialog from './components/DiyManagementDialog'
|
||||
import DiyAiPanel from './components/DiyAiPanel'
|
||||
import DiyInspectorTabs from './components/DiyInspectorTabs'
|
||||
import DiyToolbar from './components/DiyToolbar'
|
||||
import { useDiyAiRender } from './hooks/useDiyAiRender'
|
||||
import type { DiyCaptureHandle } from './lib/ai-capture'
|
||||
import type { DiyAiRenderAccess } from '@/lib/diy/ai-render'
|
||||
|
||||
const DiyViewport=dynamic(()=>import('./components/DiyViewport'),{ssr:false,loading:()=> <div className="flex h-full items-center justify-center text-sm text-zinc-500">正在加载 3D 工作台…</div>})
|
||||
export default function DiyWorkbench({ access, catalog }: {access:DiyAccess;catalog:DiyCatalogDTO}) {
|
||||
export default function DiyWorkbench({ access, catalog,canDeleteMaterials=false, aiAccess }: {access:DiyAccess;catalog:DiyCatalogDTO;canDeleteMaterials?:boolean;aiAccess:DiyAiRenderAccess}) {
|
||||
const controller=useDiyWorkbench(catalog),stage=useRef<HTMLDivElement>(null)
|
||||
const captureRef=useRef<DiyCaptureHandle|null>(null),[capturing,setCapturing]=useState(false)
|
||||
const ai=useDiyAiRender(controller,captureRef,aiAccess)
|
||||
const [historyOpen,setHistoryOpen]=useState(false),[bomOpen,setBomOpen]=useState(false),[saveOpen,setSaveOpen]=useState(false)
|
||||
const [confirmAction,setConfirmAction]=useState<'new'|'history'|null>(null)
|
||||
const [name,setName]=useState('SJ0380 组合方案'),[saving,setSaving]=useState(false)
|
||||
const [mobilePanel,setMobilePanel]=useState<'library'|'inspector'|null>(null)
|
||||
const disabled=!access.canConfigure||saving
|
||||
const [name,setName]=useState('组合方案'),[saving,setSaving]=useState(false),[managementOpen,setManagementOpen]=useState(false)
|
||||
const [mobilePanel,setMobilePanel]=useState<'library'|'inspector'|'ai'|null>(null)
|
||||
const [editingStyleCode,setEditingStyleCode]=useState('')
|
||||
const [newDoor,setNewDoor]=useState<({spu:string}&CabinetCell['door'])|undefined>()
|
||||
const [newVariant,setNewVariant]=useState<{styleCode:string;materialCode:string}|undefined>()
|
||||
const editCells=(code:string)=>{setEditingStyleCode(code);setNewDoor(undefined);setNewVariant(undefined);setMobilePanel(null);setManagementOpen(true)}
|
||||
const addPattern=(door:CabinetCell['door'])=>{setEditingStyleCode('');setNewVariant(undefined);setNewDoor({...door,spu:controller.selected?.spu||controller.seriesSpu});setMobilePanel(null);setManagementOpen(true)}
|
||||
const addColor=(styleCode:string,materialCode:string)=>{setEditingStyleCode('');setNewDoor(undefined);setNewVariant({styleCode,materialCode});setMobilePanel(null);setManagementOpen(true)}
|
||||
const disabled=!access.canConfigure||saving||capturing
|
||||
const switchRightPanel=(showAi:boolean)=>{
|
||||
if(showAi){if(!ai.open)void ai.openPanel();if(window.innerWidth<1024)setMobilePanel('ai')}
|
||||
else ai.setOpen(false)
|
||||
}
|
||||
const save=async()=>{
|
||||
if(saving)return;setSaving(true)
|
||||
try{const record=await saveDiyScheme(controller.saveSnapshot(),name,controller.scheme?.code);controller.setSaved(record);setSaveOpen(false)}
|
||||
@ -36,25 +55,19 @@ export default function DiyWorkbench({ access, catalog }: {access:DiyAccess;cata
|
||||
try{const canvas=stage.current?.querySelector('canvas');if(!canvas)throw new Error('3D画面还未就绪')
|
||||
controller.setFeedback({tone:'success',text:'正在生成预览截图…'})
|
||||
canvas.toBlob(blob=>{if(!blob){controller.setFeedback({tone:'error',text:'截图生成失败'});return}
|
||||
downloadDiyFile(blob,'DIY-SJ0380-preview.png');controller.setFeedback({tone:'success',text:'预览截图已开始下载'})},'image/png')
|
||||
downloadDiyFile(blob,'DIY-preview.png');controller.setFeedback({tone:'success',text:'预览截图已开始下载'})},'image/png')
|
||||
}catch(error){controller.setFeedback({tone:'error',text:error instanceof Error?error.message:'截图失败'})}
|
||||
}
|
||||
return <div className="flex h-full min-h-0 flex-col bg-[#f4f6f5]">
|
||||
<header className="flex shrink-0 flex-wrap items-center gap-2 border-b border-zinc-200 bg-white px-4 py-3 lg:px-5">
|
||||
<h1 className="mr-2 text-lg font-semibold text-zinc-800">DIY</h1>
|
||||
<span className="hidden text-xs text-zinc-500 xl:block">{controller.scheme?.name||'SJ0380 组合柜'}{controller.dirty?' · 未保存':''}</span>
|
||||
<div className="ml-auto flex flex-wrap gap-2">
|
||||
<Button variant="plain" size="small" disabled={disabled} onClick={()=>requestAction('new')}><Plus size={15}/>新方案</Button>
|
||||
<Button variant="gray" size="small" disabled={saving} onClick={()=>requestAction('history')}><History size={15}/>我的方案</Button>
|
||||
<Button variant="gray" size="small" disabled={!controller.snapshot.items.length||!access.canDownload} onClick={capture}><Camera size={15}/><span className="hidden sm:inline">截图</span></Button>
|
||||
<Button variant="gray" size="small" disabled={!controller.snapshot.items.length||saving} onClick={()=>setBomOpen(true)}><FileSpreadsheet size={15}/>下单明细</Button>
|
||||
<Button size="small" disabled={disabled||!controller.snapshot.items.length} onClick={()=>{setName(controller.scheme?.name||'SJ0380 组合方案');setSaveOpen(true)}}><Save size={15}/>保存方案</Button>
|
||||
</div>
|
||||
</header>
|
||||
<DiyToolbar name={controller.scheme?.name||'组合方案'} dirty={controller.dirty} hasItems={Boolean(controller.snapshot.items.length)}
|
||||
canConfigure={access.canConfigure} canDownload={access.canDownload} saving={saving} capturing={capturing}
|
||||
onHistory={()=>requestAction('history')} onBom={()=>setBomOpen(true)} onCapture={capture} onNew={()=>requestAction('new')}
|
||||
onManage={()=>{setEditingStyleCode('');setNewDoor(undefined);setNewVariant(undefined);setManagementOpen(true)}}
|
||||
onSave={()=>{setName(controller.scheme?.name||'组合方案');setSaveOpen(true)}}/>
|
||||
<div className="flex min-h-0 flex-1">
|
||||
<aside className="hidden w-[250px] shrink-0 border-r border-zinc-200 bg-white lg:flex"><DiyCabinetLibrary controller={controller} disabled={disabled}/></aside>
|
||||
<div className="relative min-w-0 flex-1" ref={stage}>
|
||||
<DiyViewport controller={controller} disabled={disabled}/>
|
||||
<aside className="hidden w-[250px] shrink-0 border-r border-zinc-200 bg-white lg:flex"><DiyCabinetLibrary controller={controller} disabled={disabled} onAddColor={addColor}/></aside>
|
||||
<div className={`relative min-w-0 flex-1 ${capturing?'pointer-events-none':''}`} ref={stage}>
|
||||
<DiyViewport controller={controller} disabled={disabled} captureRef={captureRef} onCapturing={setCapturing}/>
|
||||
<div className="absolute left-4 top-4 flex items-center gap-1 rounded-full bg-white/80 p-1 shadow-sm ring-1 ring-black/5 backdrop-blur-xl">
|
||||
{(['layout','doors'] as const).map(mode=><button type="button" key={mode} aria-pressed={controller.mode===mode}
|
||||
disabled={disabled} onClick={()=>controller.setMode(mode)} className={`flex items-center gap-1.5 rounded-full px-3 py-2 text-xs ${controller.mode===mode?'bg-zinc-800 text-white':'text-zinc-500'}`}>
|
||||
@ -66,14 +79,20 @@ export default function DiyWorkbench({ access, catalog }: {access:DiyAccess;cata
|
||||
</div>
|
||||
{controller.feedback&&<div role={controller.feedback.tone==='error'?'alert':'status'} className={`absolute left-1/2 top-20 max-w-[90%] -translate-x-1/2 rounded-lg border bg-white px-4 py-2 text-sm shadow-sm ${controller.feedback.tone==='error'?'border-red-200 text-red-600':'border-emerald-200 text-emerald-700'}`}>{controller.feedback.text}</div>}
|
||||
</div>
|
||||
<aside className="hidden w-[280px] shrink-0 border-l border-zinc-200 bg-white lg:block"><DiyInspector controller={controller} disabled={disabled}/></aside>
|
||||
<aside className={`hidden shrink-0 flex-col border-l border-zinc-200 bg-white lg:flex ${ai.open?'w-[360px] xl:w-[420px] 2xl:w-[480px]':'w-[280px]'}`}>
|
||||
{aiAccess.canView&&<DiyInspectorTabs ai={ai.open} disabled={capturing} onChange={switchRightPanel}/>}
|
||||
<div className={`min-h-0 flex-1 ${ai.open?'hidden':'block'}`}><DiyInspector controller={controller} disabled={disabled} onEditCells={editCells} onAddPattern={addPattern} onAddColor={addColor}/></div>
|
||||
{aiAccess.canView&&<div className={`min-h-0 flex-1 ${ai.open?'block':'hidden'}`}><DiyAiPanel ai={ai} name={controller.scheme?.name||'组合方案'} itemCount={controller.snapshot.items.length} doorCount={Object.keys(controller.snapshot.doors).length}/></div>}
|
||||
</aside>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-2 border-t border-zinc-200 bg-white p-2 lg:hidden">
|
||||
<Button size="small" variant="gray" className="flex-1" onClick={()=>setMobilePanel('library')}>添加柜体</Button>
|
||||
<Button size="small" variant="gray" className="flex-1" onClick={()=>setMobilePanel('inspector')}>配色与柜门</Button>
|
||||
{aiAccess.canView&&<Button size="small" variant="gray" disabled={capturing} className="flex-1" onClick={()=>switchRightPanel(true)}>AI 出图</Button>}
|
||||
</div>
|
||||
<DiyHistoryDialog open={historyOpen} onClose={()=>setHistoryOpen(false)} controller={controller}/>
|
||||
<DiyBomDialog open={bomOpen} onClose={()=>setBomOpen(false)} controller={controller} canDownload={access.canDownload}/>
|
||||
{access.canConfigure&&managementOpen&&<DiyManagementDialog open onClose={()=>setManagementOpen(false)} controller={controller} initialStyleCode={editingStyleCode} initialDoor={newDoor} initialVariant={newVariant} canDeleteMaterials={canDeleteMaterials}/>}
|
||||
<Modal isOpen={saveOpen} onClose={()=>{if(!saving)setSaveOpen(false)}} title="保存 DIY 方案" size="small"
|
||||
footer={<Button disabled={saving||!name.trim()} loading={saving} onClick={()=>void save()}>保存新版本</Button>}>
|
||||
<label htmlFor="diy-scheme-name" className="text-sm text-zinc-600">方案名称</label>
|
||||
@ -87,8 +106,8 @@ export default function DiyWorkbench({ access, catalog }: {access:DiyAccess;cata
|
||||
<Button onClick={()=>{const action=confirmAction;setConfirmAction(null);if(action==='new')controller.reset();else setHistoryOpen(true)}}>继续{confirmAction==='new'?'新建':'打开历史'}</Button></>}>
|
||||
<p className="text-sm text-zinc-600">继续操作会放弃当前未保存的配置。</p>
|
||||
</Modal>
|
||||
<Modal isOpen={mobilePanel!==null} onClose={()=>setMobilePanel(null)} title={mobilePanel==='library'?'柜体款式':'配色与柜门'} size="medium">
|
||||
<div className="h-[60vh] overflow-hidden">{mobilePanel==='library'?<DiyCabinetLibrary controller={controller} disabled={disabled}/>:<DiyInspector controller={controller} disabled={disabled}/>}</div>
|
||||
<Modal isOpen={mobilePanel!==null} onClose={()=>{if(mobilePanel==='ai')ai.setOpen(false);setMobilePanel(null)}} title={mobilePanel==='library'?'柜体款式':mobilePanel==='ai'?'AI 出图':'配色与柜门'} size="medium">
|
||||
<div className="h-[60vh] overflow-hidden">{mobilePanel==='library'?<DiyCabinetLibrary controller={controller} disabled={disabled} onAddColor={addColor}/>:mobilePanel==='ai'?<DiyAiPanel ai={ai} name={controller.scheme?.name||'组合方案'} itemCount={controller.snapshot.items.length} doorCount={Object.keys(controller.snapshot.doors).length}/>:<DiyInspector controller={controller} disabled={disabled} onEditCells={editCells} onAddPattern={addPattern} onAddColor={addColor}/>}</div>
|
||||
</Modal>
|
||||
</div>
|
||||
}
|
||||
|
||||
@ -0,0 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
export default function DiyAdvancedSettings({children,label='高级设置',onOpenChange}: {
|
||||
children:ReactNode;label?:string;onOpenChange?:(open:boolean)=>void
|
||||
}) {
|
||||
return <details className="border-t border-zinc-200 pt-3" onToggle={event=>onOpenChange?.(event.currentTarget.open)}>
|
||||
<summary className="cursor-pointer text-xs font-medium text-zinc-500 hover:text-zinc-800">{label}</summary>
|
||||
<div className="mt-4 space-y-4">{children}</div>
|
||||
</details>
|
||||
}
|
||||
39
furniture-3d-system/src/app/diy/components/DiyAiPanel.tsx
Normal file
39
furniture-3d-system/src/app/diy/components/DiyAiPanel.tsx
Normal file
@ -0,0 +1,39 @@
|
||||
'use client'
|
||||
|
||||
import { ImagePlus, Loader2, RefreshCw, Wand2 } from 'lucide-react'
|
||||
import Button from '@/components/ui/Button'
|
||||
import type { DiyAiRenderController } from '../hooks/useDiyAiRender'
|
||||
import DiyAiPromptForm from './DiyAiPromptForm'
|
||||
import DiyAiResults from './DiyAiResults'
|
||||
|
||||
export default function DiyAiPanel({ ai, name, itemCount, doorCount }: { ai:DiyAiRenderController; name:string; itemCount:number; doorCount:number }) {
|
||||
const busy = ai.generating || ai.optimizing || Boolean(ai.stage)
|
||||
const records = ai.records.filter(record => record.mode === ai.mode)
|
||||
return <div className="flex h-full min-h-0 flex-col bg-white" aria-label="DIY AI 出图面板">
|
||||
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto p-4 xl:p-5">
|
||||
<div className="flex items-center justify-between gap-2 text-[11px] text-[#5a6062]/60">
|
||||
<span className="min-w-0 truncate font-semibold text-[#2d3335]">{name}</span><span className="shrink-0">{itemCount} 柜体 · {doorCount} 柜门</span>
|
||||
</div>
|
||||
<DiyAiPromptForm ai={ai} hasItems={itemCount>0}/>
|
||||
{ai.error && <p role="alert" className="rounded-[6px] bg-red-50 px-2.5 py-2 text-[11px] leading-5 text-red-600">{ai.error}</p>}
|
||||
{ai.notice && <p role="status" className="rounded-[6px] bg-[#47646b]/5 px-2.5 py-2 text-[11px] leading-5 text-[#47646b]">{ai.notice}</p>}
|
||||
{ai.stage && <div role="status" className="flex items-start gap-2 rounded-[6px] border border-[#47646b]/10 bg-[#47646b]/5 px-2.5 py-2 text-[11px] leading-5 text-[#47646b]">
|
||||
<Loader2 className="mt-1 h-3.5 w-3.5 shrink-0 animate-spin"/><span>{ai.stage}</span></div>}
|
||||
<section className="space-y-3 border-t border-black/[0.04] pt-4">
|
||||
<div className="flex items-center gap-2"><h3 className="mr-auto text-xs font-semibold text-[#2d3335]">出图记录</h3>
|
||||
<select aria-label="出图记录范围" value={ai.scope} onChange={event => ai.setScope(event.target.value as 'current'|'all')} className="h-7 max-w-28 rounded-[5px] border border-black/[0.04] bg-white px-1.5 text-[10px] text-[#5a6062]">
|
||||
<option value="current">当前方案</option><option value="all">我的全部出图</option></select>
|
||||
<button type="button" aria-label="刷新出图记录" title="刷新出图记录" disabled={ai.loading} onClick={() => void ai.refresh()} className="h-7 w-7 rounded-[5px] text-[#5a6062]/60 hover:bg-black/[0.03]">
|
||||
<RefreshCw size={13} className={`mx-auto ${ai.loading?'animate-spin':''}`}/></button>
|
||||
</div>
|
||||
<DiyAiResults records={records} ai={ai}/>
|
||||
</section>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-2 border-t border-black/[0.04] bg-white p-4">
|
||||
<Button variant="tinted" size="small" className="h-9 shrink-0 rounded-[6px] text-[11px]" disabled={busy||!ai.access.canGenerate||!ai.prompt.trim()||!itemCount}
|
||||
loading={ai.optimizing} onClick={() => void ai.optimize()}><Wand2 size={14}/>优化提示词</Button>
|
||||
<Button size="small" className="h-9 flex-1 rounded-[6px] text-[11px]" disabled={busy||!itemCount||!ai.access.canGenerate} loading={ai.generating} onClick={() => void ai.generate()}>
|
||||
<ImagePlus size={14}/>{ai.generating?'后台生成中':`生成${ai.mode==='scene'?'效果图':'白底图'}`}</Button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@ -0,0 +1,54 @@
|
||||
'use client'
|
||||
|
||||
import { RefreshCw } from 'lucide-react'
|
||||
import Button from '@/components/ui/Button'
|
||||
import { RenderInspectorPromptTagPicker } from '@/app/ai-render-workbench/RenderInspectorPromptTagPicker'
|
||||
import type { DiyAiRenderInput } from '@/lib/diy/ai-render'
|
||||
import type { DiyAiRenderController } from '../hooks/useDiyAiRender'
|
||||
|
||||
const field = 'w-full rounded-[4px] border border-black/[0.04] bg-white px-2.5 py-1.5 text-[11px] leading-[18px] text-[#2d3335] outline-none focus:border-[#47646b]/30 disabled:opacity-50'
|
||||
const box = 'rounded-[6px] border border-black/[0.04] bg-white'
|
||||
export default function DiyAiPromptForm({ ai, hasItems }: { ai:DiyAiRenderController; hasItems:boolean }) {
|
||||
const busy = ai.generating || ai.optimizing || Boolean(ai.stage)
|
||||
return <div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-1 rounded-[7px] bg-black/[0.025] p-1 ring-1 ring-black/[0.03]">
|
||||
{(['white-background','scene'] as const).map(mode => <button key={mode} type="button" aria-pressed={ai.mode===mode} disabled={busy}
|
||||
onClick={() => ai.setMode(mode)} className={`h-8 rounded-[5px] text-[11px] font-semibold transition ${ai.mode===mode?'bg-white text-[#2d3335] shadow-sm ring-1 ring-black/[0.04]':'text-[#5a6062]/60 hover:bg-white/70'}`}>
|
||||
{mode==='scene'?'效果图':'白底图'}</button>)}
|
||||
</div>
|
||||
<details className={box}>
|
||||
<summary className="cursor-pointer px-2.5 py-2 text-[11px] font-semibold text-[#5a6062]/75">当前视角参考图</summary>
|
||||
<div className="px-2.5 pb-2">
|
||||
{ai.preview ? <figure>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={ai.preview.dataUrl} alt="当前方案生图参考" className="h-40 w-full rounded bg-[#f2f4f4] object-contain"/>
|
||||
<figcaption className="mt-1.5 text-[10px] text-[#5a6062]/60">{ai.preview.snapshot.items.length} 柜体 · {Object.keys(ai.preview.snapshot.doors).length} 柜门 · 生成时更新</figcaption>
|
||||
</figure> : <p className="py-4 text-center text-[11px] text-[#5a6062]/45">{hasItems?'正在准备参考图…':'请先添加柜体'}</p>}
|
||||
<Button variant="plain" size="small" className="mt-1 text-[11px]" disabled={busy||!hasItems} onClick={() => void ai.refreshReference()}><RefreshCw size={12}/>更新参考图</Button>
|
||||
</div>
|
||||
</details>
|
||||
<details open className={box}>
|
||||
<summary className="cursor-pointer px-2.5 py-2 text-[11px] font-semibold text-[#5a6062]/75">生图提示词</summary>
|
||||
<div className="px-2.5 pb-2">
|
||||
<textarea aria-label="生图提示词" value={ai.prompt} onChange={event => ai.setPrompt(event.target.value)} disabled={busy} maxLength={2000} rows={5} className={`${field} resize-none`}
|
||||
placeholder={ai.mode==='scene'?'例如:现代简约客厅,自然窗光,柜体完整可见':'留空按当前方案生成纯白底产品图;可补充摄影与光线要求'}/>
|
||||
</div>
|
||||
</details>
|
||||
{ai.mode==='scene' && <RenderInspectorPromptTagPicker showOutputControls={false} prompt={ai.prompt} imageCount={ai.imageCount} aspectRatio={ai.aspectRatio} optimizing={busy}
|
||||
onPromptChange={value => ai.setPrompt(value.slice(0,2000))} onImageCountChange={ai.setImageCount}
|
||||
onAspectRatioChange={value => ai.setAspectRatio(value as DiyAiRenderInput['aspectRatio'])} onRefreshRecords={() => void ai.refresh()}/>}
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<label className="space-y-1 text-[10px] font-medium text-[#5a6062]/60">比例<select aria-label="出图比例" disabled={busy} value={ai.aspectRatio} onChange={event => ai.setAspectRatio(event.target.value as DiyAiRenderInput['aspectRatio'])} className={field}>
|
||||
{['1:1','4:3','3:4','16:9','9:16'].map(value => <option value={value} key={value}>{value}</option>)}</select></label>
|
||||
<label className="space-y-1 text-[10px] font-medium text-[#5a6062]/60">张数<select aria-label="出图张数" disabled={busy} value={ai.imageCount} onChange={event => ai.setImageCount(Number(event.target.value))} className={field}>
|
||||
{[1,2,3,4,5,6,7,8].map(value => <option value={value} key={value}>{value} 张</option>)}</select></label>
|
||||
<label className="space-y-1 text-[10px] font-medium text-[#5a6062]/60">尺寸<select aria-label="出图尺寸" disabled={busy} value={ai.imageSize} onChange={event => ai.setImageSize(event.target.value as '1K'|'2K'|'4K')} className={field}>
|
||||
{['1K','2K','4K'].map(value => <option value={value} key={value}>{value}</option>)}</select></label>
|
||||
</div>
|
||||
<details className={box}><summary className="cursor-pointer px-2.5 py-2 text-[11px] font-semibold text-[#5a6062]/75">反向提示词</summary>
|
||||
<div className="px-2.5 pb-2"><textarea aria-label="反向提示词" value={ai.negativePrompt} disabled={busy} onChange={event => ai.setNegativePrompt(event.target.value)}
|
||||
placeholder="不想出现的内容,可选填" maxLength={1200} rows={3} className={`${field} resize-none`}/></div></details>
|
||||
{ai.optimization && <details className="rounded-[6px] border border-[#47646b]/10 bg-[#47646b]/5"><summary className="cursor-pointer px-2.5 py-2 text-[11px] font-semibold text-[#47646b]">优化说明</summary>
|
||||
<p className="max-h-28 overflow-y-auto whitespace-pre-wrap px-2.5 pb-2 text-[11px] leading-[18px] text-[#2d3335]/70">{ai.optimization}</p></details>}
|
||||
</div>
|
||||
}
|
||||
46
furniture-3d-system/src/app/diy/components/DiyAiResults.tsx
Normal file
46
furniture-3d-system/src/app/diy/components/DiyAiResults.tsx
Normal file
@ -0,0 +1,46 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Download } from 'lucide-react'
|
||||
import Button from '@/components/ui/Button'
|
||||
import type { DiyAiRenderDTO } from '@/lib/diy/ai-render'
|
||||
import type { DiyAiRenderController } from '../hooks/useDiyAiRender'
|
||||
import { RenderRecordGalleryLightbox } from '@/app/ai-render-workbench/RenderRecordGalleryLightbox'
|
||||
|
||||
export default function DiyAiResults({ records, ai }: { records: DiyAiRenderDTO[]; ai: DiyAiRenderController }) {
|
||||
const [large,setLarge] = useState<string|null>(null)
|
||||
useEffect(() => {
|
||||
if (!large) return
|
||||
const close = (event:KeyboardEvent) => { if(event.key==='Escape')setLarge(null) }
|
||||
window.addEventListener('keydown',close); return () => window.removeEventListener('keydown',close)
|
||||
},[large])
|
||||
return <div className="space-y-3" aria-label="DIY 出图结果">
|
||||
{large && <RenderRecordGalleryLightbox imageUrl={large} onClose={() => setLarge(null)}/>}
|
||||
{records.map(record => <section key={record.id} className="space-y-2 border-b border-black/[0.04] pb-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-1 text-[10px] text-[#5a6062]/60">
|
||||
<span className="font-semibold text-[#2d3335]">{record.name} · {record.mode==='scene'?'效果图':'白底图'}</span>
|
||||
<span>{new Date(record.createdAt).toLocaleString('zh-CN',{timeZone:'Asia/Shanghai'})}</span>
|
||||
</div>
|
||||
<p className="text-[10px] text-[#5a6062]/60">{record.snapshot.items.length} 柜体 · {Object.keys(record.snapshot.doors).length} 柜门{record.schemeCode ? ' · 已保存方案' : ' · 生成时方案'}</p>
|
||||
{record.status==='PENDING' && <p role="status" className="text-[11px] text-zinc-500">正在生成…</p>}
|
||||
{record.status==='FAILED' && <p className="text-[11px] text-red-600">{record.errorMessage || '生成失败'}</p>}
|
||||
<div className="grid grid-cols-1 gap-2.5">
|
||||
{record.images.map((image,index) => {
|
||||
const filename = `${record.name}-${record.mode==='scene'?'效果图':'白底图'}-${index+1}.png`
|
||||
return <div key={image.storageKey} className="overflow-hidden rounded-[7px] border border-black/[0.04] bg-white">
|
||||
<button type="button" className="block w-full cursor-zoom-in bg-[#eef2f2]" aria-label={`查看${record.mode==='scene'?'效果图':'白底图'} ${index+1}`} onClick={() => setLarge(image.originalUrl)}>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={image.thumbnailUrl} alt={`${record.name} ${index+1}`} loading="lazy" className="aspect-[16/10] w-full object-contain"/>
|
||||
</button>
|
||||
<div className="flex items-center justify-between px-2 py-1.5 text-xs text-zinc-400"><span>{image.width} × {image.height}</span>
|
||||
{ai.access.canDownload && <Button size="small" variant="plain" loading={ai.downloading===image.originalUrl} disabled={Boolean(ai.downloading)}
|
||||
onClick={() => void ai.download(image.originalUrl,filename)}><Download size={13}/>下载</Button>}
|
||||
</div>
|
||||
</div>
|
||||
})}
|
||||
</div>
|
||||
{record.referenceImageUrl && <button type="button" className="text-[10px] text-[#5a6062]/60 hover:text-[#2d3335]" onClick={() => setLarge(record.referenceImageUrl!)}>查看生成时参考图</button>}
|
||||
</section>)}
|
||||
{!records.length && <p className="py-8 text-center text-[11px] text-[#5a6062]/45">还没有{ai.mode==='scene'?'效果图':'白底图'}记录</p>}
|
||||
</div>
|
||||
}
|
||||
@ -0,0 +1,46 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback,useEffect,useRef,useState } from 'react'
|
||||
import { UploadCloud,Clipboard } from 'lucide-react'
|
||||
import type { DiyAssetDTO } from '@/lib/diy/types'
|
||||
import { uploadDiyAsset } from '../lib/api'
|
||||
|
||||
export default function DiyAssetUpload({spu,kind,value,onChange,onBusyChange,disabled=false,label}: {
|
||||
spu:string;kind:'image'|'model';value:DiyAssetDTO|null;onChange:(asset:DiyAssetDTO)=>void;onBusyChange?:(busy:boolean)=>void;disabled?:boolean;label:string
|
||||
}) {
|
||||
const input=useRef<HTMLInputElement>(null),[busy,setBusy]=useState(false),[error,setError]=useState(''),[fileName,setFileName]=useState('')
|
||||
const upload=useCallback(async(file:File)=>{
|
||||
if(busy||disabled)return
|
||||
setBusy(true);onBusyChange?.(true);setError('')
|
||||
try {const asset=await uploadDiyAsset(file,spu,kind);setFileName(file.name);onChange(asset)}
|
||||
catch(error){setError(error instanceof Error?error.message:'上传失败,请重试')}
|
||||
finally{setBusy(false);onBusyChange?.(false)}
|
||||
},[spu,kind,busy,disabled,onBusyChange,onChange])
|
||||
useEffect(()=>{
|
||||
if(kind!=='image')return
|
||||
const paste=(event:ClipboardEvent)=>{
|
||||
const item=Array.from(event.clipboardData?.items||[]).find(item=>item.type.startsWith('image/')),file=item?.getAsFile()
|
||||
if(file&&!disabled&&!busy){
|
||||
event.preventDefault()
|
||||
const details=input.current?.closest('details');if(details)details.open=true
|
||||
void upload(new File([file],`粘贴图片-${Date.now()}.png`,{type:file.type}))
|
||||
}
|
||||
}
|
||||
document.addEventListener('paste',paste);return()=>document.removeEventListener('paste',paste)
|
||||
},[kind,upload,disabled,busy])
|
||||
return <div className="space-y-2">
|
||||
<span className="block text-xs font-medium text-zinc-600">{label}</span>
|
||||
<input ref={input} aria-label={label} type="file" className="sr-only" accept={kind==='model'?'.zip,.glb,.gltf,.obj':'.png,.jpg,.jpeg,.webp'} disabled={busy||disabled}
|
||||
onChange={event=>{const file=event.target.files?.[0];event.target.value='';if(file)void upload(file)}}/>
|
||||
<button type="button" disabled={busy||disabled||!spu} onClick={()=>input.current?.click()}
|
||||
className="flex min-h-20 w-full items-center justify-center gap-3 rounded-lg border border-dashed border-zinc-300 bg-zinc-50 px-3 py-4 text-sm text-zinc-600 hover:border-zinc-500 disabled:opacity-50">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
{kind==='image'&&value?<img src={value.url} alt={label} className="h-16 w-16 rounded object-cover"/>:<UploadCloud size={20}/>}
|
||||
<span>{busy?'正在上传…':value?'重新上传':kind==='model'?'上传 ZIP / GLB / glTF / OBJ':'选择图片'}
|
||||
{kind==='image'&&<span className="mt-1 flex items-center justify-center gap-1 text-xs text-zinc-400"><Clipboard size={12}/>支持 Ctrl+V 粘贴截图</span>}
|
||||
</span>
|
||||
</button>
|
||||
{value&&<p className="truncate text-xs text-zinc-500">{fileName||'文件已上传'}</p>}
|
||||
{error&&<p role="alert" className="text-xs text-red-600">{error}</p>}
|
||||
</div>
|
||||
}
|
||||
@ -14,8 +14,8 @@ export default function DiyBomDialog({ open,onClose,controller:c,canDownload }:
|
||||
try{
|
||||
await new Promise(resolve=>requestAnimationFrame(resolve))
|
||||
const blob=kind==='csv'?new Blob([diyBomCsv(rows)],{type:'text/csv;charset=utf-8'}):
|
||||
await renderDiyBomImage(rows,c.scheme?.name||'SJ0380 DIY',c.scheme?.code||'')
|
||||
downloadDiyFile(blob,`DIY-${c.scheme?.code||'SJ0380'}-BOM.${kind}`);setMessage('文件已生成,已开始下载')
|
||||
await renderDiyBomImage(rows,c.scheme?.name||'DIY 组合方案',c.scheme?.code||'')
|
||||
downloadDiyFile(blob,`DIY-${c.scheme?.code||'current'}-BOM.${kind}`);setMessage('文件已生成,已开始下载')
|
||||
}catch(error){setMessage(error instanceof Error?error.message:'下载失败')}
|
||||
finally{setBusy(false)}
|
||||
}
|
||||
|
||||
@ -1,36 +1,62 @@
|
||||
'use client'
|
||||
|
||||
import { Plus } from 'lucide-react'
|
||||
import { LoaderCircle, Plus } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import dynamic from 'next/dynamic'
|
||||
import type { DiyWorkbenchController } from '../hooks/useDiyWorkbench'
|
||||
import { useDiyCabinetThumbnails } from '../hooks/useDiyCabinetThumbnails'
|
||||
import { findDiyColorForMaterial } from '@/lib/diy/materials'
|
||||
import DiyMaterialSwatch from './DiyMaterialSwatch'
|
||||
|
||||
export default function DiyCabinetLibrary({ controller, disabled }: { controller: DiyWorkbenchController; disabled: boolean }) {
|
||||
const { styles, mainColors, textures, getCatalogStyle, styleImage } = controller.catalogAccess
|
||||
const DiyCabinetThumbnailRenderer = dynamic(() => import('./DiyCabinetThumbnailRenderer'), { ssr: false })
|
||||
|
||||
export default function DiyCabinetLibrary({ controller, disabled,onAddColor }: { controller: DiyWorkbenchController; disabled: boolean;onAddColor:(styleCode:string,materialCode:string)=>void }) {
|
||||
const { getCatalogStyle } = controller.catalogAccess
|
||||
const [search,setSearch]=useState('')
|
||||
const styles=controller.catalog.styles.filter(style=>(style.spu||'SJ0380')===controller.seriesSpu)
|
||||
const series=controller.catalog.series||[{spu:controller.catalog.spu,name:controller.catalog.name,version:1}]
|
||||
const colors=controller.catalog.materials.filter(material=>material.kind==='main'&&!material.deletedAt)
|
||||
const visibleStyles=styles.filter(style=>`${style.code} ${getCatalogStyle(style.code).name}`.toLowerCase().includes(search.toLowerCase()))
|
||||
const {previews,task,complete,fail}=useDiyCabinetThumbnails(controller.catalog,visibleStyles,controller.familyCode)
|
||||
return <div className="flex min-h-0 flex-col">
|
||||
{task&&<DiyCabinetThumbnailRenderer catalog={controller.catalog} task={task} onComplete={complete} onError={fail}/>}
|
||||
<div className="border-b border-zinc-200 p-4">
|
||||
<h2 className="text-sm font-semibold text-zinc-800">SJ0380 组合柜</h2>
|
||||
<div className="mt-3 flex gap-2">
|
||||
{mainColors.map(color => <button key={color} type="button" title={color} aria-label={`选择主色 ${color}`}
|
||||
aria-pressed={controller.family === color} onClick={() => controller.setFamily(color)} disabled={disabled}
|
||||
className={`h-9 w-9 overflow-hidden rounded-full border-2 ${controller.family === color ? 'border-blue-600 ring-2 ring-blue-100' : 'border-white ring-1 ring-zinc-200'}`}>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={textures[color]} alt={color} className="h-full w-full object-cover" />
|
||||
<label className="text-xs font-medium text-zinc-500" htmlFor="diy-series">柜体系列</label>
|
||||
<select id="diy-series" value={controller.seriesSpu} onChange={event=>{controller.setSeriesSpu(event.target.value);setSearch('')}} className="mt-2 h-10 w-full rounded-md border border-zinc-300 bg-white px-2 text-sm text-zinc-800">
|
||||
{series.map(item=><option key={item.spu} value={item.spu}>{item.name}</option>)}
|
||||
</select>
|
||||
<input aria-label="搜索柜体款式" value={search} onChange={event=>setSearch(event.target.value)} placeholder="搜索款式 / 编码" className="mt-3 h-9 w-full rounded-md border border-zinc-200 px-2 text-xs outline-none focus:border-blue-400"/>
|
||||
<p className="mt-3 text-xs font-medium text-zinc-500">共用颜色</p>
|
||||
<div className="mt-2 flex max-h-28 flex-wrap gap-2 overflow-y-auto">
|
||||
{colors.map(color => <button key={color.itemCode} type="button" title={color.name} aria-label={`选择主色 ${color.name}`}
|
||||
aria-pressed={controller.familyCode === color.itemCode} onClick={() => controller.setFamilyCode(color.itemCode)} disabled={disabled}
|
||||
className={`rounded-md p-0.5 ${controller.familyCode === color.itemCode ? 'ring-2 ring-blue-600' : 'ring-1 ring-zinc-200'}`}>
|
||||
<DiyMaterialSwatch main={color}/>
|
||||
</button>)}
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-zinc-500">{controller.family}</p>
|
||||
{!colors.length&&<p className="mt-2 text-xs text-zinc-500">暂无可用颜色,请在素材管理中添加或从回收站恢复。</p>}
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 space-y-2 overflow-y-auto p-3">
|
||||
{styles.map(style => {
|
||||
{visibleStyles.map(style => {
|
||||
const entry = getCatalogStyle(style.code), count = controller.snapshot.items.filter(item => item.code === style.code).length
|
||||
return <button type="button" key={style.code} onClick={() => controller.add(style.code)} disabled={disabled}
|
||||
const color=findDiyColorForMaterial(controller.catalog,style.code,controller.familyCode)
|
||||
const preview=previews[style.code]
|
||||
return <button type="button" key={style.code} onClick={() => color?controller.add(style.code):onAddColor(style.code,controller.familyCode)} disabled={disabled||!controller.familyCode}
|
||||
aria-label={`添加${entry.name}`} className="group flex w-full items-center gap-3 rounded-lg border border-zinc-200 bg-white p-2 text-left transition hover:border-blue-300 hover:bg-blue-50/30 disabled:opacity-50">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={styleImage(style.code,controller.family)} alt={entry.name} className="h-16 w-16 shrink-0 rounded-md object-contain" />
|
||||
<span className="flex h-16 w-16 shrink-0 items-center justify-center overflow-hidden rounded-md bg-[#f2f4f4]" aria-busy={Boolean(controller.familyCode&&!preview?.image&&!preview?.error)}>
|
||||
{!controller.familyCode?<span className="text-[10px] text-zinc-500">暂无颜色</span>:preview?.image ? /* eslint-disable-next-line @next/next/no-img-element */
|
||||
<img src={preview.image} alt={entry.name} className="h-full w-full object-contain" /> : preview?.error ?
|
||||
<span className="text-[10px] text-zinc-500" title={preview.error}>预览失败</span> : <LoaderCircle size={16} className="animate-spin text-zinc-400" aria-label="正在生成款式预览"/>}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1"><span className="block text-sm font-medium text-zinc-800">{entry.name}</span>
|
||||
<span className="mt-1 block text-[11px] text-zinc-500">{Math.round(style.realSize.width)}×{Math.round(style.realSize.height)}×{Math.round(style.realSize.depth)} mm</span>
|
||||
<span className="mt-1 block text-[11px] text-zinc-500">{style.cellCount} 格{count ? ` · 已选 ${count} 件` : ''}</span>
|
||||
{!color&&controller.familyCode&&<span className="mt-1 block text-[11px] text-blue-600">添加此颜色款</span>}
|
||||
</span><Plus size={16} className="shrink-0 text-zinc-400 group-hover:text-blue-600" />
|
||||
</button>
|
||||
})}
|
||||
{!styles.length&&<p className="px-1 py-6 text-xs text-zinc-500">此系列尚无款式,可通过顶部素材管理添加。</p>}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@ -7,13 +7,20 @@ import * as THREE from 'three'
|
||||
import { buildCabinet, buildDoor, isInteriorShelf } from '@/lib/diy/geometry'
|
||||
import { cellKey, createDiyCatalogAccess } from '@/lib/diy/catalog'
|
||||
import { toThreeGeometry } from '../lib/threeGeometry'
|
||||
import { diyMaterialDisplayColor } from '../lib/material-appearance'
|
||||
import type { CabinetCell, DiyItem, DiyCatalogDTO } from '@/lib/diy/types'
|
||||
import DiyUploadedModel from './DiyUploadedModel'
|
||||
import DiyCellTarget from './DiyCellTarget'
|
||||
import DiyRenderReady from './DiyRenderReady'
|
||||
|
||||
type CabinetPointer = (event: ThreeEvent<PointerEvent>, item: DiyItem) => void
|
||||
interface Props {
|
||||
catalog: DiyCatalogDTO; item: DiyItem; doors: Record<string,string>; selected: boolean; mode: 'layout'|'doors'; disabled: boolean
|
||||
onDown: CabinetPointer; onMove: CabinetPointer; onUp: CabinetPointer
|
||||
onDoor: (id:string,cellId:string) => void
|
||||
onError?: (message:string)=>void
|
||||
onReady?: () => void
|
||||
readyKey?: string
|
||||
}
|
||||
function DoorMesh({ item, cell, code, onClick, catalog }: { catalog:DiyCatalogDTO; item:DiyItem; cell:CabinetCell; code:string; onClick:()=>void }) {
|
||||
const { patterns, getStyle }=useMemo(()=>createDiyCatalogAccess(catalog),[catalog])
|
||||
@ -21,53 +28,61 @@ function DoorMesh({ item, cell, code, onClick, catalog }: { catalog:DiyCatalogDT
|
||||
const loadedTexture=useTexture(pattern.image)
|
||||
const texture=useMemo(()=>{const value=loadedTexture.clone();value.colorSpace=THREE.SRGBColorSpace;value.needsUpdate=true;return value},[loadedTexture])
|
||||
useEffect(()=>()=>texture.dispose(),[texture])
|
||||
const geometry=useMemo(()=>toThreeGeometry(buildDoor(cell,buildCabinet(getStyle(item.code)).center,2),item.quarterTurn,true),[cell,item.code,item.quarterTurn,getStyle])
|
||||
const geometry=useMemo(()=>{const style=getStyle(item.code);return toThreeGeometry(buildDoor(cell,style.modelUrl?[0,0,0]:buildCabinet(style).center,2),item.quarterTurn,true)},[cell,item.code,item.quarterTurn,getStyle])
|
||||
useEffect(()=>()=>geometry.dispose(),[geometry])
|
||||
return <mesh geometry={geometry} onClick={event=>{event.stopPropagation();onClick()}}>
|
||||
<meshStandardMaterial map={texture} roughness={.85} />
|
||||
</mesh>
|
||||
}
|
||||
export default function DiyCabinetMesh({ catalog, item, doors, selected, mode, disabled, onDown, onMove, onUp, onDoor }:Props) {
|
||||
const { getStyle, textures, colorHex }=useMemo(()=>createDiyCatalogAccess(catalog),[catalog])
|
||||
const style=getStyle(item.code), [main,shelf]=item.colorName.split('+')
|
||||
const loadedTexture=useTexture(textures[main])
|
||||
export default function DiyCabinetMesh({ catalog, item, doors, selected, mode, disabled, onDown, onMove, onUp, onDoor,onError,onReady,readyKey='' }:Props) {
|
||||
const { getStyle,materialsForItem }=useMemo(()=>createDiyCatalogAccess(catalog),[catalog])
|
||||
const style=getStyle(item.code), {main,shelf}=materialsForItem(item)
|
||||
const rotateGrain=main?.name==='榆木黑色'||main?.name==='柚木色'
|
||||
const loadedTexture=useTexture(main?.texture||'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7')
|
||||
const loadedShelfTexture=useTexture(shelf?.texture||'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7')
|
||||
const shelfTexture=useMemo(()=>{const value=loadedShelfTexture.clone();value.colorSpace=THREE.SRGBColorSpace;value.wrapS=value.wrapT=THREE.RepeatWrapping;value.needsUpdate=true;return value},[loadedShelfTexture])
|
||||
useEffect(()=>()=>shelfTexture.dispose(),[shelfTexture])
|
||||
const texture=useMemo(()=>{
|
||||
const value=loadedTexture.clone();value.colorSpace=THREE.SRGBColorSpace
|
||||
value.wrapS=value.wrapT=THREE.RepeatWrapping;value.anisotropy=8
|
||||
if(main==='榆木黑色'||main==='柚木色'){value.center.set(.5,.5);value.rotation=Math.PI/2}
|
||||
if(rotateGrain){value.center.set(.5,.5);value.rotation=Math.PI/2}
|
||||
value.needsUpdate=true;return value
|
||||
},[loadedTexture,main])
|
||||
const geometry=useMemo(()=>({
|
||||
},[loadedTexture,rotateGrain])
|
||||
const geometry=useMemo(()=>style.modelUrl?null:({
|
||||
body:toThreeGeometry(buildCabinet(style,panel=>!isInteriorShelf(panel)),item.quarterTurn),
|
||||
shelf:toThreeGeometry(buildCabinet(style,isInteriorShelf),item.quarterTurn),
|
||||
}),[style,item.quarterTurn])
|
||||
useEffect(()=>()=>{geometry.body.dispose();geometry.shelf.dispose()},[geometry])
|
||||
useEffect(()=>()=>{geometry?.body.dispose();geometry?.shelf.dispose()},[geometry])
|
||||
useEffect(()=>()=>texture.dispose(),[texture])
|
||||
const center=useMemo(()=>buildCabinet(style).center,[style])
|
||||
const shelfMap=shelf?(shelf.texture?shelfTexture:null):(main?.texture?texture:null)
|
||||
const center=useMemo(()=>style.modelUrl?[0,0,0]:buildCabinet(style).center,[style])
|
||||
const bounds=useMemo(()=>{
|
||||
geometry.body.computeBoundingBox();const box=geometry.body.boundingBox!
|
||||
return new THREE.EdgesGeometry(new THREE.BoxGeometry(...box.getSize(new THREE.Vector3()).toArray() as [number,number,number]))
|
||||
},[geometry])
|
||||
const size=style.realSize,turned=item.quarterTurn%2
|
||||
const box=new THREE.BoxGeometry((turned?size.height:size.width)*.01,(turned?size.width:size.height)*.01,size.depth*.01)
|
||||
const edges=new THREE.EdgesGeometry(box);box.dispose();return edges
|
||||
},[style,item.quarterTurn])
|
||||
useEffect(()=>()=>bounds.dispose(),[bounds])
|
||||
const angle=item.quarterTurn*Math.PI/2
|
||||
return <group position={[-item.offsetY,item.offsetZ,0]}
|
||||
onPointerDown={event=>onDown(event,item)} onPointerMove={event=>onMove(event,item)}
|
||||
onPointerUp={event=>onUp(event,item)} onPointerCancel={event=>onUp(event,item)}>
|
||||
<mesh geometry={geometry.body} castShadow receiveShadow>
|
||||
<meshStandardMaterial map={texture} color={main==='榆木黑色'?'#505353':'#ffffff'} roughness={.9} />
|
||||
</mesh>
|
||||
{geometry.shelf.index?.count ? <mesh geometry={geometry.shelf} castShadow receiveShadow>
|
||||
<meshStandardMaterial color={colorHex[shelf]||colorHex[main]} roughness={.9}/>
|
||||
{style.modelUrl?<DiyUploadedModel style={style} main={main} shelf={shelf} turns={item.quarterTurn} onError={onError} onAppearanceReady={onReady} appearanceReadyKey={readyKey}/>:geometry&&<mesh geometry={geometry.body} castShadow receiveShadow>
|
||||
<meshStandardMaterial map={main?.texture?texture:null} color={diyMaterialDisplayColor(main)} roughness={.9} />
|
||||
</mesh>}
|
||||
{geometry?.shelf.index?.count ? <mesh geometry={geometry.shelf} castShadow receiveShadow>
|
||||
<meshStandardMaterial map={shelfMap} color={diyMaterialDisplayColor(shelf||main)} roughness={.9}/>
|
||||
</mesh> : null}
|
||||
{selected && <lineSegments geometry={bounds}><lineBasicMaterial color="#3b82f6" transparent opacity={.85}/></lineSegments>}
|
||||
{!style.modelUrl&&onReady&&<DiyRenderReady readyKey={JSON.stringify([readyKey,catalog.sourceHash,item.code,main?.itemCode,main?.texture,main?.colorHex,shelf?.itemCode,shelf?.texture,shelf?.colorHex])} onReady={onReady}/>}
|
||||
{style.cells.map(cell=>{
|
||||
const key=cellKey(item.instanceId,cell.id), door=doors[key]
|
||||
const y=(cell.center[0]-center[1])*.01, z=(cell.center[1]-center[2])*.01
|
||||
const ry=y*Math.cos(angle)-z*Math.sin(angle), rz=y*Math.sin(angle)+z*Math.cos(angle)
|
||||
return door ? <DoorMesh catalog={catalog} key={key} item={item} cell={cell} code={door} onClick={()=>{if(mode==='doors'&&!disabled)onDoor(item.instanceId,cell.id)}}/> : mode==='doors' && !disabled ?
|
||||
<mesh key={key} position={[-ry,rz,style.realSize.depth*.005+.04]} onClick={event=>{event.stopPropagation();onDoor(item.instanceId,cell.id)}}>
|
||||
<circleGeometry args={[.12,32]}/><meshBasicMaterial color="#22c55e" transparent opacity={.9} depthTest={false}/>
|
||||
</mesh>:null
|
||||
<DiyCellTarget key={key} position={[-ry,rz,style.realSize.depth*.005+.04]}
|
||||
width={(item.quarterTurn%2?cell.inner.height:cell.inner.width)*.01}
|
||||
height={(item.quarterTurn%2?cell.inner.width:cell.inner.height)*.01}
|
||||
onClick={()=>onDoor(item.instanceId,cell.id)}/>:null
|
||||
})}
|
||||
</group>
|
||||
}
|
||||
|
||||
@ -0,0 +1,38 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import type { DiyCatalogDTO } from '@/lib/diy/types'
|
||||
import type { DiyCabinetPreviewRequest } from '../lib/cabinet-previews'
|
||||
import { captureDiyThumbnail } from '../lib/capture'
|
||||
import DiyVariantPreview from './DiyVariantPreview'
|
||||
|
||||
function imageDataUrl(blob: Blob) {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => resolve(reader.result as string)
|
||||
reader.onerror = () => reject(new Error('款式预览生成失败'))
|
||||
reader.readAsDataURL(blob)
|
||||
})
|
||||
}
|
||||
|
||||
export default function DiyCabinetThumbnailRenderer({ catalog, task, onComplete, onError }: {
|
||||
catalog: DiyCatalogDTO; task: DiyCabinetPreviewRequest
|
||||
onComplete: (key: string, image: string) => void; onError: (key: string, error: string) => void
|
||||
}) {
|
||||
const stage = useRef<HTMLDivElement>(null)
|
||||
const [readyKey, setReadyKey] = useState('')
|
||||
const ready = useCallback(() => setReadyKey(task.key), [task.key])
|
||||
const fail = useCallback((message: string) => onError(task.key, message), [task.key, onError])
|
||||
useEffect(() => {
|
||||
if (readyKey !== task.key) return
|
||||
let active = true
|
||||
captureDiyThumbnail(stage.current).then(imageDataUrl).then(image => {
|
||||
if (active) onComplete(task.key, image)
|
||||
}).catch(error => { if (active) fail(error instanceof Error ? error.message : '款式预览生成失败') })
|
||||
return () => { active = false }
|
||||
}, [readyKey, task.key, onComplete, fail])
|
||||
return <div aria-hidden="true" ref={stage} className="pointer-events-none fixed -left-[10000px] top-0 h-48 w-48">
|
||||
<DiyVariantPreview catalog={catalog} styleCode={task.styleCode} mainCode={task.mainCode} shelfCode={task.shelfCode}
|
||||
className="h-48 w-48" interactive={false} onReady={ready} onError={fail} />
|
||||
</div>
|
||||
}
|
||||
36
furniture-3d-system/src/app/diy/components/DiyCameraRig.tsx
Normal file
36
furniture-3d-system/src/app/diy/components/DiyCameraRig.tsx
Normal file
@ -0,0 +1,36 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect,useMemo,useRef } from 'react'
|
||||
import { useThree } from '@react-three/fiber'
|
||||
import { OrbitControls } from '@react-three/drei'
|
||||
import type { OrbitControls as Controls } from 'three-stdlib'
|
||||
import * as THREE from 'three'
|
||||
import type { DiySnapshot } from '@/lib/diy/types'
|
||||
import type { DiyWorkbenchController } from '../hooks/useDiyWorkbench'
|
||||
|
||||
export default function DiyCameraRig({snapshot,recordCode,enabled,onCamera,layout}: {
|
||||
snapshot:DiySnapshot;recordCode:string;enabled:boolean;onCamera:(view:DiySnapshot['camera'])=>void;layout:DiyWorkbenchController['layout']
|
||||
}) {
|
||||
const {itemSize}=layout,{camera,size}=useThree(),controls=useRef<Controls>(null)
|
||||
const countKey=snapshot.items.map(item=>item.instanceId+item.quarterTurn).join(',')
|
||||
const bounds=useMemo(()=>{
|
||||
const min=Math.min(0,...snapshot.items.map(item=>-item.offsetY-itemSize(item).width/2))
|
||||
const max=Math.max(0,...snapshot.items.map(item=>-item.offsetY+itemSize(item).width/2))
|
||||
const height=Math.max(3.34,...snapshot.items.map(item=>item.offsetZ+itemSize(item).height/2))
|
||||
const depth=Math.max(3,...snapshot.items.map(item=>itemSize(item).depth)),sphereRadius=Math.hypot(max-min,height,depth)/2
|
||||
const halfFov=THREE.MathUtils.degToRad(35/2),limitingAngle=Math.min(halfFov,Math.atan(Math.tan(halfFov)*size.width/Math.max(1,size.height)))
|
||||
return {center:new THREE.Vector3((min+max)/2,height/2,0),radius:sphereRadius/Math.sin(limitingAngle)*1.15}
|
||||
},[snapshot.items,size.width,size.height,itemSize])
|
||||
const current=useRef({bounds,snapshot})
|
||||
useEffect(()=>{current.current={bounds,snapshot}},[bounds,snapshot])
|
||||
useEffect(()=>{
|
||||
const {bounds:b,snapshot:s}=current.current,view=s.camera,distance=b.radius*view.zoom,pitch=view.pitch??0
|
||||
const target=b.center.clone().add(new THREE.Vector3(...(view.target||[0,0,0])))
|
||||
camera.position.set(target.x+Math.sin(view.yaw)*Math.cos(pitch)*distance,target.y+Math.sin(pitch)*distance,target.z+Math.cos(view.yaw)*Math.cos(pitch)*distance)
|
||||
camera.lookAt(target);controls.current?.target.copy(target);controls.current?.update()
|
||||
},[countKey,recordCode,camera,size.width,size.height])
|
||||
return <OrbitControls ref={controls} makeDefault enabled={enabled} enableDamping dampingFactor={.1}
|
||||
enablePan minPolarAngle={.02} maxPolarAngle={Math.PI-.02} minDistance={bounds.radius*.2} maxDistance={bounds.radius*5}
|
||||
onEnd={()=>{const control=controls.current;if(control)onCamera({yaw:control.getAzimuthalAngle(),pitch:Math.PI/2-control.getPolarAngle(),
|
||||
zoom:Math.max(.2,Math.min(5,control.getDistance()/bounds.radius)),target:control.target.clone().sub(bounds.center).toArray() as [number,number,number]})}}/>
|
||||
}
|
||||
@ -0,0 +1,53 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useFrame, useThree } from '@react-three/fiber'
|
||||
import type { OrbitControls } from 'three-stdlib'
|
||||
import * as THREE from 'three'
|
||||
import type { DiyCaptureRef, DiyReferenceCapture } from '../lib/ai-capture'
|
||||
|
||||
export interface DiyCaptureRequest {
|
||||
id: string; ready: Set<string>; itemCount: number
|
||||
position: THREE.Vector3; target: THREE.Vector3; damping: boolean
|
||||
resolve: (value: DiyReferenceCapture) => void; reject: (error: Error) => void
|
||||
}
|
||||
export default function DiyCaptureBridge({ captureRef, itemCount, request, onRequest, onCapturing }: {
|
||||
captureRef: DiyCaptureRef; itemCount: number; request: DiyCaptureRequest | null
|
||||
onRequest: (value: DiyCaptureRequest | null) => void; onCapturing: (value: boolean) => void
|
||||
}) {
|
||||
const { camera, gl, scene, controls: rawControls } = useThree()
|
||||
const controls = rawControls as OrbitControls | undefined
|
||||
const pending = useRef<DiyCaptureRequest | null>(null)
|
||||
const frames = useRef(0)
|
||||
useEffect(() => {
|
||||
captureRef.current = { capture: () => new Promise((resolve, reject) => {
|
||||
if (pending.current) { reject(new Error('正在准备另一张参考图')); return }
|
||||
if (!itemCount || !controls) { reject(new Error('请先添加柜体并等待3D加载')); return }
|
||||
const damping = controls.enableDamping
|
||||
controls.enableDamping = false
|
||||
const finish = () => {
|
||||
clearTimeout(timeout); controls.enableDamping = damping
|
||||
pending.current = null; onRequest(null); onCapturing(false)
|
||||
}
|
||||
const next: DiyCaptureRequest = { id: crypto.randomUUID(), ready: new Set(), itemCount,
|
||||
position: camera.position.clone(), target: controls.target.clone(), damping,
|
||||
resolve: value => { finish(); resolve(value) }, reject: error => { finish(); reject(error) } }
|
||||
frames.current = 0; pending.current = next; onCapturing(true); onRequest(next)
|
||||
const timeout = setTimeout(() => next.reject(new Error('模型或贴图尚未加载完成,请稍后重试')), 30000)
|
||||
}) }
|
||||
return () => { captureRef.current = null; pending.current?.reject(new Error('3D预览已关闭')) }
|
||||
}, [camera, controls, captureRef, itemCount, onCapturing, onRequest])
|
||||
useFrame(() => {
|
||||
if (!request || pending.current !== request || !controls) return
|
||||
camera.position.copy(request.position); controls.target.copy(request.target); camera.lookAt(request.target)
|
||||
if (request.ready.size < request.itemCount) { frames.current = 0; return }
|
||||
if (++frames.current < 3) return
|
||||
try {
|
||||
gl.render(scene, camera)
|
||||
const dataUrl = gl.domElement.toDataURL('image/png')
|
||||
request.resolve({ dataUrl, cameraView: { position: request.position.toArray() as [number,number,number],
|
||||
target: request.target.toArray() as [number,number,number] } })
|
||||
} catch (error) { request.reject(error instanceof Error ? error : new Error('参考图截图失败')) }
|
||||
})
|
||||
return null
|
||||
}
|
||||
@ -0,0 +1,36 @@
|
||||
'use client'
|
||||
import { useState } from 'react'
|
||||
import Button from '@/components/ui/Button'
|
||||
import type { CabinetCell,CabinetStyle } from '@/lib/diy/types'
|
||||
import { gridCells } from '@/lib/diy/management-schema'
|
||||
import { useDiyCellDetection } from '../hooks/useDiyCellDetection'
|
||||
import DiyCellEditor from './DiyCellEditor'
|
||||
import { DiyField,diyFieldClass } from './DiyManagementFields'
|
||||
|
||||
export default function DiyCellConfiguration({style,ready,automatic=true,onChange,busy=false,onBusyChange,compact=false}: {
|
||||
style:CabinetStyle;ready:boolean;automatic?:boolean;onChange:(cells:CabinetCell[])=>void;busy?:boolean;onBusyChange?:(busy:boolean)=>void;compact?:boolean
|
||||
}) {
|
||||
const [rows,setRows]=useState<number|null>(null),[cols,setCols]=useState<number|null>(null)
|
||||
const [thickness,setThickness]=useState(style.cells[0]?.door.thickness||15),[gap,setGap]=useState(style.cells[0]?Math.max(0,(style.cells[0].inner.width-style.cells[0].door.width)/2):2)
|
||||
const detection=useDiyCellDetection(style,ready,automatic,onChange,thickness,gap,onBusyChange)
|
||||
const rowCount=rows??detection.result?.rows??1,colCount=cols??detection.result?.cols??1
|
||||
return <fieldset disabled={busy||detection.detecting} className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-2"><h3 className="text-sm font-medium text-zinc-700">{compact?'模型隔间':'贴门格口'} · {style.cells.length} 个</h3>
|
||||
<Button type="button" size="small" variant="gray" loading={detection.detecting} disabled={!ready||busy||detection.detecting} onClick={()=>void detection.detect()}>{compact?'重新识别隔间':'重新识别格口'}</Button></div>
|
||||
<p role={detection.error?'alert':'status'} className={`text-xs ${detection.error?'text-red-600':'text-zinc-500'}`}>
|
||||
{detection.detecting?'正在分析模型,识别格口…':detection.error||detection.result?.message||(style.cells.length?'已读取保存的格口,可重新识别或校准':'模型读取后将自动识别格口')}
|
||||
</p>
|
||||
<details className="rounded-md border border-zinc-200 p-3"><summary className="cursor-pointer text-xs text-zinc-600">{compact?'隔间校准(选填)':'手动校准与门片间隙'}</summary>
|
||||
<div className="mt-3 grid grid-cols-2 gap-3">
|
||||
<DiyField label="门片单边间隙(mm)"><input type="number" min={0} max={10} step="any" value={gap} onChange={event=>setGap(Number(event.target.value))} className={diyFieldClass}/></DiyField>
|
||||
<DiyField label="门片厚度(mm)"><input type="number" min={1} max={100} step="any" value={thickness} onChange={event=>setThickness(Number(event.target.value))} className={diyFieldClass}/></DiyField>
|
||||
<DiyField label="格口行数"><input type="number" min={0} max={10} value={rowCount} onChange={event=>setRows(Number(event.target.value))} className={diyFieldClass}/></DiyField>
|
||||
<DiyField label="格口列数"><input type="number" min={0} max={10} value={colCount} onChange={event=>setCols(Number(event.target.value))} className={diyFieldClass}/></DiyField>
|
||||
</div>
|
||||
<Button type="button" size="small" variant="gray" className="mt-3" onClick={()=>onChange(gridCells(style.realSize.width,style.realSize.height,style.realSize.depth,rowCount,colCount,thickness).map(cell=>({...cell,door:{...cell.door,width:cell.inner.width-gap*2,height:cell.inner.height-gap*2}})))}>生成 / 更新格口</Button>
|
||||
{compact&&<div className="mt-3"><DiyCellEditor cells={style.cells} onChange={onChange}/></div>}
|
||||
</details>
|
||||
{!compact&&<DiyCellEditor cells={style.cells} onChange={onChange}/>}
|
||||
{ready&&!detection.detecting&&!style.cells.length&&<p className="text-xs text-amber-700">当前没有贴门位置;保存为无格口款式后只能拼搭和配色。</p>}
|
||||
</fieldset>
|
||||
}
|
||||
15
furniture-3d-system/src/app/diy/components/DiyCellEditor.tsx
Normal file
15
furniture-3d-system/src/app/diy/components/DiyCellEditor.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
'use client'
|
||||
import type { CabinetCell } from '@/lib/diy/types'
|
||||
import { diyFieldClass } from './DiyManagementFields'
|
||||
export default function DiyCellEditor({cells,onChange}: {cells:CabinetCell[];onChange:(cells:CabinetCell[])=>void}) {
|
||||
const change=(index:number,key:'x'|'y'|'width'|'height',value:number)=>onChange(cells.map((cell,i)=>i===index?{
|
||||
...cell,center:key==='x'?[value,cell.center[1]]:key==='y'?[cell.center[0],value]:cell.center,
|
||||
door:key==='width'?{...cell.door,width:value}:key==='height'?{...cell.door,height:value}:cell.door,
|
||||
}:cell))
|
||||
return cells.length?<div className="max-h-40 overflow-auto rounded-md border border-zinc-200"><table className="w-full min-w-[480px] text-xs">
|
||||
<thead className="sticky top-0 bg-zinc-100 text-left text-zinc-500"><tr>{['格口','水平位置','垂直位置','门宽','门高'].map(label=><th key={label} className="p-2 font-medium">{label}</th>)}</tr></thead>
|
||||
<tbody>{cells.map((cell,index)=><tr key={cell.id}><td className="px-2 text-zinc-500">{cell.id}</td>{(['x','y','width','height'] as const).map(key=><td key={key} className="p-1">
|
||||
<input aria-label={`${cell.id} ${key}`} className={`${diyFieldClass} min-w-20 px-1`} type="number" step="any" value={Number((key==='x'?cell.center[0]:key==='y'?cell.center[1]:cell.door[key]).toFixed(2))}
|
||||
onChange={event=>change(index,key,Number(event.target.value))}/></td>)}</tr>)}</tbody>
|
||||
</table></div>:<p className="text-xs text-zinc-500">未设置格口,此款式仅参与拼搭和配色。</p>
|
||||
}
|
||||
21
furniture-3d-system/src/app/diy/components/DiyCellGuide.tsx
Normal file
21
furniture-3d-system/src/app/diy/components/DiyCellGuide.tsx
Normal file
@ -0,0 +1,21 @@
|
||||
'use client'
|
||||
import { useEffect,useMemo } from 'react'
|
||||
import * as THREE from 'three'
|
||||
import { Html } from '@react-three/drei'
|
||||
import type { CabinetCell } from '@/lib/diy/types'
|
||||
|
||||
export default function DiyCellGuide({cell,depth}: {cell:CabinetCell;depth:number}) {
|
||||
const geometry=useMemo(()=>{
|
||||
const width=cell.inner.width*.005,height=cell.inner.height*.005
|
||||
return new THREE.BufferGeometry().setFromPoints([
|
||||
new THREE.Vector3(-width,-height,0),new THREE.Vector3(width,-height,0),new THREE.Vector3(width,height,0),
|
||||
new THREE.Vector3(-width,height,0),new THREE.Vector3(-width,-height,0),
|
||||
])
|
||||
},[cell.inner.width,cell.inner.height])
|
||||
useEffect(()=>()=>geometry.dispose(),[geometry])
|
||||
return <group position={[-cell.center[0]*.01,cell.center[1]*.01,depth*.005+.08]}>
|
||||
<lineLoop geometry={geometry}><lineBasicMaterial color="#16a34a" depthTest={false}/></lineLoop>
|
||||
<mesh><circleGeometry args={[.09,24]}/><meshBasicMaterial color="#22c55e" depthTest={false}/></mesh>
|
||||
<Html center style={{pointerEvents:'none'}}><span className="whitespace-nowrap rounded bg-green-50/95 px-1 text-[10px] text-green-700">{cell.id}</span></Html>
|
||||
</group>
|
||||
}
|
||||
31
furniture-3d-system/src/app/diy/components/DiyCellTarget.tsx
Normal file
31
furniture-3d-system/src/app/diy/components/DiyCellTarget.tsx
Normal file
@ -0,0 +1,31 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import * as THREE from 'three'
|
||||
|
||||
export default function DiyCellTarget({ position, width, height, onClick }: {
|
||||
position: [number, number, number]; width: number; height: number; onClick: () => void
|
||||
}) {
|
||||
const outline = useMemo(() => {
|
||||
const plane = new THREE.PlaneGeometry(width, height)
|
||||
const edges = new THREE.EdgesGeometry(plane)
|
||||
plane.dispose()
|
||||
return edges
|
||||
}, [width, height])
|
||||
useEffect(() => () => outline.dispose(), [outline])
|
||||
|
||||
return <group position={position} onClick={event => { event.stopPropagation(); onClick() }}>
|
||||
{/* Keep the entire opening clickable without tinting the material behind it. */}
|
||||
<mesh renderOrder={1}>
|
||||
<planeGeometry args={[width, height]} />
|
||||
<meshBasicMaterial colorWrite={false} depthTest={false} depthWrite={false} />
|
||||
</mesh>
|
||||
<lineSegments geometry={outline} renderOrder={2}>
|
||||
<lineBasicMaterial color="#22c55e" depthTest={false} depthWrite={false} />
|
||||
</lineSegments>
|
||||
<mesh renderOrder={3}>
|
||||
<circleGeometry args={[.14, 32]} />
|
||||
<meshBasicMaterial color="#22c55e" transparent opacity={.9} depthTest={false} depthWrite={false} />
|
||||
</mesh>
|
||||
</group>
|
||||
}
|
||||
30
furniture-3d-system/src/app/diy/components/DiyColorForm.tsx
Normal file
30
furniture-3d-system/src/app/diy/components/DiyColorForm.tsx
Normal file
@ -0,0 +1,30 @@
|
||||
'use client'
|
||||
import { useState } from 'react'
|
||||
import Button from '@/components/ui/Button'
|
||||
import type { DiyAssetDTO } from '@/lib/diy/types'
|
||||
import type { DiyManagementInput } from '@/lib/diy/management-schema'
|
||||
import DiyAssetUpload from './DiyAssetUpload'
|
||||
import { DiyField,diyFieldClass } from './DiyManagementFields'
|
||||
import DiyAdvancedSettings from './DiyAdvancedSettings'
|
||||
|
||||
export default function DiyColorForm({spu,onSave,busy,onUploadBusy}: {
|
||||
spu:string;onSave:(input:DiyManagementInput)=>Promise<boolean>;busy:boolean;onUploadBusy:(busy:boolean)=>void
|
||||
}) {
|
||||
const [name,setName]=useState('')
|
||||
const [colorHex,setColorHex]=useState('#c5b59b'),[kind,setKind]=useState<'main'|'shelf'>('main'),[texture,setTexture]=useState<DiyAssetDTO|null>(null)
|
||||
return <form className="max-w-xl space-y-4" onSubmit={async event=>{
|
||||
event.preventDefault();if(busy)return
|
||||
if(await onSave({kind:'material',spu,name,materialKind:kind,colorHex,textureFileId:texture?.id||null})){setName('');setTexture(null)}
|
||||
}}>
|
||||
<fieldset disabled={busy} className="space-y-4">
|
||||
<DiyField label="颜色名称"><input required value={name} onChange={event=>setName(event.target.value)} className={diyFieldClass} placeholder="例如 奶油白"/></DiyField>
|
||||
<DiyField label="颜色"><div className="flex gap-2"><input aria-label="选择颜色" type="color" value={colorHex} onChange={event=>setColorHex(event.target.value)} className="h-9 w-14 shrink-0 rounded border border-zinc-300"/>
|
||||
<input aria-label="颜色色值" required pattern="#[0-9a-fA-F]{6}" value={colorHex} onChange={event=>setColorHex(event.target.value)} className={diyFieldClass}/></div></DiyField>
|
||||
<DiyAssetUpload spu={spu} kind="image" label="颜色贴图(选填)" value={texture} onChange={setTexture} onBusyChange={onUploadBusy} disabled={busy}/>
|
||||
<DiyAdvancedSettings>
|
||||
<DiyField label="换色范围"><select value={kind} onChange={event=>setKind(event.target.value as typeof kind)} className={diyFieldClass}><option value="main">整个柜体</option><option value="shelf">单独的内部板件</option></select></DiyField>
|
||||
</DiyAdvancedSettings>
|
||||
</fieldset>
|
||||
<Button type="submit" disabled={busy} loading={busy}>添加颜色</Button>
|
||||
</form>
|
||||
}
|
||||
@ -0,0 +1,27 @@
|
||||
import { resolveDiyMaterials } from '@/lib/diy/catalog'
|
||||
import { findDiyColorForMaterial } from '@/lib/diy/materials'
|
||||
import type { DiyWorkbenchController } from '../hooks/useDiyWorkbench'
|
||||
import DiyMaterialSwatch from './DiyMaterialSwatch'
|
||||
import { diyColorIsAvailable } from '@/lib/diy/asset-availability'
|
||||
|
||||
export default function DiyColorOptions({ controller: c, disabled, onAddColor }: {
|
||||
controller: DiyWorkbenchController; disabled: boolean; onAddColor: (styleCode: string, materialCode: string) => void
|
||||
}) {
|
||||
if (!c.selected) return null
|
||||
const selected = c.selected, style = c.catalogAccess.getCatalogStyle(selected.code)
|
||||
const available = c.catalog.materials.filter(material => material.kind === 'main' && !material.deletedAt && !findDiyColorForMaterial(c.catalog, style.code, material.itemCode))
|
||||
return <div className="mt-3 max-h-60 space-y-1.5 overflow-y-auto" aria-label="柜体共用配色">
|
||||
{style.colors.filter(color=>selected.colorName===color.name||diyColorIsAvailable(c.catalog,style.code,color.name)).map(color => {
|
||||
const { main, shelf } = resolveDiyMaterials(c.catalog, { code: style.code, colorName: color.name })
|
||||
return <button type="button" key={color.itemCode} onClick={() => c.setColor(selected.instanceId, color.name)} disabled={disabled}
|
||||
aria-label={`柜体颜色 ${color.name}`} aria-pressed={selected.colorName === color.name}
|
||||
className={`flex w-full items-center gap-2 rounded-md border p-2 text-left text-xs ${selected.colorName === color.name ? 'border-blue-400 bg-blue-50' : 'border-zinc-200'}`}>
|
||||
<DiyMaterialSwatch main={main} shelf={shelf} className="h-8 w-8" /><span className="min-w-0 truncate">{color.name}{main?.deletedAt||shelf?.deletedAt?' · 已删除':''}</span>
|
||||
</button>
|
||||
})}
|
||||
{available.map(material => <button type="button" key={material.itemCode} disabled={disabled} aria-label={`添加颜色款 ${style.code} ${material.itemCode}`}
|
||||
onClick={() => onAddColor(style.code, material.itemCode)} className="flex w-full items-center gap-2 rounded-md border border-zinc-200 p-2 text-left text-xs hover:border-blue-300">
|
||||
<DiyMaterialSwatch main={material} className="h-8 w-8" /><span className="min-w-0 flex-1 truncate">{material.name}</span><span className="shrink-0 text-[10px] text-blue-600">添加颜色款</span>
|
||||
</button>)}
|
||||
</div>
|
||||
}
|
||||
@ -3,32 +3,42 @@
|
||||
import { RotateCw, Trash2, Layers } from 'lucide-react'
|
||||
import Button from '@/components/ui/Button'
|
||||
import type { DiyWorkbenchController } from '../hooks/useDiyWorkbench'
|
||||
import { doorPatternFits } from '@/lib/diy/catalog'
|
||||
import type { CabinetCell } from '@/lib/diy/types'
|
||||
import DiyColorOptions from './DiyColorOptions'
|
||||
|
||||
export default function DiyInspector({ controller: c, disabled }: { controller: DiyWorkbenchController; disabled: boolean }) {
|
||||
const { colorHex, patterns, getCatalogStyle } = c.catalogAccess
|
||||
export default function DiyInspector({ controller: c, disabled, onEditCells, onAddPattern,onAddColor }: {
|
||||
controller: DiyWorkbenchController; disabled: boolean; onEditCells:(code:string)=>void; onAddPattern:(door:CabinetCell['door'])=>void
|
||||
onAddColor:(styleCode:string,materialCode:string)=>void
|
||||
}) {
|
||||
const { patterns, getCatalogStyle,getStyle } = c.catalogAccess
|
||||
const style=c.selected?getStyle(c.selected.code):null
|
||||
const visiblePatterns=patterns.filter(pattern=>!pattern.deletedAt&&(!style||style.cells.some(cell=>doorPatternFits(pattern,cell))))
|
||||
const doorSizes=[...new Map(style?.cells.map(cell=>[`${cell.door.width} × ${cell.door.height} × ${cell.door.thickness}`,cell.door])).values()]
|
||||
return <div className="flex h-full min-h-0 flex-col bg-white">
|
||||
<section className="shrink-0 border-b border-zinc-200 p-4">
|
||||
<h2 className="text-sm font-semibold text-zinc-800">当前柜体</h2>
|
||||
{c.selected ? <>
|
||||
<p className="mt-2 text-sm text-zinc-600">{getCatalogStyle(c.selected.code).name} · {c.selected.code}</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{style?.modelUrl&&<Button size="small" variant="gray" disabled={disabled} onClick={()=>onEditCells(c.selected!.code)}>编辑格口</Button>}
|
||||
<Button size="small" variant="gray" disabled={disabled} onClick={() => c.rotate(c.selectedId)}><RotateCw size={14} />横竖切换</Button>
|
||||
<Button size="small" variant="gray" disabled={disabled} onClick={c.stackSelected}><Layers size={14} />叠放</Button>
|
||||
<Button size="small" variant="plain" disabled={disabled} onClick={() => c.remove(c.selectedId)}><Trash2 size={14} />删除</Button>
|
||||
</div>
|
||||
<div className="mt-3 space-y-1.5">{getCatalogStyle(c.selected.code).colors.map(color => <button type="button" key={color.name}
|
||||
onClick={() => c.setColor(c.selectedId,color.name)} disabled={disabled} aria-pressed={c.selected?.colorName === color.name}
|
||||
className={`flex w-full items-center gap-2 rounded-md border p-2 text-left text-xs ${c.selected?.colorName === color.name ? 'border-blue-400 bg-blue-50' : 'border-zinc-200'}`}>
|
||||
<span className="h-5 w-5 shrink-0 rounded-full border border-black/10" style={{ background: color.name.includes('+') ? `linear-gradient(135deg,${colorHex[color.name.split('+')[0]]} 50%,${colorHex[color.name.split('+')[1]]} 50%)` : colorHex[color.name] }} />
|
||||
{color.name}
|
||||
</button>)}</div>
|
||||
<DiyColorOptions controller={c} disabled={disabled} onAddColor={onAddColor}/>
|
||||
</> : <p className="mt-2 text-xs leading-5 text-zinc-500">点击画面中的柜体,查看颜色与摆放操作。</p>}
|
||||
</section>
|
||||
<section className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="shrink-0 px-4 pb-2 pt-4"><h2 className="text-sm font-semibold text-zinc-800">柜门图案</h2>
|
||||
<p className="mt-1 text-xs text-zinc-500">选择图案后,切换贴门并点击格口。</p></div>
|
||||
<p className="mt-1 text-xs text-zinc-500">{style&&!style.cells.length?'当前柜体尚未配置格口,请点击“编辑格口”自动识别。':'选择图案后,切换贴门并点击格口。'}</p>
|
||||
{doorSizes.length>0&&<div className="mt-2 max-h-28 space-y-2 overflow-y-auto">{doorSizes.map(door=><div key={`${door.width}-${door.height}-${door.thickness}`} className="flex flex-wrap items-center gap-1 text-xs text-zinc-500">
|
||||
<span className="mr-auto">门片 {door.width} × {door.height} × {door.thickness} mm</span>
|
||||
<button type="button" disabled={disabled} className="text-blue-600 hover:underline disabled:opacity-50" onClick={()=>onAddPattern(door)}>新增适配柜门</button>
|
||||
</div>)}</div>}
|
||||
</div>
|
||||
<div className="grid min-h-0 grid-cols-4 gap-2 overflow-y-auto p-3">
|
||||
{patterns.map(pattern => <button type="button" key={pattern.itemCode} disabled={disabled} title={pattern.name}
|
||||
{visiblePatterns.map(pattern => <button type="button" key={pattern.itemCode} disabled={disabled} title={pattern.name}
|
||||
aria-label={`柜门图案 ${pattern.name}`} aria-pressed={c.patternCode === pattern.itemCode}
|
||||
onClick={() => { c.setPatternCode(pattern.itemCode); c.setMode('doors') }}
|
||||
className={`rounded-md border p-1 ${c.patternCode === pattern.itemCode ? 'border-blue-500 bg-blue-50 ring-1 ring-blue-200' : 'border-zinc-200'}`}>
|
||||
@ -36,6 +46,7 @@ export default function DiyInspector({ controller: c, disabled }: { controller:
|
||||
<img src={pattern.image} alt={pattern.name} className="aspect-square w-full rounded object-cover" />
|
||||
<span className="mt-1 block truncate text-[10px] text-zinc-500">{pattern.itemCode.slice(-3)}</span>
|
||||
</button>)}
|
||||
{!visiblePatterns.length&&Boolean(style?.cells.length)&&<p className="col-span-4 py-3 text-xs text-zinc-500">暂无与当前格口尺寸匹配的柜门。</p>}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@ -0,0 +1,10 @@
|
||||
'use client'
|
||||
|
||||
export default function DiyInspectorTabs({ ai, onChange, disabled=false }: { ai:boolean; onChange:(ai:boolean)=>void; disabled?:boolean }) {
|
||||
return <div className="shrink-0 border-b border-black/[0.04] p-3">
|
||||
<div role="tablist" aria-label="DIY 右侧面板" className="grid grid-cols-2 gap-1 rounded-[8px] bg-black/[0.025] p-1 ring-1 ring-black/[0.03]">
|
||||
{[{key:false,label:'配色与柜门'},{key:true,label:'AI 出图'}].map(tab => <button key={tab.label} type="button" role="tab" aria-selected={ai===tab.key} disabled={disabled}
|
||||
onClick={() => onChange(tab.key)} className={`h-8 rounded-[6px] text-[11px] font-semibold transition ${ai===tab.key?'bg-white text-[#2d3335] shadow-sm ring-1 ring-black/[0.04]':'text-[#5a6062]/55 hover:bg-white/70 hover:text-[#2d3335]'}`}>{tab.label}</button>)}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@ -0,0 +1,84 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import Modal from '@/components/ui/Modal'
|
||||
import Button from '@/components/ui/Button'
|
||||
import type { DiyManagementInput,DiyStyleCellsInput } from '@/lib/diy/management-schema'
|
||||
import type { DiyWorkbenchController } from '../hooks/useDiyWorkbench'
|
||||
import { createDiyMaterialEntry,updateDiyStyleCells } from '../lib/api'
|
||||
import DiySeriesForm from './DiySeriesForm'
|
||||
import DiyColorForm from './DiyColorForm'
|
||||
import DiyPatternForm from './DiyPatternForm'
|
||||
import DiyStyleForm from './DiyStyleForm'
|
||||
import DiyStyleCellEditor from './DiyStyleCellEditor'
|
||||
import DiyVariantForm from './DiyVariantForm'
|
||||
import { diyFieldClass } from './DiyManagementFields'
|
||||
import type { CabinetCell } from '@/lib/diy/types'
|
||||
import DiySharedColorLibrary from './DiySharedColorLibrary'
|
||||
import DiyPatternLibrary from './DiyPatternLibrary'
|
||||
import DiyRecycleBin from './DiyRecycleBin'
|
||||
|
||||
const tabs=[['series','系列'],['styles','款式模型'],['colors','颜色'],['patterns','柜门贴图'],['recycle','回收站']] as const
|
||||
export default function DiyManagementDialog({open,onClose,controller:c,initialStyleCode='',initialDoor,initialVariant,canDeleteMaterials=false}: {
|
||||
open:boolean;onClose:()=>void;controller:DiyWorkbenchController;initialStyleCode?:string;initialDoor?:({spu:string}&CabinetCell['door'])
|
||||
initialVariant?:{styleCode:string;materialCode:string};canDeleteMaterials?:boolean
|
||||
}) {
|
||||
const [tab,setTab]=useState<typeof tabs[number][0]>(initialDoor?'patterns':'styles'),[spu,setSpu]=useState(initialDoor?.spu||c.catalog.styles.find(style=>style.code===(initialVariant?.styleCode||initialStyleCode))?.spu||c.seriesSpu),[saving,setSaving]=useState(false),[uploading,setUploading]=useState(false)
|
||||
const [editingCode,setEditingCode]=useState(initialStyleCode)
|
||||
const [variantCode,setVariantCode]=useState(initialVariant?.styleCode||'')
|
||||
const [message,setMessage]=useState(''),[failed,setFailed]=useState(false),busy=saving||uploading
|
||||
const onSave=async(input:DiyManagementInput)=>{
|
||||
setSaving(true);setMessage('正在保存素材…');setFailed(false)
|
||||
try {
|
||||
const result=await createDiyMaterialEntry(input),next=await c.reloadCatalog()
|
||||
if(input.kind==='series')setSpu(result.spu)
|
||||
if(input.kind==='material'&&input.materialKind==='main'&&result.code)c.setFamilyCode(result.code)
|
||||
if(input.kind==='variant') {
|
||||
c.setFamilyCode(input.mainMaterialCode)
|
||||
const color=next.catalogStyles.find(style=>style.code===input.styleCode)?.colors.find(color=>color.itemCode===input.itemCode)
|
||||
if(color&&c.selected?.code===input.styleCode)c.setColor(c.selectedId,color.name)
|
||||
setVariantCode('')
|
||||
}
|
||||
setMessage(input.kind==='material'?'颜色已添加,可在各系列款式中共用':result.code?`已添加 · ${result.code}`:'已添加,素材库已更新');return true
|
||||
}
|
||||
catch(error){setFailed(true);setMessage(error instanceof Error?error.message:'保存失败');return false}
|
||||
finally{setSaving(false)}
|
||||
}
|
||||
const series=c.catalog.series||[{spu:c.catalog.spu,name:c.catalog.name,version:c.catalog.version}]
|
||||
const onSaveCells=async(input:DiyStyleCellsInput)=>{
|
||||
setSaving(true);setMessage('正在保存格口配置…');setFailed(false)
|
||||
try{await updateDiyStyleCells(input);await c.reloadCatalog();setMessage('格口已更新,画布已同步');return true}
|
||||
catch(error){setFailed(true);setMessage(error instanceof Error?error.message:'格口保存失败');return false}
|
||||
finally{setSaving(false)}
|
||||
}
|
||||
const existing=tab==='series'?series.map(item=>({code:item.spu,name:item.name})):tab==='styles'?c.catalog.catalogStyles.filter(item=>(item.spu||'SJ0380')===spu).map(item=>({code:item.code,name:item.name})):
|
||||
tab==='colors'?[]:c.catalog.patterns.filter(item=>(item.spu||'SJ0380')===spu).map(item=>({code:item.itemCode,name:item.name}))
|
||||
const doorSize=initialDoor?.spu===spu?initialDoor:c.catalog.styles.find(style=>(style.spu||'SJ0380')===spu&&style.cells.length)?.cells[0].door
|
||||
return <Modal isOpen={open} onClose={()=>{if(!busy)onClose()}} title="DIY 素材管理" size="xlarge" footer={<>
|
||||
<span role={failed?'alert':'status'} className={`mr-auto text-xs ${failed?'text-red-600':'text-zinc-500'}`}>{message}</span>
|
||||
<Button variant="gray" disabled={busy} onClick={onClose}>完成</Button>
|
||||
</>}>
|
||||
<div className="flex flex-wrap items-center gap-2 border-b border-zinc-200 pb-3">
|
||||
{tabs.filter(([value])=>value!=='recycle'||canDeleteMaterials).map(([value,label])=><Button type="button" key={value} size="small" variant={tab===value?'filled':'gray'} aria-pressed={tab===value} disabled={busy} onClick={()=>{setTab(value);setMessage('');setEditingCode('');setVariantCode('')}}>{label}</Button>)}
|
||||
{tab!=='colors'&&tab!=='recycle'&&<label className="ml-auto flex items-center gap-2 text-xs text-zinc-500">所属系列<select aria-label="素材所属系列" value={spu} disabled={busy} onChange={event=>{setSpu(event.target.value);setEditingCode('');setVariantCode('');setMessage('')}} className={`${diyFieldClass} max-w-60`}>{series.map(item=><option key={item.spu} value={item.spu}>{item.name}</option>)}</select></label>}
|
||||
</div>
|
||||
<div className="max-h-[65vh] overflow-y-auto py-4">
|
||||
{open&&tab!=='recycle'&&<div key={`${tab}-${spu}`}>
|
||||
{tab==='series'?<DiySeriesForm busy={busy} onSave={onSave}/>:tab==='styles'?editingCode?
|
||||
<DiyStyleCellEditor key={`${editingCode}-${series.find(item=>item.spu===spu)?.version}`} code={editingCode} catalog={c.catalog} snapshot={c.snapshot} busy={busy} onSave={onSaveCells} onCancel={()=>setEditingCode('')}/>:
|
||||
variantCode?<DiyVariantForm key={variantCode} spu={spu} styleCode={variantCode} initialMainCode={variantCode===initialVariant?.styleCode?initialVariant.materialCode:undefined} catalog={c.catalog} busy={busy} onSave={onSave} onUploadBusy={setUploading} onCancel={()=>setVariantCode('')}/>:
|
||||
<DiyStyleForm spu={spu} catalog={c.catalog} busy={busy} onSave={onSave} onUploadBusy={setUploading}/>:
|
||||
tab==='colors'?<DiyColorForm spu={spu} busy={busy} onSave={onSave} onUploadBusy={setUploading}/>:<DiyPatternForm spu={spu} busy={busy} onSave={onSave} onUploadBusy={setUploading} initialSize={doorSize}/>}
|
||||
</div>}
|
||||
{tab==='recycle'?<DiyRecycleBin busy={busy} onBusyChange={setSaving} onChanged={async()=>{await c.reloadCatalog()}}/>:
|
||||
tab==='patterns'?<DiyPatternLibrary key={spu} catalog={c.catalog} spu={spu} canDelete={canDeleteMaterials} busy={busy} onBusyChange={setSaving} onChanged={async()=>{await c.reloadCatalog()}}/>:
|
||||
tab==='colors'?<DiySharedColorLibrary catalog={c.catalog} canDelete={canDeleteMaterials} busy={busy} onBusyChange={setSaving} onDeleted={async()=>{await c.reloadCatalog()}}/>:
|
||||
<section className="mt-6 border-t border-zinc-200 pt-3"><h3 className="mb-2 text-xs font-medium text-zinc-500">已有{tabs.find(([value])=>value===tab)?.[1]}</h3>
|
||||
<div className="max-h-40 overflow-y-auto divide-y divide-zinc-100">{existing.map(item=><div key={item.code} className="flex flex-wrap items-center gap-3 py-2 text-xs"><span className="text-zinc-700">{item.name}</span><span className="text-zinc-400">{item.code}</span>
|
||||
{tab==='styles'&&<div className="ml-auto flex items-center gap-2"><Button type="button" size="small" variant="gray" disabled={busy} aria-label={`添加颜色款 ${item.code}`} onClick={()=>{setVariantCode(item.code);setEditingCode('');setMessage('')}}>添加颜色款</Button>
|
||||
{c.catalog.styles.find(style=>style.code===item.code)?.modelUrl&&<Button type="button" size="small" variant="gray" disabled={busy} aria-label={`编辑格口 ${item.code}`} onClick={()=>{setEditingCode(item.code);setVariantCode('');setMessage('')}}>编辑格口</Button>}</div>}</div>)}
|
||||
{!existing.length&&<p className="py-2 text-xs text-zinc-400">此系列尚未添加素材</p>}</div>
|
||||
</section>}
|
||||
</div>
|
||||
</Modal>
|
||||
}
|
||||
@ -0,0 +1,8 @@
|
||||
'use client'
|
||||
import { cloneElement,isValidElement,type ReactNode } from 'react'
|
||||
export const diyFieldClass='h-9 w-full rounded-md border border-zinc-300 bg-white px-2.5 text-sm text-zinc-700 outline-none focus:border-blue-500 disabled:bg-zinc-100'
|
||||
export function DiyField({label,children}: {label:string;children:ReactNode}) {
|
||||
const field=isValidElement<{'aria-label'?:string}>(children)&&typeof children.type==='string'&&['input','select','textarea'].includes(children.type)
|
||||
?cloneElement(children,{'aria-label':children.props['aria-label']||label}):children
|
||||
return <label className="block space-y-1.5"><span className="block text-xs font-medium text-zinc-600">{label}</span>{field}</label>
|
||||
}
|
||||
@ -0,0 +1,15 @@
|
||||
import type { DiyMaterialDTO } from '@/lib/diy/types'
|
||||
import { diyTextureTint } from '../lib/material-appearance'
|
||||
|
||||
export default function DiyMaterialSwatch({ main, shelf, className = 'h-9 w-9' }: {
|
||||
main?: DiyMaterialDTO; shelf?: DiyMaterialDTO; className?: string
|
||||
}) {
|
||||
const materials = shelf ? [main, shelf] : [main]
|
||||
return <span className={`inline-flex shrink-0 overflow-hidden rounded-md border border-black/10 ${className}`}>
|
||||
{materials.map((material, index) => <span key={index} className="relative isolate h-full min-w-0 flex-1" style={{ backgroundColor: material?.colorHex || '#e4e4e7' }}>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
{material?.texture && <img src={material.texture} alt={`${material.name}纹理`} className="h-full w-full object-cover" />}
|
||||
{diyTextureTint(material) && <span aria-hidden="true" className="pointer-events-none absolute inset-0 mix-blend-multiply" style={{ backgroundColor: diyTextureTint(material) }} />}
|
||||
</span>)}
|
||||
</span>
|
||||
}
|
||||
@ -0,0 +1,16 @@
|
||||
'use client'
|
||||
|
||||
import type { CabinetStyle,DiyMaterialDTO } from '@/lib/diy/types'
|
||||
import type { modelDimensions } from '../lib/model'
|
||||
import DiyUploadedModel from './DiyUploadedModel'
|
||||
import DiyPreviewStage from './DiyPreviewStage'
|
||||
import DiyCellGuide from './DiyCellGuide'
|
||||
export default function DiyModelSetupPreview({style,main,shelf,selected,onNode,onReady,onError,showCells=true}: {
|
||||
style:CabinetStyle;main?:DiyMaterialDTO;shelf?:DiyMaterialDTO;selected:string[];onNode:(name:string)=>void;
|
||||
onReady:(data:ReturnType<typeof modelDimensions>)=>void;onError:(message:string)=>void;showCells?:boolean
|
||||
}) {
|
||||
return <DiyPreviewStage dimensions={style.realSize}>
|
||||
<DiyUploadedModel style={style} main={main} shelf={shelf} highlighted={selected} onNode={onNode} onReady={onReady} onError={onError}/>
|
||||
{showCells&&style.cells.map(cell=><DiyCellGuide key={cell.id} cell={cell} depth={style.realSize.depth}/>)}
|
||||
</DiyPreviewStage>
|
||||
}
|
||||
@ -0,0 +1,25 @@
|
||||
'use client'
|
||||
import { useState } from 'react'
|
||||
import Button from '@/components/ui/Button'
|
||||
import type { DiyAssetDTO, CabinetCell } from '@/lib/diy/types'
|
||||
import type { DiyManagementInput } from '@/lib/diy/management-schema'
|
||||
import DiyAssetUpload from './DiyAssetUpload'
|
||||
import DiyAdvancedSettings from './DiyAdvancedSettings'
|
||||
import { DiyField,diyFieldClass } from './DiyManagementFields'
|
||||
export default function DiyPatternForm({spu,onSave,busy,onUploadBusy,initialSize}: {spu:string;onSave:(input:DiyManagementInput)=>Promise<boolean>;busy:boolean;onUploadBusy:(busy:boolean)=>void;initialSize?:CabinetCell['door']}) {
|
||||
const [name,setName]=useState(''),[itemCode,setItemCode]=useState(''),[barcode,setBarcode]=useState('')
|
||||
const [width,setWidth]=useState(initialSize?.width||300),[height,setHeight]=useState(initialSize?.height||300),[thickness,setThickness]=useState(initialSize?.thickness||15),[image,setImage]=useState<DiyAssetDTO|null>(null)
|
||||
return <form className="max-w-xl space-y-4" onSubmit={async event=>{event.preventDefault();if(!busy&&image&&await onSave({kind:'pattern',spu,name,itemCode,barcode,imageFileId:image.id,width,height,thickness})){setName('');setItemCode('');setBarcode('');setImage(null)}}}>
|
||||
<fieldset disabled={busy} className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-3"><DiyField label="柜门名称"><input required value={name} onChange={event=>setName(event.target.value)} className={diyFieldClass}/></DiyField>
|
||||
<DiyField label="柜门货号"><input required value={itemCode} onChange={event=>setItemCode(event.target.value.trim())} className={diyFieldClass}/></DiyField></div>
|
||||
<DiyAssetUpload spu={spu} kind="image" label="柜门贴图" value={image} onChange={setImage} onBusyChange={onUploadBusy} disabled={busy}/>
|
||||
<p className="text-xs text-zinc-500">柜门尺寸:{width} × {height} × {thickness} mm</p>
|
||||
<DiyAdvancedSettings label="高级设置(尺寸、条码)">
|
||||
<div className="grid grid-cols-3 gap-3">{[['宽度(mm)',width,setWidth],['高度(mm)',height,setHeight],['厚度(mm)',thickness,setThickness]].map(([label,value,setter])=><DiyField key={String(label)} label={String(label)}><input type="number" required min={1} max={10000} step="any" value={value as number} onChange={event=>(setter as (value:number)=>void)(Number(event.target.value))} className={diyFieldClass}/></DiyField>)}</div>
|
||||
<DiyField label="条码(选填)"><input value={barcode} onChange={event=>setBarcode(event.target.value)} className={diyFieldClass}/></DiyField>
|
||||
</DiyAdvancedSettings>
|
||||
</fieldset>
|
||||
<Button type="submit" disabled={busy||!image} loading={busy}>添加柜门贴图</Button>
|
||||
</form>
|
||||
}
|
||||
@ -0,0 +1,47 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Trash2 } from 'lucide-react'
|
||||
import Button from '@/components/ui/Button'
|
||||
import type { DiyCatalogDTO, DoorPattern } from '@/lib/diy/types'
|
||||
import { changeDiyAssetLifecycle } from '../lib/api'
|
||||
|
||||
export default function DiyPatternLibrary({ catalog, spu, canDelete, busy, onBusyChange, onChanged }: {
|
||||
catalog: DiyCatalogDTO; spu: string; canDelete: boolean; busy: boolean
|
||||
onBusyChange: (busy: boolean) => void; onChanged: () => Promise<void>
|
||||
}) {
|
||||
const patterns = catalog.patterns.filter(pattern => (pattern.spu || 'SJ0380') === spu && !pattern.deletedAt)
|
||||
const [selected, setSelected] = useState<DoorPattern | null>(null)
|
||||
const [message, setMessage] = useState(''), [failed, setFailed] = useState(false)
|
||||
const confirmation = useRef<HTMLDivElement>(null)
|
||||
useEffect(() => { if (selected) { confirmation.current?.focus({ preventScroll: true }); confirmation.current?.scrollIntoView({ block: 'nearest' }) } }, [selected])
|
||||
const remove = async () => {
|
||||
if (!selected || busy) return
|
||||
onBusyChange(true); setFailed(false); setMessage('正在删除柜门图案…')
|
||||
try {
|
||||
const revision = catalog.series?.find(series => series.spu === (selected.spu || 'SJ0380'))?.version || catalog.version
|
||||
await changeDiyAssetLifecycle({ kind: 'pattern', action: 'delete', itemCode: selected.itemCode, revision })
|
||||
await onChanged(); setMessage(`“${selected.name}”已移入回收站,可随时恢复`); setSelected(null)
|
||||
} catch (error) { setFailed(true); setMessage(error instanceof Error ? error.message : '删除失败'); setSelected(null) }
|
||||
finally { onBusyChange(false) }
|
||||
}
|
||||
return <section className="mt-6 border-t border-zinc-200 pt-3">
|
||||
<h3 className="mb-2 text-xs font-medium text-zinc-500">已有柜门贴图</h3>
|
||||
<div className="max-h-56 divide-y divide-zinc-100 overflow-y-auto">
|
||||
{patterns.map(pattern => <div key={pattern.itemCode} className="flex items-center gap-3 py-2 text-xs">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={pattern.image} alt={pattern.name} className="h-10 w-10 rounded object-cover" />
|
||||
<span className="min-w-0 flex-1"><span className="block truncate text-zinc-700">{pattern.name}</span><span className="mt-1 block text-zinc-400">{pattern.itemCode}</span></span>
|
||||
{canDelete && <Button type="button" size="small" variant="plain" disabled={busy} aria-label={`删除柜门图案 ${pattern.name}`} onClick={() => { setSelected(pattern); setMessage('') }}><Trash2 size={14} />删除</Button>}
|
||||
</div>)}
|
||||
{!patterns.length && <p className="py-2 text-xs text-zinc-400">暂无柜门贴图</p>}
|
||||
</div>
|
||||
{selected && <div ref={confirmation} tabIndex={-1} role="group" aria-label={`删除柜门图案确认 ${selected.name}`} className="mt-3 rounded-md border border-zinc-200 bg-zinc-50 p-3 text-xs outline-none">
|
||||
<p className="font-medium text-zinc-800">确认删除柜门图案“{selected.name}”?</p>
|
||||
<p className="mt-2 text-zinc-500">移入回收站后可恢复。已贴的柜门和历史方案会保留。</p>
|
||||
<div className="mt-3 flex justify-end gap-2"><Button type="button" size="small" variant="gray" disabled={busy} onClick={() => setSelected(null)}>取消</Button>
|
||||
<Button type="button" size="small" disabled={busy} onClick={() => void remove()}>确认删除</Button></div>
|
||||
</div>}
|
||||
{message && <p role={failed ? 'alert' : 'status'} className={`mt-3 text-xs ${failed ? 'text-red-600' : 'text-zinc-500'}`}>{message}</p>}
|
||||
</section>
|
||||
}
|
||||
@ -0,0 +1,31 @@
|
||||
'use client'
|
||||
|
||||
import { Suspense, useEffect, type ReactNode } from 'react'
|
||||
import { Canvas, useThree } from '@react-three/fiber'
|
||||
import { OrbitControls } from '@react-three/drei'
|
||||
import type { CabinetStyle } from '@/lib/diy/types'
|
||||
|
||||
function Fit({ dimensions }: { dimensions: CabinetStyle['realSize'] }) {
|
||||
const { camera, size } = useThree()
|
||||
const { width, height, depth } = dimensions
|
||||
useEffect(() => {
|
||||
const radius = Math.hypot(width, height, depth) * .005
|
||||
const distance = radius / Math.sin(Math.atan(Math.tan(35 * Math.PI / 360) * Math.min(1, size.width / Math.max(1, size.height)))) * 1.25
|
||||
camera.position.set(distance * .32, distance * .18, distance * .94)
|
||||
camera.lookAt(0, 0, 0)
|
||||
}, [width, height, depth, camera, size.width, size.height])
|
||||
return null
|
||||
}
|
||||
|
||||
export default function DiyPreviewStage({ dimensions, children, className = 'h-[340px]', interactive = true }: {
|
||||
dimensions: CabinetStyle['realSize']; children: ReactNode; className?: string; interactive?: boolean
|
||||
}) {
|
||||
return <div className={`${className} overflow-hidden rounded-lg border border-zinc-200 bg-[#f2f4f4]`}>
|
||||
<Canvas camera={{ fov: 35, near: .01, far: 100000 }} gl={{ antialias: true, preserveDrawingBuffer: true }}>
|
||||
<color attach="background" args={['#f2f4f4']} />
|
||||
<ambientLight intensity={1.4} /><directionalLight position={[10, 15, 20]} intensity={2} />
|
||||
<Suspense fallback={null}>{children}</Suspense>
|
||||
{interactive&&<OrbitControls makeDefault enableDamping />}<Fit dimensions={dimensions} />
|
||||
</Canvas>
|
||||
</div>
|
||||
}
|
||||
48
furniture-3d-system/src/app/diy/components/DiyRecycleBin.tsx
Normal file
48
furniture-3d-system/src/app/diy/components/DiyRecycleBin.tsx
Normal file
@ -0,0 +1,48 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { RotateCcw } from 'lucide-react'
|
||||
import Button from '@/components/ui/Button'
|
||||
import type { DiyRecycledAssetDTO } from '@/lib/diy/types'
|
||||
import { changeDiyAssetLifecycle, listDiyRecycledAssets } from '../lib/api'
|
||||
import DiyMaterialSwatch from './DiyMaterialSwatch'
|
||||
|
||||
export default function DiyRecycleBin({ busy, onBusyChange, onChanged }: {
|
||||
busy: boolean; onBusyChange: (busy: boolean) => void; onChanged: () => Promise<void>
|
||||
}) {
|
||||
const [assets, setAssets] = useState<DiyRecycledAssetDTO[]>([])
|
||||
const [loading, setLoading] = useState(true), [failed, setFailed] = useState(false), [message, setMessage] = useState('')
|
||||
const load = useCallback(async () => { setAssets(await listDiyRecycledAssets()) }, [])
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
listDiyRecycledAssets().then(rows => { if (active) setAssets(rows) }).catch(error => {
|
||||
if (active) { setFailed(true); setMessage(error instanceof Error ? error.message : '回收站读取失败') }
|
||||
}).finally(() => { if (active) setLoading(false) })
|
||||
return () => { active = false }
|
||||
}, [])
|
||||
const restore = async (asset: DiyRecycledAssetDTO) => {
|
||||
if (busy || loading) return
|
||||
onBusyChange(true); setFailed(false); setMessage('正在恢复素材…')
|
||||
try {
|
||||
await changeDiyAssetLifecycle({ kind: asset.kind, action: 'restore', itemCode: asset.itemCode, revision: asset.revision })
|
||||
await onChanged(); await load(); setMessage(`已恢复“${asset.name}”`)
|
||||
} catch (error) { setFailed(true); setMessage(error instanceof Error ? error.message : '恢复失败'); await load().catch(() => {}) }
|
||||
finally { onBusyChange(false) }
|
||||
}
|
||||
return <section aria-label="DIY 素材回收站">
|
||||
<div className="mb-3 flex items-center"><h3 className="text-sm font-medium text-zinc-800">回收站</h3>
|
||||
<Button type="button" className="ml-auto" size="small" variant="plain" disabled={busy || loading} onClick={() => { setLoading(true); void load().then(() => { setMessage(''); setFailed(false) }).catch(error => { setMessage(error.message); setFailed(true) }).finally(() => setLoading(false)) }}>刷新</Button></div>
|
||||
<div className="divide-y divide-zinc-100">
|
||||
{assets.map(asset => <div key={`${asset.kind}:${asset.itemCode}`} className="flex items-center gap-3 py-3 text-xs">
|
||||
{asset.kind === 'material' ? <DiyMaterialSwatch className="h-10 w-10" main={{ itemCode: asset.itemCode, name: asset.name, kind: 'main', colorHex: asset.colorHex || '#eeeeee', texture: asset.image }} /> : asset.image ? /* eslint-disable-next-line @next/next/no-img-element */
|
||||
<img src={asset.image} alt={asset.name} className="h-10 w-10 shrink-0 rounded object-cover" /> : <span className="h-10 w-10 shrink-0 rounded border border-zinc-200" style={{ backgroundColor: asset.colorHex || '#eeeeee' }} />}
|
||||
<span className="min-w-0 flex-1"><span className="block truncate text-zinc-700">{asset.name}</span>
|
||||
<span className="mt-1 block text-zinc-400">{asset.kind === 'material' ? '颜色' : '柜门图案'} · {asset.spu} · {asset.itemCode}</span>
|
||||
<span className="mt-1 block text-zinc-400">{new Date(asset.deletedAt).toLocaleString('zh-CN')}</span></span>
|
||||
<Button type="button" size="small" variant="gray" disabled={busy || loading} aria-label={`恢复${asset.kind === 'material' ? '颜色' : '柜门图案'} ${asset.name}`} onClick={() => void restore(asset)}><RotateCcw size={14} />恢复</Button>
|
||||
</div>)}
|
||||
{loading ? <p role="status" className="py-8 text-center text-xs text-zinc-500">正在读取回收站…</p> : !assets.length && !failed && <p className="py-8 text-center text-xs text-zinc-400">回收站为空</p>}
|
||||
</div>
|
||||
{message && <p role={failed ? 'alert' : 'status'} className={`mt-3 text-xs ${failed ? 'text-red-600' : 'text-zinc-500'}`}>{message}</p>}
|
||||
</section>
|
||||
}
|
||||
@ -0,0 +1,17 @@
|
||||
'use client'
|
||||
|
||||
import { useRef } from 'react'
|
||||
import { useFrame } from '@react-three/fiber'
|
||||
|
||||
/** Notify after a complete frame with the requested model and textures. */
|
||||
export default function DiyRenderReady({ readyKey, onReady }: { readyKey: string; onReady: () => void }) {
|
||||
const progress = useRef({ key: '', frames: 0, notified: false })
|
||||
useFrame(() => {
|
||||
if (progress.current.key !== readyKey) progress.current = { key: readyKey, frames: 0, notified: false }
|
||||
if (!progress.current.notified && ++progress.current.frames >= 2) {
|
||||
progress.current.notified = true
|
||||
onReady()
|
||||
}
|
||||
})
|
||||
return null
|
||||
}
|
||||
14
furniture-3d-system/src/app/diy/components/DiySeriesForm.tsx
Normal file
14
furniture-3d-system/src/app/diy/components/DiySeriesForm.tsx
Normal file
@ -0,0 +1,14 @@
|
||||
'use client'
|
||||
import { useState } from 'react'
|
||||
import Button from '@/components/ui/Button'
|
||||
import { DiyField,diyFieldClass } from './DiyManagementFields'
|
||||
import type { DiyManagementInput } from '@/lib/diy/management-schema'
|
||||
export default function DiySeriesForm({onSave,busy}: {onSave:(input:DiyManagementInput)=>Promise<boolean>;busy:boolean}) {
|
||||
const [spu,setSpu]=useState(''),[name,setName]=useState('')
|
||||
return <form className="max-w-xl space-y-4" onSubmit={async event=>{event.preventDefault();if(await onSave({kind:'series',spu:spu.trim().toUpperCase(),name})) {setSpu('');setName('')}}}>
|
||||
<h3 className="text-base font-semibold text-zinc-800">新增系列</h3>
|
||||
<DiyField label="系列编码"><input required value={spu} onChange={event=>setSpu(event.target.value)} placeholder="例如 SJ0381" className={diyFieldClass}/></DiyField>
|
||||
<DiyField label="系列名称"><input required value={name} onChange={event=>setName(event.target.value)} placeholder="例如 SJ0381 组合柜" className={diyFieldClass}/></DiyField>
|
||||
<Button type="submit" loading={busy} disabled={busy}>添加系列</Button>
|
||||
</form>
|
||||
}
|
||||
@ -0,0 +1,56 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Trash2 } from 'lucide-react'
|
||||
import Button from '@/components/ui/Button'
|
||||
import { diyMaterialReferences } from '@/lib/diy/materials'
|
||||
import type { DiyCatalogDTO, DiyMaterialUsageDTO } from '@/lib/diy/types'
|
||||
import { readDiyMaterialUsage, deleteDiyMaterial } from '../lib/api'
|
||||
import DiyMaterialSwatch from './DiyMaterialSwatch'
|
||||
|
||||
export default function DiySharedColorLibrary({ catalog, canDelete, busy, onBusyChange, onDeleted }: {
|
||||
catalog: DiyCatalogDTO; canDelete: boolean; busy: boolean; onBusyChange: (busy: boolean) => void; onDeleted: () => Promise<void>
|
||||
}) {
|
||||
const [usage, setUsage] = useState<DiyMaterialUsageDTO | null>(null)
|
||||
const confirmation=useRef<HTMLDivElement>(null)
|
||||
useEffect(()=>{if(usage){confirmation.current?.focus({preventScroll:true});confirmation.current?.scrollIntoView({block:'nearest'})}},[usage])
|
||||
const [message, setMessage] = useState(''), [failed, setFailed] = useState(false)
|
||||
const inspect = async (itemCode: string) => {
|
||||
onBusyChange(true); setUsage(null); setFailed(false); setMessage('正在检查颜色使用情况…')
|
||||
try { setUsage(await readDiyMaterialUsage(itemCode)); setMessage('') }
|
||||
catch (error) { setFailed(true); setMessage(error instanceof Error ? error.message : '引用检查失败') }
|
||||
finally { onBusyChange(false) }
|
||||
}
|
||||
const remove = async () => {
|
||||
if (!usage || busy) return
|
||||
onBusyChange(true); setFailed(false); setMessage('正在删除颜色…')
|
||||
try {
|
||||
await deleteDiyMaterial({ itemCode: usage.itemCode, revision: usage.revision })
|
||||
await onDeleted(); setUsage(null); setMessage(`“${usage.name}”已移入回收站,可随时恢复`)
|
||||
} catch (error) { setFailed(true); setMessage(error instanceof Error ? error.message : '颜色删除失败'); setUsage(null) }
|
||||
finally { onBusyChange(false) }
|
||||
}
|
||||
return <section className="mt-6 border-t border-zinc-200 pt-3">
|
||||
<h3 className="mb-2 text-xs font-medium text-zinc-500">共用颜色库</h3>
|
||||
<div className="max-h-56 overflow-y-auto divide-y divide-zinc-100">
|
||||
{catalog.materials.filter(material=>!material.deletedAt).map(material => {
|
||||
const count = new Set(diyMaterialReferences(catalog, material.itemCode).map(ref => ref.styleCode)).size
|
||||
const duplicate = catalog.materials.some(other => other.itemCode !== material.itemCode && other.name === material.name)
|
||||
return <div key={material.itemCode} className="flex items-center gap-3 py-2 text-xs">
|
||||
<DiyMaterialSwatch main={material} />
|
||||
<span className="min-w-0 flex-1"><span className="block truncate text-zinc-700">{material.name}{duplicate ? ` · ${material.spu || 'SJ0380'}` : ''}</span>
|
||||
<span className="mt-1 block text-zinc-400">{material.kind === 'shelf' ? '内部板件 · ' : ''}{count ? `${count} 个款式使用` : '尚未关联款式'}</span></span>
|
||||
{canDelete && <Button type="button" size="small" variant="plain" disabled={busy} aria-label={`删除颜色 ${material.name}`} onClick={() => void inspect(material.itemCode)}><Trash2 size={14} />删除</Button>}
|
||||
</div>
|
||||
})}
|
||||
{!catalog.materials.some(material=>!material.deletedAt) && <p className="py-2 text-xs text-zinc-400">暂无颜色</p>}
|
||||
</div>
|
||||
{usage && <div ref={confirmation} tabIndex={-1} role="group" aria-label={`删除颜色确认 ${usage.name}`} className="mt-3 rounded-md border border-zinc-200 bg-zinc-50 p-3 text-xs outline-none">
|
||||
<p className="font-medium text-zinc-800">确认删除颜色“{usage.name}”?</p>
|
||||
<p className="mt-2 text-zinc-500">移入回收站后可恢复。已有柜体、方案和贴图会保留。</p>
|
||||
<div className="mt-3 flex justify-end gap-2"><Button type="button" size="small" variant="gray" disabled={busy} onClick={() => { setUsage(null); setMessage('') }}>取消</Button>
|
||||
<Button type="button" size="small" disabled={busy} onClick={() => void remove()}>确认删除</Button></div>
|
||||
</div>}
|
||||
{message && <p role={failed ? 'alert' : 'status'} className={`mt-3 text-xs ${failed ? 'text-red-600' : 'text-zinc-500'}`}>{message}</p>}
|
||||
</section>
|
||||
}
|
||||
@ -0,0 +1,35 @@
|
||||
'use client'
|
||||
|
||||
import Button from '@/components/ui/Button'
|
||||
import type { CabinetStyle,DiyAssetDTO,DiyCatalogDTO } from '@/lib/diy/types'
|
||||
import DiyAssetUpload from './DiyAssetUpload'
|
||||
import DiyAdvancedSettings from './DiyAdvancedSettings'
|
||||
import { DiyField,diyFieldClass } from './DiyManagementFields'
|
||||
|
||||
export default function DiyStyleAdvancedSettings({spu,catalog,style,main,shelf,nodes,region,thumbnail,barcode,busy,
|
||||
onDimension,onRotation,onMain,onShelf,onRegion,onNode,onThumbnail,onBarcode,onOpenChange,onUploadBusy}: {
|
||||
spu:string;catalog:DiyCatalogDTO;style:CabinetStyle;main:string;shelf:string;nodes:string[];region:'main'|'shelf';thumbnail:DiyAssetDTO|null;barcode:string;busy:boolean
|
||||
onDimension:(key:keyof CabinetStyle['realSize'],value:number)=>void;onRotation:(value:number)=>void;onMain:(value:string)=>void;onShelf:(value:string)=>void
|
||||
onRegion:(value:'main'|'shelf')=>void;onNode:(value:string)=>void;onThumbnail:(value:DiyAssetDTO)=>void;onBarcode:(value:string)=>void
|
||||
onOpenChange:(open:boolean)=>void;onUploadBusy:(busy:boolean)=>void
|
||||
}) {
|
||||
return <DiyAdvancedSettings label="高级设置(尺寸、换色、缩略图)" onOpenChange={onOpenChange}>
|
||||
<fieldset disabled={busy} className="space-y-4">
|
||||
<div className="grid grid-cols-3 gap-3">{([['width','宽度(mm)'],['height','高度(mm)'],['depth','深度(mm)']] as const).map(([key,label])=><DiyField key={key} label={label}>
|
||||
<input required type="number" min={1} max={10000} step="any" value={style.realSize[key]} onChange={event=>onDimension(key,Number(event.target.value))} className={diyFieldClass}/></DiyField>)}</div>
|
||||
<DiyField label="模型朝向"><select value={style.modelRotationY} onChange={event=>onRotation(Number(event.target.value))} className={diyFieldClass}>
|
||||
{[0,90,180,270].map(value=><option key={value} value={value}>{value}°</option>)}</select></DiyField>
|
||||
<div className="grid grid-cols-2 gap-3"><DiyField label="初始颜色"><select required value={main} onChange={event=>onMain(event.target.value)} className={diyFieldClass}><option value="">请选择颜色</option>{catalog.materials.filter(m=>m.kind==='main'&&!m.deletedAt).map(m=><option key={m.itemCode} value={m.itemCode}>{m.name}</option>)}</select></DiyField>
|
||||
<DiyField label="内部板件颜色"><select value={shelf} onChange={event=>onShelf(event.target.value)} className={diyFieldClass}><option value="">沿用柜体颜色</option>{catalog.materials.filter(m=>m.kind==='shelf'&&!m.deletedAt).map(m=><option key={m.itemCode} value={m.itemCode}>{m.name}</option>)}</select></DiyField></div>
|
||||
{nodes.length>0&&<div className="space-y-2">
|
||||
<p className="text-xs text-zinc-500">默认整柜换色。需要分别换色时,可指定柜体与内部板件。</p>
|
||||
<div className="flex gap-2"><Button type="button" size="small" variant={region==='main'?'filled':'gray'} disabled={busy} onClick={()=>onRegion('main')}>柜体换色区域</Button>
|
||||
<Button type="button" size="small" variant={region==='shelf'?'filled':'gray'} disabled={busy} onClick={()=>onRegion('shelf')}>内部板件区域</Button></div>
|
||||
<div className="max-h-28 overflow-y-auto rounded border border-zinc-200 p-2">{nodes.map(node=><label key={node} className="flex items-center gap-2 py-1 text-xs text-zinc-600">
|
||||
<input type="checkbox" checked={(region==='main'?style.mainNodeNames:style.shelfNodeNames)?.includes(node)||false} onChange={()=>onNode(node)}/><span className="truncate">{node}</span></label>)}</div>
|
||||
</div>}
|
||||
<DiyAssetUpload spu={spu} kind="image" label="款式缩略图(选填,默认使用模型截图)" value={thumbnail} onChange={onThumbnail} onBusyChange={onUploadBusy} disabled={busy}/>
|
||||
<DiyField label="条码(选填)"><input value={barcode} onChange={event=>onBarcode(event.target.value)} className={diyFieldClass}/></DiyField>
|
||||
</fieldset>
|
||||
</DiyAdvancedSettings>
|
||||
}
|
||||
@ -0,0 +1,37 @@
|
||||
'use client'
|
||||
import { useCallback,useMemo,useState } from 'react'
|
||||
import Button from '@/components/ui/Button'
|
||||
import type { DiyStyleCellsInput } from '@/lib/diy/management-schema'
|
||||
import { cellConfigurationIssue,usedDoorConfigurationIssue } from '@/lib/diy/cell-configuration'
|
||||
import type { CabinetCell,DiyCatalogDTO,DiySnapshot } from '@/lib/diy/types'
|
||||
import { createDiyCatalogAccess } from '@/lib/diy/catalog'
|
||||
import DiyCellConfiguration from './DiyCellConfiguration'
|
||||
import DiyModelSetupPreview from './DiyModelSetupPreview'
|
||||
|
||||
export default function DiyStyleCellEditor({code,catalog,snapshot,busy,onSave,onCancel}: {
|
||||
code:string;catalog:DiyCatalogDTO;snapshot:DiySnapshot;busy:boolean;onSave:(input:DiyStyleCellsInput)=>Promise<boolean>;onCancel:()=>void
|
||||
}) {
|
||||
const original=catalog.styles.find(style=>style.code===code)!,entry=catalog.catalogStyles.find(style=>style.code===code)!
|
||||
const [cells,setCells]=useState<CabinetCell[]>(original.cells),[ready,setReady]=useState(false),[error,setError]=useState('')
|
||||
const [geometryBusy,setGeometryBusy]=useState(false),[geometryReady,setGeometryReady]=useState(original.cells.length>0)
|
||||
const onGeometryBusy=useCallback((value:boolean)=>{setGeometryBusy(value);if(!value)setGeometryReady(true)},[])
|
||||
const [revision]=useState(catalog.series?.find(series=>series.spu===original.spu)?.version||catalog.version)
|
||||
const style=useMemo(()=>({...original,cells,cellCount:cells.length}),[original,cells])
|
||||
const materials=createDiyCatalogAccess(catalog).materialsForItem({code,colorName:entry.colors[0].name})
|
||||
const save=async()=>{
|
||||
if(busy||geometryBusy||!geometryReady)return
|
||||
const issue=cellConfigurationIssue(cells,style.realSize)||usedDoorConfigurationIssue(snapshot,code,original.cells,cells,catalog.patterns)
|
||||
if(issue){setError(issue);return}
|
||||
setError('');await onSave({styleCode:code,revision,cells})
|
||||
}
|
||||
return <form className="space-y-4" onSubmit={event=>{event.preventDefault();void save()}}>
|
||||
<div className="flex items-center gap-3"><div className="mr-auto"><h3 className="text-sm font-semibold text-zinc-800">编辑格口 · {entry.name}</h3>
|
||||
<p className="mt-1 text-xs text-zinc-500">{code} · {entry.size}</p></div><Button type="button" size="small" variant="gray" disabled={busy} onClick={onCancel}>返回新增款式</Button></div>
|
||||
<div className="grid gap-5 lg:grid-cols-2"><DiyModelSetupPreview style={style} main={materials.main} shelf={materials.shelf} selected={[]}
|
||||
onNode={()=>{}} onReady={()=>setReady(true)} onError={setError}/>
|
||||
<div className="space-y-4"><DiyCellConfiguration style={style} ready={ready} automatic={original.cells.length===0} onChange={setCells} busy={busy} onBusyChange={onGeometryBusy}/>
|
||||
{error&&<p role="alert" className="text-xs text-red-600">{error}</p>}
|
||||
<Button type="submit" loading={busy} disabled={busy||geometryBusy||!geometryReady||!ready}>保存格口配置</Button></div>
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
70
furniture-3d-system/src/app/diy/components/DiyStyleForm.tsx
Normal file
70
furniture-3d-system/src/app/diy/components/DiyStyleForm.tsx
Normal file
@ -0,0 +1,70 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback,useMemo,useRef,useState } from 'react'
|
||||
import Button from '@/components/ui/Button'
|
||||
import type { CabinetStyle,CabinetCell,DiyAssetDTO,DiyCatalogDTO } from '@/lib/diy/types'
|
||||
import type { DiyManagementInput } from '@/lib/diy/management-schema'
|
||||
import { uploadDiyAsset } from '../lib/api'
|
||||
import { captureDiyThumbnail } from '../lib/capture'
|
||||
import DiyAssetUpload from './DiyAssetUpload'
|
||||
import DiyModelSetupPreview from './DiyModelSetupPreview'
|
||||
import DiyCellConfiguration from './DiyCellConfiguration'
|
||||
import DiyStyleAdvancedSettings from './DiyStyleAdvancedSettings'
|
||||
import { DiyField,diyFieldClass } from './DiyManagementFields'
|
||||
|
||||
export default function DiyStyleForm({spu,catalog,onSave,busy,onUploadBusy}: {
|
||||
spu:string;catalog:DiyCatalogDTO;onSave:(input:DiyManagementInput)=>Promise<boolean>;busy:boolean;onUploadBusy:(busy:boolean)=>void
|
||||
}) {
|
||||
const stage=useRef<HTMLDivElement>(null),[model,setModel]=useState<DiyAssetDTO|null>(null),[thumbnail,setThumbnail]=useState<DiyAssetDTO|null>(null)
|
||||
const [name,setName]=useState(''),[itemCode,setItemCode]=useState(''),[barcode,setBarcode]=useState(''),[advanced,setAdvanced]=useState(false)
|
||||
const [width,setWidth]=useState(600),[height,setHeight]=useState(600),[depth,setDepth]=useState(300),[rotation,setRotation]=useState(0)
|
||||
const [main,setMain]=useState((catalog.materials.find(m=>m.kind==='main'&&m.spu===spu&&!m.deletedAt)||catalog.materials.find(m=>m.kind==='main'&&!m.deletedAt))?.itemCode||''),[shelf,setShelf]=useState('')
|
||||
const [nodes,setNodes]=useState<string[]>([]),[mainNodes,setMainNodes]=useState<string[]>([]),[shelfNodes,setShelfNodes]=useState<string[]>([]),[region,setRegion]=useState<'main'|'shelf'>('main')
|
||||
const [cells,setCells]=useState<CabinetCell[]>([])
|
||||
const [ready,setReady]=useState(false),[error,setError]=useState(''),[preparing,setPreparing]=useState(false)
|
||||
const [geometryBusy,setGeometryBusy]=useState(false),[geometryReady,setGeometryReady]=useState(false)
|
||||
const onGeometryBusy=useCallback((value:boolean)=>{setGeometryBusy(value);if(!value)setGeometryReady(true)},[])
|
||||
const style=useMemo<CabinetStyle>(()=>({code:'preview',spu,modelUrl:model?.url,mainNodeNames:mainNodes,shelfNodeNames:shelfNodes,modelRotationY:rotation,
|
||||
realSize:{width,height,depth},panels:[],panelCount:0,cells,cellCount:cells.length}),[spu,model,mainNodes,shelfNodes,rotation,width,height,depth,cells])
|
||||
const toggleNode=(node:string)=>{
|
||||
const update=(values:string[])=>values.includes(node)?values.filter(name=>name!==node):[...values,node]
|
||||
if(region==='main'){setMainNodes(update);setShelfNodes(values=>values.filter(name=>name!==node))}
|
||||
else {setShelfNodes(update);setMainNodes(values=>values.filter(name=>name!==node))}
|
||||
}
|
||||
const save=async()=>{
|
||||
if(!model||!ready||!geometryReady||geometryBusy||busy||preparing)return
|
||||
setPreparing(true);onUploadBusy(true);setError('')
|
||||
try {
|
||||
let image=thumbnail
|
||||
if(!image) {
|
||||
const blob=await captureDiyThumbnail(stage.current)
|
||||
image=await uploadDiyAsset(new File([blob],`${spu}-model.png`,{type:'image/png'}),spu,'image');setThumbnail(image)
|
||||
}
|
||||
const saved=await onSave({kind:'style',spu,name,width,height,depth,modelFileId:model.id,thumbnailFileId:image.id,
|
||||
mainNodeNames:mainNodes,shelfNodeNames:shelfNodes,modelRotationY:rotation,cells,mainMaterialCode:main,shelfMaterialCode:shelf||null,itemCode,barcode})
|
||||
if(saved){setName('');setItemCode('');setBarcode('');setModel(null);setThumbnail(null);setReady(false);setGeometryReady(false);setNodes([]);setMainNodes([]);setShelfNodes([]);setCells([])}
|
||||
}catch(error){setError(error instanceof Error?error.message:'款式添加失败')}
|
||||
finally{setPreparing(false);onUploadBusy(false)}
|
||||
}
|
||||
return <form className={model?'grid gap-5 lg:grid-cols-[1fr_1.05fr]':'max-w-xl'} onSubmit={event=>{event.preventDefault();void save()}}>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-3"><DiyField label="款式名称"><input required disabled={busy} value={name} onChange={event=>setName(event.target.value)} className={diyFieldClass}/></DiyField>
|
||||
<DiyField label="货号"><input required disabled={busy} value={itemCode} onChange={event=>setItemCode(event.target.value.trim())} className={diyFieldClass}/></DiyField></div>
|
||||
<DiyAssetUpload spu={spu} kind="model" label="柜体模型" value={model} onChange={asset=>{setModel(asset);setThumbnail(null);setReady(false);setGeometryReady(false);setGeometryBusy(false);setError('');setNodes([]);setMainNodes([]);setShelfNodes([]);setCells([])}} onBusyChange={onUploadBusy} disabled={busy}/>
|
||||
<p className="text-xs text-zinc-500">款式编码自动生成;配套贴图与模型一起打包为 ZIP。</p>
|
||||
{model&&ready&&<p className="text-xs text-zinc-500">识别尺寸:{width} × {height} × {depth} mm</p>}
|
||||
{model&&<DiyCellConfiguration key={model.id} compact style={style} ready={ready} onChange={setCells} busy={busy||preparing} onBusyChange={onGeometryBusy}/>}
|
||||
<DiyStyleAdvancedSettings spu={spu} catalog={catalog} style={style} main={main} shelf={shelf} nodes={nodes} region={region} thumbnail={thumbnail} barcode={barcode} busy={busy||preparing}
|
||||
onDimension={(key,value)=>({width:setWidth,height:setHeight,depth:setDepth})[key](value)}
|
||||
onRotation={value=>{setRotation(value);if(value%180!==rotation%180){setWidth(depth);setDepth(width)}}}
|
||||
onMain={setMain} onShelf={setShelf} onRegion={setRegion} onNode={toggleNode} onThumbnail={setThumbnail} onBarcode={setBarcode} onOpenChange={setAdvanced} onUploadBusy={onUploadBusy}/>
|
||||
{error&&<p role="alert" className="text-xs text-red-600">{error}</p>}
|
||||
{!main&&<p role="alert" className="text-xs text-amber-700">请先在“颜色”中添加一个颜色。</p>}
|
||||
<Button type="submit" loading={busy||preparing} disabled={busy||preparing||geometryBusy||!geometryReady||!model||!ready||!main}>添加柜体款式</Button>
|
||||
</div>
|
||||
{model&&<div ref={stage} className="relative self-start"><DiyModelSetupPreview style={style} main={catalog.materials.find(m=>m.itemCode===main)} shelf={catalog.materials.find(m=>m.itemCode===shelf)}
|
||||
selected={preparing||!advanced?[]:region==='main'?mainNodes:shelfNodes} showCells={!preparing} onNode={node=>{if(advanced&&!busy)toggleNode(node)}} onError={setError}
|
||||
onReady={data=>{setWidth(data.width);setHeight(data.height);setDepth(data.depth);setNodes(data.nodes);setReady(true)}}/>
|
||||
{!ready&&!error&&<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-white/65 text-xs text-zinc-500">正在读取模型…</div>}</div>}
|
||||
</form>
|
||||
}
|
||||
48
furniture-3d-system/src/app/diy/components/DiyToolbar.tsx
Normal file
48
furniture-3d-system/src/app/diy/components/DiyToolbar.tsx
Normal file
@ -0,0 +1,48 @@
|
||||
'use client'
|
||||
|
||||
import { Save } from 'lucide-react'
|
||||
import Button from '@/components/ui/Button'
|
||||
|
||||
interface DiyToolbarProps {
|
||||
name: string
|
||||
dirty: boolean
|
||||
hasItems: boolean
|
||||
canConfigure: boolean
|
||||
canDownload: boolean
|
||||
saving: boolean
|
||||
capturing: boolean
|
||||
onHistory: () => void
|
||||
onBom: () => void
|
||||
onSave: () => void
|
||||
onNew: () => void
|
||||
onCapture: () => void
|
||||
onManage: () => void
|
||||
}
|
||||
|
||||
const actionStyle = 'h-11 min-h-11 min-w-0 whitespace-nowrap rounded-[6px] px-2 text-[11px] focus:px-[7px] sm:h-9 sm:min-h-9 sm:px-3 sm:text-xs sm:focus:px-[11px]'
|
||||
|
||||
export default function DiyToolbar(props: DiyToolbarProps) {
|
||||
const busy = props.saving || props.capturing
|
||||
|
||||
return <header className="flex shrink-0 flex-wrap items-center gap-x-6 gap-y-2 border-b border-black/[0.06] bg-white px-4 py-2.5 lg:px-5">
|
||||
<div className="flex w-full min-w-0 flex-1 basis-full items-center gap-3 sm:w-auto sm:basis-auto">
|
||||
<h1 className="shrink-0 text-base font-semibold tracking-tight text-[#2d3335]">DIY</h1>
|
||||
<span className="h-4 w-px shrink-0 bg-black/[0.08]" aria-hidden="true"/>
|
||||
<span className="max-w-64 truncate text-xs text-[#5a6062]" title={props.name}>{props.name}</span>
|
||||
{props.dirty && <span className="shrink-0 text-[10px] text-[#9b7942]">未保存</span>}
|
||||
</div>
|
||||
<div className="ml-auto flex max-w-full flex-wrap items-center justify-end gap-x-4 gap-y-1 sm:gap-x-5">
|
||||
{props.canConfigure && <Button type="button" variant="plain" size="small" className={actionStyle} disabled={busy} onClick={props.onManage}>素材管理</Button>}
|
||||
<div className="flex items-center gap-1" role="group" aria-label="方案操作">
|
||||
<Button type="button" variant="plain" size="small" className={actionStyle} disabled={busy || !props.canConfigure} onClick={props.onNew}>新建方案</Button>
|
||||
<Button type="button" variant="plain" size="small" className={actionStyle} disabled={busy} onClick={props.onHistory}>我的方案</Button>
|
||||
<Button type="button" variant="plain" size="small" className={`${actionStyle} ml-1`} loading={props.saving}
|
||||
disabled={busy || !props.canConfigure || !props.hasItems} onClick={props.onSave}><Save size={14}/>保存方案</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-1" role="group" aria-label="方案输出">
|
||||
<Button type="button" variant="plain" size="small" className={actionStyle} disabled={busy || !props.hasItems || !props.canDownload} onClick={props.onCapture}>截图</Button>
|
||||
<Button type="button" variant="plain" size="small" className={actionStyle} disabled={busy || !props.hasItems} onClick={props.onBom}>下单明细</Button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
}
|
||||
@ -0,0 +1,75 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect,useMemo,useRef,useState } from 'react'
|
||||
import * as THREE from 'three'
|
||||
import type { CabinetStyle,DiyMaterialDTO } from '@/lib/diy/types'
|
||||
import { loadDiyModel,modelDimensions } from '../lib/model'
|
||||
import { diyTextureGeometry } from '../lib/texture-coordinates'
|
||||
import { diyMaterialDisplayColor } from '../lib/material-appearance'
|
||||
import DiyRenderReady from './DiyRenderReady'
|
||||
|
||||
const emptyTextures: Record<string, THREE.Texture> = {}
|
||||
|
||||
interface Props {
|
||||
style:CabinetStyle;main?:DiyMaterialDTO;shelf?:DiyMaterialDTO;turns?:number
|
||||
highlighted?:string[];onNode?:(name:string)=>void;onReady?:(data:ReturnType<typeof modelDimensions>)=>void;onError?:(message:string)=>void
|
||||
onAppearanceReady?: () => void
|
||||
appearanceReadyKey?: string
|
||||
}
|
||||
export default function DiyUploadedModel({style,main,shelf,turns=0,highlighted,onNode,onReady,onError,onAppearanceReady,appearanceReadyKey=''}:Props) {
|
||||
const callbacks=useRef({onReady,onError})
|
||||
useEffect(()=>{callbacks.current={onReady,onError}},[onReady,onError])
|
||||
const textureKey=JSON.stringify([main?.itemCode,main?.texture,shelf?.itemCode,shelf?.texture])
|
||||
const [source,setSource]=useState<THREE.Group|null>(null)
|
||||
const [textureState,setTextureState]=useState<{key:string;values:Record<string,THREE.Texture>}>({key:'',values:emptyTextures})
|
||||
const textures=textureState.key===textureKey?textureState.values:emptyTextures
|
||||
useEffect(()=>{
|
||||
let active=true;setSource(null)
|
||||
loadDiyModel(style.modelUrl!).then(root=>{if(active){setSource(root);callbacks.current.onReady?.(modelDimensions(root))}})
|
||||
.catch(error=>{if(active)callbacks.current.onError?.(error instanceof Error?error.message:'模型加载失败')})
|
||||
return ()=>{active=false}
|
||||
},[style.modelUrl])
|
||||
useEffect(()=>{
|
||||
let active=true;const created:THREE.Texture[]=[],next:Record<string,THREE.Texture>={}
|
||||
Promise.all([main,shelf].filter(material=>material?.texture).map(async material=>{
|
||||
const texture=await new THREE.TextureLoader().loadAsync(material!.texture!)
|
||||
if(!active){texture.dispose();return}
|
||||
created.push(texture)
|
||||
texture.colorSpace=THREE.SRGBColorSpace;texture.wrapS=texture.wrapT=THREE.RepeatWrapping;next[material!.itemCode]=texture
|
||||
})).then(()=>{if(active)setTextureState({key:textureKey,values:next})}).catch(error=>{if(active)callbacks.current.onError?.(error instanceof Error?error.message:'材质贴图加载失败')})
|
||||
return ()=>{active=false;created.forEach(texture=>texture.dispose())}
|
||||
},[main,shelf,textureKey])
|
||||
const model=useMemo(()=>{
|
||||
if(!source)return null
|
||||
const clone=source.clone(true),wrapper=new THREE.Group(),geometries:THREE.BufferGeometry[]=[]
|
||||
clone.rotation.y=(style.modelRotationY||0)*Math.PI/180;wrapper.add(clone);wrapper.updateMatrixWorld(true)
|
||||
const box=new THREE.Box3().setFromObject(wrapper),size=box.getSize(new THREE.Vector3()),center=box.getCenter(new THREE.Vector3())
|
||||
clone.position.sub(center)
|
||||
wrapper.scale.set(style.realSize.width*.01/Math.max(size.x,.000001),style.realSize.height*.01/Math.max(size.y,.000001),style.realSize.depth*.01/Math.max(size.z,.000001))
|
||||
wrapper.traverse(node=>{
|
||||
if(!(node instanceof THREE.Mesh))return
|
||||
const original=Array.isArray(node.material)?node.material:[node.material]
|
||||
const cloned=original.map(material=>material.clone())
|
||||
node.material=Array.isArray(node.material)?cloned:cloned[0]
|
||||
const material=style.shelfNodeNames?.includes(node.name)?shelf:
|
||||
!style.mainNodeNames?.length||style.mainNodeNames.includes(node.name)?main:undefined
|
||||
if(material) {
|
||||
if(material.texture){const geometry=diyTextureGeometry(node.geometry);if(geometry!==node.geometry){node.geometry=geometry;geometries.push(geometry)}}
|
||||
const assigned=new THREE.MeshStandardMaterial({map:textures[material.itemCode]||null,color:diyMaterialDisplayColor(material),roughness:.85})
|
||||
cloned.forEach(item=>item.dispose());node.material=assigned
|
||||
}
|
||||
if(highlighted?.includes(node.name)) {
|
||||
const materials=Array.isArray(node.material)?node.material:[node.material]
|
||||
materials.forEach(material=>{if(material instanceof THREE.MeshStandardMaterial){material.emissive.set('#3576dc');material.emissiveIntensity=.3}})
|
||||
}
|
||||
node.castShadow=node.receiveShadow=true
|
||||
})
|
||||
wrapper.rotation.z=-turns*Math.PI/2
|
||||
return {root:wrapper,geometries}
|
||||
},[source,style,main,shelf,textures,turns,highlighted])
|
||||
useEffect(()=>()=>{model?.root.traverse(node=>{if(node instanceof THREE.Mesh)(Array.isArray(node.material)?node.material:[node.material]).forEach(material=>material.dispose())});model?.geometries.forEach(geometry=>geometry.dispose())},[model])
|
||||
return model?<>
|
||||
<primitive object={model.root} dispose={null} onClick={onNode?(event:{stopPropagation:()=>void;object:THREE.Object3D})=>{event.stopPropagation();onNode(event.object.name)}:undefined}/>
|
||||
{onAppearanceReady&&textureState.key===textureKey&&<DiyRenderReady readyKey={JSON.stringify([appearanceReadyKey,textureKey,style.code,style.modelUrl,style.realSize,style.modelRotationY,main?.colorHex,shelf?.colorHex])} onReady={onAppearanceReady}/>}
|
||||
</>:null
|
||||
}
|
||||
@ -0,0 +1,57 @@
|
||||
'use client'
|
||||
import { useCallback,useRef,useState } from 'react'
|
||||
import dynamic from 'next/dynamic'
|
||||
import Button from '@/components/ui/Button'
|
||||
import type { DiyAssetDTO,DiyCatalogDTO } from '@/lib/diy/types'
|
||||
import type { DiyManagementInput } from '@/lib/diy/management-schema'
|
||||
import DiyAssetUpload from './DiyAssetUpload'
|
||||
import DiyAdvancedSettings from './DiyAdvancedSettings'
|
||||
import { DiyField,diyFieldClass } from './DiyManagementFields'
|
||||
import { uploadDiyAsset } from '../lib/api'
|
||||
import { captureDiyThumbnail } from '../lib/capture'
|
||||
|
||||
const DiyVariantPreview=dynamic(()=>import('./DiyVariantPreview'),{ssr:false})
|
||||
|
||||
export default function DiyVariantForm({spu,styleCode,catalog,onSave,busy,onUploadBusy,onCancel,initialMainCode}: {
|
||||
spu:string;styleCode:string;catalog:DiyCatalogDTO;onSave:(input:DiyManagementInput)=>Promise<boolean>;busy:boolean;onUploadBusy:(busy:boolean)=>void;onCancel:()=>void
|
||||
initialMainCode?:string
|
||||
}) {
|
||||
const style=catalog.catalogStyles.find(style=>style.code===styleCode)!
|
||||
const [main,setMain]=useState(catalog.materials.find(m=>m.itemCode===initialMainCode&&!m.deletedAt)?.itemCode||catalog.materials.find(m=>m.kind==='main'&&!m.deletedAt&&!style.colors.some(color=>color.mainMaterialCode===m.itemCode&&!color.shelfMaterialCode))?.itemCode||catalog.materials.find(m=>m.kind==='main'&&!m.deletedAt)?.itemCode||'')
|
||||
const [shelf,setShelf]=useState(''),[itemCode,setItemCode]=useState(''),[barcode,setBarcode]=useState(''),[image,setImage]=useState<DiyAssetDTO|null>(null)
|
||||
const stage=useRef<HTMLDivElement>(null),[preparing,setPreparing]=useState(false),[error,setError]=useState('')
|
||||
const [readyFor,setReadyFor]=useState(''),previewKey=`${styleCode}:${main}:${shelf}`,previewReady=readyFor===previewKey
|
||||
const onPreviewReady=useCallback(()=>setReadyFor(`${styleCode}:${main}:${shelf}`),[styleCode,main,shelf])
|
||||
const save=async()=>{
|
||||
if(busy||preparing||!previewReady&&!image)return
|
||||
setPreparing(true);onUploadBusy(true);setError('')
|
||||
try {
|
||||
const thumbnail=image||await uploadDiyAsset(new File([await captureDiyThumbnail(stage.current)],`${itemCode}.png`,{type:'image/png'}),spu,'image')
|
||||
if(await onSave({kind:'variant',styleCode,mainMaterialCode:main,shelfMaterialCode:shelf||null,itemCode,barcode,thumbnailFileId:thumbnail.id})) {
|
||||
setItemCode('');setBarcode('');setImage(null)
|
||||
}
|
||||
} catch(error){setError(error instanceof Error?error.message:'颜色规格添加失败')}
|
||||
finally{setPreparing(false);onUploadBusy(false)}
|
||||
}
|
||||
return <form className="space-y-4" onSubmit={event=>{event.preventDefault();void save()}}>
|
||||
<div className="flex items-center gap-3"><h3 className="mr-auto text-sm font-medium text-zinc-800">添加颜色款 · {style.name}</h3>
|
||||
<Button type="button" size="small" variant="gray" disabled={busy||preparing} onClick={onCancel}>返回款式模型</Button></div>
|
||||
<div className="grid gap-5 lg:grid-cols-2">
|
||||
<div ref={stage} className="relative">{styleCode&&<DiyVariantPreview catalog={catalog} styleCode={styleCode} mainCode={main} shelfCode={shelf} onError={setError} onReady={onPreviewReady}/>}
|
||||
{!previewReady&&!error&&<div className="pointer-events-none absolute inset-0 flex items-center justify-center text-xs text-zinc-500">正在读取模型…</div>}</div>
|
||||
<div className="space-y-4">
|
||||
<fieldset disabled={busy||preparing} className="space-y-4">
|
||||
<DiyField label="颜色"><select required value={main} onChange={event=>{setReadyFor('');setError('');setMain(event.target.value)}} className={diyFieldClass}><option value="">请选择颜色</option>{catalog.materials.filter(m=>m.kind==='main'&&!m.deletedAt).map(m=><option key={m.itemCode} value={m.itemCode}>{m.name}</option>)}</select></DiyField>
|
||||
<DiyField label="货号"><input required value={itemCode} onChange={event=>setItemCode(event.target.value.trim())} className={diyFieldClass}/></DiyField>
|
||||
<DiyAdvancedSettings label="高级设置(内部颜色、条码、缩略图)">
|
||||
<DiyField label="内部板件颜色"><select value={shelf} onChange={event=>{setReadyFor('');setError('');setShelf(event.target.value)}} className={diyFieldClass}><option value="">沿用柜体颜色</option>{catalog.materials.filter(m=>m.kind==='shelf'&&!m.deletedAt).map(m=><option key={m.itemCode} value={m.itemCode}>{m.name}</option>)}</select></DiyField>
|
||||
<DiyField label="条码(选填)"><input value={barcode} onChange={event=>setBarcode(event.target.value)} className={diyFieldClass}/></DiyField>
|
||||
<DiyAssetUpload spu={spu} kind="image" label="颜色款缩略图(选填,默认使用预览截图)" value={image} onChange={setImage} onBusyChange={onUploadBusy} disabled={busy||preparing}/>
|
||||
</DiyAdvancedSettings>
|
||||
</fieldset>
|
||||
{error&&<p role="alert" className="text-xs text-red-600">{error}</p>}
|
||||
<Button type="submit" loading={busy||preparing} disabled={busy||preparing||!styleCode||!main||!image&&!previewReady}>保存颜色款</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
@ -0,0 +1,22 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import type { DiyCatalogDTO } from '@/lib/diy/types'
|
||||
import DiyCabinetMesh from './DiyCabinetMesh'
|
||||
import DiyPreviewStage from './DiyPreviewStage'
|
||||
|
||||
const noop = () => {}
|
||||
export default function DiyVariantPreview({ catalog, styleCode, mainCode, shelfCode, onError,onReady,className,interactive }: {
|
||||
catalog: DiyCatalogDTO; styleCode: string; mainCode: string; shelfCode: string; onError: (message: string) => void;onReady:()=>void
|
||||
className?: string; interactive?: boolean
|
||||
}) {
|
||||
const preview = useMemo(() => ({ ...catalog, catalogStyles: catalog.catalogStyles.map(style => style.code !== styleCode ? style : {
|
||||
...style, colors: [{ ...style.colors[0], name: '__preview__', mainMaterialCode: mainCode, shelfMaterialCode: shelfCode || null }],
|
||||
}) }), [catalog, styleCode, mainCode, shelfCode])
|
||||
const style = catalog.styles.find(style => style.code === styleCode)
|
||||
if (!style) return null
|
||||
return <DiyPreviewStage dimensions={style.realSize} className={className} interactive={interactive}>
|
||||
<DiyCabinetMesh key={styleCode} catalog={preview} item={{ instanceId: 'cabinet-preview', code: styleCode, colorName: '__preview__', offsetY: 0, offsetZ: 0, quarterTurn: 0, supportId: 'floor' }}
|
||||
doors={{}} selected={false} mode="layout" disabled onDown={noop} onMove={noop} onUp={noop} onDoor={noop} onError={onError} onReady={onReady} />
|
||||
</DiyPreviewStage>
|
||||
}
|
||||
@ -1,51 +1,32 @@
|
||||
'use client'
|
||||
|
||||
import { Suspense, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Suspense, useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { Canvas, useThree, type ThreeEvent } from '@react-three/fiber'
|
||||
import { Grid, OrbitControls } from '@react-three/drei'
|
||||
import type { OrbitControls as OrbitControlsImpl } from 'three-stdlib'
|
||||
import { Grid } from '@react-three/drei'
|
||||
import * as THREE from 'three'
|
||||
import { ViewportStageShell } from '@/components/platform/ViewportStageShell'
|
||||
import { descendants } from '@/lib/diy/layout'
|
||||
import type { DiyItem, DiySnapshot } from '@/lib/diy/types'
|
||||
import type { DiyItem } from '@/lib/diy/types'
|
||||
import type { DiyWorkbenchController } from '../hooks/useDiyWorkbench'
|
||||
import DiyCabinetMesh from './DiyCabinetMesh'
|
||||
|
||||
function CameraRig({ snapshot, recordCode, enabled, onCamera, layout }: {
|
||||
snapshot:DiySnapshot; recordCode:string; enabled:boolean; onCamera:(view:DiySnapshot['camera'])=>void; layout:DiyWorkbenchController['layout']
|
||||
import DiyCameraRig from './DiyCameraRig'
|
||||
import DiyCaptureBridge, { type DiyCaptureRequest } from './DiyCaptureBridge'
|
||||
import type { DiyCaptureRef } from '../lib/ai-capture'
|
||||
function Scene({ controller:c, disabled, grid, capture, captureRef, onCapture, onCapturing }: {
|
||||
controller:DiyWorkbenchController;disabled:boolean;grid:boolean;capture:DiyCaptureRequest|null
|
||||
captureRef:DiyCaptureRef;onCapture:(value:DiyCaptureRequest|null)=>void;onCapturing:(value:boolean)=>void
|
||||
}) {
|
||||
const { itemSize } = layout
|
||||
const { camera, size:viewportSize }=useThree(), controls=useRef<OrbitControlsImpl>(null)
|
||||
const countKey=snapshot.items.map(item=>item.instanceId+item.quarterTurn).join(',')
|
||||
const bounds=useMemo(()=>{
|
||||
const min=Math.min(0,...snapshot.items.map(item=>-item.offsetY-itemSize(item).width/2))
|
||||
const max=Math.max(0,...snapshot.items.map(item=>-item.offsetY+itemSize(item).width/2))
|
||||
const height=Math.max(3.34,...snapshot.items.map(item=>item.offsetZ+itemSize(item).height/2))
|
||||
const depth=3, sphereRadius=Math.hypot(max-min,height,depth)/2
|
||||
const halfFov=THREE.MathUtils.degToRad(35/2)
|
||||
const limitingAngle=Math.min(halfFov,Math.atan(Math.tan(halfFov)*viewportSize.width/Math.max(1,viewportSize.height)))
|
||||
return {center:new THREE.Vector3((min+max)/2,height/2,0),radius:sphereRadius/Math.sin(limitingAngle)*1.15}
|
||||
},[snapshot.items,viewportSize.width,viewportSize.height,itemSize])
|
||||
const boundsRef=useRef(bounds);boundsRef.current=bounds
|
||||
useEffect(()=>{
|
||||
const b=boundsRef.current, angle=snapshot.camera.yaw, distance=b.radius*snapshot.camera.zoom
|
||||
camera.position.set(b.center.x+Math.sin(angle)*distance,b.center.y,b.center.z+Math.cos(angle)*distance)
|
||||
camera.lookAt(b.center); controls.current?.target.copy(b.center);controls.current?.update()
|
||||
// Reframe structural changes and restored versions; dragging keeps the user's view.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
},[countKey,recordCode,camera,viewportSize.width,viewportSize.height])
|
||||
return <OrbitControls ref={controls} makeDefault enabled={enabled} enableDamping dampingFactor={.1}
|
||||
enablePan={false} minPolarAngle={Math.PI/2} maxPolarAngle={Math.PI/2}
|
||||
minDistance={bounds.radius*.2} maxDistance={bounds.radius*5}
|
||||
onEnd={()=>{if(controls.current)onCamera({yaw:controls.current.getAzimuthalAngle(),zoom:Math.max(.2,Math.min(5,controls.current.getDistance()/bounds.radius))})}}/>
|
||||
}
|
||||
function Scene({ controller:c, disabled }: {controller:DiyWorkbenchController;disabled:boolean}) {
|
||||
const {camera}=useThree()
|
||||
const {setFeedback}=c
|
||||
const onModelError=useCallback((message:string)=>{setFeedback({tone:'error',text:message});capture?.reject(new Error(message))},[setFeedback,capture])
|
||||
const [drag,setDrag]=useState<{id:string;dy:number;dz:number}|null>(null)
|
||||
const active=useRef<{id:string;start:THREE.Vector3;item:DiyItem;point:THREE.Vector3;moved:boolean}|null>(null)
|
||||
const plane=useMemo(()=>new THREE.Plane(new THREE.Vector3(0,0,1),0),[])
|
||||
const down=(event:ThreeEvent<PointerEvent>,item:DiyItem)=>{
|
||||
if(disabled || c.mode!=='layout' || event.button!==0)return
|
||||
if(disabled || event.button!==0)return
|
||||
event.stopPropagation();c.setSelectedId(item.instanceId)
|
||||
if(c.mode!=='layout')return
|
||||
plane.setFromNormalAndCoplanarPoint(camera.getWorldDirection(new THREE.Vector3()),event.point)
|
||||
const point=event.ray.intersectPlane(plane,new THREE.Vector3());if(!point)return
|
||||
;(event.target as unknown as {setPointerCapture:(id:number)=>void}).setPointerCapture(event.pointerId)
|
||||
active.current={id:item.instanceId,start:point.clone(),point:point.clone(),item,moved:false}
|
||||
@ -68,26 +49,37 @@ function Scene({ controller:c, disabled }: {controller:DiyWorkbenchController;di
|
||||
return <>
|
||||
<color attach="background" args={['#f2f4f4']}/><ambientLight intensity={1.2}/>
|
||||
<directionalLight position={[10,15,20]} intensity={2.1}/><directionalLight position={[-12,5,-10]} intensity={.6}/>
|
||||
<Grid position={[0,-.025,0]} args={[200,200]} cellSize={1} sectionSize={5} cellColor="#dce2e1" sectionColor="#c4cecc"
|
||||
fadeDistance={80} infiniteGrid cellThickness={.6} sectionThickness={.8}/>
|
||||
{grid&&!capture&&<Grid position={[0,-.025,0]} args={[200,200]} cellSize={1} sectionSize={5} cellColor="#dce2e1" sectionColor="#c4cecc"
|
||||
fadeDistance={80} infiniteGrid cellThickness={.6} sectionThickness={.8}/>}
|
||||
<Suspense fallback={null}>{c.snapshot.items.map(item=><DiyCabinetMesh key={item.instanceId}
|
||||
item={drag && movingIds.has(item.instanceId)?{...item,offsetY:item.offsetY+drag.dy,offsetZ:item.offsetZ+drag.dz}:item}
|
||||
catalog={c.catalog} doors={c.snapshot.doors} selected={c.selectedId===item.instanceId} mode={c.mode} disabled={disabled}
|
||||
onDown={down} onMove={move} onUp={up} onDoor={c.toggleDoor}/>)}</Suspense>
|
||||
<CameraRig layout={c.layout} snapshot={c.snapshot} recordCode={c.scheme?.code||''} enabled={!drag} onCamera={c.setCamera}/>
|
||||
catalog={c.catalog} doors={c.snapshot.doors} selected={!capture&&c.selectedId===item.instanceId} mode={capture?'layout':c.mode} disabled={disabled||Boolean(capture)}
|
||||
readyKey={capture?.id} onReady={capture?()=>capture.ready.add(item.instanceId):undefined}
|
||||
onDown={down} onMove={move} onUp={up} onDoor={c.toggleDoor} onError={onModelError}/>)}</Suspense>
|
||||
<DiyCameraRig layout={c.layout} snapshot={c.snapshot} recordCode={`${c.scheme?.code||''}-${c.cameraRevision}`} enabled={!drag&&!capture} onCamera={c.setCamera}/>
|
||||
<DiyCaptureBridge captureRef={captureRef} itemCount={c.snapshot.items.length} request={capture} onRequest={onCapture} onCapturing={onCapturing}/>
|
||||
</>
|
||||
}
|
||||
export default function DiyViewport({ controller, disabled }: {controller:DiyWorkbenchController;disabled:boolean}) {
|
||||
return <ViewportStageShell title="DIY" showSceneGrid showCameraActionButtons={false}>
|
||||
export default function DiyViewport({ controller, disabled, captureRef, onCapturing }: {
|
||||
controller:DiyWorkbenchController;disabled:boolean;captureRef:DiyCaptureRef;onCapturing:(value:boolean)=>void
|
||||
}) {
|
||||
const [grid,setGrid]=useState(true)
|
||||
const [capture,setCapture]=useState<DiyCaptureRequest|null>(null)
|
||||
return <ViewportStageShell title="DIY" showSceneGrid={grid} onToggleSceneGrid={()=>setGrid(value=>!value)} enableTranslateGizmo={false}
|
||||
onPresetChange={preset=>controller.setView(preset==='isometric'?.55:0,preset==='top'?Math.PI/2-.03:preset==='isometric'?.55:0)}
|
||||
extraSidebarActions={<><button type="button" title="侧视" aria-label="侧视" className="h-8 w-8 rounded text-xs text-zinc-600 hover:bg-zinc-100" onClick={()=>controller.setView(Math.PI/2,0)}>侧</button>
|
||||
<button type="button" title="恢复默认视角" aria-label="恢复默认视角" className="h-8 w-8 rounded text-xs text-zinc-600 hover:bg-zinc-100" onClick={()=>controller.setView(.35,.23)}>复位</button></>}>
|
||||
<Canvas camera={{position:[3,4,12],fov:35,near:.1,far:10000}} gl={{antialias:true,preserveDrawingBuffer:true}}
|
||||
aria-label="DIY 3D预览" onCreated={({gl})=>{gl.setClearColor('#f2f4f4')}}>
|
||||
<Scene controller={controller} disabled={disabled}/>
|
||||
<Scene controller={controller} disabled={disabled} grid={grid} capture={capture} captureRef={captureRef} onCapture={setCapture} onCapturing={onCapturing}/>
|
||||
</Canvas>
|
||||
{!controller.snapshot.items.length && <div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||
<div className="text-center"><div className="text-lg font-semibold text-zinc-600">从左侧添加柜体</div><p className="mt-2 text-sm text-zinc-400">组合、配色,再选择柜门图案</p></div>
|
||||
</div>}
|
||||
<div className="pointer-events-none absolute bottom-4 left-1/2 -translate-x-1/2 whitespace-nowrap rounded-full bg-white/75 px-4 py-2 text-xs text-zinc-500 ring-1 ring-black/5 backdrop-blur-xl">
|
||||
{controller.mode==='layout'?'拖动柜体摆放 · 拖动空白处旋转 · 滚轮缩放':'点击绿色格口贴门 · 再次点击同图案移除'}
|
||||
{controller.mode==='layout'?'拖动柜体摆放 · 空白处旋转/俯仰 · 右键平移 · 滚轮缩放':
|
||||
controller.selected&&!controller.catalogAccess.getStyle(controller.selected.code).cells.length?'此柜体尚无格口,请在右侧编辑格口':
|
||||
!controller.cells?'添加柜体并配置格口后即可贴门':'点击绿色轮廓内贴门 · 再次点击同图案移除'}
|
||||
</div>
|
||||
</ViewportStageShell>
|
||||
}
|
||||
|
||||
124
furniture-3d-system/src/app/diy/hooks/useDiyAiRender.ts
Normal file
124
furniture-3d-system/src/app/diy/hooks/useDiyAiRender.ts
Normal file
@ -0,0 +1,124 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { diyCompositionText, type DiyAiRenderAccess, type DiyAiRenderDTO, type DiyAiRenderInput, type DiyAiRenderMode } from '@/lib/diy/ai-render'
|
||||
import type { DiySnapshot } from '@/lib/diy/types'
|
||||
import { generateDiyAiRender, listDiyAiRenders, optimizeDiyAiPrompt } from '../lib/ai-api'
|
||||
import { buildDiyAiPrompts } from '../lib/ai-prompts'
|
||||
import type { DiyCaptureRef, DiyReferenceCapture } from '../lib/ai-capture'
|
||||
import { downloadDiyFile } from '../lib/download'
|
||||
import type { DiyWorkbenchController } from './useDiyWorkbench'
|
||||
|
||||
interface Preview extends DiyReferenceCapture { snapshot: DiySnapshot; name: string; catalogHash: string; schemeCode: string | null }
|
||||
async function compositionKey(snapshot: DiySnapshot) {
|
||||
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(diyCompositionText(snapshot)))
|
||||
return Array.from(new Uint8Array(digest), byte => byte.toString(16).padStart(2,'0')).join('')
|
||||
}
|
||||
const message = (error: unknown) => error instanceof Error ? error.message : '操作失败,请稍后重试'
|
||||
|
||||
export function useDiyAiRender(controller: DiyWorkbenchController, captureRef: DiyCaptureRef, access: DiyAiRenderAccess) {
|
||||
const [open,setOpen] = useState(false)
|
||||
const [mode,setMode] = useState<DiyAiRenderMode>('white-background')
|
||||
const [prompts,setPrompts] = useState<Record<DiyAiRenderMode,string>>({'white-background':'',scene:''})
|
||||
const prompt = prompts[mode]
|
||||
const setPrompt = (value:string) => setPrompts(previous => ({...previous,[mode]:value}))
|
||||
const [negativePrompt,setNegativePrompt] = useState(''), [aspectRatio,setAspectRatio] = useState<DiyAiRenderInput['aspectRatio']>('1:1')
|
||||
const [imageCount,setImageCount] = useState(1), [imageSize,setImageSize] = useState<'1K'|'2K'|'4K'>('2K')
|
||||
const [preview,setPreview] = useState<Preview|null>(null), [records,setRecords] = useState<DiyAiRenderDTO[]>([])
|
||||
const [scope,setScope] = useState<'current'|'all'>('current'), [loading,setLoading] = useState(false)
|
||||
const [stage,setStage] = useState(''), [generating,setGenerating] = useState(false), [optimizing,setOptimizing] = useState(false)
|
||||
const [error,setError] = useState(''), [notice,setNotice] = useState(''), [optimization,setOptimization] = useState('')
|
||||
const [downloading,setDownloading] = useState('')
|
||||
const currentComposition = diyCompositionText(controller.snapshot)
|
||||
const live = useRef({ controller, scope }), lock = useRef(false), listToken = useRef(0)
|
||||
useEffect(() => { live.current = { controller, scope } }, [controller,scope])
|
||||
const capture = useCallback(async () => {
|
||||
const c = live.current.controller, snapshot = structuredClone(c.saveSnapshot())
|
||||
if (!snapshot.items.length || !captureRef.current) throw new Error('请先添加柜体并等待3D加载')
|
||||
const name = c.scheme?.name || '组合方案', schemeCode = !c.dirty ? c.scheme?.code || null : null, catalogHash = c.catalog.sourceHash
|
||||
const image = await captureRef.current.capture()
|
||||
const next = { ...image, snapshot, name, schemeCode, catalogHash }; setPreview(next); return next
|
||||
}, [captureRef])
|
||||
const refresh = useCallback(async () => {
|
||||
const token = ++listToken.current, { controller: c, scope: currentScope } = live.current
|
||||
setLoading(true)
|
||||
try {
|
||||
const key = currentScope === 'current' ? await compositionKey(c.saveSnapshot()) : undefined
|
||||
const data = await listDiyAiRenders(key, currentScope === 'current' && !c.dirty ? c.scheme?.code : undefined)
|
||||
if (token === listToken.current) setRecords(data.records)
|
||||
} catch (err) { if (token === listToken.current) setError(message(err)) }
|
||||
finally { if (token === listToken.current) setLoading(false) }
|
||||
}, [])
|
||||
const invalidateLists = useCallback(() => { listToken.current++ }, [])
|
||||
useEffect(() => {
|
||||
if (open) void refresh()
|
||||
return invalidateLists
|
||||
}, [open,scope,currentComposition,controller.scheme?.code,controller.dirty,refresh,invalidateLists])
|
||||
// A request continues when the user switches panels. Pending records refresh while the AI panel is open.
|
||||
useEffect(() => {
|
||||
if (!open || !records.some(record => record.status === 'PENDING')) return
|
||||
const timer = setInterval(() => void refresh(), 5000)
|
||||
return () => clearInterval(timer)
|
||||
}, [open,records,refresh])
|
||||
const openPanel = async () => {
|
||||
setOpen(true); setError(''); setNotice('')
|
||||
if (lock.current) return
|
||||
setPreview(null)
|
||||
if (!live.current.controller.snapshot.items.length) { setScope('all'); return }
|
||||
setStage('正在准备当前方案参考图…')
|
||||
try {
|
||||
// Let the inspector width and viewport camera settle before taking the preview.
|
||||
await new Promise<void>(resolve => requestAnimationFrame(() => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))))
|
||||
await capture()
|
||||
} catch (err) { setError(message(err)) } finally { setStage('') }
|
||||
}
|
||||
const refreshReference = async () => {
|
||||
if(lock.current||stage)return
|
||||
setError('');setStage('正在更新当前视角参考图…')
|
||||
try { await capture() } catch(err) { setError(message(err)) } finally { setStage('') }
|
||||
}
|
||||
const generate = async () => {
|
||||
if (lock.current || !access.canGenerate) return
|
||||
if (mode === 'scene' && !prompt.trim()) { setError('请填写场景提示词,或选择场景快捷词'); return }
|
||||
lock.current = true; setGenerating(true); setError(''); setNotice(''); setStage('正在准备模型与木纹贴图…')
|
||||
const settings = { mode, userPrompt: prompt, negativePrompt, aspectRatio, imageCount, imageSize }
|
||||
try {
|
||||
const reference = await capture()
|
||||
const builtPrompts = await buildDiyAiPrompts({ ...reference, ...settings })
|
||||
setStage('AI 正在生成,可切回配色与柜门继续编辑')
|
||||
const record = await generateDiyAiRender({ requestId: crypto.randomUUID(), name:reference.name, schemeCode:reference.schemeCode,
|
||||
catalogHash:reference.catalogHash, snapshot:reference.snapshot, cameraView:reference.cameraView, ...settings, ...builtPrompts,
|
||||
referenceImageDataUrl: reference.dataUrl })
|
||||
setRecords(previous => [record,...previous.filter(item => item.id!==record.id)].slice(0,60))
|
||||
if(diyCompositionText(record.snapshot)!==diyCompositionText(live.current.controller.saveSnapshot())) {
|
||||
live.current.scope='all';setScope('all')
|
||||
}
|
||||
await refresh()
|
||||
if (record.status === 'FAILED') throw new Error(record.errorMessage || 'AI 出图失败')
|
||||
setNotice(record.status === 'PENDING' ? '任务正在生成,结果会显示在出图记录中'
|
||||
: `已生成 ${record.images.length} 张${record.mode === 'scene' ? '效果图' : '白底图'},已保存到出图记录`)
|
||||
} catch (err) { setError(message(err)); await refresh() }
|
||||
finally { lock.current = false; setGenerating(false); setStage('') }
|
||||
}
|
||||
const optimize = async () => {
|
||||
if (lock.current || !access.canGenerate) return
|
||||
if (!prompt.trim()) { setError('请先填写提示词或选择快捷词'); return }
|
||||
lock.current = true; setOptimizing(true); setError(''); setStage('正在优化提示词…')
|
||||
try {
|
||||
const reference = await capture(), data = await optimizeDiyAiPrompt(prompt, mode, reference.dataUrl)
|
||||
setPrompt(data.prompt.slice(0,2000)); setOptimization(data.fullText.split('---PROMPT---')[0].trim())
|
||||
} catch (err) { setError(message(err)) }
|
||||
finally { lock.current = false; setOptimizing(false); setStage('') }
|
||||
}
|
||||
const download = async (url: string, filename: string) => {
|
||||
if (!access.canDownload || downloading) return
|
||||
setDownloading(url); setError(''); setNotice('正在准备下载…')
|
||||
try { const response = await fetch(url); if (!response.ok) throw new Error('图片下载失败')
|
||||
downloadDiyFile(await response.blob(),filename); setNotice('图片已开始下载')
|
||||
} catch (err) { setError(message(err)) } finally { setDownloading('') }
|
||||
}
|
||||
return { open, setOpen, openPanel, refreshReference, mode, setMode, prompt, setPrompt, negativePrompt, setNegativePrompt,
|
||||
aspectRatio, setAspectRatio, imageCount, setImageCount, imageSize, setImageSize, preview, records, scope, setScope,
|
||||
loading, stage, generating, optimizing, error, notice, optimization, refresh, generate, optimize, download, downloading, access }
|
||||
}
|
||||
export type DiyAiRenderController = ReturnType<typeof useDiyAiRender>
|
||||
@ -0,0 +1,22 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import type { CabinetStyle, DiyCatalogDTO } from '@/lib/diy/types'
|
||||
import { cabinetPreviewRequests, cacheCabinetPreview, cachedCabinetPreview } from '../lib/cabinet-previews'
|
||||
|
||||
export function useDiyCabinetThumbnails(catalog: DiyCatalogDTO, styles: CabinetStyle[], mainCode: string) {
|
||||
const requests = useMemo(() => cabinetPreviewRequests(catalog, styles, mainCode), [catalog, styles, mainCode])
|
||||
const [results, setResults] = useState<Record<string, { image?: string; error?: string }>>({})
|
||||
const previews = Object.fromEntries(requests.map(request => [request.styleCode, {
|
||||
image: results[request.key]?.image || cachedCabinetPreview(request.key), error: results[request.key]?.error,
|
||||
}]))
|
||||
const task = requests.find(request => !previews[request.styleCode].image && !previews[request.styleCode].error)
|
||||
const complete = useCallback((key: string, image: string) => {
|
||||
cacheCabinetPreview(key, image)
|
||||
setResults(previous => ({ ...previous, [key]: { image } }))
|
||||
}, [])
|
||||
const fail = useCallback((key: string, error: string) => {
|
||||
setResults(previous => ({ ...previous, [key]: { error } }))
|
||||
}, [])
|
||||
return { previews, task, complete, fail }
|
||||
}
|
||||
34
furniture-3d-system/src/app/diy/hooks/useDiyCellDetection.ts
Normal file
34
furniture-3d-system/src/app/diy/hooks/useDiyCellDetection.ts
Normal file
@ -0,0 +1,34 @@
|
||||
'use client'
|
||||
import { useCallback,useEffect,useRef,useState } from 'react'
|
||||
import type { CabinetCell,CabinetStyle } from '@/lib/diy/types'
|
||||
import { loadDiyModel } from '../lib/model'
|
||||
import { detectDiyCells,type DiyCellDetection } from '../lib/cell-detection'
|
||||
|
||||
export function useDiyCellDetection(style:CabinetStyle,ready:boolean,automatic:boolean,onChange:(cells:CabinetCell[])=>void,thickness:number,gap:number,onBusyChange?:(busy:boolean)=>void) {
|
||||
const [detecting,setDetecting]=useState(false),[result,setResult]=useState<DiyCellDetection|null>(null),[error,setError]=useState('')
|
||||
const sequence=useRef(0)
|
||||
const invalidate=useCallback(()=>{sequence.current++},[])
|
||||
const detect=useCallback(async()=>{
|
||||
if(!style.modelUrl||!ready)return
|
||||
const current=++sequence.current
|
||||
setDetecting(true);setError('');onBusyChange?.(true)
|
||||
try {
|
||||
await new Promise<void>(resolve=>setTimeout(resolve,0))
|
||||
const data=await detectDiyCells(await loadDiyModel(style.modelUrl),style,thickness,gap)
|
||||
if(current!==sequence.current)return
|
||||
setResult(data)
|
||||
if(data.cells.length)onChange(data.cells)
|
||||
}catch(error){if(current===sequence.current)setError(error instanceof Error?error.message:'格口识别失败')}
|
||||
finally{if(current===sequence.current){setDetecting(false);onBusyChange?.(false)}}
|
||||
},[style,ready,onChange,thickness,gap,onBusyChange])
|
||||
useEffect(()=>{
|
||||
if(!automatic||!ready)return
|
||||
setDetecting(true);onBusyChange?.(true)
|
||||
const timer=setTimeout(()=>void detect(),150)
|
||||
return()=>{clearTimeout(timer);invalidate()}
|
||||
// Cell edits do not trigger another automatic recognition; geometry changes do.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
},[automatic,ready,style.modelUrl,style.modelRotationY,style.realSize.width,style.realSize.height,style.realSize.depth,thickness,gap,onBusyChange,invalidate])
|
||||
useEffect(()=>invalidate,[invalidate])
|
||||
return {detect,detecting,result,error}
|
||||
}
|
||||
@ -1,31 +1,51 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo, useRef, useState } from 'react'
|
||||
import { cellKey, createDiyCatalogAccess } from '@/lib/diy/catalog'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { cellKey, createDiyCatalogAccess, doorPatternFits } from '@/lib/diy/catalog'
|
||||
import { descendants, createDiyLayout } from '@/lib/diy/layout'
|
||||
import type { DiySnapshot, DiySchemeDTO, DiyCatalogDTO } from '@/lib/diy/types'
|
||||
import { fetchDiyCatalog } from '../lib/api'
|
||||
import { findDiyColorForMaterial } from '@/lib/diy/materials'
|
||||
import { diyColorIsAvailable } from '@/lib/diy/asset-availability'
|
||||
|
||||
export const EMPTY_DIY_SNAPSHOT: DiySnapshot = { version: 1, spu: 'SJ0380', items: [], doors: {}, camera: { yaw: .35, zoom: 1 } }
|
||||
export function useDiyWorkbench(catalog: DiyCatalogDTO) {
|
||||
export const EMPTY_DIY_SNAPSHOT: DiySnapshot = { version: 1, spu: 'DIY', items: [], doors: {}, camera: { yaw: .35, pitch:.23, zoom: 1,target:[0,0,0] } }
|
||||
export function useDiyWorkbench(initialCatalog: DiyCatalogDTO) {
|
||||
const [catalog,setCatalog]=useState(initialCatalog)
|
||||
const [seriesSpu,updateSeriesSpu]=useState(initialCatalog.series?.find(series=>series.spu==='SJ0380')?.spu||initialCatalog.series?.[0]?.spu||initialCatalog.spu)
|
||||
const [cameraRevision,setCameraRevision]=useState(0)
|
||||
const catalogAccess = useMemo(() => createDiyCatalogAccess(catalog), [catalog])
|
||||
const layout = useMemo(() => createDiyLayout(catalog), [catalog])
|
||||
const { mainColors, getCatalogStyle, getStyle } = catalogAccess
|
||||
const { getCatalogStyle, getStyle } = catalogAccess
|
||||
const { itemSize, moveItem, placeOnFloor } = layout
|
||||
const [snapshot, setSnapshot] = useState<DiySnapshot>(EMPTY_DIY_SNAPSHOT)
|
||||
const [scheme, setScheme] = useState<DiySchemeDTO | null>(null)
|
||||
const [dirty, setDirty] = useState(false)
|
||||
const [selectedId, setSelectedId] = useState('')
|
||||
const [family, setFamily] = useState(mainColors[2] || mainColors[0])
|
||||
const [patternCode, setPatternCode] = useState(catalog.patterns[0].itemCode)
|
||||
const mainMaterials=catalog.materials.filter(material=>material.kind==='main'&&!material.deletedAt)
|
||||
const [familyCode,setFamilyCode]=useState((mainMaterials[2]||mainMaterials[0])?.itemCode||'')
|
||||
const family=catalog.materials.find(material=>material.itemCode===familyCode)?.name||''
|
||||
const [patternCode, updatePatternCode] = useState(catalog.patterns.find(pattern=>!pattern.deletedAt)?.itemCode||'')
|
||||
const [mode, setMode] = useState<'layout' | 'doors'>('layout')
|
||||
const [feedback, setFeedback] = useState<{ tone: 'success' | 'error'; text: string } | null>(null)
|
||||
const camera = useRef(snapshot.camera)
|
||||
const change = (next: DiySnapshot) => { setSnapshot(next); setDirty(true); setFeedback(null) }
|
||||
const live=useRef({catalog,snapshot,patternCode})
|
||||
useEffect(()=>{live.current={catalog,snapshot,patternCode}},[catalog,snapshot,patternCode])
|
||||
const setPatternCode=(code:string)=>{live.current.patternCode=code;updatePatternCode(code)}
|
||||
const setSeriesSpu=(spu:string)=>{
|
||||
updateSeriesSpu(spu)
|
||||
}
|
||||
const setCamera=useCallback((view:DiySnapshot['camera'])=>{
|
||||
camera.current=view
|
||||
setSnapshot(previous=>({...previous,camera:view}))
|
||||
},[])
|
||||
const change = (next: DiySnapshot) => { live.current.snapshot=next;setSnapshot(next); setDirty(true); setFeedback(null) }
|
||||
const add = (code: string) => {
|
||||
if (snapshot.items.length >= 100) { setFeedback({ tone: 'error', text: '最多添加100个柜体' }); return }
|
||||
const style = getCatalogStyle(code)
|
||||
const colorName = (style.colors.find(color => color.name === family || color.name.startsWith(`${family}+`)) || style.colors[0]).name
|
||||
const item = placeOnFloor({ instanceId: `cabinet-${crypto.randomUUID()}`, code, colorName,
|
||||
const color=findDiyColorForMaterial(catalog,code,familyCode)
|
||||
if(!familyCode||!color||!diyColorIsAvailable(catalog,code,color.name)){setFeedback({tone:'error',text:`请先选择可用颜色,并为“${style.name}”添加“${family}”颜色款`});return}
|
||||
const colorName=color.name
|
||||
const item = placeOnFloor({ instanceId: `cabinet-${crypto.randomUUID()}`, spu:style.spu||'SJ0380',code, colorName,
|
||||
offsetY: 0, offsetZ: 0, quarterTurn: 0, supportId: 'floor' }, snapshot.items)
|
||||
change({ ...snapshot, items: [...snapshot.items, item] }); setSelectedId(item.instanceId)
|
||||
}
|
||||
@ -48,18 +68,31 @@ export function useDiyWorkbench(catalog: DiyCatalogDTO) {
|
||||
if (items) { change({ ...snapshot, items }); setFeedback({ tone: 'success', text: '柜体已吸附' }) }
|
||||
else setFeedback({ tone: 'error', text: '当前位置无法承托柜体,请靠近地面或柜顶完整放置' })
|
||||
}
|
||||
const setColor = (id: string, colorName: string) => change({ ...snapshot,
|
||||
items: snapshot.items.map(item => item.instanceId === id ? { ...item, colorName } : item) })
|
||||
const toggleDoor = (id: string, cellId: string) => {
|
||||
const key = cellKey(id, cellId), doors = { ...snapshot.doors }
|
||||
if (doors[key] === patternCode) delete doors[key]; else doors[key] = patternCode
|
||||
change({ ...snapshot, doors })
|
||||
const setColor = (id: string, colorName: string) => {
|
||||
const current=live.current.snapshot,item=current.items.find(item=>item.instanceId===id)
|
||||
if(!item||!live.current.catalog.catalogStyles.find(style=>style.code===item.code)?.colors.some(color=>color.name===colorName))return
|
||||
if(colorName!==item.colorName&&!diyColorIsAvailable(live.current.catalog,item.code,colorName))return
|
||||
change({...current,items:current.items.map(item=>item.instanceId===id?{...item,colorName}:item)})
|
||||
}
|
||||
const toggleDoor = useCallback((id: string, cellId: string) => {
|
||||
// The 3D root may reconcile after the DOM root; pointer handlers read the latest configuration.
|
||||
const {snapshot:current,catalog:currentCatalog,patternCode:currentPatternCode}=live.current
|
||||
const key = cellKey(id, cellId), doors = { ...current.doors }
|
||||
const item=current.items.find(item=>item.instanceId===id),cell=item&¤tCatalog.styles.find(style=>style.code===item.code)?.cells.find(cell=>cell.id===cellId)
|
||||
const pattern=currentCatalog.patterns.find(pattern=>pattern.itemCode===currentPatternCode)
|
||||
const existing=currentCatalog.patterns.find(pattern=>pattern.itemCode===doors[key])
|
||||
if(doors[key]===currentPatternCode||existing?.deletedAt){delete doors[key]}
|
||||
else if(!pattern||pattern.deletedAt||!cell||!doorPatternFits(pattern,cell)){setFeedback({tone:'error',text:'请选择可用且尺寸匹配的柜门图案'});return}
|
||||
else doors[key]=currentPatternCode
|
||||
const next={...current,doors};live.current.snapshot=next
|
||||
setSnapshot(next);setDirty(true);setFeedback(null)
|
||||
},[])
|
||||
const load = (record: DiySchemeDTO) => {
|
||||
setSnapshot(record.snapshot); setScheme(record); setDirty(false); setSelectedId('')
|
||||
live.current.snapshot=record.snapshot;setSnapshot(record.snapshot); setScheme(record); setDirty(false); setSelectedId('')
|
||||
setCameraRevision(n=>n+1)
|
||||
camera.current = record.snapshot.camera; setFeedback({ tone: 'success', text: '方案已载入' })
|
||||
}
|
||||
const reset = () => { change(EMPTY_DIY_SNAPSHOT); setScheme(null); setSelectedId(''); camera.current = EMPTY_DIY_SNAPSHOT.camera }
|
||||
const reset = () => { change(EMPTY_DIY_SNAPSHOT); setScheme(null); setSelectedId(''); camera.current = EMPTY_DIY_SNAPSHOT.camera;setCameraRevision(n=>n+1) }
|
||||
const saveSnapshot = (): DiySnapshot => ({ ...snapshot, camera: camera.current })
|
||||
const setSaved = (record: DiySchemeDTO) => { setScheme(record); setDirty(false); setFeedback({ tone: 'success', text: '方案已保存,历史版本已保留' }) }
|
||||
const selected = snapshot.items.find(item => item.instanceId === selectedId)
|
||||
@ -67,12 +100,19 @@ export function useDiyWorkbench(catalog: DiyCatalogDTO) {
|
||||
const stackSelected = () => {
|
||||
if (!selected) return
|
||||
const support = snapshot.items.find(item => item.instanceId !== selectedId &&
|
||||
!descendants(snapshot.items,selectedId).has(item.instanceId) && itemSize(item).width >= itemSize(selected).width)
|
||||
!descendants(snapshot.items,selectedId).has(item.instanceId) && itemSize(item).width >= itemSize(selected).width && itemSize(item).depth >= itemSize(selected).depth)
|
||||
if (!support) { setFeedback({tone:'error',text:'请先添加可完整承托的柜体'}); return }
|
||||
move(selectedId,support.offsetY,support.offsetZ+itemSize(support).height/2+itemSize(selected).height/2)
|
||||
}
|
||||
return { catalog, catalogAccess, layout, snapshot, scheme, dirty, selected, selectedId, setSelectedId, family, setFamily, patternCode, setPatternCode,
|
||||
const reloadCatalog=async()=>{const next=await fetchDiyCatalog();live.current.catalog=next;setCatalog(next)
|
||||
setFamilyCode(previous=>next.materials.some(material=>material.itemCode===previous&&material.kind==='main'&&!material.deletedAt)?previous:next.materials.find(material=>material.kind==='main'&&!material.deletedAt)?.itemCode||'')
|
||||
const nextPattern=next.patterns.find(pattern=>pattern.itemCode===live.current.patternCode&&!pattern.deletedAt)?.itemCode||next.patterns.find(pattern=>!pattern.deletedAt)?.itemCode||''
|
||||
setPatternCode(nextPattern)
|
||||
return next}
|
||||
const setView=(yaw:number,pitch:number)=>{const view={yaw,pitch,zoom:1,target:[0,0,0] as [number,number,number]};camera.current=view;setSnapshot(previous=>({...previous,camera:view}));setCameraRevision(n=>n+1)}
|
||||
return { catalog, catalogAccess, layout, snapshot, scheme, dirty, selected, selectedId, setSelectedId, family, familyCode, setFamilyCode, patternCode, setPatternCode,
|
||||
seriesSpu,setSeriesSpu,reloadCatalog,cameraRevision,setView,
|
||||
mode, setMode, feedback, setFeedback, add, remove, rotate, move, setColor, toggleDoor, load, reset, saveSnapshot, setSaved, cells, stackSelected,
|
||||
setCamera: (view: DiySnapshot['camera']) => { camera.current = view } }
|
||||
setCamera }
|
||||
}
|
||||
export type DiyWorkbenchController = ReturnType<typeof useDiyWorkbench>
|
||||
|
||||
21
furniture-3d-system/src/app/diy/lib/ai-api.ts
Normal file
21
furniture-3d-system/src/app/diy/lib/ai-api.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import type { DiyAiRenderDTO, DiyAiRenderInput, DiyAiRenderList, DiyAiRenderMode } from '@/lib/diy/ai-render'
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(path, { cache: 'no-store', ...init })
|
||||
const payload = await response.json().catch(() => null)
|
||||
if (!response.ok || !payload?.success) throw new Error(payload?.error?.message || `请求失败(${response.status})`)
|
||||
return payload.data as T
|
||||
}
|
||||
export function listDiyAiRenders(compositionKey?: string, schemeCode?: string) {
|
||||
const query = new URLSearchParams()
|
||||
if (compositionKey) query.set('compositionKey', compositionKey)
|
||||
if (schemeCode) query.set('schemeCode', schemeCode)
|
||||
return request<DiyAiRenderList>(`/api/diy/ai-renders?${query}`)
|
||||
}
|
||||
export const generateDiyAiRender = (input: DiyAiRenderInput) => request<DiyAiRenderDTO>('/api/diy/ai-renders', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input),
|
||||
})
|
||||
export const optimizeDiyAiPrompt = (prompt: string, mode: DiyAiRenderMode, referenceImageDataUrl: string) =>
|
||||
request<{ fullText: string; prompt: string }>('/api/diy/ai-renders/optimize-prompt', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ prompt, mode, referenceImageDataUrl }),
|
||||
})
|
||||
6
furniture-3d-system/src/app/diy/lib/ai-capture.ts
Normal file
6
furniture-3d-system/src/app/diy/lib/ai-capture.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import type { MutableRefObject } from 'react'
|
||||
import type { DiyAiCameraView } from '@/lib/diy/ai-render'
|
||||
|
||||
export interface DiyReferenceCapture { dataUrl: string; cameraView: DiyAiCameraView }
|
||||
export interface DiyCaptureHandle { capture: () => Promise<DiyReferenceCapture> }
|
||||
export type DiyCaptureRef = MutableRefObject<DiyCaptureHandle | null>
|
||||
40
furniture-3d-system/src/app/diy/lib/ai-prompts.ts
Normal file
40
furniture-3d-system/src/app/diy/lib/ai-prompts.ts
Normal file
@ -0,0 +1,40 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
buildLockedScenePrompt, buildWhiteBackgroundPrompt, inferWhiteBackgroundViewConstraint,
|
||||
prepareWhiteBackgroundNegativePrompt, WHITE_BACKGROUND_SAME_VIEW_STRUCTURE_LOCK_LABEL,
|
||||
WHITE_BACKGROUND_COMPONENT_TYPE_LOCK_NEGATIVE_PROMPT, WHITE_BACKGROUND_MATERIAL_SCOPE_LOCK_NEGATIVE_PROMPT,
|
||||
WHITE_BACKGROUND_COMPOSITION_LOCK_NEGATIVE_PROMPT, WHITE_BACKGROUND_REFERENCE_AUTHORITY_NEGATIVE_PROMPT,
|
||||
WHITE_BACKGROUND_MICRO_STRUCTURE_NEGATIVE_PROMPT, WHITE_BACKGROUND_CLEAN_BACKGROUND_NEGATIVE_PROMPT,
|
||||
CURVE_DETAIL_ENHANCEMENT_NEGATIVE_PROMPT,
|
||||
} from '@/app/ai-render-workbench/promptBuilders'
|
||||
import { mergeNegativePrompt } from '@/app/ai-render-workbench/workbenchHelpers'
|
||||
import { buildReadableStructureReferenceFromImage } from '@/app/ai-render-workbench/referenceImageUtils'
|
||||
import type { DiyAiCameraView, DiyAiRenderMode } from '@/lib/diy/ai-render'
|
||||
import type { DiySnapshot } from '@/lib/diy/types'
|
||||
|
||||
export async function buildDiyAiPrompts(input: { mode: DiyAiRenderMode; name: string; snapshot: DiySnapshot;
|
||||
dataUrl: string; cameraView: DiyAiCameraView; userPrompt: string; negativePrompt: string; aspectRatio: string }) {
|
||||
// Prompt context carries names only; DIY never creates or masquerades as a formal SKU.
|
||||
const sku = { name: input.name, nodeMaterials: Object.fromEntries(input.snapshot.items.map(item =>
|
||||
[item.instanceId, { color: '', materialName: item.colorName }])) }
|
||||
const common = { spu: null, sku, userPrompt: input.userPrompt, aspectRatio: input.aspectRatio }
|
||||
if (input.mode === 'scene') return {
|
||||
prompt: buildLockedScenePrompt(common), structureReferenceImageDataUrls: [],
|
||||
negativePrompt: mergeNegativePrompt(input.negativePrompt, 'missing cabinet, added cabinet, changed door artwork, changed arrangement',
|
||||
WHITE_BACKGROUND_COMPONENT_TYPE_LOCK_NEGATIVE_PROMPT, WHITE_BACKGROUND_MATERIAL_SCOPE_LOCK_NEGATIVE_PROMPT, CURVE_DETAIL_ENHANCEMENT_NEGATIVE_PROMPT),
|
||||
}
|
||||
const structure = await buildReadableStructureReferenceFromImage(input.dataUrl)
|
||||
if (!structure) throw new Error('当前画面无法提取结构参考,请重新截图')
|
||||
const view = inferWhiteBackgroundViewConstraint(input.cameraView)
|
||||
return {
|
||||
prompt: buildWhiteBackgroundPrompt({ ...common, cameraView: input.cameraView,
|
||||
structureReferenceLabels: [WHITE_BACKGROUND_SAME_VIEW_STRUCTURE_LOCK_LABEL] }),
|
||||
structureReferenceImageDataUrls: [structure],
|
||||
negativePrompt: mergeNegativePrompt(prepareWhiteBackgroundNegativePrompt(input.negativePrompt, Boolean(input.userPrompt.trim())),
|
||||
view?.negativePrompt, 'missing cabinet, added cabinet, changed door artwork, changed arrangement',
|
||||
WHITE_BACKGROUND_COMPONENT_TYPE_LOCK_NEGATIVE_PROMPT, WHITE_BACKGROUND_MATERIAL_SCOPE_LOCK_NEGATIVE_PROMPT,
|
||||
WHITE_BACKGROUND_COMPOSITION_LOCK_NEGATIVE_PROMPT, WHITE_BACKGROUND_REFERENCE_AUTHORITY_NEGATIVE_PROMPT,
|
||||
WHITE_BACKGROUND_MICRO_STRUCTURE_NEGATIVE_PROMPT, WHITE_BACKGROUND_CLEAN_BACKGROUND_NEGATIVE_PROMPT, CURVE_DETAIL_ENHANCEMENT_NEGATIVE_PROMPT),
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,5 @@
|
||||
import type { DiySchemeDTO, DiySchemePage, DiySnapshot } from '@/lib/diy/types'
|
||||
import type { DiySchemeDTO, DiySchemePage, DiySnapshot, DiyCatalogDTO, DiyAssetDTO,DiyMaterialUsageDTO,DiyRecycledAssetDTO } from '@/lib/diy/types'
|
||||
import type { DiyManagementInput,DiyStyleCellsInput,DiyMaterialDeleteInput,DiyAssetLifecycleInput } from '@/lib/diy/management-schema'
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(path, { cache: 'no-store', ...init })
|
||||
const body = await response.json().catch(() => null)
|
||||
@ -10,3 +11,22 @@ export const saveDiyScheme = (snapshot: DiySnapshot, name: string, parentCode?:
|
||||
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ snapshot, name, parentCode }),
|
||||
})
|
||||
export const readDiyScheme = (code: string) => request<DiySchemeDTO>(`/api/diy/schemes/${encodeURIComponent(code)}`)
|
||||
export const fetchDiyCatalog = () => request<DiyCatalogDTO>('/api/diy/catalog')
|
||||
export const createDiyMaterialEntry = (input:DiyManagementInput) => request<{spu:string;code?:string}>('/api/diy/catalog/manage',{
|
||||
method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(input),
|
||||
})
|
||||
export function uploadDiyAsset(file:File,spu:string,kind:'image'|'model') {
|
||||
const form=new FormData();form.set('file',file);form.set('spu',spu);form.set('kind',kind)
|
||||
return request<DiyAssetDTO>('/api/diy/catalog/upload',{method:'POST',body:form})
|
||||
}
|
||||
export const updateDiyStyleCells=(input:DiyStyleCellsInput)=>request<{spu:string;revision:number}>('/api/diy/catalog/manage',{
|
||||
method:'PATCH',headers:{'content-type':'application/json'},body:JSON.stringify(input),
|
||||
})
|
||||
export const readDiyMaterialUsage=(itemCode:string)=>request<DiyMaterialUsageDTO>(`/api/diy/catalog/materials/${encodeURIComponent(itemCode)}`)
|
||||
export const deleteDiyMaterial=(input:DiyMaterialDeleteInput)=>request<{spu:string;itemCode:string}>('/api/diy/catalog/manage',{
|
||||
method:'DELETE',headers:{'content-type':'application/json'},body:JSON.stringify(input),
|
||||
})
|
||||
export const listDiyRecycledAssets = () => request<DiyRecycledAssetDTO[]>('/api/diy/catalog/recycle-bin')
|
||||
export const changeDiyAssetLifecycle = (input: DiyAssetLifecycleInput) => request<{spu:string;itemCode:string}>('/api/diy/catalog/assets', {
|
||||
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(input),
|
||||
})
|
||||
|
||||
30
furniture-3d-system/src/app/diy/lib/cabinet-previews.ts
Normal file
30
furniture-3d-system/src/app/diy/lib/cabinet-previews.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import { findDiyColorForMaterial } from '@/lib/diy/materials'
|
||||
import { resolveDiyMaterials } from '@/lib/diy/catalog'
|
||||
import type { CabinetStyle, DiyCatalogDTO } from '@/lib/diy/types'
|
||||
|
||||
export interface DiyCabinetPreviewRequest {
|
||||
key: string
|
||||
styleCode: string
|
||||
mainCode: string
|
||||
shelfCode: string
|
||||
}
|
||||
|
||||
export function cabinetPreviewRequests(catalog: DiyCatalogDTO, styles: CabinetStyle[], mainCode: string): DiyCabinetPreviewRequest[] {
|
||||
const main = catalog.materials.find(material => material.itemCode === mainCode)
|
||||
if (!main) return []
|
||||
return styles.map(style => {
|
||||
const variant = findDiyColorForMaterial(catalog, style.code, mainCode)
|
||||
const shelf = variant && resolveDiyMaterials(catalog, { code: style.code, colorName: variant.name }).shelf
|
||||
return { key: JSON.stringify([style, main, shelf || null]), styleCode: style.code, mainCode, shelfCode: shelf?.itemCode || '' }
|
||||
})
|
||||
}
|
||||
|
||||
// Browser memory only: old database thumbnails and objects are kept intact.
|
||||
const thumbnails = new Map<string, string>()
|
||||
const maxThumbnails = 120
|
||||
export function cachedCabinetPreview(key: string) { return thumbnails.get(key) }
|
||||
export function cacheCabinetPreview(key: string, image: string) {
|
||||
thumbnails.delete(key)
|
||||
thumbnails.set(key, image)
|
||||
while (thumbnails.size > maxThumbnails) thumbnails.delete(thumbnails.keys().next().value!)
|
||||
}
|
||||
6
furniture-3d-system/src/app/diy/lib/capture.ts
Normal file
6
furniture-3d-system/src/app/diy/lib/capture.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export async function captureDiyThumbnail(stage: HTMLDivElement | null) {
|
||||
await new Promise<void>(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve())))
|
||||
const canvas = stage?.querySelector('canvas')
|
||||
if (!canvas) throw new Error('模型预览尚未就绪')
|
||||
return new Promise<Blob>((resolve, reject) => canvas.toBlob(blob => blob ? resolve(blob) : reject(new Error('缩略图生成失败')), 'image/png'))
|
||||
}
|
||||
60
furniture-3d-system/src/app/diy/lib/cell-detection-grid.ts
Normal file
60
furniture-3d-system/src/app/diy/lib/cell-detection-grid.ts
Normal file
@ -0,0 +1,60 @@
|
||||
export interface OpeningRect { left:number;right:number;bottom:number;top:number }
|
||||
|
||||
function edges(values:number[]) {
|
||||
return values.sort((a,b)=>a-b).filter((value,index,array)=>index===0||value-array[index-1]>.1)
|
||||
}
|
||||
|
||||
/** Union panel cross sections, then find enclosed rectangular empty regions. */
|
||||
export function openingsBetweenPanels(panels:OpeningRect[],width:number,height:number):OpeningRect[] {
|
||||
const xs=edges([0,width,...panels.flatMap(panel=>[panel.left,panel.right])])
|
||||
const ys=edges([0,height,...panels.flatMap(panel=>[panel.bottom,panel.top])])
|
||||
if(xs.length>250||ys.length>250)return []
|
||||
const cols=xs.length-1,rows=ys.length-1,solid=new Uint8Array(cols*rows)
|
||||
for(let row=0;row<rows;row++)for(let col=0;col<cols;col++) {
|
||||
const x=(xs[col]+xs[col+1])/2,y=(ys[row]+ys[row+1])/2
|
||||
if(panels.some(panel=>x>panel.left-.01&&x<panel.right+.01&&y>panel.bottom-.01&&y<panel.top+.01))solid[row*cols+col]=1
|
||||
}
|
||||
const found:OpeningRect[]=[]
|
||||
for(let start=0;start<solid.length;start++) {
|
||||
if(solid[start])continue
|
||||
const queue=[start];solid[start]=2
|
||||
let minCol=cols,maxCol=0,minRow=rows,maxRow=0,area=0,outside=false
|
||||
for(let head=0;head<queue.length;head++) {
|
||||
const index=queue[head],col=index%cols,row=Math.floor(index/cols)
|
||||
minCol=Math.min(minCol,col);maxCol=Math.max(maxCol,col);minRow=Math.min(minRow,row);maxRow=Math.max(maxRow,row)
|
||||
area+=(xs[col+1]-xs[col])*(ys[row+1]-ys[row])
|
||||
if(col===0||col===cols-1||row===0||row===rows-1)outside=true
|
||||
for(const next of [col>0?index-1:-1,col<cols-1?index+1:-1,row>0?index-cols:-1,row<rows-1?index+cols:-1]) {
|
||||
if(next>=0&&!solid[next]){solid[next]=2;queue.push(next)}
|
||||
}
|
||||
}
|
||||
const rect={left:xs[minCol],right:xs[maxCol+1],bottom:ys[minRow],top:ys[maxRow+1]}
|
||||
const w=rect.right-rect.left,h=rect.top-rect.bottom
|
||||
if(!outside&&w>=40&&h>=40&&area/(w*h)>.97)found.push(rect)
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
/** Raster fallback for models whose panels have been merged into one mesh. */
|
||||
export function openingsInMask(solid:Uint8Array,cols:number,rows:number,width:number,height:number):OpeningRect[] {
|
||||
// A raster may have more edges than the panel grid; flood it directly.
|
||||
const visited=solid.slice(),found:OpeningRect[]=[]
|
||||
for(let start=0;start<visited.length;start++) {
|
||||
if(visited[start])continue
|
||||
const queue=[start];visited[start]=2
|
||||
let left=cols,right=0,bottom=rows,top=0,outside=false
|
||||
for(let head=0;head<queue.length;head++) {
|
||||
const index=queue[head],col=index%cols,row=Math.floor(index/cols)
|
||||
left=Math.min(left,col);right=Math.max(right,col);bottom=Math.min(bottom,row);top=Math.max(top,row)
|
||||
if(col===0||row===0||col===cols-1||row===rows-1)outside=true
|
||||
for(const next of [col>0?index-1:-1,col<cols-1?index+1:-1,row>0?index-cols:-1,row<rows-1?index+cols:-1]) {
|
||||
if(next>=0&&!visited[next]){visited[next]=2;queue.push(next)}
|
||||
}
|
||||
}
|
||||
const w=(right-left+1)*width/cols,h=(top-bottom+1)*height/rows
|
||||
if(!outside&&w>=40&&h>=40&&queue.length/((right-left+1)*(top-bottom+1))>.97) {
|
||||
found.push({left:left*width/cols,right:(right+1)*width/cols,bottom:bottom*height/rows,top:(top+1)*height/rows})
|
||||
}
|
||||
}
|
||||
return found
|
||||
}
|
||||
@ -0,0 +1,63 @@
|
||||
import * as THREE from 'three'
|
||||
import { openingsInMask,type OpeningRect } from './cell-detection-grid'
|
||||
|
||||
interface Segment { a:THREE.Vector3;b:THREE.Vector3;direction:number }
|
||||
const pause=()=>new Promise<void>(resolve=>setTimeout(resolve,0))
|
||||
|
||||
export async function openingsFromTriangles(root:THREE.Object3D,bounds:THREE.Box3,size:{width:number;height:number;depth:number}):Promise<OpeningRect[]> {
|
||||
const dimensions=bounds.getSize(new THREE.Vector3())
|
||||
const scale=new THREE.Vector3(size.width/dimensions.x,size.height/dimensions.y,size.depth/dimensions.z)
|
||||
const plane=size.depth*.94,segments:Segment[]=[],meshes:THREE.Mesh[]=[]
|
||||
root.traverse(node=>{if((node as THREE.Mesh).isMesh)meshes.push(node as THREE.Mesh)})
|
||||
const points=[new THREE.Vector3(),new THREE.Vector3(),new THREE.Vector3()],normal=new THREE.Vector3(),edge=new THREE.Vector3()
|
||||
let triangles=0
|
||||
for(const mesh of meshes) {
|
||||
const positions=mesh.geometry.getAttribute('position'),indices=mesh.geometry.index,count=indices?.count||positions.count
|
||||
for(let index=0;index<count;index+=3) {
|
||||
if(++triangles%25000===0)await pause()
|
||||
for(let corner=0;corner<3;corner++)points[corner].fromBufferAttribute(positions,indices?indices.getX(index+corner):index+corner).applyMatrix4(mesh.matrixWorld).sub(bounds.min).multiply(scale)
|
||||
if(points.every(point=>point.z<plane)||points.every(point=>point.z>plane))continue
|
||||
const hits:THREE.Vector3[]=[]
|
||||
for(let corner=0;corner<3;corner++) {
|
||||
const a=points[corner],b=points[(corner+1)%3]
|
||||
if((a.z<=plane&&b.z>plane)||(b.z<=plane&&a.z>plane))hits.push(a.clone().lerp(b,(plane-a.z)/(b.z-a.z)))
|
||||
}
|
||||
if(hits.length!==2||hits[0].distanceToSquared(hits[1])<.000001)continue
|
||||
normal.subVectors(points[1],points[0]).cross(edge.subVectors(points[2],points[0]))
|
||||
segments.push({a:hits[0],b:hits[1],direction:normal.x<0?1:-1})
|
||||
}
|
||||
}
|
||||
const resolution=Math.max(size.width,size.height)/640,cols=Math.max(16,Math.ceil(size.width/resolution)),rows=Math.max(16,Math.ceil(size.height/resolution))
|
||||
const crossings:Array<Array<{x:number;direction:number}>>=Array.from({length:rows},()=>[])
|
||||
for(const segment of segments) {
|
||||
const {a,b}=segment
|
||||
if(Math.abs(a.y-b.y)<.00001)continue
|
||||
const start=Math.max(0,Math.ceil(Math.min(a.y,b.y)*rows/size.height-.5)),end=Math.min(rows-1,Math.floor(Math.max(a.y,b.y)*rows/size.height-.5))
|
||||
for(let row=start;row<=end;row++) {
|
||||
const y=(row+.5)*size.height/rows
|
||||
if((a.y>y)===(b.y>y))continue
|
||||
crossings[row].push({x:a.x+(b.x-a.x)*(y-a.y)/(b.y-a.y),direction:segment.direction})
|
||||
}
|
||||
}
|
||||
const mask=new Uint8Array(cols*rows)
|
||||
for(let row=0;row<rows;row++) {
|
||||
const values=crossings[row].sort((a,b)=>a.x-b.x)
|
||||
let winding=0,previous=0
|
||||
for(let index=0;index<values.length;) {
|
||||
const x=values[index].x
|
||||
if(winding)for(let col=Math.max(0,Math.ceil(previous*cols/size.width-.5));col<Math.min(cols,Math.ceil(x*cols/size.width-.5));col++)mask[row*cols+col]=1
|
||||
while(index<values.length&&Math.abs(values[index].x-x)<.01)winding+=values[index++].direction
|
||||
previous=x
|
||||
}
|
||||
}
|
||||
const vertical=segments.filter(segment=>Math.abs(segment.a.x-segment.b.x)<.1).map(segment=>segment.a.x)
|
||||
const horizontal=segments.filter(segment=>Math.abs(segment.a.y-segment.b.y)<.1).map(segment=>segment.a.y)
|
||||
const snap=(value:number,candidates:number[])=>{
|
||||
let best=value,distance=resolution*2
|
||||
for(const candidate of candidates)if(Math.abs(candidate-value)<distance){best=candidate;distance=Math.abs(candidate-value)}
|
||||
return best
|
||||
}
|
||||
return openingsInMask(mask,cols,rows,size.width,size.height).map(rect=>({
|
||||
left:snap(rect.left,vertical),right:snap(rect.right,vertical),bottom:snap(rect.bottom,horizontal),top:snap(rect.top,horizontal),
|
||||
}))
|
||||
}
|
||||
43
furniture-3d-system/src/app/diy/lib/cell-detection.ts
Normal file
43
furniture-3d-system/src/app/diy/lib/cell-detection.ts
Normal file
@ -0,0 +1,43 @@
|
||||
import * as THREE from 'three'
|
||||
import type { CabinetCell,CabinetStyle } from '@/lib/diy/types'
|
||||
import { openingsBetweenPanels,type OpeningRect } from './cell-detection-grid'
|
||||
import { openingsFromTriangles } from './cell-detection-section'
|
||||
|
||||
export interface DiyCellDetection { cells:CabinetCell[];rows:number;cols:number;method:'panels'|'section';message:string }
|
||||
const round=(value:number)=>Math.round(value*10)/10
|
||||
|
||||
export async function detectDiyCells(source:THREE.Object3D,style:CabinetStyle,thickness=15,gap=2):Promise<DiyCellDetection> {
|
||||
const root=source.clone(true)
|
||||
root.rotation.y=(style.modelRotationY||0)*Math.PI/180;root.updateMatrixWorld(true)
|
||||
const bounds=new THREE.Box3().setFromObject(root),span=bounds.getSize(new THREE.Vector3()),size=style.realSize
|
||||
if(Math.min(span.x,span.y,span.z)<=0)throw new Error('模型尺寸无效,无法识别格口')
|
||||
const scale=new THREE.Vector3(size.width/span.x,size.height/span.y,size.depth/span.z),panels:OpeningRect[]=[]
|
||||
let nonPanels=0
|
||||
root.traverse(node=>{
|
||||
if(!(node as THREE.Mesh).isMesh)return
|
||||
const box=new THREE.Box3().setFromObject(node)
|
||||
const min=box.min.clone().sub(bounds.min).multiply(scale),max=box.max.clone().sub(bounds.min).multiply(scale),width=max.x-min.x,height=max.y-min.y
|
||||
if(min.z>size.depth*.94||max.z<size.depth*.94)return
|
||||
const vertical=width<=Math.min(80,size.width*.12)&&height>=40
|
||||
const horizontal=height<=Math.min(80,size.height*.12)&&width>=40
|
||||
if(vertical||horizontal)panels.push({left:min.x,right:max.x,bottom:min.y,top:max.y})
|
||||
else if(width>40&&height>40)nonPanels++
|
||||
})
|
||||
let method:DiyCellDetection['method']='panels',openings=nonPanels?[]:openingsBetweenPanels(panels,size.width,size.height)
|
||||
if(!openings.length){method='section';openings=await openingsFromTriangles(root,bounds,size)}
|
||||
if(openings.length>100)throw new Error('识别到超过100个格口,请先检查模型正面和结构')
|
||||
const xLevels=[...new Set(openings.map(rect=>round((rect.left+rect.right)/2)))].sort((a,b)=>a-b)
|
||||
const yLevels=[...new Set(openings.map(rect=>round((rect.bottom+rect.top)/2)))].sort((a,b)=>a-b)
|
||||
const used=new Set<string>()
|
||||
const cells=openings.sort((a,b)=>a.bottom-b.bottom||a.left-b.left).map(rect=>{
|
||||
const x=(rect.left+rect.right)/2,y=(rect.bottom+rect.top)/2,row=yLevels.indexOf(round(y)),col=xLevels.indexOf(round(x))
|
||||
const center:[number,number]=[round(size.width/2-x),round(y-size.height/2)],width=round(rect.right-rect.left),height=round(rect.top-rect.bottom)
|
||||
const existing=style.cells.find(cell=>!used.has(cell.id)&&Math.hypot(cell.center[0]-center[0],cell.center[1]-center[1])<=2)
|
||||
const base=existing?.id||`r${row+1}c${col+1}`
|
||||
let id=base,suffix=1
|
||||
while(used.has(id))id=`${base}-${++suffix}`
|
||||
used.add(id)
|
||||
return {id,row,col,center,inner:{width,height,depth:size.depth},door:{width:round(width-gap*2),height:round(height-gap*2),thickness}}
|
||||
})
|
||||
return {cells,rows:yLevels.length,cols:xLevels.length,method,message:cells.length?`已识别 ${cells.length} 个格口,请核对绿色位置和门片尺寸`:'未识别到封闭的矩形格口,请检查模型朝向或使用手动配置'}
|
||||
}
|
||||
@ -7,7 +7,7 @@ export function downloadDiyFile(blob:Blob,name:string) {
|
||||
setTimeout(()=>URL.revokeObjectURL(url),1000)
|
||||
}
|
||||
export async function renderDiyBomImage(rows:DiyBomRow[],name:string,code:string) {
|
||||
const widths=[80,145,235,150,185,220,215,170,70]
|
||||
const widths=[100,80,145,235,150,185,220,215,170,70]
|
||||
const canvas=document.createElement('canvas'),scale=2,rowHeight=60
|
||||
const width=widths.reduce((sum,value)=>sum+value,0)+48,height=160+(rows.length+1)*rowHeight+55
|
||||
canvas.width=width*scale;canvas.height=height*scale
|
||||
|
||||
10
furniture-3d-system/src/app/diy/lib/material-appearance.ts
Normal file
10
furniture-3d-system/src/app/diy/lib/material-appearance.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import type { DiyMaterialDTO } from '@/lib/diy/types'
|
||||
|
||||
/** The original SJ0380 black elm uses a grey grain image with a dark finish. */
|
||||
export function diyTextureTint(material?: DiyMaterialDTO) {
|
||||
return material?.texture && material.itemCode === 'SJ038033' ? '#505353' : undefined
|
||||
}
|
||||
|
||||
export function diyMaterialDisplayColor(material?: DiyMaterialDTO) {
|
||||
return material?.texture ? diyTextureTint(material) || '#ffffff' : material?.colorHex || '#d2c5b4'
|
||||
}
|
||||
31
furniture-3d-system/src/app/diy/lib/model-buffers.ts
Normal file
31
furniture-3d-system/src/app/diy/lib/model-buffers.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import type { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'
|
||||
|
||||
function embeddedBuffer(uri: string): ArrayBuffer {
|
||||
const separator = uri.indexOf(',')
|
||||
if (separator < 0) throw new Error('模型内嵌数据格式不正确')
|
||||
const encoded = uri.slice(separator + 1)
|
||||
const binary = uri.slice(0, separator).includes(';base64') ? atob(encoded) : decodeURIComponent(encoded)
|
||||
return Uint8Array.from(binary, character => character.charCodeAt(0)).buffer
|
||||
}
|
||||
|
||||
/** Read binary model data from the supplied file or archive in memory. */
|
||||
export function registerLocalModelBuffers(loader: GLTFLoader, readArchive?: (uri: string) => Promise<ArrayBuffer>) {
|
||||
loader.register(parser => {
|
||||
const buffers = new Map<string, Promise<ArrayBuffer>>()
|
||||
return {
|
||||
name: 'DIY_local_model_buffers',
|
||||
loadBufferView(index: number) {
|
||||
const view = parser.json.bufferViews[index] as { buffer: number; byteOffset?: number; byteLength: number }
|
||||
const uri = (parser.json.buffers[view.buffer] as { uri?: string }).uri
|
||||
if (!uri) return null // GLB binary chunk is handled by the standard parser.
|
||||
let buffer = buffers.get(uri)
|
||||
if (!buffer) {
|
||||
buffer = uri.startsWith('data:') ? Promise.resolve(embeddedBuffer(uri)) : readArchive
|
||||
? readArchive(uri) : Promise.reject(new Error('glTF 缺少配套数据文件,请将模型和配套文件打包 ZIP 上传'))
|
||||
buffers.set(uri, buffer)
|
||||
}
|
||||
return buffer.then(data => data.slice(view.byteOffset || 0, (view.byteOffset || 0) + view.byteLength))
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
93
furniture-3d-system/src/app/diy/lib/model.ts
Normal file
93
furniture-3d-system/src/app/diy/lib/model.ts
Normal file
@ -0,0 +1,93 @@
|
||||
import * as THREE from 'three'
|
||||
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'
|
||||
import { OBJLoader } from 'three/examples/jsm/loaders/OBJLoader.js'
|
||||
import { MTLLoader } from 'three/examples/jsm/loaders/MTLLoader.js'
|
||||
import JSZip from 'jszip'
|
||||
import { shouldApplyZUpToYUpRotation } from '@/lib/3d/model-up-axis'
|
||||
import { registerLocalModelBuffers } from './model-buffers'
|
||||
|
||||
const cache=new Map<string,Promise<THREE.Group>>()
|
||||
function extension(url:string) {
|
||||
const parsed=new URL(url,window.location.href)
|
||||
return (parsed.searchParams.get('key')||parsed.pathname).split('.').pop()?.toLowerCase()||''
|
||||
}
|
||||
async function loadZip(url:string) {
|
||||
const response=await fetch(url);if(!response.ok)throw new Error('模型压缩包读取失败')
|
||||
const zip=await JSZip.loadAsync(await response.arrayBuffer())
|
||||
const names=Object.keys(zip.files).filter(name=>!zip.files[name].dir&&!name.startsWith('__MACOSX/'))
|
||||
const modelName=names.find(name=>/\.(glb|gltf)$/i.test(name))||names.find(name=>/\.obj$/i.test(name))
|
||||
if(!modelName)throw new Error('ZIP 中没有 GLB、glTF 或 OBJ 模型')
|
||||
const folder=modelName.slice(0,modelName.lastIndexOf('/')+1),urls=new Map<string,string>(),manager=new THREE.LoadingManager()
|
||||
const archiveName=(uri:string)=>{
|
||||
const clean=decodeURIComponent(new URL(uri,`https://diy-archive.invalid/${folder}`).pathname.slice(1))
|
||||
if(zip.files[clean]&&!zip.files[clean].dir)return clean
|
||||
const matches=names.filter(name=>name.split('/').pop()===clean.split('/').pop())
|
||||
if(matches.length===1)return matches[0]
|
||||
throw new Error(`模型配套文件缺失:${uri}`)
|
||||
}
|
||||
for(const name of names)urls.set(name,URL.createObjectURL(await zip.files[name].async('blob')))
|
||||
manager.setURLModifier(uri=>{
|
||||
if(uri.startsWith('data:')||uri.startsWith('blob:'))return uri
|
||||
return urls.get(archiveName(uri))!
|
||||
})
|
||||
try {
|
||||
const ext=modelName.split('.').pop()!.toLowerCase()
|
||||
if(ext==='obj') {
|
||||
const loader=new OBJLoader(manager),mtl=names.find(name=>name.toLowerCase().endsWith('.mtl'))
|
||||
if(mtl) {
|
||||
const materials=new MTLLoader(manager).parse(await zip.files[mtl].async('string'),'')
|
||||
let loading=false
|
||||
manager.onStart=()=>{loading=true}
|
||||
const done=new Promise<void>((resolve,reject)=>{manager.onLoad=resolve;manager.onError=uri=>reject(new Error(`模型贴图读取失败:${uri}`))})
|
||||
materials.preload();loader.setMaterials(materials)
|
||||
const root=loader.parse(await zip.files[modelName].async('string'))
|
||||
if(loading)await done
|
||||
return {root,ext}
|
||||
}
|
||||
return {root:loader.parse(await zip.files[modelName].async('string')),ext}
|
||||
}
|
||||
const content=ext==='glb'?await zip.files[modelName].async('arraybuffer'):await zip.files[modelName].async('string')
|
||||
const loader=new GLTFLoader(manager)
|
||||
registerLocalModelBuffers(loader,uri=>zip.files[archiveName(uri)].async('arraybuffer'))
|
||||
const result=await loader.parseAsync(content,'')
|
||||
return {root:result.scene,ext}
|
||||
} finally {urls.forEach(value=>URL.revokeObjectURL(value))}
|
||||
}
|
||||
async function load(url:string):Promise<THREE.Group> {
|
||||
const ext=extension(url)
|
||||
const result=ext==='zip'?await loadZip(url):ext==='obj'?{root:await new OBJLoader().loadAsync(url),ext}:
|
||||
{root:await loadGltf(url,ext),ext}
|
||||
const box=new THREE.Box3().setFromObject(result.root),size=box.getSize(new THREE.Vector3())
|
||||
if(shouldApplyZUpToYUpRotation(`.${result.ext}`,size)){result.root.rotation.x=-Math.PI/2;result.root.updateMatrixWorld(true)}
|
||||
let index=0
|
||||
const usedNames=new Set<string>()
|
||||
result.root.traverse(node=>{if(node instanceof THREE.Mesh){
|
||||
index++
|
||||
const base=node.name||`部件 ${index}`
|
||||
let name=base,suffix=1
|
||||
while(usedNames.has(name))name=`${base} (${++suffix})`
|
||||
node.name=name;usedNames.add(name)
|
||||
}})
|
||||
const normalized=new THREE.Group();normalized.add(result.root)
|
||||
const center=new THREE.Box3().setFromObject(normalized).getCenter(new THREE.Vector3())
|
||||
result.root.position.sub(center);normalized.updateMatrixWorld(true)
|
||||
return normalized
|
||||
}
|
||||
async function loadGltf(url:string,ext:string) {
|
||||
const loader=new GLTFLoader()
|
||||
registerLocalModelBuffers(loader)
|
||||
if(ext!=='gltf')return (await loader.loadAsync(url)).scene
|
||||
const response=await fetch(url)
|
||||
if(!response.ok)throw new Error('模型文件读取失败')
|
||||
return (await loader.parseAsync(await response.text(),'')).scene
|
||||
}
|
||||
export function loadDiyModel(url:string) {
|
||||
let promise=cache.get(url)
|
||||
if(!promise){promise=load(url).catch(error=>{cache.delete(url);throw error});cache.set(url,promise)}
|
||||
return promise
|
||||
}
|
||||
export function modelDimensions(root:THREE.Object3D) {
|
||||
const size=new THREE.Box3().setFromObject(root).getSize(new THREE.Vector3()),factor=Math.max(size.x,size.y,size.z)<20?1000:1
|
||||
const nodes:string[]=[];root.traverse(node=>{if(node instanceof THREE.Mesh)nodes.push(node.name)})
|
||||
return {width:Math.max(1,Math.round(size.x*factor)),height:Math.max(1,Math.round(size.y*factor)),depth:Math.max(1,Math.round(size.z*factor)),nodes:[...new Set(nodes)]}
|
||||
}
|
||||
37
furniture-3d-system/src/app/diy/lib/texture-coordinates.ts
Normal file
37
furniture-3d-system/src/app/diy/lib/texture-coordinates.ts
Normal file
@ -0,0 +1,37 @@
|
||||
import * as THREE from 'three'
|
||||
|
||||
function hasTextureCoordinates(geometry: THREE.BufferGeometry) {
|
||||
const uv = geometry.getAttribute('uv'), position = geometry.getAttribute('position')
|
||||
if (!uv || !position || uv.count !== position.count || !uv.count) return false
|
||||
let varied = false
|
||||
for (let i = 0; i < uv.count; i++) {
|
||||
const u = uv.getX(i), v = uv.getY(i)
|
||||
if (!Number.isFinite(u) || !Number.isFinite(v)) return false
|
||||
if (Math.abs(u - uv.getX(0)) > 1e-6 || Math.abs(v - uv.getY(0)) > 1e-6) varied = true
|
||||
}
|
||||
return varied
|
||||
}
|
||||
|
||||
/** Give untextured cabinet faces a box projection without changing the cached model. */
|
||||
export function diyTextureGeometry(source: THREE.BufferGeometry): THREE.BufferGeometry {
|
||||
if (hasTextureCoordinates(source) || !source.getAttribute('position')) return source
|
||||
const geometry = source.index ? source.toNonIndexed() : source.clone()
|
||||
geometry.computeBoundingBox()
|
||||
const box = geometry.boundingBox!, size = box.getSize(new THREE.Vector3())
|
||||
const position = geometry.getAttribute('position'), uv = new Float32Array(position.count * 2)
|
||||
const a = new THREE.Vector3(), b = new THREE.Vector3(), c = new THREE.Vector3(), normal = new THREE.Vector3()
|
||||
const coordinate = (value: number, min: number, span: number) => (value - min) / Math.max(span, 1e-6)
|
||||
for (let i = 0; i + 2 < position.count; i += 3) {
|
||||
a.fromBufferAttribute(position, i); b.fromBufferAttribute(position, i + 1); c.fromBufferAttribute(position, i + 2)
|
||||
normal.crossVectors(b.sub(a), c.sub(a))
|
||||
const x = Math.abs(normal.x), y = Math.abs(normal.y), z = Math.abs(normal.z)
|
||||
const axis=x>=y&&x>=z?'x':y>=z?'y':'z'
|
||||
for (let j = i; j < i + 3; j++) {
|
||||
const px = position.getX(j), py = position.getY(j), pz = position.getZ(j)
|
||||
uv[j * 2] = axis==='x' ? coordinate(pz, box.min.z, size.z) : coordinate(px, box.min.x, size.x)
|
||||
uv[j * 2 + 1] = axis==='y' ? coordinate(pz, box.min.z, size.z) : coordinate(py, box.min.y, size.y)
|
||||
}
|
||||
}
|
||||
geometry.setAttribute('uv', new THREE.BufferAttribute(uv, 2))
|
||||
return geometry
|
||||
}
|
||||
@ -3,11 +3,14 @@ import { requireSession } from '@/lib/server-session'
|
||||
import { diySchemeService } from '@/server/services/diy-scheme.service'
|
||||
import DiyWorkbench from './DiyWorkbench'
|
||||
import { diyCatalogService } from '@/server/services/diy-catalog.service'
|
||||
import { isAdmin } from '@/lib/role-scope'
|
||||
import { getDiyAiRenderAccess } from '@/server/services/diy-ai-render.access'
|
||||
|
||||
export default async function DiyPage() {
|
||||
const { authUser } = await requireSession('/login')
|
||||
const access = await diySchemeService.getAccess(authUser)
|
||||
if (!access.canView) redirect('/intake')
|
||||
const catalog = await diyCatalogService.getCatalog()
|
||||
return <DiyWorkbench access={access} catalog={catalog} />
|
||||
const catalog = await diyCatalogService.getWorkbenchCatalog()
|
||||
const aiAccess = await getDiyAiRenderAccess(authUser)
|
||||
return <DiyWorkbench access={access} catalog={catalog} canDeleteMaterials={access.canConfigure&&isAdmin(authUser)} aiAccess={aiAccess}/>
|
||||
}
|
||||
|
||||
15
furniture-3d-system/src/lib/diy/__tests__/ai-render.test.ts
Normal file
15
furniture-3d-system/src/lib/diy/__tests__/ai-render.test.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { diyCompositionText, diyAiRenderSchema } from '../ai-render'
|
||||
import type { DiySnapshot } from '../types'
|
||||
|
||||
const snapshot: DiySnapshot = { version:1,spu:'DIY',items:[{instanceId:'cabinet-one',code:'model',colorName:'black',offsetY:0,offsetZ:2,quarterTurn:0,supportId:'floor'}],doors:{},camera:{yaw:0,zoom:1} }
|
||||
test('camera-only edits keep arrangement history; colors, placement and doors identify a different arrangement', () => {
|
||||
const key=diyCompositionText(snapshot)
|
||||
expect(diyCompositionText({...snapshot,camera:{yaw:1,zoom:.5}})).toBe(key)
|
||||
for(const change of [{colorName:'walnut'},{offsetY:4},{quarterTurn:1}]) expect(diyCompositionText({...snapshot,items:[{...snapshot.items[0],...change}]})).not.toBe(key)
|
||||
expect(diyCompositionText({...snapshot,doors:{'cabinet-one#cell':'pattern'}})).not.toBe(key)
|
||||
})
|
||||
test('new DTO rejects invalid modes, unsupported ratio and excessive image count', () => {
|
||||
const input={requestId:'ea7ade88-9db3-412c-86ec-94e4ba222510',name:'test',catalogHash:'hash',snapshot,cameraView:{position:[0,0,12],target:[0,2,0]},mode:'white-background',prompt:'White background',userPrompt:'',imageCount:1,imageSize:'2K',aspectRatio:'1:1',referenceImageDataUrl:'data:image/png;base64,AQID'}
|
||||
expect(diyAiRenderSchema.safeParse(input).success).toBe(true)
|
||||
for(const change of [{mode:'eight-view'},{aspectRatio:'2:1'},{imageCount:9}])expect(diyAiRenderSchema.safeParse({...input,...change}).success).toBe(false)
|
||||
})
|
||||
@ -0,0 +1,49 @@
|
||||
/** @jest-environment node */
|
||||
import * as THREE from 'three'
|
||||
import { detectDiyCells } from '@/app/diy/lib/cell-detection'
|
||||
import type { CabinetStyle } from '../types'
|
||||
|
||||
function cabinet() {
|
||||
const root=new THREE.Group()
|
||||
const panel=(x:number,y:number,w:number,h:number)=>{
|
||||
const mesh=new THREE.Mesh(new THREE.BoxGeometry(w,h,380),new THREE.MeshStandardMaterial())
|
||||
mesh.position.set(x,y,0);root.add(mesh)
|
||||
}
|
||||
panel(-588,0,15,771);panel(588,0,15,771)
|
||||
panel(0,-393,1191,15);panel(0,393,1191,15);panel(0,0,1161,15)
|
||||
for(const x of [-196,196])for(const y of [-196.5,196.5])panel(x,y,15,378)
|
||||
const back=new THREE.Mesh(new THREE.BoxGeometry(1161,771,3),new THREE.MeshStandardMaterial())
|
||||
back.position.z=-188.5;root.add(back);root.updateMatrixWorld(true)
|
||||
return root
|
||||
}
|
||||
const style:CabinetStyle={code:'test',modelUrl:'/model.glb',panels:[],panelCount:0,cells:[],cellCount:0,realSize:{width:1191,height:801,depth:380}}
|
||||
|
||||
test('recognizes actual panel boundaries for six openings, without a supplied row/column count',async()=>{
|
||||
const result=await detectDiyCells(cabinet(),style)
|
||||
expect(result.method).toBe('panels')
|
||||
expect(result.cells).toHaveLength(6)
|
||||
expect([result.rows,result.cols]).toEqual([2,3])
|
||||
for(const cell of result.cells)expect(cell).toMatchObject({inner:{width:377,height:378},door:{width:373,height:374,thickness:15}})
|
||||
expect(result.cells.map(cell=>cell.center)).toEqual([[392,-196.5],[0,-196.5],[-392,-196.5],[392,196.5],[0,196.5],[-392,196.5]])
|
||||
})
|
||||
|
||||
test('uses triangle cross sections when a cabinet has been merged into one mesh',async()=>{
|
||||
const source=cabinet(),positions:number[]=[]
|
||||
source.traverse(node=>{if(node instanceof THREE.Mesh){const geometry=node.geometry.toNonIndexed().applyMatrix4(node.matrixWorld);positions.push(...geometry.attributes.position.array);geometry.dispose()}})
|
||||
const geometry=new THREE.BufferGeometry();geometry.setAttribute('position',new THREE.Float32BufferAttribute(positions,3))
|
||||
const result=await detectDiyCells(new THREE.Mesh(geometry,new THREE.MeshStandardMaterial()),style)
|
||||
expect(result.method).toBe('section')
|
||||
expect(result.cells).toHaveLength(6)
|
||||
for(const cell of result.cells)expect(cell.inner).toMatchObject({width:377,height:378})
|
||||
})
|
||||
|
||||
test('keeps existing opening identities when reanalyzing the same model',async()=>{
|
||||
const existing=await detectDiyCells(cabinet(),style)
|
||||
const cells=existing.cells.map((cell,index)=>({...cell,id:`saved-cell-${index}`}))
|
||||
expect((await detectDiyCells(cabinet(),{...style,cells})).cells.map(cell=>cell.id)).toEqual(cells.map(cell=>cell.id))
|
||||
})
|
||||
|
||||
test('does not invent an opening in a closed solid model',async()=>{
|
||||
const root=new THREE.Mesh(new THREE.BoxGeometry(1191,801,380),new THREE.MeshStandardMaterial())
|
||||
expect((await detectDiyCells(root,style)).cells).toEqual([])
|
||||
})
|
||||
@ -0,0 +1,19 @@
|
||||
import { nextDiyMaterialCode } from '../material-codes'
|
||||
import { diyManagementSchema } from '../management-schema'
|
||||
|
||||
test('numbering continues beyond both legacy separators and current series codes', () => {
|
||||
expect(nextDiyMaterialCode('SJ0381','style',['SJ0381-K01','SJ0381K03','SJ0381_K02','SJ0382K99','SJ0381-CUSTOM'])).toBe('SJ0381K04')
|
||||
expect(nextDiyMaterialCode('SJ0381','material',['SJ0381C99','SJ0381C02'])).toBe('SJ0381C100')
|
||||
})
|
||||
|
||||
test('material entry accepts automatic codes and preserves explicit codes for existing callers', () => {
|
||||
const material={kind:'material',spu:'SJ0381',name:'奶油白',materialKind:'main',colorHex:'#ffffff'}
|
||||
expect(diyManagementSchema.parse(material)).not.toHaveProperty('itemCode')
|
||||
expect(diyManagementSchema.parse({...material,itemCode:'LEGACY-WHITE'})).toHaveProperty('itemCode','LEGACY-WHITE')
|
||||
expect(nextDiyMaterialCode('SJ0381','material',[])).toBe('SJ0381C01')
|
||||
})
|
||||
|
||||
test('generated identifiers reject overlong series and unsafe counters', () => {
|
||||
expect(()=>nextDiyMaterialCode('S'.repeat(79),'style',[])).toThrow('有效编码')
|
||||
expect(()=>nextDiyMaterialCode('SJ0381','style',['SJ0381K99999999999999999999'])).toThrow('有效编码')
|
||||
})
|
||||
@ -0,0 +1,64 @@
|
||||
import { catalogFixture } from './catalog.fixture'
|
||||
import { buildDiyBom } from '../bom'
|
||||
import { createDiyCatalogAccess, doorPatternFits } from '../catalog'
|
||||
import { createDiySnapshotSchema } from '../schema'
|
||||
import { createDiyLayout } from '../layout'
|
||||
import { gridCells, diyManagementSchema } from '../management-schema'
|
||||
import type { DiyCatalogDTO, DiySnapshot } from '../types'
|
||||
|
||||
const original = catalogFixture.styles[0]
|
||||
const catalog: DiyCatalogDTO = {
|
||||
...catalogFixture, spu: 'DIY', series: [{ spu: 'SJ0380', name: 'SJ0380', version: 1 }, { spu: 'SJ0381', name: 'SJ0381', version: 1 }],
|
||||
styles: [...catalogFixture.styles, { code: 'SJ0381-K01', spu: 'SJ0381', realSize: { width: 600, height: 600, depth: 400 },
|
||||
panels: [], panelCount: 0, modelUrl: '/api/storage/object?key=example.glb', cells: gridCells(600, 600, 400, 1, 1), cellCount: 1 }],
|
||||
catalogStyles: [...catalogFixture.catalogStyles, { code: 'SJ0381-K01', spu: 'SJ0381', name: '新柜', size: '600×600×400 mm', colors: [{
|
||||
name: '新颜色', itemCode: 'SJ0381-WHITE', barcodes: ['BAR-381'], factoryCode: '', factoryModel: '', image: '/api/storage/object?key=white.png', mainMaterialCode: 'WHITE',
|
||||
}] }],
|
||||
materials: [...catalogFixture.materials, { itemCode: 'WHITE', spu: 'SJ0381', name: '新颜色', kind: 'main', colorHex: '#ffffff', texture: null }],
|
||||
}
|
||||
const snapshot: DiySnapshot = {
|
||||
version: 1, spu: 'DIY', items: [{ instanceId: 'cabinet-old', code: original.code, colorName: catalogFixture.catalogStyles[0].colors[0].name,
|
||||
offsetY: 0, offsetZ: original.realSize.height * .005, quarterTurn: 0, supportId: 'floor' }, {
|
||||
instanceId: 'cabinet-new', spu: 'SJ0381', code: 'SJ0381-K01', colorName: '新颜色', offsetY: 20, offsetZ: 3, quarterTurn: 0, supportId: 'floor',
|
||||
}], doors: {}, camera: { yaw: .7, zoom: 1.3, pitch: .4, target: [2, 1, 0] },
|
||||
}
|
||||
|
||||
test('mixed series preserves camera, series, real item codes and barcodes in BOM', () => {
|
||||
const parsed = createDiySnapshotSchema(catalog).parse(snapshot)
|
||||
expect(parsed.camera).toEqual(snapshot.camera)
|
||||
expect(buildDiyBom(parsed, catalog)).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ spu: 'SJ0380', itemCode: catalogFixture.catalogStyles[0].colors[0].itemCode }),
|
||||
expect.objectContaining({ spu: 'SJ0381', itemCode: 'SJ0381-WHITE', barcode: 'BAR-381' }),
|
||||
]))
|
||||
expect(createDiyCatalogAccess(catalog).materialsForItem(parsed.items[1]).main?.colorHex).toBe('#ffffff')
|
||||
})
|
||||
|
||||
test('historical snapshot keeps its old camera and infers SJ0380', () => {
|
||||
const old = { ...snapshot, spu: 'SJ0380', items: [snapshot.items[0]], camera: { yaw: 0, zoom: 1 } }
|
||||
expect(createDiySnapshotSchema(catalog).parse(old).camera).toEqual({ yaw: 0, zoom: 1 })
|
||||
expect(buildDiyBom(old, catalog)[0].spu).toBe('SJ0380')
|
||||
})
|
||||
|
||||
test('door dimensions validate width, height and thickness and legacy doors retain their original size', () => {
|
||||
const cell = catalog.styles.at(-1)!.cells[0], base = catalog.patterns[0]
|
||||
expect(doorPatternFits(base, cell)).toBe(false)
|
||||
for (const style of catalogFixture.styles) for (const legacyCell of style.cells) expect(doorPatternFits(base, legacyCell)).toBe(true)
|
||||
const oldDoor = { ...snapshot, items: [snapshot.items[0]], doors: { [`cabinet-old#${original.cells[0].id}`]: base.itemCode } }
|
||||
expect(createDiySnapshotSchema(catalog).safeParse(oldDoor).success).toBe(true)
|
||||
expect(doorPatternFits({ ...base, ...cell.door }, cell)).toBe(true)
|
||||
expect(doorPatternFits({ ...base, ...cell.door, thickness: 30 }, cell)).toBe(false)
|
||||
const withDoor = { ...snapshot, doors: { 'cabinet-new#r1c1': base.itemCode } }
|
||||
expect(createDiySnapshotSchema({ ...catalog, patterns: [{ ...base, ...cell.door, width: 100 }] }).safeParse(withDoor).success).toBe(false)
|
||||
})
|
||||
|
||||
test('a deep cabinet cannot be stacked on a shallow support', () => {
|
||||
const layout = createDiyLayout(catalog)
|
||||
const base = { ...snapshot.items[1], instanceId: 'cabinet-base', code: original.code, offsetY: 0 }
|
||||
const top = { ...snapshot.items[1], offsetY: 0 }
|
||||
expect(layout.fullySupported(top, base)).toBe(false)
|
||||
})
|
||||
|
||||
test('rejects a mismatched series and ambiguous material name', () => {
|
||||
expect(createDiySnapshotSchema(catalog).safeParse({ ...snapshot, items: [snapshot.items[0], { ...snapshot.items[1], spu: 'SJ0380' }] }).success).toBe(false)
|
||||
expect(diyManagementSchema.safeParse({ kind: 'material', spu: 'SJ0381', itemCode: 'WHITE', name: '白+蓝', materialKind: 'main', colorHex: '#ffffff' }).success).toBe(false)
|
||||
})
|
||||
@ -0,0 +1,34 @@
|
||||
import { findDiyColorForMaterial, diyMaterialReferences } from '../materials'
|
||||
import { catalogFixture } from './catalog.fixture'
|
||||
import type { DiyCatalogDTO } from '../types'
|
||||
import { createDiyCatalogAccess, resolveDiyMaterials } from '../catalog'
|
||||
|
||||
const material = catalogFixture.materials[0]
|
||||
const catalog: DiyCatalogDTO = { ...catalogFixture,
|
||||
materials: [...catalogFixture.materials, { ...material, itemCode: 'DUPLICATE-NAME', spu: 'OTHER' }],
|
||||
catalogStyles: [...catalogFixture.catalogStyles, { code: 'OTHER-K01', spu: 'OTHER', name: '跨系列柜', size: 'test', colors: [{
|
||||
name: material.name, mainMaterialCode: material.itemCode, itemCode: 'SHARED-SKU', image: '/image', barcodes: [], factoryCode: '', factoryModel: '',
|
||||
}] }],
|
||||
}
|
||||
|
||||
test('shared colors resolve by material identity across series and keep same-name materials distinct', () => {
|
||||
expect(findDiyColorForMaterial(catalog, 'OTHER-K01', material.itemCode)?.itemCode).toBe('SHARED-SKU')
|
||||
expect(findDiyColorForMaterial(catalog, 'OTHER-K01', 'DUPLICATE-NAME')).toBeUndefined()
|
||||
expect(diyMaterialReferences(catalog, material.itemCode)).toEqual(expect.arrayContaining([expect.objectContaining({ itemCode: 'SHARED-SKU', spu: 'OTHER' })]))
|
||||
expect(diyMaterialReferences(catalog, 'DUPLICATE-NAME')).toEqual([])
|
||||
})
|
||||
|
||||
test('legacy variants without explicit material codes remain counted as references', () => {
|
||||
const refs = diyMaterialReferences(catalog, material.itemCode)
|
||||
expect(refs.some(ref => ref.spu === 'SJ0380')).toBe(true)
|
||||
expect(findDiyColorForMaterial(catalog, catalog.catalogStyles[0].code, material.itemCode)).toBeDefined()
|
||||
expect(refs.some(ref=>ref.colorName.endsWith(`+${material.name}`))).toBe(true)
|
||||
})
|
||||
|
||||
test('deleted colors keep historical references and rendering while leaving new selections', () => {
|
||||
const archived = { ...catalog, materials: catalog.materials.map(entry => entry.itemCode === material.itemCode ? { ...entry, deletedAt: '2026-10-15T01:00:00Z' } : entry) }
|
||||
expect(findDiyColorForMaterial(archived, 'OTHER-K01', material.itemCode)).toBeUndefined()
|
||||
expect(resolveDiyMaterials(archived, { code: 'OTHER-K01', colorName: material.name }).main?.texture).toBe(material.texture)
|
||||
expect(diyMaterialReferences(archived, material.itemCode).length).toBeGreaterThan(0)
|
||||
expect(createDiyCatalogAccess(archived).mainColors.filter(name => name === material.name)).toHaveLength(1)
|
||||
})
|
||||
@ -0,0 +1,29 @@
|
||||
import * as THREE from 'three'
|
||||
import { diyTextureGeometry } from '@/app/diy/lib/texture-coordinates'
|
||||
|
||||
test('unmapped cabinet faces gain UV coordinates on each face while preserving the source shape', () => {
|
||||
const source = new THREE.BoxGeometry(1200, 800, 380)
|
||||
source.deleteAttribute('uv')
|
||||
const before = source.toNonIndexed().getAttribute('position').array
|
||||
const mapped = diyTextureGeometry(source), uv = mapped.getAttribute('uv')
|
||||
expect(source.getAttribute('uv')).toBeUndefined()
|
||||
expect(mapped.getAttribute('position').array).toEqual(before)
|
||||
for (let i = 0; i < uv.count; i += 3) {
|
||||
const points = [0, 1, 2].map(offset => [uv.getX(i + offset), uv.getY(i + offset)])
|
||||
expect(Math.abs((points[1][0] - points[0][0]) * (points[2][1] - points[0][1]) - (points[2][0] - points[0][0]) * (points[1][1] - points[0][1]))).toBeGreaterThan(0)
|
||||
}
|
||||
expect([...uv.array].every(value => Number.isFinite(value) && value >= 0 && value <= 1)).toBe(true)
|
||||
mapped.dispose(); source.dispose()
|
||||
})
|
||||
|
||||
test('keeps valid authored UV coordinates and replaces a constant UV fallback', () => {
|
||||
const source = new THREE.BoxGeometry(10, 20, 30)
|
||||
expect(diyTextureGeometry(source)).toBe(source)
|
||||
const uv = new THREE.Float32BufferAttribute(new Float32Array(source.getAttribute('position').count * 2), 2)
|
||||
source.setAttribute('uv', uv)
|
||||
const mapped = diyTextureGeometry(source)
|
||||
expect(mapped).not.toBe(source)
|
||||
expect(source.getAttribute('uv')).toBe(uv)
|
||||
expect([...mapped.getAttribute('uv').array].some(value => value > 0)).toBe(true)
|
||||
mapped.dispose(); source.dispose()
|
||||
})
|
||||
@ -0,0 +1,91 @@
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { useDiyWorkbench } from '@/app/diy/hooks/useDiyWorkbench'
|
||||
import { fetchDiyCatalog } from '@/app/diy/lib/api'
|
||||
import type { DiyCatalogDTO } from '../types'
|
||||
import { createDiySnapshotSchema } from '../schema'
|
||||
import { buildDiyBom } from '../bom'
|
||||
|
||||
jest.mock('@/app/diy/lib/api', () => ({ fetchDiyCatalog: jest.fn() }))
|
||||
const catalog: DiyCatalogDTO = {
|
||||
spu: 'DIY', name: 'test', version: 1, sourceHash: 'test', series: [{ spu: 'TEST', name: 'test', version: 1 }],
|
||||
styles: [{ code: 'TEST-K01', spu: 'TEST', panelCount: 0, panels: [], cellCount: 2, realSize: { width: 100, height: 100, depth: 40 },
|
||||
cells: [0, 1].map(col => ({ id: `c${col}`, row: 0, col, center: [col ? 25 : -25, 0], inner: { width: 46, height: 96, depth: 40 }, door: { width: 42, height: 92, thickness: 15 } })) }],
|
||||
catalogStyles: [{ code: 'TEST-K01', spu: 'TEST', name: 'test', size: '100x100x40', colors: [{ name: 'white', itemCode: 'BODY', barcodes: [], factoryCode: '', factoryModel: '', image: '/body.png' }] }],
|
||||
patterns: [{ itemCode: 'OLD', spu: 'TEST', name: 'old', barcode: '', image: '/old.png', width: 42, height: 92, thickness: 15 }],
|
||||
materials: [{ itemCode: 'WHITE', spu: 'TEST', name: 'white', kind: 'main', colorHex: '#ffffff', texture: null }],
|
||||
}
|
||||
|
||||
test('a retained 3D callback uses the selected pattern and preserves consecutive placements and removal', () => {
|
||||
const { result } = renderHook(() => useDiyWorkbench({ ...catalog, patterns: [...catalog.patterns, { ...catalog.patterns[0], itemCode: 'NEW' }] }))
|
||||
const retained = result.current.toggleDoor
|
||||
act(() => result.current.add('TEST-K01'))
|
||||
const id = result.current.selectedId
|
||||
act(() => {
|
||||
result.current.setPatternCode('NEW')
|
||||
retained(id, 'c0')
|
||||
retained(id, 'c1')
|
||||
})
|
||||
expect(result.current.snapshot.doors).toEqual({ [`${id}#c0`]: 'NEW', [`${id}#c1`]: 'NEW' })
|
||||
act(() => retained(id, 'c0'))
|
||||
expect(result.current.snapshot.doors).toEqual({ [`${id}#c1`]: 'NEW' })
|
||||
})
|
||||
|
||||
test('a retained 3D callback can use a newly added matching pattern after catalog reload', async () => {
|
||||
const { result } = renderHook(() => useDiyWorkbench(catalog))
|
||||
const retained = result.current.toggleDoor
|
||||
act(() => result.current.add('TEST-K01'))
|
||||
const id = result.current.selectedId
|
||||
jest.mocked(fetchDiyCatalog).mockResolvedValue({ ...catalog, patterns: [...catalog.patterns, { ...catalog.patterns[0], itemCode: 'UPLOADED' }] })
|
||||
await act(async () => { await result.current.reloadCatalog(); result.current.setPatternCode('UPLOADED'); retained(id, 'c0') })
|
||||
expect(result.current.snapshot.doors[`${id}#c0`]).toBe('UPLOADED')
|
||||
expect(result.current.feedback).toBeNull()
|
||||
})
|
||||
|
||||
test('an unregistered shared color does not silently place a different color', () => {
|
||||
const next={...catalog,materials:[...catalog.materials,{itemCode:'WALNUT',spu:'OTHER',name:'walnut',kind:'main' as const,colorHex:'#6b4423',texture:null}]}
|
||||
const {result}=renderHook(()=>useDiyWorkbench(next))
|
||||
act(()=>result.current.setFamilyCode('WALNUT'))
|
||||
act(()=>result.current.add('TEST-K01'))
|
||||
expect(result.current.snapshot.items).toHaveLength(0)
|
||||
expect(result.current.feedback?.text).toContain('添加“walnut”颜色款')
|
||||
})
|
||||
|
||||
test('a new shared color can be applied after its variant is saved, and catalog reload retains the scene', async () => {
|
||||
const {result}=renderHook(()=>useDiyWorkbench(catalog))
|
||||
act(()=>result.current.add('TEST-K01'))
|
||||
const id=result.current.selectedId
|
||||
jest.mocked(fetchDiyCatalog).mockResolvedValue({...catalog,
|
||||
materials:[...catalog.materials,{itemCode:'WALNUT',spu:'OTHER',name:'walnut',kind:'main',colorHex:'#6b4423',texture:null}],
|
||||
catalogStyles:catalog.catalogStyles.map(style=>({...style,colors:[...style.colors,{...style.colors[0],name:'walnut',itemCode:'WALNUT-SKU',mainMaterialCode:'WALNUT'}]})),
|
||||
})
|
||||
await act(async()=>{await result.current.reloadCatalog();result.current.setColor(id,'walnut');result.current.setFamilyCode('WALNUT')})
|
||||
expect(result.current.snapshot.items[0]).toMatchObject({instanceId:id,colorName:'walnut'})
|
||||
expect(result.current.catalogAccess.materialsForItem(result.current.snapshot.items[0]).main?.itemCode).toBe('WALNUT')
|
||||
act(()=>result.current.setSeriesSpu('OTHER'))
|
||||
expect(result.current.familyCode).toBe('WALNUT')
|
||||
})
|
||||
|
||||
test('deleting all selectable assets preserves the canvas and permits removing its archived door', async () => {
|
||||
const { result } = renderHook(() => useDiyWorkbench(catalog))
|
||||
act(() => result.current.add('TEST-K01'))
|
||||
const id = result.current.selectedId
|
||||
act(() => result.current.toggleDoor(id, 'c0'))
|
||||
const snapshot = result.current.snapshot
|
||||
jest.mocked(fetchDiyCatalog).mockResolvedValue({ ...catalog,
|
||||
materials: catalog.materials.map(material => ({ ...material, deletedAt: '2026-10-15T01:00:00Z' })),
|
||||
patterns: catalog.patterns.map(pattern => ({ ...pattern, deletedAt: '2026-10-15T01:00:00Z' })),
|
||||
})
|
||||
await act(async () => { await result.current.reloadCatalog() })
|
||||
expect(result.current.snapshot).toEqual(snapshot)
|
||||
expect(createDiySnapshotSchema(result.current.catalog).safeParse(snapshot).success).toBe(true)
|
||||
expect(buildDiyBom(snapshot, result.current.catalog).map(row => row.itemCode)).toEqual(['BODY', 'OLD'])
|
||||
expect(result.current.familyCode).toBe('')
|
||||
expect(result.current.patternCode).toBe('')
|
||||
expect(result.current.catalogAccess.materialsForItem(snapshot.items[0]).main?.itemCode).toBe('WHITE')
|
||||
act(() => result.current.add('TEST-K01'))
|
||||
expect(result.current.snapshot.items).toHaveLength(1)
|
||||
act(() => result.current.toggleDoor(id, 'c0'))
|
||||
expect(result.current.snapshot.doors).toEqual({})
|
||||
act(() => result.current.toggleDoor(id, 'c1'))
|
||||
expect(result.current.snapshot.doors).toEqual({})
|
||||
})
|
||||
45
furniture-3d-system/src/lib/diy/ai-render.ts
Normal file
45
furniture-3d-system/src/lib/diy/ai-render.ts
Normal file
@ -0,0 +1,45 @@
|
||||
import { z } from 'zod'
|
||||
import type { DiySnapshot } from './types'
|
||||
|
||||
export const diyAiRenderSchema = z.object({
|
||||
requestId: z.string().uuid(),
|
||||
name: z.string().trim().min(1).max(80),
|
||||
schemeCode: z.string().max(100).nullable().optional(),
|
||||
catalogHash: z.string().max(100),
|
||||
snapshot: z.unknown(),
|
||||
cameraView: z.object({ position: z.tuple([z.number().finite(), z.number().finite(), z.number().finite()]),
|
||||
target: z.tuple([z.number().finite(), z.number().finite(), z.number().finite()]) }),
|
||||
mode: z.enum(['white-background', 'scene']),
|
||||
prompt: z.string().trim().min(1).max(5000),
|
||||
userPrompt: z.string().trim().max(2000),
|
||||
negativePrompt: z.string().max(1200).nullable().optional(),
|
||||
imageCount: z.number().int().min(1).max(8),
|
||||
aspectRatio: z.enum(['1:1', '4:3', '3:4', '16:9', '9:16']),
|
||||
imageSize: z.enum(['1K', '2K', '4K']),
|
||||
referenceImageDataUrl: z.string().max(17 * 1024 * 1024),
|
||||
structureReferenceImageDataUrls: z.array(z.string().max(17 * 1024 * 1024)).max(1).optional(),
|
||||
}).superRefine((value, ctx) => {
|
||||
if (value.mode === 'scene' && !value.userPrompt) ctx.addIssue({ code: 'custom', path: ['userPrompt'], message: '请填写场景提示词' })
|
||||
})
|
||||
|
||||
export type DiyAiRenderInput = z.infer<typeof diyAiRenderSchema> & { snapshot: DiySnapshot }
|
||||
export type DiyAiRenderMode = DiyAiRenderInput['mode']
|
||||
export type DiyAiCameraView = DiyAiRenderInput['cameraView']
|
||||
export interface DiyAiRenderImage { originalUrl: string; thumbnailUrl: string; storageKey: string; width: number; height: number }
|
||||
export interface DiyAiRenderDTO {
|
||||
id: string; name: string; schemeCode: string | null; compositionKey: string
|
||||
mode: DiyAiRenderMode; status: 'PENDING' | 'SUCCEEDED' | 'FAILED'
|
||||
referenceImageUrl: string | null; images: DiyAiRenderImage[]; userPrompt: string
|
||||
errorMessage: string | null; createdAt: string; snapshot: DiySnapshot; cameraView: DiyAiCameraView
|
||||
}
|
||||
export interface DiyAiRenderAccess { canView: boolean; canGenerate: boolean; canDownload: boolean }
|
||||
export interface DiyAiRenderList { records: DiyAiRenderDTO[]; access: DiyAiRenderAccess }
|
||||
|
||||
/** Camera changes do not create a different arrangement; layout, colors and doors do. */
|
||||
export function diyCompositionText(snapshot: DiySnapshot) {
|
||||
const compare = (a:string,b:string) => a<b?-1:a>b?1:0
|
||||
return JSON.stringify({ items: [...snapshot.items].sort((a,b) => compare(a.instanceId,b.instanceId)).map(item => ({
|
||||
instanceId:item.instanceId,spu:item.spu,code:item.code,colorName:item.colorName,offsetY:item.offsetY,offsetZ:item.offsetZ,
|
||||
quarterTurn:item.quarterTurn,supportId:item.supportId,
|
||||
})), doors: Object.fromEntries(Object.entries(snapshot.doors).sort(([a],[b]) => compare(a,b))) })
|
||||
}
|
||||
7
furniture-3d-system/src/lib/diy/asset-availability.ts
Normal file
7
furniture-3d-system/src/lib/diy/asset-availability.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { resolveDiyMaterials } from './catalog'
|
||||
import type { DiyCatalogDTO } from './types'
|
||||
|
||||
export function diyColorIsAvailable(catalog: DiyCatalogDTO, styleCode: string, colorName: string) {
|
||||
const { main, shelf } = resolveDiyMaterials(catalog, { code: styleCode, colorName })
|
||||
return Boolean(main && !main.deletedAt && !shelf?.deletedAt)
|
||||
}
|
||||
@ -9,7 +9,7 @@ export function buildDiyBom(snapshot: DiySnapshot, catalog: DiyCatalogDTO): DiyB
|
||||
const style = getCatalogStyle(item.code), color = style.colors.find(color => color.name === item.colorName)!
|
||||
const key = `body:${color.itemCode}`, previous = rows.get(key)
|
||||
if (previous) previous.quantity++
|
||||
else rows.set(key, { group: '柜体', itemCode: color.itemCode, barcode: color.barcodes[0] || '',
|
||||
else rows.set(key, { spu:style.spu||'SJ0380',group: '柜体', itemCode: color.itemCode, barcode: color.barcodes[0] || '',
|
||||
styleCode: item.code, name: style.name, color: color.name, size: style.size,
|
||||
quantity: 1, positions: '' })
|
||||
const cells = getStyle(item.code).cells.slice().sort((a,b) => b.row-a.row || b.col-a.col)
|
||||
@ -17,16 +17,18 @@ export function buildDiyBom(snapshot: DiySnapshot, catalog: DiyCatalogDTO): DiyB
|
||||
position++
|
||||
const pattern = patterns.find(pattern => pattern.itemCode === snapshot.doors[cellKey(item.instanceId,cell.id)])
|
||||
if (!pattern) continue
|
||||
const key = `door:${pattern.itemCode}`, previous = rows.get(key)
|
||||
const doorSize=`${cell.door.width}×${cell.door.height}×${cell.door.thickness} mm`
|
||||
const key = `door:${pattern.itemCode}:${doorSize}`, previous = rows.get(key)
|
||||
if (previous) { previous.quantity++; previous.positions += `、${position}` }
|
||||
else rows.set(key, { group: '柜门', itemCode: pattern.itemCode, barcode: pattern.barcode, styleCode: '',
|
||||
name: pattern.name, color: pattern.name, size: `${cell.door.width}×${cell.door.height}×${cell.door.thickness} mm`,
|
||||
else rows.set(key, { spu:pattern.spu||'SJ0380',group: '柜门', itemCode: pattern.itemCode, barcode: pattern.barcode, styleCode: '',
|
||||
name: pattern.name, color: pattern.name, size: doorSize,
|
||||
quantity: 1, positions: String(position) })
|
||||
}
|
||||
}
|
||||
return [...rows.values()].sort((a,b) => a.group === b.group ? 0 : a.group === '柜体' ? -1 : 1)
|
||||
}
|
||||
export const DIY_BOM_COLUMNS = [
|
||||
{ key: 'spu', title:'系列' },
|
||||
{ key: 'group', title: '分组' }, { key: 'itemCode', title: '货号' }, { key: 'barcode', title: '条码' },
|
||||
{ key: 'styleCode', title: '型号' }, { key: 'name', title: '名称' }, { key: 'color', title: '颜色/图案' },
|
||||
{ key: 'size', title: '尺寸' }, { key: 'positions', title: '使用位置编号' }, { key: 'quantity', title: '数量' },
|
||||
|
||||
@ -6,27 +6,35 @@ const vector = z.tuple([z.number().finite(), z.number().finite(), z.number().fin
|
||||
const size = z.object({ width: positive, height: positive, depth: positive })
|
||||
const image = z.string().startsWith('/api/storage/object?key=')
|
||||
const style = z.object({
|
||||
code: z.string().min(1), cellCount: z.number().int().positive(), panelCount: z.number().int().positive(), realSize: size,
|
||||
panels: z.array(z.object({ type: z.string(), center: vector, size: z.tuple([positive,positive,positive]) })).min(1),
|
||||
code: z.string().min(1), spu: z.string().optional(), modelUrl: image.nullable().optional(),
|
||||
mainNodeNames: z.array(z.string()).optional(), shelfNodeNames: z.array(z.string()).optional(), modelRotationY: z.number().optional(),
|
||||
cellCount: z.number().int().nonnegative(), panelCount: z.number().int().nonnegative(), realSize: size,
|
||||
panels: z.array(z.object({ type: z.string(), center: vector, size: z.tuple([positive,positive,positive]) })),
|
||||
cells: z.array(z.object({ id: z.string().min(1), row: z.number().int(), col: z.number().int(),
|
||||
center: z.tuple([z.number().finite(),z.number().finite()]), inner: size,
|
||||
door: z.object({ width: positive, height: positive, thickness: positive }) })).min(1),
|
||||
}).refine(s => s.panels.length === s.panelCount && s.cells.length === s.cellCount && new Set(s.cells.map(c=>c.id)).size === s.cellCount)
|
||||
door: z.object({ width: positive, height: positive, thickness: positive }) })),
|
||||
}).refine(s => (Boolean(s.modelUrl) || s.panels.length > 0) && s.panels.length === s.panelCount && s.cells.length === s.cellCount && new Set(s.cells.map(c=>c.id)).size === s.cellCount)
|
||||
|
||||
export const diyCatalogSchema = z.object({
|
||||
spu: z.literal('SJ0380'), name: z.string().min(1), version: z.number().int().positive(), sourceHash: z.string().regex(/^[a-f0-9]{64}$/),
|
||||
styles: z.array(style).min(1),
|
||||
catalogStyles: z.array(z.object({ code: z.string(), name: z.string().min(1), size: z.string(), colors: z.array(z.object({
|
||||
spu: z.string().min(1), name: z.string().min(1), version: z.number().int().positive(), sourceHash: z.string().regex(/^[a-f0-9]{64}$/),
|
||||
series: z.array(z.object({ spu: z.string(), name: z.string(), version: z.number().int().positive() })).optional(),
|
||||
styles: z.array(style),
|
||||
catalogStyles: z.array(z.object({ code: z.string(), spu: z.string().optional(), name: z.string().min(1), size: z.string(), colors: z.array(z.object({
|
||||
name: z.string(), itemCode: z.string(), barcodes: z.array(z.string()), factoryModel: z.string(), factoryCode: z.string(), image,
|
||||
})).min(1) })).min(1),
|
||||
patterns: z.array(z.object({ name: z.string(), itemCode: z.string(), barcode: z.string(), image })).min(1),
|
||||
materials: z.array(z.object({ itemCode: z.string(), name: z.string(), kind: z.enum(['main','shelf']),
|
||||
colorHex: z.string().regex(/^#[0-9a-fA-F]{6}$/), texture: image.nullable() })).min(1),
|
||||
mainMaterialCode: z.string().nullable().optional(), shelfMaterialCode: z.string().nullable().optional(), thumbnailFileId: z.string().optional(),
|
||||
})).min(1) })),
|
||||
patterns: z.array(z.object({ name: z.string(), spu: z.string().optional(), itemCode: z.string(), barcode: z.string(), image,
|
||||
deletedAt: z.string().nullable().optional(),
|
||||
width: positive.nullable().optional(), height: positive.nullable().optional(), thickness: positive.nullable().optional() })),
|
||||
materials: z.array(z.object({ itemCode: z.string(), spu: z.string().optional(), name: z.string(), kind: z.enum(['main','shelf']),
|
||||
deletedAt: z.string().nullable().optional(),
|
||||
colorHex: z.string().regex(/^#[0-9a-fA-F]{6}$/), texture: image.nullable() })),
|
||||
}).superRefine((catalog, ctx) => {
|
||||
const codes = new Set(catalog.styles.map(s=>s.code)), names = new Set(catalog.materials.map(m=>m.name))
|
||||
const codes = new Set(catalog.styles.map(s=>s.code)), names = new Set(catalog.materials.map(m=>m.name)), materialCodes = new Set(catalog.materials.map(m=>m.itemCode))
|
||||
if (codes.size !== catalog.styles.length || codes.size !== catalog.catalogStyles.length ||
|
||||
catalog.catalogStyles.some(s=>!codes.has(s.code) || s.colors.some(c=>c.name.split('+').some(n=>!names.has(n)))) ||
|
||||
!catalog.materials.some(m=>m.kind==='main') || catalog.materials.some(m=>m.kind==='main'&&!m.texture)) {
|
||||
catalog.catalogStyles.some(s=>!codes.has(s.code) || s.colors.some(c=>c.mainMaterialCode
|
||||
? !materialCodes.has(c.mainMaterialCode) || Boolean(c.shelfMaterialCode && !materialCodes.has(c.shelfMaterialCode))
|
||||
: c.name.split('+').some(n=>!names.has(n))))) {
|
||||
ctx.addIssue({ code: 'custom', message: 'DIY 目录几何、规格或材质引用不完整' })
|
||||
}
|
||||
})
|
||||
|
||||
@ -1,10 +1,24 @@
|
||||
import type { DiyCatalogDTO } from './types'
|
||||
import type { DiyCatalogDTO, DiyItem, CabinetCell, DoorPattern } from './types'
|
||||
|
||||
export function doorPatternFits(pattern:DoorPattern,cell:CabinetCell) {
|
||||
const legacy=(pattern.spu||'SJ0380')==='SJ0380'
|
||||
const width=pattern.width??(legacy?300:null),height=pattern.height??(legacy?300:null),thickness=pattern.thickness??(legacy?15:null)
|
||||
return (width===null || Math.abs(width-cell.door.width)<=1) && (height===null || Math.abs(height-cell.door.height)<=1)
|
||||
&& (thickness===null || Math.abs(thickness-cell.door.thickness)<=1)
|
||||
}
|
||||
export function resolveDiyMaterials(catalog:DiyCatalogDTO,item:Pick<DiyItem,'code'|'colorName'>) {
|
||||
const style=catalog.catalogStyles.find(style=>style.code===item.code)
|
||||
const variant=style?.colors.find(color=>color.name===item.colorName),[mainName,shelfName]=item.colorName.split('+')
|
||||
const find=(id:string|null|undefined,name:string|undefined)=>id?catalog.materials.find(m=>m.itemCode===id):
|
||||
catalog.materials.find(m=>m.name===name&&(m.spu||'SJ0380')===(style?.spu||'SJ0380'))
|
||||
return {main:find(variant?.mainMaterialCode,mainName),shelf:find(variant?.shelfMaterialCode,shelfName)}
|
||||
}
|
||||
|
||||
/** Each workbench/request owns its catalog; no mutable global catalog or static fallback. */
|
||||
export function createDiyCatalogAccess(catalog: DiyCatalogDTO) {
|
||||
const styles = catalog.styles, catalogStyles = catalog.catalogStyles, patterns = catalog.patterns
|
||||
const colorHex = Object.fromEntries(catalog.materials.map(material => [material.name, material.colorHex]))
|
||||
const mainMaterials = catalog.materials.filter(material => material.kind === 'main')
|
||||
const mainMaterials = catalog.materials.filter(material => material.kind === 'main' && !material.deletedAt)
|
||||
const mainColors = mainMaterials.map(material => material.name)
|
||||
const textures = Object.fromEntries(mainMaterials.map(material => [material.name, material.texture!]))
|
||||
const getStyle = (code: string) => styles.find(style => style.code === code)!
|
||||
@ -14,6 +28,7 @@ export function createDiyCatalogAccess(catalog: DiyCatalogDTO) {
|
||||
const color = style.colors.find(color => color.name === family || color.name.startsWith(`${family}+`)) || style.colors[0]
|
||||
return color.image
|
||||
}
|
||||
return { styles, catalogStyles, patterns, colorHex, mainColors, textures, getStyle, getCatalogStyle, styleImage }
|
||||
const materialsForItem=(item:Pick<DiyItem,'code'|'colorName'>)=>resolveDiyMaterials(catalog,item)
|
||||
return { styles, catalogStyles, patterns, colorHex, mainColors, textures, getStyle, getCatalogStyle, styleImage,materialsForItem }
|
||||
}
|
||||
export function cellKey(instanceId: string, cellId: string) { return `${instanceId}#${cellId}` }
|
||||
|
||||
25
furniture-3d-system/src/lib/diy/cell-configuration.ts
Normal file
25
furniture-3d-system/src/lib/diy/cell-configuration.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import type { CabinetCell,CabinetStyle,DiySnapshot,DoorPattern } from './types'
|
||||
import { cellKey,doorPatternFits } from './catalog'
|
||||
|
||||
export function cellConfigurationIssue(cells:CabinetCell[],size:CabinetStyle['realSize']) {
|
||||
if(new Set(cells.map(cell=>cell.id)).size!==cells.length)return '格口编号不能重复'
|
||||
for(const cell of cells) {
|
||||
if(Math.abs(cell.center[0])+cell.inner.width/2>size.width/2+1||Math.abs(cell.center[1])+cell.inner.height/2>size.height/2+1||cell.inner.depth>size.depth+1)return '格口超出柜体尺寸'
|
||||
if(cell.door.width>cell.inner.width+1||cell.door.height>cell.inner.height+1)return '门片尺寸不能超出格口'
|
||||
}
|
||||
for(let a=0;a<cells.length;a++)for(let b=a+1;b<cells.length;b++) {
|
||||
const first=cells[a],second=cells[b]
|
||||
if(Math.abs(first.center[0]-second.center[0])<(first.inner.width+second.inner.width)/2-1&&Math.abs(first.center[1]-second.center[1])<(first.inner.height+second.inner.height)/2-1)return '格口位置互相重叠,请检查配置'
|
||||
}
|
||||
}
|
||||
|
||||
export function usedDoorConfigurationIssue(snapshot:DiySnapshot,styleCode:string,previous:CabinetCell[],cells:CabinetCell[],patterns:DoorPattern[]) {
|
||||
for(const item of snapshot.items.filter(item=>item.code===styleCode))for(const old of previous) {
|
||||
const code=snapshot.doors[cellKey(item.instanceId,old.id)]
|
||||
if(!code)continue
|
||||
const current=cells.find(cell=>cell.id===old.id),pattern=patterns.find(pattern=>pattern.itemCode===code)
|
||||
if(!current||!pattern||!doorPatternFits(pattern,current)||Math.hypot(current.center[0]-old.center[0],current.center[1]-old.center[1])>1) {
|
||||
return `格口 ${old.id} 已被方案中的柜门使用,请保留位置和适配尺寸`
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -18,7 +18,7 @@ export function createDiyLayout(catalog: DiyCatalogDTO) {
|
||||
function fullySupported(item: DiyItem, support: DiyItem) {
|
||||
const a = itemSize(item), b = itemSize(support)
|
||||
return item.offsetY - a.width / 2 >= support.offsetY - b.width / 2 - EPS &&
|
||||
item.offsetY + a.width / 2 <= support.offsetY + b.width / 2 + EPS
|
||||
item.offsetY + a.width / 2 <= support.offsetY + b.width / 2 + EPS && a.depth <= b.depth + EPS
|
||||
}
|
||||
function placeOnFloor(item: DiyItem, others: DiyItem[]): DiyItem {
|
||||
const s = itemSize(item)
|
||||
|
||||
47
furniture-3d-system/src/lib/diy/management-schema.ts
Normal file
47
furniture-3d-system/src/lib/diy/management-schema.ts
Normal file
@ -0,0 +1,47 @@
|
||||
import { z } from 'zod'
|
||||
import type { CabinetCell } from './types'
|
||||
|
||||
const code = z.string().trim().min(1).max(80).regex(/^[A-Za-z0-9_-]+$/, '编码只支持字母、数字、下划线和短横线')
|
||||
const name = z.string().trim().min(1).max(80)
|
||||
const fileId = z.string().min(1).max(100)
|
||||
const dimension = z.number().finite().positive().max(10000)
|
||||
const barcode = z.string().trim().max(100).default('')
|
||||
const cell = z.object({ id: code, row:z.number().int().nonnegative(),col:z.number().int().nonnegative(),
|
||||
center:z.tuple([z.number().finite().min(-10000).max(10000),z.number().finite().min(-10000).max(10000)]),
|
||||
inner:z.object({width:dimension,height:dimension,depth:dimension}),
|
||||
door:z.object({width:dimension,height:dimension,thickness:dimension}) })
|
||||
|
||||
export const diyManagementSchema = z.discriminatedUnion('kind', [
|
||||
z.object({kind:z.literal('series'),spu:code,name}),
|
||||
z.object({kind:z.literal('material'),spu:code,itemCode:code.optional(),name:name.refine(value=>!value.includes('+'),'颜色名称不能包含 +'),materialKind:z.enum(['main','shelf']),
|
||||
colorHex:z.string().regex(/^#[0-9a-fA-F]{6}$/),textureFileId:fileId.nullable().optional()}),
|
||||
z.object({kind:z.literal('style'),spu:code,code:code.optional(),name,width:dimension,height:dimension,depth:dimension,
|
||||
modelFileId:fileId,thumbnailFileId:fileId,mainNodeNames:z.array(z.string().max(200)).max(2000),
|
||||
shelfNodeNames:z.array(z.string().max(200)).max(2000).default([]),modelRotationY:z.number().finite().min(-360).max(360),
|
||||
cells:z.array(cell).max(100),mainMaterialCode:code,shelfMaterialCode:code.nullable().optional(),itemCode:code,barcode}),
|
||||
z.object({kind:z.literal('variant'),styleCode:code,mainMaterialCode:code,shelfMaterialCode:code.nullable().optional(),itemCode:code,barcode,
|
||||
thumbnailFileId:fileId.nullable().optional()}),
|
||||
z.object({kind:z.literal('pattern'),spu:code,itemCode:code,name,barcode,imageFileId:fileId,
|
||||
width:dimension,height:dimension,thickness:dimension}),
|
||||
])
|
||||
export type DiyManagementInput = z.infer<typeof diyManagementSchema>
|
||||
export const diyStyleCellsSchema=z.object({styleCode:code,revision:z.number().int().positive(),cells:z.array(cell).max(100)})
|
||||
export type DiyStyleCellsInput=z.infer<typeof diyStyleCellsSchema>
|
||||
export const diyMaterialDeleteSchema=z.object({itemCode:code,revision:z.number().int().positive()})
|
||||
export type DiyMaterialDeleteInput=z.infer<typeof diyMaterialDeleteSchema>
|
||||
export const diyAssetLifecycleSchema = diyMaterialDeleteSchema.extend({
|
||||
kind: z.enum(['material', 'pattern']), action: z.enum(['delete', 'restore']),
|
||||
})
|
||||
export type DiyAssetLifecycleInput = z.infer<typeof diyAssetLifecycleSchema>
|
||||
|
||||
export function gridCells(width:number,height:number,depth:number,rows:number,cols:number,thickness=15):CabinetCell[] {
|
||||
if(!rows || !cols)return []
|
||||
const innerWidth=(width-(cols+1)*thickness)/cols, innerHeight=(height-(rows+1)*thickness)/rows
|
||||
if(innerWidth<=0 || innerHeight<=0)return []
|
||||
return Array.from({length:rows*cols},(_,i)=>{
|
||||
const row=Math.floor(i/cols),col=i%cols
|
||||
return {id:`r${row+1}c${col+1}`,row,col,center:[width/2-thickness-innerWidth/2-col*(innerWidth+thickness),
|
||||
-height/2+thickness+innerHeight/2+row*(innerHeight+thickness)],
|
||||
inner:{width:innerWidth,height:innerHeight,depth},door:{width:innerWidth,height:innerHeight,thickness}}
|
||||
})
|
||||
}
|
||||
15
furniture-3d-system/src/lib/diy/material-codes.ts
Normal file
15
furniture-3d-system/src/lib/diy/material-codes.ts
Normal file
@ -0,0 +1,15 @@
|
||||
/** Allocate internal identifiers from the codes read in the locked catalog transaction. */
|
||||
export function nextDiyMaterialCode(spu: string, kind: 'style' | 'material', codes: string[]): string {
|
||||
const marker = kind === 'style' ? 'K' : 'C'
|
||||
let sequence = 0
|
||||
for (const code of codes) {
|
||||
if (!code.startsWith(spu)) continue
|
||||
const match = code.slice(spu.length).match(new RegExp(`^[-_]?${marker}(\\d+)$`))
|
||||
if (match) sequence = Math.max(sequence, Number(match[1]))
|
||||
}
|
||||
const generated = `${spu}${marker}${String(sequence + 1).padStart(2, '0')}`
|
||||
if (!Number.isSafeInteger(sequence + 1) || generated.length > 80) {
|
||||
throw new RangeError('该系列无法生成有效编码,请检查系列编码')
|
||||
}
|
||||
return generated
|
||||
}
|
||||
17
furniture-3d-system/src/lib/diy/materials.ts
Normal file
17
furniture-3d-system/src/lib/diy/materials.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { resolveDiyMaterials } from './catalog'
|
||||
import { diyColorIsAvailable } from './asset-availability'
|
||||
import type { DiyCatalogDTO, DiyMaterialReference } from './types'
|
||||
|
||||
export function findDiyColorForMaterial(catalog: DiyCatalogDTO, styleCode: string, materialCode: string) {
|
||||
const style = catalog.catalogStyles.find(entry => entry.code === styleCode)
|
||||
const matches = style?.colors.filter(color => diyColorIsAvailable(catalog, styleCode, color.name) && resolveDiyMaterials(catalog, { code: styleCode, colorName: color.name }).main?.itemCode === materialCode)
|
||||
return matches?.find(color => !resolveDiyMaterials(catalog, { code: styleCode, colorName: color.name }).shelf) || matches?.[0]
|
||||
}
|
||||
|
||||
export function diyMaterialReferences(catalog: DiyCatalogDTO, materialCode: string): DiyMaterialReference[] {
|
||||
return catalog.catalogStyles.flatMap(style => style.colors.flatMap(color => {
|
||||
const { main, shelf } = resolveDiyMaterials(catalog, { code: style.code, colorName: color.name })
|
||||
return main?.itemCode === materialCode || shelf?.itemCode === materialCode
|
||||
? [{ styleCode: style.code, styleName: style.name, spu: style.spu || 'SJ0380', colorName: color.name, itemCode: color.itemCode }] : []
|
||||
}))
|
||||
}
|
||||
@ -1,5 +1,5 @@
|
||||
import { z } from 'zod'
|
||||
import { cellKey, createDiyCatalogAccess } from './catalog'
|
||||
import { cellKey, createDiyCatalogAccess, doorPatternFits } from './catalog'
|
||||
import { createDiyLayout } from './layout'
|
||||
import type { DiyCatalogDTO } from './types'
|
||||
|
||||
@ -7,31 +7,38 @@ export function createDiySnapshotSchema(catalog: DiyCatalogDTO) {
|
||||
const { patterns, getCatalogStyle, getStyle, styles } = createDiyCatalogAccess(catalog)
|
||||
const { layoutIsValid } = createDiyLayout(catalog)
|
||||
return z.object({
|
||||
version: z.literal(1), spu: z.literal('SJ0380'),
|
||||
version: z.literal(1), spu: z.string().min(1).max(80).refine(value=>value===catalog.spu||Boolean(catalog.series?.some(series=>series.spu===value)),'方案所属系列无效'),
|
||||
items: z.array(z.object({
|
||||
instanceId: z.string().regex(/^cabinet-[a-zA-Z0-9-]{1,80}$/),
|
||||
spu: z.string().max(80).optional(),
|
||||
code: z.string().refine(code => styles.some(style => style.code === code), '柜体款式无效'),
|
||||
colorName: z.string().max(80),
|
||||
offsetY: z.number().finite().min(-10000).max(10000),
|
||||
offsetZ: z.number().finite().min(0).max(10000),
|
||||
quarterTurn: z.number().int().min(0).max(3), supportId: z.string().max(100),
|
||||
})).min(1, '请选择柜体').max(100, '最多添加100个柜体'),
|
||||
doors: z.record(z.string().max(150), z.string().max(40)),
|
||||
camera: z.object({ yaw: z.number().finite().min(-1000).max(1000), zoom: z.number().finite().min(.2).max(5) }),
|
||||
doors: z.record(z.string().max(200), z.string().max(80)),
|
||||
camera: z.object({ yaw: z.number().finite().min(-1000).max(1000), zoom: z.number().finite().min(.2).max(5),
|
||||
pitch: z.number().finite().min(-Math.PI/2).max(Math.PI/2).optional(),
|
||||
target: z.tuple([z.number().finite().min(-10000).max(10000),z.number().finite().min(-10000).max(10000),z.number().finite().min(-10000).max(10000)]).optional() }),
|
||||
}).superRefine((snapshot, ctx) => {
|
||||
if (snapshot.items.some(item => !getStyle(item.code))) return
|
||||
const ids = new Set(snapshot.items.map(item => item.instanceId))
|
||||
if (ids.size !== snapshot.items.length) ctx.addIssue({ code: 'custom', message: '柜体编号重复', path: ['items'] })
|
||||
for (const item of snapshot.items) {
|
||||
if (item.spu && item.spu !== (getStyle(item.code).spu || 'SJ0380')) ctx.addIssue({ code:'custom', message:'柜体所属系列不匹配', path:['items'] })
|
||||
if (!getCatalogStyle(item.code).colors.some(color => color.name === item.colorName)) {
|
||||
ctx.addIssue({ code: 'custom', message: '柜体颜色与款式不匹配', path: ['items'] })
|
||||
}
|
||||
}
|
||||
const cells = new Set(snapshot.items.flatMap(item => getStyle(item.code).cells.map(cell => cellKey(item.instanceId, cell.id))))
|
||||
const cellMap=new Map(snapshot.items.flatMap(item=>getStyle(item.code).cells.map(cell=>[cellKey(item.instanceId,cell.id),cell] as const)))
|
||||
for (const [key, pattern] of Object.entries(snapshot.doors)) {
|
||||
if (!cells.has(key) || !patterns.some(item => item.itemCode === pattern)) {
|
||||
ctx.addIssue({ code: 'custom', message: '柜门位置或图案无效', path: ['doors'] })
|
||||
}
|
||||
const door=patterns.find(item=>item.itemCode===pattern),cell=cellMap.get(key)
|
||||
if(door&&cell&&!doorPatternFits(door,cell))ctx.addIssue({code:'custom',message:'柜门尺寸与格口不匹配',path:['doors']})
|
||||
}
|
||||
if (!layoutIsValid(snapshot.items)) ctx.addIssue({ code: 'custom', message: '柜体存在碰撞、悬空或承托不足', path: ['items'] })
|
||||
})
|
||||
|
||||
@ -7,29 +7,40 @@ export interface CabinetCell {
|
||||
door: { width: number; height: number; thickness: number }
|
||||
}
|
||||
export interface CabinetStyle {
|
||||
spu?: string; modelUrl?: string | null; mainNodeNames?: string[]; shelfNodeNames?: string[]; modelRotationY?: number
|
||||
code: string; cellCount: number; panelCount: number
|
||||
panels: CabinetPanel[]; cells: CabinetCell[]
|
||||
realSize: { width: number; height: number; depth: number }
|
||||
}
|
||||
export interface CatalogColor {
|
||||
mainMaterialCode?: string | null; shelfMaterialCode?: string | null; thumbnailFileId?: string
|
||||
name: string; itemCode: string; barcodes: string[]; factoryModel: string; factoryCode: string
|
||||
image: string
|
||||
}
|
||||
export interface CatalogStyle { code: string; name: string; size: string; colors: CatalogColor[] }
|
||||
export interface DoorPattern { name: string; itemCode: string; barcode: string; image: string }
|
||||
export interface DiyMaterialDTO { itemCode: string; name: string; kind: 'main' | 'shelf'; colorHex: string; texture: string | null }
|
||||
export interface CatalogStyle { code: string; spu?: string; name: string; size: string; colors: CatalogColor[] }
|
||||
export interface DoorPattern { name: string; spu?: string; itemCode: string; barcode: string; image: string; width?: number | null; height?: number | null; thickness?: number | null; deletedAt?: string | null }
|
||||
export interface DiyMaterialDTO { itemCode: string; spu?: string; name: string; kind: 'main' | 'shelf'; colorHex: string; texture: string | null; deletedAt?: string | null }
|
||||
export interface DiyRecycledAssetDTO {
|
||||
kind: 'material' | 'pattern'; itemCode: string; name: string; spu: string
|
||||
deletedAt: string; revision: number; image: string | null; colorHex: string | null
|
||||
}
|
||||
export interface DiyMaterialReference { styleCode: string; styleName: string; spu: string; colorName: string; itemCode: string }
|
||||
export interface DiyMaterialUsageDTO { itemCode: string; name: string; spu: string; revision: number; references: DiyMaterialReference[] }
|
||||
export interface DiySeriesDTO { spu: string; name: string; version: number }
|
||||
export interface DiyAssetDTO { id: string; url: string; kind: 'image' | 'model' }
|
||||
export interface DiyCatalogDTO {
|
||||
spu: 'SJ0380'; name: string; version: number; sourceHash: string
|
||||
spu: string; name: string; version: number; sourceHash: string; series?: DiySeriesDTO[]
|
||||
styles: CabinetStyle[]; catalogStyles: CatalogStyle[]; patterns: DoorPattern[]; materials: DiyMaterialDTO[]
|
||||
}
|
||||
export interface DiyItem {
|
||||
spu?: string
|
||||
instanceId: string; code: string; colorName: string
|
||||
offsetY: number; offsetZ: number; quarterTurn: number; supportId: string
|
||||
}
|
||||
export interface DiySnapshot {
|
||||
version: 1; spu: 'SJ0380'; items: DiyItem[]
|
||||
version: 1; spu: string; items: DiyItem[]
|
||||
doors: Record<string, string>
|
||||
camera: { yaw: number; zoom: number }
|
||||
camera: { yaw: number; zoom: number; pitch?: number; target?: Vec3 }
|
||||
}
|
||||
export interface DiySchemeDTO {
|
||||
id: string; code: string; name: string; parentCode: string | null
|
||||
@ -38,6 +49,7 @@ export interface DiySchemeDTO {
|
||||
export interface DiySchemePage { schemes: DiySchemeDTO[]; total: number; page: number; pageSize: number; totalPages: number }
|
||||
export interface DiyAccess { canView: boolean; canConfigure: boolean; canDownload: boolean }
|
||||
export interface DiyBomRow {
|
||||
spu?: string
|
||||
group: '柜体' | '柜门'; itemCode: string; barcode: string; styleCode: string
|
||||
name: string; color: string; size: string; quantity: number; positions: string
|
||||
}
|
||||
|
||||
@ -0,0 +1,24 @@
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import type { Prisma } from '@prisma/client'
|
||||
|
||||
const source: Prisma.AssistedAssemblyAiRenderWhereInput = { params: { path: ['source'], equals: 'diy' } }
|
||||
export const diyAiRenderRepository = {
|
||||
findOwned(id: string, createdBy: string) {
|
||||
return prisma.assistedAssemblyAiRender.findFirst({ where: { id, createdBy, AND: [source] } })
|
||||
},
|
||||
listOwned(createdBy: string, compositionKey?: string, schemeCode?: string) {
|
||||
const filters: Prisma.AssistedAssemblyAiRenderWhereInput[] = [source]
|
||||
if (compositionKey) filters.push({ OR: [
|
||||
{ params: { path: ['compositionKey'], equals: compositionKey } },
|
||||
...(schemeCode ? [{ params: { path: ['schemeCode'], equals: schemeCode } }] : []),
|
||||
] })
|
||||
return prisma.assistedAssemblyAiRender.findMany({ where: { createdBy, AND: filters },
|
||||
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], take: 60 })
|
||||
},
|
||||
create(data: Prisma.AssistedAssemblyAiRenderUncheckedCreateInput) {
|
||||
return prisma.assistedAssemblyAiRender.create({ data })
|
||||
},
|
||||
update(id: string, data: Prisma.AssistedAssemblyAiRenderUpdateInput) {
|
||||
return prisma.assistedAssemblyAiRender.update({ where: { id }, data })
|
||||
},
|
||||
}
|
||||
@ -0,0 +1,43 @@
|
||||
import { createHash, randomUUID } from 'node:crypto'
|
||||
import { Prisma } from '@prisma/client'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import type { DiyAssetLifecycleInput } from '@/lib/diy/management-schema'
|
||||
import type { DiyRecycledAssetDTO } from '@/lib/diy/types'
|
||||
|
||||
export interface DiyAssetLifecycleState {
|
||||
itemCode: string; name: string; spu: string; revision: number; deletedAt: Date | null
|
||||
}
|
||||
const table = (kind: DiyAssetLifecycleInput['kind']) => Prisma.raw(kind === 'material' ? '"DiyMaterial"' : '"DiyDoorPattern"')
|
||||
|
||||
export const diyAssetLifecycleRepository = {
|
||||
async recycled(): Promise<DiyRecycledAssetDTO[]> {
|
||||
const rows = await prisma.$queryRaw<Array<Omit<DiyRecycledAssetDTO, 'deletedAt'> & { deletedAt: Date }>>`
|
||||
SELECT 'material' AS kind,m."itemCode",m.name,m.spu,m."deletedAt",c.version AS revision,f.url AS image,m."colorHex"
|
||||
FROM "DiyMaterial" m JOIN "DiyCatalog" c ON c.spu=m.spu LEFT JOIN "DiyAssetFile" f ON f.id=m."textureFileId"
|
||||
WHERE m."deletedAt" IS NOT NULL
|
||||
UNION ALL
|
||||
SELECT 'pattern' AS kind,p."itemCode",p.name,p.spu,p."deletedAt",c.version AS revision,f.url AS image,NULL AS "colorHex"
|
||||
FROM "DiyDoorPattern" p JOIN "DiyCatalog" c ON c.spu=p.spu JOIN "DiyAssetFile" f ON f.id=p."imageFileId"
|
||||
WHERE p."deletedAt" IS NOT NULL
|
||||
ORDER BY "deletedAt" DESC,"itemCode"`
|
||||
return rows.map(row => ({ ...row, deletedAt: row.deletedAt.toISOString() }))
|
||||
},
|
||||
async change(input: DiyAssetLifecycleInput, userId: string, validate: (state: DiyAssetLifecycleState | null) => void) {
|
||||
return prisma.$transaction(async tx => {
|
||||
const relation = table(input.kind)
|
||||
// Match catalog management's lock order, then serialize edits to this asset.
|
||||
await tx.$queryRaw(Prisma.sql`SELECT c.spu FROM "DiyCatalog" c JOIN ${relation} a ON a.spu=c.spu WHERE a."itemCode"=${input.itemCode} FOR UPDATE OF c`)
|
||||
const rows = await tx.$queryRaw<DiyAssetLifecycleState[]>(Prisma.sql`
|
||||
SELECT a."itemCode",a.name,a.spu,a."deletedAt",c.version AS revision
|
||||
FROM ${relation} a JOIN "DiyCatalog" c ON c.spu=a.spu WHERE a."itemCode"=${input.itemCode} FOR UPDATE OF a`)
|
||||
const state = rows[0] || null
|
||||
validate(state)
|
||||
if (!state) return null
|
||||
await tx.$executeRaw(Prisma.sql`UPDATE ${relation} SET "deletedAt"=${input.action === 'delete' ? new Date() : null},
|
||||
"deletedBy"=${input.action === 'delete' ? userId : null} WHERE "itemCode"=${input.itemCode}`)
|
||||
const hash = createHash('sha256').update(`${input.kind}:${input.itemCode}:${randomUUID()}`).digest('hex')
|
||||
await tx.$executeRaw`UPDATE "DiyCatalog" SET version=version+1,"sourceHash"=${hash} WHERE spu=${state.spu}`
|
||||
return { spu: state.spu, itemCode: input.itemCode }
|
||||
})
|
||||
},
|
||||
}
|
||||
@ -0,0 +1,89 @@
|
||||
import { createHash, randomUUID } from 'node:crypto'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { Prisma } from '@prisma/client'
|
||||
import { AppError, ErrorCode } from '@/server/core/errors'
|
||||
import type { DiyManagementInput } from '@/lib/diy/management-schema'
|
||||
|
||||
type Transaction = Prisma.TransactionClient
|
||||
function invalid(message:string):never {throw new AppError(ErrorCode.VALIDATION_FAILED,message)}
|
||||
async function series(tx:Transaction,spu:string) {
|
||||
const rows=await tx.$queryRaw<Array<{spu:string}>>`SELECT spu FROM "DiyCatalog" WHERE spu=${spu} FOR UPDATE`
|
||||
if(!rows.length)invalid('请选择已创建的系列')
|
||||
}
|
||||
async function asset(tx:Transaction,id:string,kind:'image'|'model') {
|
||||
const rows=await tx.$queryRaw<Array<{id:string;kind:string}>>`SELECT id,kind FROM "DiyAssetFile" WHERE id=${id}`
|
||||
if(!rows.length || rows[0].kind!==kind && !(kind==='image'&&rows[0].kind!=='model'))invalid('上传文件不存在或类型不匹配')
|
||||
}
|
||||
async function materials(tx:Transaction,mainCode:string,shelfCode?:string|null) {
|
||||
const rows=await tx.$queryRaw<Array<{itemCode:string;name:string;kind:string}>>`
|
||||
SELECT "itemCode",name,kind FROM "DiyMaterial" WHERE ("itemCode"=${mainCode} OR "itemCode"=${shelfCode||''}) AND "deletedAt" IS NULL FOR SHARE`
|
||||
const main=rows.find(row=>row.itemCode===mainCode&&row.kind==='main'),shelf=rows.find(row=>row.itemCode===shelfCode&&row.kind==='shelf')
|
||||
if(!main || shelfCode&&!shelf)invalid('请选择有效的柜体主色和内层颜色')
|
||||
return main!.name+(shelf?`+${shelf.name}`:'')
|
||||
}
|
||||
async function variant(tx:Transaction,input:{styleCode:string;itemCode:string;barcode:string;mainMaterialCode:string;shelfMaterialCode?:string|null;thumbnailFileId:string}) {
|
||||
const colorName=await materials(tx,input.mainMaterialCode,input.shelfMaterialCode)
|
||||
await asset(tx,input.thumbnailFileId,'image')
|
||||
await tx.$executeRaw`INSERT INTO "DiyCabinetVariant" ("itemCode","styleCode",name,barcodes,"factoryModel","factoryCode","orderQty","sortOrder","thumbnailFileId","mainMaterialCode","shelfMaterialCode")
|
||||
VALUES (${input.itemCode},${input.styleCode},${colorName},${input.barcode?[input.barcode]:[]}::text[],'','',0,
|
||||
(SELECT COALESCE(MAX("sortOrder"),-1)+1 FROM "DiyCabinetVariant" WHERE "styleCode"=${input.styleCode}),${input.thumbnailFileId},${input.mainMaterialCode},${input.shelfMaterialCode||null})`
|
||||
}
|
||||
|
||||
export const diyCatalogManagementRepository = {
|
||||
async create(input:DiyManagementInput,allocateCode?:(existingCodes:string[])=>string) {
|
||||
return prisma.$transaction(async tx=>{
|
||||
const hash=createHash('sha256').update(JSON.stringify(input)+randomUUID()).digest('hex')
|
||||
if(input.kind==='series') {
|
||||
await tx.$executeRaw`INSERT INTO "DiyCatalog" (spu,name,"sourceHash") VALUES (${input.spu},${input.name},${hash})`
|
||||
return {spu:input.spu}
|
||||
}
|
||||
let spu:string
|
||||
let createdCode:string|undefined
|
||||
if(input.kind==='variant') {
|
||||
const styles=await tx.$queryRaw<Array<{spu:string;thumbnailFileId:string}>>`
|
||||
SELECT s.spu,v."thumbnailFileId" FROM "DiyCabinetStyle" s JOIN "DiyCabinetVariant" v ON v."styleCode"=s.code WHERE s.code=${input.styleCode} ORDER BY v."sortOrder" LIMIT 1`
|
||||
if(!styles.length)invalid('柜体款式不存在')
|
||||
spu=styles[0].spu
|
||||
await series(tx,spu)
|
||||
await variant(tx,{...input,thumbnailFileId:input.thumbnailFileId||styles[0].thumbnailFileId})
|
||||
} else {
|
||||
spu=input.spu
|
||||
await series(tx,spu)
|
||||
if(input.kind==='material') {
|
||||
const existing=await tx.$queryRaw<Array<{code:string}>>`SELECT "itemCode" AS code FROM "DiyMaterial"`
|
||||
const materialCode=input.itemCode||allocateCode?.(existing.map(row=>row.code))||invalid('缺少颜色编码')
|
||||
createdCode=materialCode
|
||||
if(input.textureFileId)await asset(tx,input.textureFileId,'image')
|
||||
await tx.$executeRaw`INSERT INTO "DiyMaterial" ("itemCode",spu,name,kind,"colorHex",barcodes,"factoryModel","factoryCode","sortOrder","textureFileId")
|
||||
VALUES (${materialCode},${spu},${input.name},${input.materialKind},${input.colorHex},ARRAY[]::text[],'','',
|
||||
(SELECT COALESCE(MAX("sortOrder"),-1)+1 FROM "DiyMaterial" WHERE spu=${spu}),${input.textureFileId||null})`
|
||||
} else if(input.kind==='pattern') {
|
||||
await asset(tx,input.imageFileId,'image')
|
||||
await tx.$executeRaw`INSERT INTO "DiyDoorPattern" ("itemCode",spu,name,barcodes,"factoryModel","factoryCode","orderQty","sortOrder","imageFileId",width,height,thickness)
|
||||
VALUES (${input.itemCode},${spu},${input.name},${input.barcode?[input.barcode]:[]}::text[],'','',0,
|
||||
(SELECT COALESCE(MAX("sortOrder"),-1)+1 FROM "DiyDoorPattern" WHERE spu=${spu}),${input.imageFileId},${input.width},${input.height},${input.thickness})`
|
||||
} else if(input.kind==='style') {
|
||||
await asset(tx,input.modelFileId,'model')
|
||||
if(new Set(input.cells.map(cell=>cell.id)).size!==input.cells.length)invalid('格口编号不能重复')
|
||||
if(input.cells.some(cell=>Math.abs(cell.center[0])+cell.door.width/2>input.width/2+1 || Math.abs(cell.center[1])+cell.door.height/2>input.height/2+1))invalid('格口超出柜体尺寸')
|
||||
const existing=await tx.$queryRaw<Array<{code:string}>>`SELECT code FROM "DiyCabinetStyle"`
|
||||
const styleCode=input.code||allocateCode?.(existing.map(row=>row.code))||invalid('缺少款式编码')
|
||||
createdCode=styleCode
|
||||
await tx.$executeRaw`INSERT INTO "DiyCabinetStyle" (code,spu,name,"displaySize",width,height,depth,"cellCount","panelCount",panels,cells,"sortOrder","modelFileId","mainNodeNames","shelfNodeNames","modelRotationY")
|
||||
VALUES (${styleCode},${spu},${input.name},${`${input.width}×${input.height}×${input.depth} mm`},${input.width},${input.height},${input.depth},${input.cells.length},0,'[]'::jsonb,${JSON.stringify(input.cells)}::jsonb,
|
||||
(SELECT COALESCE(MAX("sortOrder"),-1)+1 FROM "DiyCabinetStyle" WHERE spu=${spu}),${input.modelFileId},${input.mainNodeNames}::text[],${input.shelfNodeNames}::text[],${input.modelRotationY})`
|
||||
await variant(tx,{...input,styleCode})
|
||||
}
|
||||
}
|
||||
await tx.$executeRaw`UPDATE "DiyCatalog" SET version=version+1,"sourceHash"=${hash} WHERE spu=${spu}`
|
||||
return {spu,...(createdCode?{code:createdCode}:{})}
|
||||
})
|
||||
},
|
||||
async recordFile(input:{id:string;spu:string;storageKey:string;url:string;sourcePath:string;sha256:string;bytes:number;contentType:string;kind:'model'|'image'}) {
|
||||
await prisma.$transaction(async tx=>{
|
||||
await series(tx,input.spu)
|
||||
await tx.$executeRaw`INSERT INTO "DiyAssetFile" (id,spu,"storageKey",url,"sourcePath",sha256,bytes,"contentType",kind)
|
||||
VALUES (${input.id},${input.spu},${input.storageKey},${input.url},${input.sourcePath},${input.sha256},${input.bytes},${input.contentType},${input.kind})`
|
||||
})
|
||||
},
|
||||
}
|
||||
@ -2,17 +2,20 @@ import { prisma } from '@/lib/prisma'
|
||||
import type { Prisma } from '@prisma/client'
|
||||
|
||||
type CatalogRow = Prisma.DiyCatalogGetPayload<{ include: {
|
||||
styles: { include: { variants: { include: { thumbnail: true } } } }
|
||||
styles: { include: { variants: { include: { thumbnail: true } }; model: true } }
|
||||
patterns: { include: { image: true } }
|
||||
materials: { include: { texture: true } }
|
||||
} }>
|
||||
|
||||
export const diyCatalogRepository = {
|
||||
async find(): Promise<CatalogRow | null> {
|
||||
async find(spu = 'SJ0380'): Promise<CatalogRow | null> {
|
||||
return (await this.findAll(spu))[0] || null
|
||||
},
|
||||
async findAll(spu: string | null = null): Promise<CatalogRow[]> {
|
||||
// One database snapshot; works while a shared dev process still holds its previous generated client.
|
||||
const rows = await prisma.$queryRaw<CatalogRow[]>`
|
||||
SELECT c.*,
|
||||
COALESCE((SELECT jsonb_agg(to_jsonb(s) || jsonb_build_object('variants',
|
||||
COALESCE((SELECT jsonb_agg(to_jsonb(s) || jsonb_build_object('model', (SELECT to_jsonb(mf) FROM "DiyAssetFile" mf WHERE mf.id=s."modelFileId"), 'variants',
|
||||
(SELECT jsonb_agg(to_jsonb(v) || jsonb_build_object('thumbnail',to_jsonb(f)) ORDER BY v."sortOrder")
|
||||
FROM "DiyCabinetVariant" v JOIN "DiyAssetFile" f ON f.id=v."thumbnailFileId" WHERE v."styleCode"=s.code)
|
||||
) ORDER BY s."sortOrder") FROM "DiyCabinetStyle" s WHERE s.spu=c.spu),'[]'::jsonb) AS styles,
|
||||
@ -20,8 +23,8 @@ export const diyCatalogRepository = {
|
||||
FROM "DiyDoorPattern" p JOIN "DiyAssetFile" f ON f.id=p."imageFileId" WHERE p.spu=c.spu),'[]'::jsonb) AS patterns,
|
||||
COALESCE((SELECT jsonb_agg(to_jsonb(m) || jsonb_build_object('texture',to_jsonb(f)) ORDER BY m."sortOrder")
|
||||
FROM "DiyMaterial" m LEFT JOIN "DiyAssetFile" f ON f.id=m."textureFileId" WHERE m.spu=c.spu),'[]'::jsonb) AS materials
|
||||
FROM "DiyCatalog" c WHERE c.spu=${'SJ0380'}
|
||||
FROM "DiyCatalog" c WHERE (${spu}::text IS NULL OR c.spu=${spu}) ORDER BY c.spu
|
||||
`
|
||||
return rows[0] || null
|
||||
return rows
|
||||
},
|
||||
}
|
||||
|
||||
@ -0,0 +1,22 @@
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import type { Prisma } from '@prisma/client'
|
||||
import type { DiyMaterialUsageDTO } from '@/lib/diy/types'
|
||||
|
||||
async function readUsage(tx: Prisma.TransactionClient, itemCode: string) {
|
||||
const rows = await tx.$queryRaw<DiyMaterialUsageDTO[]>`
|
||||
SELECT m."itemCode",m.name,m.spu,c.version AS revision,
|
||||
COALESCE((SELECT jsonb_agg(jsonb_build_object(
|
||||
'styleCode',s.code,'styleName',s.name,'spu',s.spu,'colorName',v.name,'itemCode',v."itemCode") ORDER BY s.spu,s.code,v."sortOrder")
|
||||
FROM "DiyCabinetVariant" v JOIN "DiyCabinetStyle" s ON s.code=v."styleCode"
|
||||
WHERE v."mainMaterialCode"=m."itemCode" OR v."shelfMaterialCode"=m."itemCode"
|
||||
OR (s.spu=m.spu AND ((v."mainMaterialCode" IS NULL AND split_part(v.name,'+',1)=m.name)
|
||||
OR (v."shelfMaterialCode" IS NULL AND split_part(v.name,'+',2)=m.name)))),'[]'::jsonb) AS "references"
|
||||
FROM "DiyMaterial" m JOIN "DiyCatalog" c ON c.spu=m.spu WHERE m."itemCode"=${itemCode} AND m."deletedAt" IS NULL`
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
export const diyMaterialRepository = {
|
||||
async usage(itemCode: string) {
|
||||
return readUsage(prisma, itemCode)
|
||||
},
|
||||
}
|
||||
@ -0,0 +1,32 @@
|
||||
import { createHash,randomUUID } from 'node:crypto'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import type { CabinetCell,DiySnapshot } from '@/lib/diy/types'
|
||||
import type { DiyStyleCellsInput } from '@/lib/diy/management-schema'
|
||||
|
||||
export interface DiyStyleGeometryRow {
|
||||
code:string;spu:string;width:number;height:number;depth:number;modelFileId:string|null;cells:CabinetCell[];revision:number
|
||||
}
|
||||
export const diyStyleGeometryRepository={
|
||||
async find(code:string) {
|
||||
const rows=await prisma.$queryRaw<DiyStyleGeometryRow[]>`SELECT s.code,s.spu,s.width,s.height,s.depth,s."modelFileId",s.cells,c.version AS revision
|
||||
FROM "DiyCabinetStyle" s JOIN "DiyCatalog" c ON c.spu=s.spu WHERE s.code=${code}`
|
||||
return rows[0]||null
|
||||
},
|
||||
async dependentSnapshots(code:string) {
|
||||
const rows=await prisma.$queryRaw<Array<{snapshot:DiySnapshot}>>`SELECT snapshot FROM "DiyScheme"
|
||||
WHERE snapshot::jsonb->'items' @> ${JSON.stringify([{code}])}::jsonb`
|
||||
return rows.map(row=>row.snapshot)
|
||||
},
|
||||
async updateCells(input:DiyStyleCellsInput,spu:string) {
|
||||
return prisma.$transaction(async tx=>{
|
||||
const rows=await tx.$queryRaw<Array<{version:number}>>`SELECT version FROM "DiyCatalog" WHERE spu=${spu} FOR UPDATE`
|
||||
if(rows[0]?.version!==input.revision)return null
|
||||
const changed=await tx.$executeRaw`UPDATE "DiyCabinetStyle" SET cells=${JSON.stringify(input.cells)}::jsonb,"cellCount"=${input.cells.length}
|
||||
WHERE code=${input.styleCode} AND spu=${spu} AND "modelFileId" IS NOT NULL`
|
||||
if(!changed)return null
|
||||
const hash=createHash('sha256').update(JSON.stringify(input)+randomUUID()).digest('hex')
|
||||
await tx.$executeRaw`UPDATE "DiyCatalog" SET version=version+1,"sourceHash"=${hash} WHERE spu=${spu}`
|
||||
return {spu,revision:input.revision+1}
|
||||
})
|
||||
},
|
||||
}
|
||||
@ -0,0 +1,88 @@
|
||||
/** @jest-environment node */
|
||||
import { diyAiRenderService } from '../diy-ai-render.service'
|
||||
import { diyAiRenderRepository } from '@/server/repositories/diy-ai-render.repository'
|
||||
import { diySchemeRepository } from '@/server/repositories/diy-scheme.repository'
|
||||
import { diyCatalogService } from '../diy-catalog.service'
|
||||
import { intakeNodePermissionSettingsService } from '../intake-node-permission-settings.service'
|
||||
import { callExternalImageGenerateApi, getExternalImageApiConfig } from '../external-image-generate.service'
|
||||
import { uploadDiyAiReference, storeDiyAiResults, discardDiyAiUploads } from '../diy-ai-render.storage'
|
||||
import { catalogFixture } from '@/lib/diy/__tests__/catalog.fixture'
|
||||
import type { AuthUser } from '@/server/core/types'
|
||||
import type { DiyAiRenderInput } from '@/lib/diy/ai-render'
|
||||
|
||||
jest.mock('@/server/repositories/diy-ai-render.repository', () => ({ diyAiRenderRepository: { findOwned: jest.fn(), listOwned: jest.fn(), create: jest.fn(), update: jest.fn() } }))
|
||||
jest.mock('@/server/repositories/diy-scheme.repository', () => ({ diySchemeRepository: { findOwned: jest.fn() } }))
|
||||
jest.mock('../diy-catalog.service', () => ({ diyCatalogService: { getWorkbenchCatalog: jest.fn() } }))
|
||||
jest.mock('../intake-node-permission-settings.service', () => ({ intakeNodePermissionSettingsService: { getSettings: jest.fn() } }))
|
||||
jest.mock('../external-image-generate.service', () => ({ callExternalImageGenerateApi: jest.fn(), getExternalImageApiConfig: jest.fn() }))
|
||||
jest.mock('../diy-ai-render.storage', () => ({ uploadDiyAiReference: jest.fn(), storeDiyAiResults: jest.fn(), discardDiyAiUploads: jest.fn() }))
|
||||
jest.mock('../prompt-optimizer.service', () => ({ promptOptimizerService: { optimize: jest.fn() } }))
|
||||
const repo = jest.mocked(diyAiRenderRepository)
|
||||
const user: AuthUser = { id: 'designer', name: 'Designer', role: 'SUPER_ADMIN', email: 'diy@example.local', status: 'ACTIVE' }
|
||||
const input: DiyAiRenderInput = { requestId: 'ea7ade88-9db3-412c-86ec-94e4ba222510', name: 'My arrangement', catalogHash: catalogFixture.sourceHash,
|
||||
snapshot: { version: 1, spu: 'SJ0380', items: [{ instanceId: 'cabinet-one', code: 'SJ0380K01', colorName: '榆木黑色', offsetY: 0, offsetZ: 1.67, quarterTurn: 0, supportId: 'floor' }],
|
||||
doors: {}, camera: { yaw: .4, pitch: .2, zoom: 1, target: [0,0,0] } }, cameraView: { position: [3,4,12], target: [0,2,0] },
|
||||
mode: 'white-background', prompt: 'Keep exact primary view; white seamless background.', userPrompt: '', imageCount: 1, imageSize: '2K', aspectRatio: '1:1',
|
||||
referenceImageDataUrl: 'data:image/png;base64,AQID', structureReferenceImageDataUrls: ['data:image/png;base64,BAUG'] }
|
||||
let row: Record<string,unknown>
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks(); row = { id: input.requestId, resultImages: [], referenceImageUrl: null, errorMessage: null, createdAt: new Date('2026-10-15T00:00:00Z') }
|
||||
jest.mocked(intakeNodePermissionSettingsService.getSettings).mockResolvedValue({ overrides: {} } as never)
|
||||
jest.mocked(diyCatalogService.getWorkbenchCatalog).mockResolvedValue(catalogFixture)
|
||||
repo.findOwned.mockResolvedValue(null); repo.listOwned.mockResolvedValue([])
|
||||
repo.create.mockImplementation(async data => { Object.assign(row,data); return row as never })
|
||||
repo.update.mockImplementation(async (_id,data) => { Object.assign(row,data); return row as never })
|
||||
jest.mocked(getExternalImageApiConfig).mockResolvedValue({enabled:true,baseUrl:'https://provider.invalid',apiKey:'test',model:'test-model'})
|
||||
jest.mocked(callExternalImageGenerateApi).mockResolvedValue({ requestId:'provider-id', model:'test-model', images:[{originalUrl:'https://provider.invalid/result.png'}], attempts:[], generatedImageIds:[], generatedAssetIds:[] })
|
||||
jest.mocked(uploadDiyAiReference).mockResolvedValue({url:'/api/storage/object?key=reference',storageKey:'reference'})
|
||||
jest.mocked(storeDiyAiResults).mockImplementation(async (_images,_user,_id,keys) => { keys.push('result'); return [{originalUrl:'/api/storage/object?key=result',thumbnailUrl:'/api/storage/object?key=result',storageKey:'result',width:1024,height:1024}] })
|
||||
})
|
||||
test('supplier access is denied before catalog, persistence and provider calls', async () => {
|
||||
await expect(diyAiRenderService.create(input,{...user,businessRoles:['SUPPLIER']})).rejects.toMatchObject({statusCode:403})
|
||||
expect(repo.create).not.toHaveBeenCalled(); expect(callExternalImageGenerateApi).not.toHaveBeenCalled()
|
||||
})
|
||||
test('stale catalog or invalid snapshot cannot incur an AI request', async () => {
|
||||
await expect(diyAiRenderService.create({...input,catalogHash:'old'},user)).rejects.toMatchObject({statusCode:422})
|
||||
await expect(diyAiRenderService.create({...input,snapshot:{...input.snapshot,items:[]}},user)).rejects.toMatchObject({statusCode:422})
|
||||
expect(repo.create).not.toHaveBeenCalled(); expect(callExternalImageGenerateApi).not.toHaveBeenCalled()
|
||||
})
|
||||
test('scheme ownership is checked independently of snapshot validity', async () => {
|
||||
jest.mocked(diySchemeRepository.findOwned).mockResolvedValue(null)
|
||||
await expect(diyAiRenderService.create({...input,schemeCode:'other-owner'},user)).rejects.toMatchObject({statusCode:404})
|
||||
expect(repo.create).not.toHaveBeenCalled()
|
||||
})
|
||||
test('primary image, same-view structure, exact camera and original colors reach durable DIY records', async () => {
|
||||
const result = await diyAiRenderService.create(input,user)
|
||||
expect(result.status).toBe('SUCCEEDED'); expect(result.snapshot).toEqual(input.snapshot); expect(result.cameraView).toEqual(input.cameraView)
|
||||
const request = jest.mocked(callExternalImageGenerateApi).mock.calls[0][0]
|
||||
expect(request.referenceImage?.buffer).toEqual(Buffer.from([1,2,3])); expect(request.additionalReferenceImages?.[0].buffer).toEqual(Buffer.from([4,5,6]))
|
||||
expect(request.prompt).toContain('exactly 1 cabinets and 0 installed door panels')
|
||||
expect(repo.create.mock.calls[0][0]).toMatchObject({createdBy:'designer',storageClass:'PERMANENT',params:{source:'diy'},workspaceSnapshot:{snapshot:input.snapshot}})
|
||||
expect(repo.create.mock.calls[0][0]).not.toHaveProperty('intakeSkuItemId')
|
||||
expect(discardDiyAiUploads).not.toHaveBeenCalled()
|
||||
})
|
||||
test('retrying the same owned request returns its record without generating again', async () => {
|
||||
const first = await diyAiRenderService.create(input,user)
|
||||
repo.findOwned.mockResolvedValue(row as never)
|
||||
const retry = await diyAiRenderService.create(input,user)
|
||||
expect(first.id).toBe(retry.id); expect(callExternalImageGenerateApi).toHaveBeenCalledTimes(1)
|
||||
expect(repo.findOwned).toHaveBeenLastCalledWith(input.requestId,'designer')
|
||||
})
|
||||
test('scene generation requires user text and discards white structure references', async () => {
|
||||
await expect(diyAiRenderService.create({...input,mode:'scene'},user)).rejects.toMatchObject({statusCode:422})
|
||||
await diyAiRenderService.create({...input,mode:'scene',userPrompt:'Modern living room'},user)
|
||||
expect(jest.mocked(callExternalImageGenerateApi).mock.calls[0][0].additionalReferenceImages).toEqual([])
|
||||
expect(repo.create.mock.calls[0][0].outputMode).toBe('SCENE')
|
||||
})
|
||||
test('failed result writes retain the saved reference and discard only unattached result uploads', async () => {
|
||||
repo.update.mockImplementation(async (_id,data) => { if(data.status==='SUCCEEDED')throw Error('write failed');Object.assign(row,data);return row as never })
|
||||
const result = await diyAiRenderService.create(input,user)
|
||||
expect(result.status).toBe('FAILED'); expect(result.referenceImageUrl).toContain('reference')
|
||||
expect(discardDiyAiUploads).toHaveBeenCalledWith(['result'])
|
||||
})
|
||||
test('list requests use the authenticated owner and optional arrangement filter', async () => {
|
||||
const key = 'a'.repeat(64)
|
||||
await diyAiRenderService.list(user,key)
|
||||
expect(repo.listOwned).toHaveBeenCalledWith('designer',key,undefined)
|
||||
await expect(diyAiRenderService.list(user,'bad')).rejects.toMatchObject({statusCode:422})
|
||||
})
|
||||
@ -0,0 +1,51 @@
|
||||
/** @jest-environment node */
|
||||
import { diyAssetLifecycleService } from '../diy-asset-lifecycle.service'
|
||||
import { diyAssetLifecycleRepository, type DiyAssetLifecycleState } from '@/server/repositories/diy-asset-lifecycle.repository'
|
||||
import { assertDiyManagementAccess } from '../diy-catalog-management.service'
|
||||
import type { AuthUser } from '@/server/core/types'
|
||||
import type { DiyAssetLifecycleInput } from '@/lib/diy/management-schema'
|
||||
|
||||
jest.mock('../diy-catalog-management.service', () => ({ assertDiyManagementAccess: jest.fn() }))
|
||||
jest.mock('@/server/repositories/diy-asset-lifecycle.repository', () => ({ diyAssetLifecycleRepository: { change: jest.fn(), recycled: jest.fn() } }))
|
||||
const admin = { id: 'admin', role: 'ADMIN' } as AuthUser
|
||||
const input: DiyAssetLifecycleInput = { kind: 'material', action: 'delete', itemCode: 'SHARED', revision: 6 }
|
||||
let state: DiyAssetLifecycleState | null
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
state = { itemCode: 'SHARED', name: '共用色', spu: 'TEST', revision: 6, deletedAt: null }
|
||||
jest.mocked(assertDiyManagementAccess).mockResolvedValue(undefined)
|
||||
jest.mocked(diyAssetLifecycleRepository.change).mockImplementation(async (request, userId, validate) => {
|
||||
validate(state)
|
||||
return { spu: 'TEST', itemCode: request.itemCode }
|
||||
})
|
||||
jest.mocked(diyAssetLifecycleRepository.recycled).mockResolvedValue([])
|
||||
})
|
||||
|
||||
test('designers cannot delete, restore or list recycled assets', async () => {
|
||||
const designer = { ...admin, role: 'USER', businessRoles: ['DESIGN'] } as AuthUser
|
||||
for (const action of ['delete', 'restore'] as const) await expect(diyAssetLifecycleService.change({ ...input, action }, designer)).rejects.toThrow('只有管理员')
|
||||
await expect(diyAssetLifecycleService.recycled(designer)).rejects.toThrow('只有管理员')
|
||||
expect(diyAssetLifecycleRepository.change).not.toHaveBeenCalled()
|
||||
expect(diyAssetLifecycleRepository.recycled).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('administrators still need access to DIY management', async () => {
|
||||
jest.mocked(assertDiyManagementAccess).mockRejectedValue(new Error('不能管理 DIY'))
|
||||
await expect(diyAssetLifecycleService.change(input, admin)).rejects.toThrow('不能管理 DIY')
|
||||
expect(diyAssetLifecycleRepository.change).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test.each(['material', 'pattern'] as const)('deletes and restores %s with its original identity', async kind => {
|
||||
await expect(diyAssetLifecycleService.change({ ...input, kind }, admin)).resolves.toEqual({ spu: 'TEST', itemCode: 'SHARED' })
|
||||
state!.deletedAt = new Date()
|
||||
await expect(diyAssetLifecycleService.change({ ...input, kind, action: 'restore' }, admin)).resolves.toEqual({ spu: 'TEST', itemCode: 'SHARED' })
|
||||
})
|
||||
|
||||
test('rejects stale confirmation, missing assets and repeated operations', async () => {
|
||||
await expect(diyAssetLifecycleService.change({ ...input, revision: 5 }, admin)).rejects.toThrow('素材库已更新')
|
||||
await expect(diyAssetLifecycleService.change({ ...input, action: 'restore' }, admin)).rejects.toThrow('素材已恢复')
|
||||
state!.deletedAt = new Date()
|
||||
await expect(diyAssetLifecycleService.change(input, admin)).rejects.toThrow('素材已在回收站')
|
||||
state = null
|
||||
await expect(diyAssetLifecycleService.change(input, admin)).rejects.toThrow('素材不存在')
|
||||
})
|
||||
@ -0,0 +1,44 @@
|
||||
/** @jest-environment node */
|
||||
import { diyCatalogManagementService } from '../diy-catalog-management.service'
|
||||
import { diyCatalogManagementRepository } from '@/server/repositories/diy-catalog-management.repository'
|
||||
import { diySchemeService } from '../diy-scheme.service'
|
||||
import type { AuthUser } from '@/server/core/types'
|
||||
jest.mock('../diy-scheme.service', () => ({ diySchemeService: { assertView: jest.fn() } }))
|
||||
jest.mock('@/server/repositories/diy-catalog-management.repository', () => ({ diyCatalogManagementRepository: { create: jest.fn() } }))
|
||||
const user = { id: 'test-user', role: 'SUPER_ADMIN' } as AuthUser
|
||||
const input = { kind: 'series' as const, spu: 'SJ0381', name: 'SJ0381 组合柜' }
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
jest.mocked(diySchemeService.assertView).mockResolvedValue({ canView: true, canConfigure: true, canDownload: true })
|
||||
})
|
||||
|
||||
test('creates the requested series through the catalog transaction', async () => {
|
||||
jest.mocked(diyCatalogManagementRepository.create).mockResolvedValue({ spu: 'SJ0381' })
|
||||
await expect(diyCatalogManagementService.create(input, user)).resolves.toEqual({ spu: 'SJ0381' })
|
||||
expect(diyCatalogManagementRepository.create).toHaveBeenCalledWith(input)
|
||||
})
|
||||
|
||||
test('duplicate PostgreSQL code returns a useful form error', async () => {
|
||||
jest.mocked(diyCatalogManagementRepository.create).mockRejectedValue({ code: 'P2010', meta: { code: '23505' } })
|
||||
await expect(diyCatalogManagementService.create(input, user)).rejects.toThrow('已存在')
|
||||
})
|
||||
|
||||
test('allocates each internal code from the codes read by the repository transaction', async () => {
|
||||
const material={kind:'material' as const,spu:'SJ0381',name:'奶油白',materialKind:'main' as const,colorHex:'#ffffff'}
|
||||
jest.mocked(diyCatalogManagementRepository.create).mockImplementation(async (entry,allocate)=>{
|
||||
expect(entry).toEqual(material)
|
||||
return {spu:'SJ0381',code:allocate!(['SJ0381C01','SJ0381C03'])}
|
||||
})
|
||||
await expect(diyCatalogManagementService.create(material,user)).resolves.toEqual({spu:'SJ0381',code:'SJ0381C04'})
|
||||
})
|
||||
|
||||
test('new styles omit manual codes while explicit legacy style codes keep their series prefix', async () => {
|
||||
const style={kind:'style' as const,spu:'SJ0381',name:'测试柜',width:600,height:600,depth:300,modelFileId:'model',thumbnailFileId:'image',
|
||||
mainNodeNames:[],shelfNodeNames:[],modelRotationY:0,cells:[],mainMaterialCode:'WHITE',itemCode:'REAL-CABINET-SKU',barcode:''}
|
||||
jest.mocked(diyCatalogManagementRepository.create).mockImplementation(async (entry,allocate)=>({
|
||||
spu:'SJ0381',code:entry.kind==='style'?(entry.code||allocate!(['SJ0381K01'])):'',
|
||||
}))
|
||||
await expect(diyCatalogManagementService.create(style,user)).resolves.toEqual({spu:'SJ0381',code:'SJ0381K02'})
|
||||
await expect(diyCatalogManagementService.create({...style,code:'K09'},user)).resolves.toEqual({spu:'SJ0381',code:'SJ0381-K09'})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user