refactor(asset-center): 拆分 AssetCenterClient 大文件(4775→3733 行)

提取 7 个独立模块:
- asset-center-types.ts(210 行):类型定义、常量、CSS 类名
- asset-center-utils.ts(615 行):纯工具函数(UV 编辑、键盘快捷键、预览节点变换、状态标签、相似性、CAD 导入、系列筛选)
- VariantEditModal.tsx(235 行):规格款编辑弹窗
- FamilyEditModal.tsx(261 行):资产族编辑弹窗
- ReviewRejectModal.tsx(55 行):驳回弹窗
- RoleEditorModal.tsx(47 行):连接角色编辑弹窗

主组件减少 1042 行,功能完全不变。
@
This commit is contained in:
WuYongJie 2026-05-29 16:09:23 +08:00
parent a5d728c7b9
commit de33fc385c
7 changed files with 1555 additions and 1173 deletions

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,261 @@
'use client'
import { ChevronDown, Pencil, Plus, Trash2 } from 'lucide-react'
import Button from '@/components/ui/Button'
import Checkbox from '@/components/ui/Checkbox'
import Input, { Textarea } from '@/components/ui/Input'
import Modal from '@/components/ui/Modal'
import { cn } from '@/lib/utils'
import { normalizeConnectionRoles } from '@/lib/open-product/connection-role-catalog'
import type { ConnectionRoleDefinition } from '@/lib/open-product/connection-role-catalog'
import type { AssetFamilyDTO, AssetSeriesDTO } from '@/server/services'
import type { AssetTypeFilter } from './asset-center-types'
import { getStatusLabel } from './asset-center-utils'
interface FamilyFormState {
type: AssetFamilyDTO['type']
code: string
name: string
description: string
assetSeriesId: string
selectedRoles: string[]
}
interface FamilyEditModalProps {
isOpen: boolean
mode: 'create' | 'edit'
form: FamilyFormState
onFormChange: (updater: (prev: FamilyFormState) => FamilyFormState) => void
typeTabs: Array<{ type: AssetTypeFilter; label: string; icon: React.ReactNode }>
assetSeries: AssetSeriesDTO[]
selectedSeriesId: string | null
roleDefinitions: ConnectionRoleDefinition[]
editingFamily: AssetFamilyDTO | null
submitting: boolean
onSave: () => void
onClose: () => void
onDelete: (family: AssetFamilyDTO, variantCount: number) => void
onCreateRole: () => void
onEditRole: (definition: ConnectionRoleDefinition) => void
onDeleteRole: (roleValue: string) => void
getVariantCount: (familyId: string) => number
}
export function FamilyEditModal({
isOpen,
mode,
form,
onFormChange,
typeTabs,
assetSeries,
selectedSeriesId,
roleDefinitions,
editingFamily,
submitting,
onSave,
onClose,
onDelete,
onCreateRole,
onEditRole,
onDeleteRole,
getVariantCount,
}: FamilyEditModalProps) {
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={mode === 'create' ? '新建资产族' : '编辑资产族'}
size="medium"
footer={
<div className="flex items-center justify-between">
{mode === 'edit' && editingFamily && (
<Button
variant="plain"
size="small"
style={{ color: 'var(--obs-shell-danger)' }}
onClick={() => {
const variantCount = getVariantCount(editingFamily.id)
onDelete(editingFamily, variantCount)
}}
>
<Trash2 className="h-3.5 w-3.5 mr-1" />
删除资产族
</Button>
)}
<div className="flex gap-2 ml-auto">
<Button variant="plain" size="small" onClick={onClose}>取消</Button>
<Button size="small" onClick={onSave} loading={submitting}>
{mode === 'create' ? '创建' : '保存修改'}
</Button>
</div>
</div>
}
>
<div className="space-y-4">
<div className="space-y-2">
<p className="text-xs font-bold text-[#2d3335]">资产类型</p>
<div className="grid grid-cols-4 gap-1 rounded-[6px] bg-black/[0.03] p-0.5 ring-1 ring-black/5">
{typeTabs.map((tab) => {
const type = tab.type as AssetFamilyDTO['type']
const active = form.type === type
return (
<button
key={type}
type="button"
onClick={() =>
onFormChange((current) => ({
...current,
type,
assetSeriesId: current.assetSeriesId || selectedSeriesId || '',
}))
}
className={cn(
'flex h-8 items-center justify-center gap-1.5 rounded-[4px] px-2 text-[10px] font-bold transition',
active
? 'bg-white text-[#2d3335] shadow-sm'
: 'text-[#5a6062]/50 hover:text-[#2d3335]',
)}
>
<span className="h-3.5 w-3.5">{tab.icon}</span>
<span>{tab.label}</span>
</button>
)
})}
</div>
</div>
<div className="grid gap-3 grid-cols-2">
<Input
label="编码"
value={form.code}
onChange={(e) => onFormChange((c) => ({ ...c, code: e.target.value }))}
placeholder="留空由系统自动生成"
/>
<Input
label="名称"
value={form.name}
onChange={(e) => onFormChange((c) => ({ ...c, name: e.target.value }))}
placeholder="例如 单板桌桌面板"
/>
</div>
{form.type !== 'HARDWARE' ? (
<div className="space-y-2">
<p className="text-xs font-bold text-[#2d3335]">归属系列</p>
<div className="grid grid-cols-2 gap-2">
{assetSeries.map((series) => {
const checked = form.assetSeriesId === series.id
return (
<label
key={series.id}
className={cn(
'flex cursor-pointer items-center gap-2 rounded-[6px] px-3 py-2 text-xs transition ring-1',
checked
? 'bg-[#2d3335] text-white ring-[#2d3335]'
: 'bg-black/[0.025] text-[#2d3335] ring-black/[0.04] hover:bg-black/[0.045]',
)}
>
<input
type="radio"
name="asset-family-series"
checked={checked}
onChange={() => {
onFormChange((current) => ({
...current,
assetSeriesId: series.id,
}))
}}
className="sr-only"
/>
<span className="min-w-0 flex-1 truncate font-bold">{series.name}</span>
<span className={cn('font-mono text-[10px]', checked ? 'text-white/60' : 'text-[#5a6062]/45')}>
{series.code}
</span>
</label>
)
})}
</div>
</div>
) : null}
<details className="group rounded-apple-lg border border-apple-separator bg-apple-bg-secondary/30">
<summary className="flex cursor-pointer list-none items-center justify-between gap-3 px-4 py-3">
<div className="flex items-center gap-2">
<p className="text-sm font-apple-semibold text-apple-label-primary">附加属性</p>
<span className="rounded-full bg-apple-bg-primary px-2 py-0.5 text-[11px] text-apple-label-tertiary">
可选
</span>
</div>
<ChevronDown className="h-4 w-4 text-apple-label-tertiary transition group-open:rotate-180" />
</summary>
<div className="space-y-4 border-t border-apple-separator/60 px-4 py-4">
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<p className="text-xs font-apple-medium text-apple-label-secondary">
连接角色
</p>
<Button variant="plain" size="small" onClick={onCreateRole}>
<Plus className="h-3.5 w-3.5" />
新增连接角色
</Button>
</div>
{roleDefinitions.length > 0 ? (
<div className="space-y-2">
{roleDefinitions.map((definition) => (
<div
key={definition.value}
className="flex items-center gap-2 rounded-apple-md border border-apple-separator bg-apple-bg-primary px-3 py-2"
>
<Checkbox
checked={form.selectedRoles.includes(definition.value)}
onChange={(e) => {
onFormChange((current) => ({
...current,
selectedRoles: e.target.checked
? normalizeConnectionRoles([...current.selectedRoles, definition.value])
: current.selectedRoles.filter((role) => role !== definition.value),
}))
}}
label={definition.label}
/>
<span className="ml-auto text-[11px] text-apple-label-tertiary">
{definition.builtIn ? '内置' : '自定义'}
</span>
<button
type="button"
onClick={() => onEditRole(definition)}
className="rounded-full p-1.5 text-apple-label-primary/60 hover:bg-apple-bg-secondary hover:text-apple-label-primary bg-apple-bg-secondary/40 shadow-apple-sm border border-apple-separator/10 transition"
title={`编辑 ${definition.label}`}
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={() => onDeleteRole(definition.value)}
className="rounded-full p-1.5 text-apple-label-primary/60 hover:bg-apple-red/10 hover:text-apple-red bg-apple-bg-secondary/40 shadow-apple-sm border border-apple-separator/10 transition"
title={`删除 ${definition.label}`}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
))}
</div>
) : (
<div className="rounded-apple-md border border-apple-separator bg-apple-bg-primary px-3 py-2 text-xs text-apple-label-secondary">
暂无连接角色。
</div>
)}
</div>
<Textarea
label="说明"
value={form.description}
onChange={(e) => onFormChange((c) => ({ ...c, description: e.target.value }))}
/>
<p className="text-[11px] text-apple-label-tertiary">
{mode === 'create'
? '创建后先进入草稿。'
: `当前状态:${editingFamily ? getStatusLabel(editingFamily.status) : '草稿'}。`}
</p>
</div>
</details>
</div>
</Modal>
)
}

View File

@ -0,0 +1,55 @@
'use client'
import Button from '@/components/ui/Button'
import { Textarea } from '@/components/ui/Input'
import Modal from '@/components/ui/Modal'
import type { ReviewDialogState } from './asset-center-types'
interface ReviewRejectModalProps {
reviewDialogState: ReviewDialogState | null
rejectReason: string
onRejectReasonChange: (value: string) => void
onConfirm: () => void
onClose: () => void
submitting: boolean
}
export function ReviewRejectModal({
reviewDialogState,
rejectReason,
onRejectReasonChange,
onConfirm,
onClose,
submitting,
}: ReviewRejectModalProps) {
return (
<Modal
isOpen={!!reviewDialogState}
onClose={onClose}
title={`驳回${reviewDialogState?.targetType === 'family' ? '资产族' : '规格款'}`}
size="small"
footer={
<div className="flex justify-end gap-2">
<Button variant="plain" size="small" onClick={onClose}>
取消
</Button>
<Button size="small" onClick={onConfirm} loading={submitting}>
确认驳回
</Button>
</div>
}
>
<div className="space-y-3">
<p className="text-sm text-apple-label-secondary">
当前目标:{reviewDialogState?.targetName ?? '-'}
</p>
<Textarea
label="驳回原因"
value={rejectReason}
onChange={(event) => onRejectReasonChange(event.target.value)}
placeholder="请填写驳回原因,后续录入人会看到这条说明。"
/>
</div>
</Modal>
)
}

View File

@ -0,0 +1,47 @@
'use client'
import Button from '@/components/ui/Button'
import Input from '@/components/ui/Input'
import Modal from '@/components/ui/Modal'
interface RoleEditorModalProps {
isOpen: boolean
mode: 'create' | 'edit'
label: string
onLabelChange: (value: string) => void
onSave: () => void
onClose: () => void
}
export function RoleEditorModal({
isOpen,
mode,
label,
onLabelChange,
onSave,
onClose,
}: RoleEditorModalProps) {
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={mode === 'create' ? '新增连接角色' : '编辑连接角色'}
size="small"
footer={
<div className="flex justify-end gap-2">
<Button variant="plain" size="small" onClick={onClose}>取消</Button>
<Button size="small" onClick={onSave}>
{mode === 'create' ? '新增' : '保存'}
</Button>
</div>
}
>
<Input
label="连接角色名称"
value={label}
onChange={(e) => onLabelChange(e.target.value)}
placeholder="例如 桌面板、桌腿、横梁连接件"
/>
</Modal>
)
}

View File

@ -0,0 +1,235 @@
'use client'
import { X } from 'lucide-react'
import Button from '@/components/ui/Button'
import Input from '@/components/ui/Input'
import Modal from '@/components/ui/Modal'
import Select from '@/components/ui/Select'
import { ModelUploader } from '@/components/upload/ModelUploader'
import type { AssetOperationProgressDTO, AssetVariantDTO, SubstrateDTO } from '@/server/services'
import type { VariantForm } from './asset-center-types'
interface VariantEditModalProps {
isOpen: boolean
mode: 'create' | 'edit'
isDuplicateMode: boolean
form: VariantForm
onFormChange: (updater: (prev: VariantForm) => VariantForm) => void
substrates: SubstrateDTO[]
familyVariantOptions: Array<{ id: string; name: string }>
variantEstimatedCostPrice: number | null
selectedVariantSubstrate: SubstrateDTO | null
variantSaveProgress: AssetOperationProgressDTO | null
submitting: boolean
editingVariantId: string | null
variants: AssetVariantDTO[]
onSave: () => void
onClose: () => void
onDuplicate: (variant: AssetVariantDTO) => void
onDelete: (variant: AssetVariantDTO) => void
onInherit: (variantId: string) => void
onUploadModel: (file: File) => Promise<string>
onUploadError: (error: { message: string }) => void
renderProgress: (progress: AssetOperationProgressDTO) => React.ReactNode
}
export function VariantEditModal({
isOpen,
mode,
isDuplicateMode,
form,
onFormChange,
substrates,
familyVariantOptions,
variantEstimatedCostPrice,
selectedVariantSubstrate,
variantSaveProgress,
submitting,
editingVariantId,
variants,
onSave,
onClose,
onDuplicate,
onDelete,
onInherit,
onUploadModel,
onUploadError,
renderProgress,
}: VariantEditModalProps) {
return (
<Modal
isOpen={isOpen}
onClose={submitting ? () => undefined : onClose}
title={mode === 'create'
? (isDuplicateMode ? '另存为新规格款' : '新建规格款')
: '编辑规格款'}
size="large"
footer={
<div className="flex items-center justify-between">
{mode === 'edit' && editingVariantId && (
<div className="flex items-center gap-2">
<Button
variant="plain"
size="small"
disabled={submitting}
onClick={() => {
const variant = variants.find((v) => v.id === editingVariantId)
if (variant) onDuplicate(variant)
}}
>
另存为新规格款
</Button>
<Button
variant="plain"
size="small"
disabled={submitting}
style={{ color: 'var(--obs-shell-danger)' }}
onClick={() => {
const variant = variants.find((v) => v.id === editingVariantId)
if (variant) onDelete(variant)
}}
>
删除规格款
</Button>
</div>
)}
<div className="flex gap-2 ml-auto">
<Button variant="plain" size="small" onClick={onClose} disabled={submitting}>取消</Button>
<Button size="small" onClick={onSave} loading={submitting}>
{mode === 'create'
? (isDuplicateMode ? '另存为新规格款' : '创建规格款')
: '保存修改'}
</Button>
</div>
</div>
}
>
<div className="space-y-4">
{variantSaveProgress ? renderProgress(variantSaveProgress) : null}
<div className="grid gap-3 grid-cols-2">
<Input
label="编码"
value={form.code}
onChange={(e) => onFormChange((c) => ({ ...c, code: e.target.value }))}
placeholder="留空由系统自动生成"
/>
<Input
label="名称"
value={form.name}
onChange={(e) => onFormChange((c) => ({ ...c, name: e.target.value }))}
placeholder="例如 桌面板 1200 白橡"
/>
</div>
<div className="grid gap-3 grid-cols-4">
<Input
label="供应商名称"
value={form.supplierName}
onChange={(e) => onFormChange((c) => ({ ...c, supplierName: e.target.value }))}
placeholder="例如 汇材板业"
/>
<Input
label="供应商代码"
value={form.supplierCode}
onChange={(e) => onFormChange((c) => ({ ...c, supplierCode: e.target.value }))}
placeholder="例如 HC"
/>
<Input
label="版本号"
value={form.versionNo}
onChange={(e) => onFormChange((c) => ({ ...c, versionNo: e.target.value }))}
placeholder="留空默认 V01"
/>
<Input
label="条码"
value={form.barcode}
onChange={(e) => onFormChange((c) => ({ ...c, barcode: e.target.value }))}
placeholder="留空自动生成"
/>
</div>
<div className="grid gap-3 grid-cols-2">
<Input
label="尺寸标签"
value={form.sizeLabel}
onChange={(e) => onFormChange((c) => ({ ...c, sizeLabel: e.target.value }))}
placeholder="例如 1200x600"
disabled={isDuplicateMode}
/>
<Select
label="基材"
searchable
searchPlaceholder="搜索基材名称或编码"
emptyText="没有匹配的基材"
value={form.substrateId}
onChange={(e) => onFormChange((c) => ({ ...c, substrateId: e.target.value }))}
options={[
{ value: '', label: '未设置' },
...substrates.map((s) => ({ value: s.id, label: `${s.name} · ${s.code}` })),
]}
/>
</div>
{mode === 'create' && (
<div className="grid gap-3 grid-cols-2">
<Select
label="继承自"
value={form.inheritedFromVariantId}
onChange={(e) => onInherit(e.target.value)}
disabled={isDuplicateMode}
options={[
{ value: '', label: '不继承' },
...familyVariantOptions.map((v) => ({ value: v.id, label: v.name })),
]}
/>
</div>
)}
<div>
<p className="mb-2 text-xs font-apple-medium text-apple-label-tertiary">3D 模型</p>
{form.modelUrl ? (
<div className="flex items-center gap-3 rounded-md border border-[var(--obs-shell-border)] bg-[var(--obs-shell-surface-bg)] p-3">
<p className="flex-1 text-sm text-apple-label-primary truncate">{form.modelUrl}</p>
{!isDuplicateMode ? (
<button
type="button"
onClick={() => onFormChange((c) => ({ ...c, modelUrl: '' }))}
className="text-apple-label-tertiary hover:text-apple-red"
>
<X className="w-4 h-4" />
</button>
) : null}
</div>
) : (
<ModelUploader
onUpload={async (file) => {
const url = await onUploadModel(file)
onFormChange((c) => ({ ...c, modelUrl: url }))
}}
onValidationError={onUploadError}
maxSize={50}
/>
)}
</div>
<div>
<p className="mb-2 text-xs font-apple-medium text-apple-label-tertiary">尺寸参数 (mm)</p>
<div className="grid gap-3 grid-cols-4">
<Input label="长度" value={form.length} onChange={(e) => onFormChange((c) => ({ ...c, length: e.target.value }))} placeholder="1200" disabled={isDuplicateMode} />
<Input label="宽度" value={form.width} onChange={(e) => onFormChange((c) => ({ ...c, width: e.target.value }))} placeholder="600" disabled={isDuplicateMode} />
<Input label="厚度" value={form.thickness} onChange={(e) => onFormChange((c) => ({ ...c, thickness: e.target.value }))} placeholder="25" disabled={isDuplicateMode} />
<Input label="损耗系数" value={form.lossFactor} onChange={(e) => onFormChange((c) => ({ ...c, lossFactor: e.target.value }))} placeholder="例如 1.15" />
</div>
</div>
<div>
<p className="mb-2 text-xs font-apple-medium text-apple-label-tertiary">挂载点</p>
<div className="grid gap-3 grid-cols-4">
<Input label="名称" value={form.mountPointName} onChange={(e) => onFormChange((c) => ({ ...c, mountPointName: e.target.value }))} />
<Input label="X" value={form.mountX} onChange={(e) => onFormChange((c) => ({ ...c, mountX: e.target.value }))} />
<Input label="Y" value={form.mountY} onChange={(e) => onFormChange((c) => ({ ...c, mountY: e.target.value }))} />
<Input label="Z" value={form.mountZ} onChange={(e) => onFormChange((c) => ({ ...c, mountZ: e.target.value }))} />
</div>
</div>
<div className="grid gap-3 grid-cols-2">
<Input label="预估成本价" value={variantEstimatedCostPrice != null ? String(variantEstimatedCostPrice) : '自动计算'} readOnly placeholder="选择基材后自动估算" />
<Input label="基材采购价" value={selectedVariantSubstrate?.suggestedPrice != null ? String(selectedVariantSubstrate.suggestedPrice) : '未设置'} readOnly placeholder="来自基材资料" />
</div>
</div>
</Modal>
)
}

View File

@ -0,0 +1,210 @@
import type {
AssetFamilyDTO,
AssetOperationProgressDTO,
AssetSeriesDTO,
AssetVariantDTO,
MaterialCategoryDTO,
MaterialDTO,
SubstrateDTO,
SubstrateDictionaryBundleDTO,
SimilarAssetVariantCandidateDTO,
AssetCadImportResult,
} from '@/server/services'
import type { PlatformMetricItem } from '@/components/platform/types'
import type {
AssetVariantUvProjectionAxis,
AssetVariantUvSurfaceConfig,
} from '@/lib/asset-variant-render-config'
// ---- Props ----
export 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
}
}
// ---- Preview transform types ----
export type PreviewNodeOffsetMap = Record<string, [number, number, number]>
export type PreviewNodeRotationMap = Record<string, [number, number, number, number]>
export type PreviewNodeTransformState = {
offsets: PreviewNodeOffsetMap
rotations: PreviewNodeRotationMap
}
export type Vector3Tuple = [number, number, number]
// ---- UV editor form ----
export type VariantUvEditorFormState = {
textureRepeat: string
textureRepeatU: string
textureRepeatV: string
textureRotation: string
textureSizeMm: string
textureProjectionAxis: '' | AssetVariantUvProjectionAxis
}
// ---- Asset type filter ----
export type AssetTypeFilter = AssetFamilyDTO['type']
// ---- Variant form ----
export 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,
}
}
export type VariantForm = ReturnType<typeof emptyVariantForm>
// ---- CAD import form ----
export type ImportableAssetType = Exclude<AssetFamilyDTO['type'], 'PACKAGING'>
export 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,
}
}
export type CadImportForm = ReturnType<typeof emptyCadImportForm>
export type CadImportFieldKey =
| 'familyId'
| 'familyCode'
| 'familyName'
| 'connectionRoles'
| 'variantName'
| 'substrateId'
| 'file'
export type CadImportFieldErrors = Partial<Record<CadImportFieldKey, string>>
// ---- Review dialog ----
export type ReviewDialogState = {
targetType: 'family' | 'variant'
targetId: string
targetName: string
}
export type ReviewVisibilityFilter = 'ALL' | 'PENDING_REVIEW' | 'REJECTED'
// ---- Constants ----
export const ASSET_CENTER_GIZMO_AXIS_COLORS = {
X: '#8f625d',
Y: '#5f766e',
Z: '#5f6d82',
} satisfies Record<'X' | 'Y' | 'Z', string>
export 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 }>
export 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
}>
export const ASSET_CENTER_SELECTED_SERIES_STORAGE_KEY = 'asset-center:selected-series-id'
export const ASSET_REVIEW_ROLES = new Set([
'SUPER_ADMIN',
'ADMIN',
'SUPERVISOR',
'SUPERVISOR',
])
// ---- CSS class constants ----
export 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'
export 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'
export 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'
export const compactRowNeutralStatusClassName =
'inline-flex h-5 shrink-0 items-center rounded-[3px] bg-[#eef2f2] px-1.5 text-[10px] font-apple-bold text-[#687477]'
export const compactRowSuccessStatusClassName =
'inline-flex h-5 shrink-0 items-center rounded-[3px] bg-[#e8eeee] px-1.5 text-[10px] font-apple-bold text-[#3f4a4d]'
export const compactRowWarningStatusClassName =
'inline-flex h-5 shrink-0 items-center rounded-[3px] bg-[#f0eeee] px-1.5 text-[10px] font-apple-bold text-[#6a6260]'
export 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'
export 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]'
export 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'
export 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'

View File

@ -0,0 +1,615 @@
import type { HolePanelBounds } from '@/components/3d/HoleOverlay2D'
import type { HoleData as HoleOverlayData } from '@/components/3d/HoleViewer'
import type {
AssetCadImportResult,
AssetFamilyDTO,
AssetSeriesDTO,
AssetVariantDTO,
SimilarAssetVariantCandidateDTO,
} from '@/server/services'
import type {
AssetVariantUvSurfaceConfig,
} from '@/lib/asset-variant-render-config'
import { getLevel1FromFamily } from '@/lib/packaging-materials/catalog'
import {
compactRowNeutralStatusClassName,
compactRowSuccessStatusClassName,
compactRowWarningStatusClassName,
compactRowDangerStatusClassName,
ASSET_REVIEW_ROLES,
type AssetTypeFilter,
type CadImportFieldErrors,
type CadImportForm,
type ImportableAssetType,
type PreviewNodeOffsetMap,
type PreviewNodeRotationMap,
type PreviewNodeTransformState,
type VariantUvEditorFormState,
type Vector3Tuple,
type ReviewVisibilityFilter,
} from './asset-center-types'
// ---- 通用工具 ----
export function sortByName<T extends { name: string }>(items: T[]): T[] {
return [...items].sort((left, right) => left.name.localeCompare(right.name, 'zh-CN'))
}
export 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)}`
}
export function isObjectRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
export function readRecordArray(value: unknown): Record<string, unknown>[] {
return Array.isArray(value)
? value.filter((item): item is Record<string, unknown> => isObjectRecord(item))
: []
}
export function readStringValue(value: unknown): string | null {
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null
}
export function readNumberValue(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) ? value : null
}
export 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
}
export function toNumberRecord(values: Record<string, string>): Record<string, number> {
const result: Record<string, number> = {}
for (const [key, value] of Object.entries(values)) {
const parsed = Number(value)
if (Number.isFinite(parsed) && parsed > 0) {
result[key] = parsed
}
}
return result
}
export function toOptionalNumber(value: string): number | null {
if (value.trim().length === 0) {
return null
}
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : null
}
// ---- UV 编辑器 ----
export function formatUvNumber(value: number | null | undefined) {
return typeof value === 'number' && Number.isFinite(value) ? String(value) : ''
}
export 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)))
}
export 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 ?? '',
}
}
export 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 } : {}),
}
}
// ---- 键盘快捷键 ----
export function isEditableElement(target: EventTarget | null) {
if (!(target instanceof HTMLElement)) {
return false
}
const tagName = target.tagName
return (
tagName === 'INPUT' ||
tagName === 'TEXTAREA' ||
tagName === 'SELECT' ||
target.isContentEditable
)
}
export 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'
}
export 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'))
)
}
// ---- 预览节点变换 ----
export function clonePreviewNodeOffsetMap(source: PreviewNodeOffsetMap): PreviewNodeOffsetMap {
return Object.fromEntries(
Object.entries(source).map(([nodeName, offset]) => [nodeName, [...offset] as [number, number, number]]),
)
}
export function clonePreviewNodeRotationMap(source: PreviewNodeRotationMap): PreviewNodeRotationMap {
return Object.fromEntries(
Object.entries(source).map(([nodeName, rotation]) => [
nodeName,
[...rotation] as [number, number, number, number],
]),
)
}
export 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]
)
})
}
export 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]
)
})
}
export function clonePreviewNodeTransformState(source: PreviewNodeTransformState): PreviewNodeTransformState {
return {
offsets: clonePreviewNodeOffsetMap(source.offsets),
rotations: clonePreviewNodeRotationMap(source.rotations),
}
}
export function arePreviewNodeTransformStatesEqual(
left: PreviewNodeTransformState,
right: PreviewNodeTransformState,
) {
return (
arePreviewNodeOffsetMapsEqual(left.offsets, right.offsets) &&
arePreviewNodeRotationMapsEqual(left.rotations, right.rotations)
)
}
// ---- 状态标签 ----
export function getStatusLabel(status: string) {
if (status === 'AVAILABLE') return '可用'
if (status === 'DISABLED') return '停用'
if (status === 'PENDING_REVIEW') return '待审核'
if (status === 'REJECTED') return '已驳回'
return '草稿'
}
export function shouldShowCompactStatus(status: string) {
return status !== 'DRAFT'
}
export function getCompactStatusClassName(status: string) {
if (status === 'AVAILABLE') {
return compactRowSuccessStatusClassName
}
if (status === 'REJECTED') {
return compactRowDangerStatusClassName
}
if (status === 'DISABLED') {
return compactRowWarningStatusClassName
}
return compactRowNeutralStatusClassName
}
// ---- 相似性 ----
export function getSimilarityConfidenceTone(confidence: SimilarAssetVariantCandidateDTO['confidence']) {
if (confidence === 'HIGH') return 'warning'
if (confidence === 'MEDIUM') return 'info'
return 'default'
}
export function getSimilarityConfidenceLabel(confidence: SimilarAssetVariantCandidateDTO['confidence']) {
if (confidence === 'HIGH') return '高相似'
if (confidence === 'MEDIUM') return '中相似'
return '低相似'
}
export function normalizeSimilarCandidates(
candidates: SimilarAssetVariantCandidateDTO[] | null | undefined,
): SimilarAssetVariantCandidateDTO[] {
return Array.isArray(candidates) ? candidates : []
}
export function normalizeCadImportResult(result: AssetCadImportResult): AssetCadImportResult {
return {
...result,
warnings: Array.isArray(result.warnings) ? result.warnings : [],
similarCandidates: normalizeSimilarCandidates(result.similarCandidates),
similarityNotice: result.similarityNotice ?? null,
}
}
// ---- CAD 导入 ----
export 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
}
export 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]),
]
}
export 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)
}),
)
}
export function sortDimensionsForComparison(size: Vector3Tuple): Vector3Tuple {
return [...size].map((value) => Math.abs(value)).sort((left, right) => right - left) as Vector3Tuple
}
export 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
}
export function buildAssetVariantHoleOverlayState(variant: AssetVariantDTO | null): {
holes: HoleOverlayData[]
activeHolePanelIds: string[]
panelNodeNameByPanelId: Record<string, string>
panelLabelByPanelId: Record<string, string>
panelBoundsByPanelId: Record<string, HolePanelBounds>
} {
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<string, string> = {}
const panelLabelByPanelId: Record<string, string> = {}
const panelBoundsByPanelId: Record<string, HolePanelBounds> = {}
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<string, string>()
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<string>()
const holes = rawHoles
.map<HoleOverlayData | null>((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,
}
}
// ---- 系列与分类筛选 ----
export function isPackagingMaterialLibraryFamily(family: AssetFamilyDTO): boolean {
return family.type === 'PACKAGING' && getLevel1FromFamily(family.metadata ?? null) !== null
}
export 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)
})
}
export 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)
}
export function familyVisibleInSeriesContext(family: AssetFamilyDTO, series: AssetSeriesDTO | null): boolean {
if (isPackagingMaterialLibraryFamily(family)) return false
if (family.type === 'HARDWARE') return true
return familyBelongsToSeries(family, series)
}
// ---- 资产族/规格款辅助 ----
export function buildVariantIdentityPrefix(familyCode: string): string {
const normalizedCode = familyCode.trim()
if (!normalizedCode) return ''
return normalizedCode.endsWith('-') ? normalizedCode : `${normalizedCode}-`
}
export function isImportableAssetType(type: AssetFamilyDTO['type']): type is ImportableAssetType {
return type === 'BOARD' || type === 'COMPONENT' || type === 'HARDWARE'
}
export function splitConnectionRoles(value: string): string[] {
return value
.split(/[,,]/)
.map((item) => item.trim())
.filter(Boolean)
}
export 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
}
export 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
}
export function canReviewAssets(role: string): boolean {
return ASSET_REVIEW_ROLES.has(role)
}
export function matchesReviewVisibilityFilter(status: string, filter: ReviewVisibilityFilter) {
return filter === 'ALL' || status === filter
}