Compare commits

...

70 Commits

Author SHA1 Message Date
SpecialX
5d9981fd7d docs(architecture): update impact map, data, audit reports, superpowers docs
Some checks failed
CI / scheduled-backup (push) Has been skipped
CI / backup-verify (push) Has been skipped
CI / weekly-dr-drill (push) Failing after 0s
CI / build-deploy (push) Has been cancelled
CI / security-scan (push) Has been cancelled
- Update 004_architecture_impact_map.md and 005_architecture_data.json

- Add audit reports: data-access-audit-framework-v1, data-access-audit-v1-data.json,

  data-access-audit-v1, g1-g5 audit outputs

- Add superpowers plans and specs (logging-refactor, documentation-system-redesign)

- Update troubleshooting/known-issues.md
2026-07-07 16:23:35 +08:00
SpecialX
7387d70289 chore(scripts): update check-db-state and seed-grade5-chinese scripts
- Update scripts/check-db-state.mjs

- Update scripts/seed-grade5-chinese.ts
2026-07-07 16:22:54 +08:00
SpecialX
fc150e1e14 feat(app,styles): update dashboard pages and global styles
- Update parent: children/[studentId]/page, elective/page, leave/page

- Update student: error-book/student-error-book-list-client

- Update teacher: classes/schedule/schedule-filters, classes/students/students-filters

- Update src/app/globals.css
2026-07-07 16:22:27 +08:00
SpecialX
ebaf03107d feat(modules-admin): update scheduling, school, settings, standards, student, textbooks, users
- scheduling: update auto-schedule-panel, schedule-change-form, schedule-change-list,

  schedule-conflicts-view, scheduling-rules-form, data-access-class-schedule, data-access

- school: update academic-year-view, departments-view, grade-form-dialog, data-access

- settings: update admin-settings-view, ai-provider-settings-card, avatar-upload,

  brand-config-card, notification-preferences-form, password-change-form,

  profile-settings-form, security-recent-logins-section, security-two-factor-section

- standards: update data-access

- student: update student-courses-view

- textbooks: update chapter-sidebar-list, create-chapter-dialog, force-graph,

  graph-kp-node, graph-prerequisite-edge, knowledge-graph-node, textbook-card,

  textbook-form-dialog, textbook-reader, textbook-settings-dialog,

  data-access-graph, data-access, use-kp-create, use-kp-delete, use-kp-update, types

- users: update user-import-dialog
2026-07-07 16:22:07 +08:00
SpecialX
783b8f5484 feat(modules-comm): update messaging, notifications, onboarding, parent, proctoring, questions, rbac
- messaging: update message-compose, message-detail, message-draft-list,

  message-group-compose, message-list, message-report-block,

  message-template-picker, unread-message-badge, data-access

- notifications: update notification-list, data-access, use-notification-stream, preferences

- onboarding: update actions, data-access, use-onboarding-form

- parent: update child-schedule-card, parent-export-button

- proctoring: update data-access

- questions: update batch-operations, create-question-dialog, import-export-buttons,

  question-actions, data-access

- rbac: update actions, permission-catalog
2026-07-07 16:21:00 +08:00
SpecialX
524ecade19 feat(lesson-preparation): major update with data-access splits and new components
- Update ai-feedback-dialog, attachment-picker, blocks/text-study-block

- Update detail-panel (detail-panel, detail-props)

- Update inline-question-editor, lesson-plan-card, lesson-plan-editor,

  lesson-plan-mobile-view, schedule-dialog, version-history-drawer

- Update paper-editor (inline-qa-dialog, paper-context-menu)

- Update structure-tree (structure-tree, tree-node-row)

- Update config/block-registry, hooks (editor-slice, use-lesson-plan-persistence,

  use-node-ai-assist), lib (ai-node-assist, consistency-check, export, type-guards)

- Update publish-service, services/default-question-service

- Update data-access files (ai-evaluation, analytics, attachments, calendar,

  comments, formative, knowledge, review, schedules, templates, versions, main)
2026-07-07 16:20:34 +08:00
SpecialX
2adf61faa8 feat(modules-assessment): update exams, files, grades, homework, invitation-codes, leave-requests
- exams: update assembly (exam-paper-preview, question-bank-list, structure-editor),

  exam-actions, exam-assembly, exam-columns, exam-data-table, exam-form,

  exam-preview-utils, exam-rich-form, editor (exam-nodes-to-editor-doc,

  exam-rich-editor-inner, question-block, selection-toolbar),

  hooks (use-exam-preview-rewrite, use-exam-preview-state, use-exam-preview-tasks,

  use-exam-preview)

- files: update data-access, use-file-batch-operations, use-file-upload

- grades: update batch-grade-entry, excel-import-dialog, export-button,

  grade-record-form, grade-record-list, knowledge-point-mastery-chart,

  report-card-print-action, report-card-print-button, data-access-analytics,

  use-batch-grade-entry-undo, use-draft-lock

- homework: update homework-assignment-form, homework-batch-grading-view,

  homework-grading-view, homework-scan-grading-view, homework-take-view, scan-uploader

- invitation-codes: update generate-invitation-codes-dialog, invitation-codes-view, data-access

- leave-requests: update leave-request-form, leave-review-dialog, data-access
2026-07-07 16:19:46 +08:00
SpecialX
d7017f0e30 feat(modules-classes): update classes, course-plans, dashboard, diagnostic, elective, error-book
- classes: update actions-shared, admin-classes-view, class-detail, class-invitation-manager,

  grade-classes-view, my-classes-grid, schedule dialogs, students-table,

  data-access-admin, data-access-students, data-access-teacher, data-access

- course-plans: update course-plan-detail, course-plan-form, course-plan-item-editor,

  template-picker-dialog, data-access

- dashboard: update dashboard-time-range-filter, use-dashboard-preferences,

  use-dashboard-realtime

- diagnostic: update class-diagnostic-view, report-list, data-access

- elective: update elective-course-form, elective-course-list, student-selection-view,

  data-access-operations, data-access-selections, data-access

- error-book: update add-error-book-dialog, error-book-detail-dialog, review-buttons
2026-07-07 16:19:08 +08:00
SpecialX
b090a815ae feat(modules-edu): update ai, adaptive-practice, announcements, attendance, audit, auth
- ai: update chat-panel, child-summary, error-book-analysis, grading-assist,

  lesson-content-generator, markdown-renderer, question-variant-generator,

  study-path, usage-dashboard, use-ai-chat-stream, use-position-persistence

- adaptive-practice: update practice-result-view, practice-session-view,

  practice-starter, data-access-analytics, lib/type-guards

- announcements: update announcement-card, detail, form, data-access

- attendance: update attendance-record-list, attendance-rules-form,

  attendance-sheet, data-access

- audit: update actions, audit-log-export-button, audit-retention-settings,

  data-access

- auth: update login-form, register-form, data-access
2026-07-07 16:18:29 +08:00
SpecialX
224740ad98 feat(shared): update permissions system, i18n, hooks, and components
- Update permission-bitmap.ts, permissions.ts, resolve-action-error.ts

- Update types/permissions.ts

- Update rbac i18n messages (en, zh-CN)

- Update locale-switcher.tsx

- Update use-action-with-toast.ts hook
2026-07-07 16:17:53 +08:00
SpecialX
b333cda8c5 refactor(data-access,lib): split data-access files and add type-guards across modules
- grades: split analytics into class/overview/student/trend files

- messaging: split into bulk/core/group/reports/templates

- school: split into classrooms/departments/grades/schools/semesters/subjects

- Add lib/type-guards.ts for attendance, classes, elective, exams,

  invitation-codes, leave-requests, notifications, scheduling, school,

  settings, standards, textbooks

- Add lesson-preparation/lib/status-mappers.ts

- Add parent/actions.ts

- Add shared/lib/date-utils.ts
2026-07-07 16:17:24 +08:00
SpecialX
4e6d397d8e docs(architecture): sync logging refactor to 004/005 and known-issues
Task 15: 004 架构影响地图新增 1.1.7 日志系统重构章节(架构图、核心组件表、Request ID 贯穿链路、Edge Runtime 限制、替换范围、环境变量),shared/lib 清单新增 logger.ts/request-context.ts/with-request-context.ts/track-event.ts 更新,hooks 清单新增 use-error-report.ts。

Task 16: 005 架构数据 JSON 新增 3 个 shared/lib 文件节点、1 个 hooks 节点、1 个 apiRoute(/api/client-error)、5 个 dependencyMatrix 依赖关系,JSON 有效性验证通过。

Task 17: known-issues.md 新增二十八、日志系统规则章节,含 5 个规则表(pino 使用、Edge Runtime 限制、Request ID 贯穿、ESLint no-console、客户端错误上报)。
2026-07-07 13:00:00 +08:00
SpecialX
7c1b764b59 feat(logging): wire useErrorReport into all 129 error.tsx boundaries
所有 129 个 error.tsx 客户端错误边界接入 useErrorReport Hook,客户端路由错误自动上报到 /api/client-error 端点,服务端通过 pino logger 统一记录。移除了 7 处 useEffect + console.error 调用。为无 props 的 error.tsx 补全 error 参数解构。
2026-07-07 12:52:32 +08:00
SpecialX
4122175915 feat(logging): add useErrorReport hook and /api/client-error endpoint
Task 12: useErrorReport Hook 使用 navigator.sendBeacon 上报客户端错误到 /api/client-error,降级到 fetch keepalive。节流策略:同一 error digest 在 1 分钟内只上报一次(基于 sessionStorage)。

Task 13: /api/client-error Route Handler 接收客户端错误,用 createModuleLogger('client-error') 记录到 pino 日志流,字段包含 message/stack/digest/path/userAgent/timestamp。
2026-07-07 12:41:41 +08:00
SpecialX
811ad11f9f feat(logging): enable ESLint no-console rule with client exemptions
全局启用 no-console: error 规则,强制服务端 .ts 文件使用 createModuleLogger。豁免场景: scripts/(脚本)、tests/(测试)、src/**/*.tsx(客户端组件,留待 Task 12-14 处理)、src/**/hooks/**/*.ts(客户端 hooks)、src/**/components/**/*.ts(客户端 utils)、src/shared/lib/query-client.ts(被客户端导入)。将 deletes/ 归档目录加入 globalIgnores。
2026-07-07 12:39:21 +08:00
SpecialX
12a766d3ee refactor(logging): replace console.* with module loggers in server modules
34 个服务端文件替换 86 处 console.* 调用为 createModuleLogger。模块覆盖: exams/audit/school/files/classes/notifications/grades/auth/homework/announcements/settings/lesson-preparation。shared lib: cache/redis-store, rate-limit/redis-limiter, redis-client, exam-homework-port。API routes: web-vitals, cron/audit-cleanup, proctoring/event。

web-vitals/route.ts 从 edge runtime 改为 nodejs runtime,因 pino 依赖 Node.js stream 内置模块,不兼容 Edge Runtime (V8 Isolate)。
2026-07-07 12:34:57 +08:00
SpecialX
0a034945d4 refactor(logging): replace console in track-event modules with logger 2026-07-07 12:14:23 +08:00
SpecialX
fefc65702d fix(logging): replace silent audit-logger failures with logger.warn 2026-07-07 12:09:38 +08:00
SpecialX
a75fdcd60d refactor(logging): replace console.error in api-response with logger 2026-07-07 12:06:36 +08:00
SpecialX
2236eb36e7 refactor(logging): replace console.error in action-utils with logger 2026-07-07 12:05:00 +08:00
SpecialX
c44f19aefd feat(logging): inject x-request-id in proxy.ts 2026-07-07 12:01:55 +08:00
SpecialX
dce2561751 feat(logging): add withRequestContext HOF for Server Actions 2026-07-07 11:51:47 +08:00
SpecialX
a208fcc601 feat(logging): add pino logger with createModuleLogger 2026-07-07 11:44:49 +08:00
SpecialX
3bd3ebc12d feat(logging): add request-context with AsyncLocalStorage 2026-07-07 11:34:40 +08:00
SpecialX
c2575960eb feat(logging): add pino dependency and LOG_LEVEL env var 2026-07-07 11:29:12 +08:00
SpecialX
94f098b0f2 perf(phase1-4): 补漏性能预算重构专项遗漏项
Phase 1 配置基线:next.config.ts 新增 experimental.optimizePackageImports(lucide-react/recharts/@xyflow/react/@tiptap/* /@radix-ui/*/date-fns)+ serverExternalPackages 追加 tencentcloud-sdk-nodejs + exceljs;providers.tsx 注入 WebVitalsReporter 闭环 RUM 上报链路(之前组件存在但未注入导致生产环境零性能数据)。

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

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

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

验证:npx tsc --noEmit 零错误;npm run lint 零新增错误(3 errors + 12 warnings 均位于未修改的 pre-existing 文件)。
2026-07-07 00:30:47 +08:00
SpecialX
6104e6a685 docs(architecture): 组件化重构专项遗漏补全 - 更新 005 lastUpdate + known-issues 25.4 节
- 005 JSON: lastUpdate 追加遗漏补全 9 模块 20 组件迁移记录
- known-issues.md: 新增 25.4 遗漏补全规则章节
- 全量验证:49 个重复组件全部删除,modules/*/components/ 下无残留
2026-07-06 22:24:17 +08:00
SpecialX
e76c626779 refactor(classes,audit,school,settings,adaptive-practice,announcements,messaging,questions,student): 组件化重构遗漏补全 - 20 个重复组件迁移
补全未被批次覆盖的 9 个模块的重复组件迁移:
- classes: 迁移 students-filters/schedule-filters/class-skeleton/class-error-boundary
- audit: 迁移 3 个 filters + audit-log-table-skeleton + audit-error-boundary
- school: 迁移 grade-insights-filters/school-skeleton/school-error-boundary
- settings: 迁移 settings-section-error-boundary
- adaptive-practice: 迁移 2 个 practice-stats-cards 到 StatsGrid
- announcements: 迁移 announcement-list-skeleton 到 SkeletonCard
- messaging: 迁移 message-list-skeleton 到 SkeletonCard
- questions: 迁移 question-filters 到 app 层
- student: 迁移 course-filters/student-schedule-filters 到 app 层
- 补充 school/settings 模块 i18n error.boundary* 键
- tsc 零错误
2026-07-06 22:22:23 +08:00
SpecialX
ee10380462 docs(architecture): 组件化重构专项完成 - 同步 005 lastUpdate + known-issues 第 25 章
- 005 JSON: lastUpdate 更新为组件化重构专项全量完成摘要
- known-issues.md: 新增第 25 章(4 个小节:底座使用规则/拆分模式/迁移规则/验证规则)
- 9 个巨型文件全部 ≤500 行,49 个重复组件全部删除
2026-07-06 20:57:14 +08:00
SpecialX
fa68ec0b34 refactor(lesson-preparation,ai,dashboard): 组件化重构第 3 批 - 高风险模块
- lesson-preparation: 拆分 lesson-plan-editor (594→273),新增 toolbar/dialogs/publish-button 子组件 + 持久化 Hook
- ai: 拆分 ai-chat-panel (417→218),新增 messages/input 子组件,保持流式响应状态稳定
- dashboard: 新增 DashboardShell 布局壳,4 个角色 dashboard 统一使用
- 删除 6 个重复组件(filters/skeletons/error-boundaries)
- tsc 零错误
2026-07-06 20:49:54 +08:00
SpecialX
025d4de50d refactor(homework,exams,textbooks): 组件化重构第 2 批 - 中风险模块
- homework: 拆分 take-view (428→330) + grading-view (507→170),新增 4 个子组件
- exams: 拆分 exam-assembly (467→268),新增 3 个子组件 + 1 个工具模块
- textbooks: 拆分 textbook-reader (458→291) + knowledge-graph-inner (411→267),新增 4 个子组件
- textbooks: 删除同名 section-error-boundary.tsx,改用 shared 层
- 删除 3 个重复 filter 组件,迁移到底座
- tsc 零错误
2026-07-06 19:54:39 +08:00
SpecialX
19a05091d3 refactor(grades,attendance,error-book,elective): 组件化重构第 1 批 - 低风险模块
- grades: 拆分 batch-grade-entry (457→374) + grade-record-list (523→231),新增 4 个子组件
- attendance: 迁移 stats-cards/filters 到 StatsGrid/FilterBar 底座
- error-book: 迁移 stats-cards/filters 到 StatsGrid/FilterBar 底座
- elective: 迁移 stats-cards/filters 到 StatsGrid/FilterBar 底座
- 删除 12 个重复组件,新增 app 层 filter 组件就近放置
- tsc 零错误,lint 无新增错误
2026-07-06 19:01:57 +08:00
SpecialX
11ddc8ccbe feat(shared): 组件化重构 Phase 1 - 底座收敛与补全
- 新增 ErrorBoundary 基础类组件 (ui/error-boundary.tsx)
- 新增 StatsGrid 容器 (ui/stats-grid.tsx)
- 扩展 Skeleton 新增 SkeletonCard variant
- 收敛 SectionErrorBoundary/RouteErrorBoundary/WidgetBoundary 为 ErrorBoundary preset
2026-07-06 18:00:08 +08:00
SpecialX
80d98e13e4 docs(plan): 组件化重构专项实施计划 v1 2026-07-06 17:49:14 +08:00
SpecialX
ef2040edf4 docs(spec): 组件化重构专项设计文档 v1 2026-07-06 17:44:15 +08:00
SpecialX
1a34d1f14e ci(perf): 添加 Lighthouse CI 性能预算回归门槛配置
Phase 2 性能预算重构配套 CI:每次 PR 与每日凌晨 3 点对 /login 路由采样 3 次(desktop preset),断言 LCP <= 3000ms、CLS <= 0.1(error 级),FCP/TBT/INP 为 warn 级。失败时阻断合并并触发性能预算审计报告基线复核。
2026-07-06 15:58:49 +08:00
SpecialX
9ce8d6d3fd 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+ 函数。
2026-07-06 15:12:30 +08:00
SpecialX
22c2e6459d refactor(eslint): 移除缓存策略临时豁免(所有模块已迁移至 invalidateFor) 2026-07-05 22:55:50 +08:00
SpecialX
8fff820e1d refactor(actions): 组 C 模块迁移至 invalidateFor(lesson-preparation/elective/announcements/messaging/notifications/settings/rbac) 2026-07-05 22:53:26 +08:00
SpecialX
7cfd85d0f5 refactor(actions): 组 B 模块迁移至 invalidateFor(exams/grades/homework/attendance/leave-requests/diagnostic/error-book/adaptive-practice) 2026-07-05 22:44:38 +08:00
SpecialX
05e68a3dad refactor(actions): 组 A 模块迁移至 invalidateFor(users/school/textbooks/questions/course-plans/standards/scheduling/audit/onboarding/invitation-codes/proctoring/i18n) 2026-07-05 22:43:08 +08:00
SpecialX
0f33484bd2 fix(questions): 完成 questions/data-access.ts 遗漏的 cacheFn 迁移 2026-07-05 22:27:07 +08:00
SpecialX
841b130f4c fix(data-access): 修复 tsc 错误 + 完成 adaptive-practice/elective 迁移至 cacheFn 2026-07-05 22:24:56 +08:00
SpecialX
27374d1b2c refactor(data-access): 组 A 模块迁移至 cacheFn(users/school/rbac/textbooks/questions/course-plans/standards/files/dashboard/parent/proctoring) 2026-07-05 21:56:18 +08:00
SpecialX
6dee6b6299 refactor(data-access): 组 C 模块迁移至 cacheFn(lesson-preparation/elective/announcements/messaging/notifications/audit/onboarding/invitation-codes/scheduling/settings) 2026-07-05 21:49:18 +08:00
SpecialX
cb5b92160c feat(cache): 扩充 INVALIDATION_MAP 覆盖剩余 27 个模块
为 i18n、adaptive-practice、announcements、attendance、audit、course-plans、
diagnostic、elective、error-book、exams、grades、homework、invitation-codes、
leave-requests、lesson-preparation、messaging、notifications、onboarding、
proctoring、questions、rbac、scheduling、school、settings、standards、
textbooks、users 共 27 个模块登记写操作的失效副作用(tags/queryKeys/paths),
便于后续 actions 文件迁移调用 invalidateFor(actionId)。

- 新增 195 个 actionId(含 create/update/delete 基础动作及 batch/import/
  appeal/draft/lock/template/formative/comment/substitute 等特殊写操作)
- tags 采用模块级粒度 ["{module}"],简化迁移
- paths 从原 revalidatePath 调用中提取,动态段用父路径覆盖
- 同步扩充 CLIENT_INVALIDATION_MAP(仅 queryKeys 子集)
- 保持 classes 部分(已有)不变
- 通过 npx tsc --noEmit 与 eslint 零错误验证
2026-07-05 21:28:40 +08:00
SpecialX
99f15ee37a docs(architecture): 同步缓存基础设施 + classes 标杆迁移 + 缓存策略规则
- 004: 补全 shared/lib/cache 9 文件说明(types/memory-store/redis-store/store-factory/cache-fn/invalidation-map/client-invalidation-map/invalidate/index)+ shared/lib/redis-client + shared/lib/query-keys

- 005: shared.lib.exports.functions 补 5 个节点(MemoryCacheStore / RedisCacheStore / CLIENT_INVALIDATION_MAP / getRedisClient / queryKeys)

- known-issues.md 新增「二十四、缓存策略规则」章节,含 14 条规则表与涉及文件清单
2026-07-05 19:04:03 +08:00
SpecialX
a3dc22cb9e refactor(classes): 客户端组件迁移至 useQuery + queryKeys 示范
Task 19: 将 class-invitation-manager.tsx 从 initialCodes prop + 本地 codes state +
手动 Action 调用 + 手动 state 更新模式,迁移至 V5 缓存策略新 API:

- 列表查询: useActionQuery + queryKeys.classes.invitations(classId)
- 撤销 mutation: useActionMutation + actionId "classes.invitation.revoke"
  (成功后由 CLIENT_INVALIDATION_MAP 自动失效 ["classes", "invitations"] 前缀)
- 生成回调: queryClient.invalidateQueries 手动失效本班级 invitations 查询
- 移除 initialCodes prop + codes state + isSubmitting 手动管理

配套改动:
- actions-invitations.ts: 新增并导出 ClassInvitationCodeOption 强类型接口,
  listClassInvitationCodesAction 返回类型从 Array<Record<string, unknown>>
  收窄为 ClassInvitationCodeOption[]
- actions.ts barrel: 补充 type ClassInvitationCodeOption 与遗漏的
  bulkEnrollStudentsAction / bulkAssignSubjectTeachersAction 导出
- 架构文档 004/005 同步更新 hook 消费方与 actions.ts exports 清单
2026-07-05 18:57:10 +08:00
SpecialX
9eb02807a4 feat(eslint): 新增缓存策略规则禁止直接调用 revalidatePath/revalidateTag 2026-07-05 18:44:07 +08:00
SpecialX
80dd67780c refactor(classes): actions 迁移至 invalidateFor 集中编排 2026-07-05 18:38:58 +08:00
SpecialX
220d702b44 refactor(classes): data-access 迁移至 cacheFn + 双导出 raw 版本 2026-07-05 18:27:32 +08:00
SpecialX
dd7a49504f refactor(hooks): useActionQuery/useActionMutation 接入 QueryClient + 向后兼容
- useActionQuery 新增 queryKey 入参模式:传入 queryKey 走 useQuery 跨页共享缓存;不传回退旧 useEffect + useState 模式。Hook 内部始终声明 useQuery/useState/useEffect 以遵守 React Hooks 规则,通过 isCacheMode 切换启用状态

- useActionMutation 新增 mutationFn + actionId 模式:成功后按 CLIENT_INVALIDATION_MAP[actionId] 自动 invalidateQueries;mutate(action?) 参数可选

- toast 替换为 notify(行为等价,便于未来替换 toast 库)

- 同步更新 004/005 架构文档签名说明
2026-07-05 18:15:58 +08:00
SpecialX
0f9d8825e7 docs(cache): 同步 cacheFn 架构图与已知问题速查 2026-07-05 18:07:18 +08:00
SpecialX
d6227d6e6c feat(cache): 实现 cacheFn 双层包装(react.cache + cacheStore) 2026-07-05 18:07:11 +08:00
SpecialX
92913a728f feat(cache): 实现 invalidateFor 三步编排函数 2026-07-05 18:00:09 +08:00
SpecialX
e510f191c9 feat(cache): 新增 query-keys.ts 工厂(classes 模块) 2026-07-05 17:56:04 +08:00
SpecialX
2d49f1bad8 feat(cache): 新增 index.ts 公共 API 聚合导出 2026-07-05 17:55:58 +08:00
SpecialX
6baa60b2a1 feat(cache): 新增 INVALIDATION_MAP 集中式失效映射表(classes 模块) 2026-07-05 17:52:41 +08:00
SpecialX
13409e55f1 feat(cache): 新增 store-factory(CACHE_DRIVER 切换) 2026-07-05 17:52:24 +08:00
SpecialX
1756ac21a8 feat(cache): 新增客户端失效映射子集(queryKeys only) 2026-07-05 17:52:19 +08:00
SpecialX
0058b0b311 feat(cache): 实现 MemoryCacheStore(LRU + TTL + tag 索引) 2026-07-05 17:49:51 +08:00
SpecialX
4174cd61d7 feat(cache): 实现 RedisCacheStore(多实例 + tag 索引 + fail-open) 2026-07-05 17:45:02 +08:00
SpecialX
44f997bad7 refactor(redis): 抽出共享 Redis 客户端单例至 shared/lib/redis-client.ts 2026-07-05 17:40:45 +08:00
SpecialX
fcf89dfb2c feat(cache): 新增 cache/types.ts 类型定义 2026-07-05 17:35:17 +08:00
SpecialX
bc03275262 refactor(cache): 提升 upstash-modules 类型声明至 shared/lib 2026-07-05 17:33:31 +08:00
SpecialX
16ffd44161 feat(cache): 新增 CACHE_DRIVER 环境变量 2026-07-05 17:32:01 +08:00
SpecialX
be31da6223 docs(cache): 缓存策略落地实施计划 + 修正 INVALIDATION_MAP 占位符一致性 2026-07-05 01:37:15 +08:00
SpecialX
8afd7af6dc docs(cache): 缓存策略落地专项设计文档 v1 - 全栈缓存策略一致性(服务端数据缓存+客户端 TanStack Query+失效编排) 2026-07-05 01:30:59 +08:00
SpecialX
9ec1be1528 fix(design-tokens): 补充 semantic-dark.css 缺失的 --radius 令牌
审查发现 semantic-light.css 定义了 --radius 但 semantic-dark.css 缺失,导致明暗不对称。补充 --radius: 0.5rem 与 :root 一致。
2026-07-05 01:09:36 +08:00
SpecialX
214ebec976 refactor(design-tokens): 全量体系化重建设计令牌
Primitive + Semantic 双层令牌架构,HEX->HSL,明暗双份,@theme inline 暴露为 Tailwind 类。

- 新建 src/app/styles/tokens/ 6 个令牌文件(primitive/semantic-light/semantic-dark/lesson-preparation/tailwind-theme/index)
- globals.css 改为 @import 引入,477->258 行
- 清理 91 处 #hex 硬编码颜色 -> hsl(var(--*))
- 清理 10 处硬编码字体 -> var(--font-family-*)
- 清理 100 文件 Tailwind 任意值(Tier 1 映射/Tier 3 注释豁免)
- 清理 M3 Surface 死代码,升级 --lp-* 令牌(HEX->HSL + 暗色补全)
- 新建 ESLint 自定义规则 no-hardcoded-design-tokens(单词边界正则)
- eslint.config.mjs 新增 no-restricted-syntax 禁止 #hex + 自定义规则加载(pathToFileURL)
- 项目规则新增设计令牌规范强制章节
- 架构图 004/005 同步设计令牌体系节点
- known-issues.md 追加设计令牌问题分类(7 个规则表)

验证: tsc --noEmit 0 errors, npm run lint 0 errors/12 warnings(均为既有问题)
2026-07-05 01:03:19 +08:00
880 changed files with 66523 additions and 14013 deletions

View File

@@ -13,6 +13,16 @@ AI_API_KEY=""
AI_BASE_URL="" AI_BASE_URL=""
AI_MODEL="" AI_MODEL=""
# ===== Redis / 缓存配置(可选) =====
# 缓存驱动: memory(默认,单实例 LRU) | redis(分布式,多实例共享)
CACHE_DRIVER=memory
# 速率限制驱动: memory(默认,单实例) | redis(分布式,多实例共享)
RATE_LIMIT_DRIVER=memory
# Upstash Redis REST 凭据(仅 CACHE_DRIVER=redis 或 RATE_LIMIT_DRIVER=redis 时必填)
# 获取方式: 注册 https://upstash.com → 创建数据库 → 复制 REST URL 和 TOKEN
UPSTASH_REDIS_REST_URL=
UPSTASH_REDIS_REST_TOKEN=
# ===== 灾备配置 ===== # ===== 灾备配置 =====
# 异地备份后端类型: s3|oss|nfs|none # 异地备份后端类型: s3|oss|nfs|none
BACKUP_OFFSITE_BACKEND=none BACKUP_OFFSITE_BACKEND=none
@@ -65,3 +75,7 @@ BACKUP_DIR=./backups
RETENTION_DAYS=30 RETENTION_DAYS=30
# 备份校验最小文件大小(字节,默认 1024) # 备份校验最小文件大小(字节,默认 1024)
BACKUP_VERIFY_MIN_SIZE=1024 BACKUP_VERIFY_MIN_SIZE=1024
# ===== 日志配置 =====
# 日志级别debug/info/warn/error默认 info
LOG_LEVEL=info

View File

@@ -0,0 +1,71 @@
name: Lighthouse CI
# 性能预算回归门槛:每次 PR 与每日凌晨 3 点对关键路由采样断言。
# 失败时阻断合并,触发审计报告 docs/architecture/audit/performance-budget-audit-report.md 中基线复核。
on:
pull_request:
branches:
- main
schedule:
- cron: "0 3 * * *" # 每天凌晨 3 点性能采样
workflow_dispatch:
jobs:
lighthouse:
runs-on: CDCD
container: dockerreg.eazygame.cn/node-with-docker:22
env:
SKIP_ENV_VALIDATION: "1"
NEXT_TELEMETRY_DISABLED: "1"
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Cache npm dependencies
uses: actions/cache@v3
id: npm-cache
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
- name: Configure npm proxy
run: |
GATEWAY_IP=$(ip route show | grep default | awk '{print $3}')
if [ -z "$GATEWAY_IP" ]; then
GATEWAY_IP="172.17.0.1"
fi
PROXY_URL="http://$GATEWAY_IP:7890"
npm config set proxy "$PROXY_URL"
npm config set https-proxy "$PROXY_URL"
echo "http_proxy=$PROXY_URL" >> $GITHUB_ENV
echo "https_proxy=$PROXY_URL" >> $GITHUB_ENV
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build
- name: Start production server
run: npm run start &
env:
PORT: "3000"
- name: Wait for server
run: |
for i in {1..30}; do
curl -s -o /dev/null -w "%{http_code}" http://localhost:3000 | grep -q "200\|307\|308" && break
sleep 2
done
- name: Install Lighthouse CI
run: npm install -g @lhci/cli@0.13.x
- name: Run Lighthouse CI
run: lhci autorun --config=./lighthouserc.json --collect.url=http://localhost:3000/login || true
- name: Assert performance budgets
run: lhci assert --config=./lighthouserc.json

View File

@@ -0,0 +1,273 @@
<h2>课文锚点时间线布局</h2>
<p class="subtitle">课文作为主轴,节点锚定到课文位置,形成教学流程时间线</p>
<div class="mockup">
<div class="mockup-header">备课编辑器 — 课文锚点时间线</div>
<div class="mockup-body" style="padding:0;">
<div style="font-family:monospace;font-size:12px;line-height:1.6;">
<!-- 顶部工具栏 -->
<div style="background:#1e293b;color:#e2e8f0;padding:8px 12px;display:flex;justify-content:space-between;align-items:center;">
<div style="display:flex;align-items:center;gap:8px;">
<span>📖</span>
<span style="font-weight:bold;">秋天(第一课时)</span>
<span style="background:#334155;padding:2px 8px;border-radius:3px;font-size:10px;">语文 · 一年级上册 · 第一单元</span>
</div>
<div style="display:flex;align-items:center;gap:8px;">
<span style="font-size:10px;color:#94a3b8;">💾 已保存 · 2 分钟前</span>
<span style="background:#334155;padding:4px 8px;border-radius:3px;font-size:10px;">📋 版本历史</span>
<span style="background:#3b82f6;padding:4px 12px;border-radius:3px;font-size:10px;">💾 保存</span>
</div>
</div>
<!-- 主体:左课文 + 右节点时间线 -->
<div style="display:grid;grid-template-columns:1fr 320px;gap:0;background:#fff;min-height:480px;">
<!-- 左侧:课文正文区(带锚点 gutter -->
<div style="display:grid;grid-template-columns:32px 1fr;background:#fffbeb;border-right:1px solid #e2e8f0;">
<!-- 锚点 gutter显示锚点标记 -->
<div style="background:#fef3c7;border-right:1px solid #fde68a;position:relative;">
<!-- 锚点标记 -->
<div style="position:absolute;top:60px;left:4px;background:#3b82f6;color:#fff;border-radius:50%;width:20px;height:20px;display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:bold;cursor:pointer;box-shadow:0 1px 3px rgba(0,0,0,0.2);">1</div>
<div style="position:absolute;top:140px;left:4px;background:#f59e0b;color:#fff;border-radius:50%;width:20px;height:20px;display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:bold;cursor:pointer;box-shadow:0 1px 3px rgba(0,0,0,0.2);">2</div>
<div style="position:absolute;top:200px;left:4px;background:#0ea5e9;color:#fff;border-radius:50%;width:20px;height:20px;display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:bold;cursor:pointer;box-shadow:0 1px 3px rgba(0,0,0,0.2);">3</div>
<div style="position:absolute;top:280px;left:4px;background:#ec4899;color:#fff;border-radius:50%;width:20px;height:20px;display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:bold;cursor:pointer;box-shadow:0 1px 3px rgba(0,0,0,0.2);">4</div>
<div style="position:absolute;top:360px;left:4px;background:#22c55e;color:#fff;border-radius:50%;width:20px;height:20px;display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:bold;cursor:pointer;box-shadow:0 1px 3px rgba(0,0,0,0.2);">5</div>
</div>
<!-- 课文内容 -->
<div style="padding:16px 20px;font-size:13px;color:#78350f;line-height:2;position:relative;">
<div style="font-size:16px;font-weight:bold;color:#92400e;text-align:center;margin-bottom:16px;">秋天</div>
<p style="margin:0 0 12px 0;">
<span style="background:#dbeafe;border-bottom:2px solid #3b82f6;padding:1px 2px;">天气凉了,树叶黄了,</span>
一片片叶子从树上落下来。
</p>
<p style="margin:0 0 12px 0;">
<span style="background:#fef3c7;border-bottom:2px solid #f59e0b;padding:1px 2px;">天空那么蓝,那么高。</span>
一群大雁往南飞,
</p>
<p style="margin:0 0 12px 0;">
<span style="background:#e0f2fe;border-bottom:2px solid #0ea5e9;padding:1px 2px;">一会儿排成个"人"字,</span>
<span style="background:#fce7f3;border-bottom:2px solid #ec4899;padding:1px 2px;">一会儿排成个"一"字。</span>
</p>
<p style="margin:0 0 12px 0;">
<span style="background:#dcfce7;border-bottom:2px solid #22c55e;padding:1px 2px;">啊!秋天来了!</span>
</p>
<!-- 拖放提示 -->
<div style="margin-top:24px;padding:8px;border:1px dashed #cbd5e1;border-radius:4px;text-align:center;font-size:10px;color:#94a3b8;">
💡 选中文字可"关联节点",或从右侧拖动节点到课文某字前
</div>
</div>
</div>
<!-- 右侧:节点时间线 -->
<div style="background:#f8fafc;padding:12px;overflow-y:auto;">
<div style="font-size:10px;color:#64748b;text-transform:uppercase;letter-spacing:1px;margin-bottom:8px;">教学流程时间线</div>
<!-- 已锚定节点(按课文位置排序) -->
<div style="display:flex;flex-direction:column;gap:6px;">
<!-- 节点 1导入锚定到"天气凉了" -->
<div style="background:#fff;border:1px solid #3b82f6;border-left:3px solid #3b82f6;border-radius:4px;padding:8px;cursor:pointer;">
<div style="display:flex;align-items:center;gap:6px;margin-bottom:4px;">
<span style="background:#3b82f6;color:#fff;border-radius:50%;width:16px;height:16px;display:inline-flex;align-items:center;justify-content:center;font-size:9px;font-weight:bold;">1</span>
<span style="font-size:11px;font-weight:bold;color:#1e3a8a;">💡 导入</span>
<span style="margin-left:auto;font-size:9px;color:#94a3b8;">📍 锚定</span>
</div>
<div style="font-size:10px;color:#64748b;background:#eff6ff;padding:4px 6px;border-radius:3px;">
"天气凉了,树叶黄了" → 提问:你见过秋天的树叶吗?
</div>
</div>
<!-- 节点 2文本研习锚定到"天空那么蓝" -->
<div style="background:#fff;border:1px solid #f59e0b;border-left:3px solid #f59e0b;border-radius:4px;padding:8px;cursor:pointer;">
<div style="display:flex;align-items:center;gap:6px;margin-bottom:4px;">
<span style="background:#f59e0b;color:#fff;border-radius:50%;width:16px;height:16px;display:inline-flex;align-items:center;justify-content:center;font-size:9px;font-weight:bold;">2</span>
<span style="font-size:11px;font-weight:bold;color:#92400e;">📝 文本研习</span>
<span style="margin-left:auto;font-size:9px;color:#94a3b8;">📍 锚定</span>
</div>
<div style="font-size:10px;color:#64748b;background:#fffbeb;padding:4px 6px;border-radius:3px;">
"天空那么蓝,那么高" → 赏析:叠词的运用
</div>
</div>
<!-- 节点 3新授锚定到"人字" -->
<div style="background:#fff;border:1px solid #0ea5e9;border-left:3px solid #0ea5e9;border-radius:4px;padding:8px;cursor:pointer;">
<div style="display:flex;align-items:center;gap:6px;margin-bottom:4px;">
<span style="background:#0ea5e9;color:#fff;border-radius:50%;width:16px;height:16px;display:inline-flex;align-items:center;justify-content:center;font-size:9px;font-weight:bold;">3</span>
<span style="font-size:11px;font-weight:bold;color:#075985;">📚 新授</span>
<span style="margin-left:auto;font-size:9px;color:#94a3b8;">📍 锚定</span>
</div>
<div style="font-size:10px;color:#64748b;background:#f0f9ff;padding:4px 6px;border-radius:3px;">
"一会儿排成个'人'字" → 讲解:大雁南飞
</div>
</div>
<!-- 节点 4练习锚定到"一字" -->
<div style="background:#fff;border:1px solid #ec4899;border-left:3px solid #ec4899;border-radius:4px;padding:8px;cursor:pointer;">
<div style="display:flex;align-items:center;gap:6px;margin-bottom:4px;">
<span style="background:#ec4899;color:#fff;border-radius:50%;width:16px;height:16px;display:inline-flex;align-items:center;justify-content:center;font-size:9px;font-weight:bold;">4</span>
<span style="font-size:11px;font-weight:bold;color:#9f1239;">✏️ 练习</span>
<span style="margin-left:auto;font-size:9px;color:#94a3b8;">📍 锚定</span>
</div>
<div style="font-size:10px;color:#64748b;background:#fdf2f8;padding:4px 6px;border-radius:3px;">
"一会儿排成个'一'字" → 3 道题
</div>
</div>
<!-- 节点 5小结锚定到"秋天来了" -->
<div style="background:#fff;border:1px solid #22c55e;border-left:3px solid #22c55e;border-radius:4px;padding:8px;cursor:pointer;">
<div style="display:flex;align-items:center;gap:6px;margin-bottom:4px;">
<span style="background:#22c55e;color:#fff;border-radius:50%;width:16px;height:16px;display:inline-flex;align-items:center;justify-content:center;font-size:9px;font-weight:bold;">5</span>
<span style="font-size:11px;font-weight:bold;color:#166534;">📌 小结</span>
<span style="margin-left:auto;font-size:9px;color:#94a3b8;">📍 锚定</span>
</div>
<div style="font-size:10px;color:#64748b;background:#f0fdf4;padding:4px 6px;border-radius:3px;">
"啊!秋天来了!" → 总结全文
</div>
</div>
<!-- 分隔线 -->
<div style="border-top:1px dashed #cbd5e1;margin:8px 0;padding-top:8px;">
<div style="font-size:9px;color:#94a3b8;text-transform:uppercase;letter-spacing:1px;margin-bottom:6px;">未锚定节点</div>
</div>
<!-- 未锚定节点 -->
<div style="background:#fff;border:1px dashed #cbd5e1;border-radius:4px;padding:8px;cursor:pointer;opacity:0.7;">
<div style="display:flex;align-items:center;gap:6px;">
<span style="font-size:11px;font-weight:bold;color:#1e3a8a;">🎯 教学目标</span>
<span style="margin-left:auto;font-size:9px;color:#94a3b8;">全局</span>
</div>
</div>
<div style="background:#fff;border:1px dashed #cbd5e1;border-radius:4px;padding:8px;cursor:pointer;opacity:0.7;">
<div style="display:flex;align-items:center;gap:6px;">
<span style="font-size:11px;font-weight:bold;color:#92400e;">⭐ 重难点</span>
<span style="margin-left:auto;font-size:9px;color:#94a3b8;">全局</span>
</div>
</div>
<div style="background:#fff;border:1px dashed #cbd5e1;border-radius:4px;padding:8px;cursor:pointer;opacity:0.7;">
<div style="display:flex;align-items:center;gap:6px;">
<span style="font-size:11px;font-weight:bold;color:#a855f7;">🏠 作业</span>
<span style="margin-left:auto;font-size:9px;color:#94a3b8;">课后</span>
</div>
</div>
<div style="background:#fff;border:1px dashed #cbd5e1;border-radius:4px;padding:8px;cursor:pointer;opacity:0.7;">
<div style="display:flex;align-items:center;gap:6px;">
<span style="font-size:11px;font-weight:bold;color:#6366f1;">📋 板书设计</span>
<span style="margin-left:auto;font-size:9px;color:#94a3b8;">全局</span>
</div>
</div>
<div style="background:#fff;border:1px dashed #cbd5e1;border-radius:4px;padding:8px;cursor:pointer;opacity:0.7;">
<div style="display:flex;align-items:center;gap:6px;">
<span style="font-size:11px;font-weight:bold;color:#64748b;">💭 教学反思</span>
<span style="margin-left:auto;font-size:9px;color:#94a3b8;">课后</span>
</div>
</div>
<!-- 添加节点按钮 -->
<div style="border:1px dashed #94a3b8;border-radius:4px;padding:8px;text-align:center;font-size:10px;color:#64748b;cursor:pointer;margin-top:4px;">
+ 添加节点
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="section" style="margin-top:24px;">
<h3>核心交互:两种锚定方式</h3>
<div class="split">
<div class="mockup">
<div class="mockup-header">方式 1拖动节点到课文某字前</div>
<div class="mockup-body" style="padding:16px;font-family:monospace;font-size:12px;">
<div style="display:flex;gap:12px;">
<div style="background:#f8fafc;padding:8px;border-radius:4px;">
<div style="font-size:9px;color:#94a3b8;margin-bottom:4px;">右侧节点</div>
<div style="background:#fff;border:1px solid #3b82f6;border-left:3px solid #3b82f6;padding:6px;border-radius:3px;cursor:grab;font-size:10px;">💡 导入</div>
</div>
<div style="font-size:18px;color:#94a3b8;align-self:center;"></div>
<div style="background:#fffbeb;padding:8px;border-radius:4px;flex:1;">
<div style="font-size:9px;color:#94a3b8;margin-bottom:4px;">课文</div>
<div style="font-size:11px;color:#78350f;line-height:1.8;">
天气凉了,<span style="background:#dbeafe;border:2px dashed #3b82f6;padding:1px 2px;border-radius:2px;">|</span>树叶黄了,<br>
一片片叶子从树上落下来。
</div>
<div style="font-size:9px;color:#3b82f6;margin-top:4px;">💡 节点锚定到此位置</div>
</div>
</div>
</div>
</div>
<div class="mockup">
<div class="mockup-header">方式 2选中文字 → 关联节点</div>
<div class="mockup-body" style="padding:16px;font-family:monospace;font-size:12px;">
<div style="background:#fffbeb;padding:8px;border-radius:4px;margin-bottom:8px;">
<div style="font-size:9px;color:#94a3b8;margin-bottom:4px;">1. 选中文字</div>
<div style="font-size:11px;color:#78350f;line-height:1.8;">
<span style="background:#fef08a;">天空那么蓝,那么高</span>
</div>
</div>
<div style="font-size:18px;color:#94a3b8;text-align:center;"></div>
<div style="background:#f8fafc;padding:8px;border-radius:4px;margin-top:8px;">
<div style="font-size:9px;color:#94a3b8;margin-bottom:4px;">2. 弹出菜单选择节点</div>
<div style="display:flex;gap:4px;flex-wrap:wrap;">
<span style="background:#fff;border:1px solid #3b82f6;padding:3px 6px;border-radius:3px;font-size:9px;cursor:pointer;">💡 导入</span>
<span style="background:#fff;border:1px solid #f59e0b;padding:3px 6px;border-radius:3px;font-size:9px;cursor:pointer;">📝 文本研习</span>
<span style="background:#fff;border:1px solid #0ea5e9;padding:3px 6px;border-radius:3px;font-size:9px;cursor:pointer;">📚 新授</span>
<span style="background:#fff;border:1px solid #ec4899;padding:3px 6px;border-radius:3px;font-size:9px;cursor:pointer;">✏️ 练习</span>
<span style="background:#fff;border:1px solid #22c55e;padding:3px 6px;border-radius:3px;font-size:9px;cursor:pointer;">📌 小结</span>
<span style="background:#fff;border:1px dashed #94a3b8;padding:3px 6px;border-radius:3px;font-size:9px;cursor:pointer;">+ 新建节点</span>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="section" style="margin-top:24px;">
<h3>数据模型锚点Anchor</h3>
<div style="background:#1e293b;color:#e2e8f0;padding:16px;border-radius:6px;font-family:monospace;font-size:11px;line-height:1.6;">
<div style="color:#94a3b8;">// 节点锚点 — 记录节点与课文位置的关联</div>
<div><span style="color:#f59e0b;">interface</span> <span style="color:#3b82f6;">NodeAnchor</span> {</div>
<div>&nbsp;&nbsp;nodeId: <span style="color:#10b981;">string</span>; <span style="color:#64748b;">// 关联的节点 ID</span></div>
<div>&nbsp;&nbsp;type: <span style="color:#10b981;">"point"</span> | <span style="color:#10b981;">"range"</span>; <span style="color:#64748b;">// 点锚点 or 范围锚点</span></div>
<div>&nbsp;&nbsp;start: <span style="color:#10b981;">number</span>; <span style="color:#64748b;">// 课文纯文本偏移量(字符)</span></div>
<div>&nbsp;&nbsp;end?: <span style="color:#10b981;">number</span>; <span style="color:#64748b;">// range 锚点的结束偏移</span></div>
<div>&nbsp;&nbsp;textPreview?: <span style="color:#10b981;">string</span>; <span style="color:#64748b;">// 锚定文字预览(便于回显)</span></div>
<div>}</div>
<br>
<div style="color:#94a3b8;">// LessonPlanDocument 扩展</div>
<div><span style="color:#f59e0b;">interface</span> <span style="color:#3b82f6;">LessonPlanDocument</span> {</div>
<div>&nbsp;&nbsp;version: <span style="color:#10b981;">3</span>; <span style="color:#64748b;">// 升级到 v3</span></div>
<div>&nbsp;&nbsp;nodes: <span style="color:#3b82f6;">LessonPlanNode</span>[];</div>
<div>&nbsp;&nbsp;edges: <span style="color:#3b82f6;">LessonPlanEdge</span>[]; <span style="color:#64748b;">// 保留:节点间连线</span></div>
<div>&nbsp;&nbsp;anchors: <span style="color:#3b82f6;">NodeAnchor</span>[]; <span style="color:#64748b;">// 新增:节点与课文的锚点</span></div>
<div>}</div>
</div>
</div>
<div class="section">
<h3>这个设计的优势</h3>
<div class="pros-cons">
<div class="pros">
<h4>优势</h4>
<ul>
<li><strong>教学流程可视化</strong>:节点按课文位置排序,天然形成时间线</li>
<li><strong>节点与课文强关联</strong>:每个节点对应课文的哪部分一目了然</li>
<li><strong>双模式锚定</strong>:拖动(点锚点)+ 选文字(范围锚点)</li>
<li><strong>保留连线能力</strong>:节点间仍可连线(如"导入→新授"流程线)</li>
<li><strong>未锚定节点</strong>:目标/重难点/作业/板书/反思等全局节点不强制锚定</li>
</ul>
</div>
<div class="cons">
<h4>需要注意</h4>
<ul>
<li>课文偏移量需基于纯文本Markdown 渲染后需映射)</li>
<li>课文内容变更后锚点可能失效(需重新定位或提示)</li>
<li>数据结构升级到 v3需迁移现有 v2 数据</li>
</ul>
</div>
</div>
</div>

View File

@@ -0,0 +1,292 @@
<h2>画布式锚点布局 — 正文固定 + 节点散布 + 连线关联</h2>
<p class="subtitle">保留 React Flow 画布交互,正文为不可移动但可缩放的中央容器,节点通过连线关联正文锚点</p>
<div class="mockup">
<div class="mockup-header">备课编辑器 — 画布视图(默认状态)</div>
<div class="mockup-body" style="padding:0;background:#f1f5f9;overflow:hidden;">
<div style="font-family:monospace;font-size:12px;line-height:1.6;position:relative;height:560px;">
<!-- 顶部工具栏 -->
<div style="background:#1e293b;color:#e2e8f0;padding:8px 12px;display:flex;justify-content:space-between;align-items:center;z-index:10;position:relative;">
<div style="display:flex;align-items:center;gap:8px;">
<span>📖</span>
<span style="font-weight:bold;">秋天(第一课时)</span>
<span style="background:#334155;padding:2px 8px;border-radius:3px;font-size:10px;">语文 · 一年级上册</span>
</div>
<div style="display:flex;align-items:center;gap:8px;">
<span style="font-size:10px;color:#94a3b8;">💾 已保存</span>
<span style="background:#334155;padding:4px 8px;border-radius:3px;font-size:10px;">📋 版本</span>
<span style="background:#3b82f6;padding:4px 12px;border-radius:3px;font-size:10px;">💾 保存</span>
</div>
</div>
<!-- 画布区域 -->
<div style="position:relative;width:100%;height:520px;background:#f1f5f9;background-image:radial-gradient(#cbd5e1 1px, transparent 1px);background-size:20px 20px;overflow:hidden;">
<!-- SVG 连线层(默认 10% 透明度) -->
<svg style="position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;opacity:0.1;" viewBox="0 0 800 520">
<!-- 节点1(导入) → 正文锚点1 -->
<path d="M 130 120 Q 200 140 280 180" stroke="#3b82f6" stroke-width="2" fill="none" stroke-dasharray="4 4"/>
<circle cx="280" cy="180" r="4" fill="#3b82f6"/>
<!-- 节点2(文本研习) → 正文锚点2 -->
<path d="M 130 220 Q 200 230 280 240" stroke="#f59e0b" stroke-width="2" fill="none" stroke-dasharray="4 4"/>
<circle cx="280" cy="240" r="4" fill="#f59e0b"/>
<!-- 节点3(新授) → 正文锚点3 -->
<path d="M 670 120 Q 600 150 520 200" stroke="#0ea5e9" stroke-width="2" fill="none" stroke-dasharray="4 4"/>
<circle cx="520" cy="200" r="4" fill="#0ea5e9"/>
<!-- 节点4(练习) → 正文锚点4 -->
<path d="M 670 220 Q 600 240 520 260" stroke="#ec4899" stroke-width="2" fill="none" stroke-dasharray="4 4"/>
<circle cx="520" cy="260" r="4" fill="#ec4899"/>
<!-- 节点5(小结) → 正文锚点5 -->
<path d="M 670 340 Q 600 320 520 300" stroke="#22c55e" stroke-width="2" fill="none" stroke-dasharray="4 4"/>
<circle cx="520" cy="300" r="4" fill="#22c55e"/>
<!-- 节点间连线(教学流程) -->
<path d="M 130 140 L 130 200" stroke="#64748b" stroke-width="1.5" fill="none"/>
<path d="M 670 140 L 670 200" stroke="#64748b" stroke-width="1.5" fill="none"/>
<path d="M 670 240 L 670 320" stroke="#64748b" stroke-width="1.5" fill="none"/>
</svg>
<!-- 中央:正文容器(不可移动,可缩放) -->
<div style="position:absolute;left:280px;top:80px;width:240px;background:#fffbeb;border:2px solid #f59e0b;border-radius:8px;padding:12px;box-shadow:0 4px 12px rgba(0,0,0,0.1);">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;border-bottom:1px solid #fde68a;padding-bottom:6px;">
<span style="font-size:11px;font-weight:bold;color:#92400e;">📜 课文正文</span>
<span style="font-size:9px;color:#94a3b8;background:#fef3c7;padding:1px 4px;border-radius:2px;">🔒 固定</span>
</div>
<div style="font-size:13px;color:#78350f;line-height:1.8;">
<div style="text-align:center;font-weight:bold;margin-bottom:6px;">秋天</div>
<p style="margin:0 0 4px 0;">
<span style="background:#dbeafe;border-bottom:2px solid #3b82f6;">天气凉了</span>,树叶黄了,
</p>
<p style="margin:0 0 4px 0;">
<span style="background:#fef3c7;border-bottom:2px solid #f59e0b;">天空那么蓝</span>,那么高。
</p>
<p style="margin:0 0 4px 0;">
一群大雁往南飞,
</p>
<p style="margin:0 0 4px 0;">
一会儿排成个<span style="background:#e0f2fe;border-bottom:2px solid #0ea5e9;">"人"字</span>
</p>
<p style="margin:0 0 4px 0;">
一会儿排成个<span style="background:#fce7f3;border-bottom:2px solid #ec4899;">"一"字</span>
</p>
<p style="margin:0;">
<span style="background:#dcfce7;border-bottom:2px solid #22c55e;">啊!秋天来了!</span>
</p>
</div>
<!-- 缩放控件 -->
<div style="position:absolute;bottom:-12px;right:-12px;background:#fff;border:1px solid #f59e0b;border-radius:50%;width:24px;height:24px;display:flex;align-items:center;justify-content:center;font-size:12px;cursor:pointer;box-shadow:0 2px 4px rgba(0,0,0,0.1);">🔍</div>
</div>
<!-- 左侧节点 -->
<!-- 节点1导入 -->
<div style="position:absolute;left:30px;top:90px;width:140px;background:#fff;border:1px solid #3b82f6;border-left:3px solid #3b82f6;border-radius:6px;padding:8px;box-shadow:0 2px 6px rgba(0,0,0,0.08);cursor:move;">
<div style="display:flex;align-items:center;gap:4px;margin-bottom:4px;">
<span style="background:#3b82f6;color:#fff;border-radius:50%;width:14px;height:14px;display:inline-flex;align-items:center;justify-content:center;font-size:8px;font-weight:bold;">1</span>
<span style="font-size:10px;font-weight:bold;color:#1e3a8a;">💡 导入</span>
</div>
<div style="font-size:9px;color:#64748b;">提问:你见过秋天的树叶吗?</div>
<!-- React Flow Handle 标记 -->
<div style="position:absolute;right:-4px;top:50%;width:8px;height:8px;background:#3b82f6;border-radius:50%;border:1px solid #fff;"></div>
</div>
<!-- 节点2文本研习 -->
<div style="position:absolute;left:30px;top:200px;width:140px;background:#fff;border:1px solid #f59e0b;border-left:3px solid #f59e0b;border-radius:6px;padding:8px;box-shadow:0 2px 6px rgba(0,0,0,0.08);cursor:move;">
<div style="display:flex;align-items:center;gap:4px;margin-bottom:4px;">
<span style="background:#f59e0b;color:#fff;border-radius:50%;width:14px;height:14px;display:inline-flex;align-items:center;justify-content:center;font-size:8px;font-weight:bold;">2</span>
<span style="font-size:10px;font-weight:bold;color:#92400e;">📝 文本研习</span>
</div>
<div style="font-size:9px;color:#64748b;">赏析:叠词的运用</div>
<div style="position:absolute;right:-4px;top:50%;width:8px;height:8px;background:#f59e0b;border-radius:50%;border:1px solid #fff;"></div>
</div>
<!-- 右侧节点 -->
<!-- 节点3新授 -->
<div style="position:absolute;left:630px;top:90px;width:140px;background:#fff;border:1px solid #0ea5e9;border-left:3px solid #0ea5e9;border-radius:6px;padding:8px;box-shadow:0 2px 6px rgba(0,0,0,0.08);cursor:move;">
<div style="display:flex;align-items:center;gap:4px;margin-bottom:4px;">
<span style="background:#0ea5e9;color:#fff;border-radius:50%;width:14px;height:14px;display:inline-flex;align-items:center;justify-content:center;font-size:8px;font-weight:bold;">3</span>
<span style="font-size:10px;font-weight:bold;color:#075985;">📚 新授</span>
</div>
<div style="font-size:9px;color:#64748b;">讲解:大雁南飞</div>
<div style="position:absolute;left:-4px;top:50%;width:8px;height:8px;background:#0ea5e9;border-radius:50%;border:1px solid #fff;"></div>
</div>
<!-- 节点4练习 -->
<div style="position:absolute;left:630px;top:200px;width:140px;background:#fff;border:1px solid #ec4899;border-left:3px solid #ec4899;border-radius:6px;padding:8px;box-shadow:0 2px 6px rgba(0,0,0,0.08);cursor:move;">
<div style="display:flex;align-items:center;gap:4px;margin-bottom:4px;">
<span style="background:#ec4899;color:#fff;border-radius:50%;width:14px;height:14px;display:inline-flex;align-items:center;justify-content:center;font-size:8px;font-weight:bold;">4</span>
<span style="font-size:10px;font-weight:bold;color:#9f1239;">✏️ 练习</span>
</div>
<div style="font-size:9px;color:#64748b;">3 道题</div>
<div style="position:absolute;left:-4px;top:50%;width:8px;height:8px;background:#ec4899;border-radius:50%;border:1px solid #fff;"></div>
</div>
<!-- 节点5小结 -->
<div style="position:absolute;left:630px;top:320px;width:140px;background:#fff;border:1px solid #22c55e;border-left:3px solid #22c55e;border-radius:6px;padding:8px;box-shadow:0 2px 6px rgba(0,0,0,0.08);cursor:move;">
<div style="display:flex;align-items:center;gap:4px;margin-bottom:4px;">
<span style="background:#22c55e;color:#fff;border-radius:50%;width:14px;height:14px;display:inline-flex;align-items:center;justify-content:center;font-size:8px;font-weight:bold;">5</span>
<span style="font-size:10px;font-weight:bold;color:#166534;">📌 小结</span>
</div>
<div style="font-size:9px;color:#64748b;">总结全文</div>
<div style="position:absolute;left:-4px;top:50%;width:8px;height:8px;background:#22c55e;border-radius:50%;border:1px solid #fff;"></div>
</div>
<!-- 顶部全局节点(未锚定) -->
<div style="position:absolute;left:30px;top:10px;width:100px;background:#fff;border:1px dashed #3b82f6;border-radius:6px;padding:6px;box-shadow:0 2px 4px rgba(0,0,0,0.05);opacity:0.8;">
<div style="font-size:9px;font-weight:bold;color:#1e3a8a;">🎯 教学目标</div>
</div>
<div style="position:absolute;left:140px;top:10px;width:100px;background:#fff;border:1px dashed #f59e0b;border-radius:6px;padding:6px;box-shadow:0 2px 4px rgba(0,0,0,0.05);opacity:0.8;">
<div style="font-size:9px;font-weight:bold;color:#92400e;">⭐ 重难点</div>
</div>
<!-- 底部全局节点(未锚定) -->
<div style="position:absolute;left:30px;top:440px;width:100px;background:#fff;border:1px dashed #a855f7;border-radius:6px;padding:6px;box-shadow:0 2px 4px rgba(0,0,0,0.05);opacity:0.8;">
<div style="font-size:9px;font-weight:bold;color:#9333ea;">🏠 作业</div>
</div>
<div style="position:absolute;left:140px;top:440px;width:100px;background:#fff;border:1px dashed #6366f1;border-radius:6px;padding:6px;box-shadow:0 2px 4px rgba(0,0,0,0.05);opacity:0.8;">
<div style="font-size:9px;font-weight:bold;color:#4f46e5;">📋 板书设计</div>
</div>
<div style="position:absolute;left:250px;top:440px;width:100px;background:#fff;border:1px dashed #64748b;border-radius:6px;padding:6px;box-shadow:0 2px 4px rgba(0,0,0,0.05);opacity:0.8;">
<div style="font-size:9px;font-weight:bold;color:#475569;">💭 教学反思</div>
</div>
<!-- React Flow Controls右下角 -->
<div style="position:absolute;bottom:12px;right:12px;background:#fff;border:1px solid #e2e8f0;border-radius:4px;padding:4px;display:flex;flex-direction:column;gap:2px;box-shadow:0 2px 4px rgba(0,0,0,0.05);">
<div style="width:20px;height:20px;display:flex;align-items:center;justify-content:center;cursor:pointer;font-size:14px;border-radius:2px;">+</div>
<div style="width:20px;height:20px;display:flex;align-items:center;justify-content:center;cursor:pointer;font-size:14px;border-radius:2px;"></div>
<div style="width:20px;height:20px;display:flex;align-items:center;justify-content:center;cursor:pointer;font-size:10px;border-radius:2px;"></div>
</div>
<!-- 添加节点按钮(左下角) -->
<div style="position:absolute;bottom:12px;left:12px;background:#3b82f6;color:#fff;padding:6px 12px;border-radius:4px;font-size:10px;cursor:pointer;box-shadow:0 2px 4px rgba(59,130,246,0.3);">
+ 添加节点
</div>
<!-- 透明度提示 -->
<div style="position:absolute;top:12px;right:12px;background:#fff;border:1px solid #e2e8f0;border-radius:4px;padding:4px 8px;font-size:9px;color:#64748b;">
连线默认 10% 透明度 · 选中节点时完整显示
</div>
</div>
</div>
</div>
</div>
<!-- 选中状态对比 -->
<div class="section" style="margin-top:24px;">
<h3>选中节点时的连线显示对比</h3>
<div class="split">
<div class="mockup">
<div class="mockup-header">默认状态 — 连线 10% 透明度</div>
<div class="mockup-body" style="padding:16px;background:#f1f5f9;">
<svg style="width:100%;height:120px;" viewBox="0 0 300 120">
<path d="M 30 60 Q 120 60 270 60" stroke="#3b82f6" stroke-width="2" fill="none" stroke-dasharray="4 4" opacity="0.1"/>
<rect x="10" y="45" width="40" height="30" fill="#fff" stroke="#3b82f6" rx="4"/>
<text x="30" y="64" text-anchor="middle" font-size="10" fill="#1e3a8a">导入</text>
<rect x="250" y="45" width="40" height="30" fill="#fffbeb" stroke="#f59e0b" stroke-width="2" rx="4"/>
<text x="270" y="64" text-anchor="middle" font-size="10" fill="#92400e">课文</text>
</svg>
<div style="text-align:center;font-size:10px;color:#94a3b8;">连线几乎不可见,画布干净</div>
</div>
</div>
<div class="mockup">
<div class="mockup-header">选中"导入"节点 — 连线 100% 显示</div>
<div class="mockup-body" style="padding:16px;background:#f1f5f9;">
<svg style="width:100%;height:120px;" viewBox="0 0 300 120">
<path d="M 30 60 Q 120 60 270 60" stroke="#3b82f6" stroke-width="2.5" fill="none" stroke-dasharray="4 4" opacity="1"/>
<circle cx="270" cy="60" r="5" fill="#3b82f6"/>
<rect x="10" y="45" width="40" height="30" fill="#fff" stroke="#3b82f6" stroke-width="3" rx="4"/>
<text x="30" y="64" text-anchor="middle" font-size="10" fill="#1e3a8a">导入</text>
<rect x="250" y="45" width="40" height="30" fill="#fffbeb" stroke="#f59e0b" stroke-width="2" rx="4"/>
<text x="270" y="64" text-anchor="middle" font-size="10" fill="#92400e">课文</text>
</svg>
<div style="text-align:center;font-size:10px;color:#3b82f6;">连线完整显示,高亮锚点位置</div>
</div>
</div>
</div>
</div>
<!-- 数据模型 -->
<div class="section">
<h3>数据模型设计</h3>
<div style="background:#1e293b;color:#e2e8f0;padding:16px;border-radius:6px;font-family:monospace;font-size:11px;line-height:1.6;">
<div style="color:#94a3b8;">// 正文容器节点(特殊节点类型,不可拖动,可缩放)</div>
<div><span style="color:#f59e0b;">interface</span> <span style="color:#3b82f6;">TextbookContentNode</span> <span style="color:#f59e0b;">extends</span> <span style="color:#3b82f6;">LessonPlanNode</span> {</div>
<div>&nbsp;&nbsp;type: <span style="color:#10b981;">"textbook_content"</span>; <span style="color:#64748b;">// 新增节点类型</span></div>
<div>&nbsp;&nbsp;data: {</div>
<div>&nbsp;&nbsp;&nbsp;&nbsp;chapterId: <span style="color:#10b981;">string</span>; <span style="color:#64748b;">// 关联教材章节</span></div>
<div>&nbsp;&nbsp;&nbsp;&nbsp;content: <span style="color:#10b981;">string</span>; <span style="color:#64748b;">// Markdown 正文(缓存)</span></div>
<div>&nbsp;&nbsp;&nbsp;&nbsp;zoom: <span style="color:#10b981;">number</span>; <span style="color:#64748b;">// 缩放比例 0.5-2.0</span></div>
<div>&nbsp;&nbsp;};</div>
<div>&nbsp;&nbsp;position: { x: <span style="color:#10b981;">number</span>; y: <span style="color:#10b981;">number</span> }; <span style="color:#64748b;">// 固定位置(不可拖动)</span></div>
<div>&nbsp;&nbsp;draggable: <span style="color:#10b981;">false</span>; <span style="color:#64748b;">// React Flow 节点锁定</span></div>
<div>}</div>
<br>
<div style="color:#94a3b8;">// 锚点连线(节点 → 正文位置)</div>
<div><span style="color:#f59e0b;">interface</span> <span style="color:#3b82f6;">AnchorEdge</span> <span style="color:#f59e0b;">extends</span> <span style="color:#3b82f6;">LessonPlanEdge</span> {</div>
<div>&nbsp;&nbsp;type: <span style="color:#10b981;">"anchor"</span>; <span style="color:#64748b;">// 锚点连线vs "flow" 流程连线)</span></div>
<div>&nbsp;&nbsp;source: <span style="color:#10b981;">string</span>; <span style="color:#64748b;">// 节点 ID</span></div>
<div>&nbsp;&nbsp;target: <span style="color:#10b981;">string</span>; <span style="color:#64748b;">// 正文节点 ID</span></div>
<div>&nbsp;&nbsp;targetHandle: <span style="color:#10b981;">string</span>; <span style="color:#64748b;">// "anchor:123:145"(正文偏移量 start:end</span></div>
<div>}</div>
<br>
<div style="color:#94a3b8;">// LessonPlanDocument v3</div>
<div><span style="color:#f59e0b;">interface</span> <span style="color:#3b82f6;">LessonPlanDocument</span> {</div>
<div>&nbsp;&nbsp;version: <span style="color:#10b981;">3</span>;</div>
<div>&nbsp;&nbsp;nodes: <span style="color:#3b82f6;">LessonPlanNode</span>[]; <span style="color:#64748b;">// 含 1 个 textbook_content + N 个教学节点</span></div>
<div>&nbsp;&nbsp;edges: <span style="color:#3b82f6;">AnchorEdge</span> | <span style="color:#3b82f6;">FlowEdge</span>[]; <span style="color:#64748b;">// 锚点连线 + 流程连线</span></div>
<div>}</div>
</div>
</div>
<!-- 交互流程 -->
<div class="section">
<h3>核心交互流程</h3>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">
<div style="background:#fff;border:1px solid #e2e8f0;border-radius:6px;padding:12px;">
<h4 style="margin:0 0 8px 0;color:#3b82f6;">🔗 锚定节点到正文</h4>
<ol style="margin:0;padding-left:16px;font-size:11px;color:#475569;line-height:1.8;">
<li>教师选中正文某段文字(或某个字)</li>
<li>选中后弹出浮动菜单:"关联节点 →"</li>
<li>从下拉列表选择已有节点,或"新建节点"</li>
<li>创建 AnchorEdgesource=节点, target=正文节点, targetHandle="anchor:start:end"</li>
<li>正文对应文字高亮显示(节点颜色)</li>
<li>连线默认 10% 透明,选中节点时 100%</li>
</ol>
</div>
<div style="background:#fff;border:1px solid #e2e8f0;border-radius:6px;padding:12px;">
<h4 style="margin:0 0 8px 0;color:#22c55e;">🖱️ 拖动节点到正文</h4>
<ol style="margin:0;padding-left:16px;font-size:11px;color:#475569;line-height:1.8;">
<li>教师从右侧节点列表拖动一个节点</li>
<li>拖动过程中,正文区域高亮可放置区域</li>
<li>拖到正文某个字前释放</li>
<li>创建点锚点point anchortargetHandle="anchor:pos"</li>
<li>节点自动定位到正文旁边(左或右空位)</li>
<li>连线默认 10% 透明,选中时完整显示</li>
</ol>
</div>
</div>
</div>
<div class="section">
<h3>设计要点</h3>
<div class="pros-cons">
<div class="pros">
<h4>优势</h4>
<ul>
<li><strong>保留画布交互</strong>:缩放/平移/拖动节点,与当前备课模块一致</li>
<li><strong>正文固定居中</strong>:不可拖动,始终是视觉中心</li>
<li><strong>连线语义化</strong>anchor 锚点连线 vs flow 流程连线</li>
<li><strong>透明度策略</strong>:默认 10%,选中时 100%,画布不杂乱</li>
<li><strong>正文可缩放</strong>:教师可放大正文便于阅读</li>
</ul>
</div>
<div class="cons">
<h4>技术挑战</h4>
<ul>
<li>正文偏移量需基于纯文本Markdown 渲染后映射)</li>
<li>正文内容变更后锚点需重新定位</li>
<li>React Flow 自定义节点需处理正文渲染</li>
<li>数据结构升级 v2 → v3需迁移</li>
</ul>
</div>
</div>
</div>

View File

@@ -0,0 +1,226 @@
<h2>备课模块布局方案对比</h2>
<p class="subtitle">3 种布局方案 — 课文固定中央,教学节点围绕组织</p>
<div class="cards" data-multiselect>
<!-- 方案 A -->
<div class="card" data-choice="a" onclick="toggleSelect(this)">
<div class="card-image" style="padding:12px;background:#f8fafc;">
<div style="font-family:monospace;font-size:11px;line-height:1.4;">
<div style="border:1px solid #cbd5e1;background:#e2e8f0;padding:4px 8px;border-radius:4px 4px 0 0;display:flex;justify-content:space-between;">
<span>📖 秋天(第一课时)</span>
<span>💾 已保存</span>
</div>
<div style="display:grid;grid-template-columns:200px 1fr 200px;gap:4px;padding:8px;background:#fff;border:1px solid #cbd5e1;border-top:none;border-radius:0 0 4px 4px;min-height:280px;">
<!-- 左侧:课前 -->
<div style="display:flex;flex-direction:column;gap:4px;">
<div style="font-size:9px;color:#64748b;text-align:center;">课前</div>
<div style="background:#dbeafe;border:1px solid #3b82f6;padding:4px;border-radius:3px;font-size:10px;">🎯 教学目标</div>
<div style="background:#fef3c7;border:1px solid #f59e0b;padding:4px;border-radius:3px;font-size:10px;">⭐ 重难点</div>
<div style="background:#e0f2fe;border:1px solid #0ea5e9;padding:4px;border-radius:3px;font-size:10px;">💡 导入</div>
<div style="background:#fce7f3;border:1px solid #ec4899;padding:4px;border-radius:3px;font-size:10px;">📝 文本研习</div>
</div>
<!-- 中央:课文 -->
<div style="background:#fffbeb;border:2px solid #f59e0b;padding:8px;border-radius:4px;display:flex;flex-direction:column;">
<div style="font-size:10px;color:#92400e;font-weight:bold;margin-bottom:4px;">📜 课文正文</div>
<div style="font-size:9px;color:#78350f;line-height:1.5;flex:1;">
天气凉了,树叶黄了,<br>
一片片叶子从树上落下来。<br>
<span style="background:#fef08a;">天空那么蓝,那么高</span><br>
一群大雁往南飞,<br>
一会儿排成个"人"字,<br>
一会儿排成个"一"字。<br>
<span style="background:#bbf7d0;">啊!秋天来了!</span>
</div>
<div style="font-size:8px;color:#92400e;margin-top:4px;">💡 选中文字可添加批注</div>
</div>
<!-- 右侧:课中/课后 -->
<div style="display:flex;flex-direction:column;gap:4px;">
<div style="font-size:9px;color:#64748b;text-align:center;">课中</div>
<div style="background:#dcfce7;border:1px solid #22c55e;padding:4px;border-radius:3px;font-size:10px;">📚 新授</div>
<div style="background:#ede9fe;border:1px solid #8b5cf6;padding:4px;border-radius:3px;font-size:10px;">✏️ 练习</div>
<div style="background:#fee2e2;border:1px solid #ef4444;padding:4px;border-radius:3px;font-size:10px;">📌 小结</div>
<div style="font-size:9px;color:#64748b;text-align:center;margin-top:4px;">课后</div>
<div style="background:#f3e8ff;border:1px solid #a855f7;padding:4px;border-radius:3px;font-size:10px;">🏠 作业</div>
<div style="background:#e0e7ff;border:1px solid #6366f1;padding:4px;border-radius:3px;font-size:10px;">📋 板书设计</div>
<div style="background:#f1f5f9;border:1px solid #64748b;padding:4px;border-radius:3px;font-size:10px;">💭 教学反思</div>
</div>
</div>
</div>
</div>
<div class="card-body">
<h3>A. 三栏布局(课前/课文/课后)</h3>
<p>课文固定中央(琥珀色边框),左侧"课前"节点(目标/重难点/导入/文本研习),右侧"课中+课后"节点(新授/练习/小结/作业/板书/反思)。节点按教学流程纵向排列。点击节点在右侧抽屉编辑。</p>
</div>
</div>
<!-- 方案 B -->
<div class="card" data-choice="b" onclick="toggleSelect(this)">
<div class="card-image" style="padding:12px;background:#f8fafc;">
<div style="font-family:monospace;font-size:11px;line-height:1.4;">
<div style="border:1px solid #cbd5e1;background:#e2e8f0;padding:4px 8px;border-radius:4px 4px 0 0;display:flex;justify-content:space-between;">
<span>📖 秋天(第一课时)</span>
<span>💾 已保存</span>
</div>
<div style="padding:8px;background:#fff;border:1px solid #cbd5e1;border-top:none;border-radius:0 0 4px 4px;min-height:280px;">
<!-- 顶部:目标/重难点 -->
<div style="display:grid;grid-template-columns:1fr 1fr;gap:4px;margin-bottom:4px;">
<div style="background:#dbeafe;border:1px solid #3b82f6;padding:4px;border-radius:3px;font-size:10px;text-align:center;">🎯 教学目标</div>
<div style="background:#fef3c7;border:1px solid #f59e0b;padding:4px;border-radius:3px;font-size:10px;text-align:center;">⭐ 重难点</div>
</div>
<!-- 中央:课文 + 左右两侧节点 -->
<div style="display:grid;grid-template-columns:120px 1fr 120px;gap:4px;margin-bottom:4px;">
<div style="display:flex;flex-direction:column;gap:4px;">
<div style="background:#e0f2fe;border:1px solid #0ea5e9;padding:4px;border-radius:3px;font-size:9px;text-align:center;">💡 导入</div>
<div style="background:#fce7f3;border:1px solid #ec4899;padding:4px;border-radius:3px;font-size:9px;text-align:center;">📝 文本研习</div>
</div>
<div style="background:#fffbeb;border:2px solid #f59e0b;padding:8px;border-radius:4px;text-align:center;">
<div style="font-size:10px;color:#92400e;font-weight:bold;">📜 课文正文</div>
<div style="font-size:9px;color:#78350f;margin-top:4px;">天气凉了,树叶黄了...<br>天空那么蓝...<br>一群大雁往南飞...</div>
</div>
<div style="display:flex;flex-direction:column;gap:4px;">
<div style="background:#dcfce7;border:1px solid #22c55e;padding:4px;border-radius:3px;font-size:9px;text-align:center;">📚 新授</div>
<div style="background:#ede9fe;border:1px solid #8b5cf6;padding:4px;border-radius:3px;font-size:9px;text-align:center;">✏️ 练习</div>
</div>
</div>
<!-- 底部:小结/作业/板书/反思 -->
<div style="display:grid;grid-template-columns:1fr 1fr 1fr 1fr;gap:4px;">
<div style="background:#fee2e2;border:1px solid #ef4444;padding:4px;border-radius:3px;font-size:9px;text-align:center;">📌 小结</div>
<div style="background:#f3e8ff;border:1px solid #a855f7;padding:4px;border-radius:3px;font-size:9px;text-align:center;">🏠 作业</div>
<div style="background:#e0e7ff;border:1px solid #6366f1;padding:4px;border-radius:3px;font-size:9px;text-align:center;">📋 板书</div>
<div style="background:#f1f5f9;border:1px solid #64748b;padding:4px;border-radius:3px;font-size:9px;text-align:center;">💭 反思</div>
</div>
</div>
</div>
</div>
<div class="card-body">
<h3>B. 上下分区布局</h3>
<p>顶部目标/重难点横排,中央课文 + 左右导入/文本研习/新授/练习,底部小结/作业/板书/反思横排。按"目标→导入→课文→新授→小结"的阅读顺序自然流动。视觉层次更清晰。</p>
</div>
</div>
<!-- 方案 C -->
<div class="card" data-choice="c" onclick="toggleSelect(this)">
<div class="card-image" style="padding:12px;background:#f8fafc;">
<div style="font-family:monospace;font-size:11px;line-height:1.4;">
<div style="border:1px solid #cbd5e1;background:#e2e8f0;padding:4px 8px;border-radius:4px 4px 0 0;display:flex;justify-content:space-between;">
<span>📖 秋天(第一课时)</span>
<span>💾 已保存</span>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:4px;padding:8px;background:#fff;border:1px solid #cbd5e1;border-top:none;border-radius:0 0 4px 4px;min-height:280px;">
<!-- 左侧:课文 -->
<div style="display:flex;flex-direction:column;gap:4px;">
<div style="background:#fffbeb;border:2px solid #f59e0b;padding:8px;border-radius:4px;flex:1;">
<div style="font-size:10px;color:#92400e;font-weight:bold;">📜 课文正文</div>
<div style="font-size:9px;color:#78350f;margin-top:4px;line-height:1.5;">
天气凉了,树叶黄了,<br>
一片片叶子从树上落下来。<br>
<span style="background:#fef08a;">天空那么蓝,那么高</span><br>
一群大雁往南飞...
</div>
</div>
<div style="background:#fce7f3;border:1px solid #ec4899;padding:4px;border-radius:3px;font-size:10px;text-align:center;">📝 文本研习(批注)</div>
</div>
<!-- 右侧:教学流程时间线 -->
<div style="display:flex;flex-direction:column;gap:3px;">
<div style="font-size:9px;color:#64748b;text-align:center;">教学流程</div>
<div style="background:#dbeafe;border:1px solid #3b82f6;padding:3px 6px;border-radius:3px;font-size:9px;display:flex;align-items:center;gap:4px;">
<span style="background:#3b82f6;color:#fff;border-radius:50%;width:14px;height:14px;display:inline-flex;align-items:center;justify-content:center;font-size:8px;">1</span>
🎯 教学目标
</div>
<div style="background:#fef3c7;border:1px solid #f59e0b;padding:3px 6px;border-radius:3px;font-size:9px;display:flex;align-items:center;gap:4px;">
<span style="background:#f59e0b;color:#fff;border-radius:50%;width:14px;height:14px;display:inline-flex;align-items:center;justify-content:center;font-size:8px;">2</span>
⭐ 重难点
</div>
<div style="background:#e0f2fe;border:1px solid #0ea5e9;padding:3px 6px;border-radius:3px;font-size:9px;display:flex;align-items:center;gap:4px;">
<span style="background:#0ea5e9;color:#fff;border-radius:50%;width:14px;height:14px;display:inline-flex;align-items:center;justify-content:center;font-size:8px;">3</span>
💡 导入
</div>
<div style="background:#dcfce7;border:1px solid #22c55e;padding:3px 6px;border-radius:3px;font-size:9px;display:flex;align-items:center;gap:4px;">
<span style="background:#22c55e;color:#fff;border-radius:50%;width:14px;height:14px;display:inline-flex;align-items:center;justify-content:center;font-size:8px;">4</span>
📚 新授
</div>
<div style="background:#ede9fe;border:1px solid #8b5cf6;padding:3px 6px;border-radius:3px;font-size:9px;display:flex;align-items:center;gap:4px;">
<span style="background:#8b5cf6;color:#fff;border-radius:50%;width:14px;height:14px;display:inline-flex;align-items:center;justify-content:center;font-size:8px;">5</span>
✏️ 练习
</div>
<div style="background:#fee2e2;border:1px solid #ef4444;padding:3px 6px;border-radius:3px;font-size:9px;display:flex;align-items:center;gap:4px;">
<span style="background:#ef4444;color:#fff;border-radius:50%;width:14px;height:14px;display:inline-flex;align-items:center;justify-content:center;font-size:8px;">6</span>
📌 小结
</div>
<div style="background:#f3e8ff;border:1px solid #a855f7;padding:3px 6px;border-radius:3px;font-size:9px;display:flex;align-items:center;gap:4px;">
<span style="background:#a855f7;color:#fff;border-radius:50%;width:14px;height:14px;display:inline-flex;align-items:center;justify-content:center;font-size:8px;">7</span>
🏠 作业
</div>
<div style="background:#e0e7ff;border:1px solid #6366f1;padding:3px 6px;border-radius:3px;font-size:9px;display:flex;align-items:center;gap:4px;">
<span style="background:#6366f1;color:#fff;border-radius:50%;width:14px;height:14px;display:inline-flex;align-items:center;justify-content:center;font-size:8px;">8</span>
📋 板书
</div>
<div style="background:#f1f5f9;border:1px solid #64748b;padding:3px 6px;border-radius:3px;font-size:9px;display:flex;align-items:center;gap:4px;">
<span style="background:#64748b;color:#fff;border-radius:50%;width:14px;height:14px;display:inline-flex;align-items:center;justify-content:center;font-size:8px;">9</span>
💭 反思
</div>
</div>
</div>
</div>
</div>
<div class="card-body">
<h3>C. 左课文 + 右时间线</h3>
<p>左侧课文正文(固定)+ 文本研习批注,右侧教学流程时间线(编号 1-9 按顺序)。点击时间线节点展开编辑抽屉。最贴近传统教案本格式,结构清晰。</p>
</div>
</div>
</div>
<div class="section" style="margin-top:24px;">
<h3>三种方案的核心差异</h3>
<div class="pros-cons">
<div class="pros">
<h4>方案 A 三栏</h4>
<ul>
<li>课文始终居中可见</li>
<li>课前/课后分区直观</li>
<li>节点可拖动微调位置</li>
</ul>
</div>
<div class="cons">
<h4>方案 A 三栏</h4>
<ul>
<li>三栏可能拥挤(小屏)</li>
<li>教学流程顺序不够明显</li>
</ul>
</div>
</div>
<div class="pros-cons">
<div class="pros">
<h4>方案 B 上下分区</h4>
<ul>
<li>视觉层次最清晰</li>
<li>阅读顺序自然(上→下)</li>
<li>课文居中突出</li>
</ul>
</div>
<div class="cons">
<h4>方案 B 上下分区</h4>
<ul>
<li>节点位置较固定</li>
<li>纵向空间需求大</li>
</ul>
</div>
</div>
<div class="pros-cons">
<div class="pros">
<h4>方案 C 左课文+右时间线</h4>
<ul>
<li>最接近传统教案</li>
<li>教学流程顺序最明确</li>
<li>课文阅读体验最佳</li>
</ul>
</div>
<div class="cons">
<h4>方案 C 左课文+右时间线</h4>
<ul>
<li>节点画布感弱(更像列表)</li>
<li>失去节点图连线能力</li>
</ul>
</div>
</div>
</div>

View File

@@ -0,0 +1,251 @@
<h2>正文占位符标记布局</h2>
<p class="subtitle">正文中嵌入占位符标记(特殊符号),默认接近透明,选中节点时完整显示</p>
<div class="mockup">
<div class="mockup-header">备课编辑器 — 默认状态(占位符 10% 透明度)</div>
<div class="mockup-body" style="padding:0;background:#f1f5f9;overflow:hidden;">
<div style="font-family:monospace;font-size:12px;line-height:1.6;position:relative;height:560px;">
<!-- 顶部工具栏 -->
<div style="background:#1e293b;color:#e2e8f0;padding:8px 12px;display:flex;justify-content:space-between;align-items:center;z-index:10;position:relative;">
<div style="display:flex;align-items:center;gap:8px;">
<span>📖</span>
<span style="font-weight:bold;">秋天(第一课时)</span>
<span style="background:#334155;padding:2px 8px;border-radius:3px;font-size:10px;">语文 · 一年级上册</span>
</div>
<div style="display:flex;align-items:center;gap:8px;">
<span style="font-size:10px;color:#94a3b8;">💾 已保存</span>
<span style="background:#3b82f6;padding:4px 12px;border-radius:3px;font-size:10px;">💾 保存</span>
</div>
</div>
<!-- 画布区域 -->
<div style="position:relative;width:100%;height:520px;background:#f1f5f9;background-image:radial-gradient(#cbd5e1 1px, transparent 1px);background-size:20px 20px;overflow:hidden;">
<!-- SVG 连线层(默认 10% 透明度) -->
<svg style="position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;opacity:0.1;" viewBox="0 0 800 520">
<path d="M 130 120 Q 200 140 290 175" stroke="#3b82f6" stroke-width="2" fill="none" stroke-dasharray="4 4"/>
<path d="M 130 220 Q 200 230 290 215" stroke="#f59e0b" stroke-width="2" fill="none" stroke-dasharray="4 4"/>
<path d="M 670 120 Q 600 150 510 195" stroke="#0ea5e9" stroke-width="2" fill="none" stroke-dasharray="4 4"/>
<path d="M 670 220 Q 600 240 510 235" stroke="#ec4899" stroke-width="2" fill="none" stroke-dasharray="4 4"/>
<path d="M 670 340 Q 600 320 510 275" stroke="#22c55e" stroke-width="2" fill="none" stroke-dasharray="4 4"/>
</svg>
<!-- 中央:正文容器(不可移动) -->
<div style="position:absolute;left:280px;top:60px;width:240px;background:#fffbeb;border:2px solid #f59e0b;border-radius:8px;padding:12px;box-shadow:0 4px 12px rgba(0,0,0,0.1);">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;border-bottom:1px solid #fde68a;padding-bottom:6px;">
<span style="font-size:11px;font-weight:bold;color:#92400e;">📜 课文正文</span>
<span style="font-size:9px;color:#94a3b8;background:#fef3c7;padding:1px 4px;border-radius:2px;">🔒 固定</span>
</div>
<div style="font-size:13px;color:#78350f;line-height:2;">
<div style="text-align:center;font-weight:bold;margin-bottom:6px;">秋天</div>
<p style="margin:0 0 6px 0;">
<!-- 占位符 1导入节点- 默认 10% 透明度 -->
<span style="display:inline-block;background:#3b82f6;color:#fff;border-radius:3px;padding:0 4px;font-size:9px;font-weight:bold;opacity:0.1;margin-right:2px;"></span>天气凉了,树叶黄了,
</p>
<p style="margin:0 0 6px 0;">
<!-- 占位符 2文本研习- 默认 10% 透明度 -->
<span style="display:inline-block;background:#f59e0b;color:#fff;border-radius:3px;padding:0 4px;font-size:9px;font-weight:bold;opacity:0.1;margin-right:2px;"></span>天空那么蓝,那么高。
</p>
<p style="margin:0 0 6px 0;">
一群大雁往南飞,
</p>
<p style="margin:0 0 6px 0;">
一会儿排成个"人"字<span style="display:inline-block;background:#0ea5e9;color:#fff;border-radius:3px;padding:0 4px;font-size:9px;font-weight:bold;opacity:0.1;margin-left:2px;"></span>
</p>
<p style="margin:0 0 6px 0;">
一会儿排成个"一"字<span style="display:inline-block;background:#ec4899;color:#fff;border-radius:3px;padding:0 4px;font-size:9px;font-weight:bold;opacity:0.1;margin-left:2px;"></span>
</p>
<p style="margin:0;">
<span style="display:inline-block;background:#22c55e;color:#fff;border-radius:3px;padding:0 4px;font-size:9px;font-weight:bold;opacity:0.1;margin-right:2px;"></span>啊!秋天来了!
</p>
</div>
</div>
<!-- 左侧节点 -->
<div style="position:absolute;left:30px;top:90px;width:140px;background:#fff;border:1px solid #3b82f6;border-left:3px solid #3b82f6;border-radius:6px;padding:8px;box-shadow:0 2px 6px rgba(0,0,0,0.08);">
<div style="display:flex;align-items:center;gap:4px;margin-bottom:4px;">
<span style="background:#3b82f6;color:#fff;border-radius:50%;width:14px;height:14px;display:inline-flex;align-items:center;justify-content:center;font-size:8px;font-weight:bold;">1</span>
<span style="font-size:10px;font-weight:bold;color:#1e3a8a;">💡 导入</span>
</div>
<div style="font-size:9px;color:#64748b;">提问:你见过秋天的树叶吗?</div>
</div>
<div style="position:absolute;left:30px;top:200px;width:140px;background:#fff;border:1px solid #f59e0b;border-left:3px solid #f59e0b;border-radius:6px;padding:8px;box-shadow:0 2px 6px rgba(0,0,0,0.08);">
<div style="display:flex;align-items:center;gap:4px;margin-bottom:4px;">
<span style="background:#f59e0b;color:#fff;border-radius:50%;width:14px;height:14px;display:inline-flex;align-items:center;justify-content:center;font-size:8px;font-weight:bold;">2</span>
<span style="font-size:10px;font-weight:bold;color:#92400e;">📝 文本研习</span>
</div>
<div style="font-size:9px;color:#64748b;">赏析:叠词的运用</div>
</div>
<!-- 右侧节点 -->
<div style="position:absolute;left:630px;top:90px;width:140px;background:#fff;border:1px solid #0ea5e9;border-left:3px solid #0ea5e9;border-radius:6px;padding:8px;box-shadow:0 2px 6px rgba(0,0,0,0.08);">
<div style="display:flex;align-items:center;gap:4px;margin-bottom:4px;">
<span style="background:#0ea5e9;color:#fff;border-radius:50%;width:14px;height:14px;display:inline-flex;align-items:center;justify-content:center;font-size:8px;font-weight:bold;">3</span>
<span style="font-size:10px;font-weight:bold;color:#075985;">📚 新授</span>
</div>
<div style="font-size:9px;color:#64748b;">讲解:大雁南飞</div>
</div>
<div style="position:absolute;left:630px;top:200px;width:140px;background:#fff;border:1px solid #ec4899;border-left:3px solid #ec4899;border-radius:6px;padding:8px;box-shadow:0 2px 6px rgba(0,0,0,0.08);">
<div style="display:flex;align-items:center;gap:4px;margin-bottom:4px;">
<span style="background:#ec4899;color:#fff;border-radius:50%;width:14px;height:14px;display:inline-flex;align-items:center;justify-content:center;font-size:8px;font-weight:bold;">4</span>
<span style="font-size:10px;font-weight:bold;color:#9f1239;">✏️ 练习</span>
</div>
<div style="font-size:9px;color:#64748b;">3 道题</div>
</div>
<div style="position:absolute;left:630px;top:320px;width:140px;background:#fff;border:1px solid #22c55e;border-left:3px solid #22c55e;border-radius:6px;padding:8px;box-shadow:0 2px 6px rgba(0,0,0,0.08);">
<div style="display:flex;align-items:center;gap:4px;margin-bottom:4px;">
<span style="background:#22c55e;color:#fff;border-radius:50%;width:14px;height:14px;display:inline-flex;align-items:center;justify-content:center;font-size:8px;font-weight:bold;">5</span>
<span style="font-size:10px;font-weight:bold;color:#166534;">📌 小结</span>
</div>
<div style="font-size:9px;color:#64748b;">总结全文</div>
</div>
<div style="position:absolute;top:12px;right:12px;background:#fff;border:1px solid #e2e8f0;border-radius:4px;padding:4px 8px;font-size:9px;color:#64748b;">
占位符默认 10% · 选中节点时 100% 显示
</div>
</div>
</div>
</div>
</div>
<!-- 选中状态对比 -->
<div class="section" style="margin-top:24px;">
<h3>选中"导入"节点时的状态变化</h3>
<div class="split">
<div class="mockup">
<div class="mockup-header">默认 — 占位符 ① 10% 透明度</div>
<div class="mockup-body" style="padding:16px;background:#fffbeb;">
<div style="font-size:14px;color:#78350f;line-height:2;font-family:serif;">
<span style="display:inline-block;background:#3b82f6;color:#fff;border-radius:3px;padding:0 5px;font-size:10px;font-weight:bold;opacity:0.1;margin-right:3px;"></span>天气凉了,树叶黄了,
</div>
<div style="margin-top:12px;font-size:10px;color:#94a3b8;text-align:center;">
占位符几乎不可见 · 画布干净
</div>
</div>
</div>
<div class="mockup">
<div class="mockup-header">选中"导入"节点 — 占位符 ① 100% + 连线显示</div>
<div class="mockup-body" style="padding:16px;background:#fffbeb;">
<div style="font-size:14px;color:#78350f;line-height:2;font-family:serif;">
<span style="display:inline-block;background:#3b82f6;color:#fff;border-radius:3px;padding:0 5px;font-size:10px;font-weight:bold;opacity:1;margin-right:3px;box-shadow:0 0 0 2px #3b82f633;"></span>天气凉了,树叶黄了,
</div>
<div style="margin-top:12px;font-size:10px;color:#3b82f6;text-align:center;">
占位符完整显示 · 连线高亮 · 锚定位置清晰
</div>
</div>
</div>
</div>
</div>
<!-- 占位符样式选项 -->
<div class="section">
<h3>占位符样式选项</h3>
<p class="subtitle">选择占位符在正文中的视觉呈现方式</p>
<div class="cards" data-multiselect>
<div class="card" data-choice="number" onclick="toggleSelect(this)">
<div class="card-image" style="padding:20px;background:#fffbeb;text-align:center;">
<div style="font-size:16px;color:#78350f;font-family:serif;line-height:2;">
<span style="display:inline-block;background:#3b82f6;color:#fff;border-radius:3px;padding:0 6px;font-size:11px;font-weight:bold;"></span>天气凉了
</div>
</div>
<div class="card-body">
<h3>数字圆圈</h3>
<p>①②③④⑤ — 与节点编号对应,简洁清晰</p>
</div>
</div>
<div class="card" data-choice="icon" onclick="toggleSelect(this)">
<div class="card-image" style="padding:20px;background:#fffbeb;text-align:center;">
<div style="font-size:16px;color:#78350f;font-family:serif;line-height:2;">
<span style="display:inline-block;background:#3b82f6;color:#fff;border-radius:3px;padding:1px 5px;font-size:11px;">💡</span>天气凉了
</div>
</div>
<div class="card-body">
<h3>节点图标</h3>
<p>💡📝📚✏️📌 — 与节点类型图标一致,直观</p>
</div>
</div>
<div class="card" data-choice="dot" onclick="toggleSelect(this)">
<div class="card-image" style="padding:20px;background:#fffbeb;text-align:center;">
<div style="font-size:16px;color:#78350f;font-family:serif;line-height:2;">
<span style="display:inline-block;background:#3b82f6;color:#fff;border-radius:50%;width:10px;height:10px;font-size:8px;text-align:center;line-height:10px;"></span>天气凉了
</div>
</div>
<div class="card-body">
<h3>彩色圆点</h3>
<p>● — 极简,颜色对应节点,不干扰阅读</p>
</div>
</div>
<div class="card" data-choice="bracket" onclick="toggleSelect(this)">
<div class="card-image" style="padding:20px;background:#fffbeb;text-align:center;">
<div style="font-size:16px;color:#78350f;font-family:serif;line-height:2;">
<span style="color:#3b82f6;font-weight:bold;">【1】</span>天气凉了
</div>
</div>
<div class="card-body">
<h3>方括号编号</h3>
<p>【1】【2】【3】— 类似脚注标记,学术感</p>
</div>
</div>
</div>
</div>
<!-- 数据模型更新 -->
<div class="section">
<h3>占位符数据模型</h3>
<div style="background:#1e293b;color:#e2e8f0;padding:16px;border-radius:6px;font-family:monospace;font-size:11px;line-height:1.6;">
<div style="color:#94a3b8;">// 正文中的占位符标记</div>
<div><span style="color:#f59e0b;">interface</span> <span style="color:#3b82f6;">ContentPlaceholder</span> {</div>
<div>&nbsp;&nbsp;id: <span style="color:#10b981;">string</span>; <span style="color:#64748b;">// 占位符 ID</span></div>
<div>&nbsp;&nbsp;nodeId: <span style="color:#10b981;">string</span>; <span style="color:#64748b;">// 关联的节点 ID</span></div>
<div>&nbsp;&nbsp;offset: <span style="color:#10b981;">number</span>; <span style="color:#64748b;">// 在正文纯文本中的字符偏移量</span></div>
<div>&nbsp;&nbsp;label: <span style="color:#10b981;">string</span>; <span style="color:#64748b;">// 显示的标记("①" / "💡" / "●" / "【1】"</span></div>
<div>&nbsp;&nbsp;color: <span style="color:#10b981;">string</span>; <span style="color:#64748b;">// 节点颜色(用于占位符背景)</span></div>
<div>}</div>
<br>
<div style="color:#94a3b8;">// 正文渲染时注入占位符</div>
<div><span style="color:#f59e0b;">function</span> <span style="color:#3b82f6;">renderContentWithPlaceholders</span>(</div>
<div>&nbsp;&nbsp;content: <span style="color:#10b981;">string</span>, <span style="color:#64748b;">// Markdown 原文</span></div>
<div>&nbsp;&nbsp;placeholders: <span style="color:#3b82f6;">ContentPlaceholder</span>[]</div>
<div>): <span style="color:#10b981;">string</span> {</div>
<div>&nbsp;&nbsp;<span style="color:#64748b;">// 按 offset 排序,在对应位置插入占位符标记</span></div>
<div>&nbsp;&nbsp;<span style="color:#64748b;">// 渲染为 &lt;span class="placeholder" data-node-id="xxx"&gt;&lt;/span&gt;</span></div>
<div>}</div>
<br>
<div style="color:#94a3b8;">// CSS 透明度控制</div>
<div>.placeholder { <span style="color:#10b981;">opacity</span>: <span style="color:#f59e0b;">0.1</span>; <span style="color:#10b981;">transition</span>: <span style="color:#f59e0b;">opacity 0.2s</span>; }</div>
<div>.placeholder.active { <span style="color:#10b981;">opacity</span>: <span style="color:#f59e0b;">1</span>; }</div>
<div>.placeholder:hover { <span style="color:#10b981;">opacity</span>: <span style="color:#f59e0b;">0.6</span>; }</div>
</div>
</div>
<div class="section">
<h3>交互流程</h3>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">
<div style="background:#fff;border:1px solid #e2e8f0;border-radius:6px;padding:12px;">
<h4 style="margin:0 0 8px 0;color:#3b82f6;">🔗 添加占位符</h4>
<ol style="margin:0;padding-left:16px;font-size:11px;color:#475569;line-height:1.8;">
<li>教师点击正文某个位置(光标位置)</li>
<li>或选中一段文字后释放</li>
<li>弹出菜单:"在此处添加节点 →"</li>
<li>选择节点类型或已有节点</li>
<li>在正文对应位置插入占位符标记</li>
<li>创建 AnchorEdge 连线</li>
</ol>
</div>
<div style="background:#fff;border:1px solid #e2e8f0;border-radius:6px;padding:12px;">
<h4 style="margin:0 0 8px 0;color:#22c55e;">👁️ 选中节点时的视觉反馈</h4>
<ol style="margin:0;padding-left:16px;font-size:11px;color:#475569;line-height:1.8;">
<li>点击画布上的某个节点</li>
<li>该节点对应的占位符 opacity 从 0.1 → 1</li>
<li>连线从 10% → 100% 显示</li>
<li>占位符添加 active 样式(边框/阴影)</li>
<li>其他占位符保持 10% 透明度</li>
<li>点击空白处恢复默认状态</li>
</ol>
</div>
</div>
</div>

View File

@@ -0,0 +1,376 @@
<h2>两种锚定方式的视觉规则</h2>
<p class="subtitle">范围锚定选文本vs 点锚定(插入占位符)— 默认/选中状态对比</p>
<!-- 规则总览 -->
<div class="section">
<h3>视觉规则总览</h3>
<div style="background:#1e293b;color:#e2e8f0;padding:16px;border-radius:6px;font-family:monospace;font-size:11px;line-height:1.8;">
<div style="color:#94a3b8;">// 两种锚定方式</div>
<div><span style="color:#f59e0b;">type</span> <span style="color:#3b82f6;">AnchorType</span> = <span style="color:#10b981;">"range"</span> | <span style="color:#10b981;">"point"</span>;</div>
<br>
<div style="color:#94a3b8;">// 范围锚定(选一段文本关联节点)</div>
<div><span style="color:#94a3b8;">// 文本背景色 = 节点颜色</span></div>
<div>.range-anchor {</div>
<div>&nbsp;&nbsp;<span style="color:#10b981;">background-color</span>: <span style="color:#f59e0b;">var(--node-color)</span>; <span style="color:#64748b;">// 节点颜色</span></div>
<div>&nbsp;&nbsp;<span style="color:#10b981;">opacity</span>: <span style="color:#f59e0b;">0</span>; <span style="color:#64748b;">// 默认完全透明(和正常文本一样)</span></div>
<div>}</div>
<div>.range-anchor.active {</div>
<div>&nbsp;&nbsp;<span style="color:#10b981;">opacity</span>: <span style="color:#f59e0b;">0.3</span>; <span style="color:#64748b;">// 选中节点时显示背景色</span></div>
<div>}</div>
<br>
<div style="color:#94a3b8;">// 点锚定(在文本中插入占位符)</div>
<div>.point-anchor {</div>
<div>&nbsp;&nbsp;<span style="color:#10b981;">opacity</span>: <span style="color:#f59e0b;">0.3</span>; <span style="color:#64748b;">// 默认半透明</span></div>
<div>}</div>
<div>.point-anchor.active {</div>
<div>&nbsp;&nbsp;<span style="color:#10b981;">opacity</span>: <span style="color:#f59e0b;">1</span>; <span style="color:#64748b;">// 选中节点时不透明</span></div>
<div>}</div>
</div>
</div>
<!-- 完整画布:默认状态 -->
<div class="section">
<h3>完整画布 — 默认状态</h3>
<div class="mockup">
<div class="mockup-header">默认状态(未选中任何节点)</div>
<div class="mockup-body" style="padding:0;background:#f1f5f9;overflow:hidden;">
<div style="font-family:monospace;font-size:12px;line-height:1.6;position:relative;height:480px;">
<!-- 画布 -->
<div style="position:relative;width:100%;height:480px;background:#f1f5f9;background-image:radial-gradient(#cbd5e1 1px, transparent 1px);background-size:20px 20px;overflow:hidden;">
<!-- SVG 连线层(默认 10% 透明度) -->
<svg style="position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;opacity:0.1;" viewBox="0 0 800 480">
<path d="M 130 100 Q 200 120 290 155" stroke="#3b82f6" stroke-width="2" fill="none" stroke-dasharray="4 4"/>
<path d="M 130 200 Q 200 210 290 195" stroke="#f59e0b" stroke-width="2" fill="none" stroke-dasharray="4 4"/>
<path d="M 670 100 Q 600 130 510 175" stroke="#0ea5e9" stroke-width="2" fill="none" stroke-dasharray="4 4"/>
<path d="M 670 200 Q 600 220 510 215" stroke="#ec4899" stroke-width="2" fill="none" stroke-dasharray="4 4"/>
<path d="M 670 320 Q 600 300 510 255" stroke="#22c55e" stroke-width="2" fill="none" stroke-dasharray="4 4"/>
</svg>
<!-- 中央:正文容器 -->
<div style="position:absolute;left:280px;top:40px;width:240px;background:#fffbeb;border:2px solid #f59e0b;border-radius:8px;padding:12px;box-shadow:0 4px 12px rgba(0,0,0,0.1);">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;border-bottom:1px solid #fde68a;padding-bottom:6px;">
<span style="font-size:11px;font-weight:bold;color:#92400e;">📜 课文正文</span>
<span style="font-size:9px;color:#94a3b8;background:#fef3c7;padding:1px 4px;border-radius:2px;">🔒 固定</span>
</div>
<div style="font-size:13px;color:#78350f;line-height:2.2;">
<div style="text-align:center;font-weight:bold;margin-bottom:6px;">秋天</div>
<p style="margin:0 0 6px 0;">
<!-- 范围锚定:默认 opacity:0完全透明和正常文本一样 -->
<span style="background:#3b82f6;opacity:0;color:#78350f;">天气凉了</span>,树叶黄了,
</p>
<p style="margin:0 0 6px 0;">
<!-- 点锚定:默认 opacity:0.3(半透明) -->
<span style="display:inline-block;background:#f59e0b;color:#fff;border-radius:3px;padding:0 4px;font-size:9px;font-weight:bold;opacity:0.3;margin-right:2px;"></span>天空那么蓝,那么高。
</p>
<p style="margin:0 0 6px 0;">
一群大雁往南飞,
</p>
<p style="margin:0 0 6px 0;">
<!-- 范围锚定:默认 opacity:0 -->
一会儿排成个<span style="background:#0ea5e9;opacity:0;color:#78350f;">"人"字</span>
<!-- 点锚定:默认 opacity:0.3 -->
<span style="display:inline-block;background:#ec4899;color:#fff;border-radius:3px;padding:0 4px;font-size:9px;font-weight:bold;opacity:0.3;margin-left:2px;"></span>
</p>
<p style="margin:0;">
<!-- 点锚定:默认 opacity:0.3 -->
<span style="display:inline-block;background:#22c55e;color:#fff;border-radius:3px;padding:0 4px;font-size:9px;font-weight:bold;opacity:0.3;margin-right:2px;"></span>啊!秋天来了!
</p>
</div>
</div>
<!-- 左侧节点 -->
<div style="position:absolute;left:30px;top:70px;width:140px;background:#fff;border:1px solid #3b82f6;border-left:3px solid #3b82f6;border-radius:6px;padding:8px;box-shadow:0 2px 6px rgba(0,0,0,0.08);">
<div style="display:flex;align-items:center;gap:4px;margin-bottom:4px;">
<span style="background:#3b82f6;color:#fff;border-radius:50%;width:14px;height:14px;display:inline-flex;align-items:center;justify-content:center;font-size:8px;font-weight:bold;">1</span>
<span style="font-size:10px;font-weight:bold;color:#1e3a8a;">💡 导入</span>
<span style="margin-left:auto;font-size:8px;color:#94a3b8;background:#eff6ff;padding:1px 4px;border-radius:2px;">范围</span>
</div>
<div style="font-size:9px;color:#64748b;">提问:你见过秋天的树叶吗?</div>
</div>
<div style="position:absolute;left:30px;top:180px;width:140px;background:#fff;border:1px solid #f59e0b;border-left:3px solid #f59e0b;border-radius:6px;padding:8px;box-shadow:0 2px 6px rgba(0,0,0,0.08);">
<div style="display:flex;align-items:center;gap:4px;margin-bottom:4px;">
<span style="background:#f59e0b;color:#fff;border-radius:50%;width:14px;height:14px;display:inline-flex;align-items:center;justify-content:center;font-size:8px;font-weight:bold;">2</span>
<span style="font-size:10px;font-weight:bold;color:#92400e;">📝 文本研习</span>
<span style="margin-left:auto;font-size:8px;color:#94a3b8;background:#fffbeb;padding:1px 4px;border-radius:2px;"></span>
</div>
<div style="font-size:9px;color:#64748b;">赏析:叠词的运用</div>
</div>
<!-- 右侧节点 -->
<div style="position:absolute;left:630px;top:70px;width:140px;background:#fff;border:1px solid #0ea5e9;border-left:3px solid #0ea5e9;border-radius:6px;padding:8px;box-shadow:0 2px 6px rgba(0,0,0,0.08);">
<div style="display:flex;align-items:center;gap:4px;margin-bottom:4px;">
<span style="background:#0ea5e9;color:#fff;border-radius:50%;width:14px;height:14px;display:inline-flex;align-items:center;justify-content:center;font-size:8px;font-weight:bold;">3</span>
<span style="font-size:10px;font-weight:bold;color:#075985;">📚 新授</span>
<span style="margin-left:auto;font-size:8px;color:#94a3b8;background:#f0f9ff;padding:1px 4px;border-radius:2px;">范围</span>
</div>
<div style="font-size:9px;color:#64748b;">讲解:大雁南飞</div>
</div>
<div style="position:absolute;left:630px;top:180px;width:140px;background:#fff;border:1px solid #ec4899;border-left:3px solid #ec4899;border-radius:6px;padding:8px;box-shadow:0 2px 6px rgba(0,0,0,0.08);">
<div style="display:flex;align-items:center;gap:4px;margin-bottom:4px;">
<span style="background:#ec4899;color:#fff;border-radius:50%;width:14px;height:14px;display:inline-flex;align-items:center;justify-content:center;font-size:8px;font-weight:bold;">4</span>
<span style="font-size:10px;font-weight:bold;color:#9f1239;">✏️ 练习</span>
<span style="margin-left:auto;font-size:8px;color:#94a3b8;background:#fdf2f8;padding:1px 4px;border-radius:2px;"></span>
</div>
<div style="font-size:9px;color:#64748b;">3 道题</div>
</div>
<div style="position:absolute;left:630px;top:300px;width:140px;background:#fff;border:1px solid #22c55e;border-left:3px solid #22c55e;border-radius:6px;padding:8px;box-shadow:0 2px 6px rgba(0,0,0,0.08);">
<div style="display:flex;align-items:center;gap:4px;margin-bottom:4px;">
<span style="background:#22c55e;color:#fff;border-radius:50%;width:14px;height:14px;display:inline-flex;align-items:center;justify-content:center;font-size:8px;font-weight:bold;">5</span>
<span style="font-size:10px;font-weight:bold;color:#166534;">📌 小结</span>
<span style="margin-left:auto;font-size:8px;color:#94a3b8;background:#f0fdf4;padding:1px 4px;border-radius:2px;"></span>
</div>
<div style="font-size:9px;color:#64748b;">总结全文</div>
</div>
<div style="position:absolute;top:12px;right:12px;background:#fff;border:1px solid #e2e8f0;border-radius:4px;padding:6px 10px;font-size:9px;color:#64748b;">
<div>🔵 范围锚定:默认 opacity:0</div>
<div>🔴 点锚定:默认 opacity:0.3</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 选中节点 1范围锚定的状态 -->
<div class="section">
<h3>选中"导入"节点(范围锚定)— 文本背景显示</h3>
<div class="mockup">
<div class="mockup-header">选中节点 1 — "天气凉了"背景色显示</div>
<div class="mockup-body" style="padding:0;background:#f1f5f9;overflow:hidden;">
<div style="font-family:monospace;font-size:12px;line-height:1.6;position:relative;height:320px;">
<div style="position:relative;width:100%;height:320px;background:#f1f5f9;background-image:radial-gradient(#cbd5e1 1px, transparent 1px);background-size:20px 20px;overflow:hidden;">
<!-- SVG 连线层(选中节点的连线 100% 显示) -->
<svg style="position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;" viewBox="0 0 800 320">
<!-- 选中节点的连线 100% 显示 -->
<path d="M 130 80 Q 200 100 290 135" stroke="#3b82f6" stroke-width="2.5" fill="none" stroke-dasharray="4 4" opacity="1"/>
<circle cx="290" cy="135" r="5" fill="#3b82f6"/>
<!-- 其他连线保持 10% -->
<path d="M 130 180 Q 200 190 290 175" stroke="#f59e0b" stroke-width="2" fill="none" stroke-dasharray="4 4" opacity="0.1"/>
<path d="M 670 80 Q 600 110 510 155" stroke="#0ea5e9" stroke-width="2" fill="none" stroke-dasharray="4 4" opacity="0.1"/>
<path d="M 670 180 Q 600 200 510 195" stroke="#ec4899" stroke-width="2" fill="none" stroke-dasharray="4 4" opacity="0.1"/>
<path d="M 670 280 Q 600 260 510 235" stroke="#22c55e" stroke-width="2" fill="none" stroke-dasharray="4 4" opacity="0.1"/>
</svg>
<!-- 正文容器 -->
<div style="position:absolute;left:280px;top:20px;width:240px;background:#fffbeb;border:2px solid #f59e0b;border-radius:8px;padding:12px;box-shadow:0 4px 12px rgba(0,0,0,0.1);">
<div style="font-size:13px;color:#78350f;line-height:2.2;">
<div style="text-align:center;font-weight:bold;margin-bottom:6px;">秋天</div>
<p style="margin:0 0 6px 0;">
<!-- 范围锚定:选中时 opacity:0.3(背景色显示) -->
<span style="background:#3b82f6;opacity:0.3;color:#78350f;border-radius:2px;">天气凉了</span>,树叶黄了,
</p>
<p style="margin:0 0 6px 0;">
<!-- 点锚定:未选中,保持 0.3 -->
<span style="display:inline-block;background:#f59e0b;color:#fff;border-radius:3px;padding:0 4px;font-size:9px;font-weight:bold;opacity:0.3;margin-right:2px;"></span>天空那么蓝,那么高。
</p>
<p style="margin:0 0 6px 0;">一群大雁往南飞,</p>
<p style="margin:0 0 6px 0;">
一会儿排成个<span style="background:#0ea5e9;opacity:0;color:#78350f;">"人"字</span>
<span style="display:inline-block;background:#ec4899;color:#fff;border-radius:3px;padding:0 4px;font-size:9px;font-weight:bold;opacity:0.3;margin-left:2px;"></span>
</p>
<p style="margin:0;">
<span style="display:inline-block;background:#22c55e;color:#fff;border-radius:3px;padding:0 4px;font-size:9px;font-weight:bold;opacity:0.3;margin-right:2px;"></span>啊!秋天来了!
</p>
</div>
</div>
<!-- 选中的节点 1高亮边框 -->
<div style="position:absolute;left:30px;top:50px;width:140px;background:#fff;border:2px solid #3b82f6;border-left:3px solid #3b82f6;border-radius:6px;padding:8px;box-shadow:0 0 0 3px #3b82f633,0 2px 6px rgba(0,0,0,0.08);">
<div style="display:flex;align-items:center;gap:4px;margin-bottom:4px;">
<span style="background:#3b82f6;color:#fff;border-radius:50%;width:14px;height:14px;display:inline-flex;align-items:center;justify-content:center;font-size:8px;font-weight:bold;">1</span>
<span style="font-size:10px;font-weight:bold;color:#1e3a8a;">💡 导入</span>
<span style="margin-left:auto;font-size:8px;color:#fff;background:#3b82f6;padding:1px 4px;border-radius:2px;">范围·选中</span>
</div>
<div style="font-size:9px;color:#64748b;">提问:你见过秋天的树叶吗?</div>
</div>
<!-- 其他节点(正常状态) -->
<div style="position:absolute;left:30px;top:160px;width:140px;background:#fff;border:1px solid #f59e0b;border-left:3px solid #f59e0b;border-radius:6px;padding:8px;box-shadow:0 2px 6px rgba(0,0,0,0.08);opacity:0.7;">
<div style="display:flex;align-items:center;gap:4px;">
<span style="background:#f59e0b;color:#fff;border-radius:50%;width:14px;height:14px;display:inline-flex;align-items:center;justify-content:center;font-size:8px;font-weight:bold;">2</span>
<span style="font-size:10px;font-weight:bold;color:#92400e;">📝 文本研习</span>
</div>
</div>
<div style="position:absolute;left:630px;top:50px;width:140px;background:#fff;border:1px solid #0ea5e9;border-left:3px solid #0ea5e9;border-radius:6px;padding:8px;box-shadow:0 2px 6px rgba(0,0,0,0.08);opacity:0.7;">
<div style="display:flex;align-items:center;gap:4px;">
<span style="background:#0ea5e9;color:#fff;border-radius:50%;width:14px;height:14px;display:inline-flex;align-items:center;justify-content:center;font-size:8px;font-weight:bold;">3</span>
<span style="font-size:10px;font-weight:bold;color:#075985;">📚 新授</span>
</div>
</div>
<div style="position:absolute;top:12px;right:12px;background:#fff;border:1px solid #3b82f6;border-radius:4px;padding:6px 10px;font-size:9px;color:#3b82f6;">
✅ 选中"导入"节点 → "天气凉了"背景显示
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 选中节点 2点锚定的状态 -->
<div class="section">
<h3>选中"文本研习"节点(点锚定)— 占位符不透明</h3>
<div class="mockup">
<div class="mockup-header">选中节点 2 — 占位符 ② 100% 显示</div>
<div class="mockup-body" style="padding:0;background:#f1f5f9;overflow:hidden;">
<div style="font-family:monospace;font-size:12px;line-height:1.6;position:relative;height:320px;">
<div style="position:relative;width:100%;height:320px;background:#f1f5f9;background-image:radial-gradient(#cbd5e1 1px, transparent 1px);background-size:20px 20px;overflow:hidden;">
<svg style="position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;" viewBox="0 0 800 320">
<path d="M 130 80 Q 200 100 290 135" stroke="#3b82f6" stroke-width="2" fill="none" stroke-dasharray="4 4" opacity="0.1"/>
<!-- 选中节点的连线 100% -->
<path d="M 130 180 Q 200 190 290 175" stroke="#f59e0b" stroke-width="2.5" fill="none" stroke-dasharray="4 4" opacity="1"/>
<circle cx="290" cy="175" r="5" fill="#f59e0b"/>
<path d="M 670 80 Q 600 110 510 155" stroke="#0ea5e9" stroke-width="2" fill="none" stroke-dasharray="4 4" opacity="0.1"/>
<path d="M 670 180 Q 600 200 510 195" stroke="#ec4899" stroke-width="2" fill="none" stroke-dasharray="4 4" opacity="0.1"/>
<path d="M 670 280 Q 600 260 510 235" stroke="#22c55e" stroke-width="2" fill="none" stroke-dasharray="4 4" opacity="0.1"/>
</svg>
<div style="position:absolute;left:280px;top:20px;width:240px;background:#fffbeb;border:2px solid #f59e0b;border-radius:8px;padding:12px;box-shadow:0 4px 12px rgba(0,0,0,0.1);">
<div style="font-size:13px;color:#78350f;line-height:2.2;">
<div style="text-align:center;font-weight:bold;margin-bottom:6px;">秋天</div>
<p style="margin:0 0 6px 0;">
<!-- 范围锚定未选中opacity:0 -->
<span style="background:#3b82f6;opacity:0;color:#78350f;">天气凉了</span>,树叶黄了,
</p>
<p style="margin:0 0 6px 0;">
<!-- 点锚定:选中时 opacity:1不透明 -->
<span style="display:inline-block;background:#f59e0b;color:#fff;border-radius:3px;padding:0 4px;font-size:9px;font-weight:bold;opacity:1;margin-right:2px;box-shadow:0 0 0 2px #f59e0b44;"></span>天空那么蓝,那么高。
</p>
<p style="margin:0 0 6px 0;">一群大雁往南飞,</p>
<p style="margin:0 0 6px 0;">
一会儿排成个<span style="background:#0ea5e9;opacity:0;color:#78350f;">"人"字</span>
<span style="display:inline-block;background:#ec4899;color:#fff;border-radius:3px;padding:0 4px;font-size:9px;font-weight:bold;opacity:0.3;margin-left:2px;"></span>
</p>
<p style="margin:0;">
<span style="display:inline-block;background:#22c55e;color:#fff;border-radius:3px;padding:0 4px;font-size:9px;font-weight:bold;opacity:0.3;margin-right:2px;"></span>啊!秋天来了!
</p>
</div>
</div>
<div style="position:absolute;left:30px;top:50px;width:140px;background:#fff;border:1px solid #3b82f6;border-left:3px solid #3b82f6;border-radius:6px;padding:8px;box-shadow:0 2px 6px rgba(0,0,0,0.08);opacity:0.7;">
<div style="display:flex;align-items:center;gap:4px;">
<span style="background:#3b82f6;color:#fff;border-radius:50%;width:14px;height:14px;display:inline-flex;align-items:center;justify-content:center;font-size:8px;font-weight:bold;">1</span>
<span style="font-size:10px;font-weight:bold;color:#1e3a8a;">💡 导入</span>
</div>
</div>
<!-- 选中的节点 2高亮边框 -->
<div style="position:absolute;left:30px;top:160px;width:140px;background:#fff;border:2px solid #f59e0b;border-left:3px solid #f59e0b;border-radius:6px;padding:8px;box-shadow:0 0 0 3px #f59e0b33,0 2px 6px rgba(0,0,0,0.08);">
<div style="display:flex;align-items:center;gap:4px;margin-bottom:4px;">
<span style="background:#f59e0b;color:#fff;border-radius:50%;width:14px;height:14px;display:inline-flex;align-items:center;justify-content:center;font-size:8px;font-weight:bold;">2</span>
<span style="font-size:10px;font-weight:bold;color:#92400e;">📝 文本研习</span>
<span style="margin-left:auto;font-size:8px;color:#fff;background:#f59e0b;padding:1px 4px;border-radius:2px;">点·选中</span>
</div>
<div style="font-size:9px;color:#64748b;">赏析:叠词的运用</div>
</div>
<div style="position:absolute;left:630px;top:50px;width:140px;background:#fff;border:1px solid #0ea5e9;border-left:3px solid #0ea5e9;border-radius:6px;padding:8px;box-shadow:0 2px 6px rgba(0,0,0,0.08);opacity:0.7;">
<div style="display:flex;align-items:center;gap:4px;">
<span style="background:#0ea5e9;color:#fff;border-radius:50%;width:14px;height:14px;display:inline-flex;align-items:center;justify-content:center;font-size:8px;font-weight:bold;">3</span>
<span style="font-size:10px;font-weight:bold;color:#075985;">📚 新授</span>
</div>
</div>
<div style="position:absolute;top:12px;right:12px;background:#fff;border:1px solid #f59e0b;border-radius:4px;padding:6px 10px;font-size:9px;color:#f59e0b;">
✅ 选中"文本研习"节点 → 占位符 ② 不透明显示
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 两种锚定方式对比表 -->
<div class="section">
<h3>两种锚定方式对比</h3>
<div style="overflow-x:auto;">
<table style="width:100%;border-collapse:collapse;font-size:12px;">
<thead>
<tr style="background:#1e293b;color:#e2e8f0;">
<th style="padding:8px 12px;text-align:left;border:1px solid #334155;">特性</th>
<th style="padding:8px 12px;text-align:left;border:1px solid #334155;">范围锚定(选文本)</th>
<th style="padding:8px 12px;text-align:left;border:1px solid #334155;">点锚定(插入占位符)</th>
</tr>
</thead>
<tbody>
<tr style="background:#fff;">
<td style="padding:8px 12px;border:1px solid #e2e8f0;font-weight:bold;">触发方式</td>
<td style="padding:8px 12px;border:1px solid #e2e8f0;">选中一段文字 → 关联节点</td>
<td style="padding:8px 12px;border:1px solid #e2e8f0;">点击文本某位置 → 插入占位符</td>
</tr>
<tr style="background:#f8fafc;">
<td style="padding:8px 12px;border:1px solid #e2e8f0;font-weight:bold;">视觉表现</td>
<td style="padding:8px 12px;border:1px solid #e2e8f0;">文本背景色 = 节点颜色</td>
<td style="padding:8px 12px;border:1px solid #e2e8f0;">插入标记符号(①②③)</td>
</tr>
<tr style="background:#fff;">
<td style="padding:8px 12px;border:1px solid #e2e8f0;font-weight:bold;">默认透明度</td>
<td style="padding:8px 12px;border:1px solid #e2e8f0;">
<span style="background:#3b82f6;color:#fff;padding:2px 6px;border-radius:3px;font-size:10px;">opacity: 0</span>
<span style="color:#64748b;font-size:10px;">(完全透明,和正常文本一样)</span>
</td>
<td style="padding:8px 12px;border:1px solid #e2e8f0;">
<span style="background:#f59e0b;color:#fff;padding:2px 6px;border-radius:3px;font-size:10px;">opacity: 0.3</span>
<span style="color:#64748b;font-size:10px;">(半透明,隐约可见)</span>
</td>
</tr>
<tr style="background:#f8fafc;">
<td style="padding:8px 12px;border:1px solid #e2e8f0;font-weight:bold;">选中时透明度</td>
<td style="padding:8px 12px;border:1px solid #e2e8f0;">
<span style="background:#3b82f6;color:#fff;padding:2px 6px;border-radius:3px;font-size:10px;">opacity: 0.3</span>
<span style="color:#64748b;font-size:10px;">(背景色显示)</span>
</td>
<td style="padding:8px 12px;border:1px solid #e2e8f0;">
<span style="background:#f59e0b;color:#fff;padding:2px 6px;border-radius:3px;font-size:10px;">opacity: 1</span>
<span style="color:#64748b;font-size:10px;">(不透明,完整显示)</span>
</td>
</tr>
<tr style="background:#fff;">
<td style="padding:8px 12px;border:1px solid #e2e8f0;font-weight:bold;">连线透明度</td>
<td colspan="2" style="padding:8px 12px;border:1px solid #e2e8f0;">
默认 <span style="background:#64748b;color:#fff;padding:2px 6px;border-radius:3px;font-size:10px;">opacity: 0.1</span> · 选中时 <span style="background:#3b82f6;color:#fff;padding:2px 6px;border-radius:3px;font-size:10px;">opacity: 1</span>
</td>
</tr>
<tr style="background:#f8fafc;">
<td style="padding:8px 12px;border:1px solid #e2e8f0;font-weight:bold;">适用场景</td>
<td style="padding:8px 12px;border:1px solid #e2e8f0;">节点与具体文字内容相关(如赏析某词、讲解某句)</td>
<td style="padding:8px 12px;border:1px solid #e2e8f0;">节点对应文本某个位置(如在此处开始导入、在此处小结)</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- 数据模型 -->
<div class="section">
<h3>数据模型</h3>
<div style="background:#1e293b;color:#e2e8f0;padding:16px;border-radius:6px;font-family:monospace;font-size:11px;line-height:1.6;">
<div style="color:#94a3b8;">// 锚点 — 统一接口,区分 type</div>
<div><span style="color:#f59e0b;">interface</span> <span style="color:#3b82f6;">NodeAnchor</span> {</div>
<div>&nbsp;&nbsp;id: <span style="color:#10b981;">string</span>;</div>
<div>&nbsp;&nbsp;nodeId: <span style="color:#10b981;">string</span>; <span style="color:#64748b;">// 关联的节点</span></div>
<div>&nbsp;&nbsp;type: <span style="color:#10b981;">"range"</span> | <span style="color:#10b981;">"point"</span>; <span style="color:#64748b;">// 两种锚定方式</span></div>
<div>&nbsp;&nbsp;start: <span style="color:#10b981;">number</span>; <span style="color:#64748b;">// 正文纯文本偏移量</span></div>
<div>&nbsp;&nbsp;end?: <span style="color:#10b981;">number</span>; <span style="color:#64748b;">// range 锚定的结束偏移point 无)</span></div>
<div>&nbsp;&nbsp;textPreview?: <span style="color:#10b981;">string</span>; <span style="color:#64748b;">// range 锚定的文字预览</span></div>
<div>}</div>
<br>
<div style="color:#94a3b8;">// 渲染规则</div>
<div><span style="color:#f59e0b;">function</span> <span style="color:#3b82f6;">getAnchorStyle</span>(anchor: <span style="color:#3b82f6;">NodeAnchor</span>, isActive: <span style="color:#10b981;">boolean</span>) {</div>
<div>&nbsp;&nbsp;<span style="color:#f59e0b;">if</span> (anchor.type === <span style="color:#10b981;">"range"</span>) {</div>
<div>&nbsp;&nbsp;&nbsp;&nbsp;<span style="color:#f59e0b;">return</span> { backgroundColor: getNodeColor(anchor.nodeId), opacity: isActive ? <span style="color:#f59e0b;">0.3</span> : <span style="color:#f59e0b;">0</span> };</div>
<div>&nbsp;&nbsp;} <span style="color:#f59e0b;">else</span> { <span style="color:#64748b;">// point</span></div>
<div>&nbsp;&nbsp;&nbsp;&nbsp;<span style="color:#f59e0b;">return</span> { opacity: isActive ? <span style="color:#f59e0b;">1</span> : <span style="color:#f59e0b;">0.3</span> };</div>
<div>&nbsp;&nbsp;}</div>
<div>}</div>
</div>
</div>

View File

@@ -0,0 +1 @@
{"reason":"idle timeout","timestamp":1782143663726}

View File

@@ -0,0 +1,3 @@
sessionDir=e:\Desktop\CICD\.superpowers\brainstorm\41500-1782168322.9344
stateDir=e:\Desktop\CICD\.superpowers\brainstorm\41500-1782168322.9344\state
contentDir=e:\Desktop\CICD\.superpowers\brainstorm\41500-1782168322.9344\content

View File

@@ -107,7 +107,31 @@ src/modules/[module]/
- 使用 `cn()` 工具函数管理条件类名 - 使用 `cn()` 工具函数管理条件类名
- **禁止**字符串拼接动态类名(`bg-${color}-500` - **禁止**字符串拼接动态类名(`bg-${color}-500`
- **禁止**使用任意值(`w-[137px]`),除非有充分理由并注释 - **禁止**使用任意值(`w-[137px]`),除非有充分理由并注释
- 设计令牌在 `src/app/globals.css` 中使用 CSS 变量定义 - 设计令牌在 `src/app/styles/tokens/` 目录中分层定义,通过 `@theme inline` 暴露为 Tailwind 类
### 设计令牌规范(强制)
- **禁止硬编码颜色**: TSX/TS/CSS 中不得出现 `#hex` 颜色字面量,统一使用 `hsl(var(--*))` 或 Tailwind 类 `bg-*`
- **禁止硬编码字体**: 不得出现 `'Inter'`/`'Fraunces'`/`'JetBrains Mono'` 字面量,使用 `var(--font-family-sans/serif/mono)`
- **禁止硬编码字号**: 不得出现 `font-size: Npx`,使用 `var(--font-size-1~9)`
- **禁止 Tailwind 任意值**: 不得使用 `w-[Npx]`/`h-[Npx]`/`p-[Npx]` 等,映射到 `--space-*` 或 Tailwind 默认阶梯
- **豁免场景**(需 `// eslint-disable-next-line no-restricted-syntax -- <reason>` 注释):
- PWA manifest`src/app/manifest.ts`
- 邮件 HTML 内联样式(`src/modules/notifications/channels/email-channel.ts`
- 图表 SVG 固定画布尺寸recharts 选择器中的 `#ccc`/`#fff`
- loading.tsx 占位骨架
- Dialog 固定宽度等无法令牌化的设计固定尺寸
- **令牌文件分布**: `src/app/styles/tokens/`primitive/semantic-light/semantic-dark/lesson-preparation/tailwind-theme/index
- **令牌分层**:
- Layer 1 Primitive`primitive.css`:原始色板/字号/间距/阴影,业务代码不直接引用
- Layer 2 Semantic`semantic-light.css` + `semantic-dark.css`:语义令牌,业务代码唯一引用入口
- 模块命名空间(`lesson-preparation.css`:`--lp-*` 令牌,明暗双份
- Tailwind 暴露(`tailwind-theme.css`:`@theme inline` 将 Semantic 令牌暴露为 `bg-*`/`text-*`/`font-*`
- **改令牌必同步图**: 修改令牌定义后,同步更新 `docs/architecture/004``005`
- **ESLint 强制约束**:
- `no-restricted-syntax`: 禁止 `#hex` 字面量
- `design-tokens/no-hardcoded-fonts`: 禁止 `'Inter'`/`'Fraunces'`/`'JetBrains Mono'` 字面量(单词边界匹配,不影响 `Interval`/`Interactive` 等标识符)
- 白名单:`primitive.css`(令牌定义)、`email-channel.ts`(邮件 HTML`manifest.ts`PWA
### 安全规范 ### 安全规范

1
.tsc_out.txt Normal file
View File

@@ -0,0 +1 @@
src/app/(dashboard)/teacher/textbooks/error.tsx(3,10): error TS2305: Module '"@/shared/components/route-error"' has no exported member 'RouteError'.

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

1734
build-output.txt Normal file

File diff suppressed because it is too large Load Diff

4
cookies.txt Normal file
View File

@@ -0,0 +1,4 @@
# Netscape HTTP Cookie File
# https://curl.se/docs/http-cookies.html
# This file was generated by libcurl! Edit at your own risk.

BIN
debug-exams.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,206 @@
# 数据库访问层重构专项 - 审计框架 v1
> 创建日期2026-07-07
> 目标:对全项目 86 个 `data-access*.ts` + ~30 个 `actions.ts` 进行深度审计,输出可执行的分级治理路线图
> 推进路径:先审计后治理(用户已确认)
> 执行方案:纯深读(用户已确认方案 B
---
## 一、审计范围
### 1.1 文件范围
| 类型 | 路径模式 | 文件数(约) | 备注 |
|---|---|---|---|
| 数据访问层 | `src/modules/**/data-access*.ts` | 86 | 主审计对象 |
| Server Actions | `src/modules/**/actions.ts` | ~30 | 辅查(权限校验、业务逻辑归属) |
| 辅助文件 | `src/modules/**/schema.ts``types.ts` | 按需 | 仅当 data-access 引用时查看 |
### 1.2 排除范围
- `src/app/**`:仅在 A-05 规则app 直访 DB触发时反向查看
- `src/shared/**`:仅在 A-07 规则shared 反向依赖)触发时查看
- 已有的模块级 audit 报告(`docs/architecture/audit/*-audit-report.md`):作为参考但不直接复用,因本次为横切关注点
### 1.3 模块分组(并行执行单元)
| 组 | 模块 | data-access 文件数 | sub-agent |
|---|---|---|---|
| **G1 核心教学 A** | lesson-preparation12+ questions + textbooks | ~16 | agent-1 |
| **G2 核心教学 B** | exams + homework7+ grades6+ diagnostic + adaptive-practice3 | ~21 | agent-2 |
| **G3 教学管理** | classes6+ school + scheduling + attendance3+ course-plans + proctoring | ~16 | agent-3 |
| **G4 用户与沟通** | users + messaging + notifications + parent + audit + auth + rbac3 | ~12 | agent-4 |
| **G5 扩展与设置** | elective5+ settings5+ dashboard + files + search + onboarding + ai + announcements + error-book3 | ~21 | agent-5 |
---
## 二、审计维度与检查规则
### 2.1 维度 1模式标准化Pattern Standardization
| 规则 ID | 检查项 | 期望状态 | 检测方式 |
|---|---|---|---|
| P-01 | `import "server-only"` 文件头 | 每个文件首行 | 静态 |
| P-02 | 类型导入使用 `import type` | 类型导入与值导入分离 | 静态 |
| P-03 | 读函数是否走 `cacheFn` 包装Raw + Wrapper 配对) | 全部覆盖 | 深读 |
| P-04 | 函数返回类型显式标注 `Promise<T>` | 无隐式推断 | 深读 |
| P-05 | 错误处理一致 | data-access 层用 throwactions 层用 ActionState | 深读 |
| P-06 | 分页参数命名统一 | `page`/`pageSize``limit`/`offset` 全局统一 | 深读 |
| P-07 | 日期序列化走 helper | `serializeDate`/`toISODateString` | 深读 |
| P-08 | 列表项映射走 `mapListItem` 模式 | 避免 inline mapping 重复 | 深读 |
| P-09 | `as` 断言出现次数 | 0除 unknown 收窄) | 静态 |
| P-10 | `any` 出现次数 | 0 | 静态 |
### 2.2 维度 2性能与查询优化Performance
| 规则 ID | 检查项 | 期望状态 | 检测方式 |
|---|---|---|---|
| F-01 | 循环内 SQL 调用N+1 | 改批量查询 + Map 解析 | 深读 |
| F-02 | `LIKE '%xxx%'` 全表扫描 | 改 FULLTEXT 或前缀匹配 | 深读 |
| F-03 | SELECT * 未指定列 | 显式列枚举 | 深读 |
| F-04 | JOIN 表数量 > 3 | 评估拆分或冗余字段 | 深读 |
| F-05 | 大表查询无 LIMIT | 添加默认 LIMIT | 深读 |
| F-06 | 重复查询同表/同条件 | 走 cacheFn 或合并查询 | 深读 |
| F-07 | 缺失索引(高频 WHERE 字段) | 提示加索引 | 深读 |
| F-08 | 跨模块多次调用 `getXxxNamesByIds` | 批量化 | 深读 |
| F-09 | 事务范围过大(含网络调用) | 收紧事务 | 深读 |
| F-10 | `count()` 全表统计无过滤 | 添加过滤条件 | 深读 |
### 2.3 维度 3架构违规治理Architecture
| 规则 ID | 检查项 | 期望状态 | 检测方式 |
|---|---|---|---|
| A-01 | data-access 含 `requirePermission` 调用 | 移至 actions | 静态 |
| A-02 | data-access 含业务逻辑(条件分支、状态机) | 移至 actions 或 lib | 深读 |
| A-03 | data-access 含 `"use server"` 标记 | 移至 actions | 静态 |
| A-04 | data-access 含 `revalidatePath` 调用 | 移至 actions | 静态 |
| A-05 | app/ 直接 import `@/shared/db` | 违规,改走 data-access | 静态 |
| A-06 | modules 间直接 import 对方 `@/shared/db/schema` 表 | 改走对方 data-access | 静态 |
| A-07 | shared/ 反向 import `@/auth`/`@/proxy`/`modules/*` | 违规 | 静态 |
| A-08 | actions.ts 漏调 `requirePermission` | 补齐 | 深读 |
| A-09 | actions.ts 含直接 DB 查询 | 移至 data-access | 深读 |
| A-10 | data-access 含 `console.log` 调试代码 | 删除 | 静态 |
### 2.4 维度 4结构与可维护性Structure
| 规则 ID | 检查项 | 期望状态 | 检测方式 |
|---|---|---|---|
| S-01 | 文件行数 > 800 行警告,> 1000 行必须拆分 | 拆分 | 静态 |
| S-02 | 单文件导出函数数 > 20 | 警告,考虑拆分 | 静态 |
| S-03 | 重复 helper多模块各自实现 serializeDate/buildScopeFilter 等) | 提取到 shared/lib | 深读 |
| S-04 | 过细拆分(同模块 ≥ 5 个子文件且单文件 < 100 行) | 评估合并 | 静态 |
| S-05 | 未使用导出dead code | 删除 | 深读 |
| S-06 | 公共导出函数缺 JSDoc | 补齐 | 深读 |
| S-07 | 跨模块重复查询逻辑 | 提取共享 data-access | 深读 |
| S-08 | 模块内 data-access 与 actions 职责混淆 | 重新分层 | 深读 |
---
## 三、严重性分级
| 级别 | 含义 | 示例 | 治理窗口 |
|---|---|---|---|
| **P0 Critical** | 架构硬违规、安全漏洞、必定性能问题 | app 直访 DB、跨模块 schema 直查、actions 漏权限、N+1 循环 SQL | 立即 |
| **P1 High** | 显著性能/可维护性问题 | 超长文件(>1000 行)、缺 cacheFn 的热路径读函数、LIKE 全表扫描 | Phase 1 |
| **P2 Medium** | 模式偏差、可优化 | 错误处理不一致、缺 JSDoc、重复 helper、分页命名不统一 | Phase 2 |
| **P3 Low** | 风格问题、可选优化 | 单行格式、import 顺序、注释措辞 | Phase 3 |
---
## 四、执行流程
### 4.1 阶段 Asub-agent 分组深读(并行)
每个 sub-agent 接收:
- 该组所有 `data-access*.ts` + 同模块 `actions.ts` 文件清单
- 完整规则表4 维度 × 38 条规则)
- 统一输出格式(见 4.3
每个 sub-agent 执行:
1. 完整读取每个文件(不使用 limit/offset
2. 按规则表逐条检测
3. 命中即记录到问题清单
4. 对每个问题给出修复建议与预估工作量
### 4.2 阶段 B主 agent 汇总
- 收集 5 个 sub-agent 的结构化输出
- 去重(同一问题被多 agent 命中时合并)
- 跨模块统计(如重复 helper 在多少模块出现)
- 生成优先级矩阵
- 编写治理路线图
### 4.3 sub-agent 输出格式
每个 sub-agent 产出 JSON 数组,每条问题:
```json
{
"id": "G1-001",
"file": "src/modules/lesson-preparation/data-access.ts",
"lines": "L123-L145",
"ruleId": "F-01",
"severity": "P0",
"dimension": "performance",
"title": "循环内调用 getClassNamesByIds",
"description": "在 for 循环内对每个 classId 单独查询 className应改为批量查询后用 Map 解析",
"recommendation": "提取 classIds 数组,一次调用 getClassNamesByIds(classIds),循环内改为 map.get(classId)",
"effort": "S (≤30 分钟)"
}
```
工作量分级:
- **XS**:≤ 15 分钟(如删除 console.log、补 import type
- **S**:≤ 30 分钟(如替换 as 断言为类型守卫)
- **M**:≤ 2 小时(如 N+1 改批量、提取 helper
- **L**:≤ 1 天(如拆分超长文件、跨模块重构)
- **XL**> 1 天(如架构层重构)
---
## 五、报告输出
### 5.1 主报告
文件:`docs/architecture/audit/data-access-audit-v1.md`
结构:
1. **执行摘要**总文件数、问题总数、P0/P1/P2/P3 分布、模块热度图
2. **量化指标仪表盘**cacheFn 覆盖率、平均行数、`as` 断言数、违规 import 数等
3. **按维度分组的问题清单**:每条含 文件:行号、规则 ID、严重性、现状描述、修复建议、预估工作量
4. **按模块分组的问题清单**:每个模块的累计问题数与 Top 问题
5. **P0-P3 优先级矩阵**:四象限图(影响 × 紧迫度)
6. **分阶段治理路线图**Phase 1 (P0) → Phase 2 (P1) → Phase 3 (P2) → Phase 4 (P3)
7. **附录**完整规则表、sub-agent 原始输出索引
### 5.2 结构化数据
文件:`docs/architecture/audit/data-access-audit-v1-data.json`
字段:`issues[]``metrics{}``moduleSummary{}``roadmap{}`
### 5.3 速查手册同步
发现的新模式问题需追加到 `docs/troubleshooting/known-issues.md`(速查手册格式)。
---
## 六、质量约束
- **零误报**:每条问题必须给出文件:行号 + 代码证据,避免臆测
- **零遗漏**86 个 data-access 文件必须全部深读,不得抽样
- **可执行**:每条修复建议必须具体到代码示例或操作步骤
- **不修改代码**:审计阶段只产出报告,不做任何源码修改
- **架构同步**审计过程中发现的架构图遗漏004/005 文档)记录到报告附录,治理阶段统一补图
---
## 七、后续衔接
审计报告 v1 完成后:
1. **用户审查报告**:确认问题清单与优先级
2. **制定治理路线图**:基于 P0-P3 分级,输出 `data-access-refactor-roadmap-v1.md`
3. **分阶段执行治理**:每阶段完成后运行 `npm run lint` + `npx tsc --noEmit` 验证
4. **同步架构文档**:每阶段完成后同步 004/005 文档与 known-issues.md

View File

@@ -0,0 +1,171 @@
{
"version": "v1",
"createdAt": "2026-07-07",
"scope": {
"dataAccessFiles": 86,
"actionsFilesAudited": 15,
"totalFilesAudited": 101
},
"summary": {
"totalIssues": 230,
"bySeverity": {
"P0": 17,
"P1": 48,
"P2": 105,
"P3": 60
},
"byDimension": {
"pattern": 54,
"performance": 71,
"architecture": 58,
"structure": 47
}
},
"metrics": {
"serverOnlyMissing": 2,
"cacheFnMissingEstimated": 60,
"filesOver800Lines": 3,
"filesOver1000Lines": 1,
"filesOver20Exports": 5,
"asAssertionsNonExempt": 2,
"anyUsage": 0,
"consoleErrorCount": 25,
"nPlusOnePatterns": 11,
"likeFullScanPatterns": 7,
"selectStarCount": 35,
"noLimitQueries": 18,
"crossModuleSchemaAccess": 7,
"businessLogicInDataAccess": 18,
"unprotectedTransactions": 4,
"actionsPermissionIssues": 4,
"actionsDirectDB": 1
},
"moduleHeatmap": [
{ "module": "messaging", "p0": 1, "p1": 4, "total": 5, "risk": "critical" },
{ "module": "classes", "p0": 1, "p1": 4, "total": 14, "risk": "critical" },
{ "module": "school", "p0": 0, "p1": 5, "total": 8, "risk": "critical" },
{ "module": "lesson-preparation", "p0": 1, "p1": 3, "total": 28, "risk": "high" },
{ "module": "scheduling", "p0": 1, "p1": 2, "total": 6, "risk": "high" },
{ "module": "adaptive-practice", "p0": 1, "p1": 2, "total": 4, "risk": "high" },
{ "module": "elective", "p0": 0, "p1": 3, "total": 7, "risk": "high" },
{ "module": "textbooks", "p0": 2, "p1": 1, "total": 11, "risk": "high" },
{ "module": "onboarding", "p0": 2, "p1": 0, "total": 2, "risk": "medium" },
{ "module": "grades", "p0": 0, "p1": 2, "total": 4, "risk": "medium" },
{ "module": "questions", "p0": 1, "p1": 1, "total": 11, "risk": "medium" },
{ "module": "audit", "p0": 1, "p1": 1, "total": 3, "risk": "medium" },
{ "module": "parent", "p0": 1, "p1": 0, "total": 1, "risk": "medium" },
{ "module": "attendance", "p0": 0, "p1": 1, "total": 9, "risk": "medium" },
{ "module": "files", "p0": 0, "p1": 4, "total": 13, "risk": "medium" },
{ "module": "exams", "p0": 1, "p1": 0, "total": 1, "risk": "low" },
{ "module": "course-plans", "p0": 1, "p1": 0, "total": 5, "risk": "low" },
{ "module": "homework", "p0": 0, "p1": 1, "total": 2, "risk": "low" },
{ "module": "diagnostic", "p0": 0, "p1": 1, "total": 2, "risk": "low" }
],
"p0Issues": [
{ "id": "G4-003", "file": "src/modules/audit/actions.ts", "lines": "L192-225", "ruleId": "A-08", "title": "purgeAuditLogsAction 用读权限执行物理删除", "category": "security" },
{ "id": "G4-002", "file": "src/modules/parent/", "lines": "—", "ruleId": "A-08", "title": "parent 模块缺失 actions.ts3 页面直访 data-access", "category": "security" },
{ "id": "G2-001", "file": "src/modules/exams/data-access.ts", "lines": "L1", "ruleId": "P-01", "title": "缺 import server-only", "category": "security" },
{ "id": "G5-001", "file": "src/modules/onboarding/data-access.ts", "lines": "L1", "ruleId": "P-01", "title": "缺 import server-only", "category": "security" },
{ "id": "G1-001", "file": "src/modules/textbooks/data-access-graph.ts", "lines": "L7-121", "ruleId": "A-06", "title": "直查 questions + diagnostic 模块表", "category": "architecture" },
{ "id": "G3-002", "file": "src/modules/scheduling/data-access.ts", "lines": "L8-17", "ruleId": "A-06", "title": "直查 classes/users/subjects 三模块表", "category": "architecture" },
{ "id": "G3-003", "file": "src/modules/scheduling/data-access-class-schedule.ts", "lines": "L28-158", "ruleId": "A-02", "title": "data-access 含校验+状态机业务逻辑", "category": "architecture" },
{ "id": "G5-002", "file": "src/modules/onboarding/actions.ts", "lines": "L15-76", "ruleId": "A-09", "title": "actions 直查 DB", "category": "architecture" },
{ "id": "G4-001", "file": "src/modules/messaging/data-access.ts", "lines": "L1-1089", "ruleId": "S-01", "title": "1089 行超 1000 硬限", "category": "structure" },
{ "id": "G1-002", "file": "src/modules/questions/data-access.ts", "lines": "L294-315", "ruleId": "F-01", "title": "deleteQuestionRecursive 递归 N+1", "category": "performance" },
{ "id": "G1-003", "file": "src/modules/questions/data-access.ts", "lines": "L350-378", "ruleId": "F-01", "title": "deleteQuestionsBatch 循环 N+1", "category": "performance" },
{ "id": "G1-004", "file": "src/modules/lesson-preparation/data-access-comments.ts", "lines": "L128-140", "ruleId": "F-01", "title": "deleteComment 递归 N+1", "category": "performance" },
{ "id": "G1-005", "file": "src/modules/textbooks/data-access.ts", "lines": "L426-458", "ruleId": "F-01", "title": "reorderChapters 循环 UPDATE", "category": "performance" },
{ "id": "G3-001", "file": "src/modules/classes/data-access.ts", "lines": "L17-313", "ruleId": "P-03", "title": "24+ 读函数未走 cacheFn", "category": "performance" },
{ "id": "G3-004", "file": "src/modules/classes/data-access-teacher.ts", "lines": "L92-116", "ruleId": "F-01", "title": "getTeacherClassesRaw 2N+1", "category": "performance" },
{ "id": "G3-005", "file": "src/modules/course-plans/data-access.ts", "lines": "L324-331", "ruleId": "F-01", "title": "reorderCoursePlanItems N+1 + 未包裹事务", "category": "performance" },
{ "id": "G2-003", "file": "src/modules/adaptive-practice/data-access-analytics.ts", "lines": "L311-384", "ruleId": "F-01", "title": "getTeacherClassPracticeOverviewsRaw 2N+1", "category": "performance" }
],
"roadmap": {
"phase0": {
"name": "紧急安全修复",
"priority": "immediate",
"tasks": [
{ "id": "G2-001", "effort": "XS", "action": "添加 import server-only 到 exams/data-access.ts" },
{ "id": "G5-001", "effort": "XS", "action": "添加 import server-only 到 onboarding/data-access.ts" },
{ "id": "G4-002", "effort": "M", "action": "新建 parent/actions.ts3 页面改调 Action" },
{ "id": "G4-003", "effort": "S", "action": "audit purge 权限点新增 + 替换" },
{ "id": "G4-004", "effort": "S", "action": "audit retention 权限点替换" },
{ "id": "G5-002", "effort": "S", "action": "onboarding/actions.ts 移除直查 DB" }
]
},
"phase1": {
"name": "P0 架构与性能修复",
"priority": "high",
"tasks": [
{ "batch": "1.1", "ids": ["G1-001", "G3-002", "G1-031", "G1-032", "G1-033"], "effort": "L", "action": "跨模块 schema 直查治理" },
{ "batch": "1.2", "ids": ["G4-001", "G4-005", "G4-006", "G4-007", "G4-008"], "effort": "L", "action": "messaging 拆分" },
{ "batch": "1.3", "ids": ["G1-002", "G1-003", "G1-004", "G1-005", "G3-001", "G3-004", "G3-005", "G2-003"], "effort": "L", "action": "N+1 热路径修复" },
{ "batch": "1.4", "ids": ["G3-003", "G3-024", "G3-025"], "effort": "M", "action": "scheduling 业务逻辑下移" },
{ "batch": "1.5", "ids": ["G5-003", "G5-004"], "effort": "L", "action": "elective 业务逻辑拆分" }
]
},
"phase2": {
"name": "P1 性能与结构优化",
"priority": "medium",
"tasks": [
{ "batch": "2.1", "ids": ["G1-006", "G1-007", "G1-008", "G1-009", "G3-017", "G4-010"], "effort": "L", "action": "LIKE 全表扫描治理" },
{ "batch": "2.2", "ids": ["G3-007", "G2-005"], "effort": "M", "action": "超长文件拆分" },
{ "batch": "2.3", "ids": ["G3-007", "G3-008", "G3-009", "G3-010", "G3-011"], "effort": "L", "action": "school 模块重构" },
{ "batch": "2.4", "ids": ["G5-005", "G5-006", "G5-007"], "effort": "M", "action": "files 模块错误处理重构" },
{ "batch": "2.5", "ids": ["G3-006", "G3-012", "G3-025", "G4-009"], "effort": "S", "action": "事务包裹修复" },
{ "batch": "2.6", "ids": ["G1-011", "G1-012", "G1-013", "G1-050", "G1-051", "G1-052", "G1-053", "G3-031", "G3-032", "G3-041", "G3-044"], "effort": "M", "action": "无 LIMIT 查询保护" }
]
},
"phase3": {
"name": "P2 模式标准化",
"priority": "low",
"tasks": [
{ "batch": "3.1", "ids": ["G1-021", "G1-022", "G1-023", "G1-024", "G3-001"], "effort": "M", "action": "cacheFn 全量补齐" },
{ "batch": "3.2", "ids": ["G1-039-049", "G3-009", "G3-026-028", "G5-007"], "effort": "M", "action": "SELECT * 改显式列" },
{ "batch": "3.3", "ids": ["G3-021", "G3-047", "G1-025"], "effort": "S", "action": "日期 helper 提取" },
{ "batch": "3.4", "ids": ["G1-026", "G1-027", "G3-020", "G3-036"], "effort": "M", "action": "重复 helper 提取" },
{ "batch": "3.5", "ids": ["G1-034-036", "G1-066", "G1-067", "G3-046"], "effort": "M", "action": "JSDoc 补齐" }
]
},
"phase4": {
"name": "P3 风格优化",
"priority": "optional",
"tasks": [
{ "ids": ["G3-029", "G3-030"], "effort": "XS", "action": "as widening 断言改类型标注" },
{ "ids": ["G1-060-065"], "effort": "XS", "action": "非空断言 ! 改类型守卫" },
{ "ids": ["G3-048"], "effort": "S", "action": "export * 改显式 re-export" },
{ "ids": ["G3-018"], "effort": "XS", "action": "死代码删除" }
]
}
},
"crossModuleRecommendations": {
"newSharedHelpers": [
{ "name": "toISODateString", "path": "src/shared/lib/date-utils.ts", "replaces": ["attendance/serializeDate", "scheduling/serializeDate", "school/toIso", "course-plans/toIso"] },
{ "name": "buildScopeFilter", "path": "src/shared/lib/scope-filter.ts", "replaces": ["attendance/buildScopeFilter", "grades/buildScopeFilter"] }
],
"newCrossModuleInterfaces": [
{ "name": "getActiveStudentIdsByClassIds", "module": "classes", "callers": ["adaptive-practice", "attendance"] },
{ "name": "getGradeNamesByIds", "module": "school", "callers": ["textbooks", "lesson-preparation"] },
{ "name": "getQuestionCountByKpIds", "module": "questions", "callers": ["textbooks"] },
{ "name": "getKpMasteryByTextbookId", "module": "diagnostic", "callers": ["textbooks"] }
],
"newPermissions": [
{ "name": "AUDIT_LOG_PURGE", "description": "审计日志物理删除", "roles": ["admin"] },
{ "name": "AUDIT_RETENTION_MANAGE", "description": "审计保留策略配置", "roles": ["admin"] }
]
},
"architectureDocGaps": [
"parent 模块缺失 actions.ts - 004 文档模块清单未标注",
"onboarding/actions.ts 直查 DB - 005 文档 dependencyMatrix 需修正",
"messaging/data-access.ts 拆分后 - 005 文档 modules.messaging.exports 需更新",
"新增权限点 AUDIT_LOG_PURGE / AUDIT_RETENTION_MANAGE - 005 文档 permissions 需补记",
"新增 shared/lib/date-utils.ts - 004/005 shared 模块清单需补记"
],
"sourceOutputs": [
"docs/architecture/audit/g1-audit-output.json",
"docs/architecture/audit/g2-data-access-audit.json",
"docs/architecture/audit/g3-audit-output.json",
"docs/architecture/audit/g4-audit-output.json",
"docs/architecture/audit/g5-audit-output.json"
]
}

View File

@@ -0,0 +1,525 @@
# 数据库访问层审计报告 v1
> 创建日期2026-07-07
> 审计范围86 个 `data-access*.ts` + ~30 个 `actions.ts`
> 审计方案纯深读5 个并行 sub-agent 全量扫描)
> 框架依据:[data-access-audit-framework-v1.md](./data-access-audit-framework-v1.md)
> 原始输出:[g1-audit-output.json](./g1-audit-output.json) · [g2-data-access-audit.json](./g2-data-access-audit.json) · [g3-audit-output.json](./g3-audit-output.json) · [g4-audit-output.json](./g4-audit-output.json) · [g5-audit-output.json](./g5-audit-output.json)
---
## 一、执行摘要
| 指标 | 数值 |
|---|---|
| 审计文件总数 | 10186 data-access + 15 actions 辅查) |
| 发现问题总数 | 230 |
| P0 Critical | 177.4% |
| P1 High | 4820.9% |
| P2 Medium | 10545.6% |
| P3 Low | 6026.1% |
### 1.1 模块热度图(按 P0+P1 数量降序)
| 模块 | P0 | P1 | P0+P1 | 总计 | 风险等级 |
|---|---|---|---|---|---|
| messaging | 1 | 4 | 5 | 5 | 🔴 极高 |
| classes | 1 | 4 | 5 | 14 | 🔴 极高 |
| school | 0 | 5 | 5 | 8 | 🔴 极高 |
| lesson-preparation | 1 | 3 | 4 | 28 | 🟠 高 |
| scheduling | 1 | 2 | 3 | 6 | 🟠 高 |
| adaptive-practice | 1 | 2 | 3 | 4 | 🟠 高 |
| elective | 0 | 3 | 3 | 7 | 🟠 高 |
| textbooks | 2 | 1 | 3 | 11 | 🟠 高 |
| onboarding | 2 | 0 | 2 | 2 | 🟡 中 |
| grades | 0 | 2 | 2 | 4 | 🟡 中 |
| questions | 1 | 1 | 2 | 11 | 🟡 中 |
| audit | 1 | 1 | 2 | 3 | 🟡 中 |
| parent | 1 | 0 | 1 | 1 | 🟡 中 |
| attendance | 0 | 1 | 1 | 9 | 🟡 中 |
| files | 0 | 4 | 4 | 13 | 🟡 中 |
| exams | 1 | 0 | 1 | 1 | 🟢 低 |
| course-plans | 1 | 0 | 1 | 5 | 🟢 低 |
| homework | 0 | 1 | 1 | 2 | 🟢 低 |
| diagnostic | 0 | 1 | 1 | 2 | 🟢 低 |
| 其他 (auth/rbac/notifications/dashboard/search/ai/announcements/error-book/proctoring/settings) | 0 | 0 | 0 | 0-3 | 🟢 低 |
### 1.2 维度分布
| 维度 | 问题数 | 占比 | P0 | P1 |
|---|---|---|---|---|
| 架构违规A-* | 58 | 25.2% | 6 | 18 |
| 性能优化F-* | 71 | 30.9% | 7 | 14 |
| 结构可维护性S-* | 47 | 20.4% | 2 | 11 |
| 模式标准化P-* | 54 | 23.5% | 2 | 5 |
---
## 二、量化指标仪表盘
| 指标 | 数值 | 备注 |
|---|---|---|
| `import "server-only"` 缺失文件 | 2 | exams/data-access.ts、onboarding/data-access.ts |
| cacheFn 未覆盖读函数(估算) | 60+ | 集中在 classes24+、questions5、textbooks3、lesson-preparation4 |
| 超长文件(>800 行) | 3 | messaging1089超硬限、school938、grades-analytics831 |
| 单文件导出函数 > 20 | 4 | messaging42+、classes/data-access.ts25+、school30+、questions28、textbooks35 |
| `as` 断言(非豁免) | 2 | classes/data-access-admin.ts、classes/data-access-teacher.tsDEFAULT_CLASS_SUBJECTS widening |
| `any` 使用 | 0 | 全部合规 |
| `console.error` 调试代码 | 25+ | school12、files12、classes3、course-plans2、audit9 |
| N+1 循环 SQLF-01 | 11 | 跨 4 组 |
| `LIKE '%xxx%'` 全表扫描 | 7 | lesson-preparation(4)、questions(1)、textbooks(1)、classes(1)、messaging(1) |
| SELECT * 未指定列 | 35+ | 跨 G116、G311、G58 |
| 无 LIMIT 大表查询 | 18 | 集中在 lesson-preparation |
| 跨模块直查 schema 表A-06 | 7 | textbooks-graph(2)、lesson-preparation(2)、questions(1)、scheduling(1)、announcements(1) |
| data-access 含业务逻辑A-02 | 18 | 集中在 scheduling、messaging、elective、classes |
| 未包裹事务的多步写F-09 | 4 | course-plans、classes、auth、school |
| actions 漏/错权限校验A-08 | 4 | parent缺失全部、auditpurge 用读权限、auditretention 用读权限) |
| actions 直查 DBA-09 | 1 | onboarding |
---
## 三、P0 Critical 问题清单17 条,必须立即治理)
### 3.1 安全漏洞类4 条)
| ID | 文件 | 问题 | 修复 |
|---|---|---|---|
| G4-003 | audit/actions.ts L192-225 | `purgeAuditLogsAction``AUDIT_LOG_READ`(读权限)执行物理删除,权限提权漏洞 | 新增 `AUDIT_LOG_PURGE` 权限点 |
| G4-002 | parent/ | 模块缺失 actions.ts3 个 app 页面直接 import data-access完全绕过 `requirePermission` | 新建 parent/actions.ts3 个页面改调 Action |
| G2-001 | exams/data-access.ts L1 | 缺 `import "server-only"`DB 逻辑可能泄露到客户端 bundle | 首行添加 `import "server-only"` |
| G5-001 | onboarding/data-access.ts L1 | 缺 `import "server-only"` | 首行添加 `import "server-only"` |
### 3.2 架构硬违规类5 条)
| ID | 文件 | 问题 | 修复 |
|---|---|---|---|
| G1-001 | textbooks/data-access-graph.ts L7-121 | 直查 questions 模块 `questionsToKnowledgePoints` 表 + diagnostic 模块 `knowledgePointMastery` 表 | 改调对方 data-access 跨模块接口 |
| G3-002 | scheduling/data-access.ts L8-17 | 直查 classes/users/subjects 三模块的 schema 表 | 改调 `getClassNamesByIds`/`getUserNamesByIds` 等 |
| G3-003 | scheduling/data-access-class-schedule.ts | data-access 含时间校验、归属校验、状态机判断 | 校验逻辑移至 actions |
| G5-002 | onboarding/actions.ts L15-76 | actions.ts 直接 `import { db }` 并查 `users` 表,违反三层架构 | data-access 新增 `getUserOnboardedAt`actions 改调 |
| G4-001 | messaging/data-access.ts L1-1089 | 单文件 1089 行超 1000 硬限8 类职责混合 | 拆分为 7 个 data-access-*.ts |
### 3.3 必定性能问题类8 条)
| ID | 文件 | 问题 | 修复 |
|---|---|---|---|
| G1-002 | questions/data-access.ts L294-315 | `deleteQuestionRecursive` 递归 N+1每子题单独查询+删除 | 收集后代 ID + `inArray` 批量删除 |
| G1-003 | questions/data-access.ts L350-378 | `deleteQuestionsBatch` 循环调用 `deleteQuestionRecursive` 产生 N×深度 查询 | 一次性收集所有后代 + 单次 `inArray` 删除 |
| G1-004 | lesson-preparation/data-access-comments.ts L128-140 | `deleteComment` 递归 N+1 | 单次查询构建 parent→children Map + 批量删除 |
| G1-005 | textbooks/data-access.ts L426-458 | `reorderChapters` 循环内逐条 UPDATE | `CASE WHEN` 批量更新 |
| G3-001 | classes/data-access.ts L17-313 | 24+ 读函数全部未走 cacheFn跨模块高频调用直连 DB | 补齐 Raw + Wrapper 配对 |
| G3-004 | classes/data-access-teacher.ts L92-116 | `getTeacherClassesRaw` 循环内对每班发起 2 次子查询2N+1 | 新增批量接口 |
| G3-005 | course-plans/data-access.ts L324-331 | `reorderCoursePlanItems` 循环内 N 次 UPDATE 且未包裹事务 | 事务 + `CASE WHEN` 批量更新 |
| G2-003 | adaptive-practice/data-access-analytics.ts L311-384 | `getTeacherClassPracticeOverviewsRaw` 对每班发起 2 条 SQL2N+1 | 批量查询 + groupBy |
---
## 四、P1 High 问题清单48 条Phase 1 治理)
### 4.1 性能类14 条)
| ID | 文件 | 规则 | 概要 |
|---|---|---|---|
| G1-006~009 | lesson-preparation/questions/textbooks | F-02 | 4 处 `LIKE '%xxx%'` 全表扫描课案标题、JSON content、题目 content、教材 4 字段) |
| G1-010 | lesson-preparation/data-access.ts L247-277 | F-04 | `getLessonPlansRaw` 5 表 LEFT JOIN |
| G1-011~013 | lesson-preparation (3 处) | F-05 | 列表查询无 LIMITgetLessonPlansRaw、getPendingReviewPlansRaw、getCalendarEventsRaw |
| G1-014~015 | lesson-preparation (2 处) | F-10 | 全表拉取后内存聚合统计 |
| G1-016 | lesson-preparation/data-access-analytics.ts L168-184 | F-06 | 5 次串行 COUNT 查询同表 |
| G1-017~018 | lesson-preparation (2 处) | F-01 | 拉全表后内存 filter |
| G1-019 | textbooks/actions.ts L396-398 | F-08 | 循环调用 `getGradeNameById`N 次 DB |
| G2-002 | grades/data-access-appeals.ts L122-151 | F-01 | `getPendingAppealsForReviewRaw` JS 层 filter 班级范围(潜在数据泄露) |
| G2-004 | adaptive-practice/data-access-analytics.ts L320-325 | F-08 | 循环内跨模块调用 `getActiveStudentIdsByClassId` |
| G3-006 | course-plans/data-access.ts L309-332 | F-09 | `reorderCoursePlanItems` 多次 UPDATE 未包裹事务 |
| G3-012 | school/data-access.ts L803-822 | F-09 | `promoteGrades` 循环 UPDATE 未包裹事务 |
| G3-017 | classes/data-access-students.ts L281-285 | F-02 | `LIKE '%xxx%'` 全表扫描 users.name/email |
| G3-022 | attendance/data-access-correlation.ts L46-193 | F-01/A-02 | 148 行业务编排逻辑(含跨模块调用) |
### 4.2 架构类11 条)
| ID | 文件 | 规则 | 概要 |
|---|---|---|---|
| G4-004 | audit/actions.ts L163-190 | A-08 | `saveAuditRetentionConfigAction` 用读权限执行写操作 |
| G4-006~008 | messaging/data-access.ts (3 处) | A-02 | 状态机/防重复业务逻辑嵌入 data-access |
| G3-007~008 | school/data-access.ts | S-01/S-02 | 938 行 + 30+ 导出函数 |
| G3-010 | school/data-access.ts | A-10 | 12 处 `console.error` 吞异常 |
| G3-011 | school/data-access.ts L246-408 | A-02 | 角色判断业务逻辑嵌入 data-access |
| G3-024~025 | classes/data-access-teacher.ts L284-439 | A-02/F-09 | `enrollTeacherByInvitationCode` 155 行状态机 + 未包裹事务 |
| G5-003 | elective/data-access-operations.ts | A-02 | 业务逻辑混淆抽签算法、冲突检测、i18n 通知) |
### 4.3 结构类5 条)
| ID | 文件 | 规则 | 概要 |
|---|---|---|---|
| G2-005 | grades/data-access-analytics.ts | S-01 | 831 行超 800 警告线 |
| G5-004 | elective/data-access-operations.ts L222-304 | S-08 | DB 写入与抽签算法混淆 |
| G5-005 | files/data-access.ts | A-10 | 12 处 `console.error` |
| G5-006 | files/data-access.ts | P-05 | try-catch 吞错误返回 null/[]/false |
| G4-009 | auth/data-access.ts | F-09 | `createUser` 两次 INSERT 无事务包裹 |
(完整 P1 清单详见各 sub-agent JSON 输出)
---
## 五、按维度分组的问题清单
### 5.1 模式标准化P-*54 条)
#### P-01 `import "server-only"` 缺失2 条 P0
| ID | 文件 | 修复 |
|---|---|---|
| G2-001 | exams/data-access.ts L1 | 首行添加 `import "server-only"` |
| G5-001 | onboarding/data-access.ts L1 | 首行添加 `import "server-only"` |
#### P-03 cacheFn 未覆盖30+ 条P2
集中模块:
- **classes/data-access.ts**24+ 读函数G3-001 P0
- **lesson-preparation**4 个G1-021
- **questions**5 个G1-023
- **textbooks**3 个G1-024
- **lesson-preparation-substitutes**1 个G1-022
修复模式:
```ts
// Before
export const getClassNamesByIds = async (classIds: string[]) => { /* SQL */ }
// After
export const getClassNamesByIdsRaw = async (classIds: string[]) => { /* SQL */ }
export const getClassNamesByIds = cacheFn(getClassNamesByIdsRaw, {
tags: ["classes:names"],
ttl: 300,
keyParts: ["classes", "getClassNamesByIds"],
})
```
#### P-05 错误处理不一致13 条P1-P2
集中模块files9 处 try-catch 吞错误、school12 处 console.error + 吞异常)
#### P-07 日期序列化 helper 重复5 处P2-P3
- attendance/data-access.ts `serializeDate`
- attendance/data-access-stats.ts `serializeDate`
- scheduling/data-access.ts `serializeDate`
- school/data-access.ts `toIso`
- course-plans/data-access.ts `toIso`/`toIsoRequired`
修复:提取到 `src/shared/lib/date-utils.ts`
#### P-09 `as` 断言2 条 P3非豁免
- classes/data-access-admin.ts L36 `DEFAULT_CLASS_SUBJECTS as readonly string[]`
- classes/data-access-teacher.ts L41 同上
#### P-10 `any` 使用
零违规,全部合规。
### 5.2 性能优化F-*71 条)
#### F-01 N+1 循环 SQL11 条,跨 P0/P1/P2
| ID | 文件 | 模式 |
|---|---|---|
| G1-002 | questions deleteQuestionRecursive | 递归内单独查询+删除 |
| G1-003 | questions deleteQuestionsBatch | 循环调用递归删除 |
| G1-004 | lesson-preparation deleteComment | 递归内单独查询+删除 |
| G1-005 | textbooks reorderChapters | 循环内逐条 UPDATE |
| G1-017 | lesson-preparation getSchedulesByDateRangeRaw | 拉全表后内存 filter |
| G1-018 | lesson-preparation getResponsesByStudentIdRaw | 拉全量后内存 filter |
| G2-002 | grades getPendingAppealsForReviewRaw | JS 层 filter 班级范围 |
| G2-003 | adaptive-practice getTeacherClassPracticeOverviewsRaw | Promise.all 内 2N+1 |
| G3-004 | classes getTeacherClassesRaw | 循环内 2 次子查询 |
| G3-005 | course-plans reorderCoursePlanItems | 循环内 N 次 UPDATE |
| G3-042 | classes generateUniqueInvitationCode | 循环内重试查询 |
#### F-02 `LIKE '%xxx%'` 全表扫描7 条 P1
| ID | 文件 | 字段 |
|---|---|---|
| G1-006 | lesson-preparation | lessonPlans.title |
| G1-007 | lesson-preparation-knowledge | lessonPlans.content (JSON) |
| G1-008 | questions | questions.content (JSON, +LOWER+CAST) |
| G1-009 | textbooks | title/subject/grade/publisher 4 字段 |
| G3-017 | classes-students | users.name/email |
| G4-010 | messaging | messages.subject/content |
修复策略:
- 短期:前缀匹配 `LIKE 'xxx%'`(可走索引)
- 中期FULLTEXT 索引 + `MATCH AGAINST IN BOOLEAN MODE`questions 表已实施,参见架构图 1.1.4
- 长期:关联表存储提取后的关系(如 lesson_plan_knowledge_point_refs
#### F-03 SELECT * 未指定列35+ 条 P2-P3
集中模块lesson-preparation16 处、school4 处、scheduling3 处、attendance2 处、course-plans7 处、files8 处)
#### F-05 无 LIMIT 大表查询18 条 P1-P2
集中模块lesson-preparation7 处、textbooks2 处、classes3 处、proctoring1 处)
#### F-09 事务范围问题4 条 P1
| ID | 文件 | 问题 |
|---|---|---|
| G3-006 | course-plans reorderCoursePlanItems | 多次 UPDATE 未包裹事务 |
| G3-012 | school promoteGrades | 循环 UPDATE 未包裹事务 |
| G3-025 | classes enrollTeacherByInvitationCode | 多次写操作未包裹事务 |
| G4-009 | auth createUser | 两次 INSERT 无事务包裹 |
#### F-10 全表 COUNT 无过滤4 条 P2
集中模块textbooks、questions、lesson-preparation、classes
### 5.3 架构违规A-*58 条)
#### A-02 data-access 含业务逻辑18 条 P0-P2
| 模块 | 文件 | 业务逻辑类型 |
|---|---|---|
| scheduling | data-access-class-schedule.ts | 时间校验 + 归属校验 + 状态机 |
| scheduling | data-access.ts | — |
| messaging | data-access.ts | 撤回状态机 + 防重复 + 页面编排 |
| elective | data-access-operations.ts | 抽签算法 + 冲突检测 + i18n 通知 |
| classes | data-access-teacher.ts | 邀请码状态机 + 角色校验 |
| classes | data-access-invitations.ts | 懒清理状态迁移 |
| school | data-access.ts | 角色判断 + 权限感知查询 |
| attendance | data-access-correlation.ts | 跨模块编排 + 成绩归一化 |
| attendance | data-access-stats.ts | 纯计算函数导出 |
| lesson-preparation | data-access-review.ts | 状态机迁移 |
| lesson-preparation | data-access-ai-evaluation.ts | 评分算法纯函数 |
| textbooks | data-access.ts | 重排序算法 |
| diagnostic | data-access.ts | 掌握度累积计算 |
| homework | data-access.ts | computeOverdueCount 闭包 |
#### A-06 跨模块直查 schema 表7 条 P0-P2
| ID | 文件 | 被查模块 |
|---|---|---|
| G1-001 | textbooks/data-access-graph.ts | questions + diagnostic |
| G1-031 | lesson-preparation/data-access.ts | textbooks (textbooks/chapters) |
| G1-032 | lesson-preparation/data-access-schedules.ts | classes |
| G1-033 | questions/data-access.ts | textbooks (knowledgePoints) |
| G3-002 | scheduling/data-access.ts | classes + users + subjects |
#### A-08 actions 权限校验问题4 条 P0-P1
| ID | 文件 | 问题 |
|---|---|---|
| G4-002 | parent/ | 模块缺失 actions.ts3 页面直访 data-access |
| G4-003 | audit/actions.ts | purge 用读权限 |
| G4-004 | audit/actions.ts | retention 配置用读权限 |
| G4-047 | rbac/data-access-assignments.ts | 内存 post-fetch 过滤导致 total 错误(伴随 A-02 |
#### A-09 actions 直查 DB1 条 P0
| ID | 文件 | 问题 |
|---|---|---|
| G5-002 | onboarding/actions.ts L15-76 | 直接 `import { db }` 并查 `users` 表 |
#### A-10 `console.error` 调试代码25+ 条 P1-P2
| 模块 | 文件 | 数量 |
|---|---|---|
| school | data-access.ts | 12 |
| files | data-access.ts | 12 |
| classes | data-access-teacher/students/admin | 3 |
| course-plans | data-access.ts | 2 |
| audit | data-access.ts | 9 |
### 5.4 结构与可维护性S-*47 条)
#### S-01 超长文件3 条 P0-P1
| ID | 文件 | 行数 | 状态 |
|---|---|---|---|
| G4-001 | messaging/data-access.ts | 1089 | 超 1000 硬限,必须拆分 |
| G3-007 | school/data-access.ts | 938 | 超 800 警告,接近硬限 |
| G2-005 | grades/data-access-analytics.ts | 831 | 超 800 警告 |
#### S-02 单文件导出函数过多4 条 P2
| 文件 | 导出数 |
|---|---|
| messaging/data-access.ts | 42+ |
| school/data-access.ts | 30+ |
| textbooks/data-access.ts | 35 |
| questions/data-access.ts | 28 |
| classes/data-access.ts | 25+ |
#### S-03 重复 helper8 条 P2
| helper | 出现模块 |
|---|---|
| serializeDate/toIso | attendance、scheduling、school、course-plans |
| toLessonPlanStatus | lesson-preparation2 文件) |
| isStringArray | lesson-preparation2 文件) |
| fetchClassesWithSubjects | classes2 函数 145+124 行重复) |
| fetchGradesWithHeads | school3 函数重复) |
#### S-06 缺 JSDoc15+ 条 P2-P3
集中模块lesson-preparationversions/templates、questions、textbooks
---
## 六、P0-P3 优先级矩阵
```
高影响
│ P0 立即治理 P1 Phase 1
│ ───────────────── ─────────────────
│ • parent 权限漏洞 • N+1 循环 SQL非热路径
│ • audit 权限提权 • LIKE 全表扫描
│ • server-only 缺失 • 超长文件school/grades
│ • 跨模块 schema 直查 • 业务逻辑嵌入 data-access
│ • N+1 循环 SQL热路径 • 事务未包裹
│ • messaging 超硬限 • console.error 吞异常
├──────────────────────────────────────────────
│ P2 Phase 2 P3 Phase 3
│ ───────────────── ─────────────────
│ • cacheFn 未覆盖 • as 断言widening
│ • SELECT * 未指定列 • 非空断言 !
│ • 无 LIMIT 大表查询 • JSDoc 补齐
│ • 重复 helper • 动态 import 注释
│ • 单文件导出过多 • export * 改显式
低影响
高紧迫 ─────────────────── 低紧迫
```
---
## 七、分阶段治理路线图
### Phase 0紧急安全修复XS-S立即执行
| 任务 | ID | 工作量 | 验证 |
|---|---|---|---|
| 添加 `import "server-only"` 到 exams/data-access.ts | G2-001 | XS | tsc + lint |
| 添加 `import "server-only"` 到 onboarding/data-access.ts | G5-001 | XS | tsc + lint |
| 新建 parent/actions.ts3 页面改调 Action | G4-002 | M | 手动测试 3 页面 |
| audit purge 权限点新增 + 替换 | G4-003 | S | 权限矩阵测试 |
| audit retention 权限点替换 | G4-004 | S | 权限矩阵测试 |
| onboarding/actions.ts 移除直查 DB | G5-002 | S | tsc + lint |
**Phase 0 完成标准**:所有 P0 安全漏洞修复,`npm run lint` + `npx tsc --noEmit` 零错误。
### Phase 1P0 架构与性能修复M-L1-2 周)
| 任务批次 | 涉及 ID | 工作量 | 依赖 |
|---|---|---|---|
| **1.1 跨模块 schema 直查治理** | G1-001, G3-002, G1-031~033 | L | 需在 questions/diagnostic/textbooks/classes 模块新增跨模块接口 |
| **1.2 messaging 拆分** | G4-001, G4-005~008 | L | 拆分为 7 个子文件 + 业务逻辑移至 actions |
| **1.3 N+1 热路径修复** | G1-002~005, G3-001, G3-004, G3-005, G2-003 | L | classes 补齐 cacheFn 是基础 |
| **1.4 scheduling 业务逻辑下移** | G3-003, G3-024, G3-025 | M | data-access-class-schedule.ts 重写 |
| **1.5 elective 业务逻辑拆分** | G5-003, G5-004 | L | 提取 lib/lottery.ts + lib/schedule-conflict.ts |
**Phase 1 完成标准**:所有 P0 修复,关键路径性能提升,架构分层清晰。
### Phase 2P1 性能与结构优化M-L2-3 周)
| 任务批次 | 涉及 ID | 工作量 |
|---|---|---|
| **2.1 LIKE 全表扫描治理** | G1-006~009, G3-017, G4-010 | LFULLTEXT 索引 + 查询重写) |
| **2.2 超长文件拆分** | G3-007, G2-005 | Mschool 按职责拆 8 文件、grades-analytics 按维度拆) |
| **2.3 school 模块重构** | G3-007~011 | L拆分 + 角色判断移至 actions + 删除 console.error |
| **2.4 files 模块错误处理重构** | G5-005, G5-006, G5-007 | M删除 try-catch + console.error |
| **2.5 事务包裹修复** | G3-006, G3-012, G3-025, G4-009 | S |
| **2.6 无 LIMIT 查询保护** | G1-011~013, G1-050~053, G3-031~032, G3-041, G3-044 | M |
**Phase 2 完成标准**:所有 P1 修复,无超长文件,无 LIKE 全表扫描,无未包裹事务。
### Phase 3P2 模式标准化S-M1-2 周)
| 任务批次 | 涉及 ID | 工作量 |
|---|---|---|
| **3.1 cacheFn 全量补齐** | G1-021~024, G3-001剩余 | M |
| **3.2 SELECT * 改显式列** | G1-039~049, G3-009, G3-026~028, G5-007 | M机械替换 |
| **3.3 日期 helper 提取** | G3-021, G3-047, G1-025 | S提取 shared/lib/date-utils.ts |
| **3.4 重复 helper 提取** | G1-026~027, G3-020, G3-036 | M |
| **3.5 JSDoc 补齐** | G1-034~036, G1-066~067, G3-046 | M |
**Phase 3 完成标准**:所有 P2 修复模式统一helper 集中到 shared/lib。
### Phase 4P3 风格优化XS按需
| 任务 | 涉及 ID | 工作量 |
|---|---|---|
| `as` widening 断言改类型标注 | G3-029~030 | XS |
| 非空断言 `!` 改类型守卫 | G1-060~065 | XS |
| `export *` 改显式 re-export | G3-048 | S |
| 死代码删除 | G3-018 | XS |
**Phase 4 完成标准**:零 `as`(非豁免)、零 `!`、零死代码。
---
## 八、跨模块治理建议
### 8.1 新增 shared/lib 公共 helper
| helper | 路径 | 用途 | 替代模块 |
|---|---|---|---|
| `toISODateString` | shared/lib/date-utils.ts | 日期序列化 | attendance/scheduling/school/course-plans |
| `buildScopeFilter` | shared/lib/scope-filter.ts | DataScope → SQL 过滤 | attendance/grades/homework 等重复实现 |
| `serializeDate` | (合并到 date-utils.ts | 同 toISODateString | — |
### 8.2 新增跨模块批量接口
| 接口 | 模块 | 用途 | 调用方 |
|---|---|---|---|
| `getActiveStudentIdsByClassIds(classIds)` | classes | 批量获取多班学生 ID | adaptive-practice、attendance |
| `getGradeNamesByIds(gradeIds)` | school | 批量获取年级名称 | textbooks、lesson-preparation |
| `getQuestionCountByKpIds(kpIds)` | questions | 知识点关联题目数 | textbooks |
| `getKpMasteryByTextbookId(textbookId)` | diagnostic | 教材下知识点掌握度 | textbooks |
### 8.3 新增权限点
| 权限点 | 用途 | 角色映射 |
|---|---|---|
| `AUDIT_LOG_PURGE` | 审计日志物理删除 | admin 专属 |
| `AUDIT_RETENTION_MANAGE` | 审计保留策略配置 | admin 专属 |
---
## 九、附录
### 9.1 完整规则表
见 [data-access-audit-framework-v1.md](./data-access-audit-framework-v1.md) 第二节。
### 9.2 sub-agent 原始输出索引
| 组 | 文件 | 问题数 |
|---|---|---|
| G1 | [g1-audit-output.json](./g1-audit-output.json) | 67 |
| G2 | [g2-data-access-audit.json](./g2-data-access-audit.json) | 11 |
| G3 | [g3-audit-output.json](./g3-audit-output.json) | 50 |
| G4 | [g4-audit-output.json](./g4-audit-output.json) | 61 |
| G5 | [g5-audit-output.json](./g5-audit-output.json) | 41 |
### 9.3 架构图遗漏记录
审计过程中发现的架构图004/005需补记项治理阶段统一补图
1. **parent 模块缺失 actions.ts** —— 004 文档模块清单未标注此异常
2. **onboarding/actions.ts 直查 DB** —— 005 文档 dependencyMatrix 需修正
3. **messaging/data-access.ts 拆分后** —— 005 文档 modules.messaging.exports 需更新
4. **新增权限点 AUDIT_LOG_PURGE / AUDIT_RETENTION_MANAGE** —— 005 文档 permissions 节点需补记
5. **新增 shared/lib/date-utils.ts** —— 004/005 shared 模块清单需补记
### 9.4 治理验证检查清单
每个 Phase 完成后必须通过:
- [ ] `npm run lint` 零错误
- [ ] `npx tsc --noEmit` 零错误
- [ ] 架构文档 004/005 同步更新
- [ ] `docs/troubleshooting/known-issues.md` 追加新模式
- [ ] 受影响模块的功能测试通过
- [ ] P0/P1 问题在 issues JSON 中标记为 resolved

View File

@@ -0,0 +1,806 @@
[
{
"id": "G1-001",
"file": "src/modules/textbooks/data-access-graph.ts",
"lines": "L7-L13, L46-L53, L107-L121",
"ruleId": "A-06",
"severity": "P0",
"dimension": "architecture",
"title": "textbooks 模块直接查询 questions/diagnostic 模块的表",
"description": "data-access-graph.ts 从 @/shared/db/schema 导入 questionsToKnowledgePoints属 questions 模块)和 knowledgePointMastery属 diagnostic 模块),并直接执行 SELECT FROM 查询L46-53 查 questionsToKnowledgePointsL107-121 查 knowledgePointMastery。这违反了三层架构'模块间通过对方 data-access 通信,不直接查询对方 DB 表'的规则。",
"recommendation": "1) questionsToKnowledgePoints 的关联题目数查询应改为调用 questions 模块 data-access 暴露的跨模块接口(如 getQuestionCountByKpIds2) knowledgePointMastery 查询应改为调用 diagnostic 模块 data-access 暴露的接口(如 getKpMasteryByTextbookId。",
"effort": "M (≤2h)"
},
{
"id": "G1-002",
"file": "src/modules/questions/data-access.ts",
"lines": "L294-L315",
"ruleId": "F-01",
"severity": "P0",
"dimension": "performance",
"title": "deleteQuestionRecursive 递归 N+1每个子题单独查询+删除",
"description": "deleteQuestionRecursive 在递归中对每个子题先 SELECT 子题列表L305-308再 for 循环递归调用自身L310-312最后 DELETE 当前题L314。对于有 N 层子题的复合题,会产生 2N 次数据库往返。",
"recommendation": "改为先递归收集所有后代 ID 到一个数组单次查询children即可然后用 inArray 批量 DELETE`await tx.delete(questions).where(inArray(questions.id, allDescendantIds))`。",
"effort": "S (≤30 分钟)"
},
{
"id": "G1-003",
"file": "src/modules/questions/data-access.ts",
"lines": "L350-L378",
"ruleId": "F-01",
"severity": "P0",
"dimension": "performance",
"title": "deleteQuestionsBatch 循环调用 deleteQuestionRecursive 产生 N+1",
"description": "deleteQuestionsBatch 在 L372-374 对 targetIds 数组 for 循环,每个 id 单独调用 deleteQuestionRecursive每次调用内部又递归查询子题。批量删除 M 个题目时产生 M × (递归深度) 次查询。",
"recommendation": "先将所有 targetIds 的后代 ID 一次性收集(用 inArray 批量查询 parentId in targetIds递归用 Map 解析),再单次 inArray 批量删除所有后代+自身。",
"effort": "M (≤2h)"
},
{
"id": "G1-004",
"file": "src/modules/lesson-preparation/data-access-comments.ts",
"lines": "L128-L140",
"ruleId": "F-01",
"severity": "P0",
"dimension": "performance",
"title": "deleteComment 递归 N+1每个子回复单独查询+删除",
"description": "deleteComment 先 SELECT 子回复列表L130-133再 for 循环递归调用 deleteCommentL134-136最后 DELETE 当前评论L137-139。嵌套回复深时产生大量 DB 往返。",
"recommendation": "改为先用单次查询获取该 plan 下所有评论,在内存中构建 parent→children Map收集所有后代 ID 后用 inArray 批量删除。",
"effort": "S (≤30 分钟)"
},
{
"id": "G1-005",
"file": "src/modules/textbooks/data-access.ts",
"lines": "L426-L458",
"ruleId": "F-01",
"severity": "P0",
"dimension": "performance",
"title": "reorderChapters 循环内逐条 UPDATEN+1",
"description": "reorderChapters 在事务内 for 循环遍历所有兄弟章节L445-457每个章节单独执行 tx.updateL448-454。重排 N 个章节产生 N 次 UPDATE 语句。",
"recommendation": "使用 CASE WHEN 批量更新:`UPDATE chapters SET order = CASE id WHEN ... THEN ... END, parentId = CASE id WHEN ... THEN ... END WHERE id IN (...)`,或用 sql`VALUES(...)` 构造批量更新。",
"effort": "M (≤2h)"
},
{
"id": "G1-006",
"file": "src/modules/lesson-preparation/data-access.ts",
"lines": "L237",
"ruleId": "F-02",
"severity": "P1",
"dimension": "performance",
"title": "LIKE '%query%' 全表扫描查询课案标题",
"description": "getLessonPlansRaw 在 L237 使用 `like(lessonPlans.title, \\`%${escapeLikePattern(params.query)}%\\`)`,前导通配符 % 导致无法使用索引,全表扫描。课案表数据量大时严重影响性能。",
"recommendation": "对 lessonPlans.title 建立全文索引MySQL FULLTEXT INDEX改用 `sql\\`MATCH(title) AGAINST(${query} IN BOOLEAN MODE)\\``;或至少对高频查询场景使用前缀匹配 `like(title, query + '%')`。",
"effort": "M (≤2h)"
},
{
"id": "G1-007",
"file": "src/modules/lesson-preparation/data-access-knowledge.ts",
"lines": "L99, L129",
"ruleId": "F-02",
"severity": "P1",
"dimension": "performance",
"title": "LIKE '%id%' 全表扫描 JSON content 字段",
"description": "getLessonPlansByKnowledgePointRawL99和 getLessonPlansByQuestionRawL129对 lessonPlans.contentJSON 列)使用 `like(content, \\`%${kpId}%\\`)` 做粗筛。JSON 列上的 LIKE 全表扫描代价极高,且无法走索引。",
"recommendation": "建立关联表 lesson_plan_knowledge_point_refs(plan_id, knowledge_point_id) 和 lesson_plan_question_refs(plan_id, question_id) 存储提取后的关联关系,改用 inArray 等值查询。短期可加 LIMIT 并在 actions 层缓存结果。",
"effort": "L (≤1d)"
},
{
"id": "G1-008",
"file": "src/modules/questions/data-access.ts",
"lines": "L60-L65",
"ruleId": "F-02",
"severity": "P1",
"dimension": "performance",
"title": "LOWER(CAST(content AS CHAR)) LIKE '%q%' 全表扫描",
"description": "getQuestionsRaw 在 L61-64 使用 `sql\\`LOWER(CAST(${questions.content} AS CHAR)) LIKE ${needle}\\`` 对 JSON content 列做 LIKE 模糊搜索,包含 LOWER + CAST + 前导 % 三重性能杀手,无法走索引。",
"recommendation": "对 questions 表增加 searchable_text 列(存储从 content 提取的纯文本),建立 FULLTEXT 索引;或引入 Meilisearch/TypeSense 等外部搜索引擎处理题目全文检索。",
"effort": "L (≤1d)"
},
{
"id": "G1-009",
"file": "src/modules/textbooks/data-access.ts",
"lines": "L48-L54, L545-L551",
"ruleId": "F-02",
"severity": "P1",
"dimension": "performance",
"title": "LIKE '%q%' 全表扫描 4 个字段",
"description": "getTextbooksRawL48-54和 getTextbooksWithScopeRawL545-551对 title/subject/grade/publisher 四个字段做 `like(field, \\`%${q}%\\`)` OR 查询4 个前导通配符 LIKE 全表扫描。",
"recommendation": "对 title 建立全文索引;或将 subject/grade/publisher 改为等值过滤(下拉选择),仅 title 做前缀匹配。",
"effort": "M (≤2h)"
},
{
"id": "G1-010",
"file": "src/modules/lesson-preparation/data-access.ts",
"lines": "L247-L277",
"ruleId": "F-04",
"severity": "P1",
"dimension": "performance",
"title": "getLessonPlansRaw 5 表 LEFT JOIN",
"description": "getLessonPlansRaw 在 L270-275 对 lessonPlans LEFT JOIN textbooks/chapters/subjects/grades/users 共 5 个表。JOIN 表数量 > 3查询计划复杂度高且无 LIMIT。",
"recommendation": "拆分为两步1) 先查 lessonPlans 主表(带 scope + 过滤条件 + LIMIT + ORDER BY2) 用 collect 的 textbookId/chapterId/subjectId/gradeId/creatorId 批量查 textbooks/chapters/subjects/grades/users 名称,在内存中 Map 关联。",
"effort": "L (≤1d)"
},
{
"id": "G1-011",
"file": "src/modules/lesson-preparation/data-access.ts",
"lines": "L247-L277",
"ruleId": "F-05",
"severity": "P1",
"dimension": "performance",
"title": "getLessonPlansRaw 列表查询无 LIMIT",
"description": "getLessonPlansRaw 查询课案列表时无 LIMIT当课案数量增长时会一次性拉取全表数据到内存做分组聚合L283-316可能导致 OOM。",
"recommendation": "添加默认分页 `.limit(pageSize).offset(offset)`,或至少 `.limit(500)` 保护;版本聚合逻辑应改为分页后处理。",
"effort": "S (≤30 分钟)"
},
{
"id": "G1-012",
"file": "src/modules/lesson-preparation/data-access-review.ts",
"lines": "L168-L218, L255-L294",
"ruleId": "F-05",
"severity": "P1",
"dimension": "performance",
"title": "getPendingReviewPlansRaw / getPlansByStatusesRaw 无 LIMIT",
"description": "getPendingReviewPlansRawL184-196和 getPlansByStatusesRawL275-285均无 LIMIT且后者还在内存中做 filterL199-208而非 SQL 过滤。待审核/按状态查询的课案可能很多。",
"recommendation": "添加分页参数 page/pageSizeSQL 层用 inArray 过滤 gradeId/subjectId 而非内存 filter加 `.limit(pageSize).offset(offset)`。",
"effort": "S (≤30 分钟)"
},
{
"id": "G1-013",
"file": "src/modules/lesson-preparation/data-access-calendar.ts",
"lines": "L43-L60, L102-L120, L136-L153",
"ruleId": "F-05",
"severity": "P1",
"dimension": "performance",
"title": "getCalendarEventsRaw 三段查询均无 LIMIT",
"description": "getCalendarEventsRaw 对 lessonPlansL43、lessonPlanVersionsL102、lessonPlanReviewRecordsL136三段查询均无 LIMIT。日历范围跨度大时可能拉取大量记录。",
"recommendation": "每段查询添加 `.limit(500)` 上限保护,或在 actions 层强制限制日期范围跨度(如最多 90 天)。",
"effort": "S (≤30 分钟)"
},
{
"id": "G1-014",
"file": "src/modules/lesson-preparation/data-access-formative.ts",
"lines": "L212-L239",
"ruleId": "F-10",
"severity": "P1",
"dimension": "performance",
"title": "getFormativeItemStatsRaw 全表拉取后内存聚合统计",
"description": "getFormativeItemStatsRaw 在 L215-218 SELECT 所有作答记录(无 LIMIT然后在 L220-232 内存循环统计 total/correct/incorrect/avgDuration。一个互动组件可能有上千条作答。",
"recommendation": "改用 SQL 聚合:`SELECT COUNT(*) as total, SUM(isCorrect=1) as correct, SUM(isCorrect=0) as incorrect, AVG(durationSec) as avgDuration FROM ... WHERE itemId=?`,单次查询完成。",
"effort": "S (≤30 分钟)"
},
{
"id": "G1-015",
"file": "src/modules/lesson-preparation/data-access-comments.ts",
"lines": "L145-L157",
"ruleId": "F-10",
"severity": "P1",
"dimension": "performance",
"title": "countUnresolvedCommentsRaw SELECT 全部 ID 后取 length 计数",
"description": "countUnresolvedCommentsRaw 在 L146-155 SELECT 所有匹配的 id 字段,然后 L156 `return rows.length` 计数。应直接用 SQL COUNT 聚合,避免拉取全部行数据。",
"recommendation": "改为 `.select({ count: count() }).from(...).where(...)`,返回 `Number(rows[0]?.count ?? 0)`。",
"effort": "XS (≤15 分钟)"
},
{
"id": "G1-016",
"file": "src/modules/lesson-preparation/data-access-analytics.ts",
"lines": "L168-L184",
"ruleId": "F-06",
"severity": "P1",
"dimension": "performance",
"title": "getGlobalLessonPlanStatsRaw 5 次串行查询同表",
"description": "getGlobalLessonPlanStatsRaw 对 lessonPlans/lessonPlanStandards 表执行 5 次 SELECT COUNT 查询L168-184且是串行 await。仪表盘每次加载产生 5 次 DB 往返。",
"recommendation": "合并为单次 GROUP BY 查询:`SELECT status, COUNT(*) FROM lessonPlans GROUP BY status`,或用 Promise.all 并行执行lessonPlanStandards 计数可合并到同一查询。",
"effort": "S (≤30 分钟)"
},
{
"id": "G1-017",
"file": "src/modules/lesson-preparation/data-access-schedules.ts",
"lines": "L77-L123",
"ruleId": "F-01",
"severity": "P1",
"dimension": "performance",
"title": "getSchedulesByDateRangeRaw 拉全表后内存 filter",
"description": "getSchedulesByDateRangeRaw 仅按日期范围查询L99-104然后用 `rows.filter((r) => teacherPlanIds.includes(r.planId))`L108-109在内存过滤教师课案。注释 L101 自述'简化:仅按日期范围过滤'。当全校课案绑定量大时拉取大量无关数据。",
"recommendation": "将 planId 过滤下推到 SQL`inArray(lessonPlanSchedules.planId, teacherPlanIds)`,配合日期范围条件,避免拉取无关行。",
"effort": "S (≤30 分钟)"
},
{
"id": "G1-018",
"file": "src/modules/lesson-preparation/data-access-formative.ts",
"lines": "L182-L205",
"ruleId": "F-01",
"severity": "P1",
"dimension": "performance",
"title": "getResponsesByStudentIdRaw 拉全量作答后内存 filter",
"description": "getResponsesByStudentIdRaw 当传入 planId 时L187-198先查该 plan 的 formative items IDL188-191再 SELECT 该学生的全部 responsesL194-197 无 itemId 过滤),最后内存 filter `itemIds.includes(r.itemId)`L198。应直接用 inArray 在 SQL 过滤。",
"recommendation": "在 L196 的 WHERE 中增加 `inArray(lessonPlanFormativeResponses.itemId, itemIds)` 条件,移除内存 filter。",
"effort": "S (≤30 分钟)"
},
{
"id": "G1-019",
"file": "src/modules/textbooks/actions.ts",
"lines": "L396-L398",
"ruleId": "F-08",
"severity": "P1",
"dimension": "performance",
"title": "getKnowledgeGraphDataAction 循环调用 getGradeNameByIdN+1",
"description": "getKnowledgeGraphDataAction 在 L396-398 用 `Promise.all(allowedGradeIds.map((gid) => getGradeNameById(gid)))` 逐个查询年级名称。虽然 Promise.all 并行了请求,但仍是 N 次 DB 查询。",
"recommendation": "school 模块应提供批量接口 `getGradeNamesByIds(gradeIds): Promise<Map<string,string>>`,单次 inArray 查询返回映射。",
"effort": "S (≤30 分钟)"
},
{
"id": "G1-020",
"file": "src/modules/questions/data-access.ts",
"lines": "L39-L49",
"ruleId": "P-04",
"severity": "P1",
"dimension": "pattern",
"title": "getQuestionsRaw 缺少显式返回类型标注",
"description": "getQuestionsRawL39使用 `=> {` 箭头函数,未显式标注返回类型 `Promise<T>`,依赖 TypeScript 推断。违反 P-04 规则'函数返回值必须显式标注,特别是 Promise<T>'。",
"recommendation": "定义返回类型并显式标注:`export const getQuestionsRaw = async (params: GetQuestionsParams = {}): Promise<QuestionsListResult> => { ... }`,将返回结构提取为命名类型。",
"effort": "XS (≤15 分钟)"
},
{
"id": "G1-021",
"file": "src/modules/lesson-preparation/data-access.ts",
"lines": "L574-L590, L416-L426, L429-L444, L593-L617",
"ruleId": "P-03",
"severity": "P2",
"dimension": "pattern",
"title": "4 个读函数未走 cacheFn 包装",
"description": "getLessonPlanStatsL574、getTextbooksForPickerL416、getChaptersForPickerL429、getTemplateByIdL593均为纯读函数但未用 cacheFn 包装。其中 getTemplateById 在 createLessonPlan 热路径中被调用L366缺少缓存影响创建性能。",
"recommendation": "为每个读函数添加 Raw + cacheFn 配对:`export const getTemplateById = cacheFn(getTemplateByIdRaw, { tags: [...], ttl: 300, keyParts: [...] })`。",
"effort": "S (≤30 分钟)"
},
{
"id": "G1-022",
"file": "src/modules/lesson-preparation/data-access-substitutes.ts",
"lines": "L125-L141",
"ruleId": "P-03",
"severity": "P2",
"dimension": "pattern",
"title": "canTeacherAccessPlan 读函数未走 cacheFn",
"description": "canTeacherAccessPlanL125是读函数查询 plan + 查询 substitutes但未用 cacheFn 包装。该函数可能在权限校验热路径被频繁调用。",
"recommendation": "拆为 canTeacherAccessPlanRaw + cacheFn 包装,注意 TTL 应较短60s因权限相关。",
"effort": "XS (≤15 分钟)"
},
{
"id": "G1-023",
"file": "src/modules/questions/data-access.ts",
"lines": "L380-L384, L391-L399, L406-L426, L433-L436, L577-L622",
"ruleId": "P-03",
"severity": "P2",
"dimension": "pattern",
"title": "5 个读函数未走 cacheFn 包装",
"description": "getKnowledgePointOptionsL380、getTextbookOptionsL391、getChapterOptionsL406、getKnowledgePointOptionsByChapterL433、exportQuestionsL577均为读函数但未用 cacheFn。前四个是级联筛选下拉数据频繁调用。",
"recommendation": "为 getKnowledgePointOptions/getTextbookOptions/getChapterOptions/getKnowledgePointOptionsByChapter 添加 cacheFnttl 可较长 600s。exportQuestions 因可能导出大结果集,可不缓存或短 TTL。",
"effort": "S (≤30 分钟)"
},
{
"id": "G1-024",
"file": "src/modules/textbooks/data-access.ts",
"lines": "L492-L504, L511-L524, L689-L703",
"ruleId": "P-03",
"severity": "P2",
"dimension": "pattern",
"title": "3 个读函数未走 cacheFn 包装",
"description": "verifyChapterBelongsToTextbookL492、verifyKnowledgePointBelongsToTextbookL511、getPrerequisiteEdgesForTextbookL689均为读函数但未用 cacheFn。verify* 函数在 actions 层归属校验热路径中被频繁调用actions.ts 中多处调用)。",
"recommendation": "添加 cacheFn 包装TTL 较短60-120s。getPrerequisiteEdgesForTextbook 用于循环检测,可缓存 300s。",
"effort": "S (≤30 分钟)"
},
{
"id": "G1-025",
"file": "src/modules/lesson-preparation/data-access-schedules.ts",
"lines": "L29-L34",
"ruleId": "P-07",
"severity": "P2",
"dimension": "pattern",
"title": "toDateStr 本地实现日期序列化,未用 shared helper",
"description": "toDateStrL29-34手动拼接 YYYY-MM-DD 字符串,未使用项目统一的 serializeDate/toISODateString helper。其他模块如 data-access.ts 的 mapRowToLessonPlan使用 `.toISOString()` 序列化。",
"recommendation": "统一使用 shared/lib 中的日期序列化 helper或将 toDateStr 提取到 shared/lib/date-utils.ts 供所有模块复用。",
"effort": "XS (≤15 分钟)"
},
{
"id": "G1-026",
"file": "src/modules/lesson-preparation/data-access-knowledge.ts",
"lines": "L16-L18",
"ruleId": "S-03",
"severity": "P2",
"dimension": "structure",
"title": "isStringArray 与 lib/type-guards 重复实现",
"description": "data-access-knowledge.ts 在 L16-18 本地定义 isStringArray而 data-access-ai-evaluation.ts L14 已从 './lib/type-guards' 导入同名函数。同一模块内重复实现 helper。",
"recommendation": "删除 data-access-knowledge.ts L16-18 的本地实现,改为 `import { isStringArray } from './lib/type-guards'`。",
"effort": "XS (≤15 分钟)"
},
{
"id": "G1-027",
"file": "src/modules/lesson-preparation/data-access-review.ts",
"lines": "L16-L23",
"ruleId": "S-03",
"severity": "P2",
"dimension": "structure",
"title": "toLessonPlanStatus/toReviewDecision 在多个文件重复定义",
"description": "data-access-review.tsL16-18和 data-access-calendar.tsL16-18各自定义了 toLessonPlanStatus 函数逻辑完全相同isLessonPlanStatus 守卫失败回退 'draft'。toReviewDecisionL21-23也仅在本文件定义但可共享。",
"recommendation": "将 toLessonPlanStatus 提取到 lib/type-guards.ts 或 lib/serialize.ts两个 data-access 文件统一导入。",
"effort": "XS (≤15 分钟)"
},
{
"id": "G1-028",
"file": "src/modules/lesson-preparation/data-access-ai-evaluation.ts",
"lines": "L141-L192",
"ruleId": "A-02",
"severity": "P2",
"dimension": "architecture",
"title": "evaluateDocument 业务逻辑(评分算法)放在 data-access 层",
"description": "evaluateDocumentL141-192是纯业务逻辑函数基于规则计算 5 维度评分 + 生成建议),不涉及任何 DB 操作,却导出在 data-access 文件中。违反 A-02'data-access 不含业务逻辑'规则。",
"recommendation": "将 evaluateDocument 移至 lib/ai-evaluation.ts纯函数模块data-access-ai-evaluation.ts 仅保留 DB CRUD。",
"effort": "XS (≤15 分钟)"
},
{
"id": "G1-029",
"file": "src/modules/lesson-preparation/data-access-review.ts",
"lines": "L40-L45, L50-L76, L82-L137, L225-L250",
"ruleId": "A-02",
"severity": "P2",
"dimension": "architecture",
"title": "状态机逻辑isValidTransition + 状态迁移)放在 data-access 层",
"description": "isValidTransitionL40-45是状态机校验纯函数submitForReviewL50-76、reviewPlanL82-137、withdrawSubmissionL225-250内部包含状态迁移判断逻辑L66-68、L100-107、L240-242属于业务编排而非纯数据访问。",
"recommendation": "将 isValidTransition 和状态迁移判断逻辑移至 actions-review.ts 或 lib/status-machine.tsdata-access 仅暴露 updateStatus(planId, newStatus) 和 insertReviewRecord() 等纯数据操作。",
"effort": "M (≤2h)"
},
{
"id": "G1-030",
"file": "src/modules/textbooks/data-access.ts",
"lines": "L426-L458",
"ruleId": "A-02",
"severity": "P2",
"dimension": "architecture",
"title": "reorderChapters 重排序业务逻辑放在 data-access 层",
"description": "reorderChaptersL426-458包含排序算法splice 插入 L442、parentId 变更判断L447等业务逻辑且在事务内循环更新。这些编排逻辑应属于 actions 层。",
"recommendation": "将排序算法和变更判断移至 actions.tsdata-access 仅暴露 updateChapterOrder(tx, id, order, parentId) 单条更新接口,由 actions 在事务内调用。",
"effort": "M (≤2h)"
},
{
"id": "G1-031",
"file": "src/modules/lesson-preparation/data-access.ts",
"lines": "L10-L15",
"ruleId": "A-06",
"severity": "P2",
"dimension": "architecture",
"title": "导入 textbooks/chapters 表(属 textbooks 模块)用于 JOIN",
"description": "data-access.ts L10-15 从 @/shared/db/schema 导入 textbooks、chapters 表(属 textbooks 模块)用于 L271-272 的 LEFT JOIN。虽然 L27 也通过 textbooks data-access 导入查询函数,但 JOIN 仍直接引用对方表。",
"recommendation": "短期:保留 JOIN 引用但添加注释说明;长期:重构为两步查询(先查 lessonPlans再用 ID 批量查 textbooks/chapters 名称),彻底消除跨模块 schema 引用。",
"effort": "L (≤1d)"
},
{
"id": "G1-032",
"file": "src/modules/lesson-preparation/data-access-schedules.ts",
"lines": "L9",
"ruleId": "A-06",
"severity": "P2",
"dimension": "architecture",
"title": "导入 classes 表(属 classes 模块)用于 JOIN",
"description": "data-access-schedules.ts L9 从 @/shared/db/schema 导入 classes 表(属 classes 模块),在 L55、L98、L164 的 LEFT JOIN 中获取 className。应通过 classes 模块 data-access 获取。",
"recommendation": "改为两步1) 查 lessonPlanSchedules不含 JOIN2) 收集 classId 后调用 classes 模块的 getClassNamesByIds(classIds) 批量获取名称,内存 Map 关联。",
"effort": "M (≤2h)"
},
{
"id": "G1-033",
"file": "src/modules/questions/data-access.ts",
"lines": "L4",
"ruleId": "A-06",
"severity": "P2",
"dimension": "architecture",
"title": "导入 knowledgePoints 表(属 textbooks 模块)用于 JOIN",
"description": "data-access.ts L4 从 @/shared/db/schema 导入 knowledgePoints 表(属 textbooks 模块),在 L463 的 INNER JOIN 中获取知识点名称。虽然 L8-14 已通过 textbooks data-access 导入查询函数,此处 JOIN 仍直接引用对方表。",
"recommendation": "getKnowledgePointsForQueries 改为两步1) 查 questionsToKnowledgePoints本模块表获取 questionId→knowledgePointId 映射2) 调用 textbooks data-access 批量获取知识点名称,内存关联。",
"effort": "M (≤2h)"
},
{
"id": "G1-034",
"file": "src/modules/lesson-preparation/data-access-versions.ts",
"lines": "L35-L55, L59-L95, L97-L126, L128-L165, L167-L205",
"ruleId": "S-06",
"severity": "P2",
"dimension": "structure",
"title": "5 个公共导出函数缺少 JSDoc 注释",
"description": "getLessonPlansRawL35、createLessonPlanVersionL59、getVersionContentRawL97、revertToVersionL128、pruneAutoVersionsL167均无 JSDoc。仅 L132/L140 有内联注释。公共导出函数应补齐 JSDoc 说明用途、参数、返回值。",
"recommendation": "为每个导出函数添加 JSDoc如 `/** 创建课案版本,在事务内 max(versionNo)+1 防止并发重复 */`。",
"effort": "S (≤30 分钟)"
},
{
"id": "G1-035",
"file": "src/modules/lesson-preparation/data-access-templates.ts",
"lines": "L45-L72, L76-L112, L114-L125",
"ruleId": "S-06",
"severity": "P2",
"dimension": "structure",
"title": "3 个公共导出函数缺少 JSDoc 注释",
"description": "getLessonPlansRawL45、saveAsTemplateL76、deletePersonalTemplateL114均无 JSDoc。saveAsTemplate 的 sourcePlanId→skeleton 提取逻辑L94-100需要文档说明。",
"recommendation": "添加 JSDoc 说明函数用途、参数含义、返回值。",
"effort": "XS (≤15 分钟)"
},
{
"id": "G1-036",
"file": "src/modules/questions/data-access.ts",
"lines": "L39-L193, L214-L247, L258-L292",
"ruleId": "S-06",
"severity": "P2",
"dimension": "structure",
"title": "核心函数 getQuestionsRaw/insertQuestionWithRelations/updateQuestionById 缺少 JSDoc",
"description": "getQuestionsRawL39、insertQuestionWithRelationsL214、updateQuestionByIdL258等核心函数无 JSDoc。getQuestionsRaw 的级联筛选逻辑L75-122较复杂需要文档说明筛选优先级。",
"recommendation": "为这些函数添加 JSDoc特别是 getQuestionsRaw 的 knowledgePointId > chapterId > textbookId 级联筛选优先级。",
"effort": "S (≤30 分钟)"
},
{
"id": "G1-037",
"file": "src/modules/questions/data-access.ts",
"lines": "L1-L662",
"ruleId": "S-02",
"severity": "P2",
"dimension": "structure",
"title": "单文件导出函数数约 28 个,超过 20 警告阈值",
"description": "data-access.ts 导出约 28 个符号(含类型、函数、接口),包括 getQuestions/getQuestionsDashboardStats/createQuestionWithRelations/updateQuestionById/deleteQuestionByIdRecursive/deleteQuestionsBatch/getKnowledgePointOptions/getTextbookOptions/getChapterOptions/getKnowledgePointOptionsByChapter/getKnowledgePointsForQuestions/getQuestionsContentForErrorCollection/getQuestionTypeMapByIds/exportQuestions/importQuestions 等。职责混合了 CRUD + 跨模块接口 + 导入导出。",
"recommendation": "按职责拆分为 data-access.ts核心 CRUD、data-access-cross-module.ts跨模块只读接口、data-access-import-export.ts导入导出。",
"effort": "L (≤1d)"
},
{
"id": "G1-038",
"file": "src/modules/textbooks/data-access.ts",
"lines": "L1-L703",
"ruleId": "S-02",
"severity": "P2",
"dimension": "structure",
"title": "单文件导出函数数约 35 个,超过 20 警告阈值",
"description": "data-access.ts 导出约 35 个符号,涵盖教材 CRUD、章节 CRUD、知识点 CRUD、排序、统计、归属校验、scope 查询、跨模块接口、前置依赖 CRUD。职责过重。",
"recommendation": "拆分为 data-access.ts教材+章节、data-access-knowledge-points.ts知识点+前置依赖、data-access-cross-module.ts跨模块只读接口。",
"effort": "L (≤1d)"
},
{
"id": "G1-039",
"file": "src/modules/lesson-preparation/data-access-ai-evaluation.ts",
"lines": "L58, L77",
"ruleId": "F-03",
"severity": "P2",
"dimension": "performance",
"title": "SELECT * 未指定列getEvaluationsByPlanIdRaw / getLatestEvaluationRaw",
"description": "getEvaluationsByPlanIdRawL58和 getLatestEvaluationRawL77使用 `.select()` 无参数SELECT 所有列。表字段可能后续增加,且传输不需要的列浪费带宽。",
"recommendation": "改为显式列枚举 `.select({ id: ..., planId: ..., ... })`,仅查询 mapRowToEvaluation 实际使用的字段。",
"effort": "XS (≤15 分钟)"
},
{
"id": "G1-040",
"file": "src/modules/lesson-preparation/data-access-analytics.ts",
"lines": "L63, L210",
"ruleId": "F-03",
"severity": "P2",
"dimension": "performance",
"title": "SELECT * 未指定列getTeacherInvestmentRaw / upsertDailyAnalytics",
"description": "getTeacherInvestmentRawL63和 upsertDailyAnalytics 内的查询L210使用 `.select()` 无参数。",
"recommendation": "显式枚举所需字段。",
"effort": "XS (≤15 分钟)"
},
{
"id": "G1-041",
"file": "src/modules/lesson-preparation/data-access-review.ts",
"lines": "L146",
"ruleId": "F-03",
"severity": "P2",
"dimension": "performance",
"title": "SELECT * 未指定列getReviewRecordsByPlanIdRaw",
"description": "getReviewRecordsByPlanIdRawL146使用 `.select()` 无参数。",
"recommendation": "显式枚举所需字段。",
"effort": "XS (≤15 分钟)"
},
{
"id": "G1-042",
"file": "src/modules/lesson-preparation/data-access-substitutes.ts",
"lines": "L34, L52",
"ruleId": "F-03",
"severity": "P2",
"dimension": "performance",
"title": "SELECT * 未指定列getSubstitutesByPlanIdRaw / getActiveSubstitutesByTeacherIdRaw",
"description": "两个读函数 L34、L52 均使用 `.select()` 无参数。",
"recommendation": "显式枚举所需字段。",
"effort": "XS (≤15 分钟)"
},
{
"id": "G1-043",
"file": "src/modules/lesson-preparation/data-access-versions.ts",
"lines": "L50",
"ruleId": "F-03",
"severity": "P2",
"dimension": "performance",
"title": "SELECT * 未指定列getLessonPlanVersionsRaw",
"description": "getLessonPlanVersionsRawL50使用 `.select()` 无参数。",
"recommendation": "显式枚举所需字段。",
"effort": "XS (≤15 分钟)"
},
{
"id": "G1-044",
"file": "src/modules/lesson-preparation/data-access-templates.ts",
"lines": "L61",
"ruleId": "F-03",
"severity": "P2",
"dimension": "performance",
"title": "SELECT * 未指定列getLessonPlansRaw",
"description": "getLessonPlansRawL61使用 `.select()` 无参数。",
"recommendation": "显式枚举所需字段。",
"effort": "XS (≤15 分钟)"
},
{
"id": "G1-045",
"file": "src/modules/lesson-preparation/data-access-knowledge.ts",
"lines": "L93, L123",
"ruleId": "F-03",
"severity": "P2",
"dimension": "performance",
"title": "SELECT * 未指定列getLessonPlansByKnowledgePointRaw / getLessonPlansByQuestionRaw",
"description": "两个函数 L93、L123 均使用 `.select()` 无参数。",
"recommendation": "显式枚举所需字段,仅查询 mapRowToListItemWithoutJoin 实际使用的列。",
"effort": "XS (≤15 分钟)"
},
{
"id": "G1-046",
"file": "src/modules/lesson-preparation/data-access-formative.ts",
"lines": "L51, L68, L169, L195, L201, L216",
"ruleId": "F-03",
"severity": "P2",
"dimension": "performance",
"title": "6 处 SELECT * 未指定列",
"description": "getFormativeItemsByPlanIdRawL51、getFormativeItemByIdRawL68、getResponsesByItemIdRawL169、getResponsesByStudentIdRawL195、L201、getFormativeItemStatsRawL216均使用 `.select()` 无参数。",
"recommendation": "显式枚举所需字段。getFormativeItemStatsRaw 尤其应仅查聚合字段。",
"effort": "S (≤30 分钟)"
},
{
"id": "G1-047",
"file": "src/modules/lesson-preparation/data-access-comments.ts",
"lines": "L33, L51",
"ruleId": "F-03",
"severity": "P2",
"dimension": "performance",
"title": "SELECT * 未指定列getCommentsByPlanIdRaw / getCommentsByBlockIdRaw",
"description": "两个读函数 L33、L51 均使用 `.select()` 无参数。",
"recommendation": "显式枚举所需字段。",
"effort": "XS (≤15 分钟)"
},
{
"id": "G1-048",
"file": "src/modules/lesson-preparation/data-access-attachments.ts",
"lines": "L31, L49, L123",
"ruleId": "F-03",
"severity": "P2",
"dimension": "performance",
"title": "3 处 SELECT * 未指定列",
"description": "getAttachmentsByPlanIdRawL31、getAttachmentsByBlockIdRawL49、getAttachmentByIdRawL123均使用 `.select()` 无参数。",
"recommendation": "显式枚举所需字段。",
"effort": "XS (≤15 分钟)"
},
{
"id": "G1-049",
"file": "src/modules/textbooks/data-access.ts",
"lines": "L427, L431",
"ruleId": "F-03",
"severity": "P2",
"dimension": "performance",
"title": "SELECT * 未指定列reorderChapters 内查询)",
"description": "reorderChapters 中 L427 `db.select().from(chapters)` 和 L431 `db.select().from(chapters)` 使用 `.select()` 无参数。",
"recommendation": "显式枚举所需字段id, textbookId, parentId, order, title。",
"effort": "XS (≤15 分钟)"
},
{
"id": "G1-050",
"file": "src/modules/textbooks/data-access.ts",
"lines": "L43-L91, L627-L660",
"ruleId": "F-05",
"severity": "P2",
"dimension": "performance",
"title": "getTextbooksRaw / getKnowledgePointOptionsRaw 无 LIMIT",
"description": "getTextbooksRawL64-79和 getKnowledgePointOptionsRawL628-648无 LIMIT。getKnowledgePointOptionsRaw 拉取全量知识点+章节+教材 JOIN数据量大时风险高。",
"recommendation": "getTextbooksRaw 添加分页或 `.limit(200)`getKnowledgePointOptionsRaw 应改为按 textbookId/subject 参数过滤,或前端懒加载。",
"effort": "S (≤30 分钟)"
},
{
"id": "G1-051",
"file": "src/modules/lesson-preparation/data-access-formative.ts",
"lines": "L165-L175, L182-L205",
"ruleId": "F-05",
"severity": "P2",
"dimension": "performance",
"title": "getResponsesByItemIdRaw / getResponsesByStudentIdRaw 无 LIMIT",
"description": "两个函数查询学生作答记录均无 LIMIT。一个互动组件可能有上千条作答一个学生可能有大量作答历史。",
"recommendation": "添加分页参数或 `.limit(500)` 上限保护。",
"effort": "XS (≤15 分钟)"
},
{
"id": "G1-052",
"file": "src/modules/lesson-preparation/data-access-comments.ts",
"lines": "L29-L39, L46-L62",
"ruleId": "F-05",
"severity": "P2",
"dimension": "performance",
"title": "getCommentsByPlanIdRaw / getCommentsByBlockIdRaw 无 LIMIT",
"description": "两个函数查询评论均无 LIMIT。热门课案评论数可能很多。",
"recommendation": "添加分页参数或 `.limit(200)` 上限保护。",
"effort": "XS (≤15 分钟)"
},
{
"id": "G1-053",
"file": "src/modules/lesson-preparation/data-access-knowledge.ts",
"lines": "L92-L101, L122-L131",
"ruleId": "F-05",
"severity": "P2",
"dimension": "performance",
"title": "getLessonPlansByKnowledgePointRaw / getLessonPlansByQuestionRaw 无 LIMIT",
"description": "两个函数对 lessonPlans 全表 LIKE 扫描后无 LIMIT且无分页。匹配数量不可控。",
"recommendation": "添加 `.limit(100)` 上限保护,或改为分页查询。",
"effort": "XS (≤15 分钟)"
},
{
"id": "G1-054",
"file": "src/modules/textbooks/data-access.ts",
"lines": "L465-L474",
"ruleId": "F-10",
"severity": "P2",
"dimension": "performance",
"title": "getTextbooksDashboardStatsRaw 全表 COUNT 无过滤",
"description": "getTextbooksDashboardStatsRawL465-474对 textbooks 和 chapters 表各执行 `count()` 无 WHERE 过滤,统计全量数据。仪表盘统计应至少按可见范围过滤。",
"recommendation": "如需按权限范围统计,传入 scope 参数添加 WHERE 条件;若确为管理员全局统计,可保留但加缓存(已有 cacheFn。",
"effort": "XS (≤15 分钟)"
},
{
"id": "G1-055",
"file": "src/modules/questions/data-access.ts",
"lines": "L204-L207",
"ruleId": "F-10",
"severity": "P2",
"dimension": "performance",
"title": "getQuestionsDashboardStatsRaw 全表 COUNT 无过滤",
"description": "getQuestionsDashboardStatsRawL204-207对 questions 表执行 `count()` 无 WHERE 过滤。仪表盘应按用户可见范围统计。",
"recommendation": "传入 scope/authorId 参数添加 WHERE 条件,或确认是否为管理员全局统计。",
"effort": "XS (≤15 分钟)"
},
{
"id": "G1-056",
"file": "src/modules/lesson-preparation/data-access.ts",
"lines": "L574-L590",
"ruleId": "F-10",
"severity": "P2",
"dimension": "performance",
"title": "getLessonPlanStats 全表 GROUP BY 无过滤",
"description": "getLessonPlanStatsL574-590对 lessonPlans 全表 GROUP BY status 统计,无 WHERE 过滤。管理员看板统计应限定范围(如本学期/本学年)。",
"recommendation": "添加时间范围 WHERE 条件(如 createdAt >= 学期开始日期),避免统计历史归档数据。",
"effort": "XS (≤15 分钟)"
},
{
"id": "G1-057",
"file": "src/modules/lesson-preparation/data-access-substitutes.ts",
"lines": "L125-L141",
"ruleId": "A-02",
"severity": "P3",
"dimension": "architecture",
"title": "canTeacherAccessPlan 含权限判断业务逻辑",
"description": "canTeacherAccessPlanL125-141包含'原教师→true / 代课教师→true'的权限判断逻辑,属于业务编排。虽然查询了 DB但'是否可访问'的判断应属于 actions 或权限层。",
"recommendation": "将 canTeacherAccessPlan 的判断逻辑移至 actions 层data-access 仅暴露 getPlanCreatorId 和 getActiveSubstitutesByTeacherId 两个纯读接口。",
"effort": "S (≤30 分钟)"
},
{
"id": "G1-058",
"file": "src/modules/lesson-preparation/data-access-analytics.ts",
"lines": "L201-L249",
"ruleId": "A-02",
"severity": "P3",
"dimension": "architecture",
"title": "upsertDailyAnalytics 含 read-then-write 业务逻辑",
"description": "upsertDailyAnalyticsL201-249先 SELECT 判断是否存在L209-218存在则 UPDATE 累加L222-234不存在则 INSERTL236-247。该 upsert 编排逻辑可下放到 actions 或用 SQL `INSERT ... ON DUPLICATE KEY UPDATE` 替代。",
"recommendation": "改用 MySQL `INSERT ... ON DUPLICATE KEY UPDATE` 单语句完成 upsert或在 actions 层编排 read-then-write。",
"effort": "M (≤2h)"
},
{
"id": "G1-059",
"file": "src/modules/lesson-preparation/data-access.ts",
"lines": "L336, L535, L612",
"ruleId": "F-03",
"severity": "P3",
"dimension": "performance",
"title": "3 处 SELECT * 未指定列",
"description": "getLessonPlanByIdRawL336、duplicateLessonPlanL535、getTemplateByIdL612使用 `.select()` 无参数。其中 getLessonPlanByIdRaw 查询后用 mapRowToLessonPlan 映射,所需字段已知。",
"recommendation": "显式枚举 mapRowToLessonPlan 所需的 14 个字段。",
"effort": "XS (≤15 分钟)"
},
{
"id": "G1-060",
"file": "src/modules/lesson-preparation/data-access-substitutes.ts",
"lines": "L136",
"ruleId": "P-09",
"severity": "P3",
"dimension": "pattern",
"title": "plan[0]!.creatorId 非空断言",
"description": "L136 `if (plan[0]!.creatorId === teacherId) return true;` 在已检查 `plan.length === 0`L135后使用 `!` 非空断言。虽逻辑正确,但可改为更安全的 `const row = plan[0]; if (row && row.creatorId === teacherId) ...`。",
"recommendation": "用 `const row = plan[0]; if (!row) return false; if (row.creatorId === teacherId) return true;` 替代非空断言。",
"effort": "XS (≤15 分钟)"
},
{
"id": "G1-061",
"file": "src/modules/lesson-preparation/data-access-calendar.ts",
"lines": "L181",
"ruleId": "P-09",
"severity": "P3",
"dimension": "pattern",
"title": "split('T')[0]! 非空断言",
"description": "L181 `e.occurredAt.toISOString().split('T')[0]!` 对数组取值使用 `!`。虽然 toISOString() 必定含 'T',但 `!` 属非空断言。",
"recommendation": "改为 `e.occurredAt.toISOString().split('T')[0] ?? ''` 或用专门的 toISODateString helper。",
"effort": "XS (≤15 分钟)"
},
{
"id": "G1-062",
"file": "src/modules/lesson-preparation/data-access-formative.ts",
"lines": "L72",
"ruleId": "P-09",
"severity": "P3",
"dimension": "pattern",
"title": "rows[0]! 非空断言",
"description": "L72 `return rows.length === 0 ? null : mapRowToItem(rows[0]!);` 使用 `!`。虽逻辑正确,但可避免。",
"recommendation": "改为 `const row = rows[0]; return row ? mapRowToItem(row) : null;`。",
"effort": "XS (≤15 分钟)"
},
{
"id": "G1-063",
"file": "src/modules/lesson-preparation/data-access-comments.ts",
"lines": "L118",
"ruleId": "P-09",
"severity": "P3",
"dimension": "pattern",
"title": "rows[0]!.resolved 非空断言",
"description": "L118 `const newResolved = !rows[0]!.resolved;` 使用 `!`。已检查 `rows.length === 0`L117但风格上可改进。",
"recommendation": "改为 `const row = rows[0]; if (!row) return; const newResolved = !row.resolved;`。",
"effort": "XS (≤15 分钟)"
},
{
"id": "G1-064",
"file": "src/modules/lesson-preparation/data-access-schedules.ts",
"lines": "L167",
"ruleId": "P-09",
"severity": "P3",
"dimension": "pattern",
"title": "created[0]! 非空断言",
"description": "L167 `const r = created[0]!;` 在 createSchedule 中查询刚插入的记录后使用 `!`。INSERT 后立即查询,理论上必定有值,但 `!` 不够安全。",
"recommendation": "改为 `const r = created[0]; if (!r) throw new Error('SCHEDULE_CREATE_FAILED');`。",
"effort": "XS (≤15 分钟)"
},
{
"id": "G1-065",
"file": "src/modules/lesson-preparation/data-access-analytics.ts",
"lines": "L98",
"ruleId": "P-09",
"severity": "P3",
"dimension": "pattern",
"title": "r.templateId! 非空断言",
"description": "L98 `templateId: r.templateId!,` 在 WHERE 已过滤 `templateId IS NOT NULL`L93后使用 `!`。",
"recommendation": "改为 `templateId: r.templateId ?? ''`,或用类型守卫收窄。",
"effort": "XS (≤15 分钟)"
},
{
"id": "G1-066",
"file": "src/modules/questions/data-access.ts",
"lines": "L1-L662",
"ruleId": "S-06",
"severity": "P3",
"dimension": "structure",
"title": "deleteQuestionRecursive/insertQuestionWithRelations 缺少 JSDoc",
"description": "deleteQuestionRecursiveL294、insertQuestionWithRelationsL214等内部函数无 JSDoc。环检测逻辑L299-303需要文档说明。",
"recommendation": "补充 JSDoc 说明环检测目的和 visited Set 的作用。",
"effort": "XS (≤15 分钟)"
},
{
"id": "G1-067",
"file": "src/modules/textbooks/data-access.ts",
"lines": "L170-L206, L208-L210, L212-L240, L242-L273, L275-L333",
"ruleId": "S-06",
"severity": "P3",
"dimension": "structure",
"title": "createTextbook/updateTextbook/deleteTextbook/createChapter 等多个函数缺少 JSDoc",
"description": "createTextbookL170、updateTextbookL192、deleteTextbookL208、createChapterL212、updateChapterContentL242、deleteChapterL275、createKnowledgePointL398、updateKnowledgePointL411、deleteKnowledgePointL422、reorderChaptersL426均无 JSDoc。deleteChapter 的级联删除逻辑L310-332较复杂需要文档。",
"recommendation": "为这些函数添加 JSDoc特别是 deleteChapter 需说明级联删除知识点+前置依赖的行为。",
"effort": "S (≤30 分钟)"
}
]

View File

@@ -0,0 +1,134 @@
[
{
"id": "G2-001",
"file": "src/modules/exams/data-access.ts",
"lines": "L1",
"ruleId": "P-01",
"severity": "P0",
"dimension": "pattern",
"title": "文件首行缺少 import \"server-only\" 标记",
"description": "data-access.ts 首行为 `import { db } from \"@/shared/db\"`,未在文件头声明 `import \"server-only\"`。该文件包含直接 DB 访问exams/examQuestions 表的 CRUD若被客户端组件意外引入会将数据库连接与查询逻辑泄露到客户端 bundle造成安全漏洞。同模块的 data-access-error-collection.tsL1与 data-access-cross-module.tsL1均已正确声明唯独主文件遗漏。",
"recommendation": "在文件第一行(所有 import 之前)添加 `import \"server-only\"`。注意:必须位于首行,否则 next.js 的 server-only 边界检测可能不生效。",
"effort": "XS (≤15 分钟)"
},
{
"id": "G2-002",
"file": "src/modules/grades/data-access-appeals.ts",
"lines": "L122-L151",
"ruleId": "F-01",
"severity": "P1",
"dimension": "performance",
"title": "getPendingAppealsForReviewRaw 在 JS 层过滤班级范围而非 SQL WHERE",
"description": "函数 WHERE 子句仅过滤 `gradeAppeals.status = 'pending'`L134未对 classIds 加任何过滤,导致 SQL 返回全库所有 pending 申诉(含 gradeRecord 全字段 innerJoin随后在 L141 用 `rows.filter((r) => classIds.includes(r.gradeRecord.classId))` 在 JS 层过滤。代码注释写明「在 JS 层过滤班级范围(避免复杂 SQL join但 innerJoin gradeRecords 已存在,加 `inArray(gradeRecords.classId, classIds)` 并不复杂。当 pending 申诉总量增长时(全校维度),单次查询会拉取大量无关行,造成内存与网络压力;同时若 JS filter 被误删将引发跨班级数据泄露。",
"recommendation": "在 L132-L137 的 `and()` 内追加 `inArray(gradeRecords.classId, classIds)` 条件classIds 为空时已在 L123 提前返回),删除 L140-L141 的 JS 层 filter直接返回 rows.map(...)。这样既收窄 SQL 结果集,又消除数据泄露风险。",
"effort": "S (≤30 分钟)"
},
{
"id": "G2-003",
"file": "src/modules/adaptive-practice/data-access-analytics.ts",
"lines": "L311-L384",
"ruleId": "F-01",
"severity": "P0",
"dimension": "performance",
"title": "getTeacherClassPracticeOverviewsRaw 在 Promise.all 内对每个班级循环发起 2 条 SQL2N+1 模式)",
"description": "函数对 classIds 数组执行两次 Promise.all 循环:(1) L320-L325 对每个 classId 调用 `getActiveStudentIdsByClassId(classId)`(每班 1 条 SQL共 N 条);(2) L328-L365 对每个班级再发起 1 条 `db.select().from(practiceSessions).where(inArray(studentId, ...))` 聚合查询(共 N 条)。加上 L317 的 getClassNamesByIds1 条),总计 2N+1 条 SQL。当教师所教班级数 N 较大(如年级主任辖 10+ 班级)时,单次请求产生 20+ 条 SQL且 Promise.all 仅并发 IO 不减少 DB 负载。",
"recommendation": "改为批量查询:(1) 一次性获取所有班级的学生 ID 映射(可用单条 SQL `SELECT classId, studentId FROM class_members WHERE classId IN (...) AND status='active'` 后在 JS 层 groupBy(2) 用单条聚合 SQL `SELECT classId, count(...), SUM(...), COUNT(DISTINCT studentId) FROM practiceSessions WHERE studentId IN (全部学生) GROUP BY studentId` 后在 JS 层按班级归并;或直接 JOIN class_members 按 classId 分组。目标:将 2N+1 降至 2-3 条 SQL。",
"effort": "M (≤2 小时)"
},
{
"id": "G2-004",
"file": "src/modules/adaptive-practice/data-access-analytics.ts",
"lines": "L320-L325",
"ruleId": "F-08",
"severity": "P1",
"dimension": "performance",
"title": "跨模块在循环内多次调用 getActiveStudentIdsByClassIdclasses 模块)",
"description": "在 Promise.all 内对每个 classId 单独调用 `@/modules/classes/data-access` 的 `getActiveStudentIdsByClassId`,属于 F-08 跨模块多次调用 getXxxByIds 模式。该函数内部本身可能已 cacheFn 包装,但首次填充缓存时仍会产生 N 条 SQL。应改用批量接口 `getActiveStudentIdsByClassIds(classIds)`(如不存在则需在 classes 模块新增)。",
"recommendation": "在 classes/data-access 新增 `getActiveStudentIdsByClassIds(classIds: string[]): Promise<Map<string, string[]>>` 批量接口(单条 SQL `WHERE classId IN (...)` 后 groupBy本函数改为一次调用获取全量映射。与 G2-003 的修复可合并执行。",
"effort": "M (≤2 小时)"
},
{
"id": "G2-005",
"file": "src/modules/grades/data-access-analytics.ts",
"lines": "L1-L831",
"ruleId": "S-01",
"severity": "P1",
"dimension": "structure",
"title": "文件 831 行超过 800 行警告阈值",
"description": "文件总计 831 行,超过 S-01 规则的 800 行警告线(虽未达 1000 行硬性上限。文件内含多个独立分析维度年级分布getGradeDistribution*、班级统计getClassGradeStats*、学生摘要getStudentGradeSummary*、排名getClassRanking*)等。职责虽同属 grades 分析,但可按分析维度进一步拆分以提升可维护性。",
"recommendation": "按分析维度拆分为 data-access-analytics-grade-distribution.ts / data-access-analytics-class-stats.ts / data-access-analytics-student-summary.ts 等,每个子文件 ≤ 300 行。或暂不拆分但监控增长,一旦逼近 1000 行必须拆分。",
"effort": "L (≤1 天)"
},
{
"id": "G2-006",
"file": "src/modules/homework/data-access.ts",
"lines": "L207-L212",
"ruleId": "A-02",
"severity": "P2",
"dimension": "architecture",
"title": "data-access 内联 computeOverdueCount 业务计算闭包",
"description": "在 getHomeworkAssignmentsRaw 的数据组装段内定义了 `computeOverdueCount` 闭包,包含条件分支 `if (!dueAt || dueAt > now) return 0` 及逾期人数推导逻辑 `Math.max(0, targetCount - submittedCount)`。虽为纯计算非状态机但「逾期」的业务定义dueAt 已过且未提交)属于业务规则,下沉到 data-access 后未来若规则变更(如加宽限期、按作业类型区分)需改 data-access 而非 actions/lib。属 A-02 边界情形。",
"recommendation": "将 computeOverdueCount 提取到 homework/lib/overdue.ts 作为纯函数 `computeOverdueCount(dueAt, targetCount, submittedCount, now)`data-access 仅负责数据获取与组装,业务规则集中到 lib。优先级较低可在重构窗口处理。",
"effort": "S (≤30 分钟)"
},
{
"id": "G2-007",
"file": "src/modules/grades/data-access-drafts.ts",
"lines": "L367-L382",
"ruleId": "P-04",
"severity": "P2",
"dimension": "pattern",
"title": "releaseDraftLock 返回值依赖隐式类型推断的元组解构",
"description": "L367-L378 执行 `db.update(gradeDrafts).set(...).where(...)` 后L381 用 `const [header] = result` 解构L382 返回 `(header?.affectedRows ?? 0) > 0`。drizzle MySQL 的 update 返回类型为 `MySqlRawQueryResult`(即 `[ResultSetHeader, FieldPacket[]]`header 类型由推断得到。代码逻辑正确,但依赖 drizzle 内部类型推断而非显式标注,未来 drizzle 版本变更返回类型时可能静默失效。函数签名已显式标注 `Promise<boolean>`L364属轻微模式偏差。",
"recommendation": "可在解构处补充类型注释 `const [header] = result as [ResultSetHeader, unknown]`(此处 as 属从 unknown/drizzle 内部类型收窄,符合豁免);或保持现状但增加单元测试覆盖锁释放场景。优先级低。",
"effort": "XS (≤15 分钟)"
},
{
"id": "G2-008",
"file": "src/modules/diagnostic/data-access.ts",
"lines": "L1-L553",
"ruleId": "A-02",
"severity": "P2",
"dimension": "architecture",
"title": "诊断掌握度累积计算函数updateMasteryFrom*)含业务规则分支",
"description": "文件含 3 个掌握度累积函数updateMasteryFromSubmission / updateMasteryFromHomeworkSubmission / updateMasteryFromExamScore。这些函数内部包含掌握度合并算法加权平均/最大值取值等业务规则)与 DB 写入混合。掌握度计算属于诊断业务规则,理想分层应将算法提取到 diagnostic/lib/mastery-calculator.tsdata-access 仅负责读写 knowledgePointMastery 表。当前实现可行但职责混合,属 A-02 边界。",
"recommendation": "提取纯函数 `computeMasteryAfterSubmission(current: MasteryState, submission: SubmissionInput): MasteryState` 到 diagnostic/lib/data-access 函数改为:读取当前掌握度 → 调用纯函数计算新值 → 写回 DB。优先级中等可在掌握度算法需调整时一并重构。",
"effort": "M (≤2 小时)"
},
{
"id": "G2-009",
"file": "src/modules/adaptive-practice/data-access.ts",
"lines": "L307",
"ruleId": "P-09",
"severity": "P3",
"dimension": "pattern",
"title": "sourceMeta as unknown 用于 JSON 序列化字段写入(属豁免范畴)",
"description": "L307 `sourceMeta: sourceMeta as unknown` 将类型化对象转为 unknown 以写入 JSON 列。此处的 as 属于「向 unknown 转换」的合规用法(框架 P-09 豁免:从 unknown 收窄或反向序列化)。仅作记录,非违规。",
"recommendation": "无需修改。若追求严谨,可改用 `JSON.parse(JSON.stringify(sourceMeta))` 显式序列化,但当前写法已合规。",
"effort": "XS (≤15 分钟)"
},
{
"id": "G2-010",
"file": "src/modules/exams/data-access.ts",
"lines": "L316",
"ruleId": "P-09",
"severity": "P3",
"dimension": "pattern",
"title": "new Map(generated.map((q) => [q.id, q] as const)) 使用 as const 构造 Map属豁免",
"description": "L316 `[q.id, q] as const` 用于向 Map 构造器提供 readonly tuple 类型。as const 属于 TypeScript 类型工具的合规用法P-09 豁免),非类型断言违规。仅作记录。",
"recommendation": "无需修改。",
"effort": "XS (≤15 分钟)"
},
{
"id": "G2-011",
"file": "src/modules/homework/data-access-write.ts",
"lines": "L16,L20",
"ruleId": "P-09",
"severity": "P3",
"dimension": "pattern",
"title": "import 语句中的 as 为模块别名(非类型断言)",
"description": "L16 `getClassTeacherById as getClassTeacherIdFromClass` 与 L20 `getExamWithQuestionsForHomework as getExamWithQuestionsFromExams` 为 ES module import 别名,用于避免跨模块同名函数冲突。非 P-09 规则所约束的类型断言。仅作记录,零违规。",
"recommendation": "无需修改。",
"effort": "XS (≤15 分钟)"
}
]

View File

@@ -0,0 +1,602 @@
[
{
"id": "G3-001",
"file": "src/modules/classes/data-access.ts",
"lines": "L17-L313",
"ruleId": "P-03",
"severity": "P0",
"dimension": "pattern",
"title": "classes/data-access.ts 中 24+ 个读函数未走 cacheFn 包装",
"description": "文件中导出的读函数getClassSubjects、getAccessibleClassIdsForTeacher、getClassGradeIdsByClassIds、getTeacherSubjectIdsForClass、getClassTeacherById、getStudentIdsByClassId、getStudentIdsByClassIds、getActiveStudentIdsByClassId、getClassActiveStudentsWithInfo、getTeacherSubjectIdsByClass、getTeacherIdsByClassIds、getStudentActiveClassId、getStudentActiveClass、getStudentActiveGradeId、getClassExists、getClassNameById、getClassGradeId、getGradeIdsByClassIds、getClassNamesByIds、getClassesByGradeId、getClassIdsByGradeIds 等)全部直接执行 DB 查询,未使用项目标准的 `cacheFn(raw, { tags, ttl, keyParts })` 模式。这些函数被跨模块高频调用attendance、scheduling、course-plans、proctoring 等模块都依赖),每次调用都直接命中 DB导致重复查询与缓存失效。",
"recommendation": "为每个公开读函数添加 Raw + Wrapper 配对模式。例如:\n```ts\nexport const getClassNamesByIdsRaw = async (classIds: string[]): Promise<Map<string, string>> => { /* 原 SQL 逻辑 */ }\nexport const getClassNamesByIds = cacheFn(getClassNamesByIdsRaw, {\n tags: [\"classes:names\"],\n ttl: 300,\n keyParts: [\"classes\", \"getClassNamesByIds\"],\n})\n```\n注意getSessionTeacherId、getTeacherIdForMutations、verifyTeacherOwnsClass 等用于权限校验的函数可不缓存(避免缓存权限提升风险)。",
"effort": "L (≤1d)"
},
{
"id": "G3-002",
"file": "src/modules/scheduling/data-access.ts",
"lines": "L8-L17",
"ruleId": "A-06",
"severity": "P0",
"dimension": "architecture",
"title": "scheduling 模块直接 import classes/users/subjects 等其他模块的 schema 表",
"description": "文件头部 `import { classes, classSchedule, classSubjectTeachers, classrooms, scheduleChanges, schedulingRules, subjects, users } from \"@/shared/db/schema\"` 中,`classes`、`classSubjectTeachers` 属于 classes 模块,`subjects` 属于 school 模块,`users` 属于 users 模块。scheduling 模块直接查询这些表违反了架构规则 A-06modules 之间应通过对方 data-access 通信,不直接查询对方 DB 表。\n\n证据\n- L122-L124 `getScheduleChangesRaw` 直接 INNER JOIN `classes` 表查询班级名称\n- L128-L146 直接查询 `users` 表解析 substituteTeacher/approver 姓名\n- L295-L302 `getTeachersForSchedulingRaw` 直接查询 `users` 表\n- L323-L335 `getClassSubjectsForSchedulingRaw` 直接 JOIN `subjects` 与 `classSubjectTeachers`",
"recommendation": "改为通过对方 data-access 调用:\n```ts\nimport { getClassNamesByIds } from \"@/modules/classes/data-access\"\nimport { getUserNamesByIds } from \"@/modules/users/data-access\"\nimport { getSubjectNameMapByIds } from \"@/modules/school/data-access\"\n\n// 替代直接 JOIN classes\nconst classNameMap = await getClassNamesByIds(classIds)\n// 替代直接查询 users\nconst userMap = await getUserNamesByIds(userIds)\n```\n对于 `classSubjectTeachers` 的查询,应在 classes 模块新增 `getSubjectTeachersForScheduling(classId)` 暴露给 scheduling 调用。",
"effort": "M (≤2h)"
},
{
"id": "G3-003",
"file": "src/modules/scheduling/data-access-class-schedule.ts",
"lines": "L28-L57, L64-L136, L142-L158",
"ruleId": "A-02",
"severity": "P0",
"dimension": "architecture",
"title": "data-access-class-schedule.ts 包含大量业务逻辑(校验、归属校验、状态机)",
"description": "createClassScheduleItem、updateClassScheduleItem、deleteClassScheduleItem 三个函数包含:\n- 时间格式校验 `isTimeHHMM`L42\n- 业务规则校验 `startTime >= endTime`L43、`weekday < 1 || weekday > 7`L44\n- 归属校验 `verifyTeacherOwnsClass`L46、L85、L94、L155\n- 字段合并与冲突检测L121-L127\n- 通过 `getTeacherIdForMutations()` 获取当前教师 IDL31、L68、L143\n\n这些业务逻辑应位于 actions 层编排层data-access 层应只负责 DB 读写。当前实现导致职责混淆S-08且这些函数既不是 \"use server\" 也不是纯 data-access处于灰色地带。",
"recommendation": "将校验与归属校验逻辑移至 actions-schedule.ts\n```ts\n// actions-schedule.ts\n\"use server\"\nexport async function createClassScheduleItemAction(prevState, formData) {\n const ctx = await requirePermission(Permissions.SCHEDULE_ADJUST)\n // 校验输入\n if (!isTimeHHMM(startTime)) return { success: false, message: \"Invalid time\" }\n // 归属校验\n const owned = await verifyTeacherOwnsClass(classId, ctx.userId)\n if (!owned) return { success: false, message: \"Class not found\" }\n // 调用 data-access\n const id = await insertClassScheduleItem({ classId, weekday, ... })\n await invalidateFor(\"scheduling.create\")\n return { success: true, data: id }\n}\n```\ndata-access-class-schedule.ts 仅保留 `insertClassScheduleItem`、`updateClassScheduleItemById`、`deleteClassScheduleItemById` 等纯 DB 操作(这些已在 data-access.ts 中定义,本文件可考虑删除)。",
"effort": "M (≤2h)"
},
{
"id": "G3-004",
"file": "src/modules/classes/data-access-teacher.ts",
"lines": "L92-L116",
"ruleId": "F-01",
"severity": "P0",
"dimension": "performance",
"title": "getTeacherClassesRaw 循环内对每个班级发起 2 次子查询N+1",
"description": "`getTeacherClassesRaw` 在获取班级列表后,使用 `Promise.all(list.map(async (c) => { ... }))` 对每个班级并行调用 `getClassHomeworkInsights({ classId: c.id, teacherId, limit: 7 })` 和 `getClassSchedule({ classId: c.id, teacherId })`。虽然使用了 Promise.all 并行化,但如果教师有 N 个班级,将产生 2N 次子查询(每次 getClassHomeworkInsights 内部还有多轮 DB 查询accessibleIds、classRow、enrollments、assignments、submissions 等),总查询数可能达到 10N+。对于任教 10+ 班级的教师,单次列表加载可能触发 100+ DB 查询。",
"recommendation": "改为批量查询:\n1. 一次性获取所有班级的 homework insights在 data-access-stats.ts 新增 `getBatchClassHomeworkInsights(classIds: string[], teacherId: string)` 批量函数\n2. 一次性获取所有班级的 schedule新增 `getBatchClassSchedule(classIds: string[])`\n3. 在 getTeacherClassesRaw 中并行调用这两个批量函数,然后用 Map 在内存中关联到班级\n\n```ts\nconst [insightsMap, scheduleMap] = await Promise.all([\n getBatchClassHomeworkInsights(list.map(c => c.id), teacherId),\n getBatchClassSchedule(list.map(c => c.id)),\n])\nconst listWithTrends = list.map(c => {\n const insights = insightsMap.get(c.id)\n const schedule = scheduleMap.get(c.id) ?? []\n return { ...c, recentAssignments: ..., schedule }\n})\n```",
"effort": "L (≤1d)"
},
{
"id": "G3-005",
"file": "src/modules/course-plans/data-access.ts",
"lines": "L324-L331",
"ruleId": "F-01",
"severity": "P0",
"dimension": "performance",
"title": "reorderCoursePlanItems 循环内发起 N 次 UPDATE 查询N+1",
"description": "`reorderCoursePlanItems` 使用 `Promise.all(items.map((item) => db.update(coursePlanItems).set({ week: item.week }).where(eq(coursePlanItems.id, item.id))))` 对每个 item 发起独立的 UPDATE 查询。如果一次排序涉及 20 个条目,将产生 20 次 DB 往返。此外这些更新没有包裹在事务中F-09若中间某个更新失败会导致部分条目排序已变更、部分未变更的不一致状态。",
"recommendation": "改为单次事务 + 批量更新(使用 CASE WHEN 或单事务内顺序更新):\n```ts\nexport async function reorderCoursePlanItems(planId: string, items: ReorderCoursePlanItemInput[]): Promise<void> {\n if (items.length === 0) return\n await db.transaction(async (tx) => {\n // 方案1使用 CASE WHEN 单次 UPDATE\n const caseExpr = sql`CASE ${items.map((item, i) => sql`WHEN id = ${item.id} THEN ${item.week}`).join(' ')} END`\n await tx.update(coursePlanItems).set({ week: caseExpr }).where(eq(coursePlanItems.planId, planId))\n // 方案2事务内顺序更新简单但仍是 N 次查询,至少保证原子性)\n // for (const item of items) {\n // await tx.update(coursePlanItems).set({ week: item.week }).where(eq(coursePlanItems.id, item.id))\n // }\n })\n}\n```",
"effort": "S (≤30 分钟)"
},
{
"id": "G3-006",
"file": "src/modules/course-plans/data-access.ts",
"lines": "L309-L332",
"ruleId": "F-09",
"severity": "P1",
"dimension": "performance",
"title": "reorderCoursePlanItems 多次 UPDATE 未包裹事务",
"description": "`reorderCoursePlanItems` 对多条 coursePlanItems 执行 UPDATE未使用 `db.transaction` 包裹。若中间某次更新失败,已成功的更新无法回滚,导致周次排序部分变更的不一致状态。同样问题存在于 `bulkUpdateItemCompleted`L337-L349使用单次 inArray UPDATE虽然单语句本身原子但若业务上需要级联校验则缺少事务边界。",
"recommendation": "```ts\nexport async function reorderCoursePlanItems(planId: string, items: ReorderCoursePlanItemInput[]): Promise<void> {\n if (items.length === 0) return\n await db.transaction(async (tx) => {\n for (const item of items) {\n await tx.update(coursePlanItems).set({ week: item.week }).where(eq(coursePlanItems.id, item.id))\n }\n })\n}\n```",
"effort": "S (≤30 分钟)"
},
{
"id": "G3-007",
"file": "src/modules/school/data-access.ts",
"lines": "L1-L938",
"ruleId": "S-01",
"severity": "P1",
"dimension": "structure",
"title": "school/data-access.ts 938 行,超过 800 行警告阈值,接近 1000 行硬上限",
"description": "文件总行数 938 行,已超过项目规范的 800 行警告阈值Server Actions / Data Access 模块建议 ≤ 800 行),接近 1000 行硬上限。文件同时包含5 类读函数departments/academicYears/schools/grades/staffOptions、3 类权限感知查询getSchoolsForUser/getGradesForUser/getOrgTree、12 个 mutation 函数create/update/delete × department/school/grade/academicYear、6 个跨模块查询接口getSubjectOptions/getGradeOptions/getGradeNameById/getSubjectNameById/getSubjectNameMapByIds/isGradeHead/isGradeManager/findGradeIdByHeadAndName、2 个统计函数getGradeOverviewStats/promoteGrades。",
"recommendation": "按职责拆分为多个文件:\n```\nsrc/modules/school/\n├─ data-access.ts # 主入口re-export\n├─ data-access-departments.ts # 部门 CRUD\n├─ data-access-schools.ts # 学校 CRUD + getSchoolsForUser\n├─ data-access-grades.ts # 年级 CRUD + getGradesForUser + promoteGrades\n├─ data-access-academic-years.ts # 学年 CRUD\n├─ data-access-staff.ts # getStaffOptions + getGradesForStaff\n├─ data-access-options.ts # getSubjectOptions + getGradeOptions + getXxxNameById\n├─ data-access-permissions.ts # isGradeHead + isGradeManager + findGradeIdByHeadAndName\n└─ data-access-org-tree.ts # getOrgTree + getGradeOverviewStats\n```",
"effort": "M (≤2h)"
},
{
"id": "G3-008",
"file": "src/modules/school/data-access.ts",
"lines": "L1-L938",
"ruleId": "S-02",
"severity": "P1",
"dimension": "structure",
"title": "school/data-access.ts 导出 30+ 函数,远超 20 个警告阈值",
"description": "文件导出函数清单30+ 个getDepartments、getAcademicYears、getSchools、getGrades、getStaffOptions、getGradesForStaff、getSchoolsForUser、getGradesForUser、createDepartment、updateDepartment、deleteDepartment、createSchool、updateSchool、deleteSchool、createGrade、updateGrade、deleteGrade、createAcademicYear、updateAcademicYear、deleteAcademicYear、getSubjectOptions、getGradeOptions、getGradeNameById、getSubjectNameById、getSubjectNameMapByIds、isGradeHead、isGradeManager、findGradeIdByHeadAndName、promoteGrades、getOrgTree、getGradeOverviewStats含 Raw 版本则达 50+ 个)。导出函数过多导致文件职责不单一,维护困难。",
"recommendation": "按职责拆分(见 G3-007 建议),每个拆分文件导出函数数控制在 5-10 个以内。",
"effort": "M (≤2h)"
},
{
"id": "G3-009",
"file": "src/modules/school/data-access.ts",
"lines": "L29, L50, L73, L294",
"ruleId": "F-03",
"severity": "P1",
"dimension": "performance",
"title": "school/data-access.ts 多处使用 db.select() 未指定列SELECT *",
"description": "以下查询使用 `db.select().from(table)` 返回所有列,违反 F-03 规则:\n- L29 `db.select().from(departments)` (getDepartmentsRaw)\n- L50 `db.select().from(academicYears)` (getAcademicYearsRaw)\n- L73 `db.select().from(schools)` (getSchoolsRaw)\n- L294 `db.select().from(schools)` (getSchoolsForUserRaw 内部)\n\n虽然这些表列数较少但 SELECT * 会返回不需要的列(如 updatedAt、内部审计字段增加网络传输与内存开销且在 schema 变更时可能意外暴露新字段。",
"recommendation": "显式枚举所需列:\n```ts\nconst rows = await db\n .select({\n id: departments.id,\n name: departments.name,\n description: departments.description,\n createdAt: departments.createdAt,\n updatedAt: departments.updatedAt,\n })\n .from(departments)\n .orderBy(asc(departments.name))\n```",
"effort": "S (≤30 分钟)"
},
{
"id": "G3-010",
"file": "src/modules/school/data-access.ts",
"lines": "L38, L61, L82, L141, L168, L229, L307, L405, L573, L605, L877, L930",
"ruleId": "A-10",
"severity": "P1",
"dimension": "architecture",
"title": "school/data-access.ts 包含 12 处 console.error 调试代码",
"description": "文件中 12 个读函数内都有 `console.error(\"xxx failed:\", error)` 后返回空数组的模式(如 L38、L61、L82、L141、L168、L229、L307、L405、L573、L605、L877、L930。这违反 A-10 规则data-access 含 console.log 调试代码)。更重要的是,这种模式吞掉异常并返回空数组,导致调用方无法区分""和""是错误的错误处理模式P-05 也要求 data-access 层用 throw。",
"recommendation": "删除所有 console.error改为 throw 让 actions 层处理:\n```ts\nexport const getDepartmentsRaw = async (): Promise<DepartmentListItem[]> => {\n const rows = await db.select({...}).from(departments).orderBy(asc(departments.name))\n return rows.map(...)\n // 移除 try/catch让异常向上传播\n}\n```\n若需保留容错应在 actions 层用 try/catch 包裹并返回 ActionState。",
"effort": "M (≤2h)"
},
{
"id": "G3-011",
"file": "src/modules/school/data-access.ts",
"lines": "L246-L310, L324-L408",
"ruleId": "A-02",
"severity": "P1",
"dimension": "architecture",
"title": "getSchoolsForUserRaw / getGradesForUserRaw 包含角色判断业务逻辑",
"description": "`getSchoolsForUserRaw`L246-L310和 `getGradesForUserRaw`L324-L408内部包含\n- 查询用户角色 `db.select({ name: roles.name }).from(roles)...`\n- 基于角色分支:`if (roleNames.has(\"admin\"))` / `if (roleNames.has(\"grade_head\"))` / `if (roleNames.has(\"teacher\"))`\n- 动态导入 classes data-access 并调用 `getAccessibleClassIdsForTeacher`、`getGradeIdsByClassIds`\n\n这是典型的权限感知业务编排逻辑应位于 actions 层或 lib 层,而非 data-access 层。data-access 层应只提供原子查询能力,由 actions 层根据用户角色选择调用哪个查询。",
"recommendation": "将角色判断逻辑移至 actions.ts 或新建 lib/school-scope-resolver.ts\n```ts\n// actions.ts\nexport async function getSchoolsForUserAction(userId: string): Promise<ActionState<SchoolListItem[]>> {\n const ctx = await requirePermission(Permissions.SCHOOL_READ)\n // 基于 ctx.dataScope 与 roles 决定调用哪个 data-access 函数\n if (ctx.dataScope.type === \"all\") {\n return { success: true, data: await getSchools() }\n }\n // ... 其他分支\n}\n```\ndata-access 层保留 getSchools()、getSchoolsByIds(ids) 等原子函数。",
"effort": "L (≤1d)"
},
{
"id": "G3-012",
"file": "src/modules/school/data-access.ts",
"lines": "L803-L822",
"ruleId": "F-09",
"severity": "P1",
"dimension": "performance",
"title": "promoteGrades 循环内多次 UPDATE 未包裹事务",
"description": "`promoteGrades` 查询所有年级后,在 `for (const row of rows)` 循环中对每个年级执行独立的 `db.update(grades).set(...)`,未使用事务。若中间某次更新失败(如唯一约束冲突、连接断开),已升级的年级无法回滚,导致年级数据部分升级、部分未升级的不一致状态。注释虽提到"",但这只是降低风险,不能替代事务。",
"recommendation": "```ts\nexport async function promoteGrades(schoolId: string): Promise<{ promoted: number }> {\n const rows = await db.select(...).from(grades).where(eq(grades.schoolId, schoolId)).orderBy(desc(grades.order))\n let promoted = 0\n await db.transaction(async (tx) => {\n for (const row of rows) {\n const newOrder = (row.order ?? 0) + 1\n const newName = promoteGradeName(row.name)\n await tx.update(grades).set({ order: newOrder, name: newName }).where(eq(grades.id, row.id))\n promoted += 1\n }\n })\n return { promoted }\n}\n```",
"effort": "S (≤30 分钟)"
},
{
"id": "G3-013",
"file": "src/modules/classes/data-access-teacher.ts",
"lines": "L73",
"ruleId": "A-10",
"severity": "P2",
"dimension": "architecture",
"title": "classes/data-access-teacher.ts 包含 console.error 调试代码",
"description": "L73 `console.error(\"getTeacherClasses query failed:\", error)` 后 `throw new Error(\"Failed to load teacher classes\")`。虽然这里重新抛出了错误(比 school 模块的吞异常好),但 console.error 仍违反 A-10 规则。生产环境应使用结构化日志(如 logAudit 或 trackEvent而非 console.error。",
"recommendation": "删除 console.error直接 throw\n```ts\n} catch (error) {\n throw new Error(\"Failed to load teacher classes\")\n}\n```\n若需记录错误上下文使用项目统一的日志工具。",
"effort": "XS (≤15 分钟)"
},
{
"id": "G3-014",
"file": "src/modules/classes/data-access-students.ts",
"lines": "L170",
"ruleId": "A-10",
"severity": "P2",
"dimension": "architecture",
"title": "classes/data-access-students.ts 包含 console.error 调试代码",
"description": "L170 `console.error(\"getStudentClasses primary query failed, falling back:\", error)` 后执行 fallback 查询。这种模式将异常吞掉并降级调用方无法感知主查询失败。console.error 违反 A-10且 fallback 逻辑(使用 `sql\\`NULL\\`` 替代 schoolName隐藏了潜在 schema 问题。",
"recommendation": "删除 console.error 与 fallback让异常向上传播由 actions 层处理。若确实需要 fallback如兼容旧 schema应使用结构化日志并添加监控埋点。",
"effort": "XS (≤15 分钟)"
},
{
"id": "G3-015",
"file": "src/modules/classes/data-access-admin.ts",
"lines": "L91, L252",
"ruleId": "A-10",
"severity": "P2",
"dimension": "architecture",
"title": "classes/data-access-admin.ts 包含 console.error 调试代码",
"description": "L91 `console.error(\"getAdminClasses primary query failed, falling back:\", error)` 和 L252 `console.error(\"getGradeManagedClasses primary query failed:\", error)`。与 G3-014 类似,主查询失败后执行 fallback 并吞掉异常。",
"recommendation": "同 G3-014删除 console.error移除 fallback 或改用结构化日志。",
"effort": "XS (≤15 分钟)"
},
{
"id": "G3-016",
"file": "src/modules/course-plans/data-access.ts",
"lines": "L167, L202",
"ruleId": "A-10",
"severity": "P2",
"dimension": "architecture",
"title": "course-plans/data-access.ts 包含 console.error 调试代码",
"description": "L167 `console.error(\"getCoursePlans failed:\", error)` 返回空数组L202 `console.error(\"getCoursePlanById failed:\", error)` 返回 null。两处都吞掉异常调用方无法区分""与""。",
"recommendation": "删除 try/catch 与 console.error让异常向上传播。actions 层已有 handleActionError 统一处理。",
"effort": "XS (≤15 分钟)"
},
{
"id": "G3-017",
"file": "src/modules/classes/data-access-students.ts",
"lines": "L281-L285",
"ruleId": "F-02",
"severity": "P1",
"dimension": "performance",
"title": "getClassStudentsRaw 使用 LIKE '%xxx%' 全表扫描",
"description": "L282-L285\n```ts\nconst needle = `%${q}%`\nconditions.push(\n sql`(LOWER(COALESCE(${users.name}, '')) LIKE ${needle} OR LOWER(${users.email}) LIKE ${needle})`\n)\n```\n`%xxx%` 前缀通配符 LIKE 无法使用 B-Tree 索引,会导致 users 表全表扫描。当 users 表数据量增长(如 10 万学生),此查询性能会急剧下降。同时 LOWER() 函数包裹列也会阻止索引使用。",
"recommendation": "1. 短期:改为前缀匹配 `LIKE ${q}%`(可使用索引),或限制搜索字段为 email唯一索引\n2. 中期:为 users.name 与 users.email 添加 FULLTEXT 索引MySQL或 pg_trgm 索引PostgreSQL\n3. 使用生成的列索引:`ALTER TABLE users ADD COLUMN name_lower VARCHAR(255) GENERATED ALWAYS AS (LOWER(name)) STORED, ADD INDEX idx_name_lower (name_lower)`\n\n```ts\n// 前缀匹配方案(可走索引)\nconst needle = `${q}%`\nconditions.push(\n or(\n like(users.name, needle),\n like(users.email, needle)\n )\n)\n```",
"effort": "M (≤2h)"
},
{
"id": "G3-018",
"file": "src/modules/scheduling/data-access.ts",
"lines": "L462-L464",
"ruleId": "S-05",
"severity": "P2",
"dimension": "structure",
"title": "getScheduleEntriesForAdminRaw 为死代码(永远返回空数组)",
"description": "L462-L464\n```ts\nexport async function getScheduleEntriesForAdminRaw(): Promise<ScheduleEntry[]> {\n return []\n}\n```\n函数体只有 `return []`,注释说明"simplified implementation returns an empty array; a real implementation should join classSchedule with classes/users..."。这是未实现的桩函数,但仍被 `cacheFn` 包装并导出,属于 dead code。调用方若依赖此函数将永远拿到空数据可能导致前端显示异常而无报错。",
"recommendation": "要么完整实现该函数JOIN classSchedule + classes + users 填充 teacherName/className/subject/room要么删除该函数及其 cacheFn 包装。若暂不实现,应抛出 `throw new Error(\"Not implemented\")` 而非静默返回空数组。",
"effort": "XS (≤15 分钟) 删除 / M (≤2h) 实现"
},
{
"id": "G3-019",
"file": "src/modules/classes/data-access-stats.ts",
"lines": "L520-L523",
"ruleId": "F-10",
"severity": "P2",
"dimension": "performance",
"title": "getClassesDashboardStatsRaw 使用 count() 无过滤条件全表统计",
"description": "L521`db.select({ value: count() }).from(classes)` 没有 WHERE 子句,对 classes 表执行全表 COUNT(*)。虽然 COUNT(*) 在 InnoDB 上仍有性能开销(尤其大表),且此处无任何业务过滤(如按学校、学年、状态过滤),统计的是历史所有班级总数,可能不符合业务预期(如已删除的班级是否应计入?)。",
"recommendation": "添加业务过滤条件:\n```ts\nexport const getClassesDashboardStatsRaw = async (): Promise<ClassesDashboardStats> => {\n const [row] = await db\n .select({ value: count() })\n .from(classes)\n .where(eq(classes.deletedAt, null)) // 若有软删除字段\n // 或按学年过滤:.where(eq(classes.academicYearId, currentAcademicYearId))\n return { classCount: Number(row?.value ?? 0) }\n}\n```\n若确实需要全表统计考虑使用缓存或物化视图。",
"effort": "XS (≤15 分钟)"
},
{
"id": "G3-020",
"file": "src/modules/classes/data-access-admin.ts",
"lines": "L42-L186, L193-L316",
"ruleId": "S-03",
"severity": "P2",
"dimension": "structure",
"title": "getAdminClassesRaw 与 getGradeManagedClassesRaw 大量代码重复",
"description": "`getAdminClassesRaw`L42-L186145 行)与 `getGradeManagedClassesRaw`L193-L316124 行)有大量重复代码:\n- 相同的 select 字段列表id/schoolName/schoolId/name/grade/gradeId/...\n- 相同的 groupBy 子句\n- 相同的 orderBy 子句\n- 相同的 try/catch + fallback 逻辑\n- 相同的 subjectsByClassId Map 构建逻辑\n- 相同的 list.map + compareClassLike 排序逻辑\n\n唯一差异getGradeManagedClasses 多了 `where(inArray(classes.gradeId, gradeIds))` 过滤条件。",
"recommendation": "提取共享 helper\n```ts\nasync function fetchClassesWithSubjects(\n whereClause?: SQL\n): Promise<AdminClassListItem[]> {\n const [rows, subjectRows] = await Promise.all([\n db.select({...}).from(classes).innerJoin(users, ...).leftJoin(classEnrollments, ...)\n .where(whereClause)\n .groupBy(...).orderBy(...),\n db.select({...}).from(classSubjectTeachers)...\n ])\n // 共享的 Map 构建与排序逻辑\n return list\n}\n\nexport const getAdminClassesRaw = async () => fetchClassesWithSubjects()\nexport const getGradeManagedClassesRaw = async (userId: string) => {\n const gradeIds = await getManagedGradeIds(userId)\n return fetchClassesWithSubjects(inArray(classes.gradeId, gradeIds))\n}\n```",
"effort": "M (≤2h)"
},
{
"id": "G3-021",
"file": "src/modules/attendance/data-access.ts",
"lines": "L50-L51",
"ruleId": "S-03",
"severity": "P2",
"dimension": "structure",
"title": "serializeDate helper 在 attendance/scheduling 多个文件中重复定义",
"description": "`serializeDate` 函数在以下文件中重复定义,且实现略有差异(返回 \"\" vs null\n- attendance/data-access.ts L50: `(d: Date | string | null): string => d ? new Date(d).toISOString().slice(0, 10) : \"\"`\n- attendance/data-access-stats.ts L97: 同上(返回 \"\"\n- scheduling/data-access.ts L27: `(d: Date | string | null): string | null => d ? new Date(d).toISOString().slice(0, 10) : null`\n- school/data-access.ts L25: `const toIso = (d: Date): string => d.toISOString()`\n- course-plans/data-access.ts L28-L31: `toIso` + `toIsoRequired` 两个函数\n\nP-07 规则要求日期序列化走 helper但目前每个模块自定义 helper违反 S-03重复 helper 应提取到 shared/lib。",
"recommendation": "在 `src/shared/lib/date-utils.ts` 统一导出:\n```ts\nexport const toISODateString = (d: Date | string | null): string | null =>\n d ? new Date(d).toISOString().slice(0, 10) : null\n\nexport const toISODateStringOrEmpty = (d: Date | string | null): string =>\n d ? new Date(d).toISOString().slice(0, 10) : \"\"\n\nexport const toISODateTimeString = (d: Date | string | null): string | null =>\n d ? new Date(d).toISOString() : null\n```\n各模块改为 `import { toISODateString } from \"@/shared/lib/date-utils\"`。",
"effort": "S (≤30 分钟)"
},
{
"id": "G3-022",
"file": "src/modules/attendance/data-access-correlation.ts",
"lines": "L46-L193",
"ruleId": "A-02",
"severity": "P1",
"dimension": "architecture",
"title": "getAttendanceGradeCorrelationRaw 包含大量业务编排逻辑",
"description": "`getAttendanceGradeCorrelationRaw`L46-L193148 行)包含:\n- scope 权限校验L57-L62`if (scope && scope.type === \"class_taught\")` / `if (scope && scope.type === \"owned\") return null`\n- 时间范围默认值计算L70-L77`DEFAULT_RANGE_DAYS = 90` 天回溯\n- 跨模块数据编排:调用 `getClassNameById`、`getClassActiveStudentsWithInfo`、`getGradeRecords`\n- 成绩归一化计算L139-L157`normalized = (r.score / r.fullScore) * 100`、加权平均\n- 考勤率计算L167-L169\n- 调用纯函数 `computeCorrelationSummary`L186-L192\n\n这是典型的业务编排逻辑应位于 actions 层或 lib 层data-access 层应只提供原子查询(如 `getAttendanceStatsByStudent`、`getGradeRecordsByClass`)。",
"recommendation": "拆分职责:\n1. data-access 层:保留 `getAttendanceAggByStudent(classId, startDate, endDate)` 原子查询\n2. lib 层:新建 `correlation-compute.ts`(已存在)存放纯计算逻辑\n3. actions 层:新建 `getAttendanceGradeCorrelationAction`,负责 scope 校验、时间范围计算、跨模块编排、调用纯计算\n\n```ts\n// actions.ts\nexport async function getAttendanceGradeCorrelationAction(classId: string, ...) {\n const ctx = await requirePermission(Permissions.ATTENDANCE_READ)\n // scope 校验\n if (ctx.dataScope.type === \"owned\") return { success: false, message: \"...\" }\n // 编排\n const className = await getClassNameById(classId)\n const students = await getClassActiveStudentsWithInfo(classId)\n const attendanceAgg = await getAttendanceAggByStudent(classId, ...)\n const gradeRecords = await getGradeRecords({ classId, ... })\n // 计算纯函数\n const summary = computeCorrelationSummary(...)\n return { success: true, data: summary }\n}\n```",
"effort": "L (≤1d)"
},
{
"id": "G3-023",
"file": "src/modules/attendance/data-access-correlation.ts",
"lines": "L145",
"ruleId": "F-01",
"severity": "P2",
"dimension": "performance",
"title": "correlation 模块使用 Array.includes 进行 O(n*m) 查找",
"description": "L145`if (!studentIds.includes(r.studentId)) continue` 在 `for (const r of filteredGradeRecords)` 循环内。若 studentIds 有 N 个学生filteredGradeRecords 有 M 条成绩记录,则此处为 O(N*M) 复杂度。虽然 N 通常较小(< 100但 M 可能较大(多年成绩记录),应使用 Set 优化。",
"recommendation": "```ts\nconst studentIdSet = new Set(studentIds)\nfor (const r of filteredGradeRecords) {\n if (!studentIdSet.has(r.studentId)) continue\n // ...\n}\n```",
"effort": "XS (≤15 分钟)"
},
{
"id": "G3-024",
"file": "src/modules/classes/data-access-teacher.ts",
"lines": "L284-L439",
"ruleId": "A-02",
"severity": "P1",
"dimension": "architecture",
"title": "enrollTeacherByInvitationCode 包含复杂业务状态机逻辑",
"description": "`enrollTeacherByInvitationCode`L284-L439155 行)包含:\n- 教师身份校验L320-L328\n- 邀请码校验L331-L335\n- 班级归属校验L337-L343\n- 科目查找与分配逻辑L346-L431\n - 已分配科目冲突检测L365 `throw new Error(\"Subject already assigned\")`\n - 自动选择首选科目L401 `DEFAULT_CLASS_SUBJECTS.find`\n - 多次 SELECT + INSERT + UPDATE 实现教师-科目绑定状态机\n- 邀请码消耗L433-L436\n\n这是典型的业务状态机应位于 actions 层。data-access 层应只提供 `insertClassSubjectTeacher`、`updateClassSubjectTeacher`、`getClassSubjectTeacher` 等原子操作。",
"recommendation": "将 enrollTeacherByInvitationCode 拆分:\n1. data-access 层:提供 `getTeacherExistingAssignment(classId, teacherId)`、`assignTeacherToSubject(classId, subjectId, teacherId)`、`findUnassignedSubject(classId)` 等原子函数\n2. actions 层:`enrollTeacherByInvitationCodeAction` 编排校验、状态机、调用原子函数、包裹事务\n3. 整个流程应用 `db.transaction` 包裹,确保邀请码消耗与教师分配原子性",
"effort": "L (≤1d)"
},
{
"id": "G3-025",
"file": "src/modules/classes/data-access-teacher.ts",
"lines": "L284-L439",
"ruleId": "F-09",
"severity": "P1",
"dimension": "performance",
"title": "enrollTeacherByInvitationCode 多次写操作未包裹事务",
"description": "`enrollTeacherByInvitationCode` 内部执行多次写操作:\n- L368-L372 `db.insert(classSubjectTeachers).values(...).onDuplicateKeyUpdate(...)`\n- L382-L385 `db.update(classSubjectTeachers).set({ teacherId: tid })...`\n- L407-L416 `db.update(classSubjectTeachers).set({ teacherId: tid })...`\n- L304 `consumeInvitationCode(code)`(内部 UPDATE\n\n这些写操作未包裹在事务中。若中间失败如 consumeInvitationCode 失败),教师已被分配到科目但邀请码未消耗,导致数据不一致(邀请码可被重复使用)。",
"recommendation": "```ts\nexport async function enrollTeacherByInvitationCode(...): Promise<string> {\n // 校验逻辑...\n return await db.transaction(async (tx) => {\n // 所有写操作使用 tx\n await tx.insert(classSubjectTeachers).values(...)\n await tx.update(classSubjectTeachers).set(...)\n if (result.codeId) {\n await tx.update(classInvitationCodes).set({ usedCount: sql`${classInvitationCodes.usedCount} + 1` })...\n }\n return cls.id\n })\n}\n```",
"effort": "M (≤2h)"
},
{
"id": "G3-026",
"file": "src/modules/scheduling/data-access.ts",
"lines": "L53, L69, L294",
"ruleId": "F-03",
"severity": "P2",
"dimension": "performance",
"title": "scheduling/data-access.ts 多处使用 db.select() 未指定列",
"description": "以下查询使用 `db.select().from(table)` 返回所有列:\n- L53 `db.select().from(schedulingRules)` (getSchedulingRulesRaw)\n- L69 `db.select().from(schedulingRules)` (upsertSchedulingRules 内部查询)\n- L294 `db.select({ id: classes.id, name: classes.name, ... })` - 此处已指定列 ✓\n\nschedulingRules 表可能包含较多字段classId、maxDailyHours、maxContinuousHours、lunchBreakStart、lunchBreakEnd、morningStart、afternoonEnd、avoidBackToBack、balancedSubjects、createdAt、updatedAtSELECT * 会返回所有字段。",
"recommendation": "显式指定所需列:\n```ts\nconst rows = await db\n .select({\n id: schedulingRules.id,\n classId: schedulingRules.classId,\n maxDailyHours: schedulingRules.maxDailyHours,\n // ... 其他所需字段\n })\n .from(schedulingRules)\n .where(...)\n```",
"effort": "XS (≤15 分钟)"
},
{
"id": "G3-027",
"file": "src/modules/attendance/data-access.ts",
"lines": "L314, L341",
"ruleId": "F-03",
"severity": "P2",
"dimension": "performance",
"title": "attendance/data-access.ts 多处使用 db.select() 未指定列",
"description": "L314 `db.select().from(attendanceRules)` (getAttendanceRulesRaw) 和 L341 `db.select().from(attendanceRules)` (upsertAttendanceRules 内部) 使用 SELECT *。attendanceRules 表字段较多classId、lateThresholdMinutes、earlyLeaveThresholdMinutes、enableAutoMark、attendanceRateThreshold、consecutiveAbsenceThreshold、createdAt、updatedAt返回全部字段会增加开销。",
"recommendation": "显式指定所需列,同 G3-026 建议。",
"effort": "XS (≤15 分钟)"
},
{
"id": "G3-028",
"file": "src/modules/course-plans/data-access.ts",
"lines": "L159, L183, L192, L366, L374, L450, L461",
"ruleId": "F-03",
"severity": "P2",
"dimension": "performance",
"title": "course-plans/data-access.ts 多处使用 db.select() 未指定列",
"description": "以下 7 处查询使用 `db.select().from(table)` 返回所有列:\n- L159 `db.select().from(coursePlans)` (getCoursePlansRaw)\n- L183 `db.select().from(coursePlans)` (getCoursePlanByIdRaw)\n- L192 `db.select().from(coursePlanItems)` (getCoursePlanByIdRaw 内部)\n- L366 `db.select().from(coursePlans)` (copyCoursePlanToClasses 内部)\n- L374 `db.select().from(coursePlanItems)` (copyCoursePlanToClasses 内部)\n- L450 `db.select().from(coursePlans)` (getGradeCoursePlanProgressRaw)\n- L461 `db.select().from(coursePlanItems)` (getGradeCoursePlanProgressRaw)\n\ncoursePlans 表字段较多id、classId、subjectId、teacherId、academicYearId、semester、totalHours、completedHours、weeklyHours、startDate、endDate、syllabus、objectives、status、createdBy、createdAt、updatedAt全量返回会增加网络与内存开销。",
"recommendation": "显式指定所需列。对于 copyCoursePlanToClasses 等需要全字段的场景,可保留 SELECT * 但添加注释说明。",
"effort": "S (≤30 分钟)"
},
{
"id": "G3-029",
"file": "src/modules/classes/data-access-admin.ts",
"lines": "L36-L40",
"ruleId": "P-09",
"severity": "P3",
"dimension": "pattern",
"title": "使用 as 断言将 DEFAULT_CLASS_SUBJECTS 转为 readonly string[]",
"description": "L36-L40\n```ts\nconst isClassSubject = (v: unknown): v is ClassSubject =>\n typeof v === \"string\" && (DEFAULT_CLASS_SUBJECTS as readonly string[]).includes(v)\n```\n`DEFAULT_CLASS_SUBJECTS as readonly string[]` 是类型断言(从具体元组类型 widening 为 readonly string[])。虽然这是 widening 断言(比 narrowing 安全),但仍违反 P-09 规则(禁止 as 断言)。`.includes(v)` 需要 `readonly string[]` 类型参数,而 DEFAULT_CLASS_SUBJECTS 可能是 `readonly [\"语文\", \"数学\", ...]` 元组类型。",
"recommendation": "改用类型安全的方式:\n```ts\nconst CLASS_SUBJECTS_READONLY: readonly string[] = DEFAULT_CLASS_SUBJECTS\nconst isClassSubject = (v: unknown): v is ClassSubject =>\n typeof v === \"string\" && CLASS_SUBJECTS_READONLY.includes(v)\n```\n或在 types.ts 中将 DEFAULT_CLASS_SUBJECTS 类型显式标注为 `readonly string[]`。",
"effort": "XS (≤15 分钟)"
},
{
"id": "G3-030",
"file": "src/modules/classes/data-access-teacher.ts",
"lines": "L41",
"ruleId": "P-09",
"severity": "P3",
"dimension": "pattern",
"title": "使用 as 断言将 DEFAULT_CLASS_SUBJECTS 转为 readonly string[]",
"description": "L41`typeof v === \"string\" && (DEFAULT_CLASS_SUBJECTS as readonly string[]).includes(v)`,与 G3-029 相同的 as 断言模式。",
"recommendation": "同 G3-029。",
"effort": "XS (≤15 分钟)"
},
{
"id": "G3-031",
"file": "src/modules/classes/data-access.ts",
"lines": "L83-L92",
"ruleId": "F-05",
"severity": "P2",
"dimension": "performance",
"title": "getAccessibleClassIdsForTeacher 等多个查询无 LIMIT 保护",
"description": "`getAccessibleClassIdsForTeacher`L83-L92查询教师所有可访问班级 ID无 LIMIT。其他无 LIMIT 的查询:\n- getStudentIdsByClassIdL147-L153\n- getStudentIdsByClassIdsL159-L166\n- getTeacherIdsByClassIdsL208-L228\n- getClassesByGradeIdL352-L359\n- getClassIdsByGradeIdsL365-L373\n- getClassNamesByIdsL334-L346\n\n虽然班级数量通常有限< 100但若数据异常增长如测试数据、迁移错误可能导致一次查询返回大量数据。",
"recommendation": "为可能返回大量数据的查询添加默认 LIMIT\n```ts\nexport const getStudentIdsByClassIds = async (classIds: string[]): Promise<string[]> => {\n if (classIds.length === 0) return []\n const rows = await db\n .select({ studentId: classEnrollments.studentId })\n .from(classEnrollments)\n .where(inArray(classEnrollments.classId, classIds))\n .limit(10000) // 安全上限\n return Array.from(new Set(rows.map((r) => r.studentId)))\n}\n```",
"effort": "S (≤30 分钟)"
},
{
"id": "G3-032",
"file": "src/modules/classes/data-access-admin.ts",
"lines": "L42-L186",
"ruleId": "F-05",
"severity": "P2",
"dimension": "performance",
"title": "getAdminClassesRaw 无 LIMIT可能返回全量班级数据",
"description": "`getAdminClassesRaw` 查询所有班级(无 WHERE、无 LIMIT并 LEFT JOIN classEnrollments 计算学生数。若系统有 1000+ 班级,此查询会返回 1000+ 行,每行还包含聚合计算,性能压力大。同样问题存在于 `getGradeManagedClassesRaw`L193-L316和 `getTeacherClassesRaw`classes/data-access-teacher.ts L46-L119。",
"recommendation": "添加分页参数或默认 LIMIT\n```ts\nexport const getAdminClassesRaw = async (params?: { limit?: number; offset?: number }): Promise<AdminClassListItem[]> => {\n const limit = Math.min(params?.limit ?? 200, 500)\n const offset = params?.offset ?? 0\n // 查询添加 .limit(limit).offset(offset)\n}\n```\n前端列表应实现分页或虚拟滚动。",
"effort": "M (≤2h)"
},
{
"id": "G3-033",
"file": "src/modules/scheduling/data-access-class-schedule.ts",
"lines": "L72-L81, L147-L151",
"ruleId": "A-09",
"severity": "P2",
"dimension": "architecture",
"title": "data-access-class-schedule.ts 直接查询 classSchedule 表(应走 scheduling/data-access.ts 统一入口)",
"description": "L72-L81 `updateClassScheduleItem` 内部直接查询 `db.select({...}).from(classSchedule).where(eq(classSchedule.id, id))`L147-L151 `deleteClassScheduleItem` 内部同样直接查询 classSchedule 表。虽然 classSchedule 是 scheduling 模块的表,但 scheduling/data-access.ts 已提供了 `insertClassScheduleItem`、`updateClassScheduleItemById`、`deleteClassScheduleItemById` 统一写入入口L343-L410。当前文件绕过这些入口直接查询导致查询逻辑分散在两个文件中维护困难。",
"recommendation": "将 L72-L81 的查询逻辑移至 scheduling/data-access.ts新增 `getClassScheduleItemById(id)` 函数:\n```ts\n// scheduling/data-access.ts\nexport async function getClassScheduleItemById(id: string) {\n const [row] = await db.select({...}).from(classSchedule).where(eq(classSchedule.id, id)).limit(1)\n return row ?? null\n}\n```\ndata-access-class-schedule.ts 调用此函数,或直接删除该文件将逻辑合并到 actions-schedule.ts见 G3-003。",
"effort": "S (≤30 分钟)"
},
{
"id": "G3-034",
"file": "src/modules/scheduling/data-access.ts",
"lines": "L108-L172",
"ruleId": "F-06",
"severity": "P2",
"dimension": "performance",
"title": "getScheduleChangesRaw 内部二次查询 users 表(可与主查询合并)",
"description": "L140-L146在主查询JOIN classes + LEFT JOIN users又对 users 表执行第二次查询 `db.select({ id, name }).from(users).where(inArray(users.id, userIds))` 来解析 substituteTeacher/approver/requester 姓名。虽然这是为了避免 JOIN 歧义,但若 scheduleChanges 数据量大(如 100 条变更userIds 可能只有 5-10 个,二次查询开销可控。然而,此模式可通过 cacheFn 缓存 getUserNamesByIds 来优化。",
"recommendation": "改为调用 users 模块 data-access\n```ts\nimport { getUserNamesByIds } from \"@/modules/users/data-access\"\n// 替代直接查询 users 表\nconst userMap = await getUserNamesByIds(userIds)\n```\n这样既符合架构规则 A-06又能利用 users data-access 的 cacheFn 缓存。",
"effort": "S (≤30 分钟)"
},
{
"id": "G3-035",
"file": "src/modules/attendance/data-access-stats.ts",
"lines": "L53-L67",
"ruleId": "A-02",
"severity": "P2",
"dimension": "architecture",
"title": "computeStats 纯计算函数导出在 data-access 文件中",
"description": "L53-L67 `export const computeStats = (rows: { status: string }[]): AttendanceStats => {...}` 是纯计算函数(无 DB 访问、无 IO但定义并导出自 data-access-stats.ts。这违反职责分层纯计算函数应位于 lib/ 或 compute/ 目录。同文件还有 `statsFromAggregate`L72-L95也是纯函数但未导出private。",
"recommendation": "将 computeStats 移至 `src/modules/attendance/lib/stats-compute.ts` 或 `src/modules/attendance/stats-compute.ts`(与现有 `correlation-compute.ts`、`trend-compute.ts`、`warning-compute.ts` 同级):\n```ts\n// attendance/stats-compute.ts\nexport const computeStats = (rows: { status: string }[]): AttendanceStats => {...}\nexport const statsFromAggregate = (row: {...}): AttendanceStats => {...}\n```\ndata-access-stats.ts 改为 import 调用。",
"effort": "XS (≤15 分钟)"
},
{
"id": "G3-036",
"file": "src/modules/school/data-access.ts",
"lines": "L92-L149, L178-L232, L324-L408",
"ruleId": "S-03",
"severity": "P2",
"dimension": "structure",
"title": "getGradesRaw / getGradesForStaffRaw / getGradesForUserRaw(teacher 分支) 三处重复查询逻辑",
"description": "三个函数都执行类似的 grades INNER JOIN schools 查询,并解析 gradeHead/teachingHead 姓名:\n- getGradesRawL92-L149全量查询\n- getGradesForStaffRawL178-L232按 staffId 过滤\n- getGradesForUserRaw 的 teacher 分支L356-L400按 gradeIds 过滤\n\n三处的 select 字段列表、headIds 收集、headById Map 构建、rows.map 返回逻辑几乎完全相同(每处约 30 行重复)。",
"recommendation": "提取共享 helper\n```ts\nasync function fetchGradesWithHeads(whereClause?: SQL): Promise<GradeListItem[]> {\n const rows = await db.select({...}).from(grades).innerJoin(schools, ...).where(whereClause).orderBy(...)\n const headIds = Array.from(new Set(rows.flatMap(r => [r.gradeHeadId, r.teachingHeadId]).filter(...)))\n const heads = headIds.length ? await db.select({...}).from(users).where(inArray(users.id, headIds)) : []\n const headById = new Map(heads.map(u => [u.id, {...}]))\n return rows.map(r => ({...}))\n}\n\nexport const getGradesRaw = async () => fetchGradesWithHeads()\nexport const getGradesForStaffRaw = async (staffId: string) =>\n fetchGradesWithHeads(or(eq(grades.gradeHeadId, staffId), eq(grades.teachingHeadId, staffId)))\n```",
"effort": "M (≤2h)"
},
{
"id": "G3-037",
"file": "src/modules/classes/data-access.ts",
"lines": "L18-L20",
"ruleId": "P-07",
"severity": "P3",
"dimension": "pattern",
"title": "getSessionTeacherId 内部使用动态 import 加载 auth 模块",
"description": "L18 `const { auth } = await import(\"@/auth\")` 使用动态 import 加载 auth 模块。虽然这可能是为了避免循环依赖,但动态 import 在 TypeScript 类型推断与打包分析上不如静态 import。此外auth 模块导入应位于文件顶部,除非有明确的循环依赖问题。",
"recommendation": "若不存在循环依赖,改为静态 import\n```ts\nimport { auth } from \"@/auth\"\n```\n若存在循环依赖保留动态 import 但添加注释说明原因:\n```ts\n// 动态 import 避免 classes ↔ auth 循环依赖\nconst { auth } = await import(\"@/auth\")\n```",
"effort": "XS (≤15 分钟)"
},
{
"id": "G3-038",
"file": "src/modules/classes/data-access-invitations.ts",
"lines": "L254-L307",
"ruleId": "A-02",
"severity": "P2",
"dimension": "architecture",
"title": "validateInvitationCode 包含懒清理业务逻辑",
"description": "`validateInvitationCode`L254-L307除了校验邀请码有效性外还包含""业务逻辑:\n- L266-L272发现邀请码已过期时主动 UPDATE status 为 'expired'\n- L274-L284发现邀请码已用尽时主动 UPDATE status 为 'exhausted'\n- L294-L304fallback 到旧格式 6 位数字码classes.invitationCode\n\n这是业务状态机逻辑状态迁移active → expired/exhausted应位于 actions 层或独立的清理逻辑中,而非 data-access 层的校验函数内。校验函数应只读,状态迁移应显式调用。",
"recommendation": "拆分职责:\n1. `validateInvitationCode` 只做校验,返回 `{ valid, classId, codeId, status, needsCleanup: true }`\n2. 调用方actions根据 needsCleanup 决定是否调用 `markInvitationCodeExpired(codeId)` 或 `markInvitationCodeExhausted(codeId)`\n3. 懒清理逻辑可作为独立函数 `cleanupExpiredCodes()` 由定时任务调用\n\n或保留当前实现但在 JSDoc 中明确标注"/"。",
"effort": "M (≤2h)"
},
{
"id": "G3-039",
"file": "src/modules/classes/data-access.ts",
"lines": "L1-L406",
"ruleId": "S-02",
"severity": "P3",
"dimension": "structure",
"title": "classes/data-access.ts 导出 25+ 函数,超过 20 个警告阈值",
"description": "文件导出函数清单getSessionTeacherId、getTeacherIdForMutations、getClassSubjects、compareClassLike、getAccessibleClassIdsForTeacher、verifyTeacherOwnsClass、getClassGradeIdsByClassIds、getTeacherSubjectIdsForClass、getClassTeacherById、getStudentIdsByClassId、getStudentIdsByClassIds、getActiveStudentIdsByClassId、getClassActiveStudentsWithInfo、getTeacherSubjectIdsByClass、getTeacherIdsByClassIds、getStudentActiveClassId、getStudentActiveClass、getStudentActiveGradeId、getClassExists、getClassNameById、getClassGradeId、getGradeIdsByClassIds、getClassNamesByIds、getClassesByGradeId、getClassIdsByGradeIds、getClassIdsByGradeIdsSubquery26 个)。此外还有 `export * from \"./data-access-stats\"` 等 6 个 re-export实际导出函数总数达 50+。",
"recommendation": "按职责拆分为多个文件:\n- data-access.ts主入口re-export\n- data-access-teacher-scope.tsgetSessionTeacherId、getAccessibleClassIdsForTeacher、verifyTeacherOwnsClass、getTeacherScopeData\n- data-access-class-queries.tsgetClassExists、getClassNameById、getClassGradeId、getClassNamesByIds、getClassesByGradeId 等)\n- data-access-student-queries.tsgetStudentIdsByClassId、getStudentActiveClass、getStudentActiveGradeId 等)\n- data-access-helpers.tscompareClassLike、normalizeSortText 等纯函数)",
"effort": "M (≤2h)"
},
{
"id": "G3-040",
"file": "src/modules/attendance/data-access.ts",
"lines": "L106-L168",
"ruleId": "F-06",
"severity": "P2",
"dimension": "performance",
"title": "getAttendanceRecordsRaw 每次分页查询都重复调用 getUserNamesByIds/getClassNamesByIds",
"description": "`getAttendanceRecordsRaw` 在每次分页查询时L148-L152都调用 `getUserNamesByIds(studentIds)`、`getClassNamesByIds(classIds)`、`resolveRecorderNames(rows)` 解析姓名。虽然这些函数内部可能有 cacheFn 缓存,但每页的 studentIds/classIds 可能高度重叠(如同一班级的不同页记录),缓存命中率取决于 TTL 与 keyParts。对于高频分页场景如教师翻页查看考勤记录这可能产生重复查询。",
"recommendation": "1. 确保 getUserNamesByIds 与 getClassNamesByIds 已使用 cacheFn 包装(若未包装,参见 G3-001\n2. 考虑在前端缓存姓名映射,避免每次翻页都重新解析\n3. 对于 recorderName可在 INSERT 时冗余存储 recordedByName 字段,避免每次查询都 JOIN反范式优化",
"effort": "M (≤2h)"
},
{
"id": "G3-041",
"file": "src/modules/proctoring/data-access.ts",
"lines": "L113-L171",
"ruleId": "F-05",
"severity": "P2",
"dimension": "performance",
"title": "getProctoringEventsRaw 查询无 LIMIT可能返回大量事件",
"description": "`getProctoringEventsRaw`L113-L171查询某场考试的所有监考事件无 LIMIT。若考试持续 2 小时30 个学生每个产生 50+ 事件,总事件数可能达 1500+。一次性返回所有事件会导致内存压力与网络延迟。虽然有 `getRecentProctoringEvents` 函数L398-L429提供 LIMIT 版本,但 getProctoringEvents 本身无保护。",
"recommendation": "添加默认 LIMIT 或分页参数:\n```ts\nexport const getProctoringEventsRaw = async (\n examId: string,\n filters?: GetProctoringEventsFilters & { limit?: number; offset?: number },\n): Promise<ProctoringEventWithDetails[]> => {\n const limit = Math.min(filters?.limit ?? 500, 1000)\n const offset = filters?.offset ?? 0\n // 查询添加 .limit(limit).offset(offset)\n}\n```\n前端面板应优先使用 getRecentProctoringEvents默认 20 条),完整列表走分页。",
"effort": "XS (≤15 分钟)"
},
{
"id": "G3-042",
"file": "src/modules/classes/data-access-invitations.ts",
"lines": "L127-L138, L146-L157",
"ruleId": "F-01",
"severity": "P2",
"dimension": "performance",
"title": "generateUniqueInvitationCode / generateUniqueCode 循环内查询 DBN+1 重试模式)",
"description": "`generateUniqueInvitationCode`L127-L138和 `generateUniqueCode`L146-L157都使用 for 循环最多 40 次重试,每次循环内执行 `db.select(...).where(eq(classes.invitationCode, code)).limit(1)` 查询 DB 检查码是否已存在。虽然正常情况下 1-2 次就能成功(碰撞概率低),但最坏情况下 40 次 DB 查询。这种模式无法批量化(每次生成的码随机),但可通过 INSERT 失败捕获唯一约束错误来优化。",
"recommendation": "改为" INSERT"模式:\n```ts\nexport async function generateUniqueInvitationCode(): Promise<string> {\n for (let attempt = 0; attempt < 40; attempt += 1) {\n const code = generateInvitationCode()\n try {\n // 直接尝试 INSERT 一个临时记录或使用 SELECT FOR UPDATE 检查\n // 更优:直接在调用方 INSERT 时捕获 duplicate 错误\n return code\n } catch (err) {\n if (isDuplicateInvitationCodeError(err)) continue\n throw err\n }\n }\n throw new Error(\"Failed to generate invitation code\")\n}\n```\n或保留当前模式但将 40 次重试降为 5 次碰撞概率极低5 次足够)。",
"effort": "S (≤30 分钟)"
},
{
"id": "G3-043",
"file": "src/modules/scheduling/data-access.ts",
"lines": "L213-L244",
"ruleId": "F-01",
"severity": "P2",
"dimension": "performance",
"title": "getClassConflictsRaw 使用 O(n²) 双重循环比较课表项",
"description": "`getClassConflictsRaw`L213-L244查询班级所有课表项后使用双重循环 `for (let i = 0; i < rows.length; i++) { for (let j = i + 1; j < rows.length; j++) {...} }` 检测时间冲突。若班级有 N 个课表项,比较次数为 N*(N-1)/2。虽然 N 通常较小(< 50但可优化为 O(N) 的扫描线算法。",
"recommendation": "优化为按 weekday 分组 + 排序后单次扫描:\n```ts\nexport async function getClassConflictsRaw(classId: string): Promise<ScheduleConflict[]> {\n const rows = await db.select({...}).from(classSchedule).where(eq(classSchedule.classId, classId)).orderBy(asc(classSchedule.weekday), asc(classSchedule.startTime))\n const conflicts: ScheduleConflict[] = []\n // 按 weekday 分组\n const byWeekday = new Map<number, typeof rows>()\n for (const r of rows) {\n const list = byWeekday.get(r.weekday) ?? []\n list.push(r)\n byWeekday.set(r.weekday, list)\n }\n // 每个 weekday 内已按 startTime 排序,只需比较相邻项\n for (const [weekday, items] of byWeekday) {\n for (let i = 0; i < items.length - 1; i++) {\n const a = items[i]\n const b = items[i + 1]\n if (a && b && a.startTime < b.endTime && b.startTime < a.endTime) {\n conflicts.push({...})\n }\n }\n }\n return conflicts\n}\n```\n注意相邻比较只能检测相邻冲突若需检测所有重叠仍需 O(n²),但可先用排序+早退优化。",
"effort": "S (≤30 分钟)"
},
{
"id": "G3-044",
"file": "src/modules/attendance/data-access-stats.ts",
"lines": "L299-L307",
"ruleId": "F-05",
"severity": "P2",
"dimension": "performance",
"title": "getClassAttendanceWarningsRaw 查询无 LIMIT可能返回大量考勤记录",
"description": "`getClassAttendanceWarningsRaw`L299-L307查询班级在时间范围内的所有考勤记录select studentId, date, status无 LIMIT。若时间范围跨 1 学期(约 100 天30 学生每天 1 条记录,总记录数达 3000+。全部加载到内存按 studentId 聚合,内存压力大。",
"recommendation": "改为 SQL 聚合查询GROUP BY studentId避免拉全量记录\n```ts\nconst rows = await db\n .select({\n studentId: attendanceRecords.studentId,\n total: count(),\n present: sql<number>`COALESCE(SUM(CASE WHEN ${attendanceRecords.status} = 'present' THEN 1 ELSE 0 END), 0)`,\n // ... 其他状态统计\n })\n .from(attendanceRecords)\n .where(where)\n .groupBy(attendanceRecords.studentId)\n```\n连续缺勤检测若需要日期序列可单独查询有 absent 记录的日期,而非全量加载。",
"effort": "M (≤2h)"
},
{
"id": "G3-045",
"file": "src/modules/classes/data-access-teacher.ts",
"lines": "L1-L631",
"ruleId": "S-01",
"severity": "P2",
"dimension": "structure",
"title": "classes/data-access-teacher.ts 631 行,接近 800 行警告阈值",
"description": "文件 631 行,已超过 500 行组件建议上限(虽 data-access 建议 ≤ 800 行,但仍偏高)。文件包含:教师班级查询、教师选项查询、教师科目查询、班级 CRUDcreateTeacherClass、updateTeacherClass、deleteTeacherClass、邀请码管理ensureClassInvitationCode、regenerateClassInvitationCode、学生注册enrollStudentByInvitationCode、enrollTeacherByInvitationCode、enrollStudentByEmail、科目教师分配setClassSubjectTeachers、DataScope 辅助getTeacherScopeData。职责过多。",
"recommendation": "进一步拆分:\n- data-access-teacher-queries.tsgetTeacherClasses、getTeacherOptions、getTeacherTeachingSubjects、getTeacherScopeData\n- data-access-teacher-mutations.tscreateTeacherClass、updateTeacherClass、deleteTeacherClass、setClassSubjectTeachers\n- data-access-teacher-enrollment.tsenrollStudentByInvitationCode、enrollTeacherByInvitationCode、enrollStudentByEmail、setStudentEnrollmentStatus\n- data-access-teacher-invitations.tsensureClassInvitationCode、regenerateClassInvitationCode",
"effort": "M (≤2h)"
},
{
"id": "G3-046",
"file": "src/modules/classes/data-access-stats.ts",
"lines": "L126-L277",
"ruleId": "S-06",
"severity": "P3",
"dimension": "structure",
"title": "getClassHomeworkInsightsRaw 151 行,缺少详细 JSDoc 说明返回结构与分支逻辑",
"description": "`getClassHomeworkInsightsRaw`L126-L277是复杂的聚合函数包含教师归属判断homeroom vs subject teacher、活跃学生筛选、作业查询、提交解析、统计计算。函数仅有简短 JSDoc `cacheFn(getClassHomeworkInsightsRaw, {...})`,未说明:\n- 返回的 ClassHomeworkInsights 结构字段含义\n- isHomeroomTeacher 分支与 subjectIdFilter 分支的区别\n- 当 subjectIdFilter 为空且非 homeroom teacher 时的早返回逻辑\n- latest/overallScores 的计算方式\n\n同样问题存在于 `getGradeHomeworkInsightsRaw`L290-L509219 行)。",
"recommendation": "补充详细 JSDoc\n```ts\n/**\n * 获取班级作业洞察汇总。\n *\n * 权限分支:\n * - 班主任homeroom teacher返回所有科目的作业统计\n * - 任课教师subject teacher仅返回其所教科目的作业统计\n *\n * 返回结构:\n * - class: 班级基本信息\n * - studentCounts: 活跃/非活跃学生数\n * - assignments: 各作业的提交/批改/分数统计\n * - latest: 最近一次作业统计\n * - overallScores: 所有作业分数汇总\n *\n * @param params.classId 班级 ID\n * @param params.teacherId 教师 ID默认从 session 获取)\n * @param params.limit 作业数量上限(默认 50\n */\n```",
"effort": "S (≤30 分钟)"
},
{
"id": "G3-047",
"file": "src/modules/course-plans/data-access.ts",
"lines": "L1-L530",
"ruleId": "P-07",
"severity": "P3",
"dimension": "pattern",
"title": "course-plans/data-access.ts 自定义 toIso/toIsoRequired 而非使用 shared helper",
"description": "L28-L31 定义了 `toIso` 和 `toIsoRequired` 两个日期序列化函数,与 attendance/scheduling 模块的 `serializeDate` 功能重叠。P-07 规则要求日期序列化走 helper但每个模块自定义导致行为不一致返回 null vs \"\" vs undefined。",
"recommendation": "参见 G3-021统一使用 `@/shared/lib/date-utils` 中的 helper。",
"effort": "XS (≤15 分钟)"
},
{
"id": "G3-048",
"file": "src/modules/classes/data-access.ts",
"lines": "L384-L389",
"ruleId": "S-08",
"severity": "P3",
"dimension": "structure",
"title": "data-access.ts 通过 export * re-export 6 个子文件,职责边界模糊",
"description": "L384-L389\n```ts\nexport * from \"./data-access-stats\"\nexport * from \"./data-access-schedule\"\nexport * from \"./data-access-students\"\nexport * from \"./data-access-admin\"\nexport * from \"./data-access-invitations\"\nexport * from \"./data-access-teacher\"\n```\n主文件通过 `export *` 聚合 6 个子文件的导出,导致:\n1. 单一导入路径 `@/modules/classes/data-access` 暴露 50+ 函数,职责边界模糊\n2. 无法 tree-shake即使只用了 getClassNamesByIds也会加载所有子模块\n3. 命名冲突风险若两个子文件导出同名函数ES 模块语义下后者覆盖前者,且无警告)\n4. 文件头部注释L391-L406提到曾有 `getTeacherScopeData` 重复定义问题,正是 export * 的风险体现",
"recommendation": "改为显式 re-export\n```ts\nexport { getClassHomeworkInsights, getGradeHomeworkInsights, getClassesDashboardStats } from \"./data-access-stats\"\nexport { getStudentSchedule, getClassSchedule, getClassIdByScheduleId } from \"./data-access-schedule\"\nexport { getStudentClasses, getClassStudents, getStudentScopeData } from \"./data-access-students\"\n// ... 其他子文件\n```\n这样可避免命名冲突且便于 IDE 跳转追踪。",
"effort": "S (≤30 分钟)"
},
{
"id": "G3-049",
"file": "src/modules/attendance/data-access-correlation.ts",
"lines": "L121-L137",
"ruleId": "F-08",
"severity": "P2",
"dimension": "performance",
"title": "correlation 模块跨模块调用 getGradeRecords 后内存过滤时间范围(非最优)",
"description": "L125-L129 调用 `getGradeRecords({ classId, scope, limit: 100 })` 获取成绩记录,注释说明"getGradeRecords createdAt classId + scope createdAt "。这意味着:\n1. DB 返回 100 条记录(可能大部分不在时间范围内)\n2. 内存中再过滤,效率低\n3. LIMIT 100 可能截断有效记录(若 100 条都是旧记录,时间范围内可能 0 条)\n\n这是跨模块 data-access 接口能力不足导致的性能问题。",
"recommendation": "在 grades 模块 data-access 中扩展 `getGradeRecords` 支持时间范围筛选:\n```ts\n// grades/data-access.ts\nexport async function getGradeRecords(params: {\n classId?: string\n scope: DataScope\n limit?: number\n startDate?: string // 新增\n endDate?: string // 新增\n}) {\n // WHERE 条件添加 createdAt 范围过滤\n}\n```\n这样 attendance 模块可直接调用并让 DB 过滤,避免内存过滤。",
"effort": "M (≤2h)"
},
{
"id": "G3-050",
"file": "src/modules/classes/data-access-teacher.ts",
"lines": "L538-L570",
"ruleId": "A-02",
"severity": "P2",
"dimension": "architecture",
"title": "enrollStudentByEmail 包含身份校验与角色验证业务逻辑",
"description": "`enrollStudentByEmail`L538-L570包含\n- 教师归属校验L543-L549\n- 学生邮箱查询L551-L555\n- 学生角色校验L558-L564查询 usersToRoles JOIN roles 确认用户是学生\n- 注册写入L566-L569\n\n角色校验是业务逻辑应位于 actions 层。data-access 层应提供 `getUserByEmail`、`getUserRole` 等原子查询,由 actions 编排。",
"recommendation": "将角色校验移至 actions 层:\n```ts\n// actions-invitations.ts\nexport async function enrollStudentByEmailAction(classId, email) {\n const ctx = await requirePermission(Permissions.CLASS_ENROLL)\n // 归属校验\n const owns = await verifyTeacherOwnsClass(classId, ctx.userId)\n if (!owns) return { success: false, message: \"...\" }\n // 查询学生\n const student = await getUserByEmail(email)\n if (!student) return { success: false, message: \"Student not found\" }\n // 角色校验\n const isStudent = await hasRole(student.id, ROLE_NAMES.STUDENT)\n if (!isStudent) return { success: false, message: \"User is not a student\" }\n // 注册\n await enrollStudent(classId, student.id)\n return { success: true }\n}\n```",
"effort": "M (≤2h)"
}
]

View File

@@ -0,0 +1,734 @@
[
{
"id": "G4-001",
"file": "src/modules/messaging/data-access.ts",
"lines": "L1-L1089",
"ruleId": "S-01",
"severity": "P0",
"dimension": "structure",
"title": "messaging/data-access.ts 超 1000 行硬性上限",
"description": "文件总长 1089 行,违反项目硬性规则「任何文件不超过 1000 行,超过必须拆分」。文件混合了消息 CRUD、群发、撤回、举报、屏蔽、草稿、模板、附件 8 类职责。",
"recommendation": "按职责拆分为:(1) data-access-messages.ts消息 CRUD + 线程);(2) data-access-group.ts群发 sendGroupMessage(3) data-access-recall.ts撤回 + 批量操作);(4) data-access-reports.ts举报 + 屏蔽);(5) data-access-drafts.ts草稿 CRUD(6) data-access-templates.ts模板 CRUD(7) data-access-recipients.ts收件人解析器。原 data-access.ts 仅作 barrel re-export。",
"effort": "L (≤1d)"
},
{
"id": "G4-002",
"file": "src/modules/parent/",
"lines": "—",
"ruleId": "A-08",
"severity": "P0",
"dimension": "architecture",
"title": "parent 模块缺失 actions.tsdata-access 被 app/ 直接引用",
"description": "parent 模块目录下只有 data-access.ts 与 types.ts无 actions.ts。Grep 证实 src/app/(dashboard)/parent/children/[studentId]/page.tsx、parent/leave/page.tsx、parent/elective/page.tsx 三处页面直接 import @/modules/parent/data-access绕过 Server Action 层与 requirePermission 校验。getParentDashboardData / getChildDashboardData / getChildren 等敏感数据查询无任何权限校验。",
"recommendation": "新建 src/modules/parent/actions.ts为每个对外暴露的读函数包装 Server Action\n```ts\n\"use server\"\nimport { requirePermission } from \"@/shared/lib/auth-guard\"\nimport { Permissions } from \"@/shared/types/permissions\"\nimport { getParentDashboardData } from \"./data-access\"\nexport async function getParentDashboardDataAction() {\n const ctx = await requirePermission(Permissions.PARENT_VIEW)\n return getParentDashboardData(ctx.userId)\n}\n```\n3 个页面改为调用 Action。",
"effort": "M (≤2h)"
},
{
"id": "G4-003",
"file": "src/modules/audit/actions.ts",
"lines": "L192-L225",
"ruleId": "A-08",
"severity": "P0",
"dimension": "architecture",
"title": "purgeAuditLogsAction 使用 AUDIT_LOG_READ 权限执行破坏性清理",
"description": "purgeAuditLogsAction 在 L197 调用 `await requirePermission(Permissions.AUDIT_LOG_READ)`,但该 Action 调用 purgeExpiredAuditLogs 会物理删除审计日志。读权限用于删除操作是严重权限提权漏洞——任何能查看审计日志的用户都能清空审计痕迹。",
"recommendation": "新增专用权限点 Permissions.AUDIT_LOG_PURGEadmin 专属),改为:\n```ts\nawait requirePermission(Permissions.AUDIT_LOG_PURGE)\n```\n同步更新 src/shared/types/permissions.ts 与角色-权限映射。",
"effort": "S (≤30 分钟)"
},
{
"id": "G4-004",
"file": "src/modules/audit/actions.ts",
"lines": "L163-L190",
"ruleId": "A-08",
"severity": "P1",
"dimension": "architecture",
"title": "saveAuditRetentionConfigAction 用读权限执行写操作",
"description": "saveAuditRetentionConfigAction 在 L167 调用 `requirePermission(Permissions.AUDIT_LOG_READ)`,但该 Action 调用 saveAuditRetentionConfig 写入保留策略配置。读权限不应授予配置写入能力。",
"recommendation": "新增 Permissions.AUDIT_RETENTION_MANAGE 或复用 Permissions.AUDIT_LOG_EXPORT将 requirePermission 改为该写权限点。",
"effort": "S (≤30 分钟)"
},
{
"id": "G4-005",
"file": "src/modules/messaging/data-access.ts",
"lines": "L1-L1089",
"ruleId": "S-02",
"severity": "P1",
"dimension": "structure",
"title": "messaging/data-access.ts 导出 42 个函数 + 4 个常量/接口,远超 20 上限",
"description": "Grep 统计 `^export (async )?(function|const)` 共 44 个导出(含 Raw+Wrapper 配对),加上 SendGroupMessageInput/SendGroupResult 2 个 interface 共 46 个公共导出。职责混杂导致单文件难以维护。",
"recommendation": "与 G4-001 拆分方案同步执行,拆分后每个 data-access-*.ts 导出数控制在 8-12 个。",
"effort": "L (≤1d)"
},
{
"id": "G4-006",
"file": "src/modules/messaging/data-access.ts",
"lines": "L449-L476",
"ruleId": "A-02",
"severity": "P1",
"dimension": "architecture",
"title": "recallMessage 在 data-access 层嵌入状态机业务逻辑",
"description": "recallMessage 函数内部实现 4 态状态机(\"ok\"/\"not_found\"/\"expired\"/\"already_recalled\"包含时间窗口校验MESSAGE_RECALL_WINDOW_MS = 2 分钟、已撤回判断、elapsed 时间计算。这些是业务规则,不应放在 data-access 层。data-access 应只做 DB 读写。",
"recommendation": "将状态机移至 actions.ts\n```ts\n// data-access 只保留纯 DB 操作\nexport async function markMessageRecalled(id: string): Promise<void> {\n await db.update(messages).set({ recalledAt: new Date() }).where(eq(messages.id, id))\n}\nexport async function getMessageForRecallCheck(id: string, userId: string) {\n return db.select({id, senderId, recalledAt, createdAt}).from(messages)\n .where(and(eq(messages.id, id), eq(messages.senderId, userId))).limit(1)\n}\n// actions.ts 中 recallMessageAction 实现状态机\n```",
"effort": "M (≤2h)"
},
{
"id": "G4-007",
"file": "src/modules/messaging/data-access.ts",
"lines": "L695-L718, L641-L668",
"ruleId": "A-02",
"severity": "P1",
"dimension": "architecture",
"title": "blockUser / reportMessage 在 data-access 层实现防重复业务规则",
"description": "blockUser 实现 self_block/already_blocked 双业务校验L699, L712reportMessage 实现 already_reported 防重复校验L644-L656。这些是业务规则应在 actions 层通过 Zod + 状态判断完成data-access 仅提供 unique 索引写入与查询原语。",
"recommendation": "data-access 层只暴露纯 insert/block 查询actions 层负责状态判断与错误码映射。可利用 DB unique 索引userBlocks(blockerId, blockedId))直接 insert + catch 冲突判定 already_blocked省去一次 SELECT。",
"effort": "M (≤2h)"
},
{
"id": "G4-008",
"file": "src/modules/messaging/data-access.ts",
"lines": "L617-L630",
"ruleId": "A-02",
"severity": "P1",
"dimension": "architecture",
"title": "getMessageDetailPageData 是页面编排函数,不应在 data-access 层",
"description": "getMessageDetailPageData 内部组合 getMessageById + 条件性 markMessageAsRead是典型的页面层编排逻辑orchestration。文件头注释 L20 明确说「getMessagesPageData 已迁出至 messages/page.tsx」但本函数仍保留在 data-access违反同层职责一致性。",
"recommendation": "删除该函数,将其逻辑移至 app/(dashboard)/messages/[id]/page.tsx 或包装为 getMessageDetailAction Server Action。",
"effort": "S (≤30 分钟)"
},
{
"id": "G4-009",
"file": "src/modules/auth/data-access.ts",
"lines": "L48-L84",
"ruleId": "F-09",
"severity": "P1",
"dimension": "performance",
"title": "createUser 两次 INSERT 无事务,存在数据不一致风险",
"description": "createUser 先 db.insert(users)L55再 db.query.roles.findFirst 查角色L71最后 db.insert(usersToRoles)L78。三次操作无事务包裹。若第二步 roleRow 未找到抛错,用户已写入但无角色;若第三步失败,用户存在但无角色关联,导致下次登录 resolvePermissions 失败。",
"recommendation": "用 db.transaction 包裹:\n```ts\nawait db.transaction(async (tx) => {\n await tx.insert(users).values({...})\n const roleRow = await tx.query.roles.findFirst({ where: eq(roles.name, roleName) })\n if (!roleRow) throw new Error('DEFAULT_ROLE_NOT_FOUND')\n await tx.insert(usersToRoles).values({ userId, roleId: roleRow.id })\n})\n```\n注意抛错会回滚用户记录避免孤儿用户。",
"effort": "S (≤30 分钟)"
},
{
"id": "G4-010",
"file": "src/modules/messaging/data-access.ts",
"lines": "L137-L141",
"ruleId": "F-02",
"severity": "P1",
"dimension": "performance",
"title": "getMessages 对 messages.subject/content 使用 LIKE '%kw%' 全表扫描",
"description": "L138-139 `like(messages.subject, kw), like(messages.content, kw)` 中 kw = `%${params.keyword.trim()}%`。前导通配符使 B-tree 索引失效messages 表增长后查询退化。同时 getMessages 还要 count() 同条件总数,单次列表请求触发 2 次全表扫描。",
"recommendation": "(1) 短期:对短关键词改前缀匹配 `kw%` 可用索引;(2) 长期:在 messages 表加 FULLTEXT 索引 `ALTER TABLE messages ADD FULLTEXT idx_subject_content(subject, content)`,改用 `match(messages.subject, messages.content).against(kw)`(3) count 也可考虑用估算值或缓存。",
"effort": "L (≤1d)"
},
{
"id": "G4-011",
"file": "src/modules/messaging/data-access.ts",
"lines": "L506,L517,L531,L542,L555,L805,L807",
"ruleId": "P-09",
"severity": "P2",
"dimension": "pattern",
"title": "messaging/data-access.ts 出现 7 处 `as` 类型断言",
"description": "Grep 证实 7 处 `as` 断言:(1) L506/517/531/542/555 `role: \"admin\" as RecipientRole` 等 5 处把 string literal 断言为联合类型 RecipientRole(2) L805 `r.reason as MessageReportReason`(3) L807 `r.status as MessageReport[\"status\"]`。规则 P-09 要求 `as` 出现次数为 0除 unknown 收窄)。",
"recommendation": "(1) RecipientRole 字面量断言:改用类型守卫函数 `function toRecipientRole(v: string): RecipientRole { return RECIPIENT_ROLES.includes(v) ? (v as RecipientRole) : 'admin' }` 或在 map 回调显式标注返回类型让 TS 推断;(2) reason/status 断言:仿照 notifications/data-access.ts L39-49 的 isNotificationType 类型守卫模式。",
"effort": "M (≤2h)"
},
{
"id": "G4-012",
"file": "src/modules/messaging/actions.ts",
"lines": "L812",
"ruleId": "P-09",
"severity": "P2",
"dimension": "pattern",
"title": "messaging/actions.ts L812 `as` 联合类型断言",
"description": "L812 `reason: input.reason as \"spam\" | \"harassment\" | \"inappropriate\" | \"other\"` 直接把 Zod 解析后的 string 断言为联合类型。ReportMessageSchema 应在 Zod 层用 z.enum() 收窄类型,避免后续 `as`。",
"recommendation": "修改 schema.ts 的 ReportMessageSchema\n```ts\nreason: z.enum(['spam', 'harassment', 'inappropriate', 'other'])\n```\n然后删除 `as` 断言TS 会从 Zod 推断正确类型。",
"effort": "XS (≤15 分钟)"
},
{
"id": "G4-013",
"file": "src/modules/messaging/data-access.ts",
"lines": "L86-L94",
"ruleId": "S-07",
"severity": "P2",
"dimension": "structure",
"title": "resolveUserNames 与 users/data-access.getUserNamesByIds 逻辑重复",
"description": "messaging/data-access.ts L86-94 自定义 resolveUserNames 函数,查询 users 表返回 Map<userId, name>。但同模块 L41 已 import getUserNamesByIds from users/data-access且后者返回 Map<userId, UserNameOption>(含 id/name/email。功能高度重复违反 S-07 跨模块重复查询逻辑。",
"recommendation": "删除 resolveUserNames统一使用 getUserNamesByIds\n```ts\nconst nameMap = await getUserNamesByIds(userIds)\n// 取值改为 nameMap.get(id)?.name ?? null\n```\n可获得 cacheFn 缓存收益。",
"effort": "S (≤30 分钟)"
},
{
"id": "G4-014",
"file": "src/modules/messaging/data-access.ts",
"lines": "L815-L825, L843-L850, L1001-L1012, L673-L688, L740-L755, L761-L776, L781-L787",
"ruleId": "P-03",
"severity": "P2",
"dimension": "pattern",
"title": "多个读函数未走 cacheFn Raw+Wrapper 配对",
"description": "以下读函数均直接 export async function 而未提供 Raw + cacheFn Wrapper 配对getMessageReports(L815)、getUserBlocks(L843)、getMessageDraftById(L1001)、hasUserReportedMessage(L673)、isUserBlocked(L740)、isEitherUserBlocked(L761)、getBlockedUserIds(L781)、getMessageAttachments(L864)。违反 P-03「读函数是否走 cacheFn 包装 - 全部覆盖」。",
"recommendation": "为每个读函数补齐 Raw + Wrapper 配对:\n```ts\nexport const getMessageReportsRaw = async (...) => {...}\nexport const getMessageReports = cacheFn(getMessageReportsRaw, { tags: ['messaging'], ttl: 60, keyParts: ['messaging', 'getMessageReports'] })\n```\n注意 hasUserReportedMessage 等布尔回传函数 ttl 可设短30s。",
"effort": "M (≤2h)"
},
{
"id": "G4-015",
"file": "src/modules/users/data-access.ts",
"lines": "L429-L498",
"ruleId": "P-03",
"severity": "P2",
"dimension": "pattern",
"title": "getAdminUsers / getAdminUserRoles 读函数未走 cacheFn",
"description": "getAdminUsers(L429) 与 getAdminUserRoles(L495) 是 admin 后台读函数,均未提供 Raw + Wrapper 配对,直接 export async function。getAdminUsers 内部还有 2 次 SQL用户列表 + count+ 1 次批量查角色,无缓存导致每次后台访问都全量打 DB。",
"recommendation": "补齐 cacheFn 包装:\n```ts\nexport const getAdminUsersRaw = async (params): Promise<AdminUserListResult> => {...}\nexport const getAdminUsers = cacheFn(getAdminUsersRaw, { tags: ['users'], ttl: 60, keyParts: ['users', 'getAdminUsers'] })\n```\ngetAdminUserRoles 同理。",
"effort": "S (≤30 分钟)"
},
{
"id": "G4-016",
"file": "src/modules/rbac/data-access-assignments.ts",
"lines": "L95-L177",
"ruleId": "P-03",
"severity": "P2",
"dimension": "pattern",
"title": "getUserRoleAssignments 读函数未走 cacheFn",
"description": "getUserRoleAssignments 是分页读函数,未提供 Raw + Wrapper 配对。该函数被 rbac/actions.ts 的角色分配页面调用,无缓存导致每次列表访问都触发 2 次 SQL + 1 次批量查角色。",
"recommendation": "拆为 getUserRoleAssignmentsRaw + getUserRoleAssignments = cacheFn(...) 配对ttl 设 60s。",
"effort": "S (≤30 分钟)"
},
{
"id": "G4-017",
"file": "src/modules/audit/data-access.ts",
"lines": "L88, L146, L165, L225, L248, L267, L380, L445, L470",
"ruleId": "A-10",
"severity": "P2",
"dimension": "architecture",
"title": "audit/data-access.ts 9 处 console.error 调试代码",
"description": "Grep 证实 9 处 `console.error(...)`L88 getAuditLogs、L146 getLoginLogs、L165 getAuditModuleOptions、L225 getDataChangeLogs、L248 getDataChangeStats、L267 getDataChangeTableOptions、L380 getAuditOverviewStats、L445 getAuditTrend、L470 getDataChangeActionStats。规则 A-10 明确禁止 data-access 含 console.log 调试代码。",
"recommendation": "接入统一日志服务shared/lib/logger需先创建。过渡期可改为\n```ts\nimport { logger } from '@/shared/lib/logger'\ncatch (error) { logger.error('getAuditLogs failed', { error }); throw error }\n```\n或直接删除 try-catch 让上层处理data-access 应 throw不应吞错。",
"effort": "S (≤30 分钟)"
},
{
"id": "G4-018",
"file": "src/modules/notifications/data-access.ts",
"lines": "L261, L282",
"ruleId": "A-10",
"severity": "P2",
"dimension": "architecture",
"title": "notifications/data-access.ts 含 console.info / console.error",
"description": "L261 `console.info('[NotificationLog] OK/FAIL ...')`、L282 `console.error('[NotificationLog] Failed to persist log:', dbError)`。代码已标注 TODO V3-P2-8 接入统一日志服务但未实施。违反 A-10。",
"recommendation": "创建 shared/lib/logger 后替换;过渡期可用 trackEvent 写入 audit_logs。console.info 至少应改为可关闭的 debug 级别。",
"effort": "S (≤30 分钟)"
},
{
"id": "G4-019",
"file": "src/modules/auth/actions.ts",
"lines": "L248-L250",
"ruleId": "A-10",
"severity": "P3",
"dimension": "architecture",
"title": "auth/actions.ts 含 console.warn 调试代码",
"description": "L248-250 `console.warn('[register] Invitation code ... was already consumed ...')` 在 actions 层打印邀请码与邮箱到日志,可能泄露用户隐私信息到日志文件。",
"recommendation": "改用 trackEvent 上报埋点(不含 email 明文),或改为 logger.warn 并脱敏 email。删除 console.warn。",
"effort": "XS (≤15 分钟)"
},
{
"id": "G4-020",
"file": "src/modules/audit/data-access.ts",
"lines": "L14-L22, L27-L35",
"ruleId": "S-03",
"severity": "P2",
"dimension": "structure",
"title": "clampPageSize / clampPage 在 audit 与 rbac 重复定义",
"description": "audit/data-access.ts L24-35 定义 DEFAULT_PAGE_SIZE / MAX_PAGE_SIZE / clampPageSize / clampPagerbac/data-access-assignments.ts L11-22 完全相同地重复定义这 4 个常量与函数。违反 S-03 重复 helper 应提取到 shared/lib。",
"recommendation": "提取到 shared/lib/pagination.ts\n```ts\nexport const DEFAULT_PAGE_SIZE = 20\nexport const MAX_PAGE_SIZE = 100\nexport function clampPageSize(size?: number): number {...}\nexport function clampPage(page?: number): number {...}\nexport function computeOffset(page: number, pageSize: number): number { return (page - 1) * pageSize }\n```\n两处 import 替换。",
"effort": "S (≤30 分钟)"
},
{
"id": "G4-021",
"file": "src/modules/notifications/data-access.ts",
"lines": "L37, L67 (messaging), L37 (notifications)",
"ruleId": "S-03",
"severity": "P2",
"dimension": "structure",
"title": "toIso / toIsoRequired 在多个模块重复定义",
"description": "notifications/data-access.ts L37 `const toIsoRequired = (d: Date): string => d.toISOString()`messaging/data-access.ts L67-69 `toIso` + `toIsoRequired`audit/data-access.ts L22 `toIso`parent/data-access.ts L63 直接调用 `r.createdAt.toISOString()`。多处重复实现日期序列化 helper违反 S-03 与 P-07日期序列化走 helper。",
"recommendation": "在 shared/lib/datetime.ts 统一导出:\n```ts\nexport const toIso = (d: Date | null | undefined): string | null => d ? d.toISOString() : null\nexport const toIsoRequired = (d: Date): string => d.toISOString()\nexport const toIsoDateString = (d: Date): string => d.toISOString().slice(0, 10)\n```\n各模块 import 替换本地实现。",
"effort": "S (≤30 分钟)"
},
{
"id": "G4-022",
"file": "src/modules/messaging/data-access.ts",
"lines": "L960-L993",
"ruleId": "A-02",
"severity": "P2",
"dimension": "architecture",
"title": "updateMessageDraft 在 data-access 实现乐观锁版本冲突业务逻辑",
"description": "updateMessageDraft 内部实现乐观锁:查询 existing.version → 比对 expectedVersion → 返回 \"ok\"/\"not_found\"/\"conflict\" 三态。这是业务状态机,应在 actions 层处理。data-access 应只暴露 getVersion + update 两个原语。",
"recommendation": "拆分data-access 提供 getMessageDraftVersion(id, userId) + updateMessageDraftRaw(id, userId, data, expectedVersion)(用 WHERE version = expectedVersion 实现原子检查actions 层根据 affectedRows 判定冲突。",
"effort": "M (≤2h)"
},
{
"id": "G4-023",
"file": "src/modules/messaging/data-access.ts",
"lines": "L348-L362, L400-L426",
"ruleId": "A-02",
"severity": "P2",
"dimension": "architecture",
"title": "toggleMessageStar / bulkToggleMessagesStar 在 data-access 层做状态分支",
"description": "toggleMessageStar 先 SELECT 当前 isStarred再 UPDATE 为相反值bulkToggleMessagesStar 更复杂——SELECT 后按 toStar/toUnstar 分组分别 UPDATE。这是条件分支业务逻辑应在 actions 层完成。",
"recommendation": "data-access 暴露 setMessageStarred(ids, userId, starred: boolean) 原语actions 层先查询当前状态、决定目标值、调用原语。或更优:用 SQL `SET isStarred = NOT isStarred WHERE id IN (...)` 单语句完成翻转。",
"effort": "M (≤2h)"
},
{
"id": "G4-024",
"file": "src/modules/parent/data-access.ts",
"lines": "L214-L237, L245-L268",
"ruleId": "A-02",
"severity": "P2",
"dimension": "architecture",
"title": "parent/data-access 含 dashboard 编排逻辑",
"description": "getChildDashboardDataRaw(L214) 内部 Promise.all 调用 6 个跨模块 data-access 函数getStudentClasses、getStudentSchedule、getStudentHomeworkAssignments、getStudentDashboardGrades、getStudentGradeSummary、getStudentExamResults是典型的 dashboard 编排。getParentDashboardDataRaw(L245) 同理。data-access 层应只负责本模块表查询,跨模块编排应在 actions 或 services 层。",
"recommendation": "新建 src/modules/parent/services/parent-dashboard-service.ts 容纳编排逻辑data-access 只保留 getChildren / verifyParentChildRelation / getParentIdsByStudentIds 等本模块表查询。",
"effort": "M (≤2h)"
},
{
"id": "G4-025",
"file": "src/modules/parent/data-access.ts",
"lines": "L260-L262",
"ruleId": "F-01",
"severity": "P2",
"dimension": "performance",
"title": "getParentDashboardData 并行 N 次 getChildDashboardData每次内部 6 次跨模块查询",
"description": "L260-262 `Promise.all(relations.map((r) => getChildDashboardData(r.studentId, r.relation)))`。若家长有 N 个孩子,触发 N × 6 = 6N 次跨模块 data-access 调用,每调用可能再触发 DB 查询。多子女家长场景下性能差。",
"recommendation": "重构为批量查询getStudentClasses(studentIds[]) / getStudentSchedule(studentIds[]) 等批量接口,一次拉取所有孩子数据,再在内存按 studentId 分组组装。需 classes/homework/grades 模块提供批量查询函数。",
"effort": "L (≤1d)"
},
{
"id": "G4-026",
"file": "src/modules/audit/data-access.ts",
"lines": "L281-L294, L299-L312, L317-L330",
"ruleId": "F-01",
"severity": "P2",
"dimension": "performance",
"title": "三个 ForExport 函数用 while 循环分页拉取全表N 次往返",
"description": "getAuditLogsForExport / getLoginLogsForExport / getDataChangeLogsForExport 均 `while (hasMore) { result = await getXxxLogs({page, pageSize: 100}); ... }`。导出大表时每 100 条一次 DB 往返10 万条审计日志 = 1000 次查询。且每次都走 cacheFn 包装层,无意义缓存。",
"recommendation": "新增不带分页的导出专用查询(流式或单次大查询):\n```ts\nexport async function getAuditLogsForExportRaw(params): Promise<AuditLog[]> {\n return db.select().from(auditLogs).where(where).orderBy(desc(auditLogs.createdAt)).limit(100000)\n}\n```\n或用 cursor-based 流式导出。导出函数不应走 cacheFn数据量大、不复用。",
"effort": "M (≤2h)"
},
{
"id": "G4-027",
"file": "src/modules/notifications/data-access.ts",
"lines": "L290-L294",
"ruleId": "F-01",
"severity": "P2",
"dimension": "performance",
"title": "logNotificationSendBatch 用 Promise.all 串行 N 次 INSERT",
"description": "L294 `Promise.all(results.map((result) => logNotificationSend(result, payload)))`。每个 logNotificationSend 内部 L268-278 一次 db.insert(notificationLogs)。N 条日志 = N 次 INSERT 往返,应批量插入。注意 Promise.all 是并发但 DB 连接池有限,仍 N 次查询。",
"recommendation": "改批量 INSERT\n```ts\nexport async function logNotificationSendBatch(results, payload) {\n const rows = results.map(r => ({ id: createId(), userId: payload.userId, title: payload.title, channel: r.channel, status: r.success ? 'success' : 'failure', messageId: r.messageId ?? null, error: r.error ?? null, sentAt: r.sentAt }))\n await db.insert(notificationLogs).values(rows)\n}\n```\n一次 INSERT 完成所有日志写入。",
"effort": "S (≤30 分钟)"
},
{
"id": "G4-028",
"file": "src/modules/messaging/data-access.ts",
"lines": "L176, L197-198, L226, L237, L502, L820, L844, L913, L1002, L1041",
"ruleId": "F-03",
"severity": "P2",
"dimension": "performance",
"title": "messaging/data-access 多处 db.select().from(table) 未显式枚举列",
"description": "多处使用 `db.select().from(messages)` / `db.select().from(users)` / `db.select().from(messageDrafts)` / `db.select().from(messageTemplates)` / `db.select().from(messageReports)` / `db.select().from(userBlocks)` 全列查询。其中 L502 `db.select({ id, name, email }).from(users)` 是好的反例。messages 表含 content 长文本字段,列表查询全列拉取浪费带宽。",
"recommendation": "列表查询显式枚举所需列:\n```ts\ndb.select({ id: messages.id, senderId: messages.senderId, receiverId: messages.receiverId, subject: messages.subject, content: messages.content, isRead: messages.isRead, isStarred: messages.isStarred, recalledAt: messages.recalledAt, readAt: messages.readAt, parentMessageId: messages.parentMessageId, groupMessageId: messages.groupMessageId, createdAt: messages.createdAt }).from(messages)\n```\n列表场景若不需 content可省略该列。",
"effort": "M (≤2h)"
},
{
"id": "G4-029",
"file": "src/modules/audit/data-access.ts",
"lines": "L56, L117, L194",
"ruleId": "F-03",
"severity": "P2",
"dimension": "performance",
"title": "audit/data-access 三个分页查询用 db.select() 全列",
"description": "getAuditLogsRaw L56、getLoginLogsRaw L117、getDataChangeLogsRaw L194 均 `db.select().from(auditLogs/loginLogs/dataChangeLogs)`。audit_logs 表含 detail (JSON)、userAgent (长字符串) 等大字段分页列表全列拉取浪费。dataChangeLogs.oldValue/newValue 是大 JSON。",
"recommendation": "列表查询显式枚举列详情字段detail / oldValue / newValue / userAgent按需在详情页查询时拉取。",
"effort": "S (≤30 分钟)"
},
{
"id": "G4-030",
"file": "src/modules/rbac/data-access.ts",
"lines": "L39",
"ruleId": "F-03",
"severity": "P3",
"dimension": "performance",
"title": "getRolesRaw 用 db.select().from(roles) 全列查询",
"description": "L39 `db.select().from(roles).orderBy(roles.name)` 查询所有角色。roles 表通常很小(< 20 行),影响有限,但仍应显式枚举列以避免 schema 变更后意外暴露字段。",
"recommendation": "改为 `db.select({ id, name, description, isSystem, isEnabled, createdAt, updatedAt }).from(roles)`。",
"effort": "XS (≤15 分钟)"
},
{
"id": "G4-031",
"file": "src/modules/users/data-access.ts",
"lines": "L449-L457",
"ruleId": "F-03",
"severity": "P2",
"dimension": "performance",
"title": "getAdminUsers 用 db.select() 全列查询 users 表",
"description": "L451-452 `db.select().from(users).where(where).orderBy(...)`。users 表含 password (bcrypt hash)、image、address 等敏感或大字段,全列拉取既浪费又可能泄露 password hash 到内存对象。",
"recommendation": "显式枚举所需列:`db.select({ id, name, email, phone, createdAt }).from(users)`。绝不能 select password 列。",
"effort": "XS (≤15 分钟)"
},
{
"id": "G4-032",
"file": "src/modules/users/data-access.ts",
"lines": "L441-L444",
"ruleId": "F-02",
"severity": "P2",
"dimension": "performance",
"title": "getAdminUsers 对 name/email 使用 ilike '%search%' 全表扫描",
"description": "L443 `or(ilike(users.name, search), ilike(users.email, search))`search = `%${params.search}%`。前导通配符使索引失效users 表增长后搜索退化。",
"recommendation": "(1) 短期email 改前缀匹配 `search%`(用户邮箱通常前缀输入);(2) 长期:加 FULLTEXT 索引或用 Elasticsearch。",
"effort": "M (≤2h)"
},
{
"id": "G4-033",
"file": "src/modules/rbac/data-access-assignments.ts",
"lines": "L107-L108",
"ruleId": "F-02",
"severity": "P2",
"dimension": "performance",
"title": "getUserRoleAssignments 对 name/email 使用 ilike '%term%' 全表扫描",
"description": "L108 `or(ilike(users.name, term), ilike(users.email, term))`term = `%${params.search}%`。同 G4-032 问题。",
"recommendation": "同 G4-032email 前缀匹配,或加 FULLTEXT 索引。",
"effort": "M (≤2h)"
},
{
"id": "G4-034",
"file": "src/modules/audit/data-access.ts",
"lines": "L47",
"ruleId": "F-02",
"severity": "P3",
"dimension": "performance",
"title": "getAuditLogsRaw 对 action 字段使用 like '%action%' 模糊匹配",
"description": "L47 `like(auditLogs.action, \\`%${params.action}%\\`)`。action 字段通常是固定枚举值(如 'user.login'),用 `%xxx%` 匹配既慢又可能误匹配('user.login' 会匹配 'admin.user.login')。应改 eq 精确匹配。",
"recommendation": "改为 `eq(auditLogs.action, params.action)`;若需多值匹配,用 inArray([actions])。",
"effort": "XS (≤15 分钟)"
},
{
"id": "G4-035",
"file": "src/modules/messaging/data-access.ts",
"lines": "L502",
"ruleId": "F-05",
"severity": "P2",
"dimension": "performance",
"title": "resolveAdminRecipients 全量查询 users 表无 LIMIT",
"description": "L502 `db.select({ id, name, email }).from(users)` 无 limit。admin 角色收件人解析时全量拉取所有用户,超大学校(万级用户)会 OOM。注释虽在 users/data-access.ts getAllUserIds 提到 P3-7 加 LIMIT 1000但此处未应用。",
"recommendation": "加分页或 LIMIT\n```ts\ndb.select({ id, name, email }).from(users).limit(1000)\n```\n或改用 getTeachersByIds / 按角色筛选避免全量。",
"effort": "XS (≤15 分钟)"
},
{
"id": "G4-036",
"file": "src/modules/messaging/data-access.ts",
"lines": "L520-L532",
"ruleId": "F-08",
"severity": "P2",
"dimension": "performance",
"title": "resolveGradeManagedRecipients 循环 N 次调用 getClassesByGradeId",
"description": "L525 `await Promise.all(scope.gradeIds.map((g) => getClassesByGradeId(g)))`。年级主任管理的年级通常 1-3 个,但模式上仍是 N 次跨模块调用。每个 getClassesByGradeId 内部一次 DB 查询N 个年级 = N 次查询。classes 模块缺少 getClassesByGradeIds(批量) 接口。",
"recommendation": "在 classes/data-access 新增 `getClassesByGradeIds(gradeIds: string[])` 批量查询接口,本处改为单次调用。",
"effort": "M (≤2h)"
},
{
"id": "G4-037",
"file": "src/modules/messaging/data-access.ts",
"lines": "L549",
"ruleId": "F-08",
"severity": "P2",
"dimension": "performance",
"title": "resolveChildrenRecipients 循环 N 次调用 getStudentActiveClassId",
"description": "L549 `await Promise.all(scope.childrenIds.map((id) => getStudentActiveClassId(id)))`。家长有 N 个孩子则 N 次调用。多子女家长场景下性能差。",
"recommendation": "在 classes/data-access 新增 `getStudentActiveClassIds(studentIds: string[])` 批量查询接口。",
"effort": "M (≤2h)"
},
{
"id": "G4-038",
"file": "src/modules/audit/data-access.ts",
"lines": "L370",
"ruleId": "F-10",
"severity": "P3",
"dimension": "performance",
"title": "getAuditOverviewStatsRaw 含 count() 全表统计",
"description": "L370 `db.select({ value: count() }).from(auditLogs)` 无 WHERE 过滤统计审计日志总数。audit_logs 表会持续增长(保留期 180 天),全表 count 在大表上慢MyISAM 快但 InnoDB 慢)。",
"recommendation": "(1) 用元数据表缓存总数,定时刷新;(2) 或用 `SELECT table_rows FROM information_schema.tables WHERE table_name='audit_logs'`(近似值);(3) 或限定统计近 30 天。",
"effort": "S (≤30 分钟)"
},
{
"id": "G4-039",
"file": "src/modules/notifications/data-access.ts",
"lines": "L98",
"ruleId": "F-03",
"severity": "P3",
"dimension": "performance",
"title": "getNotificationsRaw 用 db.select() 全列查询",
"description": "L98 `db.select().from(messageNotifications).where(where).orderBy(...)`。messageNotifications.content 可能为长文本,列表查询全列拉取浪费。",
"recommendation": "显式枚举列content 字段按需拉取。",
"effort": "XS (≤15 分钟)"
},
{
"id": "G4-040",
"file": "src/modules/users/data-access.ts",
"lines": "L196",
"ruleId": "P-07",
"severity": "P3",
"dimension": "pattern",
"title": "getUsersDashboardStatsRaw 直接调用 toISOString 而非 helper",
"description": "L196 `createdAt: u.createdAt.toISOString()` 直接调用,未使用项目统一日期序列化 helperserializeDate / toISODateString。其他模块messaging/notifications/audit均使用 toIso/toIsoRequired helper。",
"recommendation": "import { toIsoRequired } from '@/shared/lib/datetime'(需先创建,见 G4-021替换为 `createdAt: toIsoRequired(u.createdAt)`。",
"effort": "XS (≤15 分钟)"
},
{
"id": "G4-041",
"file": "src/modules/parent/data-access.ts",
"lines": "L63",
"ruleId": "P-07",
"severity": "P3",
"dimension": "pattern",
"title": "getChildrenRaw 直接调用 toISOString 而非 helper",
"description": "L63 `createdAt: r.createdAt.toISOString()` 直接调用,与 G4-040 同类问题。",
"recommendation": "同 G4-021 / G4-040使用统一 helper。",
"effort": "XS (≤15 分钟)"
},
{
"id": "G4-042",
"file": "src/modules/rbac/data-access.ts",
"lines": "L28-L31",
"ruleId": "P-07",
"severity": "P3",
"dimension": "pattern",
"title": "toRoleRecord 返回 Date 对象而非 ISO 字符串",
"description": "L28-31 `createdAt: row.createdAt, updatedAt: row.updatedAt` 直接返回 Date 对象。其他模块notifications/audit/messaging均返回 ISO 字符串。RoleRecord 类型定义可能是 Date但跨层传递 Date 在 Server Action 序列化时会丢失时区信息,应统一为 ISO 字符串。",
"recommendation": "改为 `createdAt: toIsoRequired(row.createdAt), updatedAt: toIsoRequired(row.updatedAt)`;同步更新 RoleRecord 类型为 string。",
"effort": "S (≤30 分钟)"
},
{
"id": "G4-043",
"file": "src/modules/rbac/data-access-assignments.ts",
"lines": "L160",
"ruleId": "P-07",
"severity": "P3",
"dimension": "pattern",
"title": "getUserRoleAssignments 返回 Date 对象而非 ISO 字符串",
"description": "L160 `createdAt: u.createdAt` 直接返回 Date。同 G4-042 问题。",
"recommendation": "改为 `createdAt: toIsoRequired(u.createdAt)`;同步更新 UserRoleAssignment 类型。",
"effort": "XS (≤15 分钟)"
},
{
"id": "G4-044",
"file": "src/modules/users/data-access.ts",
"lines": "L510-L538",
"ruleId": "A-02",
"severity": "P2",
"dimension": "architecture",
"title": "deleteUserById 在 data-access 层嵌入 last-admin 保护业务逻辑",
"description": "deleteUserById L510-538 实现「最后管理员保护」:查询 admin 角色 → count admin 数量 → 若 ≤1 再检查目标是否 admin → 抛错。这是业务安全规则,应在 actions 层校验data-access 只做 delete 原语。",
"recommendation": "actions.ts deleteUserAction 在调用 deleteUserById 前先调用 isLastAdmin(userId) 校验:\n```ts\n// data-access 暴露 isLastAdmin\nexport async function isLastAdmin(userId: string): Promise<boolean> {...}\n// actions.ts\nif (await isLastAdmin(userId)) return { success: false, message: 'Cannot delete last admin' }\nawait deleteUserById(userId)\n```\ndata-access.deleteUserById 只保留 `db.delete(users).where(eq(users.id, userId))`。",
"effort": "S (≤30 分钟)"
},
{
"id": "G4-045",
"file": "src/modules/rbac/data-access.ts",
"lines": "L146-L201",
"ruleId": "A-02",
"severity": "P2",
"dimension": "architecture",
"title": "rbac/data-access 多处嵌入 admin 角色保护业务逻辑",
"description": "updateRole(L151-153 admin name 锁)、deleteRole(L177-179 system role 锁)、setRoleEnabled(L192-194 admin disable 锁)、setRolePermissions(L233-235 admin perm 锁) 均在 data-access 层嵌入角色保护业务规则。这些是 RBAC 安全策略,应在 actions 层统一校验。",
"recommendation": "data-access 层提供纯 CRUD 原语actions.ts 在调用前校验 isAdminRole / isSystemRole 并抛错。可复用 rbac/actions.ts 已有的 isAdminRole 函数。",
"effort": "M (≤2h)"
},
{
"id": "G4-046",
"file": "src/modules/rbac/data-access-assignments.ts",
"lines": "L52-L89",
"ruleId": "A-02",
"severity": "P2",
"dimension": "architecture",
"title": "assignRolesToUser 在 data-access 层做角色存在性/disabled 校验",
"description": "assignRolesToUser L57-58 校验 user 存在、L71-75 校验 role 名全部存在、L78 过滤 disabled 角色。这些是业务校验,应在 actions 层完成。data-access 应只做 transactional insert/delete。",
"recommendation": "actions.ts assignUserRolesAction 在调用前用 Zod + 业务校验:检查 user 存在、role 名有效、无 disabled。data-access 只暴露 replaceUserRoles(userId, roleIds) 原语。",
"effort": "M (≤2h)"
},
{
"id": "G4-047",
"file": "src/modules/rbac/data-access-assignments.ts",
"lines": "L164-L166",
"ruleId": "A-02",
"severity": "P2",
"dimension": "architecture",
"title": "getUserRoleAssignments 在 data-access 层 post-fetch 过滤角色",
"description": "L164-166 `const filtered = params?.role ? items.filter((i) => i.roleNames.includes(params.role ?? '')) : items`。这是在内存中做角色过滤,但 total 仍是未过滤前的总数L168导致分页 totalPages 错误。这是业务逻辑 + bug。",
"recommendation": "把 role 过滤下推到 SQL用 EXISTS 子查询或 JOIN usersToRoles。或至少在 SQL 层用 `inArray(users.id, (db.select({userId}).from(usersToRoles).innerJoin(roles...).where(eq(roles.name, role))))` 子查询过滤。同时修正 total 计算。",
"effort": "M (≤2h)"
},
{
"id": "G4-048",
"file": "src/modules/messaging/actions.ts",
"lines": "L1-L972",
"ruleId": "S-01",
"severity": "P2",
"dimension": "structure",
"title": "messaging/actions.ts 972 行,接近上限",
"description": "文件 972 行,已超过 React 组件 / actions 建议 800 行上限(虽然 actions 硬上限是 1000。文件包含 25+ 个 Server Action覆盖消息 CRUD、群发、撤回、批量、草稿、模板、举报、屏蔽、附件 9 类功能。",
"recommendation": "按功能拆分为 actions-messages.ts / actions-group.ts / actions-drafts.ts / actions-templates.ts / actions-reports.ts / actions-blocks.ts / actions-attachments.ts。原 actions.ts 作 barrel re-export。",
"effort": "M (≤2h)"
},
{
"id": "G4-049",
"file": "src/modules/users/data-access.ts",
"lines": "L26, L429, L495",
"ruleId": "S-06",
"severity": "P3",
"dimension": "structure",
"title": "getUserProfileRaw / getAdminUsers / getAdminUserRoles 缺 JSDoc",
"description": "getUserProfileRaw(L26) 无 JSDocgetAdminUsers(L429) 无 JSDocgetAdminUserRoles(L495) 无 JSDoc。其他函数updateUserProfileById、updateUserAvatar、deleteUserById均有 JSDoc。规则 S-06 要求公共导出函数补齐 JSDoc。",
"recommendation": "为这 3 个函数补 JSDoc说明用途、参数、返回值、副作用。",
"effort": "XS (≤15 分钟)"
},
{
"id": "G4-050",
"file": "src/modules/messaging/data-access.ts",
"lines": "L325, L348, L369, L384, L400",
"ruleId": "S-06",
"severity": "P3",
"dimension": "structure",
"title": "messaging/data-access 多个公共函数仅有单行注释缺 JSDoc",
"description": "markMessageAsRead(L325)、toggleMessageStar(L348)、bulkMarkMessagesAsRead(L369)、bulkDeleteMessages(L384)、bulkToggleMessagesStar(L400) 等函数仅有 `/** P2-1: ... */` 单行注释,缺标准 JSDoc@param / @returns / @throws。",
"recommendation": "补全 JSDoc至少说明参数语义、返回值含义、异常情况。",
"effort": "S (≤30 分钟)"
},
{
"id": "G4-051",
"file": "src/modules/notifications/data-access.ts",
"lines": "L156, L163, L170, L177",
"ruleId": "S-06",
"severity": "P3",
"dimension": "structure",
"title": "notifications/data-access 部分 CRUD 函数缺 JSDoc",
"description": "markNotificationAsRead(L156)、markAllNotificationsAsRead(L163)、archiveNotification(L170)、unarchiveNotification(L177) 4 个公共函数均无 JSDoc。createNotification / createNotifications / getUserContactInfoRaw 等有 JSDoc。",
"recommendation": "为这 4 个函数补 JSDoc。",
"effort": "XS (≤15 分钟)"
},
{
"id": "G4-052",
"file": "src/modules/parent/data-access.ts",
"lines": "L42, L97",
"ruleId": "S-06",
"severity": "P3",
"dimension": "structure",
"title": "parent/data-access getChildrenRaw / getChildBasicInfoRaw 缺 JSDoc",
"description": "getChildrenRaw(L42) 与 getChildBasicInfoRaw(L97) 是模块核心读函数,均无 JSDoc。其他函数verifyParentChildRelationRaw、getParentIdsByStudentIdsRaw有 JSDoc。",
"recommendation": "补全 JSDoc说明入参与返回结构。",
"effort": "XS (≤15 分钟)"
},
{
"id": "G4-053",
"file": "src/modules/audit/data-access.ts",
"lines": "L272-L279",
"ruleId": "S-06",
"severity": "P3",
"dimension": "structure",
"title": "getDataChangeTableOptions JSDoc 错位",
"description": "L272-274 的 JSDoc 注释 `Export-ready: fetch all audit logs matching params` 是给 getAuditLogsForExport 用的,但实际位于 getDataChangeTableOptionsRaw 上方,且内容描述与函数名不符(注释说 audit logs函数查 dataChangeLogs.tableName 选项)。",
"recommendation": "修正 JSDocgetDataChangeTableOptionsRaw 的注释应为「获取数据变更日志中所有出现过的表名选项」getAuditLogsForExport 的注释应放在 L281 上方。",
"effort": "XS (≤15 分钟)"
},
{
"id": "G4-054",
"file": "src/modules/rbac/data-access.ts",
"lines": "L22-L32",
"ruleId": "S-06",
"severity": "P3",
"dimension": "structure",
"title": "toRoleRecord 内部 helper 无 JSDoc",
"description": "toRoleRecord(L22) 是 row→record 映射 helper无 JSDoc。虽是内部函数但项目规则建议 helper 也补简短说明。",
"recommendation": "补单行 JSDoc`/** 将 roles 表行映射为 RoleRecord 类型 */`",
"effort": "XS (≤15 分钟)"
},
{
"id": "G4-055",
"file": "src/modules/rbac/actions.ts",
"lines": "L393-L394",
"ruleId": "S-08",
"severity": "P3",
"dimension": "structure",
"title": "isAdminRole 作为 Server Action 但不返回 ActionState",
"description": "L393-394 `export async function isAdminRole(roleName: string): Promise<boolean>` 直接返回 boolean未包装 ActionState。其他所有 Action 均返回 ActionState<T>。不一致,且 client 调用方无法区分「权限拒绝」与「不是 admin」。",
"recommendation": "改为标准 ActionState\n```ts\nexport async function isAdminRoleAction(roleName: string): Promise<ActionState<boolean>> {\n try { await requirePermission(Permissions.ROLE_READ); return { success: true, data: roleName === ADMIN_ROLE_NAME } }\n catch (e) { return { success: false, message: '...' } }\n}\n```\n或下沉为纯 data-access 函数(非 Action。",
"effort": "XS (≤15 分钟)"
},
{
"id": "G4-056",
"file": "src/modules/auth/data-access.ts",
"lines": "L25-L33",
"ruleId": "P-03",
"severity": "P3",
"dimension": "pattern",
"title": "isEmailAvailable 读函数未走 cacheFn Raw+Wrapper 配对",
"description": "isEmailAvailable(L25) 是读函数,直接 export async function未提供 Raw + Wrapper 配对。虽是注册前可用性检查(缓存可能引入脏读),但模式不一致。其他模块读函数均配对。",
"recommendation": "若担心缓存影响可用性判断,可设 ttl: 5s 短缓存,至少模式一致。或显式标注 `// 不缓存:注册可用性检查需实时` 并在 lint 规则中加豁免。",
"effort": "XS (≤15 分钟)"
},
{
"id": "G4-057",
"file": "src/modules/notifications/data-access.ts",
"lines": "L252-L285",
"ruleId": "A-02",
"severity": "P3",
"dimension": "architecture",
"title": "logNotificationSend 含 try-catch + console 降级,偏业务编排",
"description": "logNotificationSend(L252-285) 内部 try-catch DB 写入失败时降级为 console.error是日志写入的容错策略属业务编排而非纯数据访问。data-access 应只做 DB 写入,失败应 throw 由上层决定降级。",
"recommendation": "data-access 层只做 `db.insert(notificationLogs).values(...)`,失败 throw上层channels/dispatchercatch 后决定是否降级。console 部分按 G4-018 处理。",
"effort": "S (≤30 分钟)"
},
{
"id": "G4-058",
"file": "src/modules/messaging/data-access.ts",
"lines": "L617-L630",
"ruleId": "S-05",
"severity": "P2",
"dimension": "structure",
"title": "getMessageDetailPageData 疑似 dead code",
"description": "文件头注释 L18-20 明确说「getMessagesPageData 已迁出至 messages/page.tsx 页面层保持模块独立性」。getMessageDetailPageData 是同类编排函数,仍保留在 data-access。需确认是否被调用若无调用方则为 dead code。",
"recommendation": "Grep 调用方:`getMessageDetailPageData`。若无 app/ 或 actions 调用,删除;若有调用,按 G4-008 迁移至页面层。",
"effort": "XS (≤15 分钟)"
},
{
"id": "G4-059",
"file": "src/modules/messaging/data-access.ts",
"lines": "L67-L69",
"ruleId": "P-08",
"severity": "P3",
"dimension": "pattern",
"title": "messaging/data-access 用本地 toIso/toIsoRequired 而非 mapListItem 模式",
"description": "L67-69 定义 toIso/toIsoRequired 本地 helper每个 map* 函数mapMessage、mapDraft、mapTemplate、mapUserBlock、mapMessageReport内联调用。规则 P-08 建议列表项映射走 mapListItem 模式统一。当前 5 个 mapper 各自实现,虽结构相似但无统一抽象。",
"recommendation": "提取 shared/lib/map-helpers.ts 通用 `mapListItem<T>(row, mapper)` 工具,或至少在模块内统一 mapper 签名风格。优先级低,当前实现可读性尚可。",
"effort": "M (≤2h)"
},
{
"id": "G4-060",
"file": "src/modules/users/data-access.ts",
"lines": "L476-L484",
"ruleId": "P-08",
"severity": "P3",
"dimension": "pattern",
"title": "getAdminUsers 列表项 inline map 而非 mapListItem 模式",
"description": "L476-483 `items: userRows.map((u) => ({ id, name, email, roles, phone, createdAt }))` 内联 map。其他模块messaging/notifications均有专用 mapper 函数mapMessage/mapNotification。",
"recommendation": "提取 `mapAdminUserListItem(row, rolesByUserId)` mapper 函数,与 mapMessage 等保持一致风格。",
"effort": "XS (≤15 分钟)"
},
{
"id": "G4-061",
"file": "src/modules/audit/data-access.ts",
"lines": "L67-L81, L128-L139, L206-L218",
"ruleId": "P-08",
"severity": "P3",
"dimension": "pattern",
"title": "audit/data-access 三个分页查询 inline map 列表项",
"description": "getAuditLogsRaw L67-81、getLoginLogsRaw L128-139、getDataChangeLogsRaw L206-218 均用 `rows.map((r) => ({...}))` 内联映射,无独立 mapper 函数。与 messaging/notifications 模式不一致。",
"recommendation": "提取 mapAuditLog / mapLoginLog / mapDataChangeLog mapper 函数,便于复用与单测。",
"effort": "S (≤30 分钟)"
}
]

View File

@@ -0,0 +1,494 @@
[
{
"id": "G5-001",
"file": "src/modules/onboarding/data-access.ts",
"lines": "L1-L5",
"ruleId": "P-01",
"severity": "P0",
"dimension": "pattern",
"title": "data-access 文件缺少 `import \"server-only\"` 文件头",
"description": "文件首行直接为 `import { eq, and } from \"drizzle-orm\"`,未声明 `import \"server-only\"`,导致此 data-access 模块可能被客户端代码意外引入,存在将 DB schema 与查询逻辑泄露到客户端 bundle 的安全风险。同模块其他 data-access如 elective/data-access.ts L1、settings/data-access.ts L1均规范声明了 `import \"server-only\"`。",
"recommendation": "在文件第一行添加 `import \"server-only\"`,与项目其他 data-access 文件保持一致:\n```ts\nimport \"server-only\"\n\nimport { eq, and } from \"drizzle-orm\"\n// ...\n```",
"effort": "XS (≤15 分钟)"
},
{
"id": "G5-002",
"file": "src/modules/onboarding/actions.ts",
"lines": "L15-L16, L72-L76",
"ruleId": "A-09",
"severity": "P0",
"dimension": "architecture",
"title": "actions.ts 含直接 DB 查询,违反三层架构",
"description": "actions.ts 在 L15-L16 直接 `import { db } from \"@/shared/db\"` 与 `import { users } from \"@/shared/db/schema\"`,并在 completeOnboardingAction 内部 L72-L76 直接执行 `db.select({ onboardedAt: users.onboardedAt }).from(users).where(eq(users.id, userId)).limit(1)`。按架构规则 `app/ → modules/ → shared/` 单向依赖actions 层是编排层,应通过 data-access 访问 DB不得直查 schema 表。直查 DB 还会绕过 cacheFn 缓存层。",
"recommendation": "在 onboarding/data-access.ts 中新增 `getUserOnboardedAt(userId): Promise<Date | null>` 读函数(含 cacheFn 包装actions.ts 改为调用该函数:\n```ts\n// data-access.ts\nexport const getUserOnboardedAtRaw = async (userId: string): Promise<Date | null> => {\n const [row] = await db.select({ onboardedAt: users.onboardedAt })\n .from(users).where(eq(users.id, userId)).limit(1)\n return row?.onboardedAt ?? null\n}\nexport const getUserOnboardedAt = cacheFn(getUserOnboardedAtRaw, {\n tags: [\"onboarding\"], ttl: 300, keyParts: [\"onboarding\", \"onboarded-at\"],\n})\n\n// actions.ts\nimport { getUserOnboardedAt } from \"./data-access\"\nconst existingOnboardedAt = await getUserOnboardedAt(userId)\nif (existingOnboardedAt) { /* ... */ }\n```",
"effort": "S (≤30 分钟)"
},
{
"id": "G5-003",
"file": "src/modules/elective/data-access-operations.ts",
"lines": "L19-L46, L90-L174, L222-L304, L306-L396",
"ruleId": "A-02",
"severity": "P1",
"dimension": "architecture",
"title": "data-access 含大量业务逻辑状态机、冲突检测、抽签算法、i18n 通知)",
"description": "文件混入了大量本应位于 actions 或 lib 的业务逻辑:\n1. L19-L46 自定义 `ElectiveBusinessError` 业务错误类(含 i18n code 映射)\n2. L62-L78 `DAY_NORMALIZE_MAP` 星期归一化映射\n3. L90-L119 `parseSchedule` 时间段解析(含正则)\n4. L125-L131 `isScheduleConflict` 时间冲突判定\n5. L137-L174 `checkScheduleConflict` 业务校验\n6. L185-L220 `checkCreditLimit` 学分上限业务校验\n7. L222-L304 `runLottery` Fisher-Yates 抽签算法\n8. L410-L446 `notifyCapacityThresholdIfNeeded` 调用 `getTranslations` + `sendNotification` 跨模块通知\ndata-access 层应只做 CRUD 与简单映射,业务规则、状态机、跨模块编排应下沉到 lib 或 actions。",
"recommendation": "拆分为三层:\n1. `elective/lib/schedule-conflict.ts` —— 纯函数 `parseSchedule` / `isScheduleConflict` / `normalizeDay`\n2. `elective/lib/lottery.ts` —— `runLotteryShuffle` 纯函数\n3. `elective/lib/business-rules.ts` —— `checkScheduleConflict` / `checkCreditLimit` / `ElectiveBusinessError`(接受 tx 与必要 data-access 函数作为参数)\n4. `elective/actions.ts` —— `notifyCapacityThresholdIfNeeded` 移至 actions含 i18n + sendNotification\ndata-access-operations.ts 只保留 `selectCourse` / `dropCourse` / `runLottery` 的 DB 写入部分。",
"effort": "L (≤1 天)"
},
{
"id": "G5-004",
"file": "src/modules/elective/data-access-operations.ts",
"lines": "L222-L304",
"ruleId": "S-08",
"severity": "P1",
"dimension": "structure",
"title": "runLottery 中 DB 写入与抽签算法混淆,职责不清",
"description": "`runLottery` 函数同时承担1) 查询课程与选课记录DB 访问2) Fisher-Yates shuffle 抽签业务算法3) 事务内批量更新状态DB 写入)。函数 83 行,复杂度高,难以单元测试抽签逻辑(需 mock DB。",
"recommendation": "拆分为:\n```ts\n// lib/lottery.ts\nexport function runLotteryShuffle(selections: CourseSelection[], capacity: number): {\n enrolledIds: string[]; waitlistIds: string[]\n} { /* 纯函数 Fisher-Yates */ }\n\n// data-access-operations.ts\nexport async function persistLotteryResult(\n courseId: string, enrolledIds: string[], waitlistIds: string[], capacity: number\n): Promise<void> { /* 仅 DB 写入 */ }\n\n// actions.ts\nexport async function runLotteryAction(...) {\n const [course, selections] = await Promise.all([...])\n const { enrolledIds, waitlistIds } = runLotteryShuffle(selections, course.capacity)\n await persistLotteryResult(courseId, enrolledIds, waitlistIds, course.capacity)\n}\n```",
"effort": "M (≤2 小时)"
},
{
"id": "G5-005",
"file": "src/modules/files/data-access.ts",
"lines": "L55, L73, L101, L126, L150, L169, L186, L195, L244, L281, L305, L328",
"ruleId": "A-10",
"severity": "P1",
"dimension": "architecture",
"title": "data-access 含 12 处 console.error 调试代码",
"description": "文件中几乎所有函数都用 `console.error(...)` 记录错误L55 `createFileAttachment failed`、L73 `getFileAttachment failed`、L101 `getFileAttachmentsByTarget failed`、L126 `getFileAttachmentsByUploader failed`、L150 `getAllFileAttachments failed`、L169 `deleteFileAttachment failed`、L186/L195 `deleteFileAttachments batch/single failed`、L244 `getFileAttachmentsWithFilters failed`、L281 `getFileStats failed`、L305 `getFileByUrl failed`、L328 `getFileAttachmentsByIds failed`。data-access 层应通过 throw 上抛错误由 actions 层统一处理,不应自行 console 输出,污染生产日志且违反 A-10 规则。",
"recommendation": "删除所有 `console.error`,改为 throw 上抛:\n```ts\nexport async function createFileAttachment(data: CreateFileAttachmentInput): Promise<FileAttachment | null> {\n await db.insert(fileAttachments).values({...})\n return getFileAttachment(data.id)\n}\n// actions 层已有 handleActionError 统一处理\n```",
"effort": "S (≤30 分钟)"
},
{
"id": "G5-006",
"file": "src/modules/files/data-access.ts",
"lines": "L35-L58, L63-L76, L87-L105, L116-L129, L140-L153, L212-L248, L259-L284, L295-L308, L319-L331",
"ruleId": "P-05",
"severity": "P1",
"dimension": "pattern",
"title": "data-access 层用 try-catch 返回 null/false/空数组,违反 throw 上抛约定",
"description": "所有函数均用 `try { ... } catch (error) { console.error(...); return null/[]/false }` 模式吞掉错误。这导致1) actions 层无法区分“记录不存在”与“DB 异常”2) 错误被静默吞掉监控告警失效3) 违反 P-05 规则data-access 层用 throwactions 层用 ActionState。例如 createFileAttachment 失败返回 nullactions 层只能返回“Failed to persist file record”丢失原始错误信息。",
"recommendation": "移除 try-catch让错误自然上抛\n```ts\nexport async function getFileAttachmentRaw(id: string): Promise<FileAttachment | null> {\n const [row] = await db.select().from(fileAttachments)\n .where(eq(fileAttachments.id, id)).limit(1)\n return row ? mapRow(row) : null\n}\n// actions.ts 的 handleActionError 会捕获并转为 ActionState\n```",
"effort": "S (≤30 分钟)"
},
{
"id": "G5-007",
"file": "src/modules/files/data-access.ts",
"lines": "L66, L89, L119, L142, L236, L262, L298, L322",
"ruleId": "F-03",
"severity": "P1",
"dimension": "performance",
"title": "多处 `db.select().from(fileAttachments)` 未指定列,等价 SELECT *",
"description": "8 处查询使用 `db.select().from(fileAttachments)` 未显式枚举列,等价于 `SELECT *`。返回所有列包括 `storagePath`、`url` 等敏感字段,增加网络传输与内存占用,且 schema 变更时可能引入意外字段。对比 announcements/data-access.ts L84-L99 显式枚举了 13 个列。",
"recommendation": "显式枚举所需列:\n```ts\nconst FILE_FIELDS = {\n id: fileAttachments.id,\n filename: fileAttachments.filename,\n originalName: fileAttachments.originalName,\n mimeType: fileAttachments.mimeType,\n size: fileAttachments.size,\n storagePath: fileAttachments.storagePath,\n url: fileAttachments.url,\n uploaderId: fileAttachments.uploaderId,\n targetType: fileAttachments.targetType,\n targetId: fileAttachments.targetId,\n createdAt: fileAttachments.createdAt,\n} as const\n\nexport const getFileAttachmentRaw = async (id: string) => {\n const [row] = await db.select(FILE_FIELDS).from(fileAttachments)\n .where(eq(fileAttachments.id, id)).limit(1)\n return row ? mapRow(row) : null\n}\n```",
"effort": "M (≤2 小时)"
},
{
"id": "G5-008",
"file": "src/modules/search/data-access.ts",
"lines": "L150, L191, L235",
"ruleId": "P-09",
"severity": "P1",
"dimension": "pattern",
"title": "使用 `!` 非空断言绕过 null 检查",
"description": "三处对 `or(...)` 返回值使用 `!` 非空断言L150 `or(like(textbooks.title, kw), like(textbooks.subject, kw), like(textbooks.publisher, kw))!`、L191 `or(like(exams.title, kw), like(exams.description, kw))!`、L235 `or(like(announcements.title, kw), like(announcements.content, kw))!`。`or()` 在所有参数为 undefined 时返回 null使用 `!` 断言会绕过类型系统的安全保护,违反 P-09 规则(禁止 as 断言与非空断言,除 unknown 收窄)。",
"recommendation": "使用条件判断或 filter 模式:\n```ts\nconst conditions = [\n like(textbooks.title, kw),\n like(textbooks.subject, kw),\n like(textbooks.publisher, kw),\n].filter(Boolean) as ReturnType<typeof like>[]\nconst where = conditions.length > 0 ? or(...conditions) : undefined\n```",
"effort": "S (≤30 分钟)"
},
{
"id": "G5-009",
"file": "src/modules/search/data-access.ts",
"lines": "L147-L150, L191, L235",
"ruleId": "F-02",
"severity": "P1",
"dimension": "performance",
"title": "textbooks/exams/announcements 三表搜索使用 `LIKE '%kw%'` 全表扫描",
"description": "searchTextbooksRaw L147-L150、searchExamsRaw L191、searchAnnouncementsRaw L235 均使用 `like(column, kw)` 其中 kw 为 `%${search}%` 格式,前缀通配符导致无法走索引,全表扫描。注释 L18-L20 已说明 questions 表用了 FULLTEXT 索引L101 `MATCH ... AGAINST ... IN BOOLEAN MODE`),但其他三表仍用 LIKE。随着数据量增长搜索性能会急剧下降。",
"recommendation": "为 textbooks.title/subject/publisher、exams.title/description、announcements.title/content 添加 FULLTEXT 索引MySQL或 GIN 索引PostgreSQL改用 MATCH AGAINST\n```sql\nALTER TABLE textbooks ADD FULLTEXT INDEX ft_textbooks_search (title, subject, publisher);\nALTER TABLE exams ADD FULLTEXT INDEX ft_exams_search (title, description);\nALTER TABLE announcements ADD FULLTEXT INDEX ft_announcements_search (title, content);\n```\n```ts\n.where(sql`MATCH(${textbooks.title}, ${textbooks.subject}, ${textbooks.publisher}) AGAINST(${booleanQuery} IN BOOLEAN MODE)`)\n```",
"effort": "L (≤1 天)"
},
{
"id": "G5-010",
"file": "src/modules/onboarding/data-access.ts",
"lines": "L87-L133",
"ruleId": "A-02",
"severity": "P1",
"dimension": "architecture",
"title": "bindParentToChild 含三因子验证业务逻辑,应移至 lib 或 actions",
"description": "`bindParentToChild` 函数 L87-L133 包含1) 三因子验证业务规则(邮箱 + 生日 + 手机后4位L97-L1112) 幂等检查L114-L1313) 错误返回 `{ error: string }` 而非 throw。这是核心业务逻辑对标 PowerSchool Access ID 验证),不应位于 data-access 层。data-access 应只做 CRUD验证规则应下沉到 lib/business-rules 或 actions。",
"recommendation": "拆分:\n1. `onboarding/lib/parent-binding.ts` —— `validateChildFactors(child, params): string | null` 纯函数验证三因子\n2. `onboarding/data-access.ts` —— `insertParentStudentRelation(parentId, studentId, relation): Promise<void>` 仅做幂等插入\n3. `onboarding/actions.ts` —— 编排:查询 child → 调用 validateChildFactors → 调用 insertParentStudentRelation",
"effort": "M (≤2 小时)"
},
{
"id": "G5-011",
"file": "src/modules/settings/data-access.ts",
"lines": "L23-L38",
"ruleId": "F-05",
"severity": "P1",
"dimension": "performance",
"title": "getAiProviderSummariesRaw 查询所有 AI Provider 无 LIMIT",
"description": "`getAiProviderSummariesRaw` 查询 `aiProviders` 表全部记录,仅 `.orderBy(desc(aiProviders.updatedAt))`,无 LIMIT 限制。若管理员未清理历史 Provider 记录,可能返回数百条数据。对比 files/data-access.ts 的 `getAllFileAttachmentsRaw` 有默认 `limit = 100`。",
"recommendation": "添加默认 LIMIT\n```ts\nexport async function getAiProviderSummariesRaw(limit = 200): Promise<AiProviderSummary[]> {\n const rows = await db.select({...}).from(aiProviders)\n .orderBy(desc(aiProviders.updatedAt))\n .limit(limit)\n return rows\n}\n```",
"effort": "XS (≤15 分钟)"
},
{
"id": "G5-012",
"file": "src/modules/settings/data-access-system-settings.ts",
"lines": "L131-L142",
"ruleId": "F-01",
"severity": "P1",
"dimension": "performance",
"title": "upsertSystemSettings 循环内调用 upsertSystemSetting每次含查询+写入N+1 问题",
"description": "`upsertSystemSettings` L140-L142 对每个 item 调用 `upsertSystemSetting`,而 `upsertSystemSetting` 内部 L110-L126 先 `getSystemSetting`(虽走缓存)再 `db.update` 或 `db.insert`。批量保存 N 个设置项时执行 N 次独立写入,无事务包裹,且中间失败会导致部分成功部分失败的数据不一致。",
"recommendation": "改为单次事务批量 upsertMySQL `INSERT ... ON DUPLICATE KEY UPDATE`\n```ts\nexport async function upsertSystemSettings(\n items: ReadonlyArray<{...}>, updatedBy?: string\n): Promise<void> {\n if (items.length === 0) return\n await db.transaction(async (tx) => {\n const rows = items.map((item) => ({\n category: item.category, key: item.key, value: item.value,\n valueType: item.valueType, updatedBy: updatedBy ?? null, updatedAt: new Date(),\n }))\n await tx.insert(systemSettings).values(rows)\n .onDuplicateKeyUpdate({\n set: { value: sql`VALUES(value)`, valueType: sql`VALUES(value_type)`,\n updatedBy: sql`VALUES(updated_by)`, updatedAt: sql`VALUES(updated_at)` }\n })\n })\n}\n```",
"effort": "M (≤2 小时)"
},
{
"id": "G5-013",
"file": "src/modules/elective/data-access.ts",
"lines": "L165-L169",
"ruleId": "F-05",
"severity": "P1",
"dimension": "performance",
"title": "getElectiveCoursesRaw 大表查询无默认 LIMIT",
"description": "`getElectiveCoursesRaw` 查询 `electiveCourses` 表,仅 `.orderBy(desc(electiveCourses.createdAt))`,无 LIMIT。当管理员查询全部课程或 gradeId 过滤返回大量记录时,会一次性返回所有匹配行。对比 announcements/data-access.ts L104 有 `pageSize` 分页。",
"recommendation": "添加默认 LIMIT 或分页参数:\n```ts\nexport const getElectiveCoursesRaw = async (\n params?: GetElectiveCoursesParams & { scope?: DataScope; currentUserId?: string; limit?: number }\n): Promise<ElectiveCourseWithDetails[]> => {\n const limit = params?.limit ?? 200\n // ...\n const rows = await (conditions.length > 0\n ? query.where(and(...conditions)) : query\n ).orderBy(desc(electiveCourses.createdAt)).limit(limit)\n // ...\n}\n```",
"effort": "XS (≤15 分钟)"
},
{
"id": "G5-014",
"file": "src/modules/elective/data-access-operations.ts",
"lines": "L228-L241",
"ruleId": "F-03",
"severity": "P1",
"dimension": "performance",
"title": "runLottery 内 `db.select().from(...)` 未指定列,等价 SELECT *",
"description": "`runLottery` L228-L241 两处 `db.select().from(electiveCourses)` 和 `db.select().from(courseSelections)` 未指定列,等价 SELECT *。electiveCourses 表含 description、schedule 等长文本字段courseSelections 含 dropReason 等字段,全量返回浪费内存与网络带宽。此外 selectCourse L317-L321、dropCourse L454-L464 也使用 `db.select().from(...)` 全列查询。",
"recommendation": "显式枚举所需列:\n```ts\nconst [courseRows, selections] = await Promise.all([\n db.select({\n id: electiveCourses.id, capacity: electiveCourses.capacity,\n selectionMode: electiveCourses.selectionMode, enrolledCount: electiveCourses.enrolledCount,\n }).from(electiveCourses).where(eq(electiveCourses.id, courseId)).limit(1),\n db.select({\n id: courseSelections.id, priority: courseSelections.priority,\n selectedAt: courseSelections.selectedAt,\n }).from(courseSelections).where(...).orderBy(...),\n])\n```",
"effort": "M (≤2 小时)"
},
{
"id": "G5-015",
"file": "src/modules/error-book/data-access.ts",
"lines": "L135-L138",
"ruleId": "F-02",
"severity": "P2",
"dimension": "performance",
"title": "getErrorBookItemsRaw 使用 `LIKE '%q%'` 全表扫描搜索 note 字段",
"description": "L136-L137 构造 `needle = '%${q.trim().toLowerCase()}%'` 并使用 `sql\\`LOWER(CAST(${errorBookItems.note} AS CHAR)) LIKE ${needle}\\``。前缀通配符 `%` 导致无法走索引,且 `LOWER(CAST(... AS CHAR))` 函数包裹进一步阻止索引使用。学生错题量增长后搜索性能下降。",
"recommendation": "为 errorBookItems.note 添加 FULLTEXT 索引,或改为前缀匹配 `LIKE '${q}%'`(可走索引):\n```sql\nALTER TABLE error_book_items ADD FULLTEXT INDEX ft_note_search (note);\n```\n```ts\nif (q && q.trim().length > 0) {\n conditions.push(sql`MATCH(${errorBookItems.note}) AGAINST(${q.trim()} IN BOOLEAN MODE)`)\n}\n```",
"effort": "M (≤2 小时)"
},
{
"id": "G5-016",
"file": "src/modules/error-book/data-access-analytics.ts",
"lines": "L531, L542",
"ruleId": "P-09",
"severity": "P2",
"dimension": "pattern",
"title": "使用 `as string` 断言,应改用类型守卫",
"description": "L531 `const subjectIds = filtered.map((r) => r.subjectId as string)` 和 L542 `const sid = row.subjectId as string` 使用 `as string` 断言。虽然 L527 已通过 `rows.filter((r) => r.subjectId !== null)` 过滤了 null但 `as` 断言绕过了类型系统,违反 P-09 规则。项目规范要求用类型守卫替代 as 断言。",
"recommendation": "使用类型守卫:\n```ts\nconst subjectIds = filtered\n .map((r) => r.subjectId)\n .filter((s): s is string => s !== null)\n// ...\nreturn filtered.map((row) => {\n const sid = row.subjectId as string // 改为:\n const sid: string = row.subjectId ?? \"\" // 或提前 filter\n // ...\n})\n```",
"effort": "XS (≤15 分钟)"
},
{
"id": "G5-017",
"file": "src/modules/settings/data-access.ts",
"lines": "L259-L315",
"ruleId": "P-03",
"severity": "P2",
"dimension": "pattern",
"title": "密码相关读函数未走 cacheFn 包装Raw + Wrapper 配对)",
"description": "`getUserPasswordHash` (L259-L268)、`getPasswordSecurityByUserId` (L270-L279) 是读函数但未提供 Raw + cacheFn Wrapper 配对。对比同文件 `getAiProviderSummariesRaw` + `getAiProviderSummaries` 配对模式。密码相关查询虽可能不希望缓存(安全考虑),但应明确注释说明不缓存的原因,或提供短 TTL 缓存。",
"recommendation": "明确注释不缓存原因,或提供短 TTL 缓存:\n```ts\n/** 读取用户密码哈希(不缓存,安全敏感数据) */\nexport async function getUserPasswordHash(userId: string): Promise<{ password: string | null } | null> {\n // 安全考虑:密码哈希不缓存,避免缓存泄露风险\n const [row] = await db.select({ password: users.password })\n .from(users).where(eq(users.id, userId)).limit(1)\n return row ?? null\n}\n```",
"effort": "XS (≤15 分钟)"
},
{
"id": "G5-018",
"file": "src/modules/settings/data-access-two-factor.ts",
"lines": "L30-L129",
"ruleId": "P-03",
"severity": "P2",
"dimension": "pattern",
"title": "2FA 读函数未提供 Raw + Wrapper 配对,依赖上游 cacheFn",
"description": "`getTwoFactorEnabled` (L30-L33)、`getTwoFactorEnabledAt` (L48-L53)、`getTotpSecret` (L70-L73)、`getBackupCodesHashed` (L101-L105) 等读函数均直接调用 `getSystemSetting`(已 cacheFn 包装),但自身未提供 Raw + Wrapper 配对。严格按 P-03 规则,所有读函数应有 Raw + cacheFn 配对。当前模式虽依赖上游缓存,但 keyParts 不会包含 2FA 特定维度,缓存粒度不准确。",
"recommendation": "为 2FA 读函数提供独立 cacheFn 配对:\n```ts\nexport const getTwoFactorEnabledRaw = async (userId: string): Promise<boolean> => {\n const record = await getSystemSetting(CATEGORY, k(\"twoFactorEnabled\", userId))\n return record?.value === \"true\"\n}\nexport const getTwoFactorEnabled = cacheFn(getTwoFactorEnabledRaw, {\n tags: [\"settings\", \"two-factor\"], ttl: 60,\n keyParts: [\"settings\", \"two-factor\", \"enabled\"],\n})\n```",
"effort": "M (≤2 小时)"
},
{
"id": "G5-019",
"file": "src/modules/elective/data-access.ts",
"lines": "L61-L62",
"ruleId": "P-07",
"severity": "P2",
"dimension": "pattern",
"title": "startDate/endDate 序列化未走 toIso helper硬编码 slice(0, 10)",
"description": "L61-L62 `startDate: r.startDate ? new Date(r.startDate).toISOString().slice(0, 10) : null` 和 `endDate: r.endDate ? new Date(r.endDate).toISOString().slice(0, 10) : null` 硬编码了 `.toISOString().slice(0, 10)` 逻辑。同文件 L22-L25 已定义 `toIso`/`toIsoRequired` helper但此处未复用且 `slice(0, 10)` 截取日期部分的行为应封装为 `toISODateString` helper 统一管理。",
"recommendation": "提取共享 helper 并复用:\n```ts\nconst toISODateString = (d: Date | null | undefined): string | null =>\n d ? d.toISOString().slice(0, 10) : null\n\n// mapCourseRow 中\nstartDate: toISODateString(r.startDate),\nendDate: toISODateString(r.endDate),\n```",
"effort": "XS (≤15 分钟)"
},
{
"id": "G5-020",
"file": "src/modules/announcements/data-access.ts",
"lines": "L413-L463, L472-L485",
"ruleId": "A-02",
"severity": "P2",
"dimension": "architecture",
"title": "resolveUserAudience / isAnnouncementVisibleToAudience 含业务逻辑",
"description": "`resolveUserAudience` (L413-L463) 根据 dataScope.type 分支处理 5 种受众类型,含 classIds → gradeIds 解析、childrenIds 遍历等业务编排逻辑;`isAnnouncementVisibleToAudience` (L472-L485) 是纯业务规则判断函数。两者属于业务规则层,应移至 lib 或 actions。当前 data-access 同时承担数据查询与业务规则判断,职责混淆。",
"recommendation": "将业务逻辑下沉到 `announcements/lib/audience.ts`\n```ts\n// lib/audience.ts\nexport function isAnnouncementVisibleToAudience(announcement, audience): boolean { /* 纯函数 */ }\nexport async function resolveUserAudience(\n userId: string, dataScope: DataScope,\n deps: { getClassGradeId, getStudentActiveClassId, getStudentActiveGradeId }\n): Promise<UserAudience | null> { /* 接受依赖注入 */ }\n```\ndata-access 仅保留 `getAnnouncementByIdForUser` 的数据查询部分。",
"effort": "M (≤2 小时)"
},
{
"id": "G5-021",
"file": "src/modules/announcements/data-access.ts",
"lines": "全文 606 行",
"ruleId": "S-01",
"severity": "P2",
"dimension": "structure",
"title": "文件 606 行,接近 800 行警告线",
"description": "announcements/data-access.ts 共 606 行,已超过项目规范的“建议 ≤ 800 行”软上限的 75%。文件同时包含CRUDinsertAnnouncement 等、分页查询getAnnouncements、已读回执markAnnouncementAsRead 等、编排函数getAdminAnnouncementsPageData 等 5 个、业务逻辑resolveUserAudience 等)。继续增长将突破警告线。",
"recommendation": "拆分为:\n1. `data-access.ts` —— 纯 CRUDinsert/update/delete/publish/archive\n2. `data-access-reads.ts` —— 查询函数getAnnouncements, getAnnouncementById, countAnnouncements, 已读回执查询)\n3. `data-access-page.ts` —— 编排函数getAdminAnnouncementsPageData 等)\n4. `lib/audience.ts` —— resolveUserAudience, isAnnouncementVisibleToAudience",
"effort": "M (≤2 小时)"
},
{
"id": "G5-022",
"file": "src/modules/settings/data-access-system-settings.ts",
"lines": "L65-L68",
"ruleId": "F-03",
"severity": "P2",
"dimension": "performance",
"title": "getAllSystemSettingsRaw 使用 `db.select().from(systemSettings)` 未指定列",
"description": "`getAllSystemSettingsRaw` L66 `const rows = await db.select().from(systemSettings)` 未显式枚举列,等价 SELECT *。systemSettings 表含 id、category、key、value、valueType、updatedBy、updatedAt、createdAt 等列,全量返回增加传输开销。对比 getSystemSettingRaw L83-L87 虽也未枚举但有 limit(1)。",
"recommendation": "显式枚举列:\n```ts\nexport async function getAllSystemSettingsRaw(): Promise<SystemSettingRecord[]> {\n const rows = await db.select({\n id: systemSettings.id, category: systemSettings.category,\n key: systemSettings.key, value: systemSettings.value,\n valueType: systemSettings.valueType, updatedBy: systemSettings.updatedBy,\n updatedAt: systemSettings.updatedAt,\n }).from(systemSettings)\n return rows\n}\n```",
"effort": "XS (≤15 分钟)"
},
{
"id": "G5-023",
"file": "src/modules/elective/data-access-selections.ts",
"lines": "L43-L46",
"ruleId": "S-03",
"severity": "P2",
"dimension": "structure",
"title": "toIso/toIsoRequired helper 在 elective 模块内重复定义",
"description": "L43-L46 定义了 `toIso` 和 `toIsoRequired` helper但 elective/data-access.ts L22-L25 已定义了相同的 helper。两处实现完全一致\n```ts\nconst toIso = (d: Date | null | undefined): string | null => d ? d.toISOString() : null\nconst toIsoRequired = (d: Date): string => d.toISOString()\n```\n违反 DRY 原则,应提取到 shared/lib 或模块内 lib 目录。",
"recommendation": "提取到 `elective/lib/date-utils.ts` 或复用 `@/shared/lib/date-utils`\n```ts\n// elective/lib/date-utils.ts\nexport const toIso = (d: Date | null | undefined): string | null =>\n d ? d.toISOString() : null\nexport const toIsoRequired = (d: Date): string => d.toISOString()\nexport const toISODateString = (d: Date | null | undefined): string | null =>\n d ? d.toISOString().slice(0, 10) : null\n```\n两个 data-access 文件统一 import。",
"effort": "XS (≤15 分钟)"
},
{
"id": "G5-024",
"file": "src/modules/onboarding/data-access.ts",
"lines": "L89, L97-L111",
"ruleId": "P-05",
"severity": "P2",
"dimension": "pattern",
"title": "bindParentToChild 返回 `{ error: string }` 而非 throw违反 data-access 错误处理约定",
"description": "`bindParentToChild` 返回类型为 `Promise<{ studentId: string } | { error: string }>`L97/L98/L99/L104/L110 用 `return { error: \"...\" }` 表示业务错误。按 P-05 规则data-access 层应用 throw 上抛错误(如 `throw new BusinessError(...)`actions 层用 ActionState 捕获。当前模式让调用方需要用 `\"error\" in result` 判断,违反统一错误处理约定。",
"recommendation": "改用 throw + 自定义 BusinessError\n```ts\nexport class OnboardingBusinessError extends BusinessError {\n constructor(public readonly code: string, public readonly params?: Record<string, string | number>) {\n super(`onboarding.errors.${code}`, code)\n }\n}\n\nexport async function bindParentToChild(params: BindParentToChildParams): Promise<{ studentId: string }> {\n // ...\n if (!child) throw new OnboardingBusinessError(\"childNotFound\")\n if (!child.birthDate) throw new OnboardingBusinessError(\"childNoBirthDate\")\n // ...\n return { studentId: child.id }\n}\n```",
"effort": "S (≤30 分钟)"
},
{
"id": "G5-025",
"file": "src/modules/settings/data-access.ts",
"lines": "L259-L315",
"ruleId": "S-06",
"severity": "P2",
"dimension": "structure",
"title": "密码相关函数缺 JSDoc 文档",
"description": "`getUserPasswordHash` (L259)、`getPasswordSecurityByUserId` (L270)、`updateUserPassword` (L281)、`upsertPasswordSecurityOnPasswordChange` (L292) 四个公共导出函数均无 JSDoc 注释。对比同文件 `getAiProviderSummariesRaw` (L18-L22)、`getAiProviderForUpdateRaw` (L102-L106) 均有 JSDoc。S-06 规则要求公共导出函数补齐 JSDoc。",
"recommendation": "为每个函数添加 JSDoc\n```ts\n/**\n * 读取用户密码哈希(用于密码变更时校验旧密码)\n * @param userId 用户 ID\n * @returns 密码哈希记录,用户不存在时返回 null\n */\nexport async function getUserPasswordHash(userId: string): Promise<{ password: string | null } | null> { ... }\n\n/**\n * 查询用户的密码安全记录(用于判断是否需要强制改密)\n * @param userId 用户 ID\n * @returns 记录 ID存在时不存在返回 null\n */\nexport async function getPasswordSecurityByUserId(userId: string): Promise<{ id: string } | null> { ... }\n```",
"effort": "XS (≤15 分钟)"
},
{
"id": "G5-026",
"file": "src/modules/onboarding/actions.ts",
"lines": "L38, L68",
"ruleId": "A-08",
"severity": "P3",
"dimension": "architecture",
"title": "使用 requireAuth 而非 requirePermission权限校验粒度不足",
"description": "`getOnboardingStatusAction` L38 和 `completeOnboardingAction` L68 使用 `requireAuth()` 而非 `requirePermission(Permissions.XXX)`。`requireAuth` 仅校验登录态不校验具体权限点。onboarding 流程虽面向所有已登录用户,但按 A-08 规则应使用 requirePermission 显式声明权限点(如 ONBOARDING_READ / ONBOARDING_COMPLETE便于权限审计与角色-权限矩阵管理。",
"recommendation": "添加 ONBOARDING 权限点并使用 requirePermission\n```ts\n// shared/types/permissions.ts\nexport const Permissions = {\n // ...\n ONBOARDING_READ: \"onboarding:read\",\n ONBOARDING_COMPLETE: \"onboarding:complete\",\n} as const\n\n// actions.ts\nconst ctx = await requirePermission(Permissions.ONBOARDING_COMPLETE)\n```",
"effort": "M (≤2 小时)"
},
{
"id": "G5-027",
"file": "src/modules/elective/data-access-settings.ts",
"lines": "L58-L73",
"ruleId": "F-06",
"severity": "P2",
"dimension": "performance",
"title": "getElectiveCreditLimitRaw 串行两次 readSettingValue可合并查询",
"description": "`getElectiveCreditLimitRaw` L62-L73 先查 `creditLimit:grade:<gradeId>`,若未命中再查 `creditLimit:default`,两次串行 DB 查询。虽每次有 cacheFn但首次未命中缓存时仍需 2 次往返。可用 OR 查询合并为单次:\n```ts\nconst [gradeRow, defaultRow] = await Promise.all([\n readSettingValue(`creditLimit:grade:${gradeId}`),\n readSettingValue(\"creditLimit:default\"),\n])\n```",
"recommendation": "改为 Promise.all 并行查询:\n```ts\nexport const getElectiveCreditLimitRaw = async (gradeId?: string | null): Promise<number> => {\n if (gradeId) {\n const [gradeValue, defaultValue] = await Promise.all([\n readSettingValue(`creditLimit:grade:${gradeId}`),\n readSettingValue(\"creditLimit:default\"),\n ])\n if (gradeValue !== null) {\n const parsed = Number(gradeValue)\n if (!Number.isNaN(parsed) && parsed > 0) return parsed\n }\n if (defaultValue !== null) {\n const parsed = Number(defaultValue)\n if (!Number.isNaN(parsed) && parsed > 0) return parsed\n }\n return DEFAULT_MAX_CREDIT_PER_TERM\n }\n // ...\n}\n```",
"effort": "XS (≤15 分钟)"
},
{
"id": "G5-028",
"file": "src/modules/elective/data-access-selections.ts",
"lines": "L107-L109, L123-L125",
"ruleId": "F-05",
"severity": "P2",
"dimension": "performance",
"title": "getCourseSelectionsRaw / getStudentSelectionsRaw 无 LIMIT",
"description": "`getCourseSelectionsRaw` L107-L109 按 courseId 查询选课记录,`getStudentSelectionsRaw` L123-L125 按 studentId 查询,均无 LIMIT。教师视角下热门课程可能有数百条选课记录学生视角下四年累计选课也可能较多。建议添加默认 LIMIT 防止极端情况。",
"recommendation": "添加默认 LIMIT\n```ts\nexport const getCourseSelectionsRaw = async (courseId: string): Promise<CourseSelectionWithDetails[]> => {\n const rows = await buildSelectionCoreSelect()\n .where(eq(courseSelections.courseId, courseId))\n .orderBy(asc(courseSelections.priority), asc(courseSelections.selectedAt))\n .limit(500) // 默认上限\n // ...\n}\n```",
"effort": "XS (≤15 分钟)"
},
{
"id": "G5-029",
"file": "src/modules/files/data-access.ts",
"lines": "L224-L230",
"ruleId": "F-02",
"severity": "P2",
"dimension": "performance",
"title": "getFileAttachmentsWithFiltersRaw 使用 `LIKE '%search%'` 全表扫描",
"description": "L225 `const kw = '%${search}%'`L227-L229 `like(fileAttachments.originalName, kw)` 和 `like(fileAttachments.filename, kw)` 使用前缀通配符 `%`,无法走索引。管理员文件管理页面搜索文件时全表扫描 fileAttachments 表。",
"recommendation": "为 originalName 和 filename 添加 FULLTEXT 索引,或改为前缀匹配:\n```sql\nALTER TABLE file_attachments ADD FULLTEXT INDEX ft_filename_search (original_name, filename);\n```\n```ts\nif (search) {\n conditions.push(sql`MATCH(${fileAttachments.originalName}, ${fileAttachments.filename}) AGAINST(${search} IN BOOLEAN MODE)`)\n}\n```",
"effort": "M (≤2 小时)"
},
{
"id": "G5-030",
"file": "src/modules/announcements/data-access.ts",
"lines": "L343-L344, L366-L367, L426, L438, L454, L575, L592, L597",
"ruleId": "A-06",
"severity": "P3",
"dimension": "architecture",
"title": "大量使用 dynamic import 调用跨模块 data-access建议改为静态 import",
"description": "文件中 8 处使用 `await import(\"@/modules/xxx/data-access\")` 动态导入L343 `getGrades`、L344 `getAdminClasses`、L366 `getGrades`、L426 `getClassGradeId`、L438 `getStudentActiveClassId`/`getStudentActiveGradeId`、L454 同上、L575 `getAllUserIds`、L592 `getUserIdsByGradeId`、L597 `getStudentIdsByClassId`/`getTeacherIdsByClassIds`。动态 import 增加运行时开销,且无法被构建工具静态分析优化。虽可能为避免循环依赖,但 announcements 与 classes/users/school 模块间无循环依赖风险。",
"recommendation": "改为静态 import\n```ts\nimport { getGrades } from \"@/modules/school/data-access\"\nimport { getAdminClasses, getClassGradeId, getStudentActiveClassId, getStudentActiveGradeId, getStudentIdsByClassId, getTeacherIdsByClassIds } from \"@/modules/classes/data-access\"\nimport { getAllUserIds, getUserIdsByGradeId } from \"@/modules/users/data-access\"\n```\n若确有循环依赖应重构模块边界而非用 dynamic import 规避。",
"effort": "S (≤30 分钟)"
},
{
"id": "G5-031",
"file": "src/modules/elective/data-access-operations.ts",
"lines": "L378-L379, L410-L446",
"ruleId": "A-02",
"severity": "P2",
"dimension": "architecture",
"title": "notifyCapacityThresholdIfNeeded 调用 getTranslations + sendNotification含 i18n 与通知编排",
"description": "`notifyCapacityThresholdIfNeeded` L410-L446 在 data-access 层调用 `getTranslations(\"elective\")` (L421) 获取 i18n 文案,并调用 `sendNotification` (L430) 发送跨模块通知。i18n 与通知编排属于业务逻辑层职责,不应位于 data-access。此外 L379 在事务内 `void notifyCapacityThresholdIfNeeded(course, newEnrolledCount)` 触发 fire-and-forget虽不阻塞事务但 data-access 层不应有副作用编排。",
"recommendation": "移至 actions 层:\n```ts\n// data-access-operations.ts\nexport async function selectCourse(courseId, studentId, priority?): Promise<{ status: CourseSelectionStatus; course: Course }> {\n // ... 返回 course 与 newEnrolledCount 供 actions 判断\n}\n\n// actions.ts\nconst { status, course, newEnrolledCount } = await selectCourse(...)\nif (status === \"enrolled\") {\n await notifyCapacityThresholdIfNeeded(course, newEnrolledCount)\n}\nawait invalidateFor(...)\n```",
"effort": "M (≤2 小时)"
},
{
"id": "G5-032",
"file": "src/modules/settings/data-access.ts",
"lines": "L153-L171, L188-L205",
"ruleId": "F-09",
"severity": "P3",
"dimension": "performance",
"title": "updateAiProvider / createAiProvider 事务内含条件分支但范围合理",
"description": "`updateAiProvider` (L153-L171) 和 `createAiProvider` (L188-L205) 在事务内执行1) 可选的重置其他默认 Provider2) 主表 update/insert。事务范围仅含 DB 操作,无网络调用,范围合理。但 `resetOtherDefaults` 在事务内全表 update当 Provider 数量多时可能锁表。标记为 P3 提示关注。",
"recommendation": "可接受现状。若 Provider 数量增长可考虑1) 添加 WHERE 过滤条件缩小 update 范围2) 用乐观锁替代事务。当前实现合理,无需立即修改。",
"effort": "XS (≤15 分钟)"
},
{
"id": "G5-033",
"file": "src/modules/error-book/data-access.ts",
"lines": "L400-L438",
"ruleId": "A-02",
"severity": "P3",
"dimension": "architecture",
"title": "recordReview 含 SM-2 算法应用逻辑,处于边界",
"description": "`recordReview` L409-L413 调用 `calculateNewInterval`、`calculateNewMastery`、`deriveStatus`、`calculateNextReviewAt`、`calculateNewCorrectStreak` 计算 SM-2 算法派生值。这些计算虽是业务逻辑,但已封装在 `sm2-algorithm.ts` 纯函数模块中data-access 仅调用并持久化结果。处于可接受边界,但严格按 A-02 规则,算法应用应位于 lib 或 actions。",
"recommendation": "可接受现状。若严格遵循规则,可将 SM-2 计算移至 `error-book/lib/review.ts`data-access 仅接受计算结果并持久化:\n```ts\n// lib/review.ts\nexport function computeReviewResult(item, result): { newInterval, newMastery, newStatus, nextReviewAt } { ... }\n\n// data-access.ts\nconst reviewResult = computeReviewResult(item, result)\nawait db.transaction(async (tx) => { /* 持久化 reviewResult */ })\n```",
"effort": "S (≤30 分钟)"
},
{
"id": "G5-034",
"file": "src/modules/ai/data-access.ts",
"lines": "L33-L49, L63-L139",
"ruleId": "A-02",
"severity": "P3",
"dimension": "architecture",
"title": "内存事件存储eventStore位于 data-access应为独立 service",
"description": "L35 `const eventStore: StoredAiEvent[] = []` 模块级内存数组L43-L49 `recordAiEvent` 写入函数L63-L139 `getAiUsageStatsRaw` 聚合统计。data-access 层应封装 DB 访问,而内存事件存储是临时实现(注释 L9-L18 说明生产环境应替换为 DB/Redis。当前实现虽可工作但混合了存储实现与数据访问接口。",
"recommendation": "提取为独立 service\n```ts\n// ai/services/usage-tracker-store.ts\nconst eventStore: StoredAiEvent[] = []\nexport function recordAiEvent(event: StoredAiEvent): void { ... }\n\n// ai/data-access.ts\nimport { eventStore } from \"./services/usage-tracker-store\"\nexport async function getAiUsageStatsRaw(): Promise<AiUsageStats> { ... }\n```\n注释已说明是临时实现生产替换为 DB 后此问题自动消失。",
"effort": "XS (≤15 分钟)"
},
{
"id": "G5-035",
"file": "src/modules/elective/data-access-operations.ts",
"lines": "L62-L78",
"ruleId": "S-06",
"severity": "P3",
"dimension": "structure",
"title": "DAY_NORMALIZE_MAP 常量与 normalizeDay 函数缺模块级 JSDoc",
"description": "L62-L78 `DAY_NORMALIZE_MAP` 常量虽有注释说明用途,但 `normalizeDay` 函数 (L76-L78) 无 JSDoc。此函数被 `isScheduleConflict` 内部调用,是冲突检测的核心。按 S-06 规则,公共导出函数应补齐 JSDoc。虽然 `normalizeDay` 未导出,但作为可测试纯函数建议导出并补 JSDoc。",
"recommendation": "添加 JSDoc 并考虑导出便于测试:\n```ts\n/**\n * 将星期字符串归一化为 1-7 数字字符串。\n * 支持中文(周一/星期一、英文全称monday、英文缩写mon。\n * @param day 原始星期字符串\n * @returns 归一化后的 1-7 数字字符串,无法识别时返回原值\n */\nexport function normalizeDay(day: string): string {\n return DAY_NORMALIZE_MAP[day.toLowerCase()] ?? day\n}\n```",
"effort": "XS (≤15 分钟)"
},
{
"id": "G5-036",
"file": "src/modules/elective/data-access-selections.ts",
"lines": "L104-L112, L120-L128",
"ruleId": "P-08",
"severity": "P3",
"dimension": "pattern",
"title": "getCourseSelectionsRaw / getStudentSelectionsRaw 重复 map+resolve 模式",
"description": "`getCourseSelectionsRaw` L107-L111 和 `getStudentSelectionsRaw` L123-L127 重复了相同的模式:`buildSelectionCoreSelect().where(...).orderBy(...)` → `resolveStudentDisplayNames(rows)` → `rows.map((r) => mapSelectionRow(r, studentNames))`。仅 where 条件和 orderBy 不同,可提取为共享 helper。",
"recommendation": "提取共享查询 helper\n```ts\nasync function querySelectionsWithDisplayNames(\n where: SQL, orderBy: SQL\n): Promise<CourseSelectionWithDetails[]> {\n const rows = await buildSelectionCoreSelect().where(where).orderBy(orderBy)\n const studentNames = await resolveStudentDisplayNames(rows)\n return rows.map((r) => mapSelectionRow(r, studentNames))\n}\n\nexport const getCourseSelectionsRaw = async (courseId: string) =>\n querySelectionsWithDisplayNames(\n eq(courseSelections.courseId, courseId),\n asc(courseSelections.priority)\n )\n```",
"effort": "XS (≤15 分钟)"
},
{
"id": "G5-037",
"file": "src/modules/announcements/data-access.ts",
"lines": "L122-L157",
"ruleId": "S-03",
"severity": "P3",
"dimension": "structure",
"title": "getAnnouncements 与 countAnnouncements 的 audience 过滤逻辑重复",
"description": "`getAnnouncementsRaw` L66-L82 和 `countAnnouncements` L133-L149 的 audience 过滤逻辑完全一致gradeClause、classClause、orClauses 构造。两处复制粘贴,修改时需同步,易遗漏。",
"recommendation": "提取共享 helper\n```ts\nfunction buildAudienceConditions(audience?: UserAudience): SQL[] {\n if (!audience) return []\n const { gradeIds, classIds } = audience\n const gradeClause = gradeIds.length > 0\n ? and(eq(announcements.type, \"grade\"), inArray(announcements.targetGradeId, gradeIds))\n : undefined\n const classClause = classIds.length > 0\n ? and(eq(announcements.type, \"class\"), inArray(announcements.targetClassId, classIds))\n : undefined\n const orClauses = [eq(announcements.type, \"school\"), gradeClause, classClause]\n .filter((c): c is NonNullable<typeof c> => c !== undefined)\n return orClauses.length > 1 ? [or(...orClauses)] : []\n}\n```",
"effort": "XS (≤15 分钟)"
},
{
"id": "G5-038",
"file": "src/modules/settings/actions.ts",
"lines": "L220",
"ruleId": "A-10",
"severity": "P3",
"dimension": "architecture",
"title": "actions.ts 含 console.error 调试代码actions 层可接受但建议用 logger",
"description": "L220 `console.error(\"[upsertAiProviderAction] Failed to save AI provider:\", error)` 在 actions 层使用 console.error。A-10 规则主要针对 data-access 层禁止 console.logactions 层用 console.error 记录错误属常见做法,但项目有 `trackEvent` 与 `logAudit` 统一日志通道,建议统一使用。",
"recommendation": "改用 trackEvent 或统一 logger\n```ts\nvoid trackEvent({\n event: \"ai.provider_upsert_failed\",\n targetType: \"ai_provider\",\n properties: { error: error instanceof Error ? error.message : String(error) },\n})\nreturn { success: false, message: \"Failed to save AI provider\" }\n```",
"effort": "XS (≤15 分钟)"
},
{
"id": "G5-039",
"file": "src/modules/announcements/actions.ts",
"lines": "L42, L92",
"ruleId": "A-10",
"severity": "P3",
"dimension": "architecture",
"title": "actions.ts 含 console.errorhandleActionError 与 notifyAnnouncementPublished",
"description": "L42 `console.error(\\`[announcements] ${actionName} failed:\\`, e)` 和 L92 `console.error(\"Failed to send announcement notifications:\", error)` 在 actions 层使用 console.error。与 G5-038 同理actions 层可接受但建议统一日志通道。",
"recommendation": "改用 trackEvent 统一上报:\n```ts\nvoid trackEvent({\n event: \"announcement.action_error\",\n targetType: \"announcement\",\n properties: { action: actionName, error: e instanceof Error ? e.message : String(e) },\n})\n```",
"effort": "XS (≤15 分钟)"
},
{
"id": "G5-040",
"file": "src/modules/error-book/data-access-analytics.ts",
"lines": "L161-L240, L309-L407",
"ruleId": "S-03",
"severity": "P3",
"dimension": "structure",
"title": "getKnowledgePointWeakness 与 getChapterWeakness 的知识点聚合逻辑重复",
"description": "`getKnowledgePointWeaknessRaw` L172-L191 和 `getChapterWeaknessRaw` L320-L338 都执行了相同的模式1) 查询 errorBookItems 的 status + knowledgePointIds2) JS 展开知识点到 kpMap3) 查询 knowledgePoints 表获取名称与 chapterId。两处代码高度相似仅最终聚合维度不同按知识点 vs 按章节)。",
"recommendation": "提取共享的知识点聚合查询:\n```ts\nasync function loadKpErrorStats(\n studentIds: string[], subjectId?: string | null\n): Promise<Map<string, { errorCount: number; masteredCount: number }>> {\n const whereClause = buildStudentErrorWhereClause(studentIds, subjectId)\n const rows = await db.select({\n status: errorBookItems.status,\n knowledgePointIds: errorBookItems.knowledgePointIds,\n }).from(errorBookItems).where(whereClause)\n // ... 展开 kpMap\n return kpMap\n}\n```\n两个函数复用此 helper。",
"effort": "S (≤30 分钟)"
},
{
"id": "G5-041",
"file": "src/modules/elective/data-access-operations.ts",
"lines": "L137-L174, L185-L220",
"ruleId": "F-01",
"severity": "P2",
"dimension": "performance",
"title": "checkScheduleConflict / checkCreditLimit 在事务内多次查询,可批量优化",
"description": "`checkScheduleConflict` L150-L161 查询学生已选课程 schedule`checkCreditLimit` L198-L209 查询学生已选课程 credit。两个函数在 `selectCourse` 事务内串行调用 (L347, L353),且都查询了 `courseSelections innerJoin electiveCourses` 相同的 join。可合并为单次查询减少事务内往返。",
"recommendation": "合并为单次查询:\n```ts\nasync function checkScheduleAndCredit(\n tx, studentId, newCourseId, studentGradeId\n): Promise<{ hasConflict: boolean; creditExceeded: boolean; current: number; max: number }> {\n const [newCourse, existingCourses] = await Promise.all([\n tx.select({ schedule: electiveCourses.schedule, credit: electiveCourses.credit })\n .from(electiveCourses).where(eq(electiveCourses.id, newCourseId)).limit(1),\n tx.select({ schedule: electiveCourses.schedule, credit: electiveCourses.credit })\n .from(courseSelections)\n .innerJoin(electiveCourses, eq(electiveCourses.id, courseSelections.courseId))\n .where(and(eq(courseSelections.studentId, studentId),\n inArray(courseSelections.status, [\"selected\", \"enrolled\", \"waitlist\"]))),\n ])\n // 一次遍历计算 conflict + credit\n}\n```",
"effort": "M (≤2 小时)"
}
]

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,547 @@
# 设计令牌专项重构 · 设计文档
> 创建日期:2026-07-04
> 状态:Draft(待用户审查)
> 执行方式:大爆炸式(单 PR)
> 关联规则:`docs/architecture/004_architecture_impact_map.md`、`.trae/rules/project_rules.md`
---
## 1. 背景与目标
### 1.1 现状摘要
项目当前令牌散落在 [`src/app/globals.css`](file:///e:/Desktop/CICD/src/app/globals.css)(477 行)与 [`tailwind.config.ts`](file:///e:/Desktop/CICD/tailwind.config.ts)(极简,无 `theme.extend`)。已有 5 类令牌:
1. **shadcn 标准令牌**(HSL 格式,完整暗色)
2. **chart-1~5 数据可视化令牌**(HSL,完整暗色)
3. **sidebar 令牌**(HSL,完整暗色)
4. **`--lp-*` 备课编辑器令牌**(HEX 格式,**无暗色**,**未在 `@theme inline` 中暴露**,无法 `bg-lp-paper`)
5. **Material Design 3 Surface 映射令牌**(在 `@theme inline` 中,**经扫描零使用**,属死代码)
### 1.2 关键技术债
扫描结果:
| 问题类型 | 出现次数 | 文件数 |
|---|---|---|
| 硬编码颜色 `#xxx` | 91 处 | 15 文件 |
| 硬编码字体 `'Inter'/'Fraunces'/'JetBrains Mono'` | 8 处 | 2 文件 |
| 硬编码 `font-size: Npx` | 15 处 | 3 文件 |
| Tailwind 任意值 `w-[Npx]/h-[Npx]/p-[Npx]/...` | 98 处 | 40 文件 |
| 缺失:font/spacing/shadow/text-size 尺度令牌 | — | — |
| `--lp-*` 无暗色 / 未暴露为 Tailwind 类 | — | — |
| M3 Surface 令牌零使用(死代码) | — | — |
项目规则(`.trae/rules/project_rules.md`)明确禁止"任意值"和"动态拼接类名",但实际任意值已散落到 40 个文件,需要专项治理。
### 1.3 重构目标
- **核心目标**:全量体系化重建设计令牌,建立完整令牌契约,清理全部硬编码与任意值
- **命名体系**:双层架构(Primitive + Semantic)+ shadcn 语义扩展
- **覆盖范围**:font/size/leading/weight/radius/shadow/spacing/duration/ease/z-index 全阶梯
- **暗色覆盖**:明暗双色全面覆盖(所有令牌都有 `:root``.dark` 双份)
- **强制约束**:ESLint 规则 + 注释豁免,防止技术债复发
---
## 2. 令牌架构
### 2.1 双层架构
```
┌──────────────────────────────────────────────────────────────────┐
│ Layer 1: Primitive (Raw Tokens) — 原始色板/字号/间距/阴影 │
│ ───────────────────────────────────────────────────────────── │
│ 命名: --color-zinc-50 ~ 950 │
│ --color-stone-50 ~ 950 │
│ --color-indigo-500/600 │
│ --font-size-1 ~ --font-size-9 │
│ --space-0 ~ --space-32 │
│ --shadow-1 ~ --shadow-6 │
│ --font-family-sans/serif/mono │
│ 特征: 不直接使用,只被 Semantic 层引用 │
│ 主题: 无主题差异(色板层不区分明暗) │
└──────────────────────────────────────────────────────────────────┘
▲ 引用
┌──────────────────────────────────────────────────────────────────┐
│ Layer 2: Semantic (Component/Context Tokens) — 语义层 │
│ ───────────────────────────────────────────────────────────── │
│ shadcn 兼容: --background / --foreground / --primary / ... │
│ 扩展层级: --background-elevated / --text-secondary / ... │
│ 模块命名空间: --lp-* (lesson-preparation) │
│ chart/sidebar: --chart-1~5 / --sidebar-* │
│ 特征: 业务代码唯一引用入口,Tailwind 通过 @theme inline 暴露 │
│ 主题: :root 与 .dark 双份定义,所有令牌都有暗色 │
└──────────────────────────────────────────────────────────────────┘
▲ 引用
┌──────────────────────────────────────────────────────────────────┐
│ Layer 3: Tailwind 类 (via @theme inline) │
│ ───────────────────────────────────────────────────────────── │
│ bg-background / text-foreground / bg-primary / ... │
│ bg-lp-paper / text-lp-inline-node-meta / ... │
│ font-sans / font-serif / font-mono │
│ text-size-2 / leading-snug / weight-semibold │
│ radius-md / shadow-2 / space-4 / duration-fast / z-modal │
└──────────────────────────────────────────────────────────────────┘
```
### 2.2 核心原则
- 业务代码 **只引用 Semantic 层**(直接 `var(--background)` 或 Tailwind 类 `bg-background`),禁止引用 Primitive 层
- Primitive 层只为 Semantic 层提供"原材料",便于未来多主题切换(只改 Semantic 层映射,不动 Primitive)
- shadcn 命名严格保留(`--background`/`--foreground`/`--primary` 等),`shared/components/ui/*` 零改动
- `--lp-*` 保留命名空间(已有 9 文件使用,改名风险大),但补全暗色与 Tailwind 暴露
### 2.3 文件分布
当前 [`globals.css`](file:///e:/Desktop/CICD/src/app/globals.css) 477 行,补全令牌后估算 1200+ 行,超过项目规则硬性上限 1000 行。拆分为令牌专题目录:
```
src/app/
├─ globals.css # 仅保留 @import、base layer、组件硬编码样式清理后的样式
└─ styles/
└─ tokens/
├─ primitive.css # Layer 1: 原始色板/字号/间距/阴影阶梯
├─ semantic-light.css # Layer 2: :root 语义令牌(明色)
├─ semantic-dark.css # Layer 2: .dark 语义令牌(暗色)
├─ lesson-preparation.css # --lp-* 令牌(明暗双份)
├─ tailwind-theme.css # @theme inline 暴露规则
└─ index.css # 汇总 @import 入口
```
`globals.css` 顶部改为:
```css
@import "tailwindcss";
@import "./styles/tokens/index.css";
@plugin "tailwindcss-animate";
@plugin "@tailwindcss/typography";
@custom-variant dark (&:where(.dark, .dark *));
```
### 2.4 Tailwind v4 集成
[`tailwind.config.ts`](file:///e:/Desktop/CICD/tailwind.config.ts) 保持极简(只 content + plugins),所有令牌通过 `@theme inline``tailwind-theme.css` 中暴露。
---
## 3. Primitive 层(原始阶梯)
### 3.1 色板(以 Zinc 为基准中性色,与现有 shadcn 一致)
```css
/* primitive.css */
:root {
/* Zinc 中性色板(shadcn 默认) */
--color-zinc-50: 0 0% 99%;
--color-zinc-100: 240 4.8% 95.9%;
--color-zinc-200: 240 5.9% 90%;
--color-zinc-300: 240 4.8% 83.9%;
--color-zinc-400: 240 5% 64.9%;
--color-zinc-500: 240 3.8% 46.1%;
--color-zinc-600: 240 5.2% 33.9%;
--color-zinc-700: 240 5.3% 26.1%;
--color-zinc-800: 240 5.9% 10%;
--color-zinc-900: 240 5.9% 3.9%;
--color-zinc-950: 240 10% 3.9%;
/* Stone 暖灰(用于 lp-* 纸感,与现有 --lp-paper-edge #f8f8f7 对齐) */
--color-stone-50: 60 4.8% 95.9%;
--color-stone-100: 60 5.1% 90%;
--color-stone-200: 20 5.9% 90%;
--color-stone-300: 24 5.7% 82.9%;
--color-stone-400: 24 5.4% 63.9%;
--color-stone-500: 25 5.1% 44.7%;
--color-stone-600: 33 5% 39.8%;
--color-stone-700: 30 5.2% 32.7%;
--color-stone-800: 12 6.5% 31.4%;
--color-stone-900: 24 10% 10%;
--color-stone-950: 20 14.3% 4.1%;
/* Indigo 强调色(lp-interaction 用) */
--color-indigo-500: 238.7 83.5% 66.7%;
--color-indigo-600: 238.6 84.5% 59.8%;
}
```
**说明**: 仅记录 Primitive 原料,业务代码不直接引用。
### 3.2 字号阶梯(替代硬编码 13.5px/14px/12.5px 等)
| Token | 值 | 用途 |
|---|---|---|
| `--font-size-1` | 12px | 元信息/角色标签 |
| `--font-size-2` | 13px | inline-node body |
| `--font-size-3` | 13.5px | inline-node 主文(备课纸感) |
| `--font-size-4` | 14px | 标题/按钮 |
| `--font-size-5` | 16px | 正文(Fraunces 16px) |
| `--font-size-6` | 18px | H2 |
| `--font-size-7` | 20px | H1 |
| `--font-size-8` | 24px | 区块标题 |
| `--font-size-9` | 32px | 页面标题 |
### 3.3 间距阶梯(覆盖 98 处任意值)
| Token | 值 | 替代示例 |
|---|---|---|
| `--space-0` | 0 | |
| `--space-0_5` | 0.125rem (2px) | `p-[2px]` |
| `--space-1` | 0.25rem (4px) | `p-[4px]` |
| `--space-1_5` | 0.375rem (6px) | `gap-[6px]` |
| `--space-2` | 0.5rem (8px) | |
| `--space-2_5` | 0.625rem (10px) | `p-[10px]` |
| `--space-3` | 0.75rem (12px) | |
| `--space-3_5` | 0.875rem (14px) | `p-[14px]` |
| `--space-4` | 1rem (16px) | |
| `--space-5` | 1.25rem (20px) | `m-[20px]` |
| `--space-6` | 1.5rem (24px) | |
| `--space-7` | 1.75rem (28px) | `w-[28px]` (lp-tb-btn) |
| `--space-8` | 2rem (32px) | |
| `--space-10` | 2.5rem (40px) | |
| `--space-12` | 3rem (48px) | |
| `--space-16` | 4rem (64px) | |
| `--space-18` | 4.5rem (72px) | `px-[72px]` (lp-paper-toolbar) |
### 3.4 阴影阶梯(替代硬编码 box-shadow)
| Token | 值 | 替代 |
|---|---|---|
| `--shadow-1` | `0 1px 2px rgba(15,15,15,0.04)` | shadow-xs |
| `--shadow-2` | `0 1px 3px rgba(15,15,15,0.06), 0 1px 2px rgba(15,15,15,0.04)` | shadow-sm |
| `--shadow-3` | `0 4px 6px rgba(15,15,15,0.05), 0 2px 4px rgba(15,15,15,0.04)` | shadow-md |
| `--shadow-4` | `0 1px 2px rgba(15,15,15,0.04), 0 8px 24px rgba(15,15,15,0.04)` | lp-paper-shadow |
| `--shadow-5` | `0 1px 2px rgba(15,15,15,0.06), 0 12px 36px rgba(15,15,15,0.08)` | lp-paper-shadow-active |
| `--shadow-6` | `0 10px 15px rgba(15,15,15,0.1), 0 4px 6px rgba(15,15,15,0.05)` | shadow-xl |
### 3.5 字体家族(替代 8 处硬编码)
| Token | 值 | 用途 |
|---|---|---|
| `--font-family-sans` | `'Inter', system-ui, sans-serif` | UI 元素 |
| `--font-family-serif` | `'Fraunces', Georgia, serif` | 主文/备课纸感 |
| `--font-family-mono` | `'JetBrains Mono', monospace` | 角色标签 |
---
## 4. Semantic 层(shadcn 扩展 + lp-* 升级 + M3 清理)
### 4.1 shadcn 标准令牌(零改动,确保 ui/ 兼容)
`--background`/`--foreground`/`--card`/`--popover`/`--primary`/`--secondary`/`--muted`/`--accent`/`--destructive`/`--border`/`--input`/`--ring`/`--radius` 全部保留现有 HSL 值,`:root``.dark` 双份定义不变。
### 4.2 语义层扩展(新增层级)
| Token | 明色 HSL | 暗色 HSL | 用途 |
|---|---|---|---|
| `--background-elevated` | `0 0% 100%` | `240 6% 10%` | 浮层/card-on-card |
| `--background-sunken` | `240 4.8% 95.9%` | `240 6% 8%` | 凹陷区/inset |
| `--text-primary` | `= --foreground` | `= --foreground` | 主文 |
| `--text-secondary` | `240 3.8% 46.1%` | `240 5% 64.9%` | 副文(= muted-foreground) |
| `--text-tertiary` | `240 4% 65%` | `240 5% 50%` | 占位/元信息 |
| `--border-strong` | `240 5.9% 70%` | `240 5% 40%` | 强调边框 |
| `--border-subtle` | `240 5.9% 95%` | `240 5% 18%` | 弱边框 |
**说明**: `--text-secondary` 等是 `--muted-foreground` 的语义别名,业务代码优先用语义名。
### 4.3 chart-1~5 / sidebar 令牌(零改动)
保留现有 HSL 值,`:root``.dark` 双份定义不变。
### 4.4 --lp-* 令牌升级(HEX→HSL + 暗色 + Tailwind 暴露)
**明色(`:root`)**:
| Token | 旧 HEX | 新 HSL | 说明 |
|---|---|---|---|
| `--lp-paper` | `#fefefe` | `0 0% 99.6%` | 纸面 |
| `--lp-paper-edge` | `#f8f8f7` | `60 9% 97%` | 纸边(对齐 stone-100) |
| `--lp-paper-shadow` | (box-shadow) | `= --shadow-4` 引用 | 阴影令牌化 |
| `--lp-paper-shadow-active` | (box-shadow) | `= --shadow-5` 引用 | 阴影令牌化 |
| `--lp-anchor-range` | `rgba(28,25,23,0.08)` | `20 14.3% 4.1% / 0.08` | 引用 stone-950 |
| `--lp-anchor-range-active` | `rgba(28,25,23,0.16)` | `20 14.3% 4.1% / 0.16` | |
| `--lp-anchor-point` | `#1c1917` | `20 14.3% 4.1%` | = stone-950 |
| `--lp-inline-node-border` | `#d6d3d1` | `30 5.7% 82.9%` | = stone-300 |
| `--lp-inline-node-text` | `#44403c` | `24 5.4% 26.9%` | ≈ stone-700 微调 |
| `--lp-inline-node-meta` | `#a8a29e` | `30 5% 64.9%` | = stone-400 |
| `--lp-interaction` | `#6366f1` | `238.6 84.5% 59.8%` | = indigo-600 |
**12 个 --lp-dot-* 节点色点**: 全部映射到 zinc/stone 色板对应值,统一为 HSL。
**暗色(`.dark`)**: 全部补齐。例如:
- `--lp-paper: 240 6% 10%`(暗背景纸面)
- `--lp-paper-edge: 240 6% 8%`
- `--lp-anchor-point: 0 0% 98%`(反相)
- `--lp-interaction: 238.7 83.5% 66.7%`(= indigo-500,暗色微亮)
### 4.5 M3 Surface 映射令牌清理
[`globals.css:185-198`](file:///e:/Desktop/CICD/src/app/globals.css#L185-L198) 中的 M3 令牌零使用(已扫描确认),全部删除:
- `--color-surface` / `--color-on-surface` / `--color-on-surface-variant`
- `--color-surface-container-lowest/low/high/highest`
- `--color-outline-variant` / `--color-outline`
- `--color-error` / `--color-tertiary` / `--color-tertiary-container`
### 4.6 globals.css 硬编码样式令牌化
| 硬编码 | 令牌化后 |
|---|---|
| `.lp-tb-btn { width: 28px; ... font-family: 'Inter'; font-size: 13px; color: var(--foreground) }` | `width: var(--space-7); font-family: var(--font-family-sans); font-size: var(--font-size-2); color: hsl(var(--foreground))` |
| `.lp-inline-node { font-family: 'Inter'; font-size: 13.5px; ... border-left: 2px solid var(--lp-inline-node-border); color: var(--lp-inline-node-text) }` | 全部用令牌引用 |
| `.lp-qa-role.teacher { color: #1c1917 }` | `color: hsl(var(--lp-anchor-point))` |
| `.lp-qa-role.student { color: #6b7280 }` | `color: hsl(var(--color-zinc-500))` |
| `.lp-inline-node-title { color: #1a1a1a }` | `color: hsl(var(--lp-anchor-point))` |
| `.lp-inline-node-body { color: #404040 }` | `color: hsl(var(--lp-inline-node-text))` |
| `.lp-qa-content { color: #404040 }` | 同上 |
| `.lp-qa-prompt { color: #525252 }` | `color: hsl(var(--color-zinc-600))` |
| `.range-anchor { background-color: var(--node-color, #1976d2) }` | fallback 改为 `hsl(var(--lp-interaction))` |
---
## 5. 尺度令牌补齐清单
### 5.1 圆角阶梯
| Token | 值 | Tailwind 类 |
|---|---|---|
| `--radius-sm` | `calc(var(--radius) - 4px)` | `rounded-sm` |
| `--radius-md` | `calc(var(--radius) - 2px)` | `rounded-md` |
| `--radius-lg` | `var(--radius)` = `0.5rem` | `rounded-lg` |
| `--radius-xl` | `calc(var(--radius) + 4px)` | `rounded-xl` |
| `--radius-2xl` | `calc(var(--radius) + 8px)` | `rounded-2xl` |
| `--radius-full` | `9999px` | `rounded-full` |
### 5.2 行高与字重
| Token | 值 |
|---|---|
| `--leading-tight` | 1.2 |
| `--leading-snug` | 1.35 |
| `--leading-normal` | 1.5 |
| `--leading-relaxed` | 1.65 |
| `--leading-loose` | 1.8 |
| `--weight-regular` | 400 |
| `--weight-medium` | 500 |
| `--weight-semibold` | 600 |
| `--weight-bold` | 700 |
### 5.3 动效与 z-index
| Token | 值 | Tailwind 类 |
|---|---|---|
| `--duration-fast` | 150ms | `duration-fast` |
| `--duration-normal` | 200ms | `duration-normal` |
| `--duration-slow` | 300ms | `duration-slow` |
| `--ease-in` | `cubic-bezier(0.4, 0, 1, 1)` | `ease-in` |
| `--ease-out` | `cubic-bezier(0, 0, 0.2, 1)` | `ease-out` |
| `--ease-in-out` | `cubic-bezier(0.4, 0, 0.2, 1)` | `ease-in-out` |
| `--z-dropdown` | 1000 | `z-dropdown` |
| `--z-sticky` | 1100 | `z-sticky` |
| `--z-modal` | 1300 | `z-modal` |
| `--z-popover` | 1400 | `z-popover` |
| `--z-toast` | 1500 | `z-toast` |
---
## 6. 硬编码清理映射表(分级)
### 6.1 91 处 #hex 颜色 — 按文件分类
| 文件 | 处数 | 清理策略 |
|---|---|---|
| `app/globals.css` | 32 | 全部令牌化(见 4.6 节) |
| `app/manifest.ts` | 2 | 保留(theme_color 元数据,需字面量),加注释 `// arbitrary-value: PWA manifest requires literal hex` |
| `app/layout.tsx` | 2 | 检查后令牌化或注释 |
| `shared/components/ui/chart.tsx` | 5 | 令牌化(用 `--chart-1~5`) |
| `modules/notifications/channels/email-channel.ts` | 7 | 保留(邮件 HTML 内联样式需字面量),加注释 |
| `modules/textbooks/components/knowledge-graph.tsx` | 10 | 令牌化(用 `--chart-*` 或新增 `--graph-node-*`) |
| `modules/textbooks/components/force-graph.tsx` | 17 | 同上 |
| `modules/lesson-preparation/components/paper-editor/*` | ~6 | 令牌化(用 `--lp-*`) |
| `modules/attendance/components/attendance-grade-correlation-card.tsx` | 3 | 令牌化(用 `--chart-*`) |
| `modules/classes/components/my-classes-grid.tsx` | 2 | 令牌化 |
| `modules/lesson-preparation/components/version-diff-view.tsx` | 1 | 令牌化(用 `--lp-*` 或新增 `--diff-add`/`--diff-remove`) |
**新增语义令牌**:
- `--diff-add` / `--diff-add-bg`(版本对比新增)
- `--diff-remove` / `--diff-remove-bg`(版本对比删除)
- `--graph-node-1` ~ `--graph-node-6`(知识图谱节点色)
### 6.2 8 处硬编码 font-family — 全部令牌化
| 文件 | 处数 | 令牌化 |
|---|---|---|
| `app/globals.css` | 7 | `var(--font-family-sans)` / `var(--font-family-serif)` / `var(--font-family-mono)` |
| `modules/lesson-preparation/components/paper-editor/textbook-tiptap-editor.tsx` | 1 | 同上 |
### 6.3 15 处硬编码 font-size — 全部令牌化
| 文件 | 处数 | 令牌化 |
|---|---|---|
| `app/globals.css` | 13 | `var(--font-size-N)` |
| `modules/notifications/channels/email-channel.ts` | 1 | 保留(邮件 HTML),加注释 |
| `modules/lesson-preparation/components/paper-editor/textbook-tiptap-editor.tsx` | 1 | `var(--font-size-N)` |
### 6.4 98 处 Tailwind 任意值 — 分级清理
**Tier 1 — 直接映射 Tailwind 默认阶梯**(约 40 处):
- `w-[28px]``w-7`(28px ≈ 1.75rem,但 w-7 = 1.75rem ✓)
- `gap-[6px]``gap-1.5`(6px = 0.375rem,gap-1.5 ✓)
- `p-[10px]` → 需新增 `--space-2_5`(10px = 0.625rem)
- `text-[13px]``text-size-2`(13px = `--font-size-2`)
**Tier 2 — 新增间距令牌后引用**(约 30 处):
- `w-[72px]``w-space-18`(`--space-18` = 4.5rem)
- `h-[16px]``h-space-4`(`--space-4` = 1rem)
- `p-[20px]``p-space-5`(`--space-5` = 1.25rem)
**Tier 3 — 注释豁免**(约 28 处):
- 图表 SVG 尺寸(如 `w-[400px]` 在 chart 容器)→ 加注释 `// arbitrary-value: chart canvas fixed size`
- 邮件 HTML 内联样式
- loading.tsx 占位骨架特殊尺寸
**ESLint 规则**: `tailwindcss/no-arbitrary-value: error`,允许通过 `// eslint-disable-next-line` + 注释理由豁免。
---
## 7. 强制约束机制
### 7.1 ESLint 规则新增
**新增依赖**: `eslint-plugin-tailwindcss`(需验证与 Tailwind v4 兼容性,若不兼容则自定义规则)
**`eslint.config.mjs` 新增规则**:
```javascript
{
// 禁止 Tailwind 任意值(允许 eslint-disable + 注释豁免)
'tailwindcss/no-arbitrary-value': 'error',
// 禁止 TSX/CSS 中硬编码颜色字面量(白名单:globals.css primitive 定义、email-channel.ts、manifest.ts)
'no-restricted-syntax': [
'error',
{
selector: "Literal[value=/#[0-9a-fA-F]{3,8}/]",
message: '禁止硬编码 hex 颜色,使用设计令牌 var(--*) 或 Tailwind 类 bg-*',
},
],
}
```
**自定义规则文件**: `eslint-rules/no-hardcoded-design-tokens.js`
- 检测 TSX/TS 中 `'Inter'`/`'Fraunces'`/`'JetBrains Mono'` 字面量
- 检测 `font-family:` CSS 属性中的字面量字体名
- 检测 `font-size: Npx` 硬编码
- 白名单:`globals.css``styles/tokens/*.css``email-channel.ts``manifest.ts`
### 7.2 项目规则更新
[`.trae/rules/project_rules.md`](file:///e:/Desktop/CICD/.trae/rules/project_rules.md) "Tailwind 规范"章节新增:
```markdown
### 设计令牌规范(强制)
- **禁止硬编码颜色**: TSX/TS/CSS 中不得出现 `#hex` 颜色字面量,统一使用 `var(--*)` 或 Tailwind 类
- **禁止硬编码字体**: 不得出现 `'Inter'`/`'Fraunces'`/`'JetBrains Mono'` 字面量,使用 `var(--font-family-sans/serif/mono)`
- **禁止硬编码字号**: 不得出现 `font-size: Npx`,使用 `var(--font-size-1~9)`
- **禁止 Tailwind 任意值**: 不得使用 `w-[Npx]`/`h-[Npx]`/`p-[Npx]` 等,映射到 `--space-*` 或 Tailwind 默认阶梯
- **豁免场景**(需 `// arbitrary-value: <reason>` 注释):
- PWA manifest(`app/manifest.ts`)
- 邮件 HTML 内联样式(`email-channel.ts`)
- 图表 SVG 固定画布尺寸
- loading.tsx 占位骨架
- **令牌文件分布**: `src/app/styles/tokens/`(primitive/semantic-light/semantic-dark/lesson-preparation/tailwind-theme)
- **改令牌必同步图**: 修改令牌定义后,同步更新 `docs/architecture/004``005`
```
### 7.3 Lint 命令不变
`npm run lint``npx tsc --noEmit` 保持零错误零警告。
---
## 8. 验证策略与架构图同步
### 8.1 验证清单
| 验证项 | 命令/方法 | 通过标准 |
|---|---|---|
| TypeScript | `npx tsc --noEmit` | 零错误 |
| ESLint | `npm run lint` | 零错误零警告 |
| 视觉回归 | `npm run test:visual` | 快照无差异(或更新后人工确认) |
| 备课编辑器视觉 | 手动检查 `teacher/lesson-plans/[id]/edit` | 纸感、inline-node、师生对话样式无变化 |
| 暗色模式 | 切换暗色主题 | 所有页面可读,无对比度问题 |
| shadcn ui 组件 | 抽查 button/card/dialog/table | 零回归 |
### 8.2 架构图同步(项目规则强制)
修改完成后,同步更新:
| 文档 | 同步内容 |
|---|---|
| [`docs/architecture/004_architecture_impact_map.md`](file:///e:/Desktop/CICD/docs/architecture/004_architecture_impact_map.md) | 新增"设计令牌体系"章节(双层架构图、文件分布、令牌分类) |
| [`docs/architecture/005_architecture_data.json`](file:///e:/Desktop/CICD/docs/architecture/005_architecture_data.json) | `modules.shared.exports` 新增 `design-tokens` 节点;`dependencyMatrix` 新增令牌依赖关系 |
| [`docs/troubleshooting/known-issues.md`](file:///e:/Desktop/CICD/docs/troubleshooting/known-issues.md) | 新增"设计令牌"问题分类,记录 Tailwind v4 + ESLint 兼容性、HEX→HSL 转换、任意值豁免规则 |
### 8.3 大爆炸式执行顺序(单 PR 内)
1. 创建 `src/app/styles/tokens/` 6 个文件(primitive/semantic-light/semantic-dark/lesson-preparation/tailwind-theme/index)
2. `globals.css` 改为 `@import` 引入,删除已迁移内容
3. `globals.css` 中剩余硬编码样式令牌化(lp-tb-btn/lp-inline-node/lp-qa-*)
4. 全局扫描替换:91 处 `#hex` → 令牌引用(按 6.1 表)
5. 全局扫描替换:8 处 font-family + 15 处 font-size → 令牌引用(按 6.2/6.3 表)
6. 全局扫描替换:98 处任意值 → Tier 1/2/3 分级处理(按 6.4 表)
7. ESLint 规则新增 + 自定义规则文件
8. 项目规则文档更新
9. 架构图同步(004/005)
10. `docs/troubleshooting/known-issues.md` 更新
11. `npm run lint` + `npx tsc --noEmit` + `npm run test:visual` 验证
12. 单次 commit:`refactor(design-tokens): 全量体系化重建设计令牌`
---
## 9. 风险与缓解
| 风险 | 缓解 |
|---|---|
| Tailwind v4 + `eslint-plugin-tailwindcss` 兼容性未知 | 实施时先验证,不兼容则自定义 AST 规则 |
| 大爆炸式改动 50+ 文件,review 困难 | 严格按 8.3 执行顺序,每步独立验证;视觉回归测试保障 |
| `--lp-*` HEX→HSL 转换可能有色差 | 视觉回归测试 + 手动备课编辑器检查 |
| 98 处任意值分级清理可能遗漏 | Tier 3 注释豁免需明确理由,ESLint 规则锁定 |
| 暗色补全可能对比度不足 | 暗色模式手动抽查所有页面 |
| shadcn ui 组件理论零改动但需验证 | 抽查 button/card/dialog/table 4 个核心组件 |
---
## 10. 范围与非目标
### 10.1 范围内
- 令牌底座重建(Primitive + Semantic 双层)
- 尺度令牌补齐(font/size/leading/weight/radius/shadow/spacing/duration/ease/z-index)
- `--lp-*` 升级(HEX→HSL + 暗色 + Tailwind 暴露)
- M3 Surface 死代码清理
- 91 处硬编码颜色清理
- 8 处硬编码 font-family 清理
- 15 处硬编码 font-size 清理
- 98 处 Tailwind 任意值清理
- ESLint 强制约束规则
- 项目规则文档更新
- 架构图同步(004/005)
- known-issues.md 更新
### 10.2 范围外(非目标)
- 第三主题(如 high-contrast、paper 纸感)预留 — YAGNI
- `shared/components/ui/*` shadcn 组件内部样式改动 — 理论零改动,仅验证
- `cn()` 工具函数修改 — 不变
- `tailwind.config.ts` 内容扩展 — 保持极简
- 设计令牌文档站(Storybook 等)— 不在本期范围
- 多主题切换架构 — 仅明暗双色

View File

@@ -0,0 +1,742 @@
# 缓存策略落地专项重构设计文档
| 字段 | 值 |
|------|---|
| 文档版本 | v1 |
| 创建日期 | 2026-07-05 |
| 作者 | Trae 协作生成 |
| 状态 | 待用户审查 |
| 范围 | 全栈缓存策略一致性(基础设施 + 规范 + 标杆示范) |
| 标杆模块 | classes |
---
## 1. 背景与现状
### 1.1 项目缓存现状
| 层 | 现状 | 问题 |
|----|------|------|
| 服务端数据缓存 | 仅用 `react.cache`(请求级 memoization30 个 data-access 文件)+ `revalidatePath`(路径级失效) | 未用 `unstable_cache` / `revalidateTag` / Redis 数据缓存;多实例下无跨实例共享 |
| 客户端缓存 | TanStack Query V5 基础设施已建(`createQueryClient` 默认 staleTime 30s/retry 1 | `useActionQuery` / `useActionMutation` 用 useEffect + useState 自实现,绕过 QueryClient30+ 组件直接 useQuery缺乏统一 queryKey 约定 |
| 失效编排 | revalidatePath 散落在 30+ actions 文件 | 易漏失效;无集中审计点 |
| 单测可 mock 性 | data-access 函数直接 export 缓存包装版本 | 难以绕过缓存层 mock架构文档 P2 待办) |
### 1.2 已有可复用资产
| 资产 | 文件 | 复用方式 |
|------|------|---------|
| Redis driver 切换模式 | `shared/lib/rate-limit/{index.ts,redis-limiter.ts,memory-limiter.ts,types.ts}` | 缓存层同构复用 `CACHE_DRIVER=memory\|redis` + 动态 import + `webpackIgnore` |
| Upstash Redis 类型声明 | `shared/lib/rate-limit/upstash-modules.d.ts` | 缓存层直接复用,避免重复声明 |
| TanStack Query 工厂 | `shared/lib/query-client.ts` | 不动,新增 queryKey 工厂与之配合 |
| `useActionQuery` / `useActionMutation` Hook | `shared/hooks/use-action-{query,mutation}.ts` | 重构内部实现,对外 API 调整(破坏性变更,需迁移消费方) |
| env 校验 | `src/env.mjs` | 新增 `CACHE_DRIVER` 字段 |
### 1.3 部署形态约束
- 多实例部署PM2 cluster / Kubernetes 多 Pod
- 必须支持跨实例共享缓存 → 引入 Redis 后端
- 复用 `UPSTASH_REDIS_REST_URL` / `UPSTASH_REDIS_REST_TOKEN` 环境变量
---
## 2. 目标与非目标
### 2.1 目标
1. 建立 `shared/lib/cache/` 数据缓存基础设施,支持 `memory` / `redis` 双 driver 切换
2. 建立 `shared/lib/query-keys.ts` 客户端 queryKey 工厂,集中化命名
3. 建立 `shared/lib/cache/invalidation-map.ts` 集中式失效映射表
4. 重构 `useActionQuery` / `useActionMutation` 接入 QueryClient + 自动 invalidate
5. 完成 classes 标杆模块全场景迁移6 data-access + 5 actions
6. 引入 ESLint 规则强制使用 `invalidateFor`,禁止直接调用 `revalidatePath` / `revalidateTag`
7. 同步架构文档004 / 005 / known-issues
### 2.2 非目标
- 其他 29 个模块的 data-access / actions 迁移(下期按模块推进)
- 其他客户端组件迁移到 useQuery下期按页面推进
- L1+L2 两级缓存YAGNI先用单层 Redis 验证)
- 缓存预热 / SWR 主动刷新YAGNI
- 缓存命中率监控埋点YAGNI
- 现有 `react.cache` 全量替换(保留作为请求级 memoization 内层)
---
## 3. 架构设计
### 3.1 模块边界与文件布局
```
src/shared/lib/
├─ cache/ # 新增:数据缓存基础设施
│ ├─ index.ts # 公共 APIcacheFn / invalidateFor / getCacheStore
│ ├─ types.ts # CacheFnOptions / CacheStore / InvalidationRule 接口
│ ├─ memory-store.ts # 内存 LRU 实现(默认,单实例降级用)
│ ├─ redis-store.ts # Redis 实现(多实例,复用 @upstash/redis
│ ├─ store-factory.ts # 按 CACHE_DRIVER 动态加载webpackIgnore
│ ├─ cache-fn.ts # cacheFn(fn, { tags, ttl, keyParts }) 包装器
│ ├─ invalidation-map.ts # mutation → { tags, queryKeys, paths } 集中映射表
│ ├─ client-invalidation-map.ts # 仅 queryKeys 子集,供客户端 bundle 使用
│ ├─ invalidate.ts # invalidateFor(actionId, params) 编排函数
│ └─ upstash-modules.d.ts # 复用 rate-limit 已有的 @upstash/redis 类型声明(提升至 shared/lib/,旧路径保留 re-export shim
├─ query-keys.ts # 新增queryKey 工厂(按 模块/资源/操作 分层)
├─ redis-client.ts # 新增:共享 Redis 单例rate-limit + cache 共用)
└─ rate-limit/ # 既有:速率限制(仅 redis-limiter.ts 改为引用 redis-client.ts
src/shared/hooks/
├─ use-action-query.ts # 重构:内部改走 useQueryQueryClient
└─ use-action-mutation.ts # 重构:内部改走 useMutation + 自动 invalidate
```
### 3.2 依赖方向(严格遵循三层架构)
```
app/(dashboard)/.../page.tsx
↓ 调用
modules/[module]/actions.ts → 调用 invalidateFor("classes.update", { id }) 编排失效
↓ 调用
modules/[module]/data-access.ts → 用 cacheFn(fn, { tags, ttl, keyParts }) 包装查询
↓ 调用
shared/lib/cache/ # 缓存基础设施
↓ 内部
shared/lib/redis-client.ts # 复用 rate-limit 的 @upstash/redis 客户端
```
### 3.3 环境变量
| 变量 | 默认 | 说明 |
|------|------|------|
| `CACHE_DRIVER` | `memory` | `memory\|redis`,对标 `RATE_LIMIT_DRIVER` |
| `UPSTASH_REDIS_REST_URL` | - | 复用 rate-limit 已有变量 |
| `UPSTASH_REDIS_REST_TOKEN` | - | 复用 rate-limit 已有变量 |
### 3.4 故障降级策略
- Redis 不可用catch 错误 → `console.error` + 透传到原函数(直查 DB→ 不阻断主流程
- 与 rate-limit 模块的 "fail-open 限流降级" 策略一致
---
## 4. 服务端缓存层详细设计
### 4.1 `cacheFn` API
```typescript
// shared/lib/cache/types.ts
export interface CacheFnOptions {
/** 缓存标签,用于按 tag 失效(必填,至少 1 个) */
tags: readonly string[]
/** TTL 秒数;不传则永久缓存(仅靠 tag 失效) */
ttl?: number
/** 自定义 key 段,默认根据 fn.name + 参数 JSON 自动生成 */
keyParts?: readonly unknown[]
}
// shared/lib/cache/cache-fn.ts
export function cacheFn<T extends (...args: any[]) => Promise<unknown>>(
fn: T,
options: CacheFnOptions
): T
```
### 4.2 调用示例classes data-access
```typescript
// 修改前
export const getClassById = cache(async (id: string) => {
return db.query.classes.findFirst({ where: eq(classes.id, id) })
})
// 修改后
export const getClassByIdRaw = async (id: string) => {
return db.query.classes.findFirst({ where: eq(classes.id, id) })
}
export const getClassById = cacheFn(getClassByIdRaw, {
tags: ["classes:detail", "classes:detail:{id}"],
ttl: 300,
keyParts: ["classes", "by-id"],
})
```
### 4.3 双层缓存包装
```typescript
import { cache as reactCache } from "react"
export const cacheFn = <T>(fn: T, options: CacheFnOptions): T => {
// 外层react.cache 提供请求级 memoization同一 RSC 请求内去重)
// 内层cacheStore.getOrSet 提供跨请求/跨实例数据缓存
return reactCache(((...args: any[]) => {
return cacheStore.getOrSet(buildKey(fn, args, options), () => fn(...args), options)
}) as T)
}
```
**双层职责**
- `react.cache`:请求内去重(同请求多次调用只查 1 次 DB—— 现有行为
- `cacheStore`:跨请求/跨实例数据缓存 —— 新增能力
### 4.4 CacheStore 接口
```typescript
// shared/lib/cache/types.ts
export interface CacheStore {
getOrSet<T>(
key: string,
producer: () => Promise<T>,
options: { tags: readonly string[]; ttl?: number }
): Promise<T>
invalidateTags(tags: readonly string[]): Promise<void>
}
```
### 4.5 两种实现
**memory-store.ts**(默认):
- `Map<string, { value, expireAt, tags }>` + LRU 淘汰maxEntries=500
- 适用于单实例 dev/test
**redis-store.ts**(多实例):
- 复用 `shared/lib/redis-client.ts` 单例
- key 格式:`next-edu:cache:{keyParts hash}`
- value`JSON.stringify`
- tag 反查索引:`next-edu:cache:tag:{tag}` → Redis Set 存放 keys
- TTL`redis EX` 原子设置
- `invalidateTags``SMEMBERS``DEL` 批量删除
### 4.6 `redis-client.ts` 共享单例
```typescript
// shared/lib/redis-client.ts
// rate-limit 与 cache 共用的 Redis 客户端单例
// 复用 env.UPSTASH_REDIS_REST_URL / TOKEN
// 动态 import + webpackIgnore与 redis-limiter.ts 同模式)
// 故障降级:返回 null调用方走 fail-open
```
**改造 `redis-limiter.ts`**:将 `getRedisClient()` 抽出至 `redis-client.ts``redis-limiter.ts` 改为 `import { getRedisClient } from "@/shared/lib/redis-client"``rate-limit/index.ts` 单例选择逻辑不变。
### 4.7 `store-factory.ts` driver 切换
```typescript
let singleton: CacheStore | null = null
export async function getCacheStore(): Promise<CacheStore> {
if (singleton) return singleton
if (env.CACHE_DRIVER === "redis") {
const { RedisCacheStore } = await import("./redis-store")
singleton = new RedisCacheStore()
} else {
singleton = new MemoryCacheStore()
}
return singleton
}
```
### 4.8 Tag 命名规范
| 格式 | 示例 | 失效场景 |
|------|------|---------|
| `{module}` | `classes`, `grades`, `users` | 模块任意写操作全模块失效 |
| `{module}:{resource}` | `classes:list`, `classes:detail`, `classes:stats` | 资源级失效 |
| `{module}:{resource}:{id}` | `classes:detail:cls_abc123` | 单条记录失效 |
**classes 模块约定的 tags**
- `classes` —— 任意写操作全模块失效
- `classes:detail` + `classes:detail:{id}` —— 单条详情失效
- `classes:students:{classId}` —— 学生列表失效
- `classes:schedule:{classId}` —— 课表失效
- `classes:stats` —— 聚合统计失效
- `classes:invitations:{classId}` —— 邀请码失效
---
## 5. 失效编排与客户端缓存
### 5.1 集中式失效映射表
```typescript
// shared/lib/cache/invalidation-map.ts
export interface InvalidationRule {
readonly tags: readonly string[]
readonly queryKeys: readonly (readonly (string | number)[])[]
readonly paths: readonly string[]
}
export const INVALIDATION_MAP = {
// ===== classes 模块 =====
"classes.create": {
tags: ["classes", "classes:list"],
queryKeys: [["classes", "list"]],
paths: ["/teacher/classes/my", "/admin/classes"],
},
"classes.update": {
tags: ["classes", "classes:detail", "classes:detail:{id}"],
queryKeys: [["classes", "detail"], ["classes", "list"]],
// paths 不含动态段revalidatePath("/teacher/classes/my") 已能覆盖详情页([id] 路由)
paths: ["/teacher/classes/my", "/admin/classes"],
},
"classes.delete": {
tags: ["classes", "classes:list", "classes:detail:{id}"],
queryKeys: [["classes", "list"], ["classes", "detail"]],
paths: ["/teacher/classes/my", "/admin/classes"],
},
"classes.students.update": {
tags: ["classes:students:{classId}"],
queryKeys: [["classes", "students"]],
paths: ["/teacher/classes/my"],
},
"classes.schedule.update": {
tags: ["classes:schedule:{classId}"],
queryKeys: [["classes", "schedule"]],
paths: ["/teacher/classes/schedule"],
},
"classes.grade.update": {
tags: ["classes"],
queryKeys: [["classes", "list"]],
paths: [],
},
"classes.invitation.create": {
tags: ["classes:invitations:{classId}"],
queryKeys: [["classes", "invitations"]],
paths: ["/teacher/classes/my/{classId}"],
},
"classes.invitation.revoke": {
tags: ["classes:invitations:{classId}"],
queryKeys: [["classes", "invitations"]],
paths: ["/teacher/classes/my/{classId}"],
},
} as const satisfies Record<string, InvalidationRule>
```
### 5.2 `invalidateFor` 编排函数
```typescript
// shared/lib/cache/invalidate.ts
/**
* 由 Server Action 在写操作成功后调用,集中编排缓存失效。
*
* 三件事:
* 1. cacheStore.invalidateTags服务端数据缓存失效
* 2. revalidateTag × NNext.js fetch 缓存 + unstable_cache 失效)
* 3. revalidatePath × NRSC 静态缓存失效)
*
* @param actionId 形如 "classes.update",对应 INVALIDATION_MAP 中的 key
* @param params 模板参数,如 { id: "cls_123" },用于填充 {id} 占位符
*/
export async function invalidateFor(
actionId: string,
params: Record<string, string> = {}
): Promise<void> {
const rule = INVALIDATION_MAP[actionId]
if (!rule) {
throw new Error(`[cache] Unknown actionId: ${actionId}. Update INVALIDATION_MAP.`)
}
// 1. 服务端数据缓存失效Redis / 内存)
const resolvedTags = rule.tags.map((t) => fillTemplate(t, params))
const store = await getCacheStore()
await store.invalidateTags(resolvedTags)
// 2. Next.js fetch 缓存 + unstable_cache 失效
resolvedTags.forEach((tag) => revalidateTag(tag))
// 3. 路径级 RSC 缓存失效
rule.paths.forEach((p) => revalidatePath(fillTemplate(p, params)))
}
```
### 5.3 actions 层调用范式classes 标杆)
```typescript
// modules/classes/actions-teacher.ts重构后
"use server"
import { requirePermission } from "@/shared/lib/auth-guard"
import { invalidateFor } from "@/shared/lib/cache"
import { updateClassInScope } from "../data-access-teacher"
export async function updateClassAction(
input: UpdateClassInput
): Promise<ActionState<{ classId: string }>> {
await requirePermission(Permission.CLASS_MANAGE)
const result = await updateClassInScope(input)
// 一行代替原来 3-5 行 revalidatePath 散乱调用
await invalidateFor("classes.update", { id: result.classId })
return { success: true, data: { classId: result.classId } }
}
```
### 5.4 客户端 queryKey 工厂
```typescript
// shared/lib/query-keys.ts
export const queryKeys = {
classes: {
all: ["classes"] as const,
lists: () => [...queryKeys.classes.all, "list"] as const,
list: (filters: ClassFilters) => [...queryKeys.classes.lists(), filters] as const,
details: () => [...queryKeys.classes.all, "detail"] as const,
detail: (id: string) => [...queryKeys.classes.details(), id] as const,
students: (classId: string) => [...queryKeys.classes.all, "students", classId] as const,
schedule: (classId: string) => [...queryKeys.classes.all, "schedule", classId] as const,
stats: (classId: string) => [...queryKeys.classes.all, "stats", classId] as const,
invitations: (classId: string) => [...queryKeys.classes.all, "invitations", classId] as const,
},
// 后续模块按此模式扩展...
} as const
export type QueryKeys = typeof queryKeys
```
### 5.5 `useActionQuery` 重构
```typescript
// shared/hooks/use-action-query.ts重构后
"use client"
import { useQuery, type UseQueryOptions } from "@tanstack/react-query"
import type { ActionState } from "@/shared/types/action-state"
export interface UseActionQueryOptions<T> extends Omit<
UseQueryOptions<ActionState<T>>,
"queryKey" | "queryFn"
> {
/** queryKey 工厂返回的元组,如 queryKeys.classes.detail(id) */
queryKey: readonly (string | number | object)[]
/** 是否启用,默认 true */
enabled?: boolean
}
export function useActionQuery<T>(
action: () => Promise<ActionState<T>>,
options: UseActionQueryOptions<T>
): { data: T | undefined; loading: boolean; error: Error | null; refetch: () => void } {
const query = useQuery({
queryKey: options.queryKey,
queryFn: action,
enabled: options.enabled ?? true,
...options,
})
// 解包 ActionState失败时抛错成功时返回 data
const data = query.data?.success ? query.data.data : undefined
const error = query.error ?? (query.data && !query.data.success
? new Error(query.data.message ?? "Action failed")
: null)
return {
data,
loading: query.isLoading,
error,
refetch: () => { void query.refetch() },
}
}
```
**破坏性变更**`useActionQuery` 必须传 `queryKey`(之前不需要)。强制集中化 queryKey 是本次重构的核心目标。
### 5.6 `useActionMutation` 重构
```typescript
// shared/hooks/use-action-mutation.ts重构后
"use client"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import type { ActionState } from "@/shared/types/action-state"
import { notify } from "@/shared/lib/notify"
import { CLIENT_INVALIDATION_MAP } from "@/shared/lib/cache/client-invalidation-map"
export interface UseActionMutationOptions<T> {
/** mutationFn */
mutationFn: () => Promise<ActionState<T>>
/** 关联的 INVALIDATION_MAP actionId成功后自动失效相关 queryKey */
actionId?: string
/** 模板参数,如 { id: "cls_123" } */
params?: Record<string, string>
/** 显式额外失效的 queryKey不通过 actionId 时使用) */
invalidateQueryKeys?: readonly (readonly (string | number | object)[])[]
successMessage?: string | false
errorMessage?: string | false
onSuccess?: (data: T | undefined) => void
onError?: (error: unknown) => void
}
export function useActionMutation<T = unknown>(options: UseActionMutationOptions<T>) {
const queryClient = useQueryClient()
const mutation = useMutation<ActionState<T>, Error, void>({
mutationFn: options.mutationFn,
onError: (error) => {
if (options.errorMessage !== false) {
notify.error(options.errorMessage ?? error.message)
}
options.onError?.(error)
},
onSuccess: async (result) => {
if (result.success) {
if (options.successMessage !== false) {
notify.success(options.successMessage ?? result.message ?? "Operation succeeded")
}
// 自动失效:根据 actionId 查 CLIENT_INVALIDATION_MAP 的客户端 queryKey 列表
const queryKeysToInvalidate = resolveClientInvalidations(options.actionId, options.params)
await Promise.all(
queryKeysToInvalidate.map((qk) => queryClient.invalidateQueries({ queryKey: qk }))
)
options.onSuccess?.(result.data)
} else {
if (options.errorMessage !== false) {
notify.error(options.errorMessage ?? result.message ?? "Operation failed")
}
options.onError?.(new Error(result.message ?? "Action returned failure"))
}
},
})
return { isWorking: mutation.isPending, mutate: mutation.mutateAsync }
}
```
### 5.7 客户端可见的失效映射子集
```typescript
// shared/lib/cache/client-invalidation-map.ts
/**
* 仅客户端 queryKey 失效映射INVALIDATION_MAP 的子集)。
* 从 invalidation-map.ts 提取 queryKeys 字段,避免客户端 bundle 拉入 revalidateTag 等 server-only。
*/
export const CLIENT_INVALIDATION_MAP = {
"classes.create": { queryKeys: [["classes", "list"]] },
"classes.update": { queryKeys: [["classes", "detail"], ["classes", "list"]] },
"classes.delete": { queryKeys: [["classes", "list"], ["classes", "detail"]] },
"classes.students.update": { queryKeys: [["classes", "students"]] },
"classes.schedule.update": { queryKeys: [["classes", "schedule"]] },
"classes.grade.update": { queryKeys: [["classes", "list"]] },
"classes.invitation.create": { queryKeys: [["classes", "invitations"]] },
"classes.invitation.revoke": { queryKeys: [["classes", "invitations"]] },
} as const
```
### 5.8 ESLint 强制规则
```javascript
// eslint.config.js节选
{
files: ["src/modules/**/actions*.ts", "src/app/api/**/route.ts"],
rules: {
"no-restricted-syntax": [
"error",
{
selector: "CallExpression[callee.name='revalidatePath']",
message: "使用 invalidateFor() 替代直接 revalidatePath(),参见 docs/architecture/004 缓存章节",
},
{
selector: "CallExpression[callee.name='revalidateTag']",
message: "使用 invalidateFor() 替代直接 revalidateTag(),参见 docs/architecture/004 缓存章节",
},
],
},
// 豁免shared/lib/cache/ 内部允许调用
overrides: [{
files: ["src/shared/lib/cache/**/*.ts"],
rules: { "no-restricted-syntax": "off" },
}],
}
```
---
## 6. classes 标杆迁移
### 6.1 data-access 迁移清单
| 文件 | 当前 react.cache 用法 | 迁移后 cacheFn 用法 |
|------|------|------|
| `data-access-teacher.ts` | `cache(getClassesByTeacherId)` 等 | `cacheFn(fn, { tags: ["classes", "classes:list"], ttl: 300 })` |
| `data-access-admin.ts` | `cache(getAllClassesForAdmin)` | `cacheFn(fn, { tags: ["classes", "classes:list"], ttl: 300 })` |
| `data-access-students.ts` | `cache(getStudentsByClass)` | `cacheFn(fn, { tags: ["classes:students:{classId}"], ttl: 60 })` |
| `data-access-stats.ts` | `cache(getClassStats)` | `cacheFn(fn, { tags: ["classes:stats"], ttl: 60 })` |
| `data-access-schedule.ts` | `cache(getScheduleByClass)` | `cacheFn(fn, { tags: ["classes:schedule:{classId}"], ttl: 600 })` |
| `data-access.ts` | 共享类型与行映射 | 不动(无 cache 调用) |
### 6.2 TTL 策略
| 资源 | TTL | 理由 |
|------|------|------|
| 列表/详情(`classes`, `classes:detail` | 300s | 容忍 5 分钟滞后,写后 tag 立即失效 |
| 学生名单(`classes:students:{classId}` | 60s | 转班/调班需较快感知 |
| 课表(`classes:schedule:{classId}` | 600s | 课表变更频率极低 |
| 统计(`classes:stats` | 60s | 聚合数据短 TTL 兜底 |
| 邀请码(`classes:invitations:{classId}` | 300s | 与列表一致 |
### 6.3 actions 迁移清单
| 文件 | 当前 revalidatePath | 迁移后 |
|------|------|------|
| `actions-teacher.ts` | `revalidatePath("/teacher/classes/my")` 等 | `invalidateFor("classes.update", { id })` |
| `actions-admin.ts` | 多处 revalidatePath | `invalidateFor("classes.create/update/delete")` |
| `actions-grade.ts` | `revalidatePath("/admin/grades")` | `invalidateFor("classes.grade.update")` |
| `actions-invitations.ts` | 多处 | `invalidateFor("classes.invitation.create/revoke")` |
| `actions-schedule.ts` | 多处 | `invalidateFor("classes.schedule.update")` |
### 6.4 双导出模式(修复 P2 单测可 mock 性)
```typescript
// data-access-teacher.ts
export const getClassByIdRaw = async (id: string) => {
return db.query.classes.findFirst({ where: eq(classes.id, id) })
}
export const getClassById = cacheFn(getClassByIdRaw, {
tags: ["classes:detail", "classes:detail:{id}"],
ttl: 300,
keyParts: ["classes", "by-id"],
})
```
测试时 import `getClassByIdRaw` 直接 mock绕过 cacheStore。
### 6.5 客户端组件迁移示范
```typescript
// app/(dashboard)/teacher/classes/my/[id]/page.tsx重构后
"use client"
import { useQuery } from "@tanstack/react-query"
import { queryKeys } from "@/shared/lib/query-keys"
import { getClassDetailAction } from "@/modules/classes/actions-teacher"
export default function ClassDetailPage({ params }: { params: { id: string } }) {
// 之前:手动 useState + useEffect + Action().then()
// 现在:声明式 useQuery自动跨页共享缓存
const { data: classDetail, isLoading } = useQuery({
queryKey: queryKeys.classes.detail(params.id),
queryFn: () => getClassDetailAction({ classId: params.id }),
})
// ...
}
```
**互操作验证**:当 `useActionMutation({ actionId: "classes.update" })` 成功后,`queryClient.invalidateQueries({ queryKey: ["classes", "detail"] })` 自动触发 refetch无需在 onSuccess 中手写 `router.refresh()`
---
## 7. 测试策略
| 类型 | 文件 | 验证目标 |
|------|------|---------|
| 单元 | `cache/memory-store.test.ts` | LRU 淘汰、TTL 过期、tag 索引维护 |
| 单元 | `cache/redis-store.test.ts` | mock Redis 客户端,验证 key 格式、SMEMBERS+DEL 批量删除 |
| 单元 | `cache/cache-fn.test.ts` | react.cache + cacheStore 双层包装keyParts 生成tags 传递 |
| 单元 | `cache/invalidation-map.test.ts` | 模板填充 `{id}` 占位符;未知 actionId 抛错 |
| 集成 | `cache/invalidate.test.ts` | invalidateFor 三步编排顺序store.invalidateTags → revalidateTag → revalidatePath |
| 集成 | `hooks/use-action-query.test.tsx` | QueryClient 集成queryKey 失效后自动 refetch |
| 集成 | `hooks/use-action-mutation.test.tsx` | actionId 关联自动 invalidate显式 invalidateQueryKeys 回退 |
| 标杆 | `modules/classes/**/*.test.ts` | 现有测试不破(仅替换 cache 包装) |
---
## 8. 架构文档同步
修改完成后同步以下文档(项目规则强制):
| 文档 | 修改内容 |
|------|---------|
| `docs/architecture/004_architecture_impact_map.md` | 新增"缓存基础设施"章节(位于 rate-limit 章节后);更新 classes 模块章节(标注 cacheFn 包装) |
| `docs/architecture/005_architecture_data.json` | `shared.lib.cache.*` 节点classes 模块的 `cacheUsage` 字段INVALIDATION_MAP 节点 |
| `docs/troubleshooting/known-issues.md` | 新增"缓存策略规则"章节cacheFn 用法、invalidateFor 强制、queryKey 工厂强制) |
---
## 9. 验收标准
| 标准 | 验证方法 |
|------|---------|
| `npm run lint` 零错误 | 含新增 ESLint 规则 |
| `npx tsc --noEmit` 零错误 | 含 cacheFn 类型推导 |
| classes 模块 6 个 data-access 全部 cacheFn 包装 | grep 验证 |
| classes 模块 5 个 actions 全部用 invalidateFor | ESLint 验证零 revalidatePath 直接调用 |
| classes 模块新增 2+ 单测通过 | `npm test cache memory-store` |
| Redis 模式下多实例缓存共享验证 | 手动CACHE_DRIVER=redis 启动两实例A 实例写后 B 实例读取验证 |
| 架构文档同步 | git diff 验证 004/005/known-issues 三个文件已更新 |
| rate-limit 模块迁移至 redis-client.ts 共享 | 现有 rate-limit 单测不破 |
---
## 10. 风险与缓解
| 风险 | 缓解 |
|------|------|
| `useActionQuery` 破坏性变更影响 11 个文件 | 本期仅迁移 classes 标杆页面;其他文件保持 useEffect 模式待下期TypeScript 编译会立即提示漏传 queryKey |
| `useActionMutation` 破坏性变更影响 50+ 文件 | 提供 `invalidateQueryKeys` 显式回退入口,不强制传 actionId旧调用方先迁移 onSuccess 的 toast 逻辑actionId 后续补 |
| Redis 故障导致缓存穿透 | fail-open 策略catch 错误 → 直查 DB与 rate-limit 一致 |
| 多实例下 tag 失效延迟 | Redis pub/sub 暂不引入;依赖 tag 索引在 Redis 中立即可见(同实例内 revalidateTag 立即生效;跨实例依赖下次请求读取 Redis |
| INVALIDATION_MAP 漏登记导致失效不全 | ESLint 规则 + 代码审查双重保障;新增写操作 PR 必须更新 INVALIDATION_MAP |
| `cacheFn` 包装函数 `this` 绑定丢失 | 仅包装纯函数data-access 不使用 `this`);签名约束 `extends (...args) => Promise<unknown>` |
---
## 11. 不在本期范围
- 其他 29 个模块的 data-access / actions 迁移(下期按模块推进)
- 其他客户端组件迁移到 useQuery下期按页面推进
- L1+L2 两级缓存YAGNI先用单层 Redis 验证)
- 缓存预热 / SWR 主动刷新YAGNI
- 缓存命中率监控埋点YAGNI
- Redis pub/sub 跨实例失效广播YAGNI单实例内 tag 失效已足够)
- 现有 `react.cache` 全量替换(保留作为请求级 memoization 内层)
---
## 12. 后续工作(下期预告)
1. 按模块推进其他 29 个模块的 data-access / actions 迁移
2. 按页面推进客户端组件迁移到 useQuery
3. 在 100+ 组件中替换 `toast.success/error``notify.success/error`V5 状态管理专项已建)
4. 评估缓存命中率监控Prometheus + Grafana
5. 评估引入 Redis pub/sub 实现跨实例实时失效广播(如跨实例失效延迟不可接受)
---
## 附录 A与 rate-limit 模块的复用关系
| 资产 | rate-limit 现状 | cache 复用方式 |
|------|----------------|---------------|
| `RATE_LIMIT_DRIVER` env | 已实现 | 新增 `CACHE_DRIVER` env独立控制 |
| `@upstash/redis` 动态 import | `redis-limiter.ts` 内部 `getRedisClient()` | 抽出至 `redis-client.ts`rate-limit + cache 共用 |
| `@upstash/ratelimit` 动态 import | `redis-limiter.ts` 内部 | 不复用cache 不需要 ratelimit |
| `upstash-modules.d.ts` | rate-limit 目录内 | 提升至 `shared/lib/upstash-modules.d.ts`rate-limit 与 cache 均从提升后的位置 import旧路径保留 re-export shim 避免破坏现有 import |
| fail-open 策略 | catch → 兜底返回 allow | catch → 直查 DB透传 producer |
---
## 附录 BqueryKey 命名约定
格式:`[module, resource, ...args]`
```typescript
queryKeys.classes.all // ["classes"]
queryKeys.classes.lists() // ["classes", "list"]
queryKeys.classes.list(filters) // ["classes", "list", filters]
queryKeys.classes.detail(id) // ["classes", "detail", id]
queryKeys.classes.students(classId) // ["classes", "students", classId]
queryKeys.classes.schedule(classId) // ["classes", "schedule", classId]
queryKeys.classes.stats(classId) // ["classes", "stats", classId]
queryKeys.classes.invitations(classId) // ["classes", "invitations", classId]
```
**约定**
- 第一段:模块名(与 `src/modules/[module]/` 一致)
- 第二段资源名复数list / detail / students / schedule / stats
- 后续段唯一标识id / classId或过滤条件对象
- 失效时使用前缀匹配:`invalidateQueries({ queryKey: ["classes", "detail"] })` 失效所有详情

View File

@@ -0,0 +1,380 @@
# 组件化重构专项设计文档
| 字段 | 值 |
|------|---|
| 文档版本 | v1 |
| 创建日期 | 2026-07-06 |
| 作者 | Trae 协作生成 |
| 状态 | 待用户审查 |
| 范围 | 巨型文件拆分9 个)+ 重复组件收敛49 个)+ 共享底座补全 |
| 策略 | 混合先收敛底座再按模块垂直闭环3 批风险分级) |
---
## 1. 背景与现状
### 1.1 巨型文件清单9 个,>400 行)
基于 2026-07-06 实际扫描:
| # | 路径 | 行数 | 模块 |
|---|------|------|------|
| 1 | `src/modules/textbooks/components/textbook-reader.tsx` | >450 | textbooks |
| 2 | `src/modules/lesson-preparation/components/lesson-plan-editor.tsx` | >450 | lesson-preparation |
| 3 | `src/modules/grades/components/batch-grade-entry.tsx` | >450 | grades |
| 4 | `src/modules/grades/components/grade-record-list.tsx` | >450 | grades |
| 5 | `src/modules/homework/components/homework-take-view.tsx` | 428 | homework |
| 6 | `src/modules/homework/components/homework-grading-view.tsx` | >450 | homework |
| 7 | `src/modules/exams/components/exam-assembly.tsx` | >450 | exams |
| 8 | `src/modules/ai/components/ai-chat-panel.tsx` | 417 | ai |
| 9 | `src/modules/textbooks/components/knowledge-graph-inner.tsx` | 411 | textbooks |
**已瘦身的 5 个文件**不在本次范围内exam-rich-form.tsx (384)、message-list.tsx (396)、selection-toolbar.tsx (366)、error-book-detail-dialog.tsx (336)、admin-users-view.tsx (332)。
### 1.2 重复组件清单49 个)
| 类型 | 数量 | 示例路径 |
|------|------|---------|
| `*-filters.tsx` | 20 | `grades/components/grade-filters.tsx` 等 |
| `*-stats-cards.tsx` / `*-stats-card.tsx` | 8 | `attendance/components/attendance-stats-cards.tsx` 等 |
| `*-skeleton.tsx` | 8 | `ai/components/ai-skeleton.tsx` 等 |
| `*-error-boundary.tsx` | 8 | `audit/components/audit-error-boundary.tsx` 等 |
| `*-dashboard.tsx` | 5 | `dashboard/components/admin-dashboard/admin-dashboard.tsx` 等 |
**已知冲突**
- `textbooks/components/section-error-boundary.tsx``shared/components/section-error-boundary.tsx` 同名
- `attendance/components/attendance-stats-card.tsx`(单数)与 `attendance-stats-cards.tsx`(复数)并存
### 1.3 共享底座现状(关键发现)
| 底座 | 状态 | 路径 | 当前接口 |
|------|------|------|---------|
| `FilterBar` | 已存在 | `shared/components/ui/filter-bar.tsx` (124 行) | children-based 组合式:`FilterBar` + `FilterSearchInput` + `FilterResetButton` |
| `StatCard` | 已存在 | `shared/components/ui/stat-card.tsx` (95 行) | 单卡片,无 grid 容器 |
| `Skeleton` | 已存在 | `shared/components/ui/skeleton.tsx` (15 行) | 仅原语,无 variant |
| `ErrorBoundary` | **缺失** | — | 有 3 个分散替代件:`section-error-boundary.tsx` (148)、`route-error.tsx` (44)、`widget-boundary.tsx` (155) |
### 1.4 架构文档缺口
`docs/architecture/005_architecture_data.json``lastUpdate` 仅记录缓存策略重构2026-07-05**未记录** `filter-bar`/`stat-card`/`widget-boundary` 等底座组件的存在,违反项目规则"新增组件必须同步 004/005"。
---
## 2. 目标与非目标
### 2.1 目标
1. **补全底座**:新增 `ui/error-boundary.tsx` 基础组件 + 收敛 3 个分散替代件为 preset
2. **扩展底座**:新增 `StatsGrid` 容器(基于 `StatCard`)、`SkeletonCard` variant基于 `Skeleton`
3. **拆分巨型文件**9 个 >400 行组件全部拆为容器+子组件,目标 ≤500 行
4. **迁移重复组件**49 个重复组件迁移到底座,删除旧实现(不留 backwards-compat shim
5. **同步架构文档**004 / 005 / known-issues.md 全量更新
### 2.2 非目标YAGNI
- `shared/db/schema.ts` (1111 行) 拆分 — 属于解耦专项
- `exams/actions.ts` (633 行) 拆分 — 属于解耦专项
- 各模块 `data-access.ts` 拆分 — 属于解耦专项
- `proctoring/exam-mode-config.tsx` 死代码清理 — 单独任务
- `useDialogState` 批量接入 — V5 状态专项收尾
- 跨模块直接 DB 查询扫描 — 解耦专项遗留
- FilterBar 接口改为配置驱动 — 当前 children-based 组合式更符合 React 模式,保持不变
- 单元测试编写 — 项目无单测覆盖,本次不引入
---
## 3. 架构设计
### 3.1 Phase 1底座收敛与补全
#### 3.1.1 ErrorBoundary 收敛方案
**新增** `src/shared/components/ui/error-boundary.tsx`
```tsx
interface ErrorBoundaryProps {
fallback: React.ReactNode | ((error: Error, reset: () => void) => React.ReactNode);
onError?: (error: Error, info: React.ErrorInfo) => void;
children: React.ReactNode;
}
export class ErrorBoundary extends React.Component<ErrorBoundaryProps, { hasError: boolean; error: Error | null }> {
// 标准 React ErrorBoundary 实现
}
```
**收敛 3 个替代件为 preset**(保留外部 API内部改用 `<ErrorBoundary>`
| 现有文件 | 收敛后 | 位置 |
|---------|-------|------|
| `shared/components/section-error-boundary.tsx` | `SectionErrorBoundary` preset | 保留原路径,内部改为 `<ErrorBoundary fallback={...}>` 包装 |
| `shared/components/route-error.tsx` | `RouteErrorBoundary` preset | 保留原路径 |
| `shared/components/widget-boundary.tsx` | `WidgetBoundary` preset | 保留原路径(含 Suspense + Skeleton 三合一) |
**模块层 8 个 `*-error-boundary.tsx`**:删除,改为直接使用 `SectionErrorBoundary` preset。
#### 3.1.2 StatsGrid 新增方案
**新增** `src/shared/components/ui/stats-grid.tsx`
```tsx
interface StatItem {
label: string;
value: string | number;
trend?: { direction: 'up' | 'down' | 'flat'; delta: string };
icon?: React.ComponentType<{ className?: string }>;
href?: string;
}
interface StatsGridProps {
items: StatItem[];
columns?: 2 | 3 | 4; // 默认响应式
isLoading?: boolean;
}
```
内部用 `StatCard` 渲染,`isLoading` 时渲染 `StatCardSkeleton`
#### 3.1.3 SkeletonCard variant 方案
**扩展** `src/shared/components/ui/skeleton.tsx`15 行 → ~80 行):
```tsx
interface SkeletonCardProps {
variant: 'table' | 'list' | 'chart' | 'form' | 'stats-grid';
rows?: number;
}
export function SkeletonCard({ variant, rows = 5 }: SkeletonCardProps) {
// 按 variant 渲染不同骨架布局
}
export function Skeleton(props: React.HTMLAttributes<HTMLDivElement>) {
// 保留原语
}
```
#### 3.1.4 FilterBar 与 StatCard
- `FilterBar`**保持现状**,不修改接口。模块层 `*-filters.tsx` 改为 `<FilterBar><FilterSearchInput .../><select .../></FilterBar>` 组合实现
- `StatCard`**保持现状**,作为 `StatsGrid` 内部使用
#### 3.1.5 Phase 1 交付物
| 文件 | 操作 |
|------|------|
| `shared/components/ui/error-boundary.tsx` | 新增 |
| `shared/components/ui/stats-grid.tsx` | 新增 |
| `shared/components/ui/skeleton.tsx` | 扩展(新增 SkeletonCard |
| `shared/components/ui/filter-bar.tsx` | 不修改 |
| `shared/components/ui/stat-card.tsx` | 不修改 |
| `shared/components/section-error-boundary.tsx` | 内部改用 ErrorBoundary |
| `shared/components/route-error.tsx` | 内部改用 ErrorBoundary |
| `shared/components/widget-boundary.tsx` | 内部改用 ErrorBoundary |
| `shared/components/index.ts` | 新增 re-export |
| `docs/architecture/004` / `005` | 同步底座节点 |
### 3.2 Phase 2模块垂直闭环
#### 3.2.1 批次划分
| 批次 | 模块 | 巨型文件 | 重复组件 | 风险 |
|------|------|---------|---------|------|
| **第 1 批** | grades | `batch-grade-entry.tsx``grade-record-list.tsx` | `grade-stats-card``grade-filters``grade-query-filters``analytics-filters` | 低 |
| | attendance | — | `attendance-stats-cards``attendance-stats-card`(单数,重复)、`attendance-filters` | 低 |
| | error-book | — | `error-book-stats-cards``error-book-filters``analytics-stats-cards` | 低 |
| | elective | — | `elective-stats-cards``elective-filters` | 低 |
| **第 2 批** | homework | `homework-take-view.tsx` (428)、`homework-grading-view.tsx` | `assignment-filters` | 中 |
| | exams | `exam-assembly.tsx` | `exam-filters` | 中 |
| | textbooks | `textbook-reader.tsx``knowledge-graph-inner.tsx` (411) | `textbook-filters``section-error-boundary.tsx`(同名冲突) | 中 |
| **第 3 批** | lesson-preparation | `lesson-plan-editor.tsx` | `lesson-plan-filters``lesson-plan-skeleton``lesson-plan-error-boundary` | 高 |
| | ai | `ai-chat-panel.tsx` (417) | `ai-skeleton``ai-error-boundary` | 高 |
| | dashboard | — | `dashboard-loading-skeleton`、4 个角色 dashboard 子组件(仅抽象布局壳) | 高 |
#### 3.2.2 单模块闭环流程6 步)
```
1. 模块审计 → Read 全部目标文件 + 重复组件,记录依赖与外部 API
2. 拆分巨型文件 → 拆为 容器 + 子组件(保持外部 API 不变)
3. 迁移到底座 → *-filters → FilterBar、*-stats-cards → StatsGrid、
*-skeleton → SkeletonCard、*-error-boundary → SectionErrorBoundary
4. 删除旧组件 → 直接删除,不留 backwards-compat shim
5. 验证 → tsc --noEmit + npm run lint + 手动回归
6. 文档同步 → 004 / 005 / known-issues.md
```
#### 3.2.3 跨批次依赖规则
- Phase 1 完成 → 启动第 1 批
- 第 1 批完成 → 评估底座接口是否需调整(允许返回 Phase 1 调整)
- 第 2 批启动前 → 第 1 批验证通过
- 第 3 批启动前 → 第 2 批验证通过(高风险模块需底座稳定)
#### 3.2.4 dashboard 4 角色抽象范围
仅抽象**布局壳**`<DashboardShell>` 含 PageHeader + StatsGrid 区 + 内容区),各角色业务逻辑保持独立。不强制 4 个 dashboard 使用相同内容区。
---
## 4. 验收标准
### 4.1 代码验收(硬性门禁)
| 检查项 | 命令 | 通过标准 |
|--------|------|---------|
| TypeScript | `npx tsc --noEmit` | 零错误 |
| ESLint | `npm run lint` | 零新增错误pre-existing warnings 不计入) |
| 文件行数 | Read 确认 | 9 个巨型文件拆分后 ≤500 行 |
| 重复组件删除 | Grep 旧组件名 | 全局无残留引用 |
| 外部 API 不变 | Grep 模块导出名 | 容器组件对外 API 签名不变 |
| 设计令牌合规 | Grep `#hex``'Inter'``w-[` | 零违规(豁免场景除外) |
### 4.2 行为验收(手动回归)
| 模块类型 | 验收方式 |
|---------|---------|
| 列表/筛选页 | 进入页面 → 应用筛选 → 重置 → 翻页 → 数据正确 |
| 表单页 | 打开 Dialog → 填写 → 提交 → 列表刷新 |
| 编辑器 | 进入 → 编辑内容 → 保存 → 重新加载内容存在 |
| dashboard | 4 角色分别登录 → 卡片渲染 → 数据正确 |
### 4.3 文档同步
- `004_architecture_impact_map.md`:模块章节组件清单 + shared/components 章节 + 文末"组件化重构专项"章节
- `005_architecture_data.json``modules.*.exports` + `sharedComponents` + `lastUpdate` + `dependencyMatrix`
- `known-issues.md`:每批新增章节,速查手册风格记录底座使用规则与拆分模式
### 4.4 Commit 规范
每批一个 squash commit
```
refactor(grades,attendance,error-book,elective): 组件化重构第 1 批
- 新增 ErrorBoundary/StatsGrid/SkeletonCard 底座
- 拆分 grades/batch-grade-entry.tsx (XXX → YYY 行)
- 删除 12 个重复组件,迁移到底座
- 同步 004/005 架构图known-issues 新增章节
验收tsc + lint 零错误,手动回归 4 模块页面通过
```
---
## 5. 风险与缓解
| 风险 | 缓解 |
|------|------|
| 底座接口设计不准 → 返工 | 第 1 批低风险模块验证后允许调整底座 |
| 巨型文件拆分破坏外部 API | 容器保持外部 API 不变,仅内部拆子组件 |
| 删除旧组件后引用未清理 | tsc + lint + grep 三重校验 |
| textbooks 同名 `section-error-boundary.tsx` 冲突 | 模块层版本删除,改用 shared 层 |
| 富文本/编辑器拆分难度高 | textbooks 按 `003_ui_refactoring_plan.md` 已有方案执行 |
| dashboard 4 角色抽象范围蔓延 | 仅抽象布局壳,业务逻辑保持独立 |
---
## 6. 失败回滚策略
- 模块闭环中暴露底座接口问题 → 停止该模块,回 Phase 1 调整底座,已完成模块不回滚
- 模块闭环中暴露业务逻辑问题(非组件化引起)→ 不在本次范围,记录到 known-issues组件化继续推进
---
## 7. 完成定义Definition of Done
- [ ] Phase 1 完成ErrorBoundary/StatsGrid/SkeletonCard 落地3 个替代件收敛为 preset
- [ ] 第 1 批完成grades/attendance/error-book/elective 闭环
- [ ] 第 2 批完成homework/exams/textbooks 闭环
- [ ] 第 3 批完成lesson-preparation/ai/dashboard 闭环
- [ ] 9 个巨型文件全部 ≤500 行
- [ ] 49 个重复组件全部删除并迁移到底座
- [ ] 004 / 005 / known-issues 同步更新
- [ ] 3 个批次 commit 全部提交
---
## 8. 附录:完整重复组件清单
### 8.1 `*-filters.tsx`20 个)
```
shared/components/question/question-bank-filters.tsx
modules/grades/components/grade-query-filters.tsx
modules/grades/components/grade-filters.tsx
modules/grades/components/analytics-filters.tsx
modules/classes/components/students-filters.tsx
modules/classes/components/schedule-filters.tsx
modules/attendance/components/attendance-filters.tsx
modules/error-book/components/error-book-filters.tsx
modules/exams/components/exam-filters.tsx
modules/audit/components/data-change-log-filters.tsx
modules/audit/components/login-log-filters.tsx
modules/audit/components/audit-log-filters.tsx
modules/lesson-preparation/components/lesson-plan-filters.tsx
modules/homework/components/assignment-filters.tsx
modules/questions/components/question-filters.tsx
modules/student/components/course-filters.tsx
modules/student/components/student-schedule-filters.tsx
modules/school/components/grade-insights-filters.tsx
modules/textbooks/components/textbook-filters.tsx
modules/elective/components/elective-filters.tsx
```
### 8.2 `*-stats-cards.tsx` / `*-stats-card.tsx`8 个)
```
modules/error-book/components/analytics-stats-cards.tsx
modules/error-book/components/error-book-stats-cards.tsx
modules/attendance/components/attendance-stats-cards.tsx
modules/attendance/components/attendance-stats-card.tsx ← 单数,与复数重复
modules/elective/components/elective-stats-cards.tsx
modules/adaptive-practice/components/practice-overview-stats-cards.tsx
modules/adaptive-practice/components/practice-stats-cards.tsx
modules/grades/components/grade-stats-card.tsx
```
### 8.3 `*-skeleton.tsx`8 个)
```
modules/messaging/components/message-list-skeleton.tsx
modules/announcements/components/announcement-list-skeleton.tsx
modules/audit/components/audit-log-table-skeleton.tsx
modules/classes/components/class-skeleton.tsx
modules/ai/components/ai-skeleton.tsx
modules/school/components/school-skeleton.tsx
modules/dashboard/components/dashboard-loading-skeleton.tsx
modules/lesson-preparation/components/lesson-plan-skeleton.tsx
```
### 8.4 ErrorBoundary 相关文件10 个)
**shared 层 preset3 个,保留并内部收敛到 `<ErrorBoundary>`**
```
shared/components/section-error-boundary.tsx
shared/components/route-error.tsx
shared/components/widget-boundary.tsx
```
**模块层待删除7 个,改为直接使用 `SectionErrorBoundary` preset**
```
modules/audit/components/audit-error-boundary.tsx
modules/school/components/school-error-boundary.tsx
modules/classes/components/class-error-boundary.tsx
modules/textbooks/components/section-error-boundary.tsx ← 与 shared 同名,删除
modules/settings/components/settings-section-error-boundary.tsx
modules/ai/components/ai-error-boundary.tsx
modules/lesson-preparation/components/lesson-plan-error-boundary.tsx
```
### 8.5 `*-dashboard.tsx`5 个)
```
modules/ai/components/ai-usage-dashboard.tsx
modules/dashboard/components/parent-dashboard/parent-dashboard.tsx
modules/dashboard/components/admin-dashboard/admin-dashboard.tsx
modules/proctoring/components/proctoring-dashboard.tsx
modules/exams/components/exam-analytics-dashboard.tsx
```

View File

@@ -0,0 +1,771 @@
# 文档体系重设计
> 日期2026-07-07
> 状态:待用户审阅
> 范围:全局规范文档(`project_rules.md` / `coding-standards.md` / `004` / `005` / `known-issues.md`+ 新增架构元数据库 + 模块工作手册
> 唯一源原则004 为架构设计意图唯一源arch.db 为代码结构唯一源known-issues.md 为经验唯一源
---
## 一、设计目标
### 1.1 核心诉求
1. **审核并修正现有规范文档**的合规性、遗漏、技术错误
2. **重新设计文档体系结构**,消除职责重叠,让每份文档只回答一类问题
3. **建立架构元数据库**arch.db让 AI 快速查询模块/函数/调用链/依赖关系,无需扫描整个代码库
4. **建立 AI 自我演进机制**,让 AI 越工作越了解项目,后来者可接力而非重新梳理
### 1.2 设计原则
| 原则 | 含义 |
|------|------|
| 单一来源 | 每类信息只有一个权威文档,其他文档引用而非重复 |
| 职责分离 | 每份文档只回答一类问题What / Why / How / When |
| 贴近代码 | 模块工作手册放在 `src/modules/[module]/README.md`,与代码同生命周期 |
| 自动优于手动 | 代码结构由扫描器自动生成arch.db减少人工维护 |
| 信任但验证 | AI 使用经验前必须审核,文档变更后重新审核 |
### 1.3 不做事项YAGNI
- 不建 Monorepo项目明确为单应用 + 模块化)
- 不引入 Storybook当前无需求
- 不建独立文档站点GitHub/Gitea 直接渲染 Markdown 足够)
- 不做实时热更新架构库CI + AI 工作前手动触发足够)
---
## 二、文档体系拓扑
### 2.1 整体结构
```
项目根/
├─ .trae/rules/project_rules.md # 全局硬性规则(强制约束)
├─ docs/
│ ├─ standards/coding-standards.md # 编码规范How to write code
│ ├─ architecture/
│ │ ├─ 004_architecture_impact_map.md # 架构设计意图Why - 瘦身后约 500 行)
│ │ ├─ 006_k12_feature_checklist.md # 功能模块清单(保留)
│ │ ├─ 007_gap_audit_report.md # 差距审计(保留)
│ │ ├─ 008_module_role_mapping.md # 模块角色映射(保留)
│ │ ├─ roadmap/ # 【新增】长远规划
│ │ │ ├─ README.md # 路线图索引
│ │ │ ├─ tech-debt.md # 技术债清单(从 004 第三部分迁入)
│ │ │ ├─ decoupling.md # 解耦路线图(从 audit/01 迁入)
│ │ │ └─ pending-features.md # 待开发功能(从 004 "未完成项"迁入)
│ │ └─ audit/archive/ # 【新增】历史审查报告归档(只读)
│ │ ├─ 005_architecture_data.json # 005 废弃后归档于此
│ │ └─ (现有 60+ 份 audit 报告迁入)
│ └─ troubleshooting/
│ └─ known-issues.md # 经验库(精简为索引式,无代码示例)
├─ src/modules/[module]/
│ └─ README.md # 【新增】模块工作手册(每模块一份)
└─ scripts/arch-scan/ # 【新增】架构扫描器
├─ scanner.ts # ts-morph 扫描器
├─ schema.ts # SQLite schema 定义
├─ query.ts # 查询函数
├─ cli.ts # CLI 入口
└─ arch.db # 生成的 SQLitegit 提交)
```
### 2.2 文档职责边界
| 文档 | 类型 | 回答的问题 | 谁维护 | 内容禁区 |
|------|------|-----------|--------|---------|
| `arch.db` | 自动生成 | What代码结构是什么 | 扫描器 | 无人为语义 |
| `004` 架构图 | 人类可读 | Why架构为什么这样设计 | 人/AI | 不含代码结构、不含规划、不含教程 |
| `project_rules.md` | 硬性规则 | Must必须遵守什么 | 人 | 不含详细规范、不含架构描述 |
| `coding-standards.md` | 编码规范 | How to write怎么写代码 | 人 | 不含架构事实、不含经验 |
| `known-issues.md` | 经验库 | Experience场景→技术、工作经验 | AI 思考后更新 | 不含代码示例、不含错误示范 |
| `modules/[m]/README.md` | 模块上下文 | How to work怎么上手模块 | 人/AI | 不重复 arch.db 的代码结构、不含经验 |
| `roadmap/*` | 规划 | When未来做什么 | 人/AI | 不含架构事实、不含经验 |
| `audit/archive/*` | 历史参考 | Past过去发现了什么 | 只读归档 | 不再更新 |
### 2.3 三类信息源的互补关系
```
arch.db (What) ── AI 查询代码结构、调用关系、依赖
↓ 基于代码生成
004 架构图 (Why) ── 人理解架构设计意图
↓ 解释决策
模块手册 (How to work) ── 人/AI 上手模块的工作流程
↓ 记录经验
known-issues (Experience) ── 遇到问题时查技术方向
```
### 2.4 废弃文档
| 文档 | 处理 | 理由 |
|------|------|------|
| `005_architecture_data.json` | 废弃,归档到 `audit/archive/` | arch.db 自动生成比手维护 JSON 准确 |
| `docs/architecture/audit/01_decoupling_roadmap.md` | 迁移到 `roadmap/decoupling.md` | 属于规划而非审查报告 |
| `docs/architecture/audit/` 下 60+ 份审查报告 | 迁移到 `audit/archive/`,只读归档 | 历史参考,不再更新 |
---
## 三、arch.db 架构元数据库
### 3.1 设计目标
让 AI 无需扫描整个代码库即可精准查询:
- 某模块的所有导出函数
- 某函数被谁调用(逆向追踪,递归)
- 某函数调用了什么(正向追踪,递归)
- 某模块依赖哪些模块(递归)
- 哪些地方用了某技术(如 cacheFn
- 架构违规检测超长文件、Server Action 缺权限校验等)
- 完整调用链(从 UI 到 DB
### 3.2 技术选型
| 决策 | 选择 | 理由 |
|------|------|------|
| 存储 | SQLite | 嵌入式、零配置、.db 文件可 git 提交、AI 可直接 sqlite3 查询 |
| 扫描器 | ts-morph | TypeScript AST 操作库,支持类型推导,能提取调用关系 |
| 扫描粒度 | 全调用图 | 模块 + 函数 + 类型 + 函数间调用关系,覆盖 UE 引用查看器所有用法 |
| 触发时机 | CI 自动 + 本地手动 + AI 工作前强制 | 三重保障架构库与代码同步 |
| 数据源 | 自动扫描代码 | 零人工维护,与代码同步 |
### 3.3 SQLite Schema
```sql
-- ============ 核心实体 ============
-- 1. 模块src/modules/* 下的目录)
CREATE TABLE modules (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
path TEXT NOT NULL,
description TEXT,
layer TEXT NOT NULL, -- "modules" | "shared" | "app" | "root"
created_at TEXT NOT NULL
);
-- 2. 文件
CREATE TABLE files (
id INTEGER PRIMARY KEY,
module_id INTEGER REFERENCES modules(id),
path TEXT NOT NULL UNIQUE,
kind TEXT NOT NULL, -- "actions" | "data-access" | "schema" | "types" | "component" | "hook" | "lib" | "config" | "route" | "page" | "layout"
lines INTEGER,
is_server INTEGER DEFAULT 0,
is_client INTEGER DEFAULT 0,
has_server_only INTEGER DEFAULT 0
);
-- 3. 导出符号(函数、类、类型、常量、组件)
CREATE TABLE symbols (
id INTEGER PRIMARY KEY,
file_id INTEGER NOT NULL REFERENCES files(id),
name TEXT NOT NULL,
kind TEXT NOT NULL, -- "function" | "class" | "type" | "interface" | "const" | "component"
is_exported INTEGER DEFAULT 0,
is_async INTEGER DEFAULT 0,
is_server_action INTEGER DEFAULT 0,
signature TEXT,
start_line INTEGER,
end_line INTEGER,
UNIQUE(file_id, name, start_line)
);
-- ============ 关系 ============
-- 4. 调用关系(符号间调用)
CREATE TABLE calls (
id INTEGER PRIMARY KEY,
caller_id INTEGER NOT NULL REFERENCES symbols(id),
callee_id INTEGER REFERENCES symbols(id),
callee_external TEXT, -- 项目外调用(如 "fetch", "console.log"
call_line INTEGER,
count INTEGER DEFAULT 1
);
-- 5. 文件级导入
CREATE TABLE file_imports (
id INTEGER PRIMARY KEY,
source_file_id INTEGER NOT NULL REFERENCES files(id),
imported_file_id INTEGER REFERENCES files(id),
import_path TEXT NOT NULL,
is_type_only INTEGER DEFAULT 0,
imported_names TEXT
);
-- 6. 模块间依赖(聚合视图)
CREATE TABLE module_deps (
id INTEGER PRIMARY KEY,
source_module_id INTEGER NOT NULL REFERENCES modules(id),
target_module_id INTEGER NOT NULL REFERENCES modules(id),
dep_type TEXT NOT NULL, -- "import" | "data-access-call" | "action-call"
UNIQUE(source_module_id, target_module_id, dep_type)
);
-- ============ 业务元数据 ============
-- 7. 技术标签
CREATE TABLE tech_tags (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
category TEXT
);
-- 8. 符号-技术标签关联
CREATE TABLE symbol_tech_tags (
symbol_id INTEGER NOT NULL REFERENCES symbols(id),
tag_id INTEGER NOT NULL REFERENCES tech_tags(id),
PRIMARY KEY(symbol_id, tag_id)
);
-- 9. 权限点
CREATE TABLE permissions (
id INTEGER PRIMARY KEY,
key TEXT NOT NULL UNIQUE,
description TEXT
);
-- 10. 路由
CREATE TABLE routes (
id INTEGER PRIMARY KEY,
path TEXT NOT NULL UNIQUE,
kind TEXT NOT NULL, -- "page" | "api" | "layout"
file_id INTEGER REFERENCES files(id),
min_permission TEXT
);
-- 11. 数据库表
CREATE TABLE db_tables (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
module_id INTEGER REFERENCES modules(id),
description TEXT
);
-- ============ 扫描元数据 ============
-- 12. 扫描元数据
CREATE TABLE scan_meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
```
### 3.4 关键索引
```sql
CREATE INDEX idx_symbols_file ON symbols(file_id);
CREATE INDEX idx_symbols_name ON symbols(name);
CREATE INDEX idx_calls_caller ON calls(caller_id);
CREATE INDEX idx_calls_callee ON calls(callee_id);
CREATE INDEX idx_file_imports_source ON file_imports(source_file_id);
CREATE INDEX idx_file_imports_target ON file_imports(imported_file_id);
CREATE INDEX idx_symbol_tech_tags_tag ON symbol_tech_tags(tag_id);
```
### 3.5 技术标签自动识别规则
扫描器通过启发式规则自动打标签:
| 标签 | 识别规则 | 类别 |
|------|---------|------|
| `cacheFn` | 文件内出现 `cacheFn(` 调用 | cache |
| `zustand` | import 自 `zustand` | state |
| `useOptimistic` | 文件内出现 `useOptimistic` 调用 | state |
| `react-hook-form` | import 自 `react-hook-form` | form |
| `TanStack Query` | import 自 `@tanstack/react-query` | state |
| `Server Action` | 文件顶部 `"use server"` 或函数级 `"use server"` | server |
| `Tiptap` | import 自 `@tiptap/*` | ui |
| `Drizzle` | import 自 `drizzle-orm` | db |
| `nuqs` | import 自 `nuqs` | state |
| `recharts` | import 自 `recharts` | ui |
### 3.6 递归查询能力6 类)
#### 查询 1逆向递归谁调用了 X递归到入口
```sql
WITH RECURSIVE upstream(caller_id, caller_name, caller_path, depth, path_chain) AS (
SELECT c.caller_id, s.name, f.path, 0, s.name
FROM calls c
JOIN symbols s ON c.caller_id = s.id
JOIN files f ON s.file_id = f.id
WHERE c.callee_id = (SELECT id FROM symbols WHERE name = ? LIMIT 1)
UNION
SELECT c.caller_id, s.name, f.path, u.depth + 1, u.path_chain || '' || s.name
FROM upstream u
JOIN calls c ON c.callee_id = u.caller_id
JOIN symbols s ON c.caller_id = s.id
JOIN files f ON s.file_id = f.id
WHERE u.depth < 10
AND u.caller_id NOT IN (SELECT caller_id FROM upstream)
)
SELECT caller_name, caller_path, depth, path_chain FROM upstream
ORDER BY depth, caller_path;
```
#### 查询 2正向递归X 调用了什么?递归到叶子)
```sql
WITH RECURSIVE downstream(callee_id, callee_name, callee_path, depth, path_chain) AS (
SELECT c.callee_id, s.name, f.path, 0, ?
FROM calls c
JOIN symbols s ON c.callee_id = s.id
JOIN files f ON s.file_id = f.id
WHERE c.caller_id = (SELECT id FROM symbols WHERE name = ? LIMIT 1)
UNION
SELECT c.callee_id, s.name, f.path, d.depth + 1, d.path_chain || '' || s.name
FROM downstream d
JOIN calls c ON c.caller_id = d.callee_id
JOIN symbols s ON c.callee_id = s.id
JOIN files f ON s.file_id = f.id
WHERE d.depth < 10
AND d.callee_id NOT IN (SELECT callee_id FROM downstream)
)
SELECT callee_name, callee_path, depth, path_chain FROM downstream
ORDER BY depth, callee_path;
```
#### 查询 3模块级逆向谁依赖了模块 X递归到根
```sql
WITH RECURSIVE mod_upstream(source_module, target_module, depth, path_chain) AS (
SELECT m.name, m2.name, 0, m.name || '' || m2.name
FROM module_deps md
JOIN modules m ON md.target_module_id = m.id
JOIN modules m2 ON md.source_module_id = m2.id
WHERE m.name = ?
UNION
SELECT mu.source_module, m.name, mu.depth + 1, mu.path_chain || '' || m.name
FROM mod_upstream mu
JOIN module_deps md ON md.target_module_id = (
SELECT id FROM modules WHERE name = mu.source_module
)
JOIN modules m ON md.source_module_id = m.id
WHERE mu.depth < 10
)
SELECT DISTINCT source_module, target_module, depth, path_chain
FROM mod_upstream ORDER BY depth;
```
#### 查询 4模块级正向模块 X 依赖了哪些模块?递归到叶子)
类似查询 3方向反转从 source_module_id 出发递归 target_module_id。实施时补全完整 SQL。
#### 查询 5双向依赖检测模块 A 和 B 之间是否有循环依赖)
同时执行查询 3A→B和查询 4B→A若两方向都存在路径则存在循环依赖。实施时封装为单一查询函数。
#### 查询 6完整调用链UI → Server Action → data-access → DB
```sql
WITH RECURSIVE trace(symbol_id, symbol_name, file_path, kind, depth, path_chain) AS (
-- 入口UI 事件处理函数handle*, onSubmit*, onClick*
SELECT s.id, s.name, f.path, f.kind, 0, s.name
FROM symbols s JOIN files f ON s.file_id = f.id
WHERE s.name LIKE 'handle%' OR s.name LIKE 'onSubmit%' OR s.name LIKE 'onClick%'
UNION
SELECT cs.id, cs.name, cf.path, cf.kind, t.depth + 1, t.path_chain || '' || cs.name
FROM trace t
JOIN calls c ON c.caller_id = t.symbol_id
JOIN symbols cs ON c.callee_id = cs.id
JOIN files cf ON cs.file_id = cf.id
WHERE t.depth < 15
AND t.symbol_id NOT IN (SELECT symbol_id FROM trace)
)
SELECT * FROM trace
WHERE kind IN ('actions', 'data-access')
ORDER BY depth, path_chain;
```
### 3.7 CLI 接口
```bash
# 1. 更新架构库
npm run arch:scan
# 2. 查询命令
npm run arch:query ref <symbolName> [--forward] [--depth=10] # 符号引用(默认逆向)
npm run arch:query module <moduleName> [--reverse] [--depth=10] # 模块依赖
npm run arch:query tech <techTag> # 技术使用
npm run arch:query path <moduleA> <moduleB> # 模块间路径
npm run arch:query violations # 架构违规
npm run arch:query trace <entrySymbol> # 完整调用链
npm run arch:query sql "<SQL>" # 自由 SQL
npm run arch:query repl # 交互式
```
### 3.8 输出格式
默认输出**树形可视化**(模仿 UE 引用查看器),可选 `--json` 输出机器可读格式:
```
$ npm run arch:query ref createExam
▼ createExam (src/modules/exams/data-access.ts#L42)
├─▼ createExamAction (src/modules/exams/actions.ts#L18) [Server Action]
│ │
│ └─▼ handleCreateExam (src/modules/exams/components/exam-form.tsx#L67) [Client]
│ │
│ └── <Form onSubmit> (src/modules/exams/components/exam-form.tsx#L120)
└─▼ importExams (src/modules/exams/import-export.ts#L234)
```
---
## 四、AI 自我演进机制
### 4.1 模块 README.md 标准结构
```markdown
# [模块名] 模块工作手册
> 经验查 known-issues.md代码结构查 arch.db本文件只记工作流程。
## 模块职责
一句话描述本模块做什么。
## 核心工作流程
1. 新增考试: ...
2. 修改成绩计算: ...
## 关键约束
- [不可违反的约束]
- [依赖关系,从 arch.db 提取]
## 架构决策(为什么这样设计)
- **为什么用 X 而不用 Y**: [决策理由]
```
### 4.2 known-issues.md 结构(唯一经验库)
```markdown
# 项目经验库
> AI 工作前必读使用经验前必审核。AI 发现更好办法时更新本文件。
> 最后审核: 2026-07-07 14:00 (commit: abc1234)
## 全局经验
### 缓存策略
| 场景 | 技术方向 | 模块 | 备注 |
|------|---------|------|------|
| 服务端数据缓存 | cacheFn + Redis | 全局 | 详见 arch.db tech_tags |
| 客户端数据缓存 | TanStack Query | 全局 | 禁止 useEffect+fetch |
### 状态管理
| 场景 | 技术方向 | 模块 | 备注 |
|------|---------|------|------|
| URL 状态 | nuqs | 全局 | 5 层状态模型 L1 |
| 表单状态 | react-hook-form + zodResolver | 全局 | L5 层 |
## 模块经验: exams
### 考试创建流程
| 场景 | 技术方向 | 备注 |
|------|---------|------|
| 考试数据缓存 | cacheFn 包裹 createExamRaw | 修改后须失效 importExams 缓存 |
| AI 题目解析 | 动态 import + webpackIgnore | 可选依赖 ollama |
## 模块经验: grades
...
## 工作经验日志(按时间倒序,定期提炼到上述分区)
### 2026-07-07 重构 createExam 调用链
- **模块**: exams
- **做了什么**: 拆分 createExam 为 createExamRaw + createExam(含缓存)
- **学到什么**: data-access 层已有 cacheFnactions 层无需再缓存
- **下次注意**: 修改 createExam 必须同步更新 importExams 的缓存失效
- **审核状态**: 待审核
```
### 4.3 known-issues.md 精简规则
| 内容类型 | 处理 |
|---------|------|
| 代码示例(多行代码块) | 删除,改为"技术方向"描述 |
| 错误示范 | 删除,只保留"正确做法" |
| 重复的架构规则 | 删除,引用 004/project_rules |
| "场景 → 技术"映射 | 保留,按模块分区 |
| 工作经验日志 | 保留(追加区) |
### 4.4 AI 工作强制流程(写入 project_rules.md
```
AI 进入项目工作流程(强制,违反即违规):
阶段 1: 上下文加载
1.1 npm run arch:scan # 更新 arch.db
1.2 npm run arch:query module <目标模块> # 查模块依赖
1.3 npm run arch:query ref <目标函数> --forward # 查调用链
1.4 阅读 src/modules/[模块]/README.md # 读模块工作流程
1.5 查 known-issues.md "模块经验: <模块>" 分区 # 读相关经验
1.5.1 审核相关经验(检查代码是否仍匹配)
1.5.2 若文档自上次审核后已变更 → 重新审核并标记
1.5.3 审核通过 → 使用;失败 → 标记失效,不使用
阶段 2: 执行工作
2.1 按规划执行
2.2 修改代码后立即运行 arch:scan
阶段 3: 经验沉淀(强制,不可跳过)
3.1 在 known-issues.md "工作经验日志" 区追加一条记录:
- 做了什么
- 学到什么
- 下次注意事项
- 审核状态: 待审核
3.2 若发现新的"场景→技术"映射 → 提炼到对应模块分区
3.3 若发现新的架构决策 → 更新 004
3.4 若代码结构变化 → arch:scan 确认 arch.db 已更新
阶段 4: 提交后审核(人工)
4.1 人工审查"待审核"日志条目
4.2 通过 → 标记"已审核 (commit, 审核人)"
4.3 失败 → 标记"审核失败,原因:..."
4.4 定期(如每两周)将成熟日志提炼到分区表格
```
### 4.5 信任但验证机制
```
AI 读取 known-issues.md 经验
检查该条经验的"审核状态":
├─ 已审核 → 检查代码是否仍匹配
│ ├─ 匹配 → 使用经验
│ └─ 不匹配 → 标记"待重新审核",不使用
├─ 待审核 → 标记"AI 使用前审核",验证后使用
└─ 审核失败 → 不使用,记录原因
使用经验工作时,若发现经验有误 → 标记"审核失败,原因:..."
```
### 4.6 防 known-issues.md 膨胀
- **工作经验日志区上限 50 条**——超过则人工提炼最早的到分区表格,删除原日志
- **分区表格无上限**——但每条保持单行索引式
- **精简目标**:从 1317 行降至约 300 行
---
## 五、004 瘦身方案
### 5.1 现状问题
004 当前 4227 行,远超架构文档应有体量。主要问题:
| 问题 | 表现 | 行数估算 |
|------|------|---------|
| 混入工作日志 | "1.1.1 M7 移动端 PWA 支持2026-07-01 新增)"等 7 个变更日志章节 | ~400 行 |
| 混入规划/待办 | "未完成项(待后续专项)"、各模块的 P0/P1/P2 修复标记 | ~600 行 |
| 混入实现细节 | 函数签名索引、文件行数表格、组件清单 | ~1500 行 |
| 模块清单冗长 | 27 个模块每个都用大段文字描述,含"V4 P2-4 已修复"等历史 | ~1500 行 |
| 真正的架构内容 | 分层图、依赖关系图、数据流向图、核心原则 | ~227 行 |
### 5.2 瘦身后目标结构(约 500 行)
```markdown
# Next_Edu 架构影响地图
> 唯一源:项目架构事实。代码结构查 arch.db经验查 known-issues.md规划查 roadmap/。
## 1. 分层架构
- 三层架构图app → modules → shared
- 分层规则4 条核心约束)
- 根模块说明auth.ts, proxy.ts
## 2. 模块清单
(表格形式,每模块一行,详情查 arch.db 和模块 README
| 模块 | 职责 | 核心依赖 | 被依赖 | README |
|------|------|---------|--------|--------|
| exams | 考试管理 | grades, classes, questions | dashboard | [README](../../src/modules/exams/README.md) |
| ... | ... | ... | ... | ... |
## 3. 模块依赖关系图
- 核心业务模块依赖图
- 扩展模块依赖图
- (循环依赖检测见 arch.db 查询)
## 4. 数据流向(核心场景)
- 考试流程数据流
- 学生提交作业数据流
- 仪表盘聚合数据流
## 5. 核心架构原则
- 三层架构单向依赖
- 模块间通过 data-access 通信
- Server Action 必须权限校验
- 设计令牌分层Primitive → Semantic → Tailwind
## 6. 设计令牌体系
- 文件分布src/app/styles/tokens/
- 令牌分层规则
- 强制约束(禁止硬编码颜色/字体/字号)
## 相关文档
- [arch.db 查询](../../scripts/arch-scan/) - 代码结构
- [known-issues.md](../troubleshooting/known-issues.md) - 经验库
- [roadmap/](./roadmap/) - 规划
- [模块 README](../../src/modules/) - 模块工作流程
```
### 5.3 迁移映射
| 004 现有内容 | 去向 | 理由 |
|------------|------|------|
| 1.1.1-1.1.7 变更日志章节 | 删除git 历史已记录) | 工作日志不属于架构事实 |
| "Phase X.X 新增"标记 | 删除 | 同上 |
| "P0-X 已修复"标记 | 删除(保留事实,删除修复历史) | 修复历史属于 git log |
| "未完成项(待后续专项)" | 迁移到 `roadmap/tech-debt.md` | 属于规划 |
| 各模块的"V1/V2/V3/V4"版本描述 | 删除,只保留当前状态 | 版本演进属于 git log |
| 函数签名索引(附录 C | 删除(查 arch.db | 代码结构属于 arch.db |
| 模块间依赖矩阵(附录 A | 删除(查 arch.db `module_deps` | 同上 |
| 关键参数影响链(附录 B | 保留(架构决策) | 属于架构意图 |
| 第三部分"已知架构问题和技术债" | 迁移到 `roadmap/tech-debt.md` | 属于规划 |
| 模块清单(第二部分) | 大幅精简为表格 | 详情查 arch.db + README |
---
## 六、规范文档修正要点
### 6.1 已发现的跨文档冲突
| 冲突项 | project_rules.md | coding-standards.md | 004 | 修正方向 |
|--------|-----------------|---------------------|-----|---------|
| 设计令牌位置 | `src/app/styles/tokens/` | `globals.css` | `src/app/styles/tokens/` | 统一为 `src/app/styles/tokens/`(以 004 为准) |
| 架构文档清单 | 仅列 004-008 + audit/01 | 无 | 自身 | project_rules.md 补全 001/002/003/008 |
| 002 编号冲突 | 无 | 无 | 无 | `002_rbac_refactoring.md``002_role_based_routing.md` 编号冲突,需重命名 |
| 缓存策略描述 | 无 | "用 unstable_cache" | 已迁移到 cacheFn | coding-standards.md 更新为 cacheFn |
### 6.2 project_rules.md 修正
1. **架构文档清单补全**:加入 001/002/003/008
2. **新增 AI 工作流程规则**:写入第四节"AI 工作强制流程"
3. **新增 arch.db 规则**AI 工作前必须 `npm run arch:scan`
4. **令牌位置统一**:与 004 一致,明确为 `src/app/styles/tokens/`
### 6.3 coding-standards.md 修正
1. **令牌位置统一**:从 `globals.css` 改为 `src/app/styles/tokens/`
2. **缓存策略更新**:从 `unstable_cache` 改为 `cacheFn`
3. **删除过时内容**tsconfig "当前差异"部分(已升级则删除,未升级则列入 roadmap
4. **状态管理章节更新**:加入 5 层状态模型L1 URL / L2 Server / L3 Client Business / L4 Global UI / L5 Form
5. **ESLint 配置章节更新**:反映已实现的 `no-restricted-syntax``design-tokens/no-hardcoded-fonts` 等规则
---
## 七、实施阶段划分
### 7.1 三阶段流水线
| 阶段 | 目标 | 产出 | 验收标准 |
|------|------|------|---------|
| 阶段 1: 同步实际 | 把 4 份文档对齐到代码现状 | 实际状态基线报告 | 4 份文档与代码零冲突 |
| 阶段 2: 审核正确性 | 基于阶段 1 基线,按 4 维度审核文档 | 问题清单 + 修正建议 | 文档内部一致、无技术错误、无遗漏、对齐大仓最佳实践 |
| 阶段 3: 反向修正代码 | 基于阶段 2 审核后文档,修正代码偏差 | 代码修正 PR | 代码与文档零冲突 |
### 7.2 阶段 1 任务分解
1. 扫描代码实际状态令牌位置、模块结构、tsconfig、Husky/lint-staged 是否配置等)
2. 对照 4 份文档找出不一致项
3. 修正文档使其与代码一致
4. 产出"实际状态基线报告"
### 7.3 阶段 2 任务分解
按 4 维度审核:
1. **合规性**:文档内部一致性、跨文档一致性
2. **遗漏**:对照大仓最佳实践找缺失规则(包边界、依赖方向、共享工具下沉)
3. **技术错误**tsconfig 目标版本、令牌位置、ESLint 规则等具体错误
4. **大仓规范对照**:提取适用于单应用模块化的部分
### 7.4 阶段 3 任务分解
1. 识别代码与审核后文档的偏差
2. 修正代码(如 tsconfig 升级、令牌位置迁移等)
3. 验证 `npm run lint``npx tsc --noEmit` 零错误
### 7.5 独立项目arch.db 扫描器
arch.db 扫描器作为独立项目,可与三阶段并行推进:
1. 实现 ts-morph 扫描器scanner.ts
2. 实现 SQLite schemaschema.ts
3. 实现查询函数query.ts
4. 实现 CLIcli.ts
5. 添加 `npm run arch:scan``npm run arch:query` 脚本
6. 配置 CI 自动运行
7. 写入 project_rules.md 作为 AI 工作前置规则
### 7.6 独立项目:模块 README 创建
约 27 个模块各创建一份 README.md实施时以 arch.db 扫描结果为准),可分批推进:
1. 标杆模块先做textbooks、grades 已在 004 标记为"标杆模块"
2. 核心业务模块exams、homework、questions
3. 教学管理模块classes、school、scheduling、attendance
4. 用户沟通模块users、messaging、notifications、parent
5. 扩展功能模块elective、proctoring、diagnostic、dashboard
6. 其他模块announcements、files、settings、auth、layout、student、lesson-preparation、standards、course-plans、audit、rbac、onboarding
---
## 八、验收标准
### 8.1 文档体系验收
- [ ] 4 份全局规范文档project_rules.md / coding-standards.md / 004 / known-issues.md内部无矛盾
- [ ] 4 份文档与代码零冲突
- [ ] 004 瘦身至约 500 行,不含规划/工作日志/代码结构
- [ ] 005 归档到 audit/archive/
- [ ] known-issues.md 精简至约 300 行,无代码示例
- [ ] roadmap/ 目录建立,包含 tech-debt.md / decoupling.md / pending-features.md
- [ ] audit/archive/ 目录建立60+ 份审查报告归档
### 8.2 arch.db 验收
- [ ] `npm run arch:scan` 能成功扫描全项目并生成 arch.db
- [ ] `npm run arch:query ref <symbol>` 能递归查询符号引用
- [ ] `npm run arch:query module <module>` 能递归查询模块依赖
- [ ] `npm run arch:query tech <tag>` 能查询技术使用
- [ ] `npm run arch:query violations` 能检测架构违规
- [ ] arch.db 与代码零偏差(扫描器在干净代码上运行无错误)
### 8.3 模块 README 验收
- [ ] 27 个模块各有 README.md
- [ ] 每个 README 含:模块职责、核心工作流程、关键约束、架构决策
- [ ] 每个 README 不含经验(查 known-issues.md、不含代码结构查 arch.db
### 8.4 AI 工作流程验收
- [ ] project_rules.md 写入 AI 工作强制流程
- [ ] AI 工作前运行 `npm run arch:scan` 成为硬性规则
- [ ] known-issues.md 含审核状态字段
- [ ] 模块 README 含审核标记
---
## 九、风险与缓解
| 风险 | 影响 | 缓解 |
|------|------|------|
| ts-morph 扫描大型项目慢 | CI 时间增加 | 扫描器增量扫描(仅变更文件),全量扫描仅 CI 触发 |
| arch.db 二进制文件 git diff 不友好 | Code review 难 | 配合导出 SQL 文本文件diff 看 SQL应用看 .db |
| 模块 README 维护成本 | AI/人遗忘更新 | project_rules.md 强制 AI 工作后更新CI 检查 README 格式 |
| known-issues.md 日志区膨胀 | 文件过大 | 50 条上限,定期提炼到分区表格 |
| 60+ 份 audit 报告归档后信息丢失 | 历史经验丢失 | 归档前提取有价值内容到模块 README 和 known-issues.md |
| AI 不遵守工作流程 | 文档体系失效 | project_rules.md 写为硬性规则CI 检查 arch.db 是否更新 |
---
## 十、未决事项
本 spec 已涵盖所有用户确认的决策。以下事项在实施阶段可能需要进一步决策:
1. **ts-morph 扫描器性能**:若全量扫描超过 30 秒,需考虑增量扫描策略
2. **arch.db 大小**:若超过 10MB需考虑是否排除部分表如 calls 表可能很大)
3. **模块 README 模板**:实施时可能需要根据实际模块调整模板
4. **技术标签体系扩展**:初始 10 个标签可能不够,实施时根据需要扩展

View File

@@ -0,0 +1,721 @@
# 日志系统重构设计文档
| 字段 | 值 |
|------|---|
| 文档版本 | v1 |
| 创建日期 | 2026-07-07 |
| 作者 | Trae 协作生成 |
| 状态 | 待用户审查 |
| 范围 | pino logger 抽象 + Request ID 贯穿 + 静默失败修复 + error.tsx 上报 + track-event 去重 |
| 策略 | 应用层 SDK 改造不部署外部可观测性服务OTel / Sentry / Prometheus 等) |
---
## 1. 背景与现状
### 1.1 现状调研摘要
基于 2026-07-07 全项目调研:
| 维度 | 状态 | 说明 |
|------|------|------|
| 业务审计日志 | ✅ 已成熟 | audit / login / change logger + 3 张 DB 表 + audit 模块管理后台 |
| 应用层结构化日志 | ❌ 未实现 | 88 处散乱 `console.*`,无级别、无 JSON、无 request ID |
| Request Correlation | ❌ 未实现 | proxy.ts 中间件零日志、不注入请求 ID |
| 错误边界上报 | ❌ 全部丢弃 | 130 个 `error.tsx` 接收 `error` prop 后直接丢弃,前端运行时错误对开发者不可见 |
| 静默失败 | ⚠️ 严重 | 三个 audit-logger 的 catch 块完全吞没错误,运维无法感知审计写入失败 |
| 统一错误处理 | ⚠️ 部分 | `handleActionError` / `handleApiError``console.error`,前缀混乱(`[ExamAction]` / `[ActionError]` / `[ApiError]` / 无前缀混用) |
| track-event | ⚠️ 重复 | 4 个 no-op stubshared / rbac / course-plans / questions 各一份) |
| 架构图覆盖 | ❌ 未覆盖 | 004 / 005 完全未记录可观测性基础设施 |
### 1.2 console.* 分布
| 类型 | 次数 | 文件数 | 主要位置 |
|------|------|--------|----------|
| `console.log` | 2 | 1 | `questions/utils/parse-content.ts` |
| `console.error` | 67 | 30 | data-access 的 catch 块、`action-utils.ts``api-response.ts`、各 Server Action |
| `console.warn` | 5 | 5 | `api/web-vitals/route.ts`、redis/store 等 |
| `console.info` | 10 | 6 | `track-event.ts``api/web-vitals/route.ts`、notifications channels |
| `console.debug` | 4 | 3 | 各模块 `track-event.ts`(仅 dev |
### 1.3 关键约束
- **proxy.ts 在 Edge Runtime 运行**[proxy.ts:64](file:///e:/Desktop/CICD/src/proxy.ts#L64) 注释明确说明),`AsyncLocalStorage` 不可用(依赖 `node:async_hooks`Edge 不支持)
- 其余部分RSC、Server Action、Route Handler、data-access运行在 Node.js runtime`AsyncLocalStorage` 完全支持
- 项目自部署(`next.config.ts``output: "standalone"`),运维通过 `docker logs` 查看日志
---
## 2. 目标与非目标
### 2.1 目标
1. **统一 logger 抽象**pino 替换 88 处散乱 `console.*`,提供 `info/error/warn/debug` + `createModuleLogger(module)` 子 logger
2. **级别控制**`LOG_LEVEL` 环境变量控制输出阈值debug/info/warn/errorZod 校验
3. **结构化输出**:生产环境 JSON含 timestamp/level/module/requestId/msg开发环境 `pino-pretty` 彩色文本
4. **Request ID 贯穿**proxy.ts 生成 ID 并注入请求头Node.js runtime 通过 `AsyncLocalStorage` 贯穿到 data-access / audit-logger
5. **静默失败告警**:三个 audit-logger 的 catch 块从 silent 改为 `logger.warn`
6. **统一错误处理接入**`handleActionError` / `handleApiError` / `safeActionCall` 接入 logger前缀通过 `module` 字段规范化
7. **track-event 去重合并**4 个 no-op stub 合并为 1 个,统一通过 `createModuleLogger("track")`
8. **error.tsx 错误上报**130 个 error.tsx 通过 `useErrorReport` Hook 上报到 `/api/client-error`,含节流防风暴
9. **架构图同步**004 / 005 / known-issues.md 全量更新
### 2.2 非目标YAGNI
- OpenTelemetry / 链路追踪trace
- Prometheus / `/metrics` 端点
- Sentry / Bugsnag / Datadog 等 SaaS 错误监控
- Web Vitals 后端持久化(保留现有 `console.warn`
- 数据库查询日志 / 慢查询日志
- 日志文件轮转(`docker logs` 已足够)
- next-auth events 回调改造(与本次重构解耦)
---
## 3. 架构设计
### 3.1 整体数据流
```
[Client] ──HTTP──> [proxy.ts (Edge Runtime)]
│ requestId = crypto.randomUUID() // Web Crypto API
│ NextResponse.next({ request: { headers } })
│ 注入 x-request-id 到下游请求头
[RSC / Server Action / Route Handler (Node.js Runtime)]
│ withRequestContext(fn):
│ 1. headers().get("x-request-id") 读取
│ 2. requestContextStorage.run({ requestId }, fn)
[data-access / audit-logger / 业务逻辑 (Node.js Runtime)]
│ logger.info({...}, "msg") 调用
│ pino mixin 自动从 getRequestContext() 取 requestId
[stdout: {"level":"info","time":...,"requestId":"abc-123","module":"audit","msg":"..."}]
```
### 3.2 新增文件清单
```
src/shared/lib/
├─ logger.ts # pino 实例 + createModuleLogger 工厂
├─ request-context.ts # AsyncLocalStorage仅 Node.js runtime
└─ with-request-context.ts # Server Action / Route Handler 入口包装
src/shared/hooks/
└─ use-error-report.ts # error.tsx 公共上报 Hook含节流
src/app/api/client-error/
└─ route.ts # 客户端错误接收端点
```
### 3.3 修改文件清单
| 文件 | 改动类型 | 说明 |
|------|---------|------|
| `package.json` | 新增依赖 | `pino``pino-pretty`dev |
| `src/env.mjs` | 新增字段 | `LOG_LEVEL`(默认 `info`Zod enum |
| `src/proxy.ts` | 修改 | 生成 requestId 并通过 `NextResponse.next` 注入请求头 |
| `src/shared/lib/action-utils.ts` | 修改 | `handleActionError` / `safeActionCall` 用 logger |
| `src/shared/lib/api-response.ts` | 修改 | `handleApiError` 用 logger |
| `src/shared/lib/audit-logger.ts` | 修改 | catch 块从 silent 改为 `logger.warn` |
| `src/shared/lib/change-logger.ts` | 修改 | 同上 |
| `src/shared/lib/login-logger.ts` | 修改 | 同上 |
| `src/shared/lib/track-event.ts` | 修改 | 改为 `createModuleLogger("track")`,删除 no-op 输出 |
| `src/modules/rbac/lib/track.ts` | 删除 | 引用方改为从 `@/shared/lib/track-event` 导入 |
| `src/modules/course-plans/lib/track-event.ts` | 删除 | 同上 |
| `src/modules/questions/utils/track-event.ts` | 删除 | 同上 |
| 88 处 `console.*` 调用点 | 修改 | 替换为 `logger.*``createModuleLogger(module)` |
| 130 个 `error.tsx` | 修改 | 在 useEffect 中调用 `useErrorReport(error)` |
| `next.config.ts` | 修改 | 在现有 `serverExternalPackages` 数组中添加 `"pino"`(当前已有 `mysql2`/`tencentcloud-sdk-nodejs`/`exceljs` |
| `.eslintrc` / `eslint.config.mjs` | 修改 | 新增 `no-console` 规则,仅允许 `logger.ts` 中使用 console |
---
## 4. 核心模块设计
### 4.1 `src/shared/lib/logger.ts`
```ts
import pino, { type Logger } from "pino"
import { env } from "@/env.mjs"
import { getRequestContext } from "./request-context"
/**
* 全局 logger 实例。
*
* - 生产环境JSON 输出到 stdoutdocker logs 友好)
* - 开发环境pino-pretty 彩色文本
* - 自动从 AsyncLocalStorage 混入 requestId / userId若存在
*/
export const logger = pino({
level: env.LOG_LEVEL,
base: { service: "cicd-app" },
formatters: {
level: (label) => ({ level: label }),
},
mixin: () => getRequestContext(),
...(env.NODE_ENV === "development" && {
transport: {
target: "pino-pretty",
options: { colorize: true, translateTime: "SYS:standard" },
},
}),
})
/**
* 创建模块级子 logger自动绑定 module 字段。
*
* @example
* ```ts
* const log = createModuleLogger("audit")
* log.info({ userId }, "User action logged")
* // 输出: {"level":"info","module":"audit","msg":"User action logged", ...}
* ```
*/
export function createModuleLogger(module: string): Logger {
return logger.child({ module })
}
export type { Logger }
```
### 4.2 `src/shared/lib/request-context.ts`
```ts
import { AsyncLocalStorage } from "node:async_hooks"
/**
* 请求上下文,贯穿整个请求生命周期。
*
* 仅在 Node.js Runtime 中可用proxy.ts 是 Edge Runtime不导入此模块
* 通过 withRequestContext 高阶函数注入。
*/
export interface RequestContext {
requestId: string
userId?: string
module?: string
}
export const requestContextStorage = new AsyncLocalStorage<RequestContext>()
/**
* 获取当前请求上下文(若存在)。
*
* - 在 withRequestContext 包装的调用栈内:返回完整上下文
* - 在调用栈外(如顶层模块初始化、定时任务):返回空对象
*
* pino logger 的 mixin 配置会自动调用此函数混入 requestId。
*/
export function getRequestContext(): Partial<RequestContext> {
return requestContextStorage.getStore() ?? {}
}
```
### 4.3 `src/shared/lib/with-request-context.ts`
```ts
import { headers } from "next/headers"
import { randomUUID } from "node:crypto"
import { requestContextStorage, type RequestContext } from "./request-context"
/**
* 包装 Server Action / Route Handler注入 requestId 到 AsyncLocalStorage。
*
* 工作流程:
* 1. 通过 `headers()` 读取 proxy.ts 注入的 `x-request-id`
* 2. 若请求头无此字段(如直接调用的内部函数),生成新 UUID
* 3. 通过 `requestContextStorage.run()` 注入到 AsyncLocalStorage
* 4. 在调用栈内的所有 logger 调用自动获得 requestId
*
* @example
* ```ts
* export const createUserAction = withRequestContext(
* async (state: ActionState<User>, input: CreateUserInput) => {
* // 此处 logger.info 会自动带 requestId
* return handleAction(...)
* }
* )
* ```
*/
export function withRequestContext<TArgs extends unknown[], TResult>(
fn: (...args: TArgs) => Promise<TResult>
): (...args: TArgs) => Promise<TResult> {
return async (...args: TArgs) => {
const headersList = await headers()
const requestId =
headersList.get("x-request-id") ?? randomUUID()
const ctx: RequestContext = { requestId }
return requestContextStorage.run(ctx, () => fn(...args))
}
}
```
### 4.4 `src/proxy.ts` 改造
```ts
import { NextResponse } from "next/server"
import type { NextRequest } from "next/server"
import { getToken } from "next-auth/jwt"
// ... 原有 imports
export async function proxy(request: NextRequest) {
const { pathname } = request.nextUrl
// 生成或复用 requestIdWeb Crypto APIEdge 兼容)
const requestId =
request.headers.get("x-request-id") ?? crypto.randomUUID()
// 跳过静态资源和登录页
if (
pathname.startsWith("/_next") ||
pathname.startsWith("/api/auth") ||
pathname === "/login" ||
pathname === "/register" ||
pathname === "/favicon.ico"
) {
return NextResponse.next({
request: { headers: injectRequestId(request, requestId) },
})
}
// ... 原有 token / onboarding / 权限检查逻辑
// 所有 NextResponse.next() / NextResponse.redirect() 调用保留,
// 但 NextResponse.next() 调用统一传入 request.headers
const response = NextResponse.next({
request: { headers: injectRequestId(request, requestId) },
})
response.headers.set("x-request-id", requestId)
return response
}
/**
* 创建包含 x-request-id 的新 Headers 对象。
* 通过 NextResponse.next({ request: { headers } }) 注入到下游 RSC 请求。
*/
function injectRequestId(request: NextRequest, requestId: string): Headers {
const headers = new Headers(request.headers)
headers.set("x-request-id", requestId)
return headers
}
```
> **说明**proxy.ts 不导入 `request-context.ts`,避免在 Edge Runtime 中加载 `node:async_hooks` 导致构建错误。
### 4.5 `src/shared/hooks/use-error-report.ts`
```ts
"use client"
import { useEffect } from "react"
interface ClientErrorPayload {
message: string
stack?: string
digest?: string
url: string
userAgent: string
timestamp: string
}
/**
* 客户端错误上报 Hook。
*
* 用于 error.tsx 接收 error prop 后上报到 /api/client-error。
*
* 节流策略:
* - 同一 digest或 message在 sessionStorage 中标记,避免 React 重渲染或快速刷新时多次上报
* - 上报失败时静默忽略,避免无限循环
*/
export function useErrorReport(error: Error & { digest?: string }): void {
useEffect(() => {
if (!error) return
const digest = error.digest ?? error.message
const storageKey = `error-reported:${digest}`
if (sessionStorage.getItem(storageKey)) return
sessionStorage.setItem(storageKey, "1")
const payload: ClientErrorPayload = {
message: error.message,
stack: error.stack,
digest: error.digest,
url: window.location.href,
userAgent: navigator.userAgent,
timestamp: new Date().toISOString(),
}
fetch("/api/client-error", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
keepalive: true, // 即使页面卸载也尝试发送
}).catch(() => {
// 上报失败时不再上报,避免无限循环
})
}, [error])
}
```
### 4.6 `src/app/api/client-error/route.ts`
```ts
import { NextResponse } from "next/server"
import { createModuleLogger } from "@/shared/lib/logger"
import { withRequestContext } from "@/shared/lib/with-request-context"
const log = createModuleLogger("client-error")
interface ClientErrorPayload {
message: string
stack?: string
digest?: string
url: string
userAgent: string
timestamp: string
}
/**
* 接收客户端 error.tsx 上报的错误。
*
* 注意:客户端错误的 requestId 与原始请求不同(来自 /api/client-error 的 HTTP 请求),
* 但 digest 字段可用于关联原始错误。
*/
export const POST = withRequestContext(async (request: Request) => {
try {
const body = (await request.json()) as ClientErrorPayload
log.error(
{
clientMessage: body.message,
stack: body.stack,
digest: body.digest,
url: body.url,
userAgent: body.userAgent,
clientTimestamp: body.timestamp,
},
"Client error reported"
)
return NextResponse.json({ ok: true })
} catch (error) {
log.error({ err: error }, "Failed to parse client error payload")
return NextResponse.json({ ok: false }, { status: 400 })
}
})
```
### 4.7 `src/shared/lib/audit-logger.ts` 改造(静默失败 → 告警)
```ts
import { createModuleLogger } from "@/shared/lib/logger"
const log = createModuleLogger("audit-logger")
export async function logAudit(params: LogAuditParams): Promise<void> {
try {
// ... 原有写入逻辑
} catch (error) {
// 旧catch { /* Silently fail */ }
// 新:记录到 logger运维可感知
log.warn(
{ err: error, action: params.action, module: params.module },
"Audit log write failed"
)
}
}
```
### 4.8 `src/shared/lib/action-utils.ts` 改造
```ts
import { createModuleLogger } from "@/shared/lib/logger"
import type { ActionState } from "@/shared/types/action-state"
import { PermissionDeniedError } from "@/shared/lib/errors"
const log = createModuleLogger("action")
export function handleActionError(e: unknown): ActionState<never> {
if (e instanceof PermissionDeniedError) {
return { success: false, message: e.message }
}
if (e instanceof BusinessError) {
return { success: false, message: e.message, errorCode: e.code }
}
if (e instanceof Error) {
// 旧console.error("[ActionError]", e.name, e.message, e.stack)
log.error({ err: e }, "Action failed")
return { success: false, message: "操作失败,请稍后重试", errorCode: "unexpected" }
}
log.error({ err: e }, "Unknown action error")
return { success: false, message: "操作失败,请稍后重试", errorCode: "unexpected" }
}
export async function safeActionCall<T>(
action: () => Promise<ActionState<T>>,
options?: {
onError?: (error: unknown) => void
onFinally?: () => void
}
): Promise<ActionState<T> | null> {
try {
return await action()
} catch (e) {
options?.onError?.(e)
// 旧console.error("[SafeActionCall]", e)
log.error({ err: e }, "Safe action call threw")
return null
} finally {
options?.onFinally?.()
}
}
```
### 4.9 `src/shared/lib/track-event.ts` 改造
```ts
import { createModuleLogger } from "@/shared/lib/logger"
const log = createModuleLogger("track")
/**
* 业务埋点接口。
*
* 不再是 no-op stub通过 logger.info 输出结构化事件,
* 后续可扩展为接入外部 analytics 服务。
*/
export function trackEvent(
name: string,
props?: Record<string, unknown>
): void {
log.info({ event: name, ...props }, "track event")
}
export function trackExamEvent(
name: string,
props?: Record<string, unknown>
): void {
trackEvent(`exam.${name}`, props)
}
export function trackAuthEvent(
name: string,
props?: Record<string, unknown>
): void {
trackEvent(`auth.${name}`, props)
}
```
### 4.10 `src/env.mjs` 改造
```ts
// 在 server schema 中添加:
LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"),
```
### 4.11 ESLint 规则
```js
// eslint.config.mjs
{
rules: {
"no-console": ["error", { allow: [], allowWithImplicit: false }]
},
// logger.ts 豁免pino 内部需用 console
overrides: [
{
files: ["src/shared/lib/logger.ts"],
rules: { "no-console": "off" }
}
]
}
```
---
## 5. 改造范围与策略
### 5.1 console.* 替换映射
| 类型 | 数量 | 替换为 | 调用方式 |
|------|------|--------|----------|
| `console.log` | 2 | `log.debug` | `const log = createModuleLogger("questions")` |
| `console.error` | 67 | `log.error` | 按文件所属模块创建子 logger |
| `console.warn` | 5 | `log.warn` | 同上 |
| `console.info` | 10 | `log.info` | 同上 |
| `console.debug` | 4 | `log.debug` | 同上 |
### 5.2 模块前缀规范化
当前散乱前缀 → 统一通过 `module` 字段:
| 当前前缀 | 新 module 字段 |
|---------|----------------|
| `[ExamAction]` | `exams` |
| `[ActionError]` | `action` |
| `[ApiError]` | `api` |
| `[AuditLogger]` | `audit-logger` |
| `[SafeActionCall]` | `action` |
| `[Files]` / `[files]` | `files` |
| 无前缀 | 按文件所属模块 |
### 5.3 Server Action 包装策略
由于 Server Action 通过 `"use server"` 自动成为 RPC不能简单用 `withRequestContext` 包装导出函数(会丢失 Next.js 类型推断)。且 `handleActionError` 是同步函数,无法 `await headers()` 读取请求头。
**最终策略**:在每个 Server Action 模块的入口点调用 `withRequestContext` 包装:
```ts
// src/modules/audit/actions.ts
"use server"
import { withRequestContext } from "@/shared/lib/with-request-context"
import { createModuleLogger } from "@/shared/lib/logger"
const log = createModuleLogger("audit")
export const createAuditLogAction = withRequestContext(
async (state: ActionState<void>, input: CreateAuditInput) => {
// 此处 logger 自动带 requestId
log.info({ input }, "Creating audit log")
return handleAction(...)
}
)
```
**data-access 层无需显式包装**:因为 data-access 总是从 Server Action 调用AsyncLocalStorage 上下文会自动贯穿到调用栈下游。data-access 中的 logger 调用会自动获得 requestId。
**实施时验证项**
- Next.js 16 是否允许高阶函数包装 Server Action保留 NextServerAction 标记)
- 若不允许,回退方案:在每个 Server Action 函数体首行调用 `await initRequestContext()`,该函数内部读取 headers 并写入 AsyncLocalStorage
### 5.4 error.tsx 改造模式
130 个 error.tsx 统一改为:
```tsx
"use client"
import { useEffect } from "react"
import { useErrorReport } from "@/shared/hooks/use-error-report"
// ... 原有 imports
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
useErrorReport(error)
// ... 原有 UI 渲染
}
```
每个 error.tsx 仅增加 2 行import + 调用 Hook
---
## 6. 测试策略
### 6.1 单元测试
| 测试文件 | 覆盖内容 |
|---------|---------|
| `__tests__/logger.test.ts` | level 控制、JSON 输出格式、mixin 注入 requestId、createModuleLogger |
| `__tests__/request-context.test.ts` | AsyncLocalStorage 读写、嵌套调用、空上下文 |
| `__tests__/use-error-report.test.tsx` | 节流sessionStorage 标记、fetch 调用、错误处理 |
### 6.2 集成测试
- 启动开发服务器,发起请求,验证日志包含 requestId
- 触发 Server Action 错误,验证日志包含 module + requestId + error stack
- 触发 client error.tsx验证 `/api/client-error` 收到请求并记录日志
### 6.3 回归验证
- `npx tsc --noEmit` 零错误
- `npm run lint` 零错误(含新增 `no-console` 规则)
- 现有 vitest 测试套件全部通过
---
## 7. 风险与权衡
| 风险 | 影响 | 缓解 |
|------|------|------|
| pino 在 Next.js bundling 中可能有问题 | 高 | 已配置 `serverExternalPackages`pino 仅服务端导入;开发期 `pino-pretty` 通过 transport 配置启用 |
| `AsyncLocalStorage` 在 Server Action 中可能不工作 | 中 | Server Action 运行在 Node.js runtime完全支持实施时先做最小验证 |
| Server Action 高阶函数包装可能丢失 Next.js 类型 | 中 | 实施时验证,必要时回退为显式 `headers()` 调用 |
| 88 处 console.* 替换可能遗漏 | 低 | 通过 ESLint `no-console` 规则强制,仅允许 `logger.ts` 中使用 |
| 130 个 error.tsx 改造量大 | 中 | 提取公共 `useErrorReport` Hook每个 error.tsx 仅加 2 行 |
| track-event 合并可能破坏调用方 | 低 | 删除前 grep 所有引用,统一改为从 `@/shared/lib/track-event` 导入 |
| 客户端错误风暴(无限循环上报) | 中 | sessionStorage 节流 + fetch 失败静默 + `keepalive` 选项 |
| proxy.ts 改造可能影响 Edge Runtime 构建 | 中 | 不导入任何 Node.js 模块,仅用 Web Crypto API |
---
## 8. 架构图同步(强制)
按项目规则"改码必同步图",重构后必须更新:
### 8.1 `docs/architecture/004_architecture_impact_map.md`
新增章节:
- `shared/lib/logger.ts` — pino 实例 + createModuleLogger
- `shared/lib/request-context.ts` — AsyncLocalStorage 请求上下文
- `shared/lib/with-request-context.ts` — Server Action / Route Handler 入口包装
- `shared/hooks/use-error-report.ts` — 客户端错误上报 Hook
- `app/api/client-error/route.ts` — 客户端错误接收端点
修改章节:
- `proxy.ts` — 增加 requestId 注入逻辑
- `shared/lib/audit-logger.ts` / `change-logger.ts` / `login-logger.ts` — 静默失败改为 logger.warn
- `shared/lib/track-event.ts` — 实现层从 no-op 改为 logger.info
- `shared/lib/action-utils.ts` / `api-response.ts` — 接入 logger
删除记录:
- `modules/rbac/lib/track.ts`
- `modules/course-plans/lib/track-event.ts`
- `modules/questions/utils/track-event.ts`
### 8.2 `docs/architecture/005_architecture_data.json`
- `modules.shared.lib.exports` 新增 `logger` / `createModuleLogger` / `requestContextStorage` / `getRequestContext` / `withRequestContext`
- `modules.shared.hooks.exports` 新增 `useErrorReport`
- `modules.app.api.client-error.exports` 新增 `POST`
- `dependencyMatrix` 更新proxy.ts → request-context仅注入请求头不导入
- 删除 `modules.rbac.lib.track``modules.course-plans.lib.track-event``modules.questions.utils.track-event`
### 8.3 `docs/troubleshooting/known-issues.md`
新增规则条目:
- pino 集成:`serverExternalPackages` 配置 + `pino-pretty` 仅 dev
- Edge Runtime 限制proxy.ts 不能导入 `node:async_hooks`
- Server Action 包装:`withRequestContext` 高阶函数使用模式
- error.tsx 错误上报:`useErrorReport` Hook + 节流策略
- ESLint `no-console` 规则与豁免
---
## 9. 实施顺序建议
1. **Phase 1基础底座**
- 装 pino / pino-pretty
- 创建 `logger.ts` / `request-context.ts` / `with-request-context.ts`
-`env.mjs` 添加 LOG_LEVEL
- 单元测试
2. **Phase 2核心接入**
- proxy.ts 注入 requestId
- `action-utils.ts` / `api-response.ts` 接入 logger
- 三个 audit-logger 静默失败 → logger.warn
- track-event 去重合并
3. **Phase 3批量替换**
- 88 处 console.* 替换为 logger.*
- 前缀规范化
- ESLint `no-console` 规则启用
4. **Phase 4error.tsx 上报**
- 创建 `use-error-report` Hook
- 创建 `/api/client-error` Route Handler
- 130 个 error.tsx 接入
5. **Phase 5架构同步**
- 更新 004 / 005 / known-issues.md
- 验证 tsc / lint / 测试通过

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -0,0 +1,70 @@
// eslint-rules/no-hardcoded-design-tokens.js
// 检测 TSX/TS 中硬编码的字体家族字面量(Inter/Fraunces/JetBrains Mono)
// 白名单:src/app/styles/tokens/primitive.css(令牌定义)、email-channel.ts、manifest.ts
// 使用单词边界匹配,避免误判 Interval/Interactive/Interrupt 等含 Inter 子串的标识符
const FORBIDDEN_FONTS = ['Inter', 'Fraunces', 'JetBrains Mono'];
const WHITELIST_FILES = [
'eslint-rules/no-hardcoded-design-tokens.js',
'src/app/styles/tokens/primitive.css',
'src/modules/notifications/channels/email-channel.ts',
'src/app/manifest.ts',
];
// 为每个字体名构建单词边界正则:\bInter\b 匹配 "Inter" 但不匹配 "Interval"
const FORBIDDEN_PATTERNS = FORBIDDEN_FONTS.map((font) => ({
font,
regex: new RegExp(`\\b${font}\\b`),
}));
module.exports = {
meta: {
type: 'problem',
docs: {
description: '禁止硬编码字体家族字面量,使用 var(--font-family-sans/serif/mono)',
category: 'Best Practices',
recommended: true,
},
messages: {
forbiddenFont:
'禁止硬编码字体家族 "{{font}}",使用 var(--font-family-sans/serif/mono) 或 var(--font-family-mono)',
},
schema: [],
},
create(context) {
const filename = context.getFilename().replace(/\\/g, '/');
if (WHITELIST_FILES.some((f) => filename.endsWith(f))) {
return {};
}
return {
Literal(node) {
if (typeof node.value !== 'string') return;
for (const { font, regex } of FORBIDDEN_PATTERNS) {
if (regex.test(node.value)) {
context.report({
node,
messageId: 'forbiddenFont',
data: { font },
});
break;
}
}
},
TemplateElement(node) {
const raw = node.value.raw;
for (const { font, regex } of FORBIDDEN_PATTERNS) {
if (regex.test(raw)) {
context.report({
node,
messageId: 'forbiddenFont',
data: { font },
});
break;
}
}
},
};
},
};

View File

@@ -1,6 +1,10 @@
import { defineConfig, globalIgnores } from "eslint/config"; import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals"; import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript"; import nextTs from "eslint-config-next/typescript";
import { fileURLToPath, pathToFileURL } from "node:url";
import { dirname, join } from "node:path";
const __dirname = dirname(fileURLToPath(import.meta.url));
const eslintConfig = defineConfig([ const eslintConfig = defineConfig([
...nextVitals, ...nextVitals,
@@ -16,6 +20,19 @@ const eslintConfig = defineConfig([
caughtErrorsIgnorePattern: "^_", caughtErrorsIgnorePattern: "^_",
}, },
], ],
// 禁止硬编码 hex 颜色字面量(白名单:tokens 定义文件、邮件、manifest)
// 白名单文件内的 #hex 需在所在行上方加 // eslint-disable-next-line no-restricted-syntax 注释豁免
"no-restricted-syntax": [
"error",
{
selector: "Literal[value=/#[0-9a-fA-F]{3,8}/]",
message:
"禁止硬编码 hex 颜色,使用设计令牌 hsl(var(--*)) 或 Tailwind 类 bg-*",
},
],
// 禁止 console.* 调用,统一使用 createModuleLogger("module-name")
// 豁免场景:scripts/、tests/、客户端 .tsx(留待客户端错误上报机制处理)、被客户端导入的 .ts
"no-console": "error",
}, },
}, },
{ {
@@ -34,6 +51,84 @@ const eslintConfig = defineConfig([
}, },
}, },
}, },
// 自定义规则:检测硬编码字体家族字面量
{
plugins: {
"design-tokens": {
rules: {
"no-hardcoded-fonts": await import(
pathToFileURL(join(__dirname, "eslint-rules/no-hardcoded-design-tokens.js")).href
).then((m) => m.default ?? m),
},
},
},
rules: {
"design-tokens/no-hardcoded-fonts": "error",
},
},
// 缓存策略规则:禁止在 actions / route.ts 中直接调用 revalidatePath/revalidateTag
{
files: ["src/modules/**/actions*.ts", "src/app/api/**/route.ts", "src/i18n/actions*.ts"],
rules: {
"no-restricted-syntax": [
"error",
{
selector: "CallExpression[callee.name='revalidatePath']",
message:
"使用 invalidateFor() 替代直接 revalidatePath(),参见 docs/architecture/004 缓存章节",
},
{
selector: "CallExpression[callee.name='revalidateTag']",
message:
"使用 invalidateFor() 替代直接 revalidateTag(),参见 docs/architecture/004 缓存章节",
},
],
},
},
// 豁免:shared/lib/cache/ 内部允许调用 revalidatePath/revalidateTag(invalidate.ts 内部实现)
{
files: ["src/shared/lib/cache/**/*.ts"],
rules: {
"no-restricted-syntax": "off",
},
},
// 豁免:scripts/ 和 tests/ 允许 console(脚本/测试场景不接入 logger)
{
files: ["scripts/**/*.{js,mjs,ts}", "tests/**/*.{ts,tsx,js}"],
rules: {
"no-console": "off",
},
},
// 暂时豁免:客户端 .tsx 文件中的 console 调用,留待客户端错误上报机制(Task 12-14)处理
// 客户端组件不能导入服务端 pino logger,需通过 useErrorReport Hook 上报
{
files: ["src/**/*.tsx"],
rules: {
"no-console": "off",
},
},
// 豁免:客户端 hooks(.ts)被客户端组件导入,不能使用服务端 logger
// 留待 Task 12-14 客户端错误上报机制处理
{
files: ["src/**/hooks/**/*.ts"],
rules: {
"no-console": "off",
},
},
// 豁免:components 目录中的 .ts utils(如 exam-preview-utils.ts)被客户端 hooks 导入
{
files: ["src/**/components/**/*.ts"],
rules: {
"no-console": "off",
},
},
// 豁免:被客户端组件导入的 .ts 文件不能使用服务端 logger(如 query-client.ts 被 providers.tsx 导入)
{
files: ["src/shared/lib/query-client.ts"],
rules: {
"no-console": "off",
},
},
// Override default ignores of eslint-config-next. // Override default ignores of eslint-config-next.
globalIgnores([ globalIgnores([
// Default ignores of eslint-config-next: // Default ignores of eslint-config-next:
@@ -48,6 +143,8 @@ const eslintConfig = defineConfig([
"tests/webapp/debug_drizzle.js", "tests/webapp/debug_drizzle.js",
// Migration/maintenance scripts using CommonJS require() // Migration/maintenance scripts using CommonJS require()
"scripts/**/*.js", "scripts/**/*.js",
// 已删除文件的归档目录,不参与 lint
"deletes/**",
]), ]),
]); ]);

35
lighthouserc.json Normal file
View File

@@ -0,0 +1,35 @@
{
"ci": {
"collect": {
"url": [
"http://localhost:3000/login"
],
"numberOfRuns": 3,
"settings": {
"preset": "desktop",
"throttling": {
"rttMs": 40,
"throughputKbps": 10240,
"cpuSlowdownMultiplier": 1
}
}
},
"assert": {
"assertions": {
"categories:performance": ["warn", { "minScore": 0.85 }],
"categories:accessibility": ["error", { "minScore": 0.9 }],
"categories:best-practices": ["warn", { "minScore": 0.85 }],
"categories:seo": ["warn", { "minScore": 0.85 }],
"largest-contentful-paint": ["error", { "maxNumericValue": 3000 }],
"cumulative-layout-shift": ["error", { "maxNumericValue": 0.1 }],
"interactive": ["warn", { "maxNumericValue": 3000 }],
"first-contentful-paint": ["warn", { "maxNumericValue": 2000 }],
"total-blocking-time": ["warn", { "maxNumericValue": 300 }]
}
},
"upload": {
"target": "filesystem",
"outputDir": "lighthouse-results"
}
}
}

BIN
lint-phase2b.log Normal file

Binary file not shown.

View File

@@ -5,9 +5,44 @@ const withNextIntl = createNextIntlPlugin("./src/i18n/request.ts");
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
output: "standalone", output: "standalone",
// mysql2 是 Node.js 服务端驱动,依赖 net/tls 等 Node 内置模块, // 服务端 Node.js 原生模块,不应被 Turbopack 打包到 server bundle。
// 不应被 Turbopack 打包到 server bundle 中,标记为外部包在运行时 require。 // - mysql2: Node.js MySQL 驱动,依赖 net/tls
serverExternalPackages: ["mysql2"], // - tencentcloud-sdk-nodejs: 腾讯云 SDK,体积 ~2-3 MB,仅在短信/对象存储等服务端调用
// - exceljs: Excel 生成库,体积 ~250-300 KB,仅在导出报表时使用
// - pino: 结构化日志库,依赖 node:async_hooks 与 transport worker 线程
serverExternalPackages: [
"mysql2",
"tencentcloud-sdk-nodejs",
"exceljs",
"pino",
],
experimental: {
// 启用 optimizePackageImports 让 Turbopack 对 barrel 导出做精确 tree-shaking,
// 避免整个库被打入首屏 chunk。覆盖项目中最常用的重型库:
// - lucide-react: 图标库,barrel 导出 1000+ 图标
// - recharts: 图表库,barrel 导出 20+ 组件
// - @xyflow/react: ReactFlow,barrel 导出 30+ 模块
// - @tiptap/*: 富文本编辑器,多个 barrel 包
// - @radix-ui/*: 无障碍 UI 原语,barrel 导出
// - date-fns: 日期工具,barrel 导出 200+ 函数
optimizePackageImports: [
"lucide-react",
"recharts",
"@xyflow/react",
"@tiptap/react",
"@tiptap/starter-kit",
"@tiptap/extension-placeholder",
"@tiptap/extension-image",
"tiptap-markdown",
"@radix-ui/react-dialog",
"@radix-ui/react-dropdown-menu",
"@radix-ui/react-popover",
"@radix-ui/react-select",
"@radix-ui/react-tabs",
"@radix-ui/react-tooltip",
"date-fns",
],
},
}; };
export default withNextIntl(nextConfig); export default withNextIntl(nextConfig);

379
package-lock.json generated
View File

@@ -37,6 +37,7 @@
"@t3-oss/env-nextjs": "^0.13.10", "@t3-oss/env-nextjs": "^0.13.10",
"@tanstack/react-query": "^5.90.12", "@tanstack/react-query": "^5.90.12",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.14.5",
"@tiptap/extension-image": "^3.27.1", "@tiptap/extension-image": "^3.27.1",
"@tiptap/extension-placeholder": "^3.15.3", "@tiptap/extension-placeholder": "^3.15.3",
"@tiptap/pm": "^3.15.3", "@tiptap/pm": "^3.15.3",
@@ -59,6 +60,7 @@
"openai": "^6.25.0", "openai": "^6.25.0",
"otplib": "^13.4.1", "otplib": "^13.4.1",
"p-queue": "^9.1.0", "p-queue": "^9.1.0",
"pino": "^9.14.0",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"react": "19.2.1", "react": "19.2.1",
"react-dom": "19.2.1", "react-dom": "19.2.1",
@@ -82,6 +84,7 @@
"@playwright/test": "^1.58.2", "@playwright/test": "^1.58.2",
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@tailwindcss/typography": "^0.5.16", "@tailwindcss/typography": "^0.5.16",
"@tanstack/react-query-devtools": "^5.101.2",
"@testing-library/dom": "^10.4.1", "@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2", "@testing-library/react": "^16.3.2",
@@ -97,6 +100,7 @@
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "16.0.10", "eslint-config-next": "16.0.10",
"jsdom": "^29.1.1", "jsdom": "^29.1.1",
"pino-pretty": "^11.3.0",
"prettier": "^3.7.4", "prettier": "^3.7.4",
"prettier-plugin-tailwindcss": "^0.7.2", "prettier-plugin-tailwindcss": "^0.7.2",
"tailwindcss": "^4", "tailwindcss": "^4",
@@ -3300,6 +3304,12 @@
"url": "https://github.com/sponsors/jonschlinkert" "url": "https://github.com/sponsors/jonschlinkert"
} }
}, },
"node_modules/@pinojs/redact": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
"integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==",
"license": "MIT"
},
"node_modules/@playwright/test": { "node_modules/@playwright/test": {
"version": "1.58.2", "version": "1.58.2",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
@@ -6535,9 +6545,20 @@
} }
}, },
"node_modules/@tanstack/query-core": { "node_modules/@tanstack/query-core": {
"version": "5.90.12", "version": "5.101.2",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.12.tgz", "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.2.tgz",
"integrity": "sha512-T1/8t5DhV/SisWjDnaiU2drl6ySvsHj1bHBCWNXd+/T+Hh1cf6JodyEYMd5sgwm+b/mETT4EV3H+zCVczCU5hg==", "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", "license": "MIT",
"funding": { "funding": {
"type": "github", "type": "github",
@@ -6545,12 +6566,12 @@
} }
}, },
"node_modules/@tanstack/react-query": { "node_modules/@tanstack/react-query": {
"version": "5.90.12", "version": "5.101.2",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.12.tgz", "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.2.tgz",
"integrity": "sha512-graRZspg7EoEaw0a8faiUASCyJrqjKPdqJ9EwuDRUF9mEYJ1YPczI9H+/agJ0mOJkPCJDk0lsz5QTrLZ/jQ2rg==", "integrity": "sha512-seDkr6kzGzX1okaaTtZPtgA688CDPlXUz1C6xSg0ESqn04Vuc8tlrYms1s3de+znBqhPVxFRfpAfUf+6XvfPWg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@tanstack/query-core": "5.90.12" "@tanstack/query-core": "5.101.2"
}, },
"funding": { "funding": {
"type": "github", "type": "github",
@@ -6560,6 +6581,24 @@
"react": "^18 || ^19" "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": { "node_modules/@tanstack/react-table": {
"version": "8.21.3", "version": "8.21.3",
"resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz", "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz",
@@ -6580,6 +6619,23 @@
"react-dom": ">=16.8" "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": { "node_modules/@tanstack/table-core": {
"version": "8.21.3", "version": "8.21.3",
"resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz", "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz",
@@ -6593,6 +6649,16 @@
"url": "https://github.com/sponsors/tannerlinsley" "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": { "node_modules/@testing-library/dom": {
"version": "10.4.1", "version": "10.4.1",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
@@ -8169,6 +8235,19 @@
"d3-zoom": "^3.0.0" "d3-zoom": "^3.0.0"
} }
}, },
"node_modules/abort-controller": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
"integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
"dev": true,
"license": "MIT",
"dependencies": {
"event-target-shim": "^5.0.0"
},
"engines": {
"node": ">=6.5"
}
},
"node_modules/accessor-fn": { "node_modules/accessor-fn": {
"version": "1.5.3", "version": "1.5.3",
"resolved": "https://registry.npmjs.org/accessor-fn/-/accessor-fn-1.5.3.tgz", "resolved": "https://registry.npmjs.org/accessor-fn/-/accessor-fn-1.5.3.tgz",
@@ -8575,6 +8654,15 @@
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/atomic-sleep": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
"integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==",
"license": "MIT",
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/available-typed-arrays": { "node_modules/available-typed-arrays": {
"version": "1.0.7", "version": "1.0.7",
"resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
@@ -9105,6 +9193,13 @@
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/colorette": {
"version": "2.0.20",
"resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz",
"integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==",
"dev": true,
"license": "MIT"
},
"node_modules/combined-stream": { "node_modules/combined-stream": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
@@ -9553,6 +9648,16 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/dateformat": {
"version": "4.6.3",
"resolved": "https://registry.npmjs.org/dateformat/-/dateformat-4.6.3.tgz",
"integrity": "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "*"
}
},
"node_modules/dayjs": { "node_modules/dayjs": {
"version": "1.11.21", "version": "1.11.21",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz",
@@ -10715,12 +10820,32 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/event-target-shim": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
"integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/eventemitter3": { "node_modules/eventemitter3": {
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz",
"integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/events": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.8.x"
}
},
"node_modules/exceljs": { "node_modules/exceljs": {
"version": "4.4.0", "version": "4.4.0",
"resolved": "https://registry.npmjs.org/exceljs/-/exceljs-4.4.0.tgz", "resolved": "https://registry.npmjs.org/exceljs/-/exceljs-4.4.0.tgz",
@@ -10769,6 +10894,13 @@
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/fast-copy": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-3.0.2.tgz",
"integrity": "sha512-dl0O9Vhju8IrcLndv2eU4ldt1ftXMqqfgN4H1cpmGV7P6jeB9FwpN9a2c8DPGE1Ys88rNUJVYDHq73CGAGOPfQ==",
"dev": true,
"license": "MIT"
},
"node_modules/fast-csv": { "node_modules/fast-csv": {
"version": "4.3.6", "version": "4.3.6",
"resolved": "https://registry.npmjs.org/fast-csv/-/fast-csv-4.3.6.tgz", "resolved": "https://registry.npmjs.org/fast-csv/-/fast-csv-4.3.6.tgz",
@@ -10842,6 +10974,13 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/fast-safe-stringify": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz",
"integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==",
"dev": true,
"license": "MIT"
},
"node_modules/fastq": { "node_modules/fastq": {
"version": "1.19.1", "version": "1.19.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz",
@@ -11426,6 +11565,13 @@
"url": "https://opencollective.com/unified" "url": "https://opencollective.com/unified"
} }
}, },
"node_modules/help-me": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz",
"integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==",
"dev": true,
"license": "MIT"
},
"node_modules/hermes-estree": { "node_modules/hermes-estree": {
"version": "0.25.1", "version": "0.25.1",
"resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz",
@@ -12284,6 +12430,16 @@
"url": "https://github.com/sponsors/panva" "url": "https://github.com/sponsors/panva"
} }
}, },
"node_modules/joycon": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz",
"integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/js-tokens": { "node_modules/js-tokens": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -14692,6 +14848,15 @@
], ],
"license": "MIT" "license": "MIT"
}, },
"node_modules/on-exit-leak-free": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
"integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==",
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/once": { "node_modules/once": {
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
@@ -14978,6 +15143,111 @@
"url": "https://github.com/sponsors/jonschlinkert" "url": "https://github.com/sponsors/jonschlinkert"
} }
}, },
"node_modules/pino": {
"version": "9.14.0",
"resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz",
"integrity": "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==",
"license": "MIT",
"dependencies": {
"@pinojs/redact": "^0.4.0",
"atomic-sleep": "^1.0.0",
"on-exit-leak-free": "^2.1.0",
"pino-abstract-transport": "^2.0.0",
"pino-std-serializers": "^7.0.0",
"process-warning": "^5.0.0",
"quick-format-unescaped": "^4.0.3",
"real-require": "^0.2.0",
"safe-stable-stringify": "^2.3.1",
"sonic-boom": "^4.0.1",
"thread-stream": "^3.0.0"
},
"bin": {
"pino": "bin.js"
}
},
"node_modules/pino-abstract-transport": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz",
"integrity": "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==",
"license": "MIT",
"dependencies": {
"split2": "^4.0.0"
}
},
"node_modules/pino-pretty": {
"version": "11.3.0",
"resolved": "https://registry.npmjs.org/pino-pretty/-/pino-pretty-11.3.0.tgz",
"integrity": "sha512-oXwn7ICywaZPHmu3epHGU2oJX4nPmKvHvB/bwrJHlGcbEWaVcotkpyVHMKLKmiVryWYByNp0jpgAcXpFJDXJzA==",
"dev": true,
"license": "MIT",
"dependencies": {
"colorette": "^2.0.7",
"dateformat": "^4.6.3",
"fast-copy": "^3.0.2",
"fast-safe-stringify": "^2.1.1",
"help-me": "^5.0.0",
"joycon": "^3.1.1",
"minimist": "^1.2.6",
"on-exit-leak-free": "^2.1.0",
"pino-abstract-transport": "^2.0.0",
"pump": "^3.0.0",
"readable-stream": "^4.0.0",
"secure-json-parse": "^2.4.0",
"sonic-boom": "^4.0.1",
"strip-json-comments": "^3.1.1"
},
"bin": {
"pino-pretty": "bin.js"
}
},
"node_modules/pino-pretty/node_modules/buffer": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
"integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.2.1"
}
},
"node_modules/pino-pretty/node_modules/readable-stream": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz",
"integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==",
"dev": true,
"license": "MIT",
"dependencies": {
"abort-controller": "^3.0.0",
"buffer": "^6.0.3",
"events": "^3.3.0",
"process": "^0.11.10",
"string_decoder": "^1.3.0"
},
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
}
},
"node_modules/pino-std-serializers": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz",
"integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==",
"license": "MIT"
},
"node_modules/playwright": { "node_modules/playwright": {
"version": "1.58.2", "version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
@@ -15237,12 +15507,38 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/process": {
"version": "0.11.10",
"resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
"integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.6.0"
}
},
"node_modules/process-nextick-args": { "node_modules/process-nextick-args": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/process-warning": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz",
"integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT"
},
"node_modules/prop-types": { "node_modules/prop-types": {
"version": "15.8.1", "version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
@@ -15414,6 +15710,17 @@
"prosemirror-transform": "^1.1.0" "prosemirror-transform": "^1.1.0"
} }
}, },
"node_modules/pump": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
"integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
"dev": true,
"license": "MIT",
"dependencies": {
"end-of-stream": "^1.1.0",
"once": "^1.3.1"
}
},
"node_modules/punycode": { "node_modules/punycode": {
"version": "2.3.1", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -15471,6 +15778,12 @@
], ],
"license": "MIT" "license": "MIT"
}, },
"node_modules/quick-format-unescaped": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
"integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==",
"license": "MIT"
},
"node_modules/react": { "node_modules/react": {
"version": "19.2.1", "version": "19.2.1",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.1.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.2.1.tgz",
@@ -15709,6 +16022,15 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/real-require": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz",
"integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==",
"license": "MIT",
"engines": {
"node": ">= 12.13.0"
}
},
"node_modules/recharts": { "node_modules/recharts": {
"version": "3.6.0", "version": "3.6.0",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.6.0.tgz", "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.6.0.tgz",
@@ -16142,6 +16464,15 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/safe-stable-stringify": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz",
"integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/safer-buffer": { "node_modules/safer-buffer": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
@@ -16176,6 +16507,13 @@
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/secure-json-parse": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-2.7.0.tgz",
"integrity": "sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==",
"dev": true,
"license": "BSD-3-Clause"
},
"node_modules/semver": { "node_modules/semver": {
"version": "6.3.1", "version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
@@ -16422,6 +16760,15 @@
"integrity": "sha512-KyFkIfr8QBlFG3uc3NaljaXdYcsbRy1KrSfc4tsQV8jW68jAktGeOcifu530Vx/5LC+PULHT0Rv8LiI8Gw+c1g==", "integrity": "sha512-KyFkIfr8QBlFG3uc3NaljaXdYcsbRy1KrSfc4tsQV8jW68jAktGeOcifu530Vx/5LC+PULHT0Rv8LiI8Gw+c1g==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/sonic-boom": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz",
"integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==",
"license": "MIT",
"dependencies": {
"atomic-sleep": "^1.0.0"
}
},
"node_modules/sonner": { "node_modules/sonner": {
"version": "2.0.7", "version": "2.0.7",
"resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz", "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz",
@@ -16472,6 +16819,15 @@
"url": "https://github.com/sponsors/wooorm" "url": "https://github.com/sponsors/wooorm"
} }
}, },
"node_modules/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
"license": "ISC",
"engines": {
"node": ">= 10.x"
}
},
"node_modules/sqlstring": { "node_modules/sqlstring": {
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz", "resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz",
@@ -16898,6 +17254,15 @@
"uuid": "dist/bin/uuid" "uuid": "dist/bin/uuid"
} }
}, },
"node_modules/thread-stream": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.2.0.tgz",
"integrity": "sha512-zLBvqpwr4Esa0kRjcrzGU6zL25lePWaCLMx0RQFrmteozIfeNdaMLpG5U7PeHzvlFkAWaRKA9/KVW4F60iB+qw==",
"license": "MIT",
"dependencies": {
"real-require": "^0.2.0"
}
},
"node_modules/tiny-invariant": { "node_modules/tiny-invariant": {
"version": "1.3.3", "version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",

View File

@@ -69,6 +69,7 @@
"@t3-oss/env-nextjs": "^0.13.10", "@t3-oss/env-nextjs": "^0.13.10",
"@tanstack/react-query": "^5.90.12", "@tanstack/react-query": "^5.90.12",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.14.5",
"@tiptap/extension-image": "^3.27.1", "@tiptap/extension-image": "^3.27.1",
"@tiptap/extension-placeholder": "^3.15.3", "@tiptap/extension-placeholder": "^3.15.3",
"@tiptap/pm": "^3.15.3", "@tiptap/pm": "^3.15.3",
@@ -91,6 +92,7 @@
"openai": "^6.25.0", "openai": "^6.25.0",
"otplib": "^13.4.1", "otplib": "^13.4.1",
"p-queue": "^9.1.0", "p-queue": "^9.1.0",
"pino": "^9.14.0",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"react": "19.2.1", "react": "19.2.1",
"react-dom": "19.2.1", "react-dom": "19.2.1",
@@ -114,6 +116,7 @@
"@playwright/test": "^1.58.2", "@playwright/test": "^1.58.2",
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@tailwindcss/typography": "^0.5.16", "@tailwindcss/typography": "^0.5.16",
"@tanstack/react-query-devtools": "^5.101.2",
"@testing-library/dom": "^10.4.1", "@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2", "@testing-library/react": "^16.3.2",
@@ -129,6 +132,7 @@
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "16.0.10", "eslint-config-next": "16.0.10",
"jsdom": "^29.1.1", "jsdom": "^29.1.1",
"pino-pretty": "^11.3.0",
"prettier": "^3.7.4", "prettier": "^3.7.4",
"prettier-plugin-tailwindcss": "^0.7.2", "prettier-plugin-tailwindcss": "^0.7.2",
"tailwindcss": "^4", "tailwindcss": "^4",

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 706 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

BIN
screenshots/01-login.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

View File

@@ -22,7 +22,7 @@ async function main() {
console.log("\n=== class_enrollments 结构 ==="); console.log("\n=== class_enrollments 结构 ===");
const [cols] = await conn.query(`SHOW COLUMNS FROM \`class_enrollments\``); const [cols] = await conn.query(`SHOW COLUMNS FROM \`class_enrollments\``);
console.table(cols); console.table(cols);
} catch (err) { } catch {
console.log("class_enrollments 表不存在"); console.log("class_enrollments 表不存在");
} }

View File

@@ -24,13 +24,9 @@ import {
examQuestions, examQuestions,
subjects, subjects,
grades, grades,
schools,
users,
usersToRoles,
roles,
} from "../src/shared/db/schema"; } from "../src/shared/db/schema";
import { createId } from "@paralleldrive/cuid2"; import { createId } from "@paralleldrive/cuid2";
import { sql, eq, and } from "drizzle-orm"; import { sql } from "drizzle-orm";
// ============ 类型定义 ============ // ============ 类型定义 ============

View File

@@ -2,8 +2,10 @@
import { Button } from "@/shared/components/ui/button" import { Button } from "@/shared/components/ui/button"
import { AlertCircle } from "lucide-react" import { AlertCircle } from "lucide-react"
import { useErrorReport } from "@/shared/hooks/use-error-report"
export default function AuthError({ reset }: { error: Error & { digest?: string }; reset: () => void }) { export default function AuthError({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
useErrorReport(error)
return ( return (
<div className="flex h-full w-full flex-col items-center justify-center gap-4 p-4 text-center"> <div className="flex h-full w-full flex-col items-center justify-center gap-4 p-4 text-center">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-destructive/10"> <div className="flex h-16 w-16 items-center justify-center rounded-full bg-destructive/10">

View File

@@ -11,7 +11,7 @@ import {
SelectValue, SelectValue,
} from "@/shared/components/ui/select" } from "@/shared/components/ui/select"
import { FilterBar, FilterSearchInput } from "@/shared/components/ui/filter-bar" import { FilterBar, FilterSearchInput } from "@/shared/components/ui/filter-bar"
import { QuestionCascadeFilter } from "./question-cascade-filter" import { QuestionCascadeFilter } from "@/modules/questions/components/question-cascade-filter"
/** /**
* *

View File

@@ -11,7 +11,7 @@ import {
SelectValue, SelectValue,
} from "@/shared/components/ui/select" } from "@/shared/components/ui/select"
import { FilterBar, FilterSearchInput } from "@/shared/components/ui/filter-bar" import { FilterBar, FilterSearchInput } from "@/shared/components/ui/filter-bar"
import { SUBJECTS, GRADES } from "../constants" import { SUBJECTS, GRADES } from "@/modules/textbooks/constants"
export function TextbookFilters() { export function TextbookFilters() {
const t = useTranslations("textbooks") const t = useTranslations("textbooks")

View File

@@ -4,9 +4,11 @@ import { AlertCircle } from "lucide-react"
import { useTranslations } from "next-intl" import { useTranslations } from "next-intl"
import { EmptyState } from "@/shared/components/ui/empty-state" import { EmptyState } from "@/shared/components/ui/empty-state"
import { useErrorReport } from "@/shared/hooks/use-error-report"
export default function EditAnnouncementError({ reset }: { error: Error & { digest?: string }; reset: () => void }) { export default function EditAnnouncementError({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
const t = useTranslations("announcements") const t = useTranslations("announcements")
useErrorReport(error)
return ( return (
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8"> <div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
<EmptyState <EmptyState

View File

@@ -4,9 +4,11 @@ import { AlertCircle } from "lucide-react"
import { useTranslations } from "next-intl" import { useTranslations } from "next-intl"
import { EmptyState } from "@/shared/components/ui/empty-state" import { EmptyState } from "@/shared/components/ui/empty-state"
import { useErrorReport } from "@/shared/hooks/use-error-report"
export default function AdminAnnouncementsError({ reset }: { error: Error & { digest?: string }; reset: () => void }) { export default function AdminAnnouncementsError({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
const t = useTranslations("announcements") const t = useTranslations("announcements")
useErrorReport(error)
return ( return (
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8"> <div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
<EmptyState <EmptyState

View File

@@ -1,5 +1,5 @@
import { AnnouncementListSkeleton } from "@/modules/announcements/components/announcement-list-skeleton" import { SkeletonCard } from "@/shared/components/ui/skeleton"
export default function AdminAnnouncementsLoading() { export default function AdminAnnouncementsLoading() {
return <AnnouncementListSkeleton showCreateButton /> return <SkeletonCard variant="list" />
} }

View File

@@ -1,5 +1,6 @@
"use client" "use client"
import type { JSX } from "react"
import { useRouter, useSearchParams } from "next/navigation" import { useRouter, useSearchParams } from "next/navigation"
import { useCallback } from "react" import { useCallback } from "react"
import { useTranslations } from "next-intl" import { useTranslations } from "next-intl"
@@ -12,19 +13,26 @@ import {
SelectValue, SelectValue,
} from "@/shared/components/ui/select" } from "@/shared/components/ui/select"
import { Input } from "@/shared/components/ui/input" import { Input } from "@/shared/components/ui/input"
import { FilterBar } from "@/shared/components/ui/filter-bar"
import {
ATTENDANCE_STATUS_OPTIONS,
ATTENDANCE_STATUS_LABEL_KEYS,
} from "@/shared/constants/attendance-status"
import { ATTENDANCE_STATUS_OPTIONS, ATTENDANCE_STATUS_LABEL_KEYS } from "../constants" interface AdminAttendanceFiltersProps {
classes: Array<{ id: string; name: string }>
type Option = { id: string; name: string }
interface AttendanceFiltersProps {
classes: Option[]
} }
export function AttendanceFilters({ classes }: AttendanceFiltersProps) { /**
* FilterBar + Select
*
* modules/attendance/components/attendance-filters.tsx
* shared wrapper
*/
export function AdminAttendanceFilters({ classes }: AdminAttendanceFiltersProps): JSX.Element {
const t = useTranslations("attendance")
const router = useRouter() const router = useRouter()
const searchParams = useSearchParams() const searchParams = useSearchParams()
const t = useTranslations("attendance")
const updateParam = useCallback( const updateParam = useCallback(
(key: string, value: string) => { (key: string, value: string) => {
@@ -42,13 +50,22 @@ export function AttendanceFilters({ classes }: AttendanceFiltersProps) {
const classId = searchParams.get("classId") ?? "all" const classId = searchParams.get("classId") ?? "all"
const status = searchParams.get("status") ?? "all" const status = searchParams.get("status") ?? "all"
const date = searchParams.get("date") ?? "" const date = searchParams.get("date") ?? ""
const hasFilters = classId !== "all" || status !== "all" || date !== ""
return ( return (
<div className="grid grid-cols-1 gap-4 rounded-lg border bg-card p-4 md:grid-cols-3"> <FilterBar
layout="wrap"
hasFilters={hasFilters}
onReset={() => {
router.push("/admin/attendance")
}}
>
<div className="grid gap-2"> <div className="grid gap-2">
<Label className="text-xs">{t("filters.class")}</Label> <Label htmlFor="filter-class" className="text-xs">
{t("filters.class")}
</Label>
<Select value={classId} onValueChange={(v) => updateParam("classId", v)}> <Select value={classId} onValueChange={(v) => updateParam("classId", v)}>
<SelectTrigger className="h-9" aria-label={t("filters.class")}> <SelectTrigger id="filter-class" className="h-9" aria-label={t("filters.class")}>
<SelectValue placeholder={t("filters.allClasses")} /> <SelectValue placeholder={t("filters.allClasses")} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -63,9 +80,11 @@ export function AttendanceFilters({ classes }: AttendanceFiltersProps) {
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label className="text-xs">{t("filters.status")}</Label> <Label htmlFor="filter-status" className="text-xs">
{t("filters.status")}
</Label>
<Select value={status} onValueChange={(v) => updateParam("status", v)}> <Select value={status} onValueChange={(v) => updateParam("status", v)}>
<SelectTrigger className="h-9" aria-label={t("filters.status")}> <SelectTrigger id="filter-status" className="h-9" aria-label={t("filters.status")}>
<SelectValue placeholder={t("filters.allStatuses")} /> <SelectValue placeholder={t("filters.allStatuses")} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -80,8 +99,11 @@ export function AttendanceFilters({ classes }: AttendanceFiltersProps) {
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label className="text-xs">{t("filters.date")}</Label> <Label htmlFor="filter-date" className="text-xs">
{t("filters.date")}
</Label>
<Input <Input
id="filter-date"
type="date" type="date"
value={date} value={date}
onChange={(e) => updateParam("date", e.target.value)} onChange={(e) => updateParam("date", e.target.value)}
@@ -89,6 +111,6 @@ export function AttendanceFilters({ classes }: AttendanceFiltersProps) {
aria-label={t("filters.date")} aria-label={t("filters.date")}
/> />
</div> </div>
</div> </FilterBar>
) )
} }

View File

@@ -1,7 +1,9 @@
"use client" "use client"
import { RouteErrorBoundary } from "@/shared/components/route-error" import { RouteErrorBoundary } from "@/shared/components/route-error"
import { useErrorReport } from "@/shared/hooks/use-error-report"
export default function AdminAttendanceError({ reset }: { error: Error & { digest?: string }; reset: () => void }) { export default function AdminAttendanceError({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
useErrorReport(error)
return <RouteErrorBoundary reset={reset} namespace="attendance" /> return <RouteErrorBoundary reset={reset} namespace="attendance" />
} }

View File

@@ -1,10 +1,11 @@
import Link from "next/link" import Link from "next/link"
import type { JSX } from "react" import type { JSX } from "react"
import { BarChart3, ClipboardList } from "lucide-react" import { BarChart3, CheckCircle2, ClipboardList, Clock, FileText, LogOut, School, Users, XCircle } from "lucide-react"
import { getTranslations } from "next-intl/server" import { getTranslations } from "next-intl/server"
import { Button } from "@/shared/components/ui/button" import { Button } from "@/shared/components/ui/button"
import { EmptyState } from "@/shared/components/ui/empty-state" import { EmptyState } from "@/shared/components/ui/empty-state"
import { StatsGrid } from "@/shared/components/ui/stats-grid"
import { requirePermission, getAuthContext } from "@/shared/lib/auth-guard" import { requirePermission, getAuthContext } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions" import { Permissions } from "@/shared/types/permissions"
import { getSearchParam, type SearchParams } from "@/shared/lib/utils" import { getSearchParam, type SearchParams } from "@/shared/lib/utils"
@@ -13,8 +14,7 @@ import { getGrades } from "@/modules/school/data-access"
import { getAttendanceRecords, getAttendanceStats } from "@/modules/attendance/data-access" import { getAttendanceRecords, getAttendanceStats } from "@/modules/attendance/data-access"
import { getClassComparison } from "@/modules/attendance/data-access-stats" import { getClassComparison } from "@/modules/attendance/data-access-stats"
import { getAttendanceGradeCorrelation } from "@/modules/attendance/data-access-correlation" import { getAttendanceGradeCorrelation } from "@/modules/attendance/data-access-correlation"
import { AttendanceFilters } from "@/modules/attendance/components/attendance-filters" import { AdminAttendanceFilters } from "./admin-attendance-filters"
import { AttendanceStatsCards } from "@/modules/attendance/components/attendance-stats-cards"
import { AttendanceRecordList } from "@/modules/attendance/components/attendance-record-list" import { AttendanceRecordList } from "@/modules/attendance/components/attendance-record-list"
import { AttendancePageLayout } from "@/modules/attendance/components/attendance-page-layout" import { AttendancePageLayout } from "@/modules/attendance/components/attendance-page-layout"
import { ClassComparisonCard } from "@/modules/attendance/components/class-comparison-card" import { ClassComparisonCard } from "@/modules/attendance/components/class-comparison-card"
@@ -82,6 +82,21 @@ export default async function AdminAttendancePage({
const gradeOptions = grades.map((g) => ({ id: g.id, name: g.name })) const gradeOptions = grades.map((g) => ({ id: g.id, name: g.name }))
const statsItems = [
{ label: t("stats.totalRecords"), value: stats.total, icon: FileText, color: "text-blue-500" },
{ label: t("stats.present"), value: stats.present, icon: CheckCircle2, color: "text-green-500" },
{ label: t("stats.absent"), value: stats.absent, icon: XCircle, color: "text-red-500" },
{ label: t("stats.late"), value: stats.late, icon: Clock, color: "text-yellow-500" },
{ label: t("stats.earlyLeave"), value: stats.earlyLeave, icon: LogOut, color: "text-orange-500" },
{ label: t("stats.schoolActivity"), value: stats.schoolActivity, icon: School, color: "text-cyan-500" },
{
label: t("stats.attendanceRate"),
value: `${stats.presentRate.toFixed(1)}%`,
icon: Users,
color: "text-primary",
},
]
const header = ( const header = (
<div className="flex items-center justify-between space-y-2"> <div className="flex items-center justify-between space-y-2">
<div> <div>
@@ -100,8 +115,8 @@ export default async function AdminAttendancePage({
return ( return (
<AttendancePageLayout <AttendancePageLayout
header={header} header={header}
stats={<AttendanceStatsCards stats={stats} />} stats={<StatsGrid items={statsItems} columns={4} />}
filters={<AttendanceFilters classes={classOptions} />} filters={<AdminAttendanceFilters classes={classOptions} />}
> >
<div className="space-y-6"> <div className="space-y-6">
{result.items.length === 0 && !classId && !status && !date ? ( {result.items.length === 0 && !classId && !status && !date ? (

View File

@@ -4,14 +4,17 @@ import { AlertCircle } from "lucide-react"
import { useTranslations } from "next-intl" import { useTranslations } from "next-intl"
import { EmptyState } from "@/shared/components/ui/empty-state" import { EmptyState } from "@/shared/components/ui/empty-state"
import { useErrorReport } from "@/shared/hooks/use-error-report"
export default function DataChangeLogsError({ export default function DataChangeLogsError({
error,
reset, reset,
}: { }: {
error: Error & { digest?: string } error: Error & { digest?: string }
reset: () => void reset: () => void
}) { }) {
const t = useTranslations("audit") const t = useTranslations("audit")
useErrorReport(error)
return ( return (
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8"> <div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
<EmptyState <EmptyState

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