Update accessory library and intake permissions

This commit is contained in:
WuYongJie 2026-05-29 13:50:58 +08:00
parent 9217b8db16
commit e07c12a34f
4 changed files with 338 additions and 34 deletions

View File

@ -77,6 +77,7 @@ const LETTER_CATEGORY_LABELS: Partial<Record<AccessoryLibraryLetter, string>> =
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<Record<AccessoryLibraryLetter, HeadType[]>>
}
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<AccessoryLibraryLetter, string[]>
> = {
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<ScrewFilterKey, Partial<Pick<SelectFieldDefinition, 'label' | 'options'>>>
> = {
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<AccessoryLibraryLetter, ScrewFilterKey[]>
> = {
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<ScrewItem, 'letter' | 'screwCategory'>,
fieldKey: ScrewFilterKey,
) {
if (
item.letter === 'E' &&
item.screwCategory !== '内外牙螺母' &&
(fieldKey === 'tail' || fieldKey === 'tooth')
) {
return false
}
return true
}
function getSelectFieldsForItem(item: Pick<ScrewItem, 'letter' | 'screwCategory'>) {
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('胶粒')
? `<path d="M92 82 L196 82 L214 104 L214 150 L196 172 L92 172 L74 150 L74 104 Z" fill="#ffffff" stroke="${accent}" stroke-width="5" />
<circle cx="144" cy="127" r="33" fill="${accent}" opacity="0.12" />
<circle cx="144" cy="127" r="18" fill="#ffffff" stroke="${accent}" stroke-width="4" />`
: `<rect x="72" y="76" width="144" height="104" rx="18" fill="#ffffff" stroke="${accent}" stroke-width="5" />
<circle cx="144" cy="128" r="34" fill="${accent}" opacity="0.12" />
<circle cx="144" cy="128" r="18" fill="#ffffff" stroke="${accent}" stroke-width="4" />`
const sideHole =
category.includes('横孔') || category.includes('圆柱')
? `<rect x="74" y="116" width="140" height="24" rx="12" fill="${accent}" opacity="0.16" />
<circle cx="92" cy="128" r="9" fill="#ffffff" stroke="${accent}" stroke-width="3" />
<circle cx="196" cy="128" r="9" fill="#ffffff" stroke="${accent}" stroke-width="3" />`
: `<line x1="94" y1="92" x2="194" y2="164" stroke="${accent}" stroke-width="3" opacity="0.45" />
<line x1="194" y1="92" x2="94" y2="164" stroke="${accent}" stroke-width="3" opacity="0.28" />`
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({
/>
</div>
) : null}
{selectedSelectFields.map((field) => (
{visibleSelectedSelectFields.map((field) => (
<div key={field.label} className="w-[112px]">
<SelectField
compact
@ -1905,6 +2095,10 @@ export default function AccessoryLibraryPanel({
>
{draft ? (
<div className="space-y-4">
{(() => {
const draftSelectFields = getSelectFieldsForItem(draft)
return (
<>
{editingError ? (
<div className="rounded-apple-lg border border-rose-200 bg-rose-50 px-4 py-3 text-xs leading-5 text-rose-700">
{editingError}
@ -1935,7 +2129,7 @@ export default function AccessoryLibraryPanel({
</div>
<div className="grid gap-4 md:grid-cols-4">
{getSelectFields(draft.letter).slice(0, 1).map((field) => (
{draftSelectFields.slice(0, 1).map((field) => (
<SelectField
key={field.label}
label={field.label}
@ -1952,7 +2146,7 @@ export default function AccessoryLibraryPanel({
onChange={(value) => updateDraftField('headType', value)}
/>
) : null}
{getSelectFields(draft.letter).slice(1, 7).map((field) => (
{draftSelectFields.slice(1, 7).map((field) => (
<SelectField
key={field.label}
label={field.label}
@ -1964,7 +2158,7 @@ export default function AccessoryLibraryPanel({
</div>
<div className="grid gap-4 md:grid-cols-4">
{getSelectFields(draft.letter).slice(7).map((field) => (
{draftSelectFields.slice(7).map((field) => (
<SelectField
key={field.label}
label={field.label}
@ -1983,6 +2177,9 @@ export default function AccessoryLibraryPanel({
/>
</label>
</div>
</>
)
})()}
<div className="grid gap-4 xl:grid-cols-2">
<MultiTagField

View File

@ -61,15 +61,15 @@ describe('alphabet library panels', () => {
})
})
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(<AccessoryLibraryPanel />)
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(<AccessoryLibraryPanel />)
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(<AccessoryLibraryPanel />)
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()

View File

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

View File

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