fix asset-center material library sync and workflow verification

This commit is contained in:
WuYongJie 2026-04-08 13:54:51 +08:00
parent 29036152d1
commit f0bf6c09a2
6 changed files with 440 additions and 328 deletions

View File

@ -13,51 +13,11 @@
## 当前未完成项
### 待前端修正 (前端 UI Agent 负责)
- [ ] **资产中心弹窗主按钮禁用态过浅**
归属:`前端负责`
说明:资产中心内一批浅色弹窗底部的主按钮,在不可提交或 loading 前后状态下,存在“底色发灰、文字发白、和浅色 footer 混在一起”的问题,主次层级不清晰。请统一修正 `filled` 主按钮在浅色主题下的默认态 / disabled 态 / loading 态对比度,不要只改单个弹窗。
涉及位置:
`资产族弹窗`:`创建` / `保存修改`
`规格款弹窗`:`创建规格款` / `保存修改`
`CAD 导入弹窗`:`开始导入` / `继续导入`
`新增/编辑连接角色弹窗`:`新增` / `保存`
`基础资料库` 页面弹窗:`创建基材` / `保存修改` / `创建分类` / `创建饰面`
- [ ] **资产中心弹窗次级文字按钮在浅底上过淡**
归属:`前端负责`
说明:浅色弹窗 footer 内的 `plain` 次级按钮,现在默认文字和背景层级太接近,小字号时尤其不清楚。请统一检查并修正 `plain` 按钮在浅色弹窗中的默认态、hover 态、禁用态,让“取消 / 完成 / 新增连接角色”这类按钮肉眼一看就能分清。
涉及位置:
`资产族弹窗`:`取消`
`规格款弹窗`:`取消`
`CAD 导入弹窗`:`取消` / `完成`
`连接角色区域`:`新增连接角色`
`新增/编辑连接角色弹窗`:`取消`
`基础资料库` 页:`新建分类`
`基础资料库` 页面弹窗:`取消`
- [ ] **资产中心列表内图标按钮默认态辨识度不足**
归属:`前端负责`
说明:列表和卡片内的编辑 / 删除图标按钮,默认态颜色过浅,未 hover 时像“没激活”或“不可点”,尤其在浅色卡片和浅色分组背景里不够清楚。请统一调整这类圆形图标按钮的默认态和 hover 态,不要只修某一个图标。
涉及位置:
`资产族 / 规格款列表`:铅笔编辑按钮
`连接角色列表`:编辑 / 删除图标按钮
`基础资料库` 页:基材 / 分类 / 饰面列表的编辑 / 删除图标按钮
- [ ] **同一弹窗内主次按钮层级需要统一规范**
归属:`前端负责`
说明:当前资产中心多个弹窗里,主按钮、次按钮、危险按钮的视觉层级不完全一致;有的主按钮太淡,有的删除按钮太抢,有的次按钮像禁用态。请按统一视觉标准整理这三类按钮在浅色主题弹窗 footer 内的层级关系,保证“主操作最清楚、次操作清楚但不抢、危险操作醒目但不刺眼”。这一项是规则统一,不是逐页补丁。
- [ ] **基础资料库页面需要做正式视觉收口**
归属:`前端负责`
说明:`基础资料库` 已经作为独立子页面接入主流程,但目前还是功能先行版。请按全局统一视觉,把这个页面的左侧说明区、右侧列表区、分类筛选 chips、列表空态和“返回资产中心 / 新建分类 / 新建饰面 / 新建基材”几组操作做成和主线一致的正式界面,不要回退为老式后台页。
### 待联调确认 (全栈 / 后端 Agent 负责)
- [ ] **资产中心资产族新流程人工验收**
归属:`联调确认`
说明:确认资产中心现在已改成“先建资产族,再在资产族内新建规格款 / 发起 CAD 导入;缺资料时跳转基础资料库补录”的结构,用户理解是否自然,弹窗开关、列表刷新、导入完成后的落点是否符合预期。
说明:确认资产中心现在已改成"先建资产族,再在资产族内新建规格款 / 发起 CAD 导入;缺资料时跳转基础资料库补录"的结构,用户理解是否自然,弹窗开关、列表刷新、导入完成后的落点是否符合预期。
- [ ] **资产族品类系列 + 中文连接角色维护人工验收**
归属:`联调确认`

View File

@ -1350,7 +1350,7 @@ export default function AssetCenterClient({
<button
type="button"
onClick={() => setSearchText('')}
className="absolute right-2 top-1/2 -translate-y-1/2 rounded-full p-0.5 text-apple-label-tertiary hover:bg-apple-bg-secondary hover:text-apple-label-primary"
className="absolute right-2 top-1/2 -translate-y-1/2 rounded-full p-0.5 text-apple-label-secondary hover:bg-apple-bg-secondary hover:text-apple-label-primary"
>
<X className="h-3.5 w-3.5" />
</button>
@ -1448,7 +1448,7 @@ export default function AssetCenterClient({
<button
type="button"
onClick={(e) => { e.stopPropagation(); openEditFamilyModal(family) }}
className="shrink-0 rounded-full p-1.5 text-apple-label-tertiary hover:bg-apple-bg-secondary hover:text-apple-label-primary transition"
className="shrink-0 rounded-full p-1.5 text-apple-label-secondary hover:bg-apple-bg-secondary hover:text-apple-label-primary transition"
title="编辑资产族"
>
<Pencil className="h-3.5 w-3.5" />
@ -1499,7 +1499,7 @@ export default function AssetCenterClient({
<button
type="button"
onClick={(e) => { e.stopPropagation(); openEditVariantModal(variant) }}
className="shrink-0 rounded-full p-1 text-apple-label-tertiary hover:bg-apple-bg-primary hover:text-apple-label-primary transition"
className="shrink-0 rounded-full p-1 text-apple-label-secondary hover:bg-apple-bg-primary hover:text-apple-label-primary transition"
title="编辑规格款"
>
<Pencil className="h-3 w-3" />
@ -1514,24 +1514,16 @@ export default function AssetCenterClient({
</div>
)}
{/* 新建规格款按钮 */}
<div className="flex items-center justify-between gap-2 border-t border-apple-separator px-4 py-2">
<button
type="button"
onClick={() => openCreateVariantModal(family.id)}
className="flex items-center gap-1.5 text-xs text-apple-blue hover:text-apple-blue/80 transition"
>
<div className="flex items-center gap-2 border-t border-apple-separator px-4 py-2">
<Button variant="tinted" size="small" onClick={() => openCreateVariantModal(family.id)}>
<Plus className="h-3.5 w-3.5" />
新建规格款
</button>
</Button>
{isImportableAssetType(family.type) ? (
<button
type="button"
onClick={() => openCadImportModal(family)}
className="flex items-center gap-1.5 text-xs text-apple-label-secondary hover:text-apple-blue transition"
>
<Button variant="plain" size="small" onClick={() => openCadImportModal(family)}>
<FileUp className="h-3.5 w-3.5" />
CAD 导入到该资产族
</button>
CAD 导入
</Button>
) : null}
</div>
</div>
@ -1553,19 +1545,14 @@ export default function AssetCenterClient({
<div>
<p className="text-sm font-apple-medium text-apple-label-primary">基础资料库</p>
<p className="text-xs text-apple-label-secondary">
统一维护基材、材质分类、饰面花色;补录完成后再回当前资产族继续操作。
{substrates.length} 个基材 · {finishes.length} 个饰面花色
</p>
</div>
</div>
<div className="text-right">
<p className="text-xs text-apple-label-tertiary">
{substrates.length} 个基材 · {finishes.length} 个饰面
</p>
<div className="mt-1 flex items-center justify-end gap-1 text-sm text-apple-blue">
打开资料页
<ArrowRight className="h-4 w-4" />
</div>
</div>
<span className="flex items-center gap-1 text-xs text-apple-blue">
去基础资料库维护
<ArrowRight className="h-3.5 w-3.5" />
</span>
</Link>
{/* 下一步引导 */}
@ -1832,6 +1819,7 @@ export default function AssetCenterClient({
</div>
{cadImportForm.assetType === 'BOARD' ? (
<>
<div className="grid gap-3 md:grid-cols-2">
<Select
label="基材"
@ -1857,6 +1845,10 @@ export default function AssetCenterClient({
errorText={cadImportSubmitAttempted ? cadImportFieldErrors.finishId : undefined}
/>
</div>
<p className="text-xs text-apple-label-tertiary">
找不到需要的基材或饰面?<Link href="/asset-center/material-library" className="text-apple-blue hover:underline">去基础资料库补录</Link>后再回来。
</p>
</>
) : cadImportForm.assetType === 'COMPONENT' ? (
<div className="grid gap-3 md:grid-cols-2">
<Select
@ -2154,7 +2146,7 @@ export default function AssetCenterClient({
<button
type="button"
onClick={() => openEditRoleEditor(definition)}
className="rounded-full p-1.5 text-apple-label-tertiary hover:bg-apple-bg-secondary hover:text-apple-label-primary transition"
className="rounded-full p-1.5 text-apple-label-secondary hover:bg-apple-bg-secondary hover:text-apple-label-primary transition"
title={`编辑 ${definition.label}`}
>
<Pencil className="h-3.5 w-3.5" />
@ -2162,7 +2154,7 @@ export default function AssetCenterClient({
<button
type="button"
onClick={() => handleDeleteRoleDefinition(definition.value)}
className="rounded-full p-1.5 text-apple-label-tertiary hover:bg-apple-bg-secondary hover:text-apple-red transition"
className="rounded-full p-1.5 text-apple-label-secondary hover:bg-apple-bg-secondary hover:text-apple-red transition"
title={`删除 ${definition.label}`}
>
<Trash2 className="h-3.5 w-3.5" />

View File

@ -3,6 +3,7 @@ import userEvent from '@testing-library/user-event'
import type { ReactNode } from 'react'
import AssetCenterClient from './AssetCenterClient'
import { createAssetFamily, importAssetCad } from '@/lib/open-product/platform-api'
import { parseZipFile } from '@/lib/zip-parser'
import type { AssetFamilyDTO, AssetVariantDTO, MaterialDTO, SeriesTemplateDTO, SubstrateDTO } from '@/server/services'
jest.mock('next/link', () => ({
@ -116,6 +117,11 @@ jest.mock('@/lib/open-product/platform-api', () => ({
uploadModelFile: jest.fn(),
}))
jest.mock('@/lib/zip-parser', () => ({
__esModule: true,
parseZipFile: jest.fn(),
}))
function renderClient() {
return render(
<AssetCenterClient
@ -168,6 +174,10 @@ function renderClient() {
describe('AssetCenterClient workflow', () => {
beforeEach(() => {
jest.clearAllMocks()
jest.mocked(parseZipFile).mockResolvedValue({
success: false,
error: 'ZIP 文件解析失败',
} as never)
global.fetch = jest.fn().mockResolvedValue({
ok: true,
json: async () => ({
@ -311,10 +321,10 @@ describe('AssetCenterClient workflow', () => {
)
await actor.click(screen.getByText('桌面板资产族'))
await actor.click(screen.getByRole('button', { name: 'CAD 导入到该资产族' }))
await actor.click(screen.getByRole('button', { name: 'CAD 导入' }))
expect(screen.getByText('导入 CAD 规格款 · 板材')).toBeInTheDocument()
expect(screen.getByText('基础资料库')).toBeInTheDocument()
expect(screen.getByRole('link', { name: /基础资料库/ })).toHaveAttribute(
expect(screen.getByRole('link', { name: '去基础资料库补录' })).toHaveAttribute(
'href',
'/asset-center/material-library',
)
@ -462,7 +472,7 @@ describe('AssetCenterClient workflow', () => {
)
await actor.click(screen.getByText('140台面资产族'))
await actor.click(screen.getByRole('button', { name: 'CAD 导入到该资产族' }))
await actor.click(screen.getByRole('button', { name: 'CAD 导入' }))
await actor.type(screen.getByPlaceholderText('BOARD-VARIANT-001'), 'TM-001')
await actor.type(screen.getByPlaceholderText('例如 桌面板 1200 / 方管桌腿 / 十字连接件'), '140台面-A')
@ -473,6 +483,9 @@ describe('AssetCenterClient workflow', () => {
const fileInput = document.querySelector('input[type="file"][accept=".zip"]') as HTMLInputElement
const zipFile = new File(['zip-content'], '140台面.zip', { type: 'application/zip' })
Object.defineProperty(zipFile, 'arrayBuffer', {
value: async () => new TextEncoder().encode('zip-content').buffer,
})
await actor.upload(fileInput, zipFile)
await actor.click(screen.getByRole('button', { name: '开始导入' }))

View File

@ -0,0 +1,227 @@
import { render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import type { ReactNode } from 'react'
import MaterialLibraryClient from './MaterialLibraryClient'
import { createSubstrate, updateMaterialCategory } from '@/lib/open-product/platform-api'
import type { MaterialCategoryDTO, MaterialDTO, SubstrateDTO } from '@/server/services'
jest.mock('next/link', () => ({
__esModule: true,
default: ({
href,
children,
...props
}: {
href: string
children: ReactNode
}) => (
<a href={href} {...props}>
{children}
</a>
),
}))
jest.mock('@/components/ui/Modal', () => ({
__esModule: true,
default: ({
isOpen,
title,
children,
footer,
}: {
isOpen: boolean
title?: ReactNode
children: ReactNode
footer?: ReactNode
}) =>
isOpen ? (
<div role="dialog" aria-label={typeof title === 'string' ? title : undefined}>
<div>{title}</div>
<div>{children}</div>
<div>{footer}</div>
</div>
) : null,
}))
jest.mock('@/components/ui/DeleteConfirmDialog', () => ({
__esModule: true,
DeleteConfirmDialog: () => null,
}))
jest.mock('@/components/ui/Toast', () => ({
__esModule: true,
useToast: () => ({
toasts: [],
success: jest.fn(),
error: jest.fn(),
warning: jest.fn(),
info: jest.fn(),
removeToast: jest.fn(),
}),
ToastContainer: () => null,
}))
jest.mock('@/components/platform/PlatformSplitLayout', () => ({
__esModule: true,
PlatformSplitLayout: ({
leftContent,
rightContent,
}: {
leftContent: ReactNode
rightContent: ReactNode
}) => (
<div>
<div>{leftContent}</div>
<div>{rightContent}</div>
</div>
),
}))
jest.mock('@/lib/open-product/platform-api', () => ({
__esModule: true,
createMaterial: jest.fn(),
createMaterialCategory: jest.fn(),
createSubstrate: jest.fn(),
deleteMaterial: jest.fn(),
deleteMaterialCategory: jest.fn(),
deleteSubstrate: jest.fn(),
updateMaterial: jest.fn(),
updateMaterialCategory: jest.fn(),
updateSubstrate: jest.fn(),
}))
const mockedCreateSubstrate = jest.mocked(createSubstrate)
const mockedUpdateMaterialCategory = jest.mocked(updateMaterialCategory)
function renderClient() {
return render(
<MaterialLibraryClient
initialSubstrates={[{ id: 'substrate-1', code: 'PB18', name: '18mm 颗粒板' }] as SubstrateDTO[]}
initialFinishes={[
{
id: 'finish-1',
name: '白橡木纹',
categoryId: 'cat-1',
categoryName: '木纹',
thumbnailUrl: null,
textureUrl: null,
color: '#CCCCCC',
metalness: 0,
roughness: 0.5,
opacity: 1,
textureRepeat: 1,
textureRotation: 0,
textureSizeMm: 600,
isPreset: false,
isActive: true,
createdAt: new Date('2026-04-08T00:00:00.000Z'),
updatedAt: new Date('2026-04-08T00:00:00.000Z'),
},
] as unknown as MaterialDTO[]}
initialMaterialCategories={[
{ id: 'cat-1', name: '木纹', icon: 'Texture', sortOrder: 1, materialCount: 1 },
] as MaterialCategoryDTO[]}
metrics={[]}
/>,
)
}
describe('MaterialLibraryClient', () => {
beforeEach(() => {
jest.clearAllMocks()
})
it('新建基材后使用后端返回的完整字段刷新列表', async () => {
const actor = userEvent.setup()
mockedCreateSubstrate.mockResolvedValue({
substrate: {
id: 'substrate-2',
code: 'PB25',
name: '25mm 颗粒板',
description: '后端标准说明',
densityKgM3: 720,
metadata: { source: 'server' },
isActive: true,
createdAt: new Date('2026-04-08T10:00:00.000Z'),
updatedAt: new Date('2026-04-08T10:00:00.000Z'),
},
})
renderClient()
await actor.click(screen.getByRole('button', { name: '新建基材' }))
const dialog = screen.getByRole('dialog', { name: '新建基材' })
const inputs = within(dialog).getAllByRole('textbox')
await actor.type(inputs[0], 'PB25')
await actor.type(inputs[1], '25mm 颗粒板')
await actor.type(inputs[2], '前端输入的旧说明')
await actor.type(within(dialog).getByPlaceholderText('例如 680'), '680')
await actor.click(within(dialog).getByRole('button', { name: '创建基材' }))
await waitFor(() => {
expect(mockedCreateSubstrate).toHaveBeenCalledWith(
expect.objectContaining({
code: 'PB25',
name: '25mm 颗粒板',
}),
)
})
await waitFor(() => {
expect(screen.getByText('25mm 颗粒板')).toBeInTheDocument()
expect(screen.getByText('PB25')).toBeInTheDocument()
expect(screen.getByText('720 kg/m³')).toBeInTheDocument()
})
})
it('编辑材质分类后,饰面搜索会立即使用新分类名', async () => {
const actor = userEvent.setup()
mockedUpdateMaterialCategory.mockResolvedValue({
category: {
id: 'cat-1',
name: '深木纹',
icon: 'Texture',
sortOrder: 1,
materialCount: 1,
},
})
renderClient()
await actor.click(screen.getByRole('button', { name: '饰面花色' }))
const categoryTitle = screen.getAllByText('木纹').find((element) => element.tagName === 'P')
if (!categoryTitle) {
throw new Error('未找到分类标题')
}
const categoryBlock = categoryTitle.closest('div')?.parentElement
if (!categoryBlock) {
throw new Error('未找到分类区块')
}
const categoryButtons = within(categoryBlock).getAllByRole('button')
await actor.click(categoryButtons[0])
const dialog = screen.getByRole('dialog', { name: '编辑材质分类' })
const categoryNameInput = within(dialog).getByPlaceholderText('例如 木纹、石纹、纯色')
await actor.clear(categoryNameInput)
await actor.type(categoryNameInput, '深木纹')
await actor.click(within(dialog).getByRole('button', { name: '保存修改' }))
await waitFor(() => {
expect(mockedUpdateMaterialCategory).toHaveBeenCalledWith(
'cat-1',
expect.objectContaining({ name: '深木纹' }),
)
})
const searchInput = screen.getByPlaceholderText('搜索分类或饰面名称...')
await actor.clear(searchInput)
await actor.type(searchInput, '深木纹')
expect(screen.getByText('白橡木纹')).toBeInTheDocument()
})
})

View File

@ -4,9 +4,7 @@ import Link from 'next/link'
import { startTransition, useMemo, useState } from 'react'
import {
ArrowLeft,
Layers3,
Package2,
Palette,
Pencil,
Plus,
Search,
@ -14,7 +12,6 @@ import {
Trash2,
X,
} from 'lucide-react'
import { Badge } from '@/components/ui/Badge'
import Button from '@/components/ui/Button'
import { DeleteConfirmDialog } from '@/components/ui/DeleteConfirmDialog'
import Input from '@/components/ui/Input'
@ -272,22 +269,7 @@ export default function MaterialLibraryClient({
})
startTransition(() => {
setSubstrates((current) =>
sortByName([
...current,
{
id: result.substrate.id,
code: result.substrate.code,
name: result.substrate.name,
description: substrateForm.description.trim() || null,
densityKgM3: toOptionalNumber(substrateForm.densityKgM3),
metadata: null,
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),
},
]),
)
setSubstrates((current) => sortByName([...current, result.substrate]))
setSubstrateModalOpen(false)
toast.success('基材已创建')
})
@ -353,6 +335,18 @@ export default function MaterialLibraryClient({
current.map((item) => (item.id === editingCategoryId ? result.category : item)),
),
)
setFinishes((current) =>
sortByName(
current.map((item) =>
item.categoryId === editingCategoryId
? {
...item,
categoryName: result.category.name,
}
: item,
),
),
)
setCategoryModalOpen(false)
toast.success('材质分类已更新')
})
@ -431,6 +425,18 @@ export default function MaterialLibraryClient({
await deleteMaterialCategory(deleteTarget.id)
startTransition(() => {
setMaterialCategories((current) => current.filter((item) => item.id !== deleteTarget.id))
setFinishes((current) =>
sortByName(
current.map((item) =>
item.categoryId === deleteTarget.id
? {
...item,
categoryName: undefined,
}
: item,
),
),
)
if (selectedCategoryId === deleteTarget.id) {
setSelectedCategoryId('all')
}
@ -461,17 +467,14 @@ export default function MaterialLibraryClient({
metrics={metrics}
rightPanelClassName="w-[580px] max-w-[52vw]"
leftContent={
<div className="flex h-full flex-col gap-4 rounded-apple-lg bg-[var(--color-surface-container-low)] p-5">
<div className="flex items-center justify-between gap-3">
<div className="flex h-full flex-col gap-5 rounded-apple-lg bg-apple-bg-secondary p-5">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-xs uppercase tracking-[0.24em] text-apple-label-tertiary">
Library Scope
</p>
<h2 className="mt-2 text-2xl font-apple-semibold text-apple-label-primary">
基础资料统一维护
<h2 className="text-xl font-apple-semibold text-apple-label-primary">
基础资料库
</h2>
<p className="mt-2 max-w-2xl text-sm text-apple-label-secondary">
资产中心主页面只保留“资产族 + 规格款”主流程。缺基材或饰面时,先来这里补录,保存后再回 CAD 导入或规格款弹窗继续操作。
<p className="mt-1.5 text-sm text-apple-label-secondary">
维护基材、分类和饰面花色,补录后返回资产中心继续操作。
</p>
</div>
<Link href="/asset-center">
@ -482,63 +485,18 @@ export default function MaterialLibraryClient({
</Link>
</div>
<div className="grid gap-4 md:grid-cols-3">
<div className="rounded-apple-lg border border-apple-separator bg-apple-bg-primary p-4">
<div className="flex items-center gap-2 text-apple-label-primary">
<Package2 className="h-4 w-4 text-apple-label-tertiary" />
<span className="text-sm font-apple-medium">基材</span>
</div>
<p className="mt-3 text-3xl font-apple-semibold text-apple-label-primary">
{substrates.length}
<p className="text-sm text-apple-label-secondary">
<span className="font-apple-medium text-apple-label-primary">{substrates.length}</span> 基材
<span className="mx-1.5 text-apple-separator">&middot;</span>
<span className="font-apple-medium text-apple-label-primary">{materialCategories.length}</span> 分类
<span className="mx-1.5 text-apple-separator">&middot;</span>
<span className="font-apple-medium text-apple-label-primary">{finishes.length}</span> 饰面
</p>
<p className="mt-1 text-xs text-apple-label-secondary">
服务板材规格款、CAD 导入和 3D 右键快捷更改。
</p>
</div>
<div className="rounded-apple-lg border border-apple-separator bg-apple-bg-primary p-4">
<div className="flex items-center gap-2 text-apple-label-primary">
<Layers3 className="h-4 w-4 text-apple-label-tertiary" />
<span className="text-sm font-apple-medium">材质分类</span>
</div>
<p className="mt-3 text-3xl font-apple-semibold text-apple-label-primary">
{materialCategories.length}
</p>
<p className="mt-1 text-xs text-apple-label-secondary">
继续保持“先分类,再具体饰面”的结构,不再写第二套口径。
</p>
</div>
<div className="rounded-apple-lg border border-apple-separator bg-apple-bg-primary p-4">
<div className="flex items-center gap-2 text-apple-label-primary">
<Palette className="h-4 w-4 text-apple-label-tertiary" />
<span className="text-sm font-apple-medium">饰面 / 花色</span>
</div>
<p className="mt-3 text-3xl font-apple-semibold text-apple-label-primary">
{finishes.length}
</p>
<p className="mt-1 text-xs text-apple-label-secondary">
删除后会自动让已引用规格款回落为空,不额外制造异常状态。
</p>
</div>
</div>
<div className="rounded-apple-lg border border-apple-separator bg-apple-bg-primary p-5">
<h3 className="text-base font-apple-semibold text-apple-label-primary">使用口径</h3>
<div className="mt-4 grid gap-3 md:grid-cols-2">
<div className="rounded-apple-md bg-apple-bg-secondary/60 p-4">
<p className="text-sm font-apple-medium text-apple-label-primary">什么时候来这里</p>
<p className="mt-2 text-sm text-apple-label-secondary">
需要新增或调整基材、材质分类、饰面花色时,统一来这个页面处理。
<p className="text-xs text-apple-label-tertiary leading-relaxed">
缺基材或饰面时先来这里补录,保存后回 CAD 导入或规格款弹窗继续操作。
</p>
</div>
<div className="rounded-apple-md bg-apple-bg-secondary/60 p-4">
<p className="text-sm font-apple-medium text-apple-label-primary">什么时候回资产中心</p>
<p className="mt-2 text-sm text-apple-label-secondary">
资料补录完成后,回资产中心继续 CAD 导入、规格款新建编辑和 3D 右键快捷更改。
</p>
</div>
</div>
</div>
</div>
}
rightContent={
<>
@ -585,7 +543,7 @@ export default function MaterialLibraryClient({
<button
type="button"
onClick={() => setSearchText('')}
className="absolute right-2 top-1/2 -translate-y-1/2 rounded-full p-0.5 text-apple-label-tertiary hover:bg-apple-bg-secondary hover:text-apple-label-primary"
className="absolute right-2 top-1/2 -translate-y-1/2 rounded-full p-0.5 text-apple-label-secondary hover:bg-apple-bg-secondary hover:text-apple-label-primary"
>
<X className="h-3.5 w-3.5" />
</button>
@ -611,80 +569,56 @@ export default function MaterialLibraryClient({
</div>
{activeTab === 'substrate' ? (
<div className="rounded-apple-lg border border-apple-separator bg-apple-bg-primary">
<div className="border-b border-apple-separator px-4 py-3">
<p className="text-sm font-apple-medium text-apple-label-primary">基材列表</p>
<p className="text-xs text-apple-label-secondary">
当前共 {filteredSubstrates.length} 条,删除后规格款会自动回落为未设置。
</p>
</div>
<div className="divide-y divide-apple-separator">
{filteredSubstrates.length === 0 ? (
<div className="px-4 py-8 text-center text-sm text-apple-label-tertiary">
暂无匹配的基材
filteredSubstrates.length === 0 ? (
<div className="py-12 text-center">
<Package2 className="mx-auto h-8 w-8 text-apple-label-tertiary" />
<p className="mt-3 text-sm text-apple-label-secondary">暂无基材</p>
<p className="mt-1 text-xs text-apple-label-tertiary">点击上方「新建基材」添加第一条记录</p>
</div>
) : (
filteredSubstrates.map((substrate) => (
<div className="divide-y divide-apple-separator rounded-apple-lg border border-apple-separator bg-apple-bg-primary">
{filteredSubstrates.map((substrate) => (
<div key={substrate.id} className="flex items-center gap-3 px-4 py-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<p className="truncate text-sm font-apple-medium text-apple-label-primary">
<p className="min-w-0 truncate text-sm font-apple-medium text-apple-label-primary">
{substrate.name}
</p>
<Badge type="info" variant="subtle" size="small">
{substrate.code}
</Badge>
</div>
<p className="mt-1 text-xs text-apple-label-secondary">
{substrate.description?.trim() || '未填写说明'}
</p>
</div>
<div className="text-right text-xs text-apple-label-tertiary">
{substrate.densityKgM3 ? `${substrate.densityKgM3} kg/m³` : '未填密度'}
<div className="ml-auto flex items-center gap-3 text-xs text-apple-label-tertiary">
<span>{substrate.code}</span>
{substrate.densityKgM3 ? <span>{substrate.densityKgM3} kg/m³</span> : null}
</div>
<button
type="button"
onClick={() => openEditSubstrateModal(substrate)}
className="rounded-full p-1.5 text-apple-label-tertiary hover:bg-apple-bg-secondary hover:text-apple-label-primary transition"
className="rounded-full p-1.5 text-apple-label-secondary hover:bg-apple-bg-secondary hover:text-apple-label-primary transition"
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={() => setDeleteTarget({ type: 'substrate', id: substrate.id, name: substrate.name })}
className="rounded-full p-1.5 text-apple-label-tertiary hover:bg-apple-bg-secondary hover:text-apple-red transition"
className="rounded-full p-1.5 text-apple-label-secondary hover:bg-apple-bg-secondary hover:text-apple-red transition"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
))
)}
</div>
))}
</div>
)
) : (
<>
<div className="rounded-apple-lg border border-apple-separator bg-apple-bg-primary p-4">
<div className="flex items-center justify-between gap-2">
<div>
<p className="text-sm font-apple-medium text-apple-label-primary">材质分类筛选</p>
<p className="text-xs text-apple-label-secondary">
先选分类再维护具体饰面;也可以查看全部。
</p>
</div>
<Select
size="small"
value={selectedCategoryId}
onChange={(event) => setSelectedCategoryId(event.target.value)}
options={[
{ value: 'all', label: '全部分类' },
...materialCategories.map((category) => ({
value: category.id,
label: `${category.name} (${category.materialCount ?? 0})`,
})),
]}
/>
</div>
<div className="mt-3 flex flex-wrap gap-2">
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => setSelectedCategoryId('all')}
className={cn(
'rounded-full border px-3 py-1.5 text-xs transition',
selectedCategoryId === 'all'
? 'border-apple-blue/30 bg-apple-blue/10 text-apple-blue'
: 'border-apple-separator bg-transparent text-apple-label-secondary hover:text-apple-label-primary',
)}
>
全部
</button>
{materialCategories.map((category) => (
<button
key={category.id}
@ -695,93 +629,80 @@ export default function MaterialLibraryClient({
className={cn(
'rounded-full border px-3 py-1.5 text-xs transition',
selectedCategoryId === category.id
? 'border-apple-blue/30 bg-apple-blue/8 text-apple-blue'
: 'border-apple-separator bg-apple-bg-secondary text-apple-label-secondary hover:text-apple-label-primary',
? 'border-apple-blue/30 bg-apple-blue/10 text-apple-blue'
: 'border-apple-separator bg-transparent text-apple-label-secondary hover:text-apple-label-primary',
)}
>
{category.name}
</button>
))}
</div>
</div>
<div className="rounded-apple-lg border border-apple-separator bg-apple-bg-primary">
<div className="border-b border-apple-separator px-4 py-3">
<p className="text-sm font-apple-medium text-apple-label-primary">饰面 / 花色资料</p>
<p className="text-xs text-apple-label-secondary">
分类与饰面在同一页面维护,当前共 {filteredFinishes.length} 条结果。
</p>
</div>
<div className="space-y-4 px-4 py-4">
{groupedFinishes.length === 0 ? (
<div className="rounded-apple-md bg-apple-bg-secondary/60 px-4 py-8 text-center text-sm text-apple-label-tertiary">
当前筛选下暂无饰面 / 花色
<div className="py-12 text-center">
<SwatchBook className="mx-auto h-8 w-8 text-apple-label-tertiary" />
<p className="mt-3 text-sm text-apple-label-secondary">暂无饰面花色</p>
<p className="mt-1 text-xs text-apple-label-tertiary">点击上方「新建饰面」添加第一条记录</p>
</div>
) : (
groupedFinishes.map((group, groupIndex) => (
<div className="space-y-4">
{groupedFinishes.map((group, groupIndex) => (
<div
key={group.category.id}
className={cn(groupIndex > 0 && 'border-t border-apple-separator pt-4')}
>
<div className="mb-3 flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="mb-2 flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<p className="truncate text-sm font-apple-medium text-apple-label-primary">
<p className="text-sm font-apple-medium text-apple-label-primary">
{group.category.name}
</p>
<span className="text-[11px] text-apple-label-tertiary">
{group.finishes.length} 种
{group.finishes.length}
</span>
</div>
</div>
{group.category.id !== 'uncategorized' ? (
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => openEditCategoryModal(group.category)}
className="rounded-full p-1.5 text-apple-label-tertiary hover:bg-apple-bg-secondary hover:text-apple-label-primary transition"
className="rounded-full p-1.5 text-apple-label-secondary hover:bg-apple-bg-secondary hover:text-apple-label-primary transition"
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={() => setDeleteTarget({ type: 'category', id: group.category.id, name: group.category.name })}
className="rounded-full p-1.5 text-apple-label-tertiary hover:bg-apple-bg-secondary hover:text-apple-red transition"
className="rounded-full p-1.5 text-apple-label-secondary hover:bg-apple-bg-secondary hover:text-apple-red transition"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
) : null}
</div>
<div className="grid gap-2">
<div className="grid gap-1.5">
{group.finishes.map((finish) => (
<div
key={finish.id}
className="flex items-center gap-3 rounded-apple-md bg-apple-bg-secondary/40 px-3 py-2"
className="flex items-center gap-3 rounded-apple-md px-3 py-2"
>
<div
className="h-8 w-8 shrink-0 rounded-apple-md border border-apple-separator"
className="h-6 w-6 shrink-0 rounded border border-apple-separator"
style={{ backgroundColor: finish.color }}
/>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-apple-medium text-apple-label-primary">
<p className="min-w-0 flex-1 truncate text-sm text-apple-label-primary">
{finish.name}
</p>
<p className="text-[11px] text-apple-label-secondary">
{finish.categoryName ?? group.category.name}
</p>
</div>
<button
type="button"
onClick={() => openEditFinishModal(finish)}
className="rounded-full p-1.5 text-apple-label-tertiary hover:bg-apple-bg-primary hover:text-apple-label-primary transition"
className="rounded-full p-1.5 text-apple-label-secondary hover:bg-apple-bg-secondary hover:text-apple-label-primary transition"
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={() => setDeleteTarget({ type: 'finish', id: finish.id, name: finish.name })}
className="rounded-full p-1.5 text-apple-label-tertiary hover:bg-apple-bg-primary hover:text-apple-red transition"
className="rounded-full p-1.5 text-apple-label-secondary hover:bg-apple-bg-secondary hover:text-apple-red transition"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
@ -789,10 +710,9 @@ export default function MaterialLibraryClient({
))}
</div>
</div>
))
))}
</div>
)}
</div>
</div>
</>
)}
</>

View File

@ -170,7 +170,7 @@ export async function importAssetCad(input: ImportAssetCadPayload): Promise<Asse
}
export async function createSubstrate(input: CreateSubstrateInput): Promise<{
substrate: { id: string; code: string; name: string }
substrate: SubstrateDTO
}> {
return requestPlatform('/api/asset-center/substrates', {
method: 'POST',