feat: 接入资产中心回收站与规格款渲染配置
This commit is contained in:
parent
286347582c
commit
2e954f3219
@ -0,0 +1,21 @@
|
||||
-- Asset Center recycle bin support
|
||||
ALTER TABLE "AssetFamily" ADD COLUMN "deletedAt" TIMESTAMP(3);
|
||||
ALTER TABLE "AssetFamily" ADD COLUMN "deletedById" TEXT;
|
||||
|
||||
ALTER TABLE "AssetVariant" ADD COLUMN "deletedAt" TIMESTAMP(3);
|
||||
ALTER TABLE "AssetVariant" ADD COLUMN "deletedById" TEXT;
|
||||
|
||||
CREATE INDEX "AssetFamily_deletedAt_idx" ON "AssetFamily"("deletedAt");
|
||||
CREATE INDEX "AssetFamily_deletedById_idx" ON "AssetFamily"("deletedById");
|
||||
CREATE INDEX "AssetVariant_deletedAt_idx" ON "AssetVariant"("deletedAt");
|
||||
CREATE INDEX "AssetVariant_deletedById_idx" ON "AssetVariant"("deletedById");
|
||||
|
||||
ALTER TABLE "AssetFamily"
|
||||
ADD CONSTRAINT "AssetFamily_deletedById_fkey"
|
||||
FOREIGN KEY ("deletedById") REFERENCES "User"("id")
|
||||
ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "AssetVariant"
|
||||
ADD CONSTRAINT "AssetVariant_deletedById_fkey"
|
||||
FOREIGN KEY ("deletedById") REFERENCES "User"("id")
|
||||
ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@ -618,6 +618,9 @@ model AssetFamily {
|
||||
rejectionReason String? @db.Text
|
||||
lastModifiedById String?
|
||||
lastModifier User? @relation("AssetFamilyLastModifier", fields: [lastModifiedById], references: [id], onDelete: SetNull)
|
||||
deletedAt DateTime?
|
||||
deletedById String?
|
||||
deleter User? @relation("AssetFamilyDeleter", fields: [deletedById], references: [id], onDelete: SetNull)
|
||||
|
||||
variants AssetVariant[]
|
||||
|
||||
@ -632,6 +635,8 @@ model AssetFamily {
|
||||
@@index([reviewedById])
|
||||
@@index([rejectedById])
|
||||
@@index([lastModifiedById])
|
||||
@@index([deletedAt])
|
||||
@@index([deletedById])
|
||||
}
|
||||
|
||||
model AssetVariant {
|
||||
@ -680,6 +685,9 @@ model AssetVariant {
|
||||
rejectionReason String? @db.Text
|
||||
lastModifiedById String?
|
||||
lastModifier User? @relation("AssetVariantLastModifier", fields: [lastModifiedById], references: [id], onDelete: SetNull)
|
||||
deletedAt DateTime?
|
||||
deletedById String?
|
||||
deleter User? @relation("AssetVariantDeleter", fields: [deletedById], references: [id], onDelete: SetNull)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
@ -695,6 +703,8 @@ model AssetVariant {
|
||||
@@index([rejectedById])
|
||||
@@index([lastModifiedById])
|
||||
@@index([inheritedFromVariantId])
|
||||
@@index([deletedAt])
|
||||
@@index([deletedById])
|
||||
}
|
||||
|
||||
model SeriesTemplate {
|
||||
@ -1048,11 +1058,13 @@ model User {
|
||||
reviewedAssetFamilies AssetFamily[] @relation("AssetFamilyReviewer")
|
||||
rejectedAssetFamilies AssetFamily[] @relation("AssetFamilyRejecter")
|
||||
modifiedAssetFamilies AssetFamily[] @relation("AssetFamilyLastModifier")
|
||||
deletedAssetFamilies AssetFamily[] @relation("AssetFamilyDeleter")
|
||||
createdAssetVariants AssetVariant[] @relation("AssetVariantCreator")
|
||||
submittedAssetVariants AssetVariant[] @relation("AssetVariantSubmitter")
|
||||
reviewedAssetVariants AssetVariant[] @relation("AssetVariantReviewer")
|
||||
rejectedAssetVariants AssetVariant[] @relation("AssetVariantRejecter")
|
||||
modifiedAssetVariants AssetVariant[] @relation("AssetVariantLastModifier")
|
||||
deletedAssetVariants AssetVariant[] @relation("AssetVariantDeleter")
|
||||
createdSeriesTemplates SeriesTemplate[] @relation("SeriesTemplateCreator")
|
||||
createdDraftGroups DraftGroup[] @relation("DraftGroupCreator")
|
||||
collaboratedProducts ProductCollaborator[] @relation("ProductCollaboratorUser")
|
||||
|
||||
@ -49,7 +49,7 @@ export async function DELETE(
|
||||
async (ctx) => {
|
||||
const { id } = await context.params
|
||||
try {
|
||||
await assetCenterService.deleteFamily(id)
|
||||
await assetCenterService.deleteFamily(id, ctx.user.id)
|
||||
return jsonSuccess({ success: true }, 200, ctx.requestId)
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
|
||||
@ -52,7 +52,7 @@ export async function DELETE(
|
||||
async (ctx) => {
|
||||
const { id } = await context.params
|
||||
try {
|
||||
await assetCenterService.deleteVariant(id)
|
||||
await assetCenterService.deleteVariant(id, ctx.user.id)
|
||||
return jsonSuccess({ success: true }, 200, ctx.requestId)
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
|
||||
@ -1,4 +1,10 @@
|
||||
import { z } from 'zod'
|
||||
import type {
|
||||
AssetVariantNodeUvConfig,
|
||||
AssetVariantUvConfig,
|
||||
AssetVariantUvProjectionAxis,
|
||||
AssetVariantUvSurfaceConfig,
|
||||
} from '@/lib/asset-variant-render-config'
|
||||
|
||||
const vector3Schema = z.tuple([z.number(), z.number(), z.number()])
|
||||
const assetStatusSchema = z.enum([
|
||||
@ -12,6 +18,26 @@ const assetCadImportFamilyModeSchema = z.enum(['existing', 'create'])
|
||||
const singleSubstrateFinishIdsSchema = z
|
||||
.array(z.string().trim().min(1))
|
||||
.max(1, '每个基材只能选择一个饰面花色')
|
||||
const assetVariantUvProjectionAxisSchema = z.enum(['x', 'y', 'z']) satisfies z.ZodType<AssetVariantUvProjectionAxis>
|
||||
const assetVariantUvSurfaceConfigSchema = z.object({
|
||||
textureRepeat: z.number().optional().nullable(),
|
||||
textureRepeatU: z.number().optional().nullable(),
|
||||
textureRepeatV: z.number().optional().nullable(),
|
||||
textureRotation: z.number().optional().nullable(),
|
||||
textureSizeMm: z.number().optional().nullable(),
|
||||
textureProjectionAxis: assetVariantUvProjectionAxisSchema.optional().nullable(),
|
||||
}) satisfies z.ZodType<AssetVariantUvSurfaceConfig>
|
||||
const assetVariantNodeUvConfigSchema = assetVariantUvSurfaceConfigSchema.extend({
|
||||
axes: z.record(assetVariantUvProjectionAxisSchema, assetVariantUvSurfaceConfigSchema.nullable()).optional().nullable(),
|
||||
surfaces: z.object({
|
||||
front: assetVariantUvSurfaceConfigSchema.optional().nullable(),
|
||||
back: assetVariantUvSurfaceConfigSchema.optional().nullable(),
|
||||
}).optional().nullable(),
|
||||
}) satisfies z.ZodType<AssetVariantNodeUvConfig>
|
||||
const assetVariantUvConfigSchema = z.object({
|
||||
version: z.literal(1),
|
||||
nodes: z.record(z.string(), assetVariantNodeUvConfigSchema),
|
||||
}) satisfies z.ZodType<AssetVariantUvConfig>
|
||||
|
||||
export const assetFamilyCreateSchema = z.object({
|
||||
type: z.enum(['BOARD', 'COMPONENT', 'HARDWARE', 'PACKAGING']),
|
||||
@ -58,6 +84,7 @@ export const assetVariantCreateSchema = z.object({
|
||||
compatibilityRules: z.record(z.string(), z.unknown()).optional().nullable(),
|
||||
namingFields: z.record(z.string(), z.unknown()).optional().nullable(),
|
||||
businessData: z.record(z.string(), z.unknown()).optional().nullable(),
|
||||
uvConfig: assetVariantUvConfigSchema.optional().nullable(),
|
||||
weightKg: z.number().optional().nullable(),
|
||||
costPrice: z.number().optional().nullable(),
|
||||
lossFactor: z.number().positive().optional().nullable(),
|
||||
@ -89,6 +116,7 @@ export const assetVariantUpdateSchema = z.object({
|
||||
compatibilityRules: z.record(z.string(), z.unknown()).optional().nullable(),
|
||||
namingFields: z.record(z.string(), z.unknown()).optional().nullable(),
|
||||
businessData: z.record(z.string(), z.unknown()).optional().nullable(),
|
||||
uvConfig: assetVariantUvConfigSchema.optional().nullable(),
|
||||
weightKg: z.number().optional().nullable(),
|
||||
costPrice: z.number().optional().nullable(),
|
||||
lossFactor: z.number().positive().optional().nullable(),
|
||||
|
||||
@ -0,0 +1,51 @@
|
||||
import { NextRequest } from 'next/server'
|
||||
import { handleRequest, jsonSuccess } from '@/server/core/response'
|
||||
import { createRequestContext } from '@/server/middleware/request-logger'
|
||||
import { timeAsync } from '@/server/monitoring/performance'
|
||||
import { assetCenterService } from '@/server/services/asset-center.service'
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const ctx = createRequestContext(request)
|
||||
|
||||
return handleRequest(request, async (reqCtx) => {
|
||||
ctx.setUserId(reqCtx.user.id)
|
||||
const { id } = await params
|
||||
|
||||
const { result } = await timeAsync('trash.restoreAssetFamily', () =>
|
||||
assetCenterService.restoreFamilyFromTrash(id),
|
||||
)
|
||||
|
||||
ctx.complete(200)
|
||||
return jsonSuccess({
|
||||
message: '资产族已恢复',
|
||||
family: result,
|
||||
}, 200, reqCtx.requestId)
|
||||
})
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const ctx = createRequestContext(request)
|
||||
|
||||
return handleRequest(request, async (reqCtx) => {
|
||||
ctx.setUserId(reqCtx.user.id)
|
||||
const { id } = await params
|
||||
|
||||
const { result } = await timeAsync('trash.permanentDeleteAssetFamily', () =>
|
||||
assetCenterService.permanentlyDeleteFamilyFromTrash(id),
|
||||
)
|
||||
|
||||
ctx.complete(200)
|
||||
return jsonSuccess({
|
||||
message: result.deletedVariantCount > 0
|
||||
? `资产族已彻底删除,同时删除 ${result.deletedVariantCount} 个规格款`
|
||||
: '资产族已彻底删除',
|
||||
deletedVariantCount: result.deletedVariantCount,
|
||||
}, 200, reqCtx.requestId)
|
||||
})
|
||||
}
|
||||
@ -0,0 +1,48 @@
|
||||
import { NextRequest } from 'next/server'
|
||||
import { handleRequest, jsonSuccess } from '@/server/core/response'
|
||||
import { createRequestContext } from '@/server/middleware/request-logger'
|
||||
import { timeAsync } from '@/server/monitoring/performance'
|
||||
import { assetCenterService } from '@/server/services/asset-center.service'
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const ctx = createRequestContext(request)
|
||||
|
||||
return handleRequest(request, async (reqCtx) => {
|
||||
ctx.setUserId(reqCtx.user.id)
|
||||
const { id } = await params
|
||||
|
||||
const { result } = await timeAsync('trash.restoreAssetVariant', () =>
|
||||
assetCenterService.restoreVariantFromTrash(id),
|
||||
)
|
||||
|
||||
ctx.complete(200)
|
||||
return jsonSuccess({
|
||||
message: '规格款已恢复',
|
||||
variant: result,
|
||||
}, 200, reqCtx.requestId)
|
||||
})
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const ctx = createRequestContext(request)
|
||||
|
||||
return handleRequest(request, async (reqCtx) => {
|
||||
ctx.setUserId(reqCtx.user.id)
|
||||
const { id } = await params
|
||||
|
||||
await timeAsync('trash.permanentDeleteAssetVariant', () =>
|
||||
assetCenterService.permanentlyDeleteVariantFromTrash(id),
|
||||
)
|
||||
|
||||
ctx.complete(200)
|
||||
return jsonSuccess({
|
||||
message: '规格款已彻底删除',
|
||||
}, 200, reqCtx.requestId)
|
||||
})
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import type { ReactNode } from 'react'
|
||||
import AssetCenterClient from './AssetCenterClient'
|
||||
@ -10,6 +10,7 @@ import type {
|
||||
SubstrateDTO,
|
||||
} from '@/server/services'
|
||||
import {
|
||||
createAssetVariantWithSimilarity,
|
||||
regenerateAssetVariantThumbnail,
|
||||
updateAssetVariant,
|
||||
} from '@/lib/open-product/platform-api'
|
||||
@ -32,7 +33,23 @@ jest.mock('next/link', () => ({
|
||||
|
||||
jest.mock('@/components/ui/Modal', () => ({
|
||||
__esModule: true,
|
||||
default: () => null,
|
||||
default: ({
|
||||
isOpen,
|
||||
title,
|
||||
children,
|
||||
footer,
|
||||
}: {
|
||||
isOpen: boolean
|
||||
title?: string
|
||||
children: ReactNode
|
||||
footer?: ReactNode
|
||||
}) =>
|
||||
isOpen ? (
|
||||
<div role="dialog" aria-label={title}>
|
||||
{children}
|
||||
{footer}
|
||||
</div>
|
||||
) : null,
|
||||
}))
|
||||
|
||||
jest.mock('@/components/ui/Button', () => ({
|
||||
@ -72,19 +89,86 @@ jest.mock('@/components/platform/ModelPreviewPanel', () => ({
|
||||
titleAside,
|
||||
materialAssignments,
|
||||
onModelNodesChange,
|
||||
onNodeContextMenu,
|
||||
}: {
|
||||
titleAside?: ReactNode
|
||||
materialAssignments?: Array<{ material?: { textureRepeat?: number } | null }>
|
||||
materialAssignments?: Array<{ material?: {
|
||||
id?: string
|
||||
textureRepeat?: number
|
||||
textureRepeatU?: number
|
||||
textureRepeatV?: number
|
||||
textureProjectionAxis?: string
|
||||
textureAxisConfigs?: Record<string, {
|
||||
textureRepeat?: number
|
||||
textureRepeatU?: number
|
||||
textureRepeatV?: number
|
||||
}>
|
||||
} | null }>
|
||||
onModelNodesChange?: (nodeNames: string[]) => void
|
||||
onNodeContextMenu?: (payload: {
|
||||
nodeName: string | null
|
||||
rawNodeName: string | null
|
||||
candidateNodeName: string | null
|
||||
clientX: number
|
||||
clientY: number
|
||||
appendToSelection: boolean
|
||||
hasRaycastHit: boolean
|
||||
}) => void
|
||||
}) => (
|
||||
<div>
|
||||
<div>ModelPreviewPanel</div>
|
||||
<div data-testid="preview-material-id">
|
||||
{String(materialAssignments?.[0]?.material?.id ?? 'none')}
|
||||
</div>
|
||||
<div data-testid="preview-material-repeat">
|
||||
{String(materialAssignments?.[0]?.material?.textureRepeat ?? 'none')}
|
||||
{String(
|
||||
materialAssignments?.[0]?.material?.textureAxisConfigs?.[
|
||||
materialAssignments?.[0]?.material?.textureProjectionAxis ?? ''
|
||||
]?.textureRepeat ??
|
||||
materialAssignments?.[0]?.material?.textureRepeat ??
|
||||
'none',
|
||||
)}
|
||||
</div>
|
||||
<div data-testid="preview-material-repeat-u">
|
||||
{String(
|
||||
materialAssignments?.[0]?.material?.textureAxisConfigs?.[
|
||||
materialAssignments?.[0]?.material?.textureProjectionAxis ?? ''
|
||||
]?.textureRepeatU ??
|
||||
materialAssignments?.[0]?.material?.textureRepeatU ??
|
||||
'none',
|
||||
)}
|
||||
</div>
|
||||
<div data-testid="preview-material-repeat-v">
|
||||
{String(
|
||||
materialAssignments?.[0]?.material?.textureAxisConfigs?.[
|
||||
materialAssignments?.[0]?.material?.textureProjectionAxis ?? ''
|
||||
]?.textureRepeatV ??
|
||||
materialAssignments?.[0]?.material?.textureRepeatV ??
|
||||
'none',
|
||||
)}
|
||||
</div>
|
||||
<div data-testid="preview-material-axis">
|
||||
{String(materialAssignments?.[0]?.material?.textureProjectionAxis ?? 'none')}
|
||||
</div>
|
||||
<button type="button" onClick={() => onModelNodesChange?.(['panel-shell'])}>
|
||||
同步预览节点
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
onNodeContextMenu?.({
|
||||
nodeName: 'panel-shell',
|
||||
rawNodeName: 'mesh-panel-shell',
|
||||
candidateNodeName: 'panel-shell',
|
||||
clientX: 120,
|
||||
clientY: 140,
|
||||
appendToSelection: false,
|
||||
hasRaycastHit: true,
|
||||
})
|
||||
}
|
||||
>
|
||||
模拟预览右键
|
||||
</button>
|
||||
<div>{titleAside}</div>
|
||||
</div>
|
||||
),
|
||||
@ -138,6 +222,10 @@ const mockedRegenerateAssetVariantThumbnail =
|
||||
>
|
||||
const mockedUpdateAssetVariant =
|
||||
updateAssetVariant as jest.MockedFunction<typeof updateAssetVariant>
|
||||
const mockedCreateAssetVariantWithSimilarity =
|
||||
createAssetVariantWithSimilarity as jest.MockedFunction<
|
||||
typeof createAssetVariantWithSimilarity
|
||||
>
|
||||
|
||||
function createFamily(overrides?: Partial<AssetFamilyDTO>): AssetFamilyDTO {
|
||||
return {
|
||||
@ -174,6 +262,88 @@ function createFamily(overrides?: Partial<AssetFamilyDTO>): AssetFamilyDTO {
|
||||
}
|
||||
}
|
||||
|
||||
function createBoardVariant(overrides?: Partial<AssetVariantDTO>): AssetVariantDTO {
|
||||
return {
|
||||
id: 'variant-board-default',
|
||||
assetFamilyId: 'family-board-default',
|
||||
assetFamilyCode: 'BOARD-DEFAULT',
|
||||
assetFamilyName: '默认桌面板',
|
||||
assetType: 'BOARD',
|
||||
code: 'BOARD-DEFAULT-A',
|
||||
name: '默认桌面板 A',
|
||||
identityKey: 'board-default-a',
|
||||
status: 'AVAILABLE',
|
||||
sizeLabel: '1200x600x18',
|
||||
substrateId: 'substrate-1',
|
||||
substrateCode: 'PB18',
|
||||
substrateName: '18mm 颗粒板',
|
||||
finishId: 'finish-1',
|
||||
finishName: '白橡木纹',
|
||||
dimensions: { length: 1200, width: 600, thickness: 18 },
|
||||
modelUrl: '/models/board-default.glb',
|
||||
thumbnailUrl: null,
|
||||
mountPoints: [{ name: 'origin', position: [0, 0, 0] }],
|
||||
assemblyBasis: { origin: 'center' },
|
||||
compatibilityRules: null,
|
||||
namingFields: null,
|
||||
businessData: null,
|
||||
weightKg: null,
|
||||
costPrice: null,
|
||||
lossFactor: 1,
|
||||
estimatedCostPrice: 88,
|
||||
inheritedFromVariantId: null,
|
||||
createdBy: 'user-1',
|
||||
creatorName: 'Test User',
|
||||
workflow: {
|
||||
submittedAt: null,
|
||||
submittedById: null,
|
||||
submittedByName: null,
|
||||
reviewedAt: null,
|
||||
reviewedById: null,
|
||||
reviewedByName: null,
|
||||
rejectedAt: null,
|
||||
rejectedById: null,
|
||||
rejectedByName: null,
|
||||
rejectionReason: null,
|
||||
lastModifiedById: null,
|
||||
lastModifiedByName: null,
|
||||
},
|
||||
createdAt: new Date('2026-04-03T10:00:00.000Z'),
|
||||
updatedAt: new Date('2026-04-03T10:00:00.000Z'),
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function createSubstrateFixture(overrides?: Partial<SubstrateDTO>): SubstrateDTO {
|
||||
return {
|
||||
id: 'substrate-1',
|
||||
code: 'PB18',
|
||||
name: '18mm 颗粒板',
|
||||
description: null,
|
||||
systemNameOptionId: null,
|
||||
systemNameOptionName: null,
|
||||
categoryId: 'cat-board',
|
||||
categoryName: '板材',
|
||||
environmentGradeId: 'env-e0',
|
||||
environmentGradeName: 'E0',
|
||||
thicknessSpecId: 'thick-18',
|
||||
thicknessSpecName: '18mm',
|
||||
thicknessMm: 18,
|
||||
sheetLengthMm: 2440,
|
||||
sheetWidthMm: 1220,
|
||||
surfaceOptionId: null,
|
||||
surfaceOptionName: null,
|
||||
suggestedPrice: null,
|
||||
weightKg: null,
|
||||
finishes: [],
|
||||
metadata: null,
|
||||
isActive: true,
|
||||
createdAt: new Date('2026-04-03T10:00:00.000Z'),
|
||||
updatedAt: new Date('2026-04-03T10:00:00.000Z'),
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
const user = {
|
||||
id: 'user-1',
|
||||
name: 'Test User',
|
||||
@ -196,6 +366,7 @@ describe('AssetCenterClient', () => {
|
||||
beforeEach(() => {
|
||||
mockedRegenerateAssetVariantThumbnail.mockReset()
|
||||
mockedUpdateAssetVariant.mockReset()
|
||||
mockedCreateAssetVariantWithSimilarity.mockReset()
|
||||
})
|
||||
|
||||
it('草稿资产在列表里不显示草稿状态,但仍保留提审操作', () => {
|
||||
@ -561,125 +732,26 @@ describe('AssetCenterClient', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('UV 保存会通过 uvConfig 字段提交,并沿用同一份 uvConfig 回显', async () => {
|
||||
it('UV 和基材草稿只暂存,点击右键保存后另存为新规格款', async () => {
|
||||
const actor = userEvent.setup()
|
||||
mockedUpdateAssetVariant.mockResolvedValue({
|
||||
id: 'variant-board-uv',
|
||||
assetFamilyId: 'family-board-uv',
|
||||
assetFamilyCode: 'BOARD-030',
|
||||
assetFamilyName: 'UV 桌面板',
|
||||
assetType: 'BOARD',
|
||||
code: 'BOARD-030-A',
|
||||
name: 'UV 桌面板 A',
|
||||
identityKey: 'board-030-a',
|
||||
status: 'AVAILABLE',
|
||||
sizeLabel: '1200x600x18',
|
||||
substrateId: 'substrate-1',
|
||||
substrateCode: 'PB18',
|
||||
substrateName: '18mm 颗粒板',
|
||||
finishId: 'finish-1',
|
||||
finishName: '白橡木纹',
|
||||
dimensions: { length: 1200, width: 600, thickness: 18 },
|
||||
modelUrl: '/models/board-uv.glb',
|
||||
thumbnailUrl: null,
|
||||
mountPoints: [{ name: 'origin', position: [0, 0, 0] }],
|
||||
assemblyBasis: { origin: 'center' },
|
||||
compatibilityRules: null,
|
||||
namingFields: null,
|
||||
businessData: {
|
||||
renderConfig: {
|
||||
uv: {
|
||||
version: 1,
|
||||
nodes: {
|
||||
__default__: {
|
||||
textureRepeat: 2,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
weightKg: null,
|
||||
costPrice: null,
|
||||
lossFactor: 1,
|
||||
estimatedCostPrice: 88,
|
||||
inheritedFromVariantId: null,
|
||||
createdBy: 'user-1',
|
||||
creatorName: 'Test User',
|
||||
workflow: {
|
||||
submittedAt: null,
|
||||
submittedById: null,
|
||||
submittedByName: null,
|
||||
reviewedAt: null,
|
||||
reviewedById: null,
|
||||
reviewedByName: null,
|
||||
rejectedAt: null,
|
||||
rejectedById: null,
|
||||
rejectedByName: null,
|
||||
rejectionReason: null,
|
||||
lastModifiedById: null,
|
||||
lastModifiedByName: null,
|
||||
},
|
||||
createdAt: new Date('2026-04-03T10:00:00.000Z'),
|
||||
updatedAt: new Date('2026-04-03T10:00:00.000Z'),
|
||||
})
|
||||
mockedRegenerateAssetVariantThumbnail.mockResolvedValue({
|
||||
id: 'variant-board-uv',
|
||||
assetFamilyId: 'family-board-uv',
|
||||
assetFamilyCode: 'BOARD-030',
|
||||
assetFamilyName: 'UV 桌面板',
|
||||
assetType: 'BOARD',
|
||||
code: 'BOARD-030-A',
|
||||
name: 'UV 桌面板 A',
|
||||
identityKey: 'board-030-a',
|
||||
status: 'AVAILABLE',
|
||||
sizeLabel: '1200x600x18',
|
||||
substrateId: 'substrate-1',
|
||||
substrateCode: 'PB18',
|
||||
substrateName: '18mm 颗粒板',
|
||||
finishId: 'finish-1',
|
||||
finishName: '白橡木纹',
|
||||
dimensions: { length: 1200, width: 600, thickness: 18 },
|
||||
modelUrl: '/models/board-uv.glb',
|
||||
thumbnailUrl: '/asset-center/thumbnails/board-uv.png',
|
||||
mountPoints: [{ name: 'origin', position: [0, 0, 0] }],
|
||||
assemblyBasis: { origin: 'center' },
|
||||
compatibilityRules: null,
|
||||
namingFields: null,
|
||||
businessData: {
|
||||
renderConfig: {
|
||||
uv: {
|
||||
version: 1,
|
||||
nodes: {
|
||||
__default__: {
|
||||
textureRepeat: 2,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
weightKg: null,
|
||||
costPrice: null,
|
||||
lossFactor: 1,
|
||||
estimatedCostPrice: 88,
|
||||
inheritedFromVariantId: null,
|
||||
createdBy: 'user-1',
|
||||
creatorName: 'Test User',
|
||||
workflow: {
|
||||
submittedAt: null,
|
||||
submittedById: null,
|
||||
submittedByName: null,
|
||||
reviewedAt: null,
|
||||
reviewedById: null,
|
||||
reviewedByName: null,
|
||||
rejectedAt: null,
|
||||
rejectedById: null,
|
||||
rejectedByName: null,
|
||||
rejectionReason: null,
|
||||
lastModifiedById: null,
|
||||
lastModifiedByName: null,
|
||||
},
|
||||
createdAt: new Date('2026-04-03T10:00:00.000Z'),
|
||||
updatedAt: new Date('2026-04-03T10:00:00.000Z'),
|
||||
mockedCreateAssetVariantWithSimilarity.mockResolvedValue({
|
||||
variant: createBoardVariant({
|
||||
id: 'variant-board-uv-copy',
|
||||
assetFamilyId: 'family-board-uv',
|
||||
assetFamilyCode: 'BOARD-030',
|
||||
assetFamilyName: 'UV 桌面板',
|
||||
code: 'BOARD-030-B',
|
||||
name: 'UV 桌面板 B',
|
||||
identityKey: 'board-030-b',
|
||||
substrateId: 'substrate-2',
|
||||
substrateCode: 'PB25',
|
||||
substrateName: '25mm 颗粒板',
|
||||
finishId: 'finish-2',
|
||||
finishName: '胡桃木纹',
|
||||
modelUrl: '/models/board-uv.glb',
|
||||
}),
|
||||
similarCandidates: [],
|
||||
similarityNotice: null,
|
||||
})
|
||||
|
||||
render(
|
||||
@ -744,7 +816,60 @@ describe('AssetCenterClient', () => {
|
||||
updatedAt: new Date('2026-04-03T10:00:00.000Z'),
|
||||
},
|
||||
]}
|
||||
initialSubstrates={[]}
|
||||
initialSubstrates={[
|
||||
createSubstrateFixture({
|
||||
id: 'substrate-1',
|
||||
code: 'PB18',
|
||||
name: '18mm 颗粒板',
|
||||
finishes: [
|
||||
{
|
||||
id: 'finish-1',
|
||||
name: '白橡木纹',
|
||||
code: 'FINISH-OAK-WHITE',
|
||||
categoryId: 'cat-finish',
|
||||
categoryName: '饰面',
|
||||
thumbnailUrl: '/finish-oak.png',
|
||||
textureUrl: '/finish-oak.jpg',
|
||||
normalMapUrl: null,
|
||||
color: '#ffffff',
|
||||
metalness: 0,
|
||||
roughness: 1,
|
||||
opacity: 1,
|
||||
textureRepeat: 1,
|
||||
textureRotation: 0,
|
||||
textureSizeMm: 600,
|
||||
isPreset: true,
|
||||
isActive: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
createSubstrateFixture({
|
||||
id: 'substrate-2',
|
||||
code: 'PB25',
|
||||
name: '25mm 颗粒板',
|
||||
finishes: [
|
||||
{
|
||||
id: 'finish-2',
|
||||
name: '胡桃木纹',
|
||||
code: 'FINISH-WALNUT',
|
||||
categoryId: 'cat-finish',
|
||||
categoryName: '饰面',
|
||||
thumbnailUrl: '/finish-walnut.png',
|
||||
textureUrl: '/finish-walnut.jpg',
|
||||
normalMapUrl: null,
|
||||
color: '#6b4a32',
|
||||
metalness: 0,
|
||||
roughness: 0.8,
|
||||
opacity: 1,
|
||||
textureRepeat: 7,
|
||||
textureRotation: 0,
|
||||
textureSizeMm: 600,
|
||||
isPreset: true,
|
||||
isActive: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
]}
|
||||
initialSeriesTemplates={seriesTemplates}
|
||||
finishes={[
|
||||
{
|
||||
@ -785,23 +910,83 @@ describe('AssetCenterClient', () => {
|
||||
await actor.click(screen.getByText('UV 桌面板'))
|
||||
await actor.click(screen.getByText(/UV 桌面板 A/))
|
||||
await actor.click(screen.getByRole('button', { name: '同步预览节点' }))
|
||||
await actor.click(screen.getByRole('button', { name: '模拟预览右键' }))
|
||||
await actor.click(screen.getByRole('button', { name: /渲染参数/ }))
|
||||
|
||||
expect(screen.getByTestId('preview-material-repeat')).toHaveTextContent('1')
|
||||
|
||||
await actor.clear(screen.getByLabelText('纹理重复'))
|
||||
await actor.type(screen.getByLabelText('纹理重复'), '2')
|
||||
await actor.click(screen.getByRole('button', { name: '确认并同步修改' }))
|
||||
await actor.click(screen.getByRole('button', { name: 'Z 轴' }))
|
||||
await actor.clear(screen.getByLabelText('整体重复'))
|
||||
await actor.type(screen.getByLabelText('整体重复'), '2')
|
||||
await actor.clear(screen.getByLabelText('旋转角度 °'))
|
||||
await actor.type(screen.getByLabelText('旋转角度 °'), '90')
|
||||
await actor.clear(screen.getByLabelText('U 压缩'))
|
||||
await actor.type(screen.getByLabelText('U 压缩'), '1')
|
||||
await actor.clear(screen.getByLabelText('V 压缩'))
|
||||
await actor.type(screen.getByLabelText('V 压缩'), '6')
|
||||
await actor.click(screen.getByRole('button', { name: '暂存渲染参数' }))
|
||||
|
||||
expect(mockedUpdateAssetVariant).toHaveBeenCalledWith(
|
||||
'variant-board-uv',
|
||||
expect(screen.getByTestId('preview-material-repeat')).toHaveTextContent('2')
|
||||
expect(screen.getByTestId('preview-material-repeat-u')).toHaveTextContent('1')
|
||||
expect(screen.getByTestId('preview-material-repeat-v')).toHaveTextContent('6')
|
||||
expect(screen.getByTestId('preview-material-axis')).toHaveTextContent('z')
|
||||
|
||||
await actor.click(screen.getByRole('button', { name: 'X 轴' }))
|
||||
expect(screen.getByLabelText('整体重复')).toHaveValue('')
|
||||
expect(screen.getByTestId('preview-material-repeat')).toHaveTextContent('1')
|
||||
expect(screen.getByTestId('preview-material-axis')).toHaveTextContent('x')
|
||||
await actor.clear(screen.getByLabelText('整体重复'))
|
||||
await actor.type(screen.getByLabelText('整体重复'), '4')
|
||||
await actor.clear(screen.getByLabelText('U 压缩'))
|
||||
await actor.type(screen.getByLabelText('U 压缩'), '2')
|
||||
|
||||
await actor.click(screen.getByRole('button', { name: 'Z 轴' }))
|
||||
expect(screen.getByLabelText('整体重复')).toHaveValue('2')
|
||||
expect(screen.getByLabelText('U 压缩')).toHaveValue('1')
|
||||
expect(screen.getByLabelText('V 压缩')).toHaveValue('6')
|
||||
expect(screen.getByTestId('preview-material-repeat')).toHaveTextContent('2')
|
||||
expect(screen.getByTestId('preview-material-axis')).toHaveTextContent('z')
|
||||
|
||||
await actor.click(screen.getByRole('button', { name: /快捷更改基材/ }))
|
||||
await actor.click(screen.getByRole('button', { name: '25mm 颗粒板 · PB25' }))
|
||||
|
||||
expect(screen.getByTestId('preview-material-id')).toHaveTextContent('finish-2')
|
||||
expect(mockedUpdateAssetVariant).not.toHaveBeenCalled()
|
||||
expect(mockedRegenerateAssetVariantThumbnail).not.toHaveBeenCalled()
|
||||
|
||||
await actor.click(screen.getByRole('button', { name: '保存' }))
|
||||
const duplicateDialog = within(screen.getByRole('dialog', { name: '另存为新规格款' }))
|
||||
expect(duplicateDialog.getByLabelText('编码')).toHaveValue('BOARD-030-A')
|
||||
expect(duplicateDialog.getByPlaceholderText('例如 桌面板 1200 白橡')).toHaveValue('UV 桌面板 A')
|
||||
expect(duplicateDialog.getByLabelText('尺寸标签')).toHaveValue('1200x600x18')
|
||||
expect(duplicateDialog.getByLabelText('尺寸标签')).toBeDisabled()
|
||||
expect(duplicateDialog.getByLabelText('长度')).toBeDisabled()
|
||||
expect(duplicateDialog.getByLabelText('宽度')).toBeDisabled()
|
||||
expect(duplicateDialog.getByLabelText('厚度')).toBeDisabled()
|
||||
|
||||
await actor.clear(duplicateDialog.getByLabelText('编码'))
|
||||
await actor.type(duplicateDialog.getByLabelText('编码'), 'BOARD-030-B')
|
||||
await actor.click(screen.getByRole('button', { name: '另存为新规格款' }))
|
||||
|
||||
expect(mockedCreateAssetVariantWithSimilarity).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
businessData: {
|
||||
renderConfig: {
|
||||
uv: {
|
||||
version: 1,
|
||||
nodes: {
|
||||
'panel-shell': {
|
||||
inheritedFromVariantId: 'variant-board-uv',
|
||||
substrateId: 'substrate-2',
|
||||
uvConfig: {
|
||||
version: 1,
|
||||
nodes: {
|
||||
'panel-shell': {
|
||||
textureProjectionAxis: 'z',
|
||||
axes: {
|
||||
z: {
|
||||
textureRepeat: 2,
|
||||
textureRepeatU: 1,
|
||||
textureRepeatV: 6,
|
||||
textureRotation: Math.PI / 2,
|
||||
},
|
||||
x: {
|
||||
textureRepeat: 4,
|
||||
textureRepeatU: 2,
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -809,7 +994,414 @@ describe('AssetCenterClient', () => {
|
||||
},
|
||||
}),
|
||||
)
|
||||
expect(mockedRegenerateAssetVariantThumbnail).toHaveBeenCalledWith('variant-board-uv')
|
||||
expect(screen.getByTestId('preview-material-repeat')).toHaveTextContent('2')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/UV 桌面板 B/)).toBeInTheDocument()
|
||||
})
|
||||
await actor.click(screen.getByText(/UV 桌面板 A/))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('preview-material-id')).toHaveTextContent('finish-1')
|
||||
expect(screen.getByTestId('preview-material-repeat')).toHaveTextContent('1')
|
||||
})
|
||||
})
|
||||
|
||||
it('仅调整 UV 渲染参数时直接保存到当前规格款', async () => {
|
||||
const actor = userEvent.setup()
|
||||
mockedUpdateAssetVariant.mockResolvedValue(createBoardVariant({
|
||||
id: 'variant-board-uv-direct',
|
||||
assetFamilyId: 'family-board-uv-direct',
|
||||
assetFamilyCode: 'BOARD-031',
|
||||
assetFamilyName: 'UV 后调桌面板',
|
||||
code: 'BOARD-031-A',
|
||||
name: 'UV 后调桌面板 A',
|
||||
uvConfig: {
|
||||
version: 1,
|
||||
nodes: {
|
||||
'panel-shell': {
|
||||
textureProjectionAxis: 'z',
|
||||
axes: {
|
||||
z: {
|
||||
textureRepeat: 3,
|
||||
textureRotation: Math.PI / 2,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
render(
|
||||
<AssetCenterClient
|
||||
initialFamilies={[
|
||||
createFamily({
|
||||
id: 'family-board-uv-direct',
|
||||
type: 'BOARD',
|
||||
code: 'BOARD-031',
|
||||
name: 'UV 后调桌面板',
|
||||
status: 'AVAILABLE',
|
||||
}),
|
||||
]}
|
||||
initialVariants={[
|
||||
createBoardVariant({
|
||||
id: 'variant-board-uv-direct',
|
||||
assetFamilyId: 'family-board-uv-direct',
|
||||
assetFamilyCode: 'BOARD-031',
|
||||
assetFamilyName: 'UV 后调桌面板',
|
||||
code: 'BOARD-031-A',
|
||||
name: 'UV 后调桌面板 A',
|
||||
}),
|
||||
]}
|
||||
initialSubstrates={[
|
||||
createSubstrateFixture({
|
||||
id: 'substrate-1',
|
||||
code: 'PB18',
|
||||
name: '18mm 颗粒板',
|
||||
finishes: [
|
||||
{
|
||||
id: 'finish-1',
|
||||
name: '白橡木纹',
|
||||
code: 'FINISH-OAK-WHITE',
|
||||
categoryId: 'cat-finish',
|
||||
categoryName: '饰面',
|
||||
thumbnailUrl: '/finish-oak.png',
|
||||
textureUrl: '/finish-oak.jpg',
|
||||
normalMapUrl: null,
|
||||
color: '#ffffff',
|
||||
metalness: 0,
|
||||
roughness: 1,
|
||||
opacity: 1,
|
||||
textureRepeat: 1,
|
||||
textureRotation: 0,
|
||||
textureSizeMm: 600,
|
||||
isPreset: true,
|
||||
isActive: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
]}
|
||||
initialSeriesTemplates={seriesTemplates}
|
||||
finishes={[
|
||||
{
|
||||
id: 'finish-1',
|
||||
name: '白橡木纹',
|
||||
code: 'FINISH-OAK-WHITE',
|
||||
categoryId: 'cat-finish',
|
||||
categoryName: '饰面',
|
||||
thumbnailUrl: '/finish-oak.png',
|
||||
textureUrl: '/finish-oak.jpg',
|
||||
normalMapUrl: null,
|
||||
roughnessMapUrl: null,
|
||||
metalnessMapUrl: null,
|
||||
aoMapUrl: null,
|
||||
color: '#ffffff',
|
||||
metalness: 0,
|
||||
roughness: 1,
|
||||
opacity: 1,
|
||||
transmission: null,
|
||||
ior: null,
|
||||
thickness: null,
|
||||
clearcoat: null,
|
||||
clearcoatRoughness: null,
|
||||
envMapIntensity: null,
|
||||
textureRepeat: 1,
|
||||
textureRotation: 0,
|
||||
textureSizeMm: 600,
|
||||
isPreset: true,
|
||||
isActive: true,
|
||||
} as unknown as MaterialDTO,
|
||||
]}
|
||||
materialCategories={[]}
|
||||
metrics={[]}
|
||||
user={user}
|
||||
/>,
|
||||
)
|
||||
|
||||
await actor.click(screen.getByText('UV 后调桌面板'))
|
||||
await actor.click(screen.getByText(/UV 后调桌面板 A/))
|
||||
await actor.click(screen.getByRole('button', { name: '同步预览节点' }))
|
||||
await actor.click(screen.getByRole('button', { name: '模拟预览右键' }))
|
||||
await actor.click(screen.getByRole('button', { name: /渲染参数/ }))
|
||||
await actor.click(screen.getByRole('button', { name: 'Z 轴' }))
|
||||
await actor.clear(screen.getByLabelText('整体重复'))
|
||||
await actor.type(screen.getByLabelText('整体重复'), '3')
|
||||
await actor.clear(screen.getByLabelText('旋转角度 °'))
|
||||
await actor.type(screen.getByLabelText('旋转角度 °'), '90')
|
||||
|
||||
await actor.click(screen.getByRole('button', { name: '保存' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedUpdateAssetVariant).toHaveBeenCalledWith(
|
||||
'variant-board-uv-direct',
|
||||
{
|
||||
uvConfig: {
|
||||
version: 1,
|
||||
nodes: {
|
||||
'panel-shell': {
|
||||
textureProjectionAxis: 'z',
|
||||
axes: {
|
||||
z: {
|
||||
textureRepeat: 3,
|
||||
textureRotation: Math.PI / 2,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
})
|
||||
expect(mockedCreateAssetVariantWithSimilarity).not.toHaveBeenCalled()
|
||||
expect(screen.queryByRole('dialog', { name: '另存为新规格款' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('UV 草稿在点击 3D 世界节点后不会被已保存配置覆盖', async () => {
|
||||
const actor = userEvent.setup()
|
||||
|
||||
render(
|
||||
<AssetCenterClient
|
||||
initialFamilies={[
|
||||
createFamily({
|
||||
id: 'family-board-draft-uv',
|
||||
type: 'BOARD',
|
||||
code: 'BOARD-DRAFT-UV',
|
||||
name: 'UV 草稿桌面板',
|
||||
applicableSeriesCodes: ['single-board-desk'],
|
||||
status: 'AVAILABLE',
|
||||
}),
|
||||
]}
|
||||
initialVariants={[
|
||||
createBoardVariant({
|
||||
id: 'variant-board-draft-uv',
|
||||
assetFamilyId: 'family-board-draft-uv',
|
||||
assetFamilyCode: 'BOARD-DRAFT-UV',
|
||||
assetFamilyName: 'UV 草稿桌面板',
|
||||
code: 'BOARD-DRAFT-UV-A',
|
||||
name: 'UV 草稿桌面板 A',
|
||||
identityKey: 'board-draft-uv-a',
|
||||
modelUrl: '/models/board-draft-uv.glb',
|
||||
}),
|
||||
]}
|
||||
initialSubstrates={[
|
||||
createSubstrateFixture({
|
||||
id: 'substrate-1',
|
||||
code: 'PB18',
|
||||
name: '18mm 颗粒板',
|
||||
}),
|
||||
createSubstrateFixture({
|
||||
id: 'substrate-2',
|
||||
code: 'PB25',
|
||||
name: '25mm 颗粒板',
|
||||
}),
|
||||
]}
|
||||
initialSeriesTemplates={seriesTemplates}
|
||||
finishes={[
|
||||
{
|
||||
id: 'finish-1',
|
||||
name: '白橡木纹',
|
||||
code: 'FINISH-OAK-WHITE',
|
||||
categoryId: 'cat-finish',
|
||||
categoryName: '饰面',
|
||||
thumbnailUrl: '/finish-oak.png',
|
||||
textureUrl: '/finish-oak.jpg',
|
||||
normalMapUrl: null,
|
||||
roughnessMapUrl: null,
|
||||
metalnessMapUrl: null,
|
||||
aoMapUrl: null,
|
||||
color: '#ffffff',
|
||||
metalness: 0,
|
||||
roughness: 1,
|
||||
opacity: 1,
|
||||
transmission: null,
|
||||
ior: null,
|
||||
thickness: null,
|
||||
clearcoat: null,
|
||||
clearcoatRoughness: null,
|
||||
envMapIntensity: null,
|
||||
textureRepeat: 1,
|
||||
textureRotation: 0,
|
||||
textureSizeMm: 600,
|
||||
isPreset: true,
|
||||
isActive: true,
|
||||
} as unknown as MaterialDTO,
|
||||
]}
|
||||
materialCategories={[]}
|
||||
metrics={[]}
|
||||
user={user}
|
||||
/>,
|
||||
)
|
||||
|
||||
await actor.click(screen.getByText('UV 草稿桌面板'))
|
||||
await actor.click(screen.getByText(/UV 草稿桌面板 A/))
|
||||
await actor.click(screen.getByRole('button', { name: '同步预览节点' }))
|
||||
await actor.click(screen.getByRole('button', { name: '模拟预览右键' }))
|
||||
await actor.click(screen.getByRole('button', { name: /渲染参数/ }))
|
||||
|
||||
expect(screen.getByTestId('preview-material-repeat')).toHaveTextContent('1')
|
||||
|
||||
await actor.clear(screen.getByLabelText('整体重复'))
|
||||
await actor.type(screen.getByLabelText('整体重复'), '3')
|
||||
|
||||
expect(screen.getByTestId('preview-material-repeat')).toHaveTextContent('3')
|
||||
|
||||
fireEvent.mouseDown(document.body)
|
||||
await actor.click(screen.getByRole('button', { name: '模拟预览右键' }))
|
||||
await actor.click(screen.getByRole('button', { name: /渲染参数/ }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText('整体重复')).toHaveValue('3')
|
||||
expect(screen.getByTestId('preview-material-repeat')).toHaveTextContent('3')
|
||||
})
|
||||
})
|
||||
|
||||
it('预览右键菜单打开后不会被全局 contextmenu 事件立即关闭', async () => {
|
||||
const actor = userEvent.setup()
|
||||
|
||||
render(
|
||||
<AssetCenterClient
|
||||
initialFamilies={[
|
||||
createFamily({
|
||||
id: 'family-board-context',
|
||||
type: 'BOARD',
|
||||
code: 'BOARD-040',
|
||||
name: '右键桌面板',
|
||||
applicableSeriesCodes: ['single-board-desk'],
|
||||
status: 'AVAILABLE',
|
||||
}),
|
||||
]}
|
||||
initialVariants={[
|
||||
{
|
||||
id: 'variant-board-context',
|
||||
assetFamilyId: 'family-board-context',
|
||||
assetFamilyCode: 'BOARD-040',
|
||||
assetFamilyName: '右键桌面板',
|
||||
assetType: 'BOARD',
|
||||
code: 'BOARD-040-A',
|
||||
name: '右键桌面板 A',
|
||||
identityKey: 'board-040-a',
|
||||
status: 'AVAILABLE',
|
||||
sizeLabel: '1200x600x18',
|
||||
substrateId: null,
|
||||
substrateCode: null,
|
||||
substrateName: null,
|
||||
finishId: null,
|
||||
finishName: null,
|
||||
dimensions: { length: 1200, width: 600, thickness: 18 },
|
||||
modelUrl: '/models/board-context.glb',
|
||||
thumbnailUrl: null,
|
||||
mountPoints: [{ name: 'origin', position: [0, 0, 0] }],
|
||||
assemblyBasis: { origin: 'center' },
|
||||
compatibilityRules: null,
|
||||
namingFields: null,
|
||||
businessData: null,
|
||||
weightKg: null,
|
||||
costPrice: null,
|
||||
lossFactor: 1,
|
||||
estimatedCostPrice: 88,
|
||||
inheritedFromVariantId: null,
|
||||
createdBy: 'user-1',
|
||||
creatorName: 'Test User',
|
||||
workflow: {
|
||||
submittedAt: null,
|
||||
submittedById: null,
|
||||
submittedByName: null,
|
||||
reviewedAt: null,
|
||||
reviewedById: null,
|
||||
reviewedByName: null,
|
||||
rejectedAt: null,
|
||||
rejectedById: null,
|
||||
rejectedByName: null,
|
||||
rejectionReason: null,
|
||||
lastModifiedById: null,
|
||||
lastModifiedByName: null,
|
||||
},
|
||||
createdAt: new Date('2026-04-03T10:00:00.000Z'),
|
||||
updatedAt: new Date('2026-04-03T10:00:00.000Z'),
|
||||
},
|
||||
]}
|
||||
initialSubstrates={[
|
||||
createSubstrateFixture({
|
||||
id: 'substrate-1',
|
||||
code: 'PB18',
|
||||
name: '18mm 颗粒板',
|
||||
}),
|
||||
createSubstrateFixture({
|
||||
id: 'substrate-2',
|
||||
code: 'PB25',
|
||||
name: '25mm 颗粒板',
|
||||
}),
|
||||
]}
|
||||
initialSeriesTemplates={seriesTemplates}
|
||||
finishes={[]}
|
||||
materialCategories={[]}
|
||||
metrics={[]}
|
||||
user={user}
|
||||
/>,
|
||||
)
|
||||
|
||||
await actor.click(screen.getByText('右键桌面板'))
|
||||
await actor.click(screen.getByText(/右键桌面板 A/))
|
||||
await actor.click(screen.getByRole('button', { name: '模拟预览右键' }))
|
||||
expect(screen.getByText('渲染参数')).toBeInTheDocument()
|
||||
expect(screen.queryByText('提交审核')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('编辑规格')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('以此规格为模板新建')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.contextMenu(document)
|
||||
|
||||
expect(screen.getByText('渲染参数')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('快捷更改基材二级菜单支持搜索筛选', async () => {
|
||||
const actor = userEvent.setup()
|
||||
|
||||
render(
|
||||
<AssetCenterClient
|
||||
initialFamilies={[
|
||||
createFamily({
|
||||
id: 'family-substrate-search',
|
||||
type: 'BOARD',
|
||||
code: 'BOARD-SUBSTRATE',
|
||||
name: '基材搜索桌面板',
|
||||
applicableSeriesCodes: ['single-board-desk'],
|
||||
}),
|
||||
]}
|
||||
initialVariants={[
|
||||
createBoardVariant({
|
||||
id: 'variant-substrate-search',
|
||||
assetFamilyId: 'family-substrate-search',
|
||||
assetFamilyCode: 'BOARD-SUBSTRATE',
|
||||
assetFamilyName: '基材搜索桌面板',
|
||||
name: '基材搜索桌面板 A',
|
||||
code: 'BOARD-SUBSTRATE-A',
|
||||
}),
|
||||
]}
|
||||
initialSubstrates={[
|
||||
createSubstrateFixture({
|
||||
id: 'substrate-oak',
|
||||
code: 'OAK18',
|
||||
name: '北美原木基材',
|
||||
}),
|
||||
createSubstrateFixture({
|
||||
id: 'substrate-walnut',
|
||||
code: 'WAL18',
|
||||
name: '胡桃木基材',
|
||||
}),
|
||||
]}
|
||||
initialSeriesTemplates={seriesTemplates}
|
||||
finishes={[]}
|
||||
materialCategories={[]}
|
||||
metrics={[]}
|
||||
user={user}
|
||||
/>,
|
||||
)
|
||||
|
||||
await actor.click(screen.getByText('基材搜索桌面板'))
|
||||
await actor.click(screen.getByText(/基材搜索桌面板 A/))
|
||||
await actor.click(screen.getByRole('button', { name: '模拟预览右键' }))
|
||||
await actor.click(screen.getByRole('button', { name: /快捷更改基材/ }))
|
||||
await actor.type(screen.getByLabelText('搜索基材'), '胡桃')
|
||||
|
||||
expect(screen.getByText('胡桃木基材 · WAL18')).toBeInTheDocument()
|
||||
expect(screen.queryByText('北美原木基材 · OAK18')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,32 +1,20 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* 回收站客户端组件
|
||||
*
|
||||
* 遵循苹果风格视觉标准
|
||||
* 使用项目元组件体系和设计 Token
|
||||
*
|
||||
* @created 2026-02-02
|
||||
* @refactored 2026-02-02 - 统一视觉标准
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import {
|
||||
Trash2,
|
||||
RefreshCcw,
|
||||
Package,
|
||||
Tag,
|
||||
Clock,
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowLeft,
|
||||
RotateCcw
|
||||
Boxes,
|
||||
Clock3,
|
||||
Package,
|
||||
RefreshCcw,
|
||||
RotateCcw,
|
||||
Tag,
|
||||
Trash2,
|
||||
} from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import Card from '@/components/ui/Card'
|
||||
import Button from '@/components/ui/Button'
|
||||
import Image from '@/components/ui/Image'
|
||||
import { DeleteConfirmDialog } from '@/components/ui/DeleteConfirmDialog'
|
||||
import { useToast, ToastContainer } from '@/components/ui/Toast'
|
||||
import { ToastContainer, useToast } from '@/components/ui/Toast'
|
||||
|
||||
interface TrashProduct {
|
||||
type: 'product'
|
||||
@ -55,45 +43,175 @@ interface TrashSKU {
|
||||
attributes: Record<string, unknown> | null
|
||||
}
|
||||
|
||||
type TrashItem = TrashProduct | TrashSKU
|
||||
interface TrashAssetFamily {
|
||||
type: 'assetFamily'
|
||||
id: string
|
||||
assetType: string
|
||||
code: string
|
||||
name: string
|
||||
status: string
|
||||
variantCount: number
|
||||
createdBy: string | null
|
||||
creatorName: string | null
|
||||
deletedAt: string
|
||||
expiresAt: string
|
||||
daysRemaining: number
|
||||
}
|
||||
|
||||
interface TrashAssetVariant {
|
||||
type: 'assetVariant'
|
||||
id: string
|
||||
assetFamilyId: string
|
||||
assetFamilyCode: string
|
||||
assetFamilyName: string
|
||||
assetType: string
|
||||
code: string
|
||||
name: string
|
||||
status: string
|
||||
sizeLabel: string | null
|
||||
substrateName: string | null
|
||||
finishName: string | null
|
||||
dimensions: Record<string, number>
|
||||
thumbnailUrl: string | null
|
||||
modelUrl: string
|
||||
createdBy: string | null
|
||||
creatorName: string | null
|
||||
deletedAt: string
|
||||
expiresAt: string
|
||||
daysRemaining: number
|
||||
}
|
||||
|
||||
type TrashItem = TrashProduct | TrashSKU | TrashAssetFamily | TrashAssetVariant
|
||||
type TrashTab = 'assetVariants' | 'assetFamilies' | 'products' | 'skus'
|
||||
|
||||
interface TrashData {
|
||||
products: TrashProduct[]
|
||||
skus: TrashSKU[]
|
||||
assetFamilies?: TrashAssetFamily[]
|
||||
assetVariants?: TrashAssetVariant[]
|
||||
summary: {
|
||||
totalProducts: number
|
||||
totalSKUs: number
|
||||
totalAssetFamilies?: number
|
||||
totalAssetVariants?: number
|
||||
retentionDays: number
|
||||
}
|
||||
}
|
||||
|
||||
const assetTypeLabel: Record<string, string> = {
|
||||
BOARD: '板材',
|
||||
COMPONENT: '组件',
|
||||
HARDWARE: '五金',
|
||||
PACKAGING: '包装',
|
||||
}
|
||||
|
||||
const statusLabel: Record<string, string> = {
|
||||
DRAFT: '草稿',
|
||||
PENDING_REVIEW: '待审核',
|
||||
REJECTED: '已驳回',
|
||||
AVAILABLE: '可用',
|
||||
DISABLED: '停用',
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string) {
|
||||
return new Date(dateStr).toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
function formatDimensions(dimensions: Record<string, number>) {
|
||||
const length = dimensions.length
|
||||
const width = dimensions.width ?? dimensions.depth
|
||||
const thickness = dimensions.thickness ?? dimensions.height
|
||||
const values = [length, width, thickness].filter((value) => Number.isFinite(value))
|
||||
return values.length > 0 ? values.map((value) => `${value}`).join(' x ') : '未记录尺寸'
|
||||
}
|
||||
|
||||
function getItemName(item: TrashItem) {
|
||||
if (item.type === 'product') return item.modelNumber
|
||||
if (item.type === 'sku') return item.skuCode
|
||||
return item.code
|
||||
}
|
||||
|
||||
function getDeleteDescription(item: TrashItem | null) {
|
||||
if (!item) return ''
|
||||
if (item.type === 'assetVariant') return `确定要彻底删除规格款「${item.code}」吗?此操作无法撤销。`
|
||||
if (item.type === 'assetFamily') return `确定要彻底删除资产族「${item.code}」吗?其下规格款会一并删除。`
|
||||
if (item.type === 'product') return `确定要彻底删除产品「${item.modelNumber}」吗?此操作无法撤销。`
|
||||
return `确定要彻底删除 SKU「${item.skuCode}」吗?此操作无法撤销。`
|
||||
}
|
||||
|
||||
function getEndpoint(item: TrashItem) {
|
||||
switch (item.type) {
|
||||
case 'assetVariant':
|
||||
return `/api/trash/asset-variants/${item.id}`
|
||||
case 'assetFamily':
|
||||
return `/api/trash/asset-families/${item.id}`
|
||||
case 'product':
|
||||
return `/api/trash/products/${item.id}`
|
||||
case 'sku':
|
||||
return `/api/trash/skus/${item.id}`
|
||||
}
|
||||
}
|
||||
|
||||
function getEmptyText(tab: TrashTab) {
|
||||
switch (tab) {
|
||||
case 'assetVariants':
|
||||
return '没有已删除的规格款'
|
||||
case 'assetFamilies':
|
||||
return '没有已删除的资产族'
|
||||
case 'products':
|
||||
return '没有已删除的正式产品'
|
||||
case 'skus':
|
||||
return '没有已删除的 SKU'
|
||||
}
|
||||
}
|
||||
|
||||
function ExpiryBadge({ days }: { days: number }) {
|
||||
const urgent = days <= 1
|
||||
return (
|
||||
<span
|
||||
className="inline-flex h-7 items-center rounded-[6px] px-2.5 text-[12px] font-semibold"
|
||||
style={{
|
||||
backgroundColor: urgent ? 'var(--obs-shell-danger-bg)' : 'var(--obs-shell-muted-bg)',
|
||||
color: urgent ? 'var(--obs-shell-danger)' : 'var(--obs-shell-muted)',
|
||||
border: `1px solid ${urgent ? 'var(--obs-shell-danger-border)' : 'var(--obs-shell-border)'}`,
|
||||
}}
|
||||
>
|
||||
{days <= 0 ? '即将清除' : `${days} 天后清除`}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export default function TrashClient() {
|
||||
const toast = useToast()
|
||||
const [data, setData] = useState<TrashData | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
const [activeTab, setActiveTab] = useState<'products' | 'skus'>('products')
|
||||
const [activeTab, setActiveTab] = useState<TrashTab>('assetVariants')
|
||||
const [selectedItem, setSelectedItem] = useState<TrashItem | null>(null)
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
||||
const [isRestoring, setIsRestoring] = useState<string | null>(null)
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
||||
const hasAutoSwitchedTab = useRef(false)
|
||||
|
||||
// 加载回收站数据
|
||||
const loadTrashData = useCallback(async () => {
|
||||
setIsLoading(true)
|
||||
setLoadError(null)
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/trash', { cache: 'no-store' })
|
||||
const json = await res.json().catch(() => null)
|
||||
if (!res.ok) {
|
||||
const message = json?.error?.message || '加载失败,请稍后重试'
|
||||
throw new Error(message)
|
||||
const response = await fetch('/api/trash', { cache: 'no-store' })
|
||||
const payload = await response.json().catch(() => null)
|
||||
if (!response.ok || !payload?.success) {
|
||||
throw new Error(payload?.error?.message || '回收站加载失败')
|
||||
}
|
||||
setData(json.data)
|
||||
setData(payload.data)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '加载回收站数据失败'
|
||||
const message = error instanceof Error ? error.message : '回收站加载失败'
|
||||
setLoadError(message)
|
||||
console.error('加载回收站数据失败:', error)
|
||||
toast.error(message)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
@ -101,63 +219,50 @@ export default function TrashClient() {
|
||||
}, [toast])
|
||||
|
||||
useEffect(() => {
|
||||
loadTrashData()
|
||||
void loadTrashData()
|
||||
}, [loadTrashData])
|
||||
|
||||
// 首次进入页面时:如果默认“产品”为空但 “SKU” 有数据,自动切换到 SKU,避免用户误以为“回收站没内容”
|
||||
const assetVariants = data?.assetVariants ?? []
|
||||
const assetFamilies = data?.assetFamilies ?? []
|
||||
const products = data?.products ?? []
|
||||
const skus = data?.skus ?? []
|
||||
const retentionDays = data?.summary.retentionDays ?? 7
|
||||
|
||||
const tabs = useMemo(
|
||||
() => [
|
||||
{ key: 'assetVariants' as const, label: '规格款', count: assetVariants.length, icon: Boxes },
|
||||
{ key: 'assetFamilies' as const, label: '资产族', count: assetFamilies.length, icon: Package },
|
||||
{ key: 'products' as const, label: '正式产品', count: products.length, icon: Package },
|
||||
{ key: 'skus' as const, label: 'SKU', count: skus.length, icon: Tag },
|
||||
],
|
||||
[assetFamilies.length, assetVariants.length, products.length, skus.length],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!data) return
|
||||
if (hasAutoSwitchedTab.current) return
|
||||
|
||||
if (data.products.length === 0 && data.skus.length > 0) {
|
||||
setActiveTab('skus')
|
||||
} else if (data.skus.length === 0 && data.products.length > 0) {
|
||||
setActiveTab('products')
|
||||
}
|
||||
|
||||
if (!data || hasAutoSwitchedTab.current) return
|
||||
const firstNonEmpty = tabs.find((tab) => tab.count > 0)
|
||||
if (firstNonEmpty) setActiveTab(firstNonEmpty.key)
|
||||
hasAutoSwitchedTab.current = true
|
||||
}, [data])
|
||||
}, [data, tabs])
|
||||
|
||||
const currentItems: TrashItem[] = useMemo(() => {
|
||||
if (activeTab === 'assetVariants') return assetVariants
|
||||
if (activeTab === 'assetFamilies') return assetFamilies
|
||||
if (activeTab === 'products') return products
|
||||
return skus
|
||||
}, [activeTab, assetFamilies, assetVariants, products, skus])
|
||||
|
||||
// 恢复项目
|
||||
const handleRestore = async (item: TrashItem) => {
|
||||
setIsRestoring(item.id)
|
||||
try {
|
||||
const endpoint = item.type === 'product'
|
||||
? `/api/trash/products/${item.id}`
|
||||
: `/api/trash/skus/${item.id}`
|
||||
|
||||
const res = await fetch(endpoint, { method: 'POST' })
|
||||
if (!res.ok) {
|
||||
const json = await res.json()
|
||||
throw new Error(json.error?.message || '恢复失败')
|
||||
const response = await fetch(getEndpoint(item), { method: 'POST' })
|
||||
const payload = await response.json().catch(() => null)
|
||||
if (!response.ok || !payload?.success) {
|
||||
throw new Error(payload?.error?.message || '恢复失败')
|
||||
}
|
||||
|
||||
// 乐观更新:立即从本地状态中移除已恢复的项,避免“已恢复但仍显示在回收站”的错觉
|
||||
setData(prevData => {
|
||||
if (!prevData) return prevData
|
||||
if (item.type === 'product') {
|
||||
return {
|
||||
...prevData,
|
||||
products: prevData.products.filter(p => p.id !== item.id),
|
||||
summary: {
|
||||
...prevData.summary,
|
||||
totalProducts: Math.max(0, prevData.summary.totalProducts - 1),
|
||||
},
|
||||
}
|
||||
}
|
||||
return {
|
||||
...prevData,
|
||||
skus: prevData.skus.filter(s => s.id !== item.id),
|
||||
summary: {
|
||||
...prevData.summary,
|
||||
totalSKUs: Math.max(0, prevData.summary.totalSKUs - 1),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
toast.success(item.type === 'product' ? '产品已恢复' : 'SKU 已恢复')
|
||||
// 同步刷新一次,确保与后端状态一致(即使刷新失败,乐观更新也能保证界面正确)
|
||||
void loadTrashData()
|
||||
toast.success(payload.data?.message || '已恢复')
|
||||
await loadTrashData()
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : '恢复失败')
|
||||
} finally {
|
||||
@ -165,386 +270,262 @@ export default function TrashClient() {
|
||||
}
|
||||
}
|
||||
|
||||
// 彻底删除
|
||||
const handlePermanentDelete = async () => {
|
||||
if (!selectedItem) return
|
||||
|
||||
const itemId = selectedItem.id
|
||||
const itemType = selectedItem.type
|
||||
|
||||
const endpoint = itemType === 'product'
|
||||
? `/api/trash/products/${itemId}`
|
||||
: `/api/trash/skus/${itemId}`
|
||||
|
||||
const res = await fetch(endpoint, { method: 'DELETE' })
|
||||
if (!res.ok) {
|
||||
const json = await res.json()
|
||||
throw new Error(json.error?.message || '删除失败')
|
||||
const response = await fetch(getEndpoint(selectedItem), { method: 'DELETE' })
|
||||
const payload = await response.json().catch(() => null)
|
||||
if (!response.ok || !payload?.success) {
|
||||
throw new Error(payload?.error?.message || '彻底删除失败')
|
||||
}
|
||||
|
||||
// 乐观更新:立即从本地状态中移除已删除的项
|
||||
setData(prevData => {
|
||||
if (!prevData) return prevData
|
||||
if (itemType === 'product') {
|
||||
return {
|
||||
...prevData,
|
||||
products: prevData.products.filter(p => p.id !== itemId),
|
||||
summary: {
|
||||
...prevData.summary,
|
||||
totalProducts: prevData.summary.totalProducts - 1
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
...prevData,
|
||||
skus: prevData.skus.filter(s => s.id !== itemId),
|
||||
summary: {
|
||||
...prevData.summary,
|
||||
totalSKUs: prevData.summary.totalSKUs - 1
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
toast.success('已彻底删除')
|
||||
|
||||
toast.success(payload.data?.message || '已彻底删除')
|
||||
setSelectedItem(null)
|
||||
await loadTrashData()
|
||||
}
|
||||
|
||||
// 格式化时间
|
||||
const formatDate = (dateStr: string) => {
|
||||
const date = new Date(dateStr)
|
||||
return date.toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
// 获取剩余天数的样式 - 使用 Apple 系统颜色
|
||||
const getDaysRemainingStyle = (days: number): { bg: string; text: string } => {
|
||||
if (days <= 1) {
|
||||
return { bg: 'bg-apple-red/10', text: 'text-apple-red' }
|
||||
}
|
||||
if (days <= 3) {
|
||||
return { bg: 'bg-apple-orange/10', text: 'text-apple-orange' }
|
||||
}
|
||||
return { bg: 'bg-apple-bg-tertiary', text: 'text-apple-label-secondary' }
|
||||
}
|
||||
|
||||
const products = data?.products ?? []
|
||||
const skus = data?.skus ?? []
|
||||
const retentionDays = data?.summary.retentionDays ?? 7
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-apple-bg-secondary">
|
||||
{/* 顶部导航 - 毛玻璃效果 */}
|
||||
<header className="sticky top-0 z-50 h-14 bg-white/80 backdrop-blur-xl border-b border-apple-separator">
|
||||
<nav className="h-full max-w-6xl mx-auto px-6 flex items-center gap-4">
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-center gap-2 px-3 py-1.5 rounded-apple-sm text-apple-label-secondary hover:text-apple-label-primary hover:bg-apple-bg-secondary/50 transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" strokeWidth={2} />
|
||||
<span className="text-apple-sm font-apple-system font-apple-medium tracking-apple-tighter">
|
||||
返回
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="w-9 h-9 rounded-apple-md flex items-center justify-center"
|
||||
style={{ backgroundColor: 'rgba(255, 59, 48, 0.1)' }}
|
||||
>
|
||||
<Trash2 className="w-[18px] h-[18px]" style={{ color: 'var(--color-red)' }} strokeWidth={1.5} />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-apple-lg font-apple-system font-apple-semibold tracking-apple-tighter text-apple-label-primary">
|
||||
回收站
|
||||
</h1>
|
||||
<p className="text-apple-xs font-apple-system text-apple-label-secondary">
|
||||
项目将在删除后 {retentionDays} 天自动清除
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
{/* 主内容区 */}
|
||||
<div className="max-w-6xl mx-auto px-6 py-8">
|
||||
{/* 错误提示:如果已有旧数据,仍允许查看;同时提供重试 */}
|
||||
{loadError && data && (
|
||||
<Card className="p-6 mb-6">
|
||||
<div className="flex items-start justify-between gap-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<div
|
||||
className="w-9 h-9 rounded-apple-md flex items-center justify-center shrink-0"
|
||||
style={{ backgroundColor: 'rgba(255, 149, 0, 0.12)' }}
|
||||
>
|
||||
<AlertTriangle
|
||||
className="w-[18px] h-[18px]"
|
||||
style={{ color: 'var(--color-orange)' }}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-apple-sm font-apple-system font-apple-medium text-apple-label-primary">
|
||||
回收站数据刷新失败
|
||||
</p>
|
||||
<p className="mt-1 text-apple-xs font-apple-system text-apple-label-secondary">
|
||||
{loadError}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="gray" size="small" onClick={loadTrashData}>
|
||||
重试
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 标签页 */}
|
||||
<div className="flex gap-2 mb-6">
|
||||
<Button
|
||||
variant={activeTab === 'products' ? 'filled' : 'gray'}
|
||||
size="regular"
|
||||
onClick={() => setActiveTab('products')}
|
||||
>
|
||||
<Package className="w-4 h-4 mr-2" />
|
||||
产品
|
||||
{products.length > 0 && (
|
||||
<span className={`ml-2 px-2 py-0.5 rounded-full text-xs ${
|
||||
activeTab === 'products' ? 'bg-white/20' : 'bg-apple-separator'
|
||||
}`}>
|
||||
{products.length}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant={activeTab === 'skus' ? 'filled' : 'gray'}
|
||||
size="regular"
|
||||
onClick={() => setActiveTab('skus')}
|
||||
>
|
||||
<Tag className="w-4 h-4 mr-2" />
|
||||
SKU
|
||||
{skus.length > 0 && (
|
||||
<span className={`ml-2 px-2 py-0.5 rounded-full text-xs ${
|
||||
activeTab === 'skus' ? 'bg-white/20' : 'bg-apple-separator'
|
||||
}`}>
|
||||
{skus.length}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 列表 */}
|
||||
{isLoading ? (
|
||||
<Card className="p-12 text-center">
|
||||
<RefreshCcw className="w-8 h-8 text-apple-label-tertiary mx-auto animate-spin" />
|
||||
<p className="mt-4 text-apple-sm font-apple-system text-apple-label-secondary">加载中...</p>
|
||||
</Card>
|
||||
) : loadError && !data ? (
|
||||
<Card className="p-12 text-center">
|
||||
<div
|
||||
className="w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-4"
|
||||
style={{ backgroundColor: 'rgba(255, 149, 0, 0.12)' }}
|
||||
>
|
||||
<AlertTriangle
|
||||
className="w-8 h-8"
|
||||
style={{ color: 'var(--color-orange)' }}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-apple-sm font-apple-system font-apple-medium text-apple-label-primary">加载失败</p>
|
||||
<p className="mt-2 text-apple-sm font-apple-system text-apple-label-secondary">{loadError}</p>
|
||||
<div className="mt-6 flex justify-center">
|
||||
<Button variant="filled" size="regular" onClick={loadTrashData}>
|
||||
重试
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
) : activeTab === 'products' ? (
|
||||
<div className="space-y-4">
|
||||
{products.length === 0 ? (
|
||||
<Card className="p-12 text-center">
|
||||
<div className="w-16 h-16 bg-apple-bg-tertiary rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<Package className="w-8 h-8 text-apple-label-tertiary" strokeWidth={1.5} />
|
||||
</div>
|
||||
<p className="text-apple-sm font-apple-system text-apple-label-secondary">回收站中没有已删除的产品</p>
|
||||
</Card>
|
||||
) : (
|
||||
products.map((product) => {
|
||||
const daysStyle = getDaysRemainingStyle(product.daysRemaining)
|
||||
return (
|
||||
<Card key={product.id} className="p-6 flex items-center gap-4">
|
||||
{/* 缩略图 */}
|
||||
<div className="w-16 h-16 bg-apple-bg-tertiary rounded-apple-lg flex items-center justify-center shrink-0 overflow-hidden">
|
||||
{product.thumbnailUrl ? (
|
||||
<Image
|
||||
src={product.thumbnailUrl}
|
||||
alt={product.name}
|
||||
className="w-full h-full object-cover"
|
||||
rounded="none"
|
||||
/>
|
||||
) : (
|
||||
<Package className="w-8 h-8 text-apple-label-tertiary" strokeWidth={1.5} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 信息 */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-apple-md font-apple-system font-apple-semibold tracking-apple-tighter text-apple-label-primary truncate">
|
||||
{product.modelNumber}
|
||||
</span>
|
||||
{product.category && (
|
||||
<span className="px-2 py-0.5 bg-apple-bg-tertiary text-apple-xs font-apple-system text-apple-label-secondary rounded-full shrink-0">
|
||||
{product.category.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-apple-sm font-apple-system text-apple-label-secondary truncate">{product.name}</p>
|
||||
<div className="flex items-center gap-4 mt-1">
|
||||
<span className="text-apple-xs font-apple-system text-apple-label-tertiary">{product.skuCount} 个 SKU</span>
|
||||
<span className="text-apple-xs font-apple-system text-apple-label-tertiary flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
删除于 {formatDate(product.deletedAt)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 剩余天数 */}
|
||||
<div className={`px-3 py-1.5 rounded-apple-md text-sm font-apple-medium ${daysStyle.bg} ${daysStyle.text}`}>
|
||||
{product.daysRemaining <= 0 ? (
|
||||
<span className="flex items-center gap-1">
|
||||
<AlertTriangle className="w-4 h-4" />
|
||||
即将删除
|
||||
</span>
|
||||
) : (
|
||||
`${product.daysRemaining} 天后删除`
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="tinted"
|
||||
size="small"
|
||||
onClick={() => handleRestore(product)}
|
||||
disabled={isRestoring === product.id}
|
||||
loading={isRestoring === product.id}
|
||||
>
|
||||
<RotateCcw className="w-4 h-4 mr-1" />
|
||||
恢复
|
||||
</Button>
|
||||
<Button
|
||||
variant="plain"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setSelectedItem(product)
|
||||
setShowDeleteDialog(true)
|
||||
}}
|
||||
className="text-apple-red hover:bg-apple-red/10"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-1" />
|
||||
彻底删除
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
const renderAssetVariant = (item: TrashAssetVariant) => (
|
||||
<div key={item.id} className="grid grid-cols-[72px_minmax(0,1fr)_auto] items-center gap-4 px-5 py-4">
|
||||
<div
|
||||
className="flex h-16 w-16 items-center justify-center overflow-hidden rounded-[8px] border"
|
||||
style={{ backgroundColor: 'var(--obs-shell-muted-bg)', borderColor: 'var(--obs-shell-border)' }}
|
||||
>
|
||||
{item.thumbnailUrl ? (
|
||||
<Image src={item.thumbnailUrl} alt={item.name} className="h-full w-full object-cover" rounded="none" />
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{skus.length === 0 ? (
|
||||
<Card className="p-12 text-center">
|
||||
<div className="w-16 h-16 bg-apple-bg-tertiary rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<Tag className="w-8 h-8 text-apple-label-tertiary" strokeWidth={1.5} />
|
||||
</div>
|
||||
<p className="text-apple-sm font-apple-system text-apple-label-secondary">回收站中没有已删除的 SKU</p>
|
||||
</Card>
|
||||
) : (
|
||||
skus.map((sku) => {
|
||||
const daysStyle = getDaysRemainingStyle(sku.daysRemaining)
|
||||
return (
|
||||
<Card key={sku.id} className="p-6 flex items-center gap-4">
|
||||
{/* 图标 - 使用品牌色淡背景 */}
|
||||
<div
|
||||
className="w-16 h-16 rounded-apple-lg flex items-center justify-center shrink-0"
|
||||
style={{ backgroundColor: 'rgba(0, 141, 116, 0.1)' }}
|
||||
>
|
||||
<Tag className="w-8 h-8" style={{ color: 'var(--color-blue)' }} strokeWidth={1.5} />
|
||||
</div>
|
||||
|
||||
{/* 信息 */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-apple-md font-apple-system font-apple-semibold tracking-apple-tighter text-apple-label-primary">
|
||||
{sku.skuCode}
|
||||
</span>
|
||||
<span className="text-apple-sm font-apple-system font-apple-medium" style={{ color: 'var(--color-green)' }}>
|
||||
¥{sku.price.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-apple-sm font-apple-system text-apple-label-secondary">
|
||||
所属产品: {sku.product.modelNumber} - {sku.product.name}
|
||||
</p>
|
||||
<div className="flex items-center gap-4 mt-1">
|
||||
<span className="text-apple-xs font-apple-system text-apple-label-tertiary">负责人: {sku.owner.name}</span>
|
||||
<span className="text-apple-xs font-apple-system text-apple-label-tertiary flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
删除于 {formatDate(sku.deletedAt)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 剩余天数 */}
|
||||
<div className={`px-3 py-1.5 rounded-apple-md text-sm font-apple-medium ${daysStyle.bg} ${daysStyle.text}`}>
|
||||
{sku.daysRemaining <= 0 ? (
|
||||
<span className="flex items-center gap-1">
|
||||
<AlertTriangle className="w-4 h-4" />
|
||||
即将删除
|
||||
</span>
|
||||
) : (
|
||||
`${sku.daysRemaining} 天后删除`
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="tinted"
|
||||
size="small"
|
||||
onClick={() => handleRestore(sku)}
|
||||
disabled={isRestoring === sku.id}
|
||||
loading={isRestoring === sku.id}
|
||||
>
|
||||
<RotateCcw className="w-4 h-4 mr-1" />
|
||||
恢复
|
||||
</Button>
|
||||
<Button
|
||||
variant="plain"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setSelectedItem(sku)
|
||||
setShowDeleteDialog(true)
|
||||
}}
|
||||
className="text-apple-red hover:bg-apple-red/10"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-1" />
|
||||
彻底删除
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
<Boxes size={24} strokeWidth={1.6} style={{ color: 'var(--obs-shell-muted)' }} />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate text-[15px] font-bold" style={{ color: 'var(--obs-shell-title)' }}>
|
||||
{item.code}
|
||||
</span>
|
||||
<span className="rounded-[5px] px-2 py-0.5 text-[11px]" style={{ backgroundColor: 'var(--obs-shell-muted-bg)', color: 'var(--obs-shell-muted)' }}>
|
||||
{assetTypeLabel[item.assetType] ?? item.assetType}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 truncate text-[13px]" style={{ color: 'var(--obs-shell-muted)' }}>
|
||||
{item.name} / {item.assetFamilyCode} {item.assetFamilyName}
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1 text-[12px]" style={{ color: 'var(--obs-shell-subtle)' }}>
|
||||
<span>{formatDimensions(item.dimensions)}</span>
|
||||
<span>{item.substrateName ?? '未绑定基材'}</span>
|
||||
<span>{item.finishName ?? '未绑定材质'}</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Clock3 size={12} />
|
||||
{formatDate(item.deletedAt)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{renderActions(item)}
|
||||
</div>
|
||||
)
|
||||
|
||||
const renderAssetFamily = (item: TrashAssetFamily) => (
|
||||
<div key={item.id} className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-4 px-5 py-4">
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate text-[15px] font-bold" style={{ color: 'var(--obs-shell-title)' }}>
|
||||
{item.code}
|
||||
</span>
|
||||
<span className="rounded-[5px] px-2 py-0.5 text-[11px]" style={{ backgroundColor: 'var(--obs-shell-muted-bg)', color: 'var(--obs-shell-muted)' }}>
|
||||
{assetTypeLabel[item.assetType] ?? item.assetType}
|
||||
</span>
|
||||
<span className="rounded-[5px] px-2 py-0.5 text-[11px]" style={{ backgroundColor: 'var(--obs-shell-muted-bg)', color: 'var(--obs-shell-muted)' }}>
|
||||
{statusLabel[item.status] ?? item.status}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 truncate text-[13px]" style={{ color: 'var(--obs-shell-muted)' }}>
|
||||
{item.name}
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1 text-[12px]" style={{ color: 'var(--obs-shell-subtle)' }}>
|
||||
<span>{item.variantCount} 个规格款</span>
|
||||
<span>{item.creatorName ?? '未知创建人'}</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Clock3 size={12} />
|
||||
{formatDate(item.deletedAt)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{renderActions(item)}
|
||||
</div>
|
||||
)
|
||||
|
||||
const renderProduct = (item: TrashProduct) => (
|
||||
<div key={item.id} className="grid grid-cols-[72px_minmax(0,1fr)_auto] items-center gap-4 px-5 py-4">
|
||||
<div
|
||||
className="flex h-16 w-16 items-center justify-center overflow-hidden rounded-[8px] border"
|
||||
style={{ backgroundColor: 'var(--obs-shell-muted-bg)', borderColor: 'var(--obs-shell-border)' }}
|
||||
>
|
||||
{item.thumbnailUrl ? (
|
||||
<Image src={item.thumbnailUrl} alt={item.name} className="h-full w-full object-cover" rounded="none" />
|
||||
) : (
|
||||
<Package size={24} strokeWidth={1.6} style={{ color: 'var(--obs-shell-muted)' }} />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-[15px] font-bold" style={{ color: 'var(--obs-shell-title)' }}>
|
||||
{item.modelNumber}
|
||||
</div>
|
||||
<div className="mt-1 truncate text-[13px]" style={{ color: 'var(--obs-shell-muted)' }}>
|
||||
{item.name}
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1 text-[12px]" style={{ color: 'var(--obs-shell-subtle)' }}>
|
||||
<span>{item.skuCount} 个 SKU</span>
|
||||
<span>{item.creator.name}</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Clock3 size={12} />
|
||||
{formatDate(item.deletedAt)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{renderActions(item)}
|
||||
</div>
|
||||
)
|
||||
|
||||
const renderSku = (item: TrashSKU) => (
|
||||
<div key={item.id} className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-4 px-5 py-4">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-[15px] font-bold" style={{ color: 'var(--obs-shell-title)' }}>
|
||||
{item.skuCode}
|
||||
</div>
|
||||
<div className="mt-1 truncate text-[13px]" style={{ color: 'var(--obs-shell-muted)' }}>
|
||||
{item.product.modelNumber} {item.product.name}
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1 text-[12px]" style={{ color: 'var(--obs-shell-subtle)' }}>
|
||||
<span>{item.owner.name}</span>
|
||||
<span>¥{item.price.toFixed(2)}</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Clock3 size={12} />
|
||||
{formatDate(item.deletedAt)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{renderActions(item)}
|
||||
</div>
|
||||
)
|
||||
|
||||
function renderActions(item: TrashItem) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<ExpiryBadge days={item.daysRemaining} />
|
||||
<Button
|
||||
variant="gray"
|
||||
size="small"
|
||||
onClick={() => handleRestore(item)}
|
||||
loading={isRestoring === item.id}
|
||||
disabled={isRestoring === item.id}
|
||||
>
|
||||
<RotateCcw className="mr-1.5 h-4 w-4" />
|
||||
恢复
|
||||
</Button>
|
||||
<Button
|
||||
variant="plain"
|
||||
size="small"
|
||||
tone="danger"
|
||||
className="text-[var(--obs-shell-danger)] hover:bg-[var(--obs-shell-danger-bg)]"
|
||||
onClick={() => {
|
||||
setSelectedItem(item)
|
||||
setShowDeleteDialog(true)
|
||||
}}
|
||||
>
|
||||
<Trash2 className="mr-1.5 h-4 w-4" />
|
||||
彻底删除
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto px-8 py-7 font-[Manrope,sans-serif]" style={{ backgroundColor: 'var(--obs-shell-bg)' }}>
|
||||
<div className="mx-auto flex max-w-7xl flex-col gap-5">
|
||||
<section className="flex items-end justify-between gap-6">
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-[8px]" style={{ backgroundColor: 'var(--obs-shell-icon-bg)' }}>
|
||||
<Trash2 size={19} strokeWidth={1.7} style={{ color: 'var(--obs-shell-icon-text)' }} />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-[24px] font-black tracking-tight" style={{ color: 'var(--obs-shell-title)' }}>
|
||||
回收站
|
||||
</h1>
|
||||
<p className="mt-1 text-[13px]" style={{ color: 'var(--obs-shell-muted)' }}>
|
||||
删除后保留 {retentionDays} 天
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="gray" size="small" onClick={loadTrashData} disabled={isLoading}>
|
||||
<RefreshCcw className={`mr-1.5 h-4 w-4 ${isLoading ? 'animate-spin' : ''}`} />
|
||||
刷新
|
||||
</Button>
|
||||
</section>
|
||||
|
||||
<section
|
||||
className="rounded-[8px] border"
|
||||
style={{ backgroundColor: 'var(--obs-shell-panel-bg)', borderColor: 'var(--obs-shell-border)' }}
|
||||
>
|
||||
<div className="flex items-center gap-1 border-b px-3 py-3" style={{ borderColor: 'var(--obs-shell-border)' }}>
|
||||
{tabs.map((tab) => {
|
||||
const Icon = tab.icon
|
||||
const active = activeTab === tab.key
|
||||
return (
|
||||
<button
|
||||
key={tab.key}
|
||||
type="button"
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
className="flex h-9 items-center gap-2 rounded-[6px] px-3 text-[13px] font-semibold transition"
|
||||
style={{
|
||||
backgroundColor: active ? 'var(--obs-shell-rail-active-bg)' : 'transparent',
|
||||
color: active ? 'var(--obs-shell-rail-active-text)' : 'var(--obs-shell-muted)',
|
||||
}}
|
||||
>
|
||||
<Icon size={15} strokeWidth={1.7} />
|
||||
<span>{tab.label}</span>
|
||||
<span
|
||||
className="rounded-full px-1.5 py-0.5 text-[11px]"
|
||||
style={{
|
||||
backgroundColor: active ? 'rgba(255,255,255,0.16)' : 'var(--obs-shell-muted-bg)',
|
||||
color: active ? 'var(--obs-shell-rail-active-text)' : 'var(--obs-shell-subtle)',
|
||||
}}
|
||||
>
|
||||
{tab.count}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{loadError && (
|
||||
<div className="flex items-center gap-2 border-b px-5 py-3 text-[13px]" style={{ borderColor: 'var(--obs-shell-border)', color: 'var(--obs-shell-danger)' }}>
|
||||
<AlertTriangle size={15} />
|
||||
{loadError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="divide-y" style={{ borderColor: 'var(--obs-shell-border)' }}>
|
||||
{isLoading ? (
|
||||
<div className="flex h-56 items-center justify-center gap-3 text-[13px]" style={{ color: 'var(--obs-shell-muted)' }}>
|
||||
<RefreshCcw className="h-5 w-5 animate-spin" />
|
||||
加载中
|
||||
</div>
|
||||
) : currentItems.length === 0 ? (
|
||||
<div className="flex h-56 items-center justify-center text-[13px]" style={{ color: 'var(--obs-shell-muted)' }}>
|
||||
{getEmptyText(activeTab)}
|
||||
</div>
|
||||
) : (
|
||||
currentItems.map((item) => {
|
||||
if (item.type === 'assetVariant') return renderAssetVariant(item)
|
||||
if (item.type === 'assetFamily') return renderAssetFamily(item)
|
||||
if (item.type === 'product') return renderProduct(item)
|
||||
return renderSku(item)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* 删除确认对话框 */}
|
||||
<DeleteConfirmDialog
|
||||
isOpen={showDeleteDialog}
|
||||
onClose={() => {
|
||||
@ -553,22 +534,15 @@ export default function TrashClient() {
|
||||
}}
|
||||
onConfirm={handlePermanentDelete}
|
||||
title="彻底删除"
|
||||
description={
|
||||
selectedItem?.type === 'product'
|
||||
? `确定要彻底删除产品 "${(selectedItem as TrashProduct).modelNumber}" 吗?此操作不可撤销。`
|
||||
: `确定要彻底删除 SKU "${(selectedItem as TrashSKU)?.skuCode}" 吗?此操作不可撤销。`
|
||||
}
|
||||
description={getDeleteDescription(selectedItem)}
|
||||
confirmText="彻底删除"
|
||||
warningItems={[
|
||||
'此操作无法撤销',
|
||||
'所有相关文件将被永久删除',
|
||||
...(selectedItem?.type === 'product' && (selectedItem as TrashProduct).skuCount > 0
|
||||
? [`将同时删除 ${(selectedItem as TrashProduct).skuCount} 个关联的 SKU`]
|
||||
: []),
|
||||
selectedItem?.type === 'assetFamily' ? '资产族下的规格款会一并删除' : '关联文件和数据会永久移除',
|
||||
]}
|
||||
itemName={selectedItem ? getItemName(selectedItem) : undefined}
|
||||
/>
|
||||
|
||||
{/* Toast 提示 */}
|
||||
<ToastContainer toasts={toast.toasts} onClose={toast.removeToast} position="top" />
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
/**
|
||||
* 回收站页面
|
||||
*
|
||||
* 显示已删除的产品和 SKU,支持恢复和彻底删除
|
||||
* 显示已删除的资产中心项目、产品和 SKU,支持恢复和彻底删除
|
||||
*
|
||||
* @created 2026-02-02
|
||||
*/
|
||||
@ -11,7 +11,7 @@ import TrashClient from './TrashClient'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: '回收站 - 家具3D系统',
|
||||
description: '管理已删除的产品和 SKU',
|
||||
description: '管理已删除的资产中心项目、产品和 SKU',
|
||||
}
|
||||
|
||||
export default function TrashPage() {
|
||||
|
||||
@ -1643,8 +1643,12 @@ function ModelViewerComponent({
|
||||
clearcoatRoughness: material.clearcoatRoughness,
|
||||
envMapIntensity: material.envMapIntensity,
|
||||
textureRepeat: material.textureRepeat,
|
||||
textureRepeatU: material.textureRepeatU,
|
||||
textureRepeatV: material.textureRepeatV,
|
||||
textureRotation: material.textureRotation,
|
||||
textureSizeMm: material.textureSizeMm,
|
||||
textureProjectionAxis: material.textureProjectionAxis,
|
||||
textureAxisConfigs: material.textureAxisConfigs,
|
||||
thumbnailUrl: material.thumbnailUrl,
|
||||
textureUrl: material.textureUrl,
|
||||
normalMapUrl: material.normalMapUrl,
|
||||
|
||||
@ -11,6 +11,7 @@ export const APP_SHELL_ROOTS = [
|
||||
'/profile',
|
||||
'/users',
|
||||
'/admin',
|
||||
'/trash',
|
||||
'/asset-center',
|
||||
'/assisted-assembly',
|
||||
'/draft-groups',
|
||||
@ -60,11 +61,13 @@ function AppShellContent({ children, user }: AppShellProps) {
|
||||
const isProfileRoute = pathname === '/profile' || pathname.startsWith('/profile/')
|
||||
const isUsersRoute = pathname === '/users' || pathname.startsWith('/users/')
|
||||
const isAdminRoute = pathname === '/admin' || pathname.startsWith('/admin/')
|
||||
const isTrashRoute = pathname === '/trash' || pathname.startsWith('/trash/')
|
||||
const isWorkbenchShellRoute =
|
||||
isHomeRoute ||
|
||||
isProfileRoute ||
|
||||
isUsersRoute ||
|
||||
isAdminRoute ||
|
||||
isTrashRoute ||
|
||||
isAssetCenterRoute ||
|
||||
isAssistedAssemblyRoute ||
|
||||
isDraftGroupsRoute ||
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { ChevronUp, LogOut, UserCircle2, Box, FileEdit, Users, LayoutDashboard } from 'lucide-react'
|
||||
import { ChevronUp, LogOut, UserCircle2, Box, FileEdit, Users, LayoutDashboard, Trash2 } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
@ -160,6 +160,14 @@ export function SidebarUserCenter() {
|
||||
<FileEdit size={14} className="opacity-40 group-hover:opacity-100" />
|
||||
我的草稿
|
||||
</Link>
|
||||
<Link
|
||||
href="/trash"
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="flex items-center gap-3 px-3.5 py-2 rounded-apple-lg text-apple-xs font-apple-bold text-apple-label-secondary hover:bg-white hover:text-apple-blue transition-all group"
|
||||
>
|
||||
<Trash2 size={14} className="opacity-40 group-hover:opacity-100" />
|
||||
回收站
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* SECTION 3: Admin & Actions */}
|
||||
|
||||
@ -32,6 +32,7 @@ describe('AppShell', () => {
|
||||
expect(shouldUseAppShell('/intake')).toBe(true)
|
||||
expect(shouldUseAppShell('/warehouse')).toBe(true)
|
||||
expect(shouldUseAppShell('/warehouse-quality')).toBe(true)
|
||||
expect(shouldUseAppShell('/trash')).toBe(true)
|
||||
|
||||
expect(shouldUseAppShell('/products')).toBe(false)
|
||||
expect(shouldUseAppShell('/products/product-1/assembly')).toBe(false)
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
/**
|
||||
* 统一右键菜单组件
|
||||
*
|
||||
* 实现 Apple 风格的分组错峰进入动画
|
||||
* 实现白曜石工作台风格的分组错峰进入动画
|
||||
*
|
||||
* @created 2026-02-15
|
||||
*/
|
||||
@ -49,6 +49,7 @@ export interface ContextMenuProps {
|
||||
onItemMouseLeave?: () => void;
|
||||
onClose?: () => void;
|
||||
width?: number;
|
||||
bodyMaxHeight?: number;
|
||||
className?: string;
|
||||
children?: React.ReactNode; // 子菜单插槽
|
||||
}
|
||||
@ -62,6 +63,7 @@ export interface SubmenuProps {
|
||||
onItemMouseEnter?: (item: ContextMenuItem) => void;
|
||||
onItemMouseLeave?: () => void;
|
||||
width?: number;
|
||||
bodyMaxHeight?: number;
|
||||
className?: string;
|
||||
children?: React.ReactNode; // 嵌套子菜单插槽
|
||||
zIndex?: number;
|
||||
@ -72,7 +74,7 @@ export interface SubmenuProps {
|
||||
const ANIMATION_CONFIG = {
|
||||
staggerDelay: 25, // 每个菜单项延迟 25ms
|
||||
duration: 180, // 动画持续时间 180ms
|
||||
easing: "cubic-bezier(0.16, 1, 0.3, 1)", // Apple 风格缓动
|
||||
easing: "cubic-bezier(0.16, 1, 0.3, 1)", // 白曜石浮层缓动
|
||||
};
|
||||
|
||||
// ============ 菜单项组件 ============
|
||||
@ -117,8 +119,8 @@ function MenuItem({
|
||||
item.disabled
|
||||
? "cursor-not-allowed border-transparent bg-apple-bg-secondary/25 text-apple-label-tertiary"
|
||||
: isActive
|
||||
? "border-apple-blue/30 bg-white/95 text-apple-blue shadow-xs"
|
||||
: "border-transparent bg-transparent text-apple-label-primary hover:border-apple-separator/80 hover:bg-white/85 focus-visible:ring-2 focus-visible:ring-apple-blue/20",
|
||||
? "border-black/[0.08] bg-white/95 text-[#2d3335] shadow-xs"
|
||||
: "border-transparent bg-transparent text-apple-label-primary hover:border-apple-separator/80 hover:bg-white/85 focus-visible:ring-2 focus-visible:ring-black/[0.08]",
|
||||
)}
|
||||
style={{
|
||||
opacity: shouldAnimate ? (isVisible ? 1 : 0) : 1,
|
||||
@ -174,6 +176,7 @@ export function ContextMenu({
|
||||
onItemMouseEnter,
|
||||
onItemMouseLeave,
|
||||
width = 180,
|
||||
bodyMaxHeight = 260,
|
||||
className,
|
||||
children,
|
||||
}: ContextMenuProps) {
|
||||
@ -213,7 +216,7 @@ export function ContextMenu({
|
||||
{(title || subtitle) && (
|
||||
<div className="border-b border-apple-separator/80 px-2 pb-2">
|
||||
{subtitle && (
|
||||
<p className="text-apple-xs font-apple-system tracking-apple-wide text-apple-blue/80">
|
||||
<p className="text-apple-xs font-apple-system tracking-apple-wide text-[#5a6062]/75">
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
@ -226,7 +229,7 @@ export function ContextMenu({
|
||||
)}
|
||||
|
||||
{/* 菜单组 */}
|
||||
<div className="max-h-[260px] overflow-y-auto py-1">
|
||||
<div className="overflow-y-auto py-1" style={{ maxHeight: `${bodyMaxHeight}px` }}>
|
||||
{groups.map((group, groupIndex) => {
|
||||
const startIndex = globalIndex;
|
||||
globalIndex += group.items.length;
|
||||
@ -281,6 +284,7 @@ export function ContextSubmenu({
|
||||
onItemMouseEnter,
|
||||
onItemMouseLeave,
|
||||
width = 160,
|
||||
bodyMaxHeight = 260,
|
||||
className,
|
||||
children,
|
||||
zIndex = 100,
|
||||
@ -319,11 +323,11 @@ export function ContextSubmenu({
|
||||
className="liquid-glass-subtle overflow-hidden rounded-apple-md border border-apple-separator/85 p-1.5 shadow-[0_14px_30px_rgba(15,23,42,0.16)]"
|
||||
style={{ width: `${width}px` }}
|
||||
>
|
||||
<p className="px-1 pb-2 text-[11px] font-apple-system tracking-apple-wide text-apple-blue/80">
|
||||
<p className="px-1 pb-2 text-[11px] font-apple-system tracking-apple-wide text-[#5a6062]/75">
|
||||
{title}
|
||||
</p>
|
||||
|
||||
<div className="max-h-[260px] overflow-y-auto">
|
||||
<div className="overflow-y-auto" style={{ maxHeight: `${bodyMaxHeight}px` }}>
|
||||
{items.map((item, index) => (
|
||||
<MenuItem
|
||||
key={item.id}
|
||||
|
||||
@ -7,7 +7,7 @@
|
||||
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { User, LogOut, Settings, ChevronDown } from 'lucide-react'
|
||||
import { User, LogOut, Settings, ChevronDown, Trash2 } from 'lucide-react'
|
||||
import Image from '@/components/ui/Image'
|
||||
|
||||
interface UserMenuUser {
|
||||
@ -252,6 +252,30 @@ export default function UserMenu({ user }: UserMenuProps) {
|
||||
|
||||
{/* 菜单项 */}
|
||||
<div className="py-1.5 px-1.5">
|
||||
<button
|
||||
onClick={() => {
|
||||
router.push('/trash');
|
||||
setIsOpen(false)
|
||||
}}
|
||||
type="button"
|
||||
className="w-full px-3 py-2 flex items-center gap-2.5 rounded-apple-md transition-all duration-150 ease-out active:scale-98"
|
||||
style={{ backgroundColor: 'transparent' }}
|
||||
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = 'var(--color-background-tertiary)'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
|
||||
>
|
||||
<Trash2
|
||||
className="w-4 h-4"
|
||||
strokeWidth={1.5}
|
||||
style={{ color: 'var(--color-label-secondary)' }}
|
||||
/>
|
||||
<span
|
||||
className="text-sm font-apple-system"
|
||||
style={{ color: 'var(--color-label-primary)' }}
|
||||
>
|
||||
回收站
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{canManageUsers && (
|
||||
<button
|
||||
onClick={() => {
|
||||
|
||||
@ -34,6 +34,10 @@ describe('UserMenu', () => {
|
||||
|
||||
await actor.click(screen.getByRole('button', { name: '李 李承豪' }))
|
||||
|
||||
await actor.click(screen.getByRole('button', { name: '回收站' }))
|
||||
expect(pushMock).toHaveBeenCalledWith('/trash')
|
||||
|
||||
await actor.click(screen.getByRole('button', { name: '李 李承豪' }))
|
||||
await actor.click(screen.getByRole('button', { name: '用户权限管理' }))
|
||||
expect(pushMock).toHaveBeenCalledWith('/users')
|
||||
expect(pushMock).not.toHaveBeenCalledWith('/profile')
|
||||
|
||||
@ -1,5 +1,9 @@
|
||||
import * as THREE from "three";
|
||||
import { normalizeTooBrightMaterial } from "@/lib/3d/engine-highlight-resources";
|
||||
import {
|
||||
applyAspectAwareTextureRepeat,
|
||||
applyMaterialUvProjection,
|
||||
normalizeTooBrightMaterial,
|
||||
} from "@/lib/3d/engine-highlight-resources";
|
||||
|
||||
describe("normalizeTooBrightMaterial", () => {
|
||||
it("保留材质编辑器写入的金属度、粗糙度和颜色", () => {
|
||||
@ -18,3 +22,103 @@ describe("normalizeTooBrightMaterial", () => {
|
||||
expect(material.color.getHexString()).toBe("f2f2f2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("UV controls", () => {
|
||||
function createUvProjectionMesh() {
|
||||
const geometry = new THREE.BufferGeometry();
|
||||
geometry.setAttribute(
|
||||
"position",
|
||||
new THREE.BufferAttribute(
|
||||
new Float32Array([
|
||||
0, 0, 0,
|
||||
2, 4, 8,
|
||||
0, 4, 0,
|
||||
]),
|
||||
3,
|
||||
),
|
||||
);
|
||||
return new THREE.Mesh(geometry, new THREE.MeshStandardMaterial());
|
||||
}
|
||||
|
||||
it("按指定 X/Y/Z 投影轴重建 UV", () => {
|
||||
const mesh = createUvProjectionMesh();
|
||||
const material = new THREE.MeshStandardMaterial();
|
||||
|
||||
material.userData.textureProjectionAxis = "x";
|
||||
applyMaterialUvProjection(mesh, material);
|
||||
const uvOnX = mesh.geometry.getAttribute("uv");
|
||||
expect(uvOnX.getX(2)).toBeCloseTo(1);
|
||||
expect(uvOnX.getY(2)).toBeCloseTo(0);
|
||||
|
||||
material.userData.textureProjectionAxis = "z";
|
||||
applyMaterialUvProjection(mesh, material);
|
||||
const uvOnZ = mesh.geometry.getAttribute("uv");
|
||||
expect(uvOnZ.getX(2)).toBeCloseTo(0);
|
||||
expect(uvOnZ.getY(2)).toBeCloseTo(1);
|
||||
});
|
||||
|
||||
it("U/V 压缩参数会乘到最终贴图重复率上", () => {
|
||||
const mesh = new THREE.Mesh(
|
||||
new THREE.BoxGeometry(1000, 500, 18),
|
||||
new THREE.MeshStandardMaterial(),
|
||||
);
|
||||
const baseTexture = new THREE.Texture({ width: 100, height: 100 });
|
||||
const baseMaterial = new THREE.MeshStandardMaterial({ map: baseTexture });
|
||||
baseMaterial.userData.textureSizeMm = 500;
|
||||
applyAspectAwareTextureRepeat(mesh, baseMaterial);
|
||||
|
||||
const compressedTexture = new THREE.Texture({ width: 100, height: 100 });
|
||||
const compressedMaterial = new THREE.MeshStandardMaterial({
|
||||
map: compressedTexture,
|
||||
});
|
||||
compressedMaterial.userData.textureSizeMm = 500;
|
||||
compressedMaterial.userData.textureRepeat = 1;
|
||||
compressedMaterial.userData.textureRepeatU = 2;
|
||||
compressedMaterial.userData.textureRepeatV = 0.5;
|
||||
applyAspectAwareTextureRepeat(mesh, compressedMaterial);
|
||||
|
||||
expect(compressedTexture.repeat.x).toBeCloseTo(baseTexture.repeat.x * 2);
|
||||
expect(compressedTexture.repeat.y).toBeCloseTo(baseTexture.repeat.y * 0.5);
|
||||
});
|
||||
|
||||
it("多轴 UV 参数按面朝向分别烘进 geometry", () => {
|
||||
const geometry = new THREE.BufferGeometry();
|
||||
geometry.setAttribute(
|
||||
"position",
|
||||
new THREE.BufferAttribute(
|
||||
new Float32Array([
|
||||
0, 0, 0,
|
||||
100, 0, 0,
|
||||
0, 100, 0,
|
||||
0, 0, 0,
|
||||
0, 100, 0,
|
||||
0, 0, 100,
|
||||
]),
|
||||
3,
|
||||
),
|
||||
);
|
||||
const mesh = new THREE.Mesh(geometry, new THREE.MeshStandardMaterial());
|
||||
const material = new THREE.MeshStandardMaterial();
|
||||
material.userData.textureAxisConfigs = {
|
||||
z: {
|
||||
textureSizeMm: 100,
|
||||
textureRepeatU: 5,
|
||||
},
|
||||
x: {
|
||||
textureSizeMm: 100,
|
||||
textureRepeatU: 2,
|
||||
},
|
||||
};
|
||||
|
||||
applyMaterialUvProjection(mesh, material);
|
||||
const uv = mesh.geometry.getAttribute("uv");
|
||||
|
||||
const getFaceUSpan = (startIndex: number) => {
|
||||
const values = [0, 1, 2].map((offset) => uv.getX(startIndex + offset));
|
||||
return Math.max(...values) - Math.min(...values);
|
||||
};
|
||||
|
||||
expect(getFaceUSpan(0)).toBeCloseTo(5);
|
||||
expect(getFaceUSpan(3)).toBeCloseTo(2);
|
||||
});
|
||||
});
|
||||
|
||||
@ -10,6 +10,21 @@ const HIGHLIGHT_HELPER_NODE_NAMES = new Set([
|
||||
HIGHLIGHT_FILL_NODE_NAME,
|
||||
]);
|
||||
|
||||
type TextureProjectionAxis = "x" | "y" | "z";
|
||||
|
||||
type TextureAxisRuntimeConfig = {
|
||||
textureRepeat?: number;
|
||||
textureRepeatU?: number;
|
||||
textureRepeatV?: number;
|
||||
textureRotation?: number;
|
||||
textureSizeMm?: number;
|
||||
};
|
||||
|
||||
type TextureAxisRuntimeConfigMap = Partial<Record<TextureProjectionAxis, TextureAxisRuntimeConfig>>;
|
||||
|
||||
const UV_PROJECTION_SOURCE_GEOMETRY_KEY = "__uvProjectionSourceGeometry";
|
||||
const UV_PROJECTION_SIGNATURE_KEY = "__uvProjectionSignature";
|
||||
|
||||
export function isHighlightHelperObjectName(
|
||||
name: string | null | undefined,
|
||||
): boolean {
|
||||
@ -95,23 +110,15 @@ function shouldRegenerateMeshUv(geometry: THREE.BufferGeometry): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
export function ensureMeshUv(mesh: THREE.Mesh): void {
|
||||
const geometry = mesh.geometry;
|
||||
if (!(geometry instanceof THREE.BufferGeometry)) return;
|
||||
|
||||
const positionAttr = geometry.getAttribute("position");
|
||||
if (!positionAttr || positionAttr.itemSize < 3) return;
|
||||
if (geometry.getAttribute("uv") && !shouldRegenerateMeshUv(geometry)) {
|
||||
if (!geometry.getAttribute("uv2")) {
|
||||
const uv = geometry.getAttribute("uv");
|
||||
geometry.setAttribute("uv2", uv.clone());
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
function resolveProjectionAxes(
|
||||
geometry: THREE.BufferGeometry,
|
||||
projectionAxis?: TextureProjectionAxis | null,
|
||||
): [number, number] | null {
|
||||
geometry.computeBoundingBox();
|
||||
const box = geometry.boundingBox;
|
||||
if (!box) return;
|
||||
if (!box) return null;
|
||||
|
||||
if (projectionAxis) return getProjectionAxisComponents(projectionAxis);
|
||||
|
||||
const size = new THREE.Vector3();
|
||||
box.getSize(size);
|
||||
@ -121,11 +128,34 @@ export function ensureMeshUv(mesh: THREE.Mesh): void {
|
||||
{ axis: 2 as const, size: size.z },
|
||||
].sort((a, b) => b.size - a.size);
|
||||
|
||||
const axisU = axisSizes[0]?.axis ?? 0;
|
||||
const axisV = axisSizes[1]?.axis ?? 1;
|
||||
return [axisSizes[0]?.axis ?? 0, axisSizes[1]?.axis ?? 1];
|
||||
}
|
||||
|
||||
function getProjectionAxisComponents(
|
||||
projectionAxis: TextureProjectionAxis,
|
||||
): [number, number] {
|
||||
if (projectionAxis === "x") return [1, 2];
|
||||
if (projectionAxis === "y") return [0, 2];
|
||||
return [0, 1];
|
||||
}
|
||||
|
||||
function writePlanarUv(
|
||||
geometry: THREE.BufferGeometry,
|
||||
projectionAxis?: TextureProjectionAxis | null,
|
||||
): void {
|
||||
const positionAttr = geometry.getAttribute("position");
|
||||
if (!positionAttr || positionAttr.itemSize < 3) return;
|
||||
|
||||
const axes = resolveProjectionAxes(geometry, projectionAxis);
|
||||
const box = geometry.boundingBox;
|
||||
if (!axes || !box) return;
|
||||
|
||||
const [axisU, axisV] = axes;
|
||||
const min = [box.min.x, box.min.y, box.min.z];
|
||||
const extentU = Math.max(axisSizes[0]?.size ?? 1, 1e-6);
|
||||
const extentV = Math.max(axisSizes[1]?.size ?? 1, 1e-6);
|
||||
const size = box.getSize(new THREE.Vector3());
|
||||
const sizes = [size.x, size.y, size.z];
|
||||
const extentU = Math.max(Math.abs(sizes[axisU] ?? 1), 1e-6);
|
||||
const extentV = Math.max(Math.abs(sizes[axisV] ?? 1), 1e-6);
|
||||
|
||||
const uvArray = new Float32Array(positionAttr.count * 2);
|
||||
for (let index = 0; index < positionAttr.count; index++) {
|
||||
@ -145,6 +175,21 @@ export function ensureMeshUv(mesh: THREE.Mesh): void {
|
||||
geometry.attributes.uv2.needsUpdate = true;
|
||||
}
|
||||
|
||||
export function ensureMeshUv(mesh: THREE.Mesh): void {
|
||||
const geometry = mesh.geometry;
|
||||
if (!(geometry instanceof THREE.BufferGeometry)) return;
|
||||
|
||||
if (geometry.getAttribute("uv") && !shouldRegenerateMeshUv(geometry)) {
|
||||
if (!geometry.getAttribute("uv2")) {
|
||||
const uv = geometry.getAttribute("uv");
|
||||
geometry.setAttribute("uv2", uv.clone());
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
writePlanarUv(geometry);
|
||||
}
|
||||
|
||||
function getMeshSurfaceProfile(mesh: THREE.Mesh): {
|
||||
longSide: number;
|
||||
shortSide: number;
|
||||
@ -264,6 +309,308 @@ export function cloneMaterialTextureMaps(material: THREE.Material): void {
|
||||
});
|
||||
}
|
||||
|
||||
function getMaterialUserData(material: THREE.Material): Record<string, unknown> {
|
||||
return (material.userData ?? {}) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function getFiniteNumber(value: unknown, fallback: number): number {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
}
|
||||
|
||||
function getPositiveNumber(value: unknown, fallback: number): number {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
||||
}
|
||||
|
||||
function normalizeTextureAxisRuntimeConfig(
|
||||
value: unknown,
|
||||
): TextureAxisRuntimeConfig | null {
|
||||
if (!isRecord(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const textureRepeat = getPositiveNumber(value.textureRepeat, Number.NaN);
|
||||
const textureRepeatU = getPositiveNumber(value.textureRepeatU, Number.NaN);
|
||||
const textureRepeatV = getPositiveNumber(value.textureRepeatV, Number.NaN);
|
||||
const textureRotation = getFiniteNumber(value.textureRotation, Number.NaN);
|
||||
const textureSizeMm = getPositiveNumber(value.textureSizeMm, Number.NaN);
|
||||
|
||||
if (
|
||||
Number.isNaN(textureRepeat) &&
|
||||
Number.isNaN(textureRepeatU) &&
|
||||
Number.isNaN(textureRepeatV) &&
|
||||
Number.isNaN(textureRotation) &&
|
||||
Number.isNaN(textureSizeMm)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...(!Number.isNaN(textureRepeat) ? { textureRepeat } : {}),
|
||||
...(!Number.isNaN(textureRepeatU) ? { textureRepeatU } : {}),
|
||||
...(!Number.isNaN(textureRepeatV) ? { textureRepeatV } : {}),
|
||||
...(!Number.isNaN(textureRotation) ? { textureRotation } : {}),
|
||||
...(!Number.isNaN(textureSizeMm) ? { textureSizeMm } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function getTextureAxisConfigs(
|
||||
material: THREE.Material,
|
||||
): TextureAxisRuntimeConfigMap | null {
|
||||
const value = getMaterialUserData(material).textureAxisConfigs;
|
||||
if (!isRecord(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const axisConfigs = (["x", "y", "z"] as const).reduce<TextureAxisRuntimeConfigMap>(
|
||||
(result, axis) => {
|
||||
const axisConfig = normalizeTextureAxisRuntimeConfig(value[axis]);
|
||||
if (axisConfig) {
|
||||
result[axis] = axisConfig;
|
||||
}
|
||||
return result;
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
return Object.keys(axisConfigs).length > 0 ? axisConfigs : null;
|
||||
}
|
||||
|
||||
function getTextureProjectionAxis(
|
||||
material: THREE.Material,
|
||||
): TextureProjectionAxis | null {
|
||||
const value = getMaterialUserData(material).textureProjectionAxis;
|
||||
return value === "x" || value === "y" || value === "z" ? value : null;
|
||||
}
|
||||
|
||||
function getTextureProjectionSignature(
|
||||
material: THREE.Material,
|
||||
axisConfigs: TextureAxisRuntimeConfigMap | null,
|
||||
projectionAxis: TextureProjectionAxis | null,
|
||||
): string | null {
|
||||
if (axisConfigs) {
|
||||
return JSON.stringify({
|
||||
mode: "surface-axis",
|
||||
base: {
|
||||
textureRepeat: getPositiveMaterialNumber(material, "textureRepeat", 1),
|
||||
textureRepeatU: getPositiveMaterialNumber(material, "textureRepeatU", 1),
|
||||
textureRepeatV: getPositiveMaterialNumber(material, "textureRepeatV", 1),
|
||||
textureRotation: getFiniteMaterialNumber(material, "textureRotation", 0),
|
||||
textureSizeMm: getMaterialTextureSizeMm(material),
|
||||
},
|
||||
axes: axisConfigs,
|
||||
});
|
||||
}
|
||||
|
||||
return projectionAxis ? JSON.stringify({ mode: "single-axis", projectionAxis }) : null;
|
||||
}
|
||||
|
||||
function cloneGeometryForUvProjection(
|
||||
geometry: THREE.BufferGeometry,
|
||||
): THREE.BufferGeometry {
|
||||
const clonedGeometry = geometry.index ? geometry.toNonIndexed() : geometry.clone();
|
||||
clonedGeometry.userData = {
|
||||
...geometry.userData,
|
||||
};
|
||||
return clonedGeometry;
|
||||
}
|
||||
|
||||
function getTriangleDominantAxis(
|
||||
positionAttr: THREE.BufferAttribute | THREE.InterleavedBufferAttribute,
|
||||
vertexIndex: number,
|
||||
): TextureProjectionAxis {
|
||||
const a = new THREE.Vector3(
|
||||
positionAttr.getX(vertexIndex),
|
||||
positionAttr.getY(vertexIndex),
|
||||
positionAttr.getZ(vertexIndex),
|
||||
);
|
||||
const b = new THREE.Vector3(
|
||||
positionAttr.getX(vertexIndex + 1),
|
||||
positionAttr.getY(vertexIndex + 1),
|
||||
positionAttr.getZ(vertexIndex + 1),
|
||||
);
|
||||
const c = new THREE.Vector3(
|
||||
positionAttr.getX(vertexIndex + 2),
|
||||
positionAttr.getY(vertexIndex + 2),
|
||||
positionAttr.getZ(vertexIndex + 2),
|
||||
);
|
||||
const normal = b.sub(a).cross(c.sub(a));
|
||||
if (normal.lengthSq() < 1e-10) {
|
||||
return "z";
|
||||
}
|
||||
|
||||
const absX = Math.abs(normal.x);
|
||||
const absY = Math.abs(normal.y);
|
||||
const absZ = Math.abs(normal.z);
|
||||
if (absX >= absY && absX >= absZ) return "x";
|
||||
if (absY >= absX && absY >= absZ) return "y";
|
||||
return "z";
|
||||
}
|
||||
|
||||
function getTextureAxisRuntimeConfig(
|
||||
material: THREE.Material,
|
||||
axisConfigs: TextureAxisRuntimeConfigMap | null,
|
||||
axis: TextureProjectionAxis,
|
||||
): Required<TextureAxisRuntimeConfig> {
|
||||
const axisConfig = axisConfigs?.[axis] ?? null;
|
||||
return {
|
||||
textureRepeat: getPositiveNumber(
|
||||
axisConfig?.textureRepeat,
|
||||
getPositiveMaterialNumber(material, "textureRepeat", 1),
|
||||
),
|
||||
textureRepeatU: getPositiveNumber(
|
||||
axisConfig?.textureRepeatU,
|
||||
getPositiveMaterialNumber(material, "textureRepeatU", 1),
|
||||
),
|
||||
textureRepeatV: getPositiveNumber(
|
||||
axisConfig?.textureRepeatV,
|
||||
getPositiveMaterialNumber(material, "textureRepeatV", 1),
|
||||
),
|
||||
textureRotation: getFiniteNumber(
|
||||
axisConfig?.textureRotation,
|
||||
getFiniteMaterialNumber(material, "textureRotation", 0),
|
||||
),
|
||||
textureSizeMm: getPositiveNumber(
|
||||
axisConfig?.textureSizeMm,
|
||||
getMaterialTextureSizeMm(material),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function rotateUv(
|
||||
u: number,
|
||||
v: number,
|
||||
rotation: number,
|
||||
): { u: number; v: number } {
|
||||
if (!Number.isFinite(rotation) || Math.abs(rotation) < 1e-8) {
|
||||
return { u, v };
|
||||
}
|
||||
|
||||
const centerU = 0.5;
|
||||
const centerV = 0.5;
|
||||
const offsetU = u - centerU;
|
||||
const offsetV = v - centerV;
|
||||
const cos = Math.cos(rotation);
|
||||
const sin = Math.sin(rotation);
|
||||
|
||||
return {
|
||||
u: offsetU * cos - offsetV * sin + centerU,
|
||||
v: offsetU * sin + offsetV * cos + centerV,
|
||||
};
|
||||
}
|
||||
|
||||
function writeSurfaceAxisPlanarUv(
|
||||
geometry: THREE.BufferGeometry,
|
||||
material: THREE.Material,
|
||||
axisConfigs: TextureAxisRuntimeConfigMap | null,
|
||||
): void {
|
||||
const positionAttr = geometry.getAttribute("position");
|
||||
if (!positionAttr || positionAttr.itemSize < 3) return;
|
||||
|
||||
geometry.computeBoundingBox();
|
||||
const box = geometry.boundingBox;
|
||||
if (!box) return;
|
||||
|
||||
const min = [box.min.x, box.min.y, box.min.z];
|
||||
const uvArray = new Float32Array(positionAttr.count * 2);
|
||||
|
||||
for (let vertexIndex = 0; vertexIndex + 2 < positionAttr.count; vertexIndex += 3) {
|
||||
const surfaceAxis = getTriangleDominantAxis(positionAttr, vertexIndex);
|
||||
const [axisU, axisV] = getProjectionAxisComponents(surfaceAxis);
|
||||
const config = getTextureAxisRuntimeConfig(material, axisConfigs, surfaceAxis);
|
||||
|
||||
for (let offset = 0; offset < 3; offset++) {
|
||||
const index = vertexIndex + offset;
|
||||
const values = [
|
||||
positionAttr.getX(index),
|
||||
positionAttr.getY(index),
|
||||
positionAttr.getZ(index),
|
||||
];
|
||||
const rawU =
|
||||
((values[axisU] ?? 0) - min[axisU]) / Math.max(config.textureSizeMm, 1e-6);
|
||||
const rawV =
|
||||
((values[axisV] ?? 0) - min[axisV]) / Math.max(config.textureSizeMm, 1e-6);
|
||||
const scaledU = rawU * config.textureRepeat * config.textureRepeatU;
|
||||
const scaledV = rawV * config.textureRepeat * config.textureRepeatV;
|
||||
const rotated = rotateUv(scaledU, scaledV, config.textureRotation);
|
||||
|
||||
uvArray[index * 2] = rotated.u;
|
||||
uvArray[index * 2 + 1] = rotated.v;
|
||||
}
|
||||
}
|
||||
|
||||
const uvAttr = new THREE.BufferAttribute(uvArray, 2);
|
||||
geometry.setAttribute("uv", uvAttr);
|
||||
geometry.setAttribute("uv2", uvAttr.clone());
|
||||
geometry.attributes.uv.needsUpdate = true;
|
||||
geometry.attributes.uv2.needsUpdate = true;
|
||||
}
|
||||
|
||||
export function applyMaterialUvProjection(
|
||||
mesh: THREE.Mesh,
|
||||
material: THREE.Material,
|
||||
): void {
|
||||
const geometry = mesh.geometry;
|
||||
if (!(geometry instanceof THREE.BufferGeometry)) return;
|
||||
|
||||
const axisConfigs = getTextureAxisConfigs(material);
|
||||
const projectionAxis = getTextureProjectionAxis(material);
|
||||
const projectionSignature = getTextureProjectionSignature(
|
||||
material,
|
||||
axisConfigs,
|
||||
projectionAxis,
|
||||
);
|
||||
const sourceGeometry = mesh.userData[UV_PROJECTION_SOURCE_GEOMETRY_KEY] as
|
||||
| THREE.BufferGeometry
|
||||
| undefined;
|
||||
const currentGeneratedSignature = mesh.geometry.userData?.[
|
||||
UV_PROJECTION_SIGNATURE_KEY
|
||||
] as string | undefined;
|
||||
|
||||
if (!projectionSignature) {
|
||||
if (sourceGeometry && currentGeneratedSignature) {
|
||||
const generatedGeometry = mesh.geometry;
|
||||
mesh.geometry = sourceGeometry;
|
||||
generatedGeometry.dispose();
|
||||
ensureMeshUv(mesh);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (sourceGeometry && currentGeneratedSignature === projectionSignature) {
|
||||
return;
|
||||
}
|
||||
|
||||
const baseGeometry = sourceGeometry ?? geometry;
|
||||
if (!sourceGeometry) {
|
||||
mesh.userData[UV_PROJECTION_SOURCE_GEOMETRY_KEY] = baseGeometry;
|
||||
}
|
||||
|
||||
if (sourceGeometry && currentGeneratedSignature) {
|
||||
mesh.geometry.dispose();
|
||||
}
|
||||
|
||||
const projectedGeometry = axisConfigs
|
||||
? cloneGeometryForUvProjection(baseGeometry)
|
||||
: baseGeometry.clone();
|
||||
if (axisConfigs) {
|
||||
writeSurfaceAxisPlanarUv(projectedGeometry, material, axisConfigs);
|
||||
} else {
|
||||
writePlanarUv(projectedGeometry, projectionAxis);
|
||||
}
|
||||
projectedGeometry.userData = {
|
||||
...projectedGeometry.userData,
|
||||
[UV_PROJECTION_SIGNATURE_KEY]: projectionSignature,
|
||||
__uvProjectionAxis: projectionAxis,
|
||||
};
|
||||
mesh.geometry = projectedGeometry;
|
||||
}
|
||||
|
||||
function getTextureAspect(texture: THREE.Texture): number {
|
||||
const sourceData = (texture.source?.data ?? texture.image) as
|
||||
| { width?: number; height?: number }
|
||||
@ -308,6 +655,22 @@ function getTextureSizeMm(material: THREE.Material, axis: "u" | "v"): number {
|
||||
return axis === "u" ? size : size;
|
||||
}
|
||||
|
||||
function getPositiveMaterialNumber(
|
||||
material: THREE.Material,
|
||||
key: string,
|
||||
fallback: number,
|
||||
): number {
|
||||
return getPositiveNumber(getMaterialUserData(material)[key], fallback);
|
||||
}
|
||||
|
||||
function getFiniteMaterialNumber(
|
||||
material: THREE.Material,
|
||||
key: string,
|
||||
fallback: number,
|
||||
): number {
|
||||
return getFiniteNumber(getMaterialUserData(material)[key], fallback);
|
||||
}
|
||||
|
||||
export function applyAspectAwareTextureRepeat(
|
||||
mesh: THREE.Mesh,
|
||||
material: THREE.Material,
|
||||
@ -316,6 +679,15 @@ export function applyAspectAwareTextureRepeat(
|
||||
const map = textureMaterial.map;
|
||||
if (!map) return;
|
||||
|
||||
if (getTextureAxisConfigs(material)) {
|
||||
map.wrapS = THREE.RepeatWrapping;
|
||||
map.wrapT = THREE.RepeatWrapping;
|
||||
map.repeat.set(1, 1);
|
||||
map.rotation = 0;
|
||||
map.needsUpdate = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const surfaceProfile = getMeshSurfaceProfile(mesh);
|
||||
if (!surfaceProfile) return;
|
||||
|
||||
@ -365,6 +737,13 @@ export function applyAspectAwareTextureRepeat(
|
||||
if (rotated) {
|
||||
[repeatU, repeatV] = [repeatV, repeatU];
|
||||
}
|
||||
|
||||
const textureRepeat = getPositiveMaterialNumber(material, "textureRepeat", 1);
|
||||
const textureRepeatU = getPositiveMaterialNumber(material, "textureRepeatU", 1);
|
||||
const textureRepeatV = getPositiveMaterialNumber(material, "textureRepeatV", 1);
|
||||
repeatU *= textureRepeat * textureRepeatU;
|
||||
repeatV *= textureRepeat * textureRepeatV;
|
||||
|
||||
map.repeat.set(repeatU, repeatV);
|
||||
|
||||
const baseRepeat = map.userData?.baseRepeat as
|
||||
|
||||
@ -7,6 +7,7 @@ import { createDefaultSceneLights, setupRoomEnvironment } from "./engine-core";
|
||||
import { bindEngineEvents, unbindEngineEvents } from "./engine-events";
|
||||
import {
|
||||
applyAspectAwareTextureRepeat,
|
||||
applyMaterialUvProjection,
|
||||
cloneMaterialTextureMaps,
|
||||
ensureMeshUv,
|
||||
isHighlightHelperObjectName,
|
||||
@ -1935,6 +1936,7 @@ export class ThreeDEngine {
|
||||
const fallbackMesh = allMeshes[0].mesh;
|
||||
const meshMaterial = material.clone();
|
||||
this.cloneMaterialTextureMaps(meshMaterial);
|
||||
applyMaterialUvProjection(fallbackMesh, meshMaterial);
|
||||
this.applyAspectAwareTextureRepeat(fallbackMesh, meshMaterial);
|
||||
normalizeTooBrightMaterial(meshMaterial, fallbackMesh);
|
||||
fallbackMesh.material = meshMaterial;
|
||||
@ -1956,6 +1958,7 @@ export class ThreeDEngine {
|
||||
const mesh = child as THREE.Mesh;
|
||||
const meshMaterial = material.clone();
|
||||
this.cloneMaterialTextureMaps(meshMaterial);
|
||||
applyMaterialUvProjection(mesh, meshMaterial);
|
||||
this.applyAspectAwareTextureRepeat(mesh, meshMaterial);
|
||||
normalizeTooBrightMaterial(meshMaterial, mesh);
|
||||
mesh.material = meshMaterial;
|
||||
|
||||
@ -5,6 +5,17 @@
|
||||
|
||||
import * as THREE from 'three'
|
||||
import { mergeMaterialWithPreset } from '@/lib/3d/material-pbr-presets'
|
||||
import type { AssetVariantUvProjectionAxis } from '@/lib/asset-variant-render-config'
|
||||
|
||||
export interface MaterialAxisUvConfig {
|
||||
textureRepeat?: number
|
||||
textureRepeatU?: number
|
||||
textureRepeatV?: number
|
||||
textureRotation?: number
|
||||
textureSizeMm?: number
|
||||
}
|
||||
|
||||
export type MaterialAxisUvConfigMap = Partial<Record<AssetVariantUvProjectionAxis, MaterialAxisUvConfig>>
|
||||
|
||||
export interface MaterialConfig {
|
||||
id: string
|
||||
@ -28,8 +39,12 @@ export interface MaterialConfig {
|
||||
clearcoatRoughness?: number
|
||||
envMapIntensity?: number
|
||||
textureRepeat?: number
|
||||
textureRepeatU?: number
|
||||
textureRepeatV?: number
|
||||
textureRotation?: number
|
||||
textureSizeMm?: number
|
||||
textureProjectionAxis?: AssetVariantUvProjectionAxis
|
||||
textureAxisConfigs?: MaterialAxisUvConfigMap | null
|
||||
}
|
||||
|
||||
export interface MaterialCategoryWithMaterials {
|
||||
@ -109,8 +124,12 @@ export class MaterialManager {
|
||||
clearcoatRoughness: config.clearcoatRoughness,
|
||||
envMapIntensity: config.envMapIntensity,
|
||||
textureRepeat: config.textureRepeat,
|
||||
textureRepeatU: config.textureRepeatU,
|
||||
textureRepeatV: config.textureRepeatV,
|
||||
textureRotation: config.textureRotation,
|
||||
textureSizeMm: config.textureSizeMm,
|
||||
textureProjectionAxis: config.textureProjectionAxis,
|
||||
textureAxisConfigs: config.textureAxisConfigs,
|
||||
textureUrl: config.textureUrl,
|
||||
normalMapUrl: config.normalMapUrl,
|
||||
roughnessMapUrl: config.roughnessMapUrl,
|
||||
@ -168,7 +187,13 @@ export class MaterialManager {
|
||||
material.envMapIntensity = resolveEnvMapIntensity(resolvedConfig, { isGlassLikeMaterial })
|
||||
material.userData = {
|
||||
...material.userData,
|
||||
textureRepeat: resolvedConfig.textureRepeat ?? 1,
|
||||
textureRepeatU: resolvedConfig.textureRepeatU ?? 1,
|
||||
textureRepeatV: resolvedConfig.textureRepeatV ?? 1,
|
||||
textureRotation: resolvedConfig.textureRotation ?? 0,
|
||||
textureProjectionAxis: resolvedConfig.textureProjectionAxis,
|
||||
textureSizeMm: resolvedConfig.textureSizeMm ?? 600,
|
||||
textureAxisConfigs: resolvedConfig.textureAxisConfigs ?? undefined,
|
||||
}
|
||||
return material
|
||||
}
|
||||
@ -316,7 +341,16 @@ export class MaterialManager {
|
||||
}
|
||||
|
||||
private applyTextureTransform(texture: THREE.Texture, config: MaterialConfig): void {
|
||||
if (config.textureRepeat && config.textureRepeat !== 1) {
|
||||
const hasAxisConfigs = Boolean(
|
||||
config.textureAxisConfigs && Object.keys(config.textureAxisConfigs).length > 0,
|
||||
)
|
||||
|
||||
if (hasAxisConfigs) {
|
||||
texture.wrapS = THREE.RepeatWrapping
|
||||
texture.wrapT = THREE.RepeatWrapping
|
||||
texture.repeat.set(1, 1)
|
||||
texture.rotation = 0
|
||||
} else if (config.textureRepeat && config.textureRepeat !== 1) {
|
||||
texture.repeat.set(config.textureRepeat, config.textureRepeat)
|
||||
texture.wrapS = THREE.RepeatWrapping
|
||||
texture.wrapT = THREE.RepeatWrapping
|
||||
@ -330,7 +364,7 @@ export class MaterialManager {
|
||||
},
|
||||
}
|
||||
|
||||
if (config.textureRotation && config.textureRotation !== 0) {
|
||||
if (!hasAxisConfigs && config.textureRotation && config.textureRotation !== 0) {
|
||||
texture.rotation = config.textureRotation
|
||||
texture.center.set(0.5, 0.5)
|
||||
}
|
||||
|
||||
227
furniture-3d-system/src/lib/asset-variant-render-config.test.ts
Normal file
227
furniture-3d-system/src/lib/asset-variant-render-config.test.ts
Normal file
@ -0,0 +1,227 @@
|
||||
import {
|
||||
applyAssetVariantUvConfigToMaterial,
|
||||
normalizeAssetVariantUvConfig,
|
||||
updateAssetVariantUvConfig,
|
||||
type AssetVariantUvConfig,
|
||||
} from "@/lib/asset-variant-render-config";
|
||||
import type { MaterialConfig } from "@/lib/3d/material-manager";
|
||||
|
||||
function createMaterialConfig(): MaterialConfig {
|
||||
return {
|
||||
id: "mat-oak",
|
||||
name: "木纹",
|
||||
categoryId: "cat-wood",
|
||||
categoryName: "木纹",
|
||||
thumbnailUrl: null,
|
||||
textureUrl: "/oak.webp",
|
||||
normalMapUrl: null,
|
||||
roughnessMapUrl: null,
|
||||
metalnessMapUrl: null,
|
||||
aoMapUrl: null,
|
||||
color: "#c89a62",
|
||||
metalness: 0,
|
||||
roughness: 0.62,
|
||||
opacity: 1,
|
||||
textureRepeat: 1,
|
||||
textureRotation: 0,
|
||||
textureSizeMm: 600,
|
||||
};
|
||||
}
|
||||
|
||||
describe("asset-variant-render-config", () => {
|
||||
it("保留各投影轴独立的 U/V 压缩参数", () => {
|
||||
const uvConfig = normalizeAssetVariantUvConfig({
|
||||
version: 1,
|
||||
nodes: {
|
||||
"panel-a": {
|
||||
textureProjectionAxis: "z",
|
||||
axes: {
|
||||
x: {
|
||||
textureRepeat: 0.8,
|
||||
textureRepeatU: 2,
|
||||
},
|
||||
z: {
|
||||
textureRepeat: 1.5,
|
||||
textureRepeatU: 3,
|
||||
textureRepeatV: 0.6,
|
||||
textureRotation: Math.PI / 2,
|
||||
textureSizeMm: 900,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(uvConfig).toEqual({
|
||||
version: 1,
|
||||
nodes: {
|
||||
"panel-a": {
|
||||
textureProjectionAxis: "z",
|
||||
axes: {
|
||||
x: {
|
||||
textureRepeat: 0.8,
|
||||
textureRepeatU: 2,
|
||||
},
|
||||
z: {
|
||||
textureRepeat: 1.5,
|
||||
textureRepeatU: 3,
|
||||
textureRepeatV: 0.6,
|
||||
textureRotation: Math.PI / 2,
|
||||
textureSizeMm: 900,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("把节点多轴 UV 控制传给 3D 材质配置", () => {
|
||||
const uvConfig: AssetVariantUvConfig = {
|
||||
version: 1,
|
||||
nodes: {
|
||||
"panel-a": {
|
||||
textureProjectionAxis: "x",
|
||||
axes: {
|
||||
x: {
|
||||
textureRepeatU: 2,
|
||||
textureRepeatV: 0.5,
|
||||
},
|
||||
z: {
|
||||
textureRepeatU: 6,
|
||||
textureRepeatV: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const material = applyAssetVariantUvConfigToMaterial(
|
||||
createMaterialConfig(),
|
||||
uvConfig,
|
||||
{ nodeName: "panel-a" },
|
||||
);
|
||||
|
||||
expect(material).toEqual(
|
||||
expect.objectContaining({
|
||||
textureProjectionAxis: "x",
|
||||
textureAxisConfigs: {
|
||||
x: {
|
||||
textureRepeatU: 2,
|
||||
textureRepeatV: 0.5,
|
||||
},
|
||||
z: {
|
||||
textureRepeatU: 6,
|
||||
textureRepeatV: 1,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(material?.textureRepeatU).toBeUndefined();
|
||||
expect(material?.textureRepeatV).toBeUndefined();
|
||||
});
|
||||
|
||||
it("更新节点 UV 时不会丢失新增字段", () => {
|
||||
const updated = updateAssetVariantUvConfig(null, {
|
||||
nodeName: "panel-a",
|
||||
projectionAxis: "y",
|
||||
}, () => ({
|
||||
textureRepeat: 2,
|
||||
textureRepeatU: 1,
|
||||
textureRepeatV: 6,
|
||||
}));
|
||||
|
||||
expect(updated).toEqual({
|
||||
version: 1,
|
||||
nodes: {
|
||||
"panel-a": {
|
||||
textureProjectionAxis: "y",
|
||||
axes: {
|
||||
y: {
|
||||
textureRepeat: 2,
|
||||
textureRepeatU: 1,
|
||||
textureRepeatV: 6,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("更新不同投影轴时互不覆盖", () => {
|
||||
const withZ = updateAssetVariantUvConfig(null, {
|
||||
nodeName: "panel-a",
|
||||
projectionAxis: "z",
|
||||
}, () => ({
|
||||
textureRepeat: 2,
|
||||
textureRepeatU: 1,
|
||||
textureRepeatV: 6,
|
||||
}));
|
||||
|
||||
const withX = updateAssetVariantUvConfig(withZ, {
|
||||
nodeName: "panel-a",
|
||||
projectionAxis: "x",
|
||||
}, () => ({
|
||||
textureRepeat: 4,
|
||||
textureRepeatU: 2,
|
||||
textureRepeatV: 1,
|
||||
}));
|
||||
|
||||
expect(withX).toEqual({
|
||||
version: 1,
|
||||
nodes: {
|
||||
"panel-a": {
|
||||
textureProjectionAxis: "x",
|
||||
axes: {
|
||||
z: {
|
||||
textureRepeat: 2,
|
||||
textureRepeatU: 1,
|
||||
textureRepeatV: 6,
|
||||
},
|
||||
x: {
|
||||
textureRepeat: 4,
|
||||
textureRepeatU: 2,
|
||||
textureRepeatV: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("自动 UV 参数和指定轴 UV 参数互不覆盖", () => {
|
||||
const autoConfig = updateAssetVariantUvConfig(null, {
|
||||
nodeName: "panel-a",
|
||||
projectionAxis: null,
|
||||
}, () => ({
|
||||
textureRepeat: 1.2,
|
||||
textureRepeatU: 1,
|
||||
textureRepeatV: 1,
|
||||
}));
|
||||
|
||||
const withX = updateAssetVariantUvConfig(autoConfig, {
|
||||
nodeName: "panel-a",
|
||||
projectionAxis: "x",
|
||||
}, () => ({
|
||||
textureRepeat: 4,
|
||||
textureRepeatU: 2,
|
||||
}));
|
||||
|
||||
expect(withX).toEqual({
|
||||
version: 1,
|
||||
nodes: {
|
||||
"panel-a": {
|
||||
textureRepeat: 1.2,
|
||||
textureRepeatU: 1,
|
||||
textureRepeatV: 1,
|
||||
textureProjectionAxis: "x",
|
||||
axes: {
|
||||
x: {
|
||||
textureRepeat: 4,
|
||||
textureRepeatU: 2,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -3,13 +3,19 @@ import type { MaterialConfig } from '@/lib/3d/material-manager'
|
||||
export const ASSET_VARIANT_RENDER_CONFIG_KEY = 'renderConfig'
|
||||
export const DEFAULT_VARIANT_UV_NODE_KEY = '__default__'
|
||||
|
||||
export type AssetVariantUvProjectionAxis = 'x' | 'y' | 'z'
|
||||
|
||||
export interface AssetVariantUvSurfaceConfig {
|
||||
textureRepeat?: number | null
|
||||
textureRepeatU?: number | null
|
||||
textureRepeatV?: number | null
|
||||
textureRotation?: number | null
|
||||
textureSizeMm?: number | null
|
||||
textureProjectionAxis?: AssetVariantUvProjectionAxis | null
|
||||
}
|
||||
|
||||
export interface AssetVariantNodeUvConfig extends AssetVariantUvSurfaceConfig {
|
||||
axes?: Partial<Record<AssetVariantUvProjectionAxis, AssetVariantUvSurfaceConfig | null>> | null
|
||||
surfaces?: {
|
||||
front?: AssetVariantUvSurfaceConfig | null
|
||||
back?: AssetVariantUvSurfaceConfig | null
|
||||
@ -33,6 +39,10 @@ function toFiniteNumber(value: unknown): number | undefined {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : undefined
|
||||
}
|
||||
|
||||
function toProjectionAxis(value: unknown): AssetVariantUvProjectionAxis | undefined {
|
||||
return value === 'x' || value === 'y' || value === 'z' ? value : undefined
|
||||
}
|
||||
|
||||
function normalizeUvSurfaceConfig(
|
||||
value: unknown,
|
||||
): AssetVariantUvSurfaceConfig | null {
|
||||
@ -41,40 +51,91 @@ function normalizeUvSurfaceConfig(
|
||||
}
|
||||
|
||||
const textureRepeat = toFiniteNumber(value.textureRepeat)
|
||||
const textureRepeatU = toFiniteNumber(value.textureRepeatU)
|
||||
const textureRepeatV = toFiniteNumber(value.textureRepeatV)
|
||||
const textureRotation = toFiniteNumber(value.textureRotation)
|
||||
const textureSizeMm = toFiniteNumber(value.textureSizeMm)
|
||||
const textureProjectionAxis = toProjectionAxis(value.textureProjectionAxis)
|
||||
|
||||
if (
|
||||
textureRepeat === undefined &&
|
||||
textureRepeatU === undefined &&
|
||||
textureRepeatV === undefined &&
|
||||
textureRotation === undefined &&
|
||||
textureSizeMm === undefined
|
||||
textureSizeMm === undefined &&
|
||||
textureProjectionAxis === undefined
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
...(textureRepeat !== undefined ? { textureRepeat } : {}),
|
||||
...(textureRepeatU !== undefined ? { textureRepeatU } : {}),
|
||||
...(textureRepeatV !== undefined ? { textureRepeatV } : {}),
|
||||
...(textureRotation !== undefined ? { textureRotation } : {}),
|
||||
...(textureSizeMm !== undefined ? { textureSizeMm } : {}),
|
||||
...(textureProjectionAxis !== undefined ? { textureProjectionAxis } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
function stripProjectionAxis(
|
||||
config: AssetVariantUvSurfaceConfig | null | undefined,
|
||||
): AssetVariantUvSurfaceConfig | null {
|
||||
if (!config) return null
|
||||
|
||||
const next = { ...config }
|
||||
delete next.textureProjectionAxis
|
||||
return normalizeUvSurfaceConfig(next)
|
||||
}
|
||||
|
||||
function mergeUvSurfaceValues(
|
||||
base: AssetVariantUvSurfaceConfig | null | undefined,
|
||||
patch: AssetVariantUvSurfaceConfig | null | undefined,
|
||||
): AssetVariantUvSurfaceConfig | null {
|
||||
return normalizeUvSurfaceConfig({
|
||||
...(base ?? {}),
|
||||
...(patch ?? {}),
|
||||
})
|
||||
}
|
||||
|
||||
function normalizeAxisUvConfigs(value: unknown) {
|
||||
if (!isRecord(value)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const axes = (['x', 'y', 'z'] as const).reduce<Partial<Record<AssetVariantUvProjectionAxis, AssetVariantUvSurfaceConfig>>>(
|
||||
(result, axis) => {
|
||||
const normalized = stripProjectionAxis(normalizeUvSurfaceConfig(value[axis]))
|
||||
if (normalized) {
|
||||
result[axis] = normalized
|
||||
}
|
||||
return result
|
||||
},
|
||||
{},
|
||||
)
|
||||
|
||||
return Object.keys(axes).length > 0 ? axes : null
|
||||
}
|
||||
|
||||
function normalizeNodeUvConfig(value: unknown): AssetVariantNodeUvConfig | null {
|
||||
if (!isRecord(value)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const base = normalizeUvSurfaceConfig(value)
|
||||
const axes = normalizeAxisUvConfigs(value.axes) ?? {}
|
||||
|
||||
const surfacesRecord = isRecord(value.surfaces) ? value.surfaces : null
|
||||
const front = normalizeUvSurfaceConfig(surfacesRecord?.front)
|
||||
const back = normalizeUvSurfaceConfig(surfacesRecord?.back)
|
||||
|
||||
if (!base && !front && !back) {
|
||||
if (!base && Object.keys(axes).length === 0 && !front && !back) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
...(base ?? {}),
|
||||
...(Object.keys(axes).length > 0 ? { axes } : {}),
|
||||
...(front || back
|
||||
? {
|
||||
surfaces: {
|
||||
@ -198,6 +259,7 @@ export function getAssetVariantUvSurfaceConfig(
|
||||
options?: {
|
||||
nodeName?: string | null
|
||||
face?: 'front' | 'back' | null
|
||||
projectionAxis?: AssetVariantUvProjectionAxis | null
|
||||
},
|
||||
): AssetVariantUvSurfaceConfig | null {
|
||||
const nodeConfig = getAssetVariantNodeUvConfig(uvConfig, options?.nodeName)
|
||||
@ -209,14 +271,40 @@ export function getAssetVariantUvSurfaceConfig(
|
||||
return nodeConfig.surfaces?.[options.face] ?? null
|
||||
}
|
||||
|
||||
return normalizeUvSurfaceConfig(nodeConfig)
|
||||
if (options?.projectionAxis === 'x' || options?.projectionAxis === 'y' || options?.projectionAxis === 'z') {
|
||||
return mergeUvSurfaceValues(
|
||||
{ textureProjectionAxis: options.projectionAxis },
|
||||
nodeConfig.axes?.[options.projectionAxis] ?? null,
|
||||
)
|
||||
}
|
||||
|
||||
if (options?.projectionAxis === null) {
|
||||
return normalizeUvSurfaceConfig({
|
||||
...nodeConfig,
|
||||
textureProjectionAxis: undefined,
|
||||
})
|
||||
}
|
||||
|
||||
const baseConfig = normalizeUvSurfaceConfig(nodeConfig)
|
||||
if (baseConfig?.textureProjectionAxis) {
|
||||
return mergeUvSurfaceValues(
|
||||
baseConfig,
|
||||
nodeConfig.axes?.[baseConfig.textureProjectionAxis] ?? null,
|
||||
)
|
||||
}
|
||||
|
||||
return baseConfig
|
||||
}
|
||||
|
||||
function hasBaseNodeUvConfig(nodeConfig: AssetVariantNodeUvConfig): boolean {
|
||||
return (
|
||||
nodeConfig.textureRepeat !== undefined ||
|
||||
nodeConfig.textureRepeatU !== undefined ||
|
||||
nodeConfig.textureRepeatV !== undefined ||
|
||||
nodeConfig.textureRotation !== undefined ||
|
||||
nodeConfig.textureSizeMm !== undefined
|
||||
nodeConfig.textureSizeMm !== undefined ||
|
||||
nodeConfig.textureProjectionAxis !== undefined ||
|
||||
Boolean(nodeConfig.axes && Object.keys(nodeConfig.axes).length > 0)
|
||||
)
|
||||
}
|
||||
|
||||
@ -231,6 +319,7 @@ export function updateAssetVariantUvConfig(
|
||||
options: {
|
||||
nodeName?: string | null
|
||||
face?: 'front' | 'back' | null
|
||||
projectionAxis?: AssetVariantUvProjectionAxis | null
|
||||
},
|
||||
updater: (current: AssetVariantUvSurfaceConfig | null) => AssetVariantUvSurfaceConfig | null,
|
||||
): AssetVariantUvConfig | null {
|
||||
@ -242,6 +331,15 @@ export function updateAssetVariantUvConfig(
|
||||
const currentNode = normalizedConfig.nodes[nodeKey] ?? {}
|
||||
const nextNode: AssetVariantNodeUvConfig = {
|
||||
...currentNode,
|
||||
...(currentNode.axes
|
||||
? {
|
||||
axes: {
|
||||
...(currentNode.axes.x ? { x: { ...currentNode.axes.x } } : {}),
|
||||
...(currentNode.axes.y ? { y: { ...currentNode.axes.y } } : {}),
|
||||
...(currentNode.axes.z ? { z: { ...currentNode.axes.z } } : {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(currentNode.surfaces
|
||||
? {
|
||||
surfaces: {
|
||||
@ -267,16 +365,47 @@ export function updateAssetVariantUvConfig(
|
||||
delete nextNode.surfaces
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const nextBaseConfig = normalizeUvSurfaceConfig(updater(normalizeUvSurfaceConfig(nextNode)))
|
||||
} else if (options.projectionAxis === 'x' || options.projectionAxis === 'y' || options.projectionAxis === 'z') {
|
||||
const axis = options.projectionAxis
|
||||
const currentAxisConfig = nextNode.axes?.[axis] ?? null
|
||||
const nextAxisConfig = stripProjectionAxis(updater(currentAxisConfig))
|
||||
|
||||
nextNode.textureProjectionAxis = axis
|
||||
|
||||
if (nextAxisConfig) {
|
||||
nextNode.axes = {
|
||||
...(nextNode.axes ?? {}),
|
||||
[axis]: nextAxisConfig,
|
||||
}
|
||||
} else if (nextNode.axes) {
|
||||
delete nextNode.axes[axis]
|
||||
if (Object.keys(nextNode.axes).length === 0) {
|
||||
delete nextNode.axes
|
||||
}
|
||||
}
|
||||
} else if (options.projectionAxis === null) {
|
||||
const currentBaseConfig = normalizeUvSurfaceConfig({
|
||||
...nextNode,
|
||||
textureProjectionAxis: undefined,
|
||||
})
|
||||
const nextBaseConfig = normalizeUvSurfaceConfig(updater(currentBaseConfig))
|
||||
delete nextNode.textureRepeat
|
||||
delete nextNode.textureRepeatU
|
||||
delete nextNode.textureRepeatV
|
||||
delete nextNode.textureRotation
|
||||
delete nextNode.textureSizeMm
|
||||
delete nextNode.textureProjectionAxis
|
||||
|
||||
if (nextBaseConfig) {
|
||||
if (nextBaseConfig.textureRepeat !== undefined) {
|
||||
nextNode.textureRepeat = nextBaseConfig.textureRepeat
|
||||
}
|
||||
if (nextBaseConfig.textureRepeatU !== undefined) {
|
||||
nextNode.textureRepeatU = nextBaseConfig.textureRepeatU
|
||||
}
|
||||
if (nextBaseConfig.textureRepeatV !== undefined) {
|
||||
nextNode.textureRepeatV = nextBaseConfig.textureRepeatV
|
||||
}
|
||||
if (nextBaseConfig.textureRotation !== undefined) {
|
||||
nextNode.textureRotation = nextBaseConfig.textureRotation
|
||||
}
|
||||
@ -284,6 +413,35 @@ export function updateAssetVariantUvConfig(
|
||||
nextNode.textureSizeMm = nextBaseConfig.textureSizeMm
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const nextBaseConfig = normalizeUvSurfaceConfig(updater(normalizeUvSurfaceConfig(nextNode)))
|
||||
delete nextNode.textureRepeat
|
||||
delete nextNode.textureRepeatU
|
||||
delete nextNode.textureRepeatV
|
||||
delete nextNode.textureRotation
|
||||
delete nextNode.textureSizeMm
|
||||
delete nextNode.textureProjectionAxis
|
||||
|
||||
if (nextBaseConfig) {
|
||||
if (nextBaseConfig.textureRepeat !== undefined) {
|
||||
nextNode.textureRepeat = nextBaseConfig.textureRepeat
|
||||
}
|
||||
if (nextBaseConfig.textureRepeatU !== undefined) {
|
||||
nextNode.textureRepeatU = nextBaseConfig.textureRepeatU
|
||||
}
|
||||
if (nextBaseConfig.textureRepeatV !== undefined) {
|
||||
nextNode.textureRepeatV = nextBaseConfig.textureRepeatV
|
||||
}
|
||||
if (nextBaseConfig.textureRotation !== undefined) {
|
||||
nextNode.textureRotation = nextBaseConfig.textureRotation
|
||||
}
|
||||
if (nextBaseConfig.textureSizeMm !== undefined) {
|
||||
nextNode.textureSizeMm = nextBaseConfig.textureSizeMm
|
||||
}
|
||||
if (nextBaseConfig.textureProjectionAxis !== undefined) {
|
||||
nextNode.textureProjectionAxis = nextBaseConfig.textureProjectionAxis
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasBaseNodeUvConfig(nextNode) && !hasSurfaceNodeUvConfig(nextNode)) {
|
||||
@ -308,15 +466,61 @@ function mergeUvSurfaceConfig(
|
||||
...(uvSurfaceConfig.textureRepeat !== undefined
|
||||
? { textureRepeat: uvSurfaceConfig.textureRepeat ?? undefined }
|
||||
: {}),
|
||||
...(uvSurfaceConfig.textureRepeatU !== undefined
|
||||
? { textureRepeatU: uvSurfaceConfig.textureRepeatU ?? undefined }
|
||||
: {}),
|
||||
...(uvSurfaceConfig.textureRepeatV !== undefined
|
||||
? { textureRepeatV: uvSurfaceConfig.textureRepeatV ?? undefined }
|
||||
: {}),
|
||||
...(uvSurfaceConfig.textureRotation !== undefined
|
||||
? { textureRotation: uvSurfaceConfig.textureRotation ?? undefined }
|
||||
: {}),
|
||||
...(uvSurfaceConfig.textureSizeMm !== undefined
|
||||
? { textureSizeMm: uvSurfaceConfig.textureSizeMm ?? undefined }
|
||||
: {}),
|
||||
...(uvSurfaceConfig.textureProjectionAxis !== undefined
|
||||
? { textureProjectionAxis: uvSurfaceConfig.textureProjectionAxis ?? undefined }
|
||||
: {}),
|
||||
}
|
||||
}
|
||||
|
||||
function toMaterialAxisUvConfigs(
|
||||
axes: AssetVariantNodeUvConfig['axes'],
|
||||
): MaterialConfig['textureAxisConfigs'] {
|
||||
if (!axes) {
|
||||
return null
|
||||
}
|
||||
|
||||
const result = (['x', 'y', 'z'] as const).reduce<NonNullable<MaterialConfig['textureAxisConfigs']>>(
|
||||
(configs, axis) => {
|
||||
const axisConfig = stripProjectionAxis(axes[axis])
|
||||
if (axisConfig) {
|
||||
configs[axis] = {
|
||||
...(axisConfig.textureRepeat !== undefined
|
||||
? { textureRepeat: axisConfig.textureRepeat ?? undefined }
|
||||
: {}),
|
||||
...(axisConfig.textureRepeatU !== undefined
|
||||
? { textureRepeatU: axisConfig.textureRepeatU ?? undefined }
|
||||
: {}),
|
||||
...(axisConfig.textureRepeatV !== undefined
|
||||
? { textureRepeatV: axisConfig.textureRepeatV ?? undefined }
|
||||
: {}),
|
||||
...(axisConfig.textureRotation !== undefined
|
||||
? { textureRotation: axisConfig.textureRotation ?? undefined }
|
||||
: {}),
|
||||
...(axisConfig.textureSizeMm !== undefined
|
||||
? { textureSizeMm: axisConfig.textureSizeMm ?? undefined }
|
||||
: {}),
|
||||
}
|
||||
}
|
||||
return configs
|
||||
},
|
||||
{},
|
||||
)
|
||||
|
||||
return Object.keys(result).length > 0 ? result : null
|
||||
}
|
||||
|
||||
export function applyAssetVariantUvConfigToMaterial(
|
||||
materialConfig: MaterialConfig | null | undefined,
|
||||
uvConfig: AssetVariantUvConfig | null | undefined,
|
||||
@ -334,11 +538,20 @@ export function applyAssetVariantUvConfigToMaterial(
|
||||
return materialConfig
|
||||
}
|
||||
|
||||
const axisConfigs = toMaterialAxisUvConfigs(nodeConfig.axes)
|
||||
const mergedBase = mergeUvSurfaceConfig(materialConfig, nodeConfig)
|
||||
const faceConfig =
|
||||
options?.face === 'front' || options?.face === 'back'
|
||||
? nodeConfig.surfaces?.[options.face] ?? null
|
||||
: nodeConfig.surfaces?.front ?? null
|
||||
|
||||
return mergeUvSurfaceConfig(mergedBase, faceConfig)
|
||||
const mergedConfig = mergeUvSurfaceConfig(mergedBase, faceConfig)
|
||||
if (!axisConfigs) {
|
||||
return mergedConfig
|
||||
}
|
||||
|
||||
return {
|
||||
...mergedConfig,
|
||||
textureAxisConfigs: axisConfigs,
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
import type { MaterialConfig } from '@/lib/3d/material-manager'
|
||||
import type { MaterialAxisUvConfig, MaterialAxisUvConfigMap, MaterialConfig } from '@/lib/3d/material-manager'
|
||||
import { encodeLocalMaterialAssetUrl } from '@/lib/material-asset-path'
|
||||
import type { MaterialDTO } from '@/server/services'
|
||||
|
||||
const UV_AXIS_KEYS = ['x', 'y', 'z'] as const
|
||||
|
||||
export function serializeMaterialConfig(
|
||||
materialConfig: MaterialConfig | null | undefined,
|
||||
): string {
|
||||
@ -31,8 +33,12 @@ export function serializeMaterialConfig(
|
||||
clearcoatRoughness: materialConfig.clearcoatRoughness ?? null,
|
||||
envMapIntensity: materialConfig.envMapIntensity ?? null,
|
||||
textureRepeat: materialConfig.textureRepeat ?? null,
|
||||
textureRepeatU: materialConfig.textureRepeatU ?? null,
|
||||
textureRepeatV: materialConfig.textureRepeatV ?? null,
|
||||
textureRotation: materialConfig.textureRotation ?? null,
|
||||
textureSizeMm: materialConfig.textureSizeMm ?? null,
|
||||
textureProjectionAxis: materialConfig.textureProjectionAxis ?? null,
|
||||
textureAxisConfigs: materialConfig.textureAxisConfigs ?? null,
|
||||
})
|
||||
}
|
||||
|
||||
@ -48,6 +54,56 @@ function toNumber(value: unknown, fallback: number): number {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : fallback
|
||||
}
|
||||
|
||||
function toOptionalNumber(value: unknown): number | undefined {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : undefined
|
||||
}
|
||||
|
||||
function parseMaterialAxisUvConfig(value: unknown): MaterialAxisUvConfig | null {
|
||||
if (!isRecord(value)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const textureRepeat = toOptionalNumber(value.textureRepeat)
|
||||
const textureRepeatU = toOptionalNumber(value.textureRepeatU)
|
||||
const textureRepeatV = toOptionalNumber(value.textureRepeatV)
|
||||
const textureRotation = toOptionalNumber(value.textureRotation)
|
||||
const textureSizeMm = toOptionalNumber(value.textureSizeMm)
|
||||
|
||||
if (
|
||||
textureRepeat === undefined &&
|
||||
textureRepeatU === undefined &&
|
||||
textureRepeatV === undefined &&
|
||||
textureRotation === undefined &&
|
||||
textureSizeMm === undefined
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
...(textureRepeat !== undefined ? { textureRepeat } : {}),
|
||||
...(textureRepeatU !== undefined ? { textureRepeatU } : {}),
|
||||
...(textureRepeatV !== undefined ? { textureRepeatV } : {}),
|
||||
...(textureRotation !== undefined ? { textureRotation } : {}),
|
||||
...(textureSizeMm !== undefined ? { textureSizeMm } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
function parseMaterialAxisUvConfigMap(value: unknown): MaterialAxisUvConfigMap | null {
|
||||
if (!isRecord(value)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const axisConfigs = UV_AXIS_KEYS.reduce<MaterialAxisUvConfigMap>((result, axis) => {
|
||||
const axisConfig = parseMaterialAxisUvConfig(value[axis])
|
||||
if (axisConfig) {
|
||||
result[axis] = axisConfig
|
||||
}
|
||||
return result
|
||||
}, {})
|
||||
|
||||
return Object.keys(axisConfigs).length > 0 ? axisConfigs : null
|
||||
}
|
||||
|
||||
export function parseMaterialConfig(
|
||||
value: string | null | undefined,
|
||||
): MaterialConfig | null {
|
||||
@ -115,6 +171,14 @@ export function parseMaterialConfig(
|
||||
typeof parsed.textureRepeat === 'number' && Number.isFinite(parsed.textureRepeat)
|
||||
? parsed.textureRepeat
|
||||
: undefined,
|
||||
textureRepeatU:
|
||||
typeof parsed.textureRepeatU === 'number' && Number.isFinite(parsed.textureRepeatU)
|
||||
? parsed.textureRepeatU
|
||||
: undefined,
|
||||
textureRepeatV:
|
||||
typeof parsed.textureRepeatV === 'number' && Number.isFinite(parsed.textureRepeatV)
|
||||
? parsed.textureRepeatV
|
||||
: undefined,
|
||||
textureRotation:
|
||||
typeof parsed.textureRotation === 'number' && Number.isFinite(parsed.textureRotation)
|
||||
? parsed.textureRotation
|
||||
@ -123,6 +187,13 @@ export function parseMaterialConfig(
|
||||
typeof parsed.textureSizeMm === 'number' && Number.isFinite(parsed.textureSizeMm)
|
||||
? parsed.textureSizeMm
|
||||
: undefined,
|
||||
textureProjectionAxis:
|
||||
parsed.textureProjectionAxis === 'x' ||
|
||||
parsed.textureProjectionAxis === 'y' ||
|
||||
parsed.textureProjectionAxis === 'z'
|
||||
? parsed.textureProjectionAxis
|
||||
: undefined,
|
||||
textureAxisConfigs: parseMaterialAxisUvConfigMap(parsed.textureAxisConfigs),
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
|
||||
@ -45,7 +45,7 @@ export interface AssetVariantFilter {
|
||||
}
|
||||
|
||||
export interface AssetVariantWithRelations extends AssetVariant {
|
||||
assetFamily: { id: string; code: string; name: string; type: AssetFamily['type'] }
|
||||
assetFamily: { id: string; code: string; name: string; type: AssetFamily['type']; deletedAt: Date | null }
|
||||
substrate: { id: string; code: string; name: string } | null
|
||||
finish: AssetCenterFinishRelation | null
|
||||
creator: { id: string; name: string } | null
|
||||
@ -71,7 +71,7 @@ export interface SubstrateFilter {
|
||||
search?: string
|
||||
}
|
||||
|
||||
const assetFamilyRelationInclude = {
|
||||
export const assetFamilyRelationInclude = {
|
||||
productCategory: { select: { id: true, name: true } },
|
||||
creator: { select: { id: true, name: true } },
|
||||
submitter: { select: { id: true, name: true } },
|
||||
@ -80,8 +80,8 @@ const assetFamilyRelationInclude = {
|
||||
lastModifier: { select: { id: true, name: true } },
|
||||
} satisfies Prisma.AssetFamilyInclude
|
||||
|
||||
const assetVariantRelationInclude = {
|
||||
assetFamily: { select: { id: true, code: true, name: true, type: true } },
|
||||
export const assetVariantRelationInclude = {
|
||||
assetFamily: { select: { id: true, code: true, name: true, type: true, deletedAt: true } },
|
||||
substrate: { select: { id: true, code: true, name: true } },
|
||||
finish: {
|
||||
select: {
|
||||
@ -175,7 +175,7 @@ class AssetFamilyRepositoryClass extends BaseRepository<
|
||||
if (ids.length === 0) return []
|
||||
|
||||
return this.prisma.assetFamily.findMany({
|
||||
where: { id: { in: ids } },
|
||||
where: { id: { in: ids }, deletedAt: null },
|
||||
include: assetFamilyRelationInclude,
|
||||
orderBy: [{ type: 'asc' }, { name: 'asc' }],
|
||||
}) as Promise<AssetFamilyWithRelations[]>
|
||||
@ -226,10 +226,24 @@ class AssetFamilyRepositoryClass extends BaseRepository<
|
||||
await this.prisma.assetFamily.delete({ where: { id } })
|
||||
}
|
||||
|
||||
async restoreById(id: string): Promise<AssetFamilyWithRelations> {
|
||||
return this.prisma.assetFamily.update({
|
||||
where: { id },
|
||||
data: {
|
||||
deletedAt: null,
|
||||
deleter: { disconnect: true },
|
||||
},
|
||||
include: assetFamilyRelationInclude,
|
||||
}) as Promise<AssetFamilyWithRelations>
|
||||
}
|
||||
|
||||
protected buildWhereClause(filter?: AssetFamilyFilter): Prisma.AssetFamilyWhereInput {
|
||||
if (!filter) return {}
|
||||
if (!filter) {
|
||||
return { deletedAt: null }
|
||||
}
|
||||
|
||||
return {
|
||||
deletedAt: null,
|
||||
type: filter.type,
|
||||
status: filter.status,
|
||||
productCategoryId: filter.productCategoryId,
|
||||
@ -282,6 +296,8 @@ class AssetVariantRepositoryClass extends BaseRepository<
|
||||
return this.prisma.assetVariant.findMany({
|
||||
where: {
|
||||
id: { in: ids },
|
||||
deletedAt: null,
|
||||
assetFamily: { is: { deletedAt: null } },
|
||||
},
|
||||
orderBy: [{ assetFamilyId: 'asc' }, { updatedAt: 'desc' }],
|
||||
include: assetVariantRelationInclude,
|
||||
@ -298,6 +314,8 @@ class AssetVariantRepositoryClass extends BaseRepository<
|
||||
where: {
|
||||
assetFamilyId: { in: assetFamilyIds },
|
||||
status: filter?.status,
|
||||
deletedAt: null,
|
||||
assetFamily: { is: { deletedAt: null } },
|
||||
},
|
||||
orderBy: [{ assetFamilyId: 'asc' }, { updatedAt: 'desc' }],
|
||||
include: assetVariantRelationInclude,
|
||||
@ -311,6 +329,8 @@ class AssetVariantRepositoryClass extends BaseRepository<
|
||||
where: {
|
||||
assetFamilyId: { in: assetFamilyIds },
|
||||
status: 'AVAILABLE',
|
||||
deletedAt: null,
|
||||
assetFamily: { is: { deletedAt: null } },
|
||||
},
|
||||
orderBy: [{ assetFamilyId: 'asc' }, { updatedAt: 'desc' }],
|
||||
include: assetVariantRelationInclude,
|
||||
@ -339,14 +359,32 @@ class AssetVariantRepositoryClass extends BaseRepository<
|
||||
await this.prisma.assetVariant.delete({ where: { id } })
|
||||
}
|
||||
|
||||
async restoreById(id: string): Promise<AssetVariantWithRelations> {
|
||||
return this.prisma.assetVariant.update({
|
||||
where: { id },
|
||||
data: {
|
||||
deletedAt: null,
|
||||
deleter: { disconnect: true },
|
||||
},
|
||||
include: assetVariantRelationInclude,
|
||||
}) as Promise<AssetVariantWithRelations>
|
||||
}
|
||||
|
||||
protected buildWhereClause(filter?: AssetVariantFilter): Prisma.AssetVariantWhereInput {
|
||||
if (!filter) return {}
|
||||
if (!filter) {
|
||||
return {
|
||||
deletedAt: null,
|
||||
assetFamily: { is: { deletedAt: null } },
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
assetFamilyId: filter.assetFamilyId,
|
||||
status: filter.status,
|
||||
substrateId: filter.substrateId,
|
||||
finishId: filter.finishId,
|
||||
deletedAt: null,
|
||||
assetFamily: { is: { deletedAt: null } },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -112,7 +112,9 @@ export type {
|
||||
} from './planning.repository'
|
||||
|
||||
export {
|
||||
assetFamilyRelationInclude,
|
||||
assetFamilyRepository,
|
||||
assetVariantRelationInclude,
|
||||
assetVariantRepository,
|
||||
substrateRelationInclude,
|
||||
substrateRepository,
|
||||
|
||||
@ -184,6 +184,13 @@ describe('assetCenterService', () => {
|
||||
})
|
||||
|
||||
it('创建规格款时会生成 identityKey 并保留继承来源', async () => {
|
||||
mockedAssetVariantRepository.findByIdWithRelations.mockResolvedValue({
|
||||
id: 'variant-board-top-template',
|
||||
assemblyBasis: null,
|
||||
compatibilityRules: null,
|
||||
namingFields: null,
|
||||
businessData: null,
|
||||
} as never)
|
||||
mockedAssetVariantRepository.createWithRelations.mockResolvedValue({
|
||||
id: 'variant-board-top-wh-1200',
|
||||
assetFamilyId: 'family-board-top',
|
||||
@ -255,6 +262,180 @@ describe('assetCenterService', () => {
|
||||
expect(result.lossFactor).toBe(1)
|
||||
})
|
||||
|
||||
it('以规格款为模板新建时会继承 CAD 孔位和装配语义,但材质仍按新规格款保存', async () => {
|
||||
mockedAssetVariantRepository.findByIdWithRelations.mockResolvedValue({
|
||||
id: 'variant-board-top-template',
|
||||
assemblyBasis: {
|
||||
holes: [
|
||||
{
|
||||
id: 'hole-template-1',
|
||||
panelId: 'panel-a',
|
||||
position: [10, 20, 0],
|
||||
diameter: 8,
|
||||
depth: 12,
|
||||
},
|
||||
],
|
||||
holeGroups: [{ id: 'group-template-1', panelId: 'panel-a' }],
|
||||
},
|
||||
compatibilityRules: { connectorRoles: ['desktop'] },
|
||||
namingFields: { panelType: 'top_panel' },
|
||||
businessData: {
|
||||
cadData: {
|
||||
panels: [{ id: 'panel-a', name: '桌面板', gltfNodeName: 'panel-shell' }],
|
||||
holes: [
|
||||
{
|
||||
id: 'hole-template-1',
|
||||
panelId: 'panel-a',
|
||||
position: [10, 20, 0],
|
||||
diameter: 8,
|
||||
depth: 12,
|
||||
},
|
||||
],
|
||||
},
|
||||
renderConfig: {
|
||||
uv: {
|
||||
version: 1,
|
||||
nodes: {
|
||||
'panel-shell': {
|
||||
textureProjectionAxis: 'z',
|
||||
axes: {
|
||||
z: {
|
||||
textureRepeat: 2,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never)
|
||||
mockedAssetVariantRepository.createWithRelations.mockResolvedValue({
|
||||
id: 'variant-board-top-wh-1200-copy',
|
||||
assetFamilyId: 'family-board-top',
|
||||
code: 'BOARD-TOP-WH-1200-COPY',
|
||||
name: '桌面板 1200 胡桃',
|
||||
identityKey:
|
||||
'BOARD|code=BOARD-TOP-WH-1200-COPY|length=1200|thickness=25|width=600|substrate=substrate-particle-board',
|
||||
substrateId: 'substrate-particle-board',
|
||||
finishId: 'finish-white-oak',
|
||||
inheritedFromVariantId: 'variant-board-top-template',
|
||||
status: 'DRAFT',
|
||||
dimensions: { length: 1200, width: 600, thickness: 25 },
|
||||
modelUrl: '/models/board-top-1200.glb',
|
||||
mountPoints: [{ name: 'origin', position: [0, 0, 0] }],
|
||||
assemblyBasis: {
|
||||
holes: [
|
||||
{
|
||||
id: 'hole-template-1',
|
||||
panelId: 'panel-a',
|
||||
position: [10, 20, 0],
|
||||
diameter: 8,
|
||||
depth: 12,
|
||||
},
|
||||
],
|
||||
holeGroups: [{ id: 'group-template-1', panelId: 'panel-a' }],
|
||||
},
|
||||
businessData: {
|
||||
cadData: {
|
||||
panels: [{ id: 'panel-a', name: '桌面板', gltfNodeName: 'panel-shell' }],
|
||||
holes: [
|
||||
{
|
||||
id: 'hole-template-1',
|
||||
panelId: 'panel-a',
|
||||
position: [10, 20, 0],
|
||||
diameter: 8,
|
||||
depth: 12,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
lossFactor: 1,
|
||||
estimatedCostPrice: 21.79,
|
||||
assetFamily: {
|
||||
id: 'family-board-top',
|
||||
code: 'BOARD-TOP',
|
||||
name: '桌面板资产族',
|
||||
type: 'BOARD',
|
||||
},
|
||||
substrate: {
|
||||
id: 'substrate-particle-board',
|
||||
code: 'PB18',
|
||||
name: '颗粒板',
|
||||
},
|
||||
finish: {
|
||||
id: 'finish-white-oak',
|
||||
name: '白橡',
|
||||
},
|
||||
createdAt: new Date('2026-03-30T12:00:00.000Z'),
|
||||
updatedAt: new Date('2026-03-30T12:00:00.000Z'),
|
||||
} as never)
|
||||
|
||||
await assetCenterService.createVariant({
|
||||
assetFamilyId: 'family-board-top',
|
||||
code: 'BOARD-TOP-WH-1200-COPY',
|
||||
name: '桌面板 1200 胡桃',
|
||||
status: 'DRAFT',
|
||||
dimensions: {
|
||||
length: 1200,
|
||||
width: 600,
|
||||
thickness: 25,
|
||||
},
|
||||
substrateId: 'substrate-particle-board',
|
||||
finishId: 'finish-white-oak',
|
||||
modelUrl: '/models/board-top-1200.glb',
|
||||
mountPoints: [{ name: 'origin', position: [0, 0, 0] }],
|
||||
inheritedFromVariantId: 'variant-board-top-template',
|
||||
uvConfig: {
|
||||
version: 1,
|
||||
nodes: {
|
||||
'panel-shell': {
|
||||
textureProjectionAxis: 'x',
|
||||
axes: {
|
||||
x: {
|
||||
textureRepeat: 4,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
lossFactor: 1,
|
||||
})
|
||||
|
||||
expect(mockedAssetVariantRepository.createWithRelations).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
assemblyBasis: expect.objectContaining({
|
||||
holes: expect.arrayContaining([
|
||||
expect.objectContaining({ id: 'hole-template-1', panelId: 'panel-a' }),
|
||||
]),
|
||||
}),
|
||||
compatibilityRules: expect.objectContaining({ connectorRoles: ['desktop'] }),
|
||||
namingFields: expect.objectContaining({ panelType: 'top_panel' }),
|
||||
businessData: expect.objectContaining({
|
||||
cadData: expect.objectContaining({
|
||||
panels: expect.arrayContaining([
|
||||
expect.objectContaining({ id: 'panel-a', gltfNodeName: 'panel-shell' }),
|
||||
]),
|
||||
}),
|
||||
renderConfig: {
|
||||
uv: {
|
||||
version: 1,
|
||||
nodes: {
|
||||
'panel-shell': {
|
||||
textureProjectionAxis: 'x',
|
||||
axes: {
|
||||
x: {
|
||||
textureRepeat: 4,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('板材规格款同尺寸同基材但编码不同,不应误判为重复款', async () => {
|
||||
mockedAssetVariantRepository.createWithRelations.mockResolvedValue({
|
||||
id: 'variant-board-top-wh-1200-arc',
|
||||
@ -1192,6 +1373,119 @@ describe('assetCenterService', () => {
|
||||
expect(result.similarityNotice).toContain('外轮廓指纹还没接入')
|
||||
})
|
||||
|
||||
it('以规格款为来源另存时,相似候选会排除来源规格款', async () => {
|
||||
const createBoardVariantRecord = (overrides: Record<string, unknown> = {}) => ({
|
||||
id: 'variant-board-top-source',
|
||||
assetFamilyId: 'family-board-top',
|
||||
code: 'BOARD-TOP-SOURCE',
|
||||
name: '桌面板来源款',
|
||||
identityKey:
|
||||
'BOARD|code=BOARD-TOP-SOURCE|length=1200|thickness=25|width=600|substrate=substrate-particle-board',
|
||||
assetType: 'BOARD',
|
||||
status: 'AVAILABLE',
|
||||
sizeLabel: '1200x600x25',
|
||||
substrateId: 'substrate-particle-board',
|
||||
finishId: 'finish-white-oak',
|
||||
dimensions: { length: 1200, width: 600, thickness: 25 },
|
||||
modelUrl: '/models/board-top-source.glb',
|
||||
thumbnailUrl: null,
|
||||
mountPoints: [{ name: 'origin', position: [0, 0, 0] }],
|
||||
namingFields: { panelType: 'top_panel' },
|
||||
assemblyBasis: {
|
||||
holes: [
|
||||
{ id: 'hole-1', diameter: 8, type: 'blind' },
|
||||
{ id: 'hole-2', diameter: 8, type: 'blind' },
|
||||
],
|
||||
holeGroups: [{ id: 'group-1', groupType: 'pair', groupRole: 'mount' }],
|
||||
},
|
||||
compatibilityRules: null,
|
||||
businessData: null,
|
||||
weightKg: null,
|
||||
costPrice: null,
|
||||
lossFactor: 1,
|
||||
estimatedCostPrice: 21.79,
|
||||
inheritedFromVariantId: null,
|
||||
createdBy: 'user-1',
|
||||
creator: { id: 'user-1', name: '测试用户' },
|
||||
assetFamily: {
|
||||
id: 'family-board-top',
|
||||
code: 'BOARD-TOP',
|
||||
name: '桌面板资产族',
|
||||
type: 'BOARD',
|
||||
},
|
||||
substrate: {
|
||||
id: 'substrate-particle-board',
|
||||
code: 'PB18',
|
||||
name: '颗粒板',
|
||||
},
|
||||
finish: {
|
||||
id: 'finish-white-oak',
|
||||
name: '白橡',
|
||||
},
|
||||
createdAt: new Date('2026-04-01T10:00:00.000Z'),
|
||||
updatedAt: new Date('2026-04-08T10:00:00.000Z'),
|
||||
...overrides,
|
||||
})
|
||||
const sourceVariant = createBoardVariantRecord()
|
||||
const siblingVariant = createBoardVariantRecord({
|
||||
id: 'variant-board-top-sibling',
|
||||
code: 'BOARD-TOP-SIBLING',
|
||||
name: '桌面板相似款',
|
||||
identityKey:
|
||||
'BOARD|code=BOARD-TOP-SIBLING|length=1200|thickness=25|width=600|substrate=substrate-particle-board',
|
||||
inheritedFromVariantId: null,
|
||||
})
|
||||
|
||||
mockedAssetFamilyRepository.findManyWithRelations.mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
id: 'family-board-top',
|
||||
type: 'BOARD',
|
||||
code: 'BOARD-TOP',
|
||||
name: '桌面板资产族',
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
} as never)
|
||||
mockedAssetVariantRepository.findByIdWithRelations.mockResolvedValue(sourceVariant as never)
|
||||
mockedAssetVariantRepository.createWithRelations.mockResolvedValue(
|
||||
createBoardVariantRecord({
|
||||
id: 'variant-board-top-copy',
|
||||
code: 'BOARD-TOP-COPY',
|
||||
name: '桌面板另存款',
|
||||
identityKey:
|
||||
'BOARD|code=BOARD-TOP-COPY|length=1200|thickness=25|width=600|substrate=substrate-particle-board',
|
||||
inheritedFromVariantId: 'variant-board-top-source',
|
||||
}) as never,
|
||||
)
|
||||
mockedAssetVariantRepository.findManyByFamilyIds.mockResolvedValue([
|
||||
sourceVariant,
|
||||
siblingVariant,
|
||||
] as never)
|
||||
|
||||
const result = await assetCenterService.createVariantWithSimilarity({
|
||||
assetFamilyId: 'family-board-top',
|
||||
code: 'BOARD-TOP-COPY',
|
||||
name: '桌面板另存款',
|
||||
status: 'DRAFT',
|
||||
dimensions: {
|
||||
length: 1200,
|
||||
width: 600,
|
||||
thickness: 25,
|
||||
},
|
||||
substrateId: 'substrate-particle-board',
|
||||
finishId: 'finish-white-oak',
|
||||
modelUrl: '/models/board-top-source.glb',
|
||||
mountPoints: [{ name: 'origin', position: [0, 0, 0] }],
|
||||
inheritedFromVariantId: 'variant-board-top-source',
|
||||
})
|
||||
|
||||
expect(result.similarCandidates.map((candidate) => candidate.variant.id)).toEqual([
|
||||
'variant-board-top-sibling',
|
||||
])
|
||||
expect(result.similarCandidates[0]?.variant.id).not.toBe('variant-board-top-source')
|
||||
})
|
||||
|
||||
it('组件规格款的身份判重应按编码,不应因尺寸相同被误判重复', async () => {
|
||||
mockedAssetFamilyRepository.findById.mockResolvedValue({
|
||||
id: 'family-component-leg',
|
||||
|
||||
@ -0,0 +1,116 @@
|
||||
import { trashService } from '../trash.service'
|
||||
import { productService } from '@/server/services/product.service'
|
||||
import { skuService } from '@/server/services/sku.service'
|
||||
import { assetCenterService } from '@/server/services/asset-center.service'
|
||||
import type { AuthUser } from '@/server/core/types'
|
||||
|
||||
jest.mock('@/server/services/product.service', () => ({
|
||||
productService: {
|
||||
getDeletedProducts: jest.fn(),
|
||||
cleanupExpiredProducts: jest.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
jest.mock('@/server/services/sku.service', () => ({
|
||||
skuService: {
|
||||
getDeletedSKUs: jest.fn(),
|
||||
cleanupExpiredSKUs: jest.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
jest.mock('@/server/services/asset-center.service', () => ({
|
||||
assetCenterService: {
|
||||
getDeletedAssets: jest.fn(),
|
||||
cleanupExpiredDeletedAssets: jest.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
describe('trashService', () => {
|
||||
const mockedProductService = jest.mocked(productService)
|
||||
const mockedSkuService = jest.mocked(skuService)
|
||||
const mockedAssetCenterService = jest.mocked(assetCenterService)
|
||||
const user: AuthUser = {
|
||||
id: 'user-1',
|
||||
email: 'user@example.com',
|
||||
name: '测试用户',
|
||||
role: 'ADMIN',
|
||||
status: 'ACTIVE',
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers()
|
||||
jest.setSystemTime(new Date('2026-05-13T00:00:00.000Z'))
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers()
|
||||
})
|
||||
|
||||
it('回收站数据应优先聚合资产中心资产族和规格款', async () => {
|
||||
mockedProductService.getDeletedProducts.mockResolvedValue([] as never)
|
||||
mockedSkuService.getDeletedSKUs.mockResolvedValue([] as never)
|
||||
mockedAssetCenterService.getDeletedAssets.mockResolvedValue({
|
||||
families: [
|
||||
{
|
||||
type: 'assetFamily',
|
||||
id: 'family-1',
|
||||
assetType: 'BOARD',
|
||||
code: 'ZMB005',
|
||||
name: '单板桌桌面05',
|
||||
status: 'DRAFT',
|
||||
variantCount: 2,
|
||||
createdBy: 'user-1',
|
||||
creatorName: '测试用户',
|
||||
deletedAt: '2026-05-12T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
variants: [
|
||||
{
|
||||
type: 'assetVariant',
|
||||
id: 'variant-1',
|
||||
assetFamilyId: 'family-1',
|
||||
assetFamilyCode: 'ZMB005',
|
||||
assetFamilyName: '单板桌桌面05',
|
||||
assetType: 'BOARD',
|
||||
code: 'ZMB005-桌面版-1200-北美原木纹',
|
||||
name: '桌面版-1200-北美原木纹',
|
||||
status: 'DRAFT',
|
||||
sizeLabel: '1200',
|
||||
substrateName: '颗粒板',
|
||||
finishName: '北美原木纹色',
|
||||
dimensions: { length: 1200, width: 600, thickness: 25 },
|
||||
thumbnailUrl: null,
|
||||
modelUrl: '/models/zmb005.glb',
|
||||
createdBy: 'user-1',
|
||||
creatorName: '测试用户',
|
||||
deletedAt: '2026-05-12T12:00:00.000Z',
|
||||
},
|
||||
],
|
||||
} as never)
|
||||
|
||||
const result = await trashService.getTrashData({ user })
|
||||
|
||||
expect(result.assetFamilies).toHaveLength(1)
|
||||
expect(result.assetVariants).toHaveLength(1)
|
||||
expect(result.assetVariants[0].code).toBe('ZMB005-桌面版-1200-北美原木纹')
|
||||
expect(result.summary.totalAssetFamilies).toBe(1)
|
||||
expect(result.summary.totalAssetVariants).toBe(1)
|
||||
expect(mockedAssetCenterService.getDeletedAssets).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('清理过期回收站时应包含资产中心软删除数据', async () => {
|
||||
mockedProductService.cleanupExpiredProducts.mockResolvedValue({ deletedCount: 0, errors: [] } as never)
|
||||
mockedSkuService.cleanupExpiredSKUs.mockResolvedValue({ deletedCount: 0, errors: [] } as never)
|
||||
mockedAssetCenterService.cleanupExpiredDeletedAssets.mockResolvedValue({
|
||||
deletedFamilyCount: 1,
|
||||
deletedVariantCount: 3,
|
||||
} as never)
|
||||
|
||||
const result = await trashService.cleanupExpired({ user })
|
||||
|
||||
expect(result.cleanedAssetFamilies).toBe(1)
|
||||
expect(result.cleanedAssetVariants).toBe(3)
|
||||
expect(mockedAssetCenterService.cleanupExpiredDeletedAssets).toHaveBeenCalledWith(7)
|
||||
})
|
||||
})
|
||||
@ -42,7 +42,9 @@ import { AppError, ErrorCode } from '@/server/core/errors'
|
||||
import type { AuthUser } from '@/server/core/types'
|
||||
import {
|
||||
assetFamilyRepository,
|
||||
assetFamilyRelationInclude,
|
||||
assetVariantRepository,
|
||||
assetVariantRelationInclude,
|
||||
categoryRepository,
|
||||
materialRepository,
|
||||
substrateRelationInclude,
|
||||
@ -56,12 +58,16 @@ import {
|
||||
} from '../repositories'
|
||||
import type {
|
||||
AssetFamilyDTO,
|
||||
AssetTrashCleanupResultDTO,
|
||||
AssetTrashDataDTO,
|
||||
AssetReviewActionInput,
|
||||
AssetVariantDTO,
|
||||
AssetWorkflowAuditDTO,
|
||||
CreateAssetVariantResultDTO,
|
||||
CreateAssetFamilyInput,
|
||||
CreateAssetVariantInput,
|
||||
DeletedAssetFamilyDTO,
|
||||
DeletedAssetVariantDTO,
|
||||
MaterialLikeDTO,
|
||||
SimilarAssetVariantCandidateDTO,
|
||||
CreateSubstrateDictionaryInput,
|
||||
@ -117,6 +123,23 @@ function normalizeBusinessDataRecord(
|
||||
return isRecord(value) ? { ...value } : null
|
||||
}
|
||||
|
||||
function normalizeJsonRecord(
|
||||
value: unknown,
|
||||
): Record<string, unknown> | null {
|
||||
return isRecord(value) ? { ...value } : null
|
||||
}
|
||||
|
||||
function resolveInheritedJsonRecord(
|
||||
explicitValue: Record<string, unknown> | null | undefined,
|
||||
inheritedValue: unknown,
|
||||
): Record<string, unknown> | null | undefined {
|
||||
if (explicitValue !== undefined) {
|
||||
return explicitValue
|
||||
}
|
||||
|
||||
return normalizeJsonRecord(inheritedValue) ?? undefined
|
||||
}
|
||||
|
||||
function areVariantUvConfigsEqual(
|
||||
left: AssetVariantUvConfig | null | undefined,
|
||||
right: AssetVariantUvConfig | null | undefined,
|
||||
@ -129,13 +152,32 @@ function areVariantUvConfigsEqual(
|
||||
function resolveBusinessDataForVariantCreate(input: {
|
||||
businessData?: Record<string, unknown> | null
|
||||
uvConfig?: AssetVariantUvConfig | null
|
||||
inheritedBusinessData?: Record<string, unknown> | null
|
||||
}): Record<string, unknown> | null | undefined {
|
||||
if (input.businessData === undefined && input.uvConfig === undefined) {
|
||||
const hasInheritedBusinessData = input.inheritedBusinessData !== undefined && input.inheritedBusinessData !== null
|
||||
if (
|
||||
input.businessData === undefined &&
|
||||
input.uvConfig === undefined &&
|
||||
!hasInheritedBusinessData
|
||||
) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const inheritedBase = normalizeBusinessDataRecord(input.inheritedBusinessData)
|
||||
const explicitBase =
|
||||
input.businessData !== undefined
|
||||
? normalizeBusinessDataRecord(input.businessData)
|
||||
: undefined
|
||||
const base =
|
||||
explicitBase !== undefined
|
||||
? {
|
||||
...(inheritedBase ?? {}),
|
||||
...(explicitBase ?? {}),
|
||||
}
|
||||
: inheritedBase
|
||||
|
||||
return writeAssetVariantUvConfig(
|
||||
normalizeBusinessDataRecord(input.businessData),
|
||||
base,
|
||||
input.uvConfig,
|
||||
)
|
||||
}
|
||||
@ -431,6 +473,54 @@ function toAssetVariantDTO(record: AssetVariantWithRelations): AssetVariantDTO {
|
||||
}
|
||||
}
|
||||
|
||||
function toDeletedAssetFamilyDTO(
|
||||
record: AssetFamilyWithRelations & { _count?: { variants?: number } },
|
||||
): DeletedAssetFamilyDTO {
|
||||
if (!record.deletedAt) {
|
||||
throw new AppError(ErrorCode.VALIDATION_FAILED, '资产族未在回收站中')
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'assetFamily',
|
||||
id: record.id,
|
||||
assetType: record.type,
|
||||
code: record.code,
|
||||
name: record.name,
|
||||
status: record.status,
|
||||
variantCount: record._count?.variants ?? 0,
|
||||
createdBy: record.createdBy,
|
||||
creatorName: record.creator?.name ?? null,
|
||||
deletedAt: record.deletedAt.toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
function toDeletedAssetVariantDTO(record: AssetVariantWithRelations): DeletedAssetVariantDTO {
|
||||
if (!record.deletedAt) {
|
||||
throw new AppError(ErrorCode.VALIDATION_FAILED, '规格款未在回收站中')
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'assetVariant',
|
||||
id: record.id,
|
||||
assetFamilyId: record.assetFamilyId,
|
||||
assetFamilyCode: record.assetFamily.code,
|
||||
assetFamilyName: record.assetFamily.name,
|
||||
assetType: record.assetType,
|
||||
code: record.code,
|
||||
name: record.name,
|
||||
status: record.status,
|
||||
sizeLabel: record.sizeLabel,
|
||||
substrateName: record.substrate?.name ?? null,
|
||||
finishName: record.finish?.name ?? null,
|
||||
dimensions: isRecord(record.dimensions) ? (record.dimensions as Record<string, number>) : {},
|
||||
thumbnailUrl: record.thumbnailUrl,
|
||||
modelUrl: record.modelUrl,
|
||||
createdBy: record.createdBy,
|
||||
creatorName: record.creator?.name ?? null,
|
||||
deletedAt: record.deletedAt.toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
function toAssetSimilaritySource(variant: AssetVariantDTO): AssetSimilaritySource {
|
||||
return {
|
||||
id: variant.id,
|
||||
@ -1344,6 +1434,15 @@ class AssetCenterService {
|
||||
)
|
||||
}
|
||||
|
||||
const inheritedVariant = input.inheritedFromVariantId
|
||||
? await assetVariantRepository.findByIdWithRelations(input.inheritedFromVariantId)
|
||||
: null
|
||||
if (input.inheritedFromVariantId && !inheritedVariant) {
|
||||
throw new AppError(ErrorCode.ASSET_VARIANT_NOT_FOUND, '继承来源规格款不存在')
|
||||
}
|
||||
|
||||
const inheritedBusinessData = normalizeJsonRecord(inheritedVariant?.businessData)
|
||||
|
||||
const resolvedLossFactor = input.lossFactor ?? null
|
||||
const resolvedEstimatedCostPrice =
|
||||
input.estimatedCostPrice ??
|
||||
@ -1355,7 +1454,20 @@ class AssetCenterService {
|
||||
const resolvedBusinessData = resolveBusinessDataForVariantCreate({
|
||||
businessData: input.businessData,
|
||||
uvConfig: input.uvConfig,
|
||||
inheritedBusinessData,
|
||||
})
|
||||
const resolvedAssemblyBasis = resolveInheritedJsonRecord(
|
||||
input.assemblyBasis,
|
||||
inheritedVariant?.assemblyBasis,
|
||||
)
|
||||
const resolvedCompatibilityRules = resolveInheritedJsonRecord(
|
||||
input.compatibilityRules,
|
||||
inheritedVariant?.compatibilityRules,
|
||||
)
|
||||
const resolvedNamingFields = resolveInheritedJsonRecord(
|
||||
input.namingFields,
|
||||
inheritedVariant?.namingFields,
|
||||
)
|
||||
|
||||
const created = await assetVariantRepository.createWithRelations({
|
||||
assetFamily: { connect: { id: input.assetFamilyId } },
|
||||
@ -1371,9 +1483,9 @@ class AssetCenterService {
|
||||
modelUrl: input.modelUrl.trim(),
|
||||
thumbnailUrl: trimOrNull(input.thumbnailUrl),
|
||||
mountPoints: input.mountPoints as unknown as Prisma.InputJsonValue,
|
||||
assemblyBasis: toJsonValue(input.assemblyBasis),
|
||||
compatibilityRules: toJsonValue(input.compatibilityRules),
|
||||
namingFields: toJsonValue(input.namingFields),
|
||||
assemblyBasis: toJsonValue(resolvedAssemblyBasis),
|
||||
compatibilityRules: toJsonValue(resolvedCompatibilityRules),
|
||||
namingFields: toJsonValue(resolvedNamingFields),
|
||||
businessData: toJsonValue(resolvedBusinessData),
|
||||
weightKg: input.weightKg ?? null,
|
||||
costPrice: input.costPrice ?? null,
|
||||
@ -1412,9 +1524,14 @@ class AssetCenterService {
|
||||
}
|
||||
|
||||
const boardVariants = await assetVariantRepository.findManyByFamilyIds(familyIds)
|
||||
const ignoredCandidateIds = new Set(
|
||||
[variant.id, variant.inheritedFromVariantId].filter(
|
||||
(value): value is string => typeof value === 'string' && value.trim().length > 0,
|
||||
),
|
||||
)
|
||||
const boardVariantDTOs = boardVariants
|
||||
.map((item) => toAssetVariantDTO(item))
|
||||
.filter((candidate) => candidate.id !== variant.id)
|
||||
.filter((candidate) => !ignoredCandidateIds.has(candidate.id))
|
||||
|
||||
if (boardVariantDTOs.length === 0) {
|
||||
return {
|
||||
@ -1688,22 +1805,176 @@ class AssetCenterService {
|
||||
})
|
||||
}
|
||||
|
||||
async deleteFamily(id: string): Promise<void> {
|
||||
async getDeletedAssets(): Promise<AssetTrashDataDTO> {
|
||||
const [families, variants] = await Promise.all([
|
||||
prisma.assetFamily.findMany({
|
||||
where: { deletedAt: { not: null } },
|
||||
orderBy: { deletedAt: 'desc' },
|
||||
include: {
|
||||
...assetFamilyRelationInclude,
|
||||
_count: { select: { variants: true } },
|
||||
},
|
||||
}),
|
||||
prisma.assetVariant.findMany({
|
||||
where: {
|
||||
deletedAt: { not: null },
|
||||
assetFamily: { is: { deletedAt: null } },
|
||||
},
|
||||
orderBy: { deletedAt: 'desc' },
|
||||
include: assetVariantRelationInclude,
|
||||
}),
|
||||
])
|
||||
|
||||
return {
|
||||
families: families.map((item) => toDeletedAssetFamilyDTO(item as unknown as AssetFamilyWithRelations & { _count: { variants: number } })),
|
||||
variants: variants.map((item) => toDeletedAssetVariantDTO(item as AssetVariantWithRelations)),
|
||||
}
|
||||
}
|
||||
|
||||
async deleteFamily(id: string, deletedById?: string | null): Promise<void> {
|
||||
const existing = await assetFamilyRepository.findById(id)
|
||||
if (!existing) {
|
||||
throw new AppError(ErrorCode.ASSET_FAMILY_NOT_FOUND, '资产族不存在')
|
||||
}
|
||||
await assetFamilyRepository.deleteById(id)
|
||||
if (existing.deletedAt) {
|
||||
throw new AppError(ErrorCode.VALIDATION_FAILED, '资产族已在回收站中')
|
||||
}
|
||||
|
||||
const deletedAt = new Date()
|
||||
await prisma.$transaction([
|
||||
prisma.assetFamily.update({
|
||||
where: { id },
|
||||
data: {
|
||||
deletedAt,
|
||||
deletedById: deletedById ?? null,
|
||||
lastModifiedById: deletedById ?? existing.lastModifiedById ?? null,
|
||||
},
|
||||
}),
|
||||
prisma.assetVariant.updateMany({
|
||||
where: {
|
||||
assetFamilyId: id,
|
||||
deletedAt: null,
|
||||
},
|
||||
data: {
|
||||
deletedAt,
|
||||
deletedById: deletedById ?? null,
|
||||
lastModifiedById: deletedById ?? null,
|
||||
},
|
||||
}),
|
||||
])
|
||||
}
|
||||
|
||||
async deleteVariant(id: string): Promise<void> {
|
||||
async deleteVariant(id: string, deletedById?: string | null): Promise<void> {
|
||||
const existing = await assetVariantRepository.findById(id)
|
||||
if (!existing) {
|
||||
throw new AppError(ErrorCode.ASSET_VARIANT_NOT_FOUND, '规格款不存在')
|
||||
}
|
||||
if (existing.deletedAt) {
|
||||
throw new AppError(ErrorCode.VALIDATION_FAILED, '规格款已在回收站中')
|
||||
}
|
||||
|
||||
await assetVariantRepository.update(id, {
|
||||
deletedAt: new Date(),
|
||||
deletedById: deletedById ?? null,
|
||||
lastModifiedById: deletedById ?? existing.lastModifiedById ?? null,
|
||||
} as Prisma.AssetVariantUpdateInput)
|
||||
}
|
||||
|
||||
async restoreFamilyFromTrash(id: string): Promise<AssetFamilyDTO> {
|
||||
const existing = await assetFamilyRepository.findWithRelationsById(id)
|
||||
if (!existing) {
|
||||
throw new AppError(ErrorCode.ASSET_FAMILY_NOT_FOUND, '资产族不存在')
|
||||
}
|
||||
if (!existing.deletedAt) {
|
||||
throw new AppError(ErrorCode.VALIDATION_FAILED, '资产族未在回收站中')
|
||||
}
|
||||
|
||||
const familyDeletedAt = existing.deletedAt
|
||||
const restored = await prisma.$transaction(async (tx) => {
|
||||
await tx.assetVariant.updateMany({
|
||||
where: {
|
||||
assetFamilyId: id,
|
||||
deletedAt: { gte: familyDeletedAt },
|
||||
},
|
||||
data: {
|
||||
deletedAt: null,
|
||||
deletedById: null,
|
||||
},
|
||||
})
|
||||
|
||||
return tx.assetFamily.update({
|
||||
where: { id },
|
||||
data: {
|
||||
deletedAt: null,
|
||||
deletedById: null,
|
||||
},
|
||||
include: assetFamilyRelationInclude,
|
||||
})
|
||||
})
|
||||
|
||||
return toAssetFamilyDTO(restored as AssetFamilyWithRelations)
|
||||
}
|
||||
|
||||
async restoreVariantFromTrash(id: string): Promise<AssetVariantDTO> {
|
||||
const existing = await assetVariantRepository.findByIdWithRelations(id)
|
||||
if (!existing) {
|
||||
throw new AppError(ErrorCode.ASSET_VARIANT_NOT_FOUND, '规格款不存在')
|
||||
}
|
||||
if (!existing.deletedAt) {
|
||||
throw new AppError(ErrorCode.VALIDATION_FAILED, '规格款未在回收站中')
|
||||
}
|
||||
if (existing.assetFamily.deletedAt) {
|
||||
throw new AppError(ErrorCode.VALIDATION_FAILED, '资产族仍在回收站中,请先恢复资产族')
|
||||
}
|
||||
|
||||
const restored = await assetVariantRepository.restoreById(id)
|
||||
return toAssetVariantDTO(restored)
|
||||
}
|
||||
|
||||
async permanentlyDeleteFamilyFromTrash(id: string): Promise<{ deletedVariantCount: number }> {
|
||||
const existing = await assetFamilyRepository.findWithRelationsById(id)
|
||||
if (!existing) {
|
||||
throw new AppError(ErrorCode.ASSET_FAMILY_NOT_FOUND, '资产族不存在')
|
||||
}
|
||||
if (!existing.deletedAt) {
|
||||
throw new AppError(ErrorCode.VALIDATION_FAILED, '资产族未在回收站中')
|
||||
}
|
||||
|
||||
const deletedVariantCount = await prisma.assetVariant.count({
|
||||
where: { assetFamilyId: id },
|
||||
})
|
||||
await assetFamilyRepository.deleteById(id)
|
||||
return { deletedVariantCount }
|
||||
}
|
||||
|
||||
async permanentlyDeleteVariantFromTrash(id: string): Promise<void> {
|
||||
const existing = await assetVariantRepository.findById(id)
|
||||
if (!existing) {
|
||||
throw new AppError(ErrorCode.ASSET_VARIANT_NOT_FOUND, '规格款不存在')
|
||||
}
|
||||
if (!existing.deletedAt) {
|
||||
throw new AppError(ErrorCode.VALIDATION_FAILED, '规格款未在回收站中')
|
||||
}
|
||||
|
||||
await assetVariantRepository.deleteById(id)
|
||||
}
|
||||
|
||||
async cleanupExpiredDeletedAssets(retentionDays: number): Promise<AssetTrashCleanupResultDTO> {
|
||||
const cutoff = new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1000)
|
||||
|
||||
const deletedFamilies = await prisma.assetFamily.deleteMany({
|
||||
where: { deletedAt: { lte: cutoff } },
|
||||
})
|
||||
const deletedVariants = await prisma.assetVariant.deleteMany({
|
||||
where: { deletedAt: { lte: cutoff } },
|
||||
})
|
||||
|
||||
return {
|
||||
deletedFamilyCount: deletedFamilies.count,
|
||||
deletedVariantCount: deletedVariants.count,
|
||||
}
|
||||
}
|
||||
|
||||
async submitFamilyForReview(id: string, user: AuthUser): Promise<AssetFamilyDTO> {
|
||||
const existing = await assetFamilyRepository.findWithRelationsById(id)
|
||||
if (!existing) {
|
||||
|
||||
@ -74,6 +74,50 @@ export interface AssetVariantDTO {
|
||||
updatedAt: Date
|
||||
}
|
||||
|
||||
export interface DeletedAssetFamilyDTO {
|
||||
type: 'assetFamily'
|
||||
id: string
|
||||
assetType: AssetType
|
||||
code: string
|
||||
name: string
|
||||
status: AssetStatus
|
||||
variantCount: number
|
||||
createdBy: string | null
|
||||
creatorName: string | null
|
||||
deletedAt: string
|
||||
}
|
||||
|
||||
export interface DeletedAssetVariantDTO {
|
||||
type: 'assetVariant'
|
||||
id: string
|
||||
assetFamilyId: string
|
||||
assetFamilyCode: string
|
||||
assetFamilyName: string
|
||||
assetType: AssetType
|
||||
code: string
|
||||
name: string
|
||||
status: AssetStatus
|
||||
sizeLabel: string | null
|
||||
substrateName: string | null
|
||||
finishName: string | null
|
||||
dimensions: Record<string, number>
|
||||
thumbnailUrl: string | null
|
||||
modelUrl: string
|
||||
createdBy: string | null
|
||||
creatorName: string | null
|
||||
deletedAt: string
|
||||
}
|
||||
|
||||
export interface AssetTrashDataDTO {
|
||||
families: DeletedAssetFamilyDTO[]
|
||||
variants: DeletedAssetVariantDTO[]
|
||||
}
|
||||
|
||||
export interface AssetTrashCleanupResultDTO {
|
||||
deletedFamilyCount: number
|
||||
deletedVariantCount: number
|
||||
}
|
||||
|
||||
export interface SimilarAssetVariantCandidateDTO {
|
||||
variant: AssetVariantDTO
|
||||
score: number
|
||||
|
||||
@ -8,8 +8,10 @@
|
||||
|
||||
import { productService } from '@/server/services/product.service'
|
||||
import { skuService } from '@/server/services/sku.service'
|
||||
import { assetCenterService } from '@/server/services/asset-center.service'
|
||||
import type { DeletedProductDTO } from '../dto/product.dto'
|
||||
import type { DeletedSKUDTO } from '../dto/sku.dto'
|
||||
import type { DeletedAssetFamilyDTO, DeletedAssetVariantDTO } from './asset-center.service.types'
|
||||
import type { AuthUser } from '../core/types'
|
||||
|
||||
// ============ 类型定义 ============
|
||||
@ -28,9 +30,13 @@ const TRASH_RETENTION_DAYS = 7
|
||||
export interface TrashData {
|
||||
products: TrashProductItem[]
|
||||
skus: TrashSKUItem[]
|
||||
assetFamilies: TrashAssetFamilyItem[]
|
||||
assetVariants: TrashAssetVariantItem[]
|
||||
summary: {
|
||||
totalProducts: number
|
||||
totalSKUs: number
|
||||
totalAssetFamilies: number
|
||||
totalAssetVariants: number
|
||||
retentionDays: number
|
||||
}
|
||||
}
|
||||
@ -53,12 +59,24 @@ export interface TrashSKUItem extends DeletedSKUDTO {
|
||||
daysRemaining: number
|
||||
}
|
||||
|
||||
export interface TrashAssetFamilyItem extends DeletedAssetFamilyDTO {
|
||||
expiresAt: string
|
||||
daysRemaining: number
|
||||
}
|
||||
|
||||
export interface TrashAssetVariantItem extends DeletedAssetVariantDTO {
|
||||
expiresAt: string
|
||||
daysRemaining: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理结果
|
||||
*/
|
||||
export interface CleanupResult {
|
||||
cleanedProducts: number
|
||||
cleanedSKUs: number
|
||||
cleanedAssetFamilies: number
|
||||
cleanedAssetVariants: number
|
||||
errors: string[]
|
||||
}
|
||||
|
||||
@ -69,10 +87,11 @@ class TrashServiceClass {
|
||||
* 获取回收站数据
|
||||
*/
|
||||
async getTrashData(ctx: ServiceContext): Promise<TrashData> {
|
||||
// 并行获取已删除的产品和 SKU
|
||||
const [deletedProducts, deletedSKUs] = await Promise.all([
|
||||
// 并行获取已删除的资产中心项目、产品和 SKU
|
||||
const [deletedProducts, deletedSKUs, deletedAssets] = await Promise.all([
|
||||
productService.getDeletedProducts(ctx),
|
||||
skuService.getDeletedSKUs(ctx),
|
||||
assetCenterService.getDeletedAssets(),
|
||||
])
|
||||
|
||||
const now = new Date()
|
||||
@ -105,12 +124,40 @@ class TrashServiceClass {
|
||||
}
|
||||
})
|
||||
|
||||
const assetFamilies: TrashAssetFamilyItem[] = deletedAssets.families.map(family => {
|
||||
const deletedAt = new Date(family.deletedAt)
|
||||
const expiresAt = new Date(deletedAt.getTime() + TRASH_RETENTION_DAYS * 24 * 60 * 60 * 1000)
|
||||
const daysRemaining = Math.max(0, Math.ceil((expiresAt.getTime() - now.getTime()) / (24 * 60 * 60 * 1000)))
|
||||
|
||||
return {
|
||||
...family,
|
||||
expiresAt: expiresAt.toISOString(),
|
||||
daysRemaining,
|
||||
}
|
||||
})
|
||||
|
||||
const assetVariants: TrashAssetVariantItem[] = deletedAssets.variants.map(variant => {
|
||||
const deletedAt = new Date(variant.deletedAt)
|
||||
const expiresAt = new Date(deletedAt.getTime() + TRASH_RETENTION_DAYS * 24 * 60 * 60 * 1000)
|
||||
const daysRemaining = Math.max(0, Math.ceil((expiresAt.getTime() - now.getTime()) / (24 * 60 * 60 * 1000)))
|
||||
|
||||
return {
|
||||
...variant,
|
||||
expiresAt: expiresAt.toISOString(),
|
||||
daysRemaining,
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
products,
|
||||
skus,
|
||||
assetFamilies,
|
||||
assetVariants,
|
||||
summary: {
|
||||
totalProducts: products.length,
|
||||
totalSKUs: skus.length,
|
||||
totalAssetFamilies: assetFamilies.length,
|
||||
totalAssetVariants: assetVariants.length,
|
||||
retentionDays: TRASH_RETENTION_DAYS,
|
||||
},
|
||||
}
|
||||
@ -120,15 +167,18 @@ class TrashServiceClass {
|
||||
* 清理过期的回收站项目
|
||||
*/
|
||||
async cleanupExpired(ctx: ServiceContext): Promise<CleanupResult> {
|
||||
// 并行清理过期的产品和 SKU
|
||||
const [productResult, skuResult] = await Promise.all([
|
||||
// 并行清理过期的资产中心项目、产品和 SKU
|
||||
const [productResult, skuResult, assetResult] = await Promise.all([
|
||||
productService.cleanupExpiredProducts(TRASH_RETENTION_DAYS, ctx),
|
||||
skuService.cleanupExpiredSKUs(TRASH_RETENTION_DAYS, ctx),
|
||||
assetCenterService.cleanupExpiredDeletedAssets(TRASH_RETENTION_DAYS),
|
||||
])
|
||||
|
||||
return {
|
||||
cleanedProducts: productResult.deletedCount,
|
||||
cleanedSKUs: skuResult.deletedCount,
|
||||
cleanedAssetFamilies: assetResult.deletedFamilyCount,
|
||||
cleanedAssetVariants: assetResult.deletedVariantCount,
|
||||
errors: [...productResult.errors, ...skuResult.errors],
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user