Merge remote-tracking branch 'origin/codex/wuyongjie-assisted-assembly-next' into codex/xuxiake-mainline

# Conflicts:
#	furniture-3d-system/src/app/intake/IntakeClient.test.tsx
This commit is contained in:
WuYongJie 2026-05-18 16:56:56 +08:00
commit db7023e45e
76 changed files with 5878 additions and 1831 deletions

View File

@ -0,0 +1,144 @@
-- Asset Center: replace legacy SeriesTemplate with AssetSeries compatibility scope.
CREATE TABLE "AssetSeries" (
"id" TEXT NOT NULL,
"code" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"productCategoryId" TEXT,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"metadata" JSONB,
"createdBy" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AssetSeries_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "AssetSeries_code_key" ON "AssetSeries"("code");
CREATE INDEX "AssetSeries_productCategoryId_idx" ON "AssetSeries"("productCategoryId");
CREATE INDEX "AssetSeries_createdBy_idx" ON "AssetSeries"("createdBy");
CREATE INDEX "AssetSeries_sortOrder_idx" ON "AssetSeries"("sortOrder");
CREATE INDEX "AssetSeries_isActive_idx" ON "AssetSeries"("isActive");
ALTER TABLE "AssetSeries"
ADD CONSTRAINT "AssetSeries_productCategoryId_fkey"
FOREIGN KEY ("productCategoryId") REFERENCES "ProductCategory"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "AssetSeries"
ADD CONSTRAINT "AssetSeries_createdBy_fkey"
FOREIGN KEY ("createdBy") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
INSERT INTO "AssetSeries" (
"id",
"code",
"name",
"description",
"productCategoryId",
"sortOrder",
"isActive",
"metadata",
"createdBy",
"createdAt",
"updatedAt"
)
SELECT
"id",
"code",
"name",
"description",
"categoryId",
ROW_NUMBER() OVER (ORDER BY "createdAt", "code") - 1,
"isActive",
jsonb_build_object('migratedFrom', 'SeriesTemplate', 'legacySeriesName', "seriesName"),
"createdBy",
"createdAt",
"updatedAt"
FROM "SeriesTemplate"
ON CONFLICT ("code") DO NOTHING;
INSERT INTO "AssetSeries" (
"id",
"code",
"name",
"sortOrder",
"isActive",
"metadata"
)
SELECT
'asset-series-' || md5(series_code),
series_code,
series_code,
1000 + ROW_NUMBER() OVER (ORDER BY series_code),
true,
jsonb_build_object('migratedFrom', 'AssetFamily.applicableSeriesCodes')
FROM (
SELECT DISTINCT unnest("applicableSeriesCodes") AS series_code
FROM "AssetFamily"
WHERE array_length("applicableSeriesCodes", 1) > 0
) source
WHERE series_code IS NOT NULL
AND btrim(series_code) <> ''
ON CONFLICT ("code") DO NOTHING;
CREATE TABLE "AssetFamilySeriesCompatibility" (
"id" TEXT NOT NULL,
"assetFamilyId" TEXT NOT NULL,
"assetSeriesId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AssetFamilySeriesCompatibility_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "AssetFamilySeriesCompatibility_assetFamilyId_assetSeriesId_key"
ON "AssetFamilySeriesCompatibility"("assetFamilyId", "assetSeriesId");
CREATE INDEX "AssetFamilySeriesCompatibility_assetFamilyId_idx"
ON "AssetFamilySeriesCompatibility"("assetFamilyId");
CREATE INDEX "AssetFamilySeriesCompatibility_assetSeriesId_idx"
ON "AssetFamilySeriesCompatibility"("assetSeriesId");
ALTER TABLE "AssetFamilySeriesCompatibility"
ADD CONSTRAINT "AssetFamilySeriesCompatibility_assetFamilyId_fkey"
FOREIGN KEY ("assetFamilyId") REFERENCES "AssetFamily"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "AssetFamilySeriesCompatibility"
ADD CONSTRAINT "AssetFamilySeriesCompatibility_assetSeriesId_fkey"
FOREIGN KEY ("assetSeriesId") REFERENCES "AssetSeries"("id") ON DELETE CASCADE ON UPDATE CASCADE;
INSERT INTO "AssetFamilySeriesCompatibility" (
"id",
"assetFamilyId",
"assetSeriesId",
"createdAt"
)
SELECT
'afsc-' || md5(f."id" || ':' || s."id"),
f."id",
s."id",
COALESCE(f."createdAt", CURRENT_TIMESTAMP)
FROM "AssetFamily" f
CROSS JOIN LATERAL unnest(f."applicableSeriesCodes") AS series_code
JOIN "AssetSeries" s ON s."code" = series_code
WHERE btrim(series_code) <> ''
ON CONFLICT ("assetFamilyId", "assetSeriesId") DO NOTHING;
ALTER TABLE "DraftGroup" ADD COLUMN "assetSeriesId" TEXT;
UPDATE "DraftGroup"
SET "assetSeriesId" = "seriesTemplateId"
WHERE "seriesTemplateId" IS NOT NULL
AND EXISTS (
SELECT 1 FROM "AssetSeries" s WHERE s."id" = "DraftGroup"."seriesTemplateId"
);
DROP INDEX IF EXISTS "DraftGroup_seriesTemplateId_idx";
ALTER TABLE "DraftGroup" DROP CONSTRAINT IF EXISTS "DraftGroup_seriesTemplateId_fkey";
ALTER TABLE "DraftGroup" DROP COLUMN IF EXISTS "seriesTemplateId";
CREATE INDEX "DraftGroup_assetSeriesId_idx" ON "DraftGroup"("assetSeriesId");
ALTER TABLE "DraftGroup"
ADD CONSTRAINT "DraftGroup_assetSeriesId_fkey"
FOREIGN KEY ("assetSeriesId") REFERENCES "AssetSeries"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "AssetFamily" DROP COLUMN IF EXISTS "applicableSeriesCodes";
DROP TABLE IF EXISTS "SeriesTemplate" CASCADE;

View File

@ -27,7 +27,7 @@ model ProductCategory {
planningTemplates PlanningTemplate[] @relation("PlanningTemplateCategory")
planningWorkspaces PlanningWorkspace[] @relation("PlanningWorkspaceCategory")
assetFamilies AssetFamily[] @relation("AssetFamilyCategory")
seriesTemplates SeriesTemplate[] @relation("SeriesTemplateCategory")
assetSeries AssetSeries[] @relation("AssetSeriesCategory")
draftGroups DraftGroup[] @relation("DraftGroupCategory")
createdAt DateTime @default(now())
@ -599,7 +599,6 @@ model AssetFamily {
description String? @db.Text
productCategoryId String?
productCategory ProductCategory? @relation("AssetFamilyCategory", fields: [productCategoryId], references: [id])
applicableSeriesCodes String[] @default([])
connectionRoles String[] @default([])
metadata Json?
status AssetStatus @default(DRAFT)
@ -623,6 +622,7 @@ model AssetFamily {
deleter User? @relation("AssetFamilyDeleter", fields: [deletedById], references: [id], onDelete: SetNull)
variants AssetVariant[]
compatibleSeries AssetFamilySeriesCompatibility[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@ -639,6 +639,48 @@ model AssetFamily {
@@index([deletedById])
}
model AssetSeries {
id String @id @default(cuid())
code String @unique
name String
description String? @db.Text
productCategoryId String?
category ProductCategory? @relation("AssetSeriesCategory", fields: [productCategoryId], references: [id])
sortOrder Int @default(0)
isActive Boolean @default(true)
metadata Json?
createdBy String?
creator User? @relation("AssetSeriesCreator", fields: [createdBy], references: [id], onDelete: SetNull)
compatibleFamilies AssetFamilySeriesCompatibility[]
draftGroups DraftGroup[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([productCategoryId])
@@index([createdBy])
@@index([sortOrder])
@@index([isActive])
}
model AssetFamilySeriesCompatibility {
id String @id @default(cuid())
assetFamilyId String
assetFamily AssetFamily @relation(fields: [assetFamilyId], references: [id], onDelete: Cascade)
assetSeriesId String
assetSeries AssetSeries @relation(fields: [assetSeriesId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
@@unique([assetFamilyId, assetSeriesId])
@@index([assetFamilyId])
@@index([assetSeriesId])
}
model AssetVariant {
id String @id @default(cuid())
assetFamilyId String
@ -707,39 +749,6 @@ model AssetVariant {
@@index([deletedById])
}
model SeriesTemplate {
id String @id @default(cuid())
code String @unique
name String
seriesName String
description String? @db.Text
categoryId String?
category ProductCategory? @relation("SeriesTemplateCategory", fields: [categoryId], references: [id])
slotSchema Json
namingRule Json?
recommendationRule Json?
previewRule Json?
intakeRule Json?
isPreset Boolean @default(false)
isActive Boolean @default(true)
createdBy String?
creator User? @relation("SeriesTemplateCreator", fields: [createdBy], references: [id], onDelete: SetNull)
draftGroups DraftGroup[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([categoryId])
@@index([createdBy])
@@index([isPreset])
@@index([isActive])
}
model DraftGroup {
id String @id @default(cuid())
code String @unique
@ -749,8 +758,8 @@ model DraftGroup {
categoryId String?
category ProductCategory? @relation("DraftGroupCategory", fields: [categoryId], references: [id])
seriesTemplateId String?
seriesTemplate SeriesTemplate? @relation(fields: [seriesTemplateId], references: [id], onDelete: SetNull)
assetSeriesId String?
assetSeries AssetSeries? @relation(fields: [assetSeriesId], references: [id], onDelete: SetNull)
status DraftGroupStatus @default(DRAFT)
summary Json
@ -765,7 +774,7 @@ model DraftGroup {
updatedAt DateTime @updatedAt
@@index([categoryId])
@@index([seriesTemplateId])
@@index([assetSeriesId])
@@index([createdBy])
@@index([status])
}
@ -1060,7 +1069,7 @@ model User {
rejectedAssetVariants AssetVariant[] @relation("AssetVariantRejecter")
modifiedAssetVariants AssetVariant[] @relation("AssetVariantLastModifier")
deletedAssetVariants AssetVariant[] @relation("AssetVariantDeleter")
createdSeriesTemplates SeriesTemplate[] @relation("SeriesTemplateCreator")
createdAssetSeries AssetSeries[] @relation("AssetSeriesCreator")
createdDraftGroups DraftGroup[] @relation("DraftGroupCreator")
collaboratedProducts ProductCollaborator[] @relation("ProductCollaboratorUser")
addedProductCollaborators ProductCollaborator[] @relation("ProductCollaboratorAddedBy")

View File

@ -47,6 +47,16 @@ jest.mock('next/headers', () => ({
jest.mock('@/server/services', () => ({
assetCenterService: {
listSeries: jest.fn().mockResolvedValue([
{
id: 'series-single-board-desk',
code: 'single-board-desk',
name: '单板桌',
description: null,
sortOrder: 0,
isActive: true,
},
]),
listFamilies: jest.fn().mockResolvedValue([
{
id: 'family-board',
@ -149,6 +159,17 @@ jest.mock('../assisted-assembly/AssistedAssemblyClient', () => ({
describe('AssistedAssemblyPage', () => {
beforeEach(() => {
jest.clearAllMocks()
jest.mocked(assetCenterService.listSeries).mockResolvedValue([
{
id: 'series-single-board-desk',
code: 'single-board-desk',
name: '单板桌',
description: null,
sortOrder: 0,
isActive: true,
} as never,
])
jest.mocked(assetCenterService.listFamilies).mockResolvedValue([
{
id: 'family-board',
@ -195,24 +216,26 @@ describe('AssistedAssemblyPage', () => {
jest.mocked(canViewProduct).mockReturnValue(true as never)
})
it('组件 / 五金统计不应把包装材料一起算进去', async () => {
it('直接进入时会等待用户先选择系列,不预加载全量组件和五金', async () => {
render(await AssistedAssemblyPage())
const metricLabel = screen.getByText('组件 / 五金')
const metricCard = metricLabel.closest('article')
expect(metricCard).not.toBeNull()
expect(within(metricCard as HTMLElement).getByText('2')).toBeInTheDocument()
expect(within(metricCard as HTMLElement).getByText('0')).toBeInTheDocument()
expect(assetCenterService.listFamilies).not.toHaveBeenCalled()
expect(assetCenterService.listVariantsByFamilyIds).not.toHaveBeenCalled()
})
it('板材资产族统计会按辅助装配实际可用资产族计算', async () => {
it('直接进入时板材物料池为空,等系列选择后再加载', async () => {
render(await AssistedAssemblyPage())
const metricLabel = screen.getByText('板材资产族')
const metricCard = metricLabel.closest('article')
expect(metricCard).not.toBeNull()
expect(within(metricCard as HTMLElement).getByText('2')).toBeInTheDocument()
expect(within(metricCard as HTMLElement).getByText('0')).toBeInTheDocument()
})
it('资产中心带草稿资产族进入时,会把来源规格款一并带入拼装页', async () => {
@ -231,6 +254,124 @@ describe('AssistedAssemblyPage', () => {
expect(screen.getByTestId('launch-entry-mode')).toHaveTextContent('asset-center')
})
it('资产中心带系列进入时,会按当前系列收窄物料池并保留五金', async () => {
jest.mocked(assetCenterService.listFamilies).mockResolvedValueOnce([
{
id: 'family-board-series',
type: 'BOARD',
status: 'AVAILABLE',
productCategoryId: 'category-desk',
productCategoryName: '书桌',
} as never,
{
id: 'family-hardware-global',
type: 'HARDWARE',
status: 'AVAILABLE',
productCategoryId: null,
productCategoryName: null,
} as never,
])
jest.mocked(assetCenterService.listVariantsByFamilyIds).mockResolvedValueOnce([
{
id: 'variant-board-series',
assetFamilyId: 'family-board-series',
status: 'AVAILABLE',
} as never,
])
render(
await AssistedAssemblyPage({
searchParams: Promise.resolve({
entryMode: 'asset-center',
source: 'asset-center',
assetSeriesId: 'series-single-board-desk',
desktopFamilyIds: 'family-board-series',
}),
}),
)
expect(assetCenterService.listFamilies).toHaveBeenNthCalledWith(1, {
assetSeriesId: 'series-single-board-desk',
})
expect(screen.getByTestId('family-ids')).toHaveTextContent(
'family-board-series,family-hardware-global',
)
expect(assetCenterService.listVariantsByFamilyIds).toHaveBeenCalledWith([
'family-board-series',
'family-hardware-global',
])
})
it('资产中心带系列进入时,会补回 URL 指定的来源资产族,避免来源物料丢失', async () => {
jest.mocked(assetCenterService.listFamilies)
.mockResolvedValueOnce([
{
id: 'family-board-series',
type: 'BOARD',
status: 'AVAILABLE',
productCategoryId: 'category-desk',
productCategoryName: '书桌',
} as never,
{
id: 'family-hardware-global',
type: 'HARDWARE',
status: 'AVAILABLE',
productCategoryId: null,
productCategoryName: null,
} as never,
])
.mockResolvedValueOnce([
{
id: 'family-board-series',
type: 'BOARD',
status: 'AVAILABLE',
productCategoryId: 'category-desk',
productCategoryName: '书桌',
} as never,
{
id: 'family-board-source',
type: 'BOARD',
status: 'DISABLED',
productCategoryId: 'category-desk',
productCategoryName: '书桌',
} as never,
{
id: 'family-hardware-global',
type: 'HARDWARE',
status: 'AVAILABLE',
productCategoryId: null,
productCategoryName: null,
} as never,
])
jest.mocked(assetCenterService.listVariantsByFamilyIds).mockResolvedValueOnce([
{
id: 'variant-board-source',
assetFamilyId: 'family-board-source',
status: 'DRAFT',
} as never,
])
render(
await AssistedAssemblyPage({
searchParams: Promise.resolve({
entryMode: 'asset-center',
source: 'asset-center',
assetSeriesId: 'series-single-board-desk',
desktopFamilyIds: 'family-board-source',
}),
}),
)
expect(assetCenterService.listFamilies).toHaveBeenNthCalledWith(1, {
assetSeriesId: 'series-single-board-desk',
})
expect(assetCenterService.listFamilies).toHaveBeenNthCalledWith(2)
expect(screen.getByTestId('family-ids')).toHaveTextContent(
'family-board-series,family-hardware-global,family-board-source',
)
expect(screen.getByTestId('variant-ids')).toHaveTextContent('variant-board-source')
})
it('源 SKU 新增时,会优先恢复 displaySummary 里的 assemblyWorkspace,而不是退回 previewNodes fallback', async () => {
const sourceWorkspace = buildManualAssemblyWorkspace({
productCategoryId: 'category-desk',
@ -474,7 +615,16 @@ describe('AssistedAssemblyPage', () => {
} as never,
])
render(await AssistedAssemblyPage())
render(
await AssistedAssemblyPage({
searchParams: Promise.resolve({
entryMode: 'asset-center',
source: 'asset-center',
assetSeriesId: 'series-single-board-desk',
desktopFamilyIds: 'family-board',
}),
}),
)
expect(screen.getByTestId('variant-uv-repeat')).toHaveTextContent('2')
expect(screen.getByTestId('variant-material-repeat')).toHaveTextContent('2')

View File

@ -52,7 +52,7 @@ jest.mock('@/server/services', () => ({
name: '进行中的草稿',
description: null,
categoryName: '单板桌',
seriesTemplateName: '单板桌',
assetSeriesName: '单板桌',
status: 'DRAFT',
summary: { selectedCount: 2 },
creatorName: '测试用户',
@ -64,7 +64,7 @@ jest.mock('@/server/services', () => ({
name: '已归档的草稿',
description: null,
categoryName: '单板桌',
seriesTemplateName: '单板桌',
assetSeriesName: '单板桌',
status: 'ARCHIVED',
summary: null,
creatorName: '测试用户',

View File

@ -167,7 +167,7 @@ const mockDraftGroup: DraftGroupSummary = {
name: '我的草稿',
description: '桌类方案',
categoryName: '单板桌',
seriesTemplateName: '单板桌',
assetSeriesName: '单板桌',
status: 'DRAFT',
summary: { selectedCount: 2 },
creatorName: '测试用户',

View File

@ -44,35 +44,35 @@ jest.mock('@/server/services', () => ({
code: 'DG-001',
name: '包装中草稿',
status: 'PACKAGING',
seriesTemplateName: '单板桌',
assetSeriesName: '单板桌',
},
{
id: 'draft-2',
code: 'DG-002',
name: '待入库草稿',
status: 'READY_FOR_INTAKE',
seriesTemplateName: '单板桌',
assetSeriesName: '单板桌',
},
{
id: 'draft-3',
code: 'DG-002',
name: '未包装草稿',
status: 'DRAFT',
seriesTemplateName: '单板桌',
assetSeriesName: '单板桌',
},
{
id: 'draft-4',
code: 'DG-003',
name: '已入库草稿',
status: 'FORMALIZED',
seriesTemplateName: '单板桌',
assetSeriesName: '单板桌',
},
{
id: 'draft-5',
code: 'DG-004',
name: '已归档草稿',
status: 'ARCHIVED',
seriesTemplateName: '单板桌',
assetSeriesName: '单板桌',
},
]),
getById: jest.fn().mockResolvedValue({
@ -81,7 +81,7 @@ jest.mock('@/server/services', () => ({
code: 'DG-001',
name: '包装中草稿',
status: 'PACKAGING',
seriesTemplateName: '单板桌',
assetSeriesName: '单板桌',
},
preSkus: [],
}),

View File

@ -70,9 +70,9 @@ jest.mock('@/server/services/draft-group.service', () => ({
description: null,
categoryId: null,
categoryName: '单板桌',
seriesTemplateId: 'template-1',
seriesTemplateCode: 'single-board-desk',
seriesTemplateName: '单板桌',
assetSeriesId: 'template-1',
assetSeriesCode: 'single-board-desk',
assetSeriesName: '单板桌',
status: 'DRAFT',
summary: { selectedCount: 2 },
notes: null,
@ -88,9 +88,9 @@ jest.mock('@/server/services/draft-group.service', () => ({
description: null,
categoryId: null,
categoryName: '单板桌',
seriesTemplateId: 'template-1',
seriesTemplateCode: 'single-board-desk',
seriesTemplateName: '单板桌',
assetSeriesId: 'template-1',
assetSeriesCode: 'single-board-desk',
assetSeriesName: '单板桌',
status: 'DRAFT',
summary: null,
notes: null,
@ -112,7 +112,7 @@ describe('首页工作台数据注入', () => {
})
it('应只把当前账号的草稿和正式产品注入首页工作台', async () => {
render(await HomePage())
render(await HomePage({ searchParams: Promise.resolve({}) }))
expect(screen.getByText('HomeClient')).toBeInTheDocument()
expect(mockedGetProducts).toHaveBeenCalledWith(

View File

@ -11,6 +11,7 @@ export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url)
const type = searchParams.get('type')
const status = searchParams.get('status')
const assetSeriesId = searchParams.get('assetSeriesId')?.trim() || undefined
try {
const families = await assetCenterService.listFamilies({
@ -22,6 +23,7 @@ export async function GET(request: NextRequest) {
status && ['DRAFT', 'PENDING_REVIEW', 'REJECTED', 'AVAILABLE', 'DISABLED'].includes(status)
? (status as 'DRAFT' | 'PENDING_REVIEW' | 'REJECTED' | 'AVAILABLE' | 'DISABLED')
: undefined,
assetSeriesId,
})
return jsonSuccess({ families }, 200, ctx.requestId)
} catch (error) {

View File

@ -57,6 +57,7 @@ export async function POST(request: NextRequest) {
familyCode?: string | null
familyName?: string | null
familyDescription?: string | null
assetSeriesId?: string | null
connectionRoles?: string[]
variantCode?: string | null
variantName?: string | null
@ -75,6 +76,7 @@ export async function POST(request: NextRequest) {
familyCode: formData.get('familyCode'),
familyName: formData.get('familyName'),
familyDescription: formData.get('familyDescription'),
assetSeriesId: formData.get('assetSeriesId'),
connectionRoles,
variantCode: formData.get('variantCode'),
variantName: formData.get('variantName'),

View File

@ -1,15 +1,15 @@
import { NextRequest } from 'next/server'
import { AppError } from '@/server/core/errors'
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
import { seriesTemplateService } from '@/server/services'
import { assetCenterService } from '@/server/services'
export async function GET(request: NextRequest) {
return handleRequest(
request,
async (ctx) => {
try {
const templates = await seriesTemplateService.listTemplates()
return jsonSuccess({ templates }, 200, ctx.requestId)
const series = await assetCenterService.listSeries()
return jsonSuccess({ series }, 200, ctx.requestId)
} catch (error) {
if (error instanceof AppError) {
return jsonError(error.code, error.message, error.statusCode, ctx.requestId)

View File

@ -45,7 +45,7 @@ export const assetFamilyCreateSchema = z.object({
name: z.string().trim().min(1),
description: z.string().optional().nullable(),
productCategoryId: z.string().trim().min(1).optional().nullable(),
applicableSeriesCodes: z.array(z.string().trim().min(1)).optional(),
compatibleSeriesIds: z.array(z.string().trim().min(1)).optional(),
connectionRoles: z.array(z.string().trim().min(1)).optional(),
metadata: z.record(z.string(), z.unknown()).optional().nullable(),
status: z.enum(['DRAFT']).optional(),
@ -56,7 +56,7 @@ export const assetFamilyUpdateSchema = z.object({
name: z.string().trim().min(1).optional(),
description: z.string().optional().nullable(),
productCategoryId: z.string().trim().min(1).optional().nullable(),
applicableSeriesCodes: z.array(z.string().trim().min(1)).optional(),
compatibleSeriesIds: z.array(z.string().trim().min(1)).optional(),
connectionRoles: z.array(z.string().trim().min(1)).optional(),
metadata: z.record(z.string(), z.unknown()).optional().nullable(),
status: assetStatusSchema.optional(),
@ -189,6 +189,7 @@ export const assetCadImportPayloadSchema = z.object({
familyCode: z.string().trim().min(1).optional().nullable(),
familyName: z.string().trim().min(1).optional().nullable(),
familyDescription: z.string().optional().nullable(),
assetSeriesId: z.string().trim().min(1).optional().nullable(),
connectionRoles: z.array(z.string().trim().min(1)).optional(),
variantCode: z.string().trim().min(1).optional().nullable(),
variantName: z.string().trim().min(1).optional().nullable(),
@ -232,17 +233,11 @@ export const assetCadImportPayloadSchema = z.object({
}
})
export const generateCombinationSchema = z.object({
seriesTemplateId: z.string().trim().min(1),
desktopFamilyIds: z.array(z.string().trim().min(1)).min(1),
legFamilyIds: z.array(z.string().trim().min(1)).optional(),
connectorFamilyIds: z.array(z.string().trim().min(1)).optional(),
})
export const createDraftGroupSchema = z.object({
name: z.string().trim().min(1),
description: z.string().optional().nullable(),
categoryId: z.string().trim().min(1).optional().nullable(),
assetSeriesId: z.string().trim().min(1).optional().nullable(),
notes: z.string().optional().nullable(),
assemblies: z.array(
z.object({

View File

@ -6,7 +6,7 @@ import type {
AssetFamilyDTO,
AssetVariantDTO,
MaterialDTO,
SeriesTemplateDTO,
AssetSeriesDTO,
SubstrateDTO,
} from '@/server/services'
@ -265,7 +265,7 @@ function createFamily(overrides?: Partial<AssetFamilyDTO>): AssetFamilyDTO {
description: null,
productCategoryId: null,
productCategoryName: null,
applicableSeriesCodes: [],
compatibleSeries: [{ id: 'template-1', code: 'single-board-desk', name: '单板桌' }],
connectionRoles: [],
metadata: null,
status: 'DRAFT',
@ -333,14 +333,18 @@ const user = {
department: null,
}
const seriesTemplates = [
const assetSeries = [
{
id: 'template-1',
code: 'single-board-desk',
name: '单板桌',
seriesName: '云序',
description: null,
sortOrder: 0,
isActive: true,
createdAt: new Date('2026-04-01T00:00:00.000Z'),
updatedAt: new Date('2026-04-01T00:00:00.000Z'),
},
] as SeriesTemplateDTO[]
] as AssetSeriesDTO[]
describe('AssetCenterClient 预览操作区', () => {
it('加载模型节点后不会默认选中第一个节点', async () => {
@ -351,7 +355,7 @@ describe('AssetCenterClient 预览操作区', () => {
initialFamilies={[createFamily()]}
initialVariants={[createVariant()]}
initialSubstrates={[] as SubstrateDTO[]}
initialSeriesTemplates={seriesTemplates}
initialAssetSeries={assetSeries}
finishes={[] as MaterialDTO[]}
materialCategories={[]}
metrics={[]}
@ -413,7 +417,7 @@ describe('AssetCenterClient 预览操作区', () => {
}),
]}
initialSubstrates={[] as SubstrateDTO[]}
initialSeriesTemplates={seriesTemplates}
initialAssetSeries={assetSeries}
finishes={[] as MaterialDTO[]}
materialCategories={[]}
metrics={[]}
@ -498,7 +502,7 @@ describe('AssetCenterClient 预览操作区', () => {
updatedAt: new Date('2026-04-09T10:00:00.000Z'),
},
] as SubstrateDTO[]}
initialSeriesTemplates={seriesTemplates}
initialAssetSeries={assetSeries}
finishes={[] as MaterialDTO[]}
materialCategories={[]}
metrics={[]}
@ -523,7 +527,7 @@ describe('AssetCenterClient 预览操作区', () => {
initialFamilies={[createFamily()]}
initialVariants={[createVariant()]}
initialSubstrates={[] as SubstrateDTO[]}
initialSeriesTemplates={seriesTemplates}
initialAssetSeries={assetSeries}
finishes={[] as MaterialDTO[]}
materialCategories={[]}
metrics={[]}
@ -567,7 +571,7 @@ describe('AssetCenterClient 预览操作区', () => {
initialFamilies={[createFamily()]}
initialVariants={[createVariant()]}
initialSubstrates={[] as SubstrateDTO[]}
initialSeriesTemplates={seriesTemplates}
initialAssetSeries={assetSeries}
finishes={[] as MaterialDTO[]}
materialCategories={[]}
metrics={[]}

View File

@ -6,7 +6,7 @@ import type {
AssetFamilyDTO,
AssetVariantDTO,
MaterialDTO,
SeriesTemplateDTO,
AssetSeriesDTO,
SubstrateDTO,
} from '@/server/services'
import {
@ -237,7 +237,7 @@ function createFamily(overrides?: Partial<AssetFamilyDTO>): AssetFamilyDTO {
description: null,
productCategoryId: null,
productCategoryName: null,
applicableSeriesCodes: [],
compatibleSeries: [{ id: 'template-1', code: 'single-board-desk', name: '单板桌' }],
connectionRoles: [],
metadata: null,
status: 'DRAFT',
@ -354,14 +354,18 @@ const user = {
department: null,
}
const seriesTemplates = [
const assetSeries = [
{
id: 'template-1',
code: 'single-board-desk',
name: '单板桌',
seriesName: '云序',
description: null,
sortOrder: 0,
isActive: true,
createdAt: new Date('2026-04-01T00:00:00.000Z'),
updatedAt: new Date('2026-04-01T00:00:00.000Z'),
},
] as SeriesTemplateDTO[]
] as AssetSeriesDTO[]
describe('AssetCenterClient', () => {
beforeEach(() => {
@ -379,7 +383,6 @@ describe('AssetCenterClient', () => {
type: 'BOARD',
code: 'BOARD-001',
name: '桌面板资产族',
applicableSeriesCodes: ['single-board-desk'],
}),
]}
initialVariants={[
@ -433,7 +436,7 @@ describe('AssetCenterClient', () => {
},
]}
initialSubstrates={[]}
initialSeriesTemplates={seriesTemplates}
initialAssetSeries={assetSeries}
finishes={[]}
materialCategories={[]}
metrics={[]}
@ -456,7 +459,6 @@ describe('AssetCenterClient', () => {
type: 'BOARD',
code: 'BOARD-010',
name: '可用桌面板资产族',
applicableSeriesCodes: ['single-board-desk'],
status: 'AVAILABLE',
}),
]}
@ -511,7 +513,7 @@ describe('AssetCenterClient', () => {
},
]}
initialSubstrates={[]}
initialSeriesTemplates={seriesTemplates}
initialAssetSeries={assetSeries}
finishes={[]}
materialCategories={[]}
metrics={[]}
@ -549,7 +551,7 @@ describe('AssetCenterClient', () => {
]}
initialVariants={[] as AssetVariantDTO[]}
initialSubstrates={[] as SubstrateDTO[]}
initialSeriesTemplates={seriesTemplates}
initialAssetSeries={assetSeries}
finishes={[] as MaterialDTO[]}
materialCategories={[]}
metrics={[]}
@ -575,7 +577,6 @@ describe('AssetCenterClient', () => {
type: 'BOARD',
code: 'BOARD-002',
name: '待审核桌面板',
applicableSeriesCodes: ['single-board-desk'],
status: 'PENDING_REVIEW',
}),
createFamily({
@ -583,12 +584,11 @@ describe('AssetCenterClient', () => {
type: 'BOARD',
code: 'BOARD-003',
name: '草稿桌面板',
applicableSeriesCodes: ['single-board-desk'],
}),
]}
initialVariants={[] as AssetVariantDTO[]}
initialSubstrates={[] as SubstrateDTO[]}
initialSeriesTemplates={seriesTemplates}
initialAssetSeries={assetSeries}
finishes={[] as MaterialDTO[]}
materialCategories={[]}
metrics={[]}
@ -662,7 +662,6 @@ describe('AssetCenterClient', () => {
type: 'BOARD',
code: 'BOARD-020',
name: '缩略图桌面板',
applicableSeriesCodes: ['single-board-desk'],
status: 'AVAILABLE',
}),
]}
@ -717,7 +716,7 @@ describe('AssetCenterClient', () => {
},
]}
initialSubstrates={[]}
initialSeriesTemplates={seriesTemplates}
initialAssetSeries={assetSeries}
finishes={[]}
materialCategories={[]}
metrics={[]}
@ -780,7 +779,6 @@ describe('AssetCenterClient', () => {
type: 'BOARD',
code: 'BOARD-030',
name: 'UV 桌面板',
applicableSeriesCodes: ['single-board-desk'],
status: 'AVAILABLE',
}),
]}
@ -888,7 +886,7 @@ describe('AssetCenterClient', () => {
],
}),
]}
initialSeriesTemplates={seriesTemplates}
initialAssetSeries={assetSeries}
finishes={[
{
id: 'finish-1',
@ -1114,7 +1112,7 @@ describe('AssetCenterClient', () => {
],
}),
]}
initialSeriesTemplates={seriesTemplates}
initialAssetSeries={assetSeries}
finishes={[
{
id: 'finish-1',
@ -1206,7 +1204,6 @@ describe('AssetCenterClient', () => {
type: 'BOARD',
code: 'BOARD-DRAFT-UV',
name: 'UV 草稿桌面板',
applicableSeriesCodes: ['single-board-desk'],
status: 'AVAILABLE',
}),
]}
@ -1234,7 +1231,7 @@ describe('AssetCenterClient', () => {
name: '25mm 颗粒板',
}),
]}
initialSeriesTemplates={seriesTemplates}
initialAssetSeries={assetSeries}
finishes={[
{
id: 'finish-1',
@ -1305,7 +1302,6 @@ describe('AssetCenterClient', () => {
type: 'BOARD',
code: 'BOARD-040',
name: '右键桌面板',
applicableSeriesCodes: ['single-board-desk'],
status: 'AVAILABLE',
}),
]}
@ -1371,7 +1367,7 @@ describe('AssetCenterClient', () => {
name: '25mm 颗粒板',
}),
]}
initialSeriesTemplates={seriesTemplates}
initialAssetSeries={assetSeries}
finishes={[]}
materialCategories={[]}
metrics={[]}
@ -1403,7 +1399,6 @@ describe('AssetCenterClient', () => {
type: 'BOARD',
code: 'BOARD-SUBSTRATE',
name: '基材搜索桌面板',
applicableSeriesCodes: ['single-board-desk'],
}),
]}
initialVariants={[
@ -1428,7 +1423,7 @@ describe('AssetCenterClient', () => {
name: '胡桃木基材',
}),
]}
initialSeriesTemplates={seriesTemplates}
initialAssetSeries={assetSeries}
finishes={[]}
materialCategories={[]}
metrics={[]}

View File

@ -89,9 +89,9 @@ import type {
AssetCadImportResult,
AssetFamilyDTO,
AssetOperationProgressDTO,
AssetSeriesDTO,
SimilarAssetVariantCandidateDTO,
AssetVariantDTO,
SeriesTemplateDTO,
SubstrateDTO,
SubstrateDictionaryBundleDTO,
} from '@/server/services'
@ -103,7 +103,7 @@ interface AssetCenterClientProps {
initialVariants: AssetVariantDTO[]
initialSubstrates: SubstrateDTO[]
initialSubstrateDictionaries?: SubstrateDictionaryBundleDTO
initialSeriesTemplates: SeriesTemplateDTO[]
initialAssetSeries: AssetSeriesDTO[]
finishes: MaterialDTO[]
materialCategories: MaterialCategoryDTO[]
metrics: PlatformMetricItem[]
@ -708,7 +708,10 @@ function buildAssetVariantHoleOverlayState(variant: AssetVariantDTO | null): {
}
}
// ---- 分类 Tab 定义 ----
// ---- 系列与分类筛选 ----
const ASSET_CENTER_SELECTED_SERIES_STORAGE_KEY = 'asset-center:selected-series-id'
type AssetTypeFilter = AssetFamilyDTO['type']
function isPackagingMaterialLibraryFamily(family: AssetFamilyDTO): boolean {
return family.type === 'PACKAGING' && getLevel1FromFamily(family.metadata ?? null) !== null
@ -716,7 +719,7 @@ function isPackagingMaterialLibraryFamily(family: AssetFamilyDTO): boolean {
function getVisibleFamiliesForTab(
families: AssetFamilyDTO[],
activeTab: AssetFamilyDTO['type'],
activeTab: AssetTypeFilter,
): AssetFamilyDTO[] {
return families.filter((family) => {
if (family.type !== activeTab) return false
@ -725,7 +728,19 @@ function getVisibleFamiliesForTab(
})
}
const TYPE_TABS: { type: AssetFamilyDTO['type']; label: string; icon: React.ReactNode }[] = [
function familyBelongsToSeries(family: AssetFamilyDTO, series: AssetSeriesDTO | null): boolean {
if (!series) return false
const compatibleSeries = family.compatibleSeries ?? []
return compatibleSeries.some((item) => item.id === series.id || item.code === series.code)
}
function familyVisibleInSeriesContext(family: AssetFamilyDTO, series: AssetSeriesDTO | null): boolean {
if (isPackagingMaterialLibraryFamily(family)) return false
if (family.type === 'HARDWARE') return true
return familyBelongsToSeries(family, series)
}
const TYPE_TABS: { type: AssetTypeFilter; label: string; icon: React.ReactNode }[] = [
{ type: 'BOARD', label: '板材', icon: <Layers3 className="h-3.5 w-3.5" /> },
{ type: 'COMPONENT', label: '组件', icon: <Shapes className="h-3.5 w-3.5" /> },
{ type: 'HARDWARE', label: '五金', icon: <Link2 className="h-3.5 w-3.5" /> },
@ -942,7 +957,7 @@ export default function AssetCenterClient({
initialVariants,
initialSubstrates,
initialSubstrateDictionaries: _initialSubstrateDictionaries,
initialSeriesTemplates,
initialAssetSeries,
finishes: initialFinishes,
materialCategories: _initialMaterialCategories,
metrics,
@ -959,8 +974,14 @@ export default function AssetCenterClient({
const [selectedVariantId, setSelectedVariantId] = useState<string | null>(null)
const [submitting, setSubmitting] = useState(false)
// Tab 状态
const [activeTab, setActiveTab] = useState<AssetFamilyDTO['type']>('BOARD')
// 系列与分类筛选状态
const [selectedSeriesId, setSelectedSeriesId] = useState<string | null>(
initialAssetSeries[0]?.id ?? null,
)
const [seriesPickerOpen, setSeriesPickerOpen] = useState(false)
const [seriesSearchText, setSeriesSearchText] = useState('')
const seriesPickerRef = useRef<HTMLDivElement | null>(null)
const [activeTab, setActiveTab] = useState<AssetTypeFilter>('BOARD')
const [assemblySelectedFamilyIds, setAssemblySelectedFamilyIds] = useState<Set<string>>(new Set())
// 搜索
@ -978,7 +999,7 @@ export default function AssetCenterClient({
code: '',
name: '',
description: '',
applicableSeriesCode: initialSeriesTemplates[0]?.code ?? '',
compatibleSeriesIds: initialAssetSeries[0]?.id ? [initialAssetSeries[0].id] : [],
selectedRoles: [] as string[],
})
const [familyRoleDefinitions, setFamilyRoleDefinitions] = useState<ConnectionRoleDefinition[]>([])
@ -1220,6 +1241,62 @@ export default function AssetCenterClient({
setVariantUvEditorForm(nextForm)
}, [activeVariantUvNodeName])
useEffect(() => {
const availableIds = new Set(initialAssetSeries.map((series) => series.id))
const savedSeriesId = window.localStorage.getItem(ASSET_CENTER_SELECTED_SERIES_STORAGE_KEY)
if (savedSeriesId && availableIds.has(savedSeriesId)) {
setSelectedSeriesId(savedSeriesId)
return
}
setSelectedSeriesId((current) =>
current && availableIds.has(current) ? current : initialAssetSeries[0]?.id ?? null,
)
}, [initialAssetSeries])
useEffect(() => {
if (selectedSeriesId) {
window.localStorage.setItem(ASSET_CENTER_SELECTED_SERIES_STORAGE_KEY, selectedSeriesId)
}
}, [selectedSeriesId])
useEffect(() => {
if (!seriesPickerOpen) return
function handlePointerDown(event: PointerEvent) {
const target = event.target
if (
target instanceof Node &&
seriesPickerRef.current &&
!seriesPickerRef.current.contains(target)
) {
setSeriesPickerOpen(false)
}
}
function handleKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') {
setSeriesPickerOpen(false)
}
}
document.addEventListener('pointerdown', handlePointerDown)
document.addEventListener('keydown', handleKeyDown)
return () => {
document.removeEventListener('pointerdown', handlePointerDown)
document.removeEventListener('keydown', handleKeyDown)
}
}, [seriesPickerOpen])
const handleSelectSeries = useCallback((seriesId: string) => {
setSelectedSeriesId(seriesId)
setSeriesPickerOpen(false)
setSeriesSearchText('')
setActiveTab('BOARD')
setSearchText('')
setReviewFilter('ALL')
}, [])
const variantsByFamilyId = useMemo(() => {
const map = new Map<string, AssetVariantDTO[]>()
variants.forEach((variant) => {
@ -1230,8 +1307,56 @@ export default function AssetCenterClient({
return map
}, [variants])
const selectedSeries = useMemo(
() => initialAssetSeries.find((series) => series.id === selectedSeriesId) ?? null,
[initialAssetSeries, selectedSeriesId],
)
const seriesAssetCounts = useMemo(() => {
const map = new Map<string, number>()
initialAssetSeries.forEach((series) => {
const count = families.filter(
(family) =>
familyBelongsToSeries(family, series) &&
family.type !== 'HARDWARE' &&
!isPackagingMaterialLibraryFamily(family),
).length
map.set(series.id, count)
})
return map
}, [families, initialAssetSeries])
const filteredAssetSeries = useMemo(() => {
const keyword = seriesSearchText.trim().toLowerCase()
if (!keyword) return initialAssetSeries
return initialAssetSeries.filter((series) => {
return (
series.name.toLowerCase().includes(keyword) ||
series.code.toLowerCase().includes(keyword) ||
(series.description ?? '').toLowerCase().includes(keyword)
)
})
}, [initialAssetSeries, seriesSearchText])
const selectedSeriesAssetCount = selectedSeries
? seriesAssetCounts.get(selectedSeries.id) ?? 0
: 0
const currentSeriesFamilies = useMemo(() => {
if (!selectedSeries) return []
return families.filter((family) => familyVisibleInSeriesContext(family, selectedSeries))
}, [families, selectedSeries])
useEffect(() => {
const visibleFamilyIds = new Set(currentSeriesFamilies.map((family) => family.id))
setAssemblySelectedFamilyIds((current) => {
const next = new Set(Array.from(current).filter((id) => visibleFamilyIds.has(id)))
return next.size === current.size ? current : next
})
}, [currentSeriesFamilies])
const searchedFamilies = useMemo(() => {
const filtered = getVisibleFamiliesForTab(families, activeTab)
const filtered = getVisibleFamiliesForTab(currentSeriesFamilies, activeTab)
if (!searchText.trim()) return filtered
const keyword = searchText.trim().toLowerCase()
return filtered.filter(
@ -1239,7 +1364,7 @@ export default function AssetCenterClient({
family.name.toLowerCase().includes(keyword) ||
family.code.toLowerCase().includes(keyword),
)
}, [families, activeTab, searchText])
}, [currentSeriesFamilies, activeTab, searchText])
const currentFamilies = useMemo(
() =>
@ -1289,6 +1414,7 @@ export default function AssetCenterClient({
return buildManualAssemblyLaunchHref({
entryMode: 'asset-center',
source: 'asset-center',
assetSeriesId: selectedSeriesId,
selectedFamilyIdsByType: {
desktopFamilyIds: selectedFamiliesForAssembly
.filter((family) => family.type === 'BOARD')
@ -1301,7 +1427,7 @@ export default function AssetCenterClient({
.map((family) => family.id),
},
})
}, [selectedFamiliesForAssembly])
}, [selectedFamiliesForAssembly, selectedSeriesId])
const selectedCadImportFamily = useMemo(
() => families.find((family) => family.id === cadImportForm.familyId) ?? null,
@ -1558,27 +1684,35 @@ export default function AssetCenterClient({
// ---- 资产族操作 ----
function openCreateFamilyModal() {
const defaultSeriesCode = initialSeriesTemplates[0]?.code ?? ''
if (!selectedSeries) {
toast.warning('请先选择系列')
return
}
const defaultSeriesIds = selectedSeriesId ? [selectedSeriesId] : []
const defaultSeriesCode = selectedSeries?.code ?? null
const defaultAssetType = activeTab
setFamilyModalMode('create')
setFamilyForm({
type: activeTab,
type: defaultAssetType,
code: '',
name: '',
description: '',
applicableSeriesCode: defaultSeriesCode,
compatibleSeriesIds: defaultAssetType === 'HARDWARE' ? [] : defaultSeriesIds,
selectedRoles: [],
})
setFamilyRoleDefinitions(
mergeConnectionRoleDefinitions({
assetType: activeTab,
seriesCode: defaultSeriesCode,
assetType: defaultAssetType,
seriesCode: defaultAssetType === 'HARDWARE' ? null : defaultSeriesCode,
}),
)
setFamilyModalOpen(true)
}
function openEditFamilyModal(family: AssetFamilyDTO) {
const applicableSeriesCode = family.applicableSeriesCodes[0] ?? ''
const compatibleSeries = family.compatibleSeries ?? []
const compatibleSeriesIds = compatibleSeries.map((series) => series.id)
const primarySeriesCode = compatibleSeries[0]?.code ?? null
const normalizedRoles = normalizeConnectionRoles(family.connectionRoles)
setFamilyModalMode('edit')
setEditingFamilyId(family.id)
@ -1587,13 +1721,13 @@ export default function AssetCenterClient({
code: family.code,
name: family.name,
description: family.description ?? '',
applicableSeriesCode,
compatibleSeriesIds,
selectedRoles: normalizedRoles,
})
setFamilyRoleDefinitions(
mergeConnectionRoleDefinitions({
assetType: family.type,
seriesCode: applicableSeriesCode,
seriesCode: primarySeriesCode,
selectedRoles: normalizedRoles,
metadata: family.metadata,
}),
@ -1641,7 +1775,7 @@ export default function AssetCenterClient({
value,
label,
assetType: familyForm.type,
seriesCode: familyForm.applicableSeriesCode || null,
seriesCode: initialAssetSeries.find((series) => familyForm.compatibleSeriesIds.includes(series.id))?.code ?? null,
builtIn: false,
},
])
@ -1653,7 +1787,11 @@ export default function AssetCenterClient({
setFamilyRoleDefinitions((current) =>
current.map((definition) =>
definition.value === editingRoleValue
? { ...definition, label, seriesCode: familyForm.applicableSeriesCode || null }
? {
...definition,
label,
seriesCode: initialAssetSeries.find((series) => familyForm.compatibleSeriesIds.includes(series.id))?.code ?? null,
}
: definition,
),
)
@ -1673,10 +1811,21 @@ export default function AssetCenterClient({
}
async function handleSaveFamily() {
if (familyForm.type !== 'HARDWARE' && familyForm.compatibleSeriesIds.length === 0) {
toast.error('请选择至少一个兼容系列')
return
}
const normalizedSelectedRoles = normalizeConnectionRoles(familyForm.selectedRoles)
const submittedCompatibleSeriesIds =
familyForm.type === 'HARDWARE' ? [] : familyForm.compatibleSeriesIds
const primarySeriesCode =
familyForm.type === 'HARDWARE'
? null
: initialAssetSeries.find((series) => submittedCompatibleSeriesIds.includes(series.id))?.code ?? null
const hiddenRecommendedRoleValues = getStandardConnectionRoleDefinitions({
assetType: familyForm.type,
seriesCode: familyForm.applicableSeriesCode || null,
seriesCode: primarySeriesCode,
})
.map((definition) => definition.value)
.filter((value) => !familyRoleDefinitions.some((definition) => definition.value === value))
@ -1695,9 +1844,7 @@ export default function AssetCenterClient({
code: familyForm.code,
name: familyForm.name,
description: familyForm.description || null,
applicableSeriesCodes: familyForm.applicableSeriesCode
? [familyForm.applicableSeriesCode]
: [],
compatibleSeriesIds: submittedCompatibleSeriesIds,
connectionRoles: normalizedSelectedRoles,
metadata,
status: 'DRAFT',
@ -1713,9 +1860,7 @@ export default function AssetCenterClient({
code: familyForm.code,
name: familyForm.name,
description: familyForm.description || null,
applicableSeriesCodes: familyForm.applicableSeriesCode
? [familyForm.applicableSeriesCode]
: [],
compatibleSeriesIds: submittedCompatibleSeriesIds,
connectionRoles: normalizedSelectedRoles,
metadata,
})
@ -2495,9 +2640,12 @@ export default function AssetCenterClient({
}
setFamilyRoleDefinitions((current) => {
const primarySeriesCode = initialAssetSeries.find(
(series) => familyForm.compatibleSeriesIds.includes(series.id),
)?.code ?? null
const merged = mergeConnectionRoleDefinitions({
assetType: familyForm.type,
seriesCode: familyForm.applicableSeriesCode || null,
seriesCode: primarySeriesCode,
selectedRoles: familyForm.selectedRoles,
metadata: familyModalMode === 'edit' ? editingFamily?.metadata : null,
})
@ -2523,11 +2671,12 @@ export default function AssetCenterClient({
})
}, [
editingFamily?.metadata,
familyForm.applicableSeriesCode,
familyForm.compatibleSeriesIds,
familyForm.selectedRoles,
familyForm.type,
familyModalMode,
familyModalOpen,
initialAssetSeries,
])
useEffect(() => {
@ -2577,6 +2726,7 @@ export default function AssetCenterClient({
familyName: cadImportForm.familyMode === 'create' ? cadImportForm.familyName : null,
familyDescription:
cadImportForm.familyMode === 'create' ? cadImportForm.familyDescription : null,
assetSeriesId: selectedSeriesId,
connectionRoles:
cadImportForm.familyMode === 'create'
? cadImportConnectionRoles
@ -3001,10 +3151,112 @@ export default function AssetCenterClient({
V2.6.4
</span>
</div>
{/* 系列入口 */}
<div className="relative space-y-2" ref={seriesPickerRef}>
<div className="flex items-center justify-between">
<span className="text-[10px] font-black uppercase tracking-[0.14em] text-[#5a6062]/55">
系列
</span>
<span className="text-[10px] font-bold text-[#5a6062]/45">
共 {initialAssetSeries.length} 个
</span>
</div>
<button
type="button"
aria-haspopup="listbox"
aria-expanded={seriesPickerOpen}
onClick={() => setSeriesPickerOpen((open) => !open)}
className="flex h-10 w-full items-center justify-between gap-3 rounded-[6px] bg-black/[0.025] px-3 text-left text-[#2d3335] ring-1 ring-black/[0.04] transition hover:bg-black/[0.04] focus:outline-none focus:ring-black/[0.12]"
>
<span className="min-w-0 flex-1">
<span className="block truncate text-xs font-bold">
{selectedSeries?.name ?? '请选择系列'}
</span>
<span className="mt-0.5 block truncate font-mono text-[9px] font-bold uppercase tracking-[0.08em] text-[#5a6062]/45">
{selectedSeries?.code ?? 'NO-SERIES'}
</span>
</span>
<span className="flex shrink-0 items-center gap-2">
<span className="rounded-[4px] bg-white px-1.5 py-0.5 text-[9px] font-bold leading-none text-[#5a6062]/60 ring-1 ring-black/[0.04]">
{selectedSeriesAssetCount}
</span>
<ChevronDown
className={cn(
'h-3.5 w-3.5 text-[#5a6062]/55 transition',
seriesPickerOpen && 'rotate-180 text-[#2d3335]',
)}
/>
</span>
</button>
{seriesPickerOpen && (
<div className="absolute left-0 right-0 top-full z-30 mt-1.5 overflow-hidden rounded-[8px] border border-black/[0.06] bg-white shadow-[0_16px_40px_rgba(15,23,42,0.12)]">
<div className="border-b border-black/[0.04] p-2">
<div className="relative">
<Search className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-[#5a6062]/35" />
<input
value={seriesSearchText}
onChange={(event) => setSeriesSearchText(event.target.value)}
placeholder="搜索系列名称或编码"
className="h-8 w-full rounded-[6px] bg-black/[0.025] pl-8 pr-2 text-xs text-[#2d3335] outline-none ring-1 ring-black/[0.04] placeholder:text-[#5a6062]/38 focus:bg-white focus:ring-black/[0.12]"
/>
</div>
</div>
<div role="listbox" className="max-h-64 overflow-y-auto p-1">
{filteredAssetSeries.length > 0 ? (
filteredAssetSeries.map((series) => {
const active = selectedSeriesId === series.id
const count = seriesAssetCounts.get(series.id) ?? 0
return (
<button
key={series.id}
type="button"
role="option"
aria-selected={active}
onClick={() => handleSelectSeries(series.id)}
className={cn(
'flex min-h-9 w-full items-center justify-between gap-3 rounded-[6px] px-2.5 py-2 text-left transition',
active
? 'bg-[#2d3335] text-white'
: 'text-[#2d3335] hover:bg-black/[0.035]',
)}
>
<span className="min-w-0 flex-1">
<span className="block truncate text-xs font-bold">{series.name}</span>
<span
className={cn(
'mt-0.5 block truncate font-mono text-[9px] font-bold uppercase tracking-[0.08em]',
active ? 'text-white/55' : 'text-[#5a6062]/42',
)}
>
{series.code}
</span>
</span>
<span
className={cn(
'shrink-0 rounded-[4px] px-1.5 py-0.5 text-[9px] font-bold leading-none',
active ? 'bg-white/15 text-white' : 'bg-black/[0.04] text-[#5a6062]/60',
)}
>
{count}
</span>
</button>
)
})
) : (
<div className="px-3 py-5 text-center text-xs text-[#5a6062]/50">
没有匹配的系列
</div>
)}
</div>
</div>
)}
</div>
{/* 分类 Tab */}
<div className="flex rounded-[6px] bg-black/[0.03] p-0.5 ring-1 ring-black/5">
{TYPE_TABS.map((tab) => {
const count = getVisibleFamiliesForTab(families, tab.type).length
const count = getVisibleFamiliesForTab(currentSeriesFamilies, tab.type).length
const isActive = activeTab === tab.type
return (
<button
@ -3103,6 +3355,7 @@ export default function AssetCenterClient({
size="small"
ariaLabel="新建资产族"
onClick={openCreateFamilyModal}
disabled={!selectedSeries}
className="h-9 w-9 rounded-lg p-0 active:scale-90"
>
<Plus className="h-4 w-4" />
@ -3122,7 +3375,7 @@ export default function AssetCenterClient({
<div className="flex items-center gap-2">
<Package2 className="h-4 w-4 text-[#2d3335]" />
<div>
<p className="text-sm font-apple-medium text-[#2d3335]">包材资料库</p>
<p className="text-sm font-semibold text-[#2d3335]">包材资料库</p>
</div>
</div>
<ArrowRight className="h-4 w-4 text-[#5a6062]/60" />
@ -3130,16 +3383,20 @@ export default function AssetCenterClient({
)}
{currentFamilies.length === 0 ? (
<div className="py-8 text-center">
<p className="text-sm text-apple-label-tertiary">
{searchText
<p className="text-sm text-[#5a6062]/55">
{!selectedSeries
? '请先选择系列'
: searchText
? `未找到匹配「${searchText}」的资产族`
: reviewFilter === 'PENDING_REVIEW'
? '当前没有待审核资产'
: reviewFilter === 'REJECTED'
? '当前没有已驳回资产'
: '暂无该类型资产族'}
: activeTab === 'HARDWARE'
? '暂无五金件'
: '当前系列暂无该类型资产族'}
</p>
{!searchText && reviewFilter === 'ALL' && (
{selectedSeries && !searchText && reviewFilter === 'ALL' && (
<div className="mt-2 space-y-1">
<button
type="button"
@ -3148,7 +3405,7 @@ export default function AssetCenterClient({
>
创建第一个资产族
</button>
<p className="text-[11px] text-apple-label-tertiary">
<p className="text-[11px] text-[#5a6062]/45">
先建资产族,再在资产族里面新建或导入带模型的规格款。
</p>
</div>
@ -3167,6 +3424,7 @@ export default function AssetCenterClient({
<div
role="button"
tabIndex={0}
aria-label={`展开资产族 ${family.code}`}
onClick={() => toggleFamilyExpand(family.id)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
@ -4105,12 +4363,50 @@ export default function AssetCenterClient({
}
>
<div className="space-y-4">
<div className="space-y-2">
<p className="text-xs font-bold text-[#2d3335]">资产类型</p>
<div className="grid grid-cols-4 gap-1 rounded-[6px] bg-black/[0.03] p-0.5 ring-1 ring-black/5">
{TYPE_TABS.map((tab) => {
const type = tab.type as AssetFamilyDTO['type']
const active = familyForm.type === type
return (
<button
key={type}
type="button"
onClick={() =>
setFamilyForm((current) => ({
...current,
type,
compatibleSeriesIds:
type === 'HARDWARE'
? []
: current.compatibleSeriesIds.length > 0
? current.compatibleSeriesIds
: selectedSeriesId
? [selectedSeriesId]
: [],
}))
}
className={cn(
'flex h-8 items-center justify-center gap-1.5 rounded-[4px] px-2 text-[10px] font-bold transition',
active
? 'bg-white text-[#2d3335] shadow-sm'
: 'text-[#5a6062]/50 hover:text-[#2d3335]',
)}
>
<span className="h-3.5 w-3.5">{tab.icon}</span>
<span>{tab.label}</span>
</button>
)
})}
</div>
</div>
<div className="grid gap-3 grid-cols-2">
<Input
label="编码"
value={familyForm.code}
onChange={(e) => setFamilyForm((c) => ({ ...c, code: e.target.value }))}
placeholder={`例如 ${activeTab}-001`}
placeholder={`例如 ${familyForm.type}-001`}
/>
<Input
label="名称"
@ -4119,6 +4415,50 @@ export default function AssetCenterClient({
placeholder="例如 单板桌桌面板"
/>
</div>
{familyForm.type !== 'HARDWARE' ? (
<div className="space-y-2">
<div className="flex items-center justify-between">
<p className="text-xs font-bold text-[#2d3335]">兼容系列</p>
<span className="text-[11px] text-[#5a6062]/55">
已选 {familyForm.compatibleSeriesIds.length} 个
</span>
</div>
<div className="grid grid-cols-2 gap-2">
{initialAssetSeries.map((series) => {
const checked = familyForm.compatibleSeriesIds.includes(series.id)
return (
<label
key={series.id}
className={cn(
'flex cursor-pointer items-center gap-2 rounded-[6px] px-3 py-2 text-xs transition ring-1',
checked
? 'bg-[#2d3335] text-white ring-[#2d3335]'
: 'bg-black/[0.025] text-[#2d3335] ring-black/[0.04] hover:bg-black/[0.045]',
)}
>
<input
type="checkbox"
checked={checked}
onChange={(event) => {
setFamilyForm((current) => ({
...current,
compatibleSeriesIds: event.target.checked
? Array.from(new Set([...current.compatibleSeriesIds, series.id]))
: current.compatibleSeriesIds.filter((id) => id !== series.id),
}))
}}
className="sr-only"
/>
<span className="min-w-0 flex-1 truncate font-bold">{series.name}</span>
<span className={cn('font-mono text-[10px]', checked ? 'text-white/60' : 'text-[#5a6062]/45')}>
{series.code}
</span>
</label>
)
})}
</div>
</div>
) : null}
<details className="group rounded-apple-lg border border-apple-separator bg-apple-bg-secondary/30">
<summary className="flex cursor-pointer list-none items-center justify-between gap-3 px-4 py-3">
<div className="flex items-center gap-2">

View File

@ -10,7 +10,7 @@ import {
updateAssetFamily,
} from '@/lib/open-product/platform-api'
import { parseZipFile } from '@/lib/zip-parser'
import type { AssetFamilyDTO, AssetVariantDTO, MaterialDTO, SeriesTemplateDTO, SubstrateDTO } from '@/server/services'
import type { AssetFamilyDTO, AssetVariantDTO, MaterialDTO, AssetSeriesDTO, SubstrateDTO } from '@/server/services'
jest.mock('next/link', () => ({
__esModule: true,
@ -138,14 +138,18 @@ function renderClient() {
initialFamilies={[]}
initialVariants={[] as AssetVariantDTO[]}
initialSubstrates={[{ id: 'substrate-1', code: 'PB18', name: '18mm 颗粒板' }] as SubstrateDTO[]}
initialSeriesTemplates={[
initialAssetSeries={[
{
id: 'template-1',
code: 'single-board-desk',
name: '单板桌',
seriesName: '云序',
description: null,
sortOrder: 0,
isActive: true,
createdAt: new Date('2026-04-01T00:00:00.000Z'),
updatedAt: new Date('2026-04-01T00:00:00.000Z'),
},
] as SeriesTemplateDTO[]}
] as AssetSeriesDTO[]}
finishes={[
{
id: 'finish-1',
@ -215,7 +219,7 @@ describe('AssetCenterClient workflow', () => {
description: null,
productCategoryId: null,
productCategoryName: null,
applicableSeriesCodes: [],
compatibleSeries: [{ id: 'template-1', code: 'single-board-desk', name: '单板桌' }],
connectionRoles: ['desktop_surface'],
metadata: null,
status: 'DRAFT',
@ -247,7 +251,6 @@ describe('AssetCenterClient workflow', () => {
type: 'BOARD',
code: 'BOARD-001',
name: '桌面板',
applicableSeriesCodes: ['single-board-desk'],
connectionRoles: ['desktop_surface'],
}))
})
@ -267,7 +270,7 @@ describe('AssetCenterClient workflow', () => {
description: null,
productCategoryId: null,
productCategoryName: null,
applicableSeriesCodes: ['single-board-desk'],
compatibleSeries: [{ id: 'template-1', code: 'single-board-desk', name: '单板桌' }],
connectionRoles: ['desktop_surface'],
metadata: {
connectionRoleDefinitions: [
@ -296,14 +299,18 @@ describe('AssetCenterClient workflow', () => {
finishes: [{ id: 'finish-1', name: '白橡木纹', code: 'FIN-1', thumbnailUrl: '' }],
},
] as SubstrateDTO[]}
initialSeriesTemplates={[
initialAssetSeries={[
{
id: 'template-1',
code: 'single-board-desk',
name: '单板桌',
seriesName: '云序',
description: null,
sortOrder: 0,
isActive: true,
createdAt: new Date('2026-04-01T00:00:00.000Z'),
updatedAt: new Date('2026-04-01T00:00:00.000Z'),
},
] as SeriesTemplateDTO[]}
] as AssetSeriesDTO[]}
finishes={[
{
id: 'finish-1',
@ -370,7 +377,7 @@ describe('AssetCenterClient workflow', () => {
description: null,
productCategoryId: null,
productCategoryName: null,
applicableSeriesCodes: ['single-board-desk'],
compatibleSeries: [{ id: 'template-1', code: 'single-board-desk', name: '单板桌' }],
connectionRoles: [],
metadata: null,
status: 'DRAFT',
@ -390,14 +397,18 @@ describe('AssetCenterClient workflow', () => {
finishes: [{ id: 'finish-1', name: '白橡木纹', code: 'FIN-1', thumbnailUrl: '' }],
},
] as SubstrateDTO[]}
initialSeriesTemplates={[
initialAssetSeries={[
{
id: 'template-1',
code: 'single-board-desk',
name: '单板桌',
seriesName: '云序',
description: null,
sortOrder: 0,
isActive: true,
createdAt: new Date('2026-04-01T00:00:00.000Z'),
updatedAt: new Date('2026-04-01T00:00:00.000Z'),
},
] as SeriesTemplateDTO[]}
] as AssetSeriesDTO[]}
finishes={[] as unknown as MaterialDTO[]}
materialCategories={[]}
metrics={[]}
@ -430,7 +441,7 @@ describe('AssetCenterClient workflow', () => {
description: null,
productCategoryId: null,
productCategoryName: null,
applicableSeriesCodes: ['single-board-desk'],
compatibleSeries: [{ id: 'template-1', code: 'single-board-desk', name: '单板桌' }],
connectionRoles: ['cross_support'],
metadata: {
connectionRoleDefinitions: [
@ -462,7 +473,7 @@ describe('AssetCenterClient workflow', () => {
description: null,
productCategoryId: null,
productCategoryName: null,
applicableSeriesCodes: ['single-board-desk'],
compatibleSeries: [{ id: 'template-1', code: 'single-board-desk', name: '单板桌' }],
connectionRoles: ['cross_support'],
metadata: {
connectionRoleDefinitions: [
@ -492,14 +503,18 @@ describe('AssetCenterClient workflow', () => {
finishes: [{ id: 'finish-1', name: '白橡木纹', code: 'FIN-1', thumbnailUrl: '' }],
},
] as SubstrateDTO[]}
initialSeriesTemplates={[
initialAssetSeries={[
{
id: 'template-1',
code: 'single-board-desk',
name: '单板桌',
seriesName: '云序',
description: null,
sortOrder: 0,
isActive: true,
createdAt: new Date('2026-04-01T00:00:00.000Z'),
updatedAt: new Date('2026-04-01T00:00:00.000Z'),
},
] as SeriesTemplateDTO[]}
] as AssetSeriesDTO[]}
finishes={[] as unknown as MaterialDTO[]}
materialCategories={[]}
metrics={[]}
@ -533,7 +548,6 @@ describe('AssetCenterClient workflow', () => {
expect(updateAssetFamily).toHaveBeenCalledWith(
'family-board-1',
expect.objectContaining({
applicableSeriesCodes: ['single-board-desk'],
connectionRoles: ['cross_support'],
metadata: expect.objectContaining({
connectionRoleDefinitions: expect.arrayContaining([
@ -559,7 +573,7 @@ describe('AssetCenterClient workflow', () => {
description: null,
productCategoryId: null,
productCategoryName: null,
applicableSeriesCodes: [],
compatibleSeries: [{ id: 'template-1', code: 'single-board-desk', name: '单板桌' }],
connectionRoles: [],
metadata: null,
status: 'DRAFT',
@ -625,7 +639,7 @@ describe('AssetCenterClient workflow', () => {
description: null,
productCategoryId: null,
productCategoryName: null,
applicableSeriesCodes: ['single-board-desk'],
compatibleSeries: [{ id: 'template-1', code: 'single-board-desk', name: '单板桌' }],
connectionRoles: ['desktop_surface'],
metadata: {
connectionRoleDefinitions: [
@ -654,14 +668,18 @@ describe('AssetCenterClient workflow', () => {
finishes: [{ id: 'finish-1', name: '白橡木纹', code: 'FIN-1', thumbnailUrl: '' }],
},
] as SubstrateDTO[]}
initialSeriesTemplates={[
initialAssetSeries={[
{
id: 'template-1',
code: 'single-board-desk',
name: '单板桌',
seriesName: '云序',
description: null,
sortOrder: 0,
isActive: true,
createdAt: new Date('2026-04-01T00:00:00.000Z'),
updatedAt: new Date('2026-04-01T00:00:00.000Z'),
},
] as SeriesTemplateDTO[]}
] as AssetSeriesDTO[]}
finishes={[
{
id: 'finish-1',
@ -747,7 +765,7 @@ describe('AssetCenterClient workflow', () => {
description: null,
productCategoryId: null,
productCategoryName: null,
applicableSeriesCodes: ['single-board-desk'],
compatibleSeries: [{ id: 'template-1', code: 'single-board-desk', name: '单板桌' }],
connectionRoles: ['desktop_surface'],
metadata: null,
status: 'AVAILABLE',
@ -817,7 +835,7 @@ describe('AssetCenterClient workflow', () => {
description: null,
productCategoryId: null,
productCategoryName: null,
applicableSeriesCodes: ['single-board-desk'],
compatibleSeries: [{ id: 'template-1', code: 'single-board-desk', name: '单板桌' }],
connectionRoles: ['desktop_surface'],
metadata: null,
status: 'PENDING_REVIEW',
@ -892,14 +910,18 @@ describe('AssetCenterClient workflow', () => {
},
] as AssetVariantDTO[]}
initialSubstrates={[{ id: 'substrate-1', code: 'PB18', name: '18mm 颗粒板' }] as SubstrateDTO[]}
initialSeriesTemplates={[
initialAssetSeries={[
{
id: 'template-1',
code: 'single-board-desk',
name: '单板桌',
seriesName: '云序',
description: null,
sortOrder: 0,
isActive: true,
createdAt: new Date('2026-04-01T00:00:00.000Z'),
updatedAt: new Date('2026-04-01T00:00:00.000Z'),
},
] as SeriesTemplateDTO[]}
] as AssetSeriesDTO[]}
finishes={[
{
id: 'finish-1',

View File

@ -21,6 +21,7 @@ import { cn } from '@/lib/utils'
import {
MaterialCategoryDTO,
MaterialLikeDTO,
MaterialDTO,
SubstrateDTO,
SubstrateDictionaryBundleDTO,
@ -221,6 +222,10 @@ const mlUploadButtonClassName =
'cursor-pointer rounded-md border border-[#101719]/20 bg-white px-2.5 py-1.5 text-[10px] font-apple-black text-[#101719] ring-1 ring-black/[0.04] transition hover:bg-black/[0.045]'
const mlTagClassName =
'inline-flex items-center gap-1 rounded-md border border-black/10 bg-black/[0.035] px-2.5 py-1 text-xs font-apple-bold text-[#263033]'
const mlIconActionButtonClassName =
'inline-flex h-8 w-8 items-center justify-center rounded-md border border-black/[0.06] bg-white text-[#5f6d70] transition hover:border-black/[0.12] hover:bg-black/[0.035] hover:text-[#101719] focus:outline-none focus:ring-2 focus:ring-black/[0.08]'
const mlDangerIconActionButtonClassName =
'inline-flex h-8 w-8 items-center justify-center rounded-md border border-transparent bg-white text-[#a44640] transition hover:border-[#a44640]/20 hover:bg-[#a44640]/8 focus:outline-none focus:ring-2 focus:ring-[#a44640]/15'
function formatFinishOptionLabel(finish: { name: string; code: string; categoryName?: string | null }): string {
return [finish.name, finish.code, finish.categoryName].filter(Boolean).join(' · ')
@ -230,7 +235,7 @@ function firstUsableMaterialImageUrl(...urls: Array<string | null | undefined>)
return urls.find((url) => url && !url.includes('/materials/thumbnails/default.svg')) ?? null
}
function MaterialSurfacePreview({ item, className }: { item: MaterialDTO, className?: string }) {
function MaterialSurfacePreview({ item, className }: { item: MaterialDTO | MaterialLikeDTO, className?: string }) {
const imageUrl = firstUsableMaterialImageUrl(item.textureUrl, item.thumbnailUrl)
return (
@ -305,6 +310,37 @@ function FinishWorkbenchPreviewCard({ form }: { form: FinishForm }) {
)
}
function SubstrateSlabPreview({ substrate }: { substrate: SubstrateDTO }) {
const finish = substrate.finishes[0] ?? null
const surfaceUrl = finish ? firstUsableMaterialImageUrl(finish.textureUrl, finish.thumbnailUrl) : null
return (
<div className="relative h-full min-h-[176px] overflow-hidden rounded-md bg-[#f3f5f5] ring-1 ring-black/[0.05]">
<div
className="absolute inset-x-8 top-10 h-[72px] rotate-[-7deg] rounded-[5px] shadow-[0_18px_42px_rgba(17,19,21,0.12)] ring-1 ring-black/[0.08]"
style={{
backgroundColor: finish?.color ?? '#d8ddde',
backgroundImage: surfaceUrl
? `linear-gradient(135deg, rgba(255,255,255,0.18), rgba(0,0,0,0.06)), url(${surfaceUrl})`
: 'linear-gradient(135deg, rgba(255,255,255,0.42), rgba(0,0,0,0.08))',
backgroundSize: surfaceUrl ? '100% 100%, cover' : '100% 100%',
backgroundPosition: 'center',
}}
/>
<div className="absolute inset-x-12 top-[108px] h-5 rotate-[-7deg] rounded-b-[5px] bg-black/[0.08] blur-[10px]" />
<div className="absolute bottom-4 left-4 right-4 flex items-end justify-between gap-3">
<div className="min-w-0">
<p className="text-[9px] font-apple-black text-[#66777c]">板材基材</p>
<p className="mt-1 truncate text-sm font-apple-black text-[#101719]">{substrate.name}</p>
</div>
<span className="shrink-0 rounded-md bg-white px-2 py-1 text-[10px] font-apple-black text-[#263033] ring-1 ring-black/[0.06]">
{substrate.thicknessSpecName}
</span>
</div>
</div>
)
}
function FinishParameterSliderField({
id, label, value, min = 0, max = 1, step = 0.01, disabled, onChange
}: {
@ -1192,18 +1228,24 @@ export default function MaterialLibraryClient({
<div className="flex gap-2">
{isView && activeSubstrate ? (
<>
<Button variant="plain" size="small" aria-label="编辑当前基材" onClick={() => startEditSubstrate(activeSubstrate)}>
<Pencil className="h-4 w-4 mr-1.5" /> 编辑
</Button>
<Button
variant="plain"
size="small"
<button
type="button"
aria-label="编辑当前基材"
title="编辑"
className={mlIconActionButtonClassName}
onClick={() => startEditSubstrate(activeSubstrate)}
>
<Pencil className="h-4 w-4" />
</button>
<button
type="button"
aria-label="删除当前基材"
className="text-apple-red"
title="删除"
className={mlDangerIconActionButtonClassName}
onClick={() => setDeleteTarget({ type: 'substrate', id: activeSubstrate.id, name: activeSubstrate.name })}
>
<Trash2 className="h-4 w-4 mr-1.5" /> 删除
</Button>
<Trash2 className="h-4 w-4" />
</button>
</>
) : null}
{isCreate || isEdit ? (
@ -1426,10 +1468,10 @@ export default function MaterialLibraryClient({
</div>
</div>
) : activeSubstrate ? (
<div className="space-y-4">
<div className="rounded-lg border border-black/10 bg-white p-6">
<div className="space-y-6">
<div>
<p className={mlEyebrowClassName}>基础信息</p>
<div className="mt-4 grid gap-3 text-sm">
<div className="mt-3 divide-y divide-black/[0.06] border-y border-black/[0.06]">
{[
{ label: '编码', value: activeSubstrate.code },
{ label: '材质主类', value: activeSubstrate.categoryName },
@ -1439,7 +1481,7 @@ export default function MaterialLibraryClient({
{ label: '板尺寸', value: `${activeSubstrate.sheetLengthMm} × ${activeSubstrate.sheetWidthMm}` },
{
label: '采购价',
value: activeSubstrate.suggestedPrice ?? '缺价格',
value: activeSubstrate.suggestedPrice != null ? activeSubstrate.suggestedPrice : '缺价格',
missing: activeSubstrate.suggestedPrice == null,
},
{
@ -1447,14 +1489,15 @@ export default function MaterialLibraryClient({
value: activeSubstrate.weightKg != null ? `${activeSubstrate.weightKg} kg` : '缺重量',
missing: activeSubstrate.weightKg == null,
},
{ label: '系统名称', value: activeSubstrate.systemNameOptionName ?? '--' },
].map(({ label, value, missing }) => (
<div key={label} className="flex items-center justify-between gap-3">
<span className="text-xs font-apple-bold text-[#687477]">{label}</span>
<div key={label} className="grid grid-cols-[96px_minmax(0,1fr)] items-center gap-3 py-2.5">
<span className="text-[11px] font-apple-bold text-[#687477]">{label}</span>
<span
className={cn(
'text-right text-sm font-apple-black',
'truncate text-right text-sm font-apple-black',
missing
? 'rounded-full bg-apple-red/10 px-2 py-0.5 text-apple-red'
? 'text-[#a44640]'
: 'text-[#263033]',
)}
>
@ -1464,19 +1507,29 @@ export default function MaterialLibraryClient({
))}
</div>
</div>
<div className="rounded-lg border border-black/10 bg-white p-6">
<p className={mlEyebrowClassName}>关联花色</p>
<div className="mt-3 flex flex-wrap gap-1.5">
<div>
<p className={mlEyebrowClassName}>绑定花色</p>
<div className="mt-3">
{activeSubstrate.finishes.length === 0 ? (
<span className="text-xs text-[#7f9297]">暂无关联花色</span>
<div className="rounded-md bg-black/[0.025] px-3 py-3 text-xs font-apple-bold text-[#7f9297] ring-1 ring-black/[0.04]">
暂无关联花色
</div>
) : (
activeSubstrate.finishes.slice(0, 1).map((finish) => (
<span key={finish.id} className={mlTagClassName}>
<div key={finish.id} className="flex items-center gap-3 rounded-md bg-black/[0.025] px-3 py-3 ring-1 ring-black/[0.04]">
{finish.textureUrl || finish.thumbnailUrl ? (
<img src={finish.textureUrl ?? finish.thumbnailUrl ?? ''} alt="" className="h-4 w-4 rounded object-cover" />
) : null}
{formatFinishOptionLabel(finish)}
</span>
<img src={finish.textureUrl ?? finish.thumbnailUrl ?? ''} alt="" className="h-10 w-10 rounded object-cover ring-1 ring-black/[0.06]" />
) : (
<span className="h-10 w-10 rounded bg-black/[0.04] ring-1 ring-black/[0.06]" style={{ backgroundColor: finish.color }} />
)}
<span className="min-w-0">
<span className="block truncate text-sm font-apple-black text-[#101719]">{finish.name}</span>
<span className="mt-0.5 block truncate text-[10px] font-apple-bold text-[#687477]">
{finish.code} · {finish.categoryName ?? '未分类'}
</span>
</span>
</div>
))
)}
</div>
@ -1705,12 +1758,15 @@ export default function MaterialLibraryClient({
className="h-10 w-full rounded-lg border border-black/10 bg-white pl-10 pr-3 text-sm font-apple-medium text-[#263033] outline-none transition placeholder:text-[#7a878a]/70 focus:border-[#101719]/30 focus:ring-4 focus:ring-black/[0.04]"
/>
</div>
{metrics.slice(0, 2).map((metric) => (
<div key={metric.label} className="hidden h-10 min-w-[94px] items-center justify-between gap-3 rounded-lg border border-black/10 bg-white px-3 text-left xl:flex">
<p className="truncate text-[10px] font-apple-black text-[#687477]">{metric.label}</p>
<p className="shrink-0 text-sm font-apple-black text-[#101719]">{metric.value}</p>
</div>
))}
<div className="hidden h-10 items-center gap-3 px-1 text-[11px] font-apple-black text-[#687477] xl:flex">
{metrics.slice(0, 2).map((metric, index) => (
<span key={metric.label} className="flex items-center gap-1.5">
{index > 0 ? <span className="h-3 w-px bg-black/10" /> : null}
<span>{metric.label}</span>
<span className="text-[#101719]">{metric.value}</span>
</span>
))}
</div>
<Button
variant="filled"
size="small"
@ -1850,52 +1906,60 @@ export default function MaterialLibraryClient({
</div>
</div>
) : (
<div className="space-y-4">
<div className="bg-white p-4">
{activeWorkbenchSubstrate ? (
<div className="flex items-center justify-between gap-4">
<div className="grid h-full min-h-0 grid-rows-[auto_minmax(0,1fr)] gap-4">
{activeWorkbenchSubstrate ? (
<div className="grid min-h-[188px] grid-cols-[minmax(220px,0.34fr)_minmax(0,1fr)] gap-4">
<SubstrateSlabPreview substrate={activeWorkbenchSubstrate} />
<div className="flex min-w-0 flex-col justify-between rounded-md bg-white p-4 ring-1 ring-black/[0.06]">
<div className="min-w-0">
<p className="text-[9px] font-apple-black text-[#66777c]">当前基材</p>
<h2 className="mt-1 truncate text-base font-apple-black tracking-tight text-[#101719]">
<h2 className="mt-1 truncate text-xl font-apple-black tracking-tight text-[#101719]">
{activeWorkbenchSubstrate.name}
</h2>
<p className="mt-2 truncate text-[11px] font-apple-bold text-[#687477]">
{[activeWorkbenchSubstrate.categoryName, activeWorkbenchSubstrate.environmentGradeName, activeWorkbenchSubstrate.surfaceOptionName]
.filter(Boolean)
.join(' / ')}
</p>
</div>
<div className="grid min-w-[520px] shrink-0 grid-cols-4 gap-2">
<div className="mt-4 grid grid-cols-4 divide-x divide-black/[0.06] border-y border-black/[0.06]">
{[
['编码', activeWorkbenchSubstrate.code],
['尺寸', `${activeWorkbenchSubstrate.sheetLengthMm} × ${activeWorkbenchSubstrate.sheetWidthMm}`],
['厚度', activeWorkbenchSubstrate.thicknessSpecName],
['花色', activeWorkbenchSubstrate.finishes[0]?.name ?? '未绑定'],
].map(([label, value]) => (
<div key={label} className="min-w-0 rounded-lg bg-black/[0.035] px-3 py-2 ring-1 ring-black/5">
<div key={label} className="min-w-0 px-3 py-2.5 first:pl-0 last:pr-0">
<p className="text-[8px] font-apple-black text-[#7f9297]">{label}</p>
<p className="mt-0.5 truncate text-xs font-apple-black text-[#101719]">{value}</p>
</div>
))}
</div>
</div>
) : (
<div className="flex items-center justify-center gap-3 py-4 text-[#8b9ca0]">
<Layers className="h-5 w-5 text-[#536267]" />
<p className="text-sm font-apple-bold">暂无基材数据</p>
</div>
)}
</div>
<div className="bg-white p-4">
<div className="mb-4 flex items-center justify-between">
<div>
<p className="text-[9px] font-apple-black text-[#66777c]">基材列表</p>
<h3 className="mt-1 text-sm font-apple-black text-[#101719]">共 {filteredSubstrates.length}</h3>
</div>
</div>
<div className="space-y-2">
) : (
<div className="flex items-center justify-center gap-3 rounded-md bg-white py-8 text-[#8b9ca0] ring-1 ring-black/[0.06]">
<Layers className="h-5 w-5 text-[#536267]" />
<p className="text-sm font-apple-bold">暂无基材数据</p>
</div>
)}
<div className="min-h-0 overflow-hidden rounded-md bg-white ring-1 ring-black/[0.06]">
<div className="grid grid-cols-[minmax(240px,1.7fr)_120px_112px_112px_132px] items-center border-b border-black/[0.06] px-4 py-3 text-[9px] font-apple-black text-[#66777c]">
<span>基材</span>
<span>类别</span>
<span>厚度</span>
<span>成本</span>
<span className="text-right">状态</span>
</div>
<div className="max-h-full min-h-0 overflow-y-auto divide-y divide-black/[0.055]">
{filteredSubstrates.map((substrate) => {
const missingPrice = substrate.suggestedPrice == null
const missingWeight = substrate.weightKg == null
const missingRequiredData = missingPrice || missingWeight
const selected =
(panel.mode === 'view-substrate' || panel.mode === 'edit-substrate') &&
panel.id === substrate.id
const finish = substrate.finishes[0] ?? null
return (
<button
@ -1903,39 +1967,43 @@ export default function MaterialLibraryClient({
type="button"
onClick={() => setPanel({ mode: 'view-substrate', id: substrate.id })}
className={cn(
'ml-substrate-register-row flex w-full items-center justify-between rounded-lg border px-4 py-3 text-left transition',
missingRequiredData ? 'ml-substrate-register-row-missing' : null,
selected ? 'ml-substrate-register-row-selected' : null,
'grid w-full grid-cols-[minmax(240px,1.7fr)_120px_112px_112px_132px] items-center gap-0 px-4 py-3 text-left transition hover:bg-black/[0.025]',
selected ? 'bg-black/[0.035] shadow-[inset_2px_0_0_#101719]' : null,
)}
>
<div className="min-w-0">
<p className="text-sm font-apple-black text-[#101719]">{substrate.name}</p>
<p className="mt-1 text-[10px] font-apple-black text-[#66777c]">{substrate.code}</p>
<p className="mt-1.5 truncate text-[11px] font-apple-bold text-[#8b9ca0]">
{[substrate.categoryName, substrate.environmentGradeName, substrate.thicknessSpecName, substrate.surfaceOptionName]
.filter(Boolean)
.join(' / ')}
</p>
</div>
<div className="ml-4 flex shrink-0 items-end gap-2 text-xs font-apple-bold">
<span className={cn(
'ml-substrate-register-badge rounded-md px-2 py-0.5',
missingPrice ? 'ml-substrate-register-badge-warning' : 'ml-substrate-register-badge-ok',
)}>
{missingPrice ? '缺价格' : substrate.suggestedPrice}
<span className="flex min-w-0 items-center gap-3">
<span
className="h-8 w-8 shrink-0 rounded bg-cover bg-center ring-1 ring-black/[0.06]"
style={{
backgroundColor: finish?.color ?? '#d8ddde',
backgroundImage: finish?.textureUrl ? `url(${finish.textureUrl})` : undefined,
}}
/>
<span className="min-w-0">
<span className="block truncate text-sm font-apple-black text-[#101719]">{substrate.name}</span>
<span className="mt-0.5 block truncate text-[10px] font-apple-black text-[#66777c]">{substrate.code}</span>
</span>
<span className={cn(
'ml-substrate-register-badge rounded-md px-2 py-0.5 text-[10px]',
missingWeight ? 'ml-substrate-register-badge-warning' : 'ml-substrate-register-badge-muted',
)}>
{missingWeight ? '缺重量' : `${substrate.weightKg}kg`}
</span>
</div>
</span>
<span className="truncate text-xs font-apple-bold text-[#4f5c5f]">{substrate.categoryName}</span>
<span className="truncate text-xs font-apple-black text-[#263033]">{substrate.thicknessSpecName}</span>
<span className="truncate text-xs font-apple-black text-[#263033]">
{missingPrice ? '缺价格' : substrate.suggestedPrice}
</span>
<span className="flex justify-end gap-1.5">
{missingPrice ? (
<span className="rounded-md bg-[#a44640]/10 px-2 py-0.5 text-[10px] font-apple-black text-[#a44640]">缺价</span>
) : null}
{missingWeight ? (
<span className="rounded-md bg-[#a44640]/10 px-2 py-0.5 text-[10px] font-apple-black text-[#a44640]">缺重</span>
) : (
<span className="rounded-md bg-black/[0.04] px-2 py-0.5 text-[10px] font-apple-black text-[#66777c]">{substrate.weightKg}kg</span>
)}
</span>
</button>
)
})}
{filteredSubstrates.length === 0 ? (
<p className="rounded-lg border border-dashed border-black/10 p-16 text-center text-sm font-apple-bold text-[#7f9297]">当前没有匹配的基材。</p>
<p className="p-16 text-center text-sm font-apple-bold text-[#7f9297]">当前没有匹配的基材。</p>
) : null}
</div>
</div>

View File

@ -97,7 +97,7 @@ function createFamily(overrides?: Partial<AssetFamilyDTO>): AssetFamilyDTO {
description: null,
productCategoryId: null,
productCategoryName: null,
applicableSeriesCodes: [],
compatibleSeries: [{ id: 'template-1', code: 'single-board-desk', name: '单板桌' }],
connectionRoles: [],
metadata: {
categoryLevel1: 'outer',

View File

@ -69,7 +69,7 @@ function createFamily(overrides?: Partial<AssetFamilyDTO>): AssetFamilyDTO {
description: null,
productCategoryId: null,
productCategoryName: null,
applicableSeriesCodes: [],
compatibleSeries: [{ id: 'template-1', code: 'single-board-desk', name: '单板桌' }],
connectionRoles: [],
metadata: {
categoryLevel1: 'outer',

View File

@ -1,6 +1,6 @@
import { Layers3, Package, Palette, Ruler } from 'lucide-react'
import { requireSession } from '@/lib/server-session'
import { assetCenterService, materialService, seriesTemplateService } from '@/server/services'
import { assetCenterService, materialService } from '@/server/services'
import AssetCenterClient from './AssetCenterClient'
export const dynamic = 'force-dynamic'
@ -19,7 +19,7 @@ export default async function AssetCenterPage() {
])
const finishes = await materialService.findAll({ limit: 200 })
const materialCategories = await materialService.findAllCategories()
const seriesTemplates = await seriesTemplateService.listTemplates()
const assetSeries = await assetCenterService.listSeries()
return (
<AssetCenterClient
@ -27,7 +27,7 @@ export default async function AssetCenterPage() {
initialVariants={variants}
initialSubstrates={substrates}
initialSubstrateDictionaries={substrateDictionaries}
initialSeriesTemplates={seriesTemplates}
initialAssetSeries={assetSeries}
finishes={finishes}
materialCategories={materialCategories}
metrics={[

View File

@ -2,7 +2,11 @@ import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import type { ReactNode } from 'react'
import AssistedAssemblyClient from './AssistedAssemblyClient'
import { createDraftGroup } from '@/lib/open-product/platform-api'
import {
createDraftGroup,
listAssetFamilies,
listAssetVariantsByFamilyIds,
} from '@/lib/open-product/platform-api'
import type { AssemblyWorkspaceSnapshot } from '@/lib/open-product/assembly-workspace'
let mockSearchParams = new URLSearchParams()
@ -23,10 +27,16 @@ jest.mock('@/components/platform/AssemblyWorkbenchV2', () => ({
__esModule: true,
AssemblyWorkbenchV2: ({
value,
availableVariants,
selectedAssetSeriesId,
onAssetSeriesSelect,
onChange,
rightDrawerContent,
}: {
value: AssemblyWorkspaceSnapshot
availableVariants?: Array<{ id: string }>
selectedAssetSeriesId?: string | null
onAssetSeriesSelect?: (assetSeriesId: string) => void
onChange: (next: AssemblyWorkspaceSnapshot) => void
rightDrawerContent?: ReactNode
}) => (
@ -35,7 +45,12 @@ jest.mock('@/components/platform/AssemblyWorkbenchV2', () => ({
<div>辅助拼装工作台</div>
<div>库存:{value.inventory.length}</div>
<div>节点:{value.nodes.length}</div>
<div>当前系列:{selectedAssetSeriesId ?? 'none'}</div>
<div>可用规格:{availableVariants?.length ?? 0}</div>
<div>首个规格款:{value.inventory[0]?.selectedVariantId ?? 'none'}</div>
<button type="button" onClick={() => onAssetSeriesSelect?.('series-a')}>
模拟点击选择系列
</button>
<button
type="button"
onClick={() =>
@ -89,12 +104,17 @@ jest.mock('@/components/platform/AssemblyWorkbenchV2', () => ({
jest.mock('@/lib/open-product/platform-api', () => ({
__esModule: true,
createDraftGroup: jest.fn(),
listAssetFamilies: jest.fn(),
listAssetSeries: jest.fn(),
listAssetVariantsByFamilyIds: jest.fn(),
}))
describe('AssistedAssemblyClient', () => {
beforeEach(() => {
push.mockReset()
jest.mocked(createDraftGroup).mockReset()
jest.mocked(listAssetFamilies).mockReset()
jest.mocked(listAssetVariantsByFamilyIds).mockReset()
mockSearchParams = new URLSearchParams()
})
@ -214,7 +234,7 @@ describe('AssistedAssemblyClient', () => {
}),
)
})
expect(jest.mocked(createDraftGroup).mock.calls[0]?.[0]).not.toHaveProperty('seriesTemplateId')
expect(jest.mocked(createDraftGroup).mock.calls[0]?.[0]).toHaveProperty('assetSeriesId', null)
})
it('资产中心带料进入时,会优先保留来源资产族的草稿规格款作为默认选择', async () => {
@ -258,10 +278,84 @@ describe('AssistedAssemblyClient', () => {
await waitFor(() => {
expect(screen.getByText('辅助拼装工作台')).toBeInTheDocument()
expect(screen.getByText('库存:1')).toBeInTheDocument()
expect(screen.getByText('节点:1')).toBeInTheDocument()
expect(screen.getByText('首个规格款:variant-board-draft')).toBeInTheDocument()
})
})
it('直接进入时选择系列后,会按系列重新加载物料池和规格款', async () => {
const actor = userEvent.setup()
jest.mocked(listAssetFamilies).mockResolvedValue([
{
id: 'family-board-series',
type: 'BOARD',
code: 'BOARD-SERIES',
name: '系列桌面',
status: 'AVAILABLE',
productCategoryId: 'category-desk',
productCategoryName: '书桌',
} as never,
{
id: 'family-hardware-global',
type: 'HARDWARE',
code: 'HW-GLOBAL',
name: '通用五金',
status: 'AVAILABLE',
productCategoryId: null,
productCategoryName: null,
} as never,
])
jest.mocked(listAssetVariantsByFamilyIds).mockResolvedValue([
{
id: 'variant-board-series',
assetFamilyId: 'family-board-series',
assetType: 'BOARD',
code: 'BOARD-SERIES-001',
name: '系列桌面规格',
status: 'AVAILABLE',
modelUrl: '/board-series.glb',
thumbnailUrl: null,
dimensions: {},
mountPoints: [],
assemblyBasis: null,
compatibilityRules: null,
} as never,
])
render(
<AssistedAssemblyClient
assetSeries={[
{
id: 'series-a',
code: 'series-a',
name: '系列A',
description: null,
sortOrder: 0,
isActive: true,
} as never,
]}
families={[]}
variants={[]}
metrics={[]}
/>,
)
expect(screen.getByText('库存:0')).toBeInTheDocument()
await actor.click(screen.getByRole('button', { name: '模拟点击选择系列' }))
await waitFor(() => {
expect(listAssetFamilies).toHaveBeenCalledWith({ assetSeriesId: 'series-a' })
expect(listAssetVariantsByFamilyIds).toHaveBeenCalledWith([
'family-board-series',
'family-hardware-global',
])
expect(screen.getByText('当前系列:series-a')).toBeInTheDocument()
expect(screen.getByText('库存:2')).toBeInTheDocument()
expect(screen.getByText('可用规格:1')).toBeInTheDocument()
})
})
it('源 SKU 新增进入时,会把来源资产族排在物料池前面,同时保留可选资产', async () => {
mockSearchParams = new URLSearchParams(
'entryMode=source-sku&source=assisted-assembly&sourceSkuId=sku-source-1&desktopFamilyIds=family-board-source',

View File

@ -1,6 +1,6 @@
'use client'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import Link from 'next/link'
import { useRouter, useSearchParams } from 'next/navigation'
import {
@ -18,11 +18,21 @@ import { cn } from '@/lib/utils'
import type { PlatformMetricItem } from '@/components/platform/types'
import { createDraftGroup } from '@/lib/open-product/platform-api'
import {
listAssetFamilies,
listAssetSeries,
listAssetVariantsByFamilyIds,
} from '@/lib/open-product/platform-api'
import type { AssistedAssemblyVariant } from '@/lib/open-product/assisted-assembly-variant'
import { applyAssetVariantUvConfigToMaterial } from '@/lib/asset-variant-render-config'
import { toMaterialConfig } from '@/lib/material-config'
import { normalizeAssetVariantWithUv } from '@/lib/open-product/asset-variant-uv'
import {
buildAssemblyWorkspaceFamilies,
deriveAssemblySaveValidation,
instantiateAssemblyNode,
syncManualAssemblyWorkspace,
updateInventoryVariantSelection,
type AssemblyWorkspaceSnapshot,
} from '@/lib/open-product/assembly-workspace'
import {
@ -30,7 +40,9 @@ import {
parseManualAssemblyLaunchParams,
type ManualAssemblyLaunchParams,
} from '@/lib/open-product/manual-assembly-launch'
import type { AssetFamilyDTO } from '@/server/services'
import type { AssetFamilyDTO, AssetSeriesDTO, AssetVariantDTO, MaterialDTO } from '@/server/services'
const EMPTY_ASSET_SERIES: AssetSeriesDTO[] = []
function hasSelectedFamilyIds(ids: {
desktopFamilyIds: string[]
@ -68,6 +80,45 @@ function orderedUnique(...lists: string[][]) {
return Array.from(new Set(lists.flat().filter(Boolean)))
}
function toRuntimeAssistedAssemblyVariant(rawVariant: AssetVariantDTO): AssistedAssemblyVariant {
const variant = normalizeAssetVariantWithUv(rawVariant)
const finishMaterialConfig = variant.finish
? toMaterialConfig(variant.finish as unknown as MaterialDTO)
: null
const materialConfig = applyAssetVariantUvConfigToMaterial(
finishMaterialConfig,
variant.uvConfig,
)
return {
...variant,
materialConfig,
}
}
function toOpenProductVariant(variant: AssistedAssemblyVariant) {
return {
id: variant.id,
assetFamilyId: variant.assetFamilyId,
code: variant.code,
name: variant.name,
assetType: variant.assetType,
status: variant.status,
modelUrl: variant.modelUrl,
thumbnailUrl: variant.thumbnailUrl,
dimensions: variant.dimensions,
mountPoints: variant.mountPoints,
substrateId: variant.substrateId,
finishId: variant.finishId,
uvConfig: variant.uvConfig,
weightKg: variant.weightKg,
costPrice: variant.costPrice,
assemblyBasis: variant.assemblyBasis,
compatibilityRules: variant.compatibilityRules,
materialConfig: variant.materialConfig,
}
}
function resolveInitialFamilyIds(input: {
launchParams: ManualAssemblyLaunchParams | null
initialWorkspace: AssemblyWorkspaceSnapshot | null
@ -129,8 +180,56 @@ function getFamilySelectionForSave(
}
}
function getLaunchSelectedFamilyIds(launchParams: ManualAssemblyLaunchParams | null) {
return orderedUnique(
launchParams?.selectedFamilyIdsByType.desktopFamilyIds ?? [],
launchParams?.selectedFamilyIdsByType.legFamilyIds ?? [],
launchParams?.selectedFamilyIdsByType.connectorFamilyIds ?? [],
)
}
function instantiateLaunchSelectedFamilies(input: {
workspace: AssemblyWorkspaceSnapshot
launchParams: ManualAssemblyLaunchParams | null
variantsByFamilyId: Map<string, AssistedAssemblyVariant[]>
}) {
if (input.launchParams?.entryMode !== 'asset-center') {
return input.workspace
}
return getLaunchSelectedFamilyIds(input.launchParams).reduce<AssemblyWorkspaceSnapshot>(
(snapshot, familyId) => {
if (snapshot.nodes.some((node) => node.assetFamilyId === familyId)) {
return snapshot
}
const inventoryItem = snapshot.inventory.find((item) => item.familyId === familyId)
const variant =
input.variantsByFamilyId.get(familyId)?.find((item) => item.status === 'AVAILABLE') ??
input.variantsByFamilyId.get(familyId)?.[0] ??
null
if (!inventoryItem || !variant) {
return snapshot
}
const withSelection = updateInventoryVariantSelection(
snapshot,
inventoryItem.inventoryId,
variant.id,
)
return instantiateAssemblyNode(withSelection, {
inventoryId: inventoryItem.inventoryId,
variant: toOpenProductVariant(variant),
})
},
input.workspace,
)
}
export default function AssistedAssemblyClient({
templates: _templates = [],
assetSeries = EMPTY_ASSET_SERIES,
families,
variants,
metrics,
@ -139,6 +238,7 @@ export default function AssistedAssemblyClient({
initialFeedback = null,
}: {
templates?: unknown[]
assetSeries?: AssetSeriesDTO[]
families: AssetFamilyDTO[]
variants: AssistedAssemblyVariant[]
metrics: PlatformMetricItem[]
@ -154,29 +254,58 @@ export default function AssistedAssemblyClient({
)
const launchParams = initialLaunchParams ?? parsedLaunchParams
const entryMode = launchParams?.entryMode ?? 'direct'
const initialAssetSeriesId = launchParams?.assetSeriesId ?? initialWorkspace?.assetSeriesId ?? null
const initialSeriesIdRef = useRef(initialAssetSeriesId)
const [selectedAssetSeriesId, setSelectedAssetSeriesId] = useState<string | null>(
initialAssetSeriesId,
)
const [availableAssetSeries, setAvailableAssetSeries] = useState(assetSeries)
const [materialFamilies, setMaterialFamilies] = useState(families)
const [materialVariants, setMaterialVariants] = useState(variants)
const [materialLoading, setMaterialLoading] = useState(false)
const [materialLoadError, setMaterialLoadError] = useState<string | null>(null)
const desktopFamilies = useMemo(() => families.filter((family) => family.type === 'BOARD'), [families])
const legFamilies = useMemo(() => families.filter((family) => family.type === 'COMPONENT'), [families])
const connectorFamilies = useMemo(() => families.filter((family) => family.type === 'HARDWARE'), [families])
const desktopFamilies = useMemo(
() => materialFamilies.filter((family) => family.type === 'BOARD'),
[materialFamilies],
)
const legFamilies = useMemo(
() => materialFamilies.filter((family) => family.type === 'COMPONENT'),
[materialFamilies],
)
const connectorFamilies = useMemo(
() => materialFamilies.filter((family) => family.type === 'HARDWARE'),
[materialFamilies],
)
const defaultVariantIdsByFamilyId = useMemo(() => {
const map = new Map<string, string | null>()
variants.forEach((variant) => {
materialVariants.forEach((variant) => {
if (!map.has(variant.assetFamilyId)) {
map.set(variant.assetFamilyId, variant.id)
}
})
return map
}, [variants])
}, [materialVariants])
const availableVariantMap = useMemo(() => {
const map = new Map<string, AssistedAssemblyVariant[]>()
materialVariants.forEach((variant) => {
const current = map.get(variant.assetFamilyId) ?? []
current.push(variant)
map.set(variant.assetFamilyId, current)
})
return map
}, [materialVariants])
const initialFamilyIds = useMemo(
() =>
resolveInitialFamilyIds({
launchParams,
initialWorkspace,
connectorFamilies,
desktopFamilies,
legFamilies,
connectorFamilies,
}),
[connectorFamilies, desktopFamilies, initialWorkspace, launchParams, legFamilies],
)
@ -190,14 +319,15 @@ export default function AssistedAssemblyClient({
const buildWorkspaceFromSelection = useCallback(
(snapshot: AssemblyWorkspaceSnapshot | null) => {
const selectedIdSet = new Set([...desktopIds, ...legIds, ...connectorIds])
const selectedFamilies = families.filter((family) => selectedIdSet.has(family.id))
const selectedFamilies = materialFamilies.filter((family) => selectedIdSet.has(family.id))
const nextFamilies = buildAssemblyWorkspaceFamilies(
selectedFamilies.map((family) => ({ ...family, assetType: family.type })),
defaultVariantIdsByFamilyId,
)
const firstCategoryFamily = selectedFamilies.find((family) => family.productCategoryId)
return syncManualAssemblyWorkspace(snapshot, {
const synced = syncManualAssemblyWorkspace(snapshot, {
assetSeriesId: selectedAssetSeriesId,
productCategoryId: initialWorkspace?.productCategoryId ?? firstCategoryFamily?.productCategoryId ?? null,
productCategoryName:
initialWorkspace?.productCategoryName ?? firstCategoryFamily?.productCategoryName ?? null,
@ -207,23 +337,92 @@ export default function AssistedAssemblyClient({
sourceProductId: launchParams?.sourceProductId ?? initialWorkspace?.sourceProductId ?? null,
families: nextFamilies,
})
return instantiateLaunchSelectedFamilies({
workspace: synced,
launchParams,
variantsByFamilyId: availableVariantMap,
})
},
[
availableVariantMap,
connectorIds,
defaultVariantIdsByFamilyId,
desktopIds,
entryMode,
families,
initialWorkspace,
launchParams,
launchParams?.sourceProductId,
launchParams?.sourceSkuId,
legIds,
materialFamilies,
selectedAssetSeriesId,
],
)
const [workspace, setWorkspace] = useState<AssemblyWorkspaceSnapshot>(() =>
buildWorkspaceFromSelection(initialWorkspace),
)
const materialLoadSeqRef = useRef(0)
useEffect(() => {
setAvailableAssetSeries(assetSeries)
}, [assetSeries])
useEffect(() => {
if (initialSeriesIdRef.current === selectedAssetSeriesId) {
initialSeriesIdRef.current = null
return
}
if (!selectedAssetSeriesId) {
setMaterialFamilies([])
setMaterialVariants([])
setMaterialLoadError(null)
return
}
const seq = materialLoadSeqRef.current + 1
materialLoadSeqRef.current = seq
setMaterialLoading(true)
setMaterialLoadError(null)
void (async () => {
try {
if (availableAssetSeries.length === 0) {
setAvailableAssetSeries(await listAssetSeries())
}
const nextFamilies = (await listAssetFamilies({ assetSeriesId: selectedAssetSeriesId }))
.filter((family) => family.status !== 'DISABLED')
const nextVariants = await listAssetVariantsByFamilyIds(
nextFamilies.map((family) => family.id),
)
if (materialLoadSeqRef.current !== seq) {
return
}
setMaterialFamilies(nextFamilies)
setMaterialVariants(
nextVariants
.filter((variant) => variant.status === 'AVAILABLE')
.map(toRuntimeAssistedAssemblyVariant),
)
} catch (error) {
if (materialLoadSeqRef.current !== seq) {
return
}
setMaterialLoadError(error instanceof Error ? error.message : '物料加载失败')
setMaterialFamilies([])
setMaterialVariants([])
} finally {
if (materialLoadSeqRef.current === seq) {
setMaterialLoading(false)
}
}
})()
}, [availableAssetSeries.length, selectedAssetSeriesId])
useEffect(() => {
setWorkspace((current) => buildWorkspaceFromSelection(current))
@ -263,6 +462,7 @@ export default function AssistedAssemblyClient({
const result = await createDraftGroup({
name: draftGroupName,
categoryId: workspace.productCategoryId,
assetSeriesId: workspace.assetSeriesId ?? null,
assemblies: [
{
key: `${entryMode}:${desktopIds.join(',')}`,
@ -296,7 +496,13 @@ export default function AssistedAssemblyClient({
candidateName={draftGroupName}
statusTag="Manual Mode"
warnings={saveValidation.blockingIssues}
availableVariants={variants}
availableVariants={materialVariants}
assetSeriesOptions={availableAssetSeries}
selectedAssetSeriesId={selectedAssetSeriesId}
isAssetSeriesLoading={materialLoading}
assetSeriesError={materialLoadError}
requireAssetSeriesSelection={!selectedAssetSeriesId && entryMode === 'direct'}
onAssetSeriesSelect={setSelectedAssetSeriesId}
onChange={setWorkspace}
className="h-full w-full"
/>

View File

@ -28,9 +28,12 @@ import {
type ManualAssemblySelectedFamilyIdsByType,
} from '@/lib/open-product/manual-assembly-launch'
import { assetCenterService, materialService } from '@/server/services'
import type { AssetFamilyDTO } from '@/server/services/asset-center.service.types'
import { normalizeAssemblyWorkspace, normalizeVariantSelection } from '@/server/services/draft-group.mapper'
import AssistedAssemblyClient from './AssistedAssemblyClient'
type AssistedAssemblySearchParams = Record<string, string | string[] | undefined>
function normalizeFamilyIds(
value: string | string[] | undefined,
) {
@ -41,6 +44,39 @@ function normalizeFamilyIds(
.filter(Boolean)
}
function buildLaunchSelectedFamilyIdSet(
params: AssistedAssemblySearchParams,
launchParams: ManualAssemblyLaunchParams | null,
) {
return new Set([
...normalizeFamilyIds(params.desktopFamilyIds),
...normalizeFamilyIds(params.legFamilyIds),
...normalizeFamilyIds(params.connectorFamilyIds),
...(launchParams?.selectedFamilyIdsByType.desktopFamilyIds ?? []),
...(launchParams?.selectedFamilyIdsByType.legFamilyIds ?? []),
...(launchParams?.selectedFamilyIdsByType.connectorFamilyIds ?? []),
])
}
function mergeFamiliesById(
baseFamilies: AssetFamilyDTO[],
extraFamilies: AssetFamilyDTO[],
) {
const merged = [...baseFamilies]
const existingIds = new Set(merged.map((family) => family.id))
extraFamilies.forEach((family) => {
if (existingIds.has(family.id)) {
return
}
existingIds.add(family.id)
merged.push(family)
})
return merged
}
function inferSourceFromAssetType(assetType: string): 'desktop' | 'leg' | 'connector' {
if (assetType === 'BOARD') {
return 'desktop'
@ -223,6 +259,7 @@ function buildSourceSkuWorkspaceFallback(input: {
})
const baseSnapshot = buildManualAssemblyWorkspace({
assetSeriesId: null,
productCategoryId: null,
productCategoryName: null,
source: getAssemblyWorkspaceSourceFromEntryMode('source-sku'),
@ -237,7 +274,6 @@ function buildSourceSkuWorkspaceFallback(input: {
assetType: family.type,
connectionRoles: family.connectionRoles,
productCategoryId: family.productCategoryId,
applicableSeriesCodes: family.applicableSeriesCodes,
})),
defaultVariantIdsByFamilyId,
),
@ -462,6 +498,7 @@ async function resolveSourceSkuContext(input: {
launchParams: {
entryMode: 'source-sku' as const,
source: 'assisted-assembly' as const,
assetSeriesId: null,
sourceSkuId: sku.id,
sourceProductId: sku.productId,
redirectedFrom: input.redirectedFrom ?? null,
@ -479,7 +516,7 @@ export default async function AssistedAssemblyPage({
searchParams,
}: {
searchParams?: Promise<Record<string, string | string[] | undefined>>
}) {
} = {}) {
const { authUser } = await requireSession('/login')
const headerStore = await headers()
const requestOrigin = resolveRequestOrigin(headerStore)
@ -493,11 +530,25 @@ export default async function AssistedAssemblyPage({
return value ?? null
},
})
const launchAssetSeriesId = rawLaunchParams?.assetSeriesId ?? null
const rawSelectedFamilyIds = buildLaunchSelectedFamilyIdSet(params, rawLaunchParams)
const shouldWaitForManualSeriesSelection =
!launchAssetSeriesId &&
rawLaunchParams?.entryMode !== 'source-sku' &&
rawSelectedFamilyIds.size === 0
const familyListPromise = launchAssetSeriesId
? assetCenterService.listFamilies({ assetSeriesId: launchAssetSeriesId })
: shouldWaitForManualSeriesSelection
? Promise.resolve([])
: assetCenterService.listFamilies()
const [families, finishes] = await Promise.all([
assetCenterService.listFamilies(),
const [assetSeries, scopedFamilies, finishes] = await Promise.all([
assetCenterService.listSeries(),
familyListPromise,
materialService.findAll({ limit: 500 }),
])
let families = scopedFamilies
let allFamiliesForLaunchContext: AssetFamilyDTO[] | null = null
let launchParams = rawLaunchParams
let initialWorkspace: AssemblyWorkspaceSnapshot | null = null
let initialFeedback: {
@ -506,31 +557,45 @@ export default async function AssistedAssemblyPage({
} | null = buildManualAssemblyLegacyFeedback(rawLaunchParams)
if (rawLaunchParams?.entryMode === 'source-sku' && rawLaunchParams.sourceSkuId) {
if (launchAssetSeriesId) {
allFamiliesForLaunchContext = await assetCenterService.listFamilies()
}
const sourceSkuContext = await resolveSourceSkuContext({
sourceSkuId: rawLaunchParams.sourceSkuId,
redirectedFrom: rawLaunchParams.redirectedFrom,
authUser,
families,
families: allFamiliesForLaunchContext ?? families,
})
launchParams = sourceSkuContext.launchParams
initialWorkspace = sourceSkuContext.initialWorkspace
initialFeedback = sourceSkuContext.feedback ?? initialFeedback
}
const launchSelectedFamilyIds = new Set([
...normalizeFamilyIds(params.desktopFamilyIds),
...normalizeFamilyIds(params.legFamilyIds),
...normalizeFamilyIds(params.connectorFamilyIds),
...(launchParams?.selectedFamilyIdsByType.desktopFamilyIds ?? []),
...(launchParams?.selectedFamilyIdsByType.legFamilyIds ?? []),
...(launchParams?.selectedFamilyIdsByType.connectorFamilyIds ?? []),
])
const launchSelectedFamilyIds = buildLaunchSelectedFamilyIdSet(params, launchParams)
if (launchAssetSeriesId && launchSelectedFamilyIds.size > 0) {
const scopedFamilyIds = new Set(families.map((family) => family.id))
const missingSelectedFamilyIds = Array.from(launchSelectedFamilyIds).filter(
(familyId) => !scopedFamilyIds.has(familyId),
)
if (missingSelectedFamilyIds.length > 0) {
allFamiliesForLaunchContext ??= await assetCenterService.listFamilies()
const selectedFallbackFamilies = allFamiliesForLaunchContext.filter((family) =>
missingSelectedFamilyIds.includes(family.id),
)
families = mergeFamiliesById(families, selectedFallbackFamilies)
}
}
const availableFamilies = families.filter(
(family) => family.status !== 'DISABLED' || launchSelectedFamilyIds.has(family.id),
)
const variants = await assetCenterService.listVariantsByFamilyIds(
availableFamilies.map((family) => family.id),
)
const availableFamilyIds = availableFamilies.map((family) => family.id)
const variants =
availableFamilyIds.length > 0
? await assetCenterService.listVariantsByFamilyIds(availableFamilyIds)
: []
const sourceVariantIdSet = new Set(initialWorkspace?.nodes.map((node) => node.variantId) ?? [])
const selectableVariants = variants.filter(
(variant) =>
@ -605,6 +670,7 @@ export default async function AssistedAssemblyPage({
return (
<AssistedAssemblyClient
templates={[]}
assetSeries={assetSeries}
families={availableFamilies}
variants={runtimeSelectableVariants}
metrics={metrics}

View File

@ -95,7 +95,7 @@ export default function HomeDraftsList({
{getDraftStatusLabel(draftGroup.status)}
</span>
<span className="text-[11px] font-semibold text-[#5a6062]/58">
{draftGroup.categoryName || draftGroup.seriesTemplateName}
{draftGroup.categoryName || draftGroup.assetSeriesName}
</span>
</div>
<p className="mt-1 truncate text-[15px] font-black leading-5 tracking-tight text-[#2d3335]">

View File

@ -44,7 +44,7 @@ export interface DraftGroupSummary {
name: string
description?: string | null
categoryName?: string | null
seriesTemplateName: string
assetSeriesName: string
status: string
summary?: Record<string, unknown> | null
creatorName: string

View File

@ -256,7 +256,7 @@ function renderSelector(props?: {
{
id: 'draft-group-1',
name: 'Draft Group',
seriesTemplateName: 'Single board desk',
assetSeriesName: 'Single board desk',
code: 'DG-001',
},
]
@ -339,7 +339,7 @@ function renderEditor() {
id: 'draft-group-1',
name: 'Draft Group',
categoryId: 'category-desk',
seriesTemplateName: 'Single board desk',
assetSeriesName: 'Single board desk',
categoryName: 'Desk',
code: 'DG-001',
},
@ -1725,7 +1725,10 @@ describe('IntakeClient', () => {
const actor = userEvent.setup()
renderSelector({ eligibleSkuList: [] })
await actor.click(screen.getByRole('button', { name: '系列产品开发明细' }))
const rightPanel = screen.getByTestId('right-panel')
await actor.click(
within(rightPanel).getByRole('button', { name: '系列产品开发明细' }),
)
await actor.click(screen.getByRole('button', { name: '新增产品' }))
expect(screen.getByRole('tab', { name: '新增系列产品' })).toHaveAttribute(

View File

@ -126,7 +126,7 @@ interface IntakeClientSelectorProps {
eligibleDraftGroups: {
id: string;
name: string;
seriesTemplateName: string;
assetSeriesName: string;
code: string;
}[];
eligibleSkuList?: WorkbenchSku[];
@ -1199,7 +1199,7 @@ function buildIntegratedSeriesRows(params: {
readPreSkuTextField(firstPreSku, "spu"))) ||
draftGroup.code ||
"SPU-001",
seriesName: draftGroup.seriesTemplateName || draftGroup.name,
seriesName: draftGroup.assetSeriesName || draftGroup.name,
draftGroupName: draftGroup.name,
designer: "",
operator: "",
@ -1222,7 +1222,7 @@ function buildIntegratedSeriesRows(params: {
const rows: IntegratedPendingSeriesRow[] = eligibleDraftGroups.map(
(draftGroup, index) => {
const bucketKey = `${draftGroup.name}::${draftGroup.seriesTemplateName}`;
const bucketKey = `${draftGroup.name}::${draftGroup.assetSeriesName}`;
const matchedSkus = skuBuckets.get(bucketKey) ?? [];
const firstSku = matchedSkus[0];
@ -1233,7 +1233,7 @@ function buildIntegratedSeriesRows(params: {
firstSku?.spuCode ||
draftGroup.code ||
`SPU-${String(index + 1).padStart(3, "0")}`,
seriesName: draftGroup.seriesTemplateName || draftGroup.name,
seriesName: draftGroup.assetSeriesName || draftGroup.name,
draftGroupName: draftGroup.name,
designer: "",
operator: "",
@ -1264,7 +1264,7 @@ function buildIntegratedSeriesRows(params: {
function getDraftGroupSeriesNameOptions(props: IntakeClientProps) {
if (isEditorMode(props)) {
return getUniqueNonEmptyValues([
props.detail.draftGroup.seriesTemplateName,
props.detail.draftGroup.assetSeriesName,
props.detail.draftGroup.name,
...props.detail.preSkus.map((preSku) =>
readPreSkuTextField(preSku, "seriesName"),
@ -1274,7 +1274,7 @@ function getDraftGroupSeriesNameOptions(props: IntakeClientProps) {
return getUniqueNonEmptyValues([
...props.eligibleDraftGroups.flatMap((draftGroup) => [
draftGroup.seriesTemplateName,
draftGroup.assetSeriesName,
draftGroup.name,
]),
...(props.eligibleSkuList ?? []).map((sku) => sku.seriesName),
@ -1300,7 +1300,7 @@ function getDraftGroupSeriesSkuOptions(props: IntakeClientProps): WorkbenchSku[]
name: preSku.customName || preSku.defaultName,
seriesName:
readPreSkuTextField(preSku, "seriesName") ||
props.detail.draftGroup.seriesTemplateName ||
props.detail.draftGroup.assetSeriesName ||
props.detail.draftGroup.name,
draftGroupName: props.detail.draftGroup.name,
status: mapStatus(preSku.status),

View File

@ -15,9 +15,9 @@ function buildDraftGroupDetail(
description: null,
categoryId: 'category-1',
categoryName: '书桌',
seriesTemplateId: 'series-template-1',
seriesTemplateCode: 'SERIES-001',
seriesTemplateName: '集成书桌系列',
assetSeriesId: 'series-template-1',
assetSeriesCode: 'SERIES-001',
assetSeriesName: '集成书桌系列',
status: 'DRAFT',
summary: null,
notes: null,

View File

@ -59,7 +59,7 @@ function readSeriesName(detail: DraftGroupDetailDTO) {
return familySeriesName.trim()
}
return detail.draftGroup.seriesTemplateName?.trim() || detail.draftGroup.name.trim()
return detail.draftGroup.assetSeriesName?.trim() || detail.draftGroup.name.trim()
}
function pickBaseSpu(detail: DraftGroupDetailDTO) {

View File

@ -13,9 +13,9 @@ function buildDraftGroup(id: string, status: DraftGroupDTO['status']): DraftGrou
description: null,
categoryId: 'category-1',
categoryName: '桌类',
seriesTemplateId: `series-template-${id}`,
seriesTemplateCode: `SERIES-${id}`,
seriesTemplateName: `系列-${id}`,
assetSeriesId: `series-template-${id}`,
assetSeriesCode: `SERIES-${id}`,
assetSeriesName: `系列-${id}`,
status,
summary: null,
notes: null,

View File

@ -116,7 +116,7 @@ export default async function IntakePage({
name: preSku.customName || preSku.defaultName,
seriesName:
readPreSkuTextField(preSku, 'seriesName') ||
detail.draftGroup.seriesTemplateName ||
detail.draftGroup.assetSeriesName ||
detail.draftGroup.name,
draftGroupName: detail.draftGroup.name,
status: mapWorkbenchStatus(preSku.status),

View File

@ -48,6 +48,7 @@ interface ResolvedHoleMarker {
localPosition: THREE.Vector3;
holeType: string | null;
hardwareMatched?: boolean;
snapLink?: HoleData["snapLink"];
}
interface ResolveStrictHoleMarkersOptions {
@ -72,6 +73,7 @@ interface ScreenHoleMarker {
holeId: string;
holeType: string | null;
hardwareMatched?: boolean;
snapLink?: HoleData["snapLink"];
x: number;
y: number;
}
@ -142,27 +144,27 @@ function getHoleMarkerPalette(marker: Pick<ScreenHoleMarker, "holeType" | "hardw
switch (marker.holeType) {
case "assembly-candidate":
return {
fill: "rgba(71, 100, 107, 0.30)",
stroke: "rgba(71, 100, 107, 0.95)",
inner: "rgba(245, 249, 249, 0.96)",
fill: "rgba(245, 184, 52, 0.34)",
stroke: "rgba(186, 124, 18, 0.96)",
inner: "rgba(255, 252, 242, 0.98)",
};
case "assembly-active":
return {
fill: "rgba(45, 51, 53, 0.28)",
stroke: "rgba(45, 51, 53, 0.98)",
inner: "rgba(247, 248, 248, 0.96)",
fill: "rgba(245, 184, 52, 0.34)",
stroke: "rgba(186, 124, 18, 0.96)",
inner: "rgba(255, 252, 242, 0.98)",
};
case "assembly-used":
return {
fill: "rgba(154, 129, 120, 0.28)",
stroke: "rgba(154, 129, 120, 0.86)",
inner: "rgba(255, 250, 247, 0.92)",
fill: "rgba(35, 116, 166, 0.30)",
stroke: "rgba(18, 89, 142, 0.96)",
inner: "rgba(241, 248, 252, 0.98)",
};
case "assembly-unused":
return {
fill: "rgba(143, 161, 166, 0.22)",
stroke: "rgba(143, 161, 166, 0.72)",
inner: "rgba(249, 251, 251, 0.90)",
fill: "rgba(48, 156, 97, 0.24)",
stroke: "rgba(28, 124, 75, 0.84)",
inner: "rgba(244, 252, 247, 0.94)",
};
default:
return marker.hardwareMatched
@ -179,6 +181,31 @@ function getHoleMarkerPalette(marker: Pick<ScreenHoleMarker, "holeType" | "hardw
}
}
function hashStringToUnit(value: string): number {
let hash = 2166136261;
for (let index = 0; index < value.length; index += 1) {
hash ^= value.charCodeAt(index);
hash = Math.imul(hash, 16777619);
}
return (hash >>> 0) / 4294967295;
}
function getSnapLinkPalette(state: NonNullable<HoleData["snapLink"]>["state"]) {
if (state === "used") {
return {
base: "rgba(18, 89, 142, 0.32)",
pulse: "rgba(18, 89, 142, 0.72)",
glow: "rgba(35, 116, 166, 0.14)",
};
}
return {
base: "rgba(186, 124, 18, 0.34)",
pulse: "rgba(245, 184, 52, 0.94)",
glow: "rgba(245, 184, 52, 0.18)",
};
}
function registerPanelAlias(
aliasMap: Map<string, string>,
alias: string,
@ -450,7 +477,14 @@ function resolvePanelObjectByHoles(options: {
let totalDistance = 0;
holes.forEach((hole) => {
const holeLocal = new THREE.Vector3(...hole.position);
const world = modelRoot.localToWorld(holeLocal.clone());
let world: THREE.Vector3;
if (hole.coordinateSpace === "object-local") {
world = anchor.localToWorld(holeLocal.clone());
} else if (hole.coordinateSpace === "node-local") {
world = candidate.localToWorld(holeLocal.clone());
} else {
world = modelRoot.localToWorld(holeLocal.clone());
}
totalDistance += originalBox.distanceToPoint(world);
});
@ -805,6 +839,7 @@ export function resolveStrictHoleMarkers({
const panelStateCache = new Map<
string,
| {
boundObject: THREE.Object3D;
targetObject: THREE.Object3D;
currentBox: THREE.Box3;
originalBox: THREE.Box3;
@ -819,6 +854,7 @@ export function resolveStrictHoleMarkers({
resolvedHolePanelId: string,
):
| {
boundObject: THREE.Object3D;
targetObject: THREE.Object3D;
currentBox: THREE.Box3;
originalBox: THREE.Box3;
@ -837,14 +873,15 @@ export function resolveStrictHoleMarkers({
return null;
}
const boundPanelObject = resolveAnimatedAnchorObject(rawBoundPanelObject);
const boundPanelBox = new THREE.Box3().setFromObject(boundPanelObject);
const boundPanelObject = rawBoundPanelObject;
const anchorPanelObject = resolveAnimatedAnchorObject(rawBoundPanelObject);
const boundPanelBox = new THREE.Box3().setFromObject(anchorPanelObject);
if (boundPanelBox.isEmpty()) {
panelStateCache.set(resolvedHolePanelId, null);
return null;
}
const explosionOffset = engine.getObjectExplosionWorldOffset(boundPanelObject);
const explosionOffset = engine.getObjectExplosionWorldOffset(anchorPanelObject);
const originalBoundPanelBox = boundPanelBox
.clone()
.translate(explosionOffset.clone().negate());
@ -855,7 +892,8 @@ export function resolveStrictHoleMarkers({
: null;
const state = {
targetObject: boundPanelObject,
boundObject: boundPanelObject,
targetObject: anchorPanelObject,
currentBox: boundPanelBox,
originalBox: originalBoundPanelBox,
cadBox,
@ -883,10 +921,31 @@ export function resolveStrictHoleMarkers({
if (!panelState) {
return null;
}
let finalWorld = modelRoot
.localToWorld(holeLocal.clone())
.clone()
.add(panelState.explosionOffset);
if (hole.coordinateSpace === "object-local") {
return {
holeId: hole.id,
panelId: resolvedHolePanelId,
targetObject: panelState.targetObject,
localPosition: holeLocal,
holeType: hole.holeType ?? null,
...(hole.hardwareMatched !== undefined ? { hardwareMatched: hole.hardwareMatched } : {}),
...(hole.snapLink ? { snapLink: hole.snapLink } : {}),
};
}
if (hole.coordinateSpace === "node-local") {
return {
holeId: hole.id,
panelId: resolvedHolePanelId,
targetObject: panelState.boundObject,
localPosition: holeLocal,
holeType: hole.holeType ?? null,
...(hole.hardwareMatched !== undefined ? { hardwareMatched: hole.hardwareMatched } : {}),
...(hole.snapLink ? { snapLink: hole.snapLink } : {}),
};
}
let finalWorld =
modelRoot.localToWorld(holeLocal.clone());
finalWorld = finalWorld.clone().add(panelState.explosionOffset);
const distanceToPanel = panelState.currentBox.distanceToPoint(finalWorld);
if (distanceToPanel > panelState.threshold) {
const normal = resolveWorldNormal(
@ -932,6 +991,7 @@ export function resolveStrictHoleMarkers({
localPosition: panelState.targetObject.worldToLocal(finalWorld.clone()),
holeType: hole.holeType ?? null,
...(hole.hardwareMatched !== undefined ? { hardwareMatched: hole.hardwareMatched } : {}),
...(hole.snapLink ? { snapLink: hole.snapLink } : {}),
};
})
.filter((value): value is ResolvedHoleMarker => value !== null);
@ -1466,11 +1526,42 @@ export function HoleOverlay2D({
holeId: marker.holeId,
holeType: marker.holeType,
hardwareMatched: marker.hardwareMatched,
snapLink: marker.snapLink,
x: Number(projected.x.toFixed(2)),
y: Number(projected.y.toFixed(2)),
});
});
screenHoleMarkersRef.current = projectedHoleMarkers;
const snapLinkMarkersById = new Map<string, ScreenHoleMarker[]>();
projectedHoleMarkers.forEach((marker) => {
if (!marker.snapLink) return;
const items = snapLinkMarkersById.get(marker.snapLink.id) ?? [];
items.push(marker);
snapLinkMarkersById.set(marker.snapLink.id, items);
});
const snapLinkPairs = Array.from(snapLinkMarkersById.entries())
.map(([linkId, markers]) => {
const sourceMarker = markers.find((marker) => marker.snapLink?.role === "source") ?? markers[0];
const targetMarker = markers.find((marker) => marker.snapLink?.role === "target") ?? markers[1];
if (!sourceMarker || !targetMarker || sourceMarker === targetMarker) return null;
return {
linkId,
state: sourceMarker.snapLink?.state ?? targetMarker.snapLink?.state ?? "candidate",
sourceMarker,
targetMarker,
};
})
.filter(
(
value,
): value is {
linkId: string;
state: NonNullable<HoleData["snapLink"]>["state"];
sourceMarker: ScreenHoleMarker;
targetMarker: ScreenHoleMarker;
} => Boolean(value),
);
const hasAnimatedSnapLinks = snapLinkPairs.some((pair) => pair.state === "candidate");
const panelMarkersForDraw = staticPanelLeaderMarkers
.map((marker): PanelMarkerForDraw | null => {
@ -1586,7 +1677,7 @@ export function HoleOverlay2D({
holeMarkers: projectedHoleMarkers,
});
if (frameSignature === lastFrameSignature) {
if (frameSignature === lastFrameSignature && !hasAnimatedSnapLinks) {
frameId = window.requestAnimationFrame(drawFrame);
return;
}
@ -1711,8 +1802,81 @@ export function HoleOverlay2D({
leftMarkers.forEach(drawLeader);
rightMarkers.forEach(drawLeader);
const drawSnapLink = (pair: (typeof snapLinkPairs)[number]) => {
const palette = getSnapLinkPalette(pair.state);
const startX = pair.sourceMarker.x;
const startY = pair.sourceMarker.y;
const endX = pair.targetMarker.x;
const endY = pair.targetMarker.y;
const dx = endX - startX;
const dy = endY - startY;
const length = Math.sqrt(dx * dx + dy * dy);
if (length <= 1) return;
const seed = hashStringToUnit(pair.linkId);
const now =
(typeof performance !== "undefined" ? performance.now() : Date.now()) / 1000;
const phase =
now * (1.15 + seed * 0.85) +
Math.sin(now * (0.77 + seed) + seed * 11) * 0.18;
const pulseCenter = ((phase % 1) + 1) % 1;
const pulseWidth = pair.state === "candidate" ? 0.16 + seed * 0.12 : 0.1;
const alphaJitter = 0.68 + Math.sin(now * (2.1 + seed) + seed * 17) * 0.18;
const midX = startX + dx * 0.5;
const midY = startY + dy * 0.5;
const normalX = -dy / length;
const normalY = dx / length;
const bend = pair.state === "candidate" ? (seed - 0.5) * 10 : 0;
const controlX = midX + normalX * bend;
const controlY = midY + normalY * bend;
context.save();
context.lineCap = "round";
context.lineJoin = "round";
context.beginPath();
context.moveTo(startX, startY);
context.quadraticCurveTo(controlX, controlY, endX, endY);
context.strokeStyle = palette.glow;
context.lineWidth = pair.state === "candidate" ? 7 : 5;
context.stroke();
context.beginPath();
context.moveTo(startX, startY);
context.quadraticCurveTo(controlX, controlY, endX, endY);
context.strokeStyle = palette.base;
context.lineWidth = pair.state === "candidate" ? 1.6 : 1.2;
context.setLineDash(pair.state === "candidate" ? [5 + seed * 5, 8 + seed * 6] : []);
context.lineDashOffset = -phase * 22;
context.stroke();
if (pair.state === "candidate") {
const pulseStart = Math.max(0, pulseCenter - pulseWidth / 2);
const pulseEnd = Math.min(1, pulseCenter + pulseWidth / 2);
const pulseStartX = startX + dx * pulseStart;
const pulseStartY = startY + dy * pulseStart;
const pulseEndX = startX + dx * pulseEnd;
const pulseEndY = startY + dy * pulseEnd;
const pulseControlX = controlX + (pulseCenter - 0.5) * dx * 0.12;
const pulseControlY = controlY + (pulseCenter - 0.5) * dy * 0.12;
context.beginPath();
context.moveTo(pulseStartX, pulseStartY);
context.quadraticCurveTo(pulseControlX, pulseControlY, pulseEndX, pulseEndY);
context.setLineDash([]);
context.strokeStyle = palette.pulse.replace(/[\d.]+\)$/u, `${Math.min(alphaJitter, 0.95).toFixed(2)})`);
context.lineWidth = 2.4 + seed * 1.2;
context.stroke();
}
context.restore();
};
snapLinkPairs.forEach(drawSnapLink);
projectedHoleMarkers.forEach((marker) => {
const palette = getHoleMarkerPalette(marker);
const isLinkedMarker = marker.holeType === "assembly-used";
context.beginPath();
context.fillStyle = palette.inner;
@ -1721,14 +1885,22 @@ export function HoleOverlay2D({
context.beginPath();
context.fillStyle = palette.fill;
context.arc(marker.x, marker.y, 4.6, 0, Math.PI * 2);
context.arc(marker.x, marker.y, isLinkedMarker ? 5.6 : 4.6, 0, Math.PI * 2);
context.fill();
context.beginPath();
context.strokeStyle = palette.stroke;
context.lineWidth = 1.2;
context.arc(marker.x, marker.y, 4.8, 0, Math.PI * 2);
context.lineWidth = isLinkedMarker ? 1.6 : 1.2;
context.arc(marker.x, marker.y, isLinkedMarker ? 5.9 : 4.8, 0, Math.PI * 2);
context.stroke();
if (isLinkedMarker) {
context.beginPath();
context.strokeStyle = "rgba(18, 89, 142, 0.42)";
context.lineWidth = 1;
context.arc(marker.x, marker.y, 8, 0, Math.PI * 2);
context.stroke();
}
});
frameId = window.requestAnimationFrame(drawFrame);

View File

@ -108,7 +108,12 @@ interface ModelViewerProps {
nodeName: string;
position: [number, number, number];
rotation: [number, number, number];
}) => void;
}) =>
| void
| {
position?: [number, number, number];
rotation?: [number, number, number];
};
}
export type ModelViewerWorldNode = EngineWorldNode;
@ -626,14 +631,13 @@ function ModelViewerComponent({
const currentSceneIdentity = useMemo(
() =>
isWorldMode
? `world:${worldNodeIdentitySignature}:${worldFrameMode}`
? `world:${worldFrameMode}`
: `model:${modelUrl.trim()}:${modelDimensionsSignature}`,
[
isWorldMode,
modelDimensionsSignature,
modelUrl,
worldFrameMode,
worldNodeIdentitySignature,
],
);
@ -1148,7 +1152,58 @@ function ModelViewerComponent({
return () => {
window.cancelAnimationFrame(animationFrameId);
};
}, [isWorldMode, loadScene, retryCount, worldNodeIdentitySignature]);
}, [isWorldMode, loadScene, retryCount]);
useEffect(() => {
const engine = engineRef.current;
if (
!engine ||
!isWorldMode ||
!worldNodes ||
loadedSceneIdentity !== currentSceneIdentity
) {
return;
}
let cancelled = false;
void engine
.syncWorldNodes(worldNodes)
.then(() => {
if (cancelled || engineRef.current !== engine) {
return;
}
const nodeNames = worldNodes.map((node) => node.nodeName);
captureOriginalNodeRotations(nodeNames);
onModelNodesChangeRef.current?.(nodeNames);
setEngineInstance(engine);
onEngineReadyRef.current?.({
engine,
modelRoot: engine.getModelRoot(),
});
syncTransformControlsRef.current?.();
setSceneLoadVersion((current) => current + 1);
})
.catch((caught) => {
if (cancelled || engineRef.current !== engine) {
return;
}
const errorInstance =
caught instanceof Error ? caught : new Error("世界节点同步失败");
setError(getErrorMessage(errorInstance));
onErrorRef.current?.(errorInstance);
});
return () => {
cancelled = true;
};
}, [
captureOriginalNodeRotations,
currentSceneIdentity,
isWorldMode,
loadedSceneIdentity,
worldNodeIdentitySignature,
worldNodes,
]);
useEffect(() => {
return () => {
@ -1254,7 +1309,9 @@ function ModelViewerComponent({
object.updateMatrixWorld(true);
});
syncTransformControlsRef.current?.();
if (!transformInteractionActiveRef.current) {
syncTransformControlsRef.current?.();
}
}, [engineInstance, isWorldMode, worldNodeTransformSignature, worldNodes]);
useEffect(() => {
@ -1469,38 +1526,65 @@ function ModelViewerComponent({
) {
return;
}
let effectiveTransform:
| void
| {
position?: [number, number, number];
rotation?: [number, number, number];
} = undefined;
if (isWorldMode && onWorldNodeTransformChange) {
effectiveTransform = onWorldNodeTransformChange({
nodeName: activeNodeName,
position: [
targetObject.position.x,
targetObject.position.y,
targetObject.position.z,
],
rotation: [
targetObject.rotation.x,
targetObject.rotation.y,
targetObject.rotation.z,
],
});
}
if (effectiveTransform?.position) {
targetObject.position.set(
effectiveTransform.position[0],
effectiveTransform.position[1],
effectiveTransform.position[2],
);
}
if (effectiveTransform?.rotation) {
targetObject.rotation.set(
effectiveTransform.rotation[0],
effectiveTransform.rotation[1],
effectiveTransform.rotation[2],
);
}
if (effectiveTransform?.position || effectiveTransform?.rotation) {
targetObject.updateMatrixWorld(true);
(
transformControlsRef.current as unknown as {
updateMatrixWorld?: (force?: boolean) => void;
} | null
)?.updateMatrixWorld?.(true);
transformHelperRef.current?.updateMatrixWorld(true);
}
const feedbackLabels = buildTransformFeedbackLabels(activeNodeName);
flushSync(() => {
if (isWorldMode && onWorldNodeTransformChange) {
onWorldNodeTransformChange({
nodeName: activeNodeName,
position: [
targetObject.position.x,
targetObject.position.y,
targetObject.position.z,
],
rotation: [
targetObject.rotation.x,
targetObject.rotation.y,
targetObject.rotation.z,
],
});
}
if (offset && onNodePositionOffsetChange) {
onNodePositionOffsetChange({
nodeName: activeNodeName,
offset,
});
}
if (rotation && onNodeRotationChange) {
onNodeRotationChange({
nodeName: activeNodeName,
rotation,
});
}
setTransformFeedbackLabels(feedbackLabels);
});
if (offset && onNodePositionOffsetChange) {
onNodePositionOffsetChange({
nodeName: activeNodeName,
offset,
});
}
if (rotation && onNodeRotationChange) {
onNodeRotationChange({
nodeName: activeNodeName,
rotation,
});
}
setTransformFeedbackLabels(feedbackLabels);
};
syncTransformControlsRef.current = () => {

View File

@ -1,10 +1,11 @@
import * as THREE from 'three'
import { render, waitFor } from '@testing-library/react'
import { act, render, waitFor } from '@testing-library/react'
import type { ButtonHTMLAttributes } from 'react'
import { ModelViewer } from '../ModelViewer'
const mockEngineInstances: Array<{
loadWorldNodes: jest.Mock
syncWorldNodes: jest.Mock
applyMaterialToMesh: jest.Mock
resolveObjectByNodeName: jest.Mock
}> = []
@ -83,6 +84,30 @@ jest.mock('@/lib/3d/engine', () => ({
}
},
),
syncWorldNodes: jest.fn(async (nodes: Array<{ nodeName: string }>) => {
await Promise.resolve()
const nextNodeNames = new Set(nodes.map((node) => node.nodeName))
Array.from(instance.nodeObjects.keys()).forEach((nodeName) => {
if (!nextNodeNames.has(nodeName)) {
instance.nodeObjects.delete(nodeName)
}
})
nodes.forEach((node) => {
if (!instance.nodeObjects.has(node.nodeName)) {
const object = new THREE.Group()
object.name = node.nodeName
instance.nodeObjects.set(node.nodeName, object)
}
})
instance.modelRoot ??= new THREE.Group()
instance.modelRoot.clear()
instance.nodeObjects.forEach((object) => {
instance.modelRoot?.add(object)
})
return instance.modelRoot
}),
loadModel: jest.fn(async () => new THREE.Group()),
setHiddenNodes: jest.fn(),
setSceneGridVisible: jest.fn(),
@ -214,7 +239,57 @@ describe('ModelViewer', () => {
})
})
it('世界节点重载后即使材质配置没变也会重新应用材质', async () => {
it('世界节点删除时走增量同步,不重新加载整个 3D 世界', async () => {
const { rerender } = render(
<ModelViewer
worldNodes={[
{
nodeName: 'node-1',
modelUrl: '/desktop.glb',
source: 'desktop',
},
{
nodeName: 'node-2',
modelUrl: '/leg.glb',
source: 'leg',
},
]}
worldFrameMode="initial"
showSceneGrid
/>,
)
await waitFor(() => {
expect(mockEngineInstances[0]?.loadWorldNodes).toHaveBeenCalledTimes(1)
})
rerender(
<ModelViewer
worldNodes={[
{
nodeName: 'node-1',
modelUrl: '/desktop.glb',
source: 'desktop',
},
]}
worldFrameMode="initial"
showSceneGrid
/>,
)
await waitFor(() => {
expect(mockEngineInstances[0]?.syncWorldNodes).toHaveBeenCalledWith([
{
nodeName: 'node-1',
modelUrl: '/desktop.glb',
source: 'desktop',
},
])
expect(mockEngineInstances[0]?.loadWorldNodes).toHaveBeenCalledTimes(1)
})
})
it('世界节点增量同步后即使材质配置没变也会重新应用材质', async () => {
const nodeMaterial = {
id: 'finish-oak',
name: '橡木',
@ -279,7 +354,8 @@ describe('ModelViewer', () => {
)
await waitFor(() => {
expect(mockEngineInstances[0]?.loadWorldNodes).toHaveBeenCalledTimes(2)
expect(mockEngineInstances[0]?.syncWorldNodes).toHaveBeenCalled()
expect(mockEngineInstances[0]?.loadWorldNodes).toHaveBeenCalledTimes(1)
})
await waitFor(() => {
@ -331,4 +407,49 @@ describe('ModelViewer', () => {
})
})
})
it('世界节点拖动时会应用业务层返回的吸附后坐标到正在拖拽的模型本体', async () => {
const onWorldNodeTransformChange = jest.fn(() => ({
position: [10, 20, 30] as [number, number, number],
rotation: [0, 0, 0] as [number, number, number],
}))
render(
<ModelViewer
worldNodes={[
{
nodeName: 'node-1',
modelUrl: '/desktop.glb',
source: 'desktop',
},
]}
worldFrameMode="initial"
enableTranslateGizmo
selectedNodeName="node-1"
onWorldNodeTransformChange={onWorldNodeTransformChange}
showSceneGrid
/>,
)
await waitFor(() => {
expect(mockTransformControlsInstances[0]?.attach).toHaveBeenCalled()
})
const object = mockEngineInstances[0]?.resolveObjectByNodeName('node-1') as THREE.Object3D
object.position.set(1, 2, 3)
const transformControls = mockTransformControlsInstances[0] as unknown as {
listeners: Map<string, (event: unknown) => void>
}
await act(async () => {
transformControls.listeners.get('objectChange')?.({})
})
expect(onWorldNodeTransformChange).toHaveBeenCalledWith({
nodeName: 'node-1',
position: [1, 2, 3],
rotation: [0, 0, 0],
})
expect(object.position.toArray()).toEqual([10, 20, 30])
})
})

View File

@ -112,6 +112,130 @@ describe("resolveStrictHoleMarkers", () => {
);
});
it("辅助装配传入节点本地孔位时,孔位应绑定到被拖拽模型节点而不是上一帧世界坐标", () => {
const modelRoot = new THREE.Group();
const panelA = createPanelMesh("assembly-node-a", new THREE.Vector3(0, 708, 0));
modelRoot.add(panelA);
modelRoot.updateMatrixWorld(true);
const markers = resolveStrictHoleMarkers({
engine: {
getObjectExplosionWorldOffset: () => new THREE.Vector3(),
} as any,
holes: [
createHole({
panelId: "node-a::hole::hole-1",
coordinateSpace: "node-local",
position: [0, 0, 0],
normal: [0, 1, 0],
}),
],
modelRoot,
panelObjectByPanelId: {
"node-a::hole::hole-1": panelA,
},
panelIdAliasMap: new Map([
["nodea::hole::hole1", "node-a::hole::hole-1"],
]),
});
expect(markers).toHaveLength(1);
expect(resolveMarkerWorldPosition(markers[0]!).y).toBeCloseTo(708, 5);
panelA.position.y = 760;
panelA.updateMatrixWorld(true);
expect(resolveMarkerWorldPosition(markers[0]!).y).toBeCloseTo(760, 5);
});
it("辅助装配节点本地孔位不应再被投影到模型包围盒最近面", () => {
const modelRoot = new THREE.Group();
const panelA = createPanelMesh("assembly-node-a", new THREE.Vector3(0, 0, 0));
modelRoot.add(panelA);
modelRoot.updateMatrixWorld(true);
const markers = resolveStrictHoleMarkers({
engine: {
getObjectExplosionWorldOffset: () => new THREE.Vector3(),
} as any,
holes: [
createHole({
panelId: "node-a::hole::hole-1",
coordinateSpace: "node-local",
position: [0, 0, 0],
normal: [0, 1, 0],
}),
],
modelRoot,
panelObjectByPanelId: {
"node-a::hole::hole-1": panelA,
},
panelIdAliasMap: new Map([
["nodea::hole::hole1", "node-a::hole::hole-1"],
]),
});
expect(markers).toHaveLength(1);
expect(markers[0]?.localPosition).toEqual(
expect.objectContaining({
x: expect.closeTo(0, 5),
y: expect.closeTo(0, 5),
z: expect.closeTo(0, 5),
}),
);
});
it("辅助装配节点本地孔位应绑定世界节点 wrapper,避免 OBJ 内部旋转再次转换", () => {
const modelRoot = new THREE.Group();
const wrapper = new THREE.Group();
wrapper.name = "assembly-node-board";
wrapper.position.set(10, 20, 30);
const objRoot = new THREE.Group();
objRoot.rotation.x = -Math.PI / 2;
const mesh = new THREE.Mesh(
new THREE.BoxGeometry(1400, 600, 25),
new THREE.MeshBasicMaterial(),
);
mesh.name = "asset_000";
objRoot.add(mesh);
wrapper.add(objRoot);
modelRoot.add(wrapper);
modelRoot.updateMatrixWorld(true);
const nodeLocalHole: [number, number, number] = [285, 2.5, -171.5];
const markers = resolveStrictHoleMarkers({
engine: {
getObjectExplosionWorldOffset: () => new THREE.Vector3(),
} as any,
holes: [
createHole({
id: "cad-hole-after-up-axis-transform",
panelId: "node-board::hole::panel_000_hole_000",
coordinateSpace: "node-local",
position: nodeLocalHole,
normal: [0, 1, 0],
}),
],
modelRoot,
panelObjectByPanelId: {
"node-board::hole::panel_000_hole_000": wrapper,
},
panelIdAliasMap: new Map([
["nodeboard::hole::panel000hole000", "node-board::hole::panel_000_hole_000"],
]),
});
expect(markers).toHaveLength(1);
expect(markers[0]?.targetObject).toBe(wrapper);
expect(resolveMarkerWorldPosition(markers[0]!)).toEqual(
expect.objectContaining({
x: expect.closeTo(295, 5),
y: expect.closeTo(22.5, 5),
z: expect.closeTo(-141.5, 5),
}),
);
});
it("插件导出坐标已与模型对齐时,不应再做额外坐标转换,但应贴到板面", () => {
const modelRoot = new THREE.Group();
const panelA = createPanelMesh("cad_panel_a", new THREE.Vector3(0, 1, 2));

View File

@ -1,8 +1,17 @@
import type * as THREE from 'three'
export interface HoleSnapLinkData {
id: string
state: 'candidate' | 'used'
role: 'source' | 'target'
peerPanelId: string
peerHoleId: string
}
export interface HoleData {
id: string
position: [number, number, number]
coordinateSpace?: 'model-root' | 'object-local' | 'node-local'
panelId?: string
diameter: number
depth: number
@ -16,6 +25,7 @@ export interface HoleData {
width?: number
length?: number
groupId?: string
snapLink?: HoleSnapLinkData
}
export interface HoleGroupData {

View File

@ -112,6 +112,7 @@ function setThumbnailRenderState(
}
document.body.dataset.thumbnailState = state
document.body.dataset.assetThumbnailRenderer = 'true'
if (errorMessage) {
document.body.dataset.thumbnailError = errorMessage
} else {
@ -152,6 +153,7 @@ export function AssetVariantThumbnailRenderer({
)
useEffect(() => {
document.body.dataset.assetThumbnailRenderer = 'true'
setThumbnailRenderState('loading')
setNodeNames([])
setModelLoaded(false)
@ -161,6 +163,9 @@ export function AssetVariantThumbnailRenderer({
controllerRef.current?.dispose()
controllerRef.current = null
modelRootRef.current = null
delete document.body.dataset.assetThumbnailRenderer
delete document.body.dataset.thumbnailState
delete document.body.dataset.thumbnailError
}
}, [assetType, materialConfig, modelUrl])
@ -274,7 +279,6 @@ export function AssetVariantThumbnailRenderer({
data-thumbnail-capture="true"
className="relative h-[360px] w-[360px] overflow-hidden bg-[#f6f7f8]"
>
<div className="pointer-events-none absolute inset-x-[10%] bottom-[10%] h-9 rounded-full bg-black/[0.08] blur-[24px]" />
<div className="absolute inset-0">
<ModelViewer
modelUrl={modelUrl}
@ -296,6 +300,21 @@ export function AssetVariantThumbnailRenderer({
<div className="sr-only" aria-hidden="true">
{error ?? 'ready'}
</div>
<style jsx global>{`
body[data-asset-thumbnail-renderer='true'] nextjs-portal,
body[data-asset-thumbnail-renderer='true'] [data-nextjs-dev-tools-button],
body[data-asset-thumbnail-renderer='true'] [data-nextjs-toast],
body[data-asset-thumbnail-renderer='true'] [data-nextjs-dialog-overlay],
body[data-asset-thumbnail-renderer='true'] [data-nextjs-dialog],
body[data-asset-thumbnail-renderer='true'] [data-nextjs-terminal],
body[data-asset-thumbnail-renderer='true'] [data-nextjs-errors],
body[data-asset-thumbnail-renderer='true'] [data-nextjs-dev-tools-indicator] {
display: none !important;
visibility: hidden !important;
opacity: 0 !important;
pointer-events: none !important;
}
`}</style>
</div>
)
}

View File

@ -69,7 +69,12 @@ interface ModelPreviewPanelProps {
onGizmoInteractionEnd?: (nodeName: string) => void
onNodePositionOffsetChange?: (payload: { nodeName: string; offset: [number, number, number] }) => void
onNodeRotationChange?: (payload: { nodeName: string; rotation: [number, number, number, number] }) => void
onWorldNodeTransformChange?: (payload: { nodeName: string; position: [number, number, number]; rotation: [number, number, number] }) => void
onWorldNodeTransformChange?: (payload: { nodeName: string; position: [number, number, number]; rotation: [number, number, number] }) =>
| void
| {
position?: [number, number, number]
rotation?: [number, number, number]
}
onEngineReady?: (payload: { engine: ThreeDEngine; modelRoot: any }) => void
onModelNodesChange?: (nodes: string[]) => void
onNodeSelect?: (payload: {

View File

@ -17,17 +17,29 @@ jest.mock('@/components/ui/ContextMenu', () => ({
ContextMenu: ({
visible,
title,
subtitle,
width,
bodyMaxHeight,
groups,
onItemClick,
}: {
visible: boolean
title?: string
subtitle?: string
width?: number
bodyMaxHeight?: number
groups: Array<{ id: string; items: Array<{ id: string; label: string }> }>
onItemClick?: (item: { id: string; label: string }) => void
}) =>
visible ? (
<div data-testid="context-menu" data-context-menu-root="true">
{title ? <div>{title}</div> : null}
<div
data-testid="context-menu"
data-context-menu-root="true"
data-title={title ?? ''}
data-subtitle={subtitle ?? ''}
data-width={width ?? ''}
data-body-max-height={bodyMaxHeight ?? ''}
>
{groups.flatMap((group) =>
group.items.map((item) => (
<button
@ -74,6 +86,8 @@ jest.mock('../ModelPreviewPanel', () => {
extraSidebarActions,
onNodeSelect,
onNodeContextMenu,
onGizmoInteractionStart,
onWorldNodeTransformChange,
}: {
worldNodes?: Array<{
nodeName: string
@ -82,13 +96,31 @@ jest.mock('../ModelPreviewPanel', () => {
worldFrameMode?: 'always' | 'initial' | 'never'
selectedNodeName?: string | null
materialAssignments?: Array<{ nodeName: string }>
holes?: Array<{ id: string; diameter?: number; depth?: number }>
holes?: Array<{
id: string
panelId?: string | null
diameter?: number
depth?: number
holeType?: string | null
snapLink?: unknown
}>
activeHolePanelIds?: string[]
highlightedNodeNames?: string[]
onHoleHover?: (payload: { holeId: string; panelId: string } | null) => void
overlayLeft?: ReactNode
overlayBottomCenter?: ReactNode
extraSidebarActions?: ReactNode
onGizmoInteractionStart?: (nodeName: string) => void
onWorldNodeTransformChange?: (payload: {
nodeName: string
position: [number, number, number]
rotation: [number, number, number]
}) =>
| void
| {
position?: [number, number, number]
rotation?: [number, number, number]
}
onNodeSelect?: (payload: {
nodeName: string | null
rawNodeName: string | null
@ -107,6 +139,7 @@ jest.mock('../ModelPreviewPanel', () => {
}) => void
}) => {
const firstNode = worldNodes[0] ?? null
const [lastEffectiveTransform, setLastEffectiveTransform] = React.useState<string>('none')
return (
<div>
@ -118,12 +151,18 @@ jest.mock('../ModelPreviewPanel', () => {
<div data-testid="selected-node">{selectedNodeName ?? 'none'}</div>
<div data-testid="material-assignment-count">材质指派:{materialAssignments.length}</div>
<div data-testid="hole-overlay-count">孔位覆盖:{holes.length}</div>
<div data-testid="hole-overlay-link-count">
连接孔位:{holes.filter((hole) => hole.snapLink).length}
</div>
<div data-testid="hole-overlay-first-metrics">
{holes[0] ? `${holes[0].diameter ?? 'none'}/${holes[0].depth ?? 'none'}` : 'none'}
</div>
<div data-testid="hole-overlay-first-type">{holes[0]?.holeType ?? 'none'}</div>
<div data-testid="active-hole-panel-count">激活孔位面板:{activeHolePanelIds.length}</div>
<div data-testid="active-hole-panel-ids">{activeHolePanelIds.join(',') || 'none'}</div>
<div data-testid="highlighted-node-count">高亮节点:{highlightedNodeNames.length}</div>
<div data-testid="highlighted-node-names">{highlightedNodeNames.join(',') || 'none'}</div>
<div data-testid="last-effective-transform">{lastEffectiveTransform}</div>
<div data-testid="toolbar-actions">{extraSidebarActions}</div>
{withTestId(overlayLeft, 'asset-browser-panel')}
{withTestId(overlayBottomCenter, 'right-drawer-zone')}
@ -197,6 +236,43 @@ jest.mock('../ModelPreviewPanel', () => {
>
模拟 hover 已使用孔位
</button>
<button
type="button"
onClick={() => {
const secondNode = worldNodes[1] ?? null
if (!secondNode) return
onGizmoInteractionStart?.(secondNode.nodeName)
const effectiveTransform = onWorldNodeTransformChange?.({
nodeName: secondNode.nodeName,
position: [110, 720, 0],
rotation: [0, 0, 0],
})
setLastEffectiveTransform(JSON.stringify(effectiveTransform?.position ?? null))
}}
>
模拟小幅拖动第二节点
</button>
<button
type="button"
onClick={() => {
const secondNode = worldNodes[1] ?? null
if (!secondNode) return
onGizmoInteractionStart?.(secondNode.nodeName)
onWorldNodeTransformChange?.({
nodeName: secondNode.nodeName,
position: [0, 720, 0],
rotation: [0, 0, 0],
})
const effectiveTransform = onWorldNodeTransformChange?.({
nodeName: secondNode.nodeName,
position: [110, 720, 0],
rotation: [0, 0, 0],
})
setLastEffectiveTransform(JSON.stringify(effectiveTransform?.position ?? null))
}}
>
模拟远距离进入吸附后小幅拖动第二节点
</button>
</div>
)
},
@ -421,6 +497,143 @@ function buildHoleSnapWorkspace() {
})
}
function buildUnsnappedHoleWorkspace() {
const preview = buildHoleSnapWorkspace()
const legNode = preview.nodes[1]!
return updateAssemblyNodeTransform(preview, legNode.nodeId, {
position: [-1500, 320, 900],
})
}
function buildSingleBoardHoleWorkspace() {
return instantiateAssemblyNode(buildWorkspace(), {
inventoryId: 'desktop:family-board-1:1',
variant: {
id: 'variant-board-1',
assetFamilyId: 'family-board-1',
assetType: 'BOARD',
code: 'BOARD-A-001',
name: '桌面A-001',
status: 'AVAILABLE',
modelUrl: '/board.glb',
thumbnailUrl: null,
dimensions: { length: 1200, width: 600, thickness: 25 },
mountPoints: [],
assemblyBasis: {
anchor: 'desktop-center',
holes: [
{
id: 'board-hole-1',
panelId: 'board-panel',
position: [0, 0, 0],
normal: [0, -1, 0],
diameter: 8,
depth: 12,
holeType: 'assembly-active',
holeGroupId: 'board-hole-group',
surfaceFaces: ['top'],
},
],
holeGroups: [
{
id: 'board-hole-group',
panelId: 'board-panel',
groupType: 'pair',
mountFace: 'top',
holeIds: ['board-hole-1'],
surfaceFaces: ['top'],
},
],
},
materialConfig: AVAILABLE_VARIANTS[0]?.materialConfig ?? null,
} as never,
})
}
function buildMultiHoleSnapWorkspace() {
const workspace = buildHoleSnapWorkspace()
const targetNode = workspace.nodes[0]
const movingNode = workspace.nodes[1]
if (!targetNode || !movingNode) return workspace
const targetBasis = targetNode.assemblyBasis ?? {}
const targetHoles = Array.isArray(targetBasis.holes) ? [...targetBasis.holes] : []
const targetPatterns = Array.isArray(targetBasis.mountPatterns) ? [...targetBasis.mountPatterns] : []
const sourceBasis = movingNode.assemblyBasis ?? {}
const sourceHoles = Array.isArray(sourceBasis.holes) ? [...sourceBasis.holes] : []
const sourcePatterns = Array.isArray(sourceBasis.mountPatterns) ? [...sourceBasis.mountPatterns] : []
return {
...workspace,
nodes: workspace.nodes.map((node) =>
node.nodeId === targetNode.nodeId
? {
...node,
assemblyBasis: {
...targetBasis,
holes: [
...targetHoles,
{
id: 'board-hole-2',
panelId: 'board-panel',
position: [10, 0, 0],
normal: [0, -1, 0],
diameter: 8,
depth: 12,
holeGroupId: 'board-hole-group',
surfaceFaces: ['top'],
},
],
mountPatterns: targetPatterns.map((pattern) =>
pattern.id === 'board-pattern-1'
? {
...pattern,
holeIds: ['board-hole-1', 'board-hole-2'],
requiredHoleIds: ['board-hole-1', 'board-hole-2'],
minRequiredHoles: 2,
preferredHoleCount: 2,
}
: pattern,
),
},
}
: node.nodeId === movingNode.nodeId
? {
...node,
assemblyBasis: {
...sourceBasis,
holes: [
...sourceHoles,
{
id: 'leg-hole-2',
panelId: 'leg-panel',
position: [10, 0, 0],
normal: [0, 1, 0],
diameter: 8,
depth: 12,
holeType: 'assembly-active',
holeGroupId: 'leg-hole-group',
surfaceFaces: ['bottom'],
},
],
mountPatterns: sourcePatterns.map((pattern) =>
pattern.id === 'leg-pattern-1'
? {
...pattern,
holeIds: ['leg-hole-1', 'leg-hole-2'],
requiredHoleIds: ['leg-hole-1', 'leg-hole-2'],
minRequiredHoles: 2,
preferredHoleCount: 2,
}
: pattern,
),
},
}
: node,
),
} satisfies AssemblyWorkspaceSnapshot
}
function buildMissingSnapSemanticWorkspace() {
const baseWorkspace = buildWorkspace()
const withDesktop = instantiateAssemblyNode(baseWorkspace, {
@ -529,14 +742,16 @@ describe('AssemblyWorkbenchV2', () => {
expect(screen.getByTestId('world-node-count')).toHaveTextContent('世界节点:0')
expect(screen.getByTestId('world-frame-mode')).toHaveTextContent('always')
expect(screen.getByRole('button', { name: '打开系列选择' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: '打开板材物料' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: '打开组件物料' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: '打开五金物料' })).toBeDisabled()
expect(screen.queryByTestId('asset-browser-panel')).not.toBeInTheDocument()
await actor.click(screen.getByRole('button', { name: '打开板材物料' }))
await actor.hover(screen.getByRole('button', { name: '打开板材物料' }))
expect(screen.getByTestId('asset-browser-panel')).toBeInTheDocument()
expect(screen.getByTestId('asset-family-rail')).toBeInTheDocument()
expect(screen.queryByText('板材资产族')).not.toBeInTheDocument()
expect(screen.queryByText('MATERIAL BROWSER')).not.toBeInTheDocument()
expect(screen.queryByText('VARIANTS')).not.toBeInTheDocument()
@ -544,6 +759,22 @@ describe('AssemblyWorkbenchV2', () => {
expect(screen.getByRole('button', { name: '加载桌面A-001到 3D 世界' })).toBeInTheDocument()
})
it('鼠标离开物料交互区域后会收起浮层', async () => {
const actor = userEvent.setup()
render(<ControlledWorkbench rightDrawerContent={<div>右侧业务区</div>} />)
await actor.hover(screen.getByRole('button', { name: '打开板材物料' }))
expect(screen.getByTestId('asset-browser-panel')).toBeInTheDocument()
fireEvent.pointerMove(document.body)
await waitFor(() => {
expect(screen.queryByTestId('asset-browser-panel')).not.toBeInTheDocument()
})
})
it('点击规格款会直接加入 3D 世界,并保持统一选中态', async () => {
const actor = userEvent.setup()
const onChange = jest.fn()
@ -667,19 +898,83 @@ describe('AssemblyWorkbenchV2', () => {
await waitFor(() => {
expect(screen.getByTestId('selected-node')).not.toHaveTextContent('none')
expect(screen.getByTestId('hole-overlay-count')).toHaveTextContent('孔位覆盖:2')
expect(screen.getByTestId('hole-overlay-link-count')).toHaveTextContent('连接孔位:2')
expect(screen.getByTestId('hole-overlay-first-metrics')).toHaveTextContent('8/12')
expect(screen.getByTestId('hole-overlay-first-type')).toHaveTextContent('assembly-candidate')
expect(screen.getByTestId('active-hole-panel-count')).toHaveTextContent('激活孔位面板:2')
expect(screen.getByTestId('highlighted-node-count')).toHaveTextContent('高亮节点:1')
})
})
it('加载多个模型但未选中节点时,也会保留基础孔位高亮', async () => {
render(
<ControlledWorkbench
initialWorkspace={buildHoleSnapWorkspace()}
rightDrawerContent={<div>右侧业务区</div>}
/>,
)
await waitFor(() => {
expect(screen.getByTestId('world-node-count')).toHaveTextContent('世界节点:2')
expect(screen.getByTestId('selected-node')).toHaveTextContent('none')
expect(screen.getByTestId('hole-overlay-count')).toHaveTextContent('孔位覆盖:2')
expect(screen.getByTestId('hole-overlay-link-count')).toHaveTextContent('连接孔位:2')
expect(screen.getByTestId('hole-overlay-first-type')).toHaveTextContent('assembly-candidate')
expect(screen.getByTestId('active-hole-panel-count')).toHaveTextContent('激活孔位面板:2')
})
})
it('选中只有自身孔位语义的节点后,也会显示未占用孔位高亮', async () => {
const actor = userEvent.setup()
render(
<ControlledWorkbench
initialWorkspace={buildSingleBoardHoleWorkspace()}
rightDrawerContent={<div>右侧业务区</div>}
/>,
)
await actor.click(screen.getByRole('button', { name: '模拟选中首节点' }))
await waitFor(() => {
expect(screen.getByTestId('selected-node')).not.toHaveTextContent('none')
expect(screen.getByTestId('hole-overlay-count')).toHaveTextContent('孔位覆盖:1')
expect(screen.getByTestId('hole-overlay-first-metrics')).toHaveTextContent('8/12')
expect(screen.getByTestId('hole-overlay-first-type')).toHaveTextContent('assembly-unused')
expect(screen.getByTestId('active-hole-panel-count')).toHaveTextContent('激活孔位面板:0')
})
})
it('选中多孔节点并出现吸附候选时,会保留选中节点全部自身孔位可见', async () => {
const actor = userEvent.setup()
render(
<ControlledWorkbench
initialWorkspace={buildMultiHoleSnapWorkspace()}
rightDrawerContent={<div>右侧业务区</div>}
/>,
)
await actor.click(screen.getByRole('button', { name: '模拟选中第二节点' }))
await waitFor(() => {
expect(screen.getByTestId('selected-node')).not.toHaveTextContent('none')
expect(screen.getByTestId('hole-overlay-count')).toHaveTextContent('孔位覆盖:4')
expect(screen.getByTestId('hole-overlay-link-count')).toHaveTextContent('连接孔位:4')
expect(screen.getByTestId('active-hole-panel-count')).toHaveTextContent('激活孔位面板:4')
expect(screen.getByTestId('active-hole-panel-ids')).toHaveTextContent('leg-hole-2')
})
})
it('hover 已使用孔位时,会高亮占用该孔位的节点', async () => {
const actor = userEvent.setup()
const preview = applyAutomaticAssemblySnapPreview(buildHoleSnapWorkspace(), 'node-2')
const snapped = attemptSnapNode(preview, 'node-2')
const preview = buildHoleSnapWorkspace()
const previewNodeId = preview.nodes.find((node) => node.snapBinding)?.nodeId
const snapped = previewNodeId ? attemptSnapNode(preview, previewNodeId) : preview
const occupiedNodeId = snapped.nodes.find((node) => node.snapBinding)?.nodeId
expect(occupiedNodeId).toBeTruthy()
expect(snapped.nodes.find((node) => node.nodeId === occupiedNodeId)?.isSnapped).toBe(true)
render(
<ControlledWorkbench
@ -690,6 +985,8 @@ describe('AssemblyWorkbenchV2', () => {
await waitFor(() => {
expect(screen.getByTestId('hole-overlay-count')).toHaveTextContent('孔位覆盖:2')
expect(screen.getByTestId('hole-overlay-link-count')).toHaveTextContent('连接孔位:2')
expect(screen.getByTestId('hole-overlay-first-type')).toHaveTextContent('assembly-used')
expect(screen.getByTestId('highlighted-node-names')).toHaveTextContent('none')
})
@ -720,6 +1017,40 @@ describe('AssemblyWorkbenchV2', () => {
})
})
it('拖动中预吸附会把吸附后的坐标回传给 3D 模型本体', async () => {
const actor = userEvent.setup()
render(
<ControlledWorkbench
initialWorkspace={buildHoleSnapWorkspace()}
rightDrawerContent={<div>右侧业务区</div>}
/>,
)
await actor.click(screen.getByRole('button', { name: '模拟小幅拖动第二节点' }))
await waitFor(() => {
expect(screen.getByTestId('last-effective-transform')).toHaveTextContent('[110,708,0]')
})
})
it('拖很远才进入吸附范围后,小幅拖动仍按进入吸附的位置保持吸住', async () => {
const actor = userEvent.setup()
render(
<ControlledWorkbench
initialWorkspace={buildUnsnappedHoleWorkspace()}
rightDrawerContent={<div>右侧业务区</div>}
/>,
)
await actor.click(screen.getByRole('button', { name: '模拟远距离进入吸附后小幅拖动第二节点' }))
await waitFor(() => {
expect(screen.getByTestId('last-effective-transform')).toHaveTextContent('[110,708,0]')
})
})
it('选中缺少吸附图样语义的节点时,会明确提示无法自动吸附', async () => {
const actor = userEvent.setup()
@ -776,4 +1107,19 @@ describe('AssemblyWorkbenchV2', () => {
expect(screen.getByTestId('context-menu')).toBeInTheDocument()
expect(screen.getByRole('button', { name: '移除 3D 世界' })).toBeInTheDocument()
})
it('右键菜单使用资产中心预览菜单的简洁浮层口径', async () => {
const actor = userEvent.setup()
render(<ControlledWorkbench />)
await openBoardBrowserAndLoadFirstVariant(actor)
await actor.click(screen.getByRole('button', { name: '模拟右键首节点' }))
const menu = screen.getByTestId('context-menu')
expect(menu).toHaveAttribute('data-title', '')
expect(menu).toHaveAttribute('data-subtitle', '')
expect(menu).toHaveAttribute('data-width', '220')
expect(Number(menu.getAttribute('data-body-max-height'))).toBeGreaterThanOrEqual(240)
})
})

View File

@ -0,0 +1,36 @@
import { render, screen } from '@testing-library/react'
import { AssetVariantThumbnailRenderer } from '../AssetVariantThumbnailRenderer'
jest.mock('next/dynamic', () => {
return function dynamic() {
return function MockModelViewer() {
return <div data-testid="thumbnail-model-viewer" />
}
}
})
describe('AssetVariantThumbnailRenderer', () => {
afterEach(() => {
delete document.body.dataset.assetThumbnailRenderer
delete document.body.dataset.thumbnailState
delete document.body.dataset.thumbnailError
})
it('marks the page as a thumbnail renderer so dev chrome is excluded from captures', () => {
const { unmount } = render(
<AssetVariantThumbnailRenderer
modelUrl="/models/board.glb"
assetType="BOARD"
/>,
)
expect(screen.getByTestId('thumbnail-model-viewer')).toBeInTheDocument()
expect(document.body.dataset.assetThumbnailRenderer).toBe('true')
expect(document.body.dataset.thumbnailState).toBe('loading')
unmount()
expect(document.body.dataset.assetThumbnailRenderer).toBeUndefined()
expect(document.body.dataset.thumbnailState).toBeUndefined()
})
})

View File

@ -248,7 +248,7 @@ export function ContextMenu({
onClick={() => onItemClick?.(item)}
onMouseEnter={() => onItemMouseEnter?.(item)}
onMouseLeave={onItemMouseLeave}
isActive={item.submenu === activeSubmenu}
isActive={Boolean(item.submenu && activeSubmenu && item.submenu === activeSubmenu)}
/>
);
})}

View File

@ -0,0 +1,52 @@
import { render, screen } from '@testing-library/react'
import { ContextMenu } from '../ContextMenu'
describe('ContextMenu', () => {
it('未传 activeSubmenu 时,普通菜单项不应该误用激活态样式', () => {
render(
<ContextMenu
visible
position={{ left: 0, top: 0 }}
groups={[
{
id: 'node-actions',
items: [
{ id: 'move-node', label: '移到待装区' },
{ id: 'remove-node', label: '移除 3D 世界' },
],
},
]}
/>,
)
const moveItem = screen.getByRole('button', { name: '移到待装区' })
const removeItem = screen.getByRole('button', { name: '移除 3D 世界' })
expect(moveItem).toHaveClass('bg-transparent')
expect(removeItem).toHaveClass('bg-transparent')
expect(moveItem).not.toHaveClass('bg-white/95')
expect(removeItem).not.toHaveClass('bg-white/95')
})
it('只有命中 activeSubmenu 的子菜单项才使用激活态样式', () => {
render(
<ContextMenu
visible
position={{ left: 0, top: 0 }}
activeSubmenu="preview-substrate"
groups={[
{
id: 'preview-actions',
items: [
{ id: 'change-substrate', label: '快捷更改基材', submenu: 'preview-substrate' },
{ id: 'go-assembly', label: '去辅助装配' },
],
},
]}
/>,
)
expect(screen.getByRole('button', { name: '快捷更改基材 >' })).toHaveClass('bg-white/95')
expect(screen.getByRole('button', { name: '去辅助装配' })).toHaveClass('bg-transparent')
})
})

View File

@ -658,7 +658,35 @@ export class ThreeDEngine {
this.scene.add(model);
model.updateMatrixWorld(true);
const positionedBox = new THREE.Box3().setFromObject(model);
this.refreshMountedModelTracking();
const positionedBox = new THREE.Box3().setFromObject(this.modelRoot);
if (!positionedBox.isEmpty()) {
if (autoFrame) {
this.frameCameraToBounds(
positionedBox,
cameraTarget ?? this.modelOriginalCenter.clone(),
);
} else if (preservedCameraState) {
this.restoreCameraState(preservedCameraState);
}
} else if (preservedCameraState) {
this.restoreCameraState(preservedCameraState);
}
return model;
}
private refreshMountedModelTracking(): void {
if (!this.modelRoot) return;
this.meshOriginalPositions.clear();
this.meshOriginalWorldCenters.clear();
this.modelOriginalCenter.set(0, 0, 0);
this.modelOriginalMaxDim = 0;
this.unresolvedPartWarnings.clear();
this.modelRoot.updateMatrixWorld(true);
const positionedBox = new THREE.Box3().setFromObject(this.modelRoot);
if (!positionedBox.isEmpty()) {
this.updateSceneGridForBounds(positionedBox);
this.modelOriginalCenter.copy(
@ -672,7 +700,7 @@ export class ThreeDEngine {
);
}
model.traverse((child) => {
this.modelRoot.traverse((child) => {
if ((child as THREE.Mesh).isMesh) {
const mesh = child as THREE.Mesh;
ensureMeshUv(mesh);
@ -691,21 +719,6 @@ export class ThreeDEngine {
}
}
});
if (!positionedBox.isEmpty()) {
if (autoFrame) {
this.frameCameraToBounds(
positionedBox,
cameraTarget ?? this.modelOriginalCenter.clone(),
);
} else if (preservedCameraState) {
this.restoreCameraState(preservedCameraState);
}
} else if (preservedCameraState) {
this.restoreCameraState(preservedCameraState);
}
return model;
}
private async loadModelAsset(
@ -826,6 +839,33 @@ export class ThreeDEngine {
return groundedRoot;
}
private async createWorldNodeWrapper(
node: EngineWorldNode,
): Promise<THREE.Group> {
const wrapper = new THREE.Group();
wrapper.name = node.nodeName;
const position = node.position ?? [0, 0, 0];
const rotation = node.rotation ?? [0, 0, 0];
const scale = node.scale ?? [1, 1, 1];
const source = node.source ?? "unknown";
wrapper.position.set(position[0], position[1], position[2]);
wrapper.rotation.set(rotation[0], rotation[1], rotation[2]);
wrapper.scale.set(scale[0], scale[1], scale[2]);
let content: THREE.Object3D;
if (node.modelUrl?.trim()) {
const { model } = await this.loadModelAsset(node.modelUrl.trim());
this.applyExpectedDimensionsScale(model, node.expectedDimensions);
content = this.buildWorkbenchNodeContent(model);
} else {
content = this.createWorkbenchProxy(source);
}
wrapper.add(content);
return wrapper;
}
private applyExpectedDimensionsScale(
model: THREE.Object3D,
expectedDimensions?: ModelExpectedDimensions | null,
@ -912,27 +952,7 @@ export class ThreeDEngine {
}
for (const node of nodes) {
const wrapper = new THREE.Group();
wrapper.name = node.nodeName;
const position = node.position ?? [0, 0, 0];
const rotation = node.rotation ?? [0, 0, 0];
const scale = node.scale ?? [1, 1, 1];
const source = node.source ?? "unknown";
wrapper.position.set(position[0], position[1], position[2]);
wrapper.rotation.set(rotation[0], rotation[1], rotation[2]);
wrapper.scale.set(scale[0], scale[1], scale[2]);
let content: THREE.Object3D;
if (node.modelUrl?.trim()) {
const { model } = await this.loadModelAsset(node.modelUrl.trim());
this.applyExpectedDimensionsScale(model, node.expectedDimensions);
content = this.buildWorkbenchNodeContent(model);
} else {
content = this.createWorkbenchProxy(source);
}
wrapper.add(content);
const wrapper = await this.createWorldNodeWrapper(node);
worldRoot.add(wrapper);
}
@ -943,6 +963,48 @@ export class ThreeDEngine {
});
}
async syncWorldNodes(nodes: EngineWorldNode[]): Promise<THREE.Group> {
if (!this.modelRoot || this.modelRoot.name !== "workspace-world") {
return this.loadWorldNodes(nodes, { frame: "initial" });
}
const nextNodeNames = new Set(nodes.map((node) => node.nodeName));
const existingByName = new Map<string, THREE.Object3D>();
this.modelRoot.children.forEach((child) => {
if (child.name) {
existingByName.set(child.name, child);
}
});
for (const child of [...this.modelRoot.children]) {
if (!nextNodeNames.has(child.name)) {
this.modelRoot.remove(child);
this.disposeSceneObjects(child);
}
}
for (const node of nodes) {
let wrapper = existingByName.get(node.nodeName) as THREE.Group | undefined;
if (!wrapper) {
wrapper = await this.createWorldNodeWrapper(node);
this.modelRoot.add(wrapper);
}
const position = node.position ?? [0, 0, 0];
const rotation = node.rotation ?? [0, 0, 0];
const scale = node.scale ?? [1, 1, 1];
wrapper.position.set(position[0], position[1], position[2]);
wrapper.rotation.set(rotation[0], rotation[1], rotation[2]);
wrapper.scale.set(scale[0], scale[1], scale[2]);
wrapper.updateMatrix();
wrapper.updateMatrixWorld(true);
}
this.modelRoot.updateMatrixWorld(true);
this.refreshMountedModelTracking();
return this.modelRoot;
}
/**
* 璁剧疆鐖嗙偢鍥炬晥鏋? * @param progress 鐖嗙偢杩涘害锛?-1
* @param spread 鐖嗙偢鎵╂暎寮哄害

View File

@ -5,10 +5,10 @@ import {
} from '../assisted-assembly-variant'
describe('assisted assembly variant thumbnails', () => {
it('falls back from generated thumbnails to material preview assets', () => {
it('uses generated model thumbnails before persisted model thumbnails', () => {
const variant = {
runtimeThumbnailUrl: '/internal/runtime.png',
thumbnailUrl: null,
thumbnailUrl: '/asset-thumbnails/board.png',
materialConfig: {
id: 'finish-oak',
name: '暖白橡木',
@ -21,12 +21,11 @@ describe('assisted assembly variant thumbnails', () => {
expect(getAssistedAssemblyVariantThumbnailUrl(variant)).toBe('/internal/runtime.png')
expect(getAssistedAssemblyVariantThumbnailSources(variant)).toEqual([
'/internal/runtime.png',
'/materials/thumbnails/oak.jpg',
'/materials/pbr/oak/oak_col.jpg',
'/asset-thumbnails/board.png',
])
})
it('uses material thumbnails when the variant has no generated thumbnail', () => {
it('does not use material textures as assisted assembly model thumbnails', () => {
const variant = {
thumbnailUrl: null,
runtimeThumbnailUrl: null,
@ -38,9 +37,17 @@ describe('assisted assembly variant thumbnails', () => {
},
} as AssistedAssemblyVariant
expect(getAssistedAssemblyVariantThumbnailUrl(variant)).toBe('/materials/thumbnails/walnut.jpg')
expect(getAssistedAssemblyVariantThumbnailSources(variant)).toEqual([
'/materials/thumbnails/walnut.jpg',
])
expect(getAssistedAssemblyVariantThumbnailUrl(variant)).toBeNull()
expect(getAssistedAssemblyVariantThumbnailSources(variant)).toEqual([])
})
it('skips asset placeholder thumbnails so the workbench can render a clearer fallback', () => {
const variant = {
thumbnailUrl: '/asset-thumbnails/placeholder.svg',
runtimeThumbnailUrl: null,
materialConfig: null,
} as AssistedAssemblyVariant
expect(getAssistedAssemblyVariantThumbnailSources(variant)).toEqual([])
})
})

File diff suppressed because it is too large Load Diff

View File

@ -29,8 +29,6 @@ export function getAssistedAssemblyVariantThumbnailUrl(
return (
variant?.runtimeThumbnailUrl ??
variant?.thumbnailUrl ??
variant?.materialConfig?.thumbnailUrl ??
variant?.materialConfig?.textureUrl ??
null
)
}
@ -41,11 +39,10 @@ export function getAssistedAssemblyVariantThumbnailSources(
const sources = [
variant?.runtimeThumbnailUrl,
variant?.thumbnailUrl,
variant?.materialConfig?.thumbnailUrl,
variant?.materialConfig?.textureUrl,
]
.map((value) => (typeof value === 'string' && value.trim().length > 0 ? value.trim() : null))
.filter((value): value is string => Boolean(value))
.filter((value) => !value.includes('/asset-thumbnails/placeholder.svg'))
return Array.from(new Set(sources))
}

View File

@ -17,6 +17,7 @@ export type ManualAssemblyProductRecovery =
export interface ManualAssemblyLaunchParams {
entryMode: AssemblyWorkspaceEntryMode
source: AssemblyWorkspaceSource
assetSeriesId?: string | null
sourceSkuId?: string | null
sourceProductId?: string | null
selectedFamilyIdsByType: ManualAssemblySelectedFamilyIdsByType
@ -84,6 +85,10 @@ export function buildManualAssemblyLaunchHref(
searchParams.set('sourceProductId', params.sourceProductId)
}
if (params.assetSeriesId) {
searchParams.set('assetSeriesId', params.assetSeriesId)
}
if (params.redirectedFrom) {
searchParams.set('redirectedFrom', params.redirectedFrom)
}
@ -131,6 +136,7 @@ export function parseManualAssemblyLaunchParams(
const entryModeValue = searchParams.get('entryMode')?.trim()
const sourceSkuId = searchParams.get('sourceSkuId')?.trim() ?? null
const sourceProductId = searchParams.get('sourceProductId')?.trim() ?? null
const assetSeriesId = searchParams.get('assetSeriesId')?.trim() ?? null
const redirectedFrom = normalizeLegacyEntry(searchParams.get('redirectedFrom')?.trim())
const productRecovery = normalizeProductRecovery(searchParams.get('productRecovery')?.trim())
const recoverableSourceSkuCount = normalizePositiveInteger(
@ -154,6 +160,7 @@ export function parseManualAssemblyLaunchParams(
return {
entryMode,
source,
assetSeriesId,
sourceSkuId,
sourceProductId,
selectedFamilyIdsByType,

View File

@ -3,6 +3,7 @@
import type {
AssetFamilyDTO,
AssetCadImportResult,
AssetSeriesDTO,
AssetVariantDTO,
AssetVariantBindingDTO,
PreSkuBindingDTO,
@ -67,6 +68,48 @@ export async function createAssetFamily(input: CreateAssetFamilyInput): Promise<
return data.family
}
export async function listAssetSeries(): Promise<AssetSeriesDTO[]> {
const data = await requestPlatform<{ series: AssetSeriesDTO[] }>('/api/asset-center/series')
return data.series
}
export async function listAssetFamilies(input?: {
assetSeriesId?: string | null
type?: 'BOARD' | 'COMPONENT' | 'HARDWARE' | 'PACKAGING' | null
status?: 'DRAFT' | 'PENDING_REVIEW' | 'REJECTED' | 'AVAILABLE' | 'DISABLED' | null
}): Promise<AssetFamilyDTO[]> {
const searchParams = new URLSearchParams()
if (input?.assetSeriesId) searchParams.set('assetSeriesId', input.assetSeriesId)
if (input?.type) searchParams.set('type', input.type)
if (input?.status) searchParams.set('status', input.status)
const query = searchParams.toString()
const data = await requestPlatform<{ families: AssetFamilyDTO[] }>(
`/api/asset-center/families${query ? `?${query}` : ''}`,
)
return data.families
}
export async function listAssetVariantsByFamilyIds(
familyIds: string[],
input?: {
status?: 'DRAFT' | 'PENDING_REVIEW' | 'REJECTED' | 'AVAILABLE' | 'DISABLED' | null
},
): Promise<AssetVariantDTO[]> {
if (familyIds.length === 0) {
return []
}
const searchParams = new URLSearchParams()
familyIds.forEach((familyId) => {
searchParams.append('familyId', familyId)
})
if (input?.status) searchParams.set('status', input.status)
const data = await requestPlatform<{ variants: AssetVariantDTO[] }>(
`/api/asset-center/variants?${searchParams.toString()}`,
)
return data.variants
}
export async function updateAssetFamily(
id: string,
input: Record<string, unknown>,
@ -240,6 +283,7 @@ export interface ImportAssetCadPayload {
familyCode?: string | null
familyName?: string | null
familyDescription?: string | null
assetSeriesId?: string | null
connectionRoles?: string[]
variantCode: string
variantName: string
@ -258,6 +302,7 @@ export async function importAssetCad(input: ImportAssetCadPayload): Promise<Asse
if (input.familyCode) formData.append('familyCode', input.familyCode)
if (input.familyName) formData.append('familyName', input.familyName)
if (input.familyDescription) formData.append('familyDescription', input.familyDescription)
if (input.assetSeriesId) formData.append('assetSeriesId', input.assetSeriesId)
if (input.connectionRoles && input.connectionRoles.length > 0) {
formData.append('connectionRoles', JSON.stringify(input.connectionRoles))
}

View File

@ -23,7 +23,6 @@ export interface OpenProductAssetFamily {
assetType: OpenProductAssetType
connectionRoles: string[]
productCategoryId?: string | null
applicableSeriesCodes?: string[]
}
export interface OpenProductAssetVariant {

View File

@ -19,7 +19,7 @@ function createFamily(overrides?: Partial<AssetFamilyDTO>): AssetFamilyDTO {
description: null,
productCategoryId: null,
productCategoryName: null,
applicableSeriesCodes: [],
compatibleSeries: [{ id: 'template-1', code: 'single-board-desk', name: '单板桌' }],
connectionRoles: [],
metadata: {
categoryLevel1: 'outer',

View File

@ -1,4 +1,4 @@
import { AssetFamily, AssetVariant, Prisma, Substrate } from '@prisma/client'
import { AssetFamily, AssetSeries, AssetVariant, Prisma, Substrate } from '@prisma/client'
import { BaseRepository, FindManyOptions } from './base.repository'
import {
buildDeletedAssetVariantUniqueValue,
@ -32,6 +32,7 @@ export interface AssetFamilyFilter {
type?: AssetFamily['type']
status?: AssetFamily['status']
productCategoryId?: string
assetSeriesId?: string
search?: string
}
@ -42,6 +43,16 @@ export interface AssetFamilyWithRelations extends AssetFamily {
reviewer: { id: string; name: string } | null
rejecter: { id: string; name: string } | null
lastModifier: { id: string; name: string } | null
compatibleSeries: Array<{
assetSeries: {
id: string
code: string
name: string
description: string | null
sortOrder: number
isActive: boolean
}
}>
}
export interface AssetVariantFilter {
@ -80,6 +91,21 @@ export interface SubstrateFilter {
export const assetFamilyRelationInclude = {
productCategory: { select: { id: true, name: true } },
compatibleSeries: {
select: {
assetSeries: {
select: {
id: true,
code: true,
name: true,
description: true,
sortOrder: true,
isActive: true,
},
},
},
orderBy: { assetSeries: { sortOrder: 'asc' } },
},
creator: { select: { id: true, name: true } },
submitter: { select: { id: true, name: true } },
reviewer: { select: { id: true, name: true } },
@ -249,21 +275,75 @@ class AssetFamilyRepositoryClass extends BaseRepository<
return { deletedAt: null }
}
const and: Prisma.AssetFamilyWhereInput[] = []
if (filter.assetSeriesId && filter.type !== 'HARDWARE') {
const seriesScope: Prisma.AssetFamilyWhereInput = {
compatibleSeries: {
some: {
assetSeriesId: filter.assetSeriesId,
assetSeries: { isActive: true },
},
},
}
and.push(
filter.type
? seriesScope
: {
OR: [
{ type: 'HARDWARE' },
seriesScope,
],
},
)
}
if (filter.search) {
and.push({
OR: [
{ name: { contains: filter.search, mode: 'insensitive' } },
{ code: { contains: filter.search, mode: 'insensitive' } },
],
})
}
return {
deletedAt: null,
type: filter.type,
status: filter.status,
productCategoryId: filter.productCategoryId,
OR: filter.search
? [
{ name: { contains: filter.search, mode: 'insensitive' } },
{ code: { contains: filter.search, mode: 'insensitive' } },
]
: undefined,
AND: and.length > 0 ? and : undefined,
}
}
}
class AssetSeriesRepositoryClass extends BaseRepository<
AssetSeries,
Prisma.AssetSeriesCreateInput,
Prisma.AssetSeriesUpdateInput
> {
protected modelName = 'assetSeries'
async findAllActive(): Promise<AssetSeries[]> {
return this.prisma.assetSeries.findMany({
where: { isActive: true },
orderBy: [{ sortOrder: 'asc' }, { name: 'asc' }],
})
}
async findByIds(ids: string[]): Promise<AssetSeries[]> {
if (ids.length === 0) return []
return this.prisma.assetSeries.findMany({
where: { id: { in: ids }, isActive: true },
orderBy: [{ sortOrder: 'asc' }, { name: 'asc' }],
})
}
async createSeries(data: Prisma.AssetSeriesCreateInput): Promise<AssetSeries> {
return this.prisma.assetSeries.create({ data })
}
}
class AssetVariantRepositoryClass extends BaseRepository<
AssetVariant,
Prisma.AssetVariantCreateInput,
@ -530,11 +610,13 @@ class SubstrateRepositoryClass extends BaseRepository<
}
export const assetFamilyRepository = new AssetFamilyRepositoryClass()
export const assetSeriesRepository = new AssetSeriesRepositoryClass()
export const assetVariantRepository = new AssetVariantRepositoryClass()
export const substrateRepository = new SubstrateRepositoryClass()
export {
AssetFamilyRepositoryClass,
AssetSeriesRepositoryClass,
AssetVariantRepositoryClass,
SubstrateRepositoryClass,
}

View File

@ -3,7 +3,7 @@ import { BaseRepository } from './base.repository'
export interface DraftGroupWithRelations extends DraftGroup {
category: { id: string; name: string } | null
seriesTemplate: { id: string; code: string; name: string; seriesName: string } | null
assetSeries: { id: string; code: string; name: string } | null
creator: { id: string; name: string }
}
@ -23,7 +23,7 @@ class DraftGroupRepositoryClass extends BaseRepository<
where: { id },
include: {
category: { select: { id: true, name: true } },
seriesTemplate: { select: { id: true, code: true, name: true, seriesName: true } },
assetSeries: { select: { id: true, code: true, name: true } },
creator: { select: { id: true, name: true } },
},
}) as Promise<DraftGroupWithRelations | null>
@ -34,7 +34,7 @@ class DraftGroupRepositoryClass extends BaseRepository<
orderBy: { updatedAt: 'desc' },
include: {
category: { select: { id: true, name: true } },
seriesTemplate: { select: { id: true, code: true, name: true, seriesName: true } },
assetSeries: { select: { id: true, code: true, name: true } },
creator: { select: { id: true, name: true } },
},
}) as Promise<DraftGroupWithRelations[]>
@ -45,7 +45,7 @@ class DraftGroupRepositoryClass extends BaseRepository<
data,
include: {
category: { select: { id: true, name: true } },
seriesTemplate: { select: { id: true, code: true, name: true, seriesName: true } },
assetSeries: { select: { id: true, code: true, name: true } },
creator: { select: { id: true, name: true } },
},
}) as Promise<DraftGroupWithRelations>

View File

@ -114,11 +114,13 @@ export type {
export {
assetFamilyRelationInclude,
assetFamilyRepository,
assetSeriesRepository,
assetVariantRelationInclude,
assetVariantRepository,
substrateRelationInclude,
substrateRepository,
AssetFamilyRepositoryClass,
AssetSeriesRepositoryClass,
AssetVariantRepositoryClass,
SubstrateRepositoryClass,
} from './asset-center.repository'
@ -132,9 +134,6 @@ export type {
SubstrateWithRelations,
} from './asset-center.repository'
export { seriesTemplateRepository, SeriesTemplateRepositoryClass } from './series-template.repository'
export type { SeriesTemplateWithRelations } from './series-template.repository'
export {
draftGroupRepository,
preSkuRepository,

View File

@ -1,53 +0,0 @@
import { Prisma, SeriesTemplate } from '@prisma/client'
import { BaseRepository } from './base.repository'
export interface SeriesTemplateWithRelations extends SeriesTemplate {
category: { id: string; name: string } | null
creator: { id: string; name: string } | null
}
class SeriesTemplateRepositoryClass extends BaseRepository<
SeriesTemplate,
Prisma.SeriesTemplateCreateInput,
Prisma.SeriesTemplateUpdateInput
> {
protected modelName = 'seriesTemplate'
async findByCode(code: string): Promise<SeriesTemplate | null> {
return this.prisma.seriesTemplate.findUnique({ where: { code } })
}
async findByIdWithRelations(id: string): Promise<SeriesTemplateWithRelations | null> {
return this.prisma.seriesTemplate.findUnique({
where: { id },
include: {
category: { select: { id: true, name: true } },
creator: { select: { id: true, name: true } },
},
}) as Promise<SeriesTemplateWithRelations | null>
}
async findAllActiveWithRelations(): Promise<SeriesTemplateWithRelations[]> {
return this.prisma.seriesTemplate.findMany({
where: { isActive: true },
orderBy: [{ isPreset: 'desc' }, { updatedAt: 'desc' }],
include: {
category: { select: { id: true, name: true } },
creator: { select: { id: true, name: true } },
},
}) as Promise<SeriesTemplateWithRelations[]>
}
async createWithRelations(data: Prisma.SeriesTemplateCreateInput): Promise<SeriesTemplateWithRelations> {
return this.prisma.seriesTemplate.create({
data,
include: {
category: { select: { id: true, name: true } },
creator: { select: { id: true, name: true } },
},
}) as Promise<SeriesTemplateWithRelations>
}
}
export const seriesTemplateRepository = new SeriesTemplateRepositoryClass()
export { SeriesTemplateRepositoryClass }

View File

@ -54,7 +54,6 @@ function createFamilyDTO(overrides: Record<string, unknown> = {}) {
description: null,
productCategoryId: null,
productCategoryName: null,
applicableSeriesCodes: [],
connectionRoles: [],
metadata: null,
status: 'DRAFT',
@ -245,6 +244,7 @@ describe('assetCadImportService', () => {
familyCode: 'BOARD-TOP',
familyName: '桌面板',
familyDescription: '单板桌桌面板',
assetSeriesId: 'series-single-board-desk',
connectionRoles: ['desktop'],
variantCode: 'BOARD-1200',
variantName: '桌面板 1200',
@ -259,6 +259,7 @@ describe('assetCadImportService', () => {
code: 'BOARD-TOP',
name: '桌面板',
description: '单板桌桌面板',
compatibleSeriesIds: ['series-single-board-desk'],
connectionRoles: ['desktop'],
createdBy: 'user-1',
})
@ -483,6 +484,7 @@ describe('assetCadImportService', () => {
familyName: '',
variantCode: '',
variantName: '',
assetSeriesId: 'series-single-board-desk',
substrateId: 'substrate-board',
finishId: 'finish-oak',
zipFile: createZipFile('140台面.zip'),
@ -495,6 +497,7 @@ describe('assetCadImportService', () => {
code: 'BOARD-TOP_PANEL',
name: '顶板',
description: null,
compatibleSeriesIds: ['series-single-board-desk'],
connectionRoles: [],
createdBy: 'user-1',
})

View File

@ -48,6 +48,7 @@ jest.mock('../../repositories', () => ({
}))
jest.mock('../asset-variant-thumbnail.service', () => ({
ASSET_VARIANT_THUMBNAIL_PLACEHOLDER_URL: '/asset-thumbnails/placeholder.svg',
assetVariantThumbnailService: {
generateForVariant: jest.fn(),
getPlaceholderUrl: jest.fn(() => '/asset-thumbnails/placeholder.svg'),
@ -711,6 +712,125 @@ describe('assetCenterService', () => {
expect(result.variant.thumbnailUrl).toBe('https://cdn.example.com/generated-thumbnail.png')
})
it('创建规格款传入占位缩略图时仍会尝试生成真实缩略图', async () => {
mockedAssetVariantRepository.createWithRelations.mockResolvedValue({
id: 'variant-board-top-placeholder',
assetFamilyId: 'family-board-top',
code: 'BOARD-TOP-PLACEHOLDER',
name: '桌面板 占位图',
identityKey:
'BOARD|code=BOARD-TOP-PLACEHOLDER|length=1200|thickness=25|width=600|substrate=substrate-particle-board',
assetType: 'BOARD',
status: 'DRAFT',
sizeLabel: null,
substrateId: 'substrate-particle-board',
finishId: 'finish-white-oak',
dimensions: { length: 1200, width: 600, thickness: 25 },
modelUrl: '/models/board-placeholder.glb',
thumbnailUrl: '/asset-thumbnails/placeholder.svg',
mountPoints: [{ name: 'origin', position: [0, 0, 0] }],
assemblyBasis: null,
compatibilityRules: null,
namingFields: null,
businessData: null,
weightKg: null,
costPrice: null,
lossFactor: 1,
estimatedCostPrice: 21.79,
inheritedFromVariantId: null,
createdBy: 'user-1',
creator: { id: 'user-1', name: '测试用户' },
assetFamily: {
id: 'family-board-top',
code: 'BOARD-TOP',
name: '桌面板资产族',
type: 'BOARD',
},
substrate: {
id: 'substrate-particle-board',
code: 'PB18',
name: '颗粒板',
},
finish: {
id: 'finish-white-oak',
name: '白橡',
},
createdAt: new Date('2026-04-11T09:05:00.000Z'),
updatedAt: new Date('2026-04-11T09:05:00.000Z'),
} as never)
mockedAssetVariantRepository.updateWithRelations.mockResolvedValue({
id: 'variant-board-top-placeholder',
assetFamilyId: 'family-board-top',
code: 'BOARD-TOP-PLACEHOLDER',
name: '桌面板 占位图',
identityKey:
'BOARD|code=BOARD-TOP-PLACEHOLDER|length=1200|thickness=25|width=600|substrate=substrate-particle-board',
assetType: 'BOARD',
status: 'DRAFT',
sizeLabel: null,
substrateId: 'substrate-particle-board',
finishId: 'finish-white-oak',
dimensions: { length: 1200, width: 600, thickness: 25 },
modelUrl: '/models/board-placeholder.glb',
thumbnailUrl: 'https://cdn.example.com/generated-placeholder-retry.png',
mountPoints: [{ name: 'origin', position: [0, 0, 0] }],
assemblyBasis: null,
compatibilityRules: null,
namingFields: null,
businessData: null,
weightKg: null,
costPrice: null,
lossFactor: 1,
estimatedCostPrice: 21.79,
inheritedFromVariantId: null,
createdBy: 'user-1',
creator: { id: 'user-1', name: '测试用户' },
assetFamily: {
id: 'family-board-top',
code: 'BOARD-TOP',
name: '桌面板资产族',
type: 'BOARD',
},
substrate: {
id: 'substrate-particle-board',
code: 'PB18',
name: '颗粒板',
},
finish: {
id: 'finish-white-oak',
name: '白橡',
},
createdAt: new Date('2026-04-11T09:05:00.000Z'),
updatedAt: new Date('2026-04-11T09:05:00.000Z'),
} as never)
const result = await assetCenterService.createVariant(
{
assetFamilyId: 'family-board-top',
code: 'BOARD-TOP-PLACEHOLDER',
name: '桌面板 占位图',
status: 'DRAFT',
dimensions: { length: 1200, width: 600, thickness: 25 },
substrateId: 'substrate-particle-board',
finishId: 'finish-white-oak',
modelUrl: '/models/board-placeholder.glb',
thumbnailUrl: '/asset-thumbnails/placeholder.svg',
mountPoints: [{ name: 'origin', position: [0, 0, 0] }],
lossFactor: 1,
createdBy: 'user-1',
},
{ renderBaseUrl: 'http://localhost:3000' },
)
expect(mockedAssetVariantThumbnailService.generateForVariant).toHaveBeenCalledWith(
expect.objectContaining({
variantId: 'variant-board-top-placeholder',
modelUrl: '/models/board-placeholder.glb',
}),
)
expect(result.thumbnailUrl).toBe('https://cdn.example.com/generated-placeholder-retry.png')
})
it('更新模型地址后如果渲染失败,会回退统一占位图但不阻塞保存', async () => {
mockedAssetVariantRepository.findByIdWithRelations.mockResolvedValue({
id: 'variant-board-top-update',
@ -1861,7 +1981,6 @@ describe('assetCenterService', () => {
description: '单板桌桌面板',
productCategoryId: 'category-table',
productCategory: { id: 'category-table', name: '餐桌' },
applicableSeriesCodes: ['single-board-desk'],
connectionRoles: ['desktop'],
metadata: { source: 'asset-center' },
status: 'AVAILABLE',
@ -1903,7 +2022,6 @@ describe('assetCenterService', () => {
code: 'BOARD-TOP',
name: '桌面板资产族',
status: 'AVAILABLE',
applicableSeriesCodes: ['single-board-desk'],
connectionRoles: ['desktop'],
}),
])

View File

@ -67,7 +67,6 @@ describe('assetCenterService review workflow', () => {
name: '桌腿资产族',
description: null,
productCategoryId: null,
applicableSeriesCodes: ['single-board-desk'],
connectionRoles: ['desk_leg'],
metadata: null,
status: 'DRAFT',
@ -147,7 +146,6 @@ describe('assetCenterService review workflow', () => {
name: '桌腿资产族',
description: null,
productCategoryId: null,
applicableSeriesCodes: ['single-board-desk'],
connectionRoles: ['desk_leg'],
metadata: null,
status: data.status ?? 'DRAFT',

View File

@ -0,0 +1,25 @@
import { hideDevelopmentChrome } from '../asset-variant-thumbnail.service'
describe('assetVariantThumbnailService development chrome guard', () => {
it('injects a cleanup rule and hides Next.js development overlays before capture', async () => {
const addStyleTag = jest.fn().mockResolvedValue(undefined)
const evaluate = jest.fn().mockResolvedValue(undefined)
await hideDevelopmentChrome({
addStyleTag,
evaluate,
} as never)
expect(addStyleTag).toHaveBeenCalledWith(
expect.objectContaining({
content: expect.stringContaining('nextjs-portal'),
}),
)
expect(addStyleTag).toHaveBeenCalledWith(
expect.objectContaining({
content: expect.stringContaining('[data-nextjs-dev-tools-button]'),
}),
)
expect(evaluate).toHaveBeenCalledTimes(1)
})
})

View File

@ -1,258 +0,0 @@
import { combinationService } from '../combination.service'
import {
assetFamilyRepository,
assetVariantRepository,
seriesTemplateRepository,
} from '../../repositories'
jest.mock('../../repositories', () => ({
assetFamilyRepository: {
findByIdsWithRelations: jest.fn(),
},
assetVariantRepository: {
findAvailableByFamilyIds: jest.fn(),
},
seriesTemplateRepository: {
findByIdWithRelations: jest.fn(),
},
}))
describe('combinationService', () => {
const mockedAssetFamilyRepository = jest.mocked(assetFamilyRepository)
const mockedAssetVariantRepository = jest.mocked(assetVariantRepository)
const mockedSeriesTemplateRepository = jest.mocked(seriesTemplateRepository)
beforeEach(() => {
jest.clearAllMocks()
mockedSeriesTemplateRepository.findByIdWithRelations.mockResolvedValue({
id: 'template-single-board-desk',
code: 'single-board-desk',
name: '单板桌',
seriesName: '云序',
previewRule: {
desktopHeight: 720,
desktopPosition: [0, 720, 0],
legOffsets: {
frontLeft: [-450, 360, 250],
frontRight: [450, 360, 250],
rearLeft: [-450, 360, -250],
rearRight: [450, 360, -250],
},
},
} as never)
mockedAssetFamilyRepository.findByIdsWithRelations.mockResolvedValue([
{
id: 'family-desktop-a',
code: 'TOP-A',
name: '桌面A',
type: 'BOARD',
connectionRoles: ['desktop'],
},
{
id: 'family-leg-a',
code: 'LEG-A',
name: '桌腿A',
type: 'COMPONENT',
connectionRoles: ['desk_leg'],
},
{
id: 'family-connector-a',
code: 'CT-A',
name: '连接件A',
type: 'HARDWARE',
connectionRoles: ['desk_leg_connector'],
},
] as never)
mockedAssetVariantRepository.findAvailableByFamilyIds.mockResolvedValue([
{
id: 'variant-desktop-a',
assetFamilyId: 'family-desktop-a',
code: 'TOP-A-1200',
name: '桌面A 1200',
assetType: 'BOARD',
status: 'AVAILABLE',
dimensions: { length: 1200, width: 600, thickness: 25 },
modelUrl: '/models/top-a.glb',
mountPoints: [{ name: 'origin', position: [0, 0, 0] }],
substrateId: null,
finishId: null,
weightKg: 18,
costPrice: 200,
assemblyBasis: null,
},
{
id: 'variant-leg-a',
assetFamilyId: 'family-leg-a',
code: 'LEG-A-720',
name: '桌腿A 720',
assetType: 'COMPONENT',
status: 'AVAILABLE',
dimensions: { length: 60, width: 60, height: 720 },
modelUrl: '/models/leg-a.glb',
mountPoints: [{ name: 'origin', position: [0, 360, 0] }],
substrateId: null,
finishId: null,
weightKg: 2.5,
costPrice: 38,
assemblyBasis: null,
},
{
id: 'variant-connector-a',
assetFamilyId: 'family-connector-a',
code: 'CT-A-001',
name: '连接件A',
assetType: 'HARDWARE',
status: 'AVAILABLE',
dimensions: { length: 80, width: 40, thickness: 3 },
modelUrl: '/models/connector-a.glb',
mountPoints: [{ name: 'origin', position: [0, 0, 0] }],
substrateId: null,
finishId: null,
weightKg: 0.3,
costPrice: 5,
assemblyBasis: null,
},
] as never)
})
it('返回可直接驱动预览的候选结果', async () => {
const result = await combinationService.generateCandidates({
seriesTemplateId: 'template-single-board-desk',
desktopFamilyIds: ['family-desktop-a'],
legFamilyIds: ['family-leg-a'],
connectorFamilyIds: ['family-connector-a'],
})
expect(result.totalCount).toBe(1)
expect(result.candidates[0]).toEqual(
expect.objectContaining({
previewMode: 'composite-ready',
assemblyResultStatus: 'ASSEMBLABLE',
statusTag: '可直接预览',
componentCodes: ['TOP-A-1200', 'LEG-A-720', 'CT-A-001'],
combinationItems: expect.arrayContaining([
expect.objectContaining({ label: '桌面板', value: '桌面A', quantity: 1 }),
expect.objectContaining({ label: '桌腿', value: '桌腿A', quantity: 4 }),
]),
previewNodes: expect.arrayContaining([
expect.objectContaining({ role: 'desktop', modelUrl: '/models/top-a.glb' }),
expect.objectContaining({ role: 'frontLeft', modelUrl: '/models/leg-a.glb' }),
]),
candidateSnapshot: expect.objectContaining({
key: 'family-desktop-a::family-leg-a',
templateCode: 'single-board-desk',
}),
assemblyWorkspace: expect.objectContaining({
workflowMode: 'assisted-manual',
nodes: expect.arrayContaining([
expect.objectContaining({ role: 'desktop', zone: 'main' }),
expect.objectContaining({ role: 'frontLeft', zone: 'staging' }),
]),
}),
}),
)
})
it('同一资产族存在多个可用规格款时返回警告而不是崩溃', async () => {
mockedAssetVariantRepository.findAvailableByFamilyIds.mockResolvedValue([
{
id: 'variant-desktop-a',
assetFamilyId: 'family-desktop-a',
code: 'TOP-A-1200',
name: '桌面A 1200',
assetType: 'BOARD',
status: 'AVAILABLE',
dimensions: { length: 1200, width: 600, thickness: 25 },
modelUrl: '/models/top-a.glb',
mountPoints: [{ name: 'origin', position: [0, 0, 0] }],
substrateId: null,
finishId: null,
weightKg: 18,
costPrice: 200,
assemblyBasis: null,
},
{
id: 'variant-desktop-a-v2',
assetFamilyId: 'family-desktop-a',
code: 'TOP-A-1400',
name: '桌面A 1400',
assetType: 'BOARD',
status: 'AVAILABLE',
dimensions: { length: 1400, width: 700, thickness: 25 },
modelUrl: '/models/top-a-1400.glb',
mountPoints: [{ name: 'origin', position: [0, 0, 0] }],
substrateId: null,
finishId: null,
weightKg: 20,
costPrice: 220,
assemblyBasis: null,
},
{
id: 'variant-leg-a',
assetFamilyId: 'family-leg-a',
code: 'LEG-A-720',
name: '桌腿A 720',
assetType: 'COMPONENT',
status: 'AVAILABLE',
dimensions: { length: 60, width: 60, height: 720 },
modelUrl: '/models/leg-a.glb',
mountPoints: [{ name: 'origin', position: [0, 360, 0] }],
substrateId: null,
finishId: null,
weightKg: 2.5,
costPrice: 38,
assemblyBasis: null,
},
] as never)
const result = await combinationService.generateCandidates({
seriesTemplateId: 'template-single-board-desk',
desktopFamilyIds: ['family-desktop-a'],
legFamilyIds: ['family-leg-a'],
})
expect(result.candidates[0].assemblyResultStatus).toBe('ASSEMBLABLE_WITH_WARNINGS')
expect(result.candidates[0].assemblyWarnings).toContain(
'桌面A 存在多个可用规格款,当前按最新规格款预览',
)
expect(result.candidates[0].previewNodes.length).toBeGreaterThan(0)
})
it('缺少必选桌面规格款时返回阻断状态和警告', async () => {
mockedAssetVariantRepository.findAvailableByFamilyIds.mockResolvedValue([
{
id: 'variant-leg-a',
assetFamilyId: 'family-leg-a',
code: 'LEG-A-720',
name: '桌腿A 720',
assetType: 'COMPONENT',
status: 'AVAILABLE',
dimensions: { length: 60, width: 60, height: 720 },
modelUrl: '/models/leg-a.glb',
mountPoints: [{ name: 'origin', position: [0, 360, 0] }],
substrateId: null,
finishId: null,
weightKg: 2.5,
costPrice: 38,
assemblyBasis: null,
},
] as never)
const result = await combinationService.generateCandidates({
seriesTemplateId: 'template-single-board-desk',
desktopFamilyIds: ['family-desktop-a'],
legFamilyIds: ['family-leg-a'],
})
expect(result.candidates[0]).toEqual(
expect.objectContaining({
assemblyResultStatus: 'BLOCKED',
statusTag: '缺少规格款',
}),
)
expect(result.candidates[0].assemblyWarnings).toContain('桌面A 缺少可用规格款')
})
})

View File

@ -90,8 +90,8 @@ describe('draftGroupService manual assembly', () => {
status: 'DRAFT',
summary: null,
notes: null,
seriesTemplateId: 'template-1',
seriesTemplate: { id: 'template-1', code: 'single-board-desk', name: '单板桌' },
assetSeriesId: 'template-1',
assetSeries: { id: 'template-1', code: 'single-board-desk', name: '单板桌' },
creator: { id: 'user-1', name: '测试用户' },
createdBy: 'user-1',
createdAt: new Date('2026-04-09T12:00:00Z'),

View File

@ -5,7 +5,6 @@ import {
draftGroupRepository,
preSkuRepository,
preSkuPackagingPlanRepository,
seriesTemplateRepository,
} from '../../repositories'
import type { AssemblyWorkspaceSnapshot } from '@/lib/open-product/assembly-workspace'
@ -32,9 +31,6 @@ jest.mock('../../repositories', () => ({
preSkuPackagingPlanRepository: {
upsertByPreSkuId: jest.fn(),
},
seriesTemplateRepository: {
findByIdWithRelations: jest.fn(),
},
}))
function buildLegacyCandidateWorkspace(input?: {
@ -209,7 +205,6 @@ describe('draftGroupService', () => {
const mockedDraftGroupRepository = jest.mocked(draftGroupRepository)
const mockedPreSkuRepository = jest.mocked(preSkuRepository)
const mockedPreSkuPackagingPlanRepository = jest.mocked(preSkuPackagingPlanRepository)
const mockedSeriesTemplateRepository = jest.mocked(seriesTemplateRepository)
const authUser = {
id: 'user-1',
@ -256,7 +251,6 @@ describe('draftGroupService', () => {
name: '纸箱',
description: null,
productCategoryId: null,
applicableSeriesCodes: [],
connectionRoles: [],
metadata: null,
status: 'AVAILABLE',
@ -273,7 +267,6 @@ describe('draftGroupService', () => {
name: '泡沫垫',
description: null,
productCategoryId: null,
applicableSeriesCodes: [],
connectionRoles: [],
metadata: null,
status: 'AVAILABLE',
@ -287,32 +280,6 @@ describe('draftGroupService', () => {
total: 2,
} as never)
mockedSeriesTemplateRepository.findByIdWithRelations.mockResolvedValue({
id: 'series-template-1',
code: 'single-board-desk',
name: '单板桌',
seriesName: '云序',
categoryId: 'category-desk',
slotSchema: {},
namingRule: {},
recommendationRule: {},
previewRule: {
desktopHeight: 720,
desktopPosition: [0, 720, 0],
legOffsets: {
frontLeft: [-450, 360, 250],
frontRight: [450, 360, 250],
rearLeft: [-450, 360, -250],
rearRight: [450, 360, -250],
},
},
intakeRule: {},
isPreset: true,
isActive: true,
createdAt: new Date('2026-03-30T08:00:00.000Z'),
updatedAt: new Date('2026-03-30T08:00:00.000Z'),
} as never)
mockedAssetVariantRepository.findAvailableByFamilyIds.mockResolvedValue([
{
id: 'variant-desktop-a-white',
@ -506,14 +473,14 @@ describe('draftGroupService', () => {
id: 'draft-group-1',
name: '云序桌系第一组',
categoryId: 'category-desk',
seriesTemplateId: 'series-template-1',
assetSeriesId: 'series-template-1',
status: 'DRAFT',
createdBy: 'user-1',
summary: { selectedCount: 2 },
createdAt: new Date('2026-03-30T12:00:00.000Z'),
updatedAt: new Date('2026-03-30T12:00:00.000Z'),
category: { id: 'category-desk', name: '单板桌' },
seriesTemplate: { id: 'series-template-1', code: 'single-board-desk', name: '单板桌' },
assetSeries: { id: 'series-template-1', code: 'single-board-desk', name: '单板桌' },
creator: { id: 'user-1', name: '规划员' },
} as never)
@ -531,7 +498,7 @@ describe('draftGroupService', () => {
name: '云序桌系第一组',
description: null,
categoryId: 'category-desk',
seriesTemplateId: 'series-template-1',
assetSeriesId: 'series-template-1',
status: 'DRAFT',
summary: { selectedCount: 1 },
notes: null,
@ -539,7 +506,7 @@ describe('draftGroupService', () => {
createdAt: new Date('2026-03-30T12:00:00.000Z'),
updatedAt: new Date('2026-03-30T12:00:00.000Z'),
category: { id: 'category-desk', name: '单板桌' },
seriesTemplate: {
assetSeries: {
id: 'series-template-1',
code: 'single-board-desk',
name: '单板桌',
@ -1306,7 +1273,7 @@ describe('draftGroupService', () => {
name: '云序桌系第一组',
description: null,
categoryId: 'category-desk',
seriesTemplateId: 'series-template-1',
assetSeriesId: 'series-template-1',
status: 'PACKAGING',
summary: { selectedCount: 2 },
notes: null,
@ -1314,7 +1281,7 @@ describe('draftGroupService', () => {
createdAt: new Date('2026-03-30T12:00:00.000Z'),
updatedAt: new Date('2026-03-30T12:00:00.000Z'),
category: { id: 'category-desk', name: '单板桌' },
seriesTemplate: {
assetSeries: {
id: 'series-template-1',
code: 'single-board-desk',
name: '单板桌',
@ -1767,7 +1734,7 @@ describe('draftGroupService', () => {
name: '云序桌系第一组',
description: null,
categoryId: 'category-desk',
seriesTemplateId: 'series-template-1',
assetSeriesId: 'series-template-1',
status: 'READY_FOR_INTAKE',
summary: { selectedCount: 2 },
notes: null,
@ -1775,7 +1742,7 @@ describe('draftGroupService', () => {
createdAt: new Date('2026-03-30T12:00:00.000Z'),
updatedAt: new Date('2026-03-30T12:00:00.000Z'),
category: { id: 'category-desk', name: '单板桌' },
seriesTemplate: {
assetSeries: {
id: 'series-template-1',
code: 'single-board-desk',
name: '单板桌',
@ -1861,7 +1828,7 @@ describe('draftGroupService', () => {
name: '云序桌系第一组',
description: null,
categoryId: 'category-desk',
seriesTemplateId: 'series-template-1',
assetSeriesId: 'series-template-1',
status: 'PACKAGING',
summary: { selectedCount: 2 },
notes: null,
@ -1869,7 +1836,7 @@ describe('draftGroupService', () => {
createdAt: new Date('2026-03-30T12:00:00.000Z'),
updatedAt: new Date('2026-03-30T12:00:00.000Z'),
category: { id: 'category-desk', name: '单板桌' },
seriesTemplate: {
assetSeries: {
id: 'series-template-1',
code: 'single-board-desk',
name: '单板桌',
@ -1969,7 +1936,7 @@ describe('draftGroupService', () => {
name: '云序桌系第一组',
description: null,
categoryId: 'category-desk',
seriesTemplateId: 'series-template-1',
assetSeriesId: 'series-template-1',
status: 'FORMALIZED',
summary: { selectedCount: 2 },
notes: null,
@ -1977,7 +1944,7 @@ describe('draftGroupService', () => {
createdAt: new Date('2026-03-30T12:00:00.000Z'),
updatedAt: new Date('2026-03-30T12:00:00.000Z'),
category: { id: 'category-desk', name: '单板桌' },
seriesTemplate: {
assetSeries: {
id: 'series-template-1',
code: 'single-board-desk',
name: '单板桌',

View File

@ -2,15 +2,12 @@ import type { PreSkuPackagingInputDTO } from '../draft-group.service.types'
import {
buildSeedPackagingPlanInput,
openPlatformDemoSeedService,
pickSeedCandidates,
} from '../open-platform-demo-seed.service'
import { DEFAULT_STANDARD_FINISH } from '@/lib/standard-finish-catalog'
import { prisma } from '@/lib/prisma'
import { assetCenterService } from '../asset-center.service'
import { combinationService } from '../combination.service'
import { draftGroupService } from '../draft-group.service'
import { intakeService } from '../intake.service'
import { seriesTemplateService } from '../series-template.service'
import { warehouseService } from '../warehouse.service'
jest.mock('bcryptjs', () => ({
@ -76,6 +73,7 @@ jest.mock('@/lib/prisma', () => ({
jest.mock('../asset-center.service', () => ({
assetCenterService: {
listSeries: jest.fn(),
createFamily: jest.fn(),
updateFamily: jest.fn(),
submitFamilyForReview: jest.fn(),
@ -88,21 +86,9 @@ jest.mock('../asset-center.service', () => ({
},
}))
jest.mock('../series-template.service', () => ({
seriesTemplateService: {
listTemplates: jest.fn(),
},
}))
jest.mock('../combination.service', () => ({
combinationService: {
generateCandidates: jest.fn(),
},
}))
jest.mock('../draft-group.service', () => ({
draftGroupService: {
createFromAutoCombination: jest.fn(),
createFromManualAssembly: jest.fn(),
savePackagingPlan: jest.fn(),
getById: jest.fn(),
},
@ -126,8 +112,6 @@ describe('openPlatformDemoSeedService', () => {
const defaultFinishCategoryName = DEFAULT_STANDARD_FINISH.category.name
const mockedPrisma = jest.mocked(prisma)
const mockedAssetCenterService = jest.mocked(assetCenterService)
const mockedSeriesTemplateService = jest.mocked(seriesTemplateService)
const mockedCombinationService = jest.mocked(combinationService)
const mockedDraftGroupService = jest.mocked(draftGroupService)
const mockedIntakeService = jest.mocked(intakeService)
const mockedWarehouseService = jest.mocked(warehouseService)
@ -136,20 +120,6 @@ describe('openPlatformDemoSeedService', () => {
jest.clearAllMocks()
})
it('会优先挑首尾两个候选,避免种子数据只落同一类组合', () => {
const candidates = [
{ key: 'candidate-1' },
{ key: 'candidate-2' },
{ key: 'candidate-3' },
{ key: 'candidate-4' },
] as Array<{ key: string }>
expect(pickSeedCandidates(candidates).map((item) => item.key)).toEqual([
'candidate-1',
'candidate-4',
])
})
it('生成包装计划输入时会保留逐件 slotKey / role / selectionKey', () => {
const packagingInput: PreSkuPackagingInputDTO = {
preSkuId: 'pre-sku-1',
@ -336,6 +306,18 @@ describe('openPlatformDemoSeedService', () => {
code: 'PB18',
name: '18E1级颗粒板',
} as never)
mockedAssetCenterService.listSeries.mockResolvedValue([
{
id: 'asset-series-1',
code: 'single-board-desk',
name: '单板桌',
description: null,
sortOrder: 0,
isActive: true,
createdAt: new Date('2026-04-01T00:00:00.000Z'),
updatedAt: new Date('2026-04-01T00:00:00.000Z'),
},
] as never)
mockedAssetCenterService.createFamily.mockImplementation(async (input) => ({
id: `${input.code}-id`,
@ -345,7 +327,6 @@ describe('openPlatformDemoSeedService', () => {
description: input.description ?? null,
productCategoryId: input.productCategoryId ?? null,
productCategoryName: '单板桌',
applicableSeriesCodes: input.applicableSeriesCodes ?? [],
connectionRoles: input.connectionRoles ?? [],
metadata: input.metadata ?? null,
status: 'DRAFT',
@ -376,7 +357,6 @@ describe('openPlatformDemoSeedService', () => {
description: input.description ?? null,
productCategoryId: input.productCategoryId ?? null,
productCategoryName: '单板桌',
applicableSeriesCodes: input.applicableSeriesCodes ?? [],
connectionRoles: input.connectionRoles ?? [],
metadata: input.metadata ?? null,
status: 'DRAFT',
@ -407,7 +387,6 @@ describe('openPlatformDemoSeedService', () => {
description: null,
productCategoryId: 'category-desk',
productCategoryName: '单板桌',
applicableSeriesCodes: ['single-board-desk'],
connectionRoles: [],
metadata: null,
status: 'PENDING_REVIEW',
@ -438,7 +417,6 @@ describe('openPlatformDemoSeedService', () => {
description: null,
productCategoryId: 'category-desk',
productCategoryName: '单板桌',
applicableSeriesCodes: ['single-board-desk'],
connectionRoles: [],
metadata: null,
status: 'AVAILABLE',
@ -655,67 +633,6 @@ describe('openPlatformDemoSeedService', () => {
updatedAt: new Date('2026-04-01T09:20:00.000Z'),
}))
mockedSeriesTemplateService.listTemplates.mockResolvedValue([
{
id: 'series-template-1',
code: 'single-board-desk',
name: '单板桌',
seriesName: '云序',
},
] as never)
mockedCombinationService.generateCandidates.mockResolvedValue({
seriesTemplateId: 'series-template-1',
totalCount: 4,
candidates: [
{
key: 'candidate-desktop-a-slim',
defaultName: '云序-桌面 A-细腿',
familySelection: {
desktopFamilyId: 'family-desktop-a',
legGroupFamilyId: 'family-leg-slim',
legRoles: {
frontLeft: 'family-leg-slim',
frontRight: 'family-leg-slim',
rearLeft: 'family-leg-slim',
rearRight: 'family-leg-slim',
},
connectorFamilyIds: ['family-connector'],
},
},
{
key: 'candidate-desktop-a-round',
defaultName: '云序-桌面 A-圆腿',
familySelection: {
desktopFamilyId: 'family-desktop-a',
legGroupFamilyId: 'family-leg-round',
legRoles: {
frontLeft: 'family-leg-round',
frontRight: 'family-leg-round',
rearLeft: 'family-leg-round',
rearRight: 'family-leg-round',
},
connectorFamilyIds: ['family-connector'],
},
},
{
key: 'candidate-desktop-b-round',
defaultName: '云序-桌面 B-圆腿',
familySelection: {
desktopFamilyId: 'family-desktop-b',
legGroupFamilyId: 'family-leg-round',
legRoles: {
frontLeft: 'family-leg-round',
frontRight: 'family-leg-round',
rearLeft: 'family-leg-round',
rearRight: 'family-leg-round',
},
connectorFamilyIds: ['family-connector'],
},
},
],
} as never)
const packagingInputA: PreSkuPackagingInputDTO = {
preSkuId: 'pre-sku-1',
defaultName: '云序-桌面 A-细腿',
@ -743,14 +660,7 @@ describe('openPlatformDemoSeedService', () => {
},
],
}
const packagingInputB: PreSkuPackagingInputDTO = {
...packagingInputA,
preSkuId: 'pre-sku-2',
defaultName: '云序-桌面 B-圆腿',
sourceCandidateKey: 'candidate-desktop-b-round',
}
mockedDraftGroupService.createFromAutoCombination.mockResolvedValue({
mockedDraftGroupService.createFromManualAssembly.mockResolvedValue({
draftGroup: {
id: 'draft-group-1',
code: 'DG-SEED-001',
@ -763,12 +673,6 @@ describe('openPlatformDemoSeedService', () => {
customName: null,
packagingInput: packagingInputA,
},
{
id: 'pre-sku-2',
defaultName: '云序-桌面 B-圆腿',
customName: null,
packagingInput: packagingInputB,
},
],
} as never)
mockedDraftGroupService.savePackagingPlan.mockResolvedValue({
@ -793,17 +697,6 @@ describe('openPlatformDemoSeedService', () => {
boxes: [{ boxNumber: 1, name: '标准包装箱 1' }],
},
},
{
id: 'pre-sku-2',
defaultName: '云序-桌面 B-圆腿',
customName: null,
status: 'DRAFT',
packagingInput: packagingInputB,
packagingPlan: {
id: 'packaging-plan-2',
boxes: [{ boxNumber: 1, name: '标准包装箱 1' }],
},
},
],
} as never)
mockedIntakeService.formalizeDraftGroup.mockResolvedValue({
@ -825,18 +718,18 @@ describe('openPlatformDemoSeedService', () => {
const result = await openPlatformDemoSeedService.seed()
expect(mockedDraftGroupService.createFromAutoCombination).toHaveBeenCalledWith(
expect(mockedDraftGroupService.createFromManualAssembly).toHaveBeenCalledWith(
expect.objectContaining({
name: '开放平台演示草稿组',
categoryId: 'category-desk',
candidates: expect.arrayContaining([
expect.objectContaining({ key: 'candidate-desktop-a-slim' }),
expect.objectContaining({ key: 'candidate-desktop-b-round' }),
assetSeriesId: 'asset-series-1',
assemblies: expect.arrayContaining([
expect.objectContaining({ key: 'seed-desktop-a' }),
]),
}),
expect.objectContaining({ id: 'user-planner' }),
)
expect(mockedDraftGroupService.savePackagingPlan).toHaveBeenCalledTimes(2)
expect(mockedDraftGroupService.savePackagingPlan).toHaveBeenCalledTimes(1)
expect(mockedIntakeService.formalizeDraftGroup).toHaveBeenCalledWith(
expect.objectContaining({
draftGroupId: 'draft-group-1',

View File

@ -23,6 +23,7 @@ interface ImportAssetCadInput {
familyCode?: string | null
familyName?: string | null
familyDescription?: string | null
assetSeriesId?: string | null
connectionRoles?: string[]
variantCode?: string | null
variantName?: string | null
@ -522,6 +523,7 @@ class AssetCadImportService {
code: familyCode,
name: familyName,
description: trimOrNull(input.familyDescription),
compatibleSeriesIds: input.assetSeriesId ? [input.assetSeriesId] : [],
connectionRoles: input.connectionRoles ?? [],
createdBy: input.createdBy,
})

View File

@ -50,6 +50,7 @@ import type { AuthUser } from '@/server/core/types'
import {
assetFamilyRepository,
assetFamilyRelationInclude,
assetSeriesRepository,
assetVariantRepository,
assetVariantRelationInclude,
categoryRepository,
@ -67,6 +68,7 @@ import type {
AssetFamilyDTO,
AssetTrashCleanupResultDTO,
AssetTrashDataDTO,
AssetSeriesDTO,
AssetReviewActionInput,
AssetVariantDTO,
AssetWorkflowAuditDTO,
@ -89,6 +91,7 @@ import type {
UpdateSubstrateInput,
} from './asset-center.service.types'
import {
ASSET_VARIANT_THUMBNAIL_PLACEHOLDER_URL,
assetVariantThumbnailService,
} from './asset-variant-thumbnail.service'
import { materialService } from './material.service'
@ -131,6 +134,11 @@ function trimOrNull(value: string | null | undefined): string | null {
return next ? next : null
}
function hasUsableVariantThumbnail(value: string | null | undefined): boolean {
const next = trimOrNull(value)
return Boolean(next && next !== ASSET_VARIANT_THUMBNAIL_PLACEHOLDER_URL)
}
function normalizeBusinessDataRecord(
value: Record<string, unknown> | null | undefined,
): Record<string, unknown> | null {
@ -269,6 +277,16 @@ function toWorkflowDTO(record: {
}
function toAssetFamilyDTO(record: AssetFamilyWithRelations): AssetFamilyDTO {
const compatibleSeries = (record.compatibleSeries ?? [])
.map((item) => item.assetSeries)
.filter((series) => series.isActive)
.sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name, 'zh-CN'))
.map((series) => ({
id: series.id,
code: series.code,
name: series.name,
}))
return {
id: record.id,
type: record.type,
@ -277,7 +295,7 @@ function toAssetFamilyDTO(record: AssetFamilyWithRelations): AssetFamilyDTO {
description: record.description,
productCategoryId: record.productCategoryId,
productCategoryName: record.productCategory?.name ?? null,
applicableSeriesCodes: record.applicableSeriesCodes,
compatibleSeries,
connectionRoles: record.connectionRoles,
metadata: isRecord(record.metadata) ? record.metadata : null,
status: record.status,
@ -289,6 +307,28 @@ function toAssetFamilyDTO(record: AssetFamilyWithRelations): AssetFamilyDTO {
}
}
function toAssetSeriesDTO(record: {
id: string
code: string
name: string
description: string | null
sortOrder: number
isActive: boolean
createdAt: Date
updatedAt: Date
}): AssetSeriesDTO {
return {
id: record.id,
code: record.code,
name: record.name,
description: record.description,
sortOrder: record.sortOrder,
isActive: record.isActive,
createdAt: record.createdAt,
updatedAt: record.updatedAt,
}
}
function decimalToNumber(value: Prisma.Decimal | number | null | undefined): number | null {
return value === null || value === undefined ? null : Number(value)
}
@ -666,6 +706,26 @@ async function ensureFamilyCodeAvailable(code: string, currentId?: string): Prom
}
}
async function ensureActiveSeriesIds(
ids: string[] | undefined,
options?: { required?: boolean },
): Promise<string[]> {
const normalizedIds = Array.from(new Set((ids ?? []).map((id) => id.trim()).filter(Boolean)))
if (normalizedIds.length === 0) {
if (options?.required) {
throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, '请选择至少一个兼容系列')
}
return []
}
const existing = await assetSeriesRepository.findByIds(normalizedIds)
if (existing.length !== normalizedIds.length) {
throw new AppError(ErrorCode.VALIDATION_FAILED, '兼容系列不存在或已停用')
}
return normalizedIds
}
async function ensureVariantCodeAvailable(code: string, currentId?: string): Promise<void> {
const existing = await assetVariantRepository.findByCode(code)
if (existing && existing.id !== currentId) {
@ -848,7 +908,7 @@ class AssetCenterService {
return variant
}
if (!force && trimOrNull(variant.thumbnailUrl)) {
if (!force && hasUsableVariantThumbnail(variant.thumbnailUrl)) {
return variant
}
@ -880,7 +940,24 @@ class AssetCenterService {
}
}
async listFamilies(filter?: Pick<AssetFamilyFilter, 'type' | 'status'>) {
async listSeries(): Promise<AssetSeriesDTO[]> {
const records = await assetSeriesRepository.findAllActive()
if (records.length > 0) {
return records.map((item) => toAssetSeriesDTO(item))
}
const created = await assetSeriesRepository.createSeries({
code: 'single-board-desk',
name: '单板桌',
description: '资产中心默认系列,可在后续系列管理中调整。',
sortOrder: 0,
isActive: true,
metadata: { source: 'default-seed' },
})
return [toAssetSeriesDTO(created)]
}
async listFamilies(filter?: Pick<AssetFamilyFilter, 'type' | 'status' | 'assetSeriesId'>) {
const result = await assetFamilyRepository.findManyWithRelations(filter ? { filter } : undefined)
return result.items.map((item) => toAssetFamilyDTO(item))
}
@ -942,13 +1019,23 @@ class AssetCenterService {
await ensureFamilyCodeAvailable(code)
await ensureCategoryExists(input.productCategoryId ?? null)
const compatibleSeriesIds =
input.type === 'HARDWARE'
? []
: await ensureActiveSeriesIds(input.compatibleSeriesIds, { required: true })
const created = await assetFamilyRepository.createWithRelations({
type: input.type,
code,
name,
description: trimOrNull(input.description),
applicableSeriesCodes: input.applicableSeriesCodes ?? [],
compatibleSeries: compatibleSeriesIds.length > 0
? {
create: compatibleSeriesIds.map((assetSeriesId) => ({
assetSeries: { connect: { id: assetSeriesId } },
})),
}
: undefined,
connectionRoles: input.connectionRoles ?? [],
metadata: toJsonValue(input.metadata),
status: ensureDraftCreationStatus(input.status),
@ -977,13 +1064,26 @@ class AssetCenterService {
await ensureFamilyCodeAvailable(nextCode, id)
await ensureCategoryExists(input.productCategoryId ?? existing.productCategoryId)
const compatibleSeriesIds =
existing.type === 'HARDWARE'
? []
: input.compatibleSeriesIds === undefined
? undefined
: await ensureActiveSeriesIds(input.compatibleSeriesIds, { required: true })
const updated = await assetFamilyRepository.updateWithRelations(id, {
code: nextCode,
name: nextName,
description:
input.description !== undefined ? trimOrNull(input.description) : undefined,
applicableSeriesCodes: input.applicableSeriesCodes ?? undefined,
compatibleSeries: compatibleSeriesIds === undefined
? undefined
: {
deleteMany: {},
create: compatibleSeriesIds.map((assetSeriesId) => ({
assetSeries: { connect: { id: assetSeriesId } },
})),
},
connectionRoles: input.connectionRoles ?? undefined,
metadata: toJsonValue(input.metadata),
productCategory:
@ -1389,7 +1489,7 @@ class AssetCenterService {
variant,
renderBaseUrl: options?.renderBaseUrl,
updatedBy: input.createdBy ?? null,
skip: options?.skipThumbnail || Boolean(trimOrNull(input.thumbnailUrl)),
skip: options?.skipThumbnail || hasUsableVariantThumbnail(input.thumbnailUrl),
})
}
@ -1404,7 +1504,8 @@ class AssetCenterService {
detail: '校验编码、资产族、基材和身份重复,并继承来源规格款数据',
})
const createdVariant = await this.createVariantInternal(input)
const shouldSkipThumbnail = options?.skipThumbnail || Boolean(trimOrNull(input.thumbnailUrl))
const shouldSkipThumbnail =
options?.skipThumbnail || hasUsableVariantThumbnail(input.thumbnailUrl)
let variant = createdVariant
if (!shouldSkipThumbnail) {
options?.onProgress?.({
@ -1830,7 +1931,7 @@ class AssetCenterService {
renderBaseUrl: options?.renderBaseUrl,
updatedBy: input.updatedBy ?? null,
force: true,
skip: options?.skipThumbnail || Boolean(trimOrNull(input.thumbnailUrl)),
skip: options?.skipThumbnail || hasUsableVariantThumbnail(input.thumbnailUrl),
})
}
@ -1840,7 +1941,7 @@ class AssetCenterService {
renderBaseUrl: options?.renderBaseUrl,
updatedBy: input.updatedBy ?? null,
force: true,
skip: options?.skipThumbnail || Boolean(trimOrNull(input.thumbnailUrl)),
skip: options?.skipThumbnail || hasUsableVariantThumbnail(input.thumbnailUrl),
})
}
@ -2387,10 +2488,13 @@ export { AssetCenterService }
export type {
AssetFamilyDTO,
AssetReviewActionInput,
AssetSeriesDTO,
AssetSeriesSummaryDTO,
AssetVariantDTO,
CreateAssetVariantResultDTO,
CreateAssetFamilyInput,
CreateAssetVariantInput,
MaterialLikeDTO,
SimilarAssetVariantCandidateDTO,
CreateSubstrateDictionaryInput,
CreateSubstrateInput,

View File

@ -17,6 +17,23 @@ export interface AssetWorkflowAuditDTO {
lastModifiedByName: string | null
}
export interface AssetSeriesDTO {
id: string
code: string
name: string
description: string | null
sortOrder: number
isActive: boolean
createdAt: Date
updatedAt: Date
}
export interface AssetSeriesSummaryDTO {
id: string
code: string
name: string
}
export interface AssetFamilyDTO {
id: string
type: AssetType
@ -25,7 +42,7 @@ export interface AssetFamilyDTO {
description: string | null
productCategoryId: string | null
productCategoryName: string | null
applicableSeriesCodes: string[]
compatibleSeries: AssetSeriesSummaryDTO[]
connectionRoles: string[]
metadata: Record<string, unknown> | null
status: AssetStatus
@ -214,7 +231,7 @@ export interface CreateAssetFamilyInput {
name: string
description?: string | null
productCategoryId?: string | null
applicableSeriesCodes?: string[]
compatibleSeriesIds?: string[]
connectionRoles?: string[]
metadata?: Record<string, unknown> | null
status?: AssetStatus
@ -226,7 +243,7 @@ export interface UpdateAssetFamilyInput {
name?: string
description?: string | null
productCategoryId?: string | null
applicableSeriesCodes?: string[]
compatibleSeriesIds?: string[]
connectionRoles?: string[]
metadata?: Record<string, unknown> | null
status?: AssetStatus

View File

@ -84,6 +84,9 @@ function collectAllowedOrigins(renderBaseUrl: string): Set<string> {
pushOrigin(process.env.S3_PUBLIC_ENDPOINT)
pushOrigin(process.env.S3_PUBLIC_URL)
pushOrigin(process.env.S3_ENDPOINT)
process.env.ASSET_THUMBNAIL_ALLOWED_MODEL_ORIGINS
?.split(',')
.forEach((origin) => pushOrigin(origin))
return origins
}
@ -359,6 +362,49 @@ async function waitForRenderState(page: Page): Promise<{
}))
}
export async function hideDevelopmentChrome(page: Page): Promise<void> {
await page.addStyleTag({
content: `
nextjs-portal,
[data-nextjs-dev-tools-button],
[data-nextjs-toast],
[data-nextjs-dialog-overlay],
[data-nextjs-dialog],
[data-nextjs-terminal],
[data-nextjs-errors],
[data-nextjs-dev-tools-indicator] {
display: none !important;
visibility: hidden !important;
opacity: 0 !important;
pointer-events: none !important;
}
`,
})
await page.evaluate(() => {
document.body.dataset.assetThumbnailRenderer = 'true'
document
.querySelectorAll<HTMLElement>(
[
'nextjs-portal',
'[data-nextjs-dev-tools-button]',
'[data-nextjs-toast]',
'[data-nextjs-dialog-overlay]',
'[data-nextjs-dialog]',
'[data-nextjs-terminal]',
'[data-nextjs-errors]',
'[data-nextjs-dev-tools-indicator]',
].join(','),
)
.forEach((element) => {
element.style.setProperty('display', 'none', 'important')
element.style.setProperty('visibility', 'hidden', 'important')
element.style.setProperty('opacity', '0', 'important')
element.style.setProperty('pointer-events', 'none', 'important')
})
})
}
class AssetVariantThumbnailService {
getPlaceholderUrl(): string {
return ASSET_VARIANT_THUMBNAIL_PLACEHOLDER_URL
@ -380,11 +426,13 @@ class AssetVariantThumbnailService {
await page.goto(renderUrl, {
waitUntil: 'domcontentloaded',
})
await hideDevelopmentChrome(page)
const renderState = await waitForRenderState(page)
if (renderState.state !== 'ready') {
throw new Error(renderState.errorMessage || '缩略图渲染失败')
}
await hideDevelopmentChrome(page)
const captureHandle = await page.$('[data-thumbnail-capture="true"]')
if (!captureHandle) {

View File

@ -1,216 +0,0 @@
import { generateSingleBoardDeskCandidates } from '@/lib/open-product/single-board-desk'
import {
buildSingleBoardDeskAssemblyWorkspace,
type AssemblyFastenerRecommendation,
} from '@/lib/open-product/assembly-workspace'
import { AppError, ErrorCode } from '@/server/core/errors'
import {
assetFamilyRepository,
assetVariantRepository,
seriesTemplateRepository,
} from '../repositories'
import {
buildAvailableVariantCounts,
buildSingleBoardDeskTemplate,
materializeSingleBoardDeskCandidate,
toOpenProductVariants,
} from './single-board-desk-materialization'
import type {
CombinationCandidateItemDTO,
CombinationCandidateDTO,
CombinationGenerationResult,
GenerateCombinationInput,
} from './combination.service.types'
function buildCombinationItems(candidate: ReturnType<typeof generateSingleBoardDeskCandidates>['candidates'][number]) {
const items: CombinationCandidateItemDTO[] = [
{
label: '桌面板',
value: candidate.slotSummary.find((item) => item.label === '桌面板')?.value ?? '未配置',
quantity: 1,
role: 'desktop',
},
]
const legFamilyIds = Object.values(candidate.familySelection.legRoles).filter(
(value): value is string => Boolean(value),
)
const uniqueLegFamilyIds = Array.from(new Set(legFamilyIds))
if (uniqueLegFamilyIds.length === 0) {
items.push({
label: '桌腿',
value: '未启用',
quantity: 0,
})
} else {
uniqueLegFamilyIds.forEach((legFamilyId) => {
const quantity = legFamilyIds.filter((value) => value === legFamilyId).length
const legSummary = candidate.slotSummary.find((item) => item.label === '桌腿组')?.value ?? legFamilyId
items.push({
label: '桌腿',
value: legSummary,
quantity,
})
})
}
if (candidate.familySelection.connectorFamilyIds.length > 0) {
items.push({
label: '连接件',
value: '系统推荐',
quantity: legFamilyIds.length,
})
}
return items
}
function buildFastenerRecommendations(
candidate: ReturnType<typeof generateSingleBoardDeskCandidates>['candidates'][number],
componentCodes: string[],
): AssemblyFastenerRecommendation[] {
const connectorFamilyIds = candidate.familySelection.connectorFamilyIds
if (connectorFamilyIds.length === 0) {
return []
}
const quantity = Object.values(candidate.familySelection.legRoles).filter(Boolean).length
return connectorFamilyIds.map((assetFamilyId, index) => ({
selectionKey: `connector:${assetFamilyId}:${index + 1}`,
assetFamilyId,
quantity,
label:
componentCodes.find((code) => code.includes('CT-')) ??
`推荐连接件 ${index + 1}`,
}))
}
class CombinationService {
async generateCandidates(input: GenerateCombinationInput): Promise<CombinationGenerationResult> {
const template = await seriesTemplateRepository.findByIdWithRelations(input.seriesTemplateId)
if (!template) {
throw new AppError(ErrorCode.SERIES_TEMPLATE_NOT_FOUND, '系列模板不存在')
}
if (template.code !== 'single-board-desk') {
throw new AppError(ErrorCode.VALIDATION_FAILED, '当前版本只支持单板桌辅助装配')
}
if (input.desktopFamilyIds.length === 0) {
throw new AppError(ErrorCode.VALIDATION_FAILED, '至少选择一个桌面板资产族')
}
const familyIds = Array.from(
new Set([
...input.desktopFamilyIds,
...(input.legFamilyIds ?? []),
...(input.connectorFamilyIds ?? []),
]),
)
const families = await assetFamilyRepository.findByIdsWithRelations(familyIds)
const familyById = new Map(families.map((item) => [item.id, item]))
const familyNameById = new Map(families.map((item) => [item.id, item.name]))
const templateConfig = buildSingleBoardDeskTemplate(template)
const result = generateSingleBoardDeskCandidates({
template: templateConfig,
desktopFamilies: input.desktopFamilyIds
.map((id) => familyById.get(id))
.filter((item): item is NonNullable<typeof item> => Boolean(item))
.map((item) => ({
id: item.id,
code: item.code,
name: item.name,
assetType: item.type,
connectionRoles: item.connectionRoles,
})),
legFamilies: (input.legFamilyIds ?? [])
.map((id) => familyById.get(id))
.filter((item): item is NonNullable<typeof item> => Boolean(item))
.map((item) => ({
id: item.id,
code: item.code,
name: item.name,
assetType: item.type,
connectionRoles: item.connectionRoles,
})),
connectorFamilies: (input.connectorFamilyIds ?? [])
.map((id) => familyById.get(id))
.filter((item): item is NonNullable<typeof item> => Boolean(item))
.map((item) => ({
id: item.id,
code: item.code,
name: item.name,
assetType: item.type,
connectionRoles: item.connectionRoles,
})),
})
const availableVariants = await assetVariantRepository.findAvailableByFamilyIds(familyIds)
const openProductVariants = toOpenProductVariants(availableVariants)
const availableVariantCounts = buildAvailableVariantCounts(openProductVariants)
const candidates: CombinationCandidateDTO[] = result.candidates.map((candidate) => {
const { materialized, assemblyWarnings, assemblyResultStatus } =
materializeSingleBoardDeskCandidate({
template: templateConfig,
candidate,
variants: openProductVariants,
availableVariantCounts,
familyNameById,
})
const statusTag =
assemblyResultStatus === 'BLOCKED'
? '缺少规格款'
: assemblyWarnings.length > 0
? '可预览但需确认'
: '可直接预览'
const combinationItems = buildCombinationItems(candidate)
const fastenerRecommendations = buildFastenerRecommendations(
candidate,
materialized.componentCodes,
)
return {
...candidate,
previewMode: 'composite-ready',
statusTag,
estimatedCost: materialized.costPrice,
previewNodes: materialized.previewNodes,
assemblyResultStatus,
assemblyWarnings,
componentCodes: materialized.componentCodes,
bomPreview: materialized.bom,
installStepPreview: materialized.installStepPreview,
combinationItems,
candidateSnapshot: {
key: candidate.key,
defaultName: candidate.defaultName,
templateCode: templateConfig.code,
combinationItems,
},
assemblyWorkspace: buildSingleBoardDeskAssemblyWorkspace({
candidateKey: candidate.key,
defaultName: candidate.defaultName,
previewNodes: materialized.previewNodes,
slotSelections: materialized.selectedSlots,
connectorRecommendations: fastenerRecommendations,
}),
}
})
return {
seriesTemplateId: template.id,
totalCount: candidates.length,
candidates,
}
}
}
export const combinationService = new CombinationService()
export { CombinationService }
export type {
CombinationCandidateDTO,
CombinationGenerationResult,
GenerateCombinationInput,
} from './combination.service.types'

View File

@ -1,44 +0,0 @@
import type { SingleBoardDeskCandidate } from '@/lib/open-product/single-board-desk'
import type { AssemblyResultStatus } from '@/lib/open-product/assembly-contract'
import type {
AssemblyWorkspaceSnapshot,
CombinationCandidateSnapshot,
} from '@/lib/open-product/assembly-workspace'
import type {
InstallPreviewStep,
PreSkuBomItem,
PreSkuPreviewNode,
} from '@/lib/open-product/types'
export interface GenerateCombinationInput {
seriesTemplateId: string
desktopFamilyIds: string[]
legFamilyIds?: string[]
connectorFamilyIds?: string[]
}
export interface CombinationCandidateItemDTO {
label: string
value: string
quantity: number
role?: string
}
export interface CombinationCandidateDTO extends SingleBoardDeskCandidate {
previewMode: 'composite-ready'
previewNodes: PreSkuPreviewNode[]
assemblyResultStatus: AssemblyResultStatus
assemblyWarnings: string[]
componentCodes: string[]
bomPreview: PreSkuBomItem[]
installStepPreview: InstallPreviewStep[]
combinationItems: CombinationCandidateItemDTO[]
candidateSnapshot: CombinationCandidateSnapshot
assemblyWorkspace: AssemblyWorkspaceSnapshot
}
export interface CombinationGenerationResult {
seriesTemplateId: string
totalCount: number
candidates: CombinationCandidateDTO[]
}

View File

@ -174,6 +174,8 @@ export function normalizeAssemblyWorkspace(value: unknown): AssemblyWorkspaceSna
return {
version: '2026-04-13',
assetSeriesId:
typeof value.assetSeriesId === 'string' ? value.assetSeriesId : null,
productCategoryId:
typeof value.productCategoryId === 'string' ? value.productCategoryId : null,
productCategoryName:
@ -226,9 +228,9 @@ export function toDraftGroupDTO(record: DraftGroupWithRelations): DraftGroupDTO
description: record.description,
categoryId: record.categoryId,
categoryName: record.category?.name ?? null,
seriesTemplateId: record.seriesTemplateId ?? '',
seriesTemplateCode: record.seriesTemplate?.code ?? '',
seriesTemplateName: record.seriesTemplate?.name ?? '',
assetSeriesId: record.assetSeriesId ?? '',
assetSeriesCode: record.assetSeries?.code ?? '',
assetSeriesName: record.assetSeries?.name ?? '',
status: record.status,
summary: isRecord(record.summary) ? record.summary : null,
notes: record.notes,

View File

@ -151,6 +151,8 @@ class DraftGroupService {
const assemblies = normalizeAssemblies(input)
const resolvedCategoryId =
input.categoryId ?? assemblies[0]?.assemblyWorkspace.productCategoryId ?? null
const resolvedAssetSeriesId =
input.assetSeriesId ?? assemblies[0]?.assemblyWorkspace.assetSeriesId ?? null
if (!input.name.trim()) {
throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, '草稿组名称不能为空')
@ -170,6 +172,7 @@ class DraftGroupService {
} as Prisma.InputJsonValue,
notes: input.notes?.trim() || null,
category: resolvedCategoryId ? { connect: { id: resolvedCategoryId } } : undefined,
assetSeries: resolvedAssetSeriesId ? { connect: { id: resolvedAssetSeriesId } } : undefined,
creator: { connect: { id: user.id } },
})

View File

@ -41,6 +41,7 @@ export interface CreateDraftGroupFromManualAssemblyInput {
name: string
description?: string | null
categoryId?: string | null
assetSeriesId?: string | null
assemblies?: DraftGroupCandidateInput[]
candidates?: DraftGroupCandidateInput[]
notes?: string | null
@ -55,9 +56,9 @@ export interface DraftGroupDTO {
description: string | null
categoryId: string | null
categoryName: string | null
seriesTemplateId: string
seriesTemplateCode: string
seriesTemplateName: string
assetSeriesId: string
assetSeriesCode: string
assetSeriesName: string
status: DraftGroupStatus
summary: Record<string, unknown> | null
notes: string | null

View File

@ -162,10 +162,13 @@ export type {
export type {
AssetFamilyDTO,
AssetReviewActionInput,
AssetSeriesDTO,
AssetSeriesSummaryDTO,
AssetVariantDTO,
CreateAssetVariantResultDTO,
CreateAssetFamilyInput,
CreateAssetVariantInput,
MaterialLikeDTO,
SimilarAssetVariantCandidateDTO,
CreateSubstrateDictionaryInput,
CreateSubstrateInput,
@ -186,16 +189,6 @@ export type {
SupportedAssetImportType,
} from './asset-cad-import.service'
export { seriesTemplateService, SeriesTemplateService } from './series-template.service'
export type { SeriesTemplateDTO } from './series-template.service'
export { combinationService, CombinationService } from './combination.service'
export type {
CombinationCandidateDTO,
CombinationGenerationResult,
GenerateCombinationInput,
} from './combination.service'
export { draftGroupService, DraftGroupService } from './draft-group.service'
export type {
CreateDraftGroupFromAutoCombinationInput,

View File

@ -10,6 +10,11 @@ import { buildStandardFinishMaterialData } from '@/lib/standard-finish-sync'
import type { OpenProductMountPoint } from '@/lib/open-product/types'
import { AppError, ErrorCode } from '@/server/core/errors'
import type { AuthUser } from '@/server/core/types'
import {
buildAssemblyWorkspaceFamilies,
buildManualAssemblyWorkspace,
instantiateAssemblyNode,
} from '@/lib/open-product/assembly-workspace'
import { assetCenterService } from './asset-center.service'
import type {
AssetFamilyDTO,
@ -17,15 +22,13 @@ import type {
CreateAssetFamilyInput,
CreateAssetVariantInput,
} from './asset-center.service.types'
import { combinationService } from './combination.service'
import { draftGroupService } from './draft-group.service'
import type {
DraftGroupCandidateInput,
DraftGroupAssemblyInput,
PreSkuPackagingInputDTO,
SavePreSkuPackagingPlanInput,
} from './draft-group.service.types'
import { intakeService } from './intake.service'
import { seriesTemplateService } from './series-template.service'
import { warehouseService } from './warehouse.service'
const OPEN_PLATFORM_DEMO_SEED_MARKER = '[seed:open-platform-demo/v1]'
@ -148,15 +151,6 @@ function toAuthUser(user: {
}
}
export function pickSeedCandidates<T extends { key: string }>(candidates: T[]): T[] {
if (candidates.length <= 2) {
return candidates
}
const picked = [candidates[0], candidates[candidates.length - 1]]
return picked[0].key === picked[1].key ? [picked[0]] : picked
}
export function buildSeedPackagingPlanInput(
packagingInput: PreSkuPackagingInputDTO,
materials: {
@ -397,11 +391,11 @@ async function ensureFamilyAvailable(
const family = existing
? await assetCenterService.updateFamily(existing.id, {
code: definition.code,
name: definition.name,
description: definition.description ?? null,
productCategoryId: definition.productCategoryId ?? null,
applicableSeriesCodes: definition.applicableSeriesCodes ?? [],
connectionRoles: definition.connectionRoles ?? [],
name: definition.name,
description: definition.description ?? null,
productCategoryId: definition.productCategoryId ?? null,
compatibleSeriesIds: definition.compatibleSeriesIds ?? [],
connectionRoles: definition.connectionRoles ?? [],
metadata: definition.metadata ?? null,
updatedBy: creator.id,
})
@ -534,7 +528,10 @@ async function cleanupPreviousSeedArtifacts(): Promise<void> {
}
}
function buildDemoFamilyDefinitions(categoryId: string): Record<string, DemoFamilyDefinition> {
function buildDemoFamilyDefinitions(
categoryId: string,
assetSeriesId: string,
): Record<string, DemoFamilyDefinition> {
return {
desktopA: {
type: 'BOARD',
@ -542,7 +539,7 @@ function buildDemoFamilyDefinitions(categoryId: string): Record<string, DemoFami
name: '演示桌面板 A',
description: '开放平台演示桌面板资产族 A',
productCategoryId: categoryId,
applicableSeriesCodes: ['single-board-desk'],
compatibleSeriesIds: [assetSeriesId],
connectionRoles: ['desktop_surface'],
metadata: {
seedKey: OPEN_PLATFORM_DEMO_SEED_MARKER,
@ -556,7 +553,7 @@ function buildDemoFamilyDefinitions(categoryId: string): Record<string, DemoFami
name: '演示桌面板 B',
description: '开放平台演示桌面板资产族 B',
productCategoryId: categoryId,
applicableSeriesCodes: ['single-board-desk'],
compatibleSeriesIds: [assetSeriesId],
connectionRoles: ['desktop_surface'],
metadata: {
seedKey: OPEN_PLATFORM_DEMO_SEED_MARKER,
@ -570,7 +567,7 @@ function buildDemoFamilyDefinitions(categoryId: string): Record<string, DemoFami
name: '演示细腿组',
description: '开放平台演示细腿资产族',
productCategoryId: categoryId,
applicableSeriesCodes: ['single-board-desk'],
compatibleSeriesIds: [assetSeriesId],
connectionRoles: ['desk_leg'],
metadata: {
seedKey: OPEN_PLATFORM_DEMO_SEED_MARKER,
@ -583,7 +580,7 @@ function buildDemoFamilyDefinitions(categoryId: string): Record<string, DemoFami
name: '演示圆腿组',
description: '开放平台演示圆腿资产族',
productCategoryId: categoryId,
applicableSeriesCodes: ['single-board-desk'],
compatibleSeriesIds: [assetSeriesId],
connectionRoles: ['desk_leg'],
metadata: {
seedKey: OPEN_PLATFORM_DEMO_SEED_MARKER,
@ -596,7 +593,7 @@ function buildDemoFamilyDefinitions(categoryId: string): Record<string, DemoFami
name: '演示桌腿连接件',
description: '开放平台演示连接件资产族',
productCategoryId: categoryId,
applicableSeriesCodes: ['single-board-desk'],
compatibleSeriesIds: [assetSeriesId],
connectionRoles: ['desk_leg_connector'],
metadata: {
seedKey: OPEN_PLATFORM_DEMO_SEED_MARKER,
@ -609,7 +606,7 @@ function buildDemoFamilyDefinitions(categoryId: string): Record<string, DemoFami
name: '演示纸箱',
description: '开放平台演示纸箱资产族',
productCategoryId: categoryId,
applicableSeriesCodes: ['single-board-desk'],
compatibleSeriesIds: [assetSeriesId],
connectionRoles: [],
metadata: {
seedKey: OPEN_PLATFORM_DEMO_SEED_MARKER,
@ -622,7 +619,7 @@ function buildDemoFamilyDefinitions(categoryId: string): Record<string, DemoFami
name: '演示泡沫护角',
description: '开放平台演示泡沫护角资产族',
productCategoryId: categoryId,
applicableSeriesCodes: ['single-board-desk'],
compatibleSeriesIds: [assetSeriesId],
connectionRoles: [],
metadata: {
seedKey: OPEN_PLATFORM_DEMO_SEED_MARKER,
@ -887,6 +884,7 @@ function buildDemoVariantDefinitions(input: {
async function ensureDemoAssets(input: {
categoryId: string
assetSeriesId: string
substrateId: string
finish: {
id: string
@ -895,7 +893,7 @@ async function ensureDemoAssets(input: {
creator: AuthUser
reviewer: AuthUser
}): Promise<DemoAssetBundle> {
const familyDefinitions = buildDemoFamilyDefinitions(input.categoryId)
const familyDefinitions = buildDemoFamilyDefinitions(input.categoryId, input.assetSeriesId)
const desktopAFamily = await ensureFamilyAvailable(
familyDefinitions.desktopA,
@ -1018,8 +1016,16 @@ class OpenPlatformDemoSeedService {
await cleanupPreviousSeedArtifacts()
const assetSeries = (await assetCenterService.listSeries()).find(
(item) => item.code === 'single-board-desk',
)
if (!assetSeries) {
throw new AppError(ErrorCode.VALIDATION_FAILED, '未找到单板桌资产系列')
}
const assets = await ensureDemoAssets({
categoryId: category.id,
assetSeriesId: assetSeries.id,
substrateId: substrateRecord.id,
finish: {
id: finishBundle.material.id,
@ -1029,30 +1035,52 @@ class OpenPlatformDemoSeedService {
reviewer,
})
const templates = await seriesTemplateService.listTemplates()
const template = templates.find((item) => item.code === 'single-board-desk')
if (!template) {
throw new AppError(ErrorCode.SERIES_TEMPLATE_NOT_FOUND, '未找到单板桌系列模板')
}
const generated = await combinationService.generateCandidates({
seriesTemplateId: template.id,
desktopFamilyIds: [assets.desktopA.family.id, assets.desktopB.family.id],
legFamilyIds: [assets.legSlim.family.id, assets.legRound.family.id],
connectorFamilyIds: [assets.connector.family.id],
const defaultVariantIdsByFamilyId = new Map<string, string | null>([
[assets.desktopA.family.id, assets.desktopA.variant.id],
])
const baseWorkspace = buildManualAssemblyWorkspace({
assetSeriesId: assetSeries.id,
productCategoryId: category.id,
productCategoryName: category.name,
source: 'assisted-assembly',
entryMode: 'direct',
families: buildAssemblyWorkspaceFamilies(
[
{ ...assets.desktopA.family, assetType: assets.desktopA.family.type },
],
defaultVariantIdsByFamilyId,
),
})
const desktopInventory = baseWorkspace.inventory.find(
(item) => item.familyId === assets.desktopA.family.id,
)
const assemblyWorkspace = desktopInventory
? instantiateAssemblyNode(baseWorkspace, {
inventoryId: desktopInventory.inventoryId,
variant: assets.desktopA.variant,
})
: baseWorkspace
const selectedAssemblies: DraftGroupAssemblyInput[] = [
{
key: 'seed-desktop-a',
defaultName: assets.desktopA.variant.name,
source: 'assisted-assembly',
assemblyWorkspace,
selectedFamilyIdsByType: {
desktopFamilyIds: [assets.desktopA.family.id],
legFamilyIds: [],
connectorFamilyIds: [],
},
},
]
const selectedCandidates = pickSeedCandidates(generated.candidates as DraftGroupCandidateInput[])
if (selectedCandidates.length === 0) {
throw new AppError(ErrorCode.VALIDATION_FAILED, '辅助装配没有生成候选,无法创建演示草稿组')
}
const draftGroupDetail = await draftGroupService.createFromAutoCombination(
const draftGroupDetail = await draftGroupService.createFromManualAssembly(
{
name: OPEN_PLATFORM_DEMO_DRAFT_GROUP_NAME,
description: '开放平台主链联调用演示草稿组',
categoryId: category.id,
candidates: selectedCandidates,
assetSeriesId: assetSeries.id,
assemblies: selectedAssemblies,
notes: `${OPEN_PLATFORM_DEMO_SEED_MARKER} 自动创建`,
},
planner,

View File

@ -1,108 +0,0 @@
import { Prisma } from '@prisma/client'
import { AppError, ErrorCode } from '@/server/core/errors'
import { SINGLE_BOARD_DESK_SLOT_SCHEMA } from '@/lib/open-product/assembly-contract'
import { seriesTemplateRepository } from '../repositories'
export interface SeriesTemplateDTO {
id: string
code: string
name: string
seriesName: string
description: string | null
categoryId: string | null
categoryName: string | null
slotSchema: Record<string, unknown>
namingRule: Record<string, unknown> | null
recommendationRule: Record<string, unknown> | null
previewRule: Record<string, unknown> | null
intakeRule: Record<string, unknown> | null
isPreset: boolean
isActive: boolean
createdAt: Date
updatedAt: Date
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function toDTO(
record: NonNullable<Awaited<ReturnType<typeof seriesTemplateRepository.findByIdWithRelations>>>,
): SeriesTemplateDTO {
return {
id: record.id,
code: record.code,
name: record.name,
seriesName: record.seriesName,
description: record.description,
categoryId: record.categoryId,
categoryName: record.category?.name ?? null,
slotSchema: isRecord(record.slotSchema) ? record.slotSchema : {},
namingRule: isRecord(record.namingRule) ? record.namingRule : null,
recommendationRule: isRecord(record.recommendationRule) ? record.recommendationRule : null,
previewRule: isRecord(record.previewRule) ? record.previewRule : null,
intakeRule: isRecord(record.intakeRule) ? record.intakeRule : null,
isPreset: record.isPreset,
isActive: record.isActive,
createdAt: record.createdAt,
updatedAt: record.updatedAt,
}
}
class SeriesTemplateService {
private async ensurePresetSingleBoardDeskTemplate(): Promise<void> {
const existing = await seriesTemplateRepository.findByCode('single-board-desk')
if (existing) return
await seriesTemplateRepository.createWithRelations({
code: 'single-board-desk',
name: '单板桌',
seriesName: '云序',
description: '一期主链固定模板:桌面板 + 四个独立腿位 + 自动连接件推荐',
slotSchema: {
slots: SINGLE_BOARD_DESK_SLOT_SCHEMA,
} as unknown as Prisma.InputJsonValue,
namingRule: {
defaultPattern: '{seriesName}-{desktopFamilyName}-{legFamilyName}',
},
recommendationRule: {
autoConnectorRoles: ['desk_leg_connector'],
},
previewRule: {
desktopHeight: 720,
desktopPosition: [0, 720, 0],
legOffsets: {
frontLeft: [-450, 360, 250],
frontRight: [450, 360, 250],
rearLeft: [-450, 360, -250],
rearRight: [450, 360, -250],
},
},
intakeRule: {
allowCreateProduct: true,
allowAttachExistingProduct: true,
},
isPreset: true,
isActive: true,
})
}
async listTemplates(): Promise<SeriesTemplateDTO[]> {
await this.ensurePresetSingleBoardDeskTemplate()
const records = await seriesTemplateRepository.findAllActiveWithRelations()
return records.map((record) => toDTO(record))
}
async getById(id: string): Promise<SeriesTemplateDTO> {
await this.ensurePresetSingleBoardDeskTemplate()
const record = await seriesTemplateRepository.findByIdWithRelations(id)
if (!record) {
throw new AppError(ErrorCode.SERIES_TEMPLATE_NOT_FOUND, '系列模板不存在')
}
return toDTO(record)
}
}
export const seriesTemplateService = new SeriesTemplateService()
export { SeriesTemplateService }

View File

@ -5,50 +5,11 @@ import {
} from '@/lib/open-product/single-board-desk'
import type { OpenProductAssetVariant } from '@/lib/open-product/types'
import type { AssemblyResultStatus } from '@/lib/open-product/assembly-contract'
import { seriesTemplateRepository } from '../repositories'
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
export function buildSingleBoardDeskTemplate(
record: NonNullable<Awaited<ReturnType<typeof seriesTemplateRepository.findByIdWithRelations>>>,
): SingleBoardDeskTemplate {
const previewRule = isRecord(record.previewRule) ? record.previewRule : {}
const legOffsets = isRecord(previewRule.legOffsets) ? previewRule.legOffsets : {}
return {
code: record.code,
name: record.name,
displayName: record.name,
naming: {
seriesName: record.seriesName,
},
previewLayout: {
desktopHeight:
typeof previewRule.desktopHeight === 'number' ? previewRule.desktopHeight : 720,
desktopPosition: Array.isArray(previewRule.desktopPosition)
? (previewRule.desktopPosition as [number, number, number])
: [0, 720, 0],
legOffsets: {
frontLeft: Array.isArray(legOffsets.frontLeft)
? (legOffsets.frontLeft as [number, number, number])
: [-450, 360, 250],
frontRight: Array.isArray(legOffsets.frontRight)
? (legOffsets.frontRight as [number, number, number])
: [450, 360, 250],
rearLeft: Array.isArray(legOffsets.rearLeft)
? (legOffsets.rearLeft as [number, number, number])
: [-450, 360, -250],
rearRight: Array.isArray(legOffsets.rearRight)
? (legOffsets.rearRight as [number, number, number])
: [450, 360, -250],
},
},
autoConnectorRoles: ['desk_leg_connector'],
}
}
export function toOpenProductVariants(
records: Awaited<ReturnType<typeof import('../repositories').assetVariantRepository.findAvailableByFamilyIds>>,
): OpenProductAssetVariant[] {