diff --git a/furniture-3d-system/src/app/intake/IntakeClient.test.tsx b/furniture-3d-system/src/app/intake/IntakeClient.test.tsx
index 5a28981f..9752f3b0 100644
--- a/furniture-3d-system/src/app/intake/IntakeClient.test.tsx
+++ b/furniture-3d-system/src/app/intake/IntakeClient.test.tsx
@@ -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 = {}) {
}
}
+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()
diff --git a/furniture-3d-system/src/app/intake/IntakeClient.tsx b/furniture-3d-system/src/app/intake/IntakeClient.tsx
index 34483777..d4413c59 100644
--- a/furniture-3d-system/src/app/intake/IntakeClient.tsx
+++ b/furniture-3d-system/src/app/intake/IntakeClient.tsx
@@ -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,
+ 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) => void;
@@ -8641,6 +8826,7 @@ function FormalIntakeWorkbench({
onOpenProductPatentLedger: () => void;
onOpenProductPatentDashboard: () => void;
onOpenSpuRuleLibraryWorkspace?: () => void;
+ showDesignControlledProductData: boolean;
}) {
const menuRef = useRef(null);
const pendingEntryOptions: { key: IntakeEntryTab; label: string }[] = [
@@ -8711,37 +8897,39 @@ function FormalIntakeWorkbench({
-
- }
- expanded={activeSection === "bom"}
- onToggle={() => onToggle("bom")}
- >
-
- {BOM_LIBRARY_OPTIONS.map((option) => (
-
- ))}
-
-
+ {showDesignControlledProductData ? (
+
+ }
+ expanded={activeSection === "bom"}
+ onToggle={() => onToggle("bom")}
+ >
+
+ {BOM_LIBRARY_OPTIONS.map((option) => (
+
+ ))}
+
+
+ ) : null}
-
- }
- expanded={activeSection === "patents"}
- onToggle={onOpenProductPatents}
- >
-
+ }
+ expanded={activeSection === "patents"}
+ onToggle={onOpenProductPatents}
>
-
产品专利明细
-
-
-
+
+
+
+ ) : null}
{onOpenSpuRuleLibraryWorkspace ? (
);
@@ -30861,9 +30996,9 @@ function renderFactoryQualityWorkspace() {
当前还没有{activeProductPhotoPanelTitle}
- 点击这里或上方“
- {currentProductPhotoGroupUploadLabel}
- ”后,这里会显示图片缩略图列表。
+ {isReadOnly
+ ? `当前还没有${activeProductPhotoPanelTitle}资料。`
+ : `点击这里或上方“${currentProductPhotoGroupUploadLabel}”后,这里会显示图片缩略图列表。`}
) : (
diff --git a/furniture-3d-system/src/components/layout/AppShell.tsx b/furniture-3d-system/src/components/layout/AppShell.tsx
index c4c107cd..13f7c980 100644
--- a/furniture-3d-system/src/components/layout/AppShell.tsx
+++ b/furniture-3d-system/src/components/layout/AppShell.tsx
@@ -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
(
+ 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 (
{!isWorkbenchShellRoute &&
}
diff --git a/furniture-3d-system/src/components/layout/AppTopNav.tsx b/furniture-3d-system/src/components/layout/AppTopNav.tsx
index f0673fb1..97d0ae19 100644
--- a/furniture-3d-system/src/components/layout/AppTopNav.tsx
+++ b/furniture-3d-system/src/components/layout/AppTopNav.tsx
@@ -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 (