feat(intake): restrict formal product workspace access

This commit is contained in:
WuYongJie 2026-05-22 19:50:38 +08:00
parent e8e83bc531
commit 08093bb748
19 changed files with 1647 additions and 321 deletions

View File

@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from 'next/server'
import { getAuthTokenCookieOptions } from '@/server/auth/auth-cookie'
import { resolveWorkspaceRedirectForUser } from '@/lib/workspace-route-permissions'
import {
dingtalkAuthService,
resolveDingtalkCallbackOrigin,
@ -30,7 +31,11 @@ export async function GET(request: NextRequest) {
try {
const redirectPath = await dingtalkAuthService.resolveRedirectPath(state)
const result = await dingtalkAuthService.authenticate(code)
const finalPath = result.isFirstLogin ? '/first-login' : redirectPath
const finalPath = result.isFirstLogin
? '/first-login'
: result.user
? resolveWorkspaceRedirectForUser(redirectPath, result.user)
: redirectPath
const appOrigin = resolveDingtalkCallbackOrigin(request.nextUrl.origin)
const response = NextResponse.redirect(new URL(finalPath, appOrigin))

View File

@ -9,6 +9,7 @@ import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
import { getAuthTokenCookieOptions } from '@/server/auth/auth-cookie'
import { authService, type LoginInput } from '@/server/services/auth.service'
import { resolvePostLoginRedirect } from '@/server/services/auth-redirect.service'
import { resolveWorkspaceRedirectForUser } from '@/lib/workspace-route-permissions'
const WINDOW_MS = 60 * 1000
const MAX_ATTEMPTS_PER_WINDOW = 20
@ -105,9 +106,10 @@ export async function POST(request: NextRequest) {
password: body.password,
})
clearFailure(key)
const resolvedRedirectPath = await resolvePostLoginRedirect(body.redirectPath)
const redirectPath = result.isFirstLogin
? '/first-login'
: await resolvePostLoginRedirect(body.redirectPath)
: resolveWorkspaceRedirectForUser(resolvedRedirectPath, result.user)
const response = jsonSuccess(
{

View File

@ -13,6 +13,18 @@ export default function AccessoryLibraryPanel() {
changedEventName={ACCESSORY_LIBRARY_CHANGED_EVENT}
entityName="配件"
queryNamePlaceholder="例如:六角螺丝 / 平角合"
extraFields={[
{
key: 'color',
label: '颜色',
placeholder: '例如:黑色 / 银色 / 胡桃色',
},
{
key: 'material',
label: '材质',
placeholder: '例如:铁 / 不锈钢 / 塑料',
},
]}
/>
)
}

View File

@ -0,0 +1,63 @@
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import AccessoryLibraryPanel from '@/app/intake/AccessoryLibraryPanel'
import PackagingMaterialLibraryPanel from '@/app/intake/PackagingMaterialLibraryPanel'
import { ACCESSORY_LIBRARY_STORAGE_KEY } from '@/lib/accessory-library'
describe('alphabet library panels', () => {
beforeEach(() => {
window.localStorage.clear()
})
it('adds color and material fields to the accessory library only', async () => {
const actor = userEvent.setup()
render(<AccessoryLibraryPanel />)
await actor.click(screen.getByRole('button', { name: '新增当前字母' }))
const dialog = await screen.findByRole('dialog', { name: '新增配件' })
expect(within(dialog).getByLabelText('颜色')).toBeInTheDocument()
expect(within(dialog).getByLabelText('材质')).toBeInTheDocument()
fireEvent.change(within(dialog).getByLabelText(/配件名称/), {
target: { value: '六角螺丝' },
})
fireEvent.change(within(dialog).getByLabelText('颜色'), {
target: { value: '黑色' },
})
fireEvent.change(within(dialog).getByLabelText('材质'), {
target: { value: '铁' },
})
await actor.click(within(dialog).getByRole('button', { name: '保存' }))
await waitFor(() => {
expect(screen.getByText('黑色')).toBeInTheDocument()
expect(screen.getByText('铁')).toBeInTheDocument()
})
const savedItems = JSON.parse(
window.localStorage.getItem(ACCESSORY_LIBRARY_STORAGE_KEY) ?? '[]',
)
expect(savedItems[0]).toMatchObject({
name: '六角螺丝',
color: '黑色',
material: '铁',
})
})
it('keeps the packaging material library on its original fields', async () => {
const actor = userEvent.setup()
render(<PackagingMaterialLibraryPanel />)
await actor.click(screen.getByRole('button', { name: '新增当前字母' }))
const dialog = await screen.findByRole('dialog', { name: '新增包材' })
expect(within(dialog).getByLabelText(/包材名称/)).toBeInTheDocument()
expect(within(dialog).queryByLabelText('颜色')).not.toBeInTheDocument()
expect(within(dialog).queryByLabelText('材质')).not.toBeInTheDocument()
})
})

View File

@ -21,6 +21,8 @@ type AlphabetLibraryItem = {
letter: AlphabetLibraryLetter
code: string
name: string
color?: string
material?: string
price: string
imageUrl: string
imageName: string
@ -31,16 +33,27 @@ type AlphabetLibraryDraft = {
letter: AlphabetLibraryLetter
code: string
name: string
color: string
material: string
price: string
imageUrl: string
imageName: string
}
type AlphabetLibraryExtraFieldKey = 'color' | 'material'
type AlphabetLibraryExtraFieldDefinition = {
key: AlphabetLibraryExtraFieldKey
label: string
placeholder?: string
}
interface AlphabetLibraryPanelProps {
storageKey: string
entityName: string
queryNamePlaceholder?: string
changedEventName?: string
extraFields?: AlphabetLibraryExtraFieldDefinition[]
}
function createItemId() {
@ -75,6 +88,8 @@ function readLibraryItems(storageKey: string) {
ALPHABET_LIBRARY_LETTERS.includes(item.letter as AlphabetLibraryLetter) &&
typeof item.code === 'string' &&
typeof item.name === 'string' &&
(item.color === undefined || typeof item.color === 'string') &&
(item.material === undefined || typeof item.material === 'string') &&
typeof item.price === 'string' &&
typeof item.imageUrl === 'string' &&
typeof item.imageName === 'string' &&
@ -124,6 +139,8 @@ function createDraft(items: AlphabetLibraryItem[], letter: AlphabetLibraryLetter
letter,
code: buildNextLibraryCode(items, letter),
name: '',
color: '',
material: '',
price: '',
imageUrl: '',
imageName: '',
@ -135,6 +152,7 @@ export default function AlphabetLibraryPanel({
entityName,
queryNamePlaceholder,
changedEventName,
extraFields = [],
}: AlphabetLibraryPanelProps) {
const [items, setItems] = useState<AlphabetLibraryItem[]>(() => readLibraryItems(storageKey))
const [selectedLetter, setSelectedLetter] = useState<AlphabetLibraryLetter>('A')
@ -211,6 +229,13 @@ export default function AlphabetLibraryPanel({
}, [groupedItems, hasActiveQuery, selectedLetter])
const currentLetterItems = groupedItems[activeLetter]
const libraryGridTemplateColumns = extraFields.length
? `130px minmax(180px,1.2fr) repeat(${extraFields.length}, minmax(110px,0.75fr)) 150px 180px`
: '130px minmax(0,1.3fr) 150px 180px'
const libraryGridMinWidth = extraFields.length ? 840 : undefined
const libraryFieldSummary = ['编号', '名称', ...extraFields.map((field) => field.label), '图片', '价格'].join(
'、',
)
function applySearch() {
setAppliedQueryCode(queryCode)
@ -230,6 +255,8 @@ export default function AlphabetLibraryPanel({
letter: item.letter,
code: item.code,
name: item.name,
color: item.color ?? '',
material: item.material ?? '',
price: item.price,
imageUrl: item.imageUrl,
imageName: item.imageName,
@ -289,6 +316,8 @@ export default function AlphabetLibraryPanel({
const normalizedCode = draft.code.trim().toUpperCase()
const normalizedName = draft.name.trim()
const normalizedColor = draft.color.trim()
const normalizedMaterial = draft.material.trim()
const normalizedPrice = draft.price.trim()
if (!normalizedCode) {
@ -316,6 +345,8 @@ export default function AlphabetLibraryPanel({
letter: draft.letter,
code: normalizedCode,
name: normalizedName,
color: normalizedColor,
material: normalizedMaterial,
price: normalizedPrice,
imageUrl: draft.imageUrl,
imageName: draft.imageName,
@ -452,7 +483,7 @@ export default function AlphabetLibraryPanel({
{activeLetter} 组{entityName}
</h4>
<p className="mt-1 text-xs leading-5 text-apple-label-secondary">
当前显示 {currentLetterItems.length} 条数据,字段固定为编号、名称、图片、价格。
当前显示 {currentLetterItems.length} 条数据,字段为{libraryFieldSummary}。
</p>
</div>
<Button
@ -467,20 +498,34 @@ export default function AlphabetLibraryPanel({
</Button>
</div>
<div className="grid grid-cols-[130px_minmax(0,1.3fr)_150px_180px] gap-3 rounded-apple-lg bg-apple-bg-secondary/70 px-3 py-2 text-xs font-apple-semibold text-apple-label-secondary">
<span>编号</span>
<span>名称</span>
<span>图片</span>
<span>价格</span>
</div>
<div className="overflow-x-auto pb-1">
<div
className="grid gap-3 rounded-apple-lg bg-apple-bg-secondary/70 px-3 py-2 text-xs font-apple-semibold text-apple-label-secondary"
style={{
gridTemplateColumns: libraryGridTemplateColumns,
minWidth: libraryGridMinWidth,
}}
>
<span>编号</span>
<span>名称</span>
{extraFields.map((field) => (
<span key={field.key}>{field.label}</span>
))}
<span>图片</span>
<span>价格</span>
</div>
<div className="mt-3 space-y-3">
{currentLetterItems.length ? (
currentLetterItems.map((item) => (
<div
key={item.id}
className="grid grid-cols-[130px_minmax(0,1.3fr)_150px_180px] gap-3 rounded-apple-lg border border-apple-separator bg-apple-bg-secondary/30 px-3 py-3"
>
<div className="mt-3 space-y-3">
{currentLetterItems.length ? (
currentLetterItems.map((item) => (
<div
key={item.id}
className="grid gap-3 rounded-apple-lg border border-apple-separator bg-apple-bg-secondary/30 px-3 py-3"
style={{
gridTemplateColumns: libraryGridTemplateColumns,
minWidth: libraryGridMinWidth,
}}
>
<div className="rounded-apple-md bg-apple-bg-primary px-3 py-2">
<p className="text-sm font-apple-semibold text-apple-label-primary">{item.code}</p>
<p className="mt-1 text-[11px] text-apple-label-secondary">{item.letter} 组</p>
@ -493,6 +538,21 @@ export default function AlphabetLibraryPanel({
</p>
</div>
{extraFields.map((field) => (
<div
key={`${item.id}-${field.key}`}
className="rounded-apple-md bg-apple-bg-primary px-3 py-2"
>
<p
className={`text-sm font-apple-medium ${
item[field.key] ? 'text-apple-label-primary' : 'text-amber-700'
}`}
>
{item[field.key] || `待补${field.label}`}
</p>
</div>
))}
<div className="rounded-apple-md bg-apple-bg-primary px-3 py-2">
{item.imageUrl ? (
<button
@ -548,20 +608,21 @@ export default function AlphabetLibraryPanel({
</button>
</div>
</div>
</div>
))
) : (
<div className="rounded-apple-lg border border-dashed border-apple-separator bg-apple-bg-secondary/20 px-6 py-12 text-center">
<p className="text-sm font-apple-medium text-apple-label-primary">
{hasActiveQuery ? '当前筛选条件下没有结果' : `${activeLetter} 组还没有${entityName}`}
</p>
<p className="mt-2 text-xs leading-5 text-apple-label-secondary">
{hasActiveQuery
? '你可以换查询词继续查。'
: '点击“新增当前字母”即可往这个字母下面继续补资料。'}
</p>
</div>
))
) : (
<div className="rounded-apple-lg border border-dashed border-apple-separator bg-apple-bg-secondary/20 px-6 py-12 text-center">
<p className="text-sm font-apple-medium text-apple-label-primary">
{hasActiveQuery ? '当前筛选条件下没有结果' : `${activeLetter} 组还没有${entityName}`}
</p>
<p className="mt-2 text-xs leading-5 text-apple-label-secondary">
{hasActiveQuery
? '你可以换查询词继续查。'
: '点击“新增当前字母”即可往这个字母下面继续补资料。'}
</p>
</div>
)}
)}
</div>
</div>
</div>
</div>
@ -653,6 +714,22 @@ export default function AlphabetLibraryPanel({
/>
</div>
{extraFields.length ? (
<div className="grid gap-4 md:grid-cols-2">
{extraFields.map((field) => (
<Input
key={field.key}
label={field.label}
value={draft[field.key]}
onChange={(event) =>
setDraft({ ...draft, [field.key]: event.target.value })
}
placeholder={field.placeholder ?? `请输入${field.label}`}
/>
))}
</div>
) : null}
<div className="rounded-apple-lg border border-apple-separator bg-apple-bg-secondary/30 p-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>

View File

@ -10,6 +10,7 @@ import {
getNonIntegratedDocumentWorkspaceCategoryLabel,
NON_INTEGRATED_BRAND_PREFIX_MAP,
NON_INTEGRATED_DOCUMENT_WORKSPACE_CATEGORIES,
NON_INTEGRATED_WORKFLOW_NODE_CATEGORIES,
NON_INTEGRATED_STORAGE_KEY,
} from './non-integrated/nonIntegratedStorage'
import {
@ -469,6 +470,41 @@ function createPatentApprovedProduct(overrides: Record<string, unknown> = {}) {
}
}
function createApprovedWorkflowStateBefore(targetCategory: string) {
const targetIndex = NON_INTEGRATED_WORKFLOW_NODE_CATEGORIES.findIndex(
(category) => category === targetCategory,
)
const hiddenListCategories = new Set(['立项评审', '检测报告'])
const readyCategories = NON_INTEGRATED_WORKFLOW_NODE_CATEGORIES.slice(
0,
targetIndex < 0 ? 0 : targetIndex,
).filter((category) => !hiddenListCategories.has(category))
const uploadFiles = readyCategories
.filter((category) =>
NON_INTEGRATED_DOCUMENT_WORKSPACE_CATEGORIES.includes(
category as (typeof NON_INTEGRATED_DOCUMENT_WORKSPACE_CATEGORIES)[number],
),
)
.map((category, index) => ({
id: `ready-file-${index + 1}`,
name: `ready-${index + 1}.pdf`,
category,
dataUrl: 'data:application/pdf;base64,ready',
mimeType: 'application/pdf',
}))
return {
uploadFiles,
documentWorkspaceConfirmations: createDefaultDocumentWorkspaceConfirmations(),
documentWorkspaceApprovedAt: Object.fromEntries(
readyCategories.map((category, index) => [
category,
`2026-05-01T00:${String(index).padStart(2, '0')}:00.000Z`,
]),
),
}
}
describe('IntakeClient', () => {
beforeEach(() => {
jest.useRealTimers()
@ -490,7 +526,7 @@ describe('IntakeClient', () => {
const rightPanel = screen.getByTestId('right-panel')
expect(within(rightPanel).getByRole('button', { name: '产品开发' })).toBeInTheDocument()
expect(within(rightPanel).getByRole('button', { name: /产品开发进度/ })).toBeInTheDocument()
expect(within(rightPanel).getByRole('button', { name: 'BOM资料' })).toBeInTheDocument()
expect(within(rightPanel).getByRole('button', { name: 'BOM资料库' })).toBeInTheDocument()
expect(within(rightPanel).getByRole('button', { name: '原材料资料库' })).toBeInTheDocument()
expect(within(rightPanel).getByRole('button', { name: '板材资料库' })).toBeInTheDocument()
expect(within(rightPanel).getByRole('button', { name: '钢架资料库' })).toBeInTheDocument()
@ -1266,6 +1302,59 @@ describe('IntakeClient', () => {
expect(within(targetRow as HTMLElement).getByDisplayValue('APP-TARGET')).toBeInTheDocument()
})
it('limits purchasing users to the target product patent record from detail', async () => {
const actor = userEvent.setup()
const products = [
createPatentApprovedProduct({
id: 'target-patent-workspace',
spu: 'TARGET-SPU',
purchaser: 'Purchasing A',
purchaseOwner: 'Purchasing A',
patentApplication: createPatentApplicationContent({
patentName: 'Target Patent',
applicationNo: 'APP-TARGET',
patentNo: 'PAT-TARGET',
}),
}),
createPatentApprovedProduct({
id: 'other-patent-workspace',
spu: 'OTHER-SPU',
patentApplication: createPatentApplicationContent({
patentName: 'Other Patent',
applicationNo: 'APP-OTHER',
patentNo: 'PAT-OTHER',
}),
}),
]
window.localStorage.setItem(
NON_INTEGRATED_STORAGE_KEY,
JSON.stringify(products),
)
renderSelector({
eligibleSkuList: [],
initialActiveSection: 'pending',
initialNonIntegratedProducts: products,
userRole: 'USER',
userName: 'Purchasing A',
userBusinessRoles: ['PURCHASING'],
userDepartmentPaths: ['供应链中心 / 采购部'],
})
await actor.click(screen.getByRole('button', { name: '打开TARGET-SPU工作页' }))
await actor.click(
within(screen.getByTestId('embedded-non-integrated-detail')).getAllByRole(
'button',
)[1],
)
expect(screen.getByRole('tab', { name: '产品专利明细' })).toBeInTheDocument()
expect(screen.getByLabelText('筛选SPU')).toHaveValue('TARGET-SPU')
expect(screen.getAllByDisplayValue('PAT-TARGET').length).toBeGreaterThan(0)
expect(screen.queryByDisplayValue('OTHER-SPU')).not.toBeInTheDocument()
expect(screen.queryByDisplayValue('PAT-OTHER')).not.toBeInTheDocument()
})
it('shows running total days and node elapsed days in the product progress table', async () => {
jest.useFakeTimers({ now: new Date('2026-04-29T00:00:00.000Z') })
const actor = userEvent.setup({ advanceTimers: jest.advanceTimersByTime })
@ -1415,7 +1504,7 @@ describe('IntakeClient', () => {
const rightPanel = screen.getByTestId('right-panel')
await actor.click(within(rightPanel).getByRole('button', { name: 'BOM资料' }))
await actor.click(within(rightPanel).getByRole('button', { name: 'BOM资料库' }))
expect(screen.getByText('material-library-copy')).toBeInTheDocument()
@ -1670,6 +1759,26 @@ describe('IntakeClient', () => {
expect(screen.getByRole('button', { name: '新增产品' })).toBeInTheDocument()
})
it('hides design controlled product data entry points from purchasing users', () => {
renderSelector({
eligibleSkuList: [],
userRole: 'USER',
userBusinessRoles: ['PURCHASING'],
userDepartment: {
id: 'purchase-department',
name: '采购部',
fullPath: '供应链中心 / 采购部',
},
userDepartmentPaths: ['供应链中心 / 采购部'],
})
const rightPanel = screen.getByTestId('right-panel')
expect(within(rightPanel).getByRole('button', { name: '产品开发明细' })).toBeInTheDocument()
expect(within(rightPanel).queryByRole('button', { name: 'BOM资料库' })).not.toBeInTheDocument()
expect(within(rightPanel).queryByRole('button', { name: '产品专利' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: '新增产品' })).not.toBeInTheDocument()
})
it('opens the series development list with the same product detail capabilities', async () => {
const actor = userEvent.setup()
@ -1880,7 +1989,7 @@ describe('IntakeClient', () => {
).not.toBeInTheDocument()
})
it('hides unrelated products from non-matching designers in the created scope', async () => {
it('hides unrelated products from non-matching designers in the responsible scope', async () => {
const actor = userEvent.setup()
window.localStorage.setItem(
@ -1896,7 +2005,7 @@ describe('IntakeClient', () => {
})
expect(screen.getByText('DNZ0001')).toBeInTheDocument()
await actor.click(screen.getByRole('button', { name: '我创建的' }))
await actor.click(screen.getByRole('button', { name: '我负责的' }))
expect(screen.queryByText('DNZ0001')).not.toBeInTheDocument()
})
@ -1960,7 +2069,7 @@ describe('IntakeClient', () => {
expect(actionCell?.querySelectorAll('button')).toHaveLength(2)
})
it('filters created products by the current DingTalk account name', async () => {
it('filters responsible products by the current DingTalk account name', async () => {
const actor = userEvent.setup()
window.localStorage.setItem(
@ -1970,13 +2079,21 @@ describe('IntakeClient', () => {
id: 'caishen-created',
spu: 'CS0001',
createdBy: '财神',
designer: '',
designer: '财神',
openingReference: {
...createEmptyNonIntegratedOpeningReference(),
designer: '财神',
},
}),
createNonIntegratedProduct({
id: 'xuxiake-created',
spu: 'XXK0001',
createdBy: '徐霞客',
designer: '',
designer: '徐霞客',
openingReference: {
...createEmptyNonIntegratedOpeningReference(),
designer: '徐霞客',
},
}),
]),
)
@ -1988,12 +2105,163 @@ describe('IntakeClient', () => {
userName: '财神',
})
await actor.click(screen.getByRole('button', { name: '我创建的' }))
await actor.click(screen.getByRole('button', { name: '我负责的' }))
expect(screen.getByText('CS0001')).toBeInTheDocument()
expect(screen.queryByText('XXK0001')).not.toBeInTheDocument()
})
it('filters responsible products by purchase owner for purchasing users', async () => {
const actor = userEvent.setup()
window.localStorage.setItem(
NON_INTEGRATED_STORAGE_KEY,
JSON.stringify([
createNonIntegratedProduct({
id: 'purchase-owner-a',
spu: 'BUY0001',
purchaser: 'Purchasing A',
purchaseOwner: 'Purchasing A',
}),
createNonIntegratedProduct({
id: 'purchase-owner-b',
spu: 'BUY0002',
purchaser: 'Purchasing B',
purchaseOwner: 'Purchasing B',
}),
]),
)
renderSelector({
eligibleSkuList: [],
userRole: 'USER',
userName: 'Purchasing A',
userBusinessRoles: ['PURCHASING'],
userDepartmentPaths: ['供应链中心 > 采购部'],
})
await actor.click(screen.getByRole('button', { name: '我负责的' }))
expect(screen.getByText('BUY0001')).toBeInTheDocument()
expect(screen.queryByText('BUY0002')).not.toBeInTheDocument()
})
it('filters responsible products by space designer for space visual users', async () => {
const actor = userEvent.setup()
window.localStorage.setItem(
NON_INTEGRATED_STORAGE_KEY,
JSON.stringify([
createNonIntegratedProduct({
id: 'space-owner-a',
spu: 'SPACE0001',
visualPlanning: 'Space A',
visualDirector: 'Space A',
designer: 'Designer A',
}),
createNonIntegratedProduct({
id: 'space-owner-b',
spu: 'SPACE0002',
visualPlanning: 'Space B',
visualDirector: 'Space B',
designer: 'Space A',
}),
]),
)
renderSelector({
eligibleSkuList: [],
userRole: 'USER',
userName: 'Space A',
userBusinessRoles: ['DESIGN'],
userDepartmentPaths: ['品牌中心 > 空间视觉部'],
})
await actor.click(screen.getByRole('button', { name: '我负责的' }))
expect(screen.getByText('SPACE0001')).toBeInTheDocument()
expect(screen.queryByText('SPACE0002')).not.toBeInTheDocument()
})
it('filters responsible products by operation owner for operation users', async () => {
const actor = userEvent.setup()
window.localStorage.setItem(
NON_INTEGRATED_STORAGE_KEY,
JSON.stringify([
createNonIntegratedProduct({
id: 'operation-owner-a',
spu: 'OPS0001',
spuOwner: 'Operation A',
operationsOwner: 'Operation A',
operator: 'Operation A',
}),
createNonIntegratedProduct({
id: 'operation-owner-b',
spu: 'OPS0002',
designer: 'Operation A',
spuOwner: 'Operation B',
operationsOwner: 'Operation B',
operator: 'Operation B',
}),
]),
)
renderSelector({
eligibleSkuList: [],
userRole: 'USER',
userName: 'Operation A',
userBusinessRoles: [],
userDepartmentPaths: ['运营中心 > 事业一部'],
})
await actor.click(screen.getByRole('button', { name: '我负责的' }))
expect(screen.getByText('OPS0001')).toBeInTheDocument()
expect(screen.queryByText('OPS0002')).not.toBeInTheDocument()
})
it('shows pending products only when the current node belongs to the user', async () => {
const actor = userEvent.setup()
window.localStorage.setItem(
NON_INTEGRATED_STORAGE_KEY,
JSON.stringify([
createNonIntegratedProduct({
...createApprovedWorkflowStateBefore('产品定厂'),
id: 'purchase-factory-pending',
spu: 'BUY-PENDING',
purchaser: 'Purchasing A',
purchaseOwner: 'Purchasing A',
}),
createNonIntegratedProduct({
id: 'purchase-owner-but-design-pending',
spu: 'BUY-NOT-YET',
purchaser: 'Purchasing A',
purchaseOwner: 'Purchasing A',
designer: 'Designer A',
openingReference: {
...createEmptyNonIntegratedOpeningReference(),
designer: 'Designer A',
},
}),
]),
)
renderSelector({
eligibleSkuList: [],
userRole: 'USER',
userName: 'Purchasing A',
userBusinessRoles: ['PURCHASING'],
userDepartmentPaths: ['供应链中心 > 采购部'],
})
await actor.click(screen.getByRole('button', { name: '待处理' }))
expect(screen.getByText('BUY-PENDING')).toBeInTheDocument()
expect(screen.queryByText('BUY-NOT-YET')).not.toBeInTheDocument()
})
it('lets planning users open products for opening-reference editing only', async () => {
const actor = userEvent.setup()
@ -2930,7 +3198,7 @@ describe('IntakeClient', () => {
'aria-pressed',
'true',
)
expect(screen.getByRole('button', { name: '我创建的' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: '我负责的' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: '待处理' })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: '待处理的' })).not.toBeInTheDocument()
expect(screen.getByText('DNZ0001')).toBeInTheDocument()

View File

@ -84,8 +84,10 @@ import {
import {
canEditAnyNonIntegratedWorkspace,
canEditNonIntegratedProductIdentity,
canEditNonIntegratedWorkspace,
canDeleteNonIntegratedProductByIntakePermission,
isIntakeNonIntegratedDesignDepartmentUser,
isIntakeNonIntegratedPricingGroupUser,
isIntakeNonIntegratedQualityGroupUser,
type IntakeNonIntegratedPermissionUser,
} from "@/lib/non-integrated-permissions";
import {
@ -191,6 +193,16 @@ function canManageSpuRuleLibrary(role: string): boolean {
return role === "SUPER_ADMIN" || role === "ADMIN";
}
function canAccessDesignControlledProductData(
user: IntakeProductPermissionUser,
): boolean {
if (user.role === "SUPER_ADMIN" || user.role === "ADMIN") {
return true;
}
return isIntakeNonIntegratedDesignDepartmentUser(user);
}
function canEditNonIntegratedProduct(
product: NonIntegratedProductRecord,
user: IntakeProductPermissionUser,
@ -569,25 +581,90 @@ function getLegacyNonIntegratedProductOwnerNames(
]);
}
function isProductCreatedByCurrentUser(
function matchesCurrentUserName(
values: Array<string | null | undefined>,
user: IntakeProductPermissionUser,
) {
const normalizedUserName = safeTrim(user.name).replace(/\s+/g, "").toLowerCase();
if (!normalizedUserName) return false;
return values.some(
(value) =>
safeTrim(value).replace(/\s+/g, "").toLowerCase() === normalizedUserName,
);
}
function hasBusinessRole(user: IntakeProductPermissionUser, role: string) {
return Boolean(user.businessRoles?.includes(role));
}
function isAdminUser(user: IntakeProductPermissionUser) {
return user.role === "SUPER_ADMIN" || user.role === "ADMIN";
}
function getUserDepartmentText(user: IntakeProductPermissionUser) {
return [
user.department?.name,
user.department?.fullPath,
...(user.departmentPaths ?? []),
]
.map((value) => value?.trim() ?? "")
.filter(Boolean)
.join(" ");
}
function isSpaceDesignUser(user: IntakeProductPermissionUser) {
const departmentText = getUserDepartmentText(user);
return hasBusinessRole(user, "DESIGN") && /空间|视觉/.test(departmentText);
}
function isOperationsOwnerUser(user: IntakeProductPermissionUser) {
return /运营/.test(getUserDepartmentText(user));
}
function getCurrentUserResponsibleOwnerValues(
product: NonIntegratedProductRecord,
user: IntakeProductPermissionUser,
) {
const userId = user.id.trim();
const userName = user.name.trim();
const creator = product.createdBy?.trim() ?? "";
const people = getNonIntegratedListPeople(product);
const scopedValues: string[] = [];
if (
creator &&
((userId && creator === userId) || (userName && creator === userName))
) {
if (hasBusinessRole(user, "DESIGN")) {
if (isSpaceDesignUser(user)) {
scopedValues.push(people.spaceDesigner);
} else {
scopedValues.push(people.appearanceDesigner, people.structureDesigner);
}
}
if (hasBusinessRole(user, "PLANNING")) {
scopedValues.push(people.planningOwner);
}
if (hasBusinessRole(user, "PURCHASING")) {
scopedValues.push(people.purchaseOwner);
}
if (isOperationsOwnerUser(user)) {
scopedValues.push(product.spuOwner, product.operator, product.operationsOwner);
}
if (scopedValues.length > 0) {
return scopedValues;
}
return getLegacyNonIntegratedProductOwnerNames(product);
}
function isCurrentUserResponsibleForNonIntegratedProduct(
product: NonIntegratedProductRecord,
user: IntakeProductPermissionUser,
) {
if (isAdminUser(user)) {
return true;
}
return Boolean(
!creator &&
userName &&
getLegacyNonIntegratedProductOwnerNames(product).includes(userName),
return matchesCurrentUserName(
getCurrentUserResponsibleOwnerValues(product, user),
user,
);
}
@ -1283,6 +1360,30 @@ function readInitialIntakeWorkspaceState(params: {
}
}
function isDesignControlledWorkspaceTab(tab: IntakeWorkspaceTab) {
if (tab.kind === "newNonIntegratedProduct") return true;
if (tab.kind === "bomLibrary") return true;
if (tab.kind === "productPatentDashboard") return true;
return tab.kind === "productPatentLedger" && !tab.target;
}
function restrictWorkspaceStateForBusinessUser(
state: InitialIntakeWorkspaceState,
): InitialIntakeWorkspaceState {
const tabs = state.tabs.filter((tab) => !isDesignControlledWorkspaceTab(tab));
const safeTabs =
tabs.length > 0 ? tabs : createDefaultIntakeWorkspaceState().tabs;
const safeActiveTabId = safeTabs.some((tab) => tab.id === state.activeTabId)
? state.activeTabId
: (safeTabs[0]?.id ?? null);
return {
tabs: safeTabs,
activeTabId: safeActiveTabId,
sequence: getMaxWorkspaceTabSequence(safeTabs),
};
}
function persistIntakeWorkspaceState(params: {
activeTabId: string | null;
shouldPersist: boolean;
@ -1305,6 +1406,11 @@ function persistIntakeWorkspaceState(params: {
}
}
function restrictSectionForBusinessUser(section: IntakeSection): IntakeSection {
if (section === "bom" || section === "patents") return "pending";
return section;
}
function getWorkspaceTabInitialSection(tab: IntakeWorkspaceTab | null) {
if (!tab) return "pending";
if (
@ -1388,7 +1494,7 @@ function resolveWorkspaceTabsForOpen(
function getBomLibraryLabel(library: BomLibraryKey) {
return (
BOM_LIBRARY_OPTIONS.find((option) => option.key === library)?.label ??
"BOM资料"
"BOM资料库"
);
}
@ -2257,6 +2363,81 @@ function getCurrentNodeCategoryForList(
);
}
function isCurrentUserResponsibleForCurrentNode(
product: NonIntegratedProductRecord,
user: IntakeProductPermissionUser,
category: NonIntegratedDocumentStatusCategory,
) {
if (isAdminUser(user)) {
return true;
}
const people = getNonIntegratedListPeople(product);
if (
category === "开品参考" ||
category === "设计卖点" ||
category === "专利申请"
) {
return matchesCurrentUserName([people.appearanceDesigner], user);
}
if (category === "产品模型") {
return matchesCurrentUserName(
[people.appearanceDesigner, people.structureDesigner],
user,
);
}
if (category === "评审资料") {
return matchesCurrentUserName(
[people.appearanceDesigner, people.structureDesigner, people.purchaseOwner],
user,
);
}
if (
category === NON_INTEGRATED_BASIC_INFO_WORKSPACE_CATEGORY ||
category === "产品实拍图" ||
category === "安装方式" ||
category === "包装细节"
) {
return matchesCurrentUserName([people.structureDesigner], user);
}
if (category === "企划布局") {
return matchesCurrentUserName([people.spaceDesigner], user);
}
if (category === "产品定厂") {
return matchesCurrentUserName([people.purchaseOwner], user);
}
if (category === "样包测量" || category === "产品核价") {
return (
matchesCurrentUserName([people.purchaseOwner], user) ||
isIntakeNonIntegratedPricingGroupUser(user)
);
}
if (category === "成品寄样") {
return matchesCurrentUserName(
[people.planningOwner, people.purchaseOwner],
user,
);
}
if (category === "产品限价") {
return matchesCurrentUserName([people.planningOwner], user);
}
if (category === "工厂质检") {
return isIntakeNonIntegratedQualityGroupUser(user);
}
return isCurrentUserResponsibleForNonIntegratedProduct(product, user);
}
function isCurrentUserPendingNonIntegratedProduct(
product: NonIntegratedProductRecord,
user: IntakeProductPermissionUser,
@ -2271,7 +2452,7 @@ function isCurrentUserPendingNonIntegratedProduct(
);
if (!currentCategory) return false;
return canEditNonIntegratedWorkspace(product, user, currentCategory);
return isCurrentUserResponsibleForCurrentNode(product, user, currentCategory);
}
function isDocumentWorkspaceApprovedForList(
@ -3164,7 +3345,10 @@ function NonIntegratedPendingDetail({
const quickScopeMatched =
activeQuickScope === "all" ||
(activeQuickScope === "created"
? isProductCreatedByCurrentUser(product, currentUser)
? isCurrentUserResponsibleForNonIntegratedProduct(
product,
currentUser,
)
: isCurrentUserPendingNonIntegratedProduct(
product,
currentUser,
@ -3391,7 +3575,7 @@ function NonIntegratedPendingDetail({
: productFilterFields;
const quickScopes: Array<{ key: NonIntegratedQuickScope; label: string }> = [
{ key: "all", label: "全部" },
{ key: "created", label: "我创建的" },
{ key: "created", label: "我负责的" },
{ key: "pending", label: "待处理" },
];
const listColumnCount =
@ -8626,6 +8810,7 @@ function FormalIntakeWorkbench({
onOpenProductPatentLedger,
onOpenProductPatentDashboard,
onOpenSpuRuleLibraryWorkspace,
showDesignControlledProductData,
}: {
activeSection: IntakeSection;
onToggle: (section: Exclude<IntakeSection, null>) => void;
@ -8641,6 +8826,7 @@ function FormalIntakeWorkbench({
onOpenProductPatentLedger: () => void;
onOpenProductPatentDashboard: () => void;
onOpenSpuRuleLibraryWorkspace?: () => void;
showDesignControlledProductData: boolean;
}) {
const menuRef = useRef<HTMLDivElement>(null);
const pendingEntryOptions: { key: IntakeEntryTab; label: string }[] = [
@ -8711,37 +8897,39 @@ function FormalIntakeWorkbench({
</div>
</AccordionSection>
<AccordionSection
title="BOM资料"
compact
icon={
<FormalSidebarIcon
icon={FORMAL_SIDEBAR_GROUP_ICONS.bom}
className={FORMAL_SIDEBAR_SECTION_ICON_CLASS}
/>
}
expanded={activeSection === "bom"}
onToggle={() => onToggle("bom")}
>
<div className="space-y-2">
{BOM_LIBRARY_OPTIONS.map((option) => (
<button
key={option.key}
type="button"
onClick={() => {
onBomLibraryChange(option.key);
}}
className={`flex w-full items-center rounded-apple-lg border px-3 py-2 text-left text-xs transition ${
activeSection === "bom" && activeBomLibrary === option.key
? "border-apple-blue bg-apple-blue/10 text-apple-blue"
: "border-apple-separator bg-apple-bg-secondary text-apple-label-secondary"
}`}
>
<span>{option.label}</span>
</button>
))}
</div>
</AccordionSection>
{showDesignControlledProductData ? (
<AccordionSection
title="BOM资料库"
compact
icon={
<FormalSidebarIcon
icon={FORMAL_SIDEBAR_GROUP_ICONS.bom}
className={FORMAL_SIDEBAR_SECTION_ICON_CLASS}
/>
}
expanded={activeSection === "bom"}
onToggle={() => onToggle("bom")}
>
<div className="space-y-2">
{BOM_LIBRARY_OPTIONS.map((option) => (
<button
key={option.key}
type="button"
onClick={() => {
onBomLibraryChange(option.key);
}}
className={`flex w-full items-center rounded-apple-lg border px-3 py-2 text-left text-xs transition ${
activeSection === "bom" && activeBomLibrary === option.key
? "border-apple-blue bg-apple-blue/10 text-apple-blue"
: "border-apple-separator bg-apple-bg-secondary text-apple-label-secondary"
}`}
>
<span>{option.label}</span>
</button>
))}
</div>
</AccordionSection>
) : null}
<AccordionSection
title="原材料资料库"
@ -8813,41 +9001,43 @@ function FormalIntakeWorkbench({
</button>
</AccordionSection>
<AccordionSection
title="产品专利"
compact
icon={
<FormalSidebarIcon
icon={FORMAL_SIDEBAR_GROUP_ICONS.patents}
className={FORMAL_SIDEBAR_SECTION_ICON_CLASS}
/>
}
expanded={activeSection === "patents"}
onToggle={onOpenProductPatents}
>
<button
type="button"
onClick={onOpenProductPatentLedger}
className={`flex w-full items-center rounded-apple-lg border px-3 py-2 text-left text-xs transition ${
activeSection === "patents"
? "border-apple-blue bg-apple-blue/10 text-apple-blue"
: "border-apple-separator bg-apple-bg-secondary text-apple-label-secondary"
}`}
{showDesignControlledProductData ? (
<AccordionSection
title="产品专利"
compact
icon={
<FormalSidebarIcon
icon={FORMAL_SIDEBAR_GROUP_ICONS.patents}
className={FORMAL_SIDEBAR_SECTION_ICON_CLASS}
/>
}
expanded={activeSection === "patents"}
onToggle={onOpenProductPatents}
>
<span>产品专利明细</span>
</button>
<button
type="button"
onClick={onOpenProductPatentDashboard}
className={`mt-2 flex w-full items-center rounded-apple-lg border px-3 py-2 text-left text-xs transition ${
activeSection === "patents"
? "border-apple-blue bg-apple-blue/10 text-apple-blue"
: "border-apple-separator bg-apple-bg-secondary text-apple-label-secondary"
}`}
>
<span>{PRODUCT_PATENT_DASHBOARD_LABEL}</span>
</button>
</AccordionSection>
<button
type="button"
onClick={onOpenProductPatentLedger}
className={`flex w-full items-center rounded-apple-lg border px-3 py-2 text-left text-xs transition ${
activeSection === "patents"
? "border-apple-blue bg-apple-blue/10 text-apple-blue"
: "border-apple-separator bg-apple-bg-secondary text-apple-label-secondary"
}`}
>
<span>产品专利明细</span>
</button>
<button
type="button"
onClick={onOpenProductPatentDashboard}
className={`mt-2 flex w-full items-center rounded-apple-lg border px-3 py-2 text-left text-xs transition ${
activeSection === "patents"
? "border-apple-blue bg-apple-blue/10 text-apple-blue"
: "border-apple-separator bg-apple-bg-secondary text-apple-label-secondary"
}`}
>
<span>{PRODUCT_PATENT_DASHBOARD_LABEL}</span>
</button>
</AccordionSection>
) : null}
{onOpenSpuRuleLibraryWorkspace ? (
<button
@ -8875,6 +9065,8 @@ export default function IntakeClient(props: IntakeClientProps) {
const canDeleteNonIntegratedProducts = canDeleteIntakeProducts(
props.user.role,
);
const canUseDesignControlledProductData =
canAccessDesignControlledProductData(props.user);
const shouldPersistWorkspaceState = !isEditorMode(props);
const shouldUseSharedNonIntegratedApi = process.env.NODE_ENV !== "test";
const [initialWorkspaceState] = useState(() =>
@ -8887,8 +9079,13 @@ export default function IntakeClient(props: IntakeClientProps) {
initialWorkspaceState.tabs[0] ??
null;
const [activeSection, setActiveSection] = useState<IntakeSection>(
props.initialActiveSection ??
getWorkspaceTabInitialSection(initialActiveWorkspaceTab),
canUseDesignControlledProductData
? (props.initialActiveSection ??
getWorkspaceTabInitialSection(initialActiveWorkspaceTab))
: restrictSectionForBusinessUser(
props.initialActiveSection ??
getWorkspaceTabInitialSection(initialActiveWorkspaceTab),
),
);
const [pendingEntryTab, setPendingEntryTab] = useState<IntakeEntryTab>(
getWorkspaceTabInitialPendingEntryTab(initialActiveWorkspaceTab),
@ -8998,16 +9195,22 @@ export default function IntakeClient(props: IntakeClientProps) {
userId: props.user.id,
shouldRestore: true,
});
const safeRestoredState = canUseDesignControlledProductData
? restoredState
: restrictWorkspaceStateForBusinessUser(restoredState);
const restoredActiveTab =
restoredState.tabs.find((tab) => tab.id === restoredState.activeTabId) ??
restoredState.tabs[0] ??
safeRestoredState.tabs.find(
(tab) => tab.id === safeRestoredState.activeTabId,
) ??
safeRestoredState.tabs[0] ??
null;
workspaceTabSequenceRef.current = restoredState.sequence;
setWorkspaceTabs(restoredState.tabs);
workspaceTabSequenceRef.current = safeRestoredState.sequence;
setWorkspaceTabs(safeRestoredState.tabs);
syncWorkspaceState(restoredActiveTab);
setWorkspaceStateReady(true);
}, [
canUseDesignControlledProductData,
props.initialNonIntegratedProducts,
props.user.id,
shouldPersistWorkspaceState,
@ -9351,6 +9554,10 @@ export default function IntakeClient(props: IntakeClientProps) {
productDevelopmentKind: ProductDevelopmentKind = "product",
initialCreateProductKind: NewNonIntegratedCreateKind = "new",
) {
if (!canUseDesignControlledProductData) {
return;
}
const existingTab = workspaceTabs.find(
(tab) =>
tab.kind === "newNonIntegratedProduct" &&
@ -9444,6 +9651,10 @@ export default function IntakeClient(props: IntakeClientProps) {
}
function openBomLibraryWorkspace(library: BomLibraryKey) {
if (!canUseDesignControlledProductData) {
return;
}
const tabId = createWorkspaceTabId("bom-library", library);
const nextTab: IntakeWorkspaceTab = {
id: tabId,
@ -9487,6 +9698,10 @@ export default function IntakeClient(props: IntakeClientProps) {
}
function openProductPatentDashboardWorkspace() {
if (!canUseDesignControlledProductData) {
return;
}
const tabId = createWorkspaceTabId("product-patent", "dashboard");
const nextTab: IntakeWorkspaceTab = {
id: tabId,
@ -9497,6 +9712,10 @@ export default function IntakeClient(props: IntakeClientProps) {
}
function openProductPatentLedgerWorkspace() {
if (!canUseDesignControlledProductData) {
return;
}
const tabId = createWorkspaceTabId("product-patent", "ledger");
const nextTab: IntakeWorkspaceTab = {
id: tabId,
@ -9520,6 +9739,10 @@ export default function IntakeClient(props: IntakeClientProps) {
}
function openProductPatents() {
if (!canUseDesignControlledProductData) {
return;
}
setActiveWorkspaceTabId(null);
setActiveSection((current) => toggleSection(current, "patents"));
}
@ -9626,16 +9849,22 @@ export default function IntakeClient(props: IntakeClientProps) {
/>
);
} else if (activeWorkspaceTab?.kind === "productPatentLedger") {
activeContent = (
<ProductPatentDashboard
products={nonIntegratedProducts}
user={props.user}
target={activeWorkspaceTab.target}
/>
);
activeContent =
canUseDesignControlledProductData || activeWorkspaceTab.target ? (
<ProductPatentDashboard
products={nonIntegratedProducts}
user={props.user}
target={activeWorkspaceTab.target}
readOnly={!canUseDesignControlledProductData}
/>
) : (
<EmptyDetail />
);
} else if (activeWorkspaceTab?.kind === "productPatentDashboard") {
activeContent = (
activeContent = canUseDesignControlledProductData ? (
<ProductPatentStatusDashboard products={nonIntegratedProducts} />
) : (
<EmptyDetail />
);
} else if (activeWorkspaceTab?.kind === "newNonIntegratedProduct") {
activeContent = (
@ -9687,9 +9916,13 @@ export default function IntakeClient(props: IntakeClientProps) {
useEffect(() => {
if (props.initialActiveSection) {
setActiveSection(props.initialActiveSection);
setActiveSection(
canUseDesignControlledProductData
? props.initialActiveSection
: restrictSectionForBusinessUser(props.initialActiveSection),
);
}
}, [props.initialActiveSection]);
}, [canUseDesignControlledProductData, props.initialActiveSection]);
useEffect(() => {
if (props.initialActiveRawMaterialLibrary) {
@ -9699,6 +9932,7 @@ export default function IntakeClient(props: IntakeClientProps) {
useEffect(() => {
if (
!canUseDesignControlledProductData ||
!props.initialOpenNewNonIntegratedProduct ||
initialOpenNewNonIntegratedProductRef.current
) {
@ -9707,7 +9941,11 @@ export default function IntakeClient(props: IntakeClientProps) {
initialOpenNewNonIntegratedProductRef.current = true;
openNewNonIntegratedProductWorkspace();
}, [openNewNonIntegratedProductWorkspace, props.initialOpenNewNonIntegratedProduct]);
}, [
canUseDesignControlledProductData,
openNewNonIntegratedProductWorkspace,
props.initialOpenNewNonIntegratedProduct,
]);
if (!isEditorMode(props)) {
const skuList = buildIntegratedSeriesRows({
@ -9740,13 +9978,19 @@ export default function IntakeClient(props: IntakeClientProps) {
canDeleteProducts={canDeleteNonIntegratedProducts}
currentUser={props.user}
productDevelopmentKind="series"
emptyMessage="当前还没有系列产品,点击右上角“新增产品”开始维护系列资料。"
emptyMessage={
canUseDesignControlledProductData
? "当前还没有系列产品,点击右上角“新增产品”开始维护系列资料。"
: "当前还没有系列产品。"
}
onProductsChange={(nextProducts) =>
handleScopedProductsChange("series", nextProducts)
}
onOpenProductWorkspace={openProductWorkspace}
onOpenNewNonIntegratedProductWorkspace={() =>
openNewNonIntegratedProductWorkspace("series")
onOpenNewNonIntegratedProductWorkspace={
canUseDesignControlledProductData
? () => openNewNonIntegratedProductWorkspace("series")
: undefined
}
/>
) : pendingEntryTab === "stock" ? (
@ -9773,8 +10017,15 @@ export default function IntakeClient(props: IntakeClientProps) {
handleScopedProductsChange("product", nextProducts, "new")
}
onOpenProductWorkspace={openProductWorkspace}
onOpenNewNonIntegratedProductWorkspace={() =>
openNewNonIntegratedProductWorkspace("product")
emptyMessage={
canUseDesignControlledProductData
? undefined
: "当前还没有产品开发明细。"
}
onOpenNewNonIntegratedProductWorkspace={
canUseDesignControlledProductData
? () => openNewNonIntegratedProductWorkspace("product")
: undefined
}
/>
);
@ -9787,7 +10038,7 @@ export default function IntakeClient(props: IntakeClientProps) {
onOpenProductWorkspace={openProductWorkspace}
/>
);
} else if (activeSection === "bom") {
} else if (activeSection === "bom" && canUseDesignControlledProductData) {
leftContent = (
<BomDetail
user={props.user}
@ -9811,7 +10062,10 @@ export default function IntakeClient(props: IntakeClientProps) {
materialLibrarySubstrates={props.materialLibrarySubstrates}
/>
);
} else if (activeSection === "patents") {
} else if (
activeSection === "patents" &&
canUseDesignControlledProductData
) {
leftContent = (
<ProductPatentDashboard products={nonIntegratedProducts} user={props.user} />
);
@ -9855,6 +10109,9 @@ export default function IntakeClient(props: IntakeClientProps) {
? openSpuRuleLibraryWorkspace
: undefined
}
showDesignControlledProductData={
canUseDesignControlledProductData
}
/>
)
}
@ -9888,13 +10145,19 @@ export default function IntakeClient(props: IntakeClientProps) {
canDeleteProducts={canDeleteNonIntegratedProducts}
currentUser={props.user}
productDevelopmentKind="series"
emptyMessage="当前还没有系列产品,点击右上角“新增产品”开始维护系列资料。"
emptyMessage={
canUseDesignControlledProductData
? "当前还没有系列产品,点击右上角“新增产品”开始维护系列资料。"
: "当前还没有系列产品。"
}
onProductsChange={(nextProducts) =>
handleScopedProductsChange("series", nextProducts)
}
onOpenProductWorkspace={openProductWorkspace}
onOpenNewNonIntegratedProductWorkspace={() =>
openNewNonIntegratedProductWorkspace("series")
onOpenNewNonIntegratedProductWorkspace={
canUseDesignControlledProductData
? () => openNewNonIntegratedProductWorkspace("series")
: undefined
}
/>
) : pendingEntryTab === "stock" ? (
@ -9921,8 +10184,15 @@ export default function IntakeClient(props: IntakeClientProps) {
handleScopedProductsChange("product", nextProducts, "new")
}
onOpenProductWorkspace={openProductWorkspace}
onOpenNewNonIntegratedProductWorkspace={() =>
openNewNonIntegratedProductWorkspace("product")
emptyMessage={
canUseDesignControlledProductData
? undefined
: "当前还没有产品开发明细。"
}
onOpenNewNonIntegratedProductWorkspace={
canUseDesignControlledProductData
? () => openNewNonIntegratedProductWorkspace("product")
: undefined
}
/>
);
@ -9935,7 +10205,7 @@ export default function IntakeClient(props: IntakeClientProps) {
onOpenProductWorkspace={openProductWorkspace}
/>
);
} else if (activeSection === "bom") {
} else if (activeSection === "bom" && canUseDesignControlledProductData) {
leftContent = (
<BomDetail
user={props.user}
@ -9959,7 +10229,10 @@ export default function IntakeClient(props: IntakeClientProps) {
materialLibrarySubstrates={props.materialLibrarySubstrates}
/>
);
} else if (activeSection === "patents") {
} else if (
activeSection === "patents" &&
canUseDesignControlledProductData
) {
leftContent = (
<ProductPatentDashboard products={nonIntegratedProducts} user={props.user} />
);
@ -10001,6 +10274,7 @@ export default function IntakeClient(props: IntakeClientProps) {
? openSpuRuleLibraryWorkspace
: undefined
}
showDesignControlledProductData={canUseDesignControlledProductData}
/>
)
}

View File

@ -3558,6 +3558,163 @@ describe("NonIntegratedDetailClient", () => {
).toBeInTheDocument();
});
it("restricts brand center planning users by department-owned nodes", async () => {
const actor = userEvent.setup();
saveNonIntegratedProduct(
createProductRecord({
id: "SZ0230-brand-center-planning-permission",
spu: "SZ0230",
productPlanning: "商品企划同事",
visualPlanning: "空间视觉同事",
visualDirector: "空间视觉同事",
openingReference: {
...createEmptyNonIntegratedOpeningReference(),
productName: "品牌中心权限测试",
planningOwner: "商品企划同事",
},
skuItems: [
createSkuItem({
id: "sku-brand-center-planning-permission-1",
model: "SZ0230K01",
salesStatus: "销售",
}),
],
uploadFiles: [
{
id: "planning-hidden-product-data",
name: "企划不可看产品资料.pdf",
category: "产品模型",
dataUrl: "data:application/pdf;base64,AAAA",
mimeType: "application/pdf",
},
{
id: "planning-readonly-review",
name: "企划只读样品评审.pdf",
category: "评审资料",
dataUrl: "data:application/pdf;base64,BBBB",
mimeType: "application/pdf",
},
{
id: "planning-readonly-photo",
name: "企划只读产品实拍.png",
category: "产品实拍图",
dataUrl: "data:image/png;base64,CCCC",
mimeType: "image/png",
},
],
}),
);
const { unmount } = render(
<NonIntegratedDetailClient
mode="edit"
productId="SZ0230-brand-center-planning-permission"
currentUserRole="USER"
currentUserName="商品企划同事"
currentUserBusinessRoles={["PLANNING"]}
currentUserDepartmentPaths={[
"江苏简尚品牌管理有限公司-品牌中心-商品企划部-桌类",
]}
/>,
);
expect(
screen.queryByTestId("document-workspace-trigger-产品模型"),
).not.toBeInTheDocument();
expect(
screen.queryByTestId("document-workspace-trigger-包装细节"),
).not.toBeInTheDocument();
expect(
screen.queryByTestId("document-workspace-trigger-产品定厂"),
).not.toBeInTheDocument();
expect(
screen.getByTestId("document-workspace-trigger-评审资料"),
).toBeInTheDocument();
expect(
screen.getByTestId("document-workspace-trigger-产品实拍图"),
).toBeInTheDocument();
await actor.click(screen.getByTestId("document-workspace-trigger-评审资料"));
expect(screen.queryByRole("button", { name: "新增轮次" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "导出Excel" })).not.toBeInTheDocument();
const confirmReviewButton = screen.getByRole("button", {
name: "确认评审资料",
});
expect(confirmReviewButton).toBeDisabled();
await actor.click(screen.getByTestId("document-workspace-trigger-产品实拍图"));
expect(
screen.queryByTestId("product-photo-detail-upload-input"),
).not.toBeInTheDocument();
expect(
screen.queryByTestId("product-photo-batch-download"),
).not.toBeInTheDocument();
await actor.click(screen.getByTestId("document-workspace-trigger-产品限价"));
await actor.type(
screen.getByTestId(
"product-limit-expectedDailyLimitPrice-sku-brand-center-planning-permission-1",
),
"1999",
);
await waitFor(() => {
const savedProduct = getNonIntegratedProductById(
"SZ0230-brand-center-planning-permission",
);
expect(savedProduct?.productLimitRows?.[0]?.expectedDailyLimitPrice).toBe(
"1999",
);
});
await actor.click(screen.getByTestId("document-workspace-trigger-企划布局"));
expect(
screen.queryByTestId(
"planning-layout-styles-draft-sku-brand-center-planning-permission-1",
),
).not.toBeInTheDocument();
expect(
screen.getByTestId("document-workspace-confirm-企划布局"),
).toBeDisabled();
unmount();
render(
<NonIntegratedDetailClient
mode="edit"
productId="SZ0230-brand-center-planning-permission"
currentUserRole="USER"
currentUserName="空间视觉同事"
currentUserBusinessRoles={["PLANNING"]}
currentUserDepartmentPaths={[
"江苏简尚品牌管理有限公司-品牌中心-空间视觉部",
]}
/>,
);
expect(
screen.queryByTestId("document-workspace-trigger-产品模型"),
).not.toBeInTheDocument();
expect(
screen.queryByTestId("document-workspace-trigger-包装细节"),
).not.toBeInTheDocument();
await actor.click(screen.getByTestId("document-workspace-trigger-企划布局"));
expect(
screen.getByTestId(
"planning-layout-styles-draft-sku-brand-center-planning-permission-1",
),
).toBeInTheDocument();
await actor.click(screen.getByTestId("document-workspace-trigger-产品限价"));
expect(
screen.getByTestId(
"product-limit-expectedDailyLimitPrice-sku-brand-center-planning-permission-1",
),
).toHaveAttribute("readonly");
});
it("hides redundant fields from the product detail summary", async () => {
const actor = userEvent.setup();
@ -4272,9 +4429,7 @@ describe("NonIntegratedDetailClient", () => {
).toHaveAttribute("readonly");
});
it("lets link users edit finished sample quantities only", async () => {
const actor = userEvent.setup();
it("hides finished sample shipments for link users", () => {
saveNonIntegratedProduct(
createProductRecord({
id: "SZ0230-finished-sample-link-user",
@ -4305,41 +4460,12 @@ describe("NonIntegratedDetailClient", () => {
);
const panel = screen.getByTestId("document-workspace-panel");
expect(screen.getByTestId("finished-sample-shipment-workspace")).toBeInTheDocument();
expect(panel).toHaveTextContent("图片");
expect(panel).toHaveTextContent("规格码");
expect(panel).toHaveTextContent("供应商名称");
expect(panel).toHaveTextContent("安装方式");
const quantityInput = screen.getByTestId(
"finished-sample-shipment-sampleQuantity-sku-finished-link-1",
);
expect(quantityInput).not.toHaveAttribute("readonly");
expect(
screen.getByTestId("finished-sample-shipment-shipped-sku-finished-link-1"),
).toBeDisabled();
expect(
screen.getByTestId(
"finished-sample-shipment-trackingNumber-sku-finished-link-1",
),
).toHaveAttribute("readonly");
expect(screen.getByTestId("finished-sample-shipment-confirm")).toBeDisabled();
await actor.type(quantityInput, "2");
await waitFor(() => {
const savedProduct = getNonIntegratedProductById(
"SZ0230-finished-sample-link-user",
);
expect(savedProduct?.finishedSampleShipmentRows).toEqual([
{
skuId: "sku-finished-link-1",
sampleQuantity: "2",
shipped: "",
trackingNumber: "",
},
]);
});
screen.queryByTestId("finished-sample-shipment-workspace"),
).not.toBeInTheDocument();
expect(screen.queryByTestId("document-workspace-trigger-成品寄样")).not.toBeInTheDocument();
expect(screen.getByTestId("opening-reference-workspace")).toBeInTheDocument();
expect(panel).toHaveTextContent("开品参考填写");
});
it("lets purchasing users fill and confirm finished sample shipments", async () => {
@ -4551,6 +4677,144 @@ describe("NonIntegratedDetailClient", () => {
});
});
it("lets supply chain pricing and quality groups maintain their own nodes", async () => {
const actor = userEvent.setup();
saveNonIntegratedProduct(
createProductRecord({
id: "SZ0230-supply-chain-group-permission",
skuBatchConfirmedAt: CONFIRMED_SKU_BATCH_AT,
designer: "测试设计师",
skuItems: [
createSkuItem({
id: "sku-supply-chain-group-permission-1",
model: "SZ0230K01",
skuCode: "SZ023001",
barcode: "SZ023001N1280V01",
manufacturer: "兰考华兰家具",
}),
],
factoryRows: [
{
skuId: "sku-supply-chain-group-permission-1",
confirmed: true,
model: "SZ0230K01",
skuCode: "SZ023001",
setCount: "1",
barcode: "SZ023001N1280V01",
specName: "款式一 140/60/75",
size: "140/60/75",
color: "胡桃色",
material: "橡胶木",
installMethod: "整装",
supplierName: "兰考华兰家具",
supplierCode: "N1280",
warehouse: "",
minimumOrderQuantity: "",
leadTime: "",
assemblyMode: "",
productionType: "",
installationService: "",
supplierCategory: "",
purchaseOrderMethod: "",
executeStandard: "GB/T3324-2024",
},
],
}),
);
const { unmount } = render(
<NonIntegratedDetailClient
mode="edit"
productId="SZ0230-supply-chain-group-permission"
currentUserRole="USER"
currentUserName="核价同事"
currentUserBusinessRoles={["PURCHASING"]}
currentUserDepartmentPaths={[
"江苏简尚品牌管理有限公司-供应链中心-核价组",
]}
/>,
);
expect(
screen.getByTestId("document-workspace-trigger-样包测量"),
).toBeInTheDocument();
expect(
screen.getByTestId("document-workspace-trigger-产品核价"),
).toBeInTheDocument();
expect(
screen.queryByTestId("document-workspace-trigger-工厂质检"),
).not.toBeInTheDocument();
await actor.click(screen.getByTestId("document-workspace-trigger-样包测量"));
const sampleLengthInput = screen.getByTestId(
"sample-measurement-lengthCm-sku-supply-chain-group-permission-1",
);
expect(sampleLengthInput).not.toHaveAttribute("readonly");
await actor.type(sampleLengthInput, "120");
await actor.click(screen.getByTestId("document-workspace-trigger-产品核价"));
const costPriceInput = screen.getByPlaceholderText("0");
expect(costPriceInput).not.toHaveAttribute("readonly");
await actor.type(costPriceInput, "88");
await waitFor(() => {
const savedProduct = getNonIntegratedProductById(
"SZ0230-supply-chain-group-permission",
);
expect(savedProduct?.sampleMeasurementRows?.[0]?.lengthCm).toBe("120");
expect(savedProduct?.pricingRows?.[0]?.costPrice).toBe("88");
});
unmount();
render(
<NonIntegratedDetailClient
mode="edit"
productId="SZ0230-supply-chain-group-permission"
currentUserRole="USER"
currentUserName="质检同事"
currentUserBusinessRoles={["PURCHASING"]}
currentUserDepartmentPaths={[
"江苏简尚品牌管理有限公司-供应链中心-品牌 采购部-质检组",
]}
/>,
);
expect(
screen.getByTestId("document-workspace-trigger-工厂质检"),
).toBeInTheDocument();
expect(
screen.queryByTestId("document-workspace-trigger-产品限价"),
).not.toBeInTheDocument();
expect(
screen.queryByTestId("document-workspace-trigger-开品参考"),
).not.toBeInTheDocument();
await actor.click(screen.getByTestId("document-workspace-trigger-工厂质检"));
const firstQualityWorkspace = screen.getByTestId("factory-quality-workspace");
expect(firstQualityWorkspace).toBeInTheDocument();
const qualityBatchInput = screen.getByPlaceholderText("填写质检批次");
expect(qualityBatchInput).not.toHaveAttribute("readonly");
await actor.type(qualityBatchInput, "FQ-001");
await selectInlineOption(
actor,
screen.getByLabelText("SZ0230K01质检结论"),
"合格",
);
await waitFor(() => {
const savedProduct = getNonIntegratedProductById(
"SZ0230-supply-chain-group-permission",
);
expect(savedProduct?.firstQualityRows?.[0]).toMatchObject({
skuId: "sku-supply-chain-group-permission-1",
qualityBatch: "FQ-001",
conclusion: "合格",
});
});
});
it("lets planning users edit opening reference, planning layout, and product limit nodes", async () => {
const actor = userEvent.setup();
const openingReferenceCategory = "开品参考";
@ -4631,13 +4895,9 @@ describe("NonIntegratedDetailClient", () => {
);
});
await actor.click(
screen.getByTestId(`document-workspace-trigger-${sampleMeasurementCategory}`),
);
expect(
screen.getByTestId("sample-measurement-lengthCm-sku-planning-permission-1"),
).toHaveAttribute("readonly");
screen.queryByTestId(`document-workspace-trigger-${sampleMeasurementCategory}`),
).not.toBeInTheDocument();
});
it("lets supervisors edit product detail nodes", () => {
@ -4669,7 +4929,7 @@ describe("NonIntegratedDetailClient", () => {
expect(screen.getByLabelText("开品名称")).not.toHaveAttribute("readonly");
});
it("keeps regular users read-only in purchasing document nodes", async () => {
it("hides purchasing document nodes for regular users", () => {
const sampleMeasurementCategory = "样包测量";
saveNonIntegratedProduct(
@ -4697,8 +4957,10 @@ describe("NonIntegratedDetailClient", () => {
);
expect(
screen.getByTestId("sample-measurement-lengthCm-sku-regular-permission-1"),
).toHaveAttribute("readonly");
screen.queryByTestId("sample-measurement-lengthCm-sku-regular-permission-1"),
).not.toBeInTheDocument();
expect(screen.queryByTestId("document-workspace-trigger-样包测量")).not.toBeInTheDocument();
expect(screen.getByTestId("opening-reference-workspace")).toBeInTheDocument();
});
it("renders first quality rows by SKU batch", async () => {
@ -4967,7 +5229,7 @@ describe("NonIntegratedDetailClient", () => {
/>,
);
await actor.click(screen.getByTestId("document-workspace-trigger-产品定厂"));
expect(screen.queryByTestId("document-workspace-trigger-产品定厂")).not.toBeInTheDocument();
expect(
screen.queryByTestId("product-factory-reopen-confirmation"),
).not.toBeInTheDocument();

View File

@ -260,10 +260,14 @@ import {
} from "./supplierDirectory";
import {
canCreateNonIntegratedProductFromIntake,
canEditAnyNonIntegratedWorkspace,
canEditNonIntegratedProductIdentity,
canEditNonIntegratedWorkspace,
isCurrentUserNonIntegratedPurchaseOwner,
isIntakeNonIntegratedBrandCenterPlanningUser,
isIntakeNonIntegratedFullEditor,
isIntakeNonIntegratedPricingGroupUser,
isIntakeNonIntegratedQualityGroupUser,
isIntakeNonIntegratedSupplyChainUser,
type IntakeNonIntegratedPermissionUser,
} from "@/lib/non-integrated-permissions";
@ -1054,10 +1058,60 @@ const PURCHASE_HIDDEN_DOCUMENT_WORKSPACE_CATEGORIES =
function isPurchaseHiddenDocumentWorkspaceCategory(
category: DocumentWorkspaceCategory,
options?: { canViewFactoryQuality?: boolean },
) {
if (category === "工厂质检" && options?.canViewFactoryQuality) {
return false;
}
return PURCHASE_HIDDEN_DOCUMENT_WORKSPACE_CATEGORIES.has(category);
}
const PLANNING_HIDDEN_DOCUMENT_WORKSPACE_CATEGORIES =
new Set<DocumentWorkspaceCategory>([
PRODUCT_DATA_WORKSPACE_CATEGORY,
PATENT_APPLICATION_WORKSPACE_CATEGORY,
PACKAGING_WORKSPACE_CATEGORY,
PRODUCT_FACTORY_WORKSPACE_CATEGORY,
SAMPLE_MEASUREMENT_WORKSPACE_CATEGORY,
FINISHED_SAMPLE_SHIPMENT_WORKSPACE_CATEGORY,
"工厂质检",
DETECTION_WORKSPACE_CATEGORY,
]);
const PLANNING_DOWNLOADABLE_DOCUMENT_WORKSPACE_CATEGORIES =
new Set<DocumentWorkspaceCategory>(["开品参考", "设计卖点"]);
const PLAIN_VIEWER_HIDDEN_DOCUMENT_WORKSPACE_CATEGORIES =
new Set<DocumentWorkspaceCategory>([
PRODUCT_DATA_WORKSPACE_CATEGORY,
REVIEW_WORKSPACE_CATEGORY,
PATENT_APPLICATION_WORKSPACE_CATEGORY,
PACKAGING_WORKSPACE_CATEGORY,
PRODUCT_FACTORY_WORKSPACE_CATEGORY,
SAMPLE_MEASUREMENT_WORKSPACE_CATEGORY,
FINISHED_SAMPLE_SHIPMENT_WORKSPACE_CATEGORY,
"工厂质检",
]);
function isPlanningHiddenDocumentWorkspaceCategory(
category: DocumentWorkspaceCategory,
) {
return PLANNING_HIDDEN_DOCUMENT_WORKSPACE_CATEGORIES.has(category);
}
function isPlainViewerHiddenDocumentWorkspaceCategory(
category: DocumentWorkspaceCategory,
) {
return PLAIN_VIEWER_HIDDEN_DOCUMENT_WORKSPACE_CATEGORIES.has(category);
}
function canPlanningDownloadDocumentWorkspaceCategory(
category: DocumentWorkspaceCategory,
) {
return PLANNING_DOWNLOADABLE_DOCUMENT_WORKSPACE_CATEGORIES.has(category);
}
function getUniqueNonEmptyStrings(
values: ReadonlyArray<string | null | undefined>,
) {
@ -11081,22 +11135,82 @@ export default function NonIntegratedDetailClient({
currentUserRole === "ADMIN" || currentUserRole === "SUPER_ADMIN";
const isCurrentUserFullDetailEditor =
isIntakeNonIntegratedFullEditor(currentPermissionUser);
const isCurrentUserSupplyChainPurchaseDepartment =
isIntakeNonIntegratedSupplyChainUser(currentPermissionUser);
const isCurrentUserSupplyChainPricingGroup =
isIntakeNonIntegratedPricingGroupUser(currentPermissionUser);
const isCurrentUserSupplyChainQualityGroup =
isIntakeNonIntegratedQualityGroupUser(currentPermissionUser);
const isCurrentUserPurchaseDownloadRestricted =
hasCurrentUserContext &&
!isCurrentUserFullDetailEditor &&
isIntakeNonIntegratedSupplyChainUser(currentPermissionUser);
(isCurrentUserSupplyChainPurchaseDepartment ||
isCurrentUserSupplyChainPricingGroup ||
isCurrentUserSupplyChainQualityGroup);
const isCurrentUserPlanningContentRestricted =
hasCurrentUserContext &&
!isCurrentUserFullDetailEditor &&
isIntakeNonIntegratedBrandCenterPlanningUser(currentPermissionUser);
const canCurrentUserEditAnyConfiguredWorkspace =
hasCurrentUserContext &&
canEditAnyNonIntegratedWorkspace(
currentPermissionProduct,
currentPermissionUser,
);
const isCurrentUserPlainReadOnlyViewer =
hasCurrentUserContext &&
!isCurrentUserFullDetailEditor &&
!canCurrentUserEditAnyConfiguredWorkspace &&
!isCurrentUserPurchaseDownloadRestricted &&
!isCurrentUserPlanningContentRestricted;
const isCurrentUserWorkspaceDownloadRestricted =
isCurrentUserPurchaseDownloadRestricted ||
isCurrentUserPlanningContentRestricted;
const canPurchaseDownloadActiveWorkspace =
isIntakeNonIntegratedSupplyChainUser(currentPermissionUser) &&
isCurrentUserSupplyChainPurchaseDepartment &&
(activeDocumentWorkspaceCategory === PRODUCT_DATA_WORKSPACE_CATEGORY ||
activeDocumentWorkspaceCategory === REVIEW_WORKSPACE_CATEGORY);
const canPlanningDownloadActiveWorkspace =
isIntakeNonIntegratedBrandCenterPlanningUser(currentPermissionUser) &&
canPlanningDownloadDocumentWorkspaceCategory(activeDocumentWorkspaceCategory);
const canDownloadWorkspaceFiles =
!isCurrentUserPurchaseDownloadRestricted ||
canPurchaseDownloadActiveWorkspace;
!isCurrentUserPlainReadOnlyViewer &&
(!isCurrentUserWorkspaceDownloadRestricted ||
canPurchaseDownloadActiveWorkspace ||
canPlanningDownloadActiveWorkspace);
const isDocumentWorkspaceCategoryVisibleForCurrentUser = useCallback(
(category: DocumentWorkspaceCategory) =>
!isCurrentUserPurchaseDownloadRestricted ||
!isPurchaseHiddenDocumentWorkspaceCategory(category),
[isCurrentUserPurchaseDownloadRestricted],
(category: DocumentWorkspaceCategory) => {
if (
isCurrentUserPlainReadOnlyViewer &&
isPlainViewerHiddenDocumentWorkspaceCategory(category)
) {
return false;
}
if (
isCurrentUserPurchaseDownloadRestricted &&
isPurchaseHiddenDocumentWorkspaceCategory(category, {
canViewFactoryQuality: isCurrentUserSupplyChainQualityGroup,
})
) {
return false;
}
if (
isCurrentUserPlanningContentRestricted &&
isPlanningHiddenDocumentWorkspaceCategory(category)
) {
return false;
}
return true;
},
[
isCurrentUserPlanningContentRestricted,
isCurrentUserPlainReadOnlyViewer,
isCurrentUserPurchaseDownloadRestricted,
isCurrentUserSupplyChainQualityGroup,
],
);
const currentUserNameNormalized = currentUserName?.trim() ?? "";
const currentUserIdNormalized = currentUserId?.trim() ?? "";
@ -14286,8 +14400,11 @@ export default function NonIntegratedDetailClient({
useEffect(() => {
if (mode === "create") return;
if (documentWorkspaceView !== "document") return;
if (!isCurrentUserPurchaseDownloadRestricted) return;
if (!isPurchaseHiddenDocumentWorkspaceCategory(activeDocumentWorkspaceCategory)) {
if (
isDocumentWorkspaceCategoryVisibleForCurrentUser(
activeDocumentWorkspaceCategory,
)
) {
return;
}
@ -14298,7 +14415,7 @@ export default function NonIntegratedDetailClient({
}, [
activeDocumentWorkspaceCategory,
documentWorkspaceView,
isCurrentUserPurchaseDownloadRestricted,
isDocumentWorkspaceCategoryVisibleForCurrentUser,
mode,
visibleDocumentWorkspaceCategories,
]);
@ -18303,6 +18420,7 @@ export default function NonIntegratedDetailClient({
function renderDocumentWorkspaceConfirmationAction() {
const category = activeDocumentWorkspaceCategory;
if (isCurrentUserPlainReadOnlyViewer) return null;
if (!shouldRequireWorkflowConfirmation(category)) return null;
const hasContent = activeDocumentWorkspaceHasContent;
@ -18338,6 +18456,7 @@ export default function NonIntegratedDetailClient({
function renderBasicInfoConfirmationAction() {
if (mode === "create") return null;
if (isCurrentUserPlainReadOnlyViewer) return null;
if (basicInfoIsApproved) {
return (
@ -21848,31 +21967,33 @@ export default function NonIntegratedDetailClient({
>
<div className="mb-2 flex shrink-0 flex-wrap items-start justify-between gap-2 border-b border-apple-separator pb-2">
{renderFirstQualityWorkspacePanelTabs()}
<div className={DOCUMENT_WORKSPACE_ACTION_GROUP_CLASS}>
{canDownloadWorkspaceFiles ? (
{isCurrentUserPlainReadOnlyViewer ? null : (
<div className={DOCUMENT_WORKSPACE_ACTION_GROUP_CLASS}>
{canDownloadWorkspaceFiles ? (
<DocumentWorkspaceActionButton
onClick={() =>
handleDownloadWorkspaceFiles(factoryQualityFiles)
}
disabled={factoryQualityFiles.length === 0}
>
批量下载
</DocumentWorkspaceActionButton>
) : null}
<DocumentWorkspaceActionButton
onClick={() =>
handleDownloadWorkspaceFiles(factoryQualityFiles)
onClick={() => void handleConfirmFirstQualityRows()}
disabled={
isReadOnly ||
displayedProductFirstQualityRows.length === 0 ||
!displayedFirstQualityRowsComplete ||
displayedFirstQualityIsConfirmed
}
disabled={factoryQualityFiles.length === 0}
>
批量下载
{displayedFirstQualityIsConfirmed
? "已质检"
: "确认首批质检"}
</DocumentWorkspaceActionButton>
) : null}
<DocumentWorkspaceActionButton
onClick={() => void handleConfirmFirstQualityRows()}
disabled={
isReadOnly ||
displayedProductFirstQualityRows.length === 0 ||
!displayedFirstQualityRowsComplete ||
displayedFirstQualityIsConfirmed
}
>
{displayedFirstQualityIsConfirmed
? "已质检"
: "确认首批质检"}
</DocumentWorkspaceActionButton>
</div>
</div>
)}
</div>
<div className="min-h-0 flex-1 overflow-hidden">
{renderFactoryQualityWorkspace()}
@ -21887,69 +22008,71 @@ export default function NonIntegratedDetailClient({
>
<div className="mb-2 flex shrink-0 flex-wrap items-start justify-between gap-2 border-b border-apple-separator pb-2">
{renderFirstQualityWorkspacePanelTabs()}
<div className={DOCUMENT_WORKSPACE_ACTION_GROUP_CLASS}>
{isReadOnly ? null : (
<>
<DocumentWorkspaceActionButton
size="small"
onClick={() =>
documentWorkspaceUploadInputRef.current?.click()
}
>
批量上传
</DocumentWorkspaceActionButton>
<input
ref={documentWorkspaceUploadInputRef}
type="file"
multiple
accept={QUALITY_STANDARD_FILE_ACCEPT}
data-testid="quality-standard-upload-input"
className="hidden"
onChange={(event) =>
void handleUploadDetectionQualityStandardFiles(event)
}
/>
</>
)}
{canDownloadWorkspaceFiles ? (
<>
<DocumentWorkspaceActionButton
onClick={() =>
handleDownloadWorkspaceFiles(qualityStandardFiles)
}
disabled={qualityStandardFiles.length === 0}
>
批量下载
</DocumentWorkspaceActionButton>
{isCurrentUserPlainReadOnlyViewer ? null : (
<div className={DOCUMENT_WORKSPACE_ACTION_GROUP_CLASS}>
{isReadOnly ? null : (
<>
<DocumentWorkspaceActionButton
size="small"
onClick={() =>
documentWorkspaceUploadInputRef.current?.click()
}
>
批量上传
</DocumentWorkspaceActionButton>
<input
ref={documentWorkspaceUploadInputRef}
type="file"
multiple
accept={QUALITY_STANDARD_FILE_ACCEPT}
data-testid="quality-standard-upload-input"
className="hidden"
onChange={(event) =>
void handleUploadDetectionQualityStandardFiles(event)
}
/>
</>
)}
{canDownloadWorkspaceFiles ? (
<>
<DocumentWorkspaceActionButton
onClick={() =>
handleDownloadWorkspaceFiles(qualityStandardFiles)
}
disabled={qualityStandardFiles.length === 0}
>
批量下载
</DocumentWorkspaceActionButton>
<DocumentWorkspaceActionButton
onClick={() =>
effectiveSelectedWorkspaceFile
? handleDownloadUploadFile(
effectiveSelectedWorkspaceFile,
)
: undefined
}
disabled={!effectiveSelectedWorkspaceFile}
>
下载
</DocumentWorkspaceActionButton>
</>
) : null}
{isReadOnly ? null : (
<DocumentWorkspaceActionButton
onClick={() =>
effectiveSelectedWorkspaceFile
? handleDownloadUploadFile(
effectiveSelectedWorkspaceFile,
? handleDeleteUploadFile(
effectiveSelectedWorkspaceFile.id,
)
: undefined
}
disabled={!effectiveSelectedWorkspaceFile}
>
下载
删除
</DocumentWorkspaceActionButton>
</>
) : null}
{isReadOnly ? null : (
<DocumentWorkspaceActionButton
onClick={() =>
effectiveSelectedWorkspaceFile
? handleDeleteUploadFile(
effectiveSelectedWorkspaceFile.id,
)
: undefined
}
disabled={!effectiveSelectedWorkspaceFile}
>
删除
</DocumentWorkspaceActionButton>
)}
</div>
)}
</div>
)}
</div>
<div className="min-h-0 flex-1 overflow-hidden">
{renderQualityStandardWorkspace()}
@ -23703,23 +23826,25 @@ export default function NonIntegratedDetailClient({
>
{isSystemEntered ? "已录入" : "未录入"}
</span>
<button
type="button"
disabled
title={
isSystemEntered
? "当前 SKU 已录入系统,后续以聚水潭回传状态为准"
: PRODUCT_PRICING_JST_PUSH_ENABLED &&
pricingReadyForFuturePush
? "请由采购/核价确认推送"
: pricingReadyForFuturePush
? "聚水潭接口未接入,暂不可确认推送"
: "请先填写成本价;聚水潭接口未接入,暂不可确认推送"
}
className="inline-flex h-7 min-w-[52px] items-center justify-center rounded-full bg-[#1f8bff] px-3 text-xs font-apple-medium text-white shadow-sm transition hover:bg-[#1478df] disabled:cursor-not-allowed disabled:bg-apple-bg-tertiary disabled:text-apple-label-tertiary disabled:shadow-none"
>
确认推送
</button>
{isCurrentUserPlainReadOnlyViewer ? null : (
<button
type="button"
disabled
title={
isSystemEntered
? "当前 SKU 已录入系统,后续以聚水潭回传状态为准"
: PRODUCT_PRICING_JST_PUSH_ENABLED &&
pricingReadyForFuturePush
? "请由采购/核价确认推送"
: pricingReadyForFuturePush
? "聚水潭接口未接入,暂不可确认推送"
: "请先填写成本价;聚水潭接口未接入,暂不可确认推送"
}
className="inline-flex h-7 min-w-[52px] items-center justify-center rounded-full bg-[#1f8bff] px-3 text-xs font-apple-medium text-white shadow-sm transition hover:bg-[#1478df] disabled:cursor-not-allowed disabled:bg-apple-bg-tertiary disabled:text-apple-label-tertiary disabled:shadow-none"
>
确认推送
</button>
)}
</div>
</td>
</tr>
@ -24560,7 +24685,9 @@ function renderFactoryQualityWorkspace() {
开品参考填写
</p>
<p className="mt-0.5 text-[11px] leading-4 text-apple-label-secondary">
开品前先记录参考图、品牌方向、类目、竞品、开品理由和设计卖点。立项通过后再补产品资料、样品评审和完整基础信息。
{isCurrentUserPlainReadOnlyViewer
? "开品前记录参考图、品牌方向、类目、竞品、开品理由和设计卖点。"
: "开品前先记录参考图、品牌方向、类目、竞品、开品理由和设计卖点。立项通过后再补产品资料、样品评审和完整基础信息。"}
</p>
</div>
<div className={DOCUMENT_WORKSPACE_ACTION_GROUP_CLASS}>
@ -25109,6 +25236,8 @@ function renderFactoryQualityWorkspace() {
hasContent: boolean;
label: string;
}) {
if (isCurrentUserPlainReadOnlyViewer) return null;
const isApproved = isWorkflowNodeConfirmed(category, hasContent);
if (isApproved) {
@ -27550,7 +27679,10 @@ function renderFactoryQualityWorkspace() {
active: activeSkuTab === option.key,
})}
>
{option.label}
{isCurrentUserPlainReadOnlyViewer &&
option.key === "fileUpload"
? "资料文件"
: option.label}
</button>
))}
{canDownloadWorkspaceFiles ? (
@ -29908,6 +30040,7 @@ function renderFactoryQualityWorkspace() {
bodyClassName="flex min-h-0 flex-1 flex-col"
compact={isProductPhotoWorkspace}
headerActions={
isCurrentUserPlainReadOnlyViewer ? null :
isOpeningReferenceWorkspace ||
isPatentApplicationWorkspace ||
isProjectReviewWorkspace ? null : isPlanningLayoutWorkspace ? (
@ -30828,14 +30961,16 @@ function renderFactoryQualityWorkspace() {
</>
) : (
<span className="px-2 text-xs text-apple-label-secondary">
待上传
{isReadOnly ? "暂无图片" : "待上传"}
</span>
)}
</div>
<p className="mt-1 truncate text-[10px] leading-4 text-apple-label-secondary">
{slotFile
? "已匹配"
: "点击上传"}
: isReadOnly
? "暂无图片"
: "点击上传"}
</p>
</button>
);
@ -30861,9 +30996,9 @@ function renderFactoryQualityWorkspace() {
当前还没有{activeProductPhotoPanelTitle}
</p>
<p className="mt-2 text-xs leading-5 text-apple-label-secondary">
点击这里或上方“
{currentProductPhotoGroupUploadLabel}
”后,这里会显示图片缩略图列表。
{isReadOnly
? `当前还没有${activeProductPhotoPanelTitle}资料。`
: `点击这里或上方“${currentProductPhotoGroupUploadLabel}”后,这里会显示图片缩略图列表。`}
</p>
</button>
) : (

View File

@ -1,9 +1,14 @@
'use client'
import { Suspense, useEffect, type ReactNode } from 'react'
import { usePathname, useSearchParams } from 'next/navigation'
import { Suspense, useEffect, useState, type ReactNode } from 'react'
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
import { AppTopNav } from './AppTopNav'
import { AppSideNav } from './AppSideNav'
import {
FORMAL_PRODUCTS_HOME_PATH,
canAccessWorkspacePath,
type WorkspacePermissionUser,
} from '@/lib/workspace-route-permissions'
// AppShell 只服务新平台主链;旧 /products 兼容链和工具页不进入这套全局壳子
export const APP_SHELL_ROOTS = [
@ -32,19 +37,35 @@ export function shouldUseAppShell(pathname: string): boolean {
interface AppShellProps {
children: ReactNode
user?: {
user?: WorkspaceShellUser | null
}
interface WorkspaceShellUser extends WorkspacePermissionUser {
id: string
name: string
email: string
role: string
businessRoles?: string[]
avatar: string | null
department: { id: string; name: string } | null
} | null
department: { id: string; name: string; fullPath?: string | null } | null
departmentPaths?: string[]
}
interface AuthMeResponse {
success?: boolean
data?: {
authenticated?: boolean
user?: WorkspaceShellUser
}
}
function AppShellContent({ children, user }: AppShellProps) {
const pathname = usePathname()
const router = useRouter()
const searchParams = useSearchParams()
const [fetchedUser, setFetchedUser] = useState<WorkspaceShellUser | null | undefined>(
user === undefined ? undefined : user,
)
const isOpeningDetailLinkView =
/^\/intake\/non-integrated\/[^/]+$/.test(pathname) &&
searchParams.get('workspace') === 'opening-reference'
@ -77,6 +98,57 @@ function AppShellContent({ children, user }: AppShellProps) {
const shouldLockMainViewport =
isWorkbenchShellRoute
useEffect(() => {
if (user !== undefined) {
setFetchedUser(user)
return
}
if (!shouldUseAppShell(pathname)) {
return
}
let active = true
void (async () => {
try {
const response = await fetch('/api/auth/me', { cache: 'no-store' })
const payload = (await response.json().catch(() => null)) as AuthMeResponse | null
if (!active) return
if (response.ok && payload?.success && payload.data?.authenticated && payload.data.user) {
setFetchedUser(payload.data.user)
return
}
setFetchedUser(null)
} catch {
if (active) {
setFetchedUser(null)
}
}
})()
return () => {
active = false
}
}, [pathname, user])
useEffect(() => {
if (fetchedUser === undefined) {
return
}
if (!shouldUseAppShell(pathname)) {
return
}
if (!canAccessWorkspacePath(pathname, fetchedUser)) {
router.replace(FORMAL_PRODUCTS_HOME_PATH)
}
}, [fetchedUser, pathname, router])
useEffect(() => {
if (!shouldLockMainViewport || typeof document === 'undefined') {
return
@ -122,7 +194,7 @@ function AppShellContent({ children, user }: AppShellProps) {
return (
<div className="min-h-screen" style={{ backgroundColor: 'var(--color-surface)' }}>
<AppTopNav
user={user}
user={user === undefined ? fetchedUser ?? null : user}
variant={isWorkbenchShellRoute ? 'workbench' : 'default'}
/>
{!isWorkbenchShellRoute && <AppSideNav />}

View File

@ -15,6 +15,12 @@ import {
} from 'lucide-react'
import UserMenu from '@/components/ui/UserMenu'
import { APP_BRAND_LOGO_SRC, APP_BRAND_NAME } from '@/lib/branding'
import {
FORMAL_PRODUCTS_HOME_PATH,
canAccessWorkspacePath,
getWorkspaceAccessLevel,
type WorkspacePermissionUser,
} from '@/lib/workspace-route-permissions'
const WORKBENCH_NAV_ITEMS = [
{ href: '/', icon: Home, label: '首页概览' },
@ -32,14 +38,16 @@ function matchesWorkbenchHref(pathname: string, href: string) {
}
interface AppTopNavProps {
user?: {
user?: ({
id: string
name: string
email: string
role: string
businessRoles?: string[]
avatar: string | null
department: { id: string; name: string } | null
} | null
department: { id: string; name: string; fullPath?: string | null } | null
departmentPaths?: string[]
} & WorkspacePermissionUser) | null
variant?: 'default' | 'workbench'
}
@ -47,9 +55,16 @@ export function AppTopNav({ user, variant = 'default' }: AppTopNavProps) {
const pathname = usePathname() ?? '/'
if (variant === 'workbench') {
const shouldRestrictNavigation =
user !== undefined && getWorkspaceAccessLevel(user) === 'formal-products-only'
const brandHref = shouldRestrictNavigation ? FORMAL_PRODUCTS_HOME_PATH : '/'
const visibleNavItems = shouldRestrictNavigation
? WORKBENCH_NAV_ITEMS.filter((item) => canAccessWorkspacePath(item.href, user))
: WORKBENCH_NAV_ITEMS
return (
<nav className="fixed left-0 right-0 top-0 z-50 flex h-16 items-center border-b border-black/[0.04] bg-white/[0.92] px-8 font-[Manrope,sans-serif] tracking-tight backdrop-blur-2xl">
<Link href="/" className="flex min-w-[172px] items-center gap-2.5">
<Link href={brandHref} className="flex min-w-[172px] items-center gap-2.5">
<img
src={APP_BRAND_LOGO_SRC}
alt=""
@ -66,7 +81,7 @@ export function AppTopNav({ user, variant = 'default' }: AppTopNavProps) {
<div className="mx-7 h-8 w-px bg-black/[0.05]" />
<div className="flex items-center gap-1.5">
{WORKBENCH_NAV_ITEMS.map((item) => {
{visibleNavItems.map((item) => {
const Icon = item.icon
const active = matchesWorkbenchHref(pathname, item.href)

View File

@ -2,9 +2,11 @@ import { render, screen } from '@testing-library/react'
import { AppShell, shouldUseAppShell } from '../AppShell'
const usePathnameMock = jest.fn()
const replaceMock = jest.fn()
jest.mock('next/navigation', () => ({
usePathname: () => usePathnameMock(),
useRouter: () => ({ replace: replaceMock }),
useSearchParams: () => new URLSearchParams(),
}))
@ -23,6 +25,7 @@ jest.mock('@/components/layout/AppSideNav', () => ({
describe('AppShell', () => {
beforeEach(() => {
usePathnameMock.mockReset()
replaceMock.mockReset()
})
it('只对新平台主链页面启用 AppShell', () => {

View File

@ -8,6 +8,8 @@ export type AccessoryLibraryItem = {
letter: string
code: string
name: string
color?: string
material?: string
price: string
imageUrl: string
imageName: string
@ -71,6 +73,8 @@ export function readAccessoryLibraryItems() {
typeof item.letter === 'string' &&
typeof item.code === 'string' &&
typeof item.name === 'string' &&
(item.color === undefined || typeof item.color === 'string') &&
(item.material === undefined || typeof item.material === 'string') &&
typeof item.price === 'string' &&
typeof item.imageUrl === 'string' &&
typeof item.imageName === 'string' &&

View File

@ -5,6 +5,7 @@
import jwt from 'jsonwebtoken';
import { Role, UserStatus } from '@prisma/client';
import type { WorkspaceAccessLevel } from '@/lib/workspace-route-permissions';
/**
* JWT 配置 - 安全增强
@ -39,6 +40,7 @@ export interface TokenPayload {
isFirstLogin: boolean;
departmentId?: string;
teamId?: string;
workspaceAccess?: WorkspaceAccessLevel;
exp?: number; // JWT 过期时间(Unix 时间戳)
iat?: number; // JWT 签发时间(Unix 时间戳)
}

View File

@ -166,6 +166,10 @@ function isBrandPlanningDepartmentPath(path: string) {
return /商品企划部/.test(path)
}
function isBrandCenterDepartmentPath(path: string) {
return /品牌中心/.test(path)
}
function isSpaceVisualDepartmentPath(path: string) {
return /空间视觉部/.test(path)
}
@ -237,6 +241,15 @@ export function isIntakeNonIntegratedPlanningDepartmentUser(
)
}
export function isIntakeNonIntegratedBrandCenterPlanningUser(
user: IntakeNonIntegratedPermissionUser,
) {
return (
hasDepartmentPath(user, isBrandCenterDepartmentPath) ||
hasBusinessRoleFallback(user, 'PLANNING')
)
}
export function isIntakeNonIntegratedSpaceDepartmentUser(
user: IntakeNonIntegratedPermissionUser,
) {
@ -344,7 +357,10 @@ export function isCurrentUserNonIntegratedSpaceOwner(
export function isIntakeNonIntegratedPlanningLayoutEditor(
user: IntakeNonIntegratedPermissionUser,
) {
return hasBusinessRole(user, 'PLANNING')
return (
hasDepartmentPath(user, isSpaceVisualDepartmentPath) ||
hasBusinessRoleFallback(user, 'PLANNING')
)
}
export function isCurrentUserNonIntegratedPurchaseOwner(

View File

@ -0,0 +1,81 @@
export const FORMAL_PRODUCTS_HOME_PATH = '/intake'
export type WorkspaceAccessLevel = 'full' | 'formal-products-only'
export interface WorkspacePermissionUser {
role?: string | null
businessRoles?: readonly string[] | null
department?: { name?: string | null; fullPath?: string | null } | null
departmentPaths?: readonly string[] | null
}
const FULL_ACCESS_ROLES = new Set(['SUPER_ADMIN', 'ADMIN'])
const DESIGN_BUSINESS_ROLE = 'DESIGN'
const RESTRICTED_ALLOWED_PATH_PREFIXES = [
FORMAL_PRODUCTS_HOME_PATH,
'/profile',
'/approval-workflows',
]
function normalizeText(value: unknown) {
return typeof value === 'string' ? value.trim() : ''
}
function getDepartmentPathValues(user: WorkspacePermissionUser) {
return [
user.department?.fullPath,
user.department?.name,
...(user.departmentPaths ?? []),
]
.map(normalizeText)
.filter(Boolean)
}
function isDesignDepartmentPath(path: string) {
return /产品研发|设计|空间视觉/.test(path)
}
export function hasFullWorkspaceAccess(user: WorkspacePermissionUser | null | undefined) {
if (!user) return false
if (user.role && FULL_ACCESS_ROLES.has(user.role)) return true
if (user.businessRoles?.includes(DESIGN_BUSINESS_ROLE)) return true
return getDepartmentPathValues(user).some(isDesignDepartmentPath)
}
export function getWorkspaceAccessLevel(
user: WorkspacePermissionUser | null | undefined,
): WorkspaceAccessLevel {
return hasFullWorkspaceAccess(user) ? 'full' : 'formal-products-only'
}
export function isRestrictedWorkspacePathAllowed(pathname: string) {
return RESTRICTED_ALLOWED_PATH_PREFIXES.some(
(path) => pathname === path || pathname.startsWith(`${path}/`),
)
}
export function canAccessWorkspacePath(
pathname: string,
user: WorkspacePermissionUser | null | undefined,
) {
if (getWorkspaceAccessLevel(user) === 'full') return true
return isRestrictedWorkspacePathAllowed(pathname)
}
export function canAccessWorkspacePathByAccessLevel(
pathname: string,
accessLevel: WorkspaceAccessLevel | null | undefined,
) {
if (!accessLevel || accessLevel === 'full') return true
return isRestrictedWorkspacePathAllowed(pathname)
}
export function resolveWorkspaceRedirectForUser(
requestedPath: string,
user: WorkspacePermissionUser | null | undefined,
) {
return canAccessWorkspacePath(requestedPath, user)
? requestedPath
: FORMAL_PRODUCTS_HOME_PATH
}

View File

@ -9,6 +9,11 @@
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import { validateToken } from '@/lib/token-validator'
import {
FORMAL_PRODUCTS_HOME_PATH,
canAccessWorkspacePathByAccessLevel,
type WorkspaceAccessLevel,
} from '@/lib/workspace-route-permissions'
export const publicPaths = [
'/login',
@ -37,6 +42,11 @@ export function isPublicPath(pathname: string): boolean {
return PUBLIC_ASSET_PATH_PATTERN.test(pathname) || publicPaths.some((path) => pathname.startsWith(path))
}
interface WorkspaceTokenPayload {
exp?: number
workspaceAccess?: WorkspaceAccessLevel
}
export async function proxy(request: NextRequest) {
const { pathname } = request.nextUrl
if (isPublicPath(pathname)) {
@ -50,7 +60,7 @@ export async function proxy(request: NextRequest) {
return NextResponse.redirect(loginUrl)
}
const payload = await validateToken<{ exp?: number }>(token, JWT_SECRET)
const payload = await validateToken<WorkspaceTokenPayload>(token, JWT_SECRET)
if (!payload) {
const loginUrl = new URL('/login', request.url)
loginUrl.searchParams.set('redirect', pathname)
@ -59,6 +69,10 @@ export async function proxy(request: NextRequest) {
return response
}
if (!canAccessWorkspacePathByAccessLevel(pathname, payload.workspaceAccess)) {
return NextResponse.redirect(new URL(FORMAL_PRODUCTS_HOME_PATH, request.url))
}
return NextResponse.next()
}

View File

@ -187,6 +187,7 @@ class AuthRepository {
email: string
name: string
role: Role
businessRoles: BusinessAccountType[]
status: UserStatus
isFirstLogin: boolean
loginSource: string
@ -201,6 +202,7 @@ class AuthRepository {
email: true,
name: true,
role: true,
businessRoles: true,
status: true,
isFirstLogin: true,
loginSource: true,

View File

@ -15,6 +15,10 @@ import { AppError, ErrorCode } from '@/server/core/errors'
import type { BusinessAccountType, Prisma, Role, UserStatus } from '@prisma/client'
import { authRepository, type AuthUserWithRelations } from '../repositories/auth.repository'
import { getUserDepartmentPathsById } from './user-department-paths.service'
import {
getWorkspaceAccessLevel,
type WorkspaceAccessLevel,
} from '@/lib/workspace-route-permissions'
// ============ 类型定义 ============
@ -91,6 +95,7 @@ export interface TokenPayload {
isFirstLogin: boolean
departmentId?: string
teamId?: string
workspaceAccess?: WorkspaceAccessLevel
}
// ============ 辅助函数 ============
@ -237,6 +242,10 @@ class AuthService {
isFirstLogin: false,
departmentId: updatedUser.departmentId || undefined,
teamId: updatedUser.teamId || undefined,
workspaceAccess: getWorkspaceAccessLevel({
role: updatedUser.role,
businessRoles: updatedUser.businessRoles,
}),
})
return {
@ -365,6 +374,10 @@ class AuthService {
isFirstLogin: user.isFirstLogin,
departmentId: user.departmentId || undefined,
teamId: user.teamId || undefined,
workspaceAccess: getWorkspaceAccessLevel({
role: user.role,
businessRoles: user.businessRoles,
}),
})
}
@ -410,6 +423,12 @@ class AuthService {
isFirstLogin: user.isFirstLogin,
departmentId: user.departmentId || undefined,
teamId: user.teamId || undefined,
workspaceAccess: getWorkspaceAccessLevel({
role: user.role,
businessRoles: user.businessRoles,
department: user.department,
departmentPaths,
}),
})
return {