From d8793db5931088752307e6285a4af0695de85257 Mon Sep 17 00:00:00 2001 From: zhaoxing <2558691827@qq.com> Date: Fri, 13 Nov 2026 16:19:17 +0800 Subject: [PATCH] =?UTF-8?q?fix(worker):=20=E4=BF=AE=E5=A4=8D=E5=AF=84?= =?UTF-8?q?=E5=8D=96=E9=A1=B5=E5=88=86=E7=89=87=E5=8A=A0=E8=BD=BD=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E5=AF=BC=E8=87=B4=E7=9A=84=E7=99=BD=E5=B1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增全局/页面级错误边界,页面渲染异常不再整页白屏 - 监听 vite:preloadError,部署后旧分片加载失败自动刷新一次 - 修复长会话 setTimeout 2^31 溢出导致立即登出 - 寄卖页资料接口失败给出重试态,不再误报未开通 --- .../components/common/AppErrorBoundary.tsx | 107 ++++++++++++++++++ apps/frontend/src/layouts/AdminLayout.tsx | 23 +++- apps/frontend/src/layouts/WorkerLayout.tsx | 34 +++++- apps/frontend/src/main.tsx | 17 ++- .../pages/worker/WorkerConsignmentPage.tsx | 25 ++++ apps/frontend/src/utils/chunk-load-error.ts | 71 ++++++++++++ .../src/utils/session-expiry-timer.ts | 16 +++ apps/frontend/tests/chunk-load-error.test.ts | 71 ++++++++++++ .../tests/session-expiry-timer.test.ts | 34 ++++++ 9 files changed, 389 insertions(+), 9 deletions(-) create mode 100644 apps/frontend/src/components/common/AppErrorBoundary.tsx create mode 100644 apps/frontend/src/utils/chunk-load-error.ts create mode 100644 apps/frontend/src/utils/session-expiry-timer.ts create mode 100644 apps/frontend/tests/chunk-load-error.test.ts create mode 100644 apps/frontend/tests/session-expiry-timer.test.ts diff --git a/apps/frontend/src/components/common/AppErrorBoundary.tsx b/apps/frontend/src/components/common/AppErrorBoundary.tsx new file mode 100644 index 00000000..ebb8e7ee --- /dev/null +++ b/apps/frontend/src/components/common/AppErrorBoundary.tsx @@ -0,0 +1,107 @@ +import { Component, type ErrorInfo, type ReactNode } from 'react' +import { Button, Result, Space, Typography } from 'antd' + +import { isChunkLoadError, shouldAutoReloadChunkFailure } from '@/utils/chunk-load-error' +import { isWorkerSite } from '@/utils/site' + +type AppErrorBoundaryProps = { + children: ReactNode + /** inline:只占内容区,保留侧边栏/导航;page:整页错误页。 */ + variant?: 'page' | 'inline' + /** 路由变化时自动清除错误,避免一个页面崩溃后整个会话都停在错误页。 */ + resetKey?: string +} + +type AppErrorBoundaryState = { + error: Error | null +} + +/** + * 全局兜底错误边界。 + * + * 没有它时,任意一个页面在渲染期抛错(典型场景:部署后点击尚未加载过的路由, + * 旧的 lazy chunk 已被新镜像删除,`import()` 直接失败)会让 React 卸载整棵树, + * 用户看到的就是没有任何提示的纯白页面。这里统一转成可操作的错误页,并对 + * 分片加载失败自动刷新一次拿新版本。 + */ +export default class AppErrorBoundary extends Component< + AppErrorBoundaryProps, + AppErrorBoundaryState +> { + state: AppErrorBoundaryState = { error: null } + + static getDerivedStateFromError(error: unknown): AppErrorBoundaryState { + return { error: error instanceof Error ? error : new Error(String(error)) } + } + + componentDidCatch(error: unknown, info: ErrorInfo) { + if (isChunkLoadError(error) && typeof window !== 'undefined') { + // 旧版本页面引用了已删除的分片:刷新一次即可拿到新 index.html 与新分片。 + // 防抖窗口内再次失败则不再刷新,落到下面错误页,避免无限刷新。 + if (shouldAutoReloadChunkFailure(window.sessionStorage)) { + window.location.reload() + return + } + } + + console.error('[AppErrorBoundary] 页面渲染失败', error, info.componentStack) + } + + componentDidUpdate(prevProps: AppErrorBoundaryProps) { + if (this.state.error && prevProps.resetKey !== this.props.resetKey) { + this.setState({ error: null }) + } + } + + render() { + const { error } = this.state + if (!error) return this.props.children + + const chunkFailed = isChunkLoadError(error) + const inline = this.props.variant === 'inline' + const homePath = isWorkerSite() ? '/hall' : '/' + + return ( +
+ + + + + + {chunkFailed ? null : ( + + 错误信息:{error.message.slice(0, 200)} + + )} + + } + /> +
+ ) + } +} diff --git a/apps/frontend/src/layouts/AdminLayout.tsx b/apps/frontend/src/layouts/AdminLayout.tsx index adb231e2..5f3b583b 100644 --- a/apps/frontend/src/layouts/AdminLayout.tsx +++ b/apps/frontend/src/layouts/AdminLayout.tsx @@ -67,6 +67,11 @@ import { shouldPrependAdminWorkOrder, } from '@/utils/admin-realtime-work-orders' import { useRealtimeEvents, type RealtimeEvent } from '@/hooks/useRealtimeEvents' +import { + hasRemainingTimerDelay, + MAX_TIMER_DELAY_MS, + resolveTimerChunkDelay, +} from '@/utils/session-expiry-timer' import dayjs from '@/lib/dayjs' const SIDEBAR_COLLAPSED_KEY = 'react-admin-sidebar-collapsed' @@ -132,9 +137,21 @@ export default function AdminLayout() { useEffect(() => { const delay = getAdminSessionExpiryDelay(expiresAt) if (delay === null) return - const timer = window.setTimeout(() => { - expireAdminSession() - }, delay) + + // 同 WorkerLayout:超过 2^31-1 毫秒的延迟会溢出成立即触发,长会话必须分段等待。 + let remaining = delay + let timer = 0 + const armTimer = () => { + timer = window.setTimeout(() => { + if (hasRemainingTimerDelay(remaining)) { + remaining -= MAX_TIMER_DELAY_MS + armTimer() + return + } + expireAdminSession() + }, resolveTimerChunkDelay(remaining)) + } + armTimer() return () => window.clearTimeout(timer) }, [expiresAt]) diff --git a/apps/frontend/src/layouts/WorkerLayout.tsx b/apps/frontend/src/layouts/WorkerLayout.tsx index 61126c6d..00bb64e8 100644 --- a/apps/frontend/src/layouts/WorkerLayout.tsx +++ b/apps/frontend/src/layouts/WorkerLayout.tsx @@ -35,9 +35,15 @@ import { isWorkerSessionStorageKey, } from '@/utils/worker-auth' import { useRealtimeEvents, type RealtimeEvent } from '@/hooks/useRealtimeEvents' +import AppErrorBoundary from '@/components/common/AppErrorBoundary' import WorkerAnnouncementBanner from '@/components/worker/WorkerAnnouncementBanner' import WorkerAnnouncementPopup from '@/components/worker/WorkerAnnouncementPopup' import WorkerNewOrderSpeech from '@/components/worker/WorkerNewOrderSpeech' +import { + hasRemainingTimerDelay, + MAX_TIMER_DELAY_MS, + resolveTimerChunkDelay, +} from '@/utils/session-expiry-timer' const menuItems: MenuProps['items'] = [ { key: '/hall', icon: , label: '抢单大厅' }, @@ -140,9 +146,21 @@ export default function WorkerLayout() { const delay = getWorkerSessionExpiryDelay(sessionExpiresAt) if (delay === null) return - const timer = window.setTimeout(() => { - expireWorkerSession() - }, delay) + // setTimeout 的延迟上限是 2^31-1 毫秒(约 24.8 天),超出会溢出成立即触发, + // 会话有效期较长时必须分段等待,否则一进页面就会被误判为过期。 + let remaining = delay + let timer = 0 + const armTimer = () => { + timer = window.setTimeout(() => { + if (hasRemainingTimerDelay(remaining)) { + remaining -= MAX_TIMER_DELAY_MS + armTimer() + return + } + expireWorkerSession() + }, resolveTimerChunkDelay(remaining)) + } + armTimer() return () => window.clearTimeout(timer) }, [sessionExpiresAt]) @@ -215,7 +233,10 @@ export default function WorkerLayout() {
- + {/* 页面级错误边界:单个页面崩溃时保留底部导航,用户可以直接切走,不用整页刷新。 */} + + +