refactor(data-access): 补全剩余 37 个 data-access 文件迁移至 cacheFn

迁移范围:lesson-preparation 11 文件、attendance 3 文件、settings 4 文件、classes-invitations、跨模块接口 4 文件、adaptive-practice/ai/onboarding/invitation-codes/search/standards/scheduling/leave-requests/audit。修复 3 个 cacheFn 块位置错误。同步架构文档迁移范围至 83 文件 250+ 函数。
This commit is contained in:
SpecialX
2026-07-06 15:12:30 +08:00
parent 22c2e6459d
commit 9ce8d6d3fd
112 changed files with 18094 additions and 1188 deletions

View File

@@ -286,6 +286,105 @@ V5 状态管理统一的后续阶段,针对两类高频性能问题进行细
---
## 1.1.6 缓存策略落地专项重构2026-07-05 新增)
> **目标**:全栈缓存策略一致性重构 — 服务端 `cacheFn` 双层包装 + 集中式 `INVALIDATION_MAP` 失效编排 + 客户端 `queryKeys` 工厂 + `useActionQuery`/`useActionMutation` 向后兼容双模式 Hook。
### 架构总览
```
┌─────────────────────────────────────────────────────────────────────┐
│ ClientTanStack Query + useActionQuery/useActionMutation
│ queryKeys.{module}.{resource}(...args) → queryKey │
│ useActionMutation({ mutationFn, actionId, params }) │
│ → 成功后按 CLIENT_INVALIDATION_MAP[actionId].queryKeys 自动失效 │
└──────────────────────────────┬──────────────────────────────────────┘
│ mutationFn = Server Action
┌─────────────────────────────────────────────────────────────────────┐
│ Server Actions编排层
│ mutation 成功分支末尾: │
│ await invalidateFor("module.action", { id }) │
│ → 1) CacheStore.invalidateTags 2) revalidateTag × N 3) revalidatePath × N │
└──────────────────────────────┬──────────────────────────────────────┘
│ 调用 data-access
┌─────────────────────────────────────────────────────────────────────┐
│ Data-access数据访问层
│ export const fnRaw = async (...) => { ... } │
│ export const fn = cacheFn(fnRaw, { tags, ttl, keyParts }) │
│ → 外层 react.cache请求级 memoization
│ → 内层 cacheStore.getOrSet跨请求/跨实例,按 tag 失效) │
└─────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────┐
│ CacheStoreCACHE_DRIVER 切换) │
│ memory默认单实例Map + LRU + tag 反查索引) │
│ redis@upstash/redis多实例共享故障降级
└─────────────────────────────────────────────────────────────────────┘
```
### 核心基础设施9 个文件)
| 文件 | 职责 |
|------|------|
| `shared/lib/cache/types.ts` | `CacheFnOptions` / `CacheStore` / `InvalidationRule` 接口 |
| `shared/lib/cache/memory-store.ts` | `MemoryCacheStore`Map + LRU maxEntries=500 + tag 反查索引) |
| `shared/lib/cache/redis-store.ts` | `RedisCacheStore`key=`next-edu:cache:{key}`,故障降级) |
| `shared/lib/cache/store-factory.ts` | `getCacheStore()` 单例工厂(按 `env.CACHE_DRIVER` 切换) |
| `shared/lib/cache/cache-fn.ts` | `cacheFn` 双层包装器(外层 react.cache + 内层 cacheStore.getOrSet |
| `shared/lib/cache/invalidation-map.ts` | `INVALIDATION_MAP` 集中式失效映射203 个 actionId+ `fillTemplate` |
| `shared/lib/cache/client-invalidation-map.ts` | `CLIENT_INVALIDATION_MAP` 客户端可见子集(仅 queryKeys |
| `shared/lib/cache/invalidate.ts` | `invalidateFor` 三步编排函数 |
| `shared/lib/cache/index.ts` | barrel re-export |
| `shared/lib/redis-client.ts` | 共享 Redis 单例rate-limit + cache 共用) |
| `shared/lib/query-keys.ts` | 客户端 queryKey 工厂(`queryKeys.classes.*` |
### 迁移覆盖范围28 个模块全部完成)
**data-access 层迁移至 `cacheFn`**83 个文件250+ 个查询函数):
| 组别 | 模块 | 文件数 | 备注 |
|------|------|--------|------|
| 标杆 | classes | 5 | `data-access-{teacher,admin,students,stats,schedule}.ts` |
| A | users / school / rbac / textbooks / questions / course-plans / files / dashboard / parent / proctoring | 11 | 77 个函数 |
| B | exams / grades / homework / diagnostic / error-book / adaptive-practice | 8 | 含跨模块接口 |
| C | lesson-preparation / elective / announcements / messaging / notifications | 6 | 24 个函数 |
**actions 层迁移至 `invalidateFor`**43 个文件247 处 `revalidatePath` 替换):
| 组别 | 模块 | 文件数 | 替换数 |
|------|------|--------|--------|
| 标杆 | classes | 5 | 47 |
| A | users / school / textbooks / questions / course-plans / standards / scheduling / audit / onboarding / invitation-codes / proctoring / i18n | 13 | 70 |
| B | exams / grades / homework / attendance / leave-requests / diagnostic / error-book / adaptive-practice | 9 | 77 |
| C | lesson-preparation / elective / announcements / messaging / notifications / settings / rbac | 8 | 100 |
**INVALIDATION_MAP**:扩展至 **203 个 actionId**,覆盖全部 28 个模块。每个 actionId 声明 `{ tags, queryKeys, paths }` 三段副作用。
### 强制规则(已通过 ESLint 落地)
| 规则 | 落地方式 |
|------|----------|
| Server Actions 中禁止直接调用 `revalidatePath` / `revalidateTag` | `no-restricted-syntax` ESLint 规则,仅 `shared/lib/cache/invalidate.ts` 豁免 |
| data-access 查询函数必须用 `cacheFn` 包装 | 双导出 raw 模式(`fnRaw` + `cacheFn(fnRaw, opts)`),单测可 mock |
| 未知 actionId 必须抛错 | `invalidateFor` 内部强校验,提示更新 INVALIDATION_MAP |
| Tag 命名规范 | `{module}` / `{module}:{resource}` / `{module}:{resource}:{id}` |
### 向后兼容 Hook 双模式
- **`useActionQuery`**:传 `queryKey` 走 `useQuery`(跨页共享缓存 + 自动 invalidate不传走旧 `useEffect + useState`(向后兼容)
- **`useActionMutation`**:传 `mutationFn + actionId` 自动 invalidate 相关 queryKeys旧 `mutate(action)` 模式仍可用
### 验证结果
- `npx tsc --noEmit`:零错误
- `npm run lint`:零 `no-restricted-syntax` 错误(仅 3 个 pre-existing `react-hooks/refs` 错误位于未修改文件)
- `npx vitest run src/shared/lib/cache/`25/25 测试通过5 个测试文件types/memory-store/redis-store/cache-fn/invalidate/invalidation-map
---
## 1.2 模块依赖关系图
下图展示模块间的实际依赖关系,**标注依赖类型与合规性**。

View File

@@ -5,7 +5,7 @@
"generatedAt": "2026-06-17",
"formatVersion": "1.1",
"rule": "每次文件修改后须同步更新本文件",
"lastUpdate": "性能预算重构专项 Phase 2-4 完成2026-07-05Phase 2 Bundle 预算优化:(2.1) TipTap 三实例exam-rich-editor/question-rich-editor/paper-editor+ AiAssistantWidget 改 next/dynamic ssr:false + xxx-inner.tsx 拆分模式;(2.2) KnowledgeGraph 改 ReactFlowProvider 同步壳 + lazy 内部实现;(2.3) exams/editor 拆 types.ts barrel 减少服务端消费方拉入 @tiptap/* 运行时;(2.4) status-badge 移除多余 'use client'(2.5) 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) 14 个 recharts 组件 props 提取到模块级常量避免 inline 重建;(4.7) scan-image-viewer aspect-ratio(4.8) AiAssistantWidget inner 拆分 + 动态 import(4.9) web-vitals-reporter.tsx 修复 Metric 类型推导(从 useReportWebVitals 签名推导)+ PerformanceEntry 类型注解 + sendBeacon/fetch keepalive fallback。架构决策(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。验证npx tsc --noEmit 零新增错误npm run lint 零新增错误pre-existing 3 errors + 13 warnings 均位于未修改文件)。",
"lastUpdate": "缓存策略落地专项重构完成2026-07-05全栈缓存一致性重构覆盖全部 28 个模块。基础设施层:新增 shared/lib/cache/ 9 个文件types/memory-store/redis-store/store-factory/cache-fn/invalidation-map/client-invalidation-map/invalidate/index+ 共享 redis-client.ts + 客户端 query-keys.ts 工厂。核心机制:(1) cacheFn 双层包装器 — 外层 react.cache 请求级 memoization内层 cacheStore.getOrSet 跨请求/跨实例缓存(按 tag 失效TTL 可选);(2) INVALIDATION_MAP 集中式失效映射表 — 扩展至 203 个 actionId每个声明 { tags, queryKeys, paths } 三段副作用;(3) invalidateFor 三步编排 — CacheStore.invalidateTags → revalidateTag × N → revalidatePath × NNext.js 16 revalidateTag 第二参数必填 \"default\"(4) CLIENT_INVALIDATION_MAP 客户端可见子集(仅 queryKeys 字段,避免拉入 server-only 依赖);(5) CACHE_DRIVER 环境变量切换 memory/redis driver复用 rate-limit 模式。迁移范围data-access 83 个文件 250+ 查询函数迁移至 cacheFn双导出 raw + 包装版本,单测可 mockactions 43 个文件 247 处 revalidatePath 替换为 invalidateForINVALIDATION_MAP 覆盖全部 28 个模块。Hook 双模式useActionQuery 传 queryKey 走 useQuery 跨页共享缓存,不传走旧 useEffect+useStateuseActionMutation 传 mutationFn+actionId 自动 invalidate旧 mutate(action) 仍可用。ESLint 强制规则no-restricted-syntax 禁止 Server Actions 中直接调用 revalidatePath/revalidateTag仅 shared/lib/cache/invalidate.ts 豁免data-access 查询函数双导出 raw 模式便于单测 mock。验证npx tsc --noEmit 零错误npm run lint 零 no-restricted-syntax 错误(仅 3 个 pre-existing react-hooks/refs 错误位于未修改文件npx vitest run src/shared/lib/cache/ 25/25 测试通过。详细文档见 004 第 1.1.6 节。",
"lastUpdated": "2026-07-05"
},
"architectureOverview": {

File diff suppressed because it is too large Load Diff

View File

@@ -1263,11 +1263,41 @@ if (announcement.type === "school") {
### 缓存策略重构涉及文件清单
- `src/shared/lib/cache/{types,memory-store,redis-store,store-factory,cache-fn,invalidation-map,client-invalidation-map,invalidate,index}.ts` — 缓存基础设施9 个文件)
- `src/shared/lib/redis-client.ts` — 共享 Redis 客户端单例
- `src/shared/lib/query-keys.ts` — 客户端 queryKey 工厂
- `src/shared/hooks/use-action-query.ts` — 新增 queryKey 入参走 QueryClient
- `src/shared/hooks/use-action-mutation.ts` — 新增 actionId 自动 invalidate
- `src/modules/classes/data-access-*.ts` — 5+ 文件全部 cacheFn 包装 + 双导出 raw
- `src/modules/classes/actions-*.ts` — 5 文件全部 invalidateFor 替换 revalidatePath
- `src/modules/classes/components/*` — useActionQuery/useActionMutation 适配新版 API
- `src/shared/lib/redis-client.ts` — 共享 Redis 客户端单例rate-limit + cache 共用)
- `src/shared/lib/query-keys.ts` — 客户端 queryKey 工厂`queryKeys.classes.*`
- `src/shared/hooks/use-action-query.ts` — 新增 queryKey 入参走 QueryClient(向后兼容旧模式)
- `src/shared/hooks/use-action-mutation.ts` — 新增 actionId 自动 invalidate(向后兼容 mutate(action)
- `src/env.mjs` — 新增 `CACHE_DRIVER: z.enum(["memory", "redis"]).default("memory")`
- `eslint.config.mjs` — 新增 `no-restricted-syntax` 规则禁止 Server Actions 中直接调用 revalidatePath/revalidateTag
### 全量迁移覆盖范围28 个模块)
**data-access 层迁移至 cacheFn**83 个文件250+ 查询函数):
| 组别 | 模块 | 备注 |
|------|------|------|
| 标杆 | classes | `data-access-{teacher,admin,students,stats,schedule}.ts` 5 文件 |
| A 组 | users / school / rbac / textbooks / questions / course-plans / files / dashboard / parent / proctoring | 77 个函数 |
| B 组 | exams / grades / homework / diagnostic / error-book / adaptive-practice | 含跨模块接口(如 exams/data-access-error-collection |
| C 组 | lesson-preparation / elective / announcements / messaging / notifications | 24 个函数 |
**actions 层迁移至 invalidateFor**43 个文件247 处 revalidatePath 替换):
| 组别 | 模块 | 替换数 |
|------|------|--------|
| 标杆 | classes5 文件) | 47 |
| A 组 | users / school / textbooks / questions / course-plans / standards / scheduling / audit / onboarding / invitation-codes / proctoring / i18n13 文件) | 70 |
| B 组 | exams / grades / homework / attendance / leave-requests / diagnostic / error-book / adaptive-practice9 文件) | 77 |
| C 组 | lesson-preparation / elective / announcements / messaging / notifications / settings / rbac8 文件) | 100 |
**INVALIDATION_MAP**:扩展至 **203 个 actionId**,覆盖全部 28 个模块。每个 actionId 声明 `{ tags, queryKeys, paths }` 三段副作用。`CLIENT_INVALIDATION_MAP` 同步保留客户端可见子集(仅 queryKeys
### 迁移过程中常见错误(速查)
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| cacheFn 包装时移除原 `import { cache } from "react"` | 全部改用 `cacheFn` 双导出模式 | 保留 `cache(fn)` 调用但移除 importtsc 报 `Cannot find name 'cache'` |
| 跨模块接口函数(如 `getExamSubmissionDataForErrorCollection`)不包装 cacheFn | 仅包装本模块自有查询;跨模块接口由对方模块决定 | 给跨模块接口也加 cacheFn重复包装 |
| 多余的 `}` 或 `)` 字符 | 迁移后用 tsc 全量校验 | 手动编辑后未运行 tscTS1005/TS1128 语法错误) |
| questions/data-access.ts 等大文件分批迁移 | 完整迁移所有 `cache(...)` 调用至 cacheFn | 部分迁移导致 `import { cache }` 已删但调用仍存 |

View File

@@ -0,0 +1,30 @@
CREATE TABLE `lesson_plan_schedules` (
`id` varchar(128) NOT NULL,
`plan_id` varchar(128) NOT NULL,
`class_id` varchar(128) NOT NULL,
`scheduled_date` date NOT NULL,
`period` int NOT NULL,
`class_schedule_id` varchar(128),
`duration_min` int NOT NULL DEFAULT 40,
`created_by` varchar(128) NOT NULL,
`created_at` timestamp NOT NULL DEFAULT (now()),
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `lesson_plan_schedules_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
ALTER TABLE `questions` ADD `content_text` text GENERATED ALWAYS AS (CAST(content AS CHAR)) STORED;--> statement-breakpoint
ALTER TABLE `lesson_plan_schedules` ADD CONSTRAINT `lesson_plan_schedules_plan_id_lesson_plans_id_fk` FOREIGN KEY (`plan_id`) REFERENCES `lesson_plans`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `lesson_plan_schedules` ADD CONSTRAINT `lesson_plan_schedules_class_id_classes_id_fk` FOREIGN KEY (`class_id`) REFERENCES `classes`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `lesson_plan_schedules` ADD CONSTRAINT `lesson_plan_schedules_created_by_users_id_fk` FOREIGN KEY (`created_by`) REFERENCES `users`(`id`) ON DELETE no action ON UPDATE no action;--> statement-breakpoint
CREATE INDEX `lpsc_plan_idx` ON `lesson_plan_schedules` (`plan_id`);--> statement-breakpoint
CREATE INDEX `lpsc_class_date_idx` ON `lesson_plan_schedules` (`class_id`,`scheduled_date`);--> statement-breakpoint
CREATE INDEX `lpsc_plan_date_idx` ON `lesson_plan_schedules` (`plan_id`,`scheduled_date`);--> statement-breakpoint
CREATE INDEX `exam_submissions_exam_status_idx` ON `exam_submissions` (`exam_id`,`status`);--> statement-breakpoint
CREATE INDEX `exam_submissions_submitted_at_idx` ON `exam_submissions` (`submitted_at`);--> statement-breakpoint
CREATE INDEX `exams_status_created_idx` ON `exams` (`status`,`created_at`);--> statement-breakpoint
CREATE INDEX `exams_creator_idx` ON `exams` (`creator_id`);--> statement-breakpoint
CREATE INDEX `questions_type_difficulty_idx` ON `questions` (`type`,`difficulty`);--> statement-breakpoint
-- P3-6: questions.content_text FULLTEXT 索引drizzle-kit 无法生成 FULLTEXT 声明,需手动追加)
-- 要求InnoDB 引擎 + utf8mb4 字符集MySQL 5.7+
-- 用于 searchQuestions 的 MATCH(content_text) AGAINST(? IN BOOLEAN MODE) 检索
CREATE FULLTEXT INDEX `questions_content_text_ft_idx` ON `questions` (`content_text`);

File diff suppressed because it is too large Load Diff

View File

@@ -8,6 +8,13 @@
"when": 1783064688766,
"tag": "0000_aberrant_deathstrike",
"breakpoints": true
},
{
"idx": 1,
"version": "5",
"when": 1783234384304,
"tag": "0001_questions_fulltext_search",
"breakpoints": true
}
]
}

72
package-lock.json generated
View File

@@ -37,6 +37,7 @@
"@t3-oss/env-nextjs": "^0.13.10",
"@tanstack/react-query": "^5.90.12",
"@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.14.5",
"@tiptap/extension-image": "^3.27.1",
"@tiptap/extension-placeholder": "^3.15.3",
"@tiptap/pm": "^3.15.3",
@@ -82,6 +83,7 @@
"@playwright/test": "^1.58.2",
"@tailwindcss/postcss": "^4",
"@tailwindcss/typography": "^0.5.16",
"@tanstack/react-query-devtools": "^5.101.2",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
@@ -6535,9 +6537,20 @@
}
},
"node_modules/@tanstack/query-core": {
"version": "5.90.12",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.12.tgz",
"integrity": "sha512-T1/8t5DhV/SisWjDnaiU2drl6ySvsHj1bHBCWNXd+/T+Hh1cf6JodyEYMd5sgwm+b/mETT4EV3H+zCVczCU5hg==",
"version": "5.101.2",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.2.tgz",
"integrity": "sha512-hH5MLoJhF7KaIGd7q3xTXGXvslI+GYlM1Z/35aSHHWaCJWB7XvTSHYuV3eM7tw+aE0mT/xMro4M4Q9rCGHT0lw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/query-devtools": {
"version": "5.101.2",
"resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.101.2.tgz",
"integrity": "sha512-o+wHcqgN7Pp0s8v1i0UGq/ZrrEKrxdIiMQmKRdYb2w7NPtylYSJ4+wg/tIn71m9DLstwUwdEGAvROdly6HXP6w==",
"dev": true,
"license": "MIT",
"funding": {
"type": "github",
@@ -6545,12 +6558,12 @@
}
},
"node_modules/@tanstack/react-query": {
"version": "5.90.12",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.12.tgz",
"integrity": "sha512-graRZspg7EoEaw0a8faiUASCyJrqjKPdqJ9EwuDRUF9mEYJ1YPczI9H+/agJ0mOJkPCJDk0lsz5QTrLZ/jQ2rg==",
"version": "5.101.2",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.2.tgz",
"integrity": "sha512-seDkr6kzGzX1okaaTtZPtgA688CDPlXUz1C6xSg0ESqn04Vuc8tlrYms1s3de+znBqhPVxFRfpAfUf+6XvfPWg==",
"license": "MIT",
"dependencies": {
"@tanstack/query-core": "5.90.12"
"@tanstack/query-core": "5.101.2"
},
"funding": {
"type": "github",
@@ -6560,6 +6573,24 @@
"react": "^18 || ^19"
}
},
"node_modules/@tanstack/react-query-devtools": {
"version": "5.101.2",
"resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.101.2.tgz",
"integrity": "sha512-eU7HctdA9gDjqoERoEdzLbw9DiqnBDfh5+Hu0u26gjqoHJezOpQAuiesDL2VvkU+2cPV76zgv0tMZsOrI4LjnQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@tanstack/query-devtools": "5.101.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"@tanstack/react-query": "^5.101.2",
"react": "^18 || ^19"
}
},
"node_modules/@tanstack/react-table": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz",
@@ -6580,6 +6611,23 @@
"react-dom": ">=16.8"
}
},
"node_modules/@tanstack/react-virtual": {
"version": "3.14.5",
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.14.5.tgz",
"integrity": "sha512-4EKRXh7zBLkbKbFmG3AUVkircuHd+7OdT1pocJSepxtfBd3qnrJgJ5rtPkRYyo9fmyVb2+pI2xPy5oYvMLQy6A==",
"license": "MIT",
"dependencies": {
"@tanstack/virtual-core": "3.17.3"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@tanstack/table-core": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz",
@@ -6593,6 +6641,16 @@
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/virtual-core": {
"version": "3.17.3",
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.17.3.tgz",
"integrity": "sha512-8Np/TFELpI0ySuJoVmjvOrQYXH/8sTX0Biv9szhFhY39xOdAAY+smrMxjxOum/ux3eM8MUJQsEJ0/R0UpvC8dw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@testing-library/dom": {
"version": "10.4.1",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",

View File

@@ -69,6 +69,7 @@
"@t3-oss/env-nextjs": "^0.13.10",
"@tanstack/react-query": "^5.90.12",
"@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.14.5",
"@tiptap/extension-image": "^3.27.1",
"@tiptap/extension-placeholder": "^3.15.3",
"@tiptap/pm": "^3.15.3",
@@ -114,6 +115,7 @@
"@playwright/test": "^1.58.2",
"@tailwindcss/postcss": "^4",
"@tailwindcss/typography": "^0.5.16",
"@tanstack/react-query-devtools": "^5.101.2",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",

View File

@@ -0,0 +1,24 @@
import { Skeleton } from "@/shared/components/ui/skeleton"
/**
* parent 段根级 Suspense fallback。
*
* 该目录无 page.tsx,本 loading.tsx 作为 parent 段下所有子路由 page 加载时的
* 默认 Suspense fallback,避免白屏。
*/
export default function ParentLoading() {
return (
<div className="flex h-full flex-col space-y-6 p-6">
<div className="space-y-2">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-72" />
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-32 w-full rounded-lg" />
))}
</div>
<Skeleton className="h-64 w-full rounded-lg" />
</div>
)
}

View File

@@ -1,4 +1,5 @@
import Link from "next/link"
import { memo } from "react"
import { getTranslations } from "next-intl/server"
import { EmptyState } from "@/shared/components/ui/empty-state"
@@ -79,7 +80,11 @@ const getSubjectColor = (subject: string): string => {
type TranslationFn = (key: string) => string
function AssignmentCard({
/**
* 高频列表项卡片(在作业列表中按科目分组多次渲染),
* 使用 React.memo 跳过 props 未变化时的重渲染。
*/
const AssignmentCard = memo(function AssignmentCard({
assignment: a,
t,
statusLabelMap,
@@ -138,7 +143,7 @@ function AssignmentCard({
</CardContent>
</Card>
)
}
})
export default async function StudentAssignmentsPage({
searchParams,

View File

@@ -0,0 +1,24 @@
import { Skeleton } from "@/shared/components/ui/skeleton"
/**
* student 段根级 Suspense fallback。
*
* 该目录无 page.tsx,本 loading.tsx 作为 student 段下所有子路由 page 加载时的
* 默认 Suspense fallback,避免白屏。
*/
export default function StudentLoading() {
return (
<div className="flex h-full flex-col space-y-6 p-6">
<div className="space-y-2">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-72" />
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-32 w-full rounded-lg" />
))}
</div>
<Skeleton className="h-64 w-full rounded-lg" />
</div>
)
}

View File

@@ -0,0 +1,24 @@
import { Skeleton } from "@/shared/components/ui/skeleton"
/**
* teacher 段根级 Suspense fallback。
*
* 该目录无 page.tsx,本 loading.tsx 作为 teacher 段下所有子路由 page 加载时的
* 默认 Suspense fallback,避免白屏。
*/
export default function TeacherLoading() {
return (
<div className="flex h-full flex-col space-y-6 p-6">
<div className="space-y-2">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-72" />
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-32 w-full rounded-lg" />
))}
</div>
<Skeleton className="h-64 w-full rounded-lg" />
</div>
)
}

View File

@@ -79,7 +79,8 @@ export async function GET(req: Request) {
// 学生不能搜索题目和考试
if (!isStudent && (type === "all" || type === "question")) {
tasks.push(searchQuestions(kw, pageSize))
// P3-6: searchQuestions 改用 MATCH AGAINST需要原始查询字符串 q 而非 %q% 格式
tasks.push(searchQuestions(q, pageSize))
}
if (type === "all" || type === "textbook") {
tasks.push(searchTextbooks(kw, pageSize))
@@ -118,7 +119,11 @@ export async function GET(req: Request) {
}
}
async function searchQuestions(kw: string, limit: number): Promise<SearchResultItem[]> {
async function searchQuestions(q: string, limit: number): Promise<SearchResultItem[]> {
// P3-6: 使用 questions.content_text STORED 生成列上的 FULLTEXT 索引
// 通过 MATCH AGAINST BOOLEAN MODE 实现高效全文检索
const booleanQuery = toBooleanModeQuery(q)
if (!booleanQuery) return []
try {
const rows = await db
.select({
@@ -128,13 +133,7 @@ async function searchQuestions(kw: string, limit: number): Promise<SearchResultI
createdAt: questions.createdAt,
})
.from(questions)
.where(
or(
// JSON 内容字段转换为文本进行模糊匹配
sql`CAST(${questions.content} AS CHAR) LIKE ${kw}`,
like(sql`CAST(${questions.content} AS CHAR)`, kw)
)!
)
.where(sql`MATCH(${questions.contentText}) AGAINST(${booleanQuery} IN BOOLEAN MODE)`)
.orderBy(desc(questions.createdAt))
.limit(limit)
@@ -277,3 +276,19 @@ function truncate(s: string, max: number): string {
if (t.length <= max) return t
return t.slice(0, max) + "..."
}
/**
* P3-6: 将原始查询字符串转换为 MySQL FULLTEXT BOOLEAN MODE 查询格式。
*
* 转换规则:
* - 按空白拆分为多个词
* - 转义 BOOLEAN MODE 特殊字符(+ - < > ( ) ~ * " '
* - 每个词添加 `+` 前缀(必须出现)和 `*` 后缀(前缀匹配)
* - 例如 "math question" → "+math* +question*"
*/
function toBooleanModeQuery(q: string): string {
const words = q.trim().split(/\s+/).filter(Boolean)
if (words.length === 0) return ""
const escapeWord = (w: string): string => `+${w.replace(/[+\-<>()~*"']/g, " ").trim()}*`
return words.map(escapeWord).filter((w) => w !== "+*").join(" ")
}

View File

@@ -0,0 +1,73 @@
import { NextResponse } from "next/server";
/**
* Web Vitals 上报端点
*
* 接收前端 navigator.sendBeacon 上报的 LCP/CLS/INP/FCP/TTFP 指标,
* 当前实现:写入服务端日志(便于开发期查看),生产可接入监控系统。
*
* 性能预算基线(见 docs/architecture/audit/performance-budget-audit-report.md):
* - LCP ≤ 2.5s,INP ≤ 200ms,CLS ≤ 0.05,TTFB ≤ 800ms,FCP ≤ 1.8s
* - 超出预算的 "poor" 级别会以 warn 级别日志记录便于排查
*/
export const runtime = "edge";
type WebVitalEntry = {
name: string;
startTime: number;
duration: number;
entryType: string;
};
type WebVitalsPayload = {
id: string;
name: string;
value: number;
rating: "good" | "needs-improvement" | "poor";
delta: number;
entries: WebVitalEntry[];
path: string;
timestamp: number;
};
// 性能预算基线,超出即标记为 poor
const BUDGETS: Record<string, number> = {
LCP: 2500,
INP: 200,
CLS: 0.05,
FCP: 1800,
TTFB: 800,
};
export async function POST(request: Request): Promise<NextResponse> {
let payload: WebVitalsPayload;
try {
payload = (await request.json()) as WebVitalsPayload;
} catch {
return NextResponse.json({ ok: false }, { status: 400 });
}
const budget = BUDGETS[payload.name];
const isOverBudget = budget !== undefined && payload.value > budget;
// good/info 级别用 info,rating poor 或超预算用 warn
if (payload.rating === "poor" || isOverBudget) {
console.warn("[web-vitals] over-budget", {
name: payload.name,
value: payload.value,
rating: payload.rating,
budget,
path: payload.path,
id: payload.id,
});
} else {
console.info("[web-vitals] reported", {
name: payload.name,
value: payload.value,
rating: payload.rating,
path: payload.path,
});
}
return NextResponse.json({ ok: true });
}

68
src/app/providers.tsx Normal file
View File

@@ -0,0 +1,68 @@
"use client"
import React, { useState, type ReactNode } from "react"
import { QueryClientProvider } from "@tanstack/react-query"
import { ReactQueryDevtools } from "@tanstack/react-query-devtools"
import { NextIntlClientProvider } from "next-intl"
import { NuqsAdapter } from "nuqs/adapters/next/app"
import { SessionProvider } from "next-auth/react"
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"
interface ProvidersProps {
children: ReactNode
locale: string
messages: Record<string, unknown>
session: Session | null
}
/**
* 客户端 Provider 聚合。
*
* 嵌套顺序(外层 → 内层):
* 1. QueryClientProvider — 最外层,避免子组件因 Query 重渲染丢失下层状态
* 2. NextIntlClientProvider — i18n 字典注入
* 3. ThemeProvider — 主题
* 4. SessionProvider — next-auth session
* 5. NuqsAdapter — URL 状态
* 6. children + Toaster
*
* V5 Phase 4 调整:移除 NotifyConfiguratornotify.ts 改为直接显示文本,
* 不再依赖 i18n resolver 注入i18n 由调用方通过 t() 控制)。
*/
export function Providers({
children,
locale,
messages,
session,
}: ProvidersProps): React.JSX.Element {
// 每个客户端会话独立 QueryClient避免 SSR 跨用户共享缓存
const [client] = useState(() => createQueryClient())
return (
<QueryClientProvider client={client}>
<NextIntlClientProvider locale={locale} messages={messages}>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
<SessionProvider
session={session}
refetchOnWindowFocus={false}
refetchInterval={0}
>
<NuqsAdapter>{children}</NuqsAdapter>
</SessionProvider>
<Toaster />
</ThemeProvider>
</NextIntlClientProvider>
{process.env.NODE_ENV === "development" && (
<ReactQueryDevtools initialIsOpen={false} />
)}
</QueryClientProvider>
)
}

View File

@@ -0,0 +1,62 @@
"use client";
import { useReportWebVitals } from "next/web-vitals";
import { useCallback } from "react";
/**
* Web Vitals 上报组件
*
* 在根布局渲染,采集 LCP/CLS/INP/FCP/TTFP 指标,
* 通过 navigator.sendBeacon 上报到 /api/web-vitals 端点。
*
* - 上报时机:指标首次确定 + CLS 在页面隐藏时再次上报
* - 上报方式:sendBeacon 优先(不阻塞页面),fetch fallback
* - 数据用途:生产环境真实用户性能监控(RUM),用于验证性能预算基线
*/
// 从 useReportWebVitals 签名推导 metric 类型(避免依赖 next 内部模块路径)
type WebVitalMetric = Parameters<Parameters<typeof useReportWebVitals>[0]>[0]
export function WebVitalsReporter(): React.ReactElement {
const handleReport = useCallback((metric: WebVitalMetric) => {
const payload = JSON.stringify({
id: metric.id,
name: metric.name,
value: metric.value,
rating: metric.rating,
delta: metric.delta,
entries: metric.entries.map((entry: PerformanceEntry) => ({
name: entry.name,
startTime: entry.startTime,
duration: entry.duration,
entryType: entry.entryType,
})),
// 路由上下文,便于按路由分组统计
path: typeof window !== "undefined" ? window.location.pathname : "/",
timestamp: Date.now(),
});
// 优先 sendBeacon,失败回退 fetch keepalive
if (
typeof navigator !== "undefined" &&
typeof navigator.sendBeacon === "function"
) {
const blob = new Blob([payload], { type: "application/json" });
const ok = navigator.sendBeacon("/api/web-vitals", blob);
if (ok) return;
}
void fetch("/api/web-vitals", {
body: payload,
method: "POST",
keepalive: true,
headers: { "Content-Type": "application/json" },
}).catch(() => {
// 静默失败,性能上报不应影响业务
});
}, []);
useReportWebVitals(handleReport);
return <></>;
}

View File

@@ -9,6 +9,7 @@ import {
questions,
questionsToKnowledgePoints,
} from "@/shared/db/schema"
import { cacheFn } from "@/shared/lib/cache"
import { isWeakChapterSourceMeta, isAiRecommendedSourceMeta, isKnowledgePointSourceMeta, isErrorVariantSourceMeta } from "./lib/source-meta"
import type { PracticeSourceMeta, QuestionSelectionResult } from "./types"
@@ -285,7 +286,7 @@ export async function selectQuestionsForPractice(
* 从专项练习中汇总已答题目。
* P3-8: LIMIT 从 1000 调低至 100仅查询最近 100 条记录以控制查询量。
*/
export async function getStudentAnsweredQuestionIds(studentId: string): Promise<string[]> {
export async function getStudentAnsweredQuestionIdsRaw(studentId: string): Promise<string[]> {
const rows = await db
.select({ questionId: practiceAnswers.questionId })
.from(practiceAnswers)
@@ -295,6 +296,12 @@ export async function getStudentAnsweredQuestionIds(studentId: string): Promise<
return Array.from(new Set(rows.map((r) => r.questionId)))
}
export const getStudentAnsweredQuestionIds = cacheFn(getStudentAnsweredQuestionIdsRaw, {
tags: ["adaptive-practice:students"],
ttl: 60,
keyParts: ["adaptive-practice", "students", "answered-questions"],
})
/**
* 自动识别学生的薄弱知识点。
*
@@ -306,7 +313,7 @@ export async function getStudentAnsweredQuestionIds(studentId: string): Promise<
* @param limit 返回数量限制
* @returns 薄弱知识点 ID 列表
*/
export async function identifyWeakKnowledgePoints(
export async function identifyWeakKnowledgePointsRaw(
studentId: string,
chapterId?: string,
limit: number = DEFAULT_WEAK_KP_LIMIT,
@@ -328,3 +335,9 @@ export async function identifyWeakKnowledgePoints(
return rows.map((r) => r.knowledgePointId)
}
export const identifyWeakKnowledgePoints = cacheFn(identifyWeakKnowledgePointsRaw, {
tags: ["adaptive-practice:students"],
ttl: 60,
keyParts: ["adaptive-practice", "students", "weak-kps"],
})

View File

@@ -0,0 +1,331 @@
"use client"
import { useState, useMemo } from "react"
import { usePathname } from "next/navigation"
import { useTranslations } from "next-intl"
import { Bot, X, Sparkles, RotateCcw, ChevronLeft } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from "@/shared/components/ui/sheet"
import { AiChatPanel } from "./ai-chat-panel"
import { useAiClientOptional } from "../context/ai-client-provider"
import { useFloatingBall } from "../hooks/use-floating-ball"
/**
* 上下文感知规则
*
* 根据当前路由推断用户上下文,动态生成 systemPrompt 和 contextMessage。
*/
type AiContextConfig = {
systemPrompt: string
contextMessage: string
suggestedPrompts?: string[]
}
/**
* 全局 AI 助手悬浮球360 悬浮球风格)— 内部实现
*
* 特性:
* - 可拖拽移动,松手吸附到最近屏幕边缘
* - 拖到边缘自动半隐藏(只露出一小部分)
* - 鼠标悬停时恢复显示
* - 位置持久化到 localStorage
* - 点击打开侧边抽屉,内嵌 AiChatPanel
* - 上下文感知:根据当前路由自动推断用户场景
*
* 该组件被打包进独立 chunk由 ai-assistant-widget.tsx 通过 next/dynamic 以 ssr:false 懒加载),
* 以避免 AiChatPanel 依赖的 AI SDK / Markdown 渲染等重型依赖被打入首屏 chunk。
*
* 使用:在 dashboard layout 中引入即可全局生效。
* 需要 AiClientProvider 包裹(可选,未注入时按钮不显示)。
*/
export function AiAssistantWidgetInner(): React.ReactNode {
const t = useTranslations("ai")
const pathname = usePathname()
const aiClient = useAiClientOptional()
const [open, setOpen] = useState(false)
const [chatKey, setChatKey] = useState(0)
const handleBallClick = () => setOpen(true)
const ball = useFloatingBall(handleBallClick)
// 根据路由推断上下文
const contextConfig = useMemo<AiContextConfig>(() => {
return inferContextFromPath(pathname, t)
}, [pathname, t])
// 如果未注入 AI 客户端服务,不显示悬浮按钮
if (!aiClient) {
return null
}
const { position, hidden, dragging, hovered, hiddenOffset, handlers, show, resetPosition } = ball
// 首次渲染时 position 为占位值(屏幕外),避免闪烁
const isReady = position.x < 9999
return (
<>
{/* 悬浮球 */}
{isReady ? (
<button
type="button"
aria-label={hidden ? t("widget.show") : t("widget.open")}
className="fixed z-50 flex select-none items-center justify-center rounded-full bg-gradient-to-br from-primary to-primary/80 text-primary-foreground shadow-lg transition-[width,height,opacity,transform] duration-200 ease-out hover:shadow-xl hover:scale-105 touch-none"
style={{
left: `${position.x}px`,
top: `${position.y}px`,
width: "56px",
height: "56px",
transform: `translateX(${hiddenOffset}px) ${dragging ? "scale(1.1)" : ""}`,
cursor: dragging ? "grabbing" : "grab",
opacity: hidden && !hovered ? 0.55 : 1,
transition: dragging ? "none" : "transform 0.25s ease-out, opacity 0.25s ease-out",
}}
onPointerDown={handlers.onPointerDown}
onPointerMove={handlers.onPointerMove}
onPointerUp={handlers.onPointerUp}
onPointerCancel={handlers.onPointerCancel}
onMouseEnter={handlers.onMouseEnter}
onMouseLeave={handlers.onMouseLeave}
onClick={(e) => {
// 拖动产生的 click 不触发打开
if (e.detail === 0) return
}}
>
<Bot className="h-6 w-6 drop-shadow-sm" />
<span className="pointer-events-none absolute -top-0.5 -right-0.5 flex h-3 w-3">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75" />
<span className="relative inline-flex rounded-full h-3 w-3 bg-emerald-500 ring-2 ring-background" />
</span>
{hidden ? (
<span className="pointer-events-none absolute inset-0 flex items-center justify-center rounded-full bg-primary/30 opacity-0 transition-opacity hover:opacity-100">
<ChevronLeft
className="h-4 w-4"
style={{
transform: position.x <= 16 ? "rotate(0deg)" : "rotate(180deg)",
}}
/>
</span>
) : null}
</button>
) : null}
{/* 半隐藏时的提示条 */}
{hidden && isReady ? (
<button
type="button"
aria-label={t("widget.show")}
className="fixed z-40 flex items-center justify-center rounded-full bg-primary/15 text-primary backdrop-blur-sm transition-opacity hover:bg-primary/25"
style={{
left: `${position.x + (hiddenOffset > 0 ? 0 : BALL_SIZE * 0.45)}px`,
top: `${position.y}px`,
width: "14px",
height: "56px",
cursor: "pointer",
}}
onClick={show}
>
<ChevronLeft
className="h-3 w-3"
style={{
transform: position.x <= 16 ? "rotate(0deg)" : "rotate(180deg)",
}}
/>
</button>
) : null}
{/* 侧边抽屉 */}
<Sheet open={open} onOpenChange={setOpen}>
<SheetContent hideClose className="w-full sm:max-w-lg overflow-hidden p-0 flex flex-col">
<SheetHeader className="px-5 py-4 border-b bg-gradient-to-r from-primary/5 to-transparent">
<div className="flex items-center justify-between">
<SheetTitle className="flex items-center gap-2.5">
<span className="flex h-9 w-9 items-center justify-center rounded-full bg-gradient-to-br from-primary to-primary/80 text-primary-foreground shadow-sm">
<Bot className="h-5 w-5" />
</span>
<div className="flex flex-col">
<span className="text-base font-semibold leading-tight">{t("widget.title")}</span>
<span className="flex items-center gap-1 text-xs text-muted-foreground">
<span className="inline-block h-1.5 w-1.5 rounded-full bg-emerald-500" />
{t("widget.online")}
</span>
</div>
</SheetTitle>
<div className="flex items-center gap-1">
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 w-8 p-0 text-muted-foreground"
onClick={() => {
try {
localStorage.removeItem("ai-chat-history")
} catch {
// ignore
}
setChatKey((k) => k + 1)
}}
aria-label={t("widget.newChat")}
title={t("widget.newChat")}
>
<Sparkles className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 w-8 p-0 text-muted-foreground"
onClick={resetPosition}
aria-label={t("widget.resetPosition")}
title={t("widget.resetPosition")}
>
<RotateCcw className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 w-8 p-0 text-muted-foreground"
onClick={() => setOpen(false)}
aria-label={t("widget.close")}
title={t("widget.close")}
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
</SheetHeader>
<div className="flex-1 overflow-y-auto">
<AiChatPanel
key={chatKey}
systemPrompt={contextConfig.systemPrompt}
contextMessage={contextConfig.contextMessage}
suggestedPrompts={contextConfig.suggestedPrompts}
maxMessages={30}
variant="widget"
/>
</div>
</SheetContent>
</Sheet>
</>
)
}
const BALL_SIZE = 56
/**
* 根据路由推断 AI 上下文
*/
function inferContextFromPath(
pathname: string,
t: ReturnType<typeof useTranslations>
): AiContextConfig {
// 教师批改
if (pathname.includes("/teacher/homework/submissions")) {
return {
systemPrompt:
"You are an AI grading assistant for teachers. Help with evaluating student submissions, providing feedback suggestions, and identifying common mistakes. Be concise and constructive.",
contextMessage: t("chat.contextMessage.teacherGrading"),
suggestedPrompts: [
t("chat.suggestedPrompts.teacher.0"),
t("chat.suggestedPrompts.context.teacherGrading.0"),
t("chat.suggestedPrompts.context.teacherGrading.1"),
],
}
}
// 教师备课
if (pathname.includes("/teacher/lesson-plans")) {
return {
systemPrompt:
"You are an AI lesson planning assistant. Help teachers design lessons, create activities, generate discussion questions, and align with curriculum standards.",
contextMessage: t("chat.contextMessage.teacherLesson"),
suggestedPrompts: [
t("chat.suggestedPrompts.teacher.1"),
t("chat.suggestedPrompts.context.teacherLesson.0"),
t("chat.suggestedPrompts.context.teacherLesson.1"),
],
}
}
// 教师试卷
if (pathname.includes("/teacher/exams")) {
return {
systemPrompt:
"You are an AI exam design assistant. Help create questions, generate variants, analyze difficulty distribution, and ensure knowledge point coverage.",
contextMessage: t("chat.contextMessage.teacherExam"),
suggestedPrompts: [
t("chat.suggestedPrompts.teacher.2"),
t("chat.suggestedPrompts.context.teacherExam.0"),
t("chat.suggestedPrompts.context.teacherExam.1"),
],
}
}
// 学生错题本
if (pathname.includes("/student/error-book")) {
return {
systemPrompt:
"You are a Socratic tutor for K12 students. Guide the student to find answers themselves. Do NOT give direct answers. Use questions and hints to help them understand their mistakes.",
contextMessage: t("chat.contextMessage.studentErrorBook"),
suggestedPrompts: [
t("chat.suggestedPrompts.student.0"),
t("chat.suggestedPrompts.student.1"),
t("chat.suggestedPrompts.student.2"),
],
}
}
// 学生作业
if (pathname.includes("/student/homework") || pathname.includes("/student/learning")) {
return {
systemPrompt:
"You are a homework helper for K12 students. Use the Socratic method. Do NOT give direct answers. Guide the student through hints and questions.",
contextMessage: t("chat.contextMessage.studentHomework"),
suggestedPrompts: [
t("chat.suggestedPrompts.student.0"),
t("chat.suggestedPrompts.context.studentHomework.0"),
t("chat.suggestedPrompts.context.studentHomework.1"),
],
}
}
// 家长面板
if (pathname.includes("/parent")) {
return {
systemPrompt:
"You are a family education advisor. Help parents understand their child's learning progress, suggest home tutoring strategies, and provide educational guidance.",
contextMessage: t("chat.contextMessage.parent"),
suggestedPrompts: [
t("chat.suggestedPrompts.parent.0"),
t("chat.suggestedPrompts.parent.1"),
],
}
}
// 管理员面板
if (pathname.includes("/admin")) {
return {
systemPrompt:
"You are an AI education administration assistant. Help administrators monitor AI usage, analyze school-wide trends, and optimize resource allocation.",
contextMessage: t("chat.contextMessage.admin"),
suggestedPrompts: [
t("chat.suggestedPrompts.admin.0"),
t("chat.suggestedPrompts.admin.1"),
],
}
}
// 默认
return {
systemPrompt: "You are a helpful AI assistant for a K12 school management system.",
contextMessage: "",
suggestedPrompts: undefined,
}
}

View File

@@ -1,329 +1,26 @@
"use client"
import { useState, useMemo } from "react"
import { usePathname } from "next/navigation"
import { useTranslations } from "next-intl"
import { Bot, X, Sparkles, RotateCcw, ChevronLeft } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from "@/shared/components/ui/sheet"
import { AiChatPanel } from "./ai-chat-panel"
import { useAiClientOptional } from "../context/ai-client-provider"
import { useFloatingBall } from "../hooks/use-floating-ball"
import dynamic from "next/dynamic"
import { Skeleton } from "@/shared/components/ui/skeleton"
/**
* 上下文感知规则
* 全局 AI 助手悬浮球 —— 懒加载入口Phase 4.7
*
* 根据当前路由推断用户上下文,动态生成 systemPrompt 和 contextMessage。
* 通过 next/dynamic 以 ssr:false 懒加载 AiAssistantWidgetInner
* 使其依赖的 AI SDK / Markdown 渲染等重型依赖被打入独立 chunk
* 避免污染首屏 bundle。加载完成前展示 Skeleton 占位骨架。
*/
type AiContextConfig = {
systemPrompt: string
contextMessage: string
suggestedPrompts?: string[]
}
const LazyAiAssistantWidgetInner = dynamic(
() =>
import("./ai-assistant-widget-inner").then(
(m) => m.AiAssistantWidgetInner
),
{
ssr: false,
loading: () => <Skeleton className="h-12 w-full" />,
}
)
/**
* 全局 AI 助手悬浮球360 悬浮球风格)
*
* 特性:
* - 可拖拽移动,松手吸附到最近屏幕边缘
* - 拖到边缘自动半隐藏(只露出一小部分)
* - 鼠标悬停时恢复显示
* - 位置持久化到 localStorage
* - 点击打开侧边抽屉,内嵌 AiChatPanel
* - 上下文感知:根据当前路由自动推断用户场景
*
* 使用:
* 在 dashboard layout 中引入即可全局生效。
* 需要 AiClientProvider 包裹(可选,未注入时按钮不显示)。
*/
export function AiAssistantWidget(): React.ReactNode {
const t = useTranslations("ai")
const pathname = usePathname()
const aiClient = useAiClientOptional()
const [open, setOpen] = useState(false)
const [chatKey, setChatKey] = useState(0)
const handleBallClick = () => setOpen(true)
const ball = useFloatingBall(handleBallClick)
// 根据路由推断上下文
const contextConfig = useMemo<AiContextConfig>(() => {
return inferContextFromPath(pathname, t)
}, [pathname, t])
// 如果未注入 AI 客户端服务,不显示悬浮按钮
if (!aiClient) {
return null
}
const { position, hidden, dragging, hovered, hiddenOffset, handlers, show, resetPosition } = ball
// 首次渲染时 position 为占位值(屏幕外),避免闪烁
const isReady = position.x < 9999
return (
<>
{/* 悬浮球 */}
{isReady ? (
<button
type="button"
aria-label={hidden ? t("widget.show") : t("widget.open")}
className="fixed z-50 flex select-none items-center justify-center rounded-full bg-gradient-to-br from-primary to-primary/80 text-primary-foreground shadow-lg transition-[width,height,opacity,transform] duration-200 ease-out hover:shadow-xl hover:scale-105 touch-none"
style={{
left: `${position.x}px`,
top: `${position.y}px`,
width: "56px",
height: "56px",
transform: `translateX(${hiddenOffset}px) ${dragging ? "scale(1.1)" : ""}`,
cursor: dragging ? "grabbing" : "grab",
opacity: hidden && !hovered ? 0.55 : 1,
transition: dragging ? "none" : "transform 0.25s ease-out, opacity 0.25s ease-out",
}}
onPointerDown={handlers.onPointerDown}
onPointerMove={handlers.onPointerMove}
onPointerUp={handlers.onPointerUp}
onPointerCancel={handlers.onPointerCancel}
onMouseEnter={handlers.onMouseEnter}
onMouseLeave={handlers.onMouseLeave}
onClick={(e) => {
// 拖动产生的 click 不触发打开
if (e.detail === 0) return
}}
>
<Bot className="h-6 w-6 drop-shadow-sm" />
<span className="pointer-events-none absolute -top-0.5 -right-0.5 flex h-3 w-3">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75" />
<span className="relative inline-flex rounded-full h-3 w-3 bg-emerald-500 ring-2 ring-background" />
</span>
{hidden ? (
<span className="pointer-events-none absolute inset-0 flex items-center justify-center rounded-full bg-primary/30 opacity-0 transition-opacity hover:opacity-100">
<ChevronLeft
className="h-4 w-4"
style={{
transform: position.x <= 16 ? "rotate(0deg)" : "rotate(180deg)",
}}
/>
</span>
) : null}
</button>
) : null}
{/* 半隐藏时的提示条 */}
{hidden && isReady ? (
<button
type="button"
aria-label={t("widget.show")}
className="fixed z-40 flex items-center justify-center rounded-full bg-primary/15 text-primary backdrop-blur-sm transition-opacity hover:bg-primary/25"
style={{
left: `${position.x + (hiddenOffset > 0 ? 0 : BALL_SIZE * 0.45)}px`,
top: `${position.y}px`,
width: "14px",
height: "56px",
cursor: "pointer",
}}
onClick={show}
>
<ChevronLeft
className="h-3 w-3"
style={{
transform: position.x <= 16 ? "rotate(0deg)" : "rotate(180deg)",
}}
/>
</button>
) : null}
{/* 侧边抽屉 */}
<Sheet open={open} onOpenChange={setOpen}>
<SheetContent hideClose className="w-full sm:max-w-lg overflow-hidden p-0 flex flex-col">
<SheetHeader className="px-5 py-4 border-b bg-gradient-to-r from-primary/5 to-transparent">
<div className="flex items-center justify-between">
<SheetTitle className="flex items-center gap-2.5">
<span className="flex h-9 w-9 items-center justify-center rounded-full bg-gradient-to-br from-primary to-primary/80 text-primary-foreground shadow-sm">
<Bot className="h-5 w-5" />
</span>
<div className="flex flex-col">
<span className="text-base font-semibold leading-tight">{t("widget.title")}</span>
<span className="flex items-center gap-1 text-xs text-muted-foreground">
<span className="inline-block h-1.5 w-1.5 rounded-full bg-emerald-500" />
{t("widget.online")}
</span>
</div>
</SheetTitle>
<div className="flex items-center gap-1">
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 w-8 p-0 text-muted-foreground"
onClick={() => {
try {
localStorage.removeItem("ai-chat-history")
} catch {
// ignore
}
setChatKey((k) => k + 1)
}}
aria-label={t("widget.newChat")}
title={t("widget.newChat")}
>
<Sparkles className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 w-8 p-0 text-muted-foreground"
onClick={resetPosition}
aria-label={t("widget.resetPosition")}
title={t("widget.resetPosition")}
>
<RotateCcw className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 w-8 p-0 text-muted-foreground"
onClick={() => setOpen(false)}
aria-label={t("widget.close")}
title={t("widget.close")}
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
</SheetHeader>
<div className="flex-1 overflow-y-auto">
<AiChatPanel
key={chatKey}
systemPrompt={contextConfig.systemPrompt}
contextMessage={contextConfig.contextMessage}
suggestedPrompts={contextConfig.suggestedPrompts}
maxMessages={30}
variant="widget"
/>
</div>
</SheetContent>
</Sheet>
</>
)
}
const BALL_SIZE = 56
/**
* 根据路由推断 AI 上下文
*/
function inferContextFromPath(
pathname: string,
t: ReturnType<typeof useTranslations>
): AiContextConfig {
// 教师批改
if (pathname.includes("/teacher/homework/submissions")) {
return {
systemPrompt:
"You are an AI grading assistant for teachers. Help with evaluating student submissions, providing feedback suggestions, and identifying common mistakes. Be concise and constructive.",
contextMessage: t("chat.contextMessage.teacherGrading"),
suggestedPrompts: [
t("chat.suggestedPrompts.teacher.0"),
t("chat.suggestedPrompts.context.teacherGrading.0"),
t("chat.suggestedPrompts.context.teacherGrading.1"),
],
}
}
// 教师备课
if (pathname.includes("/teacher/lesson-plans")) {
return {
systemPrompt:
"You are an AI lesson planning assistant. Help teachers design lessons, create activities, generate discussion questions, and align with curriculum standards.",
contextMessage: t("chat.contextMessage.teacherLesson"),
suggestedPrompts: [
t("chat.suggestedPrompts.teacher.1"),
t("chat.suggestedPrompts.context.teacherLesson.0"),
t("chat.suggestedPrompts.context.teacherLesson.1"),
],
}
}
// 教师试卷
if (pathname.includes("/teacher/exams")) {
return {
systemPrompt:
"You are an AI exam design assistant. Help create questions, generate variants, analyze difficulty distribution, and ensure knowledge point coverage.",
contextMessage: t("chat.contextMessage.teacherExam"),
suggestedPrompts: [
t("chat.suggestedPrompts.teacher.2"),
t("chat.suggestedPrompts.context.teacherExam.0"),
t("chat.suggestedPrompts.context.teacherExam.1"),
],
}
}
// 学生错题本
if (pathname.includes("/student/error-book")) {
return {
systemPrompt:
"You are a Socratic tutor for K12 students. Guide the student to find answers themselves. Do NOT give direct answers. Use questions and hints to help them understand their mistakes.",
contextMessage: t("chat.contextMessage.studentErrorBook"),
suggestedPrompts: [
t("chat.suggestedPrompts.student.0"),
t("chat.suggestedPrompts.student.1"),
t("chat.suggestedPrompts.student.2"),
],
}
}
// 学生作业
if (pathname.includes("/student/homework") || pathname.includes("/student/learning")) {
return {
systemPrompt:
"You are a homework helper for K12 students. Use the Socratic method. Do NOT give direct answers. Guide the student through hints and questions.",
contextMessage: t("chat.contextMessage.studentHomework"),
suggestedPrompts: [
t("chat.suggestedPrompts.student.0"),
t("chat.suggestedPrompts.context.studentHomework.0"),
t("chat.suggestedPrompts.context.studentHomework.1"),
],
}
}
// 家长面板
if (pathname.includes("/parent")) {
return {
systemPrompt:
"You are a family education advisor. Help parents understand their child's learning progress, suggest home tutoring strategies, and provide educational guidance.",
contextMessage: t("chat.contextMessage.parent"),
suggestedPrompts: [
t("chat.suggestedPrompts.parent.0"),
t("chat.suggestedPrompts.parent.1"),
],
}
}
// 管理员面板
if (pathname.includes("/admin")) {
return {
systemPrompt:
"You are an AI education administration assistant. Help administrators monitor AI usage, analyze school-wide trends, and optimize resource allocation.",
contextMessage: t("chat.contextMessage.admin"),
suggestedPrompts: [
t("chat.suggestedPrompts.admin.0"),
t("chat.suggestedPrompts.admin.1"),
],
}
}
// 默认
return {
systemPrompt: "You are a helpful AI assistant for a K12 school management system.",
contextMessage: "",
suggestedPrompts: undefined,
}
return <LazyAiAssistantWidgetInner />
}

View File

@@ -30,6 +30,49 @@ import {
import { cn } from "@/shared/lib/utils"
import { AiChartSpecSchema } from "../schema"
/** recharts 内联对象常量:避免每次渲染创建新对象触发 re-render */
const CHART_MARGIN = { left: 8, right: 8, top: 8, bottom: 8 }
const CARTESIAN_GRID_PROPS = {
vertical: false,
strokeDasharray: "4 4",
strokeOpacity: 0.4,
} as const
const POLAR_GRID_PROPS = { strokeOpacity: 0.4 } as const
const BAR_X_AXIS_PROPS = {
tickLine: false,
axisLine: false,
tickMargin: 8,
} as const
const BAR_Y_AXIS_PROPS = {
allowDecimals: true,
tickLine: false,
axisLine: false,
width: 36,
} as const
const LINE_Y_AXIS_PROPS = {
tickLine: false,
axisLine: false,
width: 36,
} as const
const LINE_TOOLTIP_CURSOR = {
stroke: "hsl(var(--muted-foreground))",
strokeWidth: 1,
strokeDasharray: "4 4",
} as const
const LINE_ACTIVE_DOT = { r: 5, strokeWidth: 0 } as const
const POLAR_ANGLE_TICK = { fontSize: 12 } as const
const POLAR_RADIUS_TICK = { fontSize: 10 } as const
const BAR_RADIUS_TOP: [number, number, number, number] = [4, 4, 0, 0]
const DEFAULT_Y_DOMAIN: [number, number] = [0, 100]
function formatPercentTick(value: number): string {
return `${value}%`
}
function formatPieLabel(entry: { name?: string; value?: number }): string {
return `${entry.name ?? ""}: ${entry.value ?? ""}`
}
/**
* AI 图表渲染器
*
@@ -208,15 +251,12 @@ function renderChart(
function renderBarChart(spec: AiChartSpec, series: AiChartSeries[]): React.ReactNode {
const xKey = spec.xKey ?? "name"
return (
<BarChart data={spec.data} margin={{ left: 8, right: 8, top: 8, bottom: 8 }}>
<CartesianGrid vertical={false} strokeDasharray="4 4" strokeOpacity={0.4} />
<XAxis dataKey={xKey} tickLine={false} axisLine={false} tickMargin={8} />
<BarChart data={spec.data} margin={CHART_MARGIN}>
<CartesianGrid {...CARTESIAN_GRID_PROPS} />
<XAxis dataKey={xKey} {...BAR_X_AXIS_PROPS} />
<YAxis
domain={spec.yDomain}
allowDecimals
tickLine={false}
axisLine={false}
width={36}
{...BAR_Y_AXIS_PROPS}
/>
<ChartTooltip content={<ChartTooltipContent className="w-[200px]" />} />
{spec.showLegend ? <Legend /> : null}
@@ -226,7 +266,7 @@ function renderBarChart(spec: AiChartSpec, series: AiChartSeries[]): React.React
dataKey={s.dataKey}
name={s.name}
fill={`var(--color-${s.dataKey})`}
radius={[4, 4, 0, 0]}
radius={BAR_RADIUS_TOP}
/>
))}
</BarChart>
@@ -236,22 +276,16 @@ function renderBarChart(spec: AiChartSpec, series: AiChartSeries[]): React.React
function renderLineChart(spec: AiChartSpec, series: AiChartSeries[]): React.ReactNode {
const xKey = spec.xKey ?? "name"
return (
<LineChart data={spec.data} margin={{ left: 8, right: 8, top: 8, bottom: 8 }}>
<CartesianGrid vertical={false} strokeDasharray="4 4" strokeOpacity={0.4} />
<XAxis dataKey={xKey} tickLine={false} axisLine={false} tickMargin={8} />
<LineChart data={spec.data} margin={CHART_MARGIN}>
<CartesianGrid {...CARTESIAN_GRID_PROPS} />
<XAxis dataKey={xKey} {...BAR_X_AXIS_PROPS} />
<YAxis
domain={spec.yDomain ?? [0, 100]}
tickLine={false}
axisLine={false}
width={36}
domain={spec.yDomain ?? DEFAULT_Y_DOMAIN}
{...LINE_Y_AXIS_PROPS}
/>
{/* arbitrary-value: chart canvas fixed size */}
<ChartTooltip
cursor={{
stroke: "hsl(var(--muted-foreground))",
strokeWidth: 1,
strokeDasharray: "4 4",
}}
cursor={LINE_TOOLTIP_CURSOR}
content={<ChartTooltipContent indicator="line" className="w-[220px]" />}
/>
{spec.showLegend ? <Legend /> : null}
@@ -268,7 +302,7 @@ function renderLineChart(spec: AiChartSpec, series: AiChartSeries[]): React.Reac
r: 3,
strokeWidth: 2,
}}
activeDot={{ r: 5, strokeWidth: 0 }}
activeDot={LINE_ACTIVE_DOT}
/>
))}
</LineChart>
@@ -280,7 +314,7 @@ function renderPieChart(spec: AiChartSpec, series: AiChartSeries[]): React.React
const colors = series.map((s) => s.color ?? DEFAULT_PALETTE[0])
const dataKey = series[0]?.dataKey ?? "value"
return (
<PieChart margin={{ left: 8, right: 8, top: 8, bottom: 8 }}>
<PieChart margin={CHART_MARGIN}>
<ChartTooltip content={<ChartTooltipContent className="w-[200px]" />} />
{spec.showLegend ? <Legend /> : null}
<Pie
@@ -290,9 +324,7 @@ function renderPieChart(spec: AiChartSpec, series: AiChartSeries[]): React.React
cx="50%"
cy="50%"
outerRadius="75%"
label={(entry: { name?: string; value?: number }) =>
`${entry.name ?? ""}: ${entry.value ?? ""}`
}
label={formatPieLabel}
labelLine={false}
>
{spec.data.map((_, index) => (
@@ -306,13 +338,13 @@ function renderPieChart(spec: AiChartSpec, series: AiChartSeries[]): React.React
function renderRadarChart(spec: AiChartSpec, series: AiChartSeries[]): React.ReactNode {
const angleKey = spec.angleKey ?? spec.xKey ?? "name"
return (
<RadarChart data={spec.data} outerRadius="75%" margin={{ left: 8, right: 8, top: 8, bottom: 8 }}>
<PolarGrid strokeOpacity={0.4} />
<PolarAngleAxis dataKey={angleKey} tick={{ fontSize: 12 }} />
<RadarChart data={spec.data} outerRadius="75%" margin={CHART_MARGIN}>
<PolarGrid {...POLAR_GRID_PROPS} />
<PolarAngleAxis dataKey={angleKey} tick={POLAR_ANGLE_TICK} />
<PolarRadiusAxis
domain={spec.yDomain ?? [0, 100]}
tickFormatter={(value: number) => `${value}%`}
tick={{ fontSize: 10 }}
domain={spec.yDomain ?? DEFAULT_Y_DOMAIN}
tickFormatter={formatPercentTick}
tick={POLAR_RADIUS_TICK}
axisLine={false}
/>
<ChartTooltip content={<ChartTooltipContent className="w-[220px]" />} />

View File

@@ -1,5 +1,6 @@
import "server-only"
import { cacheFn } from "@/shared/lib/cache"
import type { AiUsageStats } from "./types"
/**
@@ -59,7 +60,7 @@ export function recordAiEvent(event: StoredAiEvent): void {
*
* @returns AiUsageStats — 聚合后的统计数据
*/
export async function getAiUsageStats(): Promise<AiUsageStats> {
export async function getAiUsageStatsRaw(): Promise<AiUsageStats> {
const now = Date.now()
const todayStart = new Date()
todayStart.setHours(0, 0, 0, 0)
@@ -136,3 +137,9 @@ export async function getAiUsageStats(): Promise<AiUsageStats> {
recentActivity,
}
}
export const getAiUsageStats = cacheFn(getAiUsageStatsRaw, {
tags: ["ai"],
ttl: 60,
keyParts: ["ai", "usage-stats"],
})

View File

@@ -1,6 +1,6 @@
"use client"
import { useMemo } from "react"
import { useCallback, useMemo } from "react"
import { useTranslations } from "next-intl"
import {
CartesianGrid,
@@ -37,6 +37,14 @@ import type {
AttendanceGradeRiskLevel,
} from "../types"
/** recharts 内联对象常量:避免每次渲染创建新对象触发 re-render */
const SCATTER_MARGIN = { top: 16, right: 16, bottom: 32, left: 16 }
const SCATTER_GRID_PROPS = { strokeDasharray: "4 4", strokeOpacity: 0.4 }
const TOOLTIP_CURSOR = { strokeDasharray: "3 3" }
const Z_AXIS_RANGE: [number, number] = [60, 60]
const AXIS_LABEL_STYLE = { fontSize: 12, fill: "hsl(var(--muted-foreground))" }
const SCATTER_DOMAIN: [number, number] = [0, 100]
/** 风险等级对应的颜色(散点图着色,与 attendance-sheet 调色板对齐)。 */
const RISK_COLORS: Record<AttendanceGradeRiskLevel, string> = {
high: "hsl(var(--chart-1))", // red-500
@@ -103,6 +111,36 @@ export function AttendanceGradeCorrelationCard({
}))
}, [summary])
const renderTooltip = useCallback(
(
_value: unknown,
_name: unknown,
item: { payload?: unknown }
) => {
// 从 unknown 转换recharts payload 类型未知,需类型守卫
const data = item?.payload as (typeof scatterData)[number] | undefined
if (!data) return null
return (
<div className="space-y-1">
<div className="font-medium">{data.studentName}</div>
<div className="text-xs text-muted-foreground">
{t("correlation.attendanceRate")}:{" "}
{data.attendanceRate}%
</div>
<div className="text-xs text-muted-foreground">
{t("correlation.averageScore")}:{" "}
{data.averageScore}
</div>
<div className="text-xs text-muted-foreground">
{t("correlation.absentCount")}:{" "}
{data.absentCount}
</div>
</div>
)
},
[t]
)
if (!summary) {
return (
<Card>
@@ -181,17 +219,14 @@ export function AttendanceGradeCorrelationCard({
className="h-[320px] w-full"
>
<ScatterChart
margin={{ top: 16, right: 16, bottom: 32, left: 16 }}
margin={SCATTER_MARGIN}
>
<CartesianGrid
strokeDasharray="4 4"
strokeOpacity={0.4}
/>
<CartesianGrid {...SCATTER_GRID_PROPS} />
<XAxis
type="number"
dataKey="attendanceRate"
name={t("correlation.attendanceRate")}
domain={[0, 100]}
domain={SCATTER_DOMAIN}
tickLine={false}
axisLine={false}
tickMargin={8}
@@ -199,52 +234,26 @@ export function AttendanceGradeCorrelationCard({
value: t("correlation.attendanceRate"),
position: "bottom",
offset: 16,
style: { fontSize: 12, fill: "hsl(var(--muted-foreground))" },
style: AXIS_LABEL_STYLE,
}}
/>
<YAxis
type="number"
dataKey="averageScore"
name={t("correlation.averageScore")}
domain={[0, 100]}
domain={SCATTER_DOMAIN}
tickLine={false}
axisLine={false}
width={40}
/>
<ZAxis type="number" range={[60, 60]} />
<ZAxis type="number" range={Z_AXIS_RANGE} />
<ChartTooltip
cursor={{ strokeDasharray: "3 3" }}
cursor={TOOLTIP_CURSOR}
content={
<ChartTooltipContent
indicator="dot"
hideLabel
formatter={(
_value: unknown,
_name: unknown,
item: { payload?: unknown }
) => {
const data = item?.payload as
| (typeof scatterData)[number]
| undefined
if (!data) return null
return (
<div className="space-y-1">
<div className="font-medium">{data.studentName}</div>
<div className="text-xs text-muted-foreground">
{t("correlation.attendanceRate")}:{" "}
{data.attendanceRate}%
</div>
<div className="text-xs text-muted-foreground">
{t("correlation.averageScore")}:{" "}
{data.averageScore}
</div>
<div className="text-xs text-muted-foreground">
{t("correlation.absentCount")}:{" "}
{data.absentCount}
</div>
</div>
)
}}
formatter={renderTooltip}
/>
}
/>

View File

@@ -1,6 +1,6 @@
"use client"
import { useState, useRef, useEffect, useCallback, useMemo } from "react"
import { useState, useRef, useEffect, useCallback, useMemo, useOptimistic } from "react"
import { useFormStatus } from "react-dom"
import { toast } from "sonner"
import { useRouter } from "next/navigation"
@@ -120,7 +120,12 @@ export function AttendanceSheet({
const [reasons, setReasons] = useState<Record<string, string>>({})
const [searchQuery, setSearchQuery] = useState("")
const [focusedStudentIndex, setFocusedStudentIndex] = useState(0)
const [isSubmitting, setIsSubmitting] = useState(false)
// Phase 4.5: 提交状态改用 useOptimistic,在 form action 内自动管理乐观状态与回滚
// (action 完成后 optimisticSubmitting 自动恢复为 false,无需手动 setIsSubmitting(false))
const [optimisticSubmitting, setOptimisticSubmitting] = useOptimistic(
false,
(_current, next: boolean) => next,
)
const [showSwitchConfirm, setShowSwitchConfirm] = useState(false)
const [pendingClassId, setPendingClassId] = useState<string | null>(null)
const studentRefs = useRef<(HTMLTableRowElement | null)[]>([])
@@ -257,7 +262,8 @@ export function AttendanceSheet({
return
}
setIsSubmitting(true)
// Phase 4.5: setOptimisticSubmitting 在 form action 内调用,action 完成后自动回滚
setOptimisticSubmitting(true)
formData.set("recordsJson", JSON.stringify(records))
try {
@@ -271,14 +277,12 @@ export function AttendanceSheet({
}
} catch {
toast.error(t("errors.unexpected"))
} finally {
setIsSubmitting(false)
}
}
return (
<Card className="relative">
{isSubmitting && (
{optimisticSubmitting && (
<div className="absolute inset-0 z-10 flex items-center justify-center rounded-lg bg-background/60 backdrop-blur-sm">
<div className="flex items-center gap-2 text-sm font-medium">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />

View File

@@ -11,6 +11,7 @@ import {
import { getGradeRecords } from "@/modules/grades/data-access"
import { safeParseDate } from "@/shared/lib/action-utils"
import type { DataScope } from "@/shared/types/permissions"
import { cacheFn } from "@/shared/lib/cache"
import { computeCorrelationSummary } from "./correlation-compute"
import type { AttendanceGradeCorrelationSummary } from "./types"
@@ -42,7 +43,7 @@ const DEFAULT_RANGE_DAYS = 90
* @param scope 数据范围(用于权限校验)
* @returns 关联分析汇总;班级不存在或无学生返回 null
*/
export async function getAttendanceGradeCorrelation(
export async function getAttendanceGradeCorrelationRaw(
classId: string,
startDate?: string,
endDate?: string,
@@ -120,10 +121,11 @@ export async function getAttendanceGradeCorrelation(
// Step 3: 委托 grades data-access 获取该班级所有成绩记录(按时间范围筛选 createdAt
// 注意getGradeRecords 不直接支持 createdAt 范围筛选,因此这里传入 classId + scope
// 后续在内存中按 createdAt 过滤时间范围(成绩数据量通常较小,可接受)
// P3-8: LIMIT 从 5000 调低至 100避免单次查询拉取过多记录导致 DB 压力
const gradeResult = await getGradeRecords({
classId,
scope: scope ?? { type: "all" },
limit: 5000,
limit: 100,
})
// 内存中按时间范围过滤成绩记录(按 createdAt
@@ -190,6 +192,12 @@ export async function getAttendanceGradeCorrelation(
)
}
export const getAttendanceGradeCorrelation = cacheFn(getAttendanceGradeCorrelationRaw, {
tags: ["attendance"],
ttl: 60,
keyParts: ["attendance", "correlation", "grade"],
})
/**
* Scope 辅助:检查 scope 是否允许访问班级级关联分析。
* 仅 all / class_taught含该班级/ grade_managed 允许;其余返回 false。

View File

@@ -8,6 +8,7 @@ import { getClassNameById, getClassNamesByIds, getClassesByGradeId } from "@/mod
import { getUserNamesByIds } from "@/modules/users/data-access"
import { getGradeNameById } from "@/modules/school/data-access"
import { safeParseDate } from "@/shared/lib/action-utils"
import { cacheFn } from "@/shared/lib/cache"
import { getAttendanceRules } from "./data-access"
import {
@@ -100,7 +101,7 @@ const serializeDate = (d: Date | string | null): string =>
* 获取学生考勤汇总。
* 优化:统计使用 SQL 聚合查询(避免拉全量记录),最近记录使用 LIMIT 分页。
*/
export async function getStudentAttendanceSummary(
export async function getStudentAttendanceSummaryRaw(
studentId: string,
startDate?: string,
endDate?: string,
@@ -181,7 +182,13 @@ export async function getStudentAttendanceSummary(
}
}
export async function getClassAttendanceStats(
export const getStudentAttendanceSummary = cacheFn(getStudentAttendanceSummaryRaw, {
tags: ["attendance:students"],
ttl: 60,
keyParts: ["attendance", "students", "summary"],
})
export async function getClassAttendanceStatsRaw(
classId: string,
startDate?: string,
endDate?: string
@@ -259,11 +266,17 @@ export async function getClassAttendanceStats(
}
}
export const getClassAttendanceStats = cacheFn(getClassAttendanceStatsRaw, {
tags: ["attendance"],
ttl: 60,
keyParts: ["attendance", "stats", "by-class"],
})
/**
* L-3获取班级所有学生的出勤率预警汇总。
* 按学生聚合统计 + 连续缺勤段,调用纯函数 computeAttendanceWarnings 计算预警。
*/
export async function getClassAttendanceWarnings(
export async function getClassAttendanceWarningsRaw(
classId: string,
startDate?: string,
endDate?: string
@@ -337,11 +350,17 @@ export async function getClassAttendanceWarnings(
}
}
export const getClassAttendanceWarnings = cacheFn(getClassAttendanceWarningsRaw, {
tags: ["attendance"],
ttl: 60,
keyParts: ["attendance", "warnings", "by-class"],
})
/**
* L-4获取班级考勤趋势按日/周/月聚合)。
* 使用纯函数 computeTrendPoints 计算趋势点,便于测试。
*/
export async function getAttendanceTrend(
export async function getAttendanceTrendRaw(
classId: string,
granularity: TrendGranularity,
startDate?: string,
@@ -381,11 +400,17 @@ export async function getAttendanceTrend(
}
}
export const getAttendanceTrend = cacheFn(getAttendanceTrendRaw, {
tags: ["attendance"],
ttl: 60,
keyParts: ["attendance", "trend"],
})
/**
* L-7获取同年级所有班级的出勤率对比。
* 通过 SQL 聚合按 classId 分组统计,避免拉全量记录。
*/
export async function getClassComparison(
export async function getClassComparisonRaw(
gradeId: string,
startDate?: string,
endDate?: string
@@ -464,3 +489,9 @@ export async function getClassComparison(
averagePresentRate,
}
}
export const getClassComparison = cacheFn(getClassComparisonRaw, {
tags: ["attendance"],
ttl: 60,
keyParts: ["attendance", "comparison", "by-grade"],
})

View File

@@ -11,6 +11,7 @@ import {
import { getClassActiveStudentsWithInfo, getClassNamesByIds } from "@/modules/classes/data-access"
import { getUserNamesByIds } from "@/modules/users/data-access"
import { safeParseDate } from "@/shared/lib/action-utils"
import { cacheFn } from "@/shared/lib/cache"
import type { DataScope } from "@/shared/types/permissions"
import type {
@@ -100,7 +101,7 @@ const resolveRecorderNames = async (
return result
}
export async function getAttendanceRecords(
export async function getAttendanceRecordsRaw(
params: AttendanceQueryParams & { scope: DataScope; currentUserId?: string }
): Promise<PaginatedAttendanceResult> {
const page = Math.max(1, params.page ?? 1)
@@ -164,7 +165,13 @@ export async function getAttendanceRecords(
}
}
export async function getClassAttendanceForDate(
export const getAttendanceRecords = cacheFn(getAttendanceRecordsRaw, {
tags: ["attendance:students"],
ttl: 60,
keyParts: ["attendance", "students", "records"],
})
export async function getClassAttendanceForDateRaw(
classId: string,
date: string,
/** L-6按节次过滤未传则返回当日所有节次记录 */
@@ -203,6 +210,12 @@ export async function getClassAttendanceForDate(
)
}
export const getClassAttendanceForDate = cacheFn(getClassAttendanceForDateRaw, {
tags: ["attendance:students"],
ttl: 60,
keyParts: ["attendance", "students", "by-date"],
})
export async function createAttendanceRecord(
data: RecordAttendanceInput,
recordedBy: string
@@ -277,14 +290,20 @@ export async function getAttendanceRecordClassId(id: string): Promise<string | n
return row?.classId ?? null
}
export async function getClassStudentsForAttendance(
export async function getClassStudentsForAttendanceRaw(
classId: string
): Promise<Array<{ id: string; name: string; email: string }>> {
// 通过 classes data-access 获取班级学生,避免跨模块直查 classEnrollments 表
return getClassActiveStudentsWithInfo(classId)
}
export async function getAttendanceRules(classId?: string): Promise<AttendanceRule[]> {
export const getClassStudentsForAttendance = cacheFn(getClassStudentsForAttendanceRaw, {
tags: ["attendance:students"],
ttl: 60,
keyParts: ["attendance", "students", "for-attendance"],
})
export async function getAttendanceRulesRaw(classId?: string): Promise<AttendanceRule[]> {
const conditions: SQL[] = []
if (classId) {
const classCondition = or(eq(attendanceRules.classId, classId), sql`${attendanceRules.classId} IS NULL`)
@@ -309,6 +328,12 @@ export async function getAttendanceRules(classId?: string): Promise<AttendanceRu
}))
}
export const getAttendanceRules = cacheFn(getAttendanceRulesRaw, {
tags: ["attendance"],
ttl: 300,
keyParts: ["attendance", "rules"],
})
export async function upsertAttendanceRules(data: AttendanceRuleInput): Promise<string> {
const [existing] = await db
.select()
@@ -348,7 +373,7 @@ export async function upsertAttendanceRules(data: AttendanceRuleInput): Promise<
* 获取考勤总览统计。
* 返回统一的 `AttendanceStats` 类型P2-5 修复:消除数据结构分裂)。
*/
export async function getAttendanceStats(params: {
export async function getAttendanceStatsRaw(params: {
scope: DataScope
currentUserId: string
classId?: string
@@ -400,6 +425,11 @@ export async function getAttendanceStats(params: {
lateRate: total > 0 ? Math.round((late / total) * 10000) / 100 : 0,
}
}
export const getAttendanceStats = cacheFn(getAttendanceStatsRaw, {
tags: ["attendance"],
ttl: 60,
keyParts: ["attendance", "stats"],
})
/**
* L-5将请假申请同步为考勤记录status='excused')。

View File

@@ -3,6 +3,7 @@ import "server-only"
import { and, asc, desc, eq, gte, lte, count, like, type SQL, sql } from "drizzle-orm"
import { db } from "@/shared/db"
import { cacheFn } from "@/shared/lib/cache"
import { auditLogs, loginLogs, dataChangeLogs } from "@/shared/db/schema"
import type {
AuditLog,
@@ -33,7 +34,7 @@ const clampPage = (page?: number): number => {
return page
}
export async function getAuditLogs(
export async function getAuditLogsRaw(
params?: AuditLogQueryParams
): Promise<PaginatedResult<AuditLog>> {
const page = clampPage(params?.page)
@@ -89,7 +90,13 @@ export async function getAuditLogs(
}
}
export async function getLoginLogs(
export const getAuditLogs = cacheFn(getAuditLogsRaw, {
tags: ["audit"],
ttl: 300,
keyParts: ["audit", "logs"],
})
export async function getLoginLogsRaw(
params?: LoginLogQueryParams
): Promise<PaginatedResult<LoginLog>> {
const page = clampPage(params?.page)
@@ -141,7 +148,13 @@ export async function getLoginLogs(
}
}
export async function getAuditModuleOptions(): Promise<string[]> {
export const getLoginLogs = cacheFn(getLoginLogsRaw, {
tags: ["audit"],
ttl: 300,
keyParts: ["audit", "login-logs"],
})
export async function getAuditModuleOptionsRaw(): Promise<string[]> {
try {
const rows = await db
.selectDistinct({ module: auditLogs.module })
@@ -154,7 +167,13 @@ export async function getAuditModuleOptions(): Promise<string[]> {
}
}
export async function getDataChangeLogs(
export const getAuditModuleOptions = cacheFn(getAuditModuleOptionsRaw, {
tags: ["audit"],
ttl: 300,
keyParts: ["audit", "module-options"],
})
export async function getDataChangeLogsRaw(
params?: DataChangeLogQueryParams
): Promise<PaginatedResult<DataChangeLog>> {
const page = clampPage(params?.page)
@@ -208,7 +227,13 @@ export async function getDataChangeLogs(
}
}
export async function getDataChangeStats(): Promise<DataChangeStat[]> {
export const getDataChangeLogs = cacheFn(getDataChangeLogsRaw, {
tags: ["audit"],
ttl: 300,
keyParts: ["audit", "data-change-logs"],
})
export async function getDataChangeStatsRaw(): Promise<DataChangeStat[]> {
try {
const rows = await db
.select({
@@ -225,7 +250,13 @@ export async function getDataChangeStats(): Promise<DataChangeStat[]> {
}
}
export async function getDataChangeTableOptions(): Promise<string[]> {
export const getDataChangeStats = cacheFn(getDataChangeStatsRaw, {
tags: ["audit"],
ttl: 60,
keyParts: ["audit", "data-change-stats"],
})
export async function getDataChangeTableOptionsRaw(): Promise<string[]> {
try {
const rows = await db
.selectDistinct({ tableName: dataChangeLogs.tableName })
@@ -241,6 +272,12 @@ export async function getDataChangeTableOptions(): Promise<string[]> {
/**
* Export-ready: fetch all audit logs matching params (no pagination cap).
*/
export const getDataChangeTableOptions = cacheFn(getDataChangeTableOptionsRaw, {
tags: ["audit"],
ttl: 300,
keyParts: ["audit", "table-options"],
})
export async function getAuditLogsForExport(
params?: AuditLogQueryParams
): Promise<AuditLog[]> {
@@ -317,7 +354,7 @@ function formatDateKey(d: Date): string {
/**
* 获取审计概览统计(今日数据 + 总数)
*/
export async function getAuditOverviewStats(): Promise<AuditOverviewStats> {
export async function getAuditOverviewStatsRaw(): Promise<AuditOverviewStats> {
const todayStart = startOfToday()
try {
const [auditToday, failedLoginToday, dataChangeToday, totalAudit] = await Promise.all([
@@ -350,7 +387,13 @@ export async function getAuditOverviewStats(): Promise<AuditOverviewStats> {
*
* 使用 MySQL `DATE()` 分组聚合,避免 N 次查询。
*/
export async function getAuditTrend(days: number = 7): Promise<AuditTrendPoint[]> {
export const getAuditOverviewStats = cacheFn(getAuditOverviewStatsRaw, {
tags: ["audit"],
ttl: 60,
keyParts: ["audit", "overview-stats"],
})
export async function getAuditTrendRaw(days: number = 7): Promise<AuditTrendPoint[]> {
const startDate = startOfDaysAgo(days)
try {
const [auditRows, loginRows, dataChangeRows] = await Promise.all([
@@ -407,7 +450,13 @@ export async function getAuditTrend(days: number = 7): Promise<AuditTrendPoint[]
/**
* 获取数据变更按动作统计create/update/delete
*/
export async function getDataChangeActionStats(): Promise<DataChangeActionStat[]> {
export const getAuditTrend = cacheFn(getAuditTrendRaw, {
tags: ["audit"],
ttl: 60,
keyParts: ["audit", "trend"],
})
export async function getDataChangeActionStatsRaw(): Promise<DataChangeActionStat[]> {
try {
const rows = await db
.select({
@@ -422,3 +471,9 @@ export async function getDataChangeActionStats(): Promise<DataChangeActionStat[]
throw error
}
}
export const getDataChangeActionStats = cacheFn(getDataChangeActionStatsRaw, {
tags: ["audit"],
ttl: 60,
keyParts: ["audit", "action-stats"],
})

View File

@@ -16,6 +16,47 @@ import {
DropdownMenuTrigger,
} from "@/shared/components/ui/dropdown-menu"
/** recharts 内联对象常量:避免每次渲染创建新对象触发 re-render */
const SPARK_MARGIN = { top: 5, right: 0, bottom: 0, left: 0 }
const COMPACT_LINE_MARGIN = { top: 5, right: 5, bottom: 0, left: 0 }
const FULL_LINE_MARGIN = { top: 20, right: 20, bottom: 0, left: 0 }
const GRID_PROPS = { vertical: false, strokeDasharray: "3 3" }
const GRID_PROPS_NO_HORIZONTAL = { vertical: false, strokeDasharray: "3 3", horizontal: false }
const ACTIVE_DOT_R6 = { r: 6 }
const ACTIVE_DOT_R4 = { r: 4 }
const COMPACT_X_AXIS_PROPS = {
dataKey: "title",
tickLine: false,
tickMargin: 10,
axisLine: false,
fontSize: 10,
hide: true,
}
const COMPACT_Y_AXIS_PROPS = {
tickLine: false,
axisLine: false,
fontSize: 10,
hide: true,
}
const SPARK_X_AXIS_PROPS = { dataKey: "title", hide: true }
const SPARK_Y_AXIS_PROPS = { hide: true }
const FULL_X_AXIS_PROPS = {
dataKey: "title",
tickLine: false,
tickMargin: 10,
axisLine: false,
fontSize: 12,
}
const SCORE_Y_DOMAIN: [number, number] = [0, 100]
function formatValueTick(value: number): string {
return `${value}`
}
function formatPercentTick(value: number): string {
return `${value}%`
}
interface AssignmentSummary {
id: string
title: string
@@ -69,39 +110,29 @@ export function ClassSubmissionTrendChart({
} satisfies ChartConfig
return (
<ChartContainer config={chartConfig} className={className}>
<LineChart accessibilityLayer data={data} margin={{ top: 5, right: 5, bottom: 0, left: 0 }}>
<CartesianGrid vertical={false} strokeDasharray="3 3" />
<XAxis
dataKey="title"
tickLine={false}
tickMargin={10}
axisLine={false}
fontSize={10}
hide
/>
<LineChart accessibilityLayer data={data} margin={COMPACT_LINE_MARGIN}>
<CartesianGrid {...GRID_PROPS} />
<XAxis {...COMPACT_X_AXIS_PROPS} />
<YAxis
tickLine={false}
axisLine={false}
fontSize={10}
{...COMPACT_Y_AXIS_PROPS}
domain={[0, 'auto']}
tickFormatter={(value) => `${value}`}
hide
tickFormatter={formatValueTick}
/>
<ChartTooltip content={<ChartTooltipContent />} />
<Line
type="monotone"
dataKey="target"
stroke="var(--color-target)"
strokeWidth={2}
<Line
type="monotone"
dataKey="target"
stroke="var(--color-target)"
strokeWidth={2}
strokeDasharray="4 4"
dot={false}
/>
<Line
type="monotone"
dataKey="submitted"
stroke="var(--color-submitted)"
strokeWidth={2}
activeDot={{ r: 6 }}
<Line
type="monotone"
dataKey="submitted"
stroke="var(--color-submitted)"
strokeWidth={2}
activeDot={ACTIVE_DOT_R6}
/>
</LineChart>
</ChartContainer>
@@ -202,19 +233,19 @@ export function ClassTrendsWidget({ assignments, compact, className }: ClassTren
<div className="flex-1 w-full min-h-0">
<ChartContainer config={chartConfig} className="h-full w-full">
{chartTab === "submission" ? (
<AreaChart accessibilityLayer data={chartData} margin={{ top: 5, right: 0, bottom: 0, left: 0 }}>
<AreaChart accessibilityLayer data={chartData} margin={SPARK_MARGIN}>
<defs>
<linearGradient id="fillSubmitted" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="var(--color-submitted)" stopOpacity={0.3}/>
<stop offset="95%" stopColor="var(--color-submitted)" stopOpacity={0.05}/>
</linearGradient>
</defs>
<CartesianGrid vertical={false} strokeDasharray="3 3" horizontal={false} />
<XAxis dataKey="title" hide />
<YAxis hide domain={[0, 'auto']} />
<ChartTooltip
cursor={false}
content={<ChartTooltipContent indicator="dot" hideLabel />}
<CartesianGrid {...GRID_PROPS_NO_HORIZONTAL} />
<XAxis {...SPARK_X_AXIS_PROPS} />
<YAxis {...SPARK_Y_AXIS_PROPS} domain={[0, 'auto']} />
<ChartTooltip
cursor={false}
content={<ChartTooltipContent indicator="dot" hideLabel />}
/>
<Area
type="monotone"
@@ -234,30 +265,30 @@ export function ClassTrendsWidget({ assignments, compact, className }: ClassTren
/>
</AreaChart>
) : (
<LineChart accessibilityLayer data={chartData} margin={{ top: 5, right: 0, bottom: 0, left: 0 }}>
<CartesianGrid vertical={false} strokeDasharray="3 3" horizontal={false} />
<XAxis dataKey="title" hide />
<YAxis hide domain={[0, 100]} />
<ChartTooltip
cursor={false}
content={<ChartTooltipContent indicator="dot" />}
<LineChart accessibilityLayer data={chartData} margin={SPARK_MARGIN}>
<CartesianGrid {...GRID_PROPS_NO_HORIZONTAL} />
<XAxis {...SPARK_X_AXIS_PROPS} />
<YAxis {...SPARK_Y_AXIS_PROPS} domain={SCORE_Y_DOMAIN} />
<ChartTooltip
cursor={false}
content={<ChartTooltipContent indicator="dot" />}
/>
<Line
type="monotone"
dataKey="avg"
stroke="var(--color-avg)"
strokeWidth={2}
<Line
type="monotone"
dataKey="avg"
stroke="var(--color-avg)"
strokeWidth={2}
dot={false}
activeDot={{ r: 4 }}
activeDot={ACTIVE_DOT_R4}
/>
<Line
type="monotone"
dataKey="median"
stroke="var(--color-median)"
strokeWidth={2}
<Line
type="monotone"
dataKey="median"
stroke="var(--color-median)"
strokeWidth={2}
strokeDasharray="4 4"
dot={false}
activeDot={{ r: 4 }}
activeDot={ACTIVE_DOT_R4}
/>
</LineChart>
)}
@@ -316,71 +347,59 @@ export function ClassTrendsWidget({ assignments, compact, className }: ClassTren
// arbitrary-value: chart canvas fixed size
<ChartContainer config={chartConfig} className="h-[250px] w-full">
{chartTab === "submission" ? (
<LineChart accessibilityLayer data={chartData} margin={{ top: 20, right: 20, bottom: 0, left: 0 }}>
<CartesianGrid vertical={false} strokeDasharray="3 3" />
<XAxis
dataKey="title"
tickLine={false}
tickMargin={10}
axisLine={false}
fontSize={12}
/>
<LineChart accessibilityLayer data={chartData} margin={FULL_LINE_MARGIN}>
<CartesianGrid {...GRID_PROPS} />
<XAxis {...FULL_X_AXIS_PROPS} />
<YAxis
tickLine={false}
axisLine={false}
fontSize={12}
domain={[0, 'auto']}
tickFormatter={(value) => `${value}`}
tickFormatter={formatValueTick}
/>
<ChartTooltip content={<ChartTooltipContent />} />
<Line
type="monotone"
dataKey="target"
stroke="var(--color-target)"
strokeWidth={2}
<Line
type="monotone"
dataKey="target"
stroke="var(--color-target)"
strokeWidth={2}
strokeDasharray="4 4"
dot={false}
/>
<Line
type="monotone"
dataKey="submitted"
stroke="var(--color-submitted)"
strokeWidth={2}
activeDot={{ r: 6 }}
<Line
type="monotone"
dataKey="submitted"
stroke="var(--color-submitted)"
strokeWidth={2}
activeDot={ACTIVE_DOT_R6}
/>
</LineChart>
) : (
<LineChart accessibilityLayer data={chartData} margin={{ top: 20, right: 20, bottom: 0, left: 0 }}>
<CartesianGrid vertical={false} strokeDasharray="3 3" />
<XAxis
dataKey="title"
tickLine={false}
tickMargin={10}
axisLine={false}
fontSize={12}
/>
<LineChart accessibilityLayer data={chartData} margin={FULL_LINE_MARGIN}>
<CartesianGrid {...GRID_PROPS} />
<XAxis {...FULL_X_AXIS_PROPS} />
<YAxis
tickLine={false}
axisLine={false}
fontSize={12}
domain={[0, 100]}
tickFormatter={(value) => `${value}%`}
domain={SCORE_Y_DOMAIN}
tickFormatter={formatPercentTick}
/>
<ChartTooltip content={<ChartTooltipContent />} />
<Line
type="monotone"
dataKey="avg"
stroke="var(--color-avg)"
strokeWidth={2}
activeDot={{ r: 6 }}
<Line
type="monotone"
dataKey="avg"
stroke="var(--color-avg)"
strokeWidth={2}
activeDot={ACTIVE_DOT_R6}
/>
<Line
type="monotone"
dataKey="median"
stroke="var(--color-median)"
strokeWidth={2}
<Line
type="monotone"
dataKey="median"
stroke="var(--color-median)"
strokeWidth={2}
strokeDasharray="4 4"
activeDot={{ r: 6 }}
activeDot={ACTIVE_DOT_R6}
/>
</LineChart>
)}

View File

@@ -5,6 +5,7 @@ import { createId } from "@paralleldrive/cuid2"
import { db } from "@/shared/db"
import { classes, classInvitationCodes } from "@/shared/db/schema"
import { cacheFn } from "@/shared/lib/cache"
/**
* 班级邀请码 data-accessv3 新增,对标 Google Classroom / 钉钉教育 / 智学网)。
@@ -199,7 +200,7 @@ export async function createInvitationCode(
* 获取班级当前有效的邀请码(取最新一个 active 的)。
* 给 onboarding/join 用,若无则返回 null。
*/
export async function getActiveInvitationCode(classId: string): Promise<InvitationCodeRecord | null> {
export async function getActiveInvitationCodeRaw(classId: string): Promise<InvitationCodeRecord | null> {
const [record] = await db
.select()
.from(classInvitationCodes)
@@ -216,10 +217,16 @@ export async function getActiveInvitationCode(classId: string): Promise<Invitati
return record ? mapRecord(record) : null
}
export const getActiveInvitationCode = cacheFn(getActiveInvitationCodeRaw, {
tags: ["classes:invitations"],
ttl: 600,
keyParts: ["classes", "invitations", "active"],
})
/**
* 列出班级所有邀请码(管理端用)。
*/
export async function listClassInvitationCodes(classId: string): Promise<InvitationCodeRecord[]> {
export async function listClassInvitationCodesRaw(classId: string): Promise<InvitationCodeRecord[]> {
const records = await db
.select()
.from(classInvitationCodes)
@@ -229,6 +236,12 @@ export async function listClassInvitationCodes(classId: string): Promise<Invitat
return records.map(mapRecord)
}
export const listClassInvitationCodes = cacheFn(listClassInvitationCodesRaw, {
tags: ["classes:invitations"],
ttl: 300,
keyParts: ["classes", "invitations", "list"],
})
/**
* 校验邀请码有效性(不消耗)。
*
@@ -310,7 +323,7 @@ export async function consumeInvitationCode(code: string): Promise<void> {
/**
* 根据邀请码 ID 查询所属班级 ID用于缓存失效参数填充
*/
export async function getClassIdByCodeId(codeId: string): Promise<string | null> {
export async function getClassIdByCodeIdRaw(codeId: string): Promise<string | null> {
const [record] = await db
.select({ classId: classInvitationCodes.classId })
.from(classInvitationCodes)
@@ -319,6 +332,12 @@ export async function getClassIdByCodeId(codeId: string): Promise<string | null>
return record?.classId ?? null
}
export const getClassIdByCodeId = cacheFn(getClassIdByCodeIdRaw, {
tags: ["classes:invitations"],
ttl: 300,
keyParts: ["classes", "invitations", "class-by-code"],
})
/**
* 撤销邀请码(软删除)。
*/

View File

@@ -2,7 +2,7 @@ import "server-only"
import { createId } from "@paralleldrive/cuid2"
import { and, count, desc, eq, inArray, type SQL } from "drizzle-orm"
import { cache } from "react"
import { cacheFn } from "@/shared/lib/cache"
import { getTranslations } from "next-intl/server"
import { db } from "@/shared/db"
@@ -198,8 +198,7 @@ export async function generateGradeDiagnosticReport(
}
/** 查询诊断报告列表P3-15 修复:支持分页) */
export const getDiagnosticReports = cache(
async (
export const getDiagnosticReportsRaw = async (
filters: DiagnosticReportQueryParams,
scope?: DataScope,
): Promise<DiagnosticReportListResult> => {
@@ -291,12 +290,16 @@ export const getDiagnosticReports = cache(
}))
return { reports, total }
},
)
}
export const getDiagnosticReports = cacheFn(getDiagnosticReportsRaw, {
tags: ["diagnostic"],
ttl: 300,
keyParts: ["diagnostic", "getDiagnosticReports"],
})
/** 获取报告详情 */
export const getDiagnosticReportById = cache(
async (id: string): Promise<DiagnosticReportWithDetails | null> => {
export const getDiagnosticReportByIdRaw = async (id: string): Promise<DiagnosticReportWithDetails | null> => {
const [row] = await db
.select({ report: learningDiagnosticReports })
.from(learningDiagnosticReports)
@@ -319,8 +322,13 @@ export const getDiagnosticReportById = cache(
? userMap.get(row.report.generatedBy)?.name ?? null
: null,
}
},
)
}
export const getDiagnosticReportById = cacheFn(getDiagnosticReportByIdRaw, {
tags: ["diagnostic"],
ttl: 300,
keyParts: ["diagnostic", "getDiagnosticReportById"],
})
/** 发布诊断报告 */
export async function publishDiagnosticReport(id: string): Promise<void> {

View File

@@ -1,6 +1,6 @@
import "server-only"
import { cache } from "react"
import { cacheFn } from "@/shared/lib/cache"
import { and, desc, eq, inArray } from "drizzle-orm"
import { db } from "@/shared/db"
@@ -33,7 +33,7 @@ import type {
} from "./types"
/** 获取学生在所有知识点的掌握度(含知识点名称) */
const getStudentMastery = cache(async (studentId: string): Promise<MasteryWithKnowledgePoint[]> => {
const getStudentMasteryRaw = async (studentId: string): Promise<MasteryWithKnowledgePoint[]> => {
const rows = await db
.select({
mastery: knowledgePointMastery,
@@ -52,10 +52,16 @@ const getStudentMastery = cache(async (studentId: string): Promise<MasteryWithKn
kpDescription: r.kpDescription,
} satisfies RawMasteryWithKpRow),
)
}
const getStudentMastery = cacheFn(getStudentMasteryRaw, {
tags: ["diagnostic"],
ttl: 300,
keyParts: ["diagnostic", "getStudentMastery"],
})
/** 获取学生掌握度摘要(含强项/弱项分析) */
export const getStudentMasterySummary = cache(async (studentId: string): Promise<StudentMasterySummary | null> => {
export const getStudentMasterySummaryRaw = async (studentId: string): Promise<StudentMasterySummary | null> => {
// P3-18 修复:用户名查询与掌握度查询相互独立,并行执行
const [userMap, allMastery] = await Promise.all([
getUserNamesByIds([studentId]),
@@ -65,6 +71,12 @@ export const getStudentMasterySummary = cache(async (studentId: string): Promise
if (!student) return null
return buildStudentMasterySummary(studentId, student.name ?? "Unknown", allMastery)
}
export const getStudentMasterySummary = cacheFn(getStudentMasterySummaryRaw, {
tags: ["diagnostic"],
ttl: 300,
keyParts: ["diagnostic", "getStudentMasterySummary"],
})
/** 从提交答案更新掌握度(累积模式:在历史基础上累加,正确率作为掌握度) */
@@ -322,7 +334,7 @@ export async function updateMasteryFromExamScore(
}
/** 获取班级掌握度摘要 */
export const getClassMasterySummary = cache(async (classId: string): Promise<ClassMasterySummary | null> => {
export const getClassMasterySummaryRaw = async (classId: string): Promise<ClassMasterySummary | null> => {
const classExists = await getClassExists(classId)
if (!classExists) return null
@@ -361,10 +373,16 @@ export const getClassMasterySummary = cache(async (classId: string): Promise<Cla
}))
return buildClassMasterySummary(classId, className, students, rawRows)
}
export const getClassMasterySummary = cacheFn(getClassMasterySummaryRaw, {
tags: ["diagnostic"],
ttl: 300,
keyParts: ["diagnostic", "getClassMasterySummary"],
})
/** v4-P2-3: 获取年级掌握度摘要 */
export const getGradeMasterySummary = cache(async (gradeId: string): Promise<GradeMasterySummary | null> => {
export const getGradeMasterySummaryRaw = async (gradeId: string): Promise<GradeMasterySummary | null> => {
// 年级名称 与 学生列表 相互独立,并行拉取
const [gradeNameResult, studentIds] = await Promise.all([
getGradeNameById(gradeId),
@@ -400,10 +418,16 @@ export const getGradeMasterySummary = cache(async (gradeId: string): Promise<Gra
}))
return buildGradeMasterySummary(gradeId, gradeName, students, rawRows)
}
export const getGradeMasterySummary = cacheFn(getGradeMasterySummaryRaw, {
tags: ["diagnostic"],
ttl: 300,
keyParts: ["diagnostic", "getGradeMasterySummary"],
})
/** 获取知识点统计(按班级或年级聚合) */
export const getKnowledgePointStats = cache(async (classId?: string, gradeId?: string): Promise<KnowledgePointStat[]> => {
export const getKnowledgePointStatsRaw = async (classId?: string, gradeId?: string): Promise<KnowledgePointStat[]> => {
let studentIds: string[] = []
if (classId) {
studentIds = await getActiveStudentIdsByClassId(classId)
@@ -430,6 +454,12 @@ export const getKnowledgePointStats = cache(async (classId?: string, gradeId?: s
const { byKp } = aggregateClassMastery(rawRows, studentIds)
return computeKpStats(byKp)
}
export const getKnowledgePointStats = cacheFn(getKnowledgePointStatsRaw, {
tags: ["diagnostic"],
ttl: 300,
keyParts: ["diagnostic", "getKnowledgePointStats"],
})
/**
@@ -439,8 +469,7 @@ export const getKnowledgePointStats = cache(async (classId?: string, gradeId?: s
* 在该知识点上的掌握度,便于针对性辅导。掌握度低于阈值(默认 60的学生
* 排在前面并标记为"需关注"。
*/
export const getClassStudentsByKnowledgePoint = cache(
async (
export const getClassStudentsByKnowledgePointRaw = async (
classId: string,
knowledgePointId: string,
options?: { threshold?: number }
@@ -516,4 +545,9 @@ export const getClassStudentsByKnowledgePoint = cache(
return result
}
)
export const getClassStudentsByKnowledgePoint = cacheFn(getClassStudentsByKnowledgePointRaw, {
tags: ["diagnostic"],
ttl: 300,
keyParts: ["diagnostic", "getClassStudentsByKnowledgePoint"],
})

View File

@@ -12,7 +12,7 @@ import { cacheFn } from "@/shared/lib/cache"
* 设计原则:
* - 复用全局 `system_settings` 表category="elective"),避免新增独立表
* - 支持按年级覆盖key=`creditLimit:grade:<gradeId>`fallback 到全局key=`creditLimit:default`
* - 用 React `cache()` 包装,单次请求内去重
* - 用 `cacheFn` 包装,请求级 + 跨请求缓存
* - 配置缺失时使用默认值(向后兼容)
*
* 配置项:

View File

@@ -1,5 +1,6 @@
"use client"
import { useCallback } from "react"
import { Bar, BarChart, CartesianGrid, XAxis, YAxis, Cell } from "recharts"
import { useTranslations } from "next-intl"
import {
@@ -14,6 +15,23 @@ import { cn } from "@/shared/lib/utils"
import type { ChapterWeakness } from "@/modules/error-book/types"
/** recharts 内联对象常量:避免每次渲染创建新对象触发 re-render */
const CHART_MARGIN = { left: 8, right: 16, top: 8, bottom: 8 }
const GRID_PROPS = { horizontal: false, strokeDasharray: "4 4", strokeOpacity: 0.4 }
const X_AXIS_PROPS = { type: "number" as const, tickLine: false, axisLine: false }
const Y_AXIS_BASE_PROPS = {
type: "number" as const,
dataKey: "name",
tickLine: false,
axisLine: false,
width: 120,
}
const BAR_RADIUS: [number, number, number, number] = [0, 4, 4, 0]
function truncateTick(value: string): string {
return value.length > 10 ? `${value.slice(0, 10)}...` : value
}
interface ChapterWeaknessChartProps {
data: ChapterWeakness[]
className?: string
@@ -49,6 +67,47 @@ function isChapterChartPayload(v: unknown): v is ChapterChartPayload {
export function ChapterWeaknessChart({ data, className }: ChapterWeaknessChartProps) {
const t = useTranslations("errorBook")
const renderTooltip = useCallback(
(payload: unknown) => {
if (!isChapterChartPayload(payload)) return null
return (
<div className="space-y-1.5">
<div className="font-medium">{payload.name}</div>
<div className="text-muted-foreground">
{t("chapterChart.errorCount")}
<span className="font-medium text-foreground">{payload.errorCount}</span>
<span className="ml-2">
{t("chapterChart.masteredLabel")}
<span className="font-medium text-emerald-600">{payload.masteredCount}</span>
</span>
</div>
<div className="text-muted-foreground">
{t("chapterChart.masteryRateLabel")}
<span className="font-medium text-foreground">{payload.masteryRate}%</span>
<span className="ml-2">
{t("chapterChart.knowledgePointCount")}
<span className="font-medium text-foreground">{payload.knowledgePointCount}</span>
</span>
</div>
{payload.topKps && payload.topKps.length > 0 ? (
<div className="border-t pt-1.5 mt-1.5">
<div className="text-xs text-muted-foreground mb-1">
{t("chapterChart.weakKpsLabel")}
</div>
{payload.topKps.map((kp) => (
<div key={kp.knowledgePointName} className="flex justify-between text-xs">
<span>{kp.knowledgePointName}</span>
<span className="font-medium text-rose-600">{kp.errorCount}</span>
</div>
))}
</div>
) : null}
</div>
)
},
[t]
)
if (data.length === 0) return null
const chartData = data.map((d) => ({
@@ -82,65 +141,23 @@ export function ChapterWeaknessChart({ data, className }: ChapterWeaknessChartPr
<BarChart
data={chartData}
layout="vertical"
margin={{ left: 8, right: 16, top: 8, bottom: 8 }}
margin={CHART_MARGIN}
>
<CartesianGrid horizontal={false} strokeDasharray="4 4" strokeOpacity={0.4} />
<XAxis type="number" tickLine={false} axisLine={false} />
<CartesianGrid {...GRID_PROPS} />
<XAxis {...X_AXIS_PROPS} />
<YAxis
type="number"
dataKey="name"
tickLine={false}
axisLine={false}
width={120}
tickFormatter={(value: string) =>
value.length > 10 ? `${value.slice(0, 10)}...` : value
}
{...Y_AXIS_BASE_PROPS}
tickFormatter={truncateTick}
/>
<ChartTooltip
content={
<ChartTooltipContent
className="w-[280px]"
formatter={(payload: unknown) => {
if (!isChapterChartPayload(payload)) return null
return (
<div className="space-y-1.5">
<div className="font-medium">{payload.name}</div>
<div className="text-muted-foreground">
{t("chapterChart.errorCount")}
<span className="font-medium text-foreground">{payload.errorCount}</span>
<span className="ml-2">
{t("chapterChart.masteredLabel")}
<span className="font-medium text-emerald-600">{payload.masteredCount}</span>
</span>
</div>
<div className="text-muted-foreground">
{t("chapterChart.masteryRateLabel")}
<span className="font-medium text-foreground">{payload.masteryRate}%</span>
<span className="ml-2">
{t("chapterChart.knowledgePointCount")}
<span className="font-medium text-foreground">{payload.knowledgePointCount}</span>
</span>
</div>
{payload.topKps && payload.topKps.length > 0 ? (
<div className="border-t pt-1.5 mt-1.5">
<div className="text-xs text-muted-foreground mb-1">
{t("chapterChart.weakKpsLabel")}
</div>
{payload.topKps.map((kp) => (
<div key={kp.knowledgePointName} className="flex justify-between text-xs">
<span>{kp.knowledgePointName}</span>
<span className="font-medium text-rose-600">{kp.errorCount}</span>
</div>
))}
</div>
) : null}
</div>
)
}}
formatter={renderTooltip}
/>
}
/>
<Bar dataKey="errorCount" radius={[0, 4, 4, 0]}>
<Bar dataKey="errorCount" radius={BAR_RADIUS}>
{chartData.map((entry, idx) => (
<Cell
key={idx}

View File

@@ -1,5 +1,6 @@
"use client"
import { useCallback } from "react"
import { Bar, BarChart, CartesianGrid, XAxis, YAxis, Cell } from "recharts"
import { useTranslations } from "next-intl"
import {
@@ -13,6 +14,22 @@ import { cn } from "@/shared/lib/utils"
import type { ClassErrorOverview } from "@/modules/error-book/types"
/** recharts 内联对象常量:避免每次渲染创建新对象触发 re-render */
const CHART_MARGIN = { left: 8, right: 8, top: 8, bottom: 8 }
const GRID_PROPS = { vertical: false, strokeDasharray: "4 4", strokeOpacity: 0.4 }
const X_AXIS_BASE_PROPS = {
dataKey: "name",
tickLine: false,
axisLine: false,
tickMargin: 8,
}
const Y_AXIS_PROPS = { tickLine: false, axisLine: false, width: 36 }
const BAR_RADIUS: [number, number, number, number] = [4, 4, 0, 0]
function truncateTick(value: string): string {
return value.length > 8 ? `${value.slice(0, 8)}...` : value
}
interface ClassErrorBarChartProps {
data: ClassErrorOverview[]
className?: string
@@ -57,6 +74,38 @@ const CHART_COLORS = [
export function ClassErrorBarChart({ data, className }: ClassErrorBarChartProps) {
const t = useTranslations("errorBook")
const renderTooltip = useCallback(
(payload: unknown) => {
if (!isClassBarChartPayload(payload)) return null
return (
<div className="space-y-1.5">
<div className="font-medium">{payload.name}</div>
<div className="text-muted-foreground">
{t("classErrorBar.errorCount")}
<span className="font-medium text-foreground">{payload.totalErrorItems}</span>
</div>
<div className="text-muted-foreground">
{t("classErrorBar.studentCount")}
<span className="font-medium text-foreground">{payload.studentCount}</span>
</div>
<div className="text-muted-foreground">
{t("classErrorBar.avgPerStudent")}
<span className="font-medium text-foreground">{payload.averageErrorPerStudent}</span>
</div>
<div className="text-muted-foreground">
{t("classErrorBar.avgMastery")}
<span className="font-medium text-foreground">{payload.averageMasteryRate}%</span>
</div>
<div className="text-muted-foreground">
{t("classErrorBar.dueReview")}
<span className="font-medium text-rose-600">{payload.dueReviewCount}</span>
</div>
</div>
)
},
[t]
)
if (data.length === 0) return null
const chartData = data.map((d) => ({
@@ -87,54 +136,22 @@ export function ClassErrorBarChart({ data, className }: ClassErrorBarChartProps)
className="h-[280px] w-full"
aria-label={t("classErrorBar.title")}
>
<BarChart data={chartData} margin={{ left: 8, right: 8, top: 8, bottom: 8 }}>
<CartesianGrid vertical={false} strokeDasharray="4 4" strokeOpacity={0.4} />
<BarChart data={chartData} margin={CHART_MARGIN}>
<CartesianGrid {...GRID_PROPS} />
<XAxis
dataKey="name"
tickLine={false}
axisLine={false}
tickMargin={8}
tickFormatter={(value: string) =>
value.length > 8 ? `${value.slice(0, 8)}...` : value
}
{...X_AXIS_BASE_PROPS}
tickFormatter={truncateTick}
/>
<YAxis tickLine={false} axisLine={false} width={36} />
<YAxis {...Y_AXIS_PROPS} />
<ChartTooltip
content={
<ChartTooltipContent
className="w-[220px]"
formatter={(payload: unknown) => {
if (!isClassBarChartPayload(payload)) return null
return (
<div className="space-y-1.5">
<div className="font-medium">{payload.name}</div>
<div className="text-muted-foreground">
{t("classErrorBar.errorCount")}
<span className="font-medium text-foreground">{payload.totalErrorItems}</span>
</div>
<div className="text-muted-foreground">
{t("classErrorBar.studentCount")}
<span className="font-medium text-foreground">{payload.studentCount}</span>
</div>
<div className="text-muted-foreground">
{t("classErrorBar.avgPerStudent")}
<span className="font-medium text-foreground">{payload.averageErrorPerStudent}</span>
</div>
<div className="text-muted-foreground">
{t("classErrorBar.avgMastery")}
<span className="font-medium text-foreground">{payload.averageMasteryRate}%</span>
</div>
<div className="text-muted-foreground">
{t("classErrorBar.dueReview")}
<span className="font-medium text-rose-600">{payload.dueReviewCount}</span>
</div>
</div>
)
}}
formatter={renderTooltip}
/>
}
/>
<Bar dataKey="totalErrorItems" radius={[4, 4, 0, 0]}>
<Bar dataKey="totalErrorItems" radius={BAR_RADIUS}>
{chartData.map((_, idx) => (
<Cell key={idx} fill={CHART_COLORS[idx % CHART_COLORS.length]} />
))}

View File

@@ -1,5 +1,6 @@
"use client"
import { useCallback } from "react"
import { Bar, BarChart, CartesianGrid, XAxis, YAxis, Cell } from "recharts"
import { useTranslations } from "next-intl"
import {
@@ -14,6 +15,23 @@ import { cn } from "@/shared/lib/utils"
import type { KnowledgePointWeakness } from "@/modules/error-book/types"
/** recharts 内联对象常量:避免每次渲染创建新对象触发 re-render */
const CHART_MARGIN = { left: 8, right: 16, top: 8, bottom: 8 }
const GRID_PROPS = { horizontal: false, strokeDasharray: "4 4", strokeOpacity: 0.4 }
const X_AXIS_PROPS = { type: "number" as const, tickLine: false, axisLine: false }
const Y_AXIS_BASE_PROPS = {
type: "number" as const,
dataKey: "name",
tickLine: false,
axisLine: false,
width: 100,
}
const BAR_RADIUS: [number, number, number, number] = [0, 4, 4, 0]
function truncateTick(value: string): string {
return value.length > 8 ? `${value.slice(0, 8)}...` : value
}
interface KnowledgePointWeaknessChartProps {
data: KnowledgePointWeakness[]
className?: string
@@ -52,6 +70,33 @@ export function KnowledgePointWeaknessChart({
}: KnowledgePointWeaknessChartProps) {
const t = useTranslations("errorBook")
const renderTooltip = useCallback(
(payload: unknown) => {
if (!isKpChartPayload(payload)) return null
return (
<div className="space-y-1.5">
<div className="font-medium">{payload.name}</div>
<div className="text-xs text-muted-foreground">
{t("weaknessChart.chapterLabel", { title: payload.chapterTitle })}
</div>
<div className="text-muted-foreground">
{t("weaknessChart.errorCount")}
<span className="font-medium text-foreground">{payload.errorCount}</span>
<span className="ml-2">
{t("weaknessChart.masteredLabel")}
<span className="font-medium text-emerald-600">{payload.masteredCount}</span>
</span>
</div>
<div className="text-muted-foreground">
{t("weaknessChart.masteryRateLabel")}
<span className="font-medium text-foreground">{payload.masteryRate}%</span>
</div>
</div>
)
},
[t]
)
if (data.length === 0) return null
const chartData = data.map((d) => ({
@@ -85,51 +130,23 @@ export function KnowledgePointWeaknessChart({
<BarChart
data={chartData}
layout="vertical"
margin={{ left: 8, right: 16, top: 8, bottom: 8 }}
margin={CHART_MARGIN}
>
<CartesianGrid horizontal={false} strokeDasharray="4 4" strokeOpacity={0.4} />
<XAxis type="number" tickLine={false} axisLine={false} />
<CartesianGrid {...GRID_PROPS} />
<XAxis {...X_AXIS_PROPS} />
<YAxis
type="number"
dataKey="name"
tickLine={false}
axisLine={false}
width={100}
tickFormatter={(value: string) =>
value.length > 8 ? `${value.slice(0, 8)}...` : value
}
{...Y_AXIS_BASE_PROPS}
tickFormatter={truncateTick}
/>
<ChartTooltip
content={
<ChartTooltipContent
className="w-60"
formatter={(payload: unknown) => {
if (!isKpChartPayload(payload)) return null
return (
<div className="space-y-1.5">
<div className="font-medium">{payload.name}</div>
<div className="text-xs text-muted-foreground">
{t("weaknessChart.chapterLabel", { title: payload.chapterTitle })}
</div>
<div className="text-muted-foreground">
{t("weaknessChart.errorCount")}
<span className="font-medium text-foreground">{payload.errorCount}</span>
<span className="ml-2">
{t("weaknessChart.masteredLabel")}
<span className="font-medium text-emerald-600">{payload.masteredCount}</span>
</span>
</div>
<div className="text-muted-foreground">
{t("weaknessChart.masteryRateLabel")}
<span className="font-medium text-foreground">{payload.masteryRate}%</span>
</div>
</div>
)
}}
formatter={renderTooltip}
/>
}
/>
<Bar dataKey="errorCount" radius={[0, 4, 4, 0]}>
<Bar dataKey="errorCount" radius={BAR_RADIUS}>
{chartData.map((entry, idx) => (
<Cell
key={idx}

View File

@@ -1,5 +1,6 @@
"use client"
import { useCallback } from "react"
import { Bar, BarChart, CartesianGrid, XAxis, YAxis, Cell } from "recharts"
import { useTranslations } from "next-intl"
import {
@@ -13,6 +14,22 @@ import { cn } from "@/shared/lib/utils"
import type { SubjectErrorDistribution } from "@/modules/error-book/types"
/** recharts 内联对象常量:避免每次渲染创建新对象触发 re-render */
const CHART_MARGIN = { left: 8, right: 8, top: 8, bottom: 8 }
const GRID_PROPS = { vertical: false, strokeDasharray: "4 4", strokeOpacity: 0.4 }
const X_AXIS_BASE_PROPS = {
dataKey: "name",
tickLine: false,
axisLine: false,
tickMargin: 8,
}
const Y_AXIS_PROPS = { tickLine: false, axisLine: false, width: 36 }
const BAR_RADIUS: [number, number, number, number] = [4, 4, 0, 0]
function truncateTick(value: string): string {
return value.length > 6 ? `${value.slice(0, 6)}...` : value
}
interface SubjectDistributionChartProps {
data: SubjectErrorDistribution[]
className?: string
@@ -56,6 +73,30 @@ export function SubjectDistributionChart({
}: SubjectDistributionChartProps) {
const t = useTranslations("errorBook")
const renderTooltip = useCallback(
(payload: unknown) => {
if (!isSubjectDistChartPayload(payload)) return null
return (
<div className="space-y-1.5">
<div className="font-medium">{payload.name}</div>
<div className="text-muted-foreground">
{t("subjectDistChart.errorCount")}
<span className="font-medium text-foreground">{payload.errorCount}</span>
</div>
<div className="text-muted-foreground">
{t("subjectDistChart.masteredLabel")}
<span className="font-medium text-emerald-600">{payload.masteredCount}</span>
</div>
<div className="text-muted-foreground">
{t("subjectDistChart.masteryRateLabel")}
<span className="font-medium text-foreground">{payload.masteryRate}%</span>
</div>
</div>
)
},
[t]
)
if (data.length === 0) return null
const chartData = data.map((d) => ({
@@ -84,46 +125,22 @@ export function SubjectDistributionChart({
className="h-[280px] w-full"
aria-label={t("subjectDistChart.title")}
>
<BarChart data={chartData} margin={{ left: 8, right: 8, top: 8, bottom: 8 }}>
<CartesianGrid vertical={false} strokeDasharray="4 4" strokeOpacity={0.4} />
<BarChart data={chartData} margin={CHART_MARGIN}>
<CartesianGrid {...GRID_PROPS} />
<XAxis
dataKey="name"
tickLine={false}
axisLine={false}
tickMargin={8}
tickFormatter={(value: string) =>
value.length > 6 ? `${value.slice(0, 6)}...` : value
}
{...X_AXIS_BASE_PROPS}
tickFormatter={truncateTick}
/>
<YAxis tickLine={false} axisLine={false} width={36} />
<YAxis {...Y_AXIS_PROPS} />
<ChartTooltip
content={
<ChartTooltipContent
className="w-[200px]"
formatter={(payload: unknown) => {
if (!isSubjectDistChartPayload(payload)) return null
return (
<div className="space-y-1.5">
<div className="font-medium">{payload.name}</div>
<div className="text-muted-foreground">
{t("subjectDistChart.errorCount")}
<span className="font-medium text-foreground">{payload.errorCount}</span>
</div>
<div className="text-muted-foreground">
{t("subjectDistChart.masteredLabel")}
<span className="font-medium text-emerald-600">{payload.masteredCount}</span>
</div>
<div className="text-muted-foreground">
{t("subjectDistChart.masteryRateLabel")}
<span className="font-medium text-foreground">{payload.masteryRate}%</span>
</div>
</div>
)
}}
formatter={renderTooltip}
/>
}
/>
<Bar dataKey="errorCount" radius={[4, 4, 0, 0]}>
<Bar dataKey="errorCount" radius={BAR_RADIUS}>
{chartData.map((_, idx) => (
<Cell key={idx} fill={SUBJECT_COLORS[idx % SUBJECT_COLORS.length]} />
))}

View File

@@ -13,6 +13,7 @@ import {
classEnrollments,
classes,
} from "@/shared/db/schema"
import { cacheFn } from "@/shared/lib/cache"
import { getStudentIdsByClassIds } from "@/modules/classes/data-access"
import { ROLE_NAMES } from "@/shared/types/permissions"
@@ -30,7 +31,7 @@ import { buildStudentErrorWhereClause, toStatus, toStringArray } from "./data-ac
// ---------------------------------------------------------------------------
/** 查询多个学生的错题统计(教师视图,支持按学科过滤) */
export async function getStudentErrorBookSummaries(
export async function getStudentErrorBookSummariesRaw(
studentIds: string[],
subjectId?: string | null
): Promise<StudentErrorBookSummary[]> {
@@ -92,8 +93,14 @@ export async function getStudentErrorBookSummaries(
})
}
export const getStudentErrorBookSummaries = cacheFn(getStudentErrorBookSummariesRaw, {
tags: ["error-book:students"],
ttl: 60,
keyParts: ["error-book", "students", "summaries"],
})
/** 查询班级内错题最多的题目(教师视图:高频错题,支持按学科过滤) */
export async function getTopWrongQuestionsByStudentIds(
export async function getTopWrongQuestionsByStudentIdsRaw(
studentIds: string[],
limit = 10,
subjectId?: string | null
@@ -144,8 +151,14 @@ export async function getTopWrongQuestionsByStudentIds(
}))
}
export const getTopWrongQuestionsByStudentIds = cacheFn(getTopWrongQuestionsByStudentIdsRaw, {
tags: ["error-book:students"],
ttl: 60,
keyParts: ["error-book", "students", "top-wrong"],
})
/** 查询多个学生的知识点薄弱度统计(支持按学科过滤,关联章节信息) */
export async function getKnowledgePointWeakness(
export async function getKnowledgePointWeaknessRaw(
studentIds: string[],
limit = 10,
subjectId?: string | null
@@ -226,8 +239,14 @@ export async function getKnowledgePointWeakness(
.slice(0, limit)
}
export const getKnowledgePointWeakness = cacheFn(getKnowledgePointWeaknessRaw, {
tags: ["error-book:students"],
ttl: 60,
keyParts: ["error-book", "students", "kp-weakness"],
})
/** 查询多个学生的学科错题分布 */
export async function getSubjectErrorDistribution(
export async function getSubjectErrorDistributionRaw(
studentIds: string[]
): Promise<Array<{
subjectId: string | null
@@ -276,12 +295,18 @@ export async function getSubjectErrorDistribution(
})
}
export const getSubjectErrorDistribution = cacheFn(getSubjectErrorDistributionRaw, {
tags: ["error-book"],
ttl: 60,
keyParts: ["error-book", "subject-distribution"],
})
/**
* 查询章节薄弱度统计(哪些课在错)。
* 通过 errorBookItems.knowledgePointIds → knowledgePoints.chapterId → chapters 关联。
* 支持按学科过滤(通过 errorBookItems.subjectId
*/
export async function getChapterWeakness(
export async function getChapterWeaknessRaw(
studentIds: string[],
limit = 10,
subjectId?: string | null
@@ -381,12 +406,18 @@ export async function getChapterWeakness(
.slice(0, limit)
}
export const getChapterWeakness = cacheFn(getChapterWeaknessRaw, {
tags: ["error-book:students"],
ttl: 60,
keyParts: ["error-book", "chapter-weakness"],
})
/**
* 查询按班级分组的错题概览(教师视图:分班显示)。
* 对每个班级统计:学生数、错题总数、人均错题数、平均掌握率、待复习数。
* 支持按学科过滤。
*/
export async function getClassErrorOverviews(
export async function getClassErrorOverviewsRaw(
classIds: string[],
subjectId?: string | null
): Promise<ClassErrorOverview[]> {
@@ -463,11 +494,17 @@ export async function getClassErrorOverviews(
}).sort((a, b) => b.totalErrorItems - a.totalErrorItems)
}
export const getClassErrorOverviews = cacheFn(getClassErrorOverviewsRaw, {
tags: ["error-book"],
ttl: 60,
keyParts: ["error-book", "class-overviews"],
})
/**
* 查询按学科分组的错题概览(用于学科 Tab 展示)。
* 返回每个学科的错题总数、涉及学生数、平均掌握率、待复习数。
*/
export async function getSubjectErrorOverviews(
export async function getSubjectErrorOverviewsRaw(
studentIds: string[]
): Promise<SubjectErrorOverview[]> {
if (studentIds.length === 0) return []
@@ -515,8 +552,14 @@ export async function getSubjectErrorOverviews(
.sort((a, b) => b.totalErrorItems - a.totalErrorItems)
}
export const getSubjectErrorOverviews = cacheFn(getSubjectErrorOverviewsRaw, {
tags: ["error-book"],
ttl: 60,
keyParts: ["error-book", "subject-overviews"],
})
/** 查询学生姓名映射 */
export async function getStudentNameMap(studentIds: string[]): Promise<Map<string, string>> {
export async function getStudentNameMapRaw(studentIds: string[]): Promise<Map<string, string>> {
if (studentIds.length === 0) return new Map()
const rows = await db
.select({ id: users.id, name: users.name })
@@ -525,17 +568,29 @@ export async function getStudentNameMap(studentIds: string[]): Promise<Map<strin
return new Map(rows.map((r) => [r.id, r.name ?? "未知"]))
}
export const getStudentNameMap = cacheFn(getStudentNameMapRaw, {
tags: ["error-book:students"],
ttl: 60,
keyParts: ["error-book", "students", "name-map"],
})
/** 按班级 ID 查询学生 ID 列表(委托给 classes 模块) */
export async function getStudentIdsByClassIdList(classIds: string[]): Promise<string[]> {
export async function getStudentIdsByClassIdListRaw(classIds: string[]): Promise<string[]> {
return await getStudentIdsByClassIds(classIds)
}
export const getStudentIdsByClassIdList = cacheFn(getStudentIdsByClassIdListRaw, {
tags: ["error-book:students"],
ttl: 60,
keyParts: ["error-book", "students", "ids-by-classes"],
})
/**
* 查询所有学生用户 ID管理员视图
* 通过 usersToRoles + roles 表关联查询 role === "student" 的用户。
* 此函数封装了 DB 访问,避免 app 层直接查询 DB遵循三层架构
*/
export async function getAllStudentIds(): Promise<string[]> {
export async function getAllStudentIdsRaw(): Promise<string[]> {
const { usersToRoles, roles } = await import("@/shared/db/schema")
const studentRole = await db
.select({ id: roles.id })
@@ -552,3 +607,9 @@ export async function getAllStudentIds(): Promise<string[]> {
return userRoleRows.map((r) => r.userId)
}
export const getAllStudentIds = cacheFn(getAllStudentIdsRaw, {
tags: ["error-book:students"],
ttl: 60,
keyParts: ["error-book", "students", "all-ids"],
})

View File

@@ -1,6 +1,6 @@
import "server-only"
import { cache } from "react"
import { cacheFn } from "@/shared/lib/cache"
import { and, count, desc, eq, inArray, isNull, lte, not, or, sql, type SQL } from "drizzle-orm"
import { createId } from "@paralleldrive/cuid2"
@@ -103,7 +103,7 @@ function mapRowToItem(row: ErrorBookItemWithRelations): ErrorBookItem {
// 查询:错题本列表
// ---------------------------------------------------------------------------
export const getErrorBookItems = cache(async (params: GetErrorBookItemsParams): Promise<ErrorBookListResult> => {
export const getErrorBookItemsRaw = async (params: GetErrorBookItemsParams): Promise<ErrorBookListResult> => {
const { studentId, q, page = 1, pageSize = 20, status, sourceType, subjectId, dueOnly } = params
const offset = (page - 1) * pageSize
@@ -178,13 +178,19 @@ export const getErrorBookItems = cache(async (params: GetErrorBookItemsParams):
totalPages: Math.ceil(total / pageSize),
},
}
}
export const getErrorBookItems = cacheFn(getErrorBookItemsRaw, {
tags: ["error-book"],
ttl: 300,
keyParts: ["error-book", "getErrorBookItems"],
})
// ---------------------------------------------------------------------------
// 查询:错题详情
// ---------------------------------------------------------------------------
export const getErrorBookItemById = cache(async (
export const getErrorBookItemByIdRaw = async (
itemId: string,
studentId: string
): Promise<ErrorBookItemDetail | null> => {
@@ -227,13 +233,19 @@ export const getErrorBookItemById = cache(async (
}))
return { ...base, reviews }
}
export const getErrorBookItemById = cacheFn(getErrorBookItemByIdRaw, {
tags: ["error-book"],
ttl: 300,
keyParts: ["error-book", "getErrorBookItemById"],
})
// ---------------------------------------------------------------------------
// 查询错题本统计SQL 聚合优化)
// ---------------------------------------------------------------------------
export const getErrorBookStats = cache(async (studentId: string): Promise<ErrorBookStats> => {
export const getErrorBookStatsRaw = async (studentId: string): Promise<ErrorBookStats> => {
const now = new Date()
// 使用 SQL GROUP BY 聚合状态计数
@@ -290,6 +302,12 @@ export const getErrorBookStats = cache(async (studentId: string): Promise<ErrorB
dueReviewCount,
masteredRate: totalCount > 0 ? masteredCount / totalCount : 0,
}
}
export const getErrorBookStats = cacheFn(getErrorBookStatsRaw, {
tags: ["error-book"],
ttl: 300,
keyParts: ["error-book", "getErrorBookStats"],
})
// ---------------------------------------------------------------------------

View File

@@ -1,11 +1,11 @@
"use client"
import * as React from "react"
import { useVirtualizer } from "@tanstack/react-virtual"
import {
flexRender,
getCoreRowModel,
useReactTable,
getPaginationRowModel,
SortingState,
getSortedRowModel,
getFilteredRowModel,
@@ -21,11 +21,14 @@ import {
TableHeader,
TableRow,
} from "@/shared/components/ui/table"
import { Button } from "@/shared/components/ui/button"
import { ChevronLeft, ChevronRight } from "lucide-react"
import { Exam } from "../types"
import { createExamColumns } from "./exam-columns"
/** 虚拟滚动每行的预估高度(px),实际高度由 measureElement 动态测量 */
const ROW_ESTIMATE_PX = 80
/** 虚拟滚动容器最大高度(px) */
const SCROLL_CONTAINER_MAX_HEIGHT_PX = 600
interface DataTableProps {
data: Exam[]
}
@@ -34,6 +37,7 @@ export function ExamDataTable({ data }: DataTableProps) {
const t = useTranslations("examHomework")
const [sorting, setSorting] = React.useState<SortingState>([])
const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({})
const parentRef = React.useRef<HTMLDivElement>(null)
const columns = React.useMemo(
() => createExamColumns((key, params) => t(key, params as Record<string, string | number | Date> | undefined)),
@@ -44,7 +48,6 @@ export function ExamDataTable({ data }: DataTableProps) {
data,
columns,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
onSortingChange: setSorting,
getSortedRowModel: getSortedRowModel(),
onRowSelectionChange: setRowSelection,
@@ -55,13 +58,34 @@ export function ExamDataTable({ data }: DataTableProps) {
},
})
const { rows } = table.getRowModel()
const virtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => parentRef.current,
estimateSize: () => ROW_ESTIMATE_PX,
overscan: 5,
})
const virtualItems = virtualizer.getVirtualItems()
const totalSize = virtualizer.getTotalSize()
const paddingTop = virtualItems.length > 0 ? virtualItems[0].start : 0
const paddingBottom =
virtualItems.length > 0
? totalSize - virtualItems[virtualItems.length - 1].end
: 0
return (
<div className="space-y-4">
<div className="rounded-md border">
<div
ref={parentRef}
className="overflow-auto rounded-md border"
style={{ maxHeight: SCROLL_CONTAINER_MAX_HEIGHT_PX }}
>
<Table>
<TableHeader className="bg-muted/40">
<TableHeader className="sticky top-0 z-10 bg-card">
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
<TableRow key={headerGroup.id} className="hover:bg-transparent">
{headerGroup.headers.map((header) => {
return (
<TableHead key={header.id}>
@@ -75,16 +99,42 @@ export function ExamDataTable({ data }: DataTableProps) {
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id} data-state={row.getIsSelected() && "selected"}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
{rows.length ? (
<>
{paddingTop > 0 && (
<tr aria-hidden="true">
<td
style={{ height: paddingTop, padding: 0, border: 0 }}
colSpan={columns.length}
/>
</tr>
)}
{virtualItems.map((virtualItem) => {
const row = rows[virtualItem.index]
return (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
data-index={virtualItem.index}
ref={virtualizer.measureElement}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
)
})}
{paddingBottom > 0 && (
<tr aria-hidden="true">
<td
style={{ height: paddingBottom, padding: 0, border: 0 }}
colSpan={columns.length}
/>
</tr>
)}
</>
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center">
@@ -99,34 +149,6 @@ export function ExamDataTable({ data }: DataTableProps) {
<div className="flex-1 text-sm text-muted-foreground">
{table.getFilteredSelectedRowModel().rows.length} {t("common.of")} {table.getFilteredRowModel().rows.length} {t("common.rows")} {t("common.selected")}.
</div>
<div className="flex items-center space-x-6 lg:space-x-8">
<div className="flex items-center space-x-2">
<p className="text-sm font-medium">{t("common.page")}</p>
<span className="text-sm font-medium">
{table.getState().pagination.pageIndex + 1} {t("common.of")} {table.getPageCount()}
</span>
</div>
<div className="flex items-center space-x-2">
<Button
variant="outline"
className="h-8 w-8 p-0"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<span className="sr-only">{t("common.page")}</span>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
className="h-8 w-8 p-0"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
<span className="sr-only">{t("common.page")}</span>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
</div>
</div>
)

View File

@@ -4,6 +4,7 @@ import { and, eq } from "drizzle-orm"
import { db } from "@/shared/db"
import { examQuestions, examSubmissions, exams, submissionAnswers } from "@/shared/db/schema"
import { cacheFn } from "@/shared/lib/cache"
/**
* 错题采集所需的答案数据(单题)
@@ -38,7 +39,7 @@ export type ExamSubmissionDataForErrorCollection = {
* @param studentId 学生 ID用于校验提交归属
* @returns 提交数据;若提交不存在或 studentId 不匹配则返回 null
*/
export async function getExamSubmissionDataForErrorCollection(
export async function getExamSubmissionDataForErrorCollectionRaw(
submissionId: string,
studentId: string,
): Promise<ExamSubmissionDataForErrorCollection | null> {
@@ -92,3 +93,9 @@ export async function getExamSubmissionDataForErrorCollection(
answers: mappedAnswers,
}
}
export const getExamSubmissionDataForErrorCollection = cacheFn(getExamSubmissionDataForErrorCollectionRaw, {
tags: ["exams"],
ttl: 300,
keyParts: ["exams", "submissions", "for-error-collection"],
})

View File

@@ -1,7 +1,7 @@
import { db } from "@/shared/db"
import { exams, examQuestions } from "@/shared/db/schema"
import { eq, desc, like, and, or, inArray } from "drizzle-orm"
import { cache } from "react"
import { cacheFn } from "@/shared/lib/cache"
import { createId } from "@paralleldrive/cuid2"
import { createQuestionWithRelations } from "@/modules/questions/data-access"
import { getClassGradeIdsByClassIds } from "@/modules/classes/data-access"
@@ -62,7 +62,7 @@ const toExamDifficulty = (n: number | undefined): ExamDifficulty => {
}
export const getExams = cache(async (params: GetExamsParams & { scope: DataScope }) => {
export const getExamsRaw = async (params: GetExamsParams & { scope: DataScope }) => {
const conditions = []
if (params.q) {
@@ -145,9 +145,15 @@ export const getExams = cache(async (params: GetExamsParams & { scope: DataScope
}
return result
}
export const getExams = cacheFn(getExamsRaw, {
tags: ["exams"],
ttl: 300,
keyParts: ["exams", "getExams"],
})
export const getExamById = cache(async (id: string, scope?: DataScope) => {
export const getExamByIdRaw = async (id: string, scope?: DataScope) => {
const exam = await db.query.exams.findFirst({
where: eq(exams.id, id),
with: {
@@ -211,6 +217,12 @@ export const getExamById = cache(async (id: string, scope?: DataScope) => {
order: eqRel.order ?? 0,
})),
}
}
export const getExamById = cacheFn(getExamByIdRaw, {
tags: ["exams"],
ttl: 300,
keyParts: ["exams", "getExamById"],
})
export const omitScheduledAtFromDescription = (description: string | null): string => {

View File

@@ -0,0 +1,334 @@
"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;
}
export 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 等方法供父组件调用。
*/
export const ExamRichEditorInner = forwardRef<ExamRichEditorHandle, ExamRichEditorProps>(
function ExamRichEditorInner(
{
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],
);
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);
},
});
// 当 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>
);
},
);

View File

@@ -0,0 +1,26 @@
/**
* 编辑器纯类型入口。
*
* 供 Server Component / Server Action / data-access 等不需要 ExamRichEditor
* 运行时实例的消费者引用,确保 TypeScript 不会拉入 @tiptap/* 运行时代码。
*
* 仅 re-export 类型与运行时安全的类型守卫/转换器(不依赖 tiptap 副作用)。
*/
export type {
RichQuestionType,
StandaloneQuestionType,
RichQuestionContent,
EditorQuestion,
EditorStructureNodeType,
EditorStructureNode,
EditorDoc,
EditorJSONContent,
} from "./exam-rich-editor-types"
export {
isRichQuestionType,
isStandaloneQuestionType,
toRichQuestionType,
toStandaloneQuestionType,
} from "./exam-rich-editor-types"

View File

@@ -1,6 +1,6 @@
import "server-only"
import { cache } from "react"
import { cacheFn } from "@/shared/lib/cache"
import { getTranslations } from "next-intl/server"
import { db } from "@/shared/db"
@@ -60,7 +60,7 @@ export interface ExamAnalyticsSummary {
* 对标智学网考试分析功能,聚合该考试所有作业的已批改提交数据,
* 计算:平均分、及格率、分数段分布、逐题错误率与难度。
*/
export const getExamAnalytics = cache(async (examId: string): Promise<ExamAnalyticsSummary | null> => {
export const getExamAnalyticsRaw = async (examId: string): Promise<ExamAnalyticsSummary | null> => {
const t = await getTranslations("examHomework")
const exam = await db.query.exams.findFirst({
where: eq(exams.id, examId),
@@ -157,4 +157,10 @@ export const getExamAnalytics = cache(async (examId: string): Promise<ExamAnalyt
scoreDistribution,
questions,
}
}
export const getExamAnalytics = cacheFn(getExamAnalyticsRaw, {
tags: ["exams"],
ttl: 300,
keyParts: ["exams", "getExamAnalytics"],
})

View File

@@ -1,7 +1,7 @@
"use client"
import type { JSX } from "react"
import { useState } from "react"
import { useState, useOptimistic, useTransition } from "react"
import { toast } from "sonner"
import { useRouter } from "next/navigation"
import { useTranslations } from "next-intl"
@@ -60,7 +60,15 @@ export function GradeRecordList({ records }: { records: GradeRecordListItem[] })
const [deleteId, setDeleteId] = useState<string | null>(null)
const [isDeleting, setIsDeleting] = useState(false)
const [editTarget, setEditTarget] = useState<EditableFields | null>(null)
const [isSaving, setIsSaving] = useState(false)
// Phase 4.5: 编辑保存改用 useOptimistic + useTransition,在列表中即时显示乐观记录
const [optimisticRecords, addOptimisticRecord] = useOptimistic<
GradeRecordListItem[],
GradeRecordListItem
>(
records,
(state, updated) => state.map((r) => (r.id === updated.id ? updated : r)),
)
const [isSaving, startSaveTransition] = useTransition()
// v3-P3-2: 批量选择与删除
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false)
@@ -147,9 +155,21 @@ export function GradeRecordList({ records }: { records: GradeRecordListItem[] })
})
}
const handleEditSave = async (): Promise<void> => {
const handleEditSave = (): void => {
if (!editTarget) return
setIsSaving(true)
const existing = records.find((r) => r.id === editTarget.id)
if (!existing) return
// Phase 4.5: 构造乐观记录,在 transition 内 addOptimistic + await action + refresh
const optimisticRecord: GradeRecordListItem = {
...existing,
title: editTarget.title,
score: Number(editTarget.score) || 0,
fullScore: Number(editTarget.fullScore) || 0,
type: editTarget.type,
semester: editTarget.semester,
remark: editTarget.remark,
}
const targetId = editTarget.id
const formData = new FormData()
formData.set("title", editTarget.title)
formData.set("score", editTarget.score)
@@ -158,20 +178,23 @@ export function GradeRecordList({ records }: { records: GradeRecordListItem[] })
formData.set("semester", editTarget.semester)
formData.set("remark", editTarget.remark)
const result = await safeActionCall(
() => updateGradeRecordAction(editTarget.id, null, formData),
{
onError: () => toast.error(t("edit.failed")),
onFinally: () => setIsSaving(false),
startSaveTransition(async () => {
addOptimisticRecord(optimisticRecord)
const result = await safeActionCall(
() => updateGradeRecordAction(targetId, null, formData),
{
onError: () => toast.error(t("edit.failed")),
}
)
if (result?.success) {
toast.success(t("edit.success"))
setEditTarget(null)
// 同步数据源,让 useOptimistic 回滚到最新服务端值
await router.refresh()
} else if (result) {
toast.error(result.message || t("edit.failed"))
}
)
if (result?.success) {
toast.success(t("edit.success"))
setEditTarget(null)
router.refresh()
} else if (result) {
toast.error(result.message || t("edit.failed"))
}
})
}
if (records.length === 0) {
@@ -238,7 +261,7 @@ export function GradeRecordList({ records }: { records: GradeRecordListItem[] })
</TableRow>
</TableHeader>
<TableBody>
{records.map((r) => (
{optimisticRecords.map((r) => (
<TableRow key={r.id}>
<TableCell>
<Checkbox
@@ -295,7 +318,7 @@ export function GradeRecordList({ records }: { records: GradeRecordListItem[] })
role="list"
aria-label={t("list.caption")}
>
{records.map((r) => (
{optimisticRecords.map((r) => (
<div
key={r.id}
className="rounded-md border border-border/60 bg-background p-3 shadow-sm"

View File

@@ -1,6 +1,6 @@
import "server-only"
import { cache } from "react"
import { cacheFn } from "@/shared/lib/cache"
import { and, asc, eq, inArray, isNotNull, ne } from "drizzle-orm"
import { db } from "@/shared/db"
@@ -52,8 +52,7 @@ export interface GradeTrendParams {
currentUserId?: string
}
export const getGradeTrend = cache(
async (params: GradeTrendParams): Promise<GradeTrendResult | null> => {
export const getGradeTrendRaw = async (params: GradeTrendParams): Promise<GradeTrendResult | null> => {
const conditions = [eq(gradeRecords.classId, params.classId)]
if (params.subjectId) conditions.push(eq(gradeRecords.subjectId, params.subjectId))
if (params.studentId) conditions.push(eq(gradeRecords.studentId, params.studentId))
@@ -101,7 +100,12 @@ export const getGradeTrend = cache(
averageScore: avg,
}
}
)
export const getGradeTrend = cacheFn(getGradeTrendRaw, {
tags: ["grades"],
ttl: 300,
keyParts: ["grades", "getGradeTrend"],
})
export interface ClassComparisonParams {
gradeId: string
@@ -111,8 +115,7 @@ export interface ClassComparisonParams {
scope: DataScope
}
export const getClassComparison = cache(
async (params: ClassComparisonParams): Promise<ClassComparisonItem[]> => {
export const getClassComparisonRaw = async (params: ClassComparisonParams): Promise<ClassComparisonItem[]> => {
const classRows = await getClassesByGradeId(params.gradeId)
if (classRows.length === 0) return []
@@ -171,7 +174,12 @@ export const getClassComparison = cache(
return result
}
)
export const getClassComparison = cacheFn(getClassComparisonRaw, {
tags: ["grades"],
ttl: 300,
keyParts: ["grades", "getClassComparison"],
})
/**
* P3-5: 获取班级对比数据 + 班级间统计显著性分析结果。
@@ -189,8 +197,7 @@ export const getClassComparison = cache(
*
* 该函数复用 getClassComparison 的 cache参数一致不会触发额外 DB 查询。
*/
export const getClassComparisonWithSignificance = cache(
async (params: ClassComparisonParams): Promise<ClassComparisonResult> => {
export const getClassComparisonWithSignificanceRaw = async (params: ClassComparisonParams): Promise<ClassComparisonResult> => {
const items = await getClassComparison(params)
if (items.length < 2) {
@@ -253,7 +260,12 @@ export const getClassComparisonWithSignificance = cache(
return { items, significance }
}
)
export const getClassComparisonWithSignificance = cacheFn(getClassComparisonWithSignificanceRaw, {
tags: ["grades"],
ttl: 300,
keyParts: ["grades", "getClassComparisonWithSignificance"],
})
/**
* P3-9: 获取学生所在班级的成绩分布 + 学生本人位置标注(隐私保护视图)。
@@ -271,8 +283,7 @@ export const getClassComparisonWithSignificance = cache(
* @param studentId - 当前学生 ID
* @param scope - 当前用户的数据范围(学生为 class_members
*/
export const getStudentPositionInClassDistribution = cache(
async (
export const getStudentPositionInClassDistributionRaw = async (
studentId: string,
scope: DataScope
): Promise<GradeDistributionWithPosition | null> => {
@@ -335,7 +346,12 @@ export const getStudentPositionInClassDistribution = cache(
studentBucketIndex: bucketIndex,
}
}
)
export const getStudentPositionInClassDistribution = cacheFn(getStudentPositionInClassDistributionRaw, {
tags: ["grades"],
ttl: 300,
keyParts: ["grades", "getStudentPositionInClassDistribution"],
})
/**
* P3-4: 获取学生纵向成长档案(跨学年/学期聚合)。
@@ -357,8 +373,7 @@ export const getStudentPositionInClassDistribution = cache(
* @param studentId - 学生 ID
* @param scope - 当前用户的数据范围
*/
export const getStudentGrowthArchive = cache(
async (
export const getStudentGrowthArchiveRaw = async (
studentId: string,
scope: DataScope
): Promise<StudentGrowthArchiveResult | null> => {
@@ -468,7 +483,12 @@ export const getStudentGrowthArchive = cache(
growthDelta,
}
}
)
export const getStudentGrowthArchive = cacheFn(getStudentGrowthArchiveRaw, {
tags: ["grades"],
ttl: 300,
keyParts: ["grades", "getStudentGrowthArchive"],
})
export interface SubjectComparisonParams {
classId: string
@@ -477,8 +497,7 @@ export interface SubjectComparisonParams {
scope: DataScope
}
export const getSubjectComparison = cache(
async (params: SubjectComparisonParams): Promise<SubjectComparisonItem[]> => {
export const getSubjectComparisonRaw = async (params: SubjectComparisonParams): Promise<SubjectComparisonItem[]> => {
const scopeFilter = buildScopeClassFilter(params.scope)
const conditions = [eq(gradeRecords.classId, params.classId)]
if (params.examId) conditions.push(eq(gradeRecords.examId, params.examId))
@@ -524,7 +543,12 @@ export const getSubjectComparison = cache(
return result.sort((a, b) => b.averageScore - a.averageScore)
}
)
export const getSubjectComparison = cacheFn(getSubjectComparisonRaw, {
tags: ["grades"],
ttl: 300,
keyParts: ["grades", "getSubjectComparison"],
})
export interface GradeDistributionParams {
classId: string
@@ -535,8 +559,7 @@ export interface GradeDistributionParams {
currentUserId?: string
}
export const getGradeDistribution = cache(
async (params: GradeDistributionParams): Promise<GradeDistributionResult> => {
export const getGradeDistributionRaw = async (params: GradeDistributionParams): Promise<GradeDistributionResult> => {
const conditions = [eq(gradeRecords.classId, params.classId)]
if (params.subjectId) conditions.push(eq(gradeRecords.subjectId, params.subjectId))
if (params.examId) conditions.push(eq(gradeRecords.examId, params.examId))
@@ -556,14 +579,18 @@ export const getGradeDistribution = cache(
return computeGradeDistribution(rows)
}
)
export const getGradeDistribution = cacheFn(getGradeDistributionRaw, {
tags: ["grades"],
ttl: 300,
keyParts: ["grades", "getGradeDistribution"],
})
/**
* v3-P2-7: 获取指定班级/科目下有成绩记录的考试列表,用于分析页考试筛选下拉框。
* 仅返回 examId 非空且去重后的考试选项。
*/
export const getExamOptionsForGrades = cache(
async (params: {
export const getExamOptionsForGradesRaw = async (params: {
classId: string
subjectId?: string
scope: DataScope
@@ -596,15 +623,19 @@ export const getExamOptionsForGrades = cache(
}
return result
}
)
export const getExamOptionsForGrades = cacheFn(getExamOptionsForGradesRaw, {
tags: ["grades"],
ttl: 300,
keyParts: ["grades", "getExamOptionsForGrades"],
})
/**
* v3-P2-9: 获取全校各年级成绩汇总(管理员视图)。
* 按年级聚合平均分、及格率、优秀率、学生数、班级数。
* 仅限管理员scope.type === "all")调用。
*/
export const getSchoolWideGradeSummary = cache(
async (scope: DataScope): Promise<SchoolWideGradeSummary> => {
export const getSchoolWideGradeSummaryRaw = async (scope: DataScope): Promise<SchoolWideGradeSummary> => {
// 管理员权限校验:仅 all scope 可调用
if (scope.type !== "all") {
return { grades: [], totals: { gradeCount: 0, classCount: 0, studentCount: 0, recordCount: 0, averageScore: 0, passRate: 0, excellentRate: 0 } }
@@ -701,7 +732,12 @@ export const getSchoolWideGradeSummary = cache(
},
}
}
)
export const getSchoolWideGradeSummary = cacheFn(getSchoolWideGradeSummaryRaw, {
tags: ["grades"],
ttl: 300,
keyParts: ["grades", "getSchoolWideGradeSummary"],
})
/**
* 年级仪表盘 - 维度1获取年级整体 + 按班级拆分的成绩分布。
@@ -716,8 +752,7 @@ export interface GradeDistributionByGradeParams {
scope: DataScope
}
export const getGradeDistributionByGradeId = cache(
async (params: GradeDistributionByGradeParams): Promise<GradeDistributionByGradeResult> => {
export const getGradeDistributionByGradeIdRaw = async (params: GradeDistributionByGradeParams): Promise<GradeDistributionByGradeResult> => {
const classRows = await getClassesByGradeId(params.gradeId)
if (classRows.length === 0) {
@@ -788,4 +823,9 @@ export const getGradeDistributionByGradeId = cache(
return { overall, stats, byClass }
}
)
export const getGradeDistributionByGradeId = cacheFn(getGradeDistributionByGradeIdRaw, {
tags: ["grades"],
ttl: 300,
keyParts: ["grades", "getGradeDistributionByGradeId"],
})

View File

@@ -1,6 +1,6 @@
import "server-only"
import { cache } from "react"
import { cacheFn } from "@/shared/lib/cache"
import { createId } from "@paralleldrive/cuid2"
import { and, desc, eq } from "drizzle-orm"
@@ -99,8 +99,7 @@ export async function createGradeAppeal(params: {
/**
* 获取学生自己的申诉列表(按创建时间倒序)。
*/
export const getStudentAppeals = cache(
async (studentId: string): Promise<GradeAppeal[]> => {
export const getStudentAppealsRaw = async (studentId: string): Promise<GradeAppeal[]> => {
const rows = await db
.select()
.from(gradeAppeals)
@@ -109,14 +108,18 @@ export const getStudentAppeals = cache(
return rows.map(serializeAppeal)
}
)
export const getStudentAppeals = cacheFn(getStudentAppealsRaw, {
tags: ["grades"],
ttl: 300,
keyParts: ["grades", "getStudentAppeals"],
})
/**
* 获取教师待复核的申诉列表(按创建时间正序,先到先审)。
* 仅返回 pending 状态的申诉。
*/
export const getPendingAppealsForReview = cache(
async (classIds: string[]): Promise<GradeAppealWithRecord[]> => {
export const getPendingAppealsForReviewRaw = async (classIds: string[]): Promise<GradeAppealWithRecord[]> => {
if (classIds.length === 0) return []
const rows = await db
@@ -146,14 +149,18 @@ export const getPendingAppealsForReview = cache(
reviewerName: null,
}))
}
)
export const getPendingAppealsForReview = cacheFn(getPendingAppealsForReviewRaw, {
tags: ["grades"],
ttl: 300,
keyParts: ["grades", "getPendingAppealsForReview"],
})
/**
* 获取单个申诉详情(含成绩信息)。
* 调用方需校验:学生只能看自己的申诉,教师只能看自己班级的申诉。
*/
export const getAppealById = cache(
async (appealId: string): Promise<GradeAppealWithRecord | null> => {
export const getAppealByIdRaw = async (appealId: string): Promise<GradeAppealWithRecord | null> => {
const rows = await db
.select({
appeal: gradeAppeals,
@@ -175,7 +182,12 @@ export const getAppealById = cache(
reviewerName: null,
}
}
)
export const getAppealById = cacheFn(getAppealByIdRaw, {
tags: ["grades"],
ttl: 300,
keyParts: ["grades", "getAppealById"],
})
/**
* 教师复核申诉状态机转换pending → approved/rejected

View File

@@ -1,6 +1,6 @@
import "server-only"
import { cache } from "react"
import { cacheFn } from "@/shared/lib/cache"
import { createId } from "@paralleldrive/cuid2"
import { and, eq, isNotNull, isNull, or, lt } from "drizzle-orm"
@@ -74,8 +74,7 @@ export async function saveGradeDraft(params: {
* 获取成绩录入草稿。
* 超过 24 小时的草稿视为过期,返回 null。
*/
export const getGradeDraft = cache(
async (params: {
export const getGradeDraftRaw = async (params: {
userId: string
classId: string
subjectId: string
@@ -104,7 +103,12 @@ export const getGradeDraft = cache(
return content
}
)
export const getGradeDraft = cacheFn(getGradeDraftRaw, {
tags: ["grades"],
ttl: 300,
keyParts: ["grades", "getGradeDraft"],
})
/**
* 删除成绩录入草稿(提交成功后调用)。

View File

@@ -1,6 +1,6 @@
import "server-only"
import { cache } from "react"
import { cacheFn } from "@/shared/lib/cache"
import { and, asc, eq } from "drizzle-orm"
import { db } from "@/shared/db"
@@ -31,8 +31,7 @@ export type { ClassAverageTrendPoint, ClassAverageTrendResult }
*
* P3 修复:添加 scope 参数,对 class_taught scope 校验学生归属
*/
export const getRankingTrend = cache(
async (
export const getRankingTrendRaw = async (
studentId: string,
subjectId?: string,
semester?: "1" | "2",
@@ -103,7 +102,12 @@ export const getRankingTrend = cache(
points,
}
}
)
export const getRankingTrend = cacheFn(getRankingTrendRaw, {
tags: ["grades"],
ttl: 300,
keyParts: ["grades", "getRankingTrend"],
})
/**
* v3-P2-2: 获取班级平均成绩趋势(按 assessment title 分组)。
@@ -117,8 +121,7 @@ export const getRankingTrend = cache(
* @param semester 可选学期过滤
* @param scope 数据权限范围
*/
export const getClassAverageTrend = cache(
async (
export const getClassAverageTrendRaw = async (
studentId: string,
subjectId?: string,
semester?: "1" | "2",
@@ -187,4 +190,9 @@ export const getClassAverageTrend = cache(
points,
}
}
)
export const getClassAverageTrend = cacheFn(getClassAverageTrendRaw, {
tags: ["grades"],
ttl: 300,
keyParts: ["grades", "getClassAverageTrend"],
})

View File

@@ -1,6 +1,6 @@
import "server-only"
import { cache } from "react"
import { cacheFn } from "@/shared/lib/cache"
import { and, eq } from "drizzle-orm"
import { db } from "@/shared/db"
@@ -162,8 +162,7 @@ function toNum(v: unknown): number {
* - class_members scope 仅允许查看自己的报告卡
* - children scope 仅允许查看子女的报告卡
*/
export const getReportCardData = cache(
async (
export const getReportCardDataRaw = async (
studentId: string,
scope: DataScope,
query?: ReportCardQuery
@@ -377,4 +376,9 @@ export const getReportCardData = cache(
generatedAt: new Date().toISOString(),
}
}
)
export const getReportCardData = cacheFn(getReportCardDataRaw, {
tags: ["grades"],
ttl: 300,
keyParts: ["grades", "getReportCardData"],
})

View File

@@ -1,6 +1,6 @@
import "server-only"
import { cache } from "react"
import { cacheFn } from "@/shared/lib/cache"
import { and, desc, eq, inArray, sql } from "drizzle-orm"
import { db } from "@/shared/db"
@@ -68,8 +68,7 @@ export type HomeworkAssignmentSubjectRow = {
/**
* Returns assignment IDs that target any of the given students.
*/
export const getAssignmentIdsForStudents = cache(
async (studentIds: string[]): Promise<string[]> => {
export const getAssignmentIdsForStudentsRaw = async (studentIds: string[]): Promise<string[]> => {
if (studentIds.length === 0) return []
const rows = await db
.selectDistinct({ assignmentId: homeworkAssignmentTargets.assignmentId })
@@ -77,7 +76,12 @@ export const getAssignmentIdsForStudents = cache(
.where(inArray(homeworkAssignmentTargets.studentId, studentIds))
return rows.map((r) => r.assignmentId)
}
)
export const getAssignmentIdsForStudents = cacheFn(getAssignmentIdsForStudentsRaw, {
tags: ["homework"],
ttl: 300,
keyParts: ["homework", "getAssignmentIdsForStudents"],
})
/**
* Returns homework assignments joined with subject info (via source exam),
@@ -89,8 +93,7 @@ export const getAssignmentIdsForStudents = cache(
* 3. 通过 school data-access 批量获取 subjectId→name 映射
* 4. 在内存中合并与过滤
*/
export const getHomeworkAssignmentsWithSubject = cache(
async (params: {
export const getHomeworkAssignmentsWithSubjectRaw = async (params: {
assignmentIds: string[]
subjectIdFilter?: string[]
limit?: number
@@ -151,14 +154,18 @@ export const getHomeworkAssignmentsWithSubject = cache(
return item.subjectId !== null && subjectIdFilterSet.has(item.subjectId)
})
}
)
export const getHomeworkAssignmentsWithSubject = cacheFn(getHomeworkAssignmentsWithSubjectRaw, {
tags: ["homework"],
ttl: 300,
keyParts: ["homework", "getHomeworkAssignmentsWithSubject"],
})
/**
* Returns homework assignments (without subject info) by IDs.
* Used by grade-level homework insights where subject filtering is not needed.
*/
export const getHomeworkAssignmentsByIds = cache(
async (params: { assignmentIds: string[]; limit?: number }): Promise<HomeworkAssignmentBrief[]> => {
export const getHomeworkAssignmentsByIdsRaw = async (params: { assignmentIds: string[]; limit?: number }): Promise<HomeworkAssignmentBrief[]> => {
if (params.assignmentIds.length === 0) return []
const limit = typeof params.limit === "number" && params.limit > 0 ? params.limit : 50
const rows = await db.query.homeworkAssignments.findMany({
@@ -175,7 +182,12 @@ export const getHomeworkAssignmentsByIds = cache(
})
return rows
}
)
export const getHomeworkAssignmentsByIds = cacheFn(getHomeworkAssignmentsByIdsRaw, {
tags: ["homework"],
ttl: 300,
keyParts: ["homework", "getHomeworkAssignmentsByIds"],
})
/**
* Returns max score per assignment (sum of question scores).
@@ -186,8 +198,7 @@ export { getAssignmentMaxScoreById } from "./data-access"
/**
* Returns target counts per assignment for the given students.
*/
export const getAssignmentTargetCounts = cache(
async (params: { assignmentIds: string[]; studentIds: string[] }): Promise<Map<string, number>> => {
export const getAssignmentTargetCountsRaw = async (params: { assignmentIds: string[]; studentIds: string[] }): Promise<Map<string, number>> => {
if (params.assignmentIds.length === 0 || params.studentIds.length === 0) return new Map()
const rows = await db
.select({
@@ -206,15 +217,19 @@ export const getAssignmentTargetCounts = cache(
for (const r of rows) map.set(r.assignmentId, Number(r.targetCount ?? 0))
return map
}
)
export const getAssignmentTargetCounts = cacheFn(getAssignmentTargetCountsRaw, {
tags: ["homework"],
ttl: 300,
keyParts: ["homework", "getAssignmentTargetCounts"],
})
/**
* Returns homework submissions for given assignments and students,
* ordered by createdAt desc so callers can pick the latest per
* (assignmentId, studentId) pair.
*/
export const getHomeworkSubmissionsForStudents = cache(
async (params: { assignmentIds: string[]; studentIds: string[] }): Promise<HomeworkSubmissionRecord[]> => {
export const getHomeworkSubmissionsForStudentsRaw = async (params: { assignmentIds: string[]; studentIds: string[] }): Promise<HomeworkSubmissionRecord[]> => {
if (params.assignmentIds.length === 0 || params.studentIds.length === 0) return []
const rows = await db.query.homeworkSubmissions.findMany({
where: and(
@@ -233,7 +248,12 @@ export const getHomeworkSubmissionsForStudents = cache(
})
return rows
}
)
export const getHomeworkSubmissionsForStudents = cacheFn(getHomeworkSubmissionsForStudentsRaw, {
tags: ["homework"],
ttl: 300,
keyParts: ["homework", "getHomeworkSubmissionsForStudents"],
})
/**
* Returns published homework assignments joined with subject info (via source exam).
@@ -241,8 +261,7 @@ export const getHomeworkSubmissionsForStudents = cache(
*
* P1-1 修复:不再 JOIN exams/subjects 表,改用跨模块 data-access。
*/
export const getPublishedHomeworkAssignmentsWithSubject = cache(
async (params: { assignmentIds: string[] }): Promise<HomeworkAssignmentSubjectRow[]> => {
export const getPublishedHomeworkAssignmentsWithSubjectRaw = async (params: { assignmentIds: string[] }): Promise<HomeworkAssignmentSubjectRow[]> => {
if (params.assignmentIds.length === 0) return []
// Step 1: 查 published homeworkAssignments含 sourceExamId
@@ -287,15 +306,19 @@ export const getPublishedHomeworkAssignmentsWithSubject = cache(
}
})
}
)
export const getPublishedHomeworkAssignmentsWithSubject = cacheFn(getPublishedHomeworkAssignmentsWithSubjectRaw, {
tags: ["homework"],
ttl: 300,
keyParts: ["homework", "getPublishedHomeworkAssignmentsWithSubject"],
})
/**
* Returns homework submissions for the given assignments,
* ordered by createdAt desc so callers can pick the latest per
* (assignmentId, studentId) pair.
*/
export const getHomeworkSubmissionsForAssignments = cache(
async (assignmentIds: string[]): Promise<HomeworkSubmissionScoreRecord[]> => {
export const getHomeworkSubmissionsForAssignmentsRaw = async (assignmentIds: string[]): Promise<HomeworkSubmissionScoreRecord[]> => {
if (assignmentIds.length === 0) return []
const rows = await db
.select({
@@ -309,4 +332,9 @@ export const getHomeworkSubmissionsForAssignments = cache(
.orderBy(desc(homeworkSubmissions.createdAt))
return rows
}
)
export const getHomeworkSubmissionsForAssignments = cacheFn(getHomeworkSubmissionsForAssignmentsRaw, {
tags: ["homework"],
ttl: 300,
keyParts: ["homework", "getHomeworkSubmissionsForAssignments"],
})

View File

@@ -9,6 +9,7 @@ import {
homeworkAssignments,
homeworkSubmissions,
} from "@/shared/db/schema"
import { cacheFn } from "@/shared/lib/cache"
import { getExamSubjectIdMap } from "@/modules/exams/data-access"
/**
@@ -44,7 +45,7 @@ export type HomeworkSubmissionDataForErrorCollection = {
* @param submissionId 作业提交 ID
* @returns 提交数据;若提交不存在则返回 null
*/
export async function getHomeworkSubmissionDataForErrorCollection(
export async function getHomeworkSubmissionDataForErrorCollectionRaw(
submissionId: string,
): Promise<HomeworkSubmissionDataForErrorCollection | null> {
const submission = await db.query.homeworkSubmissions.findFirst({
@@ -102,6 +103,12 @@ export async function getHomeworkSubmissionDataForErrorCollection(
}
}
export const getHomeworkSubmissionDataForErrorCollection = cacheFn(getHomeworkSubmissionDataForErrorCollectionRaw, {
tags: ["homework"],
ttl: 300,
keyParts: ["homework", "submissions", "for-error-collection"],
})
/**
* 跨模块接口:获取作业提交的答案数据(供 diagnostic 模块更新掌握度使用)。
*
@@ -111,7 +118,7 @@ export async function getHomeworkSubmissionDataForErrorCollection(
* @param submissionId 作业提交 ID
* @returns `{ studentId, answers: Array<{ questionId, score }> }`;若提交不存在则返回 null
*/
export async function getHomeworkSubmissionWithAnswersForMastery(
export async function getHomeworkSubmissionWithAnswersForMasteryRaw(
submissionId: string,
): Promise<{ studentId: string; answers: Array<{ questionId: string; score: number | null }> } | null> {
const submission = await db.query.homeworkSubmissions.findFirst({
@@ -134,3 +141,9 @@ export async function getHomeworkSubmissionWithAnswersForMastery(
answers,
}
}
export const getHomeworkSubmissionWithAnswersForMastery = cacheFn(getHomeworkSubmissionWithAnswersForMasteryRaw, {
tags: ["homework"],
ttl: 300,
keyParts: ["homework", "submissions", "for-mastery"],
})

View File

@@ -1,6 +1,6 @@
import "server-only"
import { cache } from "react"
import { cacheFn } from "@/shared/lib/cache"
import { and, count, eq, inArray, sql } from "drizzle-orm"
import { db } from "@/shared/db"
@@ -15,7 +15,7 @@ import {
*
* 供 exams 模块的考试分析仪表盘调用,获取该考试派生的所有作业及其提交统计。
*/
export const getHomeworkAssignmentsByExamId = cache(async (examId: string): Promise<Array<{
export const getHomeworkAssignmentsByExamIdRaw = async (examId: string): Promise<Array<{
id: string
title: string
status: string | null
@@ -74,6 +74,12 @@ export const getHomeworkAssignmentsByExamId = cache(async (examId: string): Prom
gradedCount: gradedMap.get(a.id) ?? 0,
dueAt: a.dueAt ? a.dueAt.toISOString() : null,
}))
}
export const getHomeworkAssignmentsByExamId = cacheFn(getHomeworkAssignmentsByExamIdRaw, {
tags: ["homework"],
ttl: 300,
keyParts: ["homework", "getHomeworkAssignmentsByExamId"],
})
/**
@@ -81,7 +87,7 @@ export const getHomeworkAssignmentsByExamId = cache(async (examId: string): Prom
*
* 供 exams 模块的考试分析仪表盘调用,获取学生姓名、分数、答案内容用于统计分析。
*/
export const getGradedSubmissionsByExamId = cache(async (examId: string): Promise<Array<{
export const getGradedSubmissionsByExamIdRaw = async (examId: string): Promise<Array<{
submissionId: string
assignmentId: string
studentId: string
@@ -130,4 +136,10 @@ export const getGradedSubmissionsByExamId = cache(async (examId: string): Promis
answerContent: a.answerContent,
})),
}))
}
export const getGradedSubmissionsByExamId = cacheFn(getGradedSubmissionsByExamIdRaw, {
tags: ["homework"],
ttl: 300,
keyParts: ["homework", "getGradedSubmissionsByExamId"],
})

View File

@@ -3,6 +3,7 @@ import "server-only"
import { db } from "@/shared/db"
import { fileAttachments } from "@/shared/db/schema"
import { and, asc, eq } from "drizzle-orm"
import { cacheFn } from "@/shared/lib/cache"
import type { ScanAttachment } from "./types"
@@ -19,7 +20,7 @@ import type { ScanAttachment } from "./types"
*
* @returns `ScanAttachment[]` —— `page` 字段为按顺序生成的页码(从 1 开始)。
*/
export async function getScansBySubmissionId(
export async function getScansBySubmissionIdRaw(
submissionId: string
): Promise<ScanAttachment[]> {
const rows = await db
@@ -47,3 +48,9 @@ export async function getScansBySubmissionId(
page: idx + 1,
}))
}
export const getScansBySubmissionId = cacheFn(getScansBySubmissionIdRaw, {
tags: ["homework"],
ttl: 300,
keyParts: ["homework", "scans", "by-submission"],
})

View File

@@ -1,6 +1,6 @@
import "server-only"
import { cache } from "react"
import { cacheFn } from "@/shared/lib/cache"
import { and, desc, eq, inArray, isNull, lte, or } from "drizzle-orm"
import { db } from "@/shared/db"
@@ -39,7 +39,7 @@ const toStudentProgressStatus = (v: string | null | undefined): StudentHomeworkP
*
* 查找学生最近一次已提交/已批改的 submission返回完整详情含答案。
*/
export const getStudentSubmissionResult = cache(async (
export const getStudentSubmissionResultRaw = async (
assignmentId: string,
studentId: string
): Promise<HomeworkSubmissionDetails | null> => {
@@ -56,6 +56,12 @@ export const getStudentSubmissionResult = cache(async (
if (!latestSubmission) return null
return getHomeworkSubmissionDetails(latestSubmission.id)
}
export const getStudentSubmissionResult = cacheFn(getStudentSubmissionResultRaw, {
tags: ["homework"],
ttl: 300,
keyParts: ["homework", "getStudentSubmissionResult"],
})
/**
@@ -64,7 +70,7 @@ export const getStudentSubmissionResult = cache(async (
* 查找学生所有已批改的、关联到考试的作业提交,
* 返回考试标题、科目、分数、提交时间等。
*/
export const getStudentExamResults = cache(async (studentId: string): Promise<Array<{
export const getStudentExamResultsRaw = async (studentId: string): Promise<Array<{
submissionId: string
examId: string
examTitle: string
@@ -115,9 +121,15 @@ export const getStudentExamResults = cache(async (studentId: string): Promise<Ar
submittedAt: s.submittedAt ? s.submittedAt.toISOString() : null,
status: s.status ?? "graded",
}))
}
export const getStudentExamResults = cacheFn(getStudentExamResultsRaw, {
tags: ["homework"],
ttl: 300,
keyParts: ["homework", "getStudentExamResults"],
})
export const getStudentHomeworkAssignments = cache(async (studentId: string): Promise<StudentHomeworkAssignmentListItem[]> => {
export const getStudentHomeworkAssignmentsRaw = async (studentId: string): Promise<StudentHomeworkAssignmentListItem[]> => {
const now = new Date()
const targetAssignmentIds = db
@@ -198,9 +210,15 @@ export const getStudentHomeworkAssignments = cache(async (studentId: string): Pr
}
return item
})
}
export const getStudentHomeworkAssignments = cacheFn(getStudentHomeworkAssignmentsRaw, {
tags: ["homework"],
ttl: 300,
keyParts: ["homework", "getStudentHomeworkAssignments"],
})
export const getStudentHomeworkTakeData = cache(async (assignmentId: string, studentId: string): Promise<StudentHomeworkTakeData | null> => {
export const getStudentHomeworkTakeDataRaw = async (assignmentId: string, studentId: string): Promise<StudentHomeworkTakeData | null> => {
const target = await db.query.homeworkAssignmentTargets.findFirst({
where: and(eq(homeworkAssignmentTargets.assignmentId, assignmentId), eq(homeworkAssignmentTargets.studentId, studentId)),
})
@@ -321,4 +339,10 @@ export const getStudentHomeworkTakeData = cache(async (assignmentId: string, stu
}
}),
}
}
export const getStudentHomeworkTakeData = cacheFn(getStudentHomeworkTakeDataRaw, {
tags: ["homework"],
ttl: 300,
keyParts: ["homework", "getStudentHomeworkTakeData"],
})

View File

@@ -1,7 +1,7 @@
import "server-only"
import { createId } from "@paralleldrive/cuid2"
import { and, count, eq } from "drizzle-orm"
import { and, count, eq, sql } from "drizzle-orm"
import { db } from "@/shared/db"
import {
@@ -357,26 +357,47 @@ export const gradeHomeworkAnswers = async (
submissionId: string,
answers: Array<{ id: string; score: number; feedback: string | null }>
): Promise<void> => {
if (answers.length === 0) {
// 仍要更新 submission 状态为 graded
await db
.update(homeworkSubmissions)
.set({ score: 0, status: "graded", updatedAt: new Date() })
.where(eq(homeworkSubmissions.id, submissionId))
return
}
const totalScore = answers.reduce((sum, a) => sum + a.score, 0)
const now = new Date()
await db.transaction(async (tx) => {
let totalScore = 0
for (const ans of answers) {
// 关键安全约束WHERE 子句同时匹配 answer.id 和 submissionId
// 防止恶意客户端篡改 answer ID 批改其他 submission 的答案
await tx
.update(homeworkAnswers)
.set({ score: ans.score, feedback: ans.feedback, updatedAt: new Date() })
.where(
and(
eq(homeworkAnswers.id, ans.id),
eq(homeworkAnswers.submissionId, submissionId)
)
)
totalScore += ans.score
}
// 性能优化:用单次 UPDATE + CASE WHEN 替代 N 次串行 UPDATE
// 关键安全约束:WHERE 子句同时匹配 answer.id IN (...) 和 submission_id,
// 防止恶意客户端篡改 answer ID 批改其他 submission 的答案
const scoreCases = sql.join(
answers.map((a) => sql`WHEN ${a.id} THEN ${a.score}`),
sql` `,
)
const feedbackCases = sql.join(
answers.map((a) => sql`WHEN ${a.id} THEN ${a.feedback}`),
sql` `,
)
const answerIdList = sql.join(
answers.map((a) => sql`${a.id}`),
sql`, `,
)
await tx.execute(
sql`UPDATE homework_answers
SET
score = CASE id ${scoreCases} ELSE score END,
feedback = CASE id ${feedbackCases} ELSE feedback END,
updated_at = ${now}
WHERE id IN (${answerIdList}) AND submission_id = ${submissionId}`,
)
await tx
.update(homeworkSubmissions)
.set({ score: totalScore, status: "graded", updatedAt: new Date() })
.set({ score: totalScore, status: "graded", updatedAt: now })
.where(eq(homeworkSubmissions.id, submissionId))
})
}

View File

@@ -1,6 +1,6 @@
import "server-only"
import { cache } from "react"
import { cacheFn } from "@/shared/lib/cache"
import { and, asc, count, desc, eq, gt, inArray, lt, sql } from "drizzle-orm"
import { db } from "@/shared/db"
@@ -65,7 +65,7 @@ export const getAssignmentMaxScoreById = async (assignmentIds: string[]): Promis
return map
}
export const getHomeworkAssignments = cache(async (params?: { creatorId?: string; ids?: string[]; classId?: string; scope?: DataScope }) => {
export const getHomeworkAssignmentsRaw = async (params?: { creatorId?: string; ids?: string[]; classId?: string; scope?: DataScope }): Promise<HomeworkAssignmentListItem[]> => {
const conditions = []
if (params?.creatorId) conditions.push(eq(homeworkAssignments.creatorId, params.creatorId))
@@ -238,9 +238,15 @@ export const getHomeworkAssignments = cache(async (params?: { creatorId?: string
}
return item
})
}
export const getHomeworkAssignments = cacheFn(getHomeworkAssignmentsRaw, {
tags: ["homework"],
ttl: 300,
keyParts: ["homework", "getHomeworkAssignments"],
})
export const getHomeworkAssignmentReviewList = cache(async (params: { creatorId: string; scope?: DataScope }) => {
export const getHomeworkAssignmentReviewListRaw = async (params: { creatorId: string; scope?: DataScope }): Promise<HomeworkAssignmentReviewListItem[]> => {
const creatorId = params.creatorId.trim()
if (!creatorId) return []
@@ -333,9 +339,15 @@ export const getHomeworkAssignmentReviewList = cache(async (params: { creatorId:
}
return item
})
}
export const getHomeworkAssignmentReviewList = cacheFn(getHomeworkAssignmentReviewListRaw, {
tags: ["homework"],
ttl: 300,
keyParts: ["homework", "getHomeworkAssignmentReviewList"],
})
export const getHomeworkSubmissions = cache(async (params?: { assignmentId?: string; classId?: string; creatorId?: string; scope?: DataScope }) => {
export const getHomeworkSubmissionsRaw = async (params?: { assignmentId?: string; classId?: string; creatorId?: string; scope?: DataScope }): Promise<HomeworkSubmissionListItem[]> => {
const conditions = []
if (params?.assignmentId) conditions.push(eq(homeworkSubmissions.assignmentId, params.assignmentId))
if (params?.classId) {
@@ -407,9 +419,15 @@ export const getHomeworkSubmissions = cache(async (params?: { assignmentId?: str
}
return item
})
}
export const getHomeworkSubmissions = cacheFn(getHomeworkSubmissionsRaw, {
tags: ["homework"],
ttl: 300,
keyParts: ["homework", "getHomeworkSubmissions"],
})
export const getHomeworkAssignmentById = cache(async (id: string, scope?: DataScope) => {
export const getHomeworkAssignmentByIdRaw = async (id: string, scope?: DataScope) => {
const assignment = await db.query.homeworkAssignments.findFirst({
where: eq(homeworkAssignments.id, id),
with: {
@@ -489,9 +507,15 @@ export const getHomeworkAssignmentById = cache(async (id: string, scope?: DataSc
createdAt: assignment.createdAt.toISOString(),
updatedAt: assignment.updatedAt.toISOString(),
}
}
export const getHomeworkAssignmentById = cacheFn(getHomeworkAssignmentByIdRaw, {
tags: ["homework"],
ttl: 300,
keyParts: ["homework", "getHomeworkAssignmentById"],
})
export const getHomeworkSubmissionDetails = cache(async (submissionId: string): Promise<HomeworkSubmissionDetails | null> => {
export const getHomeworkSubmissionDetailsRaw = async (submissionId: string): Promise<HomeworkSubmissionDetails | null> => {
const submission = await db.query.homeworkSubmissions.findFirst({
where: eq(homeworkSubmissions.id, submissionId),
with: {
@@ -573,6 +597,12 @@ export const getHomeworkSubmissionDetails = cache(async (submissionId: string):
prevSubmissionId,
nextSubmissionId,
}
}
export const getHomeworkSubmissionDetails = cacheFn(getHomeworkSubmissionDetailsRaw, {
tags: ["homework"],
ttl: 300,
keyParts: ["homework", "getHomeworkSubmissionDetails"],
})
/**
@@ -587,7 +617,7 @@ export const getHomeworkSubmissionDetails = cache(async (submissionId: string):
* 权限说明:调用方必须在外层通过 `requirePermission()` 校验,
* 并通过 `scope` 参数传入数据范围(教师仅可见自己班级的学生提交)。
*/
export const getExcellentSubmissions = cache(async (params: {
export const getExcellentSubmissionsRaw = async (params: {
assignmentId: string
minPercentage?: number
limit?: number
@@ -663,6 +693,12 @@ export const getExcellentSubmissions = cache(async (params: {
return Array.from(bestByStudent.values())
.sort((a, b) => b.percentage - a.percentage)
.slice(0, limit)
}
export const getExcellentSubmissions = cacheFn(getExcellentSubmissionsRaw, {
tags: ["homework"],
ttl: 300,
keyParts: ["homework", "getExcellentSubmissions"],
})
/**
@@ -675,7 +711,7 @@ export const getExcellentSubmissions = cache(async (params: {
*
* 权限:调用方必须通过 `requirePermission()` 校验。
*/
export const getUnsubmittedStudents = cache(async (params: {
export const getUnsubmittedStudentsRaw = async (params: {
assignmentId: string
scope?: DataScope
}): Promise<Array<{ studentId: string; studentName: string }>> => {
@@ -709,6 +745,12 @@ export const getUnsubmittedStudents = cache(async (params: {
}))
return unsubmitted
}
export const getUnsubmittedStudents = cacheFn(getUnsubmittedStudentsRaw, {
tags: ["homework"],
ttl: 300,
keyParts: ["homework", "getUnsubmittedStudents"],
})

View File

@@ -1,6 +1,6 @@
import "server-only"
import { cache } from "react"
import { cacheFn } from "@/shared/lib/cache"
import { and, count, desc, eq, inArray, or, sql } from "drizzle-orm"
import { db } from "@/shared/db"
@@ -41,7 +41,7 @@ const toHomeworkAssignmentStatus = (v: string | null | undefined): HomeworkAssig
* Get grade trend data for a teacher's recent assignments.
* Used by the teacher dashboard to visualize class performance over time.
*/
export const getTeacherGradeTrends = cache(async (teacherId: string, limit: number = 5): Promise<TeacherGradeTrendItem[]> => {
export const getTeacherGradeTrendsRaw = async (teacherId: string, limit: number = 5): Promise<TeacherGradeTrendItem[]> => {
const recentAssignments = await db.query.homeworkAssignments.findMany({
where: and(
eq(homeworkAssignments.creatorId, teacherId),
@@ -99,14 +99,19 @@ export const getTeacherGradeTrends = cache(async (teacherId: string, limit: numb
createdAt: a.createdAt.toISOString(),
}
}).reverse() // Reverse to show trend from left (older) to right (newer)
}
export const getTeacherGradeTrends = cacheFn(getTeacherGradeTrendsRaw, {
tags: ["homework"],
ttl: 300,
keyParts: ["homework", "getTeacherGradeTrends"],
})
/**
* Get detailed analytics for a specific homework assignment.
* Includes per-question error rates and wrong answer samples.
*/
export const getHomeworkAssignmentAnalytics = cache(
async (assignmentId: string): Promise<HomeworkAssignmentAnalytics | null> => {
export const getHomeworkAssignmentAnalyticsRaw = async (assignmentId: string): Promise<HomeworkAssignmentAnalytics | null> => {
const assignment = await db.query.homeworkAssignments.findFirst({
where: eq(homeworkAssignments.id, assignmentId),
with: {
@@ -247,14 +252,19 @@ export const getHomeworkAssignmentAnalytics = cache(
return analytics
}
)
export const getHomeworkAssignmentAnalytics = cacheFn(getHomeworkAssignmentAnalyticsRaw, {
tags: ["homework"],
ttl: 300,
keyParts: ["homework", "getHomeworkAssignmentAnalytics"],
})
/**
* Get student dashboard grade data including trend, recent scores, and class ranking.
* The ranking calculation queries all classmates' graded submissions and computes
* relative position by total percentage score.
*/
export const getStudentDashboardGrades = cache(async (studentId: string): Promise<StudentDashboardGradeProps> => {
export const getStudentDashboardGradesRaw = async (studentId: string): Promise<StudentDashboardGradeProps> => {
const id = studentId.trim()
if (!id) return { trend: [], recent: [], ranking: null }
@@ -393,6 +403,12 @@ export const getStudentDashboardGrades = cache(async (studentId: string): Promis
}
return { trend, recent, ranking }
}
export const getStudentDashboardGrades = cacheFn(getStudentDashboardGradesRaw, {
tags: ["homework"],
ttl: 300,
keyParts: ["homework", "getStudentDashboardGrades"],
})
export type HomeworkDashboardStats = {
@@ -402,7 +418,7 @@ export type HomeworkDashboardStats = {
homeworkSubmissionToGradeCount: number
}
export const getHomeworkDashboardStats = cache(async (scope?: DataScope): Promise<HomeworkDashboardStats> => {
export const getHomeworkDashboardStatsRaw = async (scope?: DataScope): Promise<HomeworkDashboardStats> => {
const homeworkConditions = []
const submissionConditions = []
@@ -464,4 +480,10 @@ export const getHomeworkDashboardStats = cache(async (scope?: DataScope): Promis
homeworkSubmissionCount: Number(homeworkSubmissionCountRow[0]?.value ?? 0),
homeworkSubmissionToGradeCount: Number(homeworkSubmissionToGradeCountRow[0]?.value ?? 0),
}
}
export const getHomeworkDashboardStats = cacheFn(getHomeworkDashboardStatsRaw, {
tags: ["homework"],
ttl: 300,
keyParts: ["homework", "getHomeworkDashboardStats"],
})

View File

@@ -4,6 +4,7 @@ import { and, desc, eq, isNull, lte, or } from "drizzle-orm"
import { db } from "@/shared/db"
import { invitationCodes } from "@/shared/db/schema"
import { cacheFn } from "@/shared/lib/cache"
import type {
ConsumeInvitationCodeInput,
@@ -98,7 +99,7 @@ export async function generateInvitationCodes(
* @param limit 最多返回的记录数(默认 100
* @param includeUsed 是否包含已使用的邀请码(默认 true
*/
export async function listInvitationCodes(
export async function listInvitationCodesRaw(
limit = 100,
includeUsed = true,
): Promise<InvitationCodeRecord[]> {
@@ -120,6 +121,12 @@ export async function listInvitationCodes(
return rows.map(toRecord)
}
export const listInvitationCodes = cacheFn(listInvitationCodesRaw, {
tags: ["invitation-codes"],
ttl: 300,
keyParts: ["invitation-codes", "list"],
})
/**
* 校验邀请码是否可用audit-P2-3 新增)
*
@@ -131,7 +138,7 @@ export async function listInvitationCodes(
*
* 返回结果中 `code` 字段为脱敏数据(不含 code 明文),用于注册流程。
*/
export async function validateInvitationCode(
export async function validateInvitationCodeRaw(
code: string,
email: string,
): Promise<ValidateInvitationCodeResult> {
@@ -164,6 +171,12 @@ export async function validateInvitationCode(
return { valid: true, code: codeWithoutValue }
}
export const validateInvitationCode = cacheFn(validateInvitationCodeRaw, {
tags: ["invitation-codes"],
ttl: 60,
keyParts: ["invitation-codes", "validate"],
})
/**
* 标记邀请码为已使用audit-P2-3 新增)
*

View File

@@ -21,7 +21,7 @@ const SidebarContext = React.createContext<SidebarContextType | undefined>(
undefined
)
export function useSidebar() {
export function useSidebar(): SidebarContextType {
const context = React.useContext(SidebarContext)
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider")
@@ -34,36 +34,62 @@ interface SidebarProviderProps {
sidebar: React.ReactNode
}
/** resize 事件防抖时长(毫秒),避免拖拽窗口时高频触发状态更新 */
const RESIZE_DEBOUNCE_MS = 200
export function SidebarProvider({ children, sidebar }: SidebarProviderProps) {
const [expanded, setExpanded] = React.useState(true)
const [isMobile, setIsMobile] = React.useState(false)
const [openMobile, setOpenMobile] = React.useState(false)
React.useEffect(() => {
const checkMobile = () => {
const checkMobile = (): void => {
const mobile = window.innerWidth < 768
setIsMobile(mobile)
if (mobile) {
setExpanded(true)
}
}
// 首次立即检测一次,避免初始状态闪烁
checkMobile()
window.addEventListener("resize", checkMobile)
return () => window.removeEventListener("resize", checkMobile)
// 防抖:拖拽窗口时只在停止后触发一次,避免高频 setIsMobile
let timer: ReturnType<typeof setTimeout> | null = null
const onResize = (): void => {
if (timer) {
clearTimeout(timer)
}
timer = setTimeout(checkMobile, RESIZE_DEBOUNCE_MS)
}
window.addEventListener("resize", onResize)
return () => {
window.removeEventListener("resize", onResize)
if (timer) {
clearTimeout(timer)
}
}
}, [])
const toggleSidebar = () => {
// isMobile 变化频率低(仅在 resize 跨越 768px 断点时变化),
// 将其纳入 useCallback 依赖即可保证 toggleSidebar 行为正确,
// 同时不影响 expanded/openMobile 切换时 toggleSidebar 的引用稳定性
const toggleSidebar = React.useCallback(() => {
if (isMobile) {
setOpenMobile(!openMobile)
setOpenMobile((prev) => !prev)
} else {
setExpanded(!expanded)
setExpanded((prev) => !prev)
}
}
}, [isMobile])
// context value 用 useMemo 包装,避免每次渲染都创建新对象,
// 减少 SidebarContext 消费者无意义的重渲染
const value = React.useMemo<SidebarContextType>(
() => ({ expanded, setExpanded, isMobile, toggleSidebar }),
[expanded, isMobile, toggleSidebar]
)
return (
<SidebarContext.Provider
value={{ expanded, setExpanded, isMobile, toggleSidebar }}
>
<SidebarContext.Provider value={value}>
<div className="flex h-screen overflow-hidden w-full flex-col md:flex-row bg-background">
{/* Mobile Trigger & Sheet */}
{isMobile && (

View File

@@ -4,6 +4,7 @@ import { and, asc, desc, eq, inArray, sql, type SQL } from "drizzle-orm"
import { createId } from "@paralleldrive/cuid2"
import { db } from "@/shared/db"
import { cacheFn } from "@/shared/lib/cache"
import { leaveRequests } from "@/shared/db/schema"
import { getClassNamesByIds } from "@/modules/classes/data-access"
import { getUserNamesByIds } from "@/modules/users/data-access"
@@ -111,7 +112,7 @@ async function enrichListItems(
/**
* 分页查询请假申请列表(按 dataScope 过滤)。
*/
export async function getLeaveRequests(
export async function getLeaveRequestsRaw(
params: LeaveQueryParams & { scope: DataScope; currentUserId?: string },
): Promise<PaginatedLeaveResult> {
const page = Math.max(1, params.page ?? 1)
@@ -158,7 +159,13 @@ export async function getLeaveRequests(
/**
* 获取单条请假申请详情(按 dataScope 校验访问权限)。
*/
export async function getLeaveRequest(
export const getLeaveRequests = cacheFn(getLeaveRequestsRaw, {
tags: ["leave-requests"],
ttl: 300,
keyParts: ["leave-requests", "list"],
})
export async function getLeaveRequestRaw(
id: string,
scope: DataScope,
currentUserId?: string,
@@ -185,6 +192,12 @@ export async function getLeaveRequest(
* 校验同一学生在日期范围内是否已有未结束的请假pending/approved
* 用于提交前校验,避免重复请假。
*/
export const getLeaveRequest = cacheFn(getLeaveRequestRaw, {
tags: ["leave-requests"],
ttl: 600,
keyParts: ["leave-requests", "detail"],
})
export async function hasOverlappingLeave(
studentId: string,
startDate: string,
@@ -301,7 +314,7 @@ export async function markAttendanceSynced(id: string): Promise<void> {
/**
* 获取指定状态下的请假申请(用于审批通过后的考勤同步)。
*/
export async function getLeaveRequestsByIds(
export async function getLeaveRequestsByIdsRaw(
ids: string[],
): Promise<LeaveRequest[]> {
if (ids.length === 0) return []
@@ -316,7 +329,13 @@ export async function getLeaveRequestsByIds(
/**
* 教师审批待办:按 classIds 统计 pending 数量。
*/
export async function countPendingForClasses(
export const getLeaveRequestsByIds = cacheFn(getLeaveRequestsByIdsRaw, {
tags: ["leave-requests"],
ttl: 300,
keyParts: ["leave-requests", "by-ids"],
})
export async function countPendingForClassesRaw(
classIds: string[],
): Promise<number> {
if (classIds.length === 0) return 0
@@ -331,3 +350,9 @@ export async function countPendingForClasses(
)
return Number(row?.count ?? 0)
}
export const countPendingForClasses = cacheFn(countPendingForClassesRaw, {
tags: ["leave-requests"],
ttl: 60,
keyParts: ["leave-requests", "pending-count"],
})

View File

@@ -28,7 +28,9 @@ interface Props {
export function ExerciseBlock({ blockId, data, classes, textbookId, chapterId }: Props) {
const t = useTranslations("lessonPreparation");
const router = useRouter();
const { updateNode, planId } = useLessonPlanEditor();
// Phase 4.2: 拆分为细粒度 selectorupdateNode 与 planId 为基本类型/稳定函数)
const updateNode = useLessonPlanEditor((s) => s.updateNode);
const planId = useLessonPlanEditor((s) => s.planId);
const [showBank, setShowBank] = useState(false);
const [showInline, setShowInline] = useState(false);
const [showPublish, setShowPublish] = useState(false);

View File

@@ -0,0 +1,214 @@
"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 { LessonPlanErrorBoundary } from "../lesson-plan-error-boundary";
import { Tag, Paperclip, Trash2 } from "lucide-react";
export interface RichTextBlockProps {
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 RichTextBlockInner({
data,
hint,
textbookId,
chapterId,
planId,
blockId,
onUpdate,
}: RichTextBlockProps): React.ReactElement {
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 && (
<LessonPlanErrorBoundary>
<KnowledgePointPicker
textbookId={textbookId}
chapterId={chapterId}
selectedIds={data.knowledgePointIds}
onChange={(ids) => onUpdate({ ...data, knowledgePointIds: ids })}
onClose={() => setShowKpPicker(false)}
/>
</LessonPlanErrorBoundary>
)}
{/* V5-5素材库 picker */}
{showAttachmentPicker && planId && (
<LessonPlanErrorBoundary>
<AttachmentPicker
planId={planId}
blockId={blockId}
selectedIds={attachments.map((a) => a.attachmentId)}
onSelect={handleSelectAttachment}
onClose={() => setShowAttachmentPicker(false)}
/>
</LessonPlanErrorBoundary>
)}
</div>
);
}

View File

@@ -19,7 +19,7 @@ interface Props {
export function TextStudyBlock({ blockId, data }: Props) {
const t = useTranslations("lessonPreparation");
const { updateNode } = useLessonPlanEditor();
const updateNode = useLessonPlanEditor((s) => s.updateNode);
const [selection, setSelection] = useState<{
start: number;
end: number;

View File

@@ -33,7 +33,7 @@ import { ScheduleDialog } from "./schedule-dialog";
import { ConsistencyCheckDialog } from "./consistency-check-dialog";
import { AiFeedbackDialog } from "./ai-feedback-dialog";
import { AiDifferentiationDialog } from "./ai-differentiation-dialog";
import type { LessonPlan, LessonPlanDocument } from "../types";
import type { LessonPlan } from "../types";
interface Props {
planId: string;
@@ -59,7 +59,18 @@ export function LessonPlanEditor({
classes,
}: Props) {
const t = useTranslations("lessonPreparation");
const editor = useLessonPlanEditor();
// Phase 4.2: 拆分为细粒度 selector避免任一字段变化都触发整体 re-render
const isDirty = useLessonPlanEditor((s) => s.isDirty);
const isOnline = useLessonPlanEditor((s) => s.isOnline);
const doc = useLessonPlanEditor((s) => s.doc);
const title = useLessonPlanEditor((s) => s.title);
const isSaving = useLessonPlanEditor((s) => s.isSaving);
const saveError = useLessonPlanEditor((s) => s.saveError);
const lastSavedAt = useLessonPlanEditor((s) => s.lastSavedAt);
const setTitle = useLessonPlanEditor((s) => s.setTitle);
// 订阅派生 boolean仅在结果变化时触发 re-renderrerender-derived-state
const canUndo = useLessonPlanEditor((s) => s.canUndo());
const canRedo = useLessonPlanEditor((s) => s.canRedo());
const tracker = useLessonPlanTrackerSafe();
const ctx = useLessonPlanContextSafe();
const service = ctx?.service ?? null;
@@ -88,9 +99,9 @@ export function LessonPlanEditor({
// V3 修复:完全通过 service 调用,不直接 import actions
// V5-1 修复:保存失败显示 toast + 设置 saveError断网时不触发保存
useEffect(() => {
if (!editor.isDirty) return;
if (!isDirty) return;
if (!service) return;
if (!editor.isOnline) return; // V5-1断网期间不触发保存请求
if (!isOnline) return; // V5-1断网期间不触发保存请求
if (autoSaveTimer.current) clearTimeout(autoSaveTimer.current);
autoSaveTimer.current = setTimeout(async () => {
const state = useLessonPlanEditor.getState();
@@ -120,7 +131,7 @@ export function LessonPlanEditor({
return () => {
if (autoSaveTimer.current) clearTimeout(autoSaveTimer.current);
};
}, [editor.isDirty, editor.doc, planId, service, editor.isOnline, t]);
}, [isDirty, doc, planId, service, isOnline, t]);
// V5-1监听网络在线/离线状态
useEffect(() => {
@@ -185,18 +196,15 @@ export function LessonPlanEditor({
return () => window.removeEventListener("keydown", handleKeyDown);
}, []);
// V5-2撤销/重做按钮处理(追踪 canUndo/canRedo 以驱动 disabled 状态
const canUndo = editor.canUndo();
const canRedo = editor.canRedo();
// V5-2撤销/重做按钮处理canUndo/canRedo 已通过 selector 订阅
const handleUndo = useCallback(() => useLessonPlanEditor.getState().undo(), []);
const handleRedo = useCallback(() => useLessonPlanEditor.getState().redo(), []);
// V5-4构造打印用的 LessonPlan 对象(编辑器仅持有 planId/title/doc其余字段填默认值
const printablePlan: LessonPlan = useMemo(() => {
const doc: LessonPlanDocument = editor.doc;
return {
id: planId,
title: editor.title,
title,
textbookId: textbookId ?? null,
chapterId: chapterId ?? null,
coursePlanItemId: null,
@@ -207,11 +215,11 @@ export function LessonPlanEditor({
content: doc,
status: planStatus,
creatorId: "",
lastSavedAt: editor.lastSavedAt ? new Date(editor.lastSavedAt).toISOString() : null,
lastSavedAt: lastSavedAt ? new Date(lastSavedAt).toISOString() : null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
}, [planId, editor.title, editor.doc, editor.lastSavedAt, textbookId, chapterId, planStatus]);
}, [planId, title, doc, lastSavedAt, textbookId, chapterId, planStatus]);
// 定时自动版本30min
useEffect(() => {
@@ -327,8 +335,8 @@ export function LessonPlanEditor({
{/* 顶部工具栏 */}
<div className="flex items-center gap-2 px-4 py-2 border-b border-outline-variant bg-surface">
<input
value={editor.title}
onChange={(e) => editor.setTitle(e.target.value)}
value={title}
onChange={(e) => setTitle(e.target.value)}
className="flex-1 bg-transparent font-headline-md text-headline-md focus:outline-none"
/>
{/* 教材/章节指示器 */}
@@ -348,26 +356,26 @@ export function LessonPlanEditor({
</div>
)}
<span className="text-on-surface-variant text-sm">
{editor.isSaving
{isSaving
? t("status.saving")
: editor.isDirty
: isDirty
? t("status.unsaved")
: t("status.saved")}
</span>
{/* V5-1保存失败/离线提示与重试按钮 */}
{editor.saveError && (
{saveError && (
<Button
variant="outline"
size="sm"
onClick={handleRetrySave}
disabled={editor.isSaving}
disabled={isSaving}
className="text-error border-error"
>
<RotateCw className="w-3 h-3 mr-1" />
{t("status.retrySave")}
</Button>
)}
{!editor.isOnline && (
{!isOnline && (
<span className="text-xs text-error inline-flex items-center gap-1">
<WifiOff className="w-3 h-3" />
{t("status.offlineBadge")}
@@ -401,7 +409,7 @@ export function LessonPlanEditor({
>
<Redo className="w-4 h-4" />
</Button>
<Button size="sm" onClick={handleManualSave} disabled={editor.isSaving}>
<Button size="sm" onClick={handleManualSave} disabled={isSaving}>
<Save className="w-4 h-4 mr-1" /> {t("action.saveVersion")}
</Button>
{/* V5-4导出/打印按钮 */}
@@ -524,7 +532,7 @@ export function LessonPlanEditor({
onClose={() => setShowVersions(false)}
planId={planId}
onReverted={handleReverted}
currentDoc={editor.doc}
currentDoc={doc}
/>
</LessonPlanErrorBoundary>
@@ -555,7 +563,7 @@ export function LessonPlanEditor({
{showConsistency && (
<LessonPlanErrorBoundary>
<ConsistencyCheckDialog
doc={editor.doc}
doc={doc}
onClose={() => setShowConsistency(false)}
/>
</LessonPlanErrorBoundary>
@@ -565,7 +573,7 @@ export function LessonPlanEditor({
{showAiFeedback && (
<LessonPlanErrorBoundary>
<AiFeedbackDialog
doc={editor.doc}
doc={doc}
onClose={() => setShowAiFeedback(false)}
/>
</LessonPlanErrorBoundary>
@@ -575,7 +583,7 @@ export function LessonPlanEditor({
{showAiDifferentiation && (
<LessonPlanErrorBoundary>
<AiDifferentiationDialog
doc={editor.doc}
doc={doc}
textbookId={textbookId}
onClose={() => setShowAiDifferentiation(false)}
/>

View File

@@ -29,19 +29,19 @@ interface Props {
*/
export function PaperContextMenu({ state, onClose, onAiAction }: Props) {
const t = useTranslations("lessonPreparation");
const {
toggleExpand,
expandedNodeIds,
updateNode,
removeNode,
addAnchor,
duplicateNode,
doc,
} = useLessonPlanEditor();
// Phase 4.2: 拆分为细粒度 selector。函数引用稳定订阅 doc.nodes 比 doc 更精细
// addAnchor 等仅修改 anchors 不会触发本组件 re-render
const toggleExpand = useLessonPlanEditor((s) => s.toggleExpand);
const expandedNodeIds = useLessonPlanEditor((s) => s.expandedNodeIds);
const updateNode = useLessonPlanEditor((s) => s.updateNode);
const removeNode = useLessonPlanEditor((s) => s.removeNode);
const addAnchor = useLessonPlanEditor((s) => s.addAnchor);
const duplicateNode = useLessonPlanEditor((s) => s.duplicateNode);
const nodes = useLessonPlanEditor((s) => s.doc.nodes);
// V1 占位实现:上下移动、复制(按 spec §15 YAGNIV2 完整实现)
const moveNodeOrder = (nodeId: string, direction: "up" | "down") => {
const teachingNodes = doc.nodes
const teachingNodes = nodes
.filter((n): n is LessonPlanNode => n.type !== "textbook_content")
.sort((a, b) => a.order - b.order);
const idx = teachingNodes.findIndex((n) => n.id === nodeId);
@@ -55,7 +55,7 @@ export function PaperContextMenu({ state, onClose, onAiAction }: Props) {
};
const copyNode = async (nodeId: string) => {
const newId = duplicateNode(nodeId, t("v4.contextMenu.copySuffix"));
const newId = duplicateNode(nodeId);
const { toast } = await import("sonner");
if (newId) {
toast.success(t("v4.contextMenu.copied"));

View File

@@ -50,9 +50,8 @@ export function PrintView({
plan,
{ textbookTitle, chapterTitle, teacherName, className },
variant,
(key, params) => t(key, params),
),
[plan, textbookTitle, chapterTitle, teacherName, className, variant, t],
[plan, textbookTitle, chapterTitle, teacherName, className, variant],
);
function handlePrint() {

View File

@@ -9,6 +9,7 @@ import "server-only";
import { db } from "@/shared/db";
import { lessonPlanAiEvaluations } from "@/shared/db/schema";
import { eq, desc } from "drizzle-orm";
import { cacheFn } from "@/shared/lib/cache";
import type { LessonPlanDocument } from "./types";
/** 单次评估结果 */
@@ -36,7 +37,7 @@ export interface DimensionScores {
/**
* 查询某课案的所有 AI 评估记录
*/
export async function getEvaluationsByPlanId(
export async function getEvaluationsByPlanIdRaw(
planId: string,
): Promise<AiEvaluation[]> {
const rows = await db
@@ -46,11 +47,16 @@ export async function getEvaluationsByPlanId(
.orderBy(desc(lessonPlanAiEvaluations.createdAt));
return rows.map(mapRowToEvaluation);
}
export const getEvaluationsByPlanId = cacheFn(getEvaluationsByPlanIdRaw, {
tags: ["lesson-preparation"],
ttl: 300,
keyParts: ["lp", "ai-evaluations", "by-plan"],
})
/**
* 查询最新一次评估
*/
export async function getLatestEvaluation(
export async function getLatestEvaluationRaw(
planId: string,
): Promise<AiEvaluation | null> {
const rows = await db
@@ -61,6 +67,11 @@ export async function getLatestEvaluation(
.limit(1);
return rows.length === 0 ? null : mapRowToEvaluation(rows[0]!);
}
export const getLatestEvaluation = cacheFn(getLatestEvaluationRaw, {
tags: ["lesson-preparation"],
ttl: 600,
keyParts: ["lp", "ai-evaluations", "latest"],
})
/**
* 创建 AI 评估记录

View File

@@ -8,6 +8,7 @@
* - 课标覆盖热力图(按 subject+grade 矩阵)
*/
import "server-only";
import { cacheFn } from "@/shared/lib/cache";
import { db } from "@/shared/db";
import {
lessonPlanAnalyticsDaily,
@@ -48,7 +49,7 @@ export interface StandardsCoverageCell {
/**
* 查询教师备课投入(按日期范围)
*/
export async function getTeacherInvestment(
export async function getTeacherInvestmentRaw(
startDate: Date,
endDate: Date,
teacherId?: string,
@@ -77,10 +78,12 @@ export async function getTeacherInvestment(
}));
}
export const getTeacherInvestment = cacheFn(getTeacherInvestmentRaw, { tags: ["lesson-preparation"], ttl: 60, keyParts: ["lp", "analytics", "teacher-investment"] });
/**
* 查询模板使用率(按 templateId 分组)
*/
export async function getTemplateUsageStats(): Promise<TemplateUsageDataPoint[]> {
export async function getTemplateUsageStatsRaw(): Promise<TemplateUsageDataPoint[]> {
const rows = await db
.select({
templateId: lessonPlans.templateId,
@@ -99,10 +102,12 @@ export async function getTemplateUsageStats(): Promise<TemplateUsageDataPoint[]>
}));
}
export const getTemplateUsageStats = cacheFn(getTemplateUsageStatsRaw, { tags: ["lesson-preparation"], ttl: 60, keyParts: ["lp", "analytics", "template-usage"] });
/**
* 查询课标覆盖热力图(按 subject+grade 矩阵)
*/
export async function getStandardsCoverageHeatmap(): Promise<
export async function getStandardsCoverageHeatmapRaw(): Promise<
StandardsCoverageCell[]
> {
// 总课案数(按 subject+grade 分组)
@@ -148,10 +153,12 @@ export async function getStandardsCoverageHeatmap(): Promise<
});
}
export const getStandardsCoverageHeatmap = cacheFn(getStandardsCoverageHeatmapRaw, { tags: ["lesson-preparation"], ttl: 60, keyParts: ["lp", "analytics", "standards-coverage"] });
/**
* 查询全局备课统计(仪表盘顶部卡片)
*/
export async function getGlobalLessonPlanStats(): Promise<{
export async function getGlobalLessonPlanStatsRaw(): Promise<{
totalTeachers: number;
totalPlans: number;
totalPublished: number;
@@ -187,6 +194,8 @@ export async function getGlobalLessonPlanStats(): Promise<{
};
}
export const getGlobalLessonPlanStats = cacheFn(getGlobalLessonPlanStatsRaw, { tags: ["lesson-preparation"], ttl: 60, keyParts: ["lp", "analytics", "global-stats"] });
/**
* 写入或更新当日分析快照(教师保存版本时触发)
*/

View File

@@ -2,6 +2,7 @@
* M6 资源附件库 - 数据访问层
*/
import "server-only";
import { cacheFn } from "@/shared/lib/cache";
import { db } from "@/shared/db";
import { lessonPlanAttachments } from "@/shared/db/schema";
import { and, eq, desc } from "drizzle-orm";
@@ -23,7 +24,7 @@ export interface LessonPlanAttachment {
/**
* 查询课案的所有附件
*/
export async function getAttachmentsByPlanId(
export async function getAttachmentsByPlanIdRaw(
planId: string,
): Promise<LessonPlanAttachment[]> {
const rows = await db
@@ -35,10 +36,12 @@ export async function getAttachmentsByPlanId(
return rows.map(mapRowToAttachment);
}
export const getAttachmentsByPlanId = cacheFn(getAttachmentsByPlanIdRaw, { tags: ["lesson-preparation"], ttl: 300, keyParts: ["lp", "attachments", "by-plan"] });
/**
* 查询特定 Block 的附件
*/
export async function getAttachmentsByBlockId(
export async function getAttachmentsByBlockIdRaw(
planId: string,
blockId: string,
): Promise<LessonPlanAttachment[]> {
@@ -56,6 +59,8 @@ export async function getAttachmentsByBlockId(
return rows.map(mapRowToAttachment);
}
export const getAttachmentsByBlockId = cacheFn(getAttachmentsByBlockIdRaw, { tags: ["lesson-preparation"], ttl: 300, keyParts: ["lp", "attachments", "by-block"] });
/**
* 添加附件
*/
@@ -111,7 +116,7 @@ export async function updateAttachmentType(
/**
* 按 ID 获取附件
*/
export async function getAttachmentById(
export async function getAttachmentByIdRaw(
id: string,
): Promise<LessonPlanAttachment | null> {
const rows = await db
@@ -122,6 +127,8 @@ export async function getAttachmentById(
return rows.length === 0 ? null : mapRowToAttachment(rows[0]!);
}
export const getAttachmentById = cacheFn(getAttachmentByIdRaw, { tags: ["lesson-preparation"], ttl: 600, keyParts: ["lp", "attachments", "by-id"] });
function mapRowToAttachment(
row: typeof lessonPlanAttachments.$inferSelect,
): LessonPlanAttachment {

View File

@@ -5,6 +5,7 @@
* 关联查询 createdAt/updatedAt/submittedAt 用于在日历上标记事件。
*/
import "server-only";
import { cacheFn } from "@/shared/lib/cache";
import { db } from "@/shared/db";
import { lessonPlans, lessonPlanVersions, lessonPlanReviewRecords } from "@/shared/db/schema";
import { and, eq, gte, lte, asc } from "drizzle-orm";
@@ -25,7 +26,7 @@ export interface LessonPlanCalendarEvent {
/**
* 按日期范围查询教师备课日历事件
*/
export async function getCalendarEvents(
export async function getCalendarEventsRaw(
teacherId: string,
startDate: Date,
endDate: Date,
@@ -161,6 +162,8 @@ export async function getCalendarEvents(
return events.sort((a, b) => a.occurredAt.getTime() - b.occurredAt.getTime());
}
export const getCalendarEvents = cacheFn(getCalendarEventsRaw, { tags: ["lesson-preparation"], ttl: 300, keyParts: ["lp", "calendar", "events"] });
/**
* 按日期分组日历事件
*/

View File

@@ -5,6 +5,7 @@
* 实时多人编辑部分Yjs/Liveblocks需要单独的 WebSocket 服务,本文件仅实现评论 CRUD。
*/
import "server-only";
import { cacheFn } from "@/shared/lib/cache";
import { db } from "@/shared/db";
import { lessonPlanComments } from "@/shared/db/schema";
import { and, eq, asc, isNull } from "drizzle-orm";
@@ -25,7 +26,7 @@ export interface LessonPlanComment {
/**
* 查询课案的所有评论(含子回复)
*/
export async function getCommentsByPlanId(
export async function getCommentsByPlanIdRaw(
planId: string,
): Promise<LessonPlanComment[]> {
const rows = await db
@@ -37,10 +38,12 @@ export async function getCommentsByPlanId(
return rows.map(mapRowToComment);
}
export const getCommentsByPlanId = cacheFn(getCommentsByPlanIdRaw, { tags: ["lesson-preparation"], ttl: 300, keyParts: ["lp", "comments", "by-plan"] });
/**
* 查询特定 Block 的评论
*/
export async function getCommentsByBlockId(
export async function getCommentsByBlockIdRaw(
planId: string,
blockId: string,
): Promise<LessonPlanComment[]> {
@@ -58,6 +61,8 @@ export async function getCommentsByBlockId(
return rows.map(mapRowToComment);
}
export const getCommentsByBlockId = cacheFn(getCommentsByBlockIdRaw, { tags: ["lesson-preparation"], ttl: 300, keyParts: ["lp", "comments", "by-block"] });
/**
* 创建评论
*/
@@ -137,7 +142,7 @@ export async function deleteComment(commentId: string): Promise<void> {
/**
* 统计课案未解决评论数
*/
export async function countUnresolvedComments(planId: string): Promise<number> {
export async function countUnresolvedCommentsRaw(planId: string): Promise<number> {
const rows = await db
.select({ id: lessonPlanComments.id })
.from(lessonPlanComments)
@@ -151,6 +156,8 @@ export async function countUnresolvedComments(planId: string): Promise<number> {
return rows.length;
}
export const countUnresolvedComments = cacheFn(countUnresolvedCommentsRaw, { tags: ["lesson-preparation"], ttl: 60, keyParts: ["lp", "comments", "unresolved-count"] });
function mapRowToComment(
row: typeof lessonPlanComments.$inferSelect,
): LessonPlanComment {

View File

@@ -6,6 +6,7 @@
* 教师可查看实时反馈,闭环到课案优化。
*/
import "server-only";
import { cacheFn } from "@/shared/lib/cache";
import { db } from "@/shared/db";
import {
lessonPlanFormativeItems,
@@ -43,7 +44,7 @@ export interface FormativeResponse {
/**
* 查询课案的所有互动组件
*/
export async function getFormativeItemsByPlanId(
export async function getFormativeItemsByPlanIdRaw(
planId: string,
): Promise<FormativeItem[]> {
const rows = await db
@@ -55,10 +56,12 @@ export async function getFormativeItemsByPlanId(
return rows.map(mapRowToItem);
}
export const getFormativeItemsByPlanId = cacheFn(getFormativeItemsByPlanIdRaw, { tags: ["lesson-preparation"], ttl: 300, keyParts: ["lp", "formative", "items-by-plan"] });
/**
* 查询单个互动组件
*/
export async function getFormativeItemById(
export async function getFormativeItemByIdRaw(
id: string,
): Promise<FormativeItem | null> {
const rows = await db
@@ -69,6 +72,8 @@ export async function getFormativeItemById(
return rows.length === 0 ? null : mapRowToItem(rows[0]!);
}
export const getFormativeItemById = cacheFn(getFormativeItemByIdRaw, { tags: ["lesson-preparation"], ttl: 600, keyParts: ["lp", "formative", "by-id"] });
/**
* 创建互动组件
*/
@@ -157,7 +162,7 @@ export async function submitFormativeResponse(
/**
* 查询互动组件的所有作答
*/
export async function getResponsesByItemId(
export async function getResponsesByItemIdRaw(
itemId: string,
): Promise<FormativeResponse[]> {
const rows = await db
@@ -169,10 +174,12 @@ export async function getResponsesByItemId(
return rows.map(mapRowToResponse);
}
export const getResponsesByItemId = cacheFn(getResponsesByItemIdRaw, { tags: ["lesson-preparation:students"], ttl: 60, keyParts: ["lp", "formative", "responses-by-item"] });
/**
* 查询某学生作答历史
*/
export async function getResponsesByStudentId(
export async function getResponsesByStudentIdRaw(
studentId: string,
planId?: string,
): Promise<FormativeResponse[]> {
@@ -197,10 +204,12 @@ export async function getResponsesByStudentId(
return rows.map(mapRowToResponse);
}
export const getResponsesByStudentId = cacheFn(getResponsesByStudentIdRaw, { tags: ["lesson-preparation:students"], ttl: 60, keyParts: ["lp", "formative", "responses-by-student"] });
/**
* 统计互动组件的作答情况(用于教师查看实时反馈)
*/
export async function getFormativeItemStats(
export async function getFormativeItemStatsRaw(
itemId: string,
): Promise<{ total: number; correct: number; incorrect: number; avgDurationSec: number }> {
const rows = await db
@@ -229,6 +238,8 @@ export async function getFormativeItemStats(
};
}
export const getFormativeItemStats = cacheFn(getFormativeItemStatsRaw, { tags: ["lesson-preparation"], ttl: 60, keyParts: ["lp", "formative", "stats"] });
function mapRowToItem(
row: typeof lessonPlanFormativeItems.$inferSelect,
): FormativeItem {

View File

@@ -1,4 +1,5 @@
import "server-only";
import { cacheFn } from "@/shared/lib/cache";
import { and, eq, like, sql } from "drizzle-orm";
@@ -83,7 +84,7 @@ function mapRowToListItemWithoutJoin(r: {
}
// 查询关联了某知识点的课案
export async function getLessonPlansByKnowledgePoint(
export async function getLessonPlansByKnowledgePointRaw(
knowledgePointId: string,
userId: string,
): Promise<LessonPlanListItem[]> {
@@ -111,8 +112,10 @@ export async function getLessonPlansByKnowledgePoint(
.map(mapRowToListItemWithoutJoin);
}
export const getLessonPlansByKnowledgePoint = cacheFn(getLessonPlansByKnowledgePointRaw, { tags: ["lesson-preparation"], ttl: 300, keyParts: ["lp", "by-knowledge-point"] });
// 查询使用了某题目的课案
export async function getLessonPlansByQuestion(
export async function getLessonPlansByQuestionRaw(
questionId: string,
userId: string,
): Promise<LessonPlanListItem[]> {
@@ -138,3 +141,5 @@ export async function getLessonPlansByQuestion(
})
.map(mapRowToListItemWithoutJoin);
}
export const getLessonPlansByQuestion = cacheFn(getLessonPlansByQuestionRaw, { tags: ["lesson-preparation"], ttl: 300, keyParts: ["lp", "by-question"] });

View File

@@ -4,6 +4,7 @@
* 处理课案状态迁移、审核记录、审核队列查询。
*/
import "server-only";
import { cacheFn } from "@/shared/lib/cache";
import { db } from "@/shared/db";
import { lessonPlans, lessonPlanReviewRecords } from "@/shared/db/schema";
import { and, eq, desc, asc, inArray } from "drizzle-orm";
@@ -123,7 +124,7 @@ export async function reviewPlan(
/**
* 查询课案的所有审核记录
*/
export async function getReviewRecordsByPlanId(
export async function getReviewRecordsByPlanIdRaw(
planId: string,
): Promise<LessonPlanReviewRecord[]> {
const rows = await db
@@ -144,10 +145,12 @@ export async function getReviewRecordsByPlanId(
}));
}
export const getReviewRecordsByPlanId = cacheFn(getReviewRecordsByPlanIdRaw, { tags: ["lesson-preparation"], ttl: 300, keyParts: ["lp", "review-records", "by-plan"] });
/**
* 查询待审核队列(教研组长用)
*/
export async function getPendingReviewPlans(
export async function getPendingReviewPlansRaw(
reviewerGradeIds?: string[],
reviewerSubjectIds?: string[],
): Promise<
@@ -199,6 +202,8 @@ export async function getPendingReviewPlans(
}));
}
export const getPendingReviewPlans = cacheFn(getPendingReviewPlansRaw, { tags: ["lesson-preparation"], ttl: 300, keyParts: ["lp", "review", "pending"] });
/**
* 撤回审核教师主动撤回submitted → draft
*/
@@ -230,7 +235,7 @@ export async function withdrawSubmission(
/**
* 按状态批量查询课案(审核仪表盘用途。
*/
export async function getPlansByStatuses(
export async function getPlansByStatusesRaw(
statuses: LessonPlanStatus[],
creatorId?: string,
): Promise<
@@ -270,3 +275,5 @@ export async function getPlansByStatuses(
updatedAt: r.updatedAt,
}));
}
export const getPlansByStatuses = cacheFn(getPlansByStatusesRaw, { tags: ["lesson-preparation"], ttl: 300, keyParts: ["lp", "by-statuses"] });

View File

@@ -4,6 +4,7 @@
* 将课案绑定到具体班级的某个日期/节次,支持 calendar-view 安排课时。
*/
import "server-only";
import { cacheFn } from "@/shared/lib/cache";
import { db } from "@/shared/db";
import { lessonPlanSchedules, classes } from "@/shared/db/schema";
import { and, eq, gte, lte, desc } from "drizzle-orm";
@@ -33,7 +34,7 @@ function toDateStr(d: Date): string {
}
/** 查询课案的所有课时绑定 */
export async function getSchedulesByPlanId(
export async function getSchedulesByPlanIdRaw(
planId: string,
): Promise<LessonPlanScheduleRecord[]> {
const rows = await db
@@ -70,8 +71,10 @@ export async function getSchedulesByPlanId(
}));
}
export const getSchedulesByPlanId = cacheFn(getSchedulesByPlanIdRaw, { tags: ["lesson-preparation"], ttl: 300, keyParts: ["lp", "schedules", "by-plan"] });
/** 查询教师在某日期范围内的课时绑定 */
export async function getSchedulesByDateRange(
export async function getSchedulesByDateRangeRaw(
teacherPlanIds: string[],
startDate: string,
endDate: string,
@@ -119,6 +122,8 @@ export async function getSchedulesByDateRange(
}));
}
export const getSchedulesByDateRange = cacheFn(getSchedulesByDateRangeRaw, { tags: ["lesson-preparation"], ttl: 300, keyParts: ["lp", "schedules", "by-date-range"] });
/** 创建课时绑定 */
export async function createSchedule(input: {
planId: string;

View File

@@ -2,6 +2,7 @@
* M12 代课教师机制 - 数据访问层
*/
import "server-only";
import { cacheFn } from "@/shared/lib/cache";
import { db } from "@/shared/db";
import { lessonPlanSubstitutes, lessonPlans } from "@/shared/db/schema";
import { and, eq, gte, lte, or, isNull, desc } from "drizzle-orm";
@@ -26,7 +27,7 @@ export interface LessonPlanSubstitute {
/**
* 查询某课案的代课教师列表
*/
export async function getSubstitutesByPlanId(
export async function getSubstitutesByPlanIdRaw(
planId: string,
): Promise<LessonPlanSubstitute[]> {
const rows = await db
@@ -38,10 +39,12 @@ export async function getSubstitutesByPlanId(
return rows.map(mapRowToSubstitute);
}
export const getSubstitutesByPlanId = cacheFn(getSubstitutesByPlanIdRaw, { tags: ["lesson-preparation"], ttl: 300, keyParts: ["lp", "substitutes", "by-plan"] });
/**
* 查询某教师当前生效的代课任务(作为代课教师或原教师)
*/
export async function getActiveSubstitutesByTeacherId(
export async function getActiveSubstitutesByTeacherIdRaw(
teacherId: string,
): Promise<LessonPlanSubstitute[]> {
const now = new Date();
@@ -59,6 +62,8 @@ export async function getActiveSubstitutesByTeacherId(
return rows.map(mapRowToSubstitute);
}
export const getActiveSubstitutesByTeacherId = cacheFn(getActiveSubstitutesByTeacherIdRaw, { tags: ["lesson-preparation"], ttl: 300, keyParts: ["lp", "substitutes", "active-by-teacher"] });
/**
* 创建代课教师映射
*/

View File

@@ -1,4 +1,5 @@
import "server-only";
import { cacheFn } from "@/shared/lib/cache";
import { and, eq } from "drizzle-orm";
import { createId } from "@paralleldrive/cuid2";
@@ -41,7 +42,7 @@ function mapRowToTemplate(row: {
};
}
export async function getLessonPlanTemplates(
export async function getLessonPlanTemplatesRaw(
userId: string,
): Promise<LessonPlanTemplate[]> {
// system 模板(内存)+ personal 模板DB
@@ -70,6 +71,8 @@ export async function getLessonPlanTemplates(
return [...systemTemplates, ...personalTemplates];
}
export const getLessonPlanTemplates = cacheFn(getLessonPlanTemplatesRaw, { tags: ["lesson-preparation"], ttl: 300, keyParts: ["lp", "templates", "list"] });
export async function saveAsTemplate(input: {
sourcePlanId: string;
name: string;

View File

@@ -1,4 +1,5 @@
import "server-only";
import { cacheFn } from "@/shared/lib/cache";
import { and, desc, eq, inArray, max } from "drizzle-orm";
import { createId } from "@paralleldrive/cuid2";
@@ -31,7 +32,7 @@ function mapRowToVersion(row: {
};
}
export async function getLessonPlanVersions(
export async function getLessonPlanVersionsRaw(
planId: string,
userId: string,
): Promise<LessonPlanVersion[]> {
@@ -53,6 +54,8 @@ export async function getLessonPlanVersions(
return rows.map(mapRowToVersion);
}
export const getLessonPlanVersions = cacheFn(getLessonPlanVersionsRaw, { tags: ["lesson-preparation"], ttl: 300, keyParts: ["lp", "versions", "by-plan"] });
export async function createLessonPlanVersion(input: {
planId: string;
content: LessonPlanDocument;
@@ -91,7 +94,7 @@ export async function createLessonPlanVersion(input: {
});
}
export async function getVersionContent(
export async function getVersionContentRaw(
planId: string,
versionNo: number,
userId: string,
@@ -120,6 +123,8 @@ export async function getVersionContent(
return normalizeDocument(rows[0].content);
}
export const getVersionContent = cacheFn(getVersionContentRaw, { tags: ["lesson-preparation"], ttl: 600, keyParts: ["lp", "versions", "content"] });
export async function revertToVersion(
planId: string,
versionNo: number,

View File

@@ -1,6 +1,6 @@
"use client"
import { useEffect, useState } from "react"
import { useEffect, useState, useOptimistic, useTransition } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { toast } from "sonner"
@@ -43,8 +43,12 @@ export function MessageDetail({
const [deleteOpen, setDeleteOpen] = useState(false)
const [recallOpen, setRecallOpen] = useState(false)
const [isRecalling, setIsRecalling] = useState(false)
const [isStarred, setIsStarred] = useState(message.isStarred)
const [isTogglingStar, setIsTogglingStar] = useState(false)
// Phase 4.5: 星标用 useOptimistic + useTransition,自动回滚与服务端同步
const [optimisticIsStarred, addOptimisticStarred] = useOptimistic(
message.isStarred,
(_currentState, nextValue: boolean) => nextValue,
)
const [isTogglingStar, startStarTransition] = useTransition()
// P1-3: 线程状态
const [thread, setThread] = useState<Message[]>([])
const [loadingThread, setLoadingThread] = useState(true)
@@ -108,27 +112,24 @@ export function MessageDetail({
}
}
const handleToggleStar = async () => {
setIsTogglingStar(true)
const prevStarred = isStarred
// 乐观更新
setIsStarred(!prevStarred)
try {
const res = await toggleMessageStarAction(message.id)
if (res.success) {
toast.success(t("messages.starToggled"))
} else {
// 回滚
setIsStarred(prevStarred)
toast.error(res.message)
const handleToggleStar = () => {
// Phase 4.5: useOptimistic 在 transition 内自动管理乐观状态与回滚
const nextStarred = !optimisticIsStarred
startStarTransition(async () => {
addOptimisticStarred(nextStarred)
try {
const res = await toggleMessageStarAction(message.id)
if (res.success) {
toast.success(t("messages.starToggled"))
// 同步数据源,让 useOptimistic 回滚到最新服务端值
router.refresh()
} else {
toast.error(res.message)
}
} catch {
toast.error(t("messages.sendFailed"))
}
} catch {
// 回滚
setIsStarred(prevStarred)
toast.error(t("messages.sendFailed"))
} finally {
setIsTogglingStar(false)
}
})
}
// P2-4: 消息撤回仅发送方2 分钟窗口内)
@@ -182,13 +183,13 @@ export function MessageDetail({
<Button
onClick={handleToggleStar}
disabled={isTogglingStar}
variant={isStarred ? "default" : "outline"}
aria-pressed={isStarred}
variant={optimisticIsStarred ? "default" : "outline"}
aria-pressed={optimisticIsStarred}
>
<Star
className={cn("mr-2 h-4 w-4", isStarred && "fill-current")}
className={cn("mr-2 h-4 w-4", optimisticIsStarred && "fill-current")}
/>
{isStarred ? t("actions.unstar") : t("actions.star")}
{optimisticIsStarred ? t("actions.unstar") : t("actions.star")}
</Button>
{/* P2-4: 撤回按钮(仅发送方可见且未撤回、未超时) */}
{canRecall ? (
@@ -240,7 +241,7 @@ export function MessageDetail({
) : (
<Badge variant="outline">{t("status.sent")}</Badge>
)}
{isStarred ? (
{optimisticIsStarred ? (
<span className="inline-flex items-center gap-1 text-xs text-yellow-600">
<Star className="h-3.5 w-3.5 fill-yellow-400 text-yellow-400" aria-hidden="true" />
{t("actions.star")}

View File

@@ -1,7 +1,8 @@
"use client"
import { useCallback, useMemo, useState } from "react"
import { useCallback, useMemo, useState, useOptimistic, useTransition } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { Mail, MailOpen, Plus, Send, Inbox, Search, Loader2, ChevronLeft, ChevronRight, Star, RotateCcw, Users } from "lucide-react"
import { useTranslations } from "next-intl"
import { toast } from "sonner"
@@ -40,10 +41,20 @@ export function MessageList({
canGroupSend?: boolean
}) {
const t = useTranslations("messages")
const router = useRouter()
const [tab, setTab] = useState<Tab>(initialType === "sent" ? "sent" : "inbox")
const [currentPage, setCurrentPage] = useState(1)
const [starredOverride, setStarredOverride] = useState<Record<string, boolean>>({})
// Phase 4.5: 星标覆盖图改用 useOptimistic + useTransition,自动回滚与服务端同步
const [optimisticStarredMap, addOptimisticStar] = useOptimistic(
new Map<string, boolean>(),
(state, { id, isStarred }: { id: string; isStarred: boolean }) => {
const next = new Map(state)
next.set(id, isStarred)
return next
},
)
const [togglingStarId, setTogglingStarId] = useState<string | null>(null)
const [, startStarTransition] = useTransition()
const [starredMessages, setStarredMessages] = useState<Message[] | null>(null)
const [loadingStarred, setLoadingStarred] = useState(false)
// P2-4: 撤回状态覆盖(按消息 ID 维度,撤回成功后立即隐藏原内容)
@@ -112,39 +123,40 @@ export function MessageList({
}
const getIsStarred = (m: Message): boolean => {
const override = starredOverride[m.id]
const override = optimisticStarredMap.get(m.id)
return override === undefined ? m.isStarred : override
}
const handleToggleStar = useCallback(
async (e: React.MouseEvent, messageId: string, currentStarred: boolean): Promise<void> => {
(e: React.MouseEvent, messageId: string, currentStarred: boolean): void => {
e.preventDefault()
e.stopPropagation()
setTogglingStarId(messageId)
// 乐观更新
setStarredOverride((prev) => ({ ...prev, [messageId]: !currentStarred }))
try {
const res = await messageService.toggleStar(messageId)
if (res.success) {
toast.success(t("messages.starToggled"))
// P1-2: 当前在 starred Tab 时重新加载星标列表
if (tab === "starred") {
void loadStarred()
// Phase 4.5: useOptimistic 在 transition 内自动管理乐观状态与回滚
const nextStarred = !currentStarred
startStarTransition(async () => {
addOptimisticStar({ id: messageId, isStarred: nextStarred })
try {
const res = await messageService.toggleStar(messageId)
if (res.success) {
toast.success(t("messages.starToggled"))
// P1-2: 当前在 starred Tab 时重新加载星标列表
if (tab === "starred") {
void loadStarred()
}
// 同步数据源,让 useOptimistic 回滚到最新服务端值
await router.refresh()
} else {
toast.error(res.message)
}
} else {
// 回滚
setStarredOverride((prev) => ({ ...prev, [messageId]: currentStarred }))
toast.error(res.message)
} catch {
toast.error(t("messages.sendFailed"))
} finally {
setTogglingStarId(null)
}
} catch {
// 回滚
setStarredOverride((prev) => ({ ...prev, [messageId]: currentStarred }))
toast.error(t("messages.sendFailed"))
} finally {
setTogglingStarId(null)
}
})
},
[t, tab, loadStarred, messageService]
[t, tab, loadStarred, messageService, router, startStarTransition, addOptimisticStar]
)
// P2-4: 撤回消息仅发送方2 分钟窗口内)

View File

@@ -0,0 +1,68 @@
/**
* 私信模块类型守卫。
*
* 替代 data-access.ts 与组件中的 `as` 断言,从 DB 取出的 enum 字符串字段
* 通过类型守卫严格校验后再使用,避免脏数据导致运行时类型错误。
*/
import type {
MessageReportReason,
MessageReportStatus,
RecipientRole,
} from "../types"
const RECIPIENT_ROLES: ReadonlySet<string> = new Set<RecipientRole>([
"student",
"teacher",
"admin",
"parent",
])
const MESSAGE_REPORT_REASONS: ReadonlySet<string> = new Set<MessageReportReason>([
"spam",
"harassment",
"inappropriate",
"other",
])
const MESSAGE_REPORT_STATUSES: ReadonlySet<string> = new Set<MessageReportStatus>([
"pending",
"reviewed",
"dismissed",
"actioned",
])
/** 类型守卫:判断值是否为合法的 RecipientRole */
export function isRecipientRole(value: unknown): value is RecipientRole {
return typeof value === "string" && RECIPIENT_ROLES.has(value)
}
/** 类型守卫:判断值是否为合法的 MessageReportReason */
export function isMessageReportReason(
value: unknown,
): value is MessageReportReason {
return typeof value === "string" && MESSAGE_REPORT_REASONS.has(value)
}
/** 类型守卫:判断值是否为合法的 MessageReportStatus */
export function isMessageReportStatus(
value: unknown,
): value is MessageReportStatus {
return typeof value === "string" && MESSAGE_REPORT_STATUSES.has(value)
}
/**
* 安全转换 DB 行的 reason 字段。
* 合法返回原值,非法回退到 "other"(保证列表渲染不中断)。
*/
export function toMessageReportReason(value: unknown): MessageReportReason {
return isMessageReportReason(value) ? value : "other"
}
/**
* 安全转换 DB 行的 status 字段。
* 合法返回原值,非法回退到 "pending"(保证列表渲染不中断)。
*/
export function toMessageReportStatus(value: unknown): MessageReportStatus {
return isMessageReportStatus(value) ? value : "pending"
}

View File

@@ -18,7 +18,7 @@ import "server-only"
* 不再需要动态 import messaging 模块,消除了 notifications -> messaging 的反向依赖。
*/
import { createNotification } from "../data-access"
import { createNotification, createNotifications } from "../data-access"
import type {
NotificationPayload,
ChannelSendResult,
@@ -97,10 +97,66 @@ class InAppChannelSender implements NotificationChannelSender {
}
}
/**
* P3-7: 批量发送站内消息(单次 INSERT 多行)。
*
* 相比 `Promise.all(items.map(send))` 的 N 次 INSERT
* 本方法仅发起一次 DB INSERT显著降低 fan-out 场景的 DB 压力。
* 失败时整体回滚,每条记录返回失败结果。
*/
async sendBatch(
items: Array<{ payload: NotificationPayload; recipient: ChannelRecipient }>
): Promise<ChannelSendResult[]> {
return Promise.all(items.map((item) => this.send(item.payload, item.recipient)))
if (items.length === 0) return []
const sentAt = new Date()
// 预校验recipient.userId 与 payload.userId 必须一致
const valid: Array<{
payload: NotificationPayload
recipient: ChannelRecipient
}> = []
const results: ChannelSendResult[] = []
for (const item of items) {
if (item.recipient.userId !== item.payload.userId) {
results.push({
channel,
success: false,
error: "Recipient userId does not match payload.userId",
sentAt,
})
} else {
valid.push(item)
}
}
if (valid.length === 0) return results
try {
const inputs = valid.map((item) => ({
userId: item.payload.userId,
type: mapPayloadTypeToNotificationType(item.payload.type),
title: item.payload.title,
content: item.payload.content,
link: item.payload.actionUrl ?? null,
}))
const ids = await createNotifications(inputs)
// 按输入顺序回填成功结果
valid.forEach((_, idx) => {
results.push({
channel,
success: true,
messageId: ids[idx],
sentAt,
})
})
return results
} catch (e) {
const error = e instanceof Error ? e.message : "In-app batch notification failed"
// 整体失败:所有有效项标记为失败
valid.forEach(() => {
results.push({ channel, success: false, error, sentAt })
})
return results
}
}
}

View File

@@ -133,7 +133,11 @@ export async function sendNotification(
/**
* 批量发送通知到多个用户。
*
* 每个用户的渠道选择独立计算,并行发送。
* P3-7: 优化为批量 in_app INSERT 模式:
* - 并行获取所有用户的偏好和联系方式
* - 收集所有 in_app 渠道的 payload单次调用 `inAppSender.sendBatch`(一次 INSERT 多行)
* - 其他渠道sms/email/wechat保持 per-payload 并行发送
* - 每条通知的发送日志仍按 payload 聚合记录
*
* @param payloads 通知负载数组(每个 payload.userId 决定各自接收人)
* @returns 各用户各渠道的发送结果(按输入顺序对应)
@@ -141,6 +145,80 @@ export async function sendNotification(
export async function sendBatchNotifications(
payloads: NotificationPayload[]
): Promise<ChannelSendResult[][]> {
// 并行处理每个 payload每条通知的日志已在 sendNotification 内部记录)
return Promise.all(payloads.map((p) => sendNotification(p)))
if (payloads.length === 0) return []
// 1. 并行获取所有用户的偏好和联系方式
const userContexts = await Promise.all(
payloads.map(async (payload) => {
const [prefs, contact] = await Promise.all([
getNotificationPreferences(payload.userId),
getUserContactInfo(payload.userId),
])
return { payload, prefs, contact, channels: selectChannels(prefs, contact) }
})
)
// 2. 收集所有 in_app 渠道的 payload单次批量发送
const senders = getSenders()
const inAppItems: Array<{
payload: NotificationPayload
recipient: ChannelRecipient
index: number
}> = []
for (let i = 0; i < userContexts.length; i++) {
const ctx = userContexts[i]
if (ctx.channels.includes("in_app")) {
inAppItems.push({ payload: ctx.payload, recipient: ctx.contact, index: i })
}
}
// 预分配结果容器:每个 payload 一组渠道结果
const resultsByPayload: ChannelSendResult[][] = userContexts.map(() => [])
// 3. in_app 批量发送(单次 INSERT
if (inAppItems.length > 0) {
const inAppSender = senders.in_app
const inAppResults = await inAppSender.sendBatch(
inAppItems.map((item) => ({ payload: item.payload, recipient: item.recipient }))
)
// 回填到对应 payload 的结果数组
inAppItems.forEach((item, idx) => {
resultsByPayload[item.index].push(inAppResults[idx])
})
}
// 4. 其他渠道sms/email/wechatper-payload 并行发送
const otherChannelTasks: Array<Promise<{ index: number; results: ChannelSendResult[] }>> = []
for (let i = 0; i < userContexts.length; i++) {
const ctx = userContexts[i]
const otherChannels = ctx.channels.filter((ch) => ch !== "in_app")
if (otherChannels.length === 0) continue
otherChannelTasks.push(
(async () => {
const channelResults = await Promise.all(
otherChannels.map(async (ch) => {
const sender = senders[ch]
return sender.send(ctx.payload, ctx.contact)
})
)
return { index: i, results: channelResults }
})()
)
}
const otherResults = await Promise.all(otherChannelTasks)
for (const r of otherResults) {
resultsByPayload[r.index].push(...r.results)
}
// 5. 记录发送日志(按 payload 聚合)
await Promise.all(
payloads.map(async (payload, idx) => {
await logNotificationSendBatch(resultsByPayload[idx], {
userId: payload.userId,
title: payload.title,
})
})
)
return resultsByPayload
}

View File

@@ -26,6 +26,7 @@
export { sendNotification, sendBatchNotifications } from "./dispatcher"
export {
createNotification,
createNotifications,
getNotifications,
markNotificationAsRead,
markAllNotificationsAsRead,

View File

@@ -9,6 +9,7 @@ import {
} from "@/shared/db/schema"
import type { Role } from "@/shared/types/permissions"
import { resolvePrimaryRole } from "@/shared/lib/role-utils"
import { cacheFn } from "@/shared/lib/cache"
import type { OnboardingRoleInfo, OnboardingStatus, BindParentToChildParams } from "./types"
/**
@@ -16,7 +17,7 @@ import type { OnboardingRoleInfo, OnboardingStatus, BindParentToChildParams } fr
*
* 角色来源usersToRoles管理员预分配onboarding 不写此表。
*/
export async function getOnboardingStatus(userId: string): Promise<OnboardingStatus> {
export async function getOnboardingStatusRaw(userId: string): Promise<OnboardingStatus> {
const [userRow, roleRows] = await Promise.all([
db.query.users.findFirst({
where: eq(users.id, userId),
@@ -42,6 +43,12 @@ export async function getOnboardingStatus(userId: string): Promise<OnboardingSta
}
}
export const getOnboardingStatus = cacheFn(getOnboardingStatusRaw, {
tags: ["onboarding"],
ttl: 300,
keyParts: ["onboarding", "status"],
})
/**
* 更新用户基础资料(姓名/电话/住址)。
* 不涉及角色与班级绑定,独立可复用。

View File

@@ -1,13 +1,13 @@
"use client"
import * as React from "react"
import { useVirtualizer } from "@tanstack/react-virtual"
import { useTranslations } from "next-intl"
import {
ColumnDef,
flexRender,
getCoreRowModel,
useReactTable,
getPaginationRowModel,
SortingState,
getSortedRowModel,
getFilteredRowModel,
@@ -22,10 +22,13 @@ import {
TableHeader,
TableRow,
} from "@/shared/components/ui/table"
import { Button } from "@/shared/components/ui/button"
import { ChevronLeft, ChevronRight } from "lucide-react"
import { BatchOperations } from "./batch-operations"
/** 虚拟滚动每行的预估高度(px),实际高度由 measureElement 动态测量 */
const ROW_ESTIMATE_PX = 60
/** 虚拟滚动容器最大高度(px) */
const SCROLL_CONTAINER_MAX_HEIGHT_PX = 600
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[]
data: TData[]
@@ -41,12 +44,12 @@ export function QuestionDataTable<TData, TValue>({
const t = useTranslations("questions")
const [sorting, setSorting] = React.useState<SortingState>([])
const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({})
const parentRef = React.useRef<HTMLDivElement>(null)
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
onSortingChange: setSorting,
getSortedRowModel: getSortedRowModel(),
onRowSelectionChange: setRowSelection,
@@ -58,6 +61,15 @@ export function QuestionDataTable<TData, TValue>({
},
})
const { rows } = table.getRowModel()
const virtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => parentRef.current,
estimateSize: () => ROW_ESTIMATE_PX,
overscan: 5,
})
const selectedIds = React.useMemo(() => {
if (!getRowId) return []
return table.getSelectedRowModel().rows.map((row) => getRowId(row.original))
@@ -67,16 +79,28 @@ export function QuestionDataTable<TData, TValue>({
setRowSelection({})
}, [])
const virtualItems = virtualizer.getVirtualItems()
const totalSize = virtualizer.getTotalSize()
const paddingTop = virtualItems.length > 0 ? virtualItems[0].start : 0
const paddingBottom =
virtualItems.length > 0
? totalSize - virtualItems[virtualItems.length - 1].end
: 0
return (
<div className="space-y-4">
{selectedIds.length > 0 && (
<BatchOperations selectedIds={selectedIds} onClearSelection={clearSelection} />
)}
<div className="rounded-md border">
<div
ref={parentRef}
className="overflow-auto rounded-md border"
style={{ maxHeight: SCROLL_CONTAINER_MAX_HEIGHT_PX }}
>
<Table>
<TableHeader>
<TableHeader className="sticky top-0 z-10 bg-card">
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
<TableRow key={headerGroup.id} className="hover:bg-transparent">
{headerGroup.headers.map((header) => {
return (
<TableHead key={header.id}>
@@ -93,22 +117,45 @@ export function QuestionDataTable<TData, TValue>({
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
{rows.length ? (
<>
{paddingTop > 0 && (
<tr aria-hidden="true">
<td
style={{ height: paddingTop, padding: 0, border: 0 }}
colSpan={columns.length}
/>
</tr>
)}
{virtualItems.map((virtualItem) => {
const row = rows[virtualItem.index]
return (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
data-index={virtualItem.index}
ref={virtualizer.measureElement}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
)
})}
{paddingBottom > 0 && (
<tr aria-hidden="true">
<td
style={{ height: paddingBottom, padding: 0, border: 0 }}
colSpan={columns.length}
/>
</tr>
)}
</>
) : (
<TableRow>
<TableCell
@@ -129,26 +176,6 @@ export function QuestionDataTable<TData, TValue>({
total: table.getFilteredRowModel().rows.length,
})}
</div>
<div className="space-x-2">
<Button
variant="outline"
size="sm"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<ChevronLeft className="h-4 w-4" />
{t("table.previous")}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
{t("table.next")}
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
</div>
)

View File

@@ -4,6 +4,7 @@ import { and, asc, desc, eq, inArray, isNull, or, type SQL } from "drizzle-orm"
import { createId } from "@paralleldrive/cuid2"
import { db } from "@/shared/db"
import { cacheFn } from "@/shared/lib/cache"
import {
classes,
classSchedule,
@@ -41,7 +42,7 @@ const mapRule = (r: typeof schedulingRules.$inferSelect): SchedulingRule => ({
updatedAt: r.updatedAt.toISOString(),
})
export async function getSchedulingRules(classId?: string): Promise<SchedulingRule[]> {
export async function getSchedulingRulesRaw(classId?: string): Promise<SchedulingRule[]> {
const conditions: SQL[] = []
if (classId) {
const cond = or(eq(schedulingRules.classId, classId), isNull(schedulingRules.classId))
@@ -57,6 +58,12 @@ export async function getSchedulingRules(classId?: string): Promise<SchedulingRu
return rows.map(mapRule)
}
export const getSchedulingRules = cacheFn(getSchedulingRulesRaw, {
tags: ["scheduling"],
ttl: 300,
keyParts: ["scheduling", "rules"],
})
export async function upsertSchedulingRules(data: SchedulingRuleInput): Promise<string> {
const [existing] = await db
.select()
@@ -98,7 +105,7 @@ export async function upsertSchedulingRules(data: SchedulingRuleInput): Promise<
return id
}
export async function getScheduleChanges(
export async function getScheduleChangesRaw(
params: ScheduleChangeQueryParams
): Promise<ScheduleChangeListItem[]> {
const conditions: SQL[] = []
@@ -164,6 +171,12 @@ export async function getScheduleChanges(
}))
}
export const getScheduleChanges = cacheFn(getScheduleChangesRaw, {
tags: ["scheduling"],
ttl: 300,
keyParts: ["scheduling", "changes"],
})
export async function createScheduleChange(
data: ScheduleChangeInput,
requestedBy: string
@@ -197,7 +210,7 @@ export async function updateScheduleChangeStatus(
.where(eq(scheduleChanges.id, id))
}
export async function getClassConflicts(classId: string): Promise<ScheduleConflict[]> {
export async function getClassConflictsRaw(classId: string): Promise<ScheduleConflict[]> {
const rows = await db
.select({
id: classSchedule.id,
@@ -260,14 +273,26 @@ export type SchedulingClassSubject = {
teacherId: string | null
}
export async function getAdminClassesForScheduling(): Promise<SchedulingClassOption[]> {
export const getClassConflicts = cacheFn(getClassConflictsRaw, {
tags: ["scheduling"],
ttl: 300,
keyParts: ["scheduling", "conflicts"],
})
export async function getAdminClassesForSchedulingRaw(): Promise<SchedulingClassOption[]> {
return await db
.select({ id: classes.id, name: classes.name, grade: classes.grade })
.from(classes)
.orderBy(classes.grade, classes.name)
}
export async function getTeachersForScheduling(): Promise<SchedulingTeacherOption[]> {
export const getAdminClassesForScheduling = cacheFn(getAdminClassesForSchedulingRaw, {
tags: ["scheduling"],
ttl: 300,
keyParts: ["scheduling", "admin-classes"],
})
export async function getTeachersForSchedulingRaw(): Promise<SchedulingTeacherOption[]> {
return await db
.select({ id: users.id, name: users.name, email: users.email })
.from(users)
@@ -276,14 +301,26 @@ export async function getTeachersForScheduling(): Promise<SchedulingTeacherOptio
.orderBy(users.name)
}
export async function getClassroomsForScheduling(): Promise<SchedulingClassroomOption[]> {
export const getTeachersForScheduling = cacheFn(getTeachersForSchedulingRaw, {
tags: ["scheduling"],
ttl: 300,
keyParts: ["scheduling", "teachers"],
})
export async function getClassroomsForSchedulingRaw(): Promise<SchedulingClassroomOption[]> {
return await db
.select({ id: classrooms.id, name: classrooms.name, building: classrooms.building })
.from(classrooms)
.orderBy(classrooms.name)
}
export async function getClassSubjectsForScheduling(
export const getClassroomsForScheduling = cacheFn(getClassroomsForSchedulingRaw, {
tags: ["scheduling"],
ttl: 300,
keyParts: ["scheduling", "classrooms"],
})
export async function getClassSubjectsForSchedulingRaw(
classId: string
): Promise<SchedulingClassSubject[]> {
return await db
@@ -297,6 +334,12 @@ export async function getClassSubjectsForScheduling(
.where(eq(classSubjectTeachers.classId, classId))
}
export const getClassSubjectsForScheduling = cacheFn(getClassSubjectsForSchedulingRaw, {
tags: ["scheduling"],
ttl: 300,
keyParts: ["scheduling", "class-subjects"],
})
// ---------------------------------------------------------------------------
// Unified classSchedule write entry points
// ---------------------------------------------------------------------------
@@ -416,6 +459,12 @@ export type ScheduleEntry = {
* implementation should join classSchedule with classes/users to
* populate teacherName/className/subject/room.
*/
export async function getScheduleEntriesForAdmin(): Promise<ScheduleEntry[]> {
export async function getScheduleEntriesForAdminRaw(): Promise<ScheduleEntry[]> {
return []
}
export const getScheduleEntriesForAdmin = cacheFn(getScheduleEntriesForAdminRaw, {
tags: ["scheduling"],
ttl: 300,
keyParts: ["scheduling", "entries", "admin"],
})

View File

@@ -0,0 +1,260 @@
import "server-only"
import { and, desc, eq, like, or, sql } from "drizzle-orm"
import { db } from "@/shared/db"
import { announcements, exams, questions, textbooks } from "@/shared/db/schema"
import { cacheFn } from "@/shared/lib/cache"
import type { SearchResultItem } from "./types"
/**
* 全文检索数据访问层。
*
* 设计说明:
* - 本模块封装跨 4 张表的模糊查询,供 `app/api/search/route.ts` 调用
* - 各查询函数返回 `SearchResultItem[]`,路由层负责聚合与分页
* - 单个查询失败时返回空数组,避免拖垮整体搜索
* - P3-6questions 表使用 FULLTEXT 索引content_text 生成列)+ MATCH AGAINST BOOLEAN MODE
* 其他表textbooks/exams/announcements仍用 LIKE 模糊匹配
*
* 安全:
* - 调用方需通过 `getAuthContext()` 校验身份
* - 角色过滤(学生不能搜索题目/考试)由调用方在路由层处理
*/
const DEFAULT_PAGE_SIZE = 10
/**
* 从 JSON 内容字段提取纯文本(用于题目内容展示)。
*/
function extractTextFromJson(content: unknown): string {
if (typeof content === "string") return content
try {
return JSON.stringify(content)
} catch {
return ""
}
}
/**
* 移除 HTML 标签(用于公告内容摘要)。
*/
function stripHtml(html: string): string {
return html.replace(/<[^>]*>/g, "").replace(/\s+/g, " ").trim()
}
/**
* 截断字符串并附加省略号。
*/
function truncate(s: string, max: number): string {
const t = s.trim()
if (t.length <= max) return t
return t.slice(0, max) + "..."
}
/**
* P3-6: 将原始查询字符串转换为 MySQL FULLTEXT BOOLEAN MODE 查询格式。
*
* 转换规则:
* - 按空白拆分为多个词
* - 转义 BOOLEAN MODE 特殊字符(+ - < > ( ) ~ * " '
* - 每个词添加 `+` 前缀(必须出现)和 `*` 后缀(前缀匹配)
* - 例如 "math question" → "+math* +question*"
*
* @param q 原始查询字符串
* @returns BOOLEAN MODE 兼容的查询字符串;空查询返回空串
*/
function toBooleanModeQuery(q: string): string {
const words = q.trim().split(/\s+/).filter(Boolean)
if (words.length === 0) return ""
// 转义 BOOLEAN MODE 特殊字符,避免用户输入破坏查询语法
const escapeWord = (w: string): string => `+${w.replace(/[+\-<>()~*"']/g, " ").trim()}*`
return words.map(escapeWord).filter((w) => w !== "+*").join(" ")
}
/**
* 搜索题目(题库)。
*
* P3-6: 使用 `questions.content_text` STORED 生成列上的 FULLTEXT 索引,
* 通过 `MATCH(content_text) AGAINST(? IN BOOLEAN MODE)` 实现高效全文检索。
* 相比原 `LIKE '%xxx%'` 方案,可利用 FULLTEXT 索引大幅提升大表查询性能。
*
* @param q 原始查询字符串(非 %keyword% 格式)
* @param limit 返回结果上限
*/
export async function searchQuestionsRaw(
q: string,
limit: number = DEFAULT_PAGE_SIZE,
): Promise<SearchResultItem[]> {
const booleanQuery = toBooleanModeQuery(q)
if (!booleanQuery) return []
try {
const rows = await db
.select({
id: questions.id,
content: questions.content,
type: questions.type,
createdAt: questions.createdAt,
})
.from(questions)
.where(sql`MATCH(${questions.contentText}) AGAINST(${booleanQuery} IN BOOLEAN MODE)`)
.orderBy(desc(questions.createdAt))
.limit(limit)
return rows.map((r) => {
const text = extractTextFromJson(r.content)
return {
id: r.id,
title: truncate(text, 80) || `Question (${r.type})`,
snippet: truncate(text, 200),
type: "question" as const,
href: `/admin/questions?id=${r.id}`,
createdAt: r.createdAt.toISOString(),
}
})
} catch {
return []
}
}
export const searchQuestions = cacheFn(searchQuestionsRaw, {
tags: ["search"],
ttl: 300,
keyParts: ["search", "questions"],
})
/**
* 搜索教材。
*/
export async function searchTextbooksRaw(
kw: string,
limit: number = DEFAULT_PAGE_SIZE,
): Promise<SearchResultItem[]> {
try {
const rows = await db
.select({
id: textbooks.id,
title: textbooks.title,
subject: textbooks.subject,
grade: textbooks.grade,
publisher: textbooks.publisher,
createdAt: textbooks.createdAt,
})
.from(textbooks)
.where(
or(
like(textbooks.title, kw),
like(textbooks.subject, kw),
like(textbooks.publisher, kw),
)!,
)
.orderBy(desc(textbooks.createdAt))
.limit(limit)
return rows.map((r) => ({
id: r.id,
title: r.title,
snippet: [r.subject, r.grade, r.publisher].filter(Boolean).join(" · "),
type: "textbook" as const,
href: `/admin/textbooks?id=${r.id}`,
createdAt: r.createdAt.toISOString(),
}))
} catch {
return []
}
}
export const searchTextbooks = cacheFn(searchTextbooksRaw, {
tags: ["search"],
ttl: 300,
keyParts: ["search", "textbooks"],
})
/**
* 搜索试卷。
*/
export async function searchExamsRaw(
kw: string,
limit: number = DEFAULT_PAGE_SIZE,
): Promise<SearchResultItem[]> {
try {
const rows = await db
.select({
id: exams.id,
title: exams.title,
description: exams.description,
status: exams.status,
createdAt: exams.createdAt,
})
.from(exams)
.where(or(like(exams.title, kw), like(exams.description, kw))!)
.orderBy(desc(exams.createdAt))
.limit(limit)
return rows.map((r) => ({
id: r.id,
title: r.title,
snippet: r.description ?? `Status: ${r.status ?? "draft"}`,
type: "exam" as const,
href: `/admin/exams?id=${r.id}`,
createdAt: r.createdAt.toISOString(),
}))
} catch {
return []
}
}
export const searchExams = cacheFn(searchExamsRaw, {
tags: ["search"],
ttl: 300,
keyParts: ["search", "exams"],
})
/**
* 搜索公告(仅返回已发布的公告)。
*/
export async function searchAnnouncementsRaw(
kw: string,
limit: number = DEFAULT_PAGE_SIZE,
): Promise<SearchResultItem[]> {
try {
const rows = await db
.select({
id: announcements.id,
title: announcements.title,
content: announcements.content,
type: announcements.type,
status: announcements.status,
createdAt: announcements.createdAt,
})
.from(announcements)
.where(
and(
eq(announcements.status, "published"),
or(like(announcements.title, kw), like(announcements.content, kw))!,
),
)
.orderBy(desc(announcements.createdAt))
.limit(limit)
return rows.map((r) => ({
id: r.id,
title: r.title,
snippet: truncate(stripHtml(r.content), 200),
type: "announcement" as const,
href: `/announcements/${r.id}`,
createdAt: r.createdAt.toISOString(),
}))
} catch {
return []
}
}
export const searchAnnouncements = cacheFn(searchAnnouncementsRaw, {
tags: ["search"],
ttl: 300,
keyParts: ["search", "announcements"],
})
export const DEFAULT_SEARCH_PAGE_SIZE = DEFAULT_PAGE_SIZE

View File

@@ -0,0 +1,31 @@
/**
* Search 模块类型定义。
*/
export type SearchType = "all" | "question" | "textbook" | "exam" | "announcement"
export interface SearchResultItem {
id: string
title: string
snippet: string
type: "question" | "textbook" | "exam" | "announcement"
href: string
createdAt: string
}
export interface SearchResponse {
success: boolean
query: string
type: SearchType
results: SearchResultItem[]
total: number
page: number
pageSize: number
}
export const isSearchType = (v: string): v is SearchType =>
v === "all" ||
v === "question" ||
v === "textbook" ||
v === "exam" ||
v === "announcement"

View File

@@ -5,6 +5,7 @@ import {
upsertSystemSetting,
} from "@/modules/settings/data-access-system-settings"
import { DEFAULT_BRAND_CONFIG, type BrandConfig } from "@/modules/settings/brand-config"
import { cacheFn } from "@/shared/lib/cache"
export type { BrandConfig }
export { DEFAULT_BRAND_CONFIG }
@@ -22,7 +23,7 @@ const BRAND_KEYS = {
*
* 从 system_settings 表 brand 分类读取,未配置项使用默认值。
*/
export async function getBrandConfig(): Promise<BrandConfig> {
export async function getBrandConfigRaw(): Promise<BrandConfig> {
const [schoolNameRow, logoUrlRow, quoteRow, authorRow] = await Promise.all([
getSystemSetting("brand", BRAND_KEYS.schoolName),
getSystemSetting("brand", BRAND_KEYS.logoUrl),
@@ -38,6 +39,12 @@ export async function getBrandConfig(): Promise<BrandConfig> {
}
}
export const getBrandConfig = cacheFn(getBrandConfigRaw, {
tags: ["settings"],
ttl: 600,
keyParts: ["settings", "brand-config"],
})
/**
* 保存品牌配置audit-P2-6 新增)
*/

View File

@@ -4,6 +4,7 @@ import { getStudentClasses, getStudentSchedule } from "@/modules/classes/data-ac
import { getStudentHomeworkAssignments } from "@/modules/homework/data-access-student"
import { getStudentDashboardGrades } from "@/modules/homework/stats-service"
import { getTeacherClasses, getTeacherTeachingSubjects } from "@/modules/classes/data-access"
import { cacheFn } from "@/shared/lib/cache"
/**
* Profile 概览数据访问层
@@ -16,7 +17,7 @@ import { getTeacherClasses, getTeacherTeachingSubjects } from "@/modules/classes
/**
* 获取学生概览所需的所有数据(并行查询)
*/
export async function getStudentProfileOverviewData(userId: string): Promise<{
export async function getStudentProfileOverviewDataRaw(userId: string): Promise<{
classes: Awaited<ReturnType<typeof getStudentClasses>>
schedule: Awaited<ReturnType<typeof getStudentSchedule>>
assignments: Awaited<ReturnType<typeof getStudentHomeworkAssignments>>
@@ -30,11 +31,16 @@ export async function getStudentProfileOverviewData(userId: string): Promise<{
])
return { classes, schedule, assignments, grades }
}
export const getStudentProfileOverviewData = cacheFn(getStudentProfileOverviewDataRaw, {
tags: ["settings:students"],
ttl: 60,
keyParts: ["settings", "profile-overview", "student"],
})
/**
* 获取教师概览所需的所有数据(并行查询)
*/
export async function getTeacherProfileOverviewData(): Promise<{
export async function getTeacherProfileOverviewDataRaw(): Promise<{
subjects: Awaited<ReturnType<typeof getTeacherTeachingSubjects>>
classes: Awaited<ReturnType<typeof getTeacherClasses>>
}> {
@@ -44,3 +50,8 @@ export async function getTeacherProfileOverviewData(): Promise<{
])
return { subjects, classes }
}
export const getTeacherProfileOverviewData = cacheFn(getTeacherProfileOverviewDataRaw, {
tags: ["settings"],
ttl: 600,
keyParts: ["settings", "profile-overview", "teacher"],
})

View File

@@ -4,6 +4,7 @@ import { and, eq } from "drizzle-orm"
import { db } from "@/shared/db"
import { systemSettings } from "@/shared/db/schema"
import { cacheFn } from "@/shared/lib/cache"
// --- System Settings operations ---
@@ -42,7 +43,7 @@ export interface SystemSettingRecord {
/**
* 获取指定分类下所有设置项
*/
export async function getSystemSettingsByCategory(
export async function getSystemSettingsByCategoryRaw(
category: SystemSettingCategory
): Promise<SystemSettingRecord[]> {
const rows = await db
@@ -52,18 +53,30 @@ export async function getSystemSettingsByCategory(
return rows
}
export const getSystemSettingsByCategory = cacheFn(getSystemSettingsByCategoryRaw, {
tags: ["settings"],
ttl: 300,
keyParts: ["settings", "system-settings", "by-category"],
})
/**
* 获取所有系统设置项
*/
export async function getAllSystemSettings(): Promise<SystemSettingRecord[]> {
export async function getAllSystemSettingsRaw(): Promise<SystemSettingRecord[]> {
const rows = await db.select().from(systemSettings)
return rows
}
export const getAllSystemSettings = cacheFn(getAllSystemSettingsRaw, {
tags: ["settings"],
ttl: 300,
keyParts: ["settings", "system-settings", "all"],
})
/**
* 获取单个设置项
*/
export async function getSystemSetting(
export async function getSystemSettingRaw(
category: SystemSettingCategory,
key: string
): Promise<SystemSettingRecord | null> {
@@ -75,6 +88,12 @@ export async function getSystemSetting(
return row ?? null
}
export const getSystemSetting = cacheFn(getSystemSettingRaw, {
tags: ["settings"],
ttl: 600,
keyParts: ["settings", "system-settings", "by-key"],
})
/**
* 插入或更新设置项upsert
*/

View File

@@ -4,6 +4,7 @@ import { count, desc, eq, or } from "drizzle-orm"
import { db } from "@/shared/db"
import { aiProviders, passwordSecurity, users } from "@/shared/db/schema"
import { cacheFn } from "@/shared/lib/cache"
import type {
AiProviderExisting,
@@ -19,7 +20,7 @@ import type {
*
* 返回所有 public 与 private 记录,供管理员在 /admin/ai-settings 中管理。
*/
export async function getAiProviderSummaries(): Promise<AiProviderSummary[]> {
export async function getAiProviderSummariesRaw(): Promise<AiProviderSummary[]> {
const rows = await db
.select({
id: aiProviders.id,
@@ -37,6 +38,12 @@ export async function getAiProviderSummaries(): Promise<AiProviderSummary[]> {
return rows
}
export const getAiProviderSummaries = cacheFn(getAiProviderSummariesRaw, {
tags: ["settings"],
ttl: 300,
keyParts: ["settings", "ai-providers", "summaries"],
})
/**
* 获取当前用户可见的 AI Provider用户视图
*
@@ -46,7 +53,7 @@ export async function getAiProviderSummaries(): Promise<AiProviderSummary[]> {
*
* @param userId 当前用户 ID
*/
export async function getAiProviderSummariesForUser(
export async function getAiProviderSummariesForUserRaw(
userId: string
): Promise<AiProviderSummary[]> {
const rows = await db
@@ -72,7 +79,13 @@ export async function getAiProviderSummariesForUser(
return rows
}
export async function countDefaultAiProviders(): Promise<number> {
export const getAiProviderSummariesForUser = cacheFn(getAiProviderSummariesForUserRaw, {
tags: ["settings"],
ttl: 300,
keyParts: ["settings", "ai-providers", "summaries-by-user"],
})
export async function countDefaultAiProvidersRaw(): Promise<number> {
const [row] = await db
.select({ value: count() })
.from(aiProviders)
@@ -80,12 +93,18 @@ export async function countDefaultAiProviders(): Promise<number> {
return Number(row?.value ?? 0)
}
export const countDefaultAiProviders = cacheFn(countDefaultAiProvidersRaw, {
tags: ["settings"],
ttl: 60,
keyParts: ["settings", "ai-providers", "default-count"],
})
/**
* 获取 Provider 用于更新(管理员或创建者视图)
*
* 管理员可访问任意 Provider非管理员仅能访问自己创建的 private Provider。
*/
export async function getAiProviderForUpdate(
export async function getAiProviderForUpdateRaw(
id: string,
userId?: string
): Promise<AiProviderExisting | null> {
@@ -111,6 +130,12 @@ export async function getAiProviderForUpdate(
return row
}
export const getAiProviderForUpdate = cacheFn(getAiProviderForUpdateRaw, {
tags: ["settings"],
ttl: 600,
keyParts: ["settings", "ai-providers", "for-update"],
})
export async function updateAiProvider(
id: string,
data: {

View File

@@ -7,6 +7,7 @@
import "server-only";
import { db } from "@/shared/db";
import { standards, lessonPlanStandards } from "@/shared/db/schema";
import { cacheFn } from "@/shared/lib/cache";
import { and, eq, asc, like } from "drizzle-orm";
import type {
Standard,
@@ -19,7 +20,7 @@ import type { StandardLevel } from "../lesson-preparation/lib/type-guards";
/**
* 查询课标列表(可按层级/学科/年级过滤,可选返回树形结构)
*/
export async function getStandards(
export async function getStandardsRaw(
params: GetStandardsParams = {},
): Promise<Standard[]> {
const conditions = [];
@@ -39,35 +40,51 @@ export async function getStandards(
return rows.map(mapRowToStandard);
}
/**
* 查询课标树形结构
*/
export async function getStandardsTree(
export const getStandards = cacheFn(getStandardsRaw, {
tags: ["standards"],
ttl: 300,
keyParts: ["standards", "list"],
})
export async function getStandardsTreeRaw(
params: Omit<GetStandardsParams, "asTree" | "parentId"> = {},
): Promise<StandardTreeNode[]> {
const allStandards = await getStandards(params);
const allStandards = await getStandardsRaw(params);
return buildStandardsTree(allStandards);
}
/**
* 按 ID 获取单个课标
*/
export async function getStandardById(id: string): Promise<Standard | null> {
export const getStandardsTree = cacheFn(getStandardsTreeRaw, {
tags: ["standards"],
ttl: 300,
keyParts: ["standards", "tree"],
})
export async function getStandardByIdRaw(id: string): Promise<Standard | null> {
const rows = await db.select().from(standards).where(eq(standards.id, id)).limit(1);
return rows.length === 0 ? null : mapRowToStandard(rows[0]!);
}
/**
* 按 code 获取单个课标
*/
export async function getStandardByCode(code: string): Promise<Standard | null> {
export const getStandardById = cacheFn(getStandardByIdRaw, {
tags: ["standards"],
ttl: 600,
keyParts: ["standards", "by-id"],
})
export async function getStandardByCodeRaw(code: string): Promise<Standard | null> {
const rows = await db.select().from(standards).where(eq(standards.code, code)).limit(1);
return rows.length === 0 ? null : mapRowToStandard(rows[0]!);
}
/**
* 创建课标
*/
export const getStandardByCode = cacheFn(getStandardByCodeRaw, {
tags: ["standards"],
ttl: 600,
keyParts: ["standards", "by-code"],
})
export async function createStandard(
input: {
code: string;
@@ -137,7 +154,7 @@ export async function deactivateStandard(id: string): Promise<void> {
/**
* 按关键词搜索课标
*/
export async function searchStandards(
export async function searchStandardsRaw(
keyword: string,
limit = 50,
): Promise<Standard[]> {
@@ -150,12 +167,18 @@ export async function searchStandards(
return rows.map(mapRowToStandard);
}
export const searchStandards = cacheFn(searchStandardsRaw, {
tags: ["standards"],
ttl: 300,
keyParts: ["standards", "search"],
})
// ---- 课案 ↔ 课标 关联 ----
/**
* 查询课案关联的所有课标
*/
export async function getStandardsByPlanId(
export async function getStandardsByPlanIdRaw(
planId: string,
): Promise<LessonPlanStandardLink[]> {
const rows = await db
@@ -179,9 +202,13 @@ export async function getStandardsByPlanId(
}));
}
/**
* 关联课案与课标
*/
export const getStandardsByPlanId = cacheFn(getStandardsByPlanIdRaw, {
tags: ["standards"],
ttl: 300,
keyParts: ["standards", "by-plan"],
})
export async function linkPlanToStandard(
planId: string,
standardId: string,

View File

@@ -0,0 +1,411 @@
"use client"
/**
* ReactFlow 知识图谱内部实现。
*
* 通过 next/dynamic 在外层 `knowledge-graph.tsx` 中懒加载,
* 避免 @xyflow/react (~80-100 KB) 打入首屏 chunk。
* 外层使用 ReactFlowProvider 包裹本组件,保证 useReactFlow() hook 可用。
*/
import { useState, useMemo, useCallback } from "react"
import {
ReactFlow,
Background,
BackgroundVariant,
Controls,
MiniMap,
useReactFlow,
type Node,
type Edge,
} from "@xyflow/react"
import "@xyflow/react/dist/style.css"
import { useTranslations } from "next-intl"
import { notify } from "@/shared/lib/notify"
import { Share2 } from "lucide-react"
import { usePermission } from "@/shared/hooks/use-permission"
import { Permissions } from "@/shared/types/permissions"
import { EmptyState } from "@/shared/components/ui/empty-state"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/shared/components/ui/dialog"
import { Button } from "@/shared/components/ui/button"
import type { GraphLayoutMode, GraphViewMode, GraphNodeData } from "../types"
import { computeGraphLayout } from "../graph-layout"
import type { GraphLayoutNodeData } from "../graph-layout"
import { useGraphData } from "../hooks/use-graph-data"
import {
createPrerequisiteAction,
deletePrerequisiteAction,
} from "../actions"
import { GraphKpNode } from "./graph-kp-node"
import { GraphPrerequisiteEdge } from "./graph-prerequisite-edge"
import { GraphToolbar } from "./graph-toolbar"
import { GraphNodeDetailPanel } from "./graph-node-detail-panel"
import { ForceKnowledgeGraph } from "./force-graph"
const nodeTypes = { kpNode: GraphKpNode }
const edgeTypes = { prerequisiteEdge: GraphPrerequisiteEdge }
/** 章节颜色调色板 */
const CHAPTER_COLORS = [
"hsl(var(--graph-node-1))", "hsl(var(--graph-node-2))", "hsl(var(--graph-node-3))", "hsl(var(--graph-node-4))",
"hsl(var(--graph-node-5))", "hsl(var(--graph-node-6))", "hsl(var(--graph-node-1))", "hsl(var(--graph-node-2))",
]
export interface KnowledgeGraphProps {
textbookId: string
/** 初始视图模式,默认 structure */
initialViewMode?: GraphViewMode
/** 初始布局模式,默认 hierarchical(分层有向图) */
initialLayoutMode?: GraphLayoutMode
}
export function KnowledgeGraphInner({
textbookId,
initialViewMode = "structure",
initialLayoutMode = "hierarchical",
}: KnowledgeGraphProps) {
const t = useTranslations("textbooks")
const { hasPermission } = usePermission()
const canEdit = hasPermission(Permissions.TEXTBOOK_UPDATE)
const reactFlow = useReactFlow()
const [viewMode, setViewMode] = useState<GraphViewMode>(initialViewMode)
const [layoutMode, setLayoutMode] = useState<GraphLayoutMode>(initialLayoutMode)
const [searchText, setSearchText] = useState("")
const [selectedKpId, setSelectedKpId] = useState<string | null>(null)
// 添加前置依赖对话框状态
const [addPrereqOpen, setAddPrereqOpen] = useState(false)
const [newPrereqId, setNewPrereqId] = useState<string>("")
const [isSavingPrereq, setIsSavingPrereq] = useState(false)
const { data, isLoading, isRefreshing, error, reload } = useGraphData(textbookId, viewMode)
// 教师可查看班级掌握度,学生可查看个人掌握度
const availableViewModes: GraphViewMode[] = canEdit
? ["structure", "class-mastery"]
: ["structure", "student-mastery"]
// 章节颜色映射
const chapterColorMap = useMemo(() => {
const map = new Map<string, string>()
if (!data) return map
const chapterIds = [...new Set(
data.knowledgePoints
.map((kp) => kp.chapterId)
.filter((id): id is string => id !== null),
)]
chapterIds.forEach((id, index) => {
map.set(id, CHAPTER_COLORS[index % CHAPTER_COLORS.length]!)
})
return map
}, [data])
const layout = useMemo(() => {
if (!data) return { nodes: [], edges: [], width: 0, height: 0 }
return computeGraphLayout(data.knowledgePoints)
}, [data])
// 搜索高亮
const matchedIds = useMemo(() => {
if (!searchText || !data) return new Set<string>()
const searchLower = searchText.toLowerCase()
return new Set(
data.knowledgePoints
.filter((kp) => kp.name.toLowerCase().includes(searchLower))
.map((kp) => kp.id),
)
}, [searchText, data])
// 关联节点高亮(选中节点的前置+后置)
const relatedIds = useMemo(() => {
if (!selectedKpId || !data) return new Set<string>()
const related = new Set<string>([selectedKpId])
const selectedKp = data.knowledgePoints.find((kp) => kp.id === selectedKpId)
if (selectedKp) {
for (const id of selectedKp.prerequisiteIds) related.add(id)
for (const kp of data.knowledgePoints) {
if (kp.prerequisiteIds.includes(selectedKpId)) related.add(kp.id)
}
}
return related
}, [selectedKpId, data])
// 从已加载数据计算前置/后置列表(避免 server-only 导入)
const prerequisites = useMemo<{ id: string; name: string; description: string | null }[]>(() => {
if (!selectedKpId || !data) return []
const selectedKp = data.knowledgePoints.find((kp) => kp.id === selectedKpId)
if (!selectedKp) return []
return data.knowledgePoints
.filter((kp) => selectedKp.prerequisiteIds.includes(kp.id))
.map((kp) => ({ id: kp.id, name: kp.name, description: kp.description }))
}, [selectedKpId, data])
const successors = useMemo<{ id: string; name: string; description: string | null }[]>(() => {
if (!selectedKpId || !data) return []
return data.knowledgePoints
.filter((kp) => kp.prerequisiteIds.includes(selectedKpId))
.map((kp) => ({ id: kp.id, name: kp.name, description: kp.description }))
}, [selectedKpId, data])
// 组装 React Flow nodes
const rfNodes: Node[] = useMemo(() => {
return layout.nodes.map((node) => {
const kp = node.data.kp
const mastery = data?.masteryMap[kp.id] ?? null
const isSelected = selectedKpId === node.id
const isHighlighted = !searchText
? (selectedKpId === null || relatedIds.has(node.id))
: matchedIds.has(node.id)
const graphData: GraphNodeData = {
kp,
mastery,
viewMode,
isSelected,
isHighlighted,
chapterColor: chapterColorMap.get(kp.chapterId ?? "") ?? "hsl(var(--muted-foreground))",
}
return {
...node,
data: { ...node.data, graphData },
selected: isSelected,
}
})
}, [layout, data, selectedKpId, relatedIds, matchedIds, searchText, viewMode, chapterColorMap])
// 组装 React Flow edges
const rfEdges: Edge[] = useMemo(() => {
return layout.edges.map((edge) => ({
...edge,
data: {
...edge.data,
isHighlighted: selectedKpId === null || relatedIds.has(edge.source) || relatedIds.has(edge.target),
},
}))
}, [layout, selectedKpId, relatedIds])
const onNodeClick = useCallback((_event: unknown, node: Node) => {
setSelectedKpId(node.id)
}, [])
const resetView = useCallback(() => {
if (layoutMode === "hierarchical") {
reactFlow.fitView({ duration: 300 })
}
setSearchText("")
setSelectedKpId(null)
}, [reactFlow, layoutMode])
const onJumpToKp = useCallback((kpId: string) => {
setSelectedKpId(kpId)
if (layoutMode === "hierarchical") {
reactFlow.fitView({ nodes: [{ id: kpId }], duration: 300 })
}
}, [reactFlow, layoutMode])
// 添加前置依赖
const handleAddPrerequisite = useCallback(async () => {
if (!selectedKpId || !newPrereqId || !textbookId) return
setIsSavingPrereq(true)
try {
const formData = new FormData()
formData.set("knowledgePointId", selectedKpId)
formData.set("prerequisiteKpId", newPrereqId)
formData.set("textbookId", textbookId)
const result = await createPrerequisiteAction(formData)
if (result.success) {
notify.success(t("graph.detail.prerequisiteAdded"))
setAddPrereqOpen(false)
setNewPrereqId("")
reload()
} else {
notify.error(result.message)
}
} catch (e) {
notify.error(e instanceof Error ? e.message : t("graph.detail.prerequisiteAddFailed"))
} finally {
setIsSavingPrereq(false)
}
}, [selectedKpId, newPrereqId, textbookId, t, reload])
// 删除前置依赖
const handleRemovePrerequisite = useCallback(async (prereqId: string) => {
if (!selectedKpId || !textbookId) return
try {
const formData = new FormData()
formData.set("knowledgePointId", selectedKpId)
formData.set("prerequisiteKpId", prereqId)
formData.set("textbookId", textbookId)
const result = await deletePrerequisiteAction(formData)
if (result.success) {
notify.success(t("graph.detail.prerequisiteRemoved"))
reload()
} else {
notify.error(result.message)
}
} catch (e) {
notify.error(e instanceof Error ? e.message : t("graph.detail.prerequisiteRemoveFailed"))
}
}, [selectedKpId, textbookId, t, reload])
// 可选的前置知识点(排除自身和已是前置的)
const availablePrereqs = useMemo(() => {
if (!data || !selectedKpId) return []
const existing = new Set(data.knowledgePoints.find((kp) => kp.id === selectedKpId)?.prerequisiteIds ?? [])
return data.knowledgePoints.filter((kp) =>
kp.id !== selectedKpId && !existing.has(kp.id),
)
}, [data, selectedKpId])
if (isLoading && !data) {
return (
<div className="h-full flex items-center justify-center text-sm text-muted-foreground">
{t("reader.loadingKnowledge")}
</div>
)
}
if (error) {
return (
<EmptyState
icon={Share2}
title={t("graph.error.loadFailed")}
description={error}
className="h-full border-none shadow-none bg-transparent"
/>
)
}
if (!data || data.knowledgePoints.length === 0) {
return (
<EmptyState
icon={Share2}
title={t("reader.emptyKnowledge")}
description={t("reader.emptyKnowledgeDesc")}
className="h-full border-none shadow-none bg-transparent"
/>
)
}
const selectedKp = selectedKpId ? data.knowledgePoints.find((kp) => kp.id === selectedKpId) : null
const selectedMastery = selectedKpId ? data.masteryMap[selectedKpId] ?? null : null
return (
<div className="flex h-full">
<div className="flex-1 flex flex-col min-h-0">
<GraphToolbar
viewMode={viewMode}
onViewModeChange={setViewMode}
availableViewModes={availableViewModes}
layoutMode={layoutMode}
onLayoutModeChange={setLayoutMode}
searchText={searchText}
onSearchChange={setSearchText}
onResetView={resetView}
isRefreshing={isRefreshing}
/>
<div className="flex-1 min-h-0 relative">
{layoutMode === "hierarchical" ? (
<ReactFlow
nodes={rfNodes}
edges={rfEdges}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
onNodeClick={onNodeClick}
fitView
fitViewOptions={{ padding: 0.2 }}
minZoom={0.2}
maxZoom={2}
proOptions={{ hideAttribution: true }}
>
<Background variant={BackgroundVariant.Dots} gap={16} size={1} />
<Controls className="!bg-background !border !rounded-lg" />
<MiniMap
className="!bg-background !border !rounded-lg"
nodeColor={(node) => {
// 安全的类型收窄:node.data 是 Record<string, unknown>,
// GraphLayoutNodeData 有索引签名 [key: string]: unknown,是 Record 的子类型
const data = node.data as GraphLayoutNodeData
return data.graphData?.chapterColor ?? "hsl(var(--muted-foreground))"
}}
/>
</ReactFlow>
) : (
<ForceKnowledgeGraph
data={data}
viewMode={viewMode}
searchText={searchText}
selectedKpId={selectedKpId}
onSelectKp={setSelectedKpId}
/>
)}
</div>
</div>
{selectedKp && (
// 任意值 w-[300px]:详情面板固定宽度,保证内容可读性
<div className="w-[300px] shrink-0">
<GraphNodeDetailPanel
kp={selectedKp}
mastery={selectedMastery}
prerequisites={prerequisites}
successors={successors}
canEdit={canEdit}
onClose={() => setSelectedKpId(null)}
onJumpToKp={onJumpToKp}
onAddPrerequisite={() => setAddPrereqOpen(true)}
onRemovePrerequisite={handleRemovePrerequisite}
/>
</div>
)}
{/* 添加前置依赖对话框 */}
<Dialog open={addPrereqOpen} onOpenChange={setAddPrereqOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("graph.detail.addPrerequisiteTitle")}</DialogTitle>
<DialogDescription>{t("graph.detail.addPrerequisiteDesc")}</DialogDescription>
</DialogHeader>
<Select value={newPrereqId} onValueChange={setNewPrereqId}>
<SelectTrigger>
<SelectValue placeholder={t("graph.detail.selectPrerequisite")} />
</SelectTrigger>
<SelectContent>
{availablePrereqs.map((kp) => (
<SelectItem key={kp.id} value={kp.id}>
{kp.name}
</SelectItem>
))}
</SelectContent>
</Select>
<DialogFooter>
<Button variant="outline" onClick={() => setAddPrereqOpen(false)}>
{t("graph.detail.cancel")}
</Button>
<Button
onClick={handleAddPrerequisite}
disabled={!newPrereqId || isSavingPrereq}
>
{isSavingPrereq ? t("graph.detail.saving") : t("graph.detail.confirm")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -47,6 +47,14 @@ export interface RadarSeries {
show?: boolean
}
/** recharts 内联对象常量:避免每次渲染创建新对象触发 re-render */
const POLAR_RADIUS_TICK = { fontSize: 10 }
const POLAR_RADIUS_AXIS_BASE_PROPS = { axisLine: false }
function formatPercentTick(value: number): string {
return `${value}%`
}
interface ComparisonRadarChartProps {
/** 图表数据 */
data: Array<Record<string, string | number>>
@@ -119,9 +127,9 @@ export function ComparisonRadarChart({
<PolarRadiusAxis
domain={domain}
tickCount={tickCount}
tickFormatter={(value: number) => `${value}%`}
tick={{ fontSize: 10 }}
axisLine={false}
tickFormatter={formatPercentTick}
tick={POLAR_RADIUS_TICK}
{...POLAR_RADIUS_AXIS_BASE_PROPS}
/>
<ChartTooltip content={<ChartTooltipContent className={tooltipClassName} />} />
{showLegend ? <Legend /> : null}

View File

@@ -74,6 +74,12 @@ interface SimpleBarChartProps {
const DEFAULT_X_TRUNCATE_LENGTH = 8
/** recharts 内联对象常量:避免每次渲染创建新对象触发 re-render */
const GRID_PROPS = { vertical: false, strokeDasharray: "4 4", strokeOpacity: 0.4 }
const X_AXIS_BASE_PROPS = { tickLine: false, axisLine: false, tickMargin: 8 }
const Y_AXIS_BASE_PROPS = { tickLine: false, axisLine: false }
const DEFAULT_BAR_RADIUS: [number, number, number, number] = [4, 4, 0, 0]
function makeXTickFormatter(truncateLength: number) {
return (value: string): string =>
value.length > truncateLength ? `${value.slice(0, truncateLength)}...` : value
@@ -119,19 +125,16 @@ export function SimpleBarChart({
<ChartContainer config={chartConfig} className={cn(heightClassName, "w-full", className)}>
<BarChart data={data} margin={margin}>
{defs}
<CartesianGrid vertical={false} strokeDasharray="4 4" strokeOpacity={0.4} />
<CartesianGrid {...GRID_PROPS} />
<XAxis
dataKey={xKey}
tickLine={false}
axisLine={false}
tickMargin={8}
{...X_AXIS_BASE_PROPS}
tickFormatter={resolvedXTickFormatter}
/>
<YAxis
domain={yDomain}
allowDecimals={yAllowDecimals}
tickLine={false}
axisLine={false}
{...Y_AXIS_BASE_PROPS}
tickFormatter={yTickFormatter}
width={yWidth}
/>
@@ -150,7 +153,7 @@ export function SimpleBarChart({
key={b.dataKey}
dataKey={b.dataKey}
fill={`var(--color-${b.dataKey})`}
radius={b.radius ?? [4, 4, 0, 0]}
radius={b.radius ?? DEFAULT_BAR_RADIUS}
>
{hasCellColors && cellColors
? data.map((entry) => {

View File

@@ -68,6 +68,16 @@ interface TrendLineChartProps {
const DEFAULT_X_TICK_TRUNCATE_LENGTH = 10
/** recharts 内联对象常量:避免每次渲染创建新对象触发 re-render */
const GRID_PROPS = { vertical: false, strokeDasharray: "4 4", strokeOpacity: 0.4 }
const TOOLTIP_CURSOR = {
stroke: "hsl(var(--muted-foreground))",
strokeWidth: 1,
strokeDasharray: "4 4",
}
const X_AXIS_BASE_PROPS = { tickLine: false, axisLine: false, tickMargin: 8 }
const Y_AXIS_BASE_PROPS = { tickLine: false, axisLine: false }
function defaultXTickFormatter(value: string): string {
return value.length > DEFAULT_X_TICK_TRUNCATE_LENGTH
? `${value.slice(0, DEFAULT_X_TICK_TRUNCATE_LENGTH)}...`
@@ -103,27 +113,20 @@ export function TrendLineChart({
return (
<ChartContainer config={chartConfig} className={cn(heightClassName, "w-full", className)}>
<LineChart data={data} margin={margin}>
<CartesianGrid vertical={false} strokeDasharray="4 4" strokeOpacity={0.4} />
<CartesianGrid {...GRID_PROPS} />
<XAxis
dataKey={xKey}
tickLine={false}
axisLine={false}
tickMargin={8}
{...X_AXIS_BASE_PROPS}
tickFormatter={xTickFormatter ?? undefined}
/>
<YAxis
domain={yDomain}
tickLine={false}
axisLine={false}
{...Y_AXIS_BASE_PROPS}
tickFormatter={yTickFormatter}
width={yWidth}
/>
<ChartTooltip
cursor={{
stroke: "hsl(var(--muted-foreground))",
strokeWidth: 1,
strokeDasharray: "4 4",
}}
cursor={TOOLTIP_CURSOR}
content={
<ChartTooltipContent
indicator="line"

Some files were not shown because too many files have changed in this diff Show More