From e07c12a34fa988021406f8dfbdc7abf30c3d22fe Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Fri, 29 May 2026 13:50:58 +0800 Subject: [PATCH] Update accessory library and intake permissions --- .../src/app/intake/AccessoryLibraryPanel.tsx | 237 ++++++++++++++++-- .../app/intake/AlphabetLibraryPanel.test.tsx | 99 +++++++- .../intake-node-permission-settings.test.ts | 22 +- .../lib/intake-node-permission-settings.ts | 14 +- 4 files changed, 338 insertions(+), 34 deletions(-) diff --git a/furniture-3d-system/src/app/intake/AccessoryLibraryPanel.tsx b/furniture-3d-system/src/app/intake/AccessoryLibraryPanel.tsx index 1b2d372b..893fcd31 100644 --- a/furniture-3d-system/src/app/intake/AccessoryLibraryPanel.tsx +++ b/furniture-3d-system/src/app/intake/AccessoryLibraryPanel.tsx @@ -77,6 +77,7 @@ const LETTER_CATEGORY_LABELS: Partial> = B: '平头螺丝', C: '连接螺杆', D: '偏心连接件', + E: '预埋螺母', } const A_HEAD_TYPES: HeadType[] = [ @@ -123,6 +124,11 @@ const B_HEAD_TYPES: HeadType[] = [ aliases: '沉头、平杯', scenario: '表面齐平、拉手安装、预埋件连接', }, + { + name: '子母', + aliases: '对锁螺丝、公母螺丝、扣管螺丝', + scenario: '板件对锁连接、成套公母连接件', + }, { name: '杯头', aliases: '圆柱头、内六角杯头', @@ -136,7 +142,13 @@ const HEAD_TYPES_BY_LETTER: Partial> } const A_SCREW_CATEGORIES = ['自攻螺丝', '过山丝'] -const B_SCREW_CATEGORIES = ['平头螺丝', '内六角螺丝', '拉手螺丝', '杯头螺丝'] +const B_SCREW_CATEGORIES = [ + '平头螺丝', + '子母螺丝', + '内六角螺丝', + '拉手螺丝', + '杯头螺丝', +] const C_SCREW_CATEGORIES = ['全牙螺杆', '双头螺杆', '双头牙'] const D_SCREW_CATEGORIES = [ '三合一连接件', @@ -145,12 +157,19 @@ const D_SCREW_CATEGORIES = [ '隐形连接件', '双头偏心连接杆', ] +const E_SCREW_CATEGORIES = [ + '内外牙螺母', + '胶粒螺母', + '横孔螺母', + '圆柱螺母', +] const CATEGORY_SHORTCUTS_BY_LETTER: Partial< Record > = { C: C_SCREW_CATEGORIES, D: D_SCREW_CATEGORIES, + E: E_SCREW_CATEGORIES, } const SELECT_FIELDS = [ @@ -455,11 +474,69 @@ const D_SELECT_FIELD_OVERRIDES: Partial< }, } +const E_SELECT_FIELD_OVERRIDES: Partial< + Record>> +> = { + screwCategory: { + options: E_SCREW_CATEGORIES, + }, + drive: { + options: ['内六角', '一字', '无槽'], + }, + tail: { + label: '介型', + options: ['带介', '无介', '无'], + }, + material: { + label: '材质/表面处理/颜色', + options: [ + '锌合金', + '塑料', + '白色 塑料', + '尼龙', + '白色 尼龙', + '碳钢', + '镀锌', + '镀镍', + '铜', + '不锈钢', + '黑色', + '本色', + ], + }, + tooth: { + options: ['木牙', '机牙', '粗牙', '细牙'], + }, + diameter: { + label: '规格', + options: ['M4', 'M5', 'M6', 'M8', 'M10', 'M12'], + }, + length: { + label: '尺寸', + options: [ + '8mm', + '10mm', + '12mm', + '13mm', + '15mm', + '16mm', + '18mm', + '20mm', + '25mm', + '30mm', + ], + }, + preDrill: { + options: ['需预开孔', '按配套孔位安装'], + }, +} + const HIDDEN_SELECT_FIELD_KEYS_BY_LETTER: Partial< Record > = { C: ['drive'], D: ['drive', 'tail', 'finish', 'tooth', 'color', 'preDrill'], + E: ['finish', 'color'], } const EMPTY_FILTERS: ScrewFilterState = { @@ -567,6 +644,9 @@ function getTableColumns(letter: AccessoryLibraryLetter, shouldShowHeadType: boo }).map((column) => { if (letter === 'C' && column === '长度') return '尺寸' if (letter === 'D' && column === '材质') return '材质/表面处理' + if (letter === 'E' && column === '材质') return '材质/表面处理/颜色' + if (letter === 'E' && column === '尾型') return '介型' + if (letter === 'E' && column === '长度') return '尺寸' return column }) } @@ -664,6 +744,21 @@ const DEFAULT_SCREW_FIELDS_BY_LETTER: Partial< preDrill: '按配套孔位安装', environments: ['室内'], }, + E: { + screwCategory: '内外牙螺母', + headType: '', + drive: '内六角', + tail: '无介', + material: '锌合金', + finish: '', + tooth: '木牙', + diameter: 'M6', + length: '13mm', + usages: ['板式家具', '柜体', '床架'], + color: '', + preDrill: '需预开孔', + environments: ['室内'], + }, } const PRIMARY_BUTTON_CLASS = @@ -719,7 +814,9 @@ function getSelectFields(letter: AccessoryLibraryLetter) { ? C_SELECT_FIELD_OVERRIDES : letter === 'D' ? D_SELECT_FIELD_OVERRIDES - : {} + : letter === 'E' + ? E_SELECT_FIELD_OVERRIDES + : {} const hiddenKeys = HIDDEN_SELECT_FIELD_KEYS_BY_LETTER[letter] ?? [] return SELECT_FIELDS.filter((field) => !hiddenKeys.includes(field.key)).map( @@ -730,6 +827,27 @@ function getSelectFields(letter: AccessoryLibraryLetter) { ) } +function shouldShowSelectFieldForItem( + item: Pick, + fieldKey: ScrewFilterKey, +) { + if ( + item.letter === 'E' && + item.screwCategory !== '内外牙螺母' && + (fieldKey === 'tail' || fieldKey === 'tooth') + ) { + return false + } + + return true +} + +function getSelectFieldsForItem(item: Pick) { + return getSelectFields(item.letter).filter((field) => + shouldShowSelectFieldForItem(item, field.key), + ) +} + function normalizeScrewItem(item: AccessoryLibraryItem): ScrewItem { const letter = ACCESSORY_LIBRARY_LETTERS.includes(item.letter as AccessoryLibraryLetter) ? (item.letter as AccessoryLibraryLetter) @@ -867,9 +985,32 @@ function renderEccentricConnectorArtwork(accent: string, category: string) { ` } +function renderEmbeddedNutArtwork(accent: string, category: string) { + const shell = + category.includes('胶粒') + ? ` + + ` + : ` + + ` + const sideHole = + category.includes('横孔') || category.includes('圆柱') + ? ` + + ` + : ` + ` + + return ` + ${shell} + ${sideHole} + ` +} + function getTemporaryReferenceImage(item: ScrewItem) { if (item.imageUrl.trim()) return '' - if (item.letter !== 'C' && item.letter !== 'D') return '' + if (item.letter !== 'C' && item.letter !== 'D' && item.letter !== 'E') return '' const category = item.screwCategory.trim() || '配件' const palette = getTemporaryReferencePalette(category) @@ -881,7 +1022,9 @@ function getTemporaryReferenceImage(item: ScrewItem) { const artwork = item.letter === 'C' ? renderThreadedRodArtwork(palette.accent, category) - : renderEccentricConnectorArtwork(palette.accent, category) + : item.letter === 'D' + ? renderEccentricConnectorArtwork(palette.accent, category) + : renderEmbeddedNutArtwork(palette.accent, category) const title = escapeSvgText(category) const subtitle = escapeSvgText(spec || item.name || '临时参考图') const svg = ` @@ -923,6 +1066,7 @@ function buildScrewName( | 'screwCategory' | 'material' | 'finish' + | 'tooth' >, ) { const diameter = item.diameter.trim() @@ -947,6 +1091,22 @@ function buildScrewName( .join(' ') } + if (item.letter === 'E') { + const materialOrFinish = item.material.trim() || item.finish.trim() + const isThreadedInsertNut = item.screwCategory === '内外牙螺母' + return [ + specSize, + isThreadedInsertNut ? item.tail : '', + item.drive, + isThreadedInsertNut ? item.tooth : '', + materialOrFinish, + 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) @@ -991,6 +1151,18 @@ function createScrewDraft( } } +function normalizeScrewDraftForCategory(draft: ScrewItem): ScrewItem { + if (draft.letter !== 'E' || draft.screwCategory === '内外牙螺母') { + return draft + } + + return { + ...draft, + tail: '', + tooth: '', + } +} + function normalizeDuplicateValue(value: string | string[] | undefined) { return Array.isArray(value) ? value.join(',').trim() : (value ?? '').trim() } @@ -1164,6 +1336,22 @@ export default function AccessoryLibraryPanel({ () => getSelectFields(selectedLetter), [selectedLetter], ) + const visibleSelectedSelectFields = useMemo( + () => + selectedSelectFields.filter((field) => { + if ( + selectedLetter === 'E' && + appliedFilters.screwCategory && + appliedFilters.screwCategory !== '内外牙螺母' && + (field.key === 'tail' || field.key === 'tooth') + ) { + return false + } + + return true + }), + [appliedFilters.screwCategory, selectedLetter, selectedSelectFields], + ) const selectedHiddenFieldKeys = useMemo( () => getHiddenFieldKeys(selectedLetter, shouldShowHeadType), [selectedLetter, shouldShowHeadType], @@ -1220,7 +1408,7 @@ export default function AccessoryLibraryPanel({ return false } - return selectedSelectFields.every((field) => { + return visibleSelectedSelectFields.every((field) => { const filterValue = appliedFilters[field.key] if (field.key === 'screwCategory') return true return !filterValue || item[field.key] === filterValue @@ -1232,9 +1420,9 @@ export default function AccessoryLibraryPanel({ appliedFilters, isSelectedLetterConfigured, selectedLetterItems, - selectedSelectFields, shouldShowCategoryShortcuts, shouldShowHeadType, + visibleSelectedSelectFields, ]) function updateFilterDraft(key: keyof ScrewFilterState, value: string) { @@ -1328,7 +1516,7 @@ export default function AccessoryLibraryPanel({ setDraft((current) => { if (!current) return current - const nextDraft = { ...current, [key]: value } + const nextDraft = normalizeScrewDraftForCategory({ ...current, [key]: value }) if ( [ 'diameter', @@ -1339,6 +1527,7 @@ export default function AccessoryLibraryPanel({ 'screwCategory', 'material', 'finish', + 'tooth', ].includes(key) ) { return { @@ -1425,7 +1614,8 @@ export default function AccessoryLibraryPanel({ if (!draft) return - const duplicateItem = findDuplicateScrewItem(items, draft, editingId) + const normalizedDraft = normalizeScrewDraftForCategory(draft) + const duplicateItem = findDuplicateScrewItem(items, normalizedDraft, editingId) if (duplicateItem) { setEditingError( `已存在相同配置:${duplicateItem.code} ${duplicateItem.name}。请至少修改分类、头型、槽型、尾型、材质、牙型、规格、长度、颜色中的任意一项。`, @@ -1434,15 +1624,15 @@ export default function AccessoryLibraryPanel({ } const nextItem: ScrewItem = { - ...draft, - letter: draft.letter, + ...normalizedDraft, + letter: normalizedDraft.letter, code: editingId - ? draft.code.trim().toUpperCase() - : buildNextScrewCode(items, draft.letter), - name: buildScrewName(draft), - price: draft.price.trim(), - imageUrl: draft.imageUrl.trim(), - imageName: draft.imageName.trim(), + ? normalizedDraft.code.trim().toUpperCase() + : buildNextScrewCode(items, normalizedDraft.letter), + name: buildScrewName(normalizedDraft), + price: normalizedDraft.price.trim(), + imageUrl: normalizedDraft.imageUrl.trim(), + imageName: normalizedDraft.imageName.trim(), updatedAt: new Date().toISOString(), } @@ -1514,7 +1704,7 @@ export default function AccessoryLibraryPanel({ /> ) : null} - {selectedSelectFields.map((field) => ( + {visibleSelectedSelectFields.map((field) => (
{draft ? (
+ {(() => { + const draftSelectFields = getSelectFieldsForItem(draft) + return ( + <> {editingError ? (
{editingError} @@ -1935,7 +2129,7 @@ export default function AccessoryLibraryPanel({
- {getSelectFields(draft.letter).slice(0, 1).map((field) => ( + {draftSelectFields.slice(0, 1).map((field) => ( updateDraftField('headType', value)} /> ) : null} - {getSelectFields(draft.letter).slice(1, 7).map((field) => ( + {draftSelectFields.slice(1, 7).map((field) => (
- {getSelectFields(draft.letter).slice(7).map((field) => ( + {draftSelectFields.slice(7).map((field) => (
+ + ) + })()}
{ }) }) - it('lets every accessory letter group be selected even before it is configured', async () => { + it('lets unconfigured accessory letter groups be selected', async () => { const actor = userEvent.setup() render() - await actor.click(screen.getByRole('button', { name: 'E 0 条' })) + await actor.click(screen.getByRole('button', { name: 'F 0 条' })) - expect(screen.getByText('E 组配件')).toBeInTheDocument() - expect(screen.getByText('E 组暂未配置配件分类')).toBeInTheDocument() + expect(screen.getByText('F 组配件')).toBeInTheDocument() + expect(screen.getByText('F 组暂未配置配件分类')).toBeInTheDocument() expect(screen.queryByRole('button', { name: '新增 A 组' })).not.toBeInTheDocument() await actor.click(screen.getByRole('button', { name: 'A 自攻螺丝 0 条' })) @@ -139,6 +139,97 @@ describe('alphabet library panels', () => { }) }) + it('includes child-mother screws in the B accessory group', async () => { + const actor = userEvent.setup() + + render() + + await actor.click(screen.getByRole('button', { name: 'B 平头螺丝 0 条' })) + + expect(screen.getByRole('button', { name: '子母螺丝' })).toBeInTheDocument() + + await actor.click(screen.getByRole('button', { name: '新增 B 组' })) + + const dialog = await screen.findByRole('dialog', { name: '新增B组配件' }) + fireEvent.change(within(dialog).getByLabelText('分类'), { + target: { value: '子母螺丝' }, + }) + + expect(within(dialog).getByLabelText('配件名称')).toHaveValue( + 'M4*20 内六角 平头 平尾 子母螺丝', + ) + }) + + it('uses embedded nut rules for E accessories', async () => { + const actor = userEvent.setup() + + render() + + await actor.click(screen.getByRole('button', { name: 'E 预埋螺母 0 条' })) + + expect(screen.getByText('E 组配件 · 预埋螺母')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: '全部头型' })).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: '全部分类' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: '内外牙螺母' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: '胶粒螺母' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: '横孔螺母' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: '圆柱螺母' })).toBeInTheDocument() + + await actor.click(screen.getByRole('button', { name: '新增 E 组' })) + + const dialog = await screen.findByRole('dialog', { name: '新增E组配件' }) + expect(within(dialog).getByLabelText('配件名称')).toHaveValue( + 'M6*13 无介 内六角 木牙 锌合金 内外牙螺母', + ) + expect(within(dialog).queryByLabelText('头型')).not.toBeInTheDocument() + expect(within(dialog).getByLabelText('槽型')).toBeInTheDocument() + expect(within(dialog).getByLabelText('介型')).toBeInTheDocument() + expect(within(dialog).getByLabelText('牙型')).toBeInTheDocument() + expect(within(dialog).getByLabelText('规格')).toBeInTheDocument() + expect(within(dialog).getByLabelText('尺寸')).toBeInTheDocument() + expect(within(dialog).getByLabelText('材质/表面处理/颜色')).toBeInTheDocument() + + fireEvent.change(within(dialog).getByLabelText('分类'), { + target: { value: '胶粒螺母' }, + }) + expect(within(dialog).queryByLabelText('介型')).not.toBeInTheDocument() + expect(within(dialog).queryByLabelText('牙型')).not.toBeInTheDocument() + fireEvent.change(within(dialog).getByLabelText('槽型'), { + target: { value: '无槽' }, + }) + fireEvent.change(within(dialog).getByLabelText('材质/表面处理/颜色'), { + target: { value: '白色 塑料' }, + }) + + expect(within(dialog).getByLabelText('配件名称')).toHaveValue( + 'M6*13 无槽 白色 塑料 胶粒螺母', + ) + + await actor.click(within(dialog).getByRole('button', { name: '保存' })) + + await waitFor(() => { + expect(screen.getByText('M6*13 无槽 白色 塑料 胶粒螺母')).toBeInTheDocument() + }) + + const savedItems = JSON.parse( + window.localStorage.getItem(ACCESSORY_LIBRARY_STORAGE_KEY) ?? '[]', + ) + expect(savedItems[0]).toMatchObject({ + letter: 'E', + code: 'E0', + name: 'M6*13 无槽 白色 塑料 胶粒螺母', + screwCategory: '胶粒螺母', + headType: '', + drive: '无槽', + tail: '', + material: '白色 塑料', + finish: '', + tooth: '', + diameter: 'M6', + length: '13mm', + }) + }) + it('filters C and D accessory groups by category shortcuts', async () => { const actor = userEvent.setup() diff --git a/furniture-3d-system/src/lib/intake-node-permission-settings.test.ts b/furniture-3d-system/src/lib/intake-node-permission-settings.test.ts index 4d3c7a04..e749017b 100644 --- a/furniture-3d-system/src/lib/intake-node-permission-settings.test.ts +++ b/furniture-3d-system/src/lib/intake-node-permission-settings.test.ts @@ -15,7 +15,7 @@ describe('intake node permission settings', () => { '江苏简尚品牌管理有限公司 > 品牌中心 > 空间视觉部', ], }), - ).toEqual(['brandCenter', 'space']) + ).toEqual(['space']) expect( resolveIntakeNodePermissionSubjectKeys({ @@ -29,6 +29,16 @@ describe('intake node permission settings', () => { }), ).toEqual(['space']) + expect( + resolveIntakeNodePermissionSubjectKeys({ + role: 'USER', + businessRoles: [], + departmentPaths: [ + '江苏简尚品牌管理有限公司 > 品牌中心 > 商品企划部 > 桌类', + ], + }), + ).toEqual(['planning']) + expect( resolveIntakeNodePermissionSubjectKeys({ role: 'USER', @@ -41,7 +51,7 @@ describe('intake node permission settings', () => { }) it('uses the same matrix for default view, download, and admin-opened overrides', () => { - const spaceKeys = ['brandCenter', 'space'] as const + const spaceKeys = ['space'] as const expect( resolveIntakeNodePermissionAccess({}, 'planningLayout', spaceKeys), ).toMatchObject({ @@ -98,12 +108,12 @@ describe('intake node permission settings', () => { const overrides = { [buildIntakeNodePermissionOverrideKey( 'productData', - 'space', + 'brandCenter', 'view', )]: true, [buildIntakeNodePermissionOverrideKey( 'productData', - 'space', + 'brandCenter', 'download', )]: true, } @@ -111,8 +121,8 @@ describe('intake node permission settings', () => { resolveIntakeNodePermissionAccess(overrides, 'productData', spaceKeys), ).toMatchObject({ defaultState: 'hidden', - view: true, - download: true, + view: false, + download: false, edit: false, }) }) diff --git a/furniture-3d-system/src/lib/intake-node-permission-settings.ts b/furniture-3d-system/src/lib/intake-node-permission-settings.ts index f908a733..98a6abe9 100644 --- a/furniture-3d-system/src/lib/intake-node-permission-settings.ts +++ b/furniture-3d-system/src/lib/intake-node-permission-settings.ts @@ -248,7 +248,7 @@ export const CORE_NODE_PERMISSION_SUBJECTS: IntakeNodePermissionSubject[] = [ { key: "brandCenter", name: "品牌中心", - department: "品牌中心-事业部 / 策划 / 企划", + department: "品牌中心-事业部 / 策划(不含商品企划、空间视觉)", }, { key: "planning", @@ -804,20 +804,26 @@ export function resolveIntakeNodePermissionSubjectKeys( user: IntakeNonIntegratedPermissionUser, ) { const keys: IntakeNodePermissionSubjectKey[] = []; + const isBrandPlanningUser = hasDepartmentPath(user, isBrandPlanningDepartmentPath); + const isSpaceVisualUser = hasDepartmentPath(user, isSpaceVisualDepartmentPath); if (hasDepartmentPath(user, isProductResearchDepartmentPath)) { pushUniqueSubject(keys, "design"); } - if (hasDepartmentPath(user, isBrandCenterDepartmentPath)) { + if ( + hasDepartmentPath(user, isBrandCenterDepartmentPath) && + !isBrandPlanningUser && + !isSpaceVisualUser + ) { pushUniqueSubject(keys, "brandCenter"); } - if (hasDepartmentPath(user, isBrandPlanningDepartmentPath)) { + if (isBrandPlanningUser) { pushUniqueSubject(keys, "planning"); } - if (hasDepartmentPath(user, isSpaceVisualDepartmentPath)) { + if (isSpaceVisualUser) { pushUniqueSubject(keys, "space"); }