feat: 重构资产中心系列优先入口

This commit is contained in:
WuYongJie 2026-05-15 14:39:45 +08:00
parent 1f2d2af257
commit b603555df7
57 changed files with 1221 additions and 1257 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -214,7 +214,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 () => {

View File

@ -198,6 +198,7 @@ export default function AssistedAssemblyClient({
const firstCategoryFamily = selectedFamilies.find((family) => family.productCategoryId)
return syncManualAssemblyWorkspace(snapshot, {
assetSeriesId: launchParams?.assetSeriesId ?? initialWorkspace?.assetSeriesId ?? null,
productCategoryId: initialWorkspace?.productCategoryId ?? firstCategoryFamily?.productCategoryId ?? null,
productCategoryName:
initialWorkspace?.productCategoryName ?? firstCategoryFamily?.productCategoryName ?? null,
@ -215,6 +216,7 @@ export default function AssistedAssemblyClient({
entryMode,
families,
initialWorkspace,
launchParams?.assetSeriesId,
launchParams?.sourceProductId,
launchParams?.sourceSkuId,
legIds,
@ -263,6 +265,7 @@ export default function AssistedAssemblyClient({
const result = await createDraftGroup({
name: draftGroupName,
categoryId: workspace.productCategoryId,
assetSeriesId: workspace.assetSeriesId ?? null,
assemblies: [
{
key: `${entryMode}:${desktopIds.join(',')}`,

View File

@ -223,6 +223,7 @@ function buildSourceSkuWorkspaceFallback(input: {
})
const baseSnapshot = buildManualAssemblyWorkspace({
assetSeriesId: null,
productCategoryId: null,
productCategoryName: null,
source: getAssemblyWorkspaceSourceFromEntryMode('source-sku'),
@ -237,7 +238,6 @@ function buildSourceSkuWorkspaceFallback(input: {
assetType: family.type,
connectionRoles: family.connectionRoles,
productCategoryId: family.productCategoryId,
applicableSeriesCodes: family.applicableSeriesCodes,
})),
defaultVariantIdsByFamilyId,
),
@ -462,6 +462,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,

View File

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

View File

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

View File

@ -249,7 +249,7 @@ function renderSelector(props?: {
{
id: 'draft-group-1',
name: 'Draft Group',
seriesTemplateName: 'Single board desk',
assetSeriesName: 'Single board desk',
code: 'DG-001',
},
]
@ -332,7 +332,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',
},
@ -1698,13 +1698,13 @@ describe('IntakeClient', () => {
{
id: 'draft-group-1',
name: 'Nordic Draft Group',
seriesTemplateName: 'Nordic Desk',
assetSeriesName: 'Nordic Desk',
code: 'DG-001',
},
{
id: 'draft-group-2',
name: 'Cloud Draft Group',
seriesTemplateName: 'Cloud Sofa',
assetSeriesName: 'Cloud Sofa',
code: 'DG-002',
},
],
@ -1761,7 +1761,7 @@ describe('IntakeClient', () => {
const eligibleDraftGroups = Array.from({ length: 26 }, (_, index) => ({
id: `draft-group-${index + 1}`,
name: `Draft Group ${index + 1}`,
seriesTemplateName: `Series ${index + 1}`,
assetSeriesName: `Series ${index + 1}`,
code: `DG-${String(index + 1).padStart(3, '0')}`,
}))

View File

@ -120,7 +120,7 @@ interface IntakeClientSelectorProps {
eligibleDraftGroups: {
id: string;
name: string;
seriesTemplateName: string;
assetSeriesName: string;
code: string;
}[];
eligibleSkuList?: WorkbenchSku[];
@ -780,7 +780,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: "",
@ -803,7 +803,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];
@ -814,7 +814,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: "",

View File

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

View File

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

View File

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

View File

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

View File

@ -163,6 +163,7 @@ export interface AssemblyAutoSnapCandidate {
export interface AssemblyWorkspaceSnapshot {
version: '2026-04-13' | '2026-04-09' | '2026-04-08'
assetSeriesId?: string | null
productCategoryId?: string | null
productCategoryName?: string | null
workflowMode: 'assisted-manual'
@ -199,6 +200,7 @@ export interface AssemblyWorkspaceFamilyInput {
}
interface BuildManualAssemblyWorkspaceInput {
assetSeriesId?: string | null
productCategoryId: string | null
productCategoryName: string | null
source: AssemblyWorkspaceSource
@ -1817,6 +1819,7 @@ export function buildManualAssemblyWorkspace(
const snapshot: AssemblyWorkspaceSnapshot = {
version: '2026-04-13',
assetSeriesId: input.assetSeriesId ?? null,
productCategoryId: input.productCategoryId,
productCategoryName: input.productCategoryName,
workflowMode: 'assisted-manual',
@ -1848,6 +1851,7 @@ export function syncManualAssemblyWorkspace(
if (
snapshot.productCategoryId !== input.productCategoryId ||
(snapshot.assetSeriesId ?? null) !== (input.assetSeriesId ?? null) ||
snapshot.source !== input.source ||
snapshot.entryMode !== input.entryMode ||
(snapshot.sourceSkuId ?? null) !== (input.sourceSkuId ?? null) ||
@ -1884,6 +1888,7 @@ export function syncManualAssemblyWorkspace(
{
...snapshot,
productCategoryId: input.productCategoryId,
assetSeriesId: input.assetSeriesId ?? null,
productCategoryName: input.productCategoryName,
workflowMode: 'assisted-manual',
entryMode: input.entryMode,
@ -1926,6 +1931,7 @@ export function buildSingleBoardDeskAssemblyWorkspace(
})
const snapshot = buildManualAssemblyWorkspace({
assetSeriesId: null,
productCategoryId: null,
productCategoryName: null,
source: 'assisted-assembly',

View File

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

View File

@ -240,6 +240,7 @@ export interface ImportAssetCadPayload {
familyCode?: string | null
familyName?: string | null
familyDescription?: string | null
assetSeriesId?: string | null
connectionRoles?: string[]
variantCode: string
variantName: string
@ -258,6 +259,7 @@ export async function importAssetCad(input: ImportAssetCadPayload): Promise<Asse
if (input.familyCode) formData.append('familyCode', input.familyCode)
if (input.familyName) formData.append('familyName', input.familyName)
if (input.familyDescription) formData.append('familyDescription', input.familyDescription)
if (input.assetSeriesId) formData.append('assetSeriesId', input.assetSeriesId)
if (input.connectionRoles && input.connectionRoles.length > 0) {
formData.append('connectionRoles', JSON.stringify(input.connectionRoles))
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1861,7 +1861,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 +1902,6 @@ describe('assetCenterService', () => {
code: 'BOARD-TOP',
name: '桌面板资产族',
status: 'AVAILABLE',
applicableSeriesCodes: ['single-board-desk'],
connectionRoles: ['desktop'],
}),
])

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -50,6 +50,7 @@ import type { AuthUser } from '@/server/core/types'
import {
assetFamilyRepository,
assetFamilyRelationInclude,
assetSeriesRepository,
assetVariantRepository,
assetVariantRelationInclude,
categoryRepository,
@ -67,6 +68,7 @@ import type {
AssetFamilyDTO,
AssetTrashCleanupResultDTO,
AssetTrashDataDTO,
AssetSeriesDTO,
AssetReviewActionInput,
AssetVariantDTO,
AssetWorkflowAuditDTO,
@ -269,6 +271,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 +289,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 +301,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 +700,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) {
@ -880,7 +934,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 +1013,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 +1058,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:
@ -2387,10 +2481,13 @@ export { AssetCenterService }
export type {
AssetFamilyDTO,
AssetReviewActionInput,
AssetSeriesDTO,
AssetSeriesSummaryDTO,
AssetVariantDTO,
CreateAssetVariantResultDTO,
CreateAssetFamilyInput,
CreateAssetVariantInput,
MaterialLikeDTO,
SimilarAssetVariantCandidateDTO,
CreateSubstrateDictionaryInput,
CreateSubstrateInput,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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