refactor(asset-center): 提取预览右键菜单子菜单为独立组件

- PreviewSubmenus.tsx(252 行):基材选择子菜单 + UV 渲染参数子菜单
- AssetCenterClient.tsx 3268→3085 行
@
This commit is contained in:
WuYongJie 2026-05-29 19:02:41 +08:00
parent 85d9eb7acc
commit fe80bc77ba
2 changed files with 273 additions and 204 deletions

View File

@ -176,6 +176,7 @@ import { VariantEditModal } from './VariantEditModal'
import { FamilyEditModal } from './FamilyEditModal'
import { CadImportModal } from './CadImportModal'
import { FamilyListPanel } from './FamilyListPanel'
import { SubstrateSubmenu, UvSettingsSubmenu } from './PreviewSubmenus'
// ---- 主组件内的渲染辅助函数 ----
@ -2898,212 +2899,28 @@ export default function AssetCenterClient({
bodyMaxHeight={previewContextMenuBodyMaxHeight}
>
{activePreviewSubmenu === 'preview-substrate' ? (
<div
className="pointer-events-auto absolute animate-fadeInUp"
style={{
left: `${previewSubstrateSubmenuMetrics.left}px`,
top: `${previewSubstrateSubmenuMetrics.top}px`,
width: `${previewSubstrateSubmenuMetrics.width}px`,
maxHeight: `${previewSubstrateSubmenuMetrics.maxHeight}px`,
zIndex: 221,
animationDuration: '180ms',
}}
data-context-menu-root="true"
>
<div className="liquid-glass-subtle overflow-hidden rounded-apple-md border border-apple-separator/85 p-1.5 shadow-[0_14px_30px_rgba(15,23,42,0.16)]">
<div className="border-b border-apple-separator/80 px-1 pb-1.5">
<p className="px-1 pb-1 text-[11px] font-apple-system tracking-apple-wide text-[#5a6062]/75">
选择基材
</p>
<div className="relative">
<Search className="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-[#5a6062]/45" />
<input
aria-label="搜索基材"
value={previewSubstrateSearch}
onChange={(event) => setPreviewSubstrateSearch(event.target.value)}
placeholder="搜索名称或编码"
className="h-7 w-full rounded-apple-md border border-apple-separator/80 bg-white/80 pl-7 pr-2 text-[12px] text-[#2d3335] outline-hidden transition placeholder:text-[#5a6062]/45 focus:border-black/[0.12] focus:bg-white"
/>
</div>
</div>
<div
className="overflow-y-auto px-1 py-1"
style={{ maxHeight: `${Math.max(150, previewSubstrateSubmenuMetrics.maxHeight - 62)}px` }}
>
{previewFilteredSubstrateItems.length > 0 ? (
previewFilteredSubstrateItems.map((item) => (
<button
key={item.id}
type="button"
onClick={() => handlePreviewContextMenuItemClick(item)}
className="mb-0.5 flex h-8 w-full items-center justify-between gap-3 rounded-apple-md border border-transparent px-2 text-left text-apple-label-primary transition hover:border-apple-separator/80 hover:bg-white/85 focus-visible:ring-2 focus-visible:ring-black/[0.08]"
>
<span className="min-w-0 truncate text-[12px] font-apple-system font-apple-medium">
{item.label}
</span>
</button>
))
) : (
<div className="px-2 py-3 text-[12px] text-[#5a6062]/65">
没有匹配基材
</div>
)}
</div>
</div>
</div>
<SubstrateSubmenu
metrics={previewSubstrateSubmenuMetrics}
search={previewSubstrateSearch}
onSearchChange={setPreviewSubstrateSearch}
filteredItems={previewFilteredSubstrateItems}
onSelect={handlePreviewContextMenuItemClick}
/>
) : null}
{activePreviewSubmenu === 'preview-uv' ? (
<div
className="pointer-events-auto absolute animate-fadeInUp"
style={{
left: `${previewUvSettingsPanelPosition.left}px`,
top: `${previewUvSettingsPanelPosition.top}px`,
width: '264px',
maxHeight: `${previewUvSettingsPanelPosition.maxHeight}px`,
animationDuration: '180ms',
}}
data-context-menu-root="true"
>
<div className="liquid-glass-subtle overflow-hidden rounded-apple-md border border-apple-separator/85 p-1.5 shadow-[0_14px_30px_rgba(15,23,42,0.16)]">
<div className="flex items-start justify-between gap-2 border-b border-apple-separator/80 px-2 pb-1.5">
<div className="min-w-0">
<p className="text-[11px] font-apple-system tracking-apple-wide text-[#5a6062]/75">
{activeVariantUvNodeLabel}
{!selectedFinishMaterialConfig ? ' · 未绑定基材花色' : ''}
</p>
<p className="truncate text-[13px] font-apple-system font-apple-medium text-[#2d3335]">
渲染参数
</p>
</div>
<Badge variant="subtle" size="small" className="shrink-0 border-none bg-white/75 text-[#5a6062]">
{variantUvConfigDirty ? '待保存' : '已同步'}
</Badge>
</div>
<div
className="overflow-y-auto px-1 py-1"
style={{ maxHeight: `${Math.max(190, previewUvSettingsPanelPosition.maxHeight - 48)}px` }}
>
<div className="mb-0.5 flex h-6 w-full items-center justify-between gap-2 rounded-apple-md border border-transparent px-2 text-left text-apple-label-primary">
<span className="shrink-0 text-[12px] font-apple-system font-apple-medium">
投影轴
</span>
<div className="grid w-[148px] grid-cols-4 gap-0.5 rounded-apple-sm border border-apple-separator bg-white/70 p-0.5">
{UV_PROJECTION_AXIS_OPTIONS.map((option) => {
const isSelected = variantUvEditorForm.textureProjectionAxis === option.value
return (
<button
key={option.value || 'auto'}
type="button"
aria-label={option.label}
aria-pressed={isSelected}
onClick={() => handleSelectVariantUvProjectionAxis(option.value)}
className={cn(
'h-5 rounded-[3px] text-[11px] font-apple-system font-apple-medium transition',
isSelected
? 'bg-[#2d3335] text-white shadow-xs'
: 'text-[#5a6062] hover:bg-black/[0.04] hover:text-[#2d3335]',
)}
>
{option.label === '自动' ? '自' : option.label.replace(' 轴', '')}
</button>
)
})}
</div>
</div>
{[
{
label: '整体重复',
ariaLabel: '整体重复',
value: variantUvEditorForm.textureRepeat,
placeholder: '1.0',
field: 'textureRepeat' as const,
},
{
label: '旋转角度 °',
ariaLabel: '旋转角度 °',
value: variantUvEditorForm.textureRotation,
placeholder: '0°',
field: 'textureRotation' as const,
},
{
label: '纹理尺寸 mm',
ariaLabel: '纹理尺寸 mm',
value: variantUvEditorForm.textureSizeMm,
placeholder: '600',
field: 'textureSizeMm' as const,
},
{
label: 'U 压缩',
ariaLabel: 'U 压缩',
value: variantUvEditorForm.textureRepeatU,
placeholder: '1.0',
field: 'textureRepeatU' as const,
},
{
label: 'V 压缩',
ariaLabel: 'V 压缩',
value: variantUvEditorForm.textureRepeatV,
placeholder: '1.0',
field: 'textureRepeatV' as const,
},
].map((item) => (
<div
key={item.field}
className="mb-0.5 flex h-6 w-full items-center justify-between gap-2 rounded-apple-md border border-transparent px-2 text-left text-apple-label-primary"
>
<label
htmlFor={`variant-uv-${item.field}`}
className="shrink-0 text-[12px] font-apple-system font-apple-medium"
>
{item.label}
</label>
<input
id={`variant-uv-${item.field}`}
aria-label={item.ariaLabel}
inputMode="decimal"
value={item.value}
onChange={(event) =>
handlePatchVariantUvEditorForm({ [item.field]: event.target.value })
}
placeholder={item.placeholder}
className="h-6 w-[108px] rounded-apple-sm border border-apple-separator bg-white/80 px-2 text-right text-[12px] text-apple-label-primary outline-hidden transition placeholder:text-apple-label-tertiary focus:border-[#2d3335]"
/>
</div>
))}
<div className="mx-1 my-0.5 h-px bg-apple-separator/80" />
<div className="mb-0.5 grid grid-cols-2 gap-1">
<button
type="button"
onClick={handleResetVariantUvEditor}
disabled={submitting || !variantUvConfigDirty}
className="flex h-6 items-center rounded-apple-md border border-transparent px-2 text-left text-[12px] font-apple-system font-apple-medium text-apple-label-primary transition hover:border-apple-separator/80 hover:bg-white/85 disabled:cursor-not-allowed disabled:bg-apple-bg-secondary/25 disabled:text-apple-label-tertiary"
>
还原
</button>
<button
type="button"
onClick={handleClearVariantUvEditor}
disabled={submitting}
className="flex h-6 items-center rounded-apple-md border border-transparent px-2 text-left text-[12px] font-apple-system font-apple-medium text-apple-label-primary transition hover:border-apple-separator/80 hover:bg-white/85 disabled:cursor-not-allowed disabled:bg-apple-bg-secondary/25 disabled:text-apple-label-tertiary"
>
清空
</button>
</div>
<button
type="button"
disabled={submitting || !variantUvConfigDirty}
onClick={handleKeepVariantUvDraft}
className="mb-0.5 flex h-6 w-full items-center rounded-apple-md border border-transparent px-2 text-left text-[12px] font-apple-system font-apple-medium text-apple-label-primary transition hover:border-apple-separator/80 hover:bg-white/85 disabled:cursor-not-allowed disabled:bg-apple-bg-secondary/25 disabled:text-apple-label-tertiary"
>
暂存渲染参数
</button>
</div>
</div>
</div>
<UvSettingsSubmenu
position={previewUvSettingsPanelPosition}
form={variantUvEditorForm}
onFormPatch={handlePatchVariantUvEditorForm}
onSelectProjectionAxis={handleSelectVariantUvProjectionAxis}
onReset={handleResetVariantUvEditor}
onClear={handleClearVariantUvEditor}
onKeepDraft={handleKeepVariantUvDraft}
configDirty={variantUvConfigDirty}
submitting={submitting}
nodeLabel={activeVariantUvNodeLabel}
hasMaterialConfig={!!selectedFinishMaterialConfig}
/>
) : null}
</ContextMenu>
)}

View File

@ -0,0 +1,252 @@
'use client'
import { Search } from 'lucide-react'
import { Badge } from '@/components/ui/Badge'
import { cn } from '@/lib/utils'
import type { AssetVariantUvProjectionAxis } from '@/lib/asset-variant-render-config'
import type { VariantUvEditorFormState } from './asset-center-types'
import { UV_PROJECTION_AXIS_OPTIONS } from './asset-center-types'
// ---- 基材选择子菜单 ----
interface SubstrateSubmenuItem {
id: string
label: string
}
interface SubstrateSubmenuMetrics {
left: number
top: number
width: number
maxHeight: number
}
interface SubstrateSubmenuProps {
metrics: SubstrateSubmenuMetrics
search: string
onSearchChange: (value: string) => void
filteredItems: SubstrateSubmenuItem[]
onSelect: (item: SubstrateSubmenuItem) => void
}
export function SubstrateSubmenu({
metrics,
search,
onSearchChange,
filteredItems,
onSelect,
}: SubstrateSubmenuProps) {
return (
<div
className="pointer-events-auto absolute animate-fadeInUp"
style={{
left: `${metrics.left}px`,
top: `${metrics.top}px`,
width: `${metrics.width}px`,
maxHeight: `${metrics.maxHeight}px`,
zIndex: 221,
animationDuration: '180ms',
}}
data-context-menu-root="true"
>
<div className="liquid-glass-subtle overflow-hidden rounded-apple-md border border-apple-separator/85 p-1.5 shadow-[0_14px_30px_rgba(15,23,42,0.16)]">
<div className="border-b border-apple-separator/80 px-1 pb-1.5">
<p className="px-1 pb-1 text-[11px] font-apple-system tracking-apple-wide text-[#5a6062]/75">
选择基材
</p>
<div className="relative">
<Search className="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-[#5a6062]/45" />
<input
aria-label="搜索基材"
value={search}
onChange={(event) => onSearchChange(event.target.value)}
placeholder="搜索名称或编码"
className="h-7 w-full rounded-apple-md border border-apple-separator/80 bg-white/80 pl-7 pr-2 text-[12px] text-[#2d3335] outline-hidden transition placeholder:text-[#5a6062]/45 focus:border-black/[0.12] focus:bg-white"
/>
</div>
</div>
<div
className="overflow-y-auto px-1 py-1"
style={{ maxHeight: `${Math.max(150, metrics.maxHeight - 62)}px` }}
>
{filteredItems.length > 0 ? (
filteredItems.map((item) => (
<button
key={item.id}
type="button"
onClick={() => onSelect(item)}
className="mb-0.5 flex h-8 w-full items-center justify-between gap-3 rounded-apple-md border border-transparent px-2 text-left text-apple-label-primary transition hover:border-apple-separator/80 hover:bg-white/85 focus-visible:ring-2 focus-visible:ring-black/[0.08]"
>
<span className="min-w-0 truncate text-[12px] font-apple-system font-apple-medium">
{item.label}
</span>
</button>
))
) : (
<div className="px-2 py-3 text-[12px] text-[#5a6062]/65">
没有匹配基材
</div>
)}
</div>
</div>
</div>
)
}
// ---- UV 渲染参数子菜单 ----
interface UvSettingsPanelPosition {
left: number
top: number
maxHeight: number
}
interface UvSubmenuProps {
position: UvSettingsPanelPosition
form: VariantUvEditorFormState
onFormPatch: (patch: Partial<VariantUvEditorFormState>) => void
onSelectProjectionAxis: (value: '' | AssetVariantUvProjectionAxis) => void
onReset: () => void
onClear: () => void
onKeepDraft: () => void
configDirty: boolean
submitting: boolean
nodeLabel: string
hasMaterialConfig: boolean
}
export function UvSettingsSubmenu({
position,
form,
onFormPatch,
onSelectProjectionAxis,
onReset,
onClear,
onKeepDraft,
configDirty,
submitting,
nodeLabel,
hasMaterialConfig,
}: UvSubmenuProps) {
return (
<div
className="pointer-events-auto absolute animate-fadeInUp"
style={{
left: `${position.left}px`,
top: `${position.top}px`,
width: '264px',
maxHeight: `${position.maxHeight}px`,
animationDuration: '180ms',
}}
data-context-menu-root="true"
>
<div className="liquid-glass-subtle overflow-hidden rounded-apple-md border border-apple-separator/85 p-1.5 shadow-[0_14px_30px_rgba(15,23,42,0.16)]">
<div className="flex items-start justify-between gap-2 border-b border-apple-separator/80 px-2 pb-1.5">
<div className="min-w-0">
<p className="text-[11px] font-apple-system tracking-apple-wide text-[#5a6062]/75">
{nodeLabel}
{!hasMaterialConfig ? ' · 未绑定基材花色' : ''}
</p>
<p className="truncate text-[13px] font-apple-system font-apple-medium text-[#2d3335]">
渲染参数
</p>
</div>
<Badge variant="subtle" size="small" className="shrink-0 border-none bg-white/75 text-[#5a6062]">
{configDirty ? '待保存' : '已同步'}
</Badge>
</div>
<div
className="overflow-y-auto px-1 py-1"
style={{ maxHeight: `${Math.max(190, position.maxHeight - 48)}px` }}
>
<div className="mb-0.5 flex h-6 w-full items-center justify-between gap-2 rounded-apple-md border border-transparent px-2 text-left text-apple-label-primary">
<span className="shrink-0 text-[12px] font-apple-system font-apple-medium">
投影轴
</span>
<div className="grid w-[148px] grid-cols-4 gap-0.5 rounded-apple-sm border border-apple-separator bg-white/70 p-0.5">
{UV_PROJECTION_AXIS_OPTIONS.map((option) => {
const isSelected = form.textureProjectionAxis === option.value
return (
<button
key={option.value || 'auto'}
type="button"
aria-label={option.label}
aria-pressed={isSelected}
onClick={() => onSelectProjectionAxis(option.value)}
className={cn(
'h-5 rounded-[3px] text-[11px] font-apple-system font-apple-medium transition',
isSelected
? 'bg-[#2d3335] text-white shadow-xs'
: 'text-[#5a6062] hover:bg-black/[0.04] hover:text-[#2d3335]',
)}
>
{option.label === '自动' ? '自' : option.label.replace(' 轴', '')}
</button>
)
})}
</div>
</div>
{[
{ label: '整体重复', ariaLabel: '整体重复', value: form.textureRepeat, placeholder: '1.0', field: 'textureRepeat' as const },
{ label: '旋转角度 °', ariaLabel: '旋转角度 °', value: form.textureRotation, placeholder: '0°', field: 'textureRotation' as const },
{ label: '纹理尺寸 mm', ariaLabel: '纹理尺寸 mm', value: form.textureSizeMm, placeholder: '600', field: 'textureSizeMm' as const },
{ label: 'U 压缩', ariaLabel: 'U 压缩', value: form.textureRepeatU, placeholder: '1.0', field: 'textureRepeatU' as const },
{ label: 'V 压缩', ariaLabel: 'V 压缩', value: form.textureRepeatV, placeholder: '1.0', field: 'textureRepeatV' as const },
].map((item) => (
<div
key={item.field}
className="mb-0.5 flex h-6 w-full items-center justify-between gap-2 rounded-apple-md border border-transparent px-2 text-left text-apple-label-primary"
>
<label
htmlFor={`variant-uv-${item.field}`}
className="shrink-0 text-[12px] font-apple-system font-apple-medium"
>
{item.label}
</label>
<input
id={`variant-uv-${item.field}`}
aria-label={item.ariaLabel}
inputMode="decimal"
value={item.value}
onChange={(event) => onFormPatch({ [item.field]: event.target.value })}
placeholder={item.placeholder}
className="h-6 w-[108px] rounded-apple-sm border border-apple-separator bg-white/80 px-2 text-right text-[12px] text-apple-label-primary outline-hidden transition placeholder:text-apple-label-tertiary focus:border-[#2d3335]"
/>
</div>
))}
<div className="mx-1 my-0.5 h-px bg-apple-separator/80" />
<div className="mb-0.5 grid grid-cols-2 gap-1">
<button
type="button"
onClick={onReset}
disabled={submitting || !configDirty}
className="flex h-6 items-center rounded-apple-md border border-transparent px-2 text-left text-[12px] font-apple-system font-apple-medium text-apple-label-primary transition hover:border-apple-separator/80 hover:bg-white/85 disabled:cursor-not-allowed disabled:bg-apple-bg-secondary/25 disabled:text-apple-label-tertiary"
>
还原
</button>
<button
type="button"
onClick={onClear}
disabled={submitting}
className="flex h-6 items-center rounded-apple-md border border-transparent px-2 text-left text-[12px] font-apple-system font-apple-medium text-apple-label-primary transition hover:border-apple-separator/80 hover:bg-white/85 disabled:cursor-not-allowed disabled:bg-apple-bg-secondary/25 disabled:text-apple-label-tertiary"
>
清空
</button>
</div>
<button
type="button"
disabled={submitting || !configDirty}
onClick={onKeepDraft}
className="mb-0.5 flex h-6 w-full items-center rounded-apple-md border border-transparent px-2 text-left text-[12px] font-apple-system font-apple-medium text-apple-label-primary transition hover:border-apple-separator/80 hover:bg-white/85 disabled:cursor-not-allowed disabled:bg-apple-bg-secondary/25 disabled:text-apple-label-tertiary"
>
暂存渲染参数
</button>
</div>
</div>
</div>
)
}