diff --git a/furniture-3d-system/prisma/migrations/20260523103000_add_intake_node_permission_overrides/migration.sql b/furniture-3d-system/prisma/migrations/20260523103000_add_intake_node_permission_overrides/migration.sql new file mode 100644 index 00000000..5483aada --- /dev/null +++ b/furniture-3d-system/prisma/migrations/20260523103000_add_intake_node_permission_overrides/migration.sql @@ -0,0 +1,22 @@ +CREATE TABLE IF NOT EXISTS "IntakeNodePermissionOverride" ( + "id" TEXT NOT NULL, + "targetKey" TEXT NOT NULL, + "subjectKey" TEXT NOT NULL, + "action" TEXT NOT NULL, + "enabled" BOOLEAN NOT NULL DEFAULT true, + "createdBy" TEXT, + "updatedBy" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "IntakeNodePermissionOverride_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX IF NOT EXISTS "IntakeNodePermissionOverride_targetKey_subjectKey_action_key" +ON "IntakeNodePermissionOverride"("targetKey", "subjectKey", "action"); + +CREATE INDEX IF NOT EXISTS "IntakeNodePermissionOverride_targetKey_idx" +ON "IntakeNodePermissionOverride"("targetKey"); + +CREATE INDEX IF NOT EXISTS "IntakeNodePermissionOverride_subjectKey_idx" +ON "IntakeNodePermissionOverride"("subjectKey"); diff --git a/furniture-3d-system/prisma/schema.prisma b/furniture-3d-system/prisma/schema.prisma index d98922bb..ca6a9ee0 100644 --- a/furniture-3d-system/prisma/schema.prisma +++ b/furniture-3d-system/prisma/schema.prisma @@ -1563,6 +1563,24 @@ model ThemeConfig { @@index([startDate, endDate]) } +model IntakeNodePermissionOverride { + id String @id @default(cuid()) + + targetKey String + subjectKey String + action String + enabled Boolean @default(true) + + createdBy String? + updatedBy String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([targetKey, subjectKey, action]) + @@index([targetKey]) + @@index([subjectKey]) +} + // ============================================ // 五金件库(标准五金件复用系统) // ============================================ diff --git a/furniture-3d-system/src/app/api/intake/node-permission-settings/route.ts b/furniture-3d-system/src/app/api/intake/node-permission-settings/route.ts new file mode 100644 index 00000000..f817649b --- /dev/null +++ b/furniture-3d-system/src/app/api/intake/node-permission-settings/route.ts @@ -0,0 +1,46 @@ +import { NextRequest } from "next/server"; +import { AppError } from "@/server/core/errors"; +import { handleRequest, jsonError, jsonSuccess } from "@/server/core/response"; +import { intakeNodePermissionSettingsService } from "@/server/services/intake-node-permission-settings.service"; + +export async function GET(request: NextRequest) { + return handleRequest( + request, + async (ctx) => { + const settings = await intakeNodePermissionSettingsService.getSettings(); + return jsonSuccess(settings, 200, ctx.requestId); + }, + { requireAuth: true }, + ); +} + +export async function PUT(request: NextRequest) { + return handleRequest( + request, + async (ctx) => { + const rawBody = await request.json().catch(() => null); + + try { + const settings = + await intakeNodePermissionSettingsService.updateSettings( + rawBody, + ctx.user, + ); + return jsonSuccess(settings, 200, ctx.requestId); + } catch (error) { + if (error instanceof AppError) { + return jsonError( + error.code, + error.message, + error.statusCode, + ctx.requestId, + ); + } + + throw error; + } + }, + { requireAuth: true }, + ); +} + diff --git a/furniture-3d-system/src/app/intake/AccessoryLibraryPanel.tsx b/furniture-3d-system/src/app/intake/AccessoryLibraryPanel.tsx index 2b6bdd55..4ce035da 100644 --- a/furniture-3d-system/src/app/intake/AccessoryLibraryPanel.tsx +++ b/furniture-3d-system/src/app/intake/AccessoryLibraryPanel.tsx @@ -1,30 +1,1414 @@ 'use client' +/* eslint-disable @next/next/no-img-element */ -import AlphabetLibraryPanel from '@/app/intake/AlphabetLibraryPanel' +import { useEffect, useMemo, useState, type ChangeEvent } from 'react' +import { + Copy, + Eye, + ImageIcon, + Pencil, + Plus, + Search, + Trash2, + Upload, +} from 'lucide-react' +import Modal from '@/components/ui/Modal' import { ACCESSORY_LIBRARY_CHANGED_EVENT, ACCESSORY_LIBRARY_STORAGE_KEY, + readAccessoryLibraryItems, + type AccessoryLibraryItem, } from '@/lib/accessory-library' -export default function AccessoryLibraryPanel() { +const ACCESSORY_LIBRARY_IMAGE_MAX_SIZE_BYTES = 1.5 * 1024 * 1024 +const ACCESSORY_LIBRARY_LETTERS = Array.from({ length: 26 }, (_, index) => + String.fromCharCode('A'.charCodeAt(0) + index), +) + +type AccessoryLibraryLetter = (typeof ACCESSORY_LIBRARY_LETTERS)[number] + +type HeadType = { + name: string + aliases: string + scenario: string +} + +type ScrewFilterKey = + | 'drive' + | 'tail' + | 'material' + | 'finish' + | 'tooth' + | 'diameter' + | 'length' + | 'color' + | 'preDrill' + +type ScrewFilterState = Record & { + code: string + name: string +} + +type ScrewItem = AccessoryLibraryItem & { + letter: AccessoryLibraryLetter + headType: string + drive: string + tail: string + material: string + finish: string + tooth: string + diameter: string + length: string + usages: string[] + color: string + preDrill: string + environments: string[] +} + +type AccessoryLibraryPanelProps = { + canDeleteItems?: boolean +} + +const LETTER_CATEGORY_LABELS: Partial> = { + A: '自攻螺丝', +} + +const HEAD_TYPES: HeadType[] = [ + { + name: '沉头', + aliases: '平头、埋头', + scenario: '木板连接、表面齐平、隐藏安装', + }, + { + name: '盘头', + aliases: '圆盘头', + scenario: '五金件、角码、滑轨、塑料件固定', + }, + { + name: '大扁头', + aliases: '伞头、蘑菇头、大圆头', + scenario: '合页、连接片、薄铁片、需要大压紧面积', + }, + { + name: '圆头', + aliases: '半圆头、圆帽头', + scenario: '外露位、小五金、装饰固定', + }, + { + name: '华司头', + aliases: '带垫头、法兰头', + scenario: '防压穿、增加接触面、薄件固定', + }, + { + name: '六角头', + aliases: '外六角头', + scenario: '重型家具、户外木作、结构件固定', + }, +] + +const SELECT_FIELDS = [ + { + key: 'drive', + label: '槽型/驱动', + options: [ + '十字', + '一字', + '内六角', + '外六角', + '梅花', + '米字', + '十字一字组合', + '方槽', + ], + }, + { + key: 'tail', + label: '尾型', + options: ['尖尾', '平尾', '割尾', '尖尾割尾', '平尾割尾', '钻尾'], + }, + { + key: 'material', + label: '材质', + options: [ + '碳钢', + '低碳钢', + '中碳钢', + '1022A碳钢', + '304不锈钢', + '316不锈钢', + '410不锈钢', + '201不锈钢', + '铜', + '黄铜', + '尼龙/塑料', + ], + }, + { + key: 'finish', + label: '表面处理', + options: [ + '镀白锌', + '镀蓝白锌', + '彩锌', + '黄锌', + '发黑', + '黑磷化', + '灰磷化', + '镀镍', + '镀铬', + '达克罗', + '热镀锌', + '镀铜', + '仿古铜', + '青古铜', + '红古铜', + '本色', + '喷黑', + '喷白', + '烤漆', + ], + }, + { + key: 'tooth', + label: '牙型', + options: [ + '粗牙', + '细牙', + '高低牙', + '三角牙', + '双线牙', + '木牙', + '机牙自攻', + '纤维板牙', + ], + }, + { + key: 'diameter', + label: '规格/直径', + options: [ + 'M2', + 'M2.3', + 'M2.5', + 'M2.6', + 'M3', + 'M3.5', + 'M4', + 'M4.2', + 'M4.5', + 'M4.8', + 'M5', + 'M5.5', + 'M6', + 'M6.3', + 'M8', + ], + }, + { + key: 'length', + label: '长度', + options: [ + '4mm', + '5mm', + '6mm', + '8mm', + '10mm', + '12mm', + '14mm', + '16mm', + '18mm', + '20mm', + '22mm', + '25mm', + '28mm', + '30mm', + '32mm', + '35mm', + '38mm', + '40mm', + '45mm', + '50mm', + '55mm', + '60mm', + '65mm', + '70mm', + '75mm', + '80mm', + '90mm', + '100mm', + ], + }, + { + key: 'color', + label: '颜色', + options: ['银色', '黑色', '金色', '彩锌色', '铜色', '古铜色', '本色', '白色'], + }, + { + key: 'preDrill', + label: '是否预开孔', + options: ['需预开孔', '可直接自攻'], + }, +] satisfies Array<{ + key: ScrewFilterKey + label: string + options: string[] +}> + +const EMPTY_FILTERS: ScrewFilterState = { + code: '', + name: '', + headType: '', + drive: '', + tail: '', + material: '', + finish: '', + tooth: '', + diameter: '', + length: '', + color: '', + preDrill: '', +} + +const USAGE_TAGS = [ + '实木', + '板式家具', + '刨花板', + '中纤板', + '密度板', + '多层板', + '胶合板', + '竹板', + '塑料件', + '五金件', + '合页', + '铰链', + '滑轨', + '拉手', + '角码', + '连接片', + '层板托', + '背板', + '桌腿', + '椅子', + '柜体', + '抽屉', + '床架', + '户外家具', + '防腐木', +] + +const ENVIRONMENT_TAGS = ['室内', '潮湿环境', '户外', '防锈', '耐腐蚀'] + +const TABLE_COLUMNS = [ + '编号', + '图片', + '名称', + '头型', + '槽型/驱动', + '尾型', + '材质', + '表面处理', + '牙型', + '规格/直径', + '长度', + '用途标签', + '颜色', + '是否预开孔', + '适用环境', + '价格', + '操作', +] + +const SCREW_DUPLICATE_KEYS = [ + 'headType', + 'drive', + 'tail', + 'material', + 'tooth', + 'diameter', + 'length', + 'color', +] satisfies Array + +const DEFAULT_SCREW_FIELDS = { + headType: '沉头', + drive: '十字', + tail: '尖尾', + material: '1022A碳钢', + finish: '镀白锌', + tooth: '木牙', + diameter: 'M4', + length: '16mm', + usages: ['实木', '板式家具', '刨花板'], + color: '银色', + preDrill: '可直接自攻', + environments: ['室内', '防锈'], +} satisfies Pick< + ScrewItem, + | 'headType' + | 'drive' + | 'tail' + | 'material' + | 'finish' + | 'tooth' + | 'diameter' + | 'length' + | 'usages' + | 'color' + | 'preDrill' + | 'environments' +> + +const PRIMARY_BUTTON_CLASS = + 'inline-flex h-9 items-center justify-center gap-1.5 rounded-apple-lg border border-zinc-950 bg-zinc-950 px-3 text-xs font-apple-semibold text-white transition hover:border-zinc-800 hover:bg-zinc-800' +const SECONDARY_BUTTON_CLASS = + 'inline-flex h-9 items-center justify-center gap-1.5 rounded-apple-lg border border-apple-separator bg-apple-bg-primary px-3 text-xs font-apple-medium text-apple-label-primary transition hover:bg-apple-bg-secondary' +const OPERATION_ICON_BUTTON_CLASS = + 'inline-flex h-7 w-7 items-center justify-center rounded-apple-md border border-apple-separator bg-apple-bg-primary text-apple-label-primary transition hover:bg-apple-bg-secondary' +const DELETE_ICON_BUTTON_CLASS = + 'inline-flex h-7 w-7 items-center justify-center rounded-apple-md border border-rose-200 bg-white text-rose-600 transition hover:bg-rose-50' +const FIELD_CLASS = + 'mt-1 h-9 w-full rounded-apple-lg border border-apple-separator bg-apple-bg-primary px-3 text-xs text-apple-label-primary outline-none focus:border-apple-blue' +const COMPACT_FIELD_CLASS = + 'mt-0.5 h-7 w-full rounded-apple-md border border-apple-separator bg-apple-bg-primary px-2 text-[11px] text-apple-label-primary outline-none focus:border-apple-blue' + +function createItemId() { + if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { + return crypto.randomUUID() + } + + return `accessory-library-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` +} + +function normalizeText(value: string) { + return value.trim().toLowerCase() +} + +function asStringArray(value: unknown, fallback: string[]) { + if (!Array.isArray(value)) return fallback + return value.filter((item): item is string => typeof item === 'string') +} + +function normalizeScrewItem(item: AccessoryLibraryItem): ScrewItem { + return { + ...item, + letter: ACCESSORY_LIBRARY_LETTERS.includes(item.letter as AccessoryLibraryLetter) + ? (item.letter as AccessoryLibraryLetter) + : 'A', + headType: item.headType?.trim() || DEFAULT_SCREW_FIELDS.headType, + drive: item.drive?.trim() || DEFAULT_SCREW_FIELDS.drive, + tail: item.tail?.trim() || DEFAULT_SCREW_FIELDS.tail, + material: item.material?.trim() || DEFAULT_SCREW_FIELDS.material, + finish: item.finish?.trim() || DEFAULT_SCREW_FIELDS.finish, + tooth: item.tooth?.trim() || DEFAULT_SCREW_FIELDS.tooth, + diameter: item.diameter?.trim() || DEFAULT_SCREW_FIELDS.diameter, + length: item.length?.trim() || DEFAULT_SCREW_FIELDS.length, + usages: asStringArray(item.usages, DEFAULT_SCREW_FIELDS.usages), + color: item.color?.trim() || DEFAULT_SCREW_FIELDS.color, + preDrill: item.preDrill?.trim() || DEFAULT_SCREW_FIELDS.preDrill, + environments: asStringArray( + item.environments, + DEFAULT_SCREW_FIELDS.environments, + ), + } +} + +function readScrewItems() { + return readAccessoryLibraryItems().map(normalizeScrewItem) +} + +function buildNextScrewCode(items: ScrewItem[], excludeItemId?: string | null) { + const maxIndex = items.reduce((max, item) => { + if (item.letter !== 'A' || item.id === excludeItemId) return max + const matched = /^A(\d+)$/.exec(item.code.trim().toUpperCase()) + if (!matched) return max + return Math.max(max, Number(matched[1])) + }, -1) + + return `A${maxIndex + 1}` +} + +function formatScrewLength(length: string) { + return length.trim().replace(/mm$/i, '') +} + +function buildScrewName( + item: Pick, +) { + const diameter = item.diameter.trim() + const length = formatScrewLength(item.length) + const specLength = + diameter && length ? `${diameter}*${length}` : diameter || length + + return [specLength, item.drive, item.headType, item.tail, '自攻螺丝'] + .map((value) => value.trim()) + .filter(Boolean) + .join(' ') +} + +function createScrewDraft(items: ScrewItem[], source?: ScrewItem): ScrewItem { + const nextDraft: ScrewItem = { + id: createItemId(), + letter: 'A', + code: buildNextScrewCode(items), + name: '', + price: source?.price ?? '', + imageUrl: source?.imageUrl ?? '', + imageName: source?.imageName ?? '', + updatedAt: new Date().toISOString(), + headType: source?.headType ?? DEFAULT_SCREW_FIELDS.headType, + drive: source?.drive ?? DEFAULT_SCREW_FIELDS.drive, + tail: source?.tail ?? DEFAULT_SCREW_FIELDS.tail, + material: source?.material ?? DEFAULT_SCREW_FIELDS.material, + finish: source?.finish ?? DEFAULT_SCREW_FIELDS.finish, + tooth: source?.tooth ?? DEFAULT_SCREW_FIELDS.tooth, + diameter: source?.diameter ?? DEFAULT_SCREW_FIELDS.diameter, + length: source?.length ?? DEFAULT_SCREW_FIELDS.length, + usages: source?.usages ? [...source.usages] : [...DEFAULT_SCREW_FIELDS.usages], + color: source?.color ?? DEFAULT_SCREW_FIELDS.color, + preDrill: source?.preDrill ?? DEFAULT_SCREW_FIELDS.preDrill, + environments: source?.environments + ? [...source.environments] + : [...DEFAULT_SCREW_FIELDS.environments], + } + + return { + ...nextDraft, + name: buildScrewName(nextDraft), + } +} + +function normalizeDuplicateValue(value: string | string[]) { + return Array.isArray(value) ? value.join(',').trim() : value.trim() +} + +function findDuplicateScrewItem( + items: ScrewItem[], + draft: ScrewItem, + excludeItemId?: string | null, +) { return ( - + items.find((item) => { + if (item.letter !== 'A' || item.id === excludeItemId) return false + + return SCREW_DUPLICATE_KEYS.every( + (key) => + normalizeDuplicateValue(item[key]) === normalizeDuplicateValue(draft[key]), + ) + }) ?? null + ) +} + +function renderTagList(values: string[], limit = 3) { + return ( +
+ {values.slice(0, limit).map((value) => ( + + {value} + + ))} + {values.length > limit ? ( + + +{values.length - limit} + + ) : null} +
+ ) +} + +function SelectField({ + label, + value, + options, + compact = false, + onChange, +}: { + label: string + value: string + options: string[] + compact?: boolean + onChange: (value: string) => void +}) { + return ( + + ) +} + +function MultiTagField({ + title, + values, + selectedValues, + onToggle, +}: { + title: string + values: string[] + selectedValues: string[] + onToggle: (value: string) => void +}) { + return ( +
+
+ {title} +
+
+ {values.map((value) => { + const isSelected = selectedValues.includes(value) + + return ( + + ) + })} +
+
+ ) +} + +export default function AccessoryLibraryPanel({ + canDeleteItems = false, +}: AccessoryLibraryPanelProps) { + const [items, setItems] = useState(readScrewItems) + const [selectedLetter, setSelectedLetter] = + useState('A') + const [activeHeadType, setActiveHeadType] = useState('全部') + const [draft, setDraft] = useState(null) + const [editingId, setEditingId] = useState(null) + const [copySource, setCopySource] = useState(null) + const [editingError, setEditingError] = useState('') + const [previewItem, setPreviewItem] = useState(null) + const [filterDraft, setFilterDraft] = + useState(EMPTY_FILTERS) + const [appliedFilters, setAppliedFilters] = + useState(EMPTY_FILTERS) + + useEffect(() => { + try { + window.localStorage.setItem( + ACCESSORY_LIBRARY_STORAGE_KEY, + JSON.stringify(items), + ) + window.dispatchEvent(new Event(ACCESSORY_LIBRARY_CHANGED_EVENT)) + } catch { + console.warn('配件资料库本地缓存空间不足,未能完成持久化保存。') + } + }, [items]) + + const aItems = useMemo( + () => + items + .filter((item) => item.letter === 'A') + .sort((left, right) => + left.code.localeCompare(right.code, 'zh-CN', { numeric: true }), + ), + [items], + ) + const selectedLetterItems = useMemo( + () => + items + .filter((item) => item.letter === selectedLetter) + .sort((left, right) => + left.code.localeCompare(right.code, 'zh-CN', { numeric: true }), + ), + [items, selectedLetter], + ) + const selectedLetterCategory = LETTER_CATEGORY_LABELS[selectedLetter] + const isSelectedLetterConfigured = selectedLetter === 'A' + + const totalItemsByLetter = useMemo(() => { + return ACCESSORY_LIBRARY_LETTERS.reduce>( + (result, letter) => { + result[letter as AccessoryLibraryLetter] = items.filter( + (item) => item.letter === letter, + ).length + return result + }, + {} as Record, + ) + }, [items]) + + const visibleItems = useMemo(() => { + if (!isSelectedLetterConfigured) return [] + + return aItems.filter((item) => { + const codeKeyword = normalizeText(appliedFilters.code) + const nameKeyword = normalizeText(appliedFilters.name) + const headTypeFilter = + appliedFilters.headType || (activeHeadType === '全部' ? '' : activeHeadType) + + if (codeKeyword && !normalizeText(item.code).includes(codeKeyword)) { + return false + } + + if (nameKeyword && !normalizeText(item.name).includes(nameKeyword)) { + return false + } + + if (headTypeFilter && item.headType !== headTypeFilter) { + return false + } + + return SELECT_FIELDS.every((field) => { + const filterValue = appliedFilters[field.key] + return !filterValue || item[field.key] === filterValue + }) + }) + }, [activeHeadType, appliedFilters, aItems, isSelectedLetterConfigured]) + + function updateFilterDraft(key: keyof ScrewFilterState, value: string) { + setFilterDraft((current) => ({ + ...current, + [key]: value, + })) + } + + function applyFilters() { + setAppliedFilters(filterDraft) + setActiveHeadType(filterDraft.headType || '全部') + } + + function resetFilters() { + setFilterDraft(EMPTY_FILTERS) + setAppliedFilters(EMPTY_FILTERS) + setActiveHeadType('全部') + } + + function selectLetter(letter: AccessoryLibraryLetter) { + setSelectedLetter(letter) + if (letter !== 'A') { + setFilterDraft(EMPTY_FILTERS) + setAppliedFilters(EMPTY_FILTERS) + setActiveHeadType('全部') + } + } + + function selectHeadType(headType: string) { + const nextHeadType = headType === '全部' ? '' : headType + setActiveHeadType(headType) + setFilterDraft((current) => ({ + ...current, + headType: nextHeadType, + })) + setAppliedFilters((current) => ({ + ...current, + headType: nextHeadType, + })) + } + + function openCreateModal(source?: ScrewItem) { + setEditingId(null) + setCopySource(source ?? null) + setDraft(createScrewDraft(items, source)) + setEditingError('') + } + + function openEditModal(item: ScrewItem) { + setEditingId(item.id) + setCopySource(null) + setDraft({ + ...item, + usages: [...item.usages], + environments: [...item.environments], + }) + setEditingError('') + } + + function closeDraftModal() { + setDraft(null) + setEditingId(null) + setCopySource(null) + setEditingError('') + } + + function updateDraftField( + key: K, + value: ScrewItem[K], + ) { + setEditingError('') + setDraft((current) => { + if (!current) return current + + const nextDraft = { ...current, [key]: value } + if (['diameter', 'length', 'drive', 'headType', 'tail'].includes(key)) { + return { + ...nextDraft, + name: buildScrewName(nextDraft), + } + } + + return nextDraft + }) + } + + function toggleDraftTag(key: 'usages' | 'environments', value: string) { + setEditingError('') + setDraft((current) => { + if (!current) return current + const currentValues = current[key] + const nextValues = currentValues.includes(value) + ? currentValues.filter((item) => item !== value) + : [...currentValues, value] + + return { + ...current, + [key]: nextValues, + } + }) + } + + async function handleDraftImageChange(event: ChangeEvent) { + const file = event.target.files?.[0] + if (!file || !draft) return + + if (!file.type.startsWith('image/')) { + setEditingError('这里只支持上传配件图片文件。') + event.target.value = '' + return + } + + if (file.size > ACCESSORY_LIBRARY_IMAGE_MAX_SIZE_BYTES) { + setEditingError('图片请控制在 1.5MB 内,避免资料库预览过慢。') + event.target.value = '' + return + } + + const imageUrl = await new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = () => + resolve(typeof reader.result === 'string' ? reader.result : '') + reader.onerror = () => reject(new Error('图片读取失败')) + reader.readAsDataURL(file) + }).catch(() => '') + + if (!imageUrl) { + setEditingError('图片读取失败,请重新上传。') + event.target.value = '' + return + } + + setEditingError('') + setDraft({ + ...draft, + imageUrl, + imageName: file.name, + }) + event.target.value = '' + } + + function clearDraftImage() { + if (!draft) return + + setDraft({ + ...draft, + imageUrl: '', + imageName: '', + }) + } + + function saveDraft() { + if (!draft) return + + const duplicateItem = findDuplicateScrewItem(items, draft, editingId) + if (duplicateItem) { + setEditingError( + `已存在相同配置:${duplicateItem.code} ${duplicateItem.name}。请至少修改头型、槽型/驱动、尾型、材质、牙型、规格/直径、长度、颜色中的任意一项。`, + ) + return + } + + const nextItem: ScrewItem = { + ...draft, + letter: 'A', + code: editingId ? draft.code.trim().toUpperCase() : buildNextScrewCode(items), + name: buildScrewName(draft), + price: draft.price.trim(), + imageUrl: draft.imageUrl.trim(), + imageName: draft.imageName.trim(), + updatedAt: new Date().toISOString(), + } + + setItems((current) => { + if (editingId) { + return current.map((item) => (item.id === editingId ? nextItem : item)) + } + + return [...current, nextItem] + }) + + closeDraftModal() + } + + function removeItem(item: ScrewItem) { + if (!canDeleteItems) return + + const shouldDelete = window.confirm( + `确认删除配件【${item.code} ${item.name}】吗?删除后 BOM 选择里也不会再带出这个配件。`, + ) + if (!shouldDelete) return + + setItems((current) => current.filter((currentItem) => currentItem.id !== item.id)) + if (previewItem?.id === item.id) { + setPreviewItem(null) + } + } + + const hasActiveFilters = Object.values(appliedFilters).some((value) => + value.trim(), + ) + + return ( +
+
+
+
+
+ + +
+ item.name)} + onChange={(value) => updateFilterDraft('headType', value)} + /> +
+ {SELECT_FIELDS.map((field) => ( +
+ updateFilterDraft(field.key, value)} + /> +
+ ))} +
+
+ +
+ + +
+
+ +
+ 当前共 {items.length} 条配件资料 + + 当前字母 {selectedLetter} + {selectedLetterCategory ? `(${selectedLetterCategory})` : ''} 共{' '} + {selectedLetterItems.length} 条 + + {hasActiveFilters && isSelectedLetterConfigured ? ( + 筛选后显示 {visibleItems.length} 条 + ) : null} +
+
+ +
+ + +
+
+
+

+ {selectedLetter} 组配件 + {selectedLetterCategory ? ` · ${selectedLetterCategory}` : ''} +

+
+ {isSelectedLetterConfigured ? ( + + ) : null} +
+ + {isSelectedLetterConfigured ? ( +
+ + {HEAD_TYPES.map((headType) => { + const isActive = activeHeadType === headType.name + return ( + + ) + })} +
+ ) : null} + + {isSelectedLetterConfigured ? ( +
+ + + + {TABLE_COLUMNS.map((column) => ( + + ))} + + + + {visibleItems.length ? ( + visibleItems.map((item, index) => ( + + + + + + + + + + + + + + + + + + + + )) + ) : ( + + + + )} + +
+ {column} +
+ {item.code} + + {item.imageUrl ? ( + + ) : ( +
+ +
+ )} +
+ {item.name} + + {item.headType} + + {item.drive} + + {item.tail} + + {item.material} + + {item.finish} + + {item.tooth} + + {item.diameter} + + {item.length} + + {renderTagList(item.usages)} + + {item.color} + + {item.preDrill} + + {renderTagList(item.environments, 2)} + + {item.price} + +
+ + + {canDeleteItems ? ( + + ) : null} +
+
+ {hasActiveFilters + ? '当前筛选条件下没有结果' + : 'A 组还没有配件资料'} +
+
+ ) : ( +
+
+

+ {selectedLetter} 组暂未配置配件分类 +

+
+
+ )} +
+
+ + + + +
+ } + > + {draft ? ( +
+ {editingError ? ( +
+ {editingError} +
+ ) : null} + +
+ + + +
+ +
+ item.name)} + onChange={(value) => updateDraftField('headType', value)} + /> + {SELECT_FIELDS.slice(0, 7).map((field) => ( + updateDraftField(field.key, value)} + /> + ))} +
+ +
+ {SELECT_FIELDS.slice(7).map((field) => ( + updateDraftField(field.key, value)} + /> + ))} + +
+ +
+ toggleDraftTag('usages', value)} + /> + toggleDraftTag('environments', value)} + /> +
+ +
+
+
+

+ 配件图片 +

+
+ +
+
+ {draft.imageUrl ? ( +
+ {draft.name +
+

+ {draft.imageName || '已上传图片'} +

+ +
+
+ ) : ( +
+ + 待上传图片 +
+ )} +
+
+
+ ) : null} + + + setPreviewItem(null)} + title={previewItem ? `${previewItem.code} · ${previewItem.name}` : '图片预览'} + size="large" + > + {previewItem?.imageUrl ? ( +
+
+ {previewItem.name} +
+

+ + {previewItem.imageName || `${previewItem.code} 图片`} +

+
+ ) : null} +
+ ) } diff --git a/furniture-3d-system/src/app/intake/AlphabetLibraryPanel.test.tsx b/furniture-3d-system/src/app/intake/AlphabetLibraryPanel.test.tsx index a51a8db1..dd299010 100644 --- a/furniture-3d-system/src/app/intake/AlphabetLibraryPanel.test.tsx +++ b/furniture-3d-system/src/app/intake/AlphabetLibraryPanel.test.tsx @@ -9,44 +9,141 @@ describe('alphabet library panels', () => { window.localStorage.clear() }) - it('adds color and material fields to the accessory library only', async () => { + afterEach(() => { + jest.restoreAllMocks() + }) + + it('uses the structured self-tapping screw library for accessories', async () => { const actor = userEvent.setup() render() - await actor.click(screen.getByRole('button', { name: '新增当前字母' })) + expect(screen.getByText('A 自攻螺丝')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: '新增' })).not.toBeInTheDocument() - const dialog = await screen.findByRole('dialog', { name: '新增配件' }) + await actor.click(screen.getByRole('button', { name: '新增 A 组' })) - expect(within(dialog).getByLabelText('颜色')).toBeInTheDocument() - expect(within(dialog).getByLabelText('材质')).toBeInTheDocument() + const dialog = await screen.findByRole('dialog', { name: '新增自攻螺丝' }) + expect(within(dialog).getByLabelText('配件名称')).toHaveValue( + 'M4*16 十字 沉头 尖尾 自攻螺丝', + ) - fireEvent.change(within(dialog).getByLabelText(/配件名称/), { - target: { value: '六角螺丝' }, - }) - fireEvent.change(within(dialog).getByLabelText('颜色'), { - target: { value: '黑色' }, - }) - fireEvent.change(within(dialog).getByLabelText('材质'), { - target: { value: '铁' }, + fireEvent.change(within(dialog).getByLabelText('长度'), { + target: { value: '20mm' }, }) + expect(within(dialog).getByLabelText('配件名称')).toHaveValue( + 'M4*20 十字 沉头 尖尾 自攻螺丝', + ) + await actor.click(within(dialog).getByRole('button', { name: '保存' })) await waitFor(() => { - expect(screen.getByText('黑色')).toBeInTheDocument() - expect(screen.getByText('铁')).toBeInTheDocument() + expect( + screen.getByText('M4*20 十字 沉头 尖尾 自攻螺丝'), + ).toBeInTheDocument() }) const savedItems = JSON.parse( window.localStorage.getItem(ACCESSORY_LIBRARY_STORAGE_KEY) ?? '[]', ) expect(savedItems[0]).toMatchObject({ - name: '六角螺丝', - color: '黑色', - material: '铁', + letter: 'A', + code: 'A0', + name: 'M4*20 十字 沉头 尖尾 自攻螺丝', + headType: '沉头', + drive: '十字', + tail: '尖尾', + material: '1022A碳钢', + tooth: '木牙', + diameter: 'M4', + length: '20mm', + color: '银色', }) }) + it('lets every accessory letter group be selected even before it is configured', async () => { + const actor = userEvent.setup() + + render() + + await actor.click(screen.getByRole('button', { name: 'B 0 条' })) + + expect(screen.getByText('B 组配件')).toBeInTheDocument() + expect(screen.getByText('B 组暂未配置配件分类')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: '新增 A 组' })).not.toBeInTheDocument() + + await actor.click(screen.getByRole('button', { name: 'A 自攻螺丝 0 条' })) + + expect(screen.getByText('A 组配件 · 自攻螺丝')).toBeInTheDocument() + expect(screen.getByRole('button', { name: '新增 A 组' })).toBeInTheDocument() + }) + + it('opens copy-new mode and blocks exact duplicate screw configs', async () => { + const actor = userEvent.setup() + + render() + + await actor.click(screen.getByRole('button', { name: '新增 A 组' })) + let dialog = await screen.findByRole('dialog', { name: '新增自攻螺丝' }) + await actor.click(within(dialog).getByRole('button', { name: '保存' })) + + await waitFor(() => { + expect( + screen.getByText('M4*16 十字 沉头 尖尾 自攻螺丝'), + ).toBeInTheDocument() + }) + + await actor.click(screen.getByRole('button', { name: '复制' })) + dialog = await screen.findByRole('dialog', { name: '复制新建:A0' }) + await actor.click(within(dialog).getByRole('button', { name: '保存' })) + + expect(await within(dialog).findByText(/已存在相同配置/)).toBeInTheDocument() + + fireEvent.change(within(dialog).getByLabelText('长度'), { + target: { value: '25mm' }, + }) + await actor.click(within(dialog).getByRole('button', { name: '保存' })) + + await waitFor(() => { + expect( + screen.getByText('M4*25 十字 沉头 尖尾 自攻螺丝'), + ).toBeInTheDocument() + }) + }) + + it('shows delete only when accessory library deletion is allowed', async () => { + const actor = userEvent.setup() + const confirmSpy = jest.spyOn(window, 'confirm').mockReturnValue(true) + + const { rerender } = render() + + await actor.click(screen.getByRole('button', { name: '新增 A 组' })) + let dialog = await screen.findByRole('dialog', { name: '新增自攻螺丝' }) + await actor.click(within(dialog).getByRole('button', { name: '保存' })) + + await waitFor(() => { + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + }) + expect(screen.queryByRole('button', { name: '删除' })).not.toBeInTheDocument() + + rerender() + const deleteButton = await screen.findByRole('button', { name: '删除' }) + + await actor.click(deleteButton) + + await waitFor(() => { + expect( + screen.queryByText('M4*16 十字 沉头 尖尾 自攻螺丝'), + ).not.toBeInTheDocument() + }) + expect(confirmSpy).toHaveBeenCalledWith( + expect.stringContaining('确认删除配件'), + ) + expect( + JSON.parse(window.localStorage.getItem(ACCESSORY_LIBRARY_STORAGE_KEY) ?? '[]'), + ).toEqual([]) + }) + it('keeps the packaging material library on its original fields', async () => { const actor = userEvent.setup() diff --git a/furniture-3d-system/src/app/intake/IntakeClient.tsx b/furniture-3d-system/src/app/intake/IntakeClient.tsx index d4413c59..7b776c57 100644 --- a/furniture-3d-system/src/app/intake/IntakeClient.tsx +++ b/furniture-3d-system/src/app/intake/IntakeClient.tsx @@ -13,15 +13,22 @@ import { useState, } from "react"; import { + ArrowDownToLine, + Boxes, Calculator, Check, + ClipboardCheck, ClipboardList, Copy, Database, Download, FileSpreadsheet, + Home, + KeyRound, + Layers, Layers3, Package, + PackageOpen, Palette, Pencil, Plus, @@ -29,6 +36,7 @@ import { ShieldCheck, Trash2, Upload, + Warehouse, X as XIcon, type LucideIcon, } from "lucide-react"; @@ -90,6 +98,20 @@ import { isIntakeNonIntegratedQualityGroupUser, type IntakeNonIntegratedPermissionUser, } from "@/lib/non-integrated-permissions"; +import { + ALL_NODE_PERMISSION_TARGETS, + CORE_NODE_PERMISSION_SUBJECTS, + NODE_PERMISSION_STATE_LABEL, + ORDINARY_NODE_PERMISSION_SUBJECTS, + PRODUCT_DEVELOPMENT_PERMISSION_TARGETS, + TOP_ENTRY_PERMISSION_TARGETS, + buildIntakeNodePermissionOverrideKey, + sanitizeIntakeNodePermissionOverrides, + type IntakeNodePermissionOverrideMap, + type IntakeNodePermissionState, + type IntakeNodePermissionSubject, + type IntakeNodePermissionTargetKey, +} from "@/lib/intake-node-permission-settings"; import { getNextProductPatentFeeDueDate, listProductPatentRecords, @@ -193,6 +215,10 @@ function canManageSpuRuleLibrary(role: string): boolean { return role === "SUPER_ADMIN" || role === "ADMIN"; } +function canManageNodePermissionSettings(role: string): boolean { + return role === "SUPER_ADMIN" || role === "ADMIN"; +} + function canAccessDesignControlledProductData( user: IntakeProductPermissionUser, ): boolean { @@ -241,6 +267,7 @@ type IntakeSection = | "rawMaterialLibrary" | "referenceEstimator" | "patents" + | "adminNodePermission" | null; type IntakeEntryTab = "integrated" | "nonIntegrated" | "stock"; type ProductDevelopmentKind = NonNullable< @@ -276,6 +303,7 @@ const FORMAL_SIDEBAR_GROUP_ICONS = { referenceEstimator: Calculator, patents: ShieldCheck, spuRuleLibrary: Database, + nodePermission: KeyRound, } satisfies Record; function FormalSidebarIcon({ @@ -287,6 +315,410 @@ function FormalSidebarIcon({ }) { return