diff --git a/furniture-3d-system/prisma/migrations/20260513113000_add_asset_center_trash/migration.sql b/furniture-3d-system/prisma/migrations/20260513113000_add_asset_center_trash/migration.sql new file mode 100644 index 00000000..b4325fc0 --- /dev/null +++ b/furniture-3d-system/prisma/migrations/20260513113000_add_asset_center_trash/migration.sql @@ -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; diff --git a/furniture-3d-system/prisma/schema.prisma b/furniture-3d-system/prisma/schema.prisma index a1d4d47b..abd5c956 100644 --- a/furniture-3d-system/prisma/schema.prisma +++ b/furniture-3d-system/prisma/schema.prisma @@ -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") diff --git a/furniture-3d-system/src/app/api/asset-center/families/[id]/route.ts b/furniture-3d-system/src/app/api/asset-center/families/[id]/route.ts index 3dcff3cf..0ba350b6 100644 --- a/furniture-3d-system/src/app/api/asset-center/families/[id]/route.ts +++ b/furniture-3d-system/src/app/api/asset-center/families/[id]/route.ts @@ -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) { diff --git a/furniture-3d-system/src/app/api/asset-center/variants/[id]/route.ts b/furniture-3d-system/src/app/api/asset-center/variants/[id]/route.ts index 94af59e8..ad6f2730 100644 --- a/furniture-3d-system/src/app/api/asset-center/variants/[id]/route.ts +++ b/furniture-3d-system/src/app/api/asset-center/variants/[id]/route.ts @@ -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) { diff --git a/furniture-3d-system/src/app/api/platform/schemas.ts b/furniture-3d-system/src/app/api/platform/schemas.ts index b150a1ff..d40cf78e 100644 --- a/furniture-3d-system/src/app/api/platform/schemas.ts +++ b/furniture-3d-system/src/app/api/platform/schemas.ts @@ -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 +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 +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 +const assetVariantUvConfigSchema = z.object({ + version: z.literal(1), + nodes: z.record(z.string(), assetVariantNodeUvConfigSchema), +}) satisfies z.ZodType 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(), diff --git a/furniture-3d-system/src/app/api/trash/asset-families/[id]/route.ts b/furniture-3d-system/src/app/api/trash/asset-families/[id]/route.ts new file mode 100644 index 00000000..3c825776 --- /dev/null +++ b/furniture-3d-system/src/app/api/trash/asset-families/[id]/route.ts @@ -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) + }) +} diff --git a/furniture-3d-system/src/app/api/trash/asset-variants/[id]/route.ts b/furniture-3d-system/src/app/api/trash/asset-variants/[id]/route.ts new file mode 100644 index 00000000..f202e7c9 --- /dev/null +++ b/furniture-3d-system/src/app/api/trash/asset-variants/[id]/route.ts @@ -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) + }) +} diff --git a/furniture-3d-system/src/app/asset-center/AssetCenterClient.test.tsx b/furniture-3d-system/src/app/asset-center/AssetCenterClient.test.tsx index 7fa676de..9e865fa4 100644 --- a/furniture-3d-system/src/app/asset-center/AssetCenterClient.test.tsx +++ b/furniture-3d-system/src/app/asset-center/AssetCenterClient.test.tsx @@ -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 ? ( +
+ {children} + {footer} +
+ ) : 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 + } | 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 }) => (
ModelPreviewPanel
+
+ {String(materialAssignments?.[0]?.material?.id ?? 'none')} +
- {String(materialAssignments?.[0]?.material?.textureRepeat ?? 'none')} + {String( + materialAssignments?.[0]?.material?.textureAxisConfigs?.[ + materialAssignments?.[0]?.material?.textureProjectionAxis ?? '' + ]?.textureRepeat ?? + materialAssignments?.[0]?.material?.textureRepeat ?? + 'none', + )} +
+
+ {String( + materialAssignments?.[0]?.material?.textureAxisConfigs?.[ + materialAssignments?.[0]?.material?.textureProjectionAxis ?? '' + ]?.textureRepeatU ?? + materialAssignments?.[0]?.material?.textureRepeatU ?? + 'none', + )} +
+
+ {String( + materialAssignments?.[0]?.material?.textureAxisConfigs?.[ + materialAssignments?.[0]?.material?.textureProjectionAxis ?? '' + ]?.textureRepeatV ?? + materialAssignments?.[0]?.material?.textureRepeatV ?? + 'none', + )} +
+
+ {String(materialAssignments?.[0]?.material?.textureProjectionAxis ?? 'none')}
+
{titleAside}
), @@ -138,6 +222,10 @@ const mockedRegenerateAssetVariantThumbnail = > const mockedUpdateAssetVariant = updateAssetVariant as jest.MockedFunction +const mockedCreateAssetVariantWithSimilarity = + createAssetVariantWithSimilarity as jest.MockedFunction< + typeof createAssetVariantWithSimilarity + > function createFamily(overrides?: Partial): AssetFamilyDTO { return { @@ -174,6 +262,88 @@ function createFamily(overrides?: Partial): AssetFamilyDTO { } } +function createBoardVariant(overrides?: Partial): 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 { + 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( + , + ) + + 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( + , + ) + + 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( + , + ) + + 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( + , + ) + + 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() }) }) diff --git a/furniture-3d-system/src/app/asset-center/AssetCenterClient.tsx b/furniture-3d-system/src/app/asset-center/AssetCenterClient.tsx index 5a7ddc17..bfd21b0f 100644 --- a/furniture-3d-system/src/app/asset-center/AssetCenterClient.tsx +++ b/furniture-3d-system/src/app/asset-center/AssetCenterClient.tsx @@ -6,7 +6,6 @@ import { ArrowRight, ChevronDown, ChevronRight, - ChevronUp, FileUp, Layers3, Link2, @@ -14,7 +13,6 @@ import { Pencil, Plus, Search, - Settings2, Shapes, SwatchBook, Trash2, @@ -30,7 +28,6 @@ import Select from '@/components/ui/Select' import { calculateMenuPosition, ContextMenu, - ContextSubmenu, type ContextMenuGroup, type ContextMenuItem, } from '@/components/ui/ContextMenu' @@ -46,6 +43,8 @@ import { getAssetVariantUvSurfaceConfig, normalizeAssetVariantUvConfig, updateAssetVariantUvConfig, + type AssetVariantUvConfig, + type AssetVariantUvProjectionAxis, type AssetVariantUvSurfaceConfig, } from '@/lib/asset-variant-render-config' import { inferCadImportDefaults } from '@/lib/asset-cad-import-defaults' @@ -64,7 +63,6 @@ import { buildManualAssemblyLaunchHref } from '@/lib/open-product/manual-assembl import { normalizeAssetVariantWithUv, normalizeAssetVariantsWithUv, - writeAssetVariantUvConfigToVariantBusinessData, } from '@/lib/open-product/asset-variant-uv' import { calculateEstimatedCost } from '@/lib/open-product/substrate-rules' import { getLevel1FromFamily } from '@/lib/packaging-materials/catalog' @@ -128,8 +126,11 @@ type Vector3Tuple = [number, number, number] type VariantUvEditorFormState = { textureRepeat: string + textureRepeatU: string + textureRepeatV: string textureRotation: string textureSizeMm: string + textureProjectionAxis: '' | AssetVariantUvProjectionAxis } const ASSET_CENTER_GIZMO_AXIS_COLORS = { @@ -138,6 +139,13 @@ const ASSET_CENTER_GIZMO_AXIS_COLORS = { Z: '#5f6d82', } satisfies Record<'X' | 'Y' | 'Z', string> +const UV_PROJECTION_AXIS_OPTIONS = [ + { value: '', label: '自动' }, + { value: 'x', label: 'X 轴' }, + { value: 'y', label: 'Y 轴' }, + { value: 'z', label: 'Z 轴' }, +] satisfies Array<{ value: '' | AssetVariantUvProjectionAxis; label: string }> + // ---- 工具函数 ---- function sortByName(items: T[]): T[] { @@ -148,13 +156,24 @@ function formatUvNumber(value: number | null | undefined) { return typeof value === 'number' && Number.isFinite(value) ? String(value) : '' } +function formatUvRotationDegrees(value: number | null | undefined) { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return '' + } + const degrees = value * 180 / Math.PI + return Number.isInteger(degrees) ? String(degrees) : String(Number(degrees.toFixed(2))) +} + function createVariantUvEditorFormState( config: AssetVariantUvSurfaceConfig | null | undefined, ): VariantUvEditorFormState { return { textureRepeat: formatUvNumber(config?.textureRepeat), - textureRotation: formatUvNumber(config?.textureRotation), + textureRepeatU: formatUvNumber(config?.textureRepeatU), + textureRepeatV: formatUvNumber(config?.textureRepeatV), + textureRotation: formatUvRotationDegrees(config?.textureRotation), textureSizeMm: formatUvNumber(config?.textureSizeMm), + textureProjectionAxis: config?.textureProjectionAxis ?? '', } } @@ -172,21 +191,37 @@ function parseVariantUvEditorFormState( } const textureRepeat = parseField(state.textureRepeat) - const textureRotation = parseField(state.textureRotation) + const textureRepeatU = parseField(state.textureRepeatU) + const textureRepeatV = parseField(state.textureRepeatV) + const textureRotationDegrees = parseField(state.textureRotation) + const textureRotation = + textureRotationDegrees === undefined ? undefined : textureRotationDegrees * Math.PI / 180 const textureSizeMm = parseField(state.textureSizeMm) + const textureProjectionAxis = + state.textureProjectionAxis === 'x' || + state.textureProjectionAxis === 'y' || + state.textureProjectionAxis === 'z' + ? state.textureProjectionAxis + : undefined 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 } : {}), } } @@ -856,8 +891,6 @@ export default function AssetCenterClient({ }) const [familyRoleDefinitions, setFamilyRoleDefinitions] = useState([]) const [editingFamilyId, setEditingFamilyId] = useState(null) - const [selectedInspectorExpanded, setSelectedInspectorExpanded] = useState(false) - const [materialInspectorExpanded, setMaterialInspectorExpanded] = useState(false) const [roleEditorOpen, setRoleEditorOpen] = useState(false) const [roleEditorMode, setRoleEditorMode] = useState<'create' | 'edit'>('create') const [editingRoleValue, setEditingRoleValue] = useState(null) @@ -912,6 +945,7 @@ export default function AssetCenterClient({ nodeName: null, }) const [activePreviewSubmenu, setActivePreviewSubmenu] = useState(null) + const [previewSubstrateSearch, setPreviewSubstrateSearch] = useState('') const previewNodeOffsetMapRef = useRef({}) const previewNodeRotationMapRef = useRef({}) const previewTransformBaselineRef = useRef(null) @@ -919,11 +953,24 @@ export default function AssetCenterClient({ const [variantUvEditorForm, setVariantUvEditorForm] = useState( createVariantUvEditorFormState(null), ) + const [variantCreateUvConfig, setVariantCreateUvConfig] = useState(null) + const [draftVariantSubstrateId, setDraftVariantSubstrateId] = useState(null) + const variantUvEditorFormRef = useRef(variantUvEditorForm) + const [draftVariantUvConfig, setDraftVariantUvConfig] = useState(null) + const draftVariantUvConfigRef = useRef(null) + const activeVariantUvNodeNameRef = useRef(null) // ---- 派生数据 ---- const selectedVariant = variants.find((v) => v.id === selectedVariantId) ?? null const currentUserCanReviewAssets = useMemo(() => canReviewAssets(user.role), [user.role]) + const variantSubstrateDirty = Boolean( + selectedVariant && + (draftVariantSubstrateId ?? null) !== (selectedVariant.substrateId ?? null), + ) + const previewSubstrateId = variantSubstrateDirty + ? draftVariantSubstrateId + : selectedVariant?.substrateId ?? null const editingFamily = useMemo( () => families.find((family) => family.id === editingFamilyId) ?? null, [editingFamilyId, families], @@ -943,22 +990,22 @@ export default function AssetCenterClient({ ) const selectedFinish = useMemo( () => { - if (selectedVariant?.finish) { + if (!variantSubstrateDirty && selectedVariant?.finish) { return selectedVariant.finish } - if (selectedVariant?.finishId) { + if (!variantSubstrateDirty && selectedVariant?.finishId) { const finishFromList = finishes.find((finish) => finish.id === selectedVariant.finishId) if (finishFromList) { return finishFromList } } - const substrate = selectedVariant?.substrateId - ? substrates.find((item) => item.id === selectedVariant.substrateId) + const substrate = previewSubstrateId + ? substrates.find((item) => item.id === previewSubstrateId) : null const substrateFinish = substrate?.finishes?.find( - (finish) => !selectedVariant?.finishId || finish.id === selectedVariant.finishId, + (finish) => variantSubstrateDirty || !selectedVariant?.finishId || finish.id === selectedVariant.finishId, ) ?? substrate?.finishes?.[0] ?? null if (!substrateFinish) { return null @@ -966,7 +1013,14 @@ export default function AssetCenterClient({ return finishes.find((finish) => finish.id === substrateFinish.id) ?? substrateFinish }, - [finishes, selectedVariant?.finish, selectedVariant?.finishId, selectedVariant?.substrateId, substrates], + [ + finishes, + previewSubstrateId, + selectedVariant?.finish, + selectedVariant?.finishId, + substrates, + variantSubstrateDirty, + ], ) const selectedFinishMaterialConfig = useMemo( () => toMaterialConfig(selectedFinish), @@ -980,33 +1034,31 @@ export default function AssetCenterClient({ return null }, [previewNodeNames, selectedPreviewNodeName]) const activeVariantUvNodeLabel = activeVariantUvNodeName ?? '默认全部节点' - const savedVariantUvEditorForm = useMemo( - () => - createVariantUvEditorFormState( - getAssetVariantUvSurfaceConfig(selectedVariant?.uvConfig, { - nodeName: activeVariantUvNodeName, - }), - ), - [activeVariantUvNodeName, selectedVariant?.id, selectedVariant?.uvConfig], + const savedVariantUvConfig = useMemo( + () => normalizeAssetVariantUvConfig(selectedVariant?.uvConfig ?? null), + [selectedVariant?.uvConfig], + ) + const savedVariantUvConfigSignature = useMemo( + () => JSON.stringify(savedVariantUvConfig), + [savedVariantUvConfig], + ) + const selectedVariantModelSignature = useMemo( + () => + JSON.stringify({ + modelUrl: selectedVariant?.modelUrl ?? null, + dimensions: selectedVariant?.dimensions ?? null, + }), + [selectedVariant?.dimensions, selectedVariant?.modelUrl], ) - const draftVariantUvConfig = useMemo(() => { - if (!selectedVariant) { - return null - } - - return updateAssetVariantUvConfig( - selectedVariant.uvConfig, - { nodeName: activeVariantUvNodeName }, - () => parseVariantUvEditorFormState(variantUvEditorForm), - ) - }, [activeVariantUvNodeName, selectedVariant, variantUvEditorForm]) const variantUvConfigDirty = useMemo( () => JSON.stringify(normalizeAssetVariantUvConfig(draftVariantUvConfig)) !== - JSON.stringify(normalizeAssetVariantUvConfig(selectedVariant?.uvConfig ?? null)), - [draftVariantUvConfig, selectedVariant?.uvConfig], + savedVariantUvConfigSignature, + [draftVariantUvConfig, savedVariantUvConfigSignature], + ) + const hasPendingVariantDraft = Boolean( + selectedVariant && (variantUvConfigDirty || variantSubstrateDirty), ) - const hasPendingVariantSync = Boolean(selectedVariant && variantUvConfigDirty) const previewMaterialAssignments = useMemo(() => { if (!selectedFinishMaterialConfig || previewNodeNames.length === 0) return [] return previewNodeNames.map((nodeName) => ({ @@ -1049,13 +1101,39 @@ export default function AssetCenterClient({ }, [previewNodeRotationMap]) useEffect(() => { - setVariantUvEditorForm(savedVariantUvEditorForm) - }, [savedVariantUvEditorForm]) + variantUvEditorFormRef.current = variantUvEditorForm + }, [variantUvEditorForm]) useEffect(() => { - setSelectedInspectorExpanded(false) - setMaterialInspectorExpanded(false) - }, [selectedVariantId]) + draftVariantUvConfigRef.current = draftVariantUvConfig + }, [draftVariantUvConfig]) + + useEffect(() => { + activeVariantUvNodeNameRef.current = activeVariantUvNodeName + }, [activeVariantUvNodeName]) + + useEffect(() => { + draftVariantUvConfigRef.current = savedVariantUvConfig + setDraftVariantUvConfig(savedVariantUvConfig) + setDraftVariantSubstrateId(selectedVariant?.substrateId ?? null) + const nextForm = createVariantUvEditorFormState( + getAssetVariantUvSurfaceConfig(savedVariantUvConfig, { + nodeName: activeVariantUvNodeNameRef.current, + }), + ) + variantUvEditorFormRef.current = nextForm + setVariantUvEditorForm(nextForm) + }, [savedVariantUvConfig, savedVariantUvConfigSignature, selectedVariant?.id, selectedVariant?.substrateId]) + + useEffect(() => { + const nextForm = createVariantUvEditorFormState( + getAssetVariantUvSurfaceConfig(draftVariantUvConfigRef.current, { + nodeName: activeVariantUvNodeName, + }), + ) + variantUvEditorFormRef.current = nextForm + setVariantUvEditorForm(nextForm) + }, [activeVariantUvNodeName]) const variantsByFamilyId = useMemo(() => { const map = new Map() @@ -1574,22 +1652,31 @@ export default function AssetCenterClient({ function openCreateVariantModal(family: AssetFamilyDTO) { setVariantModalMode('create') setEditingVariantId(null) + setVariantCreateUvConfig(null) setVariantForm(emptyVariantForm(family.id, buildVariantIdentityPrefix(family.code))) setExpandedFamilyIds((prev) => new Set(prev).add(family.id)) setVariantModalOpen(true) } - function openDuplicateVariantModal(variant: AssetVariantDTO) { + function openDuplicateVariantModal( + variant: AssetVariantDTO, + draft?: { + substrateId?: string | null + uvConfig?: AssetVariantUvConfig | null + }, + ) { const dims = variant.dimensions ?? {} const mp = variant.mountPoints?.[0] + const nextUvConfig = normalizeAssetVariantUvConfig(draft?.uvConfig ?? variant.uvConfig ?? null) setVariantModalMode('create') setEditingVariantId(null) + setVariantCreateUvConfig(nextUvConfig) setVariantForm({ assetFamilyId: variant.assetFamilyId, - code: '', - name: `${variant.name} 副本`, + code: variant.code, + name: variant.name, sizeLabel: variant.sizeLabel ?? '', - substrateId: variant.substrateId ?? '', + substrateId: draft?.substrateId ?? variant.substrateId ?? '', finishId: variant.finishId ?? '', length: String(dims.length ?? ''), width: String(dims.width ?? ''), @@ -1609,6 +1696,7 @@ export default function AssetCenterClient({ function openEditVariantModal(variant: AssetVariantDTO) { setVariantModalMode('edit') setEditingVariantId(variant.id) + setVariantCreateUvConfig(null) const dims = variant.dimensions ?? {} const mp = variant.mountPoints?.[0] setVariantForm({ @@ -1636,6 +1724,7 @@ export default function AssetCenterClient({ function closeVariantModal() { setVariantModalOpen(false) setEditingVariantId(null) + setVariantCreateUvConfig(null) } function applyInheritedVariant(variantId: string) { @@ -1686,12 +1775,17 @@ export default function AssetCenterClient({ ], lossFactor: toOptionalNumber(variantForm.lossFactor), inheritedFromVariantId: variantForm.inheritedFromVariantId || null, + ...(variantCreateUvConfig ? { uvConfig: variantCreateUvConfig } : {}), }) const created = normalizeAssetVariantWithUv(createdResult.variant) + const createdUvConfig = normalizeAssetVariantUvConfig(created.uvConfig ?? null) startTransition(() => { setVariants((current) => [created, ...current]) setExpandedFamilyIds((prev) => new Set(prev).add(created.assetFamilyId)) + draftVariantUvConfigRef.current = createdUvConfig + setDraftVariantUvConfig(createdUvConfig) + setDraftVariantSubstrateId(created.substrateId ?? null) setSelectedVariantId(created.id) closeVariantModal() }) @@ -1874,29 +1968,50 @@ export default function AssetCenterClient({ const closePreviewContextMenu = useCallback(() => { setPreviewContextMenuState((current) => ({ ...current, visible: false })) setActivePreviewSubmenu(null) + setPreviewSubstrateSearch('') }, []) - const handleQuickUpdateVariant = useCallback( - async (input: Record, successMessage: string) => { - if (!selectedVariantId) return + const handleSavePreviewDraft = useCallback(async () => { + if (!selectedVariant || !hasPendingVariantDraft) return + if (!variantSubstrateDirty) { setSubmitting(true) try { - const updated = normalizeAssetVariantWithUv(await updateAssetVariant(selectedVariantId, input)) + const updated = normalizeAssetVariantWithUv(await updateAssetVariant(selectedVariant.id, { + uvConfig: draftVariantUvConfig, + })) + const updatedUvConfig = normalizeAssetVariantUvConfig(updated.uvConfig ?? null) startTransition(() => { - setVariants((current) => current.map((variant) => ( - variant.id === selectedVariantId ? updated : variant - ))) - toast.success(successMessage) + setVariants((current) => + current.map((variant) => (variant.id === updated.id ? updated : variant)), + ) + draftVariantUvConfigRef.current = updatedUvConfig + setDraftVariantUvConfig(updatedUvConfig) + setDraftVariantSubstrateId(updated.substrateId ?? null) }) + toast.success('渲染参数已保存') } catch (error) { - toast.error(error instanceof Error ? error.message : '更新失败,请稍后再试') + toast.error(error instanceof Error ? error.message : '渲染参数保存失败,请稍后再试') } finally { setSubmitting(false) closePreviewContextMenu() } - }, - [closePreviewContextMenu, selectedVariantId, toast], - ) + return + } + + openDuplicateVariantModal(selectedVariant, { + substrateId: draftVariantSubstrateId, + uvConfig: draftVariantUvConfig, + }) + closePreviewContextMenu() + }, [ + closePreviewContextMenu, + draftVariantSubstrateId, + draftVariantUvConfig, + hasPendingVariantDraft, + selectedVariant, + toast, + variantSubstrateDirty, + ]) const handleRegenerateVariantThumbnail = useCallback(async () => { if (!selectedVariant) return @@ -1921,73 +2036,104 @@ export default function AssetCenterClient({ } }, [selectedVariant, toast]) - const handleResetVariantUvEditor = useCallback(() => { - setVariantUvEditorForm(savedVariantUvEditorForm) - }, [savedVariantUvEditorForm]) - - const handleClearVariantUvEditor = useCallback(() => { - setVariantUvEditorForm(createVariantUvEditorFormState(null)) - }, []) - - const handleSaveVariantUvConfig = useCallback(async () => { - if (!selectedVariant) { - return - } - - setSubmitting(true) - try { - const nextUvConfig = normalizeAssetVariantUvConfig(draftVariantUvConfig) - const businessData = writeAssetVariantUvConfigToVariantBusinessData( - selectedVariant, - nextUvConfig, - ) - const savedVariant = normalizeAssetVariantWithUv( - await updateAssetVariant(selectedVariant.id, { - businessData, - }), - ) - let updated = savedVariant - let thumbnailRegenerated = false - - if (savedVariant.modelUrl) { - try { - updated = normalizeAssetVariantWithUv( - await regenerateAssetVariantThumbnail(savedVariant.id), - ) - thumbnailRegenerated = true - } catch (thumbnailError) { - updated = savedVariant - toast.error( - thumbnailError instanceof Error - ? `UV 已保存,但缩略图再生失败:${thumbnailError.message}` - : 'UV 已保存,但缩略图再生失败,请稍后再试', - ) - } + const handlePatchVariantUvEditorForm = useCallback( + (patch: Partial) => { + const nextForm = { + ...variantUvEditorFormRef.current, + ...patch, } - startTransition(() => { - setVariants((current) => - current.map((variant) => (variant.id === updated.id ? updated : variant)), + variantUvEditorFormRef.current = nextForm + setVariantUvEditorForm(nextForm) + setDraftVariantUvConfig((current) => { + const projectionAxis = nextForm.textureProjectionAxis || null + const nextConfig = updateAssetVariantUvConfig( + current, + { nodeName: activeVariantUvNodeName, projectionAxis }, + () => parseVariantUvEditorFormState(nextForm), ) + draftVariantUvConfigRef.current = nextConfig + return nextConfig }) + }, + [activeVariantUvNodeName], + ) - toast.success( - savedVariant.modelUrl - ? thumbnailRegenerated - ? 'UV 已保存,缩略图已同步更新' - : 'UV 已保存' - : 'UV 已保存', + const handleSelectVariantUvProjectionAxis = useCallback( + (projectionAxis: '' | AssetVariantUvProjectionAxis) => { + setDraftVariantUvConfig((current) => { + const nextConfig = updateAssetVariantUvConfig( + current, + { nodeName: activeVariantUvNodeName }, + (currentBaseConfig) => { + const nextBaseConfig = { + ...(currentBaseConfig ?? {}), + } + + if (projectionAxis) { + nextBaseConfig.textureProjectionAxis = projectionAxis + } else { + delete nextBaseConfig.textureProjectionAxis + } + + return Object.keys(nextBaseConfig).length > 0 ? nextBaseConfig : null + }, + ) + const nextForm = createVariantUvEditorFormState( + getAssetVariantUvSurfaceConfig(nextConfig, { + nodeName: activeVariantUvNodeName, + projectionAxis: projectionAxis || null, + }), + ) + variantUvEditorFormRef.current = nextForm + setVariantUvEditorForm(nextForm) + draftVariantUvConfigRef.current = nextConfig + return nextConfig + }) + }, + [activeVariantUvNodeName], + ) + + const handleResetVariantUvEditor = useCallback(() => { + draftVariantUvConfigRef.current = savedVariantUvConfig + setDraftVariantUvConfig(savedVariantUvConfig) + const nextForm = createVariantUvEditorFormState( + getAssetVariantUvSurfaceConfig(savedVariantUvConfig, { + nodeName: activeVariantUvNodeName, + }), + ) + variantUvEditorFormRef.current = nextForm + setVariantUvEditorForm(nextForm) + }, [activeVariantUvNodeName, savedVariantUvConfig]) + + const handleClearVariantUvEditor = useCallback(() => { + const projectionAxis = variantUvEditorFormRef.current.textureProjectionAxis + const nextForm = createVariantUvEditorFormState( + projectionAxis ? { textureProjectionAxis: projectionAxis } : null, + ) + variantUvEditorFormRef.current = nextForm + setVariantUvEditorForm(nextForm) + setDraftVariantUvConfig((current) => { + const nextConfig = updateAssetVariantUvConfig( + current, + { nodeName: activeVariantUvNodeName, projectionAxis: projectionAxis || null }, + () => null, ) - } catch (error) { - toast.error(error instanceof Error ? error.message : 'UV 保存失败,请稍后再试') - } finally { - setSubmitting(false) - } - }, [draftVariantUvConfig, selectedVariant, toast]) + draftVariantUvConfigRef.current = nextConfig + return nextConfig + }) + }, [activeVariantUvNodeName]) + + const handleKeepVariantUvDraft = useCallback(() => { + toast.success('渲染参数已暂存') + }, [toast]) useEffect(() => { setPreviewNodeNames([]) setSelectedPreviewNodeName(null) + }, [selectedVariantModelSignature]) + + useEffect(() => { setPreviewNodeOffsetMap({}) setPreviewNodeRotationMap({}) setPreviewNodeTransformUndoStack([]) @@ -2209,12 +2355,10 @@ export default function AssetCenterClient({ } document.addEventListener('mousedown', handleClose) - document.addEventListener('contextmenu', handleClose) document.addEventListener('keydown', handleEsc) return () => { document.removeEventListener('mousedown', handleClose) - document.removeEventListener('contextmenu', handleClose) document.removeEventListener('keydown', handleEsc) } }, [closePreviewContextMenu, previewContextMenuState.visible]) @@ -2366,17 +2510,49 @@ export default function AssetCenterClient({ } const previewContextMenuPosition = useMemo( - () => calculateMenuPosition(previewContextMenuState.x, previewContextMenuState.y, 200, 220), + () => calculateMenuPosition(previewContextMenuState.x, previewContextMenuState.y, 220, 224), [previewContextMenuState.x, previewContextMenuState.y], ) - const previewSubstrateSubmenuPosition = useMemo( - () => ({ - left: 212, - top: 46, - }), - [], - ) + const previewContextMenuBodyMaxHeight = useMemo(() => { + const viewportHeight = typeof window !== 'undefined' ? window.innerHeight : 900 + const menuChromeHeight = 24 + const availableHeight = viewportHeight - previewContextMenuPosition.top - menuChromeHeight + return Math.max(240, Math.min(360, availableHeight)) + }, [previewContextMenuPosition.top]) + + const previewUvSettingsPanelPosition = useMemo(() => { + const panelWidth = 264 + const panelHeight = 326 + const viewportGap = 12 + const menuGap = 12 + const rightSideLeft = 220 + menuGap + const baseTop = 10 + const viewportHeight = typeof window !== 'undefined' ? window.innerHeight : 900 + const viewportWidth = typeof window !== 'undefined' ? window.innerWidth : 1440 + const minTop = viewportGap - previewContextMenuPosition.top + const maxTop = viewportHeight - previewContextMenuPosition.top - panelHeight - viewportGap + const top = Math.max(minTop, Math.min(baseTop, maxTop)) + const availableHeight = viewportHeight - previewContextMenuPosition.top - top - viewportGap + const maxHeight = Math.max( + 220, + Math.min(panelHeight, availableHeight), + ) + + if (previewContextMenuPosition.left + rightSideLeft + panelWidth > viewportWidth - viewportGap) { + return { + left: -panelWidth - menuGap, + top, + maxHeight, + } + } + + return { + left: rightSideLeft, + top, + maxHeight, + } + }, [previewContextMenuPosition.left, previewContextMenuPosition.top]) const previewSubstrateItems = useMemo( () => @@ -2387,53 +2563,141 @@ export default function AssetCenterClient({ [substrates], ) + const previewFilteredSubstrateItems = useMemo(() => { + const keyword = previewSubstrateSearch.trim().toLowerCase() + if (!keyword) { + return previewSubstrateItems + } + return previewSubstrateItems.filter((item) => item.label.toLowerCase().includes(keyword)) + }, [previewSubstrateItems, previewSubstrateSearch]) + + const previewSubstrateSubmenuMetrics = useMemo(() => { + const longestLabelLength = previewSubstrateItems.reduce( + (max, item) => Math.max(max, item.label.length), + 0, + ) + const width = Math.max(240, Math.min(380, 96 + longestLabelLength * 7)) + const viewportGap = 12 + const menuGap = 12 + const rightSideLeft = 220 + menuGap + const viewportWidth = typeof window !== 'undefined' ? window.innerWidth : 1440 + const viewportHeight = typeof window !== 'undefined' ? window.innerHeight : 900 + const top = 4 + const left = + previewContextMenuPosition.left + rightSideLeft + width > viewportWidth - viewportGap + ? -width - menuGap + : rightSideLeft + const maxHeight = Math.max( + 220, + Math.min(360, viewportHeight - previewContextMenuPosition.top - top - viewportGap), + ) + + return { left, top, width, maxHeight } + }, [previewContextMenuPosition.left, previewContextMenuPosition.top, previewSubstrateItems]) + const previewContextMenuGroups = useMemo(() => { if (!selectedVariant) return [] return [ { - id: 'preview-edit', + id: 'preview-asset', items: [ - { - id: 'preview-edit-variant', - label: '编辑当前规格款', - }, { id: 'preview-change-substrate', label: '快捷更改基材', submenu: 'preview-substrate', disabled: substrates.length === 0, }, + { + id: 'preview-go-assembly', + label: '去辅助装配', + }, + ], + }, + { + id: 'preview-render', + items: [ + { + id: 'preview-uv-settings', + label: '渲染参数', + submenu: 'preview-uv', + }, + ], + }, + { + id: 'preview-save', + items: [ + { + id: 'preview-save-draft', + label: '保存', + disabled: submitting || !hasPendingVariantDraft, + }, + ], + }, + { + id: 'preview-danger', + items: [ + { + id: 'preview-delete-variant', + label: '删除规格', + disabled: submitting, + }, ], }, ] - }, [selectedVariant, substrates.length]) + }, [hasPendingVariantDraft, selectedVariant, submitting, substrates.length]) const handlePreviewContextMenuItemClick = useCallback((item: ContextMenuItem) => { if (!selectedVariant) return - if (item.id === 'preview-edit-variant') { - openEditVariantModal(selectedVariant) + if (item.id === 'preview-go-assembly') { + window.location.href = manualAssemblyLaunchHref closePreviewContextMenu() return } + if (item.id === 'preview-delete-variant') { + setDeleteTarget({ type: 'variant', id: selectedVariant.id, name: selectedVariant.name }) + closePreviewContextMenu() + return + } + + if (item.id === 'preview-save-draft') { + void handleSavePreviewDraft() + return + } + + if (item.id === 'preview-uv-settings') { + setActivePreviewSubmenu('preview-uv') + return + } + + if (item.id === 'preview-change-substrate') { + setActivePreviewSubmenu('preview-substrate') + return + } + if (item.id.startsWith('substrate:')) { const substrateId = item.id.replace('substrate:', '') - void handleQuickUpdateVariant({ substrateId }, '基材已更新') + setDraftVariantSubstrateId(substrateId) + toast.success('基材调整已暂存') return } - }, [closePreviewContextMenu, handleQuickUpdateVariant, selectedVariant]) + }, [ + closePreviewContextMenu, + handleSavePreviewDraft, + manualAssemblyLaunchHref, + selectedVariant, + toast, + ]) const handlePreviewContextMenuHover = useCallback((item: ContextMenuItem) => { - if (item.id === 'preview-change-substrate') { - setActivePreviewSubmenu('preview-substrate') + if (item.submenu) { + setActivePreviewSubmenu(item.submenu) return } - if (!item.submenu) { - setActivePreviewSubmenu(null) - } + setActivePreviewSubmenu(null) }, []) // ---- 渲染 ---- @@ -2517,6 +2781,7 @@ export default function AssetCenterClient({ }} onNodeContextMenu={(payload) => { setSelectedPreviewNodeName(payload.nodeName ?? payload.rawNodeName ?? selectedPreviewNodeName) + setPreviewSubstrateSearch('') setPreviewContextMenuState({ visible: true, x: payload.clientX, @@ -2995,247 +3260,6 @@ export default function AssetCenterClient({ - {selectedVariant ? ( -
- - -
-
-
- {(selectedVariant.status === 'DRAFT' || selectedVariant.status === 'REJECTED') ? ( - - ) : null} - {selectedVariant.status === 'PENDING_REVIEW' && currentUserCanReviewAssets ? ( - <> - - - - ) : null} - - - - - 去辅助装配 - - -
- -
- - -
-
-
- - setVariantUvEditorForm((current) => ({ - ...current, - textureRepeat: event.target.value, - })) - } - placeholder="1.0" - size="small" - containerClassName="!space-y-1" - /> - - setVariantUvEditorForm((current) => ({ - ...current, - textureRotation: event.target.value, - })) - } - placeholder="0°" - size="small" - containerClassName="!space-y-1" - /> - - setVariantUvEditorForm((current) => ({ - ...current, - textureSizeMm: event.target.value, - })) - } - placeholder="600" - size="small" - containerClassName="!space-y-1" - /> -
- -
- - -
-
-
-
-
-
- - {hasPendingVariantSync ? ( -
- -
- ) : null} -
- ) : null} @@ -3243,25 +3267,222 @@ export default function AssetCenterClient({ - + {activePreviewSubmenu === 'preview-substrate' ? ( +
+
+
+

+ 选择基材 +

+
+ + setPreviewSubstrateSearch(event.target.value)} + placeholder="搜索名称或编码" + className="h-7 w-full rounded-apple-md border border-apple-separator/80 bg-white/80 pl-7 pr-2 text-[12px] text-[#2d3335] outline-hidden transition placeholder:text-[#5a6062]/45 focus:border-black/[0.12] focus:bg-white" + /> +
+
+ +
+ {previewFilteredSubstrateItems.length > 0 ? ( + previewFilteredSubstrateItems.map((item) => ( + + )) + ) : ( +
+ 没有匹配基材 +
+ )} +
+
+
+ ) : null} + {activePreviewSubmenu === 'preview-uv' ? ( +
+
+
+
+

+ {activeVariantUvNodeLabel} + {!selectedFinishMaterialConfig ? ' · 未绑定基材花色' : ''} +

+

+ 渲染参数 +

+
+ + {variantUvConfigDirty ? '待保存' : '已同步'} + +
+ +
+
+ + 投影轴 + +
+ {UV_PROJECTION_AXIS_OPTIONS.map((option) => { + const isSelected = variantUvEditorForm.textureProjectionAxis === option.value + return ( + + ) + })} +
+
+ {[ + { + label: '整体重复', + ariaLabel: '整体重复', + value: variantUvEditorForm.textureRepeat, + placeholder: '1.0', + field: 'textureRepeat' as const, + }, + { + label: '旋转角度 °', + ariaLabel: '旋转角度 °', + value: variantUvEditorForm.textureRotation, + placeholder: '0°', + field: 'textureRotation' as const, + }, + { + label: '纹理尺寸 mm', + ariaLabel: '纹理尺寸 mm', + value: variantUvEditorForm.textureSizeMm, + placeholder: '600', + field: 'textureSizeMm' as const, + }, + { + label: 'U 压缩', + ariaLabel: 'U 压缩', + value: variantUvEditorForm.textureRepeatU, + placeholder: '1.0', + field: 'textureRepeatU' as const, + }, + { + label: 'V 压缩', + ariaLabel: 'V 压缩', + value: variantUvEditorForm.textureRepeatV, + placeholder: '1.0', + field: 'textureRepeatV' as const, + }, + ].map((item) => ( +
+ + + handlePatchVariantUvEditorForm({ [item.field]: event.target.value }) + } + placeholder={item.placeholder} + className="h-6 w-[108px] rounded-apple-sm border border-apple-separator bg-white/80 px-2 text-right text-[12px] text-apple-label-primary outline-hidden transition placeholder:text-apple-label-tertiary focus:border-[#2d3335]" + /> +
+ ))} + +
+ +
+ + +
+ +
+
+
+ ) : null} )} @@ -3609,6 +3830,7 @@ export default function AssetCenterClient({ value={variantForm.sizeLabel} onChange={(e) => setVariantForm((c) => ({ ...c, sizeLabel: e.target.value }))} placeholder="例如 1200x600" + disabled={isDuplicateVariantMode} /> setVariantForm((c) => ({ ...c, length: e.target.value }))} placeholder="1200" /> - setVariantForm((c) => ({ ...c, width: e.target.value }))} placeholder="600" /> - setVariantForm((c) => ({ ...c, thickness: e.target.value }))} placeholder="25" /> + setVariantForm((c) => ({ ...c, length: e.target.value }))} placeholder="1200" disabled={isDuplicateVariantMode} /> + setVariantForm((c) => ({ ...c, width: e.target.value }))} placeholder="600" disabled={isDuplicateVariantMode} /> + setVariantForm((c) => ({ ...c, thickness: e.target.value }))} placeholder="25" disabled={isDuplicateVariantMode} /> setVariantForm((c) => ({ ...c, lossFactor: e.target.value }))} placeholder="例如 1.15" />
@@ -3893,7 +4118,7 @@ export default function AssetCenterClient({ startTransition(() => { setFamilies((c) => c.filter((f) => f.id !== deleteTarget.id)) setFamilyModalOpen(false) - toast.success('资产族已删除') + toast.success('资产族已移入回收站') }) } else { await deleteAssetVariant(deleteTarget.id) @@ -3901,20 +4126,20 @@ export default function AssetCenterClient({ setVariants((c) => c.filter((v) => v.id !== deleteTarget.id)) if (selectedVariantId === deleteTarget.id) setSelectedVariantId(null) setVariantModalOpen(false) - toast.success('规格款已删除') + toast.success('规格款已移入回收站') }) } }} title={deleteTarget?.type === 'family' ? '删除资产族' : '删除规格款'} description={ deleteTarget?.type === 'family' - ? `确定要删除资产族「${deleteTarget?.name}」吗?其下所有规格款也将一并删除。` - : `确定要删除规格款「${deleteTarget?.name}」吗?` + ? `确定要将资产族「${deleteTarget?.name}」移入回收站吗?其下规格款会一并移入回收站。` + : `确定要将规格款「${deleteTarget?.name}」移入回收站吗?` } confirmText="确认删除" - cascadeInfo={ + warningItems={ deleteTarget?.type === 'family' && deleteTarget.cascadeCount - ? { skuCount: deleteTarget.cascadeCount } + ? [`将同时移动 ${deleteTarget.cascadeCount} 个规格款`] : undefined } /> diff --git a/furniture-3d-system/src/app/trash/TrashClient.tsx b/furniture-3d-system/src/app/trash/TrashClient.tsx index 62e9f26a..bdbcdc99 100644 --- a/furniture-3d-system/src/app/trash/TrashClient.tsx +++ b/furniture-3d-system/src/app/trash/TrashClient.tsx @@ -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 | 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 + 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 = { + BOARD: '板材', + COMPONENT: '组件', + HARDWARE: '五金', + PACKAGING: '包装', +} + +const statusLabel: Record = { + 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) { + 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 ( + + {days <= 0 ? '即将清除' : `${days} 天后清除`} + + ) +} + export default function TrashClient() { const toast = useToast() const [data, setData] = useState(null) const [isLoading, setIsLoading] = useState(true) const [loadError, setLoadError] = useState(null) - const [activeTab, setActiveTab] = useState<'products' | 'skus'>('products') + const [activeTab, setActiveTab] = useState('assetVariants') const [selectedItem, setSelectedItem] = useState(null) - const [showDeleteDialog, setShowDeleteDialog] = useState(false) const [isRestoring, setIsRestoring] = useState(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 ( -
- {/* 顶部导航 - 毛玻璃效果 */} -
- -
- - {/* 主内容区 */} -
- {/* 错误提示:如果已有旧数据,仍允许查看;同时提供重试 */} - {loadError && data && ( - -
-
-
- -
-
-

- 回收站数据刷新失败 -

-

- {loadError} -

-
-
- -
-
- )} - - {/* 标签页 */} -
- - -
- - {/* 列表 */} - {isLoading ? ( - - -

加载中...

-
- ) : loadError && !data ? ( - -
- -
-

加载失败

-

{loadError}

-
- -
-
- ) : activeTab === 'products' ? ( -
- {products.length === 0 ? ( - -
- -
-

回收站中没有已删除的产品

-
- ) : ( - products.map((product) => { - const daysStyle = getDaysRemainingStyle(product.daysRemaining) - return ( - - {/* 缩略图 */} -
- {product.thumbnailUrl ? ( - {product.name} - ) : ( - - )} -
- - {/* 信息 */} -
-
- - {product.modelNumber} - - {product.category && ( - - {product.category.name} - - )} -
-

{product.name}

-
- {product.skuCount} 个 SKU - - - 删除于 {formatDate(product.deletedAt)} - -
-
- - {/* 剩余天数 */} -
- {product.daysRemaining <= 0 ? ( - - - 即将删除 - - ) : ( - `${product.daysRemaining} 天后删除` - )} -
- - {/* 操作按钮 */} -
- - -
-
- ) - }) - )} -
+ const renderAssetVariant = (item: TrashAssetVariant) => ( +
+
+ {item.thumbnailUrl ? ( + {item.name} ) : ( -
- {skus.length === 0 ? ( - -
- -
-

回收站中没有已删除的 SKU

-
- ) : ( - skus.map((sku) => { - const daysStyle = getDaysRemainingStyle(sku.daysRemaining) - return ( - - {/* 图标 - 使用品牌色淡背景 */} -
- -
- - {/* 信息 */} -
-
- - {sku.skuCode} - - - ¥{sku.price.toFixed(2)} - -
-

- 所属产品: {sku.product.modelNumber} - {sku.product.name} -

-
- 负责人: {sku.owner.name} - - - 删除于 {formatDate(sku.deletedAt)} - -
-
- - {/* 剩余天数 */} -
- {sku.daysRemaining <= 0 ? ( - - - 即将删除 - - ) : ( - `${sku.daysRemaining} 天后删除` - )} -
- - {/* 操作按钮 */} -
- - -
-
- ) - }) - )} -
+ )}
+
+
+ + {item.code} + + + {assetTypeLabel[item.assetType] ?? item.assetType} + +
+
+ {item.name} / {item.assetFamilyCode} {item.assetFamilyName} +
+
+ {formatDimensions(item.dimensions)} + {item.substrateName ?? '未绑定基材'} + {item.finishName ?? '未绑定材质'} + + + {formatDate(item.deletedAt)} + +
+
+ {renderActions(item)} +
+ ) + + const renderAssetFamily = (item: TrashAssetFamily) => ( +
+
+
+ + {item.code} + + + {assetTypeLabel[item.assetType] ?? item.assetType} + + + {statusLabel[item.status] ?? item.status} + +
+
+ {item.name} +
+
+ {item.variantCount} 个规格款 + {item.creatorName ?? '未知创建人'} + + + {formatDate(item.deletedAt)} + +
+
+ {renderActions(item)} +
+ ) + + const renderProduct = (item: TrashProduct) => ( +
+
+ {item.thumbnailUrl ? ( + {item.name} + ) : ( + + )} +
+
+
+ {item.modelNumber} +
+
+ {item.name} +
+
+ {item.skuCount} 个 SKU + {item.creator.name} + + + {formatDate(item.deletedAt)} + +
+
+ {renderActions(item)} +
+ ) + + const renderSku = (item: TrashSKU) => ( +
+
+
+ {item.skuCode} +
+
+ {item.product.modelNumber} {item.product.name} +
+
+ {item.owner.name} + ¥{item.price.toFixed(2)} + + + {formatDate(item.deletedAt)} + +
+
+ {renderActions(item)} +
+ ) + + function renderActions(item: TrashItem) { + return ( +
+ + + +
+ ) + } + + return ( +
+
+
+
+
+
+ +
+
+

+ 回收站 +

+

+ 删除后保留 {retentionDays} 天 +

+
+
+
+ +
+ +
+
+ {tabs.map((tab) => { + const Icon = tab.icon + const active = activeTab === tab.key + return ( + + ) + })} +
+ + {loadError && ( +
+ + {loadError} +
+ )} + +
+ {isLoading ? ( +
+ + 加载中 +
+ ) : currentItems.length === 0 ? ( +
+ {getEmptyText(activeTab)} +
+ ) : ( + 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) + }) + )} +
+
+
- {/* 删除确认对话框 */} { @@ -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 提示 */}
) diff --git a/furniture-3d-system/src/app/trash/page.tsx b/furniture-3d-system/src/app/trash/page.tsx index 100944cc..fe82c2b0 100644 --- a/furniture-3d-system/src/app/trash/page.tsx +++ b/furniture-3d-system/src/app/trash/page.tsx @@ -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() { diff --git a/furniture-3d-system/src/components/3d/ModelViewer.tsx b/furniture-3d-system/src/components/3d/ModelViewer.tsx index 0831b35b..9f5c2130 100644 --- a/furniture-3d-system/src/components/3d/ModelViewer.tsx +++ b/furniture-3d-system/src/components/3d/ModelViewer.tsx @@ -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, diff --git a/furniture-3d-system/src/components/layout/AppShell.tsx b/furniture-3d-system/src/components/layout/AppShell.tsx index 84763d9a..c4c107cd 100644 --- a/furniture-3d-system/src/components/layout/AppShell.tsx +++ b/furniture-3d-system/src/components/layout/AppShell.tsx @@ -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 || diff --git a/furniture-3d-system/src/components/layout/SidebarUserCenter.tsx b/furniture-3d-system/src/components/layout/SidebarUserCenter.tsx index 1bda7583..9da91d5e 100644 --- a/furniture-3d-system/src/components/layout/SidebarUserCenter.tsx +++ b/furniture-3d-system/src/components/layout/SidebarUserCenter.tsx @@ -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() { 我的草稿 + 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" + > + + 回收站 +
{/* SECTION 3: Admin & Actions */} diff --git a/furniture-3d-system/src/components/layout/__tests__/AppShell.test.tsx b/furniture-3d-system/src/components/layout/__tests__/AppShell.test.tsx index 9e8ceb34..fbe133a1 100644 --- a/furniture-3d-system/src/components/layout/__tests__/AppShell.test.tsx +++ b/furniture-3d-system/src/components/layout/__tests__/AppShell.test.tsx @@ -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) diff --git a/furniture-3d-system/src/components/ui/ContextMenu.tsx b/furniture-3d-system/src/components/ui/ContextMenu.tsx index e8b3911f..1cb8e178 100644 --- a/furniture-3d-system/src/components/ui/ContextMenu.tsx +++ b/furniture-3d-system/src/components/ui/ContextMenu.tsx @@ -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) && (
{subtitle && ( -

+

{subtitle}

)} @@ -226,7 +229,7 @@ export function ContextMenu({ )} {/* 菜单组 */} -
+
{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` }} > -

+

{title}

-
+
{items.map((item, index) => ( + + {canManageUsers && (