perf(phase1-4): 补漏性能预算重构专项遗漏项

Phase 1 配置基线:next.config.ts 新增 experimental.optimizePackageImports(lucide-react/recharts/@xyflow/react/@tiptap/* /@radix-ui/*/date-fns)+ serverExternalPackages 追加 tencentcloud-sdk-nodejs + exceljs;providers.tsx 注入 WebVitalsReporter 闭环 RUM 上报链路(之前组件存在但未注入导致生产环境零性能数据)。

Phase 2 Bundle 预算优化补漏:TipTap 三实例(shared/ui/rich-text-editor + exams/editor/exam-rich-editor + lesson-preparation/blocks/rich-text-block)之前仅文件拆分未真正 next/dynamic ssr:false,本次补齐 lazy wrapper + xxx-inner.tsx 拆分模式;ReactFlow knowledge-graph.tsx 改用 next/dynamic 加载 inner;chart.tsx 改 recharts 具名导入替代 import * as RechartsPrimitive barrel。

Phase 4 组件渲染优化补漏:12 个 recharts 图表组件(parent/grades/dashboard/homework 模块下)margin props 提取到模块级 CHART_MARGIN 常量避免 inline 重建;layout.tsx metadata 增强(metadataBase/openGraph/twitter/robots/authors/creator)+ getLocale/getMessages/auth 串行 await 改 Promise.all 并行。

同步架构文档 004/005 + known-issues.md:新增 Phase 1 配置基线规则章节 + Phase 2 文件清单修正(之前引用不存在的 question-rich-editor.tsx 和 paper-rich-editor.tsx,实际是 rich-text-editor.tsx 和 rich-text-block.tsx)+ Phase 4.9 metadata + Promise.all 规则补充。架构图 1.1.5 章节新增未完成项(React Compiler/force-dynamic 评估/Playwright 性能断言)。

验证:npx tsc --noEmit 零错误;npm run lint 零新增错误(3 errors + 12 warnings 均位于未修改的 pre-existing 文件)。
This commit is contained in:
SpecialX
2026-07-07 00:30:47 +08:00
parent 6104e6a685
commit 94f098b0f2
25 changed files with 335 additions and 830 deletions

View File

@@ -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` 注入 `<WebVitalsReporter />`(之前组件存在但未注入,链路断裂) |
| 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` `<img>` 加 `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 测试基础设施完善后接入。
### 关键架构决策

View File

@@ -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 行(最大 37549 个重复组件全部删除无残留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) 请求级去重 > 跨请求 cacheReact cache() not unstable_cache(3) 批量 SQL > 循环 SQLINSERT batch + UPDATE CASE WHEN(4) React 19 useOptimistic 替代手动 isPending(5) 细粒度 Zustand selector > useShallow 多字段 selector(6) recharts 具名导入 + optimizePackageImports 替代 barrel 导入。未完成项React Compilerbabel-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 @@
}
}
}
}
}

View File

@@ -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 | `<WebVitalsReporter />` 在 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` — 注入 `<WebVitalsReporter />`
- `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: () => <Skeleton/> })` | 顶层静态 import 整个 TipTap/ReactFlow |
| Provider 上下文与懒加载实现分离 | 同步壳保留 `<ReactFlowProvider>`inner 用 `useReactFlow()` 钩子 | inner 直接调用 `useReactFlow()` 但未在同层 Provider 内 |
| forwardRef 组件动态导入需保留 forwardRef 外壳 | `export const ExamRichEditor = forwardRef((props, ref) => <LazyInner {...props} ref={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 <span>...</span> }` | 在文件首行加 `"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<Parameters<typeof useReportWebVitals>[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 乐观更新应用规则(补充)

View File

@@ -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);

View File

@@ -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 读取 localegetRequestConfig 已配置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 sessionPromise.all 并行获取,避免串行 await 增加 TTFB/FCP/LCP。
// 三个调用相互独立,可安全并行。
const [locale, messages, session] = await Promise.all([
getLocale(),
getMessages(),
auth(),
]);
return (
<html lang={locale} suppressHydrationWarning>

View File

@@ -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({
<NuqsAdapter>{children}</NuqsAdapter>
</SessionProvider>
<Toaster />
<WebVitalsReporter />
</ThemeProvider>
</NextIntlClientProvider>
{process.env.NODE_ENV === "development" && (

View File

@@ -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 (
<ResponsiveContainer width="100%" height={240}>
<LineChart data={data} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}>
<LineChart data={data} margin={CHART_MARGIN}>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis
dataKey="date"

View File

@@ -11,6 +11,9 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
import { formatDate } from "@/shared/lib/utils"
import type { StudentDashboardGradeProps } from "@/modules/homework/types"
/** recharts 图表 margin 常量(避免每次渲染创建新对象引用) */
const CHART_MARGIN = { left: 12, right: 12, top: 12, bottom: 12 } as const
export function StudentGradesCard({ grades }: { grades: StudentDashboardGradeProps }) {
const t = useTranslations("dashboard")
const locale = useLocale()
@@ -59,7 +62,7 @@ export function StudentGradesCard({ grades }: { grades: StudentDashboardGradePro
},
]}
heightClassName="h-[200px]"
margin={{ left: 12, right: 12, top: 12, bottom: 12 }}
margin={CHART_MARGIN}
yWidth={30}
tooltipClassName="w-[200px]"
/>

View File

@@ -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]"
/>

View File

@@ -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 (
<Button
type="button"
variant={active ? "secondary" : "ghost"}
size="icon"
className={cn(
"h-8 w-8 shrink-0",
active && "bg-muted text-foreground hover:bg-muted"
)}
disabled={disabled}
onMouseDown={(e) => e.preventDefault()}
onClick={(e) => {
e.preventDefault()
onClick()
}}
title={title}
aria-label={title}
>
<Icon className="h-4 w-4" />
</Button>
)
}
/**
* 试卷富文本编辑器 —— 基于 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<ExamRichEditorHandle, ExamRichEditorProps>(
function ExamRichEditor(
{
initialContent,
placeholder,
onChange,
readOnly = false,
className,
},
ref
) {
const t = useTranslations("examHomework")
const resolvedPlaceholder = placeholder ?? t("exam.editor.contentPlaceholder")
const containerRef = useRef<HTMLDivElement>(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<QuestionBlockType | undefined>(() => {
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 (
<div className={cn("flex items-center justify-center p-8 text-muted-foreground", className)}>
{t("exam.editor.loading")}
</div>
)
}
return (
<div
ref={containerRef}
className={cn(
"relative flex flex-col rounded-md border bg-background shadow-sm overflow-hidden",
className
)}
>
{!readOnly && (
<>
<div className="flex flex-wrap items-center gap-1 border-b bg-background/95 p-1 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<ToolbarButton
onClick={handleBold}
icon={Bold}
title={t("exam.richEditor.bold")}
active={editor.isActive("bold")}
/>
<ToolbarButton
onClick={handleItalic}
icon={Italic}
title={t("exam.richEditor.italic")}
active={editor.isActive("italic")}
/>
<ToolbarButton
onClick={handleStrike}
icon={Strikethrough}
title={t("exam.richEditor.strike")}
active={editor.isActive("strike")}
/>
<ToolbarButton
onClick={handleDotted}
icon={Underline}
title={t("exam.richEditor.dotted")}
active={editor.isActive("dotted")}
/>
<Separator orientation="vertical" className="mx-1 h-6" />
<ToolbarButton
onClick={handleBulletList}
icon={List}
title={t("exam.richEditor.bulletList")}
active={editor.isActive("bulletList")}
/>
<ToolbarButton
onClick={handleOrderedList}
icon={ListOrdered}
title={t("exam.richEditor.orderedList")}
active={editor.isActive("orderedList")}
/>
<ToolbarButton
onClick={handleBlockquote}
icon={Quote}
title={t("exam.richEditor.quote")}
active={editor.isActive("blockquote")}
/>
<div className="ml-auto flex items-center gap-1">
<ToolbarButton onClick={handleUndo} icon={Undo} title={t("exam.richEditor.undo")} />
<ToolbarButton onClick={handleRedo} icon={Redo} title={t("exam.richEditor.redo")} />
</div>
</div>
<SelectionToolbar editor={editor} activeQuestionType={activeQuestionType} />
</>
)}
<EditorContent editor={editor} className="flex-1 overflow-y-auto min-h-0" />
</div>
)
}
const LazyInner = dynamic(
() =>
import("./exam-rich-editor-inner").then((m) => m.ExamRichEditorInner),
{
ssr: false,
loading: () => <Skeleton className="h-[500px] w-full" />,
},
)
export const ExamRichEditor = forwardRef<ExamRichEditorHandle, ExamRichEditorProps>(
function ExamRichEditor(props, ref) {
return <LazyInner {...props} ref={ref} />
},
)

View File

@@ -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"
/>

View File

@@ -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) => {

View File

@@ -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"
/>

View File

@@ -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}
/>
</div>

View File

@@ -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"
/>

View File

@@ -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

View File

@@ -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"
/>

View File

@@ -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({
</div>
) : (
<ResponsiveContainer width="100%" height="100%">
<BarChart data={data} margin={{ top: 10, right: 10, left: -20, bottom: 0 }}>
<BarChart data={data} margin={CHART_MARGIN}>
<CartesianGrid strokeDasharray="3 3" vertical={false} />
<XAxis
dataKey="name"

View File

@@ -1,4 +1,11 @@
"use client";
"use client"
/**
* 备课富文本 block inner 实现。
*
* 通过 rich-text-block.tsx 的 next/dynamic lazy wrapper 懒加载,
* 使 @tiptap/* 依赖被打入独立 chunk。
*/
import { useEditor, EditorContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
@@ -32,7 +39,7 @@ export function RichTextBlockInner({
planId,
blockId,
onUpdate,
}: RichTextBlockProps): React.ReactElement {
}: RichTextBlockProps) {
const t = useTranslations("lessonPreparation");
const editor = useEditor({
extensions: [

View File

@@ -1,214 +1,28 @@
"use client";
"use client"
import { useEditor, EditorContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Placeholder from "@tiptap/extension-placeholder";
import Image from "@tiptap/extension-image";
import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import type { RichTextBlockData, RichTextAttachment } from "../../types";
import { KnowledgePointPicker } from "../knowledge-point-picker";
import { AttachmentPicker } from "../attachment-picker";
import { SectionErrorBoundary } from "@/shared/components/section-error-boundary";
import { Tag, Paperclip, Trash2 } from "lucide-react";
/**
* 备课富文本 block —— 懒加载入口Phase 2.1
*
* 实际实现位于 rich-text-block-inner.tsx,通过 next/dynamic 以 ssr:false 懒加载,
* 使 @tiptap/* 重型依赖(约 180-220 KB被打入独立 chunk,避免污染首屏 bundle。
* 加载完成前展示 Skeleton 占位骨架。
*/
interface Props {
data: RichTextBlockData;
hint?: string;
textbookId?: string;
chapterId?: string;
/** V5-5当前课案 ID用于附件库 picker */
planId?: string;
/** V5-5当前 block ID用于附件关联 */
blockId?: string;
onUpdate: (data: RichTextBlockData) => 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 (
<div>
<EditorContent editor={editor} />
<div className="flex items-center gap-2 mt-2 px-3 flex-wrap">
{data.knowledgePointIds.length > 0 && (
<span className="text-xs text-on-surface-variant">
{t("knowledgePoint.linked", { count: data.knowledgePointIds.length })}
</span>
)}
<button
onClick={() => setShowKpPicker(true)}
className="text-xs text-primary hover:underline inline-flex items-center gap-1"
>
<Tag className="w-3 h-3" />
{t("knowledgePoint.annotate")}
</button>
{/* V5-5素材库入口 */}
{planId && (
<button
onClick={() => setShowAttachmentPicker(true)}
className="text-xs text-primary hover:underline inline-flex items-center gap-1"
>
<Paperclip className="w-3 h-3" />
{t("attachment.insert")}
</button>
)}
</div>
{/* V5-5已嵌入附件列表非图片附件在此展示图片已在富文本中渲染 */}
{attachments.length > 0 && (
<div className="mt-2 px-3 space-y-1">
<div className="text-xs text-on-surface-variant">
{t("attachment.embeddedCount", { count: attachments.length })}
</div>
{attachments
.filter((a) => a.kind !== "image")
.map((a) => (
<div
key={a.attachmentId}
className="flex items-center gap-2 text-xs border border-outline-variant rounded p-1"
>
<a
href={a.url}
target="_blank"
rel="noopener noreferrer"
className="flex-1 hover:underline truncate"
>
{a.displayName}
</a>
<span className="text-on-surface-variant">{a.kind}</span>
<button
onClick={() => handleRemoveAttachment(a.attachmentId)}
className="text-error hover:bg-error/10 p-0.5 rounded"
aria-label={t("attachment.remove")}
>
<Trash2 className="w-3 h-3" aria-hidden="true" />
</button>
</div>
))}
{/* V5-5图片附件单独展示缩略图即使已在富文本中内联此处也展示便于管理 */}
{attachments.some((a) => a.kind === "image") && (
<div className="flex flex-wrap gap-2">
{attachments
.filter((a) => a.kind === "image")
.map((a) => (
<div
key={a.attachmentId}
className="relative group border border-outline-variant rounded"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={a.url}
alt={a.displayName}
className="w-16 h-16 object-cover rounded"
/>
<button
onClick={() => handleRemoveAttachment(a.attachmentId)}
className="absolute top-0 right-0 bg-error text-on-error rounded-full p-0.5 opacity-0 group-hover:opacity-100 transition-opacity"
aria-label={t("attachment.remove")}
>
<Trash2 className="w-2.5 h-2.5" aria-hidden="true" />
</button>
</div>
))}
</div>
)}
</div>
)}
{showKpPicker && (
<SectionErrorBoundary namespace="lessonPreparation">
<KnowledgePointPicker
textbookId={textbookId}
chapterId={chapterId}
selectedIds={data.knowledgePointIds}
onChange={(ids) => onUpdate({ ...data, knowledgePointIds: ids })}
onClose={() => setShowKpPicker(false)}
/>
</SectionErrorBoundary>
)}
{/* V5-5素材库 picker */}
{showAttachmentPicker && planId && (
<SectionErrorBoundary namespace="lessonPreparation">
<AttachmentPicker
planId={planId}
blockId={blockId}
selectedIds={attachments.map((a) => a.attachmentId)}
onSelect={handleSelectAttachment}
onClose={() => setShowAttachmentPicker(false)}
/>
</SectionErrorBoundary>
)}
</div>
);
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: () => <Skeleton className="h-[120px] w-full" />,
},
)
export function RichTextBlock(
props: React.ComponentProps<typeof LazyInner>,
): React.ReactElement {
return <LazyInner {...props} />
}

View File

@@ -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]"
/>

View File

@@ -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: () => (
<div className="flex h-full flex-col gap-2 p-4">
<Skeleton className="h-10 w-full" />
<Skeleton className="flex-1 w-full" />
</div>
),
},
)
export function KnowledgeGraph(props: KnowledgeGraphProps): ReactNode {
export type { KnowledgeGraphProps } from "./knowledge-graph-inner"
export function KnowledgeGraph(props: KnowledgeGraphProps): React.ReactElement {
return (
<ReactFlowProvider>
<KnowledgeGraphInner {...props} />
<LazyInner {...props} />
</ReactFlowProvider>
)
}

View File

@@ -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<typeof RechartsPrimitive.ResponsiveContainer>["children"]
children: React.ComponentProps<typeof ResponsiveContainer>["children"]
}
>(({ id, className, children, config, ...props }, ref) => {
const uniqueId = React.useId()
@@ -58,9 +62,9 @@ const ChartContainer = React.forwardRef<
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>
<ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
</ResponsiveContainer>
</div>
</ChartContext.Provider>
)
@@ -100,11 +104,11 @@ ${colorConfig
)
}
const ChartTooltip = RechartsPrimitive.Tooltip
const ChartTooltip = RechartsTooltip
const ChartTooltipContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<typeof RechartsTooltip> &
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,

View File

@@ -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
}) => (
<Button
variant={isActive ? "secondary" : "ghost"}
size="icon"
className={cn(
"h-8 w-8 shrink-0",
isActive && "bg-muted text-foreground hover:bg-muted",
isActive && "bg-muted text-foreground hover:bg-muted"
)}
onClick={(e) => {
e.preventDefault();
onClick();
e.preventDefault()
onClick()
}}
title={title}
>
<Icon className="h-4 w-4" />
</Button>
);
)
const EditorToolbar = ({ editor }: { editor: Editor }) => {
if (!editor) return null;
if (!editor) return null
return (
<div className="flex flex-wrap items-center gap-1 border-b bg-background/95 p-1 backdrop-blur supports-[backdrop-filter]:bg-background/60">
@@ -142,14 +147,14 @@ const EditorToolbar = ({ editor }: { editor: Editor }) => {
/>
</div>
</div>
);
};
)
}
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 (
<div
className={cn(
"flex flex-col rounded-md border bg-background shadow-sm overflow-hidden",
className,
)}
>
<div className={cn("flex flex-col rounded-md border bg-background shadow-sm overflow-hidden", className)}>
<EditorToolbar editor={editor as Editor} />
<EditorContent editor={editor} className="flex-1 overflow-y-auto min-h-0" />
</div>
);
)
}

View File

@@ -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
}) => (
<Button
variant={isActive ? "secondary" : "ghost"}
size="icon"
className={cn(
"h-8 w-8 shrink-0",
isActive && "bg-muted text-foreground hover:bg-muted"
)}
onClick={(e) => {
e.preventDefault()
onClick()
}}
title={title}
>
<Icon className="h-4 w-4" />
</Button>
export type { RichTextEditorProps } from "./rich-text-editor-inner"
const LazyInner = dynamic(
() =>
import("./rich-text-editor-inner").then((m) => m.RichTextEditorInner),
{
ssr: false,
loading: () => <Skeleton className="h-[200px] w-full" />,
},
)
const EditorToolbar = ({ editor }: { editor: Editor }) => {
if (!editor) return null
return (
<div className="flex flex-wrap items-center gap-1 border-b bg-background/95 p-1 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<ToolbarButton
onClick={() => editor.chain().focus().toggleBold().run()}
isActive={editor.isActive("bold")}
icon={Bold}
title="Bold"
/>
<ToolbarButton
onClick={() => editor.chain().focus().toggleItalic().run()}
isActive={editor.isActive("italic")}
icon={Italic}
title="Italic"
/>
<ToolbarButton
onClick={() => editor.chain().focus().toggleStrike().run()}
isActive={editor.isActive("strike")}
icon={Strikethrough}
title="Strikethrough"
/>
<ToolbarButton
onClick={() => editor.chain().focus().toggleCode().run()}
isActive={editor.isActive("code")}
icon={Code}
title="Code"
/>
<Separator orientation="vertical" className="mx-1 h-6" />
<ToolbarButton
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
isActive={editor.isActive("heading", { level: 1 })}
icon={Heading1}
title="Heading 1"
/>
<ToolbarButton
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
isActive={editor.isActive("heading", { level: 2 })}
icon={Heading2}
title="Heading 2"
/>
<ToolbarButton
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
isActive={editor.isActive("heading", { level: 3 })}
icon={Heading3}
title="Heading 3"
/>
<Separator orientation="vertical" className="mx-1 h-6" />
<ToolbarButton
onClick={() => editor.chain().focus().toggleBulletList().run()}
isActive={editor.isActive("bulletList")}
icon={List}
title="Bullet List"
/>
<ToolbarButton
onClick={() => editor.chain().focus().toggleOrderedList().run()}
isActive={editor.isActive("orderedList")}
icon={ListOrdered}
title="Ordered List"
/>
<ToolbarButton
onClick={() => editor.chain().focus().toggleBlockquote().run()}
isActive={editor.isActive("blockquote")}
icon={Quote}
title="Blockquote"
/>
<div className="ml-auto flex items-center gap-1">
<ToolbarButton
onClick={() => editor.chain().focus().undo().run()}
isActive={false}
icon={Undo}
title="Undo"
/>
<ToolbarButton
onClick={() => editor.chain().focus().redo().run()}
isActive={false}
icon={Redo}
title="Redo"
/>
</div>
</div>
)
}
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 (
<div className={cn("flex flex-col rounded-md border bg-background shadow-sm overflow-hidden", className)}>
<EditorToolbar editor={editor as Editor} />
<EditorContent editor={editor} className="flex-1 overflow-y-auto min-h-0" />
</div>
)
export function RichTextEditor(
props: React.ComponentProps<typeof LazyInner>,
): React.ReactElement {
return <LazyInner {...props} />
}