From b01579ca99cccf623d1cd7b38210d17403550b21 Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Tue, 1 Sep 2026 11:10:59 +0800 Subject: [PATCH] fix(oa-board): prevent production unified table redirect --- .../src/app/intake/oa-board/OaBoardClient.tsx | 5 ++- .../oaBoardTimeoutFollowupRequest.test.ts | 22 +++++++++++ .../oa-board/oaBoardTimeoutFollowupRequest.ts | 12 ++++++ .../src/components/layout/AppShell.tsx | 21 ++++++++-- .../layout/__tests__/AppShell.test.tsx | 39 ++++++++++++++++++- 5 files changed, 92 insertions(+), 7 deletions(-) create mode 100644 furniture-3d-system/src/app/intake/oa-board/oaBoardTimeoutFollowupRequest.test.ts create mode 100644 furniture-3d-system/src/app/intake/oa-board/oaBoardTimeoutFollowupRequest.ts diff --git a/furniture-3d-system/src/app/intake/oa-board/OaBoardClient.tsx b/furniture-3d-system/src/app/intake/oa-board/OaBoardClient.tsx index ea68dd10..c36aebec 100644 --- a/furniture-3d-system/src/app/intake/oa-board/OaBoardClient.tsx +++ b/furniture-3d-system/src/app/intake/oa-board/OaBoardClient.tsx @@ -42,6 +42,7 @@ import type { } from '@/server/services/oa-brand-planning-board.service' import SampleProgressClient from '@/app/intake/sample-progress/SampleProgressClient' import type { SampleProgressDTO } from '@/server/services/sample-progress.service' +import { getTimeoutFollowupBoardKeys } from './oaBoardTimeoutFollowupRequest' type FilterState = { keyword: string @@ -4121,7 +4122,7 @@ export default function OaBoardClient({ ) useEffect(() => { - const boardKeys = Array.from(new Set(items.map((item) => item.boardKey).filter(Boolean))).slice(0, 500) + const boardKeys = getTimeoutFollowupBoardKeys(pagedItems) if (boardKeys.length === 0) { setTimeoutFollowups([]) return @@ -4161,7 +4162,7 @@ export default function OaBoardClient({ return () => { cancelled = true } - }, [items, permissionScope]) + }, [pagedItems, permissionScope]) const metricCardClass = (isActive: boolean) => [ 'group flex h-[72px] items-center justify-between rounded-md border px-3.5 text-left transition', diff --git a/furniture-3d-system/src/app/intake/oa-board/oaBoardTimeoutFollowupRequest.test.ts b/furniture-3d-system/src/app/intake/oa-board/oaBoardTimeoutFollowupRequest.test.ts new file mode 100644 index 00000000..8a350c1d --- /dev/null +++ b/furniture-3d-system/src/app/intake/oa-board/oaBoardTimeoutFollowupRequest.test.ts @@ -0,0 +1,22 @@ +import { getTimeoutFollowupBoardKeys } from './oaBoardTimeoutFollowupRequest' + +describe('getTimeoutFollowupBoardKeys', () => { + it('deduplicates and trims the visible page keys', () => { + expect( + getTimeoutFollowupBoardKeys([ + { boardKey: ' flow-1 ' }, + { boardKey: 'flow-1' }, + { boardKey: 'flow-2' }, + { boardKey: '' }, + { boardKey: null }, + ]), + ).toEqual(['flow-1', 'flow-2']) + }) + + it('keeps the request bounded even when the dashboard has many records', () => { + const items = Array.from({ length: 600 }, (_, index) => ({ boardKey: `flow-${index}` })) + + expect(getTimeoutFollowupBoardKeys(items)).toHaveLength(500) + expect(getTimeoutFollowupBoardKeys(items)[499]).toBe('flow-499') + }) +}) diff --git a/furniture-3d-system/src/app/intake/oa-board/oaBoardTimeoutFollowupRequest.ts b/furniture-3d-system/src/app/intake/oa-board/oaBoardTimeoutFollowupRequest.ts new file mode 100644 index 00000000..a7eeab53 --- /dev/null +++ b/furniture-3d-system/src/app/intake/oa-board/oaBoardTimeoutFollowupRequest.ts @@ -0,0 +1,12 @@ +type BoardKeyItem = { boardKey: string | null | undefined } + +/** Keep the follow-up lookup bounded to the records currently visible on the page. */ +export function getTimeoutFollowupBoardKeys(items: readonly BoardKeyItem[]) { + return Array.from( + new Set( + items + .map((item) => item.boardKey?.trim() ?? '') + .filter(Boolean), + ), + ).slice(0, 500) +} diff --git a/furniture-3d-system/src/components/layout/AppShell.tsx b/furniture-3d-system/src/components/layout/AppShell.tsx index 8e926bb0..a7f5e040 100644 --- a/furniture-3d-system/src/components/layout/AppShell.tsx +++ b/furniture-3d-system/src/components/layout/AppShell.tsx @@ -75,6 +75,21 @@ interface AuthMeResponse { authenticated?: boolean user?: WorkspaceShellUser } + error?: { + code?: string + } +} + +export function isExplicitAuthFailureResponse( + response: Pick, + payload: AuthMeResponse | null, +) { + return Boolean( + payload?.data?.authenticated === false || + response.status === 401 || + response.status === 403 || + payload?.error?.code?.startsWith('AUTH_'), + ) } function isProductWorkbenchRoute(pathname: string) { @@ -188,11 +203,11 @@ function AppShellContent({ children, user, localOaBoardPreview = false }: AppShe return } - setFetchedUser(null) - } catch { - if (active) { + if (isExplicitAuthFailureResponse(response, payload)) { setFetchedUser(null) } + } catch { + // A transient network/proxy failure must not turn a valid session into a redirect. } })() diff --git a/furniture-3d-system/src/components/layout/__tests__/AppShell.test.tsx b/furniture-3d-system/src/components/layout/__tests__/AppShell.test.tsx index 2af1103a..fd6648ae 100644 --- a/furniture-3d-system/src/components/layout/__tests__/AppShell.test.tsx +++ b/furniture-3d-system/src/components/layout/__tests__/AppShell.test.tsx @@ -1,5 +1,5 @@ -import { render, screen } from '@testing-library/react' -import { AppShell, shouldUseAppShell } from '../AppShell' +import { render, screen, waitFor } from '@testing-library/react' +import { AppShell, isExplicitAuthFailureResponse, shouldUseAppShell } from '../AppShell' const usePathnameMock = jest.fn() const replaceMock = jest.fn() @@ -238,6 +238,41 @@ describe('AppShell', () => { expect(screen.getByText('intake content')).toBeInTheDocument() }) + it('仅把明确的认证失败响应视为未登录', () => { + expect( + isExplicitAuthFailureResponse( + { status: 401 }, + { success: false, error: { code: 'AUTH_002' } }, + ), + ).toBe(true) + expect( + isExplicitAuthFailureResponse( + { status: 500 }, + { success: false, error: { code: 'SYSTEM_001' } }, + ), + ).toBe(false) + }) + + it('鉴权网络失败时不触发默认工作区跳转', async () => { + usePathnameMock.mockReturnValue('/supply-chain/new-products/unified-table') + const originalFetch = global.fetch + global.fetch = jest.fn(() => Promise.reject(new Error('network unavailable'))) + + try { + render( + +
unified table content
+
, + ) + + await waitFor(() => expect(global.fetch).toHaveBeenCalledWith('/api/auth/me', { cache: 'no-store' })) + expect(replaceMock).not.toHaveBeenCalled() + expect(screen.getByText('unified table content')).toBeInTheDocument() + } finally { + global.fetch = originalFetch + } + }) + it('桌面供应链沿用自身布局,不渲染产品开发侧栏', () => { usePathnameMock.mockReturnValue('/supply-chain/dashboard')