feat: 接入资产中心回收站与规格款渲染配置

This commit is contained in:
WuYongJie 2026-05-13 21:54:01 +08:00
parent 286347582c
commit 2e954f3219
32 changed files with 3916 additions and 1071 deletions

View File

@ -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;

View File

@ -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")

View File

@ -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) {

View File

@ -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) {

View File

@ -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(),

View File

@ -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)
})
}

View File

@ -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)
})
}

View File

@ -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

View File

@ -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>
)

View File

@ -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() {

View File

@ -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,

View File

@ -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 ||

View File

@ -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 */}

View File

@ -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)

View File

@ -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}

View File

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

View File

@ -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')

View File

@ -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);
});
});

View File

@ -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

View File

@ -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;

View File

@ -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)
}

View 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,
},
},
},
},
});
});
});

View File

@ -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,
}
}

View File

@ -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

View File

@ -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 } },
}
}
}

View File

@ -112,7 +112,9 @@ export type {
} from './planning.repository'
export {
assetFamilyRelationInclude,
assetFamilyRepository,
assetVariantRelationInclude,
assetVariantRepository,
substrateRelationInclude,
substrateRepository,

View File

@ -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',

View File

@ -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)
})
})

View File

@ -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) {

View File

@ -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

View File

@ -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],
}
}