@
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:
parent
a5d728c7b9
commit
de33fc385c
File diff suppressed because it is too large
Load Diff
261
furniture-3d-system/src/app/asset-center/FamilyEditModal.tsx
Normal file
261
furniture-3d-system/src/app/asset-center/FamilyEditModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
47
furniture-3d-system/src/app/asset-center/RoleEditorModal.tsx
Normal file
47
furniture-3d-system/src/app/asset-center/RoleEditorModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
235
furniture-3d-system/src/app/asset-center/VariantEditModal.tsx
Normal file
235
furniture-3d-system/src/app/asset-center/VariantEditModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
210
furniture-3d-system/src/app/asset-center/asset-center-types.ts
Normal file
210
furniture-3d-system/src/app/asset-center/asset-center-types.ts
Normal 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'
|
||||
615
furniture-3d-system/src/app/asset-center/asset-center-utils.ts
Normal file
615
furniture-3d-system/src/app/asset-center/asset-center-utils.ts
Normal 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
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user