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:
@@ -286,6 +286,105 @@ V5 状态管理统一的后续阶段,针对两类高频性能问题进行细
|
||||
|
||||
---
|
||||
|
||||
## 1.1.6 缓存策略落地专项重构(2026-07-05 新增)
|
||||
|
||||
> **目标**:全栈缓存策略一致性重构 — 服务端 `cacheFn` 双层包装 + 集中式 `INVALIDATION_MAP` 失效编排 + 客户端 `queryKeys` 工厂 + `useActionQuery`/`useActionMutation` 向后兼容双模式 Hook。
|
||||
|
||||
### 架构总览
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ Client(TanStack 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 失效) │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ CacheStore(CACHE_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 模块依赖关系图
|
||||
|
||||
下图展示模块间的实际依赖关系,**标注依赖类型与合规性**。
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"generatedAt": "2026-06-17",
|
||||
"formatVersion": "1.1",
|
||||
"rule": "每次文件修改后须同步更新本文件",
|
||||
"lastUpdate": "性能预算重构专项 Phase 2-4 完成(2026-07-05):Phase 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) 请求级去重 > 跨请求 cache(React cache() not unstable_cache);(3) 批量 SQL > 循环 SQL(INSERT 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 × N(Next.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 + 包装版本,单测可 mock);actions 43 个文件 247 处 revalidatePath 替换为 invalidateFor;INVALIDATION_MAP 覆盖全部 28 个模块。Hook 双模式:useActionQuery 传 queryKey 走 useQuery 跨页共享缓存,不传走旧 useEffect+useState;useActionMutation 传 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": {
|
||||
|
||||
1175
docs/architecture/audit/performance-budget-audit-report.md
Normal file
1175
docs/architecture/audit/performance-budget-audit-report.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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 替换):
|
||||
|
||||
| 组别 | 模块 | 替换数 |
|
||||
|------|------|--------|
|
||||
| 标杆 | 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 }` 三段副作用。`CLIENT_INVALIDATION_MAP` 同步保留客户端可见子集(仅 queryKeys)。
|
||||
|
||||
### 迁移过程中常见错误(速查)
|
||||
|
||||
| 规则 | 正确写法 | 错误写法 |
|
||||
|------|---------|---------|
|
||||
| cacheFn 包装时移除原 `import { cache } from "react"` | 全部改用 `cacheFn` 双导出模式 | 保留 `cache(fn)` 调用但移除 import(tsc 报 `Cannot find name 'cache'`) |
|
||||
| 跨模块接口函数(如 `getExamSubmissionDataForErrorCollection`)不包装 cacheFn | 仅包装本模块自有查询;跨模块接口由对方模块决定 | 给跨模块接口也加 cacheFn(重复包装) |
|
||||
| 多余的 `}` 或 `)` 字符 | 迁移后用 tsc 全量校验 | 手动编辑后未运行 tsc(TS1005/TS1128 语法错误) |
|
||||
| questions/data-access.ts 等大文件分批迁移 | 完整迁移所有 `cache(...)` 调用至 cacheFn | 部分迁移导致 `import { cache }` 已删但调用仍存 |
|
||||
|
||||
|
||||
30
drizzle/0001_questions_fulltext_search.sql
Normal file
30
drizzle/0001_questions_fulltext_search.sql
Normal 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`);
|
||||
11740
drizzle/meta/0001_snapshot.json
Normal file
11740
drizzle/meta/0001_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
72
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
24
src/app/(dashboard)/parent/loading.tsx
Normal file
24
src/app/(dashboard)/parent/loading.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
24
src/app/(dashboard)/student/loading.tsx
Normal file
24
src/app/(dashboard)/student/loading.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
24
src/app/(dashboard)/teacher/loading.tsx
Normal file
24
src/app/(dashboard)/teacher/loading.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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(" ")
|
||||
}
|
||||
|
||||
73
src/app/api/web-vitals/route.ts
Normal file
73
src/app/api/web-vitals/route.ts
Normal 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
68
src/app/providers.tsx
Normal 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 调整:移除 NotifyConfigurator(notify.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>
|
||||
)
|
||||
}
|
||||
62
src/app/web-vitals-reporter.tsx
Normal file
62
src/app/web-vitals-reporter.tsx
Normal 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 <></>;
|
||||
}
|
||||
@@ -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"],
|
||||
})
|
||||
|
||||
331
src/modules/ai/components/ai-assistant-widget-inner.tsx
Normal file
331
src/modules/ai/components/ai-assistant-widget-inner.tsx
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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 />
|
||||
}
|
||||
|
||||
@@ -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]" />} />
|
||||
|
||||
@@ -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"],
|
||||
})
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -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"],
|
||||
})
|
||||
|
||||
@@ -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')。
|
||||
|
||||
@@ -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"],
|
||||
})
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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-access(v3 新增,对标 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"],
|
||||
})
|
||||
|
||||
/**
|
||||
* 撤销邀请码(软删除)。
|
||||
*/
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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"],
|
||||
})
|
||||
|
||||
@@ -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` 包装,请求级 + 跨请求缓存
|
||||
* - 配置缺失时使用默认值(向后兼容)
|
||||
*
|
||||
* 配置项:
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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]} />
|
||||
))}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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]} />
|
||||
))}
|
||||
|
||||
@@ -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"],
|
||||
})
|
||||
|
||||
@@ -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"],
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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"],
|
||||
})
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
334
src/modules/exams/editor/exam-rich-editor-inner.tsx
Normal file
334
src/modules/exams/editor/exam-rich-editor-inner.tsx
Normal 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>
|
||||
);
|
||||
},
|
||||
);
|
||||
26
src/modules/exams/editor/types.ts
Normal file
26
src/modules/exams/editor/types.ts
Normal 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"
|
||||
@@ -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"],
|
||||
})
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"],
|
||||
})
|
||||
|
||||
@@ -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)。
|
||||
|
||||
@@ -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"],
|
||||
})
|
||||
|
||||
/**
|
||||
* 删除成绩录入草稿(提交成功后调用)。
|
||||
|
||||
@@ -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"],
|
||||
})
|
||||
|
||||
@@ -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"],
|
||||
})
|
||||
|
||||
@@ -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"],
|
||||
})
|
||||
|
||||
@@ -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"],
|
||||
})
|
||||
|
||||
@@ -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"],
|
||||
})
|
||||
|
||||
@@ -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"],
|
||||
})
|
||||
|
||||
@@ -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"],
|
||||
})
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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"],
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -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"],
|
||||
})
|
||||
|
||||
@@ -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 新增)
|
||||
*
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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"],
|
||||
})
|
||||
|
||||
@@ -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: 拆分为细粒度 selector(updateNode 与 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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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-render(rerender-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)}
|
||||
/>
|
||||
|
||||
@@ -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 YAGNI,V2 完整实现)
|
||||
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"));
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 评估记录
|
||||
|
||||
@@ -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"] });
|
||||
|
||||
/**
|
||||
* 写入或更新当日分析快照(教师保存版本时触发)
|
||||
*/
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"] });
|
||||
|
||||
/**
|
||||
* 按日期分组日历事件
|
||||
*/
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"] });
|
||||
|
||||
@@ -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"] });
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"] });
|
||||
|
||||
/**
|
||||
* 创建代课教师映射
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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 分钟窗口内)
|
||||
|
||||
68
src/modules/messaging/lib/type-guards.ts
Normal file
68
src/modules/messaging/lib/type-guards.ts
Normal 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"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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/wechat)per-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
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
export { sendNotification, sendBatchNotifications } from "./dispatcher"
|
||||
export {
|
||||
createNotification,
|
||||
createNotifications,
|
||||
getNotifications,
|
||||
markNotificationAsRead,
|
||||
markAllNotificationsAsRead,
|
||||
|
||||
@@ -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"],
|
||||
})
|
||||
|
||||
/**
|
||||
* 更新用户基础资料(姓名/电话/住址)。
|
||||
* 不涉及角色与班级绑定,独立可复用。
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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"],
|
||||
})
|
||||
|
||||
260
src/modules/search/data-access.ts
Normal file
260
src/modules/search/data-access.ts
Normal 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-6:questions 表使用 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
|
||||
31
src/modules/search/types.ts
Normal file
31
src/modules/search/types.ts
Normal 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"
|
||||
@@ -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 新增)
|
||||
*/
|
||||
|
||||
@@ -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"],
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
*/
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
411
src/modules/textbooks/components/knowledge-graph-inner.tsx
Normal file
411
src/modules/textbooks/components/knowledge-graph-inner.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user