feat(intake): refine bom libraries and accessory rods
This commit is contained in:
parent
8ae5540eef
commit
9700d84c66
@ -1,6 +1,6 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { ComponentProps, ReactNode } from 'react'
|
||||
import MaterialLibraryClient from './MaterialLibraryClient'
|
||||
import {
|
||||
createMaterial,
|
||||
@ -143,7 +143,7 @@ const dictionaries: SubstrateDictionaryBundleDTO = {
|
||||
systemNameOptions: [{ id: 'sys-board', type: 'SYSTEM_NAME_OPTION', name: '板材规则名', sortOrder: 0, isActive: true, createdAt: new Date(), updatedAt: new Date() }],
|
||||
}
|
||||
|
||||
function renderClient() {
|
||||
function renderClient(overrides: Partial<ComponentProps<typeof MaterialLibraryClient>> = {}) {
|
||||
return render(
|
||||
<MaterialLibraryClient
|
||||
initialSubstrates={[{
|
||||
@ -177,6 +177,7 @@ function renderClient() {
|
||||
initialDictionaries={dictionaries}
|
||||
metrics={[]}
|
||||
user={{ businessRoles: ['DESIGN'] }}
|
||||
{...overrides}
|
||||
/>,
|
||||
)
|
||||
}
|
||||
@ -190,6 +191,27 @@ describe('MaterialLibraryClient', () => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
it('花色单独入口不显示板材全局字典', () => {
|
||||
renderClient({ initialActiveTab: 'finish', visibleTabs: ['finish'] })
|
||||
|
||||
expect(screen.getByRole('button', { name: '饰面花色' })).toBeInTheDocument()
|
||||
expect(screen.queryByText('全局字典')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('材质主类')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('环保等级')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('厚度规格')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('贴面类型')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('默认入口保留板材全局字典', () => {
|
||||
renderClient()
|
||||
|
||||
expect(screen.getByText('全局字典')).toBeInTheDocument()
|
||||
expect(screen.getByText('材质主类')).toBeInTheDocument()
|
||||
expect(screen.getByText('环保等级')).toBeInTheDocument()
|
||||
expect(screen.getByText('厚度规格')).toBeInTheDocument()
|
||||
expect(screen.getByText('贴面类型')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('新建基材时按规则字段提交并显示自动名称', async () => {
|
||||
const actor = userEvent.setup()
|
||||
mockedCreateSubstrate.mockResolvedValue({
|
||||
@ -397,6 +419,7 @@ describe('MaterialLibraryClient', () => {
|
||||
it('编辑花色时上传纹理贴图会立即保存并刷新卡片预览', async () => {
|
||||
const actor = userEvent.setup()
|
||||
const textureUrl = '/materials/textures/material-library/oak.webp'
|
||||
const onFinishesChange = jest.fn()
|
||||
mockedUploadMaterialAsset.mockResolvedValue(textureUrl)
|
||||
mockedUpdateMaterial.mockResolvedValue({
|
||||
...initialFinishes[0],
|
||||
@ -404,7 +427,7 @@ describe('MaterialLibraryClient', () => {
|
||||
previewUrl: `/api/materials/thumbnail?path=${encodeURIComponent(textureUrl)}`,
|
||||
})
|
||||
|
||||
const view = renderClient()
|
||||
const view = renderClient({ onFinishesChange })
|
||||
|
||||
await actor.click(await screen.findByRole('button', { name: '编辑当前花色' }))
|
||||
|
||||
@ -424,6 +447,14 @@ describe('MaterialLibraryClient', () => {
|
||||
expect(mockedUpdateMaterial).toHaveBeenCalledWith('finish-oak', {
|
||||
textureUrl,
|
||||
})
|
||||
expect(onFinishesChange).toHaveBeenCalledWith(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: 'finish-oak',
|
||||
textureUrl,
|
||||
}),
|
||||
]),
|
||||
)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
|
||||
@ -419,6 +419,8 @@ function FinishParameterSliderField({
|
||||
|
||||
// --- Main Page Component ---
|
||||
|
||||
type MaterialLibraryTabKey = 'series' | 'finish' | 'substrate'
|
||||
|
||||
interface MaterialLibraryClientProps {
|
||||
initialCategories: MaterialCategoryDTO[]
|
||||
initialFinishes: MaterialDTO[]
|
||||
@ -427,6 +429,10 @@ interface MaterialLibraryClientProps {
|
||||
initialDictionaries: SubstrateDictionaryBundleDTO
|
||||
metrics?: Array<{ label: string; value: string | number; icon?: ReactNode }>
|
||||
embedded?: boolean
|
||||
canEditOverride?: boolean
|
||||
initialActiveTab?: MaterialLibraryTabKey
|
||||
visibleTabs?: MaterialLibraryTabKey[]
|
||||
onFinishesChange?: (finishes: MaterialDTO[]) => void
|
||||
user?: {
|
||||
role?: string
|
||||
businessRoles?: string[] | null
|
||||
@ -443,14 +449,36 @@ export default function MaterialLibraryClient({
|
||||
initialDictionaries,
|
||||
metrics = [],
|
||||
embedded = false,
|
||||
canEditOverride,
|
||||
initialActiveTab = 'finish',
|
||||
visibleTabs,
|
||||
onFinishesChange,
|
||||
user,
|
||||
}: MaterialLibraryClientProps) {
|
||||
const toast = useToast()
|
||||
const canOperateDesignWorkspace = user ? canOperateDesignWorkspaceForUser(user) : false
|
||||
const canOperateDesignWorkspace =
|
||||
canEditOverride ?? (user ? canOperateDesignWorkspaceForUser(user) : false)
|
||||
const showReadOnlyWarning = useCallback(() => {
|
||||
toast.warning('当前账号只能查看基础资料库,不能新增、编辑或保存')
|
||||
}, [toast])
|
||||
const [activeTab, setActiveTab] = useState<'series' | 'finish' | 'substrate'>('finish')
|
||||
const visibleTabsKey = visibleTabs?.join('|') ?? 'all'
|
||||
const normalizedVisibleTabs = useMemo<MaterialLibraryTabKey[]>(() => {
|
||||
const tabs: MaterialLibraryTabKey[] =
|
||||
visibleTabs && visibleTabs.length > 0
|
||||
? visibleTabs
|
||||
: ['series', 'substrate', 'finish']
|
||||
return tabs.filter((tab, index) => tabs.indexOf(tab) === index)
|
||||
}, [visibleTabsKey])
|
||||
const visibleTabSet = useMemo(
|
||||
() => new Set<MaterialLibraryTabKey>(normalizedVisibleTabs),
|
||||
[normalizedVisibleTabs],
|
||||
)
|
||||
const resolvedInitialActiveTab = visibleTabSet.has(initialActiveTab)
|
||||
? initialActiveTab
|
||||
: (normalizedVisibleTabs[0] ?? 'finish')
|
||||
const [activeTab, setActiveTab] = useState<MaterialLibraryTabKey>(
|
||||
resolvedInitialActiveTab,
|
||||
)
|
||||
const [seriesList, setSeriesList] = useState(() => sortAssetSeries(initialAssetSeries))
|
||||
const [categories, setCategories] = useState(() => sortMaterialCategories(initialCategories))
|
||||
const [finishes, setFinishes] = useState(initialFinishes)
|
||||
@ -476,6 +504,16 @@ export default function MaterialLibraryClient({
|
||||
| { mode: 'create-series' }
|
||||
|
||||
const [panel, setPanel] = useState<PanelState>({ mode: 'empty' })
|
||||
const panelMatchesActiveTab = useCallback((state: PanelState, tab: MaterialLibraryTabKey) => {
|
||||
if (state.mode === 'empty') return true
|
||||
if (tab === 'series') {
|
||||
return state.mode === 'view-series' || state.mode === 'edit-series' || state.mode === 'create-series'
|
||||
}
|
||||
if (tab === 'finish') {
|
||||
return state.mode === 'view-finish' || state.mode === 'edit-finish' || state.mode === 'create-finish'
|
||||
}
|
||||
return state.mode === 'view-substrate' || state.mode === 'edit-substrate' || state.mode === 'create-substrate'
|
||||
}, [])
|
||||
|
||||
const [finishForm, setFinishForm] = useState<FinishForm>(emptyFinishForm())
|
||||
const [substrateForm, setSubstrateForm] = useState<SubstrateForm>(emptySubstrateForm(initialDictionaries))
|
||||
@ -499,6 +537,17 @@ export default function MaterialLibraryClient({
|
||||
name: string
|
||||
dictionaryType?: SubstrateDictionaryType
|
||||
} | null>(null)
|
||||
|
||||
const updateFinishes = useCallback(
|
||||
(next: MaterialDTO[] | ((current: MaterialDTO[]) => MaterialDTO[])) => {
|
||||
setFinishes((current) => {
|
||||
const resolved = typeof next === 'function' ? next(current) : next
|
||||
onFinishesChange?.(resolved)
|
||||
return resolved
|
||||
})
|
||||
},
|
||||
[onFinishesChange],
|
||||
)
|
||||
const [uploadingAsset, setUploadingAsset] = useState<{ id: string; type: 'finish' | 'substrate' } | null>(null)
|
||||
|
||||
const defaultFinishCategoryId = categories.length > 0 ? categories[0].id : ''
|
||||
@ -613,6 +662,21 @@ export default function MaterialLibraryClient({
|
||||
? seriesList.find((item) => item.id === (panel as any).id)
|
||||
: null
|
||||
|
||||
useEffect(() => {
|
||||
if (visibleTabSet.has(activeTab)) return
|
||||
setActiveTab(resolvedInitialActiveTab)
|
||||
setPanel({ mode: 'empty' })
|
||||
setSearchQuery('')
|
||||
setSelectedCategoryId('all')
|
||||
setSelectedSubstrateCategoryId('all')
|
||||
setFinishSearch('')
|
||||
}, [activeTab, resolvedInitialActiveTab, visibleTabSet])
|
||||
|
||||
useEffect(() => {
|
||||
if (panelMatchesActiveTab(panel, activeTab)) return
|
||||
setPanel({ mode: 'empty' })
|
||||
}, [activeTab, panel, panelMatchesActiveTab])
|
||||
|
||||
useEffect(() => {
|
||||
if (panel.mode !== 'empty') return
|
||||
if (activeTab === 'series' && filteredSeries[0]) {
|
||||
@ -645,6 +709,7 @@ export default function MaterialLibraryClient({
|
||||
.filter((item) => item.finishes.some((finish) => finish.id === activeWorkbenchFinishId))
|
||||
.slice(0, 3)
|
||||
}, [activeWorkbenchFinishId, substrates])
|
||||
const canOpenLinkedSubstrates = visibleTabSet.has('substrate')
|
||||
|
||||
// --- Handlers ---
|
||||
|
||||
@ -772,7 +837,7 @@ export default function MaterialLibraryClient({
|
||||
if (panel.mode === 'edit-finish') {
|
||||
const result = await updateMaterial(panel.id, { [field]: url })
|
||||
startTransition(() => {
|
||||
setFinishes((current) =>
|
||||
updateFinishes((current) =>
|
||||
sortByName(current.map((item) => (item.id === panel.id ? result : item))),
|
||||
)
|
||||
})
|
||||
@ -817,14 +882,14 @@ export default function MaterialLibraryClient({
|
||||
if (panel.mode === 'create-finish') {
|
||||
const result = await createMaterial(payload)
|
||||
startTransition(() => {
|
||||
setFinishes((current) => sortByName([...current, result]))
|
||||
updateFinishes((current) => sortByName([...current, result]))
|
||||
setPanel({ mode: 'view-finish', id: result.id })
|
||||
toast.success('花色已创建')
|
||||
})
|
||||
} else if (panel.mode === 'edit-finish') {
|
||||
const result = await updateMaterial(panel.id, payload)
|
||||
startTransition(() => {
|
||||
setFinishes((current) =>
|
||||
updateFinishes((current) =>
|
||||
sortByName(current.map((item) => (item.id === panel.id ? result : item))),
|
||||
)
|
||||
setPanel({ mode: 'view-finish', id: panel.id })
|
||||
@ -1013,7 +1078,7 @@ export default function MaterialLibraryClient({
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await deleteMaterial(finishId)
|
||||
setFinishes((current) => current.filter((item) => item.id !== finishId))
|
||||
updateFinishes((current) => current.filter((item) => item.id !== finishId))
|
||||
setSubstrates((current) =>
|
||||
current.map((substrate) => ({
|
||||
...substrate,
|
||||
@ -1177,7 +1242,7 @@ export default function MaterialLibraryClient({
|
||||
textureUrl: null,
|
||||
normalMapUrl: null,
|
||||
})
|
||||
setFinishes((current) => sortByName([...current, newFinish]))
|
||||
updateFinishes((current) => sortByName([...current, newFinish]))
|
||||
finishIds = [newFinish.id]
|
||||
}
|
||||
}
|
||||
@ -1958,43 +2023,48 @@ export default function MaterialLibraryClient({
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-5">
|
||||
<div className="space-y-2">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="系列维护"
|
||||
onClick={() => { setActiveTab('series'); setPanel({ mode: 'empty' }) }}
|
||||
className={cn(
|
||||
'flex w-full items-center justify-between rounded-lg px-3 py-2.5 text-left text-sm font-apple-bold transition',
|
||||
activeTab === 'series'
|
||||
? 'border border-[#101719]/16 bg-white text-[#101719] shadow-[inset_2px_0_0_#101719]'
|
||||
: 'text-[#4f5c5f] hover:bg-black/[0.04] hover:text-[#101719]',
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center gap-2.5">
|
||||
<GitBranch className="h-4 w-4" />
|
||||
系列
|
||||
</span>
|
||||
<span className="text-[11px] font-apple-black">{seriesList.length}</span>
|
||||
</button>
|
||||
{visibleTabSet.has('series') ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="系列维护"
|
||||
onClick={() => { setActiveTab('series'); setPanel({ mode: 'empty' }) }}
|
||||
className={cn(
|
||||
'flex w-full items-center justify-between rounded-lg px-3 py-2.5 text-left text-sm font-apple-bold transition',
|
||||
activeTab === 'series'
|
||||
? 'border border-[#101719]/16 bg-white text-[#101719] shadow-[inset_2px_0_0_#101719]'
|
||||
: 'text-[#4f5c5f] hover:bg-black/[0.04] hover:text-[#101719]',
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center gap-2.5">
|
||||
<GitBranch className="h-4 w-4" />
|
||||
系列
|
||||
</span>
|
||||
<span className="text-[11px] font-apple-black">{seriesList.length}</span>
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
aria-label="基材录入"
|
||||
onClick={() => { setActiveTab('substrate'); setPanel({ mode: 'empty' }) }}
|
||||
className={cn(
|
||||
'flex w-full items-center justify-between rounded-lg px-3 py-2.5 text-left text-sm font-apple-bold transition',
|
||||
activeTab === 'substrate'
|
||||
? 'border border-[#101719]/16 bg-white text-[#101719] shadow-[inset_2px_0_0_#101719]'
|
||||
: 'text-[#4f5c5f] hover:bg-black/[0.04] hover:text-[#101719]',
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center gap-2.5">
|
||||
<Layers className="h-4 w-4" />
|
||||
板材基材
|
||||
</span>
|
||||
<span className="text-[11px] font-apple-black">{substrates.length}</span>
|
||||
</button>
|
||||
{visibleTabSet.has('substrate') ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="基材录入"
|
||||
onClick={() => { setActiveTab('substrate'); setPanel({ mode: 'empty' }) }}
|
||||
className={cn(
|
||||
'flex w-full items-center justify-between rounded-lg px-3 py-2.5 text-left text-sm font-apple-bold transition',
|
||||
activeTab === 'substrate'
|
||||
? 'border border-[#101719]/16 bg-white text-[#101719] shadow-[inset_2px_0_0_#101719]'
|
||||
: 'text-[#4f5c5f] hover:bg-black/[0.04] hover:text-[#101719]',
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center gap-2.5">
|
||||
<Layers className="h-4 w-4" />
|
||||
板材基材
|
||||
</span>
|
||||
<span className="text-[11px] font-apple-black">{substrates.length}</span>
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
<div className={cn('rounded-lg', activeTab === 'finish' ? 'bg-black/[0.035] ring-1 ring-black/5' : null)}>
|
||||
{visibleTabSet.has('finish') ? (
|
||||
<div className={cn('rounded-lg', activeTab === 'finish' ? 'bg-black/[0.035] ring-1 ring-black/5' : null)}>
|
||||
<div className="flex items-center justify-between px-3 py-2.5">
|
||||
<button
|
||||
type="button"
|
||||
@ -2087,10 +2157,11 @@ export default function MaterialLibraryClient({
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{activeTab === 'substrate' ? (
|
||||
{activeTab === 'substrate' && visibleTabSet.has('substrate') ? (
|
||||
<div className="mt-5 space-y-1">
|
||||
<div className="flex items-center justify-between px-2">
|
||||
<p className="text-[10px] font-apple-black text-[#687477]">板材分类</p>
|
||||
@ -2136,19 +2207,21 @@ export default function MaterialLibraryClient({
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-black/10 p-4">
|
||||
<p className="mb-3 text-[10px] font-apple-black text-[#687477]">全局字典</p>
|
||||
<div className="space-y-2">
|
||||
{dictionaryTypesForSubstrate.map((type) => (
|
||||
<DictionaryStat
|
||||
key={type}
|
||||
label={dictionaryLabel(type)}
|
||||
count={dictionaryOptionsByType(dictionaries, type).length}
|
||||
onClick={canOperateDesignWorkspace ? () => openDictionaryModal(type) : undefined}
|
||||
/>
|
||||
))}
|
||||
{visibleTabSet.has('substrate') ? (
|
||||
<div className="border-t border-black/10 p-4">
|
||||
<p className="mb-3 text-[10px] font-apple-black text-[#687477]">全局字典</p>
|
||||
<div className="space-y-2">
|
||||
{dictionaryTypesForSubstrate.map((type) => (
|
||||
<DictionaryStat
|
||||
key={type}
|
||||
label={dictionaryLabel(type)}
|
||||
count={dictionaryOptionsByType(dictionaries, type).length}
|
||||
onClick={canOperateDesignWorkspace ? () => openDictionaryModal(type) : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</aside>
|
||||
|
||||
<section className="relative min-w-0 flex-1 overflow-hidden bg-[#eef2f3]">
|
||||
@ -2351,6 +2424,7 @@ export default function MaterialLibraryClient({
|
||||
))}
|
||||
</div>
|
||||
|
||||
{canOpenLinkedSubstrates ? (
|
||||
<div className="mt-3 border-t border-black/12 pt-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-[10px] font-apple-black text-[#4f5b5e]">关联基材</p>
|
||||
@ -2364,7 +2438,10 @@ export default function MaterialLibraryClient({
|
||||
<button
|
||||
key={substrate.id}
|
||||
type="button"
|
||||
onClick={() => { setActiveTab('substrate'); setPanel({ mode: 'view-substrate', id: substrate.id }) }}
|
||||
onClick={() => {
|
||||
setActiveTab('substrate')
|
||||
setPanel({ mode: 'view-substrate', id: substrate.id })
|
||||
}}
|
||||
className="block w-full px-0 py-1.5 text-left transition hover:bg-black/[0.025]"
|
||||
>
|
||||
<span className="block text-[10px] font-apple-black text-[#4f5b5e]">{substrate.code}</span>
|
||||
@ -2378,6 +2455,7 @@ export default function MaterialLibraryClient({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@ -68,12 +68,14 @@ type ScrewItem = AccessoryLibraryItem & {
|
||||
}
|
||||
|
||||
type AccessoryLibraryPanelProps = {
|
||||
canEditItems?: boolean
|
||||
canDeleteItems?: boolean
|
||||
}
|
||||
|
||||
const LETTER_CATEGORY_LABELS: Partial<Record<AccessoryLibraryLetter, string>> = {
|
||||
A: '自攻螺丝',
|
||||
B: '平头螺丝',
|
||||
C: '连接螺杆',
|
||||
}
|
||||
|
||||
const A_HEAD_TYPES: HeadType[] = [
|
||||
@ -134,6 +136,7 @@ const HEAD_TYPES_BY_LETTER: Partial<Record<AccessoryLibraryLetter, HeadType[]>>
|
||||
|
||||
const A_SCREW_CATEGORIES = ['自攻螺丝', '过山丝']
|
||||
const B_SCREW_CATEGORIES = ['平头螺丝', '内六角螺丝', '拉手螺丝', '杯头螺丝']
|
||||
const C_SCREW_CATEGORIES = ['全牙螺杆', '双头螺杆', '双头牙']
|
||||
|
||||
const SELECT_FIELDS = [
|
||||
{
|
||||
@ -294,7 +297,7 @@ const SELECT_FIELDS = [
|
||||
type SelectFieldDefinition = (typeof SELECT_FIELDS)[number]
|
||||
|
||||
const B_SELECT_FIELD_OVERRIDES: Partial<
|
||||
Record<ScrewFilterKey, Pick<SelectFieldDefinition, 'options'>>
|
||||
Record<ScrewFilterKey, Partial<Pick<SelectFieldDefinition, 'label' | 'options'>>>
|
||||
> = {
|
||||
screwCategory: {
|
||||
options: B_SCREW_CATEGORIES,
|
||||
@ -331,6 +334,63 @@ const B_SELECT_FIELD_OVERRIDES: Partial<
|
||||
},
|
||||
}
|
||||
|
||||
const C_SELECT_FIELD_OVERRIDES: Partial<
|
||||
Record<ScrewFilterKey, Partial<Pick<SelectFieldDefinition, 'label' | 'options'>>>
|
||||
> = {
|
||||
screwCategory: {
|
||||
options: C_SCREW_CATEGORIES,
|
||||
},
|
||||
tail: {
|
||||
options: ['双平尾', '尖平尾', '双尖尾', '平尾', '尖尾'],
|
||||
},
|
||||
material: {
|
||||
options: ['碳钢', '35CrMoA', '304不锈钢', '201不锈钢', '316不锈钢', '铁'],
|
||||
},
|
||||
finish: {
|
||||
options: ['本色', '发黑', '镀白锌', '镀锌', '彩锌', '黄锌', '镀镍', '喷黑'],
|
||||
},
|
||||
tooth: {
|
||||
options: ['全牙', '双头牙', '木牙公制牙', '两头公制牙', '两头木牙'],
|
||||
},
|
||||
diameter: {
|
||||
options: ['M4', 'M5', 'M6', 'M8', 'M10', 'M12', 'M16'],
|
||||
},
|
||||
length: {
|
||||
label: '尺寸',
|
||||
options: [
|
||||
'20mm',
|
||||
'25mm',
|
||||
'30mm',
|
||||
'35mm',
|
||||
'40mm',
|
||||
'45mm',
|
||||
'50mm',
|
||||
'60mm',
|
||||
'70mm',
|
||||
'80mm',
|
||||
'90mm',
|
||||
'100mm',
|
||||
'110mm',
|
||||
'120mm',
|
||||
'150mm',
|
||||
'200mm',
|
||||
'300mm',
|
||||
],
|
||||
},
|
||||
color: {
|
||||
options: ['黑色', '银色', '金色', '彩锌色', '本色'],
|
||||
},
|
||||
preDrill: {
|
||||
options: ['需预开孔', '按配套孔位安装', '无需预开孔'],
|
||||
},
|
||||
}
|
||||
|
||||
const HIDDEN_SELECT_FIELD_KEYS_BY_LETTER: Partial<
|
||||
Record<AccessoryLibraryLetter, ScrewFilterKey[]>
|
||||
> = {
|
||||
C: ['drive'],
|
||||
}
|
||||
|
||||
const EMPTY_FILTERS: ScrewFilterState = {
|
||||
code: '',
|
||||
name: '',
|
||||
@ -398,16 +458,26 @@ const TABLE_COLUMNS = [
|
||||
'操作',
|
||||
]
|
||||
|
||||
function getTableColumns(letter: AccessoryLibraryLetter, shouldShowHeadType: boolean) {
|
||||
return TABLE_COLUMNS.filter((column) => {
|
||||
if (!shouldShowHeadType && column === '头型') return false
|
||||
if (letter === 'C' && column === '槽型') return false
|
||||
return true
|
||||
}).map((column) => (letter === 'C' && column === '长度' ? '尺寸' : column))
|
||||
}
|
||||
|
||||
const SCREW_DUPLICATE_KEYS = [
|
||||
'screwCategory',
|
||||
'headType',
|
||||
'drive',
|
||||
'tail',
|
||||
'material',
|
||||
'finish',
|
||||
'tooth',
|
||||
'diameter',
|
||||
'length',
|
||||
'color',
|
||||
'preDrill',
|
||||
] satisfies Array<keyof ScrewItem>
|
||||
|
||||
const DEFAULT_SCREW_FIELDS = {
|
||||
@ -459,6 +529,21 @@ const DEFAULT_SCREW_FIELDS_BY_LETTER: Partial<
|
||||
preDrill: '需预开孔',
|
||||
environments: ['室内', '防锈'],
|
||||
},
|
||||
C: {
|
||||
screwCategory: '全牙螺杆',
|
||||
headType: '',
|
||||
drive: '',
|
||||
tail: '双平尾',
|
||||
material: '35CrMoA',
|
||||
finish: '发黑',
|
||||
tooth: '全牙',
|
||||
diameter: 'M8',
|
||||
length: '100mm',
|
||||
usages: ['板式家具', '五金件', '床架'],
|
||||
color: '黑色',
|
||||
preDrill: '需预开孔',
|
||||
environments: ['室内', '防锈'],
|
||||
},
|
||||
}
|
||||
|
||||
const PRIMARY_BUTTON_CLASS =
|
||||
@ -499,16 +584,24 @@ function getLetterDefaults(letter: AccessoryLibraryLetter) {
|
||||
}
|
||||
|
||||
function getHeadTypes(letter: AccessoryLibraryLetter) {
|
||||
return HEAD_TYPES_BY_LETTER[letter] ?? A_HEAD_TYPES
|
||||
return HEAD_TYPES_BY_LETTER[letter] ?? []
|
||||
}
|
||||
|
||||
function getSelectFields(letter: AccessoryLibraryLetter) {
|
||||
if (letter !== 'B') return SELECT_FIELDS
|
||||
const overrides =
|
||||
letter === 'B'
|
||||
? B_SELECT_FIELD_OVERRIDES
|
||||
: letter === 'C'
|
||||
? C_SELECT_FIELD_OVERRIDES
|
||||
: {}
|
||||
const hiddenKeys = HIDDEN_SELECT_FIELD_KEYS_BY_LETTER[letter] ?? []
|
||||
|
||||
return SELECT_FIELDS.map((field) => ({
|
||||
...field,
|
||||
...(B_SELECT_FIELD_OVERRIDES[field.key] ?? {}),
|
||||
}))
|
||||
return SELECT_FIELDS.filter((field) => !hiddenKeys.includes(field.key)).map(
|
||||
(field) => ({
|
||||
...field,
|
||||
...(overrides[field.key] ?? {}),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeScrewItem(item: AccessoryLibraryItem): ScrewItem {
|
||||
@ -564,15 +657,28 @@ function formatScrewLength(length: string) {
|
||||
function buildScrewName(
|
||||
item: Pick<
|
||||
ScrewItem,
|
||||
'diameter' | 'length' | 'drive' | 'headType' | 'tail' | 'screwCategory'
|
||||
| 'letter'
|
||||
| 'diameter'
|
||||
| 'length'
|
||||
| 'drive'
|
||||
| 'headType'
|
||||
| 'tail'
|
||||
| 'screwCategory'
|
||||
>,
|
||||
) {
|
||||
const diameter = item.diameter.trim()
|
||||
const length = formatScrewLength(item.length)
|
||||
const specLength =
|
||||
diameter && length ? `${diameter}*${length}` : diameter || length
|
||||
const size = formatScrewLength(item.length)
|
||||
const specSize =
|
||||
diameter && size ? `${diameter}*${size}` : diameter || size
|
||||
|
||||
return [specLength, item.drive, item.headType, item.tail, item.screwCategory]
|
||||
if (item.letter === 'C') {
|
||||
return [specSize, item.tail, item.screwCategory]
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
return [specSize, item.drive, item.headType, item.tail, item.screwCategory]
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
@ -627,7 +733,7 @@ function findDuplicateScrewItem(
|
||||
) {
|
||||
return (
|
||||
items.find((item) => {
|
||||
if (item.letter !== 'A' || item.id === excludeItemId) return false
|
||||
if (item.letter !== draft.letter || item.id === excludeItemId) return false
|
||||
|
||||
return SCREW_DUPLICATE_KEYS.every(
|
||||
(key) =>
|
||||
@ -734,6 +840,7 @@ function MultiTagField({
|
||||
}
|
||||
|
||||
export default function AccessoryLibraryPanel({
|
||||
canEditItems = true,
|
||||
canDeleteItems = false,
|
||||
}: AccessoryLibraryPanelProps) {
|
||||
const [items, setItems] = useState<ScrewItem[]>(readScrewItems)
|
||||
@ -777,10 +884,18 @@ export default function AccessoryLibraryPanel({
|
||||
() => getHeadTypes(selectedLetter),
|
||||
[selectedLetter],
|
||||
)
|
||||
const shouldShowHeadType = selectedHeadTypes.length > 0
|
||||
const selectedSelectFields = useMemo(
|
||||
() => getSelectFields(selectedLetter),
|
||||
[selectedLetter],
|
||||
)
|
||||
const selectedTableColumns = useMemo(
|
||||
() => {
|
||||
const columns = getTableColumns(selectedLetter, shouldShowHeadType)
|
||||
return canEditItems ? columns : columns.filter((column) => column !== '操作')
|
||||
},
|
||||
[canEditItems, selectedLetter, shouldShowHeadType],
|
||||
)
|
||||
|
||||
const totalItemsByLetter = useMemo(() => {
|
||||
return ACCESSORY_LIBRARY_LETTERS.reduce<Record<AccessoryLibraryLetter, number>>(
|
||||
@ -801,7 +916,9 @@ export default function AccessoryLibraryPanel({
|
||||
const codeKeyword = normalizeText(appliedFilters.code)
|
||||
const nameKeyword = normalizeText(appliedFilters.name)
|
||||
const headTypeFilter =
|
||||
appliedFilters.headType || (activeHeadType === '全部' ? '' : activeHeadType)
|
||||
shouldShowHeadType
|
||||
? appliedFilters.headType || (activeHeadType === '全部' ? '' : activeHeadType)
|
||||
: ''
|
||||
|
||||
if (codeKeyword && !normalizeText(item.code).includes(codeKeyword)) {
|
||||
return false
|
||||
@ -826,6 +943,7 @@ export default function AccessoryLibraryPanel({
|
||||
isSelectedLetterConfigured,
|
||||
selectedLetterItems,
|
||||
selectedSelectFields,
|
||||
shouldShowHeadType,
|
||||
])
|
||||
|
||||
function updateFilterDraft(key: keyof ScrewFilterState, value: string) {
|
||||
@ -867,6 +985,8 @@ export default function AccessoryLibraryPanel({
|
||||
}
|
||||
|
||||
function openCreateModal(source?: ScrewItem) {
|
||||
if (!canEditItems) return
|
||||
|
||||
setEditingId(null)
|
||||
setCopySource(source ?? null)
|
||||
setDraft(createScrewDraft(items, selectedLetter, source))
|
||||
@ -874,6 +994,8 @@ export default function AccessoryLibraryPanel({
|
||||
}
|
||||
|
||||
function openEditModal(item: ScrewItem) {
|
||||
if (!canEditItems) return
|
||||
|
||||
setEditingId(item.id)
|
||||
setCopySource(null)
|
||||
setDraft({
|
||||
@ -901,9 +1023,14 @@ export default function AccessoryLibraryPanel({
|
||||
|
||||
const nextDraft = { ...current, [key]: value }
|
||||
if (
|
||||
['diameter', 'length', 'drive', 'headType', 'tail', 'screwCategory'].includes(
|
||||
key,
|
||||
)
|
||||
[
|
||||
'diameter',
|
||||
'length',
|
||||
'drive',
|
||||
'headType',
|
||||
'tail',
|
||||
'screwCategory',
|
||||
].includes(key)
|
||||
) {
|
||||
return {
|
||||
...nextDraft,
|
||||
@ -932,6 +1059,8 @@ export default function AccessoryLibraryPanel({
|
||||
}
|
||||
|
||||
async function handleDraftImageChange(event: ChangeEvent<HTMLInputElement>) {
|
||||
if (!canEditItems) return
|
||||
|
||||
const file = event.target.files?.[0]
|
||||
if (!file || !draft) return
|
||||
|
||||
@ -971,6 +1100,8 @@ export default function AccessoryLibraryPanel({
|
||||
}
|
||||
|
||||
function clearDraftImage() {
|
||||
if (!canEditItems) return
|
||||
|
||||
if (!draft) return
|
||||
|
||||
setDraft({
|
||||
@ -981,6 +1112,8 @@ export default function AccessoryLibraryPanel({
|
||||
}
|
||||
|
||||
function saveDraft() {
|
||||
if (!canEditItems) return
|
||||
|
||||
if (!draft) return
|
||||
|
||||
const duplicateItem = findDuplicateScrewItem(items, draft, editingId)
|
||||
@ -1061,15 +1194,17 @@ export default function AccessoryLibraryPanel({
|
||||
placeholder="沉头螺丝 / 盘头螺丝"
|
||||
/>
|
||||
</label>
|
||||
<div className="w-[112px]">
|
||||
<SelectField
|
||||
compact
|
||||
label="头型"
|
||||
value={filterDraft.headType}
|
||||
options={selectedHeadTypes.map((item) => item.name)}
|
||||
onChange={(value) => updateFilterDraft('headType', value)}
|
||||
/>
|
||||
</div>
|
||||
{shouldShowHeadType ? (
|
||||
<div className="w-[112px]">
|
||||
<SelectField
|
||||
compact
|
||||
label="头型"
|
||||
value={filterDraft.headType}
|
||||
options={selectedHeadTypes.map((item) => item.name)}
|
||||
onChange={(value) => updateFilterDraft('headType', value)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{selectedSelectFields.map((field) => (
|
||||
<div key={field.label} className="w-[112px]">
|
||||
<SelectField
|
||||
@ -1163,7 +1298,7 @@ export default function AccessoryLibraryPanel({
|
||||
{selectedLetterCategory ? ` · ${selectedLetterCategory}` : ''}
|
||||
</h2>
|
||||
</div>
|
||||
{isSelectedLetterConfigured ? (
|
||||
{isSelectedLetterConfigured && canEditItems ? (
|
||||
<button
|
||||
type="button"
|
||||
className={PRIMARY_BUTTON_CLASS}
|
||||
@ -1175,7 +1310,7 @@ export default function AccessoryLibraryPanel({
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{isSelectedLetterConfigured ? (
|
||||
{isSelectedLetterConfigured && shouldShowHeadType ? (
|
||||
<div className="mb-4 flex flex-wrap items-center gap-1.5 rounded-apple-lg border border-apple-separator bg-apple-bg-secondary/30 px-2 py-1.5">
|
||||
<button
|
||||
type="button"
|
||||
@ -1214,7 +1349,7 @@ export default function AccessoryLibraryPanel({
|
||||
<table className="w-full min-w-[1980px] border-collapse text-xs">
|
||||
<thead className="sticky top-0 z-10 bg-apple-bg-primary text-apple-label-secondary">
|
||||
<tr className="border-b border-apple-separator">
|
||||
{TABLE_COLUMNS.map((column) => (
|
||||
{selectedTableColumns.map((column) => (
|
||||
<th
|
||||
key={column}
|
||||
className={`whitespace-nowrap border-r border-apple-separator px-3 py-2 text-left font-apple-semibold last:border-r-0 ${
|
||||
@ -1269,12 +1404,16 @@ export default function AccessoryLibraryPanel({
|
||||
<td className="whitespace-nowrap border-r border-apple-separator px-3 py-2 text-apple-label-secondary">
|
||||
{item.screwCategory}
|
||||
</td>
|
||||
<td className="whitespace-nowrap border-r border-apple-separator px-3 py-2 text-apple-label-secondary">
|
||||
{item.headType}
|
||||
</td>
|
||||
<td className="whitespace-nowrap border-r border-apple-separator px-3 py-2 text-apple-label-secondary">
|
||||
{item.drive}
|
||||
</td>
|
||||
{shouldShowHeadType ? (
|
||||
<td className="whitespace-nowrap border-r border-apple-separator px-3 py-2 text-apple-label-secondary">
|
||||
{item.headType}
|
||||
</td>
|
||||
) : null}
|
||||
{selectedLetter !== 'C' ? (
|
||||
<td className="whitespace-nowrap border-r border-apple-separator px-3 py-2 text-apple-label-secondary">
|
||||
{item.drive}
|
||||
</td>
|
||||
) : null}
|
||||
<td className="whitespace-nowrap border-r border-apple-separator px-3 py-2 text-apple-label-secondary">
|
||||
{item.tail}
|
||||
</td>
|
||||
@ -1308,49 +1447,51 @@ export default function AccessoryLibraryPanel({
|
||||
<td className="whitespace-nowrap border-r border-apple-separator px-3 py-2 font-apple-semibold text-apple-label-primary">
|
||||
{item.price}
|
||||
</td>
|
||||
<td
|
||||
className={`sticky right-0 z-10 whitespace-nowrap px-2 py-2 shadow-[-8px_0_12px_rgba(0,0,0,0.04)] ${
|
||||
index % 2 === 0 ? 'bg-[#f7f8f8]' : 'bg-apple-bg-primary'
|
||||
}`}
|
||||
>
|
||||
<div className="flex justify-end gap-1">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="编辑"
|
||||
title="编辑"
|
||||
className={OPERATION_ICON_BUTTON_CLASS}
|
||||
onClick={() => openEditModal(item)}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="复制"
|
||||
title="复制"
|
||||
className={OPERATION_ICON_BUTTON_CLASS}
|
||||
onClick={() => openCreateModal(item)}
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
{canDeleteItems ? (
|
||||
{canEditItems ? (
|
||||
<td
|
||||
className={`sticky right-0 z-10 whitespace-nowrap px-2 py-2 shadow-[-8px_0_12px_rgba(0,0,0,0.04)] ${
|
||||
index % 2 === 0 ? 'bg-[#f7f8f8]' : 'bg-apple-bg-primary'
|
||||
}`}
|
||||
>
|
||||
<div className="flex justify-end gap-1">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="删除"
|
||||
title="删除"
|
||||
className={DELETE_ICON_BUTTON_CLASS}
|
||||
onClick={() => removeItem(item)}
|
||||
aria-label="编辑"
|
||||
title="编辑"
|
||||
className={OPERATION_ICON_BUTTON_CLASS}
|
||||
onClick={() => openEditModal(item)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</td>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="复制"
|
||||
title="复制"
|
||||
className={OPERATION_ICON_BUTTON_CLASS}
|
||||
onClick={() => openCreateModal(item)}
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
{canDeleteItems ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="删除"
|
||||
title="删除"
|
||||
className={DELETE_ICON_BUTTON_CLASS}
|
||||
onClick={() => removeItem(item)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</td>
|
||||
) : null}
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={TABLE_COLUMNS.length}
|
||||
colSpan={selectedTableColumns.length}
|
||||
className="border-b border-apple-separator px-6 py-12 text-center text-sm text-apple-label-secondary"
|
||||
>
|
||||
{hasActiveFilters
|
||||
@ -1445,12 +1586,14 @@ export default function AccessoryLibraryPanel({
|
||||
onChange={(value) => updateDraftField(field.key, value)}
|
||||
/>
|
||||
))}
|
||||
<SelectField
|
||||
label="头型"
|
||||
value={draft.headType}
|
||||
options={getHeadTypes(draft.letter).map((item) => item.name)}
|
||||
onChange={(value) => updateDraftField('headType', value)}
|
||||
/>
|
||||
{getHeadTypes(draft.letter).length ? (
|
||||
<SelectField
|
||||
label="头型"
|
||||
value={draft.headType}
|
||||
options={getHeadTypes(draft.letter).map((item) => item.name)}
|
||||
onChange={(value) => updateDraftField('headType', value)}
|
||||
/>
|
||||
) : null}
|
||||
{getSelectFields(draft.letter).slice(1, 7).map((field) => (
|
||||
<SelectField
|
||||
key={field.label}
|
||||
|
||||
@ -23,7 +23,7 @@ describe('alphabet library panels', () => {
|
||||
|
||||
await actor.click(screen.getByRole('button', { name: '新增 A 组' }))
|
||||
|
||||
const dialog = await screen.findByRole('dialog', { name: '新增自攻螺丝' })
|
||||
const dialog = await screen.findByRole('dialog', { name: '新增A组配件' })
|
||||
expect(within(dialog).getByLabelText('配件名称')).toHaveValue(
|
||||
'M4*16 十字 沉头 尖尾 自攻螺丝',
|
||||
)
|
||||
@ -66,10 +66,10 @@ describe('alphabet library panels', () => {
|
||||
|
||||
render(<AccessoryLibraryPanel />)
|
||||
|
||||
await actor.click(screen.getByRole('button', { name: 'B 0 条' }))
|
||||
await actor.click(screen.getByRole('button', { name: 'D 0 条' }))
|
||||
|
||||
expect(screen.getByText('B 组配件')).toBeInTheDocument()
|
||||
expect(screen.getByText('B 组暂未配置配件分类')).toBeInTheDocument()
|
||||
expect(screen.getByText('D 组配件')).toBeInTheDocument()
|
||||
expect(screen.getByText('D 组暂未配置配件分类')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: '新增 A 组' })).not.toBeInTheDocument()
|
||||
|
||||
await actor.click(screen.getByRole('button', { name: 'A 自攻螺丝 0 条' }))
|
||||
@ -78,13 +78,70 @@ describe('alphabet library panels', () => {
|
||||
expect(screen.getByRole('button', { name: '新增 A 组' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('uses the connection screw rod rules for C accessories', async () => {
|
||||
const actor = userEvent.setup()
|
||||
|
||||
render(<AccessoryLibraryPanel />)
|
||||
|
||||
await actor.click(screen.getByRole('button', { name: 'C 连接螺杆 0 条' }))
|
||||
|
||||
expect(screen.getByText('C 组配件 · 连接螺杆')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: '全部头型' })).not.toBeInTheDocument()
|
||||
|
||||
await actor.click(screen.getByRole('button', { name: '新增 C 组' }))
|
||||
|
||||
const dialog = await screen.findByRole('dialog', { name: '新增C组配件' })
|
||||
expect(within(dialog).getByLabelText('配件名称')).toHaveValue(
|
||||
'M8*100 双平尾 全牙螺杆',
|
||||
)
|
||||
expect(within(dialog).queryByLabelText('头型')).not.toBeInTheDocument()
|
||||
expect(within(dialog).queryByLabelText('槽型')).not.toBeInTheDocument()
|
||||
expect(within(dialog).getByLabelText('尺寸')).toBeInTheDocument()
|
||||
|
||||
fireEvent.change(within(dialog).getByLabelText('分类'), {
|
||||
target: { value: '双头牙' },
|
||||
})
|
||||
fireEvent.change(within(dialog).getByLabelText('尾型'), {
|
||||
target: { value: '尖平尾' },
|
||||
})
|
||||
fireEvent.change(within(dialog).getByLabelText('尺寸'), {
|
||||
target: { value: '70mm' },
|
||||
})
|
||||
|
||||
expect(within(dialog).getByLabelText('配件名称')).toHaveValue(
|
||||
'M8*70 尖平尾 双头牙',
|
||||
)
|
||||
|
||||
await actor.click(within(dialog).getByRole('button', { name: '保存' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('M8*70 尖平尾 双头牙')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const savedItems = JSON.parse(
|
||||
window.localStorage.getItem(ACCESSORY_LIBRARY_STORAGE_KEY) ?? '[]',
|
||||
)
|
||||
expect(savedItems[0]).toMatchObject({
|
||||
letter: 'C',
|
||||
code: 'C0',
|
||||
name: 'M8*70 尖平尾 双头牙',
|
||||
screwCategory: '双头牙',
|
||||
headType: '',
|
||||
drive: '',
|
||||
tail: '尖平尾',
|
||||
tooth: '全牙',
|
||||
diameter: 'M8',
|
||||
length: '70mm',
|
||||
})
|
||||
})
|
||||
|
||||
it('opens copy-new mode and blocks exact duplicate screw configs', async () => {
|
||||
const actor = userEvent.setup()
|
||||
|
||||
render(<AccessoryLibraryPanel />)
|
||||
|
||||
await actor.click(screen.getByRole('button', { name: '新增 A 组' }))
|
||||
let dialog = await screen.findByRole('dialog', { name: '新增自攻螺丝' })
|
||||
let dialog = await screen.findByRole('dialog', { name: '新增A组配件' })
|
||||
await actor.click(within(dialog).getByRole('button', { name: '保存' }))
|
||||
|
||||
await waitFor(() => {
|
||||
@ -118,7 +175,7 @@ describe('alphabet library panels', () => {
|
||||
const { rerender } = render(<AccessoryLibraryPanel />)
|
||||
|
||||
await actor.click(screen.getByRole('button', { name: '新增 A 组' }))
|
||||
let dialog = await screen.findByRole('dialog', { name: '新增自攻螺丝' })
|
||||
let dialog = await screen.findByRole('dialog', { name: '新增A组配件' })
|
||||
await actor.click(within(dialog).getByRole('button', { name: '保存' }))
|
||||
|
||||
await waitFor(() => {
|
||||
@ -144,6 +201,16 @@ describe('alphabet library panels', () => {
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps accessory library read-only when editing is not allowed', () => {
|
||||
render(<AccessoryLibraryPanel canEditItems={false} />)
|
||||
|
||||
expect(screen.queryByRole('button', { name: '新增 A 组' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: '编辑' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: '复制' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: '删除' })).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '查询' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps the packaging material library on its original fields', async () => {
|
||||
const actor = userEvent.setup()
|
||||
|
||||
@ -157,4 +224,11 @@ describe('alphabet library panels', () => {
|
||||
expect(within(dialog).queryByLabelText('颜色')).not.toBeInTheDocument()
|
||||
expect(within(dialog).queryByLabelText('材质')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps packaging material library read-only when editing is not allowed', () => {
|
||||
render(<PackagingMaterialLibraryPanel canEditItems={false} />)
|
||||
|
||||
expect(screen.queryByRole('button', { name: '新增当前字母' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: '新增' })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -51,6 +51,7 @@ type AlphabetLibraryExtraFieldDefinition = {
|
||||
interface AlphabetLibraryPanelProps {
|
||||
storageKey: string
|
||||
entityName: string
|
||||
canEditItems?: boolean
|
||||
queryNamePlaceholder?: string
|
||||
changedEventName?: string
|
||||
extraFields?: AlphabetLibraryExtraFieldDefinition[]
|
||||
@ -150,6 +151,7 @@ function createDraft(items: AlphabetLibraryItem[], letter: AlphabetLibraryLetter
|
||||
export default function AlphabetLibraryPanel({
|
||||
storageKey,
|
||||
entityName,
|
||||
canEditItems = true,
|
||||
queryNamePlaceholder,
|
||||
changedEventName,
|
||||
extraFields = [],
|
||||
@ -243,12 +245,16 @@ export default function AlphabetLibraryPanel({
|
||||
}
|
||||
|
||||
function openCreateModal(letter: AlphabetLibraryLetter) {
|
||||
if (!canEditItems) return
|
||||
|
||||
setEditingId(null)
|
||||
setEditingError('')
|
||||
setDraft(createDraft(items, letter))
|
||||
}
|
||||
|
||||
function openEditModal(item: AlphabetLibraryItem) {
|
||||
if (!canEditItems) return
|
||||
|
||||
setEditingId(item.id)
|
||||
setEditingError('')
|
||||
setDraft({
|
||||
@ -264,6 +270,8 @@ export default function AlphabetLibraryPanel({
|
||||
}
|
||||
|
||||
async function handleDraftImageChange(event: ChangeEvent<HTMLInputElement>) {
|
||||
if (!canEditItems) return
|
||||
|
||||
const file = event.target.files?.[0]
|
||||
if (!file || !draft) return
|
||||
|
||||
@ -302,6 +310,8 @@ export default function AlphabetLibraryPanel({
|
||||
}
|
||||
|
||||
function clearDraftImage() {
|
||||
if (!canEditItems) return
|
||||
|
||||
if (!draft) return
|
||||
|
||||
setDraft({
|
||||
@ -312,6 +322,8 @@ export default function AlphabetLibraryPanel({
|
||||
}
|
||||
|
||||
function saveDraft() {
|
||||
if (!canEditItems) return
|
||||
|
||||
if (!draft) return
|
||||
|
||||
const normalizedCode = draft.code.trim().toUpperCase()
|
||||
@ -368,6 +380,8 @@ export default function AlphabetLibraryPanel({
|
||||
}
|
||||
|
||||
function removeItem(id: string) {
|
||||
if (!canEditItems) return
|
||||
|
||||
const target = items.find((item) => item.id === id)
|
||||
if (!target) return
|
||||
|
||||
@ -405,16 +419,18 @@ export default function AlphabetLibraryPanel({
|
||||
<Search className="h-4 w-4" />
|
||||
查询
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="tinted"
|
||||
size="small"
|
||||
className={LIBRARY_PRIMARY_BUTTON_CLASS}
|
||||
onClick={() => openCreateModal(activeLetter)}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
新增当前字母
|
||||
</Button>
|
||||
{canEditItems ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="tinted"
|
||||
size="small"
|
||||
className={LIBRARY_PRIMARY_BUTTON_CLASS}
|
||||
onClick={() => openCreateModal(activeLetter)}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
新增当前字母
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -462,13 +478,15 @@ export default function AlphabetLibraryPanel({
|
||||
{hasActiveQuery ? `${filteredCount}/${totalCount}` : totalCount} 条
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openCreateModal(letterKey)}
|
||||
className="rounded-full border border-zinc-950 bg-zinc-950 px-2 py-1 text-[11px] text-white transition hover:border-zinc-800 hover:bg-zinc-800"
|
||||
>
|
||||
新增
|
||||
</button>
|
||||
{canEditItems ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openCreateModal(letterKey)}
|
||||
className="rounded-full border border-zinc-950 bg-zinc-950 px-2 py-1 text-[11px] text-white transition hover:border-zinc-800 hover:bg-zinc-800"
|
||||
>
|
||||
新增
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@ -486,16 +504,18 @@ export default function AlphabetLibraryPanel({
|
||||
当前显示 {currentLetterItems.length} 条数据,字段为{libraryFieldSummary}。
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="gray"
|
||||
size="small"
|
||||
className={LIBRARY_PRIMARY_BUTTON_CLASS}
|
||||
onClick={() => openCreateModal(activeLetter)}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
新增 {activeLetter} 组
|
||||
</Button>
|
||||
{canEditItems ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="gray"
|
||||
size="small"
|
||||
className={LIBRARY_PRIMARY_BUTTON_CLASS}
|
||||
onClick={() => openCreateModal(activeLetter)}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
新增 {activeLetter} 组
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto pb-1">
|
||||
@ -591,22 +611,24 @@ export default function AlphabetLibraryPanel({
|
||||
>
|
||||
{item.price || '待补价格'}
|
||||
</p>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2 text-[11px]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openEditModal(item)}
|
||||
className="rounded-full border border-zinc-950 bg-zinc-950 px-2 py-1 text-white transition hover:border-zinc-800 hover:bg-zinc-800"
|
||||
>
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeItem(item.id)}
|
||||
className="rounded-full border border-apple-separator px-2 py-1 text-rose-600 transition hover:bg-rose-50"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
{canEditItems ? (
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2 text-[11px]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openEditModal(item)}
|
||||
className="rounded-full border border-zinc-950 bg-zinc-950 px-2 py-1 text-white transition hover:border-zinc-800 hover:bg-zinc-800"
|
||||
>
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeItem(item.id)}
|
||||
className="rounded-full border border-apple-separator px-2 py-1 text-rose-600 transition hover:bg-rose-50"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
@ -618,7 +640,9 @@ export default function AlphabetLibraryPanel({
|
||||
<p className="mt-2 text-xs leading-5 text-apple-label-secondary">
|
||||
{hasActiveQuery
|
||||
? '你可以换查询词继续查。'
|
||||
: '点击“新增当前字母”即可往这个字母下面继续补资料。'}
|
||||
: canEditItems
|
||||
? '点击“新增当前字母”即可往这个字母下面继续补资料。'
|
||||
: `当前账号只能查看${entityName}资料。`}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -164,7 +164,11 @@ function persistEdgeBandingCategories(categories: EdgeBandingLibraryCategory[])
|
||||
)
|
||||
}
|
||||
|
||||
export default function EdgeBandingLibraryPanel() {
|
||||
export default function EdgeBandingLibraryPanel({
|
||||
canEditItems = true,
|
||||
}: {
|
||||
canEditItems?: boolean
|
||||
}) {
|
||||
const initialItems = useMemo(() => readEdgeBandingLibraryItems(), [])
|
||||
const initialCategories = useMemo(() => readEdgeBandingCategories(), [])
|
||||
const [items, setItems] = useState(initialItems)
|
||||
@ -378,6 +382,8 @@ export default function EdgeBandingLibraryPanel() {
|
||||
}
|
||||
|
||||
function startCreate() {
|
||||
if (!canEditItems) return
|
||||
|
||||
setEditorMode('create')
|
||||
setEditingItemId(null)
|
||||
setSelectedItemId(null)
|
||||
@ -393,6 +399,8 @@ export default function EdgeBandingLibraryPanel() {
|
||||
}
|
||||
|
||||
function startCreateCategory() {
|
||||
if (!canEditItems) return
|
||||
|
||||
setSelectedGroupKey(null)
|
||||
setEditorMode('createCategory')
|
||||
setEditingItemId(null)
|
||||
@ -405,6 +413,14 @@ export default function EdgeBandingLibraryPanel() {
|
||||
function selectItem(item: EdgeBandingLibraryItem) {
|
||||
setSelectedGroupKey(getEdgeBandingGroupKey(item))
|
||||
setSelectedItemId(item.id)
|
||||
if (!canEditItems) {
|
||||
setEditorMode('view')
|
||||
setEditingItemId(null)
|
||||
setEditorDraft(createEmptyEdgeBandingDraft())
|
||||
setPanelMessage('')
|
||||
return
|
||||
}
|
||||
|
||||
setEditorMode('edit')
|
||||
setEditingItemId(item.id)
|
||||
setEditorDraft({
|
||||
@ -418,6 +434,8 @@ export default function EdgeBandingLibraryPanel() {
|
||||
}
|
||||
|
||||
function copySelectedItem() {
|
||||
if (!canEditItems) return
|
||||
|
||||
if (!selectedItem) return
|
||||
|
||||
setSelectedGroupKey(getEdgeBandingGroupKey(selectedItem))
|
||||
@ -443,6 +461,8 @@ export default function EdgeBandingLibraryPanel() {
|
||||
}
|
||||
|
||||
function saveDraft() {
|
||||
if (!canEditItems) return
|
||||
|
||||
if (!draftCanSave) {
|
||||
setPanelMessage('请先填完整规格名称、规格、单位、单价和适配板材厚度。')
|
||||
return
|
||||
@ -479,6 +499,8 @@ export default function EdgeBandingLibraryPanel() {
|
||||
}
|
||||
|
||||
function saveCategoryDraft() {
|
||||
if (!canEditItems) return
|
||||
|
||||
const nextCategory = categoryDraft
|
||||
if (!nextCategory) {
|
||||
setPanelMessage('请先填写分类名称。')
|
||||
@ -506,6 +528,8 @@ export default function EdgeBandingLibraryPanel() {
|
||||
}
|
||||
|
||||
function deleteSelectedItem() {
|
||||
if (!canEditItems) return
|
||||
|
||||
if (!editingItemId) return
|
||||
|
||||
const target = normalizedItems.find((item) => item.id === editingItemId)
|
||||
@ -527,6 +551,8 @@ export default function EdgeBandingLibraryPanel() {
|
||||
}
|
||||
|
||||
function deleteActiveGroup() {
|
||||
if (!canEditItems) return
|
||||
|
||||
if (!activeGroup) return
|
||||
|
||||
const targetIds = new Set(activeGroup.items.map((item) => item.id))
|
||||
@ -549,9 +575,10 @@ export default function EdgeBandingLibraryPanel() {
|
||||
}
|
||||
|
||||
const shouldShowEditor =
|
||||
isCreatingCategory ||
|
||||
editorMode === 'create' ||
|
||||
(editorMode === 'edit' && Boolean(editingItemId))
|
||||
canEditItems &&
|
||||
(isCreatingCategory ||
|
||||
editorMode === 'create' ||
|
||||
(editorMode === 'edit' && Boolean(editingItemId)))
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col gap-4">
|
||||
@ -587,16 +614,18 @@ export default function EdgeBandingLibraryPanel() {
|
||||
<Search className="h-4 w-4" />
|
||||
查询
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="tinted"
|
||||
size="small"
|
||||
className={LIBRARY_PRIMARY_BUTTON_CLASS}
|
||||
onClick={startCreate}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
新建封边
|
||||
</Button>
|
||||
{canEditItems ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="tinted"
|
||||
size="small"
|
||||
className={LIBRARY_PRIMARY_BUTTON_CLASS}
|
||||
onClick={startCreate}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
新建封边
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -614,29 +643,31 @@ export default function EdgeBandingLibraryPanel() {
|
||||
{edgeBandingGroups.length} 类
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="tinted"
|
||||
size="small"
|
||||
className={`!min-h-8 !px-2 !py-1 text-xs ${LIBRARY_PRIMARY_BUTTON_CLASS}`}
|
||||
onClick={startCreateCategory}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
新建分类
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="plain"
|
||||
size="small"
|
||||
className="!min-h-8 !px-2 !py-1 text-xs"
|
||||
onClick={deleteActiveGroup}
|
||||
disabled={!activeGroup}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
删除分类
|
||||
</Button>
|
||||
</div>
|
||||
{canEditItems ? (
|
||||
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="tinted"
|
||||
size="small"
|
||||
className={`!min-h-8 !px-2 !py-1 text-xs ${LIBRARY_PRIMARY_BUTTON_CLASS}`}
|
||||
onClick={startCreateCategory}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
新建分类
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="plain"
|
||||
size="small"
|
||||
className="!min-h-8 !px-2 !py-1 text-xs"
|
||||
onClick={deleteActiveGroup}
|
||||
disabled={!activeGroup}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
删除分类
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-3 min-h-0 flex-1 space-y-2 overflow-y-auto pr-1">
|
||||
{edgeBandingGroups.length === 0 ? (
|
||||
@ -700,7 +731,9 @@ export default function EdgeBandingLibraryPanel() {
|
||||
<div className="mt-3 min-h-0 flex-1 space-y-2 overflow-y-auto pr-1">
|
||||
{activeGroupItems.length === 0 ? (
|
||||
<div className="rounded-apple-lg border border-dashed border-apple-separator bg-apple-bg-secondary/50 px-4 py-8 text-center text-xs leading-6 text-apple-label-secondary">
|
||||
请先在左侧选择封边分类,或新建封边条。
|
||||
{canEditItems
|
||||
? '请先在左侧选择封边分类,或新建封边条。'
|
||||
: '请先在左侧选择封边分类。'}
|
||||
</div>
|
||||
) : (
|
||||
activeGroupItems.map((item) => {
|
||||
@ -919,7 +952,7 @@ export default function EdgeBandingLibraryPanel() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full min-h-[420px] items-center justify-center rounded-apple-lg border border-dashed border-apple-separator bg-apple-bg-secondary/40 p-6 text-center text-sm leading-7 text-apple-label-secondary">
|
||||
请先新建一条封边资料。
|
||||
{canEditItems ? '请先新建一条封边资料。' : '当前账号只能查看封边资料。'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -76,7 +76,28 @@ jest.mock('@/components/platform/AccordionSection', () => ({
|
||||
|
||||
jest.mock('@/app/asset-center/material-library/MaterialLibraryClient', () => ({
|
||||
__esModule: true,
|
||||
default: () => <div>material-library-copy</div>,
|
||||
default: ({
|
||||
initialActiveTab,
|
||||
visibleTabs,
|
||||
canEditOverride,
|
||||
}: {
|
||||
initialActiveTab?: string
|
||||
visibleTabs?: string[]
|
||||
canEditOverride?: boolean
|
||||
}) => (
|
||||
<div>
|
||||
material-library-copy
|
||||
<span data-testid="material-library-active-tab">
|
||||
{initialActiveTab ?? 'finish'}
|
||||
</span>
|
||||
<span data-testid="material-library-visible-tabs">
|
||||
{(visibleTabs ?? []).join(',')}
|
||||
</span>
|
||||
<span data-testid="material-library-can-edit">
|
||||
{String(canEditOverride)}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
jest.mock('./non-integrated/NonIntegratedDetailClient', () => ({
|
||||
@ -529,6 +550,7 @@ describe('IntakeClient', () => {
|
||||
expect(within(rightPanel).getByRole('button', { name: 'BOM资料库' })).toBeInTheDocument()
|
||||
expect(within(rightPanel).getByRole('button', { name: '原材料资料库' })).toBeInTheDocument()
|
||||
expect(within(rightPanel).getByRole('button', { name: '板材资料库' })).toBeInTheDocument()
|
||||
expect(within(rightPanel).getByRole('button', { name: '花色资料库' })).toBeInTheDocument()
|
||||
expect(within(rightPanel).getByRole('button', { name: '钢架资料库' })).toBeInTheDocument()
|
||||
expect(within(rightPanel).getByRole('button', { name: '配件资料库' })).toBeInTheDocument()
|
||||
expect(within(rightPanel).getByRole('button', { name: '包材资料库' })).toBeInTheDocument()
|
||||
@ -1498,7 +1520,7 @@ describe('IntakeClient', () => {
|
||||
expect(within(leftPanel).queryByText('GBGJ-SZ0230')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the copied material library and still switches to the packaging library config', async () => {
|
||||
it('splits board and finish material libraries while keeping packaging library config', async () => {
|
||||
const actor = userEvent.setup()
|
||||
renderSelector({ eligibleSkuList: [] })
|
||||
|
||||
@ -1507,6 +1529,14 @@ describe('IntakeClient', () => {
|
||||
await actor.click(within(rightPanel).getByRole('button', { name: 'BOM资料库' }))
|
||||
|
||||
expect(screen.getByText('material-library-copy')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('material-library-active-tab')).toHaveTextContent('substrate')
|
||||
expect(screen.getByTestId('material-library-visible-tabs')).toHaveTextContent('substrate')
|
||||
|
||||
await actor.click(within(rightPanel).getByRole('button', { name: '花色资料库' }))
|
||||
|
||||
expect(screen.getByText('material-library-copy')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('material-library-active-tab')).toHaveTextContent('finish')
|
||||
expect(screen.getByTestId('material-library-visible-tabs')).toHaveTextContent('finish')
|
||||
|
||||
await actor.click(within(rightPanel).getByRole('button', { name: '包材资料库' }))
|
||||
|
||||
@ -1759,7 +1789,9 @@ describe('IntakeClient', () => {
|
||||
expect(screen.getByRole('button', { name: '新增产品' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides design controlled product data entry points from purchasing users', () => {
|
||||
it('keeps BOM libraries visible but read-only entry points hidden from purchasing users', async () => {
|
||||
const actor = userEvent.setup()
|
||||
|
||||
renderSelector({
|
||||
eligibleSkuList: [],
|
||||
userRole: 'USER',
|
||||
@ -1774,9 +1806,45 @@ describe('IntakeClient', () => {
|
||||
|
||||
const rightPanel = screen.getByTestId('right-panel')
|
||||
expect(within(rightPanel).getByRole('button', { name: '产品开发明细' })).toBeInTheDocument()
|
||||
expect(within(rightPanel).queryByRole('button', { name: 'BOM资料库' })).not.toBeInTheDocument()
|
||||
expect(within(rightPanel).getByRole('button', { name: 'BOM资料库' })).toBeInTheDocument()
|
||||
expect(within(rightPanel).queryByRole('button', { name: '产品专利' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: '新增产品' })).not.toBeInTheDocument()
|
||||
|
||||
await actor.click(within(rightPanel).getByRole('button', { name: 'BOM资料库' }))
|
||||
expect(screen.getByText('material-library-copy')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('material-library-active-tab')).toHaveTextContent('substrate')
|
||||
})
|
||||
|
||||
it('keeps BOM library editing enabled for admins', async () => {
|
||||
const actor = userEvent.setup()
|
||||
|
||||
renderSelector({
|
||||
eligibleSkuList: [],
|
||||
userRole: 'ADMIN',
|
||||
userBusinessRoles: ['PURCHASING'],
|
||||
})
|
||||
|
||||
const rightPanel = screen.getByTestId('right-panel')
|
||||
await actor.click(within(rightPanel).getByRole('button', { name: /BOM/ }))
|
||||
|
||||
expect(screen.getByText('material-library-copy')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('material-library-can-edit')).toHaveTextContent('true')
|
||||
})
|
||||
|
||||
it('keeps BOM library editing enabled for designers', async () => {
|
||||
const actor = userEvent.setup()
|
||||
|
||||
renderSelector({
|
||||
eligibleSkuList: [],
|
||||
userRole: 'USER',
|
||||
userBusinessRoles: ['DESIGN'],
|
||||
})
|
||||
|
||||
const rightPanel = screen.getByTestId('right-panel')
|
||||
await actor.click(within(rightPanel).getByRole('button', { name: /BOM/ }))
|
||||
|
||||
expect(screen.getByText('material-library-copy')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('material-library-can-edit')).toHaveTextContent('true')
|
||||
})
|
||||
|
||||
it('opens the series development list with the same product detail capabilities', async () => {
|
||||
@ -2527,6 +2595,7 @@ describe('IntakeClient', () => {
|
||||
|
||||
expect(screen.getByRole('tab', { name: '板材资料库' })).toBeInTheDocument()
|
||||
expect(screen.getByText('material-library-copy')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('material-library-active-tab')).toHaveTextContent('substrate')
|
||||
|
||||
await actor.click(screen.getByRole('button', { name: '关闭板材资料库' }))
|
||||
|
||||
@ -3291,12 +3360,8 @@ describe('IntakeClient', () => {
|
||||
|
||||
const rightPanel = screen.getByTestId('right-panel')
|
||||
const ruleLibraryButton = within(rightPanel).getByRole('button', { name: 'SPU规则库' })
|
||||
const rightPanelText = rightPanel.textContent ?? ''
|
||||
|
||||
expect(ruleLibraryButton).toBeInTheDocument()
|
||||
expect(rightPanelText.indexOf('SPU规则库')).toBeGreaterThan(
|
||||
rightPanelText.indexOf('产品专利'),
|
||||
)
|
||||
|
||||
await actor.click(ruleLibraryButton)
|
||||
|
||||
|
||||
@ -287,6 +287,7 @@ type ProductDevelopmentKind = NonNullable<
|
||||
type NewNonIntegratedCreateKind = "new" | "stock";
|
||||
type BomLibraryKey =
|
||||
| "materialLibrary"
|
||||
| "finishLibrary"
|
||||
| "edgeBandingLibrary"
|
||||
| "steelFrameLibrary"
|
||||
| "packagingLibrary"
|
||||
@ -1283,9 +1284,17 @@ const BOM_LIBRARY_OPTIONS: {
|
||||
key: "materialLibrary",
|
||||
label: "板材资料库",
|
||||
description:
|
||||
"直接复用资产中心板材资料库的结构与内容,方便在正式产品里继续查看和维护。",
|
||||
"只展示板材基材,方便在正式产品 BOM 下维护板材本体资料。",
|
||||
helper:
|
||||
"这里展示资产中心板材资料库的复制视图,基材、饰面和字典规则保持同一套操作方式。",
|
||||
"数据仍复用资产中心基础资料库,系列和饰面花色入口在这里不显示。",
|
||||
},
|
||||
{
|
||||
key: "finishLibrary",
|
||||
label: "花色资料库",
|
||||
description:
|
||||
"只展示饰面花色,方便在正式产品 BOM 下维护花色、贴图和参数。",
|
||||
helper:
|
||||
"数据仍复用资产中心基础资料库,板材基材入口在这里不显示。",
|
||||
},
|
||||
{
|
||||
key: "edgeBandingLibrary",
|
||||
@ -1835,7 +1844,6 @@ function readInitialIntakeWorkspaceState(params: {
|
||||
|
||||
function isDesignControlledWorkspaceTab(tab: IntakeWorkspaceTab) {
|
||||
if (tab.kind === "newNonIntegratedProduct") return true;
|
||||
if (tab.kind === "bomLibrary") return true;
|
||||
if (tab.kind === "adminNodePermission") return true;
|
||||
if (tab.kind === "productPatentDashboard") return true;
|
||||
return tab.kind === "productPatentLedger" && !tab.target;
|
||||
@ -1882,7 +1890,6 @@ function persistIntakeWorkspaceState(params: {
|
||||
|
||||
function restrictSectionForBusinessUser(section: IntakeSection): IntakeSection {
|
||||
if (
|
||||
section === "bom" ||
|
||||
section === "patents" ||
|
||||
section === "adminNodePermission"
|
||||
) {
|
||||
@ -6212,6 +6219,7 @@ function BomDetail({
|
||||
materialLibraryFinishes,
|
||||
materialLibraryCategories,
|
||||
materialLibraryDictionaries,
|
||||
onMaterialLibraryFinishesChange,
|
||||
}: {
|
||||
user: IntakeClientProps["user"];
|
||||
activeBomLibrary: BomLibraryKey;
|
||||
@ -6219,33 +6227,50 @@ function BomDetail({
|
||||
materialLibraryFinishes: MaterialDTO[];
|
||||
materialLibraryCategories: MaterialCategoryDTO[];
|
||||
materialLibraryDictionaries: SubstrateDictionaryBundleDTO;
|
||||
onMaterialLibraryFinishesChange?: (finishes: MaterialDTO[]) => void;
|
||||
}) {
|
||||
if (activeBomLibrary === "materialLibrary") {
|
||||
const canEditBomLibraries =
|
||||
user.role === "SUPER_ADMIN" ||
|
||||
user.role === "ADMIN" ||
|
||||
isIntakeNonIntegratedDesignDepartmentUser(user);
|
||||
|
||||
if (activeBomLibrary === "materialLibrary" || activeBomLibrary === "finishLibrary") {
|
||||
const visibleTab = activeBomLibrary === "materialLibrary" ? "substrate" : "finish";
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col overflow-hidden rounded-apple-xl border border-apple-separator bg-apple-bg-primary shadow-apple-xs">
|
||||
<MaterialLibraryClient
|
||||
key={activeBomLibrary}
|
||||
embedded
|
||||
canEditOverride={canEditBomLibraries}
|
||||
initialActiveTab={visibleTab}
|
||||
visibleTabs={[visibleTab]}
|
||||
initialSubstrates={materialLibrarySubstrates}
|
||||
initialFinishes={materialLibraryFinishes}
|
||||
initialCategories={materialLibraryCategories}
|
||||
initialDictionaries={materialLibraryDictionaries}
|
||||
onFinishesChange={onMaterialLibraryFinishesChange}
|
||||
user={user}
|
||||
metrics={[
|
||||
{
|
||||
label: "板材基材",
|
||||
value: materialLibrarySubstrates.length,
|
||||
icon: <Package className="h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
label: "饰面花色",
|
||||
value: materialLibraryFinishes.length,
|
||||
icon: <Palette className="h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
label: "花色分类",
|
||||
value: materialLibraryCategories.length,
|
||||
icon: <Layers3 className="h-4 w-4" />,
|
||||
},
|
||||
...(visibleTab === "substrate"
|
||||
? [
|
||||
{
|
||||
label: "板材基材",
|
||||
value: materialLibrarySubstrates.length,
|
||||
icon: <Package className="h-4 w-4" />,
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
label: "饰面花色",
|
||||
value: materialLibraryFinishes.length,
|
||||
icon: <Palette className="h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
label: "花色分类",
|
||||
value: materialLibraryCategories.length,
|
||||
icon: <Layers3 className="h-4 w-4" />,
|
||||
},
|
||||
]),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
@ -6256,7 +6281,7 @@ function BomDetail({
|
||||
return (
|
||||
<div className="flex h-full flex-col rounded-apple-xl border border-apple-separator bg-apple-bg-primary p-4 shadow-apple-xs">
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<SteelFrameLibraryPanel />
|
||||
<SteelFrameLibraryPanel canEditItems={canEditBomLibraries} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@ -6266,7 +6291,7 @@ function BomDetail({
|
||||
return (
|
||||
<div className="flex h-full flex-col rounded-apple-xl border border-apple-separator bg-apple-bg-primary p-4 shadow-apple-xs">
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<EdgeBandingLibraryPanel />
|
||||
<EdgeBandingLibraryPanel canEditItems={canEditBomLibraries} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@ -6276,7 +6301,7 @@ function BomDetail({
|
||||
return (
|
||||
<div className="flex h-full flex-col rounded-apple-xl border border-apple-separator bg-apple-bg-primary p-4 shadow-apple-xs">
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<PackagingMaterialLibraryPanel />
|
||||
<PackagingMaterialLibraryPanel canEditItems={canEditBomLibraries} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@ -6286,7 +6311,11 @@ function BomDetail({
|
||||
<div className="flex h-full flex-col rounded-apple-xl border border-apple-separator bg-apple-bg-primary p-4 shadow-apple-xs">
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<AccessoryLibraryPanel
|
||||
canDeleteItems={user.role === "SUPER_ADMIN" || user.role === "ADMIN"}
|
||||
canEditItems={canEditBomLibraries}
|
||||
canDeleteItems={
|
||||
canEditBomLibraries &&
|
||||
(user.role === "SUPER_ADMIN" || user.role === "ADMIN")
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -9410,39 +9439,37 @@ function FormalIntakeWorkbench({
|
||||
</div>
|
||||
</AccordionSection>
|
||||
|
||||
{showDesignControlledProductData ? (
|
||||
<AccordionSection
|
||||
title="BOM资料库"
|
||||
compact
|
||||
icon={
|
||||
<FormalSidebarIcon
|
||||
icon={FORMAL_SIDEBAR_GROUP_ICONS.bom}
|
||||
className={FORMAL_SIDEBAR_SECTION_ICON_CLASS}
|
||||
/>
|
||||
}
|
||||
expanded={activeSection === "bom"}
|
||||
onToggle={() => onToggle("bom")}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
{BOM_LIBRARY_OPTIONS.map((option) => (
|
||||
<button
|
||||
key={option.key}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onBomLibraryChange(option.key);
|
||||
}}
|
||||
className={`flex w-full items-center rounded-apple-lg border px-3 py-2 text-left text-xs transition ${
|
||||
activeSection === "bom" && activeBomLibrary === option.key
|
||||
? "border-apple-blue bg-apple-blue/10 text-apple-blue"
|
||||
: "border-apple-separator bg-apple-bg-secondary text-apple-label-secondary"
|
||||
}`}
|
||||
>
|
||||
<span>{option.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</AccordionSection>
|
||||
) : null}
|
||||
<AccordionSection
|
||||
title="BOM资料库"
|
||||
compact
|
||||
icon={
|
||||
<FormalSidebarIcon
|
||||
icon={FORMAL_SIDEBAR_GROUP_ICONS.bom}
|
||||
className={FORMAL_SIDEBAR_SECTION_ICON_CLASS}
|
||||
/>
|
||||
}
|
||||
expanded={activeSection === "bom"}
|
||||
onToggle={() => onToggle("bom")}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
{BOM_LIBRARY_OPTIONS.map((option) => (
|
||||
<button
|
||||
key={option.key}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onBomLibraryChange(option.key);
|
||||
}}
|
||||
className={`flex w-full items-center rounded-apple-lg border px-3 py-2 text-left text-xs transition ${
|
||||
activeSection === "bom" && activeBomLibrary === option.key
|
||||
? "border-apple-blue bg-apple-blue/10 text-apple-blue"
|
||||
: "border-apple-separator bg-apple-bg-secondary text-apple-label-secondary"
|
||||
}`}
|
||||
>
|
||||
<span>{option.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</AccordionSection>
|
||||
|
||||
<AccordionSection
|
||||
title="原材料资料库"
|
||||
@ -9600,6 +9627,9 @@ export default function IntakeClient(props: IntakeClientProps) {
|
||||
const [nonIntegratedProducts, setNonIntegratedProducts] = useState<
|
||||
NonIntegratedProductRecord[]
|
||||
>(props.initialNonIntegratedProducts);
|
||||
const [materialLibraryFinishes, setMaterialLibraryFinishes] = useState<
|
||||
MaterialDTO[]
|
||||
>(() => props.materialLibraryFinishes);
|
||||
const selectedRawMaterialName = props.initialRawMaterialName?.trim() ?? "";
|
||||
const [workspaceTabs, setWorkspaceTabs] = useState<IntakeWorkspaceTab[]>(
|
||||
() => initialWorkspaceState.tabs,
|
||||
@ -10162,10 +10192,6 @@ export default function IntakeClient(props: IntakeClientProps) {
|
||||
}
|
||||
|
||||
function openBomLibraryWorkspace(library: BomLibraryKey) {
|
||||
if (!canUseDesignControlledProductData) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tabId = createWorkspaceTabId("bom-library", library);
|
||||
const nextTab: IntakeWorkspaceTab = {
|
||||
id: tabId,
|
||||
@ -10307,7 +10333,7 @@ export default function IntakeClient(props: IntakeClientProps) {
|
||||
seriesNameOptions={seriesNameOptions}
|
||||
existingSeriesNames={existingSeriesNames}
|
||||
seriesDraftSkuOptions={seriesDraftSkuOptions}
|
||||
materialLibraryFinishes={props.materialLibraryFinishes}
|
||||
materialLibraryFinishes={materialLibraryFinishes}
|
||||
initialDocumentWorkspaceView={activeWorkspaceTab.detailState?.view}
|
||||
initialDocumentWorkspaceCategory={activeWorkspaceTab.detailState?.category}
|
||||
currentUserRole={props.user.role}
|
||||
@ -10345,9 +10371,10 @@ export default function IntakeClient(props: IntakeClientProps) {
|
||||
user={props.user}
|
||||
activeBomLibrary={activeWorkspaceTab.bomLibrary}
|
||||
materialLibrarySubstrates={props.materialLibrarySubstrates}
|
||||
materialLibraryFinishes={props.materialLibraryFinishes}
|
||||
materialLibraryFinishes={materialLibraryFinishes}
|
||||
materialLibraryCategories={props.materialLibraryCategories}
|
||||
materialLibraryDictionaries={props.materialLibraryDictionaries}
|
||||
onMaterialLibraryFinishesChange={setMaterialLibraryFinishes}
|
||||
/>
|
||||
);
|
||||
} else if (activeWorkspaceTab?.kind === "rawMaterialLibrary") {
|
||||
@ -10396,7 +10423,7 @@ export default function IntakeClient(props: IntakeClientProps) {
|
||||
seriesNameOptions={seriesNameOptions}
|
||||
existingSeriesNames={existingSeriesNames}
|
||||
seriesDraftSkuOptions={seriesDraftSkuOptions}
|
||||
materialLibraryFinishes={props.materialLibraryFinishes}
|
||||
materialLibraryFinishes={materialLibraryFinishes}
|
||||
currentUserRole={props.user.role}
|
||||
currentUserId={props.user.id}
|
||||
currentUserName={props.user.name}
|
||||
@ -10555,15 +10582,16 @@ export default function IntakeClient(props: IntakeClientProps) {
|
||||
onOpenProductWorkspace={openProductWorkspace}
|
||||
/>
|
||||
);
|
||||
} else if (activeSection === "bom" && canUseDesignControlledProductData) {
|
||||
} else if (activeSection === "bom") {
|
||||
leftContent = (
|
||||
<BomDetail
|
||||
user={props.user}
|
||||
activeBomLibrary={activeBomLibrary}
|
||||
materialLibrarySubstrates={props.materialLibrarySubstrates}
|
||||
materialLibraryFinishes={props.materialLibraryFinishes}
|
||||
materialLibraryFinishes={materialLibraryFinishes}
|
||||
materialLibraryCategories={props.materialLibraryCategories}
|
||||
materialLibraryDictionaries={props.materialLibraryDictionaries}
|
||||
onMaterialLibraryFinishesChange={setMaterialLibraryFinishes}
|
||||
/>
|
||||
);
|
||||
} else if (activeSection === "rawMaterialLibrary") {
|
||||
@ -10730,15 +10758,16 @@ export default function IntakeClient(props: IntakeClientProps) {
|
||||
onOpenProductWorkspace={openProductWorkspace}
|
||||
/>
|
||||
);
|
||||
} else if (activeSection === "bom" && canUseDesignControlledProductData) {
|
||||
} else if (activeSection === "bom") {
|
||||
leftContent = (
|
||||
<BomDetail
|
||||
user={props.user}
|
||||
activeBomLibrary={activeBomLibrary}
|
||||
materialLibrarySubstrates={props.materialLibrarySubstrates}
|
||||
materialLibraryFinishes={props.materialLibraryFinishes}
|
||||
materialLibraryFinishes={materialLibraryFinishes}
|
||||
materialLibraryCategories={props.materialLibraryCategories}
|
||||
materialLibraryDictionaries={props.materialLibraryDictionaries}
|
||||
onMaterialLibraryFinishesChange={setMaterialLibraryFinishes}
|
||||
/>
|
||||
);
|
||||
} else if (activeSection === "rawMaterialLibrary") {
|
||||
|
||||
@ -6,12 +6,17 @@ import {
|
||||
PACKAGING_MATERIAL_LIBRARY_STORAGE_KEY,
|
||||
} from '@/lib/packaging-material-library'
|
||||
|
||||
export default function PackagingMaterialLibraryPanel() {
|
||||
export default function PackagingMaterialLibraryPanel({
|
||||
canEditItems = true,
|
||||
}: {
|
||||
canEditItems?: boolean
|
||||
}) {
|
||||
return (
|
||||
<AlphabetLibraryPanel
|
||||
storageKey={PACKAGING_MATERIAL_LIBRARY_STORAGE_KEY}
|
||||
changedEventName={PACKAGING_MATERIAL_LIBRARY_CHANGED_EVENT}
|
||||
entityName="包材"
|
||||
canEditItems={canEditItems}
|
||||
queryNamePlaceholder="例如:泡沫 / 纸箱 / 护角"
|
||||
/>
|
||||
)
|
||||
|
||||
@ -35,7 +35,11 @@ const STEEL_FRAME_CREATE_PRESETS = [
|
||||
const LIBRARY_PRIMARY_BUTTON_CLASS =
|
||||
'!border-zinc-950 !bg-zinc-950 !bg-none !text-white !shadow-none hover:!border-zinc-800 hover:!bg-zinc-800 focus:!border-zinc-600 disabled:!border-zinc-200 disabled:!bg-zinc-200 disabled:!text-zinc-400'
|
||||
|
||||
export default function SteelFrameLibraryPanel() {
|
||||
export default function SteelFrameLibraryPanel({
|
||||
canEditItems = true,
|
||||
}: {
|
||||
canEditItems?: boolean
|
||||
}) {
|
||||
const initialState = useMemo(() => readSteelFrameLibraryState(), [])
|
||||
const [referenceSteelPrice, setReferenceSteelPrice] = useState(initialState.referenceSteelPrice)
|
||||
const [savedItems, setSavedItems] = useState(initialState.items)
|
||||
@ -140,6 +144,8 @@ export default function SteelFrameLibraryPanel() {
|
||||
}
|
||||
|
||||
function handleStartCreate(presetName: string) {
|
||||
if (!canEditItems) return
|
||||
|
||||
setEditorMode('create')
|
||||
setEditingItemId(null)
|
||||
setSelectedItemId(null)
|
||||
@ -157,6 +163,14 @@ export default function SteelFrameLibraryPanel() {
|
||||
|
||||
function handleSelectItem(item: SteelFramePanelItem) {
|
||||
setSelectedItemId(item.id)
|
||||
if (!canEditItems) {
|
||||
setEditorMode('view')
|
||||
setEditingItemId(null)
|
||||
setEditorDraft(createEmptySteelFrameDraft())
|
||||
setPanelMessage('')
|
||||
return
|
||||
}
|
||||
|
||||
setEditorMode('edit')
|
||||
setEditingItemId(item.id)
|
||||
setEditorDraft(item)
|
||||
@ -183,6 +197,8 @@ export default function SteelFrameLibraryPanel() {
|
||||
}
|
||||
|
||||
function handleSaveDraft() {
|
||||
if (!canEditItems) return
|
||||
|
||||
if (!draftCanSave) {
|
||||
setPanelMessage(draftRequiresWidth ? '请先填完名称、长、宽、壁厚。' : '请先填完名称、长、壁厚。')
|
||||
return
|
||||
@ -212,6 +228,8 @@ export default function SteelFrameLibraryPanel() {
|
||||
}
|
||||
|
||||
function handleDeleteDraft() {
|
||||
if (!canEditItems) return
|
||||
|
||||
if (!editingItemId) return
|
||||
|
||||
const fallbackItem = items.find((item) => item.id !== editingItemId) ?? null
|
||||
@ -224,7 +242,9 @@ export default function SteelFrameLibraryPanel() {
|
||||
setPanelMessage('钢架规格已删除。')
|
||||
}
|
||||
|
||||
const shouldShowEditor = editorMode === 'create' || (editorMode === 'edit' && Boolean(editingItemId))
|
||||
const shouldShowEditor =
|
||||
canEditItems &&
|
||||
(editorMode === 'create' || (editorMode === 'edit' && Boolean(editingItemId)))
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@ -269,19 +289,21 @@ export default function SteelFrameLibraryPanel() {
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-end justify-end gap-2">
|
||||
{STEEL_FRAME_CREATE_PRESETS.map((preset) => (
|
||||
<Button
|
||||
key={preset.key}
|
||||
type="button"
|
||||
size="small"
|
||||
variant="gray"
|
||||
className={LIBRARY_PRIMARY_BUTTON_CLASS}
|
||||
onClick={() => handleStartCreate(preset.name)}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{preset.label}
|
||||
</Button>
|
||||
))}
|
||||
{canEditItems
|
||||
? STEEL_FRAME_CREATE_PRESETS.map((preset) => (
|
||||
<Button
|
||||
key={preset.key}
|
||||
type="button"
|
||||
size="small"
|
||||
variant="gray"
|
||||
className={LIBRARY_PRIMARY_BUTTON_CLASS}
|
||||
onClick={() => handleStartCreate(preset.name)}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{preset.label}
|
||||
</Button>
|
||||
))
|
||||
: null}
|
||||
<Button
|
||||
type="button"
|
||||
size="small"
|
||||
@ -309,8 +331,11 @@ export default function SteelFrameLibraryPanel() {
|
||||
<Input
|
||||
label="每吨钢管参考价"
|
||||
value={referenceSteelPrice}
|
||||
onChange={(event) => setReferenceSteelPrice(event.target.value)}
|
||||
onChange={(event) => {
|
||||
if (canEditItems) setReferenceSteelPrice(event.target.value)
|
||||
}}
|
||||
placeholder="例如:4200"
|
||||
readOnly={!canEditItems}
|
||||
helperText="每米单价 = 单支重量 × 每吨钢管参考价 ÷ 6000"
|
||||
/>
|
||||
</div>
|
||||
@ -347,7 +372,9 @@ export default function SteelFrameLibraryPanel() {
|
||||
<div className="mt-3 max-h-[620px] space-y-2 overflow-y-auto pr-1">
|
||||
{sortedFilteredItems.length === 0 ? (
|
||||
<div className="rounded-apple-lg border border-dashed border-apple-separator bg-apple-bg-secondary/50 px-4 py-8 text-center text-xs leading-6 text-apple-label-secondary">
|
||||
当前查询条件下还没有钢架规格,直接点上面的新增按钮即可开始录入。
|
||||
{canEditItems
|
||||
? '当前查询条件下还没有钢架规格,直接点上面的新增按钮即可开始录入。'
|
||||
: '当前查询条件下还没有钢架规格。'}
|
||||
</div>
|
||||
) : (
|
||||
sortedFilteredItems.map((item) => {
|
||||
@ -500,7 +527,9 @@ export default function SteelFrameLibraryPanel() {
|
||||
{buildSteelFrameSpecTitle(selectedItem)}
|
||||
</h4>
|
||||
<p className="mt-1 text-sm text-apple-label-secondary">
|
||||
这是当前已保存的钢架规格。需要新尺寸时,直接点上方新增按钮即可。
|
||||
{canEditItems
|
||||
? '这是当前已保存的钢架规格。需要新尺寸时,直接点上方新增按钮即可。'
|
||||
: '这是当前已保存的钢架规格。当前账号只能查看。'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@ -536,7 +565,9 @@ export default function SteelFrameLibraryPanel() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full min-h-[520px] items-center justify-center rounded-apple-lg border border-dashed border-apple-separator bg-apple-bg-secondary/40 p-6 text-center text-sm leading-7 text-apple-label-secondary">
|
||||
这里已经没有系统预置规格了。请直接点上方“新增方管 / 新增扁管 / 新增圆管”开始录入。
|
||||
{canEditItems
|
||||
? '这里已经没有系统预置规格了。请直接点上方“新增方管 / 新增扁管 / 新增圆管”开始录入。'
|
||||
: '这里还没有钢架规格。当前账号只能查看。'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user