feat(intake): refine bom libraries and accessory rods

This commit is contained in:
WuYongJie 2026-05-26 16:30:37 +08:00
parent 8ae5540eef
commit 9700d84c66
10 changed files with 833 additions and 320 deletions

View File

@ -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(() => {

View File

@ -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>
) : (

View File

@ -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}

View File

@ -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()
})
})

View File

@ -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>
)}

View File

@ -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>

View File

@ -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)

View File

@ -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") {

View File

@ -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="例如:泡沫 / 纸箱 / 护角"
/>
)

View File

@ -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>