Skip to content

Commit bb6173e

Browse files
committed
perf(frontend): lazy-load routes and split vendor chunks
Cut initial JS by ~112 kB gzip by moving recharts and all non-Dashboard pages off the critical path. Routes now code-split via React.lazy, with a RouteErrorBoundary that auto-reloads once on ChunkLoadError so users pinned to a stale version recover transparently after a deploy. Vendor chunks are pinned via manualChunks so business-code changes no longer bust the react/radix/i18n/recharts caches. clsx/tailwind-merge are merged into react-vendor to keep the tailwind utility path off recharts-vendor, which otherwise pulled recharts into the entry graph.
1 parent e936f71 commit bb6173e

7 files changed

Lines changed: 256 additions & 101 deletions

File tree

frontend/src/App.tsx

Lines changed: 35 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,47 @@
1+
import { lazy, Suspense } from 'react'
12
import { Navigate, Route, Routes } from 'react-router-dom'
23
import AuthGate from './components/AuthGate'
34
import Layout from './components/Layout'
4-
import Accounts from './pages/Accounts'
5+
import RouteErrorBoundary from './components/RouteErrorBoundary'
6+
import StateShell from './components/StateShell'
57
import Dashboard from './pages/Dashboard'
6-
import Operations from './pages/Operations'
7-
import Proxies from './pages/Proxies'
8-
import SchedulerBoard from './pages/SchedulerBoard'
9-
import Settings from './pages/Settings'
10-
import Guide from './pages/Guide'
11-
import ApiReference from './pages/ApiReference'
12-
import APIKeys from './pages/APIKeys'
13-
import Usage from './pages/Usage'
14-
import ImageStudio from './pages/ImageStudio'
15-
import PromptFilter from './pages/PromptFilter'
8+
9+
const Accounts = lazy(() => import('./pages/Accounts'))
10+
const Operations = lazy(() => import('./pages/Operations'))
11+
const Proxies = lazy(() => import('./pages/Proxies'))
12+
const SchedulerBoard = lazy(() => import('./pages/SchedulerBoard'))
13+
const Settings = lazy(() => import('./pages/Settings'))
14+
const Guide = lazy(() => import('./pages/Guide'))
15+
const ApiReference = lazy(() => import('./pages/ApiReference'))
16+
const APIKeys = lazy(() => import('./pages/APIKeys'))
17+
const Usage = lazy(() => import('./pages/Usage'))
18+
const ImageStudio = lazy(() => import('./pages/ImageStudio'))
19+
const PromptFilter = lazy(() => import('./pages/PromptFilter'))
1620

1721
export default function App() {
1822
return (
1923
<AuthGate>
2024
<Layout>
21-
<Routes>
22-
<Route path="/" element={<Dashboard />} />
23-
<Route path="/accounts" element={<Accounts />} />
24-
<Route path="/api-keys" element={<APIKeys />} />
25-
<Route path="/proxies" element={<Proxies />} />
26-
<Route path="/images" element={<Navigate to="/images/studio" replace />} />
27-
<Route path="/images/:view" element={<ImageStudio />} />
28-
<Route path="/prompt-filter" element={<Navigate to="/prompt-filter/overview" replace />} />
29-
<Route path="/prompt-filter/:view" element={<PromptFilter />} />
30-
<Route path="/ops" element={<Operations />} />
31-
<Route path="/ops/scheduler" element={<SchedulerBoard />} />
32-
<Route path="/usage" element={<Usage />} />
33-
<Route path="/settings" element={<Settings />} />
34-
<Route path="/docs" element={<Guide />} />
35-
<Route path="/api-reference" element={<ApiReference />} />
36-
</Routes>
25+
<RouteErrorBoundary>
26+
<Suspense fallback={<StateShell variant="page" loading>{null}</StateShell>}>
27+
<Routes>
28+
<Route path="/" element={<Dashboard />} />
29+
<Route path="/accounts" element={<Accounts />} />
30+
<Route path="/api-keys" element={<APIKeys />} />
31+
<Route path="/proxies" element={<Proxies />} />
32+
<Route path="/images" element={<Navigate to="/images/studio" replace />} />
33+
<Route path="/images/:view" element={<ImageStudio />} />
34+
<Route path="/prompt-filter" element={<Navigate to="/prompt-filter/overview" replace />} />
35+
<Route path="/prompt-filter/:view" element={<PromptFilter />} />
36+
<Route path="/ops" element={<Operations />} />
37+
<Route path="/ops/scheduler" element={<SchedulerBoard />} />
38+
<Route path="/usage" element={<Usage />} />
39+
<Route path="/settings" element={<Settings />} />
40+
<Route path="/docs" element={<Guide />} />
41+
<Route path="/api-reference" element={<ApiReference />} />
42+
</Routes>
43+
</Suspense>
44+
</RouteErrorBoundary>
3745
</Layout>
3846
</AuthGate>
3947
)

frontend/src/components/DashboardUsageCharts.tsx

Lines changed: 1 addition & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,7 @@ import {
1818
import { Card, CardContent } from '@/components/ui/card'
1919
import StateShell from './StateShell'
2020
import type { ChartAggregation } from '../types'
21-
22-
export type TimeRangeKey = '1h' | '6h' | '24h' | '7d' | '30d'
21+
import { TIME_RANGE_OPTIONS, getBucketConfig, type TimeRangeKey } from '../lib/timeRange'
2322

2423
interface DashboardUsageChartsProps {
2524
chartData: ChartAggregation | null
@@ -65,26 +64,6 @@ const compactNumberFormatter = new Intl.NumberFormat(undefined, {
6564
maximumFractionDigits: 1,
6665
})
6766

68-
const TIME_RANGE_OPTIONS: TimeRangeKey[] = ['1h', '6h', '24h', '7d', '30d']
69-
70-
/** 根据时间跨度计算桶大小(分钟)和桶数量 */
71-
export function getBucketConfig(range: TimeRangeKey): { bucketMinutes: number; bucketCount: number } {
72-
switch (range) {
73-
case '1h':
74-
return { bucketMinutes: 5, bucketCount: 12 }
75-
case '6h':
76-
return { bucketMinutes: 15, bucketCount: 24 }
77-
case '24h':
78-
return { bucketMinutes: 30, bucketCount: 48 }
79-
case '7d':
80-
return { bucketMinutes: 360, bucketCount: 28 }
81-
case '30d':
82-
return { bucketMinutes: 1440, bucketCount: 30 }
83-
default:
84-
return { bucketMinutes: 5, bucketCount: 12 }
85-
}
86-
}
87-
8867
export default function DashboardUsageCharts({
8968
chartData: serverData,
9069
refreshedAt,
@@ -423,41 +402,3 @@ function getTooltipLabel(payload: readonly { payload?: Record<string, unknown> }
423402
return typeof rawValue === 'string' && rawValue ? rawValue : ''
424403
}
425404

426-
/** 将 Date 格式化为带本地时区偏移的 RFC3339 字符串(避免 UTC/本地时间不一致) */
427-
function toLocalRFC3339(date: Date): string {
428-
const pad = (n: number) => String(n).padStart(2, '0')
429-
const offset = date.getTimezoneOffset()
430-
const sign = offset <= 0 ? '+' : '-'
431-
const absOffset = Math.abs(offset)
432-
const tzH = pad(Math.floor(absOffset / 60))
433-
const tzM = pad(absOffset % 60)
434-
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}${sign}${tzH}:${tzM}`
435-
}
436-
437-
/** 根据 TimeRangeKey 计算时间范围的起始 ISO 字符串 */
438-
export function getTimeRangeISO(range: TimeRangeKey): { start: string; end: string } {
439-
const now = new Date()
440-
const end = toLocalRFC3339(now)
441-
let offsetMs: number
442-
switch (range) {
443-
case '1h':
444-
offsetMs = 60 * 60 * 1000
445-
break
446-
case '6h':
447-
offsetMs = 6 * 60 * 60 * 1000
448-
break
449-
case '24h':
450-
offsetMs = 24 * 60 * 60 * 1000
451-
break
452-
case '7d':
453-
offsetMs = 7 * 24 * 60 * 60 * 1000
454-
break
455-
case '30d':
456-
offsetMs = 30 * 24 * 60 * 60 * 1000
457-
break
458-
default:
459-
offsetMs = 60 * 60 * 1000
460-
}
461-
const start = toLocalRFC3339(new Date(now.getTime() - offsetMs))
462-
return { start, end }
463-
}
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { Component, type ErrorInfo, type ReactNode } from 'react'
2+
import { Button } from '@/components/ui/button'
3+
import { AlertCircle } from 'lucide-react'
4+
5+
interface Props {
6+
children: ReactNode
7+
fallbackTitle?: string
8+
fallbackDescription?: string
9+
retryLabel?: string
10+
}
11+
12+
interface State {
13+
error: Error | null
14+
}
15+
16+
const CHUNK_RELOAD_FLAG = 'codex2api:chunk-reloaded'
17+
18+
function isChunkLoadError(error: unknown): boolean {
19+
if (!error) return false
20+
const message = error instanceof Error ? `${error.name} ${error.message}` : String(error)
21+
return /ChunkLoadError|Loading chunk \d+ failed|Failed to fetch dynamically imported module|Importing a module script failed/i.test(
22+
message,
23+
)
24+
}
25+
26+
export default class RouteErrorBoundary extends Component<Props, State> {
27+
state: State = { error: null }
28+
29+
static getDerivedStateFromError(error: Error): State {
30+
return { error }
31+
}
32+
33+
componentDidCatch(error: Error, info: ErrorInfo) {
34+
if (isChunkLoadError(error)) {
35+
try {
36+
const reloaded = sessionStorage.getItem(CHUNK_RELOAD_FLAG)
37+
if (!reloaded) {
38+
sessionStorage.setItem(CHUNK_RELOAD_FLAG, '1')
39+
window.location.reload()
40+
return
41+
}
42+
} catch {
43+
// sessionStorage may be unavailable; fall through to render fallback
44+
}
45+
}
46+
if (import.meta.env.DEV) {
47+
console.error('[RouteErrorBoundary]', error, info)
48+
}
49+
}
50+
51+
componentDidMount() {
52+
try {
53+
sessionStorage.removeItem(CHUNK_RELOAD_FLAG)
54+
} catch {
55+
// ignore
56+
}
57+
}
58+
59+
handleRetry = () => {
60+
try {
61+
sessionStorage.removeItem(CHUNK_RELOAD_FLAG)
62+
} catch {
63+
// ignore
64+
}
65+
window.location.reload()
66+
}
67+
68+
render() {
69+
const { error } = this.state
70+
if (!error) return this.props.children
71+
72+
const chunkError = isChunkLoadError(error)
73+
const title =
74+
this.props.fallbackTitle ??
75+
(chunkError ? '页面资源已更新,请刷新' : '页面渲染失败')
76+
const description =
77+
this.props.fallbackDescription ??
78+
(chunkError
79+
? '应用版本已更新,本地缓存的旧资源已不可用。点击下方按钮刷新页面以加载最新版本。'
80+
: error.message || '发生了未知错误,请刷新页面或稍后重试。')
81+
const retryLabel = this.props.retryLabel ?? '刷新页面'
82+
83+
return (
84+
<div
85+
className="flex flex-col items-center justify-center gap-3 rounded-lg border border-border bg-card/80 p-8 text-center shadow-sm min-h-[320px]"
86+
role="alert"
87+
>
88+
<div className="size-14 flex items-center justify-center rounded-full bg-destructive/12 text-destructive">
89+
<AlertCircle className="size-6" />
90+
</div>
91+
<strong className="text-lg font-bold text-foreground">{title}</strong>
92+
<p className="max-w-[420px] text-sm leading-relaxed text-muted-foreground">{description}</p>
93+
<Button variant="outline" onClick={this.handleRetry}>
94+
{retryLabel}
95+
</Button>
96+
</div>
97+
)
98+
}
99+
}

frontend/src/lib/timeRange.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
export type TimeRangeKey = '1h' | '6h' | '24h' | '7d' | '30d'
2+
3+
export const TIME_RANGE_OPTIONS: TimeRangeKey[] = ['1h', '6h', '24h', '7d', '30d']
4+
5+
export function getBucketConfig(range: TimeRangeKey): { bucketMinutes: number; bucketCount: number } {
6+
switch (range) {
7+
case '1h':
8+
return { bucketMinutes: 5, bucketCount: 12 }
9+
case '6h':
10+
return { bucketMinutes: 15, bucketCount: 24 }
11+
case '24h':
12+
return { bucketMinutes: 30, bucketCount: 48 }
13+
case '7d':
14+
return { bucketMinutes: 360, bucketCount: 28 }
15+
case '30d':
16+
return { bucketMinutes: 1440, bucketCount: 30 }
17+
default:
18+
return { bucketMinutes: 5, bucketCount: 12 }
19+
}
20+
}
21+
22+
function toLocalRFC3339(date: Date): string {
23+
const pad = (n: number) => String(n).padStart(2, '0')
24+
const offset = date.getTimezoneOffset()
25+
const sign = offset <= 0 ? '+' : '-'
26+
const absOffset = Math.abs(offset)
27+
const tzH = pad(Math.floor(absOffset / 60))
28+
const tzM = pad(absOffset % 60)
29+
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}${sign}${tzH}:${tzM}`
30+
}
31+
32+
export function getTimeRangeISO(range: TimeRangeKey): { start: string; end: string } {
33+
const now = new Date()
34+
const end = toLocalRFC3339(now)
35+
let offsetMs: number
36+
switch (range) {
37+
case '1h':
38+
offsetMs = 60 * 60 * 1000
39+
break
40+
case '6h':
41+
offsetMs = 6 * 60 * 60 * 1000
42+
break
43+
case '24h':
44+
offsetMs = 24 * 60 * 60 * 1000
45+
break
46+
case '7d':
47+
offsetMs = 7 * 24 * 60 * 60 * 1000
48+
break
49+
case '30d':
50+
offsetMs = 30 * 24 * 60 * 60 * 1000
51+
break
52+
default:
53+
offsetMs = 60 * 60 * 1000
54+
}
55+
const start = toLocalRFC3339(new Date(now.getTime() - offsetMs))
56+
return { start, end }
57+
}

frontend/src/pages/Dashboard.tsx

Lines changed: 40 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
import type { ReactNode } from 'react'
2-
import { useCallback, useEffect, useRef, useState } from 'react'
2+
import { lazy, Suspense, useCallback, useEffect, useRef, useState } from 'react'
33
import { useTranslation } from 'react-i18next'
44
import { api } from '../api'
5-
import DashboardUsageCharts, { getTimeRangeISO, getBucketConfig } from '../components/DashboardUsageCharts'
6-
import type { TimeRangeKey } from '../components/DashboardUsageCharts'
5+
import { getTimeRangeISO, getBucketConfig, type TimeRangeKey } from '../lib/timeRange'
76
import PageHeader from '../components/PageHeader'
87
import StateShell from '../components/StateShell'
98
import StatCard from '../components/StatCard'
@@ -12,8 +11,36 @@ import { useDataLoader } from '../hooks/useDataLoader'
1211
import { Card, CardContent } from '@/components/ui/card'
1312
import { Users, CheckCircle, XCircle, Activity, Zap, Clock, AlertTriangle, BarChart3, Database } from 'lucide-react'
1413

14+
const DashboardUsageCharts = lazy(() => import('../components/DashboardUsageCharts'))
15+
1516
const DASHBOARD_REFRESH_INTERVAL_MS = 15_000
1617

18+
function ChartsSkeleton() {
19+
return (
20+
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
21+
{[0, 1, 2, 3].map((i) => (
22+
<Card key={i} className="py-0">
23+
<CardContent className="p-6">
24+
<div className="mb-5 space-y-2">
25+
<div className="h-4 w-32 rounded-md bg-muted animate-pulse" />
26+
<div className="h-3 w-48 rounded-md bg-muted/60 animate-pulse" />
27+
</div>
28+
<div className="h-[280px] flex items-end gap-2 px-4 pb-4">
29+
{[40, 65, 30, 80, 55, 70, 45, 60, 35, 75, 50, 68].map((h, j) => (
30+
<div
31+
key={j}
32+
className="flex-1 rounded-t-md bg-muted/50 animate-pulse"
33+
style={{ height: `${h}%`, animationDelay: `${j * 80}ms` }}
34+
/>
35+
))}
36+
</div>
37+
</CardContent>
38+
</Card>
39+
))}
40+
</div>
41+
)
42+
}
43+
1744
export default function Dashboard() {
1845
const { t } = useTranslation()
1946
const [timeRange, setTimeRange] = useState<TimeRangeKey>('1h')
@@ -146,14 +173,16 @@ export default function Dashboard() {
146173
</div>
147174
</CardContent>
148175
</Card>
149-
<DashboardUsageCharts
150-
chartData={chartData}
151-
refreshedAt={chartRefreshedAt}
152-
refreshIntervalMs={DASHBOARD_REFRESH_INTERVAL_MS}
153-
timeRange={timeRange}
154-
onTimeRangeChange={setTimeRange}
155-
loading={chartLoading}
156-
/>
176+
<Suspense fallback={<ChartsSkeleton />}>
177+
<DashboardUsageCharts
178+
chartData={chartData}
179+
refreshedAt={chartRefreshedAt}
180+
refreshIntervalMs={DASHBOARD_REFRESH_INTERVAL_MS}
181+
timeRange={timeRange}
182+
onTimeRangeChange={setTimeRange}
183+
loading={chartLoading}
184+
/>
185+
</Suspense>
157186
</div>
158187
)}
159188
</>

frontend/src/pages/Usage.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
import { useCallback, useEffect, useRef, useState } from 'react'
22
import { useTranslation } from 'react-i18next'
33
import { api } from '../api'
4-
import { getTimeRangeISO } from '../components/DashboardUsageCharts'
5-
import type { TimeRangeKey } from '../components/DashboardUsageCharts'
4+
import { getTimeRangeISO, type TimeRangeKey } from '../lib/timeRange'
65
import PageHeader from '../components/PageHeader'
76
import Pagination from '../components/Pagination'
87
import StateShell from '../components/StateShell'

0 commit comments

Comments
 (0)