diff --git a/docs/architecture/004_architecture_impact_map.md b/docs/architecture/004_architecture_impact_map.md index 3f5b9df..594cb8c 100644 --- a/docs/architecture/004_architecture_impact_map.md +++ b/docs/architecture/004_architecture_impact_map.md @@ -241,19 +241,24 @@ V5 状态管理统一的后续阶段,针对两类高频性能问题进行细 -## 1.1.5 性能预算重构专项(2026-07-05 新增) +## 1.1.5 性能预算重构专项(2026-07-05 新增 + 2026-07-07 补漏) > 完整审计报告见 `docs/architecture/audit/performance-budget-audit-report.md`。 > 本章节记录本次重构对架构产生的关键变更,以便后续维护时定位。 +> 2026-07-07 补漏:Phase 1 配置基线(next.config.ts optimizePackageImports + serverExternalPackages 扩展 + WebVitalsReporter 注入闭环)+ Phase 2 实际动态导入实现(TipTap 3 处 + ReactFlow 1 处之前仅文件拆分未真正 dynamic import,本次补齐 next/dynamic ssr:false)+ chart.tsx 具名导入 + 12 个 recharts 图表 CHART_MARGIN 提取 + layout.tsx Promise.all + metadata 增强。 ### 影响范围概览 | Phase | 改造内容 | 关键文件 | |-------|---------|---------| -| 2.1 | tiptap 动态导入(3 处) | `shared/components/ui/rich-text-editor.tsx` + `modules/exams/editor/exam-rich-editor.tsx` + `modules/lesson-preparation/components/blocks/rich-text-block.tsx` | -| 2.2 | ReactFlow 动态导入 | `modules/textbooks/components/knowledge-graph.tsx` 拆为 `knowledge-graph-inner.tsx` + lazy wrapper,ReactFlowProvider 留外层 | +| 1.1 | next.config.ts optimizePackageImports | `next.config.ts` 新增 `experimental.optimizePackageImports` 覆盖 lucide-react/recharts/@xyflow/react/@tiptap/* /@radix-ui/*/date-fns | +| 1.2 | serverExternalPackages 扩展 | `next.config.ts` 追加 `tencentcloud-sdk-nodejs` + `exceljs`(原仅 `mysql2`) | +| 1.3 | Web Vitals 上报闭环 | `src/app/providers.tsx` 注入 ``(之前组件存在但未注入,链路断裂) | +| 1.4 | Lighthouse CI | `.gitea/workflows/lighthouse.yml` + `lighthouserc.json`(PR + 每日凌晨 3 点采样,LCP/CLS error 级) | +| 2.1 | tiptap 动态导入(3 处) | `shared/components/ui/rich-text-editor.tsx` + `rich-text-editor-inner.tsx`;`modules/exams/editor/exam-rich-editor.tsx` + `exam-rich-editor-inner.tsx`(forwardRef wrapper);`modules/lesson-preparation/components/blocks/rich-text-block.tsx` + `rich-text-block-inner.tsx` | +| 2.2 | ReactFlow 动态导入 | `modules/textbooks/components/knowledge-graph.tsx` 改用 `next/dynamic` 加载 `knowledge-graph-inner.tsx`,ReactFlowProvider 留外层同步壳 | | 2.3 | exams/editor barrel 拆分 | 新增 `modules/exams/editor/types.ts` 作为纯类型入口,供 server-only 消费方引用 | -| 2.4 | chart.tsx barrel + status-badge 移除 use client | chart.tsx 已是具名导出;`status-badge.tsx` 移除 `"use client"`(纯展示组件) | +| 2.4 | chart.tsx 具名导入 + status-badge 移除 use client | `shared/components/ui/chart.tsx` 改 `import { ResponsiveContainer, Tooltip, Legend } from "recharts"` 替代 `import * as RechartsPrimitive`;`status-badge.tsx` 移除 `"use client"`(纯展示组件) | | 2.5 | 4 个角色根路由 loading.tsx | 新增 `teacher/loading.tsx` + `student/loading.tsx` + `parent/loading.tsx`(admin 已有) | | 3.1 | unstable_cache 评估 | 评估后决定不引入,与项目现有 React `cache()` 模式一致 | | 3.2 | auth 函数 cache 包装 | `shared/lib/session.ts:getSession` + `shared/lib/auth-guard.ts:resolveDataScope` + `getAuthContext` + `modules/classes/data-access.ts:getSessionTeacherId` 全部用 React `cache()` 包装,`getSessionTeacherId` 改走 `getAuthContext()` | @@ -266,12 +271,18 @@ V5 状态管理统一的后续阶段,针对两类高频性能问题进行细 | 4.1 | SidebarProvider memoize | `modules/layout/components/sidebar-provider.tsx` resize debounce 200ms + useMemo context + useCallback toggleSidebar | | 4.2 | lesson-plan-editor selector 拆分 | `lesson-plan-editor.tsx` + 3 个子组件改为单字段 selector | | 4.3 | 题库表 + 考试表虚拟化 | 新增 `@tanstack/react-virtual` 依赖;`question-data-table.tsx` + `exam-data-table.tsx` 引入 `useVirtualizer` | -| 4.4 | recharts props 模块级常量 | 14 个 chart 文件提取 `CHART_MARGIN`/`AXIS_TICK`/`TOOLTIP_CURSOR` 等模块级常量 | +| 4.4 | recharts props 模块级常量 | 12 个 chart 文件提取 `CHART_MARGIN` 模块级常量(parent/grades/dashboard/homework 模块下) | | 4.5 | useOptimistic 改造 | `message-detail.tsx` + `message-list.tsx` + `grade-record-list.tsx` + `attendance-sheet.tsx` 改用 `useOptimistic` + `useTransition` | | 4.6 | 高频子组件 React.memo | `AssignmentCard` + `StatusBadge` + `EmptyState` 添加 React.memo(ClassCard/GraphKpNode 已有) | | 4.7 | AiAssistantWidget 懒加载 | 新增 `modules/ai/components/ai-assistant-widget-inner.tsx`,外层改为 lazy wrapper | | 4.8 | scan-image-viewer aspect-ratio | `modules/homework/components/scan-image-viewer.tsx` `` 加 `aspectRatio: "4 / 3"` + width/height 属性 | -| 4.9 | metadata 增强 | `app/layout.tsx` 补 `metadataBase`/`openGraph`/`robots`/`applicationName`/`authors`/`creator` | +| 4.9 | metadata 增强 + Promise.all | `app/layout.tsx` 补 `metadataBase`/`openGraph`/`twitter`/`robots`/`applicationName`/`authors`/`creator`;`getLocale()`+`getMessages()`+`auth()` 串行 await 改 `Promise.all` 并行 | + +### 未完成项(待后续专项) + +- **React Compiler**:`babel-plugin-react-compiler` 仍为 RC 状态,暂不引入。待 React 19 稳定后评估。 +- **force-dynamic 96 处评估**:全项目 96 处 `export const dynamic = "force-dynamic"`,需逐个评估页面数据变更频率,低频变更页面改 `revalidate = 3600`。 +- **Playwright 性能断言**:待 e2e 测试基础设施完善后接入。 ### 关键架构决策 diff --git a/docs/architecture/005_architecture_data.json b/docs/architecture/005_architecture_data.json index 2d50ed3..2cebeed 100644 --- a/docs/architecture/005_architecture_data.json +++ b/docs/architecture/005_architecture_data.json @@ -5,8 +5,8 @@ "generatedAt": "2026-06-17", "formatVersion": "1.1", "rule": "每次文件修改后须同步更新本文件", - "lastUpdate": "组件化重构专项全量完成(2026-07-06,含遗漏补全):Phase 1 底座补全 + 三批 10 个模块闭环 + 遗漏补全 9 个模块。Phase 1:新增 ErrorBoundary 基础类组件、StatsGrid 容器、SkeletonCard variant,收敛 SectionErrorBoundary/RouteErrorBoundary/WidgetBoundary 为 preset。第 1 批(grades/attendance/error-book/elective):拆分 2 个巨型文件,迁移 12 个重复组件。第 2 批(homework/exams/textbooks):拆分 5 个巨型文件,迁移 3 个 filter + 1 个同名 error-boundary。第 3 批(lesson-preparation/ai/dashboard):拆分 2 个巨型文件,新增 DashboardShell,迁移 6 个重复组件。遗漏补全(classes/audit/school/settings/adaptive-practice/announcements/messaging/questions/student):迁移剩余 20 个重复组件(9 filters + 2 stats-cards + 5 skeletons + 4 error-boundaries),补充 school/settings/classes 模块 i18n error.boundary* 键。总计:9 个巨型文件全部 ≤500 行(最大 375),49 个重复组件全部删除无残留,modules/*/components/ 下无 *-filters/*-stats-card*/*-skeleton/*-error-boundary 文件,tsc 零错误,lint 无新增错误。", - "lastUpdated": "2026-07-06" + "lastUpdate": "性能预算重构专项 Phase 1-4 完成(2026-07-05 审计 + 2026-07-07 补漏):Phase 1 配置基线:(1.1) next.config.ts 新增 experimental.optimizePackageImports 覆盖 lucide-react/recharts/@xyflow/react/@tiptap/* /@radix-ui/*/date-fns;(1.2) serverExternalPackages 追加 tencentcloud-sdk-nodejs + exceljs;(1.3) providers.tsx 注入 WebVitalsReporter 闭环 RUM 上报链路;(1.4) lighthouse.yml + lighthouserc.json CI 性能门槛(LCP<=3000ms/CLS<=0.1 error 级)。Phase 2 Bundle 预算优化:(2.1) TipTap 三实例(shared/ui/rich-text-editor + exams/editor/exam-rich-editor + lesson-preparation/blocks/rich-text-block)全部改 next/dynamic ssr:false + xxx-inner.tsx 拆分模式;(2.2) KnowledgeGraph 改 ReactFlowProvider 同步壳 + next/dynamic lazy inner;(2.3) exams/editor/types.ts barrel 减少服务端消费方拉入 @tiptap/* 运行时;(2.4) chart.tsx 改 recharts 具名导入(ResponsiveContainer/Tooltip/Legend)替代 import * as RechartsPrimitive barrel;(2.5) status-badge 移除多余 use client;(2.6) teacher/student/parent 角色路由补齐 loading.tsx。Phase 3 数据层优化:(3.1) getSession/getAuthContext/resolveDataScope/getSessionTeacherId 全部 React cache() 包裹实现请求级去重;(3.2) copyCoursePlanToClasses 改单事务 + 2 次 batch INSERT;(3.3) gradeHomeworkAnswers 改 UPDATE CASE WHEN 单次批量更新;(3.4) searchQuestions 改 FULLTEXT INDEX + MATCH AGAINST BOOLEAN MODE + toBooleanModeQuery 工具;(3.5) getAllUserIds 加默认 LIMIT 1000 + 分页参数;(3.6) announcements/notifications fan-out 改 createNotifications 批量 INSERT + sendBatchNotifications 并行收集;(3.7) 高 LIMIT 默认值调低(attendance/adaptive-practice/questions data-access 1000-5000→100)。Phase 4 组件渲染优化:(4.1) AssignmentCard/EmptyState/StatusBadge React.memo;(4.2) lesson-plan-editor/text-study-block/exercise-block/paper-context-menu 拆分 Zustand 细粒度 selector(避免整体订阅);(4.3) message-detail/message-list/grade-record-list/attendance-sheet 改 useOptimistic + useTransition 乐观更新;(4.4) question-data-table/exam-data-table 接入 @tanstack/react-virtual 列表虚拟化;(4.5) sidebar-provider resize 改 debounce + useMemo;(4.6) 12 个 recharts 图表组件 margin props 提取到模块级 CHART_MARGIN 常量避免 inline 重建(parent/grades/dashboard/homework 12 文件);(4.7) scan-image-viewer aspect-ratio;(4.8) AiAssistantWidget inner 拆分 + 动态 import;(4.9) layout.tsx metadata 增强(metadataBase/openGraph/twitter/robots/authors/creator)+ Promise.all 并行获取 locale/messages/session。架构决策:(1) 动态 import 标准模式 xxx-inner.tsx + lazy wrapper;(2) 请求级去重 > 跨请求 cache(React cache() not unstable_cache);(3) 批量 SQL > 循环 SQL(INSERT batch + UPDATE CASE WHEN);(4) React 19 useOptimistic 替代手动 isPending;(5) 细粒度 Zustand selector > useShallow 多字段 selector;(6) recharts 具名导入 + optimizePackageImports 替代 barrel 导入。未完成项:React Compiler(babel-plugin-react-compiler RC 状态暂不引入)、force-dynamic 96 处评估(待后续专项)、Playwright 性能断言(待 e2e 基础设施完善)。验证:npx tsc --noEmit 零新增错误;npm run lint 零新增错误。", + "lastUpdated": "2026-07-07" }, "architectureOverview": { "layers": [ @@ -27632,4 +27632,4 @@ } } } -} +} \ No newline at end of file diff --git a/docs/troubleshooting/known-issues.md b/docs/troubleshooting/known-issues.md index 538af7b..574156e 100644 --- a/docs/troubleshooting/known-issues.md +++ b/docs/troubleshooting/known-issues.md @@ -1140,22 +1140,41 @@ if (announcement.type === "school") { - `src/modules/notifications/channels/in-app-channel.ts` — `sendBatch` 重写为单次批量 INSERT - `src/modules/notifications/dispatcher.ts` — `sendBatchNotifications` 重构为 in_app 批量 + 其他渠道并行 -## Phase 2 Bundle 预算优化规则(2026-07-05) +## Phase 1 配置基线规则(2026-07-07 补漏) + +| 规则 | 正确写法 | 错误写法 | +|------|---------|---------| +| 启用 optimizePackageImports 优化 barrel 库 | `experimental: { optimizePackageImports: ["lucide-react", "recharts", "@xyflow/react", "@tiptap/*", "@radix-ui/*", "date-fns"] }` | 不配置(整个 barrel 被打入首屏 chunk) | +| serverExternalPackages 覆盖所有 Node.js 原生模块 | `serverExternalPackages: ["mysql2", "tencentcloud-sdk-nodejs", "exceljs"]` | 仅 `["mysql2"]`(遗漏腾讯云 SDK 2-3 MB + Excel 250-300 KB) | +| Web Vitals 上报组件必须注入到 Providers | `` 在 Providers 内渲染(与 Toaster 同级) | 实现了组件但未注入(生产环境零 RUM 数据) | +| Lighthouse CI 门槛 LCP/CLS 为 error 级 | `assertions: { "largest-contentful-paint": ["error", { "maxNumericValue": 3000 }], "cumulative-layout-shift": ["error", { "maxNumericValue": 0.1 }] }` | 全部用 warn(性能回归不阻断合并) | + +**Phase 1 涉及文件**: +- `next.config.ts` — 新增 experimental.optimizePackageImports + 扩展 serverExternalPackages +- `src/app/providers.tsx` — 注入 `` +- `src/app/web-vitals-reporter.tsx` — useReportWebVitals + sendBeacon 上报 +- `src/app/api/web-vitals/route.ts` — edge runtime 端点 + 预算基线对比 +- `.gitea/workflows/lighthouse.yml` + `lighthouserc.json` — CI 性能门槛 + +## Phase 2 Bundle 预算优化规则(2026-07-05 + 2026-07-07 补漏) | 规则 | 正确写法 | 错误写法 | |------|---------|---------| | 重型客户端库用 next/dynamic ssr:false | `dynamic(() => import("./xxx-inner").then(m => m.XxxInner), { ssr: false, loading: () => })` | 顶层静态 import 整个 TipTap/ReactFlow | | Provider 上下文与懒加载实现分离 | 同步壳保留 ``,inner 用 `useReactFlow()` 钩子 | inner 直接调用 `useReactFlow()` 但未在同层 Provider 内 | +| forwardRef 组件动态导入需保留 forwardRef 外壳 | `export const ExamRichEditor = forwardRef((props, ref) => )` | 直接 `export const ExamRichEditor = dynamic(...)`(ref 丢失) | | 服务端消费方仅需类型时拆 types barrel | `import type { EditorDoc } from "@/modules/exams/editor/types"`(纯类型入口) | `import { EditorDoc } from "@/modules/exams/editor/exam-rich-editor-types"`(拉入 @tiptap/* 运行时) | +| recharts 改具名导入替代 barrel | `import { ResponsiveContainer, Tooltip, Legend } from "recharts"` | `import * as RechartsPrimitive from "recharts"`(Turbopack 难以 tree-shake) | | 纯展示组件无需 "use client" | `function StatusBadge({ status }) { return ... }` | 在文件首行加 `"use client"` 但未使用任何客户端 hook | | 角色路由必须配 loading.tsx | `app/(dashboard)/teacher/loading.tsx` 提供 Skeleton fallback | 仅依赖默认 Next.js 加载状态导致页面切换白屏 | **Phase 2 涉及文件**: -- `src/modules/exams/editor/exam-rich-editor.tsx` — 动态 import 化 -- `src/modules/questions/components/question-rich-editor.tsx` — 动态 import 化 -- `src/modules/lesson-preparation/components/paper-editor/paper-rich-editor.tsx` — 动态 import 化 -- `src/modules/ai/components/ai-assistant-widget.tsx` — lazy wrapper + ai-assistant-widget-inner.tsx 拆分 -- `src/modules/textbooks/components/knowledge-graph.tsx` + `knowledge-graph-inner.tsx` — ReactFlowProvider 同步壳 + lazy inner +- `src/shared/components/ui/rich-text-editor.tsx` + `rich-text-editor-inner.tsx` — TipTap 动态导入 +- `src/modules/exams/editor/exam-rich-editor.tsx` + `exam-rich-editor-inner.tsx` — TipTap 动态导入(forwardRef wrapper) +- `src/modules/lesson-preparation/components/blocks/rich-text-block.tsx` + `rich-text-block-inner.tsx` — TipTap 动态导入 +- `src/modules/ai/components/ai-assistant-widget.tsx` + `ai-assistant-widget-inner.tsx` — AI SDK 动态导入 +- `src/modules/textbooks/components/knowledge-graph.tsx` + `knowledge-graph-inner.tsx` — ReactFlow 动态导入 +- `src/shared/components/ui/chart.tsx` — recharts 具名导入替代 barrel - `src/modules/exams/editor/types.ts` — 新建类型 barrel - `src/shared/components/ui/status-badge.tsx` — 移除 "use client" - `src/app/(dashboard)/teacher/loading.tsx`、`student/loading.tsx`、`parent/loading.tsx` — 新建 Skeleton fallback @@ -1194,6 +1213,8 @@ if (announcement.type === "school") { | next/web-vitals Metric 类型从签名推导 | `type M = Parameters[0]>[0]` | `import type { Metric } from 'next/web-vitals'`(v15+ 已不导出) | | PerformanceEntry 显式标注类型 | `metric.entries.map((entry: PerformanceEntry) => ({...}))` | `(entry) => ({...})`(implicit any) | | sendBeacon 失败时回退 fetch keepalive | `if (!navigator.sendBeacon(url, blob)) { await fetch(url, { keepalive: true }) }` | 仅用 fetch(页面卸载时可能被取消) | +| layout.tsx metadata 必须增强 SEO 字段 | `metadataBase: new URL(env.NEXTAUTH_URL ?? "http://localhost:3000"), openGraph: {...}, twitter: {...}, robots: {...}, authors: [...], creator: "..."` | 仅 `title` + `description`(OG image 加载异常、SEO 缺失、build 时 warn) | +| layout.tsx 串行 await 改 Promise.all | `const [locale, messages, session] = await Promise.all([getLocale(), getMessages(), auth()])` | 三次串行 `await`(增加 TTFB/FCP/LCP) | **Phase 4.1/4.3/4.4/4.6-4.9 涉及文件**: - `src/shared/components/ui/empty-state.tsx` — React.memo @@ -1203,8 +1224,9 @@ if (announcement.type === "school") { - `src/modules/exams/components/exam-data-table.tsx` — useVirtualizer - `src/modules/layout/components/sidebar-provider.tsx` — resize debounce + useMemo - `src/modules/homework/components/scan-image-viewer.tsx` — aspect-ratio -- 14 个 chart 组件(`src/modules/*/components/*-chart.tsx`) — recharts props 提取模块级常量 +- 12 个 chart 组件(parent/grades/dashboard/homework 模块下) — recharts margin 提取 CHART_MARGIN 模块级常量 - `src/app/web-vitals-reporter.tsx` — Metric 类型推导修复 + PerformanceEntry 注解 +- `src/app/layout.tsx` — metadata 增强(metadataBase/openGraph/twitter/robots/authors/creator)+ Promise.all 并行获取 locale/messages/session - `package.json` — 新增 `@tanstack/react-virtual`、`@tanstack/react-query-devtools` ## Phase 4.3 useOptimistic 乐观更新应用规则(补充) diff --git a/next.config.ts b/next.config.ts index 41393b8..f3d7d77 100644 --- a/next.config.ts +++ b/next.config.ts @@ -5,9 +5,42 @@ const withNextIntl = createNextIntlPlugin("./src/i18n/request.ts"); const nextConfig: NextConfig = { output: "standalone", - // mysql2 是 Node.js 服务端驱动,依赖 net/tls 等 Node 内置模块, - // 不应被 Turbopack 打包到 server bundle 中,标记为外部包在运行时 require。 - serverExternalPackages: ["mysql2"], + // 服务端 Node.js 原生模块,不应被 Turbopack 打包到 server bundle。 + // - mysql2: Node.js MySQL 驱动,依赖 net/tls + // - tencentcloud-sdk-nodejs: 腾讯云 SDK,体积 ~2-3 MB,仅在短信/对象存储等服务端调用 + // - exceljs: Excel 生成库,体积 ~250-300 KB,仅在导出报表时使用 + serverExternalPackages: [ + "mysql2", + "tencentcloud-sdk-nodejs", + "exceljs", + ], + experimental: { + // 启用 optimizePackageImports 让 Turbopack 对 barrel 导出做精确 tree-shaking, + // 避免整个库被打入首屏 chunk。覆盖项目中最常用的重型库: + // - lucide-react: 图标库,barrel 导出 1000+ 图标 + // - recharts: 图表库,barrel 导出 20+ 组件 + // - @xyflow/react: ReactFlow,barrel 导出 30+ 模块 + // - @tiptap/*: 富文本编辑器,多个 barrel 包 + // - @radix-ui/*: 无障碍 UI 原语,barrel 导出 + // - date-fns: 日期工具,barrel 导出 200+ 函数 + optimizePackageImports: [ + "lucide-react", + "recharts", + "@xyflow/react", + "@tiptap/react", + "@tiptap/starter-kit", + "@tiptap/extension-placeholder", + "@tiptap/extension-image", + "tiptap-markdown", + "@radix-ui/react-dialog", + "@radix-ui/react-dropdown-menu", + "@radix-ui/react-popover", + "@radix-ui/react-select", + "@radix-ui/react-tabs", + "@radix-ui/react-tooltip", + "date-fns", + ], + }, }; export default withNextIntl(nextConfig); diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 84a18c1..7e6e0f2 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -13,14 +13,39 @@ const inter = Inter({ }); export const metadata: Metadata = { - title: "Next_Edu - K12 智慧教务系统", + metadataBase: new URL( + process.env.NEXTAUTH_URL ?? "http://localhost:3000" + ), + title: { + default: "Next_Edu - K12 智慧教务系统", + template: "%s | Next_Edu", + }, description: "Enterprise Grade K12 Education Management System", + applicationName: "Next_Edu", manifest: "/manifest.webmanifest", appleWebApp: { capable: true, statusBarStyle: "default", title: "Next_Edu", }, + openGraph: { + type: "website", + locale: "zh_CN", + siteName: "Next_Edu", + title: "Next_Edu - K12 智慧教务系统", + description: "Enterprise Grade K12 Education Management System", + }, + twitter: { + card: "summary_large_image", + title: "Next_Edu - K12 智慧教务系统", + description: "Enterprise Grade K12 Education Management System", + }, + robots: { + index: true, + follow: true, + }, + authors: [{ name: "Next_Edu Team" }], + creator: "Next_Edu", }; export const viewport: Viewport = { @@ -41,13 +66,13 @@ export default async function RootLayout({ }: Readonly<{ children: React.ReactNode; }>) { - // v3 i18n:从 cookie 读取 locale(getRequestConfig 已配置),SSR 注入字典 - const locale = await getLocale(); - const messages = await getMessages(); - - // v3: SSR 期间获取 session,传给 AuthSessionProvider, - // 避免 next-auth useSession 在 SSR/CSR 之间返回不同 status 导致 hydration mismatch。 - const session = await auth(); + // v3 i18n + SSR session:Promise.all 并行获取,避免串行 await 增加 TTFB/FCP/LCP。 + // 三个调用相互独立,可安全并行。 + const [locale, messages, session] = await Promise.all([ + getLocale(), + getMessages(), + auth(), + ]); return ( diff --git a/src/app/providers.tsx b/src/app/providers.tsx index b5c9b68..051d944 100644 --- a/src/app/providers.tsx +++ b/src/app/providers.tsx @@ -10,6 +10,7 @@ import type { Session } from "next-auth" import { ThemeProvider } from "@/shared/components/theme-provider" import { Toaster } from "@/shared/components/ui/sonner" import { createQueryClient } from "@/shared/lib/query-client" +import { WebVitalsReporter } from "@/app/web-vitals-reporter" interface ProvidersProps { children: ReactNode @@ -58,6 +59,7 @@ export function Providers({ {children} + {process.env.NODE_ENV === "development" && ( diff --git a/src/modules/dashboard/components/admin-dashboard/user-growth-chart.tsx b/src/modules/dashboard/components/admin-dashboard/user-growth-chart.tsx index 52f6eb1..3ca0472 100644 --- a/src/modules/dashboard/components/admin-dashboard/user-growth-chart.tsx +++ b/src/modules/dashboard/components/admin-dashboard/user-growth-chart.tsx @@ -14,6 +14,9 @@ import { import { EmptyState } from "@/shared/components/ui/empty-state" +/** recharts 图表 margin 常量(避免每次渲染创建新对象引用) */ +const CHART_MARGIN = { top: 5, right: 30, left: 20, bottom: 5 } as const + interface UserGrowthChartProps { data: Array<{ date: string; count: number }> /** Translation key for the line/tooltip label (e.g. "chart.newUsers" or "chart.newSubmissions") */ @@ -36,7 +39,7 @@ export function UserGrowthChart({ data, labelKey = "chart.newUsers" }: UserGrowt return ( - + diff --git a/src/modules/dashboard/components/teacher-dashboard/teacher-grade-trends.tsx b/src/modules/dashboard/components/teacher-dashboard/teacher-grade-trends.tsx index 257d911..d16bbc3 100644 --- a/src/modules/dashboard/components/teacher-dashboard/teacher-grade-trends.tsx +++ b/src/modules/dashboard/components/teacher-dashboard/teacher-grade-trends.tsx @@ -7,6 +7,9 @@ import { ChartCardShell } from "@/shared/components/charts/chart-card-shell" import { TrendLineChart } from "@/shared/components/charts/trend-line-chart" import type { TeacherGradeTrendItem } from "@/modules/homework/types" +/** recharts 图表 margin 常量(避免每次渲染创建新对象引用) */ +const CHART_MARGIN = { left: 12, right: 12, top: 12, bottom: 12 } as const + export function TeacherGradeTrends({ trends }: { trends: TeacherGradeTrendItem[] }) { const t = useTranslations("dashboard") const hasTrends = trends.length > 0 @@ -48,7 +51,7 @@ export function TeacherGradeTrends({ trends }: { trends: TeacherGradeTrendItem[] }, ]} heightClassName="h-[200px]" - margin={{ left: 12, right: 12, top: 12, bottom: 12 }} + margin={CHART_MARGIN} yWidth={30} tooltipClassName="w-[200px]" /> diff --git a/src/modules/exams/editor/exam-rich-editor.tsx b/src/modules/exams/editor/exam-rich-editor.tsx index 801fa6e..710a2f0 100644 --- a/src/modules/exams/editor/exam-rich-editor.tsx +++ b/src/modules/exams/editor/exam-rich-editor.tsx @@ -1,334 +1,40 @@ "use client" -import { useCallback, useEffect, useImperativeHandle, useMemo, useRef, forwardRef } from "react" -import { useEditor, EditorContent, type Editor } from "@tiptap/react" -import StarterKit from "@tiptap/starter-kit" -import Placeholder from "@tiptap/extension-placeholder" -import { useTranslations } from "next-intl" -import { - Bold, - Italic, - List, - ListOrdered, - Quote, - Redo, - Strikethrough, - Underline, - Undo, -} from "lucide-react" - -import { cn } from "@/shared/lib/utils" -import { Button } from "@/shared/components/ui/button" -import { Separator } from "@/shared/components/ui/separator" -import { - BlankNode, - DottedMark, - GroupBlock, - ImageNode, - QuestionBlock, - SectionBlock, - type QuestionBlockType, -} from "./extensions" -import { SelectionToolbar } from "./selection-toolbar" -import type { EditorJSONContent } from "./exam-rich-editor-types" - -export interface ExamRichEditorHandle { - /** 获取当前编辑器文档(Tiptap JSONContent) */ - getJSON: () => EditorJSONContent | null - /** 设置编辑器文档 */ - setJSON: (doc: EditorJSONContent) => void - /** 获取纯文本(用于标题等) */ - getText: () => string - /** 插入题目块 */ - insertQuestion: (type: QuestionBlockType, score?: number) => void - /** 将选区包裹为题目块 */ - wrapInQuestion: (type: QuestionBlockType, score?: number) => void - /** 插入大题分组 */ - insertGroup: (title?: string, instruction?: string) => void - /** 将选区包裹为大题分组 */ - wrapInGroup: (title?: string, instruction?: string) => void - /** 插入试卷分卷 */ - insertSection: (title?: string, level?: number) => void - /** 将选区包裹为试卷分卷 */ - wrapInSection: (title?: string, level?: number) => void - /** 清空编辑器 */ - clear: () => void - /** 获取 Editor 实例(高级用法) */ - getEditor: () => Editor | null -} - -interface ExamRichEditorProps { - /** 初始内容(JSONContent) */ - initialContent?: EditorJSONContent | null - /** 占位提示文本 */ - placeholder?: string - /** 内容变化回调 */ - onChange?: (doc: EditorJSONContent) => void - /** 是否只读 */ - readOnly?: boolean - className?: string -} - -interface ToolbarButtonProps { - onClick: () => void - icon: React.ElementType - title: string - active?: boolean - disabled?: boolean -} - -function ToolbarButton({ onClick, icon: Icon, title, active, disabled }: ToolbarButtonProps) { - return ( - - ) -} - /** - * 试卷富文本编辑器 —— 基于 Tiptap,集成自定义节点(题目块/大题分组/填空/加点字/图片)。 - * 选中文本时浮现 SelectionToolbar 提供快捷标记操作。 - * 通过 ref 暴露 getJSON/setJSON 等方法供父组件调用。 + * 试卷富文本编辑器 —— 懒加载入口(Phase 2.1) + * + * 实际实现位于 exam-rich-editor-inner.tsx,通过 next/dynamic 以 ssr:false 懒加载, + * 使 @tiptap/* 重型依赖(约 180-220 KB)被打入独立 chunk,避免污染首屏 bundle。 + * 加载完成前展示 Skeleton 占位骨架。 + * + * 通过 forwardRef 透传 ExamRichEditorHandle ref,保持与原直接导入一致的 API。 + * Next.js 13+ 的 dynamic 支持自动 ref 转发(当被包装组件是 forwardRef 时)。 */ -export const ExamRichEditor = forwardRef( - function ExamRichEditor( - { - initialContent, - placeholder, - onChange, - readOnly = false, - className, - }, - ref - ) { - const t = useTranslations("examHomework") - const resolvedPlaceholder = placeholder ?? t("exam.editor.contentPlaceholder") - const containerRef = useRef(null) - const onChangeRef = useRef(onChange) - onChangeRef.current = onChange - const extensions = useMemo( - () => [ - StarterKit.configure({ - heading: { levels: [1, 2, 3] }, - }), - Placeholder.configure({ - placeholder: resolvedPlaceholder, - emptyEditorClass: - "is-editor-empty before:content-[attr(data-placeholder)] before:text-muted-foreground before:float-left before:pointer-events-none before:h-0", - }), - DottedMark, - BlankNode, - ImageNode, - QuestionBlock, - GroupBlock, - SectionBlock, - ], - [resolvedPlaceholder] - ) +import dynamic from "next/dynamic" +import { forwardRef } from "react" +import { Skeleton } from "@/shared/components/ui/skeleton" +import type { + ExamRichEditorHandle, + ExamRichEditorProps, +} from "./exam-rich-editor-inner" - const editor = useEditor({ - immediatelyRender: false, - extensions, - editable: !readOnly, - content: initialContent ?? undefined, - editorProps: { - attributes: { - class: - "prose prose-sm dark:prose-invert max-w-none min-h-[400px] p-4 focus:outline-none", - }, - }, - onUpdate: ({ editor }) => { - onChangeRef.current?.(editor.getJSON() as EditorJSONContent) - }, - }) +export type { + ExamRichEditorHandle, + ExamRichEditorProps, +} from "./exam-rich-editor-inner" - // 当 initialContent 变化时(如切换试卷),重置编辑器内容 - useEffect(() => { - if (!editor) return - if (initialContent) { - editor.commands.setContent(initialContent) - } - // 仅在 initialContent 引用变化时执行 - }, [initialContent, editor]) - - useImperativeHandle( - ref, - (): ExamRichEditorHandle => ({ - getJSON: () => (editor ? (editor.getJSON() as EditorJSONContent) : null), - setJSON: (doc) => { - editor?.commands.setContent(doc) - }, - getText: () => editor?.getText() ?? "", - insertQuestion: (type, score = 2) => { - editor?.chain().focus().insertQuestion({ type, score }).run() - }, - wrapInQuestion: (type, score = 2) => { - editor?.chain().focus().wrapInQuestion({ type, score }).run() - }, - insertGroup: (title, instruction) => { - editor?.chain().focus().insertGroup(title, instruction).run() - }, - wrapInGroup: (title, instruction) => { - editor?.chain().focus().wrapInGroup(title, instruction).run() - }, - insertSection: (title, level) => { - editor?.chain().focus().insertSection(title, level).run() - }, - wrapInSection: (title, level) => { - editor?.chain().focus().wrapInSection(title, level).run() - }, - clear: () => { - editor?.commands.clearContent(true) - }, - getEditor: () => editor, - }), - [editor] - ) - - const handleBold = useCallback(() => { - editor?.chain().focus().toggleBold().run() - }, [editor]) - - const handleItalic = useCallback(() => { - editor?.chain().focus().toggleItalic().run() - }, [editor]) - - const handleStrike = useCallback(() => { - editor?.chain().focus().toggleStrike().run() - }, [editor]) - - const handleDotted = useCallback(() => { - editor?.chain().focus().toggleDotted().run() - }, [editor]) - - const handleBulletList = useCallback(() => { - editor?.chain().focus().toggleBulletList().run() - }, [editor]) - - const handleOrderedList = useCallback(() => { - editor?.chain().focus().toggleOrderedList().run() - }, [editor]) - - const handleBlockquote = useCallback(() => { - editor?.chain().focus().toggleBlockquote().run() - }, [editor]) - - const handleUndo = useCallback(() => { - editor?.chain().focus().undo().run() - }, [editor]) - - const handleRedo = useCallback(() => { - editor?.chain().focus().redo().run() - }, [editor]) - - // 计算当前选区所在题目块类型(用于工具栏状态显示) - const activeQuestionType = useMemo(() => { - if (!editor) return undefined - const $from = editor.state.selection.$from - for (let d = $from.depth; d > 0; d--) { - const node = $from.node(d) - if (node.type.name === "questionBlock") { - return (node.attrs.type as QuestionBlockType) ?? "single_choice" - } - } - return undefined - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [editor, editor?.state.selection]) - - if (!editor) { - return ( -
- {t("exam.editor.loading")} -
- ) - } - - return ( -
- {!readOnly && ( - <> -
- - - - - - - - - - - -
- - -
-
- - - - )} - - -
- ) - } +const LazyInner = dynamic( + () => + import("./exam-rich-editor-inner").then((m) => m.ExamRichEditorInner), + { + ssr: false, + loading: () => , + }, +) + +export const ExamRichEditor = forwardRef( + function ExamRichEditor(props, ref) { + return + }, ) diff --git a/src/modules/grades/components/class-comparison-chart.tsx b/src/modules/grades/components/class-comparison-chart.tsx index adcb3f2..9997e7f 100644 --- a/src/modules/grades/components/class-comparison-chart.tsx +++ b/src/modules/grades/components/class-comparison-chart.tsx @@ -15,6 +15,9 @@ import { import { cn } from "@/shared/lib/utils" import type { ClassComparisonItem, ClassComparisonSignificance } from "@/modules/grades/types" +/** recharts 图表 margin 常量(避免每次渲染创建新对象引用) */ +const CHART_MARGIN = { left: 8, right: 8, top: 8, bottom: 8 } as const + interface ClassComparisonChartProps { data: ClassComparisonItem[] /** @@ -175,7 +178,7 @@ export function ClassComparisonChart({ yDomain={[0, 100]} yTickFormatter={(value: number) => `${value}%`} yWidth={36} - margin={{ left: 8, right: 8, top: 8, bottom: 8 }} + margin={CHART_MARGIN} showLegend tooltipClassName="w-60" /> diff --git a/src/modules/grades/components/grade-distribution-chart.tsx b/src/modules/grades/components/grade-distribution-chart.tsx index 479d288..dbd222a 100644 --- a/src/modules/grades/components/grade-distribution-chart.tsx +++ b/src/modules/grades/components/grade-distribution-chart.tsx @@ -8,6 +8,9 @@ import { ChartCardShell } from "@/shared/components/charts/chart-card-shell" import { SimpleBarChart } from "@/shared/components/charts/simple-bar-chart" import type { GradeDistributionResult } from "@/modules/grades/types" +/** recharts 图表 margin 常量(避免每次渲染创建新对象引用) */ +const CHART_MARGIN = { left: 8, right: 8, top: 8, bottom: 8 } as const + /** * v4-P3-4: 色盲友好的双重编码。 * 每个分数段使用不同的 SVG pattern(条纹/点状/交叉线等)+ 颜色, @@ -162,7 +165,7 @@ export function GradeDistributionChart({ xTickFormatter={null} yAllowDecimals={false} yWidth={32} - margin={{ left: 8, right: 8, top: 8, bottom: 8 }} + margin={CHART_MARGIN} defs={PATTERN_DEFS} cellColors={BUCKET_FILLS} tooltipFormatter={(payload: unknown) => { diff --git a/src/modules/grades/components/grade-trend-card.tsx b/src/modules/grades/components/grade-trend-card.tsx index 4b19f4d..41d9d27 100644 --- a/src/modules/grades/components/grade-trend-card.tsx +++ b/src/modules/grades/components/grade-trend-card.tsx @@ -19,6 +19,9 @@ import { formatDate } from "@/shared/lib/utils" import type { ClassAverageTrendResult } from "../types" import type { StudentGradeSummary } from "../types" +/** recharts 图表 margin 常量(避免每次渲染创建新对象引用) */ +const CHART_MARGIN = { left: 12, right: 12, top: 12, bottom: 12 } as const + interface GradeTrendCardProps { summary: StudentGradeSummary /** v3-P2-2: 班级平均趋势数据,传入后会在趋势图中叠加对比线 */ @@ -166,7 +169,7 @@ export function GradeTrendCard({ summary, classAverageData }: GradeTrendCardProp data={chartData} series={series} heightClassName="h-60" - margin={{ left: 12, right: 12, top: 12, bottom: 12 }} + margin={CHART_MARGIN} yWidth={30} tooltipClassName="w-52" /> diff --git a/src/modules/grades/components/grade-trend-chart.tsx b/src/modules/grades/components/grade-trend-chart.tsx index b0180c8..bd05971 100644 --- a/src/modules/grades/components/grade-trend-chart.tsx +++ b/src/modules/grades/components/grade-trend-chart.tsx @@ -9,6 +9,9 @@ import { TrendLineChart } from "@/shared/components/charts/trend-line-chart" import { formatDate } from "@/shared/lib/utils" import type { GradeTrendResult } from "@/modules/grades/types" +/** recharts 图表 margin 常量(避免每次渲染创建新对象引用) */ +const CHART_MARGIN = { left: 8, right: 8, top: 8, bottom: 8 } as const + interface GradeTrendChartProps { data: GradeTrendResult | null } @@ -55,7 +58,7 @@ export function GradeTrendChart({ data }: GradeTrendChartProps): JSX.Element { activeDotRadius: 5, }, ]} - margin={{ left: 8, right: 8, top: 8, bottom: 8 }} + margin={CHART_MARGIN} yWidth={36} /> diff --git a/src/modules/grades/components/growth-archive-chart.tsx b/src/modules/grades/components/growth-archive-chart.tsx index cd30470..0dafb13 100644 --- a/src/modules/grades/components/growth-archive-chart.tsx +++ b/src/modules/grades/components/growth-archive-chart.tsx @@ -9,6 +9,9 @@ import { ChartCardShell } from "@/shared/components/charts/chart-card-shell" import { TrendLineChart } from "@/shared/components/charts/trend-line-chart" import type { StudentGrowthArchiveResult } from "../types" +/** recharts 图表 margin 常量(避免每次渲染创建新对象引用) */ +const CHART_MARGIN = { left: 12, right: 12, top: 12, bottom: 12 } as const + interface GrowthArchiveChartProps { /** 学生成长档案数据;为 null 时渲染空状态 */ data: StudentGrowthArchiveResult | null @@ -148,7 +151,7 @@ export function GrowthArchiveChart({ data }: GrowthArchiveChartProps): JSX.Eleme data={chartData} series={series} heightClassName="h-64" - margin={{ left: 12, right: 12, top: 12, bottom: 12 }} + margin={CHART_MARGIN} yWidth={30} tooltipClassName="w-64" /> diff --git a/src/modules/grades/components/knowledge-point-mastery-chart.tsx b/src/modules/grades/components/knowledge-point-mastery-chart.tsx index f6f6280..e7535db 100644 --- a/src/modules/grades/components/knowledge-point-mastery-chart.tsx +++ b/src/modules/grades/components/knowledge-point-mastery-chart.tsx @@ -11,6 +11,9 @@ import { SimpleBarChart } from "@/shared/components/charts/simple-bar-chart" import { Button } from "@/shared/components/ui/button" import type { KnowledgePointStat } from "@/modules/diagnostic/types" +/** recharts 图表 margin 常量(避免每次渲染创建新对象引用) */ +const CHART_MARGIN = { left: 8, right: 8, top: 8, bottom: 8 } as const + interface KnowledgePointMasteryChartProps { /** 知识点掌握度统计数据;为空数组时渲染空状态 */ data: KnowledgePointStat[] @@ -167,7 +170,7 @@ export function KnowledgePointMasteryChart({ xTickFormatter="default" xTruncateLength={6} yWidth={32} - margin={{ left: 8, right: 8, top: 8, bottom: 8 }} + margin={CHART_MARGIN} cellColors={cellColors} tooltipFormatter={(payload: unknown) => { if (!isMasteryTooltipPayload(payload)) return null diff --git a/src/modules/grades/components/ranking-trend-card.tsx b/src/modules/grades/components/ranking-trend-card.tsx index a5b75c4..7378e9c 100644 --- a/src/modules/grades/components/ranking-trend-card.tsx +++ b/src/modules/grades/components/ranking-trend-card.tsx @@ -10,6 +10,9 @@ import { TrendLineChart } from "@/shared/components/charts/trend-line-chart" import { formatDate } from "@/shared/lib/utils" import type { RankingTrendResult } from "../types" +/** recharts 图表 margin 常量(避免每次渲染创建新对象引用) */ +const CHART_MARGIN = { left: 12, right: 12, top: 12, bottom: 12 } as const + /** * 排名趋势图组件。 * @@ -69,7 +72,7 @@ export function RankingTrendCard({ trend }: { trend: RankingTrendResult | null } yDomain={[Math.max(maxRank, 1), 1]} yTickFormatter={(value: number) => `#${value}`} heightClassName="h-60" - margin={{ left: 12, right: 12, top: 12, bottom: 12 }} + margin={CHART_MARGIN} yWidth={40} tooltipClassName="w-52" /> diff --git a/src/modules/homework/components/homework-assignment-question-error-overview-card.tsx b/src/modules/homework/components/homework-assignment-question-error-overview-card.tsx index 41f35f2..6138007 100644 --- a/src/modules/homework/components/homework-assignment-question-error-overview-card.tsx +++ b/src/modules/homework/components/homework-assignment-question-error-overview-card.tsx @@ -5,6 +5,9 @@ import { useTranslations } from "next-intl" import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card" import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts" +/** recharts 图表 margin 常量(避免每次渲染创建新对象引用) */ +const CHART_MARGIN = { top: 10, right: 10, left: -20, bottom: 0 } as const + export function HomeworkAssignmentQuestionErrorOverviewCard({ questions, gradedSampleCount, @@ -32,7 +35,7 @@ export function HomeworkAssignmentQuestionErrorOverviewCard({ ) : ( - + void; -} - -export function RichTextBlock({ - data, - hint, - textbookId, - chapterId, - planId, - blockId, - onUpdate, -}: Props) { - const t = useTranslations("lessonPreparation"); - const editor = useEditor({ - extensions: [ - StarterKit, - Placeholder.configure({ placeholder: hint ?? t("richText.placeholder") }), - // V5-5:集成 Tiptap Image 扩展,支持图片直接嵌入富文本 - Image.configure({ - inline: false, - allowBase64: false, - HTMLAttributes: { - class: "rich-text-image max-w-full h-auto rounded", - }, - }), - ], - content: data.html, - immediatelyRender: false, - onUpdate: ({ editor }) => { - onUpdate({ ...data, html: editor.getHTML() }); - }, - editorProps: { - attributes: { - // arbitrary-value: tiptap editor fixed size - class: - "prose prose-sm max-w-none focus:outline-none min-h-[60px] px-3 py-2", - }, - }, - }); - - // 外部 content 变化时同步(如版本回退) - useEffect(() => { - if (editor && !editor.isDestroyed && data.html !== editor.getHTML()) { - editor.commands.setContent(data.html); - } - }, [data.html, editor]); - - const [showKpPicker, setShowKpPicker] = useState(false); - const [showAttachmentPicker, setShowAttachmentPicker] = useState(false); // V5-5 - - // V5-5:从素材库选择附件后,插入到富文本(图片用 Image 命令,其余追加到 attachments 列表) - function handleSelectAttachment(attachment: RichTextAttachment) { - const currentAttachments = data.attachments ?? []; - if (attachment.kind === "image" && editor) { - // 图片直接插入富文本 - editor.commands.setImage({ src: attachment.url, alt: attachment.displayName }); - } - // 所有附件(含图片)都记录到 attachments 列表,便于后续管理与素材库复用 - onUpdate({ - ...data, - attachments: [...currentAttachments, attachment], - }); - } - - // V5-5:移除已嵌入的附件 - function handleRemoveAttachment(attachmentId: string) { - const currentAttachments = data.attachments ?? []; - onUpdate({ - ...data, - attachments: currentAttachments.filter((a) => a.attachmentId !== attachmentId), - }); - } - - // V5-5:渲染附件区块 - const attachments = data.attachments ?? []; - - return ( -
- -
- {data.knowledgePointIds.length > 0 && ( - - {t("knowledgePoint.linked", { count: data.knowledgePointIds.length })} - - )} - - {/* V5-5:素材库入口 */} - {planId && ( - - )} -
- - {/* V5-5:已嵌入附件列表(非图片附件在此展示,图片已在富文本中渲染) */} - {attachments.length > 0 && ( -
-
- {t("attachment.embeddedCount", { count: attachments.length })} -
- {attachments - .filter((a) => a.kind !== "image") - .map((a) => ( -
- - {a.displayName} - - {a.kind} - -
- ))} - {/* V5-5:图片附件单独展示缩略图(即使已在富文本中内联,此处也展示便于管理) */} - {attachments.some((a) => a.kind === "image") && ( -
- {attachments - .filter((a) => a.kind === "image") - .map((a) => ( -
- {/* eslint-disable-next-line @next/next/no-img-element */} - {a.displayName} - -
- ))} -
- )} -
- )} - - {showKpPicker && ( - - onUpdate({ ...data, knowledgePointIds: ids })} - onClose={() => setShowKpPicker(false)} - /> - - )} - {/* V5-5:素材库 picker */} - {showAttachmentPicker && planId && ( - - a.attachmentId)} - onSelect={handleSelectAttachment} - onClose={() => setShowAttachmentPicker(false)} - /> - - )} -
- ); +import dynamic from "next/dynamic" +import { Skeleton } from "@/shared/components/ui/skeleton" + +export type { RichTextBlockProps } from "./rich-text-block-inner" + +const LazyInner = dynamic( + () => import("./rich-text-block-inner").then((m) => m.RichTextBlockInner), + { + ssr: false, + loading: () => , + }, +) + +export function RichTextBlock( + props: React.ComponentProps, +): React.ReactElement { + return } diff --git a/src/modules/parent/components/child-grade-summary.tsx b/src/modules/parent/components/child-grade-summary.tsx index d80b37f..348b500 100644 --- a/src/modules/parent/components/child-grade-summary.tsx +++ b/src/modules/parent/components/child-grade-summary.tsx @@ -10,6 +10,9 @@ import { Badge } from "@/shared/components/ui/badge" import { formatDate } from "@/shared/lib/utils" import type { StudentDashboardGradeProps } from "@/modules/homework/types" +/** recharts 图表 margin 常量(避免每次渲染创建新对象引用) */ +const CHART_MARGIN = { left: 8, right: 8, top: 8, bottom: 8 } as const + type TrendDirection = "up" | "down" | "flat" | "unknown" const computeTrend = (trend: { percentage: number }[]): TrendDirection => { @@ -126,7 +129,7 @@ export function ChildGradeSummary({ xKey="index" xTickFormatter={null} heightClassName="h-[160px]" - margin={{ left: 8, right: 8, top: 8, bottom: 8 }} + margin={CHART_MARGIN} yWidth={30} tooltipClassName="w-[200px]" /> diff --git a/src/modules/textbooks/components/knowledge-graph.tsx b/src/modules/textbooks/components/knowledge-graph.tsx index 77a94da..3eebc3c 100644 --- a/src/modules/textbooks/components/knowledge-graph.tsx +++ b/src/modules/textbooks/components/knowledge-graph.tsx @@ -1,21 +1,39 @@ "use client" /** - * 知识图谱外层包装:ReactFlowProvider 壳。 + * 知识图谱外层包装:ReactFlowProvider 同步壳 + lazy inner。 * - * 实际实现位于 knowledge-graph-inner.tsx,本文件仅提供 Provider 上下文, - * 保证 inner 组件中 useReactFlow() hook 可用。 + * ReactFlowProvider 必须在同步代码中保留,以保证 inner 中 useReactFlow() 钩子 + * 在 lazy 加载完成后可立即消费 Provider 上下文。 + * + * inner 实现通过 next/dynamic ssr:false 懒加载,使 @xyflow/react 重型依赖 + * (约 200-300 KB)被打入独立 chunk,避免污染首屏 bundle。 */ -import type { ReactNode } from "react" +import dynamic from "next/dynamic" import { ReactFlowProvider } from "@xyflow/react" +import { Skeleton } from "@/shared/components/ui/skeleton" +import type { KnowledgeGraphProps } from "./knowledge-graph-inner" -import { KnowledgeGraphInner, type KnowledgeGraphProps } from "./knowledge-graph-inner" +const LazyInner = dynamic( + () => import("./knowledge-graph-inner").then((m) => m.KnowledgeGraphInner), + { + ssr: false, + loading: () => ( +
+ + +
+ ), + }, +) -export function KnowledgeGraph(props: KnowledgeGraphProps): ReactNode { +export type { KnowledgeGraphProps } from "./knowledge-graph-inner" + +export function KnowledgeGraph(props: KnowledgeGraphProps): React.ReactElement { return ( - + ) } diff --git a/src/shared/components/ui/chart.tsx b/src/shared/components/ui/chart.tsx index 959d95e..546b8a5 100644 --- a/src/shared/components/ui/chart.tsx +++ b/src/shared/components/ui/chart.tsx @@ -2,7 +2,11 @@ "use client" import * as React from "react" -import * as RechartsPrimitive from "recharts" +import { + ResponsiveContainer, + Tooltip as RechartsTooltip, + Legend as RechartsLegend, +} from "recharts" import { cn } from "@/shared/lib/utils" @@ -39,7 +43,7 @@ const ChartContainer = React.forwardRef< HTMLDivElement, React.ComponentProps<"div"> & { config: ChartConfig - children: React.ComponentProps["children"] + children: React.ComponentProps["children"] } >(({ id, className, children, config, ...props }, ref) => { const uniqueId = React.useId() @@ -58,9 +62,9 @@ const ChartContainer = React.forwardRef< {...props} > - + {children} - +
) @@ -100,11 +104,11 @@ ${colorConfig ) } -const ChartTooltip = RechartsPrimitive.Tooltip +const ChartTooltip = RechartsTooltip const ChartTooltipContent = React.forwardRef< HTMLDivElement, - React.ComponentProps & + React.ComponentProps & React.ComponentProps<"div"> & { hideLabel?: boolean hideIndicator?: boolean @@ -260,7 +264,7 @@ const ChartTooltipContent = React.forwardRef< ) ChartTooltipContent.displayName = "ChartTooltipContent" -const ChartLegend = RechartsPrimitive.Legend +const ChartLegend = RechartsLegend const ChartLegendContent = React.forwardRef< HTMLDivElement, diff --git a/src/shared/components/ui/rich-text-editor-inner.tsx b/src/shared/components/ui/rich-text-editor-inner.tsx index 3a1d377..9cbc17d 100644 --- a/src/shared/components/ui/rich-text-editor-inner.tsx +++ b/src/shared/components/ui/rich-text-editor-inner.tsx @@ -1,11 +1,18 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -"use client"; +"use client" -import * as React from "react"; -import { useEditor, EditorContent, type Editor } from "@tiptap/react"; -import StarterKit from "@tiptap/starter-kit"; -import { Markdown } from "tiptap-markdown"; -import Placeholder from "@tiptap/extension-placeholder"; +/** + * 通用富文本编辑器 inner 实现。 + * + * 通过 rich-text-editor.tsx 的 next/dynamic lazy wrapper 懒加载, + * 使 @tiptap/* 依赖被打入独立 chunk。 + */ + +import * as React from "react" +import { useEditor, EditorContent, type Editor } from "@tiptap/react" +import StarterKit from "@tiptap/starter-kit" +import { Markdown } from "tiptap-markdown" +import Placeholder from "@tiptap/extension-placeholder" import { Bold, Italic, @@ -18,45 +25,43 @@ import { ListOrdered, Quote, Undo, - Redo, -} from "lucide-react"; + Redo +} from "lucide-react" -import { cn } from "@/shared/lib/utils"; -import { Button } from "@/shared/components/ui/button"; -import { Separator } from "@/shared/components/ui/separator"; +import { cn } from "@/shared/lib/utils" +import { Button } from "@/shared/components/ui/button" +import { Separator } from "@/shared/components/ui/separator" -// Since we don't have Toggle component yet, let's create a local one or use Button -// We will use Button for simplicity and to avoid dependency issues if Radix Toggle isn't installed const ToolbarButton = ({ isActive, onClick, icon: Icon, title, }: { - isActive?: boolean; - onClick: () => void; - icon: React.ElementType; - title: string; + isActive?: boolean + onClick: () => void + icon: React.ElementType + title: string }) => ( -); +) const EditorToolbar = ({ editor }: { editor: Editor }) => { - if (!editor) return null; + if (!editor) return null return (
@@ -142,14 +147,14 @@ const EditorToolbar = ({ editor }: { editor: Editor }) => { />
- ); -}; + ) +} -interface RichTextEditorInnerProps { - value: string; - onChange: (value: string) => void; - placeholder?: string; - className?: string; +export interface RichTextEditorProps { + value: string + onChange: (value: string) => void + placeholder?: string + className?: string } export function RichTextEditorInner({ @@ -157,7 +162,7 @@ export function RichTextEditorInner({ onChange, placeholder = "Start writing...", className, -}: RichTextEditorInnerProps): React.ReactElement { +}: RichTextEditorProps) { const editor = useEditor({ immediatelyRender: false, extensions: [ @@ -169,8 +174,7 @@ export function RichTextEditorInner({ Markdown, Placeholder.configure({ placeholder, - emptyEditorClass: - "is-editor-empty before:content-[attr(data-placeholder)] before:text-muted-foreground before:float-left before:pointer-events-none before:h-0", + emptyEditorClass: "is-editor-empty before:content-[attr(data-placeholder)] before:text-muted-foreground before:float-left before:pointer-events-none before:h-0", }), ], editorProps: { @@ -181,27 +185,22 @@ export function RichTextEditorInner({ content: value, onUpdate: ({ editor }) => { // Get markdown content - const markdown = (editor.storage as any).markdown.getMarkdown(); - onChange(markdown); + const markdown = (editor.storage as any).markdown.getMarkdown() + onChange(markdown) }, - }); + }) // Sync value changes from outside (e.g. when switching chapters) React.useEffect(() => { if (editor && value !== (editor.storage as any).markdown.getMarkdown()) { - editor.commands.setContent(value); + editor.commands.setContent(value) } - }, [value, editor]); + }, [value, editor]) return ( -
+
- ); + ) } diff --git a/src/shared/components/ui/rich-text-editor.tsx b/src/shared/components/ui/rich-text-editor.tsx index 80d36d8..8d90882 100644 --- a/src/shared/components/ui/rich-text-editor.tsx +++ b/src/shared/components/ui/rich-text-editor.tsx @@ -1,201 +1,29 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ "use client" -import * as React from "react" -import { useEditor, EditorContent, type Editor } from "@tiptap/react" -import StarterKit from "@tiptap/starter-kit" -import { Markdown } from "tiptap-markdown" -import Placeholder from "@tiptap/extension-placeholder" -import { - Bold, - Italic, - Strikethrough, - Code, - Heading1, - Heading2, - Heading3, - List, - ListOrdered, - Quote, - Undo, - Redo -} from "lucide-react" +/** + * 通用富文本编辑器 —— 懒加载入口(Phase 2.1) + * + * 实际实现位于 rich-text-editor-inner.tsx,通过 next/dynamic 以 ssr:false 懒加载, + * 使 @tiptap/* 重型依赖(约 180-220 KB)被打入独立 chunk,避免污染首屏 bundle。 + * 加载完成前展示 Skeleton 占位骨架。 + */ -import { cn } from "@/shared/lib/utils" -import { Button } from "@/shared/components/ui/button" -import { Separator } from "@/shared/components/ui/separator" +import dynamic from "next/dynamic" +import { Skeleton } from "@/shared/components/ui/skeleton" -// Since we don't have Toggle component yet, let's create a local one or use Button -// We will use Button for simplicity and to avoid dependency issues if Radix Toggle isn't installed -const ToolbarButton = ({ - isActive, - onClick, - icon: Icon, - title, -}: { - isActive?: boolean - onClick: () => void - icon: React.ElementType - title: string -}) => ( - +export type { RichTextEditorProps } from "./rich-text-editor-inner" + +const LazyInner = dynamic( + () => + import("./rich-text-editor-inner").then((m) => m.RichTextEditorInner), + { + ssr: false, + loading: () => , + }, ) -const EditorToolbar = ({ editor }: { editor: Editor }) => { - if (!editor) return null - - return ( -
- editor.chain().focus().toggleBold().run()} - isActive={editor.isActive("bold")} - icon={Bold} - title="Bold" - /> - editor.chain().focus().toggleItalic().run()} - isActive={editor.isActive("italic")} - icon={Italic} - title="Italic" - /> - editor.chain().focus().toggleStrike().run()} - isActive={editor.isActive("strike")} - icon={Strikethrough} - title="Strikethrough" - /> - editor.chain().focus().toggleCode().run()} - isActive={editor.isActive("code")} - icon={Code} - title="Code" - /> - - - - editor.chain().focus().toggleHeading({ level: 1 }).run()} - isActive={editor.isActive("heading", { level: 1 })} - icon={Heading1} - title="Heading 1" - /> - editor.chain().focus().toggleHeading({ level: 2 }).run()} - isActive={editor.isActive("heading", { level: 2 })} - icon={Heading2} - title="Heading 2" - /> - editor.chain().focus().toggleHeading({ level: 3 }).run()} - isActive={editor.isActive("heading", { level: 3 })} - icon={Heading3} - title="Heading 3" - /> - - - - editor.chain().focus().toggleBulletList().run()} - isActive={editor.isActive("bulletList")} - icon={List} - title="Bullet List" - /> - editor.chain().focus().toggleOrderedList().run()} - isActive={editor.isActive("orderedList")} - icon={ListOrdered} - title="Ordered List" - /> - editor.chain().focus().toggleBlockquote().run()} - isActive={editor.isActive("blockquote")} - icon={Quote} - title="Blockquote" - /> - -
- editor.chain().focus().undo().run()} - isActive={false} - icon={Undo} - title="Undo" - /> - editor.chain().focus().redo().run()} - isActive={false} - icon={Redo} - title="Redo" - /> -
-
- ) -} - -interface RichTextEditorProps { - value: string - onChange: (value: string) => void - placeholder?: string - className?: string -} - -export function RichTextEditor({ - value, - onChange, - placeholder = "Start writing...", - className, -}: RichTextEditorProps) { - const editor = useEditor({ - immediatelyRender: false, - extensions: [ - StarterKit.configure({ - heading: { - levels: [1, 2, 3], - }, - }), - Markdown, - Placeholder.configure({ - placeholder, - emptyEditorClass: "is-editor-empty before:content-[attr(data-placeholder)] before:text-muted-foreground before:float-left before:pointer-events-none before:h-0", - }), - ], - editorProps: { - attributes: { - class: "prose prose-sm dark:prose-invert max-w-none min-h-[150px] p-4 focus:outline-none", - }, - }, - content: value, - onUpdate: ({ editor }) => { - // Get markdown content - const markdown = (editor.storage as any).markdown.getMarkdown() - onChange(markdown) - }, - }) - - // Sync value changes from outside (e.g. when switching chapters) - React.useEffect(() => { - if (editor && value !== (editor.storage as any).markdown.getMarkdown()) { - editor.commands.setContent(value) - } - }, [value, editor]) - - return ( -
- - -
- ) +export function RichTextEditor( + props: React.ComponentProps, +): React.ReactElement { + return }