迁移范围:lesson-preparation 11 文件、attendance 3 文件、settings 4 文件、classes-invitations、跨模块接口 4 文件、adaptive-practice/ai/onboarding/invitation-codes/search/standards/scheduling/leave-requests/audit。修复 3 个 cacheFn 块位置错误。同步架构文档迁移范围至 83 文件 250+ 函数。
1176 lines
65 KiB
Markdown
1176 lines
65 KiB
Markdown
# 性能预算重构专项 - 完整审计报告
|
||
|
||
> **审计日期**: 2026-07-05
|
||
> **审计范围**: e:\Desktop\CICD 全项目(Next.js 16.0.10 + React 19.2.1 + Turbopack + Drizzle ORM + MySQL + NextAuth v5)
|
||
> **审计维度**: ① Bundle 体积预算(首期重点)② Core Web Vitals ③ 组件渲染性能 ④ Server Action / 数据层
|
||
> **审计方式**: 静态代码搜索 + 依赖图分析 + 源码路径追踪(未运行时采样)
|
||
> **交付形式**: 完整审计报告(不做实施)
|
||
> **说明**: 当前 `next build` 因 Turbopack 编译错误未成功出包,量化指标为基于依赖图与库体积经验值的合理推断值,需在修复 P0 项后重新构建获取实际产物体积进行第二轮校准。
|
||
|
||
---
|
||
|
||
## 目录
|
||
|
||
- [执行摘要](#执行摘要)
|
||
- [第一部分:Bundle 体积预算审计](#第一部分bundle-体积预算审计首期重点)
|
||
- [第二部分:Core Web Vitals 审计](#第二部分core-web-vitals-审计)
|
||
- [第三部分:组件渲染性能审计](#第三部分组件渲染性能审计)
|
||
- [第四部分:Server Action / 数据层审计](#第四部分server-action--数据层审计)
|
||
- [第五部分:综合性能预算基线表](#第五部分综合性能预算基线表)
|
||
- [第六部分:P0/P1 修复路线图](#第六部分p0p1-修复路线图)
|
||
- [附录 A:关键文件清单](#附录-a关键文件清单)
|
||
- [附录 B:审计涉及的依赖清单](#附录-b审计涉及的依赖清单)
|
||
|
||
---
|
||
|
||
## 执行摘要
|
||
|
||
本次性能预算审计覆盖 4 个维度,共发现 **24 个 P0/P1 问题、22 个 P2 问题、14 个 P3 问题**,全部为静态发现,未做运行时验证。
|
||
|
||
### P0 严重问题(影响生产稳定性 / 必须先修)
|
||
|
||
| # | 维度 | 问题 | 文件位置 |
|
||
|---|------|------|---------|
|
||
| 1 | Bundle | `KnowledgeGraph`(@xyflow/react)未动态导入,textbook 路由 chunk 膨胀 ~120 KB | `src/modules/textbooks/components/knowledge-graph.tsx:5-14` |
|
||
| 2 | Bundle | `ExamRichEditor` / `RichTextEditor` / `rich-text-block` 三处 tiptap 未动态导入,编辑器路由 chunk 膨胀 ~180 KB | `src/shared/components/ui/rich-text-editor.tsx:5-8`、`src/modules/exams/editor/exam-rich-editor.tsx:4-6`、`src/modules/lesson-preparation/components/blocks/rich-text-block.tsx:3-6` |
|
||
| 3 | Bundle | `next.config.ts` 未启用 `experimental.optimizePackageImports`(lucide-recharts-radix-tiptap 全部未优化) | `next.config.ts:6-11` |
|
||
| 4 | Bundle | `serverExternalPackages` 仅含 mysql2,未追加 `tencentcloud-sdk-nodejs`(2-3 MB 误打包风险) | `next.config.ts:10` |
|
||
| 5 | Bundle | `src/modules/exams/editor/index.ts` barrel 已实际造成构建错误(代码中已有显式绕过注释) | `src/modules/exams/editor/index.ts` |
|
||
| 6 | CWV | 无 Web Vitals 上报闭环,生产环境零真实用户性能数据 | `src/app/layout.tsx`(缺失 `useReportWebVitals`) |
|
||
| 7 | 组件 | React Compiler(`babel-plugin-react-compiler`)未安装,memoization 缺失无法自动补全 | `package.json` + `next.config.ts` |
|
||
| 8 | 组件 | `SidebarProvider` value 内联对象 + toggleSidebar 未 useCallback + resize 监听未防抖,全站 Header/Sidebar 高频重渲染 | `src/modules/layout/components/sidebar-provider.tsx:63-66` |
|
||
| 9 | 组件 | 备课编辑器 `LessonPlanEditor` 通过 `useLessonPlanEditor()` 订阅全 store,每次按键触发三栏全量重渲染 | `src/modules/lesson-preparation/components/lesson-plan-editor.tsx:62` |
|
||
| 10 | 组件 | 题库表 `QuestionDataTable` 无虚拟化,1000+ 题时首屏渲染秒级卡顿 | `src/modules/questions/components/question-data-table.tsx:45-59` |
|
||
| 11 | 数据层 | `copyCoursePlanToClasses` 双层循环单条 INSERT,无事务无批量无大小限制 | `src/modules/course-plans/data-access.ts:347-406` |
|
||
| 12 | 数据层 | 跨请求数据缓存完全缺失(0 处 `unstable_cache`),低变更频率数据每次重查 DB | 全项目 |
|
||
| 13 | 数据层 | 每次 Server Action 调用必查 DB 解析 DataScope(admin 0 次 / teacher 2 次 / parent 2 次串行) | `src/shared/lib/auth-guard.ts:44-47` |
|
||
| 14 | 数据层 | `searchQuestions` JSON 字段 CAST 后 `LIKE %kw%` 全表扫描 | `src/modules/search/data-access.ts:74-75` |
|
||
| 15 | 数据层 | `getAllUserIds` 无 LIMIT 全表返回,5000+ 用户场景公告 fan-out 内存爆炸 | `src/modules/users/data-access.ts:348-351` |
|
||
|
||
### P1 高优先级问题(影响高频接口性能)
|
||
|
||
详见各分章节,共 9 项关键 P1(含:recharts barrel import、tencentcloud-sdk serverExternalPackages、exams/editor barrel 拆分、loading.tsx 缺失、force-dynamic 滥用、Lighthouse CI 缺失、layout 串行 await、autoGradeSubmission 循环 UPDATE、exams 索引缺失等)。
|
||
|
||
### 关键优化杠杆点(ROI 排序)
|
||
|
||
1. **React Compiler 启用**:单点改动,自动解决第 3 部分列出的多数 memoization 反模式
|
||
2. **next.config.ts 完善**:单点改动,optimizePackageImports + serverExternalPackages 扩展
|
||
3. **tiptap / ReactFlow 动态导入**:3 处文件改动,编辑器路由 chunk 减少 ~300 KB
|
||
4. **备课编辑器 store 订阅拆分**:单文件改动,编辑器按键响应速度预计提升 5-10x
|
||
5. **unstable_cache 引入**:DataScope + 低变更数据缓存,单次 Server Action DB 查询数减半
|
||
|
||
---
|
||
|
||
## 第一部分:Bundle 体积预算审计(首期重点)
|
||
|
||
### 1.1 大体积依赖清单
|
||
|
||
| 依赖 | 版本 | 估算体积(min+gzip) | 用途 | 服务端/客户端 | 严重度 |
|
||
|------|------|----------------------|------|---------------|--------|
|
||
| `recharts` | ^3.6.0 | ~95-110 KB | 图表库(13 处使用) | 客户端 | P1 |
|
||
| `@xyflow/react` | ^12.11.0 | ~120-140 KB | ReactFlow 知识图谱 | 客户端 | P1 |
|
||
| `react-force-graph-2d` | ^1.29.1 | ~80-100 KB(含 d3-force) | 引力图谱 | 客户端 | P2(已 dynamic) |
|
||
| `tencentcloud-sdk-nodejs` | ^4.1.254 | **~2-3 MB**(巨型 SDK) | 腾讯云短信 | 服务端 | P0 |
|
||
| `exceljs` | ^4.4.0 | ~250-300 KB | Excel 导入导出 | 服务端 | P1 |
|
||
| `@tiptap/*`(5 包 + tiptap-markdown) | 3.x | ~180-220 KB | 富文本编辑器(3 处) | 客户端 | P0 |
|
||
| `@dnd-kit/*`(core+sortable+utilities) | 6.x/10.x | ~30-40 KB | 拖拽 | 客户端 | P2 |
|
||
| `lucide-react` | ^0.562.0 | 单图标 ~1 KB,全集 ~800 KB | 图标库(100+ 处) | 客户端 | P1 |
|
||
| `radix-ui/*`(16 个包) | 各版本 | 单包 ~3-8 KB | UI 原语 | 客户端 | P2 |
|
||
| `openai` | ^6.25.0 | ~60-80 KB | AI SDK | 服务端 | P2 |
|
||
| `react-markdown` + remark-* + rehype-* | 4.x-10.x | ~50-70 KB | Markdown 渲染 | 客户端 | P2 |
|
||
| `@tanstack/react-query` + `react-table` | 5.x/8.x | ~35-45 KB | 数据层 | 客户端 | P3 |
|
||
|
||
### 1.2 "use client" 指令分布
|
||
|
||
| 指标 | 数值 |
|
||
|------|------|
|
||
| `"use client"` 文件总数 | **553** |
|
||
| `src/**/*.{ts,tsx}` 文件总数 | >700 |
|
||
| 客户端组件占比(估算) | **~70-75%** |
|
||
| `shared/components/ui/` 下 "use client" 文件 | 40 个 |
|
||
|
||
**典型反例**:
|
||
|
||
| 文件 | 问题 | 严重度 |
|
||
|------|------|--------|
|
||
| `src/shared/components/ui/status-badge.tsx:1` | 仅渲染 Badge 无 hook 无事件,过度标记为 client | P2 |
|
||
| `src/shared/components/ui/label.tsx:1` | 仅 forwardRef + cva,可考虑改 server | P3 |
|
||
|
||
### 1.3 动态导入使用情况
|
||
|
||
| 模式 | 数量 | 位置 |
|
||
|------|------|------|
|
||
| `next/dynamic` | **2** | `force-graph.tsx:18`、`homework-assignment-exam-error-explorer-lazy.tsx:5` |
|
||
| `React.lazy` | **0** | 无 |
|
||
| 服务端动态 `import()` | 1 | `sms-channel.ts:186`(tencentcloud) |
|
||
|
||
**应使用但未使用动态导入的重型组件**:
|
||
|
||
#### [P0] ReactFlow(@xyflow/react)未动态导入
|
||
- 位置:`src/modules/textbooks/components/knowledge-graph.tsx:5-14`
|
||
- 代码:`import { ReactFlow, Background, ... } from "@xyflow/react"` 顶层静态导入
|
||
- 影响路由:`teacher/textbooks/[id]` 与 `student/learning/textbooks/[id]`
|
||
- 估算影响:每个 textbook 路由 chunk 增加 ~120-140 KB
|
||
- 修复参考:`force-graph.tsx:74-80` 已有的 `dynamic(() => import(...), { ssr: false, loading: ... })` 模式
|
||
|
||
#### [P0] tiptap 富文本编辑器未动态导入(3 处)
|
||
- 位置 1:`src/shared/components/ui/rich-text-editor.tsx:5-8`
|
||
- 位置 2:`src/modules/exams/editor/exam-rich-editor.tsx:4-6`
|
||
- 位置 3:`src/modules/lesson-preparation/components/blocks/rich-text-block.tsx:3-6`
|
||
- 影响路由:`teacher/exams/[id]/edit-rich`、`teacher/exams/new`、教材阅读页链路、备课编辑页
|
||
- 估算影响:每个含编辑器路由增加 ~180-220 KB
|
||
|
||
### 1.4 大体积第三方库使用模式
|
||
|
||
#### lucide-react(100+ 处使用)
|
||
- ✅ 全部使用具名导入 `import { IconName } from "lucide-react"`
|
||
- ❌ 缺少 `experimental.optimizePackageImports: ["lucide-react"]`(Turbopack 内建 tree-shaking 可能不彻底)
|
||
- 严重度:P1
|
||
|
||
#### recharts(13 处使用)
|
||
- ✅ 业务侧具名导入
|
||
- ❌ [P1 反例] `src/shared/components/ui/chart.tsx:5` → `import * as RechartsPrimitive from "recharts"` barrel 导入
|
||
- 影响:chart.tsx 被 12 个图表组件引用,barrel 导入让 Turbopack 难以精确剔除未用子模块
|
||
- 修复:改为按需具名导入
|
||
|
||
#### @xyflow/react(4 处使用,仅 textbooks 模块)
|
||
- 模块隔离良好 ✅
|
||
- 但未动态导入导致 textbook 路由 chunk 膨胀 ❌
|
||
|
||
#### tencentcloud-sdk-nodejs(服务端专用,已正确隔离)
|
||
- 位置:`src/modules/notifications/channels/sms-channel.ts:186`
|
||
- 模式:`await import("tencentcloud-sdk-nodejs")` ✅
|
||
- [P0] 建议追加 `serverExternalPackages: ["tencentcloud-sdk-nodejs"]` 防止误打包
|
||
|
||
#### exceljs(1 处使用)
|
||
- 位置:`src/shared/lib/excel.ts:3`
|
||
- 模式:静态 `import ExcelJS from "exceljs"`(值导入)
|
||
- 风险:若被 client 组件误引用将导致 250-300 KB 打包
|
||
|
||
### 1.5 next.config.ts 配置审计
|
||
|
||
**当前完整内容**(11 行):
|
||
|
||
```ts
|
||
const nextConfig: NextConfig = {
|
||
output: "standalone",
|
||
serverExternalPackages: ["mysql2"],
|
||
};
|
||
```
|
||
|
||
**缺失配置项**:
|
||
|
||
| 缺失配置 | 影响 | 严重度 |
|
||
|---------|------|--------|
|
||
| `experimental.optimizePackageImports` | lucide-react/recharts/@xyflow/react/@radix-ui/* /@tiptap/* 未启用按需优化 | P0 |
|
||
| `serverExternalPackages` 未含 `tencentcloud-sdk-nodejs` | 2-3 MB SDK 误打包风险 | P0 |
|
||
| `serverExternalPackages` 未含 `exceljs` | 250-300 KB 误打包风险 | P1 |
|
||
| 自定义 webpack/turbopack manualChunks | 无 splitChunks 自定义 | P2 |
|
||
| `experimental.webVitalsAttribution` | 无法精确定位 Web Vitals 来源元素 | P2 |
|
||
|
||
**建议补充配置**:
|
||
|
||
```ts
|
||
const nextConfig: NextConfig = {
|
||
output: "standalone",
|
||
serverExternalPackages: ["mysql2", "tencentcloud-sdk-nodejs", "exceljs"],
|
||
experimental: {
|
||
optimizePackageImports: [
|
||
"lucide-react",
|
||
"recharts",
|
||
"@xyflow/react",
|
||
"@tiptap/react",
|
||
"@tiptap/starter-kit",
|
||
"@tiptap/extension-placeholder",
|
||
"@tiptap/extension-image",
|
||
"@radix-ui/react-dialog",
|
||
"@radix-ui/react-dropdown-menu",
|
||
"@radix-ui/react-select",
|
||
// ... 其余 radix 包
|
||
],
|
||
},
|
||
};
|
||
```
|
||
|
||
### 1.6 Barrel File 风险
|
||
|
||
| 路径 | 导出 | 风险 |
|
||
|------|------|------|
|
||
| `src/shared/components/ui/` | 无 index.ts barrel | ✅ 良好 |
|
||
| `src/modules/exams/editor/index.ts` | ExamRichEditor + 6 个 tiptap 扩展 + 类型 | **P0 高风险** |
|
||
| `src/modules/exams/ai-pipeline/index.ts` | — | 低 |
|
||
| `src/shared/hooks/index.ts` | 7 个 hook | 低 |
|
||
|
||
**[P0] exams/editor/index.ts 高风险 barrel**
|
||
- 位置:`src/modules/exams/editor/index.ts:1-26`
|
||
- 已造成实际问题:`src/app/(dashboard)/teacher/exams/[id]/edit-rich/page.tsx:9-14` 已有显式注释绕过 barrel
|
||
- 但同文件第 15 行同步导入 `ExamRichForm`(其内部第 22-32 行又通过 barrel 导入)→ 绕过失效
|
||
- 错误信息:`'Class extends value undefined is not a constructor or null'`(Server Component 连带加载 @tiptap/react 客户端扩展)
|
||
- 修复方向:拆分为 `editor/types.ts`(仅类型,可被 server 引用)+ `editor/index.ts`(仅值导出,禁止 server 引用)
|
||
|
||
### 1.7 图片/字体/静态资源
|
||
|
||
| 资源 | 现状 | 评估 |
|
||
|------|------|------|
|
||
| `next/image` 使用 | 4 处(exam-preview、scan-uploader、scan-image-viewer、exam-rich-form) | ✅ 良好 |
|
||
| 原生 `<img>` | 1 处(scan-image-viewer.tsx:143,已加 eslint-disable 注释) | ⚠️ 见 CLS-1 |
|
||
| `next/font` | 1 个(Inter,`display: "swap"` + `variable`) | ✅ 良好 |
|
||
| `manifest.ts` | 完整 | ✅ 良好 |
|
||
| `public/` | 5 个 SVG | ✅ 良好 |
|
||
|
||
### 1.8 Bundle 预算建议基线表
|
||
|
||
> 因当前 `next build` 失败无法获取实际产物体积,以下为基于依赖估算的 K12 教务系统推荐基线(gzip 后)。
|
||
|
||
| 路由类别 | First Load JS 阈值 | 当前估算 | 严重度 |
|
||
|---------|-------------------|---------|--------|
|
||
| 落地页 / 登录 / 注册(`(auth)/*`) | ≤ 180 KB | ~150-180 KB | P2 |
|
||
| 学生端 Dashboard | ≤ 250 KB | ~250-320 KB | P2 |
|
||
| 教师端 Dashboard | ≤ 280 KB | ~280-350 KB | P2 |
|
||
| 列表/详情页(公告/作业/成绩等) | ≤ 300 KB | ~300-400 KB(recharts 拉入) | P1 |
|
||
| **教材阅读页**(含 ReactFlow + tiptap + force-graph) | ≤ 600 KB | **~700-900 KB**(全量静态导入) | **P0** |
|
||
| **试卷富文本编辑页**(含 tiptap 全家桶) | ≤ 500 KB | **~600-750 KB** | **P0** |
|
||
| **备课编辑页**(含 tiptap + dnd-kit) | ≤ 550 KB | ~600-800 KB | **P0** |
|
||
| 单路由增量 chunk(动态加载组件) | ≤ 200 KB | force-graph 单独 chunk ~80 KB ✅ | — |
|
||
| 共享框架 chunk(react/react-dom/next) | ≤ 130 KB | ~130 KB(标准) | — |
|
||
| 单文件体积上限(lint 规则) | ≤ 1000 行 | 已有项目规则约束 | — |
|
||
|
||
---
|
||
|
||
## 第二部分:Core Web Vitals 审计
|
||
|
||
### 2.1 LCP(最大内容绘制)
|
||
|
||
#### [P1] LCP-1:根 layout.tsx 串行 await
|
||
- **位置**:`src/app/layout.tsx:45-50`
|
||
- **问题**:每个 SSR 请求串行调用 `getLocale()` → `getMessages()` → `auth()`,三者无数据依赖
|
||
- **影响**:TTFB、LCP、FCP(每个页面渲染都被推迟)
|
||
- **修复**:`const [locale, messages, session] = await Promise.all([getLocale(), getMessages(), auth()])`
|
||
|
||
#### [P1] LCP-3:teacher/student/parent/management 根路由缺失 loading.tsx
|
||
- **问题**:通过 Glob 搜索 `**/loading.tsx` 仅找到 `admin/loading.tsx`,4 个角色根路由段下无 loading.tsx
|
||
- **影响**:角色切换/首次进入时白屏闪烁,LCP/CLS 退化
|
||
- **建议**:为 4 个角色根路由分别创建 `loading.tsx`,复用 `DashboardLoadingSkeleton`
|
||
|
||
#### [P1] LCP-5:force-dynamic 滥用,无 ISR 缓存收益
|
||
- **问题**:全项目共 **96 处** `export const dynamic = "force-dynamic"`,即使是相对静态的页面(学校列表、部门列表、教材目录)也强制动态
|
||
- **影响**:每请求都重新 SSR + DB 查询,TTFB/LCP 无静态缓存命中加速
|
||
- **建议**:对低频变更页面用 `revalidate = 3600` 替代 `force-dynamic`
|
||
|
||
#### [P2] LCP-2:备课编辑器 textbook → chapters 数据瀑布
|
||
- **位置**:`src/app/(dashboard)/teacher/lesson-plans/[planId]/edit/page.tsx:34-46`
|
||
- **问题**:第 34-46 行串行调用 `getTextbookById()` → `getChaptersByTextbookId()`
|
||
- **参考**:`parent/lesson-plans/[planId]/view/page.tsx:49-52` 已修复为 `Promise.all`
|
||
|
||
#### 正面发现
|
||
- ✅ `teacher/lesson-plans/page.tsx`、`teacher/exams/[id]/build/page.tsx`、`teacher/exams/[id]/analytics/page.tsx`、`parent/lesson-plans/[planId]/view/page.tsx` 均用 `Promise.all`
|
||
- ✅ 4 个角色 dashboard 均用 React `use()` + Suspense 流式渲染
|
||
- ✅ `DashboardSection` 封装 `SectionErrorBoundary + Suspense + 骨架屏`,分区流式渲染
|
||
|
||
### 2.2 CLS(累计布局偏移)
|
||
|
||
#### [P2] CLS-1:scan-image-viewer.tsx 使用 `<img>` 且未指定宽高
|
||
- **位置**:`src/modules/homework/components/scan-image-viewer.tsx:142-148`
|
||
- **问题**:原生 `<img>`(已加 eslint-disable 注释)仅设 `maxHeight: "80vh"`,无 width/height 也无 aspect-ratio
|
||
- **影响**:图片加载完成时推动布局
|
||
- **修复**:用 `next/image` 替换(同文件第 194-200 行缩略图已正确用 `next/image fill sizes="48px"`),或为 `<img>` 加 `aspect-ratio` 内联样式
|
||
|
||
#### 正面发现
|
||
- ✅ 字体配置正确(`display: "swap"` + `variable`)
|
||
- ✅ `<iframe>` 全项目零使用
|
||
- ✅ Sonner Toaster 使用 fixed 定位
|
||
- ✅ ThemeProvider 配置 `disableTransitionOnChange`
|
||
|
||
### 2.3 INP(交互到下一次绘制)
|
||
|
||
#### [P2] INP-3:AiAssistantWidget 未懒加载
|
||
- **位置**:`src/app/(dashboard)/layout.tsx:7,29`
|
||
- **问题**:`<AiAssistantWidget />` 在 dashboard layout 同步渲染,依赖 `useFloatingBall`(拖拽位置计算 + localStorage 持久化 + 屏幕边缘吸附)+ `AiChatPanel` + `Sheet`,未通过 `next/dynamic` 懒加载
|
||
- **影响**:INP(主线程占用)、FCP(hydration 时间)
|
||
- **建议**:用 `next/dynamic` 包裹,`ssr: false`,初始仅渲染悬浮球,点击后再加载 chat panel
|
||
|
||
#### [P3] INP-4:AppSidebar 未 memo 化导航项
|
||
- **位置**:`src/modules/layout/components/app-sidebar.tsx:31-60`
|
||
- **问题**:每次渲染对 `allNavItems` 做 `filter + map` 重新计算 `navItems`,未使用 `useMemo`
|
||
- **建议**:`useMemo` 包裹 `navItems` 计算,依赖 `[allNavItems, permissions]`
|
||
|
||
#### 正面发现
|
||
- ✅ 全项目 44 处 `useTransition` / `useDeferredValue` 使用,覆盖良好(exam-assembly 防抖、exam-rich-form 双 transition、scan-uploader 等)
|
||
- ✅ 备课编辑器使用 zustand store,无大对象 setState
|
||
- ✅ react-force-graph-2d 已正确动态加载
|
||
|
||
### 2.4 FCP / TTFB
|
||
|
||
#### [P1] FCP-1:根 layout 串行 await(同 LCP-1)
|
||
见 LCP-1。
|
||
|
||
#### [P2] FCP-4:next.config.ts 未启用 optimizePackageImports
|
||
见第一部分 1.5。
|
||
|
||
#### 正面发现
|
||
- ✅ `proxy.ts` 中间件无数据库同步调用,纯 JWT 位图运算
|
||
- ✅ `instrumentation.ts` 启动时一次性执行
|
||
- ✅ `globals.css` 多层 @import 均为本地资源无外部阻塞
|
||
|
||
### 2.5 资源加载
|
||
|
||
#### [P2] RES-1:metadata 缺 metadataBase / openGraph / robots
|
||
- **位置**:`src/app/layout.tsx:15-24`
|
||
- **问题**:metadata 仅含 `title`/`description`/`manifest`/`appleWebApp`,缺 `metadataBase`、`openGraph`、`twitter`、`robots`;`src/app/icon.svg` 文件可能不存在(`public/` 下仅有 5 个 SVG)
|
||
- **影响**:FCP(OG image 加载)、SEO、build 时 warn
|
||
- **建议**:补充 `metadataBase: new URL(process.env.NEXTAUTH_URL ?? "http://localhost:3000")`
|
||
|
||
#### 正面发现
|
||
- ✅ `viewport` 导出符合 Next.js 16 规范
|
||
- ✅ `manifest.ts` 配置完整
|
||
- ✅ `<Script>` 标签零使用,无第三方脚本阻塞
|
||
- ✅ `next/image` 使用合规(4 处)
|
||
|
||
### 2.6 性能监控缺失
|
||
|
||
#### [P0] MON-1:无 Web Vitals 上报闭环
|
||
- **问题**:Grep `web-vitals|reportWebVitals|useReportWebVitals|onCLS|onINP|onLCP` 在 `src/` 下零匹配;`package.json` 无 `web-vitals` 依赖
|
||
- **影响**:生产环境无法追踪真实用户 LCP/CLS/INP/TTFB/FCP,所有性能问题只能靠经验推断
|
||
- **建议**:
|
||
1. 安装 `web-vitals` 包
|
||
2. 在 `src/app/layout.tsx` 或新建 `src/instrumentation-client.ts` 实现 `useReportWebVitals`,上报到 `/api/web-vitals` 或第三方(Vercel Analytics / PostHog / Sentry)
|
||
|
||
#### [P1] MON-2:无 Lighthouse CI 配置
|
||
- **问题**:项目根目录无 `.lighthouserc.json` / `.lighthouserc.js`;`package.json` 无 `@lh3/cli` 依赖;`.gitea/workflows/ci.yml` 无 lighthouse step
|
||
- **影响**:CI 流水线无 Web Vitals 回归门槛,性能劣化无法自动拦截
|
||
- **建议**:在 `.gitea/workflows/` 新增 `lighthouse.yml`,配置 Lighthouse CI 对关键路由(`/dashboard`、`/teacher/lesson-plans`、`/admin/questions`)做采样断言
|
||
|
||
#### [P2] MON-4:Playwright 无性能断言
|
||
- **问题**:`playwright.config.ts` 已配置 E2E + 视觉回归,但未使用 `page.metrics()` 或 `@playwright/test-performance` 做性能断言
|
||
- **建议**:在 `tests/e2e/` 新增 `performance.spec.ts`,使用 `page.evaluate(() => performance.getEntriesByType('navigation'))` 断言关键路由 LCP
|
||
|
||
### 2.7 路由级性能风险
|
||
|
||
| 路由 | LCP 风险 | INP 风险 | 严重度 |
|
||
|------|---------|---------|--------|
|
||
| 备课编辑器 `teacher/lesson-plans/[planId]/edit` | 中(waterfall + ReactFlow + tiptap) | 中(多 useTransition + zustand) | P2 |
|
||
| 试卷富文本编辑器 `teacher/exams/[id]/edit-rich` | 中(两 await 无 Promise.all) | 高(tiptap + 多状态) | P2 |
|
||
| 教材详情/知识图谱 `teacher/textbooks/[id]` | 低(已 Promise.all) | 高(react-force-graph + @xyflow) | P2 |
|
||
| 题库管理 `admin/questions` | 中(200 题一次拉) | 高(200 题列表渲染) | P2 |
|
||
| 4 个角色 dashboard | 低(已流式渲染) | 中(recharts) | P3 |
|
||
|
||
### 2.8 Web Vitals 预算建议表
|
||
|
||
| 指标 | Good(达标)| Needs Improvement | Poor | 建议预算(P95)|
|
||
|------|------------|-------------------|------|---------------|
|
||
| **LCP** | ≤ 2.5s | 2.5s - 4.0s | > 4.0s | ≤ 2.5s(备课编辑器允许 ≤ 3.0s)|
|
||
| **INP** | ≤ 200ms | 200ms - 500ms | > 500ms | ≤ 200ms(仪表盘图表允许 ≤ 300ms)|
|
||
| **CLS** | ≤ 0.1 | 0.1 - 0.25 | > 0.25 | ≤ 0.05 |
|
||
| **TTFB** | ≤ 800ms | 800ms - 1800ms | > 1800ms | ≤ 800ms(dashboard 路由)|
|
||
| **FCP** | ≤ 1.8s | 1.8s - 3.0s | > 3.0s | ≤ 1.8s |
|
||
|
||
#### 按路由细分预算建议
|
||
|
||
| 路由 | LCP | INP | TTFB | 主要风险 |
|
||
|------|-----|-----|------|---------|
|
||
| `/login` | ≤ 1.5s | ≤ 100ms | ≤ 500ms | 无 |
|
||
| `/admin/dashboard` | ≤ 2.5s | ≤ 300ms | ≤ 800ms | recharts 多图表 + 6 个并行数据流 |
|
||
| `/teacher/dashboard` | ≤ 2.5s | ≤ 300ms | ≤ 800ms | 同上 |
|
||
| `/student/dashboard` | ≤ 2.0s | ≤ 200ms | ≤ 800ms | 数据较少 |
|
||
| `/parent/dashboard` | ≤ 2.5s | ≤ 250ms | ≤ 800ms | 多子女卡片 + AI 摘要 |
|
||
| `/teacher/lesson-plans/[id]/edit` | ≤ 3.0s | ≤ 400ms | ≤ 1000ms | ReactFlow + tiptap + 数据瀑布 |
|
||
| `/teacher/exams/[id]/edit-rich` | ≤ 3.0s | ≤ 400ms | ≤ 1000ms | tiptap 富文本 |
|
||
| `/teacher/textbooks/[id]` | ≤ 2.5s | ≤ 400ms | ≤ 800ms | react-force-graph-2d(动态加载)|
|
||
| `/admin/questions` | ≤ 2.5s | ≤ 400ms | ≤ 800ms | 200 题列表 |
|
||
|
||
---
|
||
|
||
## 第三部分:组件渲染性能审计
|
||
|
||
### 3.1 Memoization 缺失审计
|
||
|
||
#### 全局使用统计
|
||
|
||
| 指标 | 数量 | 备注 |
|
||
|------|------|------|
|
||
| `React.memo` / `memo()` | **4 处 / 4 文件** | 严重偏低(项目有 100+ 客户端组件) |
|
||
| `useMemo` | 113 处 / 70 文件 | 中等覆盖 |
|
||
| `useCallback` | 129 处 / 53 文件 | 中等覆盖 |
|
||
| `style={{ ... }}` 内联 | **122 处 / 36 文件** | 大量内联对象每次渲染重建 |
|
||
|
||
`memo / useMemo` 比例 = 4 / 113 ≈ **3.5%**,远低于企业级项目 30%+ 常见水平。
|
||
|
||
#### React.memo 实际使用位置(仅 4 处)
|
||
- `src/modules/ai/components/ai-markdown-renderer.tsx:162` — `AiMarkdownRenderer` 正确 memo
|
||
- `src/modules/textbooks/components/graph-prerequisite-edge.tsx`
|
||
- `src/modules/textbooks/components/graph-kp-node.tsx`
|
||
- `src/modules/student/components/student-courses-view.tsx`
|
||
|
||
#### [P1] 反例 1:recharts 内联对象 props
|
||
- **位置**:`src/modules/dashboard/components/admin-dashboard/user-growth-chart.tsx:39-61`
|
||
- **代码**:
|
||
```tsx
|
||
<LineChart data={data} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}>
|
||
<XAxis tick={{ fontSize: 12 }} />
|
||
<Tooltip contentStyle={{ backgroundColor: "hsl(var(--background))", ... }} />
|
||
<Line dot={{ fill: "hsl(var(--primary))", r: 3 }} />
|
||
</LineChart>
|
||
```
|
||
- **影响**:`margin` / `tick` / `contentStyle` / `dot` 每次渲染均为新对象引用,触发 recharts 内部 SVG 元素重渲染
|
||
- **修复**:提升为模块级常量
|
||
|
||
#### [P1] 反例 2:内联事件回调每行重建
|
||
- **位置**:`src/modules/messaging/components/message-list.tsx:319-321`
|
||
- **代码**:`<button onClick={(e) => void handleToggleStar(e, m.id, isStarred)} ... />`
|
||
- **影响**:20 条消息 × 多个内联回调,父组件每次 setTab/setKeyword 都重建 20+ 函数
|
||
|
||
#### [P2] 反例 3:style 内联 122 处
|
||
- **位置**:`lesson-preparation/components/detail-panel/qa-editor.tsx`(16 处)、`paper-editor/paper-context-menu.tsx`(11 处)、`paper-editor/inline-node.tsx`(11 处)、`detail-panel/detail-head.tsx`(7 处)、`detail-panel/detail-panel.tsx`(7 处)
|
||
- **影响**:备课编辑器子组件每次父级渲染都重算样式对象
|
||
|
||
#### [P2] 反例 4:昂贵计算未 memo
|
||
- **位置**:`src/modules/lesson-preparation/components/paper-editor/paper-editor.tsx:36`
|
||
- **代码**:`const textbookNode = doc.nodes.find((n) => n.type === "textbook_content");`
|
||
- **影响**:`doc` 引用变化(任意节点更新)都重算
|
||
- **同样问题**:`src/modules/lesson-preparation/components/structure-tree/structure-tree.tsx:24`
|
||
|
||
### 3.2 Context 嵌套审计
|
||
|
||
#### 全部 Context Provider 清单(共 17 个)
|
||
|
||
根布局链路(providers.tsx + dashboard layout.tsx):
|
||
|
||
```
|
||
QueryClientProvider (1)
|
||
└─ NextIntlClientProvider (2)
|
||
└─ ThemeProvider (3)
|
||
└─ SessionProvider (4)
|
||
└─ NuqsAdapter (5)
|
||
└─ AiClientProvider (6, dashboard 层)
|
||
└─ SidebarProvider (7, dashboard 层)
|
||
└─ children
|
||
```
|
||
|
||
**7 层嵌套**,未达到 5+ 警戒线的"严重"标准但有提升空间。
|
||
|
||
#### [P0] 关键反例:SidebarProvider value 内联对象
|
||
- **位置**:`src/modules/layout/components/sidebar-provider.tsx:63-66`
|
||
- **代码**:
|
||
```tsx
|
||
<SidebarContext.Provider
|
||
value={{ expanded, setExpanded, isMobile, toggleSidebar }}
|
||
>
|
||
```
|
||
- **问题**:
|
||
1. `value` 是每次渲染新建的对象字面量,引用必然不同
|
||
2. `toggleSidebar` 不是 `useCallback`,每次重建
|
||
3. `useSidebar()` 的所有 consumer(`SiteHeader`、`AppSidebar` 等)在 SidebarProvider 任意 state 变化时全部重渲染
|
||
4. 第 51 行 `window.addEventListener("resize", checkMobile)` 未防抖,拖动窗口时连续触发 `setIsMobile`
|
||
- **影响**:全站所有 dashboard 页面 Header/Sidebar 重渲染频率高
|
||
- **修复**:`value` 用 `useMemo([expanded, isMobile])`,`toggleSidebar` 用 `useCallback`,resize 加防抖
|
||
|
||
### 3.3 大列表虚拟化审计
|
||
|
||
#### 关键发现:package.json 中未安装任何虚拟化库
|
||
- ❌ `@tanstack/react-virtual`
|
||
- ❌ `react-window`
|
||
- ❌ `react-virtuoso`
|
||
|
||
#### [P0] 重点反例:题库表无虚拟化
|
||
- **位置**:`src/modules/questions/components/question-data-table.tsx:45-59`
|
||
- **代码**:
|
||
```tsx
|
||
const table = useReactTable({
|
||
data, // 题库可能上千条
|
||
columns,
|
||
getCoreRowModel: getCoreRowModel(),
|
||
getPaginationRowModel: getPaginationRowModel(),
|
||
getSortedRowModel: getSortedRowModel(),
|
||
getFilteredRowModel: getFilteredRowModel(), // 客户端全量过滤
|
||
// 无 enableRowVirtualization / getVirtualizedRowModel
|
||
})
|
||
```
|
||
- **影响**:题库 1000+ 条时,即使分页每页 10 条,`getFilteredRowModel` 仍要遍历全部数据;切换筛选条件时一次性 diff 大量 DOM
|
||
- **预期数据量**:1000-10000+ 题
|
||
|
||
#### 其他大列表
|
||
|
||
| 列表 | 文件:行号 | 渲染方式 | 预期数据量 | 严重度 |
|
||
|------|----------|----------|-----------|--------|
|
||
| 考试列表 | `src/modules/exams/components/exam-data-table.tsx:78-87` | 全量渲染 | 100-1000+ | P1 |
|
||
| 出勤表 | `src/modules/attendance/components/attendance-sheet.tsx` | 一次性渲染全班学生 × 6 状态按钮 | 30-60 学生/班 × 6 = 180-360 控件 | P1 |
|
||
| 审计日志表 | `src/modules/audit/components/audit-log-table.tsx:61-105` | 服务端分页 + 每行 `AuditLogDetailDialog` | 10-50/页 | P2 |
|
||
| 消息列表 | `src/modules/messaging/components/message-list.tsx:263-352` | 客户端分页 PAGE_SIZE=20,每行 Card 较重 | 20/页 | P2 |
|
||
| 用户列表 | `src/modules/users/components/admin-users-view.tsx:219-265` | 服务端分页 + 每行 DropdownMenu | 10-50/页 | P2 |
|
||
| 备课结构树 | `src/modules/lesson-preparation/components/structure-tree/structure-tree.tsx:79-107` | 全量渲染所有节点 | 5-50 节点 | P3 |
|
||
| 教材章节图 | `src/modules/textbooks/components/knowledge-graph.tsx` + `force-graph.tsx` | @xyflow/react + react-force-graph-2d | 100+ 节点 | P2 |
|
||
|
||
### 3.4 Zustand Store 性能审计
|
||
|
||
#### Store 清单(共 2 个)
|
||
|
||
| Store | 文件 | slices | 使用情况 |
|
||
|-------|------|--------|----------|
|
||
| `useUiStore` | `src/shared/stores/ui-store.ts` | 单一 | **未被任何组件使用(死代码)** |
|
||
| `useLessonPlanEditor` | `src/modules/lesson-preparation/hooks/use-lesson-plan-editor.ts` | 5 个 slices(editor / selection / version / history / expanded) | 备课编辑器全量使用 |
|
||
|
||
#### [P1] 反例 1:useModal 返回新对象引用(当前为死代码)
|
||
- **位置**:`src/shared/stores/ui-store.ts:73-78`
|
||
- **代码**:
|
||
```tsx
|
||
return useUiStore((s) => ({
|
||
open: Boolean(s.modals[name]),
|
||
props: s.modals[name]?.props,
|
||
openModal: (props?: ModalProps) => s.openModal(name, props),
|
||
closeModal: () => s.closeModal(name),
|
||
}))
|
||
```
|
||
- **问题**:Zustand v5 默认使用 `Object.is` 比较,selector 返回新对象字面量意味着每次 store 变化都触发组件重渲染
|
||
- **当前状态**:Grep 显示 `useUiStore` / `useModal` 仅在 `ui-store.ts` 自身出现,**项目其他位置未使用**,是预备的 modal registry 但未落地。一旦采用会立刻触发性能问题
|
||
- **修复**:用 `useShallow` 包裹
|
||
|
||
#### [P0] 反例 2:备课编辑器无 selector 订阅全 store
|
||
- **位置**(4 处直接调用 `useLessonPlanEditor()` 不带 selector):
|
||
|
||
| 文件:行号 | 解构 | 影响 |
|
||
|----------|------|------|
|
||
| `src/modules/lesson-preparation/components/lesson-plan-editor.tsx:62` | `const editor = useLessonPlanEditor()` | 订阅全 store,每次按键/选区/展开/撤销/保存状态变化都重渲染整个三栏布局 |
|
||
| `src/modules/lesson-preparation/components/blocks/text-study-block.tsx:22` | `const { updateNode } = useLessonPlanEditor()` | updateNode 引用稳定,但订阅粒度过粗 |
|
||
| `src/modules/lesson-preparation/components/blocks/exercise-block.tsx:31` | `const { updateNode, planId } = useLessonPlanEditor()` | 同上 |
|
||
| `src/modules/lesson-preparation/components/paper-editor/paper-context-menu.tsx:40` | 多重解构 | 同上 |
|
||
|
||
- **影响**:备课编辑器是高频交互场景(键盘输入 + 节点选中 + 撤销重做),订阅全 store 导致每次按键都重渲染 StructureTree + PaperEditor + DetailPanel 三个子树
|
||
- **修复**:拆为多个细粒度 selector:`useLessonPlanEditor((s) => s.title)`、`s.isSaving`、`s.isDirty` 等;block 组件改为 `const updateNode = useLessonPlanEditor((s) => s.updateNode)`
|
||
|
||
#### 正面发现
|
||
- ✅ `useLessonPlanEditor` 已正确拆分为 5 个 slice,便于按 slice 订阅
|
||
- ✅ Slice 文件使用 `import type { StateCreator }` 类型导入,无运行时循环依赖
|
||
- ✅ 备课模块其余 25 处 `useLessonPlanEditor((s) => s.xxx)` 使用了正确 selector 模式
|
||
|
||
### 3.5 Server / Client 边界审计
|
||
|
||
#### 优秀实践
|
||
- ✅ Server Component 路由分发:`src/app/(dashboard)/dashboard/page.tsx`
|
||
- ✅ Server Component + Suspense + Streams:admin-dashboard.tsx
|
||
- ✅ Server Component 嵌套 Provider:dashboard layout 在 server 函数体内 `createFullAiClientService()`,避免模块级单例
|
||
- ✅ 100+ 页面 Server Component
|
||
- ✅ Server → Client 通过 ReactNode slot:`<SidebarProvider sidebar={<AppSidebar />}>`
|
||
- ✅ Server Action + `useFormStatus`:5 处
|
||
- ✅ Dialog 内部状态管理:lesson-preparation providers 用 `useMemo` 包裹 service value
|
||
|
||
#### [P2] 反例:Server → Client 数据传递过大对象
|
||
|
||
| 传递点 | 文件 | 数据量 | 评估 |
|
||
|--------|------|--------|------|
|
||
| `messages` prop → `MessageList` | `src/app/(dashboard)/messages/page.tsx` → `message-list.tsx` | 全部 messages(含 content、senderName 等) | ⚠️ 可能过大,无字段裁剪 |
|
||
| `messages` prop → NextIntlClientProvider | `providers.tsx:46` | **全量 i18n 字典** | ⚠️ 应按 namespace 拆分 |
|
||
|
||
### 3.6 React 19 特定优化审计
|
||
|
||
#### 关键缺失
|
||
|
||
| 特性 | 是否启用 | 严重度 | 备注 |
|
||
|------|---------|--------|------|
|
||
| React Compiler (`babel-plugin-react-compiler`) | ❌ **未安装** | **P0** | package.json 无依赖,next.config.ts 无配置 |
|
||
| `use()` Hook 处理 Promise | ❌ 0 处使用 | P2 | admin-dashboard 注释声称使用,实际是 Suspense + await |
|
||
| `useOptimistic` | ❌ 0 处使用 | P1 | 消息星标、出勤状态、批改提交等多处应使用 |
|
||
| `useFormStatus` | ✅ 5 处 | — | attendance-sheet、attendance-rules-form、create-chapter-dialog、textbook-form-dialog、password-change-form 等 |
|
||
| `useActionState` | ✅ 3 处 | — | password-change-form、leave-request-form、leave-review-dialog |
|
||
|
||
#### [P0] React Compiler 缺失
|
||
- **问题**:
|
||
- `package.json` 无 `babel-plugin-react-compiler` 依赖
|
||
- `next.config.ts` 未配置 `reactCompiler: true`
|
||
- `eslint-plugin-react-compiler` 未安装
|
||
- **影响**:React Compiler 可自动 memoize 组件内部状态和派生值,可一次性解决第 3.1 节列出的所有内联对象/未 memo 问题。React 19 + Next.js 16 是启用 Compiler 的最佳时机
|
||
- **修复**:安装 `babel-plugin-react-compiler` + `eslint-plugin-react-compiler`,在 `next.config.ts` 加 `experimental: { reactCompiler: true }` 或使用 Next.js 16 的稳定 API
|
||
|
||
#### [P1] useOptimistic 缺失场景
|
||
|
||
##### 场景 1:消息星标切换
|
||
- **位置**:`src/modules/messaging/components/message-list.tsx:119-148`
|
||
- **现状**:用 `starredOverride` state + try/catch 回滚模拟乐观更新
|
||
- **修复**:
|
||
```tsx
|
||
const optimisticMessages = useOptimistic(messages, (state, { id, isStarred }) =>
|
||
state.map(m => m.id === id ? { ...m, isStarred } : m)
|
||
)
|
||
```
|
||
|
||
##### 场景 2:出勤状态切换
|
||
- **位置**:`src/modules/attendance/components/attendance-sheet.tsx:129-140`
|
||
- **现状**:`statuses` state 实际是乐观值容器
|
||
- **修复**:用 `useOptimistic` 表达
|
||
|
||
##### 场景 3:成绩录入
|
||
- **位置**:`src/modules/grades/components/batch-grade-entry.tsx`、`grade-record-form.tsx`
|
||
|
||
### 3.7 重渲染热点审计
|
||
|
||
#### [P0] 7.1 备课编辑器(lesson-preparation)— 重灾区
|
||
|
||
##### 7.1.1 LessonPlanEditor 订阅全 store
|
||
- **位置**:`src/modules/lesson-preparation/components/lesson-plan-editor.tsx:62`
|
||
- **代码**:`const editor = useLessonPlanEditor();`
|
||
- **影响**:该组件渲染顶部工具栏(标题输入框、保存/撤销/重做/发布等 10+ 按钮)+ 三栏布局(StructureTree + PaperEditor + DetailPanel)+ 5 个 Dialog。`editor` 任意字段变化(每次按键改 title、每次选区变化、每次撤销重做、每次保存状态切换)都触发整个组件树重渲染
|
||
- **额外问题**:`editor.canUndo()` / `editor.canRedo()` 在 render 中直接调用(第 189-190 行),不是 selector,每次渲染都执行函数调用,且依赖整个 history slice
|
||
|
||
##### 7.1.2 PaperEditor 订阅 doc 整体
|
||
- **位置**:`src/modules/lesson-preparation/components/paper-editor/paper-editor.tsx:25`
|
||
- **代码**:`const doc = useLessonPlanEditor((s) => s.doc);`
|
||
- **影响**:`doc` 是不可变对象,任意 node 更新都生成新 `doc` 引用,导致 PaperEditor 全量重渲染
|
||
- **额外问题**:第 36 行 `doc.nodes.find()` 无 memo;第 54-65 行 `onPaperContextMenu` / `onInlineNodeContextMenu` / 第 86 行 `onContextMenu` 内联回调;第 133-141 行 `expandedNodes.map` 内联函数
|
||
|
||
##### 7.1.3 StructureTree 同样问题
|
||
- **位置**:`src/modules/lesson-preparation/components/structure-tree/structure-tree.tsx:12-14`
|
||
- **影响**:订阅 `doc` + `expandedNodeIds` + `addNode`,每次 doc 变化都重渲染所有 TreeNodeRow。第 79-107 行 `teachingNodes.map` 内联 `childItems` 数组与 `onToggleChildren` 回调
|
||
|
||
##### 7.1.4 tree-node-row.tsx 内联 style 11 处
|
||
- **位置**:`src/modules/lesson-preparation/components/structure-tree/tree-node-row.tsx`
|
||
- **影响**:11 处 `style={{}}` 内联,每次父级 doc 变化都重算
|
||
|
||
#### [P1] 7.3 表格组件(@tanstack/react-table)
|
||
|
||
##### 7.3.1 题库表 QuestionDataTable(P0)
|
||
- **位置**:`src/modules/questions/components/question-data-table.tsx:36-155`
|
||
- **问题**:无虚拟化(见 3.3)+ 第 78-93 行 `table.getHeaderGroups().map` 渲染表头 + 第 96-111 行 `table.getRowModel().rows.map` 渲染所有行 + 第 102-108 行 `row.getVisibleCells().map` 渲染所有单元格
|
||
- **额外问题**:第 61-64 行 `selectedIds` useMemo 依赖 `[table, getRowId]`,table 引用每次 useReactTable 调用都变化,导致 memo 失效
|
||
|
||
##### 7.3.2 考试表 ExamDataTable(P1)
|
||
- **位置**:`src/modules/exams/components/exam-data-table.tsx:33-132`
|
||
- **问题**:同样问题,无虚拟化
|
||
|
||
##### 7.3.3 审计日志表(P2)
|
||
- **位置**:`src/modules/audit/components/audit-log-table.tsx:31-118`
|
||
- **问题**:服务端分页(好),但每行渲染 `<AuditLogDetailDialog>`(第 101 行),即使未打开也实例化 Dialog 组件
|
||
- **建议**:改为点击行时 `next/dynamic` 加载 Dialog
|
||
|
||
#### [P2] 7.4 表单组件
|
||
|
||
##### 7.4.2 备课编辑器标题输入直接改 store
|
||
- **位置**:`src/modules/lesson-preparation/components/lesson-plan-editor.tsx:329-333`
|
||
- **代码**:`<input value={editor.title} onChange={(e) => editor.setTitle(e.target.value)} />`
|
||
- **影响**:每次按键直接触发 store 更新 → 触发 LessonPlanEditor 全量重渲染(因第 62 行无 selector)
|
||
- **修复**:使用 `useDeferredValue` 或本地 state + debounce
|
||
|
||
##### 7.4.3 AttendanceSheet 多重 useState
|
||
- **位置**:`src/modules/attendance/components/attendance-sheet.tsx:115-127`
|
||
- **问题**:11 个 useState(classId / date / period / statuses / reasons / searchQuery / focusedStudentIndex / isSubmitting / showSwitchConfirm / pendingClassIndex)。`statuses` / `reasons` 用 `Record<string, ...>` immutable 更新,每次按键都创建新对象 → 全表重渲染
|
||
|
||
---
|
||
|
||
## 第四部分:Server Action / 数据层审计
|
||
|
||
### 4.1 N+1 查询审计
|
||
|
||
#### [P0] 反例:循环单条 INSERT,无事务无批量化
|
||
- **位置**:`src/modules/course-plans/data-access.ts:347-406` `copyCoursePlanToClasses`
|
||
- **问题**:外层 `for (classId of targetClassIds)` 内 `await db.insert(coursePlans)`,嵌套 `for (item of sourceItems)` 内再 `await db.insert(coursePlanItems)`。10 班 × 20 周 = 200 次顺序 INSERT,无事务保护,无大小限制
|
||
- **影响**:复制 10 个班需 200+ 次往返;中途失败导致部分复制遗留脏数据;调用方传入 100+ classIds 时响应时间分钟级
|
||
- **修复**:改为 `db.transaction(async tx => { await tx.insert(coursePlans).values([...]) ; await tx.insert(coursePlanItems).values([...]) })`,并限制 `targetClassIds.length <= 50`
|
||
|
||
#### [P1] 反例:事务内循环 UPDATE
|
||
- **位置 1**:`src/modules/homework/data-access-write.ts:326-338` `autoGradeSubmission` 事务内
|
||
- **位置 2**:`src/modules/homework/data-access-write.ts:360-375` `gradeHomeworkAnswers` 事务内
|
||
- **问题**:`for (const ans of gradedAnswers) { await tx.update(homeworkAnswers) }`,每道题一次 UPDATE
|
||
- **影响**:30 题作业需 30 次 UPDATE,事务持锁时间线性增长,并发批改时锁竞争加剧
|
||
- **修复**:改用 MySQL `INSERT ... ON DUPLICATE KEY UPDATE` 或构造 `CASE WHEN id IN (...) THEN score ... END` 单条 UPDATE
|
||
|
||
#### [P2] 反例:批量删除 fallback 路径循环
|
||
- **位置**:`src/modules/files/data-access.ts:174-182` `deleteFileAttachments` fallback
|
||
- **问题**:主路径已 `inArray` 批量,但 fallback 仍循环单条 delete
|
||
|
||
#### 良好实践
|
||
- ✅ `src/modules/dashboard/data-access.ts:15-30` `getAdminDashboardData` 用 `Promise.all` 并行 6 个 stats
|
||
- ✅ `src/modules/homework/stats-service.ts:58-82` `getTeacherGradeTrends` 用 `Promise.all` 并行
|
||
- ✅ `src/modules/grades/data-access.ts:96-107` `getGradeRecords` 用 `Promise.all` 并行 count + rows
|
||
|
||
### 4.2 索引缺失审计
|
||
|
||
#### Schema 索引现状
|
||
- 总表数: 50+ 张
|
||
- 已建索引: 110+ 个(含复合、唯一、外键)
|
||
- 整体索引覆盖良好,但存在以下缺口
|
||
|
||
#### [P1] 高频查询字段无索引
|
||
|
||
| 表 | 缺失索引 | 影响 |
|
||
|---|---|---|
|
||
| `exams` (schema.ts:517-561) | `creatorId` 无索引 | `getExams` 按 `creatorId` 过滤("我的试卷"),全表扫描 |
|
||
| `exams` | `status` 无索引 | `getExams` 按 status 过滤;`getHomeworkDashboardStats` 反复按 status 过滤 |
|
||
| `examQuestions` (schema.ts:565-572) | `questionId` 无索引 | 题目被引用检测、删除时反查 exam_questions 全表扫描 |
|
||
| `homeworkAssignmentQuestions` (schema.ts:635-653) | `questionId` 无索引 | 同上 |
|
||
| `submissionAnswers` (schema.ts:589-602) | `questionId` 无索引 | 题目级统计反查全表 |
|
||
|
||
#### [P2] 复合索引未覆盖单字段查询
|
||
|
||
| 表 | 现状 | 问题 |
|
||
|---|---|---|
|
||
| `examSubmissions` (schema.ts:574-587) | 仅有 `(examId, studentId)` 复合 | 单独按 `studentId` 查询(学生全部提交)走全表 |
|
||
| `homeworkAnswers` (schema.ts:701-723) | 仅有 `(submissionId, questionId)` 复合 | 单独按 `questionId` 反查(错题统计)走全表 |
|
||
|
||
#### 修复建议
|
||
```sql
|
||
CREATE INDEX exams_creator_idx ON exams(creator_id);
|
||
CREATE INDEX exams_status_idx ON exams(status);
|
||
CREATE INDEX exam_questions_question_idx ON exam_questions(question_id);
|
||
CREATE INDEX hw_assignment_questions_question_idx ON homework_assignment_questions(question_id);
|
||
CREATE INDEX exam_submissions_student_idx ON exam_submissions(student_id);
|
||
CREATE INDEX submission_answers_question_idx ON submission_answers(question_id);
|
||
CREATE INDEX hw_answers_question_idx ON homework_answers(question_id);
|
||
```
|
||
|
||
#### 良好实践
|
||
- ✅ `attendanceRecords` 索引覆盖优秀:`classDatePeriodIdx` 复合索引覆盖按节次点名场景
|
||
- ✅ `gradeRecords` 已建 `classSubjectIdx` 复合索引
|
||
- ✅ `messages` 已建 `receiverReadIdx`、`receiverStarredIdx` 复合索引
|
||
- ✅ `auditLogs` 已建 `createdAtIdx` 支持时间范围查询
|
||
|
||
### 4.3 并行数据获取审计
|
||
|
||
#### [P2] 串行 await 可优化为 Promise.all
|
||
|
||
| 文件:行号 | 反例 | 优化 |
|
||
|---|---|---|
|
||
| `src/modules/users/data-access.ts:27-37` `getUserProfile` | 先 `db.query.users.findFirst`,再 `db.select roles` —— 两次串行 await | 改 `Promise.all([findUser, findRoles])` |
|
||
| `src/modules/homework/stats-service.ts:261-302` `getStudentDashboardGrades` | 9 次串行 await | 部分可并行(如 `assignments` 与 `getAssignmentMaxScoreById` 无依赖关系) |
|
||
| `src/modules/dashboard/page.tsx:11-15` 根 dashboard 页 | `await auth()` → `await resolvePermissions(roles)` 串行 | JWT 已含 permissionsBitmap,可直接 `decodePermissionsBitmap(session.user.permissionsBitmap)` 跳过 `resolvePermissions` DB 查询 |
|
||
| `src/modules/rbac/lib/data-scope-resolver.ts:71-79` parent scope | `getChildren` → `getGradeIdsForStudentIds` 串行 | 可在 `getChildren` 一并返回 studentIds → gradeIds 联表查询,消除二次往返 |
|
||
|
||
#### 良好实践
|
||
- ✅ `src/modules/dashboard/actions.ts:58-65` `getTeacherDashboardAction` 6 路并行
|
||
- ✅ `src/modules/dashboard/actions.ts:108-113` `getStudentDashboardAction` 4 路并行
|
||
- ✅ `src/modules/dashboard/data-access.ts:23-30` admin dashboard 6 路并行
|
||
- ✅ `src/modules/exams/data-access.ts:235-238` `resolveSubjectGradeNames` 2 路并行
|
||
|
||
### 4.4 缓存策略审计
|
||
|
||
#### [P0] 跨请求数据缓存完全缺失
|
||
|
||
| 现状 | 证据 |
|
||
|---|---|
|
||
| 全项目 0 处 `unstable_cache` 使用 | `Grep unstable_cache` 返回 0 匹配 |
|
||
| 全项目 0 处 `"use cache"` 指令 | Next.js 16 的 `"use cache"` 未启用 |
|
||
| 全项目 0 处 `cache: "no-store" / "force-cache"` 显式配置 | `Grep cache:\s*["']` 返回 0 匹配 |
|
||
| 全项目 0 处 `next: { revalidate }` 配置 | 同上 |
|
||
| Data-access 层仅使用 `cache()` from React(请求内去重) | 例如 `src/modules/dashboard/data-access.ts:15` `cache(async ...)` |
|
||
|
||
#### 影响
|
||
每个 Server Action 调用都重新查 DB,包括低频变更的高频读取数据:
|
||
- `getSubjectOptions` / `getGradeOptions`(学期内几乎不变)
|
||
- `getClassesDashboardStats`(班级总数)
|
||
- `getSystemSettings`(系统配置)
|
||
- `getTextbooks`(教材列表)
|
||
- `getRoles`(角色列表)
|
||
- `resolvePermissions`(每次 JWT refresh 才查 DB,但根 dashboard 仍重复查)
|
||
|
||
DataScope 每次 Server Action 调用都从 DB 重查(1-3 次查询)
|
||
|
||
#### 修复建议
|
||
```ts
|
||
// 1. 低变更频率数据用 unstable_cache + tag
|
||
import { unstable_cache } from "next/cache"
|
||
|
||
export const getSubjectOptions = unstable_cache(
|
||
async () => db.select().from(subjects).orderBy(asc(subjects.name)),
|
||
["subject-options"],
|
||
{ tags: ["subjects"], revalidateTag: 3600 }
|
||
)
|
||
|
||
// 2. Dashboard stats 用 5 分钟短缓存
|
||
export const getClassesDashboardStats = unstable_cache(
|
||
async () => { /* ... */ },
|
||
["classes-dashboard-stats"],
|
||
{ tags: ["dashboard", "classes"], revalidateTag: 300 }
|
||
)
|
||
|
||
// 3. DataScope 用 React cache() 请求级去重 + 短时 unstable_cache
|
||
// 注意:DataScope 变更(教师新接班级)需触发 tag 失效
|
||
```
|
||
|
||
#### 良好实践
|
||
- ✅ `revalidatePath` 在 50+ actions 文件中正确使用,路径精确(如 `/teacher/exams/all`、`/student/error-book`)
|
||
- ✅ `src/auth.ts:55-86` JWT callback 在 token refresh 时刷新 roles/permissions
|
||
|
||
### 4.5 数据传输体积审计
|
||
|
||
#### [P1] 单次查询拉取过大
|
||
|
||
| 文件:行号 | 问题 | 影响 |
|
||
|---|---|---|
|
||
| `src/modules/search/data-access.ts:64-79` `searchQuestions` | `select({ content: questions.content, ... })` 把整个 JSON 题干内容(可能含图片 base64 / SlateJS 节点树)拉出,仅用于 `truncate(text, 200)` 摘要 | 单题 content 可达 10-50 KB;搜索 10 条返回 500 KB+ |
|
||
| `src/modules/homework/stats-service.ts:162-172` `getHomeworkAssignmentAnalytics` | `db.query.homeworkSubmissions.findMany({ with: { answers: true, student: true } })` 一次拉取所有已批改提交 + 答案 + 学生信息,`limit: 5000`(line 345) | 50 人班级 × 30 题 = 1500 行 answer + 50 行 submission + 50 行 user,单次返回 MB 级 |
|
||
| `src/modules/users/data-access.ts:348-351` `getAllUserIds` | `db.select({ id: users.id }).from(users)` 无 LIMIT | 大型学校 5000+ 用户时返回 5000 行 |
|
||
| `src/modules/grades/data-access.ts:105` `getGradeRecords` | `.limit(params.limit ?? 1000)` 默认 1000 行 | 默认值偏高,前端通常只展示 20 行 |
|
||
|
||
#### [P2] 中等过度传输
|
||
- `src/modules/homework/stats-service.ts:216` `wrongAnswers.slice(0, 500)` 每题返回最多 500 个错答样本
|
||
- `src/modules/textbooks/data-access.ts:43-91` `getTextbooks` 无显式 LIMIT
|
||
|
||
#### 良好实践
|
||
- ✅ `users.password` 字段在所有 select 中均未暴露
|
||
- ✅ `getExams` 用 Drizzle `with: { subject, gradeEntity }` 关联查询,避免 N+1
|
||
- ✅ `getGradeRecords` 批量 fetch 名称而非逐行 JOIN,模式正确
|
||
- ✅ `getAdminUsers` 显式 `pageSize = Math.min(100, ...)` 限制
|
||
|
||
### 4.6 权限校验性能审计
|
||
|
||
#### [P0] 每次 Server Action 调用必查 DB
|
||
|
||
调用链: `actions.ts → requirePermission() → getAuthContext() → resolveDataScope() → DATA_SCOPE_RULES[].resolve(userId) → DB`
|
||
|
||
| 角色 | DB 查询次数 | 证据 |
|
||
|---|---|---|
|
||
| admin | 0(直接返回 `{type:"all"}`) | `data-scope-resolver.ts:30` |
|
||
| grade_head / teaching_head | 1 次(`getGradesForStaff`) | `data-scope-resolver.ts:36` |
|
||
| teacher | 2 次(并行:classes + subjects) | `data-scope-resolver.ts:48` 调 `getTeacherScopeData` 内部 Promise.all |
|
||
| student | 1+ 次(`getStudentScopeData`) | `data-scope-resolver.ts:60` |
|
||
| parent | 2 次(**串行**:`getChildren` → `getGradeIdsForStudentIds`) | `data-scope-resolver.ts:72-74` |
|
||
|
||
**关键证据**(`src/shared/lib/auth-guard.ts:44-47`):
|
||
```ts
|
||
// Resolve data scope from DB (not cached in JWT since it can change)
|
||
const dataScope = await resolveDataScope(userId, roleNames)
|
||
```
|
||
|
||
注释明确说明"不缓存因可变",导致每次 Server Action 至少 1-2 次 DB 查询仅用于权限。
|
||
|
||
#### [P2] getSessionTeacherId 未用 React cache() 包装
|
||
- **位置**:`src/modules/classes/data-access.ts:17-31` `getSessionTeacherId`
|
||
- **问题**:每次 `await auth()` + DB 查询,无 `cache()` 包装。同一请求内多次调用(如 dashboard action + 子函数)会重复查询
|
||
- **对比**:`getUserProfile`(`users/data-access.ts:26`)、`getAdminDashboardData`(`dashboard/data-access.ts:15`)、`getStudentDashboardGrades`(`stats-service.ts:257`)均用 `cache()` 包装,请求内去重正确
|
||
|
||
#### 修复建议
|
||
```ts
|
||
// 1. getAuthContext 用 React cache() 请求级去重
|
||
export const getAuthContext = cache(async (): Promise<AuthContext> => {
|
||
const session = await getSession()
|
||
// ... existing logic
|
||
})
|
||
|
||
// 2. DataScope 短时 unstable_cache(30s),变更时通过 tag 失效
|
||
const resolveDataScope = unstable_cache(
|
||
async (userId, roleNames) => { /* ... */ },
|
||
[`data-scope-${userId}`],
|
||
{ tags: [`user-scope-${userId}`], revalidateTag: 30 }
|
||
)
|
||
|
||
// 3. 教师新接班级 / 学生转班时调用 revalidateTag(`user-scope-${userId}`)
|
||
|
||
// 4. getSessionTeacherId 加 cache() 包装
|
||
export const getSessionTeacherId = cache(async () => { /* ... */ })
|
||
```
|
||
|
||
#### 良好实践
|
||
- ✅ `src/auth.ts:47-83` JWT callback 在 token 创建 + refresh 时统一查询 roles + permissions
|
||
- ✅ `src/auth.ts:47-50` permissions 用 bitmap 压缩(14 字符 vs 1.1 KB 数组),节省 cookie 体积
|
||
|
||
### 4.7 批量操作审计
|
||
|
||
#### [P0] 无事务、无批量、无大小限制
|
||
见 4.1 `copyCoursePlanToClasses`。
|
||
|
||
#### 良好实践
|
||
- ✅ 全项目 41+ 处 `db.transaction` 包裹写入操作
|
||
- ✅ `src/modules/grades/data-access.ts:179-201` `batchCreateGradeRecords` 正确用事务 + 批量 insert
|
||
- ✅ `src/modules/users/data-access.ts:130` 用户导入用事务
|
||
- ✅ `src/modules/elective/data-access-operations.ts` 选课操作多处用事务
|
||
|
||
### 4.8 慢查询风险审计
|
||
|
||
#### [P0] 全表扫描 / 无 LIMIT 全量返回
|
||
|
||
| 文件:行号 | 问题 |
|
||
|---|---|
|
||
| `src/modules/search/data-access.ts:74-75` `searchQuestions` | `CAST(${questions.content} AS CHAR) LIKE ${kw}` —— JSON 字段 CAST 后 LIKE,无 FULLTEXT 索引,必然全表扫描 |
|
||
| `src/modules/lesson-preparation/data-access-knowledge.ts:98, 126` | `like(lessonPlans.content, \`%${...}%\`)` —— JSON content 列 LIKE |
|
||
| `src/modules/users/data-access.ts:348-351` `getAllUserIds` | `db.select({ id }).from(users)` 无 LIMIT,无 WHERE |
|
||
| `src/modules/announcements/data-access.ts:560-561` | school 公告 fan-out 调 `getAllUserIds()`,5000+ 用户全量加载到内存 |
|
||
| `src/modules/files/data-access.ts:128-139` `getAllFileAttachments` | 全量返回文件表,无分页 |
|
||
|
||
#### [P1] LIKE 前缀通配 `%xxx%`(无法走索引)
|
||
|
||
| 文件:行号 | 字段 |
|
||
|---|---|
|
||
| `src/modules/textbooks/data-access.ts:50-53` | title / subject / grade / publisher 4 列 `%${q}%` |
|
||
| `src/modules/exams/data-access.ts:70` | title / description `%${q}%` |
|
||
| `src/modules/standards/data-access.ts:147` | title `%${keyword}%` |
|
||
| `src/modules/lesson-preparation/data-access.ts:238` | title `%${q}%` |
|
||
| `src/modules/audit/data-access.ts:46` | action `%${params.action}%` |
|
||
| `src/modules/users/data-access.ts:384` | `ilike(users.name, search)` / `ilike(users.email, search)` |
|
||
| `src/modules/rbac/data-access-assignments.ts:103` | `ilike(users.name, term)` / `ilike(users.email, term)` |
|
||
| `src/modules/messaging/data-access.ts:140` | `like(messages.subject, kw)` / `like(messages.content, kw)` |
|
||
|
||
✅ 例外:`src/modules/files/data-access.ts:204` `like(fileAttachments.mimeType, \`${mimeType}%\`)` 是前缀匹配,可走索引
|
||
|
||
#### [P2] 高 LIMIT 默认值
|
||
|
||
| 文件:行号 | LIMIT |
|
||
|---|---|
|
||
| `src/modules/grades/data-access.ts:105` `getGradeRecords` | 默认 1000 |
|
||
| `src/modules/homework/stats-service.ts:276` `getStudentDashboardGrades` | `limit: 200` |
|
||
| `src/modules/homework/stats-service.ts:345` `classGradedSubmissions` | `limit: 5000` |
|
||
| `src/modules/homework/stats-service.ts:216` | `wrongAnswers.slice(0, 500)` |
|
||
| `src/modules/adaptive-practice/data-access-strategy.ts:293` | `.limit(1000)` |
|
||
|
||
#### 修复建议
|
||
- **搜索 API 改用 MySQL FULLTEXT 索引**:
|
||
```sql
|
||
ALTER TABLE questions ADD FULLTEXT INDEX ft_content (content);
|
||
ALTER TABLE announcements ADD FULLTEXT INDEX ft_title_content (title, content);
|
||
-- 查询改用 MATCH ... AGAINST
|
||
```
|
||
- `getAllUserIds` 改为游标分页 + fan-out 改为批量插入 `message_notifications`(每次 500 条)
|
||
- `LIKE %xxx%` 改为前缀匹配 `LIKE xxx%` 或 ES/OpenSearch 外部搜索引擎
|
||
- 高 LIMIT 改为基于业务实际值:`classGradedSubmissions` 50 人班级够用 1000
|
||
|
||
---
|
||
|
||
## 第五部分:综合性能预算基线表
|
||
|
||
### 5.1 Bundle 体积预算(gzip 后)
|
||
|
||
| 维度 | 预算 | 强制等级 |
|
||
|------|------|---------|
|
||
| 落地页 / 登录 First Load JS | ≤ 180 KB | 强制 |
|
||
| 学生端 Dashboard First Load JS | ≤ 250 KB | 强制 |
|
||
| 教师端 Dashboard First Load JS | ≤ 280 KB | 强制 |
|
||
| 列表/详情页 First Load JS | ≤ 300 KB | 强制 |
|
||
| 教材阅读页 First Load JS(含 ReactFlow + tiptap + force-graph) | ≤ 600 KB | 强制 |
|
||
| 试卷富文本编辑页 First Load JS | ≤ 500 KB | 强制 |
|
||
| 备课编辑页 First Load JS | ≤ 550 KB | 强制 |
|
||
| 单路由增量 chunk(动态加载组件) | ≤ 200 KB | 推荐 |
|
||
| 共享框架 chunk(react/react-dom/next) | ≤ 130 KB | — |
|
||
| 单文件体积上限 | ≤ 1000 行 | 强制(已有 lint 规则) |
|
||
|
||
### 5.2 Web Vitals 预算(P95)
|
||
|
||
| 指标 | 预算 | 强制等级 |
|
||
|------|------|---------|
|
||
| LCP | ≤ 2.5s(备课编辑器允许 ≤ 3.0s) | 强制 |
|
||
| INP | ≤ 200ms(仪表盘图表允许 ≤ 300ms) | 强制 |
|
||
| CLS | ≤ 0.05 | 强制 |
|
||
| TTFB | ≤ 800ms(dashboard 路由) | 强制 |
|
||
| FCP | ≤ 1.8s | 强制 |
|
||
|
||
### 5.3 数据层性能预算
|
||
|
||
| 维度 | 预算 | 强制等级 |
|
||
|------|------|---------|
|
||
| 单次 Server Action 响应时间(P50) | ≤ 200 ms | 强制 |
|
||
| 单次 Server Action 响应时间(P99) | ≤ 800 ms | 强制 |
|
||
| 单次 DB 查询执行时间 | ≤ 50 ms | 强制 |
|
||
| Server Action 内 DB 查询次数 | ≤ 5 次 | 强制 |
|
||
| 单次查询返回行数 | ≤ 200 行 | 强制 |
|
||
| 单次查询返回数据体积 | ≤ 100 KB | 强制 |
|
||
| N+1 查询 | **严格禁止** | 强制(CI 检查) |
|
||
| 循环内 `await db.insert/update/delete` | **严格禁止**(除非有 fallback 注释) | 强制 |
|
||
| 权限校验 DB 查询次数 | ≤ 1 次/请求(用 React `cache()` 去重) | 强制 |
|
||
| DataScope 解析 DB 查询次数 | ≤ 1 次/请求(30s unstable_cache) | 强制 |
|
||
| 跨请求缓存命中率(dashboard/统计) | ≥ 80% | 推荐 |
|
||
| `revalidatePath` 路径精确度 | ≤ 2 条/写操作 | 强制 |
|
||
| 批量操作事务包裹率 | 100% | 强制 |
|
||
| 批量操作大小限制 | ≤ 50 行/事务 | 强制 |
|
||
| LIKE 前缀通配 `%xxx%` 出现次数 | 0(除非有 FULLTEXT 索引) | 强制 |
|
||
| 全表扫描查询(无 LIMIT + 无 WHERE) | 0 | 强制 |
|
||
| 默认分页 size | ≤ 50 | 推荐 |
|
||
| 单条返回 JSON 字段体积 | ≤ 50 KB | 强制 |
|
||
| Server → Client 传输 `password`/`passwordHash`/`token` 字段 | 0 | 强制 |
|
||
|
||
### 5.4 关键 KPI(建议接入监控)
|
||
|
||
- Server Action P50/P99 响应时间(按 action 名分维度)
|
||
- DB 查询次数 / Server Action 调用
|
||
- 慢查询(>50ms)次数与 SQL 摘要
|
||
- DataScope 解析耗时
|
||
- `unstable_cache` 命中率
|
||
- N+1 自动检测(建议接入 `drizzle-orm/dev` 的 query log + CI 规则)
|
||
- Web Vitals 上报(LCP/CLS/INP/TTFB/FCP)
|
||
- Bundle 体积变化(每次 PR 与基线对比)
|
||
|
||
---
|
||
|
||
## 第六部分:P0/P1 修复路线图
|
||
|
||
### 6.1 P0 修复路线图(按 ROI 排序)
|
||
|
||
| # | 项目 | 涉及章节 | 预期收益 | 单点改动 | 风险 |
|
||
|---|------|---------|---------|---------|------|
|
||
| 1 | 启用 React Compiler | §3.6 | 一次性解决 §3.1 大部分内联对象问题 | ✅ | 低( Compiler 有 fallback) |
|
||
| 2 | `next.config.ts` 完善(optimizePackageImports + serverExternalPackages) | §1.5 | 全路由 -30~80 KB + 防止 2-3 MB 误打包 | ✅ | 低 |
|
||
| 3 | tiptap / ReactFlow 动态导入(4 处文件) | §1.3 | 编辑器路由 chunk 减少 ~300 KB | ✅ | 低 |
|
||
| 4 | `exams/editor/index.ts` barrel 拆分 | §1.6 | 修复已存在的构建错误 + 防止 server 误拉 tiptap | ✅ | 中(需测试 server 引用路径) |
|
||
| 5 | 接入 Web Vitals 上报 | §2.6 | 生产环境性能可见性 | ✅ | 低 |
|
||
| 6 | `SidebarProvider` value memoize + resize 防抖 | §3.2 | 全站 dashboard 重渲染减少 50%+ | ✅ | 低 |
|
||
| 7 | `lesson-plan-editor.tsx:62` 拆分 selector | §3.4, §3.7 | 备课编辑器按键响应速度提升 5-10x | ✅ | 中(需测试编辑器交互) |
|
||
| 8 | 题库表启用虚拟化 | §3.3, §3.7 | 1000+ 题时首屏可交互时间从秒级降到 100ms | ❌(需引入 @tanstack/react-virtual) | 中 |
|
||
| 9 | `copyCoursePlanToClasses` 改为事务 + 批量 INSERT | §4.1, §4.7 | 复制 10 班从分钟级降到秒级 | ✅ | 低 |
|
||
| 10 | `unstable_cache` 引入(DataScope + 低变更数据) | §4.4, §4.6 | 单次 Server Action DB 查询数减半 | ❌(需引入 tag 失效机制) | 中(缓存一致性) |
|
||
| 11 | `searchQuestions` 改用 FULLTEXT 索引 | §4.5, §4.8 | 搜索接口从全表扫描降到索引扫描 | ❌(需 DB 迁移) | 中(需测试搜索召回率) |
|
||
| 12 | `getAllUserIds` 加 LIMIT + 公告 fan-out 改批量 | §4.5, §4.8 | 防止 5000+ 用户场景内存爆炸 | ✅ | 低 |
|
||
|
||
### 6.2 P1 修复路线图
|
||
|
||
| # | 项目 | 涉及章节 | 预期收益 |
|
||
|---|------|---------|---------|
|
||
| 1 | `serverExternalPackages` 追加 `exceljs` | §1.5 | 防止 250-300 KB 误打包 |
|
||
| 2 | `chart.tsx` 改 barrel 为具名导入 | §1.4 | 全图表路由 -20~40 KB |
|
||
| 3 | 移除 `status-badge.tsx` 等过度标记的 `"use client"` | §1.2 | 提升 SSR 覆盖率 |
|
||
| 4 | 4 个角色根路由新增 `loading.tsx` | §2.1 | LCP/CLS 改善 |
|
||
| 5 | 96 处 `force-dynamic` 评估改 `revalidate` | §2.1 | TTFB/LCP 静态缓存命中加速 |
|
||
| 6 | Lighthouse CI 接入 | §2.6 | CI 流水线 Web Vitals 回归门槛 |
|
||
| 7 | 根 `layout.tsx` 串行 await 改 `Promise.all` | §2.1 | TTFB/FCP 改善 |
|
||
| 8 | `autoGradeSubmission` / `gradeHomeworkAnswers` 改批量 UPDATE | §4.1 | 30 题作业 UPDATE 次数从 30 降到 1 |
|
||
| 9 | exams 表新增 5 个索引 | §4.2 | "我的试卷"等高频查询全表 → 索引扫描 |
|
||
| 10 | recharts props 提升为模块级常量 | §3.1 | 仪表盘图表重渲染减少 |
|
||
| 11 | `useOptimistic` 用于消息星标 / 出勤 / 成绩 | §3.6 | 替代手写 override state,UX 提升 |
|
||
| 12 | 考试表启用虚拟化 | §3.3 | 数据量增长时避免卡顿 |
|
||
| 13 | 高频子组件 `React.memo`(MessageCard、TreeNodeRow、InlineNode、AttendanceRow) | §3.1 | 阻断父级渲染扩散 |
|
||
|
||
### 6.3 P2 跟进项
|
||
|
||
- `useModal` 修复(如计划采用)
|
||
- `AuditLogDetailDialog` 懒加载
|
||
- `use()` Hook 替代部分 Suspense 嵌套
|
||
- 备课编辑器 title 输入 `useDeferredValue`
|
||
- `getSessionTeacherId` 加 `cache()` 包装
|
||
- parent DataScope 改为单次 JOIN
|
||
- 默认 LIMIT 调低
|
||
- `metadata` 补充 `metadataBase` / `openGraph` / `robots`
|
||
- Playwright 性能断言
|
||
- `AiAssistantWidget` 懒加载
|
||
- scan-image-viewer `<img>` 加 aspect-ratio
|
||
- 备课编辑器 textbook → chapters 改 `Promise.all`
|
||
|
||
### 6.4 建议执行顺序
|
||
|
||
```
|
||
Phase 1: 配置基线(无业务代码改动)
|
||
├── next.config.ts 完善
|
||
├── React Compiler 启用
|
||
├── Web Vitals 上报接入
|
||
└── Lighthouse CI 接入
|
||
|
||
Phase 2: Bundle 瘦身(影响编辑器路由)
|
||
├── tiptap / ReactFlow 动态导入
|
||
├── exams/editor barrel 拆分
|
||
└── 重新 next build 获取实际体积数据
|
||
|
||
Phase 3: 数据层重构(影响所有 Server Action)
|
||
├── unstable_cache 引入
|
||
├── DataScope 缓存
|
||
├── copyCoursePlanToClasses 改造
|
||
└── exams 索引补齐
|
||
|
||
Phase 4: 组件渲染优化
|
||
├── SidebarProvider memoize
|
||
├── lesson-plan-editor selector 拆分
|
||
├── 题库表虚拟化
|
||
└── recharts props 常量化
|
||
|
||
Phase 5: 验收与基线校准
|
||
├── 实际 Web Vitals 数据采集
|
||
├── 实际 Bundle 体积对照基线表
|
||
└── 预算阈值写入 CI 阻断规则
|
||
```
|
||
|
||
---
|
||
|
||
## 附录 A:关键文件清单
|
||
|
||
### 配置文件
|
||
- `e:\Desktop\CICD\next.config.ts`
|
||
- `e:\Desktop\CICD\package.json`
|
||
- `e:\Desktop\CICD\playwright.config.ts`
|
||
- `e:\Desktop\CICD\vitest.config.ts`
|
||
|
||
### 入口与 Provider
|
||
- `e:\Desktop\CICD\src\app\layout.tsx`
|
||
- `e:\Desktop\CICD\src\app\providers.tsx`
|
||
- `e:\Desktop\CICD\src\app\(dashboard)\layout.tsx`
|
||
- `e:\Desktop\CICD\src\app\manifest.ts`
|
||
- `e:\Desktop\CICD\src\proxy.ts`
|
||
- `e:\Desktop\CICD\src\instrumentation.ts`
|
||
- `e:\Desktop\CICD\src\auth.ts`
|
||
|
||
### 重型组件(Bundle 风险点)
|
||
- `e:\Desktop\CICD\src\modules\textbooks\components\knowledge-graph.tsx`(ReactFlow,未 dynamic)
|
||
- `e:\Desktop\CICD\src\modules\textbooks\components\force-graph.tsx`(react-force-graph-2d,已 dynamic ✅)
|
||
- `e:\Desktop\CICD\src\shared\components\ui\rich-text-editor.tsx`(tiptap)
|
||
- `e:\Desktop\CICD\src\modules\exams\editor\exam-rich-editor.tsx`(tiptap)
|
||
- `e:\Desktop\CICD\src\modules\lesson-preparation\components\blocks\rich-text-block.tsx`(tiptap)
|
||
|
||
### Barrel 文件(高风险)
|
||
- `e:\Desktop\CICD\src\modules\exams\editor\index.ts`(已造成构建错误)
|
||
|
||
### 重渲染热点(组件性能风险点)
|
||
- `e:\Desktop\CICD\src\modules\layout\components\sidebar-provider.tsx`
|
||
- `e:\Desktop\CICD\src\modules\lesson-preparation\components\lesson-plan-editor.tsx`
|
||
- `e:\Desktop\CICD\src\modules\lesson-preparation\components\paper-editor\paper-editor.tsx`
|
||
- `e:\Desktop\CICD\src\modules\lesson-preparation\components\structure-tree\structure-tree.tsx`
|
||
- `e:\Desktop\CICD\src\modules\dashboard\components\admin-dashboard\user-growth-chart.tsx`
|
||
- `e:\Desktop\CICD\src\modules\questions\components\question-data-table.tsx`
|
||
- `e:\Desktop\CICD\src\modules\exams\components\exam-data-table.tsx`
|
||
|
||
### 数据层风险点
|
||
- `e:\Desktop\CICD\src\modules\course-plans\data-access.ts`(copyCoursePlanToClasses P0)
|
||
- `e:\Desktop\CICD\src\modules\homework\data-access-write.ts`(循环 UPDATE P1)
|
||
- `e:\Desktop\CICD\src\modules\search\data-access.ts`(LIKE 全表扫描 P0)
|
||
- `e:\Desktop\CICD\src\modules\users\data-access.ts`(getAllUserIds 无 LIMIT P0)
|
||
- `e:\Desktop\CICD\src\shared\lib\auth-guard.ts`(DataScope 每次重查 P0)
|
||
- `e:\Desktop\CICD\src\shared\db\schema.ts`(exams 索引缺失 P1)
|
||
- `e:\Desktop\CICD\src\modules\homework\stats-service.ts`(5000 行 + with 关联 P1)
|
||
|
||
### 缺失文件清单(建议新增)
|
||
- `src/app/(dashboard)/teacher/loading.tsx`
|
||
- `src/app/(dashboard)/student/loading.tsx`
|
||
- `src/app/(dashboard)/parent/loading.tsx`
|
||
- `src/app/(dashboard)/management/loading.tsx`
|
||
- `e:\Desktop\CICD\.gitea\workflows\lighthouse.yml`(建议新增 Lighthouse CI)
|
||
- `e:\Desktop\CICD\src\app\instrumentation-client.ts`(建议实现 `useReportWebVitals`)
|
||
- `e:\Desktop\CICD\src\app\api\web-vitals\route.ts`(建议新增指标上报端点)
|
||
|
||
---
|
||
|
||
## 附录 B:审计涉及的依赖清单
|
||
|
||
### 客户端大体积依赖(按估算体积排序)
|
||
|
||
| 依赖 | 估算体积(min+gzip) | 用途 | 当前问题 |
|
||
|------|---------------------|------|---------|
|
||
| `@tiptap/*`(5 包 + tiptap-markdown) | ~180-220 KB | 富文本编辑器(3 处) | 未动态导入(P0) |
|
||
| `@xyflow/react` | ~120-140 KB | ReactFlow 知识图谱 | 未动态导入(P0) |
|
||
| `recharts` | ~95-110 KB | 图表库(13 处) | chart.tsx barrel import(P1) |
|
||
| `react-force-graph-2d` | ~80-100 KB | 引力图谱 | 已动态导入 ✅ |
|
||
| `react-markdown` + remark-* + rehype-* | ~50-70 KB | Markdown 渲染 | — |
|
||
| `openai` | ~60-80 KB | AI SDK | 服务端 |
|
||
| `@tanstack/react-query` + `react-table` | ~35-45 KB | 数据层 | — |
|
||
| `@dnd-kit/*`(core+sortable+utilities) | ~30-40 KB | 拖拽 | — |
|
||
| `radix-ui/*`(16 个包) | 单包 ~3-8 KB | UI 原语 | — |
|
||
| `lucide-react` | 单图标 ~1 KB,全集 ~800 KB | 图标库(100+ 处) | 缺 optimizePackageImports(P1) |
|
||
|
||
### 服务端大体积依赖(防误打包)
|
||
|
||
| 依赖 | 估算体积 | 用途 | 当前状态 |
|
||
|------|----------|------|---------|
|
||
| `tencentcloud-sdk-nodejs` | **~2-3 MB** | 腾讯云短信 | 已动态 import ✅,但未加 serverExternalPackages(P0) |
|
||
| `exceljs` | ~250-300 KB | Excel 导入导出 | 静态 import,未加 serverExternalPackages(P1) |
|
||
| `mysql2` | — | MySQL 驱动 | 已加 serverExternalPackages ✅ |
|
||
|
||
### 缺失依赖(建议新增)
|
||
|
||
| 依赖 | 用途 | 必要性 |
|
||
|------|------|--------|
|
||
| `babel-plugin-react-compiler` | React Compiler 自动 memoization | P0 |
|
||
| `eslint-plugin-react-compiler` | React Compiler ESLint 规则 | P0 |
|
||
| `web-vitals` | Web Vitals 上报 | P0 |
|
||
| `@tanstack/react-virtual` | 题库/考试表虚拟化 | P0 |
|
||
| `@lh3/cli` | Lighthouse CI | P1 |
|
||
|
||
---
|
||
|
||
## 审计结论
|
||
|
||
本次性能预算审计完成,共发现 **15 个 P0 严重问题**、**13 个 P1 高优先级问题**、**22 个 P2 中等优先级问题**、**14 个 P3 低优先级问题**。
|
||
|
||
**核心建议**:按第六部分"建议执行顺序"分 5 个 Phase 推进,**Phase 1 配置基线**(4 项单点改动,低风险高收益)应作为首期落地,**Phase 2 Bundle 瘦身**和 **Phase 3 数据层重构**为中期重点,**Phase 4 组件渲染优化**为长期持续工作,**Phase 5 验收与基线校准**闭环反馈到预算阈值。
|
||
|
||
**关键提醒**:当前 `next build` 因 Turbopack 编译错误未成功出包,本报告所有量化指标为静态估算。修复 Phase 1 + Phase 2 后必须重新构建,获取实际 Bundle 体积与运行时 Web Vitals 数据,再对照第五部分基线表进行第二轮校准。
|
||
|
||
**架构图同步建议**:本审计为只读研究,未修改源码,**不需要同步更新 `docs/architecture/004_architecture_impact_map.md` 与 `005_architecture_data.json`**。若后续落实任何修复(如新增 loading.tsx、修改 next.config.ts、新增 web-vitals 上报、引入 unstable_cache),需按项目规则同步架构图。
|