diff --git a/furniture-3d-system/src/app/api/bom-costing/materials/route.ts b/furniture-3d-system/src/app/api/bom-costing/materials/route.ts index 63b24860..e8c5ce32 100644 --- a/furniture-3d-system/src/app/api/bom-costing/materials/route.ts +++ b/furniture-3d-system/src/app/api/bom-costing/materials/route.ts @@ -9,7 +9,7 @@ export async function GET(request: NextRequest) { request, async (ctx) => { try { - bomCostingAccessService.assertCanView(ctx.user) + await bomCostingAccessService.assertCanView(ctx.user) const [materials, sync] = await Promise.all([ bomCostingScenarioService.listMaterials(), bomCostingScenarioService.getSyncFreshness(), diff --git a/furniture-3d-system/src/app/bom-costing/page.tsx b/furniture-3d-system/src/app/bom-costing/page.tsx index 0522f637..68096dfc 100644 --- a/furniture-3d-system/src/app/bom-costing/page.tsx +++ b/furniture-3d-system/src/app/bom-costing/page.tsx @@ -1,6 +1,6 @@ import { redirect } from 'next/navigation' import { requireSession } from '@/lib/server-session' -import { canViewBomCostingWorkbench } from '@/lib/bom-costing-permissions' +import { bomCostingAccessService } from '@/server/services/bom-costing/bom-costing-access.service' import { bomCostingService } from '@/server/services/bom-costing/bom-costing.service' import BomCostingWorkbenchClient from './BomCostingWorkbenchClient' @@ -10,7 +10,7 @@ export const metadata = { export default async function BomCostingPage() { const { authUser } = await requireSession('/login') - if (!canViewBomCostingWorkbench(authUser)) { + if (!(await bomCostingAccessService.canView(authUser))) { redirect('/intake') } diff --git a/furniture-3d-system/src/app/intake/intakeWorkspaceState.ts b/furniture-3d-system/src/app/intake/intakeWorkspaceState.ts index 93e48070..5623c6c6 100644 --- a/furniture-3d-system/src/app/intake/intakeWorkspaceState.ts +++ b/furniture-3d-system/src/app/intake/intakeWorkspaceState.ts @@ -91,6 +91,7 @@ function resolveIntakeFormalWorkspaceAccess( overrides, targetKey, resolveIntakeNodePermissionSubjectKeys(user), + user.id, ); } diff --git a/furniture-3d-system/src/app/intake/non-integrated/useNonIntegratedPermissionState.ts b/furniture-3d-system/src/app/intake/non-integrated/useNonIntegratedPermissionState.ts index 8fd675eb..a6a502e4 100644 --- a/furniture-3d-system/src/app/intake/non-integrated/useNonIntegratedPermissionState.ts +++ b/furniture-3d-system/src/app/intake/non-integrated/useNonIntegratedPermissionState.ts @@ -202,9 +202,10 @@ export function useNonIntegratedPermissionState({ nodePermissionOverrides, targetKey, currentPermissionSubjectKeys, + currentPermissionUser.id, ); }, - [currentPermissionSubjectKeys, nodePermissionOverrides], + [currentPermissionSubjectKeys, currentPermissionUser.id, nodePermissionOverrides], ); const activeNodePermissionAccess = getNodePermissionAccessForCategory( activeDocumentWorkspaceCategory, diff --git a/furniture-3d-system/src/app/users/components/PermissionLevelNavigation.tsx b/furniture-3d-system/src/app/users/components/PermissionLevelNavigation.tsx new file mode 100644 index 00000000..326fe944 --- /dev/null +++ b/furniture-3d-system/src/app/users/components/PermissionLevelNavigation.tsx @@ -0,0 +1,64 @@ +'use client' + +import { ChevronLeft, ChevronRight, type LucideIcon } from 'lucide-react' + +export type PermissionLevelNavigationItem = { + key: string + label: string + icon?: LucideIcon + active?: boolean + hasChildren?: boolean +} + +export function PermissionLevelNavigation({ + title, + items, + backLabel, + onBack, + onSelect, +}: { + title: string + items: PermissionLevelNavigationItem[] + backLabel?: string + onBack?: () => void + onSelect: (key: string) => void +}) { + return ( + + ) +} diff --git a/furniture-3d-system/src/app/users/components/ProductDevelopmentNodePermissionWorkspace.test.tsx b/furniture-3d-system/src/app/users/components/ProductDevelopmentNodePermissionWorkspace.test.tsx index 84dd6e5e..a831498e 100644 --- a/furniture-3d-system/src/app/users/components/ProductDevelopmentNodePermissionWorkspace.test.tsx +++ b/furniture-3d-system/src/app/users/components/ProductDevelopmentNodePermissionWorkspace.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, waitFor } from '@testing-library/react' +import { render, screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { ProductDevelopmentNodePermissionWorkspace } from './ProductDevelopmentNodePermissionWorkspace' @@ -17,7 +17,7 @@ describe('ProductDevelopmentNodePermissionWorkspace', () => { jest.restoreAllMocks() }) - it('loads the existing permission matrix and saves changes through the same API', async () => { + it('keeps only the active navigation level visible and saves changes through the same API', async () => { const actor = userEvent.setup() const fetchMock = jest .fn() @@ -33,13 +33,39 @@ describe('ProductDevelopmentNodePermissionWorkspace', () => { render() expect(await screen.findByText('已读取当前权限配置')).toBeInTheDocument() - expect(screen.getByRole('button', { name: '基础信息' })).toBeInTheDocument() + expect(screen.queryByRole('navigation', { name: '产品资料' })).not.toBeInTheDocument() - await actor.click(screen.getByRole('button', { name: /展开/ })) + await actor.click(screen.getByRole('button', { name: '产品资料' })) + expect(screen.getByRole('navigation', { name: '产品资料' })).toBeInTheDocument() + expect(screen.queryByText('选择二层页面;产品开发明细可进入节点权限')).not.toBeInTheDocument() + + await actor.click(screen.getByRole('button', { name: '产品开发明细' })) + expect(screen.queryByRole('navigation', { name: '产品资料' })).not.toBeInTheDocument() + expect(screen.getByRole('navigation', { name: '产品开发明细节点' })).toBeInTheDocument() + expect(screen.queryByText('选择需要配置的三级节点')).not.toBeInTheDocument() + + expect(screen.getAllByText('采购')).toHaveLength(1) + expect(screen.getByText('采购-跟单')).toBeInTheDocument() + expect(screen.getByText('采购-质检')).toBeInTheDocument() + expect(screen.getByText('仓库')).toBeInTheDocument() + const designRow = screen.getByText('设计').closest('tr') + expect(designRow).not.toBeNull() + await waitFor(() => expect(within(designRow!).getByRole('button', { name: /人/ })).toBeEnabled()) + await actor.click(within(designRow!).getByRole('button', { name: /人/ })) + expect(designRow).toHaveClass('sticky', 'top-[33px]') + + await actor.click(screen.getByRole('button', { name: '设计卖点' })) + expect(screen.getByRole('navigation', { name: '产品开发明细节点' })).toBeInTheDocument() + + await actor.click(screen.getByRole('button', { name: '返回产品资料' })) + expect(screen.getByRole('navigation', { name: '产品资料' })).toBeInTheDocument() + expect(screen.queryByRole('navigation', { name: '产品开发明细节点' })).not.toBeInTheDocument() + + await actor.click(screen.getByRole('button', { name: '首页概览' })) await actor.click(screen.getByRole('button', { name: '开通供应商可见' })) - await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)) - const [, saveOptions] = fetchMock.mock.calls[1] as [string, RequestInit] + await waitFor(() => expect(fetchMock.mock.calls.some(([, options]) => options?.method === 'PUT')).toBe(true)) + const [, saveOptions] = fetchMock.mock.calls.find(([, options]) => options?.method === 'PUT') as [string, RequestInit] expect(saveOptions.method).toBe('PUT') expect(JSON.parse(String(saveOptions.body))).toEqual({ overrides: { diff --git a/furniture-3d-system/src/app/users/components/ProductDevelopmentNodePermissionWorkspace.tsx b/furniture-3d-system/src/app/users/components/ProductDevelopmentNodePermissionWorkspace.tsx index a91a2ea4..e4ebbd88 100644 --- a/furniture-3d-system/src/app/users/components/ProductDevelopmentNodePermissionWorkspace.tsx +++ b/furniture-3d-system/src/app/users/components/ProductDevelopmentNodePermissionWorkspace.tsx @@ -30,10 +30,13 @@ import { sanitizeIntakeNodePermissionOverrides, type IntakeNodePermissionAction, type IntakeNodePermissionOverrideMap, - type IntakeNodePermissionSubject, + type IntakeNodePermissionPrincipalKey, + type IntakeNodePermissionSubjectKey, type IntakeNodePermissionTargetKey, } from '@/lib/intake-node-permission-settings' import { ProductDevelopmentPermissionTable } from './ProductDevelopmentPermissionTable' +import { PermissionLevelNavigation } from './PermissionLevelNavigation' +import { usePermissionManagedUsers } from './usePermissionManagedUsers' const TOP_ENTRY_ICONS: Partial> = { homeDashboard: Home, @@ -41,6 +44,8 @@ const TOP_ENTRY_ICONS: Partial assistedAssembly: Layers, draftGroups: PackageOpen, formalProducts: ArrowDownToLine, + bomCostingWorkbench: Calculator, + oaNewProductBoard: ClipboardList, warehouseManage: Warehouse, warehouseQuality: ClipboardCheck, aiRenderWorkbench: Sparkles, @@ -61,35 +66,14 @@ const FORMAL_WORKSPACE_ICONS: Partial void -}) { - return ( - - ) +function isProductMaterialBranch(targetKey: IntakeNodePermissionTargetKey) { + return FORMAL_WORKSPACE_PERMISSION_TARGETS.some((target) => target.key === targetKey) || + PRODUCT_DEVELOPMENT_PERMISSION_TARGETS.some((target) => target.key === targetKey) } export function ProductDevelopmentNodePermissionWorkspace() { const [activeTargetKey, setActiveTargetKey] = useState(TOP_ENTRY_PERMISSION_TARGETS[0]!.key) + const [navigationLevel, setNavigationLevel] = useState<'secondary' | 'tertiary' | null>(null) const [overrides, setOverrides] = useState({}) const [loaded, setLoaded] = useState(false) const [saving, setSaving] = useState(false) @@ -98,6 +82,21 @@ export function ProductDevelopmentNodePermissionWorkspace() { ALL_NODE_PERMISSION_TARGETS.find((target) => target.key === activeTargetKey) ?? ALL_NODE_PERMISSION_TARGETS[0]! const disabled = saving || !loaded + const managedUsers = usePermissionManagedUsers() + + function selectTopTarget(targetKey: IntakeNodePermissionTargetKey) { + setActiveTargetKey(targetKey) + setNavigationLevel(targetKey === 'formalProducts' ? 'secondary' : null) + } + + function selectFormalTarget(targetKey: IntakeNodePermissionTargetKey) { + setActiveTargetKey(targetKey) + if (targetKey === 'productDevelopmentList') setNavigationLevel('tertiary') + } + + function selectDevelopmentNode(targetKey: IntakeNodePermissionTargetKey) { + setActiveTargetKey(targetKey) + } useEffect(() => { let cancelled = false @@ -160,13 +159,20 @@ export function ProductDevelopmentNodePermissionWorkspace() { } function toggleOverride( - subjectKey: IntakeNodePermissionSubject['key'], + principalKey: IntakeNodePermissionPrincipalKey, action: IntakeNodePermissionAction, + subjectKeys: readonly IntakeNodePermissionSubjectKey[], + userId?: string, ) { if (disabled) return - const access = resolveIntakeNodePermissionAccess(overrides, activeTarget.key, [subjectKey]) + const access = resolveIntakeNodePermissionAccess( + overrides, + activeTarget.key, + subjectKeys, + userId, + ) const getKey = (targetAction: IntakeNodePermissionAction) => - buildIntakeNodePermissionOverrideKey(activeTarget.key, subjectKey, targetAction) + buildIntakeNodePermissionOverrideKey(activeTarget.key, principalKey, targetAction) const nextValue = !access[action] const next = { ...overrides, [getKey(action)]: nextValue } @@ -181,72 +187,105 @@ export function ProductDevelopmentNodePermissionWorkspace() { } return ( -
-
-
-
-

产品开发节点开放权限

-

按业务对象配置可见、下载和操作范围

-
- {status} +
+
- - + + {navigationLevel === 'secondary' ? ( + ({ + key: target.key, + label: target.label, + icon: FORMAL_WORKSPACE_ICONS[target.key] ?? ClipboardList, + active: target.key === activeTarget.key, + hasChildren: target.key === 'productDevelopmentList', + }))} + onSelect={(key) => selectFormalTarget(key as IntakeNodePermissionTargetKey)} + /> + ) : null} + + {navigationLevel === 'tertiary' ? ( + { + setActiveTargetKey('productDevelopmentList') + setNavigationLevel('secondary') + }} + items={PRODUCT_DEVELOPMENT_PERMISSION_TARGETS.map((target) => ({ + key: target.key, + label: target.label, + active: target.key === activeTarget.key, + }))} + onSelect={(key) => selectDevelopmentNode(key as IntakeNodePermissionTargetKey)} + /> + ) : null} + +
+
+
+

产品开发节点开放权限

+

+ 左侧一级入口保持显示;当前二级或三级菜单固定在旁边,便于连续配置 +

+
+
+ + {status} +
+
+ +
+
+
+

{activeTarget.label}

+

当前配置对象 · 业务岗位可单独设置例外权限

+
+ + 可见 / 下载 / 操作 + +
+ +
+
) } diff --git a/furniture-3d-system/src/app/users/components/ProductDevelopmentPermissionTable.tsx b/furniture-3d-system/src/app/users/components/ProductDevelopmentPermissionTable.tsx index 9cd730f9..2316448a 100644 --- a/furniture-3d-system/src/app/users/components/ProductDevelopmentPermissionTable.tsx +++ b/furniture-3d-system/src/app/users/components/ProductDevelopmentPermissionTable.tsx @@ -1,18 +1,86 @@ 'use client' -import { useState } from 'react' -import { Check, X } from 'lucide-react' +import { Check, ChevronDown, Users, X } from 'lucide-react' +import { Fragment, useMemo, useState } from 'react' import { CORE_NODE_PERMISSION_SUBJECTS, NODE_PERMISSION_STATE_LABEL, ORDINARY_NODE_PERMISSION_SUBJECTS, resolveIntakeNodePermissionAccess, + resolveIntakeNodePermissionSubjectKeys, type IntakeNodePermissionAction, type IntakeNodePermissionOverrideMap, + type IntakeNodePermissionPrincipalKey, type IntakeNodePermissionState, type IntakeNodePermissionSubject, + type IntakeNodePermissionSubjectKey, type IntakeNodePermissionTarget, } from '@/lib/intake-node-permission-settings' +import type { PermissionManagedUser } from './usePermissionManagedUsers' + +type PermissionSubjectDisplay = { + subject: IntakeNodePermissionSubject + label: string + nested?: boolean +} + +const ALL_PERMISSION_SUBJECTS = [ + ...CORE_NODE_PERMISSION_SUBJECTS, + ...ORDINARY_NODE_PERMISSION_SUBJECTS, +] + +const SUBJECT_LABELS: Partial> = { + design: '设计', + planning: '企划', + brandCenter: '策划', + purchase: '采购', + merchandiser: '采购-跟单', + quality: '采购-质检', + operation: '运营', + warehouse: '仓库', + customerService: '客服', + management: '经管', + viewer: '普通用户', + supplier: '供应商', + externalIntake: '外部人员', +} + +function createSubjectDisplay( + key: IntakeNodePermissionSubjectKey, + nested = false, +): PermissionSubjectDisplay { + const subject = ALL_PERMISSION_SUBJECTS.find((item) => item.key === key) + if (!subject) throw new Error(`未找到权限业务类型:${key}`) + return { subject, label: SUBJECT_LABELS[key] ?? subject.name, nested } +} + +const PRIMARY_PERMISSION_SUBJECTS = [ + createSubjectDisplay('design'), + createSubjectDisplay('planning'), + createSubjectDisplay('brandCenter'), +] + +const PURCHASE_PERMISSION_SUBJECTS = [ + createSubjectDisplay('purchase'), + createSubjectDisplay('merchandiser'), + createSubjectDisplay('quality'), +] + +const OTHER_PERMISSION_SUBJECTS = [ + createSubjectDisplay('operation'), + createSubjectDisplay('warehouse'), + createSubjectDisplay('customerService'), + createSubjectDisplay('management'), + createSubjectDisplay('viewer'), + createSubjectDisplay('supplier'), + createSubjectDisplay('externalIntake'), +] + +const VISIBLE_PERMISSION_SUBJECTS = [ + ...PRIMARY_PERMISSION_SUBJECTS, + ...PURCHASE_PERMISSION_SUBJECTS, + ...OTHER_PERMISSION_SUBJECTS, +] function getStateClassName(state: IntakeNodePermissionState) { if (state === 'edit') return 'bg-[#e8f0ee] text-[#334548]' @@ -55,19 +123,86 @@ export function ProductDevelopmentPermissionTable({ target, overrides, disabled, + users, + usersLoading, + usersError, onToggle, }: { target: IntakeNodePermissionTarget overrides: IntakeNodePermissionOverrideMap disabled: boolean + users: PermissionManagedUser[] + usersLoading: boolean + usersError: string | null onToggle: ( - subjectKey: IntakeNodePermissionSubject['key'], + principalKey: IntakeNodePermissionPrincipalKey, action: IntakeNodePermissionAction, + subjectKeys: readonly IntakeNodePermissionSubjectKey[], + userId?: string, ) => void }) { - const [ordinaryExpanded, setOrdinaryExpanded] = useState(false) + const [expandedSubjects, setExpandedSubjects] = useState>(new Set()) + const usersBySubject = useMemo(() => { + const grouped = new Map() + VISIBLE_PERMISSION_SUBJECTS.forEach(({ subject }) => { + grouped.set( + subject.key, + users.filter((user) => + resolveIntakeNodePermissionSubjectKeys(user).includes(subject.key), + ), + ) + }) + return grouped + }, [users]) - function renderSubjectRow(subject: IntakeNodePermissionSubject) { + function toggleExpanded(subjectKey: string) { + setExpandedSubjects((current) => { + const next = new Set(current) + if (next.has(subjectKey)) next.delete(subjectKey) + else next.add(subjectKey) + return next + }) + } + + function renderUserRow(subject: IntakeNodePermissionSubject, user: PermissionManagedUser) { + const userSubjectKeys = resolveIntakeNodePermissionSubjectKeys(user) + const access = resolveIntakeNodePermissionAccess( + overrides, + target.key, + userSubjectKeys, + user.id, + ) + return ( + + +
+ + {user.name.slice(0, 1)} + +
{user.name}
+
+ + 个人例外优先于业务类型 + {(['view', 'download', 'edit'] as IntakeNodePermissionAction[]).map((action) => ( + + onToggle(`user@${user.id}`, action, userSubjectKeys, user.id)} + /> + + ))} + + {access.edit ? '可操作' : access.download ? '可查看、下载' : access.view ? '仅查看' : '未开放'} + + + + ) + } + + function renderSubjectRow(display: PermissionSubjectDisplay) { + const { subject, label, nested } = display const defaultState = target.defaults[subject.key] ?? 'hidden' const access = resolveIntakeNodePermissionAccess(overrides, target.key, [subject.key]) const effectiveState: IntakeNodePermissionState = access.edit @@ -76,11 +211,16 @@ export function ProductDevelopmentPermissionTable({ ? 'readonly' : 'hidden' + const subjectUsers = usersBySubject.get(subject.key) ?? [] + const expanded = expandedSubjects.has(subject.key) + return ( - - -
{subject.name}
-
{subject.department}
+ + + +
{label}
@@ -96,24 +236,24 @@ export function ProductDevelopmentPermissionTable({ onToggle(subject.key, 'view')} + label={access.view ? `关闭${label}可见` : `开通${label}可见`} + onChange={() => onToggle(subject.key, 'view', [subject.key])} /> onToggle(subject.key, 'download')} + label={access.download ? `关闭${label}下载` : `开通${label}下载`} + onChange={() => onToggle(subject.key, 'download', [subject.key])} /> onToggle(subject.key, 'edit')} + label={access.edit ? `关闭${label}操作` : `开通${label}操作`} + onChange={() => onToggle(subject.key, 'edit', [subject.key])} /> @@ -125,41 +265,50 @@ export function ProductDevelopmentPermissionTable({ : '当前仅可查看。' : '当前已关闭。'} + + + + {expanded ? ( + usersError ? ( + {usersError} + ) : subjectUsers.length > 0 ? ( + subjectUsers.map((user) => renderUserRow(subject, user)) + ) : ( + 该业务类型暂无运行中账号 + ) + ) : null} + ) } return (
- +
- + + - - - - {CORE_NODE_PERMISSION_SUBJECTS.map(renderSubjectRow)} - - - - {ordinaryExpanded ? ORDINARY_NODE_PERMISSION_SUBJECTS.map(renderSubjectRow) : null} + {PRIMARY_PERMISSION_SUBJECTS.map(renderSubjectRow)} + {PURCHASE_PERMISSION_SUBJECTS.map(renderSubjectRow)} + {OTHER_PERMISSION_SUBJECTS.map(renderSubjectRow)}
对象业务类型 权限状态 可见 下载 操作 说明人员
核心对象
- -
diff --git a/furniture-3d-system/src/app/users/components/SupplyChainPermissionWorkspace.test.tsx b/furniture-3d-system/src/app/users/components/SupplyChainPermissionWorkspace.test.tsx index 4a319a74..6e6ca7bb 100644 --- a/furniture-3d-system/src/app/users/components/SupplyChainPermissionWorkspace.test.tsx +++ b/furniture-3d-system/src/app/users/components/SupplyChainPermissionWorkspace.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, waitFor } from '@testing-library/react' +import { render, screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { SupplyChainPermissionWorkspace } from './SupplyChainPermissionWorkspace' @@ -27,13 +27,36 @@ describe('SupplyChainPermissionWorkspace', () => { render() expect(await screen.findByText('已读取当前权限配置')).toBeInTheDocument() - expect(screen.queryByText('供应商')).not.toBeInTheDocument() - expect(screen.queryByText('外部录入员')).not.toBeInTheDocument() + expect(screen.getByRole('navigation', { name: '供应链入口' })).toBeInTheDocument() + expect(screen.getByText('供应商')).toBeInTheDocument() + expect(screen.getByText('外部人员')).toBeInTheDocument() + expect(screen.getAllByText('固定排除')).toHaveLength(2) + expect(screen.queryByText('供应链中心入口与工作台总览')).not.toBeInTheDocument() + expect(screen.getAllByText('采购')).toHaveLength(1) + expect(screen.getByText('采购-跟单')).toBeInTheDocument() + expect(screen.getByText('采购-质检')).toBeInTheDocument() + expect(screen.getByText('仓库')).toBeInTheDocument() - await actor.click(screen.getByRole('button', { name: '开通设计师可见' })) - await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)) - expect(JSON.parse(String((fetchMock.mock.calls[1] as [string, RequestInit])[1].body))).toEqual({ - overrides: { 'workspace:design:view': true }, + const table = screen.getByRole('table') + expect(table.parentElement).toHaveClass('min-h-0', 'flex-1', 'overflow-auto') + expect(table.parentElement?.parentElement).toHaveClass('overflow-hidden') + + const designRow = screen.getByText('设计').closest('tr') + expect(designRow).not.toBeNull() + await waitFor(() => expect(within(designRow!).getByRole('button', { name: /人/ })).toBeEnabled()) + await actor.click(within(designRow!).getByRole('button', { name: /人/ })) + expect(designRow).toHaveClass('sticky', 'top-[33px]') + + await actor.click(screen.getByRole('button', { name: '数据库' })) + expect(screen.getByRole('navigation', { name: '数据库' })).toBeInTheDocument() + await actor.click(screen.getByRole('button', { name: '产品全量档案' })) + expect(screen.getByRole('navigation', { name: '数据库' })).toBeInTheDocument() + + await actor.click(screen.getByRole('button', { name: '开通设计可见' })) + await waitFor(() => expect(fetchMock.mock.calls.some(([, options]) => options?.method === 'PUT')).toBe(true)) + const [, saveOptions] = fetchMock.mock.calls.find(([, options]) => options?.method === 'PUT') as [string, RequestInit] + expect(JSON.parse(String(saveOptions.body))).toEqual({ + overrides: { 'productArchive:design:view': true }, }) }) }) diff --git a/furniture-3d-system/src/app/users/components/SupplyChainPermissionWorkspace.tsx b/furniture-3d-system/src/app/users/components/SupplyChainPermissionWorkspace.tsx index 9e957d9f..036d79cd 100644 --- a/furniture-3d-system/src/app/users/components/SupplyChainPermissionWorkspace.tsx +++ b/furniture-3d-system/src/app/users/components/SupplyChainPermissionWorkspace.tsx @@ -1,7 +1,7 @@ 'use client' -import { useEffect, useState } from 'react' -import { Check, ClipboardList, Database, LayoutDashboard, PackagePlus, ShoppingCart, Warehouse, X } from 'lucide-react' +import { Fragment, useEffect, useMemo, useState } from 'react' +import { Check, ChevronDown, ClipboardList, Database, LayoutDashboard, PackagePlus, ShoppingCart, Users, Warehouse, X } from 'lucide-react' import { SUPPLY_CHAIN_PERMISSION_ACTION_LABELS, SUPPLY_CHAIN_PERMISSION_CORE_SUBJECTS, @@ -9,12 +9,16 @@ import { SUPPLY_CHAIN_PERMISSION_TARGETS, buildSupplyChainPermissionOverrideKey, resolveSupplyChainPermissionAccess, + resolveSupplyChainPermissionSubjectKeys, sanitizeSupplyChainPermissionOverrides, type SupplyChainPermissionAction, type SupplyChainPermissionOverrideMap, + type SupplyChainPermissionPrincipalKey, type SupplyChainPermissionSubject, type SupplyChainPermissionTargetKey, } from '@/lib/supply-chain-permission-settings' +import { usePermissionManagedUsers, type PermissionManagedUser } from './usePermissionManagedUsers' +import { PermissionLevelNavigation } from './PermissionLevelNavigation' const TARGET_ICONS: Record = { workspace: LayoutDashboard, @@ -30,6 +34,104 @@ const TARGET_ICONS: Record ['workspace', 'dashboard'].includes(target.key)), + }, + { + key: 'database', + label: '数据库', + description: '产品档案、类目和仓库配置', + icon: Database, + targets: SUPPLY_CHAIN_PERMISSION_TARGETS.filter((target) => ['productArchive', 'categoryConfig', 'warehouseConfig'].includes(target.key)), + }, + { + key: 'new-products', + label: '新品开发', + description: '新品开发总表与打样进度明细', + icon: PackagePlus, + targets: SUPPLY_CHAIN_PERMISSION_TARGETS.filter((target) => ['newProductOverview', 'newProductUnifiedTable', 'newProductSampleProgress'].includes(target.key)), + }, + { + key: 'purchase', + label: '采购协作', + description: '日常事项与产品变价', + icon: ShoppingCart, + targets: SUPPLY_CHAIN_PERMISSION_TARGETS.filter((target) => ['purchaseDailyAffairs', 'purchasePriceChange'].includes(target.key)), + }, + { + key: 'audit', + label: '审计记录', + description: '供应链操作日志', + icon: ClipboardList, + targets: SUPPLY_CHAIN_PERMISSION_TARGETS.filter((target) => target.key === 'operationLogs'), + }, +] as const + +type SupplyPermissionSubjectDisplay = { + subject: SupplyChainPermissionSubject + label: string + nested?: boolean +} + +const ALL_SUPPLY_PERMISSION_SUBJECTS = [ + ...SUPPLY_CHAIN_PERMISSION_CORE_SUBJECTS, + ...SUPPLY_CHAIN_PERMISSION_ORDINARY_SUBJECTS, +] + +const SUPPLY_SUBJECT_LABELS: Partial> = { + design: '设计', + planning: '企划', + brandCenter: '策划', + purchase: '采购', + merchandiser: '采购-跟单', + quality: '采购-质检', + operation: '运营', + warehouse: '仓库', + customerService: '客服', + management: '经管', + viewer: '普通用户', +} + +function createSupplySubjectDisplay( + key: SupplyChainPermissionSubject['key'], + nested = false, +): SupplyPermissionSubjectDisplay { + const subject = ALL_SUPPLY_PERMISSION_SUBJECTS.find((item) => item.key === key) + if (!subject) throw new Error(`未找到供应链权限业务类型:${key}`) + return { subject, label: SUPPLY_SUBJECT_LABELS[key] ?? subject.name, nested } +} + +const PRIMARY_SUPPLY_PERMISSION_SUBJECTS = [ + createSupplySubjectDisplay('design'), + createSupplySubjectDisplay('planning'), + createSupplySubjectDisplay('brandCenter'), +] + +const PURCHASE_SUPPLY_PERMISSION_SUBJECTS = [ + createSupplySubjectDisplay('purchase'), + createSupplySubjectDisplay('merchandiser'), + createSupplySubjectDisplay('quality'), +] + +const OTHER_SUPPLY_PERMISSION_SUBJECTS = [ + createSupplySubjectDisplay('operation'), + createSupplySubjectDisplay('warehouse'), + createSupplySubjectDisplay('customerService'), + createSupplySubjectDisplay('management'), + createSupplySubjectDisplay('viewer'), +] + +const VISIBLE_SUPPLY_PERMISSION_SUBJECTS = [ + ...PRIMARY_SUPPLY_PERMISSION_SUBJECTS, + ...PURCHASE_SUPPLY_PERMISSION_SUBJECTS, + ...OTHER_SUPPLY_PERMISSION_SUBJECTS, +] + function Toggle({ checked, disabled, @@ -64,11 +166,28 @@ function Toggle({ export function SupplyChainPermissionWorkspace() { const [activeTargetKey, setActiveTargetKey] = useState('workspace') const [overrides, setOverrides] = useState({}) + const [expandedSubjects, setExpandedSubjects] = useState>(new Set()) const [loaded, setLoaded] = useState(false) const [saving, setSaving] = useState(false) const [status, setStatus] = useState('正在读取权限配置...') const activeTarget = SUPPLY_CHAIN_PERMISSION_TARGETS.find((target) => target.key === activeTargetKey) ?? SUPPLY_CHAIN_PERMISSION_TARGETS[0]! + const activeGroup = SUPPLY_PERMISSION_GROUPS.find((group) => + group.targets.some((target) => target.key === activeTarget.key), + ) ?? SUPPLY_PERMISSION_GROUPS[0] const disabled = !loaded || saving + const managedUsers = usePermissionManagedUsers() + const usersBySubject = useMemo(() => { + const grouped = new Map() + VISIBLE_SUPPLY_PERMISSION_SUBJECTS.forEach(({ subject }) => { + grouped.set( + subject.key, + managedUsers.users.filter((user) => + resolveSupplyChainPermissionSubjectKeys(user).includes(subject.key), + ), + ) + }) + return grouped + }, [managedUsers.users]) useEffect(() => { let cancelled = false @@ -128,11 +247,21 @@ export function SupplyChainPermissionWorkspace() { } } - function toggle(subject: SupplyChainPermissionSubject, action: SupplyChainPermissionAction) { + function toggle( + principalKey: SupplyChainPermissionPrincipalKey, + subjectKeys: readonly SupplyChainPermissionSubject['key'][], + action: SupplyChainPermissionAction, + userId?: string, + ) { if (disabled) return - const access = resolveSupplyChainPermissionAccess(overrides, activeTarget.key, [subject.key]) + const access = resolveSupplyChainPermissionAccess( + overrides, + activeTarget.key, + subjectKeys, + userId, + ) const key = (targetAction: SupplyChainPermissionAction) => - buildSupplyChainPermissionOverrideKey(activeTarget.key, subject.key, targetAction) + buildSupplyChainPermissionOverrideKey(activeTarget.key, principalKey, targetAction) const nextValue = !access[action] const next = { ...overrides, [key(action)]: nextValue } if (action === 'view' && !nextValue) { @@ -145,14 +274,63 @@ export function SupplyChainPermissionWorkspace() { void persist(next) } - function renderSubject(subject: SupplyChainPermissionSubject) { + function toggleExpanded(subjectKey: string) { + setExpandedSubjects((current) => { + const next = new Set(current) + if (next.has(subjectKey)) next.delete(subjectKey) + else next.add(subjectKey) + return next + }) + } + + function renderUser(subject: SupplyChainPermissionSubject, user: PermissionManagedUser) { + const userSubjectKeys = resolveSupplyChainPermissionSubjectKeys(user) + const access = resolveSupplyChainPermissionAccess( + overrides, + activeTarget.key, + userSubjectKeys, + user.id, + ) + return ( + + +
+ {user.name.slice(0, 1)} +

{user.name}

+
+ + 个人例外优先于业务类型 + {(['view', 'download', 'edit'] as SupplyChainPermissionAction[]).map((action) => ( + + toggle(`user@${user.id}`, userSubjectKeys, action, user.id)} + /> + + ))} + + {access.edit ? '可操作' : access.download ? '可查看、下载' : access.view ? '仅查看' : '未开放'} + + + + ) + } + + function renderSubject(display: SupplyPermissionSubjectDisplay) { + const { subject, label, nested } = display const access = resolveSupplyChainPermissionAccess(overrides, activeTarget.key, [subject.key]) const state = access.edit ? '可操作' : access.view ? '仅查看' : '未开放' + const subjectUsers = usersBySubject.get(subject.key) ?? [] + const expanded = expandedSubjects.has(subject.key) return ( - - -

{subject.name}

-

{subject.department}

+ + + +

{label}

toggle(subject, action)} + label={access[action] ? `关闭${label}${SUPPLY_CHAIN_PERMISSION_ACTION_LABELS[action]}` : `开通${label}${SUPPLY_CHAIN_PERMISSION_ACTION_LABELS[action]}`} + onClick={() => toggle(subject.key, [subject.key], action)} /> ))} {access.edit ? '已开通查看和操作。' : access.download ? '已开通查看和下载。' : access.view ? '当前仅可查看。' : '默认不开放。'} + + + + + {expanded ? ( + managedUsers.error ? ( + {managedUsers.error} + ) : subjectUsers.length > 0 ? ( + subjectUsers.map((user) => renderUser(subject, user)) + ) : ( + 该业务类型暂无运行中账号 + ) + ) : null} +
+ ) + } + + function renderExcludedSubject(label: string) { + return ( + + {label} + + 固定排除 + + {(['view', 'download', 'edit'] as SupplyChainPermissionAction[]).map((action) => ( + + undefined} /> + + ))} + 不进入供应链中心。 + — ) } return ( -
-