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:
commit
db7023e45e
@ -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;
|
||||
@ -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")
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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: '测试用户',
|
||||
|
||||
@ -167,7 +167,7 @@ const mockDraftGroup: DraftGroupSummary = {
|
||||
name: '我的草稿',
|
||||
description: '桌类方案',
|
||||
categoryName: '单板桌',
|
||||
seriesTemplateName: '单板桌',
|
||||
assetSeriesName: '单板桌',
|
||||
status: 'DRAFT',
|
||||
summary: { selectedCount: 2 },
|
||||
creatorName: '测试用户',
|
||||
|
||||
@ -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: [],
|
||||
}),
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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'),
|
||||
|
||||
@ -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)
|
||||
@ -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({
|
||||
|
||||
@ -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={[]}
|
||||
|
||||
@ -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={[]}
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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={[
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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"
|
||||
/>
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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]">
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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 = () => {
|
||||
|
||||
@ -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])
|
||||
})
|
||||
})
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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 {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
@ -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 鐖嗙偢鎵╂暎寮哄害
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -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
@ -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))
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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))
|
||||
}
|
||||
|
||||
@ -23,7 +23,6 @@ export interface OpenProductAssetFamily {
|
||||
assetType: OpenProductAssetType
|
||||
connectionRoles: string[]
|
||||
productCategoryId?: string | null
|
||||
applicableSeriesCodes?: string[]
|
||||
}
|
||||
|
||||
export interface OpenProductAssetVariant {
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 }
|
||||
@ -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',
|
||||
})
|
||||
|
||||
@ -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'],
|
||||
}),
|
||||
])
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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 缺少可用规格款')
|
||||
})
|
||||
})
|
||||
@ -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'),
|
||||
|
||||
@ -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: '单板桌',
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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,
|
||||
})
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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'
|
||||
@ -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[]
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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 } },
|
||||
})
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 }
|
||||
@ -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[] {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user