4 Commits

Author SHA1 Message Date
dc97a7385f perf(frontend): Context value useMemo 稳定化
AuthContext.Provider 和 ToastContext.Provider 的 value 对象
用 useMemo 包裹,避免每次 Provider render 产生新引用导致
所有消费者不必要的 re-render。
2026-08-06 17:34:05 +08:00
b725599cb0 perf(frontend): Vite 构建分包 — manualChunks 拆分 vendor
将 react/react-dom/react-router-dom → vendor-react
将 antd/@ant-design/icons → vendor-antd
将 html5-qrcode → vendor-qrcode
第三方巨头的独立 chunk 可被浏览器长期缓存,业务代码更新时无需重新下载。
2026-08-06 17:34:00 +08:00
3d164159ac perf(frontend): 递归组件 Memo 优化 — React.memo + useMemo
TaskTreeViewer:
- Modal/ReceiveConfirmModal/RejectModal/TransferModal 加 React.memo
- TaskNodeCard 递归组件加 React.memo,阻断父节点状态变更引发的整树重渲染
- countAllTasks 用 useMemo 缓存,仅在 task_tree 引用变化时重新计算

TaskListCard:
- TaskNode 递归组件加 React.memo
- countAll 用 useMemo 缓存
2026-08-06 17:33:54 +08:00
9d204a57d8 perf(frontend): 路由级代码分割 — React.lazy + Suspense
将所有页面组件改为按路由懒加载,Layout 组件保持静态导入。
新增 LoadingSpinner 作为 Suspense 回退组件。
html5-qrcode (~200KB) 和 antd 仅在访问扫码/管理后台路由时加载。
2026-08-06 17:33:48 +08:00
7 changed files with 104 additions and 54 deletions

View File

@ -1,20 +1,26 @@
import { Suspense, lazy } from "react";
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
import { App as AntApp } from "antd";
import { ToastProvider } from "./components/ui/Toast";
import { AuthProvider } from "./contexts/AuthContext";
import AppLayout from "./components/layout/AppLayout";
import ScanPage from "./pages/ScanPage";
import MyTasksPage from "./pages/MyTasksPage";
import NotificationsPage from "./pages/NotificationsPage";
import ProfilePage from "./pages/ProfilePage";
import LoadingSpinner from "./components/ui/LoadingSpinner";
// Layout 组件静态导入(始终需要,体积小)
import AppLayout from "./components/layout/AppLayout";
import AdminLayout from "./components/layout/AdminLayout";
import AdminLoginPage from "./pages/admin/AdminLoginPage";
import AdminDashboard from "./pages/admin/AdminDashboard";
import AdminProductsPage from "./pages/admin/AdminProductsPage";
import AdminTasksPage from "./pages/admin/AdminTasksPage";
import AdminPrintConfigPage from "./pages/admin/AdminPrintConfigPage";
// 🚀 路由级代码分割 — 按需懒加载页面组件
const ScanPage = lazy(() => import("./pages/ScanPage"));
const MyTasksPage = lazy(() => import("./pages/MyTasksPage"));
const NotificationsPage = lazy(() => import("./pages/NotificationsPage"));
const ProfilePage = lazy(() => import("./pages/ProfilePage"));
const AdminLoginPage = lazy(() => import("./pages/admin/AdminLoginPage"));
const AdminDashboard = lazy(() => import("./pages/admin/AdminDashboard"));
const AdminProductsPage = lazy(() => import("./pages/admin/AdminProductsPage"));
const AdminTasksPage = lazy(() => import("./pages/admin/AdminTasksPage"));
const AdminPrintConfigPage = lazy(() => import("./pages/admin/AdminPrintConfigPage"));
export default function App() {
return (
@ -22,28 +28,30 @@ export default function App() {
<ToastProvider>
<AuthProvider>
<BrowserRouter>
<Routes>
{/* 移动端 */}
<Route path="/" element={<Navigate to="/scan" replace />} />
<Route element={<AppLayout />}>
<Route path="/scan" element={<ScanPage />} />
<Route path="/tasks" element={<MyTasksPage />} />
<Route path="/notifications" element={<NotificationsPage />} />
<Route path="/profile" element={<ProfilePage />} />
</Route>
<Suspense fallback={<LoadingSpinner />}>
<Routes>
{/* 移动端 */}
<Route path="/" element={<Navigate to="/admin/dashboard" replace />} />
<Route element={<AppLayout />}>
<Route path="/scan" element={<ScanPage />} />
<Route path="/tasks" element={<MyTasksPage />} />
<Route path="/notifications" element={<NotificationsPage />} />
<Route path="/profile" element={<ProfilePage />} />
</Route>
{/* PC 管理端 — 登录页(独立,无侧边栏) */}
<Route path="/admin/login" element={<AdminLoginPage />} />
{/* PC 管理端 — 登录页(独立,无侧边栏) */}
<Route path="/admin/login" element={<AdminLoginPage />} />
{/* PC 管理端 — 需要登录 */}
<Route path="/admin" element={<Navigate to="/admin/dashboard" replace />} />
<Route element={<AdminLayout />}>
<Route path="/admin/dashboard" element={<AdminDashboard />} />
<Route path="/admin/products" element={<AdminProductsPage />} />
<Route path="/admin/tasks" element={<AdminTasksPage />} />
<Route path="/admin/print-config" element={<AdminPrintConfigPage />} />
</Route>
</Routes>
{/* PC 管理端 — 需要登录 */}
<Route path="/admin" element={<Navigate to="/admin/dashboard" replace />} />
<Route element={<AdminLayout />}>
<Route path="/admin/dashboard" element={<AdminDashboard />} />
<Route path="/admin/products" element={<AdminProductsPage />} />
<Route path="/admin/tasks" element={<AdminTasksPage />} />
<Route path="/admin/print-config" element={<AdminPrintConfigPage />} />
</Route>
</Routes>
</Suspense>
</BrowserRouter>
</AuthProvider>
</ToastProvider>

View File

@ -1,4 +1,4 @@
import { useState, useCallback } from "react";
import { useState, useCallback, useMemo, memo } from "react";
import {
Search,
Loader2,
@ -75,7 +75,7 @@ function getStatusConfig(status: string) {
// 通用 Modal 容器
// ============================================================
function Modal({
const Modal = memo(function Modal({
open,
onClose,
title,
@ -110,13 +110,13 @@ function Modal({
</div>
</div>
);
}
});
// ============================================================
// 确认接收弹窗
// ============================================================
function ReceiveConfirmModal({
const ReceiveConfirmModal = memo(function ReceiveConfirmModal({
open,
task,
submitting,
@ -163,13 +163,13 @@ function ReceiveConfirmModal({
</div>
</Modal>
);
}
});
// ============================================================
// 品质驳回弹窗
// ============================================================
function RejectModal({
const RejectModal = memo(function RejectModal({
open,
task,
submitting,
@ -244,13 +244,13 @@ function RejectModal({
</form>
</Modal>
);
}
});
// ============================================================
// 完工裂变转交弹窗
// ============================================================
function TransferModal({
const TransferModal = memo(function TransferModal({
open,
task,
submitting,
@ -496,7 +496,7 @@ function TransferModal({
</form>
</Modal>
);
}
});
// ============================================================
// 单个任务节点卡片
@ -507,7 +507,7 @@ interface ModalTarget {
action: "receive" | "reject" | "transfer";
}
function TaskNodeCard({
const TaskNodeCard = memo(function TaskNodeCard({
task,
isLast,
onAction,
@ -653,7 +653,7 @@ function TaskNodeCard({
)}
</div>
);
}
});
// ============================================================
// 主容器组件
@ -779,6 +779,12 @@ export default function TaskTreeViewer() {
// ---- 渲染 ----
// 🚀 缓存递归计算 — 仅在 task_tree 变化时重新计算
const totalTaskCount = useMemo(
() => (product?.task_tree ? countAllTasks(product.task_tree) : 0),
[product?.task_tree],
);
const modalTask = modalTarget?.task ?? null;
return (
@ -880,7 +886,7 @@ export default function TaskTreeViewer() {
<p className="text-sm font-semibold text-gray-800">
{product.top_level_tasks?.length ?? 0}
{product.task_tree?.length
? ` · ${countAllTasks(product.task_tree)} 总计`
? ` · ${totalTaskCount} 总计`
: ""}
</p>
</div>

View File

@ -1,4 +1,5 @@
/** 任务进度列表卡片 — 递归渲染 task_tree */
import { useMemo, memo } from "react";
import { ClipboardList, GitBranch, AlertTriangle } from "lucide-react";
import type { TaskResponse, TaskSummary } from "../../types/api";
import { TASK_STATUS } from "../../types/api";
@ -30,7 +31,7 @@ interface TaskNodeProps {
depth: number;
}
function TaskNode({ task, depth }: TaskNodeProps) {
const TaskNode = memo(function TaskNode({ task, depth }: TaskNodeProps) {
return (
<>
<div className="flex items-center gap-3 px-4 py-3 border-t border-gray-50" style={{ paddingLeft: 16 + depth * 16 }}>
@ -62,19 +63,22 @@ function TaskNode({ task, depth }: TaskNodeProps) {
))}
</>
);
}
});
function countAll(tasks: TaskResponse[]): number {
return tasks.reduce((s, t) => s + 1 + (t.child_tasks ? countAll(t.child_tasks) : 0), 0);
}
export default function TaskListCard({ tasks }: TaskListCardProps) {
// 🚀 缓存递归计算
const totalCount = useMemo(() => countAll(tasks), [tasks]);
return (
<div className="rounded-xl bg-white shadow-sm">
<div className="flex items-center gap-2 border-b border-gray-100 px-4 py-3">
<ClipboardList className="h-5 w-5 text-blue-600" />
<h3 className="font-semibold text-gray-800"></h3>
<span className="ml-auto text-xs text-gray-400">{countAll(tasks)} </span>
<span className="ml-auto text-xs text-gray-400">{totalCount} </span>
</div>
{tasks.length === 0 ? (
<div className="px-4 py-8 text-center text-sm text-gray-400"></div>

View File

@ -0,0 +1,10 @@
import { Loader2 } from "lucide-react";
/** 全局路由懒加载回退组件 */
export default function LoadingSpinner() {
return (
<div className="flex items-center justify-center py-32">
<Loader2 className="h-8 w-8 animate-spin text-blue-500" />
</div>
);
}

View File

@ -3,6 +3,7 @@ import {
useContext,
useState,
useCallback,
useMemo,
type ReactNode,
} from "react";
import { X, CheckCircle, AlertCircle, Info } from "lucide-react";
@ -52,8 +53,11 @@ export function ToastProvider({ children }: { children: ReactNode }) {
}, 3500);
}, []);
// 🚀 稳定 Context value 引用
const ctxValue = useMemo<ToastContextValue>(() => ({ toast }), [toast]);
return (
<ToastContext.Provider value={{ toast }}>
<ToastContext.Provider value={ctxValue}>
{children}
{/* Toast 渲染区 */}

View File

@ -3,6 +3,7 @@ import {
useContext,
useState,
useCallback,
useMemo,
useEffect,
type ReactNode,
} from "react";
@ -107,15 +108,19 @@ export function AuthProvider({ children }: { children: ReactNode }) {
logoutInternal();
}, []);
// 🚀 稳定 Context value 引用,避免消费者不必要的 re-render
const ctxValue = useMemo<AuthContextValue>(
() => ({
...state,
login,
logout,
isAuthenticated: !!state.token && !!state.user,
}),
[state, login, logout],
);
return (
<AuthContext.Provider
value={{
...state,
login,
logout,
isAuthenticated: !!state.token && !!state.user,
}}
>
<AuthContext.Provider value={ctxValue}>
{children}
</AuthContext.Provider>
);

View File

@ -7,6 +7,19 @@ import basicSsl from "@vitejs/plugin-basic-ssl";
export default defineConfig({
plugins: [react(), tailwindcss(), basicSsl()],
// 🚀 构建优化:将第三方巨头独立分包,利用浏览器缓存
build: {
rollupOptions: {
output: {
manualChunks: {
"vendor-react": ["react", "react-dom", "react-router-dom"],
"vendor-antd": ["antd", "@ant-design/icons"],
"vendor-qrcode": ["html5-qrcode"],
},
},
},
},
clearScreen: false,
server: {
host: "0.0.0.0",