diff --git a/furniture-3d-system/src/app/asset-center/AssetCenterClient.tsx b/furniture-3d-system/src/app/asset-center/AssetCenterClient.tsx index e612732a..30b6fb13 100644 --- a/furniture-3d-system/src/app/asset-center/AssetCenterClient.tsx +++ b/furniture-3d-system/src/app/asset-center/AssetCenterClient.tsx @@ -35,8 +35,6 @@ import { DeleteConfirmDialog } from '@/components/ui/DeleteConfirmDialog' import { useToast, ToastContainer } from '@/components/ui/Toast' import { AssetWorkbenchEmptyBackdrop } from '@/components/platform/AssetWorkbenchEmptyBackdrop' import { ModelPreviewPanel } from '@/components/platform/ModelPreviewPanel' -import type { HolePanelBounds } from '@/components/3d/HoleOverlay2D' -import type { HoleData as HoleOverlayData } from '@/components/3d/HoleViewer' import type { PlatformMetricItem } from '@/components/platform/types' import { applyAssetVariantUvConfigToMaterial, @@ -65,7 +63,6 @@ import { normalizeAssetVariantsWithUv, } from '@/lib/open-product/asset-variant-uv' import { calculateEstimatedCost } from '@/lib/open-product/substrate-rules' -import { getLevel1FromFamily } from '@/lib/packaging-materials/catalog' import { canOperateDesignWorkspace as canOperateDesignWorkspaceForUser } from '@/lib/role-scope' import { cn } from '@/lib/utils' import { @@ -97,85 +94,88 @@ import type { SubstrateDictionaryBundleDTO, } from '@/server/services' -// ---- Props ---- +// ---- 从拆分模块导入 ---- -interface AssetCenterClientProps { - initialFamilies: AssetFamilyDTO[] - initialVariants: AssetVariantDTO[] - initialSubstrates: SubstrateDTO[] - initialSubstrateDictionaries?: SubstrateDictionaryBundleDTO - initialAssetSeries: AssetSeriesDTO[] - finishes: MaterialDTO[] - materialCategories: MaterialCategoryDTO[] - metrics: PlatformMetricItem[] - user: { - id: string - name: string - email: string - role: string - businessRoles?: string[] | null - departmentPaths?: string[] | null - avatar: string | null - department: { id: string; name: string; fullPath?: string | null } | null - } -} +import type { + AssetCenterClientProps, + AssetTypeFilter, + CadImportFieldErrors, + CadImportForm, + ImportableAssetType, + PreviewNodeOffsetMap, + PreviewNodeRotationMap, + PreviewNodeTransformState, + ReviewDialogState, + ReviewVisibilityFilter, + VariantForm, + VariantUvEditorFormState, + Vector3Tuple, +} from './asset-center-types' +import { + ASSET_CENTER_GIZMO_AXIS_COLORS, + ASSET_CENTER_SELECTED_SERIES_STORAGE_KEY, + UV_PROJECTION_AXIS_OPTIONS, + VARIANT_SAVE_PROGRESS_STEPS, + compactRowActionClassName, + compactRowDangerActionClassName, + compactRowDangerStatusClassName, + compactRowIconActionClassName, + compactRowNeutralStatusClassName, + compactRowPrimaryActionClassName, + compactRowSuccessStatusClassName, + compactRowWarningStatusClassName, + emptyCadImportForm, + emptyVariantForm, + variantInlineCreateButtonClassName, + variantInlineImportButtonClassName, +} from './asset-center-types' +import { + arePreviewNodeOffsetMapsEqual, + arePreviewNodeRotationMapsEqual, + arePreviewNodeTransformStatesEqual, + buildAssetVariantHoleOverlayState, + buildCadImportChecklist, + buildCadImportFieldErrors, + buildVariantIdentityPrefix, + canReviewAssets, + clonePreviewNodeOffsetMap, + clonePreviewNodeRotationMap, + clonePreviewNodeTransformState, + createAssetOperationId, + createVariantUvEditorFormState, + familyBelongsToSeries, + familyVisibleInSeriesContext, + getCompactStatusClassName, + getSimilarityConfidenceLabel, + getSimilarityConfidenceTone, + getStatusLabel, + getVisibleFamiliesForTab, + isEditableElement, + isImportableAssetType, + isObjectRecord, + isPackagingMaterialLibraryFamily, + matchesRedoShortcut, + matchesReviewVisibilityFilter, + matchesUndoShortcut, + normalizeCadImportResult, + normalizeSimilarCandidates, + parseVariantUvEditorFormState, + readNumberValue, + readRecordArray, + readStringValue, + readVector3Tuple, + shouldShowCompactStatus, + sortByName, + splitConnectionRoles, + toNumberRecord, + toOptionalNumber, +} from './asset-center-utils' +import { RoleEditorModal } from './RoleEditorModal' +import { ReviewRejectModal } from './ReviewRejectModal' +import { VariantEditModal } from './VariantEditModal' +import { FamilyEditModal } from './FamilyEditModal' -type PreviewNodeOffsetMap = Record -type PreviewNodeRotationMap = Record -type PreviewNodeTransformState = { - offsets: PreviewNodeOffsetMap - rotations: PreviewNodeRotationMap -} - -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 = { - X: '#8f625d', - Y: '#5f766e', - 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 }> - -const VARIANT_SAVE_PROGRESS_STEPS = [ - { step: 'validating', label: '校验提交' }, - { step: 'creating', label: '创建草稿' }, - { step: 'thumbnail', label: '生成预览' }, - { step: 'similarity', label: '相似检测' }, - { step: 'syncing', label: '同步界面' }, - { step: 'completed', label: '完成' }, -] satisfies Array<{ - step: AssetOperationProgressDTO['step'] - label: string -}> - -// ---- 工具函数 ---- - -function sortByName(items: T[]): T[] { - return [...items].sort((left, right) => left.name.localeCompare(right.name, 'zh-CN')) -} - -function createAssetOperationId(prefix: string): string { - const cryptoApi = globalThis.crypto - if (typeof cryptoApi?.randomUUID === 'function') { - return `${prefix}-${cryptoApi.randomUUID()}` - } - return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}` -} +// ---- 主组件内的渲染辅助函数 ---- function buildInitialVariantSaveProgress(isDuplicateVariantMode: boolean): AssetOperationProgressDTO { return { @@ -256,233 +256,6 @@ function renderVariantSaveProgress(progress: AssetOperationProgressDTO) { ) } -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), - textureRepeatU: formatUvNumber(config?.textureRepeatU), - textureRepeatV: formatUvNumber(config?.textureRepeatV), - textureRotation: formatUvRotationDegrees(config?.textureRotation), - textureSizeMm: formatUvNumber(config?.textureSizeMm), - textureProjectionAxis: config?.textureProjectionAxis ?? '', - } -} - -function parseVariantUvEditorFormState( - state: VariantUvEditorFormState, -): AssetVariantUvSurfaceConfig | null { - const parseField = (value: string) => { - const normalized = value.trim() - if (!normalized) { - return undefined - } - - const parsed = Number(normalized) - return Number.isFinite(parsed) ? parsed : undefined - } - - const textureRepeat = parseField(state.textureRepeat) - 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 && - textureProjectionAxis === undefined - ) { - return null - } - - return { - ...(textureRepeat !== undefined ? { textureRepeat } : {}), - ...(textureRepeatU !== undefined ? { textureRepeatU } : {}), - ...(textureRepeatV !== undefined ? { textureRepeatV } : {}), - ...(textureRotation !== undefined ? { textureRotation } : {}), - ...(textureSizeMm !== undefined ? { textureSizeMm } : {}), - ...(textureProjectionAxis !== undefined ? { textureProjectionAxis } : {}), - } -} - -function isEditableElement(target: EventTarget | null) { - if (!(target instanceof HTMLElement)) { - return false - } - - const tagName = target.tagName - return ( - tagName === 'INPUT' || - tagName === 'TEXTAREA' || - tagName === 'SELECT' || - target.isContentEditable - ) -} - -function matchesUndoShortcut(event: KeyboardEvent) { - const hasCommandKey = event.ctrlKey || event.metaKey - if (!hasCommandKey || event.altKey || event.shiftKey) { - return false - } - - const code = event.code.toLowerCase() - const key = event.key.toLowerCase() - return code === 'keyz' || key === 'z' -} - -function matchesRedoShortcut(event: KeyboardEvent) { - const hasCommandKey = event.ctrlKey || event.metaKey - if (!hasCommandKey || event.altKey) { - return false - } - - const code = event.code.toLowerCase() - const key = event.key.toLowerCase() - return ( - (event.shiftKey && (code === 'keyz' || key === 'z')) || - (!event.shiftKey && (code === 'keyy' || key === 'y')) - ) -} - -function clonePreviewNodeOffsetMap(source: PreviewNodeOffsetMap): PreviewNodeOffsetMap { - return Object.fromEntries( - Object.entries(source).map(([nodeName, offset]) => [nodeName, [...offset] as [number, number, number]]), - ) -} - -function clonePreviewNodeRotationMap(source: PreviewNodeRotationMap): PreviewNodeRotationMap { - return Object.fromEntries( - Object.entries(source).map(([nodeName, rotation]) => [ - nodeName, - [...rotation] as [number, number, number, number], - ]), - ) -} - -function arePreviewNodeOffsetMapsEqual(left: PreviewNodeOffsetMap, right: PreviewNodeOffsetMap) { - const leftEntries = Object.entries(left) - const rightEntries = Object.entries(right) - - if (leftEntries.length !== rightEntries.length) { - return false - } - - return leftEntries.every(([nodeName, offset]) => { - const target = right[nodeName] - return ( - Boolean(target) && - target[0] === offset[0] && - target[1] === offset[1] && - target[2] === offset[2] - ) - }) -} - -function arePreviewNodeRotationMapsEqual(left: PreviewNodeRotationMap, right: PreviewNodeRotationMap) { - const leftEntries = Object.entries(left) - const rightEntries = Object.entries(right) - - if (leftEntries.length !== rightEntries.length) { - return false - } - - return leftEntries.every(([nodeName, rotation]) => { - const target = right[nodeName] - return ( - Boolean(target) && - target[0] === rotation[0] && - target[1] === rotation[1] && - target[2] === rotation[2] && - target[3] === rotation[3] - ) - }) -} - -function clonePreviewNodeTransformState(source: PreviewNodeTransformState): PreviewNodeTransformState { - return { - offsets: clonePreviewNodeOffsetMap(source.offsets), - rotations: clonePreviewNodeRotationMap(source.rotations), - } -} - -function arePreviewNodeTransformStatesEqual( - left: PreviewNodeTransformState, - right: PreviewNodeTransformState, -) { - return ( - arePreviewNodeOffsetMapsEqual(left.offsets, right.offsets) && - arePreviewNodeRotationMapsEqual(left.rotations, right.rotations) - ) -} - -function toNumberRecord(values: Record): Record { - const result: Record = {} - for (const [key, value] of Object.entries(values)) { - const parsed = Number(value) - if (Number.isFinite(parsed) && parsed > 0) { - result[key] = parsed - } - } - return result -} - -function toOptionalNumber(value: string): number | null { - if (value.trim().length === 0) { - return null - } - const parsed = Number(value) - return Number.isFinite(parsed) ? parsed : null -} - -function getStatusLabel(status: string) { - if (status === 'AVAILABLE') return '可用' - if (status === 'DISABLED') return '停用' - if (status === 'PENDING_REVIEW') return '待审核' - if (status === 'REJECTED') return '已驳回' - return '草稿' -} - -function shouldShowCompactStatus(status: string) { - return status !== 'DRAFT' -} - -function getCompactStatusClassName(status: string) { - if (status === 'AVAILABLE') { - return compactRowSuccessStatusClassName - } - if (status === 'REJECTED') { - return compactRowDangerStatusClassName - } - if (status === 'DISABLED') { - return compactRowWarningStatusClassName - } - return compactRowNeutralStatusClassName -} - function renderCompactStatus(status: string, testId?: string) { if (!shouldShowCompactStatus(status)) { return null @@ -495,257 +268,6 @@ function renderCompactStatus(status: string, testId?: string) { ) } -function getSimilarityConfidenceTone(confidence: SimilarAssetVariantCandidateDTO['confidence']) { - if (confidence === 'HIGH') return 'warning' - if (confidence === 'MEDIUM') return 'info' - return 'default' -} - -function getSimilarityConfidenceLabel(confidence: SimilarAssetVariantCandidateDTO['confidence']) { - if (confidence === 'HIGH') return '高相似' - if (confidence === 'MEDIUM') return '中相似' - return '低相似' -} - -function normalizeSimilarCandidates( - candidates: SimilarAssetVariantCandidateDTO[] | null | undefined, -): SimilarAssetVariantCandidateDTO[] { - return Array.isArray(candidates) ? candidates : [] -} - -function normalizeCadImportResult(result: AssetCadImportResult): AssetCadImportResult { - return { - ...result, - warnings: Array.isArray(result.warnings) ? result.warnings : [], - similarCandidates: normalizeSimilarCandidates(result.similarCandidates), - similarityNotice: result.similarityNotice ?? null, - } -} - -function isObjectRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value) -} - -function readRecordArray(value: unknown): Record[] { - return Array.isArray(value) - ? value.filter((item): item is Record => isObjectRecord(item)) - : [] -} - -function readStringValue(value: unknown): string | null { - return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null -} - -function readNumberValue(value: unknown): number | null { - return typeof value === 'number' && Number.isFinite(value) ? value : null -} - -function readVector3Tuple(value: unknown): Vector3Tuple | null { - if (Array.isArray(value) && value.length >= 3) { - const x = readNumberValue(value[0]) - const y = readNumberValue(value[1]) - const z = readNumberValue(value[2]) - return x !== null && y !== null && z !== null ? [x, y, z] : null - } - - if (isObjectRecord(value)) { - const x = readNumberValue(value.x) - const y = readNumberValue(value.y) - const z = readNumberValue(value.z) - return x !== null && y !== null && z !== null ? [x, y, z] : null - } - - return null -} - -function readPanelBounds(value: unknown): HolePanelBounds | null { - if (!isObjectRecord(value)) return null - const min = readVector3Tuple(value.min) - const max = readVector3Tuple(value.max) - return min && max ? { min, max } : null -} - -function getBoundsSize(bounds: HolePanelBounds): Vector3Tuple { - return [ - Math.abs(bounds.max[0] - bounds.min[0]), - Math.abs(bounds.max[1] - bounds.min[1]), - Math.abs(bounds.max[2] - bounds.min[2]), - ] -} - -function getMaxRelativeDimensionDiff(left: Vector3Tuple, right: Vector3Tuple): number { - return Math.max( - ...left.map((value, index) => { - const baseline = Math.max(Math.abs(right[index]), 1) - return Math.abs(1 - Math.abs(value) / baseline) - }), - ) -} - -function sortDimensionsForComparison(size: Vector3Tuple): Vector3Tuple { - return [...size].map((value) => Math.abs(value)).sort((left, right) => right - left) as Vector3Tuple -} - -function shouldRemapCadPanelBounds(bounds: HolePanelBounds, panelSize: Vector3Tuple | null): boolean { - if (!panelSize || Math.max(...panelSize.map((value) => Math.abs(value))) <= 0) { - return false - } - - const boundsSize = getBoundsSize(bounds) - const sortedBoundsSize = sortDimensionsForComparison(boundsSize) - const sortedPanelSize = sortDimensionsForComparison(panelSize) - return getMaxRelativeDimensionDiff(sortedBoundsSize, sortedPanelSize) > 0.25 -} - -function buildAssetVariantHoleOverlayState(variant: AssetVariantDTO | null): { - holes: HoleOverlayData[] - activeHolePanelIds: string[] - panelNodeNameByPanelId: Record - panelLabelByPanelId: Record - panelBoundsByPanelId: Record -} { - if (!variant || variant.assetType === 'HARDWARE') { - return { - holes: [], - activeHolePanelIds: [], - panelNodeNameByPanelId: {}, - panelLabelByPanelId: {}, - panelBoundsByPanelId: {}, - } - } - - const assemblyBasis = isObjectRecord(variant.assemblyBasis) ? variant.assemblyBasis : null - const businessData = isObjectRecord(variant.businessData) ? variant.businessData : null - const cadData = isObjectRecord(businessData?.cadData) ? businessData.cadData : null - const holeGroups = readRecordArray(assemblyBasis?.holeGroups ?? cadData?.holeGroups) - const panelNodeNameByPanelId: Record = {} - const panelLabelByPanelId: Record = {} - const panelBoundsByPanelId: Record = {} - - readRecordArray(cadData?.panels).forEach((panel, index) => { - const panelId = readStringValue(panel.id) ?? readStringValue(panel.panelId) - if (!panelId) return - - const nodeName = - readStringValue(panel.gltfNodeName) ?? - readStringValue(panel.nodeName) ?? - readStringValue(panel.name) ?? - panelId - panelNodeNameByPanelId[panelId] = nodeName - panelLabelByPanelId[panelId] = - readStringValue(panel.name) ?? - readStringValue(panel.label) ?? - readStringValue(panel.panelType) ?? - `板件 ${index + 1}` - - const bounds = readPanelBounds(panel.boundingBox) - if (bounds) { - const panelSize = readVector3Tuple(panel.size) - const needsRemap = shouldRemapCadPanelBounds(bounds, panelSize) - panelBoundsByPanelId[panelId] = needsRemap - ? { ...bounds, remapToModelBounds: true } - : bounds - } - }) - - const groupPanelIdById = new Map() - holeGroups.forEach((group) => { - const groupId = readStringValue(group.id) - const panelId = readStringValue(group.panelId) - if (groupId && panelId) { - groupPanelIdById.set(groupId, panelId) - } - }) - - const rawHoles = readRecordArray(assemblyBasis?.holes).length > 0 - ? readRecordArray(assemblyBasis?.holes) - : readRecordArray(cadData?.holes) - const activeHolePanelIds = new Set() - const holes = rawHoles - .map((hole, index) => { - const id = readStringValue(hole.id) ?? `asset-hole-${index + 1}` - const groupId = readStringValue(hole.groupId) ?? readStringValue(hole.holeGroupId) - const panelId = - readStringValue(hole.panelId) ?? - (groupId ? groupPanelIdById.get(groupId) ?? null : null) - const position = readVector3Tuple(hole.position) - if (!panelId || !position) return null - - const normal = readVector3Tuple(hole.normal) ?? [0, 0, 1] - const diameter = readNumberValue(hole.diameter) ?? 0 - const depth = readNumberValue(hole.depth) ?? 0 - const holeType = - readStringValue(hole.holeType) ?? - readStringValue(hole.type) ?? - readStringValue(hole.inferredUsage) ?? - null - - activeHolePanelIds.add(panelId) - if (!panelNodeNameByPanelId[panelId]) { - panelNodeNameByPanelId[panelId] = panelId - } - if (!panelLabelByPanelId[panelId]) { - panelLabelByPanelId[panelId] = panelId.replace(/^cad_/i, '') - } - - return { - id, - panelId, - position, - diameter, - depth, - normal, - holeType, - confidence: readNumberValue(hole.confidence) ?? 1, - ...(groupId ? { groupId } : {}), - } - }) - .filter((item): item is HoleOverlayData => item !== null) - - return { - holes, - activeHolePanelIds: Array.from(activeHolePanelIds), - panelNodeNameByPanelId, - panelLabelByPanelId, - panelBoundsByPanelId, - } -} - -// ---- 系列与分类筛选 ---- - -const ASSET_CENTER_SELECTED_SERIES_STORAGE_KEY = 'asset-center:selected-series-id' -type AssetTypeFilter = AssetFamilyDTO['type'] - -function isPackagingMaterialLibraryFamily(family: AssetFamilyDTO): boolean { - return family.type === 'PACKAGING' && getLevel1FromFamily(family.metadata ?? null) !== null -} - -function getVisibleFamiliesForTab( - families: AssetFamilyDTO[], - activeTab: AssetTypeFilter, -): AssetFamilyDTO[] { - return families.filter((family) => { - if (family.type !== activeTab) return false - if (activeTab !== 'PACKAGING') return true - return !isPackagingMaterialLibraryFamily(family) - }) -} - -function familyBelongsToSeries(family: AssetFamilyDTO, series: AssetSeriesDTO | null): boolean { - if (!series) return false - if (family.assetSeriesId) { - return family.assetSeriesId === series.id - } - const compatibleSeries = family.compatibleSeries ?? [] - return compatibleSeries.some((item) => item.id === series.id || item.code === series.code) -} - -function familyVisibleInSeriesContext(family: AssetFamilyDTO, series: AssetSeriesDTO | null): boolean { - if (isPackagingMaterialLibraryFamily(family)) return false - if (family.type === 'HARDWARE') return true - return familyBelongsToSeries(family, series) -} - const TYPE_TABS: { type: AssetTypeFilter; label: string; icon: React.ReactNode }[] = [ { type: 'BOARD', label: '板材', icon: }, { type: 'COMPONENT', label: '组件', icon: }, @@ -753,197 +275,6 @@ const TYPE_TABS: { type: AssetTypeFilter; label: string; icon: React.ReactNode } { type: 'PACKAGING', label: '包装', icon: }, ] -// ---- 空表单模板 ---- - -function buildVariantIdentityPrefix(familyCode: string): string { - const normalizedCode = familyCode.trim() - if (!normalizedCode) return '' - return normalizedCode.endsWith('-') ? normalizedCode : `${normalizedCode}-` -} - -function emptyVariantForm(assetFamilyId: string, identityPrefix = '') { - return { - assetFamilyId, - code: '', - name: identityPrefix, - sizeLabel: '', - substrateId: '', - finishId: '', - supplierName: '', - supplierCode: '', - versionNo: '', - barcode: '', - length: '', - width: '', - thickness: '', - modelUrl: '', - mountPointName: 'origin', - mountX: '0', - mountY: '0', - mountZ: '0', - lossFactor: '1', - inheritedFromVariantId: '', - includeLegConnectorRole: false, - } -} - -type VariantForm = ReturnType - -type ImportableAssetType = Exclude - -function isImportableAssetType(type: AssetFamilyDTO['type']): type is ImportableAssetType { - return type === 'BOARD' || type === 'COMPONENT' || type === 'HARDWARE' -} - -function emptyCadImportForm(assetType: ImportableAssetType, useExistingFamily: boolean) { - return { - assetType, - familyMode: useExistingFamily ? ('existing' as const) : ('create' as const), - familyId: '', - familyCode: '', - familyName: '', - familyDescription: '', - connectionRoles: '', - variantCode: '', - variantName: '', - sizeLabel: '', - substrateId: '', - finishId: '', - lossFactor: '1', - file: null as File | null, - } -} - -type CadImportForm = ReturnType - -type CadImportFieldKey = - | 'familyId' - | 'familyCode' - | 'familyName' - | 'connectionRoles' - | 'variantName' - | 'substrateId' - | 'file' - -type CadImportFieldErrors = Partial> - -function splitConnectionRoles(value: string): string[] { - return value - .split(/[,,]/) - .map((item) => item.trim()) - .filter(Boolean) -} - -function buildCadImportFieldErrors(params: { - form: CadImportForm - assetSeriesId?: string | null -}): CadImportFieldErrors { - const { form, assetSeriesId } = params - const errors: CadImportFieldErrors = {} - - if (form.familyMode === 'existing') { - if (!form.familyId) { - errors.familyId = '请选择目标资产族' - } - } else { - if (!assetSeriesId && !form.familyCode.trim()) { - errors.familyCode = '请填写资产族编码' - } - if (!form.familyName.trim()) { - errors.familyName = '请填写资产族名称' - } - } - - if (!form.variantName.trim()) { - errors.variantName = '请填写规格款名称' - } - if (form.assetType === 'BOARD') { - if (!form.substrateId) { - errors.substrateId = '板材必须选择基材' - } - } - - if (!form.file) { - errors.file = '请上传 CAD 导出的 ZIP 文件' - } - - return errors -} - -function buildCadImportChecklist(assetType: ImportableAssetType): string[] { - const notes = [ - '仅支持 CAD 插件 v2.2.14 及以上导出的 ZIP。', - '压缩包内需包含模型文件和 hardware.json,根级必须保留 holes 数组;没有孔位也要传 holes: []。', - ] - - if (assetType === 'BOARD') { - notes.push('如果 ZIP 里有多块板件,当前会按第一块板件尺寸导入规格款。') - } - - if (assetType === 'COMPONENT') { - notes.push('组件尺寸优先按模型包围盒回填,孔位语义会继续保留给后续自由装配。') - } - - if (assetType === 'HARDWARE') { - notes.push('五金会优先保留安装基准与规格签名,不走板件孔位归属。') - } - - return notes -} - -const ASSET_REVIEW_ROLES = new Set([ - 'SUPER_ADMIN', - 'ADMIN', - 'SUPERVISOR', - 'SUPERVISOR', -]) - -function canReviewAssets(role: string): boolean { - return ASSET_REVIEW_ROLES.has(role) -} - -const compactRowActionClassName = - 'inline-flex h-6 items-center rounded-[4px] px-1.5 text-[10px] font-apple-bold text-[#687477] transition hover:bg-black/[0.045] hover:text-[#263033] disabled:cursor-not-allowed disabled:opacity-45' - -const compactRowPrimaryActionClassName = - 'inline-flex h-6 items-center rounded-[4px] bg-[#edf1f1] px-1.5 text-[10px] font-apple-bold text-[#101719] transition hover:bg-[#e3e9e9] disabled:cursor-not-allowed disabled:opacity-45' - -const compactRowDangerActionClassName = - 'inline-flex h-6 items-center rounded-[4px] px-1.5 text-[10px] font-apple-bold text-red-600 transition hover:bg-red-500/10 disabled:cursor-not-allowed disabled:opacity-45' - -const compactRowNeutralStatusClassName = - 'inline-flex h-5 shrink-0 items-center rounded-[3px] bg-[#eef2f2] px-1.5 text-[10px] font-apple-bold text-[#687477]' - -const compactRowSuccessStatusClassName = - 'inline-flex h-5 shrink-0 items-center rounded-[3px] bg-[#e8eeee] px-1.5 text-[10px] font-apple-bold text-[#3f4a4d]' - -const compactRowWarningStatusClassName = - 'inline-flex h-5 shrink-0 items-center rounded-[3px] bg-[#f0eeee] px-1.5 text-[10px] font-apple-bold text-[#6a6260]' - -const compactRowDangerStatusClassName = - 'inline-flex h-5 shrink-0 items-center rounded-[3px] bg-red-500/[0.08] px-1.5 text-[10px] font-apple-bold text-red-600' - -const compactRowIconActionClassName = - 'inline-flex h-6 w-6 items-center justify-center rounded-[4px] text-[#7a878a] transition hover:bg-black/[0.045] hover:text-[#263033]' - -const variantInlineCreateButtonClassName = - 'flex h-9 min-w-0 items-center justify-center gap-2 rounded-[5px] border border-black/[0.08] bg-white px-3 text-[11px] font-black text-[#263033] shadow-[0_1px_2px_rgba(16,23,25,0.04)] transition hover:border-black/[0.14] hover:bg-[#f8faf9] hover:text-[#101719] focus-visible:ring-1 focus-visible:ring-[#2d3335]/15' - -const variantInlineImportButtonClassName = - 'flex h-9 min-w-0 items-center justify-center gap-2 rounded-[5px] border border-black/[0.08] bg-white px-3 text-[11px] font-black text-[#263033] shadow-[0_1px_2px_rgba(16,23,25,0.04)] transition hover:border-black/[0.14] hover:bg-[#f8faf9] hover:text-[#101719] focus-visible:ring-1 focus-visible:ring-[#2d3335]/15' - -type ReviewDialogState = { - targetType: 'family' | 'variant' - targetId: string - targetName: string -} - -type ReviewVisibilityFilter = 'ALL' | 'PENDING_REVIEW' | 'REJECTED' - -function matchesReviewVisibilityFilter(status: string, filter: ReviewVisibilityFilter) { - return filter === 'ALL' || status === filter -} - // ---- 主组件 ---- export default function AssetCenterClient({ @@ -4291,439 +3622,67 @@ export default function AssetCenterClient({ ) : null} - {/* 规格款弹窗 —— 放在资产中心工作台外部,避免被父级 overflow/transform 裁切 */} - undefined : closeVariantModal} - title={variantModalMode === 'create' - ? (isDuplicateVariantMode ? '另存为新规格款' : '新建规格款') - : '编辑规格款'} - size="large" - footer={ -
- {variantModalMode === 'edit' && editingVariantId && ( -
- - -
- )} -
- - -
-
- } - > -
- {variantSaveProgress ? renderVariantSaveProgress(variantSaveProgress) : null} -
- setVariantForm((c) => ({ ...c, code: e.target.value }))} - placeholder="留空由系统自动生成" - /> - setVariantForm((c) => ({ ...c, name: e.target.value }))} - placeholder="例如 桌面板 1200 白橡" - /> -
-
- setVariantForm((c) => ({ ...c, supplierName: e.target.value }))} - placeholder="例如 汇材板业" - /> - setVariantForm((c) => ({ ...c, supplierCode: e.target.value }))} - placeholder="例如 HC" - /> - setVariantForm((c) => ({ ...c, versionNo: e.target.value }))} - placeholder="留空默认 V01" - /> - setVariantForm((c) => ({ ...c, barcode: e.target.value }))} - placeholder="留空自动生成" - /> -
-
- setVariantForm((c) => ({ ...c, sizeLabel: e.target.value }))} - placeholder="例如 1200x600" - disabled={isDuplicateVariantMode} - /> - applyInheritedVariant(e.target.value)} - disabled={isDuplicateVariantMode} - options={[ - { value: '', label: '不继承' }, - ...familyVariantOptions.map((v) => ({ value: v.id, label: v.name })), - ]} - /> -
- )} -
-

3D 模型

- {variantForm.modelUrl ? ( -
-

{variantForm.modelUrl}

- {!isDuplicateVariantMode ? ( - - ) : null} -
- ) : ( - { - const url = await uploadModelFile(file) - setVariantForm((c) => ({ ...c, modelUrl: url })) - }} - onValidationError={(err) => toast.error(err.message)} - maxSize={50} - /> - )} -
-
-

尺寸参数 (mm)

-
- 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" /> -
-
-
-

挂载点

-
- setVariantForm((c) => ({ ...c, mountPointName: e.target.value }))} /> - setVariantForm((c) => ({ ...c, mountX: e.target.value }))} /> - setVariantForm((c) => ({ ...c, mountY: e.target.value }))} /> - setVariantForm((c) => ({ ...c, mountZ: e.target.value }))} /> -
-
-
- - -
-
-
+ mode={variantModalMode} + isDuplicateMode={isDuplicateVariantMode} + form={variantForm} + onFormChange={setVariantForm} + substrates={substrates} + familyVariantOptions={familyVariantOptions} + variantEstimatedCostPrice={variantEstimatedCostPrice} + selectedVariantSubstrate={selectedVariantSubstrate} + variantSaveProgress={variantSaveProgress} + submitting={submitting} + editingVariantId={editingVariantId} + variants={variants} + onSave={handleSaveVariant} + onClose={closeVariantModal} + onDuplicate={openDuplicateVariantModal} + onDelete={(variant) => setDeleteTarget({ type: 'variant', id: variant.id, name: variant.name })} + onInherit={applyInheritedVariant} + onUploadModel={uploadModelFile} + onUploadError={(err) => toast.error(err.message)} + renderProgress={renderVariantSaveProgress} + /> - {/* 资产族弹窗 */} - - {familyModalMode === 'edit' && ( - - )} -
- - -
- - } - > -
-
-

资产类型

-
- {TYPE_TABS.map((tab) => { - const type = tab.type as AssetFamilyDTO['type'] - const active = familyForm.type === type - return ( - - ) - })} -
-
-
- setFamilyForm((c) => ({ ...c, code: e.target.value }))} - placeholder="留空由系统自动生成" - /> - setFamilyForm((c) => ({ ...c, name: e.target.value }))} - placeholder="例如 单板桌桌面板" - /> -
- {familyForm.type !== 'HARDWARE' ? ( -
-

归属系列

-
- {initialAssetSeries.map((series) => { - const checked = familyForm.assetSeriesId === series.id - return ( - - ) - })} -
-
- ) : null} -
- -
-

附加属性

- - 可选 - -
- -
-
-
-
-

- 连接角色 -

- -
- {familyRoleDefinitions.length > 0 ? ( -
- {familyRoleDefinitions.map((definition) => ( -
- { - setFamilyForm((current) => ({ - ...current, - selectedRoles: e.target.checked - ? normalizeConnectionRoles([...current.selectedRoles, definition.value]) - : current.selectedRoles.filter((role) => role !== definition.value), - })) - }} - label={definition.label} - /> - - {definition.builtIn ? '内置' : '自定义'} - - - -
- ))} -
- ) : ( -
- 暂无连接角色。 -
- )} -
-