feat(users): refine permission management

This commit is contained in:
WuYongJie 2026-08-19 13:44:38 +08:00
parent 6178d09e31
commit c0aab147bf
25 changed files with 1171 additions and 222 deletions

View File

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

View File

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

View File

@ -91,6 +91,7 @@ function resolveIntakeFormalWorkspaceAccess(
overrides,
targetKey,
resolveIntakeNodePermissionSubjectKeys(user),
user.id,
);
}

View File

@ -202,9 +202,10 @@ export function useNonIntegratedPermissionState({
nodePermissionOverrides,
targetKey,
currentPermissionSubjectKeys,
currentPermissionUser.id,
);
},
[currentPermissionSubjectKeys, nodePermissionOverrides],
[currentPermissionSubjectKeys, currentPermissionUser.id, nodePermissionOverrides],
);
const activeNodePermissionAccess = getNodePermissionAccessForCategory(
activeDocumentWorkspaceCategory,

View File

@ -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 (
<aside className="flex min-h-0 flex-col border-r border-[var(--obs-shell-border)] bg-[var(--obs-shell-panel-bg)]">
<div className="shrink-0 border-b border-[var(--obs-shell-border)] px-3 py-3">
{onBack ? (
<button
type="button"
onClick={onBack}
className="mb-2 inline-flex items-center gap-1 text-[10px] font-bold text-[var(--obs-shell-muted)] transition hover:text-[var(--obs-shell-title)]"
>
<ChevronLeft className="h-3.5 w-3.5" strokeWidth={1.8} />
{backLabel ?? '返回上一级'}
</button>
) : null}
<p className="text-[12px] font-black text-[var(--obs-shell-title)]">{title}</p>
</div>
<nav className="min-h-0 flex-1 space-y-0.5 overflow-y-auto p-2" aria-label={title}>
{items.map((item) => {
const Icon = item.icon
return (
<button
key={item.key}
type="button"
onClick={() => onSelect(item.key)}
className={`flex w-full items-center gap-2 rounded-[6px] px-2 py-2 text-left text-[11px] font-bold transition ${
item.active
? 'bg-[var(--obs-shell-muted-bg)] text-[var(--obs-shell-title)]'
: 'text-[var(--obs-shell-muted)] hover:bg-[var(--obs-shell-hover-bg)] hover:text-[var(--obs-shell-title)]'
}`}
>
{Icon ? <Icon className="h-3.5 w-3.5 shrink-0" strokeWidth={1.8} /> : null}
<span className="min-w-0 flex-1 truncate">{item.label}</span>
{item.hasChildren ? <ChevronRight className="h-3.5 w-3.5 shrink-0" strokeWidth={1.8} /> : null}
</button>
)
})}
</nav>
</aside>
)
}

View File

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

View File

@ -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<Record<IntakeNodePermissionTargetKey, LucideIcon>> = {
homeDashboard: Home,
@ -41,6 +44,8 @@ const TOP_ENTRY_ICONS: Partial<Record<IntakeNodePermissionTargetKey, LucideIcon>
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<Record<IntakeNodePermissionTargetKey, Luci
brandFiling: ClipboardCheck,
}
function TargetTab({
active,
icon: Icon,
label,
onClick,
}: {
active: boolean
icon: LucideIcon
label: string
onClick: () => void
}) {
return (
<button
type="button"
onClick={onClick}
className={`inline-flex h-9 shrink-0 items-center gap-2 rounded-[5px] px-3 text-[12px] font-bold transition ${
active
? 'bg-[#2d3335] text-[#ffffff]'
: 'text-[var(--obs-shell-muted)] hover:bg-[var(--obs-shell-hover-bg)] hover:text-[var(--obs-shell-title)]'
}`}
>
<Icon className="h-[15px] w-[15px] shrink-0" strokeWidth={1.8} />
<span className="whitespace-nowrap">{label}</span>
</button>
)
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<IntakeNodePermissionOverrideMap>({})
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 (
<div className="grid h-full min-h-0 grid-cols-[minmax(0,1fr)_208px] overflow-hidden bg-[var(--obs-shell-content-bg)]">
<section className="flex min-h-0 min-w-0 flex-col">
<div className="flex h-11 shrink-0 items-center justify-between gap-4 border-b border-[var(--obs-shell-border)] bg-[var(--obs-shell-header-bg)] px-4">
<div>
<p className="text-[12px] font-black text-[var(--obs-shell-title)]">产品开发节点开放权限</p>
<p className="text-[10px] text-[var(--obs-shell-muted)]">按业务对象配置可见、下载和操作范围</p>
</div>
<span className="text-[11px] font-medium text-[var(--obs-shell-muted)]">{status}</span>
<div className={`grid h-full min-h-0 overflow-hidden bg-[var(--obs-shell-content-bg)] ${
navigationLevel ? 'grid-cols-[76px_190px_minmax(0,1fr)]' : 'grid-cols-[76px_minmax(0,1fr)]'
}`}>
<aside className="flex min-h-0 flex-col items-center gap-1 overflow-y-auto border-r border-[var(--obs-shell-border)] bg-[var(--obs-shell-panel-bg)] px-2 py-3">
<div className="mb-1 flex h-8 w-8 items-center justify-center rounded-[8px] bg-[var(--obs-shell-title)] text-white">
<ShieldCheck className="h-4 w-4" strokeWidth={1.8} />
</div>
{TOP_ENTRY_PERMISSION_TARGETS.map((target) => {
const Icon = TOP_ENTRY_ICONS[target.key] ?? Home
const isProductMaterials = target.key === 'formalProducts'
const active = target.key === activeTarget.key ||
(isProductMaterials && isProductMaterialBranch(activeTarget.key))
<div className="flex shrink-0 items-center gap-1 overflow-x-auto border-b border-[var(--obs-shell-border)] bg-white px-3 py-2">
{TOP_ENTRY_PERMISSION_TARGETS.map((target) => (
<TargetTab
key={target.key}
active={target.key === activeTarget.key}
icon={TOP_ENTRY_ICONS[target.key] ?? Home}
label={target.label}
onClick={() => setActiveTargetKey(target.key)}
/>
))}
</div>
<div className="flex shrink-0 items-center gap-1 overflow-x-auto border-b border-[var(--obs-shell-border)] bg-white px-3 py-2">
{FORMAL_WORKSPACE_PERMISSION_TARGETS.map((target) => (
<TargetTab
key={target.key}
active={target.key === activeTarget.key}
icon={FORMAL_WORKSPACE_ICONS[target.key] ?? ClipboardList}
label={target.label}
onClick={() => setActiveTargetKey(target.key)}
/>
))}
</div>
<ProductDevelopmentPermissionTable
target={activeTarget}
overrides={overrides}
disabled={disabled}
onToggle={toggleOverride}
/>
</section>
<aside className="flex min-h-0 flex-col border-l border-[var(--obs-shell-border)] bg-white">
<div className="border-b border-[var(--obs-shell-border)] px-3 py-3">
<p className="text-[12px] font-black text-[var(--obs-shell-title)]">产品开发明细节点</p>
<p className="mt-0.5 text-[10px] text-[var(--obs-shell-muted)]">选择需要配置的流程节点</p>
</div>
<div className="flex min-h-0 flex-1 flex-col gap-1 overflow-y-auto p-2">
{PRODUCT_DEVELOPMENT_PERMISSION_TARGETS.map((target) => {
const active = target.key === activeTarget.key
return (
return (
<div key={target.key} className="w-full">
<button
key={target.key}
type="button"
onClick={() => setActiveTargetKey(target.key)}
className={`relative min-h-9 rounded-[5px] px-3 text-left text-[12px] font-bold transition ${
onClick={() => selectTopTarget(target.key)}
className={`flex w-full flex-col items-center gap-1 rounded-[8px] px-1 py-2 text-center transition ${
active
? 'bg-[#2d3335] text-[#ffffff]'
? 'bg-[var(--obs-shell-muted-bg)] text-[var(--obs-shell-title)]'
: 'text-[var(--obs-shell-muted)] hover:bg-[var(--obs-shell-hover-bg)] hover:text-[var(--obs-shell-title)]'
}`}
aria-label={target.label}
>
{target.label}
<Icon className="h-[18px] w-[18px]" strokeWidth={1.8} />
<span className="max-w-[62px] text-[9px] font-bold leading-3">{target.label}</span>
</button>
)
})}
</div>
</div>
)
})}
</aside>
{navigationLevel === 'secondary' ? (
<PermissionLevelNavigation
title="产品资料"
items={FORMAL_WORKSPACE_PERMISSION_TARGETS.map((target) => ({
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' ? (
<PermissionLevelNavigation
title="产品开发明细节点"
backLabel="返回产品资料"
onBack={() => {
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}
<section className="flex min-h-0 min-w-0 flex-col">
<header className="flex min-h-14 shrink-0 items-center justify-between gap-4 border-b border-[var(--obs-shell-border)] bg-[var(--obs-shell-header-bg)] px-5">
<div className="min-w-0">
<p className="text-[13px] font-black text-[var(--obs-shell-title)]">产品开发节点开放权限</p>
<p className="mt-0.5 truncate text-[10px] text-[var(--obs-shell-muted)]">
左侧一级入口保持显示;当前二级或三级菜单固定在旁边,便于连续配置
</p>
</div>
<div className="flex shrink-0 items-center gap-2 text-[11px] font-medium text-[var(--obs-shell-muted)]">
<span className="h-1.5 w-1.5 rounded-full bg-[#7a9d91]" />
<span>{status}</span>
</div>
</header>
<div className="flex min-h-0 flex-1 flex-col p-3">
<div className="mb-3 flex shrink-0 items-center justify-between rounded-[8px] border border-[var(--obs-shell-border)] bg-[var(--obs-shell-panel-bg)] px-3 py-2.5">
<div className="min-w-0">
<p className="truncate text-[12px] font-black text-[var(--obs-shell-title)]">{activeTarget.label}</p>
<p className="mt-0.5 text-[10px] text-[var(--obs-shell-muted)]">当前配置对象 · 业务岗位可单独设置例外权限</p>
</div>
<span className="shrink-0 rounded-[5px] bg-[var(--obs-shell-muted-bg)] px-2 py-1 text-[10px] font-bold text-[var(--obs-shell-muted)]">
可见 / 下载 / 操作
</span>
</div>
<ProductDevelopmentPermissionTable
target={activeTarget}
overrides={overrides}
disabled={disabled}
users={managedUsers.users}
usersLoading={managedUsers.loading}
usersError={managedUsers.error}
onToggle={toggleOverride}
/>
</div>
</section>
</div>
)
}

View File

@ -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<Record<IntakeNodePermissionSubjectKey, string>> = {
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<Set<string>>(new Set())
const usersBySubject = useMemo(() => {
const grouped = new Map<string, PermissionManagedUser[]>()
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 (
<tr key={`${subject.key}-${user.id}`} className="border-b border-[var(--obs-shell-border)] bg-[var(--obs-shell-surface-bg)] last:border-b-0">
<td className="py-2 pl-10 pr-3">
<div className="flex items-center gap-2">
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-[var(--obs-shell-muted-bg)] text-[10px] font-black text-[var(--obs-shell-muted)]">
{user.name.slice(0, 1)}
</span>
<div className="min-w-0 truncate text-[11px] font-bold text-[var(--obs-shell-title)]">{user.name}</div>
</div>
</td>
<td className="px-3 py-2 text-[10px] text-[var(--obs-shell-muted)]">个人例外优先于业务类型</td>
{(['view', 'download', 'edit'] as IntakeNodePermissionAction[]).map((action) => (
<td key={action} className="px-3 py-2 text-center">
<PermissionToggle
checked={access[action]}
disabled={disabled}
label={`${user.name}${action === 'view' ? '可见' : action === 'download' ? '下载' : '操作'}`}
onChange={() => onToggle(`user@${user.id}`, action, userSubjectKeys, user.id)}
/>
</td>
))}
<td className="px-3 py-2 text-[10px] text-[var(--obs-shell-muted)]">
{access.edit ? '可操作' : access.download ? '可查看、下载' : access.view ? '仅查看' : '未开放'}
</td>
<td />
</tr>
)
}
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 (
<tr key={subject.key} className="border-b border-[var(--obs-shell-border)] last:border-b-0">
<td className="px-3 py-2.5">
<div className="text-[12px] font-bold text-[var(--obs-shell-title)]">{subject.name}</div>
<div className="mt-0.5 text-[11px] text-[var(--obs-shell-muted)]">{subject.department}</div>
<Fragment key={subject.key}>
<tr className={`border-b border-[var(--obs-shell-border)] last:border-b-0 ${
expanded ? 'sticky top-[33px] z-[6] bg-[var(--obs-shell-panel-bg)]' : ''
}`}>
<td className={`py-2.5 pr-3 ${nested ? 'pl-8' : 'pl-3'}`}>
<div className="text-[12px] font-bold text-[var(--obs-shell-title)]">{label}</div>
</td>
<td className="px-3 py-2.5">
<div className="flex flex-wrap items-center gap-1.5">
@ -96,24 +236,24 @@ export function ProductDevelopmentPermissionTable({
<PermissionToggle
checked={access.view}
disabled={disabled}
label={access.view ? `关闭${subject.name}可见` : `开通${subject.name}可见`}
onChange={() => onToggle(subject.key, 'view')}
label={access.view ? `关闭${label}可见` : `开通${label}可见`}
onChange={() => onToggle(subject.key, 'view', [subject.key])}
/>
</td>
<td className="px-3 py-2.5 text-center">
<PermissionToggle
checked={access.download}
disabled={disabled}
label={access.download ? `关闭${subject.name}下载` : `开通${subject.name}下载`}
onChange={() => onToggle(subject.key, 'download')}
label={access.download ? `关闭${label}下载` : `开通${label}下载`}
onChange={() => onToggle(subject.key, 'download', [subject.key])}
/>
</td>
<td className="px-3 py-2.5 text-center">
<PermissionToggle
checked={access.edit}
disabled={disabled}
label={access.edit ? `关闭${subject.name}操作` : `开通${subject.name}操作`}
onChange={() => onToggle(subject.key, 'edit')}
label={access.edit ? `关闭${label}操作` : `开通${label}操作`}
onChange={() => onToggle(subject.key, 'edit', [subject.key])}
/>
</td>
<td className="px-3 py-2.5 text-[11px] text-[var(--obs-shell-muted)]">
@ -125,41 +265,50 @@ export function ProductDevelopmentPermissionTable({
: '当前仅可查看。'
: '当前已关闭。'}
</td>
<td className="px-3 py-2.5 text-right">
<button
type="button"
onClick={() => toggleExpanded(subject.key)}
className="inline-flex h-7 items-center gap-1 rounded-[5px] border border-[var(--obs-shell-border)] bg-[var(--obs-shell-panel-bg)] px-2 text-[10px] font-bold text-[var(--obs-shell-muted)] transition hover:bg-[var(--obs-shell-hover-bg)] hover:text-[var(--obs-shell-title)]"
aria-expanded={expanded}
>
<Users className="h-3.5 w-3.5" strokeWidth={1.8} />
<span>{usersLoading ? '读取中' : `${subjectUsers.length} 人`}</span>
<ChevronDown className={`h-3.5 w-3.5 transition ${expanded ? 'rotate-180' : ''}`} strokeWidth={1.8} />
</button>
</td>
</tr>
{expanded ? (
usersError ? (
<tr><td colSpan={7} className="bg-[var(--obs-shell-surface-bg)] px-10 py-3 text-[11px] text-red-600">{usersError}</td></tr>
) : subjectUsers.length > 0 ? (
subjectUsers.map((user) => renderUserRow(subject, user))
) : (
<tr><td colSpan={7} className="bg-[var(--obs-shell-surface-bg)] px-10 py-3 text-[11px] text-[var(--obs-shell-muted)]">该业务类型暂无运行中账号</td></tr>
)
) : null}
</Fragment>
)
}
return (
<div className="min-h-0 flex-1 overflow-auto border-y border-[var(--obs-shell-border)] bg-white">
<table className="w-full min-w-[920px] border-collapse text-xs">
<table className="w-full min-w-[1040px] border-collapse text-xs">
<thead className="sticky top-0 z-10 bg-[#f7f8f8] text-[var(--obs-shell-muted)]">
<tr>
<th className="border-b border-[var(--obs-shell-border)] px-3 py-2 text-left font-bold">对象</th>
<th className="border-b border-[var(--obs-shell-border)] px-3 py-2 text-left font-bold">业务类型</th>
<th className="border-b border-[var(--obs-shell-border)] px-3 py-2 text-left font-bold">权限状态</th>
<th className="border-b border-[var(--obs-shell-border)] px-3 py-2 text-center font-bold">可见</th>
<th className="border-b border-[var(--obs-shell-border)] px-3 py-2 text-center font-bold">下载</th>
<th className="border-b border-[var(--obs-shell-border)] px-3 py-2 text-center font-bold">操作</th>
<th className="border-b border-[var(--obs-shell-border)] px-3 py-2 text-left font-bold">说明</th>
<th className="border-b border-[var(--obs-shell-border)] px-3 py-2 text-right font-bold">人员</th>
</tr>
</thead>
<tbody>
<tr className="border-b border-[var(--obs-shell-border)] bg-[#fafbfb]">
<td colSpan={6} className="px-3 py-2 text-[11px] font-bold text-[var(--obs-shell-muted)]">核心对象</td>
</tr>
{CORE_NODE_PERMISSION_SUBJECTS.map(renderSubjectRow)}
<tr className="border-b border-[var(--obs-shell-border)] bg-[#fafbfb]">
<td colSpan={6} className="px-3 py-2">
<button
type="button"
onClick={() => setOrdinaryExpanded((current) => !current)}
className="flex w-full items-center justify-between text-left text-[11px] font-bold text-[var(--obs-shell-muted)]"
>
<span>普通业务</span>
<span>{ordinaryExpanded ? '收起' : '展开'}</span>
</button>
</td>
</tr>
{ordinaryExpanded ? ORDINARY_NODE_PERMISSION_SUBJECTS.map(renderSubjectRow) : null}
{PRIMARY_PERMISSION_SUBJECTS.map(renderSubjectRow)}
{PURCHASE_PERMISSION_SUBJECTS.map(renderSubjectRow)}
{OTHER_PERMISSION_SUBJECTS.map(renderSubjectRow)}
</tbody>
</table>
</div>

View File

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

View File

@ -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<SupplyChainPermissionTargetKey, typeof LayoutDashboard> = {
workspace: LayoutDashboard,
@ -30,6 +34,104 @@ const TARGET_ICONS: Record<SupplyChainPermissionTargetKey, typeof LayoutDashboar
operationLogs: ClipboardList,
}
const SUPPLY_PERMISSION_GROUPS = [
{
key: 'entry',
label: '供应链入口',
description: '供应链中心入口与工作台总览',
icon: LayoutDashboard,
targets: SUPPLY_CHAIN_PERMISSION_TARGETS.filter((target) => ['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<Record<SupplyChainPermissionSubject['key'], string>> = {
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<SupplyChainPermissionTargetKey>('workspace')
const [overrides, setOverrides] = useState<SupplyChainPermissionOverrideMap>({})
const [expandedSubjects, setExpandedSubjects] = useState<Set<string>>(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<string, PermissionManagedUser[]>()
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 (
<tr key={`${subject.key}-${user.id}`} className="border-b border-[var(--obs-shell-border)] bg-[var(--obs-shell-surface-bg)] last:border-b-0">
<td className="py-2 pl-10 pr-3">
<div className="flex items-center gap-2">
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-[var(--obs-shell-muted-bg)] text-[10px] font-black text-[var(--obs-shell-muted)]">{user.name.slice(0, 1)}</span>
<p className="min-w-0 truncate text-[11px] font-bold text-[var(--obs-shell-title)]">{user.name}</p>
</div>
</td>
<td className="px-3 py-2 text-[10px] text-[var(--obs-shell-muted)]">个人例外优先于业务类型</td>
{(['view', 'download', 'edit'] as SupplyChainPermissionAction[]).map((action) => (
<td key={action} className="px-3 py-2 text-center">
<Toggle
checked={access[action]}
disabled={disabled}
label={`${user.name}${SUPPLY_CHAIN_PERMISSION_ACTION_LABELS[action]}`}
onClick={() => toggle(`user@${user.id}`, userSubjectKeys, action, user.id)}
/>
</td>
))}
<td className="px-3 py-2 text-[10px] text-[var(--obs-shell-muted)]">
{access.edit ? '可操作' : access.download ? '可查看、下载' : access.view ? '仅查看' : '未开放'}
</td>
<td />
</tr>
)
}
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 (
<tr key={subject.key} className="border-b border-[var(--obs-shell-border)] last:border-b-0">
<td className="px-3 py-2.5">
<p className="text-[12px] font-bold text-[var(--obs-shell-title)]">{subject.name}</p>
<p className="mt-0.5 text-[11px] text-[var(--obs-shell-muted)]">{subject.department}</p>
<Fragment key={subject.key}>
<tr className={`border-b border-[var(--obs-shell-border)] last:border-b-0 ${
expanded ? 'sticky top-[33px] z-[6] bg-[var(--obs-shell-panel-bg)]' : ''
}`}>
<td className={`py-2.5 pr-3 ${nested ? 'pl-8' : 'pl-3'}`}>
<p className="text-[12px] font-bold text-[var(--obs-shell-title)]">{label}</p>
</td>
<td className="px-3 py-2.5">
<span className={`inline-flex h-6 items-center rounded-[4px] px-2 text-[11px] font-bold ${
@ -164,73 +342,138 @@ export function SupplyChainPermissionWorkspace() {
<Toggle
checked={access[action]}
disabled={disabled}
label={access[action] ? `关闭${subject.name}${SUPPLY_CHAIN_PERMISSION_ACTION_LABELS[action]}` : `开通${subject.name}${SUPPLY_CHAIN_PERMISSION_ACTION_LABELS[action]}`}
onClick={() => 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)}
/>
</td>
))}
<td className="px-3 py-2.5 text-[11px] text-[var(--obs-shell-muted)]">
{access.edit ? '已开通查看和操作。' : access.download ? '已开通查看和下载。' : access.view ? '当前仅可查看。' : '默认不开放。'}
</td>
<td className="px-3 py-2.5 text-right">
<button
type="button"
onClick={() => toggleExpanded(subject.key)}
className="inline-flex h-7 items-center gap-1 rounded-[5px] border border-[var(--obs-shell-border)] bg-[var(--obs-shell-panel-bg)] px-2 text-[10px] font-bold text-[var(--obs-shell-muted)] transition hover:bg-[var(--obs-shell-hover-bg)] hover:text-[var(--obs-shell-title)]"
aria-expanded={expanded}
>
<Users className="h-3.5 w-3.5" strokeWidth={1.8} />
<span>{managedUsers.loading ? '读取中' : `${subjectUsers.length} 人`}</span>
<ChevronDown className={`h-3.5 w-3.5 transition ${expanded ? 'rotate-180' : ''}`} strokeWidth={1.8} />
</button>
</td>
</tr>
{expanded ? (
managedUsers.error ? (
<tr><td colSpan={7} className="bg-[var(--obs-shell-surface-bg)] px-10 py-3 text-[11px] text-red-600">{managedUsers.error}</td></tr>
) : subjectUsers.length > 0 ? (
subjectUsers.map((user) => renderUser(subject, user))
) : (
<tr><td colSpan={7} className="bg-[var(--obs-shell-surface-bg)] px-10 py-3 text-[11px] text-[var(--obs-shell-muted)]">该业务类型暂无运行中账号</td></tr>
)
) : null}
</Fragment>
)
}
function renderExcludedSubject(label: string) {
return (
<tr key={label} className="border-b border-[var(--obs-shell-border)] last:border-b-0">
<td className="px-3 py-2.5 text-[12px] font-bold text-[var(--obs-shell-title)]">{label}</td>
<td className="px-3 py-2.5">
<span className="inline-flex h-6 items-center rounded-[4px] bg-[#f4f5f5] px-2 text-[11px] font-bold text-[#9aa2a4]">固定排除</span>
</td>
{(['view', 'download', 'edit'] as SupplyChainPermissionAction[]).map((action) => (
<td key={action} className="px-3 py-2.5 text-center">
<Toggle checked={false} disabled label={`${label}固定排除`} onClick={() => undefined} />
</td>
))}
<td className="px-3 py-2.5 text-[11px] text-[var(--obs-shell-muted)]">不进入供应链中心。</td>
<td className="px-3 py-2.5 text-right text-[11px] text-[var(--obs-shell-subtle)]">—</td>
</tr>
)
}
return (
<div className="grid h-full min-h-0 grid-cols-[216px_minmax(0,1fr)] overflow-hidden bg-[var(--obs-shell-content-bg)]">
<aside className="flex min-h-0 flex-col border-r border-[var(--obs-shell-border)] bg-white">
<div className="border-b border-[var(--obs-shell-border)] px-3 py-3">
<p className="text-[12px] font-black text-[var(--obs-shell-title)]">供应链开发权限</p>
<p className="mt-0.5 text-[10px] text-[var(--obs-shell-muted)]">供应商和外部录入员固定排除</p>
<div className="grid h-full min-h-0 grid-cols-[76px_190px_minmax(0,1fr)] overflow-hidden bg-[var(--obs-shell-content-bg)]">
<aside className="flex min-h-0 flex-col items-center gap-2 overflow-y-auto border-r border-[var(--obs-shell-border)] bg-[var(--obs-shell-panel-bg)] px-2 py-3">
<div className="mb-1 flex h-8 w-8 items-center justify-center rounded-[8px] bg-[var(--obs-shell-title)] text-white">
<PackagePlus className="h-4 w-4" strokeWidth={1.8} />
</div>
<div className="min-h-0 flex-1 space-y-1 overflow-y-auto p-2">
{SUPPLY_CHAIN_PERMISSION_TARGETS.map((target) => {
const Icon = TARGET_ICONS[target.key]
const active = target.key === activeTarget.key
return (
{SUPPLY_PERMISSION_GROUPS.map((group) => {
const GroupIcon = group.icon
const hasActiveTarget = group.targets.some((target) => target.key === activeTarget.key)
return (
<div key={group.key} className="w-full">
<button
key={target.key}
type="button"
onClick={() => setActiveTargetKey(target.key)}
className={`flex min-h-9 w-full items-center gap-2 rounded-[5px] px-3 text-left text-[12px] font-bold transition ${
active ? 'bg-[#2d3335] text-white' : 'text-[var(--obs-shell-muted)] hover:bg-[var(--obs-shell-hover-bg)] hover:text-[var(--obs-shell-title)]'
onClick={() => setActiveTargetKey(group.targets[0]!.key)}
className={`flex w-full flex-col items-center gap-1 rounded-[8px] px-1 py-2 text-center transition ${
hasActiveTarget
? 'bg-[var(--obs-shell-muted-bg)] text-[var(--obs-shell-title)]'
: 'text-[var(--obs-shell-muted)] hover:bg-[var(--obs-shell-hover-bg)] hover:text-[var(--obs-shell-title)]'
}`}
aria-label={group.label}
>
<Icon className="h-4 w-4 shrink-0" strokeWidth={1.8} />
<span className="min-w-0 truncate">{target.label}</span>
<GroupIcon className="h-5 w-5" strokeWidth={1.8} />
<span className="whitespace-nowrap text-[10px] font-bold">{group.label}</span>
</button>
)
})}
</div>
</div>
)
})}
</aside>
<PermissionLevelNavigation
title={activeGroup.label}
items={activeGroup.targets.map((target) => ({
key: target.key,
label: target.label,
icon: TARGET_ICONS[target.key],
active: target.key === activeTarget.key,
}))}
onSelect={(key) => setActiveTargetKey(key as SupplyChainPermissionTargetKey)}
/>
<section className="flex min-h-0 min-w-0 flex-col">
<header className="flex min-h-12 shrink-0 items-center justify-between gap-4 border-b border-[var(--obs-shell-border)] bg-[var(--obs-shell-header-bg)] px-4">
<header className="flex min-h-14 shrink-0 items-center justify-between gap-4 border-b border-[var(--obs-shell-border)] bg-[var(--obs-shell-header-bg)] px-5">
<div className="min-w-0">
<p className="text-[12px] font-black text-[var(--obs-shell-title)]">{activeTarget.label}</p>
<p className="truncate text-[10px] text-[var(--obs-shell-muted)]">{activeTarget.description}</p>
<p className="text-[13px] font-black text-[var(--obs-shell-title)]">供应链开发权限</p>
<p className="mt-0.5 truncate text-[10px] text-[var(--obs-shell-muted)]">供应商和外部录入员固定排除;当前模块页面固定显示在左侧二级栏</p>
</div>
<div className="flex shrink-0 items-center gap-2 text-[11px] font-medium text-[var(--obs-shell-muted)]">
<span className="h-1.5 w-1.5 rounded-full bg-[#7a9d91]" />
<span>{status}</span>
</div>
<span className="shrink-0 text-[11px] font-medium text-[var(--obs-shell-muted)]">{status}</span>
</header>
<div className="min-h-0 flex-1 overflow-auto bg-white">
<table className="w-full min-w-[820px] border-collapse text-xs">
<thead className="sticky top-0 z-10 bg-[#f7f8f8] text-[var(--obs-shell-muted)]">
<tr>
<th className="border-b border-[var(--obs-shell-border)] px-3 py-2 text-left font-bold">业务类型</th>
<th className="border-b border-[var(--obs-shell-border)] px-3 py-2 text-left font-bold">当前状态</th>
<th className="border-b border-[var(--obs-shell-border)] px-3 py-2 text-center font-bold">可见</th>
<th className="border-b border-[var(--obs-shell-border)] px-3 py-2 text-center font-bold">下载</th>
<th className="border-b border-[var(--obs-shell-border)] px-3 py-2 text-center font-bold">操作</th>
<th className="border-b border-[var(--obs-shell-border)] px-3 py-2 text-left font-bold">说明</th>
</tr>
</thead>
<tbody>
<tr className="border-b border-[var(--obs-shell-border)] bg-[#fafbfb]"><td colSpan={6} className="px-3 py-2 text-[11px] font-bold text-[var(--obs-shell-muted)]">核心业务类型</td></tr>
{SUPPLY_CHAIN_PERMISSION_CORE_SUBJECTS.map(renderSubject)}
<tr className="border-b border-[var(--obs-shell-border)] bg-[#fafbfb]"><td colSpan={6} className="px-3 py-2 text-[11px] font-bold text-[var(--obs-shell-muted)]">其他业务类型</td></tr>
{SUPPLY_CHAIN_PERMISSION_ORDINARY_SUBJECTS.map(renderSubject)}
</tbody>
</table>
<div className="flex min-h-0 flex-1 flex-col overflow-hidden p-3">
<div className="mb-3 flex shrink-0 items-center justify-between rounded-[8px] border border-[var(--obs-shell-border)] bg-[var(--obs-shell-panel-bg)] px-3 py-2.5">
<div className="min-w-0">
<p className="truncate text-[12px] font-black text-[var(--obs-shell-title)]">{activeTarget.label}</p>
<p className="mt-0.5 text-[10px] text-[var(--obs-shell-muted)]">{activeTarget.description} · 业务岗位可单独设置例外权限</p>
</div>
<span className="shrink-0 rounded-[5px] bg-[var(--obs-shell-muted-bg)] px-2 py-1 text-[10px] font-bold text-[var(--obs-shell-muted)]">可见 / 下载 / 操作</span>
</div>
<div className="min-h-0 flex-1 overflow-auto rounded-[8px] border border-[var(--obs-shell-border)] bg-[var(--obs-shell-panel-bg)]">
<table className="w-full min-w-[1040px] border-collapse text-xs">
<thead className="sticky top-0 z-10 bg-[var(--obs-shell-surface-bg)] text-[var(--obs-shell-muted)]">
<tr>
<th className="border-b border-[var(--obs-shell-border)] px-3 py-2 text-left font-bold">业务类型</th>
<th className="border-b border-[var(--obs-shell-border)] px-3 py-2 text-left font-bold">当前状态</th>
<th className="border-b border-[var(--obs-shell-border)] px-3 py-2 text-center font-bold">可见</th>
<th className="border-b border-[var(--obs-shell-border)] px-3 py-2 text-center font-bold">下载</th>
<th className="border-b border-[var(--obs-shell-border)] px-3 py-2 text-center font-bold">操作</th>
<th className="border-b border-[var(--obs-shell-border)] px-3 py-2 text-left font-bold">说明</th>
<th className="border-b border-[var(--obs-shell-border)] px-3 py-2 text-right font-bold">人员</th>
</tr>
</thead>
<tbody>
{PRIMARY_SUPPLY_PERMISSION_SUBJECTS.map(renderSubject)}
{PURCHASE_SUPPLY_PERMISSION_SUBJECTS.map(renderSubject)}
{OTHER_SUPPLY_PERMISSION_SUBJECTS.map(renderSubject)}
{['供应商', '外部人员'].map(renderExcludedSubject)}
</tbody>
</table>
</div>
</div>
</section>
</div>

View File

@ -0,0 +1,114 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import { getApiErrorMessage } from '@/lib/utils'
export type PermissionManagedUser = {
id: string
name: string
email: string
role: string
status: string
businessRoles?: string[]
department?: { id?: string; name?: string; fullPath?: string } | null
departmentPaths?: string[]
}
type OrganizationDepartment = {
id: string
name: string
fullPath: string
users?: PermissionManagedUser[]
teams?: Array<{ users?: PermissionManagedUser[] }>
children?: OrganizationDepartment[]
}
type OrganizationTreePayload = {
departments?: OrganizationDepartment[]
unassignedUsers?: PermissionManagedUser[]
}
function attachDepartment(
user: PermissionManagedUser,
department: OrganizationDepartment,
): PermissionManagedUser {
return {
...user,
department: user.department ?? {
id: department.id,
name: department.name,
fullPath: department.fullPath,
},
departmentPaths: Array.from(new Set([
...(user.departmentPaths ?? []),
department.fullPath,
].filter(Boolean))),
}
}
function flattenDepartmentUsers(departments: OrganizationDepartment[]) {
const users: PermissionManagedUser[] = []
function visit(department: OrganizationDepartment) {
department.users?.forEach((user) => users.push(attachDepartment(user, department)))
department.teams?.forEach((team) => {
team.users?.forEach((user) => users.push(attachDepartment(user, department)))
})
department.children?.forEach(visit)
}
departments.forEach(visit)
return users
}
export function usePermissionManagedUsers() {
const [users, setUsers] = useState<PermissionManagedUser[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let active = true
void (async () => {
try {
setLoading(true)
setError(null)
const response = await fetch('/api/users/organization-tree?status=ACTIVE', {
cache: 'no-store',
credentials: 'include',
})
const payload = await response.json().catch(() => null) as {
success?: boolean
data?: OrganizationTreePayload
error?: unknown
} | null
if (!response.ok || !payload?.data) {
throw new Error(getApiErrorMessage(payload?.error, '人员列表读取失败'))
}
if (!active) return
const merged = [
...flattenDepartmentUsers(payload.data.departments ?? []),
...(payload.data.unassignedUsers ?? []),
]
const uniqueUsers = Array.from(
new Map(merged.map((user) => [user.id, user])).values(),
).sort((left, right) => left.name.localeCompare(right.name, 'zh-CN'))
setUsers(uniqueUsers)
} catch (loadError) {
if (active) {
setUsers([])
setError(loadError instanceof Error ? loadError.message : '人员列表读取失败')
}
} finally {
if (active) setLoading(false)
}
})()
return () => {
active = false
}
}, [])
return useMemo(() => ({ users, loading, error }), [error, loading, users])
}

View File

@ -20,6 +20,7 @@ export interface AiRenderWorkbenchAccess {
}
export type AiRenderWorkbenchPermissionUser = ScopedRoleUser & {
id?: string | null
supplierCode?: string | null
}
@ -52,6 +53,7 @@ export function getAiRenderWorkbenchAccess(
overrides,
'aiRenderWorkbench',
resolveIntakeNodePermissionSubjectKeys(user),
user.id,
)
if (!nodeAccess.view && !nodeAccess.download && !nodeAccess.edit) {
return {

View File

@ -14,6 +14,7 @@ describe('BOM 核算权限', () => {
const user = {
role: 'USER',
businessRoles: [businessRole],
workspaceAllowedPathPrefixes: [BOM_COSTING_WORKBENCH_PATH],
}
expect(canViewBomCostingWorkbench(user)).toBe(true)
expect(canAccessWorkspacePath(BOM_COSTING_WORKBENCH_PATH, user)).toBe(true)
@ -28,7 +29,6 @@ describe('BOM 核算权限', () => {
canAccessWorkspacePath(BOM_COSTING_WORKBENCH_PATH, {
role: 'USER',
businessRoles: ['STRATEGY'],
workspaceAllowedPathPrefixes: [BOM_COSTING_WORKBENCH_PATH],
}),
).toBe(false)
})

View File

@ -19,6 +19,7 @@ export interface InstallationDemoWorkbenchAccess {
}
export type InstallationDemoWorkbenchPermissionUser = ScopedRoleUser & {
id?: string | null
supplierCode?: string | null
}
@ -77,6 +78,7 @@ export function getInstallationDemoWorkbenchAccess(
overrides,
'installationDemoWorkbench',
resolveIntakeNodePermissionSubjectKeys(user),
user.id,
)
if (nodeAccess.edit) {
return {

View File

@ -358,4 +358,53 @@ describe('intake node permission settings', () => {
edit: false,
})
})
it('applies personal overrides after business type permissions', () => {
const userId = 'user-123'
const subjectKeys = ['purchase'] as const
const overrides = sanitizeIntakeNodePermissionOverrides({
[buildIntakeNodePermissionOverrideKey('bomCostingWorkbench', 'purchase', 'view')]: true,
[buildIntakeNodePermissionOverrideKey('bomCostingWorkbench', `user@${userId}`, 'view')]: false,
})
expect(
resolveIntakeNodePermissionAccess(
overrides,
'bomCostingWorkbench',
subjectKeys,
userId,
),
).toMatchObject({ view: false, download: false, edit: false })
expect(
resolveIntakeNodePermissionAccess(
overrides,
'bomCostingWorkbench',
subjectKeys,
'another-user',
),
).toMatchObject({ view: true })
})
it('lets a personal edit override open a closed business type', () => {
const userId = 'user-456'
const overrides = sanitizeIntakeNodePermissionOverrides({
[buildIntakeNodePermissionOverrideKey('warehouseManage', `user@${userId}`, 'edit')]: true,
})
expect(
resolveIntakeNodePermissionAccess(overrides, 'warehouseManage', ['viewer'], userId),
).toMatchObject({ view: true, edit: true })
})
it('accepts only valid personal permission principal keys', () => {
expect(
sanitizeIntakeNodePermissionOverrides({
'warehouseManage:user@valid-user_1:view': true,
'warehouseManage:user@invalid:user:view': true,
'warehouseManage:user@invalid user:view': true,
}),
).toEqual({
'warehouseManage:user@valid-user_1:view': true,
})
})
})

View File

@ -36,6 +36,8 @@ export type IntakeNodePermissionTargetKey =
| "assistedAssembly"
| "draftGroups"
| "formalProducts"
| "bomCostingWorkbench"
| "oaNewProductBoard"
| "warehouseManage"
| "warehouseQuality"
| "aiRenderWorkbench"
@ -84,6 +86,9 @@ export type IntakeNodePermissionTarget = {
};
export type IntakeNodePermissionOverrideMap = Record<string, boolean>;
export type IntakeNodePermissionPrincipalKey =
| IntakeNodePermissionSubjectKey
| `user@${string}`;
const ALL_SUBJECT_KEYS: IntakeNodePermissionSubjectKey[] = [
"supplier",
@ -110,6 +115,8 @@ const ALL_TARGET_KEYS: IntakeNodePermissionTargetKey[] = [
"assistedAssembly",
"draftGroups",
"formalProducts",
"bomCostingWorkbench",
"oaNewProductBoard",
"warehouseManage",
"warehouseQuality",
"aiRenderWorkbench",
@ -396,6 +403,26 @@ export const TOP_ENTRY_PERMISSION_TARGETS: IntakeNodePermissionTarget[] = [
externalIntake: "edit",
},
},
{
key: "bomCostingWorkbench",
label: "BOM核算",
defaults: {
...NODE_PERMISSION_HIDDEN,
design: "readonly",
planning: "readonly",
purchase: "readonly",
operation: "readonly",
},
},
{
key: "oaNewProductBoard",
label: "OA 新品流程看板",
defaults: {
...NODE_PERMISSION_FORMAL_PRODUCTS_DEFAULT,
customerService: "readonly",
externalIntake: "edit",
},
},
{
key: "warehouseManage",
label: "仓库管理",
@ -438,6 +465,8 @@ const TOP_ENTRY_PATH_PREFIXES: Partial<
assistedAssembly: ["/assisted-assembly"],
draftGroups: ["/draft-groups"],
formalProducts: ["/intake"],
bomCostingWorkbench: ["/bom-costing"],
oaNewProductBoard: ["/intake/oa-board"],
warehouseManage: ["/warehouse"],
warehouseQuality: ["/warehouse-quality"],
aiRenderWorkbench: ["/ai-render-workbench"],
@ -893,22 +922,31 @@ export function getIntakeNodePermissionTargetKeyByWorkspaceCategory(
export function buildIntakeNodePermissionOverrideKey(
targetKey: IntakeNodePermissionTargetKey,
subjectKey: IntakeNodePermissionSubjectKey,
subjectKey: IntakeNodePermissionPrincipalKey,
action: IntakeNodePermissionAction,
) {
return `${targetKey}:${subjectKey}:${action}`;
}
export function buildIntakeNodePermissionUserKey(userId: string) {
return `user@${userId.trim()}` as const;
}
function isIntakeNodePermissionPrincipalKey(value: string) {
if (SUBJECT_KEY_SET.has(value)) return true;
return /^user@[A-Za-z0-9_-]+$/.test(value);
}
export function parseIntakeNodePermissionOverrideKey(key: string) {
const [targetKey, subjectKey, action, ...rest] = key.split(":");
if (rest.length > 0) return null;
if (!targetKey || !TARGET_KEY_SET.has(targetKey)) return null;
if (!subjectKey || !SUBJECT_KEY_SET.has(subjectKey)) return null;
if (!subjectKey || !isIntakeNodePermissionPrincipalKey(subjectKey)) return null;
if (!action || !ACTION_SET.has(action)) return null;
return {
targetKey: targetKey as IntakeNodePermissionTargetKey,
subjectKey: subjectKey as IntakeNodePermissionSubjectKey,
subjectKey: subjectKey as IntakeNodePermissionPrincipalKey,
action: action as IntakeNodePermissionAction,
};
}
@ -1071,6 +1109,7 @@ export function resolveIntakeNodePermissionAccess(
overrides: IntakeNodePermissionOverrideMap,
targetKey: IntakeNodePermissionTargetKey | string | null | undefined,
subjectKeys: readonly IntakeNodePermissionSubjectKey[],
userId?: string | null,
) {
const target =
targetKey && TARGET_KEY_SET.has(targetKey)
@ -1170,6 +1209,32 @@ export function resolveIntakeNodePermissionAccess(
overrideEdit ||= subjectEditOverride === true;
}
const normalizedUserId = userId?.trim();
if (normalizedUserId) {
const userKey = buildIntakeNodePermissionUserKey(normalizedUserId);
const personalView = overrides[
buildIntakeNodePermissionOverrideKey(target.key, userKey, "view")
];
const personalDownload = overrides[
buildIntakeNodePermissionOverrideKey(target.key, userKey, "download")
];
const personalEdit = overrides[
buildIntakeNodePermissionOverrideKey(target.key, userKey, "edit")
];
if (personalView !== undefined) view = personalView;
if (personalDownload !== undefined) download = personalDownload;
if (personalEdit !== undefined) edit = personalEdit;
if (edit || download) view = true;
if (!view) {
download = false;
edit = false;
}
overrideView ||= personalView === true;
overrideDownload ||= personalDownload === true;
overrideEdit ||= personalEdit === true;
}
return {
defaultState,
defaultView,
@ -1201,6 +1266,7 @@ export function resolveIntakeTopEntryAllowedPathPrefixes(
overrides,
target.key,
subjectKeys,
user.id,
);
if (!access.view && !access.download && !access.edit) {
return;

View File

@ -7,6 +7,7 @@ import {
isSupplyChainExcludedUser,
resolveSupplyChainPermissionAccess,
resolveSupplyChainPermissionSubjectKeys,
sanitizeSupplyChainPermissionOverrides,
} from './supply-chain-permission-settings'
describe('supply chain permission settings', () => {
@ -109,4 +110,42 @@ describe('supply chain permission settings', () => {
getSupplyChainPermissionTargetForPath('/supply-chain/new-products/sample-progress'),
).toBe('newProductSampleProgress')
})
it('applies personal overrides after business type permissions', () => {
const userId = 'user-123'
const overrides = {
[buildSupplyChainPermissionOverrideKey('workspace', 'purchase', 'view')]: true,
[buildSupplyChainPermissionOverrideKey('workspace', `user@${userId}`, 'view')]: false,
}
expect(
resolveSupplyChainPermissionAccess(overrides, 'workspace', ['purchase'], userId),
).toEqual({ view: false, download: false, edit: false })
expect(
resolveSupplyChainPermissionAccess(overrides, 'workspace', ['purchase'], 'another-user'),
).toEqual({ view: true, download: false, edit: false })
})
it('lets a personal edit override open a closed business type', () => {
const userId = 'user-456'
const overrides = {
[buildSupplyChainPermissionOverrideKey('productArchive', `user@${userId}`, 'edit')]: true,
}
expect(
resolveSupplyChainPermissionAccess(overrides, 'productArchive', ['viewer'], userId),
).toEqual({ view: true, download: false, edit: true })
})
it('accepts only valid personal permission principal keys', () => {
expect(
sanitizeSupplyChainPermissionOverrides({
'workspace:user@valid-user_1:view': true,
'workspace:user@invalid:user:view': true,
'workspace:user@invalid user:view': true,
}),
).toEqual({
'workspace:user@valid-user_1:view': true,
})
})
})

View File

@ -40,8 +40,12 @@ export type SupplyChainPermissionTarget = {
}
export type SupplyChainPermissionOverrideMap = Record<string, boolean>
export type SupplyChainPermissionPrincipalKey =
| SupplyChainPermissionSubjectKey
| `user@${string}`
export type SupplyChainPermissionUser = {
id?: string | null
role?: string | null
businessRoles?: readonly string[] | null
supplierCode?: string | null
@ -157,19 +161,30 @@ export function getSupplyChainRoleDefaultAccess(user: SupplyChainPermissionUser)
export function buildSupplyChainPermissionOverrideKey(
targetKey: SupplyChainPermissionTargetKey,
subjectKey: SupplyChainPermissionSubjectKey,
subjectKey: SupplyChainPermissionPrincipalKey,
action: SupplyChainPermissionAction,
) {
return `${targetKey}:${subjectKey}:${action}`
}
export function buildSupplyChainPermissionUserKey(userId: string) {
return `user@${userId.trim()}` as const
}
function isSupplyChainPermissionPrincipalKey(value: string) {
if (SUPPLY_CHAIN_PERMISSION_SUBJECT_KEYS.has(value as SupplyChainPermissionSubjectKey)) {
return true
}
return /^user@[A-Za-z0-9_-]+$/.test(value)
}
export function parseSupplyChainPermissionOverrideKey(value: string) {
const [targetKey, subjectKey, action, ...rest] = value.split(':')
if (rest.length > 0) return null
if (!targetKey || !SUPPLY_CHAIN_PERMISSION_TARGET_KEY_SET.has(targetKey as SupplyChainPermissionTargetKey)) {
return null
}
if (!subjectKey || !SUPPLY_CHAIN_PERMISSION_SUBJECT_KEYS.has(subjectKey as SupplyChainPermissionSubjectKey)) {
if (!subjectKey || !isSupplyChainPermissionPrincipalKey(subjectKey)) {
return null
}
if (!action || !SUPPLY_CHAIN_PERMISSION_ACTION_SET.has(action as SupplyChainPermissionAction)) {
@ -178,7 +193,7 @@ export function parseSupplyChainPermissionOverrideKey(value: string) {
return {
targetKey: targetKey as SupplyChainPermissionTargetKey,
subjectKey: subjectKey as SupplyChainPermissionSubjectKey,
subjectKey: subjectKey as SupplyChainPermissionPrincipalKey,
action: action as SupplyChainPermissionAction,
}
}
@ -212,6 +227,7 @@ export function resolveSupplyChainPermissionAccess(
overrides: SupplyChainPermissionOverrideMap,
targetKey: SupplyChainPermissionTargetKey,
subjectKeys: readonly SupplyChainPermissionSubjectKey[],
userId?: string | null,
) {
let view = false
let download = false
@ -233,6 +249,28 @@ export function resolveSupplyChainPermissionAccess(
edit ||= subjectEdit
})
const normalizedUserId = userId?.trim()
if (normalizedUserId) {
const userKey = buildSupplyChainPermissionUserKey(normalizedUserId)
const personalView = overrides[
buildSupplyChainPermissionOverrideKey(targetKey, userKey, 'view')
]
const personalDownload = overrides[
buildSupplyChainPermissionOverrideKey(targetKey, userKey, 'download')
]
const personalEdit = overrides[
buildSupplyChainPermissionOverrideKey(targetKey, userKey, 'edit')
]
if (personalView !== undefined) view = personalView
if (personalDownload !== undefined) download = personalDownload
if (personalEdit !== undefined) edit = personalEdit
if (edit || download) view = true
if (!view) {
download = false
edit = false
}
}
return { view, download, edit }
}

View File

@ -4,10 +4,7 @@ import {
INSTALLATION_DEMO_WORKBENCH_PATH,
canViewInstallationDemoWorkbench,
} from './installation-demo-workbench-permissions'
import {
BOM_COSTING_WORKBENCH_PATH,
canViewBomCostingWorkbench,
} from './bom-costing-permissions'
import { BOM_COSTING_WORKBENCH_PATH } from './bom-costing-permissions'
import {
isBomCostingOnlyUser,
isExternalIntakeUser,
@ -103,11 +100,15 @@ function isPathAllowedByPrefixes(
pathname: string,
prefixes: readonly string[] | null | undefined,
) {
const requiresDedicatedOaPermission =
pathname === '/intake/oa-board' || pathname.startsWith('/intake/oa-board/')
return Boolean(
prefixes?.some((prefix) =>
prefix === '/'
? pathname === '/'
: pathname === prefix || pathname.startsWith(`${prefix}/`),
: requiresDedicatedOaPermission && prefix === '/intake'
? false
: pathname === prefix || pathname.startsWith(`${prefix}/`),
),
)
}
@ -146,7 +147,12 @@ export function canAccessWorkspacePath(
pathname === BOM_COSTING_WORKBENCH_PATH ||
pathname.startsWith(`${BOM_COSTING_WORKBENCH_PATH}/`)
) {
return canViewBomCostingWorkbench(user)
return Boolean(
user &&
(isBomCostingOnlyUser(user) ||
hasFullWorkspaceAccess(user) ||
isPathAllowedByPrefixes(pathname, user.workspaceAllowedPathPrefixes)),
)
}
if (

View File

@ -25,10 +25,7 @@ import {
getInstallationDemoWorkbenchAccess,
INSTALLATION_DEMO_WORKBENCH_PATH,
} from '@/lib/installation-demo-workbench-permissions'
import {
BOM_COSTING_WORKBENCH_PATH,
canViewBomCostingWorkbench,
} from '@/lib/bom-costing-permissions'
import { BOM_COSTING_WORKBENCH_PATH } from '@/lib/bom-costing-permissions'
import { isExternalIntakeUser } from '@/lib/non-integrated-external-entry-permissions'
import { isSupplierAccount } from '@/lib/supplier-account'
import { resolveIntakeTopEntryAllowedPathPrefixes } from '@/lib/intake-node-permission-settings'
@ -123,6 +120,7 @@ function isEmail(identifier: string): boolean {
}
type WorkspaceTokenPermissionUser = {
id?: string | null
role?: string | null
businessRoles?: readonly string[] | null
loginSource?: string | null
@ -155,9 +153,6 @@ async function buildWorkspaceTokenPermissions(user: WorkspaceTokenPermissionUser
if (getInstallationDemoWorkbenchAccess(user, overrides).canView) {
allowedPathPrefixes.push(INSTALLATION_DEMO_WORKBENCH_PATH)
}
if (canViewBomCostingWorkbench(user)) {
allowedPathPrefixes.push(BOM_COSTING_WORKBENCH_PATH)
}
}
if (canAccessUserManagement(user)) {
allowedPathPrefixes.push(USER_MANAGEMENT_PATH)
@ -178,6 +173,7 @@ async function buildAuthUserDTO(
const managedTeamId =
user.managedTeam?.id ?? (user.role === 'SUPERVISOR' ? user.teamId : null)
const workspacePermissions = await buildWorkspaceTokenPermissions({
id: user.id,
role: user.role,
businessRoles: user.businessRoles,
loginSource: user.loginSource,
@ -354,6 +350,7 @@ class AuthService {
}
const workspacePermissions = await buildWorkspaceTokenPermissions({
id: effectiveUser.id,
role: effectiveUser.role,
businessRoles: effectiveUser.businessRoles,
loginSource: effectiveUser.loginSource,
@ -469,6 +466,7 @@ class AuthService {
const departmentPaths = await getUserDepartmentPathsById(user.id)
const workspacePermissions = await buildWorkspaceTokenPermissions({
id: user.id,
role: user.role,
businessRoles: user.businessRoles,
departmentPaths,
@ -551,6 +549,7 @@ class AuthService {
const managedTeamId =
user.managedTeam?.id ?? (user.role === 'SUPERVISOR' ? user.teamId : null)
const workspacePermissions = await buildWorkspaceTokenPermissions({
id: user.id,
role: user.role,
businessRoles: user.businessRoles,
loginSource: user.loginSource,

View File

@ -0,0 +1,54 @@
import { intakeNodePermissionSettingsService } from '../intake-node-permission-settings.service'
import { bomCostingAccessService } from './bom-costing-access.service'
jest.mock('../intake-node-permission-settings.service', () => ({
intakeNodePermissionSettingsService: {
getSettings: jest.fn(),
},
}))
const mockedSettingsService = jest.mocked(intakeNodePermissionSettingsService)
function buildUser(overrides: Record<string, unknown> = {}) {
return {
id: 'user-1',
email: 'user@example.com',
name: '测试用户',
role: 'USER',
status: 'ACTIVE',
businessRoles: ['PURCHASING'],
departmentPaths: ['江苏简尚品牌管理有限公司 > 供应链中心 > 采购部'],
...overrides,
} as never
}
describe('BomCostingAccessService', () => {
beforeEach(() => {
jest.clearAllMocks()
mockedSettingsService.getSettings.mockResolvedValue({ overrides: {} })
})
it('uses a personal override after the business type permission', async () => {
mockedSettingsService.getSettings.mockResolvedValue({
overrides: {
'bomCostingWorkbench:purchase:view': true,
'bomCostingWorkbench:user@user-1:view': false,
},
})
await expect(bomCostingAccessService.canView(buildUser())).resolves.toBe(false)
await expect(
bomCostingAccessService.canView(buildUser({ id: 'user-2' })),
).resolves.toBe(true)
})
it('keeps administrators and supervisors open without reading overrides', async () => {
await expect(
bomCostingAccessService.canView(buildUser({ role: 'ADMIN' })),
).resolves.toBe(true)
await expect(
bomCostingAccessService.canView(buildUser({ role: 'SUPERVISOR' })),
).resolves.toBe(true)
expect(mockedSettingsService.getSettings).not.toHaveBeenCalled()
})
})

View File

@ -1,12 +1,37 @@
import 'server-only'
import { canViewBomCostingWorkbench } from '@/lib/bom-costing-permissions'
import {
resolveIntakeNodePermissionAccess,
resolveIntakeNodePermissionSubjectKeys,
} from '@/lib/intake-node-permission-settings'
import {
isBomCostingOnlyUser,
isExternalIntakeUser,
} from '@/lib/non-integrated-external-entry-permissions'
import { isSupplierAccount } from '@/lib/supplier-account'
import { AppError, ErrorCode } from '@/server/core/errors'
import type { AuthUser } from '@/server/core/types'
import { intakeNodePermissionSettingsService } from '../intake-node-permission-settings.service'
const FULL_ACCESS_ROLES = new Set(['SUPER_ADMIN', 'ADMIN', 'SUPERVISOR'])
export class BomCostingAccessService {
assertCanView(user: AuthUser) {
if (!canViewBomCostingWorkbench(user)) {
async canView(user: AuthUser) {
if (isBomCostingOnlyUser(user)) return true
if (isSupplierAccount(user) || isExternalIntakeUser(user)) return false
if (FULL_ACCESS_ROLES.has(user.role)) return true
const { overrides } = await intakeNodePermissionSettingsService.getSettings()
return resolveIntakeNodePermissionAccess(
overrides,
'bomCostingWorkbench',
resolveIntakeNodePermissionSubjectKeys(user),
user.id,
).view
}
async assertCanView(user: AuthUser) {
if (!(await this.canView(user))) {
throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '当前账号不能访问 BOM 核算')
}
}

View File

@ -491,12 +491,12 @@ function serializeCaseDetail(record: BomCostingCaseDetailRecord) {
export class BomCostingService {
async getCapabilities(user: AuthUser) {
bomCostingAccessService.assertCanView(user)
await bomCostingAccessService.assertCanView(user)
return getBomCostingCapabilities()
}
async listCases(user: AuthUser) {
bomCostingAccessService.assertCanView(user)
await bomCostingAccessService.assertCanView(user)
const records = await bomCostingRepository.listCases({
createdById: bomCostingAccessService.canViewAllCases(user) ? undefined : user.id,
limit: 50,
@ -571,7 +571,7 @@ export class BomCostingService {
}
async getCase(caseId: string, user: AuthUser) {
bomCostingAccessService.assertCanView(user)
await bomCostingAccessService.assertCanView(user)
const record = await bomCostingRepository.findCaseById({
id: caseId,
createdById: bomCostingAccessService.canViewAllCases(user) ? undefined : user.id,
@ -609,7 +609,7 @@ export class BomCostingService {
}
async createCase(sourceUrl: string, user: AuthUser) {
bomCostingAccessService.assertCanView(user)
await bomCostingAccessService.assertCanView(user)
const parsedUrl = parseSafeCompetitorUrl(sourceUrl)
const provider = getCompetitorAnalysisProvider()
if (!provider) {
@ -1011,7 +1011,7 @@ export class BomCostingService {
input: UpdateBomCostingLineInput,
user: AuthUser,
) {
bomCostingAccessService.assertCanView(user)
await bomCostingAccessService.assertCanView(user)
const line = await bomCostingRepository.findLineForUpdate(lineId)
if (
!line ||
@ -1247,7 +1247,7 @@ export class BomCostingService {
lineId: string,
user: AuthUser,
) {
bomCostingAccessService.assertCanView(user)
await bomCostingAccessService.assertCanView(user)
const line = await bomCostingRepository.findLineForUpdate(lineId)
if (
!line ||

View File

@ -484,6 +484,7 @@ class IntakeNonIntegratedScopeService {
overrides,
targetKey,
subjectKeys,
user.id,
)
if (!nodePermissionAccess.edit) {
@ -542,6 +543,7 @@ class IntakeNonIntegratedScopeService {
overrides,
targetKey,
subjectKeys,
user.id,
)
if (!nodePermissionAccess.download) {
@ -575,6 +577,7 @@ class IntakeNonIntegratedScopeService {
overrides,
targetKey,
subjectKeys,
user.id,
)
if (!nodePermissionAccess.view) {

View File

@ -11,7 +11,7 @@ import {
sanitizeSupplyChainPermissionOverrides,
type SupplyChainPermissionAction,
type SupplyChainPermissionOverrideMap,
type SupplyChainPermissionSubjectKey,
type SupplyChainPermissionPrincipalKey,
type SupplyChainPermissionTargetKey,
type SupplyChainPermissionUser,
} from '@/lib/supply-chain-permission-settings'
@ -50,7 +50,7 @@ class SupplyChainPermissionSettingsService {
rows.forEach((row) => {
const key = buildSupplyChainPermissionOverrideKey(
row.targetKey as SupplyChainPermissionTargetKey,
row.subjectKey as SupplyChainPermissionSubjectKey,
row.subjectKey as SupplyChainPermissionPrincipalKey,
row.action as SupplyChainPermissionAction,
)
if (parseSupplyChainPermissionOverrideKey(key)) overrides[key] = row.enabled
@ -85,6 +85,7 @@ class SupplyChainPermissionSettingsService {
await this.readOverrides(),
targetKey,
resolveSupplyChainPermissionSubjectKeys(user),
user.id,
)
} catch (error) {
if (isMissingPermissionTable(error)) {
@ -128,7 +129,12 @@ class SupplyChainPermissionSettingsService {
'operationLogs',
]
return targetKeys.filter((targetKey) =>
resolveSupplyChainPermissionAccess(overrides, targetKey, subjectKeys).view,
resolveSupplyChainPermissionAccess(
overrides,
targetKey,
subjectKeys,
user.id,
).view,
)
} catch (error) {
if (isMissingPermissionTable(error)) return []