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:
@@ -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 测试基础设施完善后接入。
|
||||
|
||||
### 关键架构决策
|
||||
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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 乐观更新应用规则(补充)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 (
|
||||
<html lang={locale} suppressHydrationWarning>
|
||||
|
||||
@@ -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" && (
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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]"
|
||||
/>
|
||||
|
||||
@@ -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]"
|
||||
/>
|
||||
|
||||
@@ -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(
|
||||
|
||||
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"
|
||||
|
||||
export type {
|
||||
ExamRichEditorHandle,
|
||||
ExamRichEditorProps,
|
||||
} from "./exam-rich-editor-inner"
|
||||
|
||||
const LazyInner = dynamic(
|
||||
() =>
|
||||
import("./exam-rich-editor-inner").then((m) => m.ExamRichEditorInner),
|
||||
{
|
||||
initialContent,
|
||||
placeholder,
|
||||
onChange,
|
||||
readOnly = false,
|
||||
className,
|
||||
ssr: false,
|
||||
loading: () => <Skeleton className="h-[500px] w-full" />,
|
||||
},
|
||||
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]
|
||||
)
|
||||
|
||||
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",
|
||||
export const ExamRichEditor = forwardRef<ExamRichEditorHandle, ExamRichEditorProps>(
|
||||
function ExamRichEditor(props, ref) {
|
||||
return <LazyInner {...props} ref={ref} />
|
||||
},
|
||||
},
|
||||
onUpdate: ({ editor }) => {
|
||||
onChangeRef.current?.(editor.getJSON() as EditorJSONContent)
|
||||
},
|
||||
})
|
||||
|
||||
// 当 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>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
import dynamic from "next/dynamic"
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
|
||||
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",
|
||||
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" />,
|
||||
},
|
||||
}),
|
||||
],
|
||||
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>
|
||||
);
|
||||
export function RichTextBlock(
|
||||
props: React.ComponentProps<typeof LazyInner>,
|
||||
): React.ReactElement {
|
||||
return <LazyInner {...props} />
|
||||
}
|
||||
|
||||
@@ -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]"
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user