From 214ebec97624ea50efb90f3a07446dfa8868fe95 Mon Sep 17 00:00:00 2001 From: SpecialX <47072643+wangxiner55@users.noreply.github.com> Date: Sun, 5 Jul 2026 01:03:19 +0800 Subject: [PATCH] =?UTF-8?q?refactor(design-tokens):=20=E5=85=A8=E9=87=8F?= =?UTF-8?q?=E4=BD=93=E7=B3=BB=E5=8C=96=E9=87=8D=E5=BB=BA=E8=AE=BE=E8=AE=A1?= =?UTF-8?q?=E4=BB=A4=E7=89=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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(均为既有问题) --- .trae/rules/project_rules.md | 26 +- .../004_architecture_impact_map.md | 206 ++- docs/architecture/005_architecture_data.json | 535 +++++- .../2026-07-04-design-tokens-refactor.md | 1536 +++++++++++++++++ ...026-07-04-design-tokens-refactor-design.md | 547 ++++++ docs/troubleshooting/known-issues.md | 261 +++ eslint-rules/no-hardcoded-design-tokens.js | 70 + eslint.config.mjs | 29 + .../admin/curriculum-map/loading.tsx | 4 + .../(dashboard)/admin/lesson-plans/page.tsx | 1 + .../management/grade/practice/error.tsx | 1 + .../parent/course-plans/[id]/loading.tsx | 2 + .../parent/course-plans/loading.tsx | 3 + .../(dashboard)/parent/lesson-plans/page.tsx | 1 + src/app/(dashboard)/parent/practice/page.tsx | 1 + .../student/course-plans/[id]/loading.tsx | 2 + .../student/course-plans/loading.tsx | 3 + .../(dashboard)/student/lesson-plans/page.tsx | 1 + .../teacher/classes/schedule/page.tsx | 1 + .../teacher/classes/students/page.tsx | 1 + .../teacher/lesson-plans/calendar/loading.tsx | 7 + .../teacher/lesson-plans/heatmap/loading.tsx | 4 + .../teacher/lesson-plans/library/loading.tsx | 3 + .../teacher/lesson-plans/library/page.tsx | 1 + .../teacher/lesson-plans/new/page.tsx | 1 + .../(dashboard)/teacher/lesson-plans/page.tsx | 4 + .../(dashboard)/teacher/practice/error.tsx | 1 + src/app/(dashboard)/teacher/practice/page.tsx | 5 + src/app/globals.css | 425 ++--- src/app/layout.tsx | 24 +- src/app/manifest.ts | 2 + src/app/styles/tokens/index.css | 9 + src/app/styles/tokens/lesson-preparation.css | 81 + src/app/styles/tokens/primitive.css | 110 ++ src/app/styles/tokens/semantic-dark.css | 65 + src/app/styles/tokens/semantic-light.css | 68 + src/app/styles/tokens/tailwind-theme.css | 148 ++ .../ai/components/ai-chart-renderer.tsx | 1 + .../attendance-grade-correlation-card.tsx | 7 +- .../components/attendance-trend-chart.tsx | 1 + .../components/class-comparison-card.tsx | 1 + .../class-detail/class-trends-widget.tsx | 2 + .../classes/components/my-classes-grid.tsx | 8 +- .../components/course-plan-calendar.tsx | 2 +- .../components/template-picker-dialog.tsx | 2 +- .../components/elective-course-form.tsx | 2 +- .../components/chapter-weakness-chart.tsx | 1 + .../components/class-error-bar-chart.tsx | 1 + .../components/error-book-detail-dialog.tsx | 2 +- .../knowledge-point-weakness-chart.tsx | 2 +- .../components/subject-distribution-chart.tsx | 1 + .../exams/components/exam-preview-dialog.tsx | 6 +- .../components/homework-grading-view.tsx | 2 +- .../components/ai-differentiation-dialog.tsx | 1 + .../components/ai-feedback-dialog.tsx | 1 + .../components/anchor-migration-banner.tsx | 10 +- .../components/attachment-picker.tsx | 1 + .../components/blocks/blackboard-block.tsx | 4 + .../components/blocks/key-point-block.tsx | 2 +- .../components/blocks/new-teaching-block.tsx | 3 +- .../components/blocks/objective-block.tsx | 2 +- .../components/blocks/reflection-block.tsx | 1 + .../components/blocks/rich-text-block.tsx | 1 + .../components/blocks/summary-block.tsx | 2 +- .../components/calendar-view.tsx | 2 + .../components/consistency-check-dialog.tsx | 1 + .../components/curriculum-map-view.tsx | 4 +- .../components/detail-panel/detail-head.tsx | 2 +- .../components/detail-panel/qa-editor.tsx | 10 +- .../components/inline-question-editor.tsx | 3 +- .../components/lesson-plan-editor.tsx | 13 +- .../components/lesson-plan-mobile-view.tsx | 1 + .../components/paper-editor/inline-node.tsx | 2 +- .../paper-editor/inline-qa-dialog.tsx | 2 +- .../paper-editor/paper-context-menu.tsx | 6 +- .../components/paper-editor/paper-editor.tsx | 6 +- .../paper-editor/textbook-tiptap-editor.tsx | 2 +- .../components/print-view.tsx | 4 +- .../components/publish-homework-dialog.tsx | 1 + .../components/question-bank-picker.tsx | 1 + .../components/schedule-dialog.tsx | 31 +- .../components/template-picker.tsx | 10 +- .../components/version-diff-view.tsx | 3 +- .../components/version-diff-viewer.tsx | 5 +- .../notifications/channels/email-channel.ts | 18 +- .../components/parent-attendance-calendar.tsx | 2 +- .../textbooks/components/force-graph.tsx | 16 +- .../textbooks/components/knowledge-graph.tsx | 8 +- src/shared/components/ui/chart.tsx | 1 + 89 files changed, 3976 insertions(+), 436 deletions(-) create mode 100644 docs/superpowers/plans/2026-07-04-design-tokens-refactor.md create mode 100644 docs/superpowers/specs/2026-07-04-design-tokens-refactor-design.md create mode 100644 eslint-rules/no-hardcoded-design-tokens.js create mode 100644 src/app/styles/tokens/index.css create mode 100644 src/app/styles/tokens/lesson-preparation.css create mode 100644 src/app/styles/tokens/primitive.css create mode 100644 src/app/styles/tokens/semantic-dark.css create mode 100644 src/app/styles/tokens/semantic-light.css create mode 100644 src/app/styles/tokens/tailwind-theme.css diff --git a/.trae/rules/project_rules.md b/.trae/rules/project_rules.md index eb8a90a..1189f6a 100644 --- a/.trae/rules/project_rules.md +++ b/.trae/rules/project_rules.md @@ -107,7 +107,31 @@ src/modules/[module]/ - 使用 `cn()` 工具函数管理条件类名 - **禁止**字符串拼接动态类名(`bg-${color}-500`) - **禁止**使用任意值(`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 -- ` 注释): + - 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) ### 安全规范 diff --git a/docs/architecture/004_architecture_impact_map.md b/docs/architecture/004_architecture_impact_map.md index c876a12..ef8dacf 100644 --- a/docs/architecture/004_architecture_impact_map.md +++ b/docs/architecture/004_architecture_impact_map.md @@ -91,6 +91,50 @@ --- +## 1.1.2 设计令牌体系(2026-07-04 新增) + +项目采用**双层令牌架构**(Primitive + Semantic),Tailwind v4 通过 `@theme inline` 暴露为业务可用的类。 + +### 文件分布 + +``` +src/app/ +├─ globals.css # @import 入口 + base layer + 组件硬编码样式 +└─ styles/tokens/ + ├─ primitive.css # Layer 1: 原始色板(zinc/stone/indigo)、字号/间距/阴影/字体家族阶梯 + ├─ semantic-light.css # Layer 2: :root 语义令牌(shadcn + 扩展 + chart/sidebar + diff/graph) + ├─ semantic-dark.css # Layer 2: .dark 语义令牌(完整暗色) + ├─ lesson-preparation.css # --lp-* 令牌(明暗双份,HEX→HSL) + ├─ tailwind-theme.css # @theme inline 暴露所有令牌为 Tailwind 类 + └─ index.css # 汇总 @import 入口 +``` + +### 令牌分类 + +| 分类 | 命名 | 暴露为 Tailwind 类 | +|---|---|---| +| shadcn 标准 | `--background`/`--foreground`/`--primary`/... | `bg-background`/`text-foreground`/`bg-primary`/... | +| 扩展层级 | `--background-elevated`/`--text-secondary`/`--border-strong`/... | `bg-background-elevated`/`text-text-secondary`/... | +| chart | `--chart-1~5` | `bg-chart-1`/... | +| sidebar | `--sidebar-*` | `bg-sidebar`/`text-sidebar-foreground`/... | +| lp-* 命名空间 | `--lp-paper`/`--lp-inline-node-text`/`--lp-dot-*` | `bg-lp-paper`/`text-lp-inline-node-text`/... | +| 字体家族 | `--font-family-sans/serif/mono` | `font-sans`/`font-serif`/`font-mono` | +| 字号阶梯 | `--font-size-1~9`(含 `--font-size-0` 微字号) | `text-size-1`~`text-size-9` | +| 间距阶梯 | `--space-0~18` | `w-[length:var(--space-N)]` 等 | +| 阴影阶梯 | `--shadow-1~6` | `shadow-1`~`shadow-6` | +| 圆角阶梯 | `--radius-sm/md/lg/xl/2xl/full` | `rounded-sm`/`rounded-md`/... | +| 动效 | `--duration-*`/`--ease-*` | `duration-fast`/`ease-in`/... | +| z-index | `--z-dropdown/sticky/modal/popover/toast` | `z-dropdown`/`z-modal`/... | + +### 强制约束 + +- 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) +- 任意值 `w-[Npx]` 等需通过 `// eslint-disable-next-line no-restricted-syntax -- ` 注释豁免(如 Dialog 固定宽度、图表画布、loading.tsx 骨架等) + +--- + ## 1.2 模块依赖关系图 下图展示模块间的实际依赖关系,**标注依赖类型与合规性**。 @@ -456,6 +500,8 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions" - `isRecord(v)` — 通用类型守卫(P1-6 代码去重新增:位于 `lib/type-guards.ts`,判断未知值是否为非空对象 `v is Record`;消除 homework/stats-service、lesson-preparation/node-summary、lesson-preparation/ai-suggest 等 12+ 个文件中重复定义的 `isRecord` 局部实现,从 `unknown` 安全收窄类型,替代 `as Record` 断言) - `trackEvent(name, payload)` / `trackExamEvent(name, payload)` / `trackAuthEvent(name, payload)` — 监控埋点接口(位于 `lib/track-event.ts`,server-only,当前输出到 console.info,预留接入外部监控服务;EventName 联合类型覆盖 announcement/message/notification/attendance/elective/exam/homework/ai/audit/files/auth 模块事件;✅ P3 新增事件名 `homework.excellent_viewed`、`homework.remind_unsubmitted`;✅ audit-P1-9 新增 auth 模块 8 个事件 `auth.signin_success`/`auth.signin_failure`/`auth.signout`/`auth.signup`/`auth.2fa_enabled`/`auth.2fa_disabled`/`auth.account_locked`/`auth.rate_limited`,并新增 `trackAuthEvent` 便捷函数自动设置 targetType="user") - `ROLE_NAMES` — 角色名常量对象(✅ 2026-06-24 新增:位于 `types/permissions.ts`,包含 ADMIN/TEACHER/STUDENT/PARENT/GRADE_HEAD/TEACHING_HEAD 键,替代 DB 查询中硬编码的角色名字符串;被 error-book/data-access-analytics.getAllStudentIds 使用 `ROLE_NAMES.STUDENT`) +- `apiSuccess(data, init?)` / `apiError(message, status, errorCode?, init?)` / `apiFromAction(result, options?)` / `handleApiError(error, init?)` / `withApiErrorHandler(handler)` / `parseJsonBody(req)` / `errorToStatus(error)` — API 路由层统一响应工具(✅ 2026-07-04 API 规范化重构新增:位于 `lib/api-response.ts`,仅用于 `app/api/.../route.ts`;统一响应信封 `{ success, message?, errorCode?, data? }` 与 `ActionState` 对齐;集中错误→HTTP 状态码映射替代字符串匹配推导;`withApiErrorHandler` HOF 统一异常处理消除每个 route 重复 try/catch;`apiFromAction` 通过 `errorCode` 驱动状态码替代 `result.message?.includes("not found")` 脆弱模式;`errorToStatus` 映射规则:PermissionDeniedError→403 / NotFoundError→404 / ValidationError→400 / BusinessError→400 / 其他→500) +- `formatSseEvent(data)` / `formatSseError(message)` / `formatSseDone()` / `createSseResponse(stream)` / `createSseError(message, status)` — SSE 共享工具(✅ 2026-07-04 API 规范化重构新增:位于 `lib/sse.ts`,统一 `app/api/.../stream/route.ts` 的事件格式与响应头;标准 SSE 头 `Content-Type: text/event-stream` + `Cache-Control: no-cache, no-transform` + `Connection: keep-alive`;客户端约定错误统一 `{ type: "error", message }`、流结束信号 `[DONE]`) **共享组件导出**(P0-b / P1-a / P1-b / P1-c / P2-a / P2-b / P3-a / P3-b / P3-c / P3-d / 第二轮 P0-1/P0-2/P0-3/P1-1/P1-2/P1-3/P1-4 重构新增,按类别组织): @@ -497,6 +543,27 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions" | `useActionMutation` | `hooks/use-action-mutation.ts` | `useActionMutation(options?): { isWorking, mutate }` | 通用 Server Action mutation Hook,替代 50+ 文件中重复的 setIsWorking + try/catch/finally + toast 模式 | 1 个示范(P1-4: schools-view),潜在影响 50+ 文件 | | `useActionQuery` | `hooks/use-action-query.ts` | `useActionQuery(action, options?): { data, loading, error, refetch }` | 通用 Server Action 查询 Hook,替代 11 个文件中重复的 useEffect + useState(loading) + Action().then().catch().finally() 模式,内置竞态防护 | 1 个示范(P1-4: create-question-dialog),潜在影响 11 个文件 | +**V5 状态管理统一专项基础设施**(阶段 1 新增,零业务行为变更): + +| 资产 | 文件 | 签名 | 用途 | 后续消费方 | +|------|------|------|------|--------| +| `createQueryClient` | `lib/query-client.ts` | `createQueryClient(): QueryClient` | TanStack Query 工厂,默认 staleTime 30s / retry 1 / refetchOnWindowFocus false / mutations 全局 onError 兜底 | `app/providers.tsx` | +| `configureNotify` + `notify` + `rawToast` | `lib/notify.ts` | `configureNotify(resolver); notify.{success,error,info,warning,promise}; rawToast` | 统一 toast 接口,包装 sonner 并内置 i18n key 解析。`configureNotify` 由 `Providers` 的 `NotifyConfigurator` 注入 next-intl `t` 函数 | `query-client.ts` 全局兜底 + 业务组件迁移后由各模块使用(替换 100+ 处 `import { toast } from "sonner"`) | +| `useDialogState` | `hooks/use-dialog-state.ts` | `useDialogState(): readonly [boolean, () => void, () => void, () => void]` | Dialog/Sheet 开关状态 Hook,替换 30+ 处 `const [open, setOpen] = useState(false)` 模式 | 待迁移:30+ 处 Dialog 组件 | +| `useUiStore` + `useModal` | `stores/ui-store.ts` | `useUiStore: { modals, openModal, closeModal, isOpen, closeAll }; useModal(name)` | 全局 UI store(zustand),集中管理 modal registry。命令式 API + Hook 双入口。与 sonner 的分工:toast 走 notify,modal 走本 store | 待迁移:30+ 处 Dialog 组件切换为 `useModal(name)` | +| `createServiceProvider` | `lib/create-service-provider.tsx` | `createServiceProvider(displayName): { Provider, useService, Context }` | Service DI Provider 工厂,替换 14 处重复的 `createContext(null)` + Provider + useService 抛错模板 | 待迁移:announcements / attendance / audit / dashboard / messaging / settings 等 14 处 Service DI Provider | +| `Providers` | `app/providers.tsx` | `Providers({ children, locale, messages, session }): React.JSX.Element` | 客户端 Provider 聚合。嵌套顺序:QueryClientProvider → NextIntlClientProvider → NotifyConfigurator → ThemeProvider → SessionProvider → NuqsAdapter → children + Toaster。DevTools 仅 dev 环境 | `app/layout.tsx` | + +**V5 状态分层模型**(5 层): + +| 层 | 责任 | 选型 | +|----|------|------| +| URL 状态 | 列表过滤、tab 持久化、深链接 | `nuqs useQueryState`(单一来源,逐步替换 `useSearchParams` + `window.location.search`) | +| 服务端状态 | 列表/详情/分页数据,mutation + 缓存失效 | TanStack Query(混合模式:RSC 拿首屏 props,useQuery 负责交互数据) | +| 客户端业务状态 | 模块级状态机(如 exam preview、lesson editor) | zustand slice(按模块拆,参考 lesson-preparation 现有模式) | +| 全局 UI 状态 | modal registry | zustand ui-store | +| 表单状态 | 表单密集模块 | react-hook-form + zodResolver | + **共享工具函数导出**(第二轮 P1-3 重构新增): | 函数 | 文件 | 签名 | 用途 | 消费方 | @@ -582,6 +649,8 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions" | ~~`components/onboarding-gate.tsx`~~ | ~~312~~ | ✅ audit-P0-4 已删除(引导流程业务逻辑已迁移至 `modules/onboarding/`,原文件无任何 import 引用,删除以避免文件膨胀) | | `components/global-search.tsx` | 221 | 全局搜索(业务泄漏) | | `types/permissions.ts` | 157 | 67 个权限点常量 + Role/DataScope/AuthContext 类型(✅ rbac 新增:`Role` 类型从固定联合类型改为 `string` 支持动态角色;新增 `BUILTIN_ROLES` 常量、`BuiltinRole` 类型、`isBuiltinRole()` 类型守卫;`isRole()` 标记为 deprecated) | +| `lib/api-response.ts` | 212 | ✅ 2026-07-04 API 规范化重构新增:API 路由层统一响应工具(apiSuccess/apiError/apiFromAction/handleApiError/withApiErrorHandler/parseJsonBody/errorToStatus);统一响应信封 + 集中错误→HTTP 状态码映射,消除每个 route 重复 try/catch 与字符串匹配推导状态码 | +| `lib/sse.ts` | 81 | ✅ 2026-07-04 API 规范化重构新增:SSE 共享工具(formatSseEvent/formatSseError/formatSseDone/createSseResponse/createSseError);统一 stream 路由的事件格式与响应头,消除 `ai/chat/stream` 与 `notifications/stream` 重复实现 | --- @@ -1577,6 +1646,7 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions" | `types.ts` | 85 | 私信类型 + ✅ 审计 V1-P1-6 新增 `RecipientRole` 类型 + re-export 通知类型(向后兼容) | | `hooks/use-message-search.ts` | ~60 | ✅ P1-7 新增:消息搜索 hook(防抖 + 请求竞态取消) | | `lib/build-reply-href.ts` | ~30 | ✅ 审计 V1-P1-7 新增:构建回复 URL 纯函数(URLSearchParams 编码) | +| `lib/type-guards.ts` | ~40 | ✅ 类型守卫专项重构(2026-07-04)新增:`isRecipientRole` / `isMessageReportReason` / `isMessageReportStatus` 守卫 + `toMessageReportReason` / `toMessageReportStatus` 安全窄化函数(fallback 默认值),替代 `data-access.ts` 与 `message-report-block.tsx` 中的 `as` 断言 | **组件清单**: | 组件 | 职责 | @@ -2567,7 +2637,7 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions" - Lib(`lib/document-migration.ts`):`defaultDataForType` / `migrateV1ToV2` / `migrateV2ToV3` / `normalizeDocument` / `buildInitialContent` / `buildDefaultSkeleton` / `isTextbookContentNode` / `isAnchorEdge` / `getAnchorsForNode` / `getActiveAnchorIds` / `getAnchorEdges` - Lib(`lib/anchor-mark.ts`,V4 新增):Tiptap `AnchorMark` 扩展(区间锚)+ `AnchorPoint` 扩展(点锚),替代 v3 字符串偏移锚点 - Lib(`lib/node-summary.ts`):`getNodeSummary` / `getTextbookContentSummary` / `getNodeColor` / `NODE_COLORS` -- Lib(`lib/export.ts`,V5-4 新增 + V4 增强):`flattenLessonPlanForPrint` / `flattenBlockData`(V4:新增 `interaction` case + `flattenInteraction` 函数)/ `PrintableLessonPlan` 类型 +- Lib(`lib/export.ts`,V5-4 新增 + V4 增强 + i18n 注入):`flattenLessonPlanForPrint` / `flattenBlockData`(V4:新增 `interaction` case + `flattenInteraction` 函数)/ `PrintableLessonPlan` 类型 / `TranslationFn` 类型(V5-4 i18n:所有 label 通过翻译函数注入,调用方 print-view.tsx 注入 `useTranslations`) - Data-access-versions(`data-access-versions.ts`):`getLessonPlanVersions` / `createLessonPlanVersion` / `getVersionContent` / `revertToVersion` / `pruneAutoVersions` - Data-access-templates(`data-access-templates.ts`):`getLessonPlanTemplates` / `saveAsTemplate` / `deletePersonalTemplate` - Data-access-knowledge(`data-access-knowledge.ts`):`getLessonPlansByKnowledgePoint` / `getLessonPlansByQuestion` @@ -2672,7 +2742,7 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions" | `types.ts` | 类型定义(含 v1/v2/v3 文档类型、TextbookContentNode、LessonPlanNode、NodeAnchor、AnchorEdge、FlowEdge、11 种 BlockData 接口) | | `constants.ts` | 常量定义 | | `schema.ts` | Zod 验证(V3:错误消息改为 i18n 键,如 `error.titleRequired`) | -| `lib/type-guards.ts` | **集中类型守卫(V3 新增)**:11 种 BlockData 类型守卫(isRichTextBlockData/isTextStudyBlockData/isExerciseBlockData/isObjectiveBlockData/isKeyPointBlockData/isImportBlockData/isNewTeachingBlockData/isSummaryBlockData/isHomeworkBlockData/isBlackboardBlockData/isReflectionBlockData)+ 节点类型守卫(isTextbookContentNode/isLessonPlanNode)+ 题目类型守卫(isValidQuestionType + **V4 导出 `VALID_QUESTION_TYPES` 常量 + `ValidQuestionType` 类型**)+ 基础类型守卫(isLessonPlanStatus/isTemplateType/isTemplateScope/isBlockType)+ **Block 字段值类型守卫(V3 续新增)**:isBlackboardLayout/isImportMethod/isExercisePurpose/isObjectiveDimension/isKeyPointType/isHomeworkType/isReflectionAspect(用于 select onChange 替代 `as` 断言)+ **normalizeTemplateBlocks 规范化函数(V3 续审计新增)**:从 DB `unknown` 安全转换为 `TemplateBlockSkeleton[]`,替代 `as LessonPlanTemplate["blocks"]` 断言 | +| `lib/type-guards.ts` | **集中类型守卫(V3 新增)**:11 种 BlockData 类型守卫(isRichTextBlockData/isTextStudyBlockData/isExerciseBlockData/isObjectiveBlockData/isKeyPointBlockData/isImportBlockData/isNewTeachingBlockData/isSummaryBlockData/isHomeworkBlockData/isBlackboardBlockData/isReflectionBlockData)+ 节点类型守卫(isTextbookContentNode/isLessonPlanNode)+ 题目类型守卫(isValidQuestionType + **V4 导出 `VALID_QUESTION_TYPES` 常量 + `ValidQuestionType` 类型**)+ 基础类型守卫(isLessonPlanStatus/isTemplateType/isTemplateScope/isBlockType/isReviewDecision)+ **Block 字段值类型守卫(V3 续新增)**:isBlackboardLayout/isImportMethod/isExercisePurpose/isObjectiveDimension/isKeyPointType/isHomeworkType/isReflectionAspect(用于 select onChange 替代 `as` 断言)+ **normalizeTemplateBlocks 规范化函数(V3 续审计新增)**:从 DB `unknown` 安全转换为 `TemplateBlockSkeleton[]`,替代 `as LessonPlanTemplate["blocks"]` 断言 + **类型守卫专项重构(2026-07-04)**:所有 `isXxxBlockData` 守卫参数从 `BlockData` 拓宽为 `unknown`(守卫内部已用 `isObject` 检查,安全;使守卫可从 `unknown` 直接收窄,支持 `AnyLessonPlanNode.data` 联合类型);新增 `mergeBlockDataPatch(node, patch)` 辅助函数 + `BLOCK_DATA_GUARDS` 注册表(BlockType → 守卫映射),替代 AI patch 合并中的 `{ ...node.data, ...patch } as BlockData` 断言,校验失败回退原 data 保证编辑器完整性 | | `lib/i18n-errors.ts` | **Zod 错误 i18n 翻译辅助(V3 新增)**:`translateFieldErrors`(将 Zod fieldErrors 中的 i18n 键翻译为实际消息)/ `safeParseWithI18n`(安全解析 Zod 结果并返回带翻译的 ActionState 错误格式) | | `lib/document-migration.ts` | **纯函数**:v1→v2(migrateV1ToV2)/ v2→v3(migrateV2ToV3)/ 规范化(normalizeDocument,兼容 v1/v2/v3/v4)/ 初始内容(buildInitialContent)/ 默认骨架(buildDefaultSkeleton)/ defaultDataForType / 工具函数(isTextbookContentNode/isAnchorEdge/getAnchorsForNode/getActiveAnchorIds/getAnchorEdges) | | `lib/anchor-mark.ts` | **V4 新增**:Tiptap `AnchorMark` 扩展(区间锚)+ `AnchorPoint` 扩展(点锚),替代 v3 字符串偏移锚点;存储在正文富文本 doc 中 | @@ -2820,7 +2890,7 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions" > - **J4 架构图同步**:004_architecture_impact_map.md 新增 V4 纸感重构章节;005_architecture_data.json 更新 lesson_preparation 节点——description 改为 V4 描述、dependencies 移除 `@xyflow/react` 改为 `@tiptap/*` 包、files 数组移除 9 个废弃文件并新增 V4 组件、auditFixes 新增 V4-PAPER-1~8 + V4-PAPER-DOC 条目 > - **J5 known-issues V4 规则**:`docs/troubleshooting/known-issues.md` 新增 V4 纸感重构规则表,记录 8 条规则——React Flow 弃用、Tiptap Mark 锚点替代字符串偏移、setState in effect 禁用、doc.version=4、editor-slice 移除画布相关方法、interaction block 注册、PrintView 按 order 排序、AnchorMigrationBanner 失效提示 > - **数据模型 v4**:`types.ts` `LessonPlanDocument.version = 4`;`lib/document-migration.ts` `normalizeDocument` 兼容 v1/v2/v3/v4 链式迁移;`editor-slice.ts` 移除 `updateNodePosition`/`connect`/`setEdges`/`autoLayout` 画布相关方法;`hooks/expanded-slice.ts` 新增 `expandedNodeIds`/`toggleExpand`/`setExpanded` 状态(节点展开到正文流);`lib/anchor-mark.ts` 新增 Tiptap `AnchorMark` + `AnchorPoint` 扩展(替代 v3 字符串偏移锚点) -> - **V2-1 复制节点**:`hooks/editor-slice.ts` 新增 `duplicateNode(id)` 方法(深拷贝 `BlockData` + 新 id + 标题加"副本" + order 置末,interaction 节点的 turns 重新生成 id 避免冲突);`components/paper-editor/paper-context-menu.tsx` 的 `copyNode` 从 toast 占位改为调用 `duplicateNode` 并 toast 成功提示;i18n 新增 `v4.contextMenu.copied`/`copyFailed` 键(zh-CN + en) +> - **V2-1 复制节点**:`hooks/editor-slice.ts` 新增 `duplicateNode(id, copySuffix)` 方法(深拷贝 `BlockData` + 新 id + 标题加 `copySuffix` + order 置末,interaction 节点的 turns 重新生成 id 避免冲突;`copySuffix` 由调用方注入 i18n 文案);`components/paper-editor/paper-context-menu.tsx` 的 `copyNode` 从 toast 占位改为调用 `duplicateNode` 并 toast 成功提示;i18n 新增 `v4.contextMenu.copied`/`copyFailed`/`copySuffix` 键(zh-CN + en) > - **V2-2 节点级 AI 协助 4 项**:`lib/ai-node-assist.ts` 新增 4 个服务函数(`generateNodeContent`/`optimizeNodeExpression`/`suggestNodeDifferentiation`/`generateLayeredQuestions`),全部纯服务端、Zod 校验、失败返回 null;`actions-ai.ts` 新增 4 个 Server Action,每个 action 校验 `LESSON_PLAN_READ + LESSON_PLAN_CREATE + AI_CHAT` 三个权限点;`hooks/use-node-ai-assist.ts` 新增客户端 hook;`components/paper-editor/paper-editor.tsx` 注入 `onAiAction` 回调;`components/detail-panel/detail-panel.tsx` 的 4 个 `AiButton` 接入真实 action;i18n 新增 `v4.contextMenu.aiRunning`/`aiSuccess`/`aiFailed`/`aiComingSoon` 键(zh-CN + en) > - **V4-I18N-1 i18n 完整审查与修复**:对 lesson-preparation 模块 i18n 配置进行全量审查并修复——P0 修复运行时 MISSING_MESSAGE:`version.diff.*` 3 键(title/summary/noChanges)+ `schema.ts` 引用的 8 个 `error.*` 键(labelTooLong/versionNoInvalid/nameRequired/nameTooLong/queryTooLong/blockIdRequired/commentTooLong/reviewerRequired)+ `v4.contextMenu` 6 键(copied/copyFailed/aiComingSoon/aiRunning/aiSuccess/aiFailed)(zh-CN + en 同步);P1 修复 en locale 下大面积报错:en `calendar` 节键名对齐 zh-CN(weekView/monthView/loadFailed + prev/next/eventMeta/weekDays 子节),en `analytics` 节补齐 totalPublished/totalSubmitted/totalStandardsLinked/loadFailed;P2 修复 7 处硬编码中文(detail-props.tsx 的 stageLabel/differentiationLabel、inline-node.tsx 的 exerciseCount、inline-qa-dialog.tsx 的 expectedAnswer、qa-editor.tsx 的 turnContentPlaceholder、curriculum-map-view.tsx 的 legendTitle/noData)并新增对应 6 个 i18n 键(`v4.detail.stageLabel`/`differentiationLabel`/`exerciseCount`/`turnContentPlaceholder`、`v4.heatmap.legendTitle`/`noData`);清理 7 个 dead 节(review/comment/formative/substitute/evaluation/standards/gradeHead,analytics 因被 `app/(dashboard)/admin/curriculum-map` 引用而保留);`lib/i18n-errors.ts` 新增 `t.has(msg)` 运行时守卫,避免 `as` 断言绕过类型检查(键不存在时返回原消息而非抛出 MISSING_MESSAGE);zh-CN 与 en 顶层键集一致性验证通过(43 = 43);005_architecture_data.json 同步追加 `V4-I18N-1` auditFixes 条目;known-issues.md 追加 3 个 i18n 规则表(键缺失与中英文不同步 / dead 节清理规则 / 动态键翻译守卫) @@ -3469,6 +3539,47 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions" --- +## 2.34 search(全文检索模块)— ✅ 2026-07-04 API 规范化重构新增 + +**职责**:封装跨 4 张表(questions / textbooks / exams / announcements)的全文检索数据访问层,供 `app/api/search/route.ts` 调用。本次 API 规范化重构从 `app/api/search/route.ts` 下沉而来,消除 `app/` 层直接查询 DB 的架构违规(违反「app → modules → shared」三层架构单向依赖)。 + +**导出函数**(`data-access.ts`,server-only): +- `searchQuestions(kw, limit?)` — 题库检索:`CAST(content AS CHAR) LIKE` 模糊匹配 JSON 字段,返回 `SearchResultItem[]`(href 指向 `/admin/questions?id=`) +- `searchTextbooks(kw, limit?)` — 教材检索:title/subject/publisher 三字段 OR 模糊匹配 +- `searchExams(kw, limit?)` — 试卷检索:title/description 模糊匹配 +- `searchAnnouncements(kw, limit?)` — 公告检索:仅返回 `status=published` 的公告,title/content 模糊匹配;HTML 内容通过 `stripHtml` 提取纯文本摘要 +- `DEFAULT_SEARCH_PAGE_SIZE`(= 10)— 默认分页大小常量 + +**类型定义**(`types.ts`): +- `SearchType = "all" | "question" | "textbook" | "exam" | "announcement"` +- `SearchResultItem` — `{ id, title, snippet, type, href, createdAt }` +- `SearchResponse` — `{ success, query, type, results, total, page, pageSize }` +- `isSearchType(v): v is SearchType` — 类型守卫 + +**设计要点**: +- ✅ **容错降级**:每个查询函数 try/catch 包裹,单表查询失败返回空数组而非抛错,避免拖垮整体搜索 +- ✅ **职责分离**:本模块仅负责 DB 查询;角色过滤(学生不能搜题目/考试)由路由层基于 `getAuthContext()` 处理 +- ✅ **摘要生成**:`extractTextFromJson`(JSON content → string)、`stripHtml`(去 HTML 标签)、`truncate`(截断+省略号)三个内部纯函数 +- ✅ **`as` 断言清理**:所有 `as const` 与 `as SearchResultItem` 类型收窄均使用类型守卫或字面量标注,符合项目 TypeScript 严格模式 + +**依赖关系**: +- 依赖:`shared/db`(db 客户端)、`shared/db/schema`(questions/textbooks/exams/announcements 表)、`drizzle-orm`(and/or/eq/like/desc/sql) +- 被依赖:`app/api/search/route.ts` + +**数据库表**(只读): +- `questions`(content JSON 字段、type、createdAt) +- `textbooks`(title、subject、grade、publisher、createdAt) +- `exams`(title、description、status、createdAt) +- `announcements`(title、content、type、status、createdAt) + +**文件清单**: +| 文件 | 行数 | 职责 | +|------|------|------| +| `data-access.ts` | 212 | 4 个 search 函数 + 3 个内部纯函数(extractTextFromJson/stripHtml/truncate)+ DEFAULT_SEARCH_PAGE_SIZE 常量 | +| `types.ts` | 31 | SearchType/SearchResultItem/SearchResponse 类型 + isSearchType 类型守卫 | + +--- + # 第三部分:已知架构问题和技术债 ## 3.1 P0 严重问题(必须立即修复) @@ -3816,6 +3927,95 @@ shared/lib/{audit-logger, change-logger, auth-guard} → @/auth → shared/lib/* --- +## 3.7 API 规范化专项重构(2026-07-04) + +> 本次重构统一 `app/api/**/route.ts` 的响应信封、错误处理与状态码映射,消除 11 个路由的重复模板代码与脆弱字符串匹配模式。新增 2 个共享工具文件 + 1 个 search 模块。 + +### 3.7.1 新增共享工具 + +| 文件 | 用途 | +|------|------| +| `shared/lib/api-response.ts` | API 路由层统一响应工具:`apiSuccess` / `apiError` / `apiFromAction` / `handleApiError` / `withApiErrorHandler` HOF / `parseJsonBody` / `errorToStatus` | +| `shared/lib/sse.ts` | SSE 共享工具:`formatSseEvent` / `formatSseError` / `formatSseDone` / `createSseResponse` / `createSseError` | + +### 3.7.2 新增模块 + +**`modules/search/`**(2 个文件):从 `app/api/search/route.ts` 下沉 4 个查询函数(searchQuestions/searchTextbooks/searchExams/searchAnnouncements),消除 `app/` 直查 DB 的架构违规。 + +### 3.7.3 重构路由清单(11 个) + +| 路由 | 重构内容 | +|------|---------| +| `api/search` | 改用 `modules/search/data-access`(消除直查 DB);`withApiErrorHandler` + `apiSuccess` | +| `api/proctoring/event` | 改用 `getExamSubmissionForProctoring`(消除与 data-access 重复实现);`parseJsonBody` + Zod + `ValidationError`;修复 401→403 | +| `api/upload` | `apiFromAction(result, { headers })` 替代字符串匹配;415 等状态码改为 errorCode 驱动 | +| `api/files/[id]` | GET/DELETE 用 `apiFromAction`;路由从 80+ 行简化到 38 行 | +| `api/files/batch-delete` | 使用 `apiFromAction` | +| `api/export` | `EXPORT_TYPES: Record` 映射表替代 4 处嵌套 try/catch;统一 `requirePermission` | +| `api/import` | `ValidationError` + `apiError`;修复 401→403 | +| `api/ai/chat` | `apiSuccess` / `apiError` + `withApiErrorHandler` | +| `api/ai/chat/stream` | 改用 `createSseError` / `createSseResponse` / `formatSseEvent` / `formatSseDone`;补充 `dynamic = "force-dynamic"` | +| `api/notifications/stream` | 改用 SSE 共享 helpers;补充 `dynamic = "force-dynamic"`;`PermissionDeniedError` → 403,其他 → 401 | +| `api/cron/audit-cleanup` | `withApiErrorHandler`;字段名 `error` → `message`、`ok` → `success` | +| `api/onboarding/complete` | 410 响应改用 `apiError(..., 410, "deprecated")` | +| `api/onboarding/status` | 同上 | +| `api/rate-limit-test` | `apiSuccess` / `apiError` + `withApiErrorHandler` | + +### 3.7.4 模块改动 + +**`modules/files/actions.ts`**:移除本地 `handleActionError`,改用 `@/shared/lib/action-utils` 统一版本;为所有失败分支添加 `errorCode`(`validation_error` / `not_found` / `permission_denied` / `unexpected`),驱动 `apiFromAction` 状态码映射。 + +### 3.7.5 客户端适配(6 个文件) + +所有客户端 fetch 调用适配新响应信封 `{ success, message?, errorCode?, data? }`: + +| 文件 | 改动 | +|------|------| +| `modules/files/hooks/use-file-upload.ts` | 响应体类型改为 `{ success, data: Partial }` | +| `modules/exams/editor/selection-toolbar.tsx` | `data.url` → `data.data.url`,`data.id` → `data.data.id` | +| `modules/homework/components/scan-uploader.tsx` | 同上 | +| `modules/settings/components/avatar-upload.tsx` | 修复原双重 `.json()` bug;适配 `responseJson.data.url` | +| `modules/files/hooks/use-file-batch-operations.ts` | `body.deletedCount` → `body.data?.deletedCount` | +| `modules/users/components/user-import-dialog.tsx` | `data.sheets?.[0]?.rows` → `data.data?.sheets?.[0]?.rows` | + +### 3.7.6 设计决策 + +| 问题 | 决策 | 理由 | +|------|------|------| +| 搜索 API 架构违规 | 新建 `modules/search/` 模块 | 遵循三层架构 `app → modules → shared`,下沉 DB 查询到 data-access 层 | +| deprecated onboarding 路由 | 保留 410 响应 + 统一信封 | 兼容旧客户端,`apiError(..., 410, "deprecated")` 表明废弃原因 | +| rate-limit-test 测试端点 | 保留并规范化 | 测试基础设施,使用统一信封便于客户端解析 | +| 错误→状态码映射 | errorCode 驱动 | 替代 `result.message?.includes("not found")` 字符串匹配脆弱模式 | +| 异常处理 | `withApiErrorHandler` HOF | 消除每个 route 重复 try/catch,统一 `PermissionDeniedError`→403 映射 | + +### 3.7.7 响应信封规范 + +```typescript +type ApiResponse = { + success: boolean + message?: string + errorCode?: string + data?: T +} +``` + +错误→状态码映射规则: +- `PermissionDeniedError` → 403(已认证但无权限) +- `NotFoundError` → 404 +- `ValidationError` / `BusinessError` → 400 +- 未认证 → 401 +- 限流 → 429 +- 其他 → 500 + +`ActionState.errorCode` → 状态码映射: +- `not_found` → 404 +- `validation_error` → 400 +- `auth_required` → 401 +- `permission_denied` → 403 +- 其他 → 500 + +--- + # 附录 A:模块间依赖矩阵 > 行表示使用方,列表示被使用方。`✅` 合理依赖,`❌` 违规直查,`⟳` 循环依赖。 diff --git a/docs/architecture/005_architecture_data.json b/docs/architecture/005_architecture_data.json index 8b350c8..46659c8 100644 --- a/docs/architecture/005_architecture_data.json +++ b/docs/architecture/005_architecture_data.json @@ -5,7 +5,8 @@ "generatedAt": "2026-06-17", "formatVersion": "1.1", "rule": "每次文件修改后须同步更新本文件", - "lastUpdate": "M7 移动端 PWA 支持(2026-07-01):(1) 新增 src/app/manifest.ts(Next.js App Router 元数据路由,生成 /manifest.webmanifest,PWA 清单:name/short_name=NextEdu/start_url=/display=standalone/theme_color=#1976d2/icons=/icon.svg)。(2) src/app/layout.tsx 新增 viewport 导出(width=device-width, initialScale=1, maximumScale=5, userScalable=true, themeColor 按明暗主题切换)+ metadata 新增 manifest/appleWebApp 配置。(3) lesson-preparation 模块 node-editor.tsx 与 lesson-plan-readonly-view.tsx 的 ReactFlow 组件新增触控友好属性(panOnDrag/zoomOnPinch/zoomOnDoubleClick={false}/selectionOnDrag={false}),防止移动端双击误缩放。--- 之前更新:新增 008_module_role_mapping.md 模块-角色-功能映射总览文档。RBAC 模块 P1/P2 审计修复完成(2026-06-24,permissions-audit-report 续):P1 阶段:(1) P1-1/P1-2/P1-3 i18n 化:zh-CN/en rbac.json 补全 67 个权限点标签+描述(134 键×2 语言);6 个 RBAC 组件改用 useTranslations 替换硬编码英文;admin-users-view.tsx 改用 useTranslations 替换硬编码中文,users.json 新增对应键。(2) P1-5/P1-6 resolveDataScope 重构:新增 classes/data-access.ts 三个轻量级函数(getTeacherScopeData/getStudentScopeData/getGradeIdsForStudentIds);新增 rbac/lib/data-scope-config.ts(DataScopeRule 接口)+ rbac/lib/data-scope-resolver.ts(DATA_SCOPE_RULES 配置数组 + resolveDataScopeFromConfig);shared/lib/auth-guard.ts 改用动态 import 调用 resolver,移除直接 DB 查询 classes/classEnrollments/classSubjectTeachers/grades/parentStudentRelations 表,移除硬编码 roleNames.includes('xxx') 链。(3) P1-8/P1-9 安全性增强:assignRolesToUser 新增 isEnabled 字段查询,过滤禁用角色(admin 例外)。P2 阶段:(4) P2-1 权限矩阵搜索+折叠。(5) P2-3 权限变更影响提示(userCount)。(6) P2-5 a11y 增强(caption/aria-label/fieldset/legend)。(7) P2-7 埋点接口(rbac/lib/track.ts)。(8) P2-8 role-utils.ts 保留 grade_head/teaching_head 角色差异。--- 之前更新:actions.ts 从 1525 行降至 633 行(低于 800 建议上限),修复 updateExamFromRichEditorAction 直接操作数据库的 P0-4 违规。(1) 新增 actions-helpers.ts(96 行):提取跨文件共享的辅助函数 getStringValue/getBoolValue/parseExamModeConfig/failState/successState/invalidFormState/prepareExamCreateContext。(2) 新增 ai-pipeline/auto-mark.ts(356 行):从 actions.ts 迁出 autoMarkExamAction Server Action 及其纯转换辅助 buildTiptapDocFromAiResponse/splitByDottedTexts/extractTitleFromAiResponse/AutoMarkSchema/AutoMarkResult。(3) 新增 actions-rich-editor.ts(250 行):迁移 createExamFromRichEditorAction/updateExamFromRichEditorAction 及共享辅助 convertStructureNode/RichExamCreateSchema/RichExamUpdateSchema。(4) data-access.ts 新增 updateExamWithRichEditorData(DB 事务下沉,修复 P0-4:原 updateExamFromRichEditorAction 直接 db.transaction 写 exams/examQuestions 表)。(5) ai-pipeline/index.ts 新增 loadAiDraftQuestionsAndStructure(从 actions.ts 下沉)。(6) actions.ts 保留 re-export 保持向后兼容(autoMarkExamAction/createExamFromRichEditorAction/updateExamFromRichEditorAction/AutoMarkResult/AiPreviewData/AiRewriteQuestionData)。(7) exam-rich-form.tsx 更新 import 路径直连新文件。--- 之前更新:用户权限(RBAC)模块审计修复(2026-06-24,permissions-audit-report):(1) 新增 shared/lib/type-guards.ts(isPermission/toPermission 类型守卫),替代 rbac/data-access.ts、rbac/data-access-permissions.ts、shared/lib/permissions.ts、shared/lib/auth-guard.ts 中多处 as Permission/as Role[] 断言。(2) 新增 rbac/data-access-permissions.ts(getPermissionRoleCounts),将权限统计查询从 admin/permissions/page.tsx 下沉到 data-access 层,移除 app 层直接 DB 查询。(3) 新增 rbac/components/error-boundary.tsx(RBAC 专用错误边界)。(4) 新增 8 个 loading.tsx/error.tsx(admin/roles、admin/roles/[id]、admin/permissions、admin/users)。(5) users/data-access.ts 新增 deleteUserById(含最后管理员保护逻辑);getCurrentStudentUser 改用 shared/lib/session.getAuthContext() 统一认证入口(替代直接 auth())。(6) users/actions.ts:deleteUserAction 签名从 (prevState, formData) 改为 (userId),改调 data-access.deleteUserById;删除空实现 updateUserRoleAction;移除直接 DB 导入。(7) users/components/admin-users-view.tsx:移除 fetch('/api/admin/users/...') 客户端 fetch,改用 useActionMutation + deleteUserAction Server Action。(8) 005 JSON 新增 rbac 模块完整记录(8 actions + 10 data-access + 3 data-access-assignments + 1 data-access-permissions + 4 schema + 7 types + 1 lib + 8 components + 依赖关系 + dbTables)。--- 之前更新:error-book 学生端组件 i18n + 类型安全 + 解耦改造(2026-06-24):(1) 7 个组件全部接入 useTranslations('error-book'),替换所有硬编码中文为翻译键。(2) error-book-stats-cards/error-book-list 从服务端组件改为 'use client'。(3) review-buttons 的 REVIEW_OPTIONS 从模块级常量改为组件内构建(依赖 t 函数)。(4) error-book-item-card/add-error-book-dialog/error-book-detail-dialog 移除 as 断言,改用类型守卫。(5) error-book-detail-dialog 移除直接 import AiErrorBookAnalysis(modules/ai) 和 createPracticeSessionAction(modules/adaptive-practice),新增 aiAnalysisSlot/onStartVariantPractice 可选 props 注入。(6) add-error-book-dialog 移除直接 import getQuestionsAction(modules/questions),新增 questionOptions/onLoadQuestions 可选 props 注入。(7) 同步更新 004/005 架构文档。注意:page.tsx 需后续更新以传入新 props 恢复 AI 分析/变式练习/题库选择功能。--- 之前更新:grades 模块批量录入重新设计(2026-06-24):按试卷录入每题得分,替换原只录总分的批量录入。(1) 新增 grade_record_answers 表(gradeRecordId/questionId/score/fullScore/feedback,外键 cascade 删除,迁移 0010_grade_record_answers.sql)。(2) grades data-access 新增 batchCreateGradeRecordsByExam:单事务写入 grade_records + grade_record_answers + 投影到 exam_submissions(status=graded) + submission_answers(answerContent=null),使错题集/成绩分析等下游模块无需改造即可读取教师录入的成绩。(3) grades actions 新增 batchCreateGradeRecordsByExamAction(requirePermission + getExamForGradeEntry scope 校验 + assertClassInScope + safeJsonParse + BatchGradeEntryByExamSchema 校验 + updateMasteryFromExamScore + notifyGradeEntered)。(4) exams data-access 新增 getExamsForGradeEntry(按 scope 过滤试卷列表,只返回有题目的试卷)+ getExamForGradeEntry(获取单个试卷详情含题目列表,innerJoin questions 获取 type,含 scope 校验)。(5) 新增类型:grades/types.ts(GradeRecordAnswer/BatchGradeEntryByExamQuestion/BatchGradeEntryByExamItem)、exams/types.ts(ExamQuestionItem/ExamForGradeEntry/ExamOptionForEntry)。(6) 新增 Zod schema:BatchGradeEntryByExamSchema(examId/classId/subjectId 必填,title 1-255,fullScore 1-1000,records 1-500)。(7) UI:BatchGradeEntryByExam 组件完全重写 BatchGradeEntry,Excel 式表格(行=学生,列=题目,末列=总分自动计算),支持多行多列粘贴、Enter 跳下一行、Tab 跳下一格、分数校验、撤销机制(sessionStorage 5 分钟有效)。(8) page.tsx 重写:服务端预加载试卷列表+班级+classGradeMap+试卷详情+学生列表,URL 参数 ?examId=&classId=。(9) i18n 新增 batchByExam 章节(zh-CN/en ~35 键)。(10) grades 模块新增对 exams 模块的依赖(getExamsForGradeEntry/getExamForGradeEntry)。--- 之前更新:lesson-preparation 模块 V4 交互修复+版本聚合(2026-06-23):(V4-1) TextbookContentNode 修复正文文本选择和缩放手柄被画布拖拽拦截的问题:在正文内容区和缩放手柄 div 上添加 React Flow 的 nodrag CSS class(React Flow 识别此 class 跳过 pointerdown 拖拽逻辑),移除容器层无效的 onMouseDown stopPropagation;节点仍可通过头部拖动。(V4-2) 版本聚合显示:LessonPlanListItem 新增 versionCount + versions 字段(LessonPlanVersionSummary 类型:id/title/status/updatedAt/lastSavedAt);getLessonPlans 查询后按 textbookId+chapterId+creatorId 分组(无教材/章节的课案各自独立),每组取 updatedAt 最新为代表,附加版本摘要数组;data-access-knowledge.ts 的两个查询函数补齐 versionCount=1/versions=[] 默认值;LessonPlanCard 多版本时在标题右侧显示版本数徽章 + 下方版本选择下拉框(选择后 router.push 到对应版本编辑/查看页);i18n 新增 list.versionCount/versionSelectorLabel/versionCurrent 键(zh-CN/en)。--- 之前更新:lesson-preparation 模块 V3 多角色课案查看(2026-06-23):(V3-1) 新增 publishLessonPlan/unpublishLessonPlan data-access 函数 + publishLessonPlanAction/unpublishLessonPlanAction Server Actions(LESSON_PLAN_PUBLISH 权限),支持课案 status=published 供学生/家长/教研组长只读查看。(V3-2) 新增 LessonPlanReadonlyView 只读画布组件(复用 React Flow,nodesDraggable=false, nodesConnectable=false)。(V3-3) LessonPlanCard/LessonPlanList/LessonPlanEditor 新增 viewMode prop(teacher/student/parent/admin/gradeHead)+ 发布/撤回按钮(AlertDialog 确认);LessonPlanEditor 新增 initialStatus prop。(V3-4) TextbookContentNode 新增 anchorableNodes/onCreateNewNode props,AnchorNodeSelector 重写为节点列表+创建新节点选项;NodeEditPanel 选中 textbook_content 时显示操作提示+锚点列表(含删除)。(V3-5) TemplatePicker 加载并显示个人模板,分区显示系统/个人模板。(V3-6) rf-mappers 增强:toRfNodes ctx 新增 anchorableNodes/onCreateNewNode;toRfEdges 锚点边颜色用 getNodeColor(anchor.nodeId) 替代硬编码,anchorId 存入 edge.data;fromRfEdges 从 e.data.anchorId 读取(回退 className 判断)。(V3-7) student/parent/grade_head/teaching_head 角色新增 LESSON_PLAN_READ 权限。(V3-8) DataScope class_members/children 新增可选 gradeIds? 字段,auth-guard resolveDataScope 通过 classEnrollments.innerJoin(classes) 预解析年级 ID。(V3-9) 新增 8 个路由:/student/lesson-plans、/student/lesson-plans/[planId]/view、/parent/lesson-plans、/parent/lesson-plans/[planId]/view、/admin/lesson-plans、/admin/lesson-plans/[planId]/view、/grade-head/lesson-plans、/grade-head/lesson-plans/[planId]/view。admin/student/parent/grade_head/teaching_head 导航新增课案相关入口。--- 之前更新:年级管理模块改进 v4-P2-6(2026-06-23):(1) school/data-access.ts 新增 getGradeOverviewStats() 函数 + GradeOverviewStats 接口(每个年级的 classCount/studentCount/teacherCount,动态导入 classes/data-access 避免循环依赖);GradesClient 新增 gradeStats prop,渲染年级概览卡片视图(班级/学生/教师数 + 年级主任/教学主任 + 快捷操作入口);admin/school/grades/page.tsx 新增 getGradeOverviewStats() 查询。(2) 新增 GradeInsightsFilters 组件(ChipNav 替代原生 form get,无整页刷新);admin/school/grades/insights/page.tsx 与 management/grade/insights/page.tsx 重写,添加 i18n + 表格 overflow-x-auto。(3) 修复 grades/actions.ts 中 assertClassInScope 同步函数位于 \"use server\" 文件的 bug,迁移至独立 lib/scope-check.ts 文件,actions.ts 与 actions-analytics.ts 均从 ./lib/scope-check 导入。school.json(en + zh-CN)新增 grades.gradeInsights 和 grades.gradeOverview 两个章节 i18n 键。--- 之前更新:grades 模块 v3/v4 P3 长期问题修复(2026-06-23):(v3-P3-1) batch-grade-entry.tsx 新增下载模板按钮,客户端生成 CSV 模板(含学生姓名/分数/备注列头 + BOM 支持 Excel UTF-8)。(v3-P3-2) grade-record-list.tsx 新增多选复选框(全选/单选)+ 批量删除工具栏 + 批量删除确认对话框;data-access.ts 新增 bulkDeleteGradeRecords(使用 inArray 一次性删除避免 N+1);actions.ts 新增 bulkDeleteGradeRecordsAction(GRADE_RECORD_MANAGE 权限 + 限制单次最多 500 条)。(v4-P3-2) batch-grade-entry.tsx 顶部新增可折叠新手引导提示框(4 步使用说明),使用 localStorage 记住用户关闭状态。i18n zh-CN/en grades.json 同步新增 batch.downloadTemplate/templateAriaLabel/templateStudentName/templateScore/templateRemark/templateFilename + batch.guide.* + list.bulkDelete/bulkDeleteConfirmation/bulkDeleteSelected/bulkDeleteSuccess/bulkDeleteFailed/selectAll/selectRow/clearSelection 键。--- 之前更新:V4 AI 模块深度增强(ai-module-v2 对标 Khanmigo/Duolingo Max/Squirrel AI/Century Tech):(V4-1) SSE 流式响应:shared/lib/ai/client.ts 新增 createAiChatCompletionStream(AsyncGenerator 逐 token 产出),新增 API 路由 /api/ai/chat/stream(POST,requirePermission(AI_CHAT)+checkDailyLimit+filterUserInput+学生苏格拉底系统提示+ReadableStream 流式输出+filterAiOutput+incrementDailyUsage+trackEvent),新增 hook useAiChatStream(fetch+ReadableStream reader+SSE 解析+AbortController 停止生成+localStorage 持久化最近 20 条)。(V4-2) Markdown 渲染:新增组件 AiMarkdownRenderer(react-markdown+remark-gfm,代码块/表格/列表+hover 复制按钮+memo 优化),AiChatPanel 全面重写(流式渲染+停止按钮+清空确认+建议提示词空状态+aria-live+错误展示)。(V4-3) 全局 AI 助手:新增组件 AiAssistantWidget(fixed 浮动按钮+Sheet 侧抽屉+usePathname 路由推断上下文+inferContextFromPath 映射 7 类场景系统提示:教师批改/备课/考试/学生错题本/学生作业/家长/管理员+useAiClientOptional 无 Provider 时隐藏+pulsing 绿色指示器),dashboard layout 全局注入 AiClientProvider+AiAssistantWidget。(V4-4) 内容安全:新增 services/content-safety.ts(filterUserInput 阻断暴力/自残/色情/毒品/黑客/PII 索取;filterAiOutput 输出二次过滤+学生场景阻断直接答案;checkDailyLimit 学生 50/教师 200/家长 30/管理员 500;incrementDailyUsage;getDailyLimit),COPPA/FERPA K12 合规。(V4-5) 多角色 AI 覆盖:types.ts 新增 ChildSummaryInput/Result、StudyPathInput/Result、AiUsageStats 类型 + AiService/AiClientService 接口扩展;schema.ts 新增 4 个 Zod schema;prompt-templates.ts 新增 CHILD_SUMMARY_SYSTEM_PROMPT(家庭教育顾问,家长友好语言)+ STUDY_PATH_SYSTEM_PROMPT(自适应学习路径设计师,3-7 步骤);ai-service.ts 新增 generateChildSummary(聚合成绩/出勤/错题本数据)+ recommendStudyPath(基于掌握度数据);actions.ts 新增 generateChildSummaryAction/recommendStudyPathAction(AI_CHAT 权限)+ getAiUsageStatsAction(AI_CONFIGURE 权限);新增组件 AiChildSummary(家长端:整体评估 Markdown+优势绿勾+改进橙警+家庭辅导建议+下一步徽章)、AiUsageDashboard(管理员端:4 统计卡片+按能力进度条+按角色徽章+Top 用户列表+最近活动日志)、AiStudyPath(学生端:当前等级横幅+学习路径步骤+连接线+状态图标+预估时间+激励消息)。(V4-6) i18n 修复与扩展:修复 AiGradingAssist/AiLessonContentGenerator/AiQuestionVariantGenerator 共 8 处错误 i18n 键引用(CardDescription 重复 title、label/placeholder/button 复用 generateContent、3 个变体类型标签全部显示「生成」);zh-CN/en ai.json 全面重写新增 chat.streaming/stopGeneration/copy/clearConfirm/suggestedPrompts、grading.description/batch*、lessonPrep.description/additionalContext/insertContent、exam.variantType.*/targetDifficulty/addVariant、parent.*、admin.*、studyPath.*、widget.*、safety.* 等键。架构文档 004/005 同步更新。" + "lastUpdate": "M7 移动端 PWA 支持(2026-07-01):(1) 新增 src/app/manifest.ts(Next.js App Router 元数据路由,生成 /manifest.webmanifest,PWA 清单:name/short_name=NextEdu/start_url=/display=standalone/theme_color=#1976d2/icons=/icon.svg)。(2) src/app/layout.tsx 新增 viewport 导出(width=device-width, initialScale=1, maximumScale=5, userScalable=true, themeColor 按明暗主题切换)+ metadata 新增 manifest/appleWebApp 配置。(3) lesson-preparation 模块 node-editor.tsx 与 lesson-plan-readonly-view.tsx 的 ReactFlow 组件新增触控友好属性(panOnDrag/zoomOnPinch/zoomOnDoubleClick={false}/selectionOnDrag={false}),防止移动端双击误缩放。--- 之前更新:新增 008_module_role_mapping.md 模块-角色-功能映射总览文档。RBAC 模块 P1/P2 审计修复完成(2026-06-24,permissions-audit-report 续):P1 阶段:(1) P1-1/P1-2/P1-3 i18n 化:zh-CN/en rbac.json 补全 67 个权限点标签+描述(134 键×2 语言);6 个 RBAC 组件改用 useTranslations 替换硬编码英文;admin-users-view.tsx 改用 useTranslations 替换硬编码中文,users.json 新增对应键。(2) P1-5/P1-6 resolveDataScope 重构:新增 classes/data-access.ts 三个轻量级函数(getTeacherScopeData/getStudentScopeData/getGradeIdsForStudentIds);新增 rbac/lib/data-scope-config.ts(DataScopeRule 接口)+ rbac/lib/data-scope-resolver.ts(DATA_SCOPE_RULES 配置数组 + resolveDataScopeFromConfig);shared/lib/auth-guard.ts 改用动态 import 调用 resolver,移除直接 DB 查询 classes/classEnrollments/classSubjectTeachers/grades/parentStudentRelations 表,移除硬编码 roleNames.includes('xxx') 链。(3) P1-8/P1-9 安全性增强:assignRolesToUser 新增 isEnabled 字段查询,过滤禁用角色(admin 例外)。P2 阶段:(4) P2-1 权限矩阵搜索+折叠。(5) P2-3 权限变更影响提示(userCount)。(6) P2-5 a11y 增强(caption/aria-label/fieldset/legend)。(7) P2-7 埋点接口(rbac/lib/track.ts)。(8) P2-8 role-utils.ts 保留 grade_head/teaching_head 角色差异。--- 之前更新:actions.ts 从 1525 行降至 633 行(低于 800 建议上限),修复 updateExamFromRichEditorAction 直接操作数据库的 P0-4 违规。(1) 新增 actions-helpers.ts(96 行):提取跨文件共享的辅助函数 getStringValue/getBoolValue/parseExamModeConfig/failState/successState/invalidFormState/prepareExamCreateContext。(2) 新增 ai-pipeline/auto-mark.ts(356 行):从 actions.ts 迁出 autoMarkExamAction Server Action 及其纯转换辅助 buildTiptapDocFromAiResponse/splitByDottedTexts/extractTitleFromAiResponse/AutoMarkSchema/AutoMarkResult。(3) 新增 actions-rich-editor.ts(250 行):迁移 createExamFromRichEditorAction/updateExamFromRichEditorAction 及共享辅助 convertStructureNode/RichExamCreateSchema/RichExamUpdateSchema。(4) data-access.ts 新增 updateExamWithRichEditorData(DB 事务下沉,修复 P0-4:原 updateExamFromRichEditorAction 直接 db.transaction 写 exams/examQuestions 表)。(5) ai-pipeline/index.ts 新增 loadAiDraftQuestionsAndStructure(从 actions.ts 下沉)。(6) actions.ts 保留 re-export 保持向后兼容(autoMarkExamAction/createExamFromRichEditorAction/updateExamFromRichEditorAction/AutoMarkResult/AiPreviewData/AiRewriteQuestionData)。(7) exam-rich-form.tsx 更新 import 路径直连新文件。--- 之前更新:用户权限(RBAC)模块审计修复(2026-06-24,permissions-audit-report):(1) 新增 shared/lib/type-guards.ts(isPermission/toPermission 类型守卫),替代 rbac/data-access.ts、rbac/data-access-permissions.ts、shared/lib/permissions.ts、shared/lib/auth-guard.ts 中多处 as Permission/as Role[] 断言。(2) 新增 rbac/data-access-permissions.ts(getPermissionRoleCounts),将权限统计查询从 admin/permissions/page.tsx 下沉到 data-access 层,移除 app 层直接 DB 查询。(3) 新增 rbac/components/error-boundary.tsx(RBAC 专用错误边界)。(4) 新增 8 个 loading.tsx/error.tsx(admin/roles、admin/roles/[id]、admin/permissions、admin/users)。(5) users/data-access.ts 新增 deleteUserById(含最后管理员保护逻辑);getCurrentStudentUser 改用 shared/lib/session.getAuthContext() 统一认证入口(替代直接 auth())。(6) users/actions.ts:deleteUserAction 签名从 (prevState, formData) 改为 (userId),改调 data-access.deleteUserById;删除空实现 updateUserRoleAction;移除直接 DB 导入。(7) users/components/admin-users-view.tsx:移除 fetch('/api/admin/users/...') 客户端 fetch,改用 useActionMutation + deleteUserAction Server Action。(8) 005 JSON 新增 rbac 模块完整记录(8 actions + 10 data-access + 3 data-access-assignments + 1 data-access-permissions + 4 schema + 7 types + 1 lib + 8 components + 依赖关系 + dbTables)。--- 之前更新:error-book 学生端组件 i18n + 类型安全 + 解耦改造(2026-06-24):(1) 7 个组件全部接入 useTranslations('error-book'),替换所有硬编码中文为翻译键。(2) error-book-stats-cards/error-book-list 从服务端组件改为 'use client'。(3) review-buttons 的 REVIEW_OPTIONS 从模块级常量改为组件内构建(依赖 t 函数)。(4) error-book-item-card/add-error-book-dialog/error-book-detail-dialog 移除 as 断言,改用类型守卫。(5) error-book-detail-dialog 移除直接 import AiErrorBookAnalysis(modules/ai) 和 createPracticeSessionAction(modules/adaptive-practice),新增 aiAnalysisSlot/onStartVariantPractice 可选 props 注入。(6) add-error-book-dialog 移除直接 import getQuestionsAction(modules/questions),新增 questionOptions/onLoadQuestions 可选 props 注入。(7) 同步更新 004/005 架构文档。注意:page.tsx 需后续更新以传入新 props 恢复 AI 分析/变式练习/题库选择功能。--- 之前更新:grades 模块批量录入重新设计(2026-06-24):按试卷录入每题得分,替换原只录总分的批量录入。(1) 新增 grade_record_answers 表(gradeRecordId/questionId/score/fullScore/feedback,外键 cascade 删除,迁移 0010_grade_record_answers.sql)。(2) grades data-access 新增 batchCreateGradeRecordsByExam:单事务写入 grade_records + grade_record_answers + 投影到 exam_submissions(status=graded) + submission_answers(answerContent=null),使错题集/成绩分析等下游模块无需改造即可读取教师录入的成绩。(3) grades actions 新增 batchCreateGradeRecordsByExamAction(requirePermission + getExamForGradeEntry scope 校验 + assertClassInScope + safeJsonParse + BatchGradeEntryByExamSchema 校验 + updateMasteryFromExamScore + notifyGradeEntered)。(4) exams data-access 新增 getExamsForGradeEntry(按 scope 过滤试卷列表,只返回有题目的试卷)+ getExamForGradeEntry(获取单个试卷详情含题目列表,innerJoin questions 获取 type,含 scope 校验)。(5) 新增类型:grades/types.ts(GradeRecordAnswer/BatchGradeEntryByExamQuestion/BatchGradeEntryByExamItem)、exams/types.ts(ExamQuestionItem/ExamForGradeEntry/ExamOptionForEntry)。(6) 新增 Zod schema:BatchGradeEntryByExamSchema(examId/classId/subjectId 必填,title 1-255,fullScore 1-1000,records 1-500)。(7) UI:BatchGradeEntryByExam 组件完全重写 BatchGradeEntry,Excel 式表格(行=学生,列=题目,末列=总分自动计算),支持多行多列粘贴、Enter 跳下一行、Tab 跳下一格、分数校验、撤销机制(sessionStorage 5 分钟有效)。(8) page.tsx 重写:服务端预加载试卷列表+班级+classGradeMap+试卷详情+学生列表,URL 参数 ?examId=&classId=。(9) i18n 新增 batchByExam 章节(zh-CN/en ~35 键)。(10) grades 模块新增对 exams 模块的依赖(getExamsForGradeEntry/getExamForGradeEntry)。--- 之前更新:lesson-preparation 模块 V4 交互修复+版本聚合(2026-06-23):(V4-1) TextbookContentNode 修复正文文本选择和缩放手柄被画布拖拽拦截的问题:在正文内容区和缩放手柄 div 上添加 React Flow 的 nodrag CSS class(React Flow 识别此 class 跳过 pointerdown 拖拽逻辑),移除容器层无效的 onMouseDown stopPropagation;节点仍可通过头部拖动。(V4-2) 版本聚合显示:LessonPlanListItem 新增 versionCount + versions 字段(LessonPlanVersionSummary 类型:id/title/status/updatedAt/lastSavedAt);getLessonPlans 查询后按 textbookId+chapterId+creatorId 分组(无教材/章节的课案各自独立),每组取 updatedAt 最新为代表,附加版本摘要数组;data-access-knowledge.ts 的两个查询函数补齐 versionCount=1/versions=[] 默认值;LessonPlanCard 多版本时在标题右侧显示版本数徽章 + 下方版本选择下拉框(选择后 router.push 到对应版本编辑/查看页);i18n 新增 list.versionCount/versionSelectorLabel/versionCurrent 键(zh-CN/en)。--- 之前更新:lesson-preparation 模块 V3 多角色课案查看(2026-06-23):(V3-1) 新增 publishLessonPlan/unpublishLessonPlan data-access 函数 + publishLessonPlanAction/unpublishLessonPlanAction Server Actions(LESSON_PLAN_PUBLISH 权限),支持课案 status=published 供学生/家长/教研组长只读查看。(V3-2) 新增 LessonPlanReadonlyView 只读画布组件(复用 React Flow,nodesDraggable=false, nodesConnectable=false)。(V3-3) LessonPlanCard/LessonPlanList/LessonPlanEditor 新增 viewMode prop(teacher/student/parent/admin/gradeHead)+ 发布/撤回按钮(AlertDialog 确认);LessonPlanEditor 新增 initialStatus prop。(V3-4) TextbookContentNode 新增 anchorableNodes/onCreateNewNode props,AnchorNodeSelector 重写为节点列表+创建新节点选项;NodeEditPanel 选中 textbook_content 时显示操作提示+锚点列表(含删除)。(V3-5) TemplatePicker 加载并显示个人模板,分区显示系统/个人模板。(V3-6) rf-mappers 增强:toRfNodes ctx 新增 anchorableNodes/onCreateNewNode;toRfEdges 锚点边颜色用 getNodeColor(anchor.nodeId) 替代硬编码,anchorId 存入 edge.data;fromRfEdges 从 e.data.anchorId 读取(回退 className 判断)。(V3-7) student/parent/grade_head/teaching_head 角色新增 LESSON_PLAN_READ 权限。(V3-8) DataScope class_members/children 新增可选 gradeIds? 字段,auth-guard resolveDataScope 通过 classEnrollments.innerJoin(classes) 预解析年级 ID。(V3-9) 新增 8 个路由:/student/lesson-plans、/student/lesson-plans/[planId]/view、/parent/lesson-plans、/parent/lesson-plans/[planId]/view、/admin/lesson-plans、/admin/lesson-plans/[planId]/view、/grade-head/lesson-plans、/grade-head/lesson-plans/[planId]/view。admin/student/parent/grade_head/teaching_head 导航新增课案相关入口。--- 之前更新:年级管理模块改进 v4-P2-6(2026-06-23):(1) school/data-access.ts 新增 getGradeOverviewStats() 函数 + GradeOverviewStats 接口(每个年级的 classCount/studentCount/teacherCount,动态导入 classes/data-access 避免循环依赖);GradesClient 新增 gradeStats prop,渲染年级概览卡片视图(班级/学生/教师数 + 年级主任/教学主任 + 快捷操作入口);admin/school/grades/page.tsx 新增 getGradeOverviewStats() 查询。(2) 新增 GradeInsightsFilters 组件(ChipNav 替代原生 form get,无整页刷新);admin/school/grades/insights/page.tsx 与 management/grade/insights/page.tsx 重写,添加 i18n + 表格 overflow-x-auto。(3) 修复 grades/actions.ts 中 assertClassInScope 同步函数位于 \"use server\" 文件的 bug,迁移至独立 lib/scope-check.ts 文件,actions.ts 与 actions-analytics.ts 均从 ./lib/scope-check 导入。school.json(en + zh-CN)新增 grades.gradeInsights 和 grades.gradeOverview 两个章节 i18n 键。--- 之前更新:grades 模块 v3/v4 P3 长期问题修复(2026-06-23):(v3-P3-1) batch-grade-entry.tsx 新增下载模板按钮,客户端生成 CSV 模板(含学生姓名/分数/备注列头 + BOM 支持 Excel UTF-8)。(v3-P3-2) grade-record-list.tsx 新增多选复选框(全选/单选)+ 批量删除工具栏 + 批量删除确认对话框;data-access.ts 新增 bulkDeleteGradeRecords(使用 inArray 一次性删除避免 N+1);actions.ts 新增 bulkDeleteGradeRecordsAction(GRADE_RECORD_MANAGE 权限 + 限制单次最多 500 条)。(v4-P3-2) batch-grade-entry.tsx 顶部新增可折叠新手引导提示框(4 步使用说明),使用 localStorage 记住用户关闭状态。i18n zh-CN/en grades.json 同步新增 batch.downloadTemplate/templateAriaLabel/templateStudentName/templateScore/templateRemark/templateFilename + batch.guide.* + list.bulkDelete/bulkDeleteConfirmation/bulkDeleteSelected/bulkDeleteSuccess/bulkDeleteFailed/selectAll/selectRow/clearSelection 键。--- 之前更新:V4 AI 模块深度增强(ai-module-v2 对标 Khanmigo/Duolingo Max/Squirrel AI/Century Tech):(V4-1) SSE 流式响应:shared/lib/ai/client.ts 新增 createAiChatCompletionStream(AsyncGenerator 逐 token 产出),新增 API 路由 /api/ai/chat/stream(POST,requirePermission(AI_CHAT)+checkDailyLimit+filterUserInput+学生苏格拉底系统提示+ReadableStream 流式输出+filterAiOutput+incrementDailyUsage+trackEvent),新增 hook useAiChatStream(fetch+ReadableStream reader+SSE 解析+AbortController 停止生成+localStorage 持久化最近 20 条)。(V4-2) Markdown 渲染:新增组件 AiMarkdownRenderer(react-markdown+remark-gfm,代码块/表格/列表+hover 复制按钮+memo 优化),AiChatPanel 全面重写(流式渲染+停止按钮+清空确认+建议提示词空状态+aria-live+错误展示)。(V4-3) 全局 AI 助手:新增组件 AiAssistantWidget(fixed 浮动按钮+Sheet 侧抽屉+usePathname 路由推断上下文+inferContextFromPath 映射 7 类场景系统提示:教师批改/备课/考试/学生错题本/学生作业/家长/管理员+useAiClientOptional 无 Provider 时隐藏+pulsing 绿色指示器),dashboard layout 全局注入 AiClientProvider+AiAssistantWidget。(V4-4) 内容安全:新增 services/content-safety.ts(filterUserInput 阻断暴力/自残/色情/毒品/黑客/PII 索取;filterAiOutput 输出二次过滤+学生场景阻断直接答案;checkDailyLimit 学生 50/教师 200/家长 30/管理员 500;incrementDailyUsage;getDailyLimit),COPPA/FERPA K12 合规。(V4-5) 多角色 AI 覆盖:types.ts 新增 ChildSummaryInput/Result、StudyPathInput/Result、AiUsageStats 类型 + AiService/AiClientService 接口扩展;schema.ts 新增 4 个 Zod schema;prompt-templates.ts 新增 CHILD_SUMMARY_SYSTEM_PROMPT(家庭教育顾问,家长友好语言)+ STUDY_PATH_SYSTEM_PROMPT(自适应学习路径设计师,3-7 步骤);ai-service.ts 新增 generateChildSummary(聚合成绩/出勤/错题本数据)+ recommendStudyPath(基于掌握度数据);actions.ts 新增 generateChildSummaryAction/recommendStudyPathAction(AI_CHAT 权限)+ getAiUsageStatsAction(AI_CONFIGURE 权限);新增组件 AiChildSummary(家长端:整体评估 Markdown+优势绿勾+改进橙警+家庭辅导建议+下一步徽章)、AiUsageDashboard(管理员端:4 统计卡片+按能力进度条+按角色徽章+Top 用户列表+最近活动日志)、AiStudyPath(学生端:当前等级横幅+学习路径步骤+连接线+状态图标+预估时间+激励消息)。(V4-6) i18n 修复与扩展:修复 AiGradingAssist/AiLessonContentGenerator/AiQuestionVariantGenerator 共 8 处错误 i18n 键引用(CardDescription 重复 title、label/placeholder/button 复用 generateContent、3 个变体类型标签全部显示「生成」);zh-CN/en ai.json 全面重写新增 chat.streaming/stopGeneration/copy/clearConfirm/suggestedPrompts、grading.description/batch*、lessonPrep.description/additionalContext/insertContent、exam.variantType.*/targetDifficulty/addVariant、parent.*、admin.*、studyPath.*、widget.*、safety.* 等键。架构文档 004/005 同步更新。", + "lastUpdated": "2026-07-04" }, "architectureOverview": { "layers": [ @@ -1221,6 +1222,203 @@ "shared/lib/action-utils.safeParseDate", "shared/lib/action-utils.safeParseNumber" ] + }, + { + "name": "createQueryClient", + "file": "lib/query-client.ts", + "signature": "createQueryClient(): QueryClient", + "purpose": "V5 状态管理统一专项新增:TanStack Query 工厂,默认配置 staleTime 30s / retry 1 / refetchOnWindowFocus false / mutations.retry 0 + 全局 onError 兜底(notify.error + console.error)。必须在客户端 useState 内调用,避免 SSR 跨用户共享缓存", + "deps": [ + "@tanstack/react-query" + ], + "usedBy": [ + "app/providers.tsx" + ] + }, + { + "name": "configureNotify + notify + rawToast", + "file": "lib/notify.ts", + "signature": "configureNotify(resolver: I18nResolver): void; const notify: { success, error, info, warning, promise }; const rawToast: typeof sonner.toast", + "purpose": "V5 状态管理统一专项新增:统一 toast 接口。notify 包装 sonner 并内置 i18n key 解析(消除 100+ 处 t(\"xxx\") 重复)。通过 configureNotify 在 Providers 的 NotifyConfigurator 中注入 next-intl 的 t 函数。rawToast 保留 sonner escape hatch", + "deps": [ + "sonner", + "next-intl (运行时通过 configureNotify 注入)" + ], + "usedBy": [ + "app/providers.tsx (configureNotify)", + "shared/lib/query-client.ts (notify.error 全局兜底)", + "业务组件迁移后由各模块使用" + ] + }, + { + "name": "createServiceProvider", + "file": "lib/create-service-provider.tsx", + "signature": "createServiceProvider(displayName: string): { Provider, useService, Context }", + "purpose": "V5 状态管理统一专项新增:Service DI Provider 工厂,替换项目中 14 处重复的 createContext(null) + Provider + useService 抛错模板。用于注入模块数据服务对象(不可变引用),非可变状态", + "deps": [ + "react" + ], + "usedBy": [ + "待迁移:announcements / attendance / audit / dashboard / messaging / settings 等模块的 Service DI Provider" + ] + }, + { + "name": "apiSuccess", + "file": "lib/api-response.ts", + "signature": "apiSuccess(data: T, init?: { status?: number; headers?: HeadersInit }): NextResponse", + "purpose": "构造 API 成功响应 { success: true, data }", + "deps": [ + "NextResponse" + ], + "usedBy": [ + "app/api/**/route.ts" + ] + }, + { + "name": "apiError", + "file": "lib/api-response.ts", + "signature": "apiError(message: string, status: number, errorCode?: string, init?: { headers?: HeadersInit }): NextResponse", + "purpose": "构造 API 失败响应 { success: false, message, errorCode }", + "deps": [ + "NextResponse" + ], + "usedBy": [ + "app/api/**/route.ts" + ] + }, + { + "name": "apiFromAction", + "file": "lib/api-response.ts", + "signature": "apiFromAction(result: ActionState, options?: { successStatus?: number; headers?: HeadersInit }): NextResponse", + "purpose": "从 ActionState 构造响应,通过 errorCode 驱动状态码(not_found→404 / validation_error→400 / auth_required→401 / permission_denied→403 / 其他→500),替代字符串匹配推导", + "deps": [ + "ActionState", + "NextResponse" + ], + "usedBy": [ + "api/upload", + "api/files/[id]", + "api/files/batch-delete" + ] + }, + { + "name": "handleApiError", + "file": "lib/api-response.ts", + "signature": "handleApiError(error: unknown, init?: { headers?: HeadersInit }): NextResponse", + "purpose": "API 路由异常处理器:PermissionDeniedError→403 / NotFoundError→404 / ValidationError→400 / BusinessError→400 / 其他→500,已知业务错误不记录日志,未预期错误 console.error 记录", + "deps": [ + "PermissionDeniedError", + "BusinessError", + "NotFoundError", + "ValidationError" + ], + "usedBy": [ + "withApiErrorHandler" + ] + }, + { + "name": "withApiErrorHandler", + "file": "lib/api-response.ts", + "signature": "withApiErrorHandler(handler: (...args: TArgs) => Promise): (...args: TArgs) => Promise", + "purpose": "HOF 包装 route handler,统一异常处理,消除每个 route 重复 try/catch 模板", + "deps": [ + "handleApiError" + ], + "usedBy": [ + "api/search", + "api/proctoring/event", + "api/export", + "api/import", + "api/ai/chat", + "api/cron/audit-cleanup", + "api/rate-limit-test" + ] + }, + { + "name": "parseJsonBody", + "file": "lib/api-response.ts", + "signature": "parseJsonBody(req: Request): Promise", + "purpose": "安全解析 JSON 请求体,失败抛 ValidationError(由 withApiErrorHandler 转 400)", + "deps": [ + "ValidationError" + ], + "usedBy": [ + "api/proctoring/event" + ] + }, + { + "name": "errorToStatus", + "file": "lib/api-response.ts", + "signature": "errorToStatus(error: unknown): number", + "purpose": "错误→HTTP 状态码集中映射,避免字符串匹配推导状态码", + "deps": [ + "PermissionDeniedError", + "NotFoundError", + "ValidationError", + "BusinessError" + ], + "usedBy": [ + "handleApiError" + ] + }, + { + "name": "formatSseEvent", + "file": "lib/sse.ts", + "signature": "formatSseEvent(data: unknown): string", + "purpose": "序列化 payload 为 SSE data: 行(data: {...}\\n\\n)", + "deps": [], + "usedBy": [ + "api/ai/chat/stream", + "api/notifications/stream" + ] + }, + { + "name": "formatSseError", + "file": "lib/sse.ts", + "signature": "formatSseError(message: string): string", + "purpose": "格式化标准错误事件 { type: 'error', message }", + "deps": [ + "formatSseEvent" + ], + "usedBy": [ + "api/ai/chat/stream", + "api/notifications/stream" + ] + }, + { + "name": "formatSseDone", + "file": "lib/sse.ts", + "signature": "formatSseDone(): string", + "purpose": "SSE 终止信号 data: [DONE]\\n\\n", + "deps": [], + "usedBy": [ + "api/ai/chat/stream", + "api/notifications/stream" + ] + }, + { + "name": "createSseResponse", + "file": "lib/sse.ts", + "signature": "createSseResponse(stream: ReadableStream): Response", + "purpose": "构造标准 SSE 成功响应(流建立后使用),含 Content-Type/Cache-Control/Connection 头", + "deps": [], + "usedBy": [ + "api/ai/chat/stream", + "api/notifications/stream" + ] + }, + { + "name": "createSseError", + "file": "lib/sse.ts", + "signature": "createSseError(message: string, status: number): Response", + "purpose": "在流建立前返回错误(鉴权失败、参数校验失败),text/event-stream Content-Type", + "deps": [ + "formatSseError" + ], + "usedBy": [ + "api/ai/chat/stream", + "api/notifications/stream" + ] } ], "hooks": [ @@ -1329,6 +1527,29 @@ "usedBy": [ "app/api/files/batch-delete/route.ts" ] + }, + { + "name": "useDialogState", + "file": "hooks/use-dialog-state.ts", + "signature": "useDialogState(): readonly [boolean, () => void, () => void, () => void]", + "purpose": "V5 状态管理统一专项新增:Dialog/Sheet 开关状态 Hook,替换项目中 30+ 处 const [open, setOpen] = useState(false) 模式。提供语义化方法名 openModal / closeModal / toggle,避免散落 setOpen(true)/setOpen(false) 调用", + "usedBy": [ + "待迁移:users/user-import-dialog.tsx、textbooks/textbook-form-dialog.tsx、error-book/error-book-detail-dialog.tsx 等 30+ 处 Dialog 组件" + ] + } + ], + "stores": [ + { + "name": "useUiStore + useModal", + "file": "stores/ui-store.ts", + "signature": "useUiStore: zustand store { modals, openModal, closeModal, isOpen, closeAll }; useModal(name): { open, props, openModal, closeModal }", + "purpose": "V5 状态管理统一专项新增:全局 UI store(zustand),集中管理 modal registry。替换 30+ 处 Dialog 各自 useState(false) 的反模式。支持命令式 API(openModal/closeModal)和 Hook API(useModal)双入口。与 sonner 的分工:toast 走 shared/lib/notify,modal 走本 store", + "deps": [ + "zustand" + ], + "usedBy": [ + "待迁移:30+ 处 Dialog 组件切换为 useModal(name)" + ] } ], "components": [ @@ -1937,6 +2158,40 @@ "classes 模块 error.tsx(4 个)", "student 模块 error.tsx(4 个)" ] + }, + { + "path": "lib/api-response.ts", + "lines": 212, + "description": "✅ 2026-07-04 API 规范化重构新增:API 路由层统一响应工具。导出 apiSuccess/apiError/apiFromAction/handleApiError/withApiErrorHandler/parseJsonBody/errorToStatus。统一响应信封 { success, message?, errorCode?, data? } 与 ActionState 对齐。集中错误→HTTP 状态码映射(PermissionDeniedError→403 / NotFoundError→404 / ValidationError→400 / BusinessError→400 / 其他→500),消除字符串匹配推导。", + "exports": [ + "apiSuccess", + "apiError", + "apiFromAction", + "handleApiError", + "withApiErrorHandler", + "parseJsonBody", + "errorToStatus", + "ApiResponse" + ], + "usedBy": [ + "app/api/**/route.ts" + ] + }, + { + "path": "lib/sse.ts", + "lines": 81, + "description": "✅ 2026-07-04 API 规范化重构新增:Server-Sent Events 共享工具。导出 formatSseEvent/formatSseError/formatSseDone/createSseResponse/createSseError。统一 stream 路由的事件格式与响应头(Content-Type: text/event-stream + Cache-Control: no-cache, no-transform + Connection: keep-alive)。消除 ai/chat/stream 与 notifications/stream 重复实现。", + "exports": [ + "formatSseEvent", + "formatSseError", + "formatSseDone", + "createSseResponse", + "createSseError" + ], + "usedBy": [ + "api/ai/chat/stream", + "api/notifications/stream" + ] } ], "types": [ @@ -2906,6 +3161,78 @@ ], "description": "选课记录(复合主键 courseId+studentId;status: selected/enrolled/waitlist/dropped/rejected;P2-4 新增 dropReason: varchar(255) 退课理由,可空)" } + }, + "designTokens": { + "name": "design-tokens", + "type": "css-tokens", + "path": "src/app/styles/tokens/", + "files": [ + "primitive.css", + "semantic-light.css", + "semantic-dark.css", + "lesson-preparation.css", + "tailwind-theme.css", + "index.css" + ], + "layers": [ + { + "name": "primitive", + "description": "原始色板/字号/间距/阴影/字体家族,业务代码不直接引用", + "tokens": [ + "--color-zinc-50 ~ 950", + "--color-stone-50 ~ 950", + "--color-indigo-500/600", + "--font-size-1 ~ 9", + "--space-0 ~ 18", + "--shadow-1 ~ 6", + "--font-family-sans/serif/mono", + "--leading-tight/snug/normal/relaxed/loose", + "--weight-regular/medium/semibold/bold", + "--duration-fast/normal/slow", + "--ease-in/out/in-out", + "--z-dropdown/sticky/modal/popover/toast" + ] + }, + { + "name": "semantic", + "description": "语义令牌,业务代码唯一引用入口,明暗双份", + "tokens": [ + "--background/foreground/card/popover/primary/secondary/muted/accent/destructive/border/input/ring/radius", + "--background-elevated/sunken", + "--text-primary/secondary/tertiary", + "--border-strong/subtle", + "--chart-1 ~ 5", + "--sidebar-*", + "--diff-add/add-bg/remove/remove-bg", + "--graph-node-1 ~ 6" + ] + }, + { + "name": "lesson-preparation", + "description": "--lp-* 命名空间令牌,明暗双份", + "tokens": [ + "--lp-paper/paper-edge/paper-shadow/paper-shadow-active", + "--lp-anchor-range/range-active/point", + "--lp-inline-node-border/text/meta", + "--lp-interaction", + "--lp-dot-objective/key-point/import/new-teaching/consolidation/summary/homework/blackboard/text-study/exercise/rich-text/reflection/interaction/textbook/default" + ] + } + ], + "tailwindExposure": "tailwind-theme.css @theme inline", + "constraints": { + "eslint": [ + "no-restricted-syntax: 禁止 #hex 字面量", + "design-tokens/no-hardcoded-fonts: 禁止 Inter/Fraunces/JetBrains Mono 字面量" + ], + "whitelist": [ + "src/app/styles/tokens/primitive.css", + "src/modules/notifications/channels/email-channel.ts", + "src/app/manifest.ts" + ] + }, + "addedAt": "2026-07-04", + "description": "设计令牌专项重构(2026-07-04):Primitive + Semantic 双层架构,HEX→HSL,明暗双份,@theme inline 暴露为 Tailwind 类。详见 docs/architecture/004_architecture_impact_map.md 1.1.2 章节" } }, "auth": { @@ -16401,6 +16728,15 @@ "usedBy": [ "message-detail.tsx" ] + }, + { + "name": "isRecipientRole / isMessageReportReason / isMessageReportStatus / toMessageReportReason / toMessageReportStatus", + "file": "lib/type-guards.ts", + "purpose": "✅ 类型守卫专项重构(2026-07-04)新增:替代 messaging 模块内的 as 断言。isXxx 守卫收窄 unknown 到对应联合类型;toXxx 安全窄化函数在守卫失败时返回 fallback 默认值(reason→'other',status→'pending')。用于 data-access.ts mapMessageReport 替代 `r.reason as MessageReportReason`,以及 message-report-block.tsx Select onChange 替代 `v as MessageReportReason`", + "usedBy": [ + "data-access.ts", + "components/message-report-block.tsx" + ] } ] } @@ -20457,6 +20793,15 @@ "file": "lib/anchor-injector.ts", "purpose": "正文变更后按文本相似度重定位锚点偏移量" }, + { + "name": "mergeBlockDataPatch", + "file": "lib/type-guards.ts", + "purpose": "✅ 类型守卫专项重构(2026-07-04)新增:安全合并 AI patch 到节点 data,替代 `{ ...node.data, ...patch } as BlockData` 断言。AI 返回的 patch 结构不可信,合并后必须通过对应 BlockType 的类型守卫校验:校验通过返回合并后的新数据;校验失败回退到原 node.data,保证编辑器数据完整性。内部通过 BLOCK_DATA_GUARDS: Record 注册表查找对应守卫。同时该文件所有 isXxxBlockData 守卫参数类型从 BlockData 放宽到 unknown(兼容 BlockDataGuard 协变签名)", + "usedBy": [ + "hooks/use-node-ai-assist.ts", + "lib/ai-node-assist.ts" + ] + }, { "name": "getTextbooksForPicker", "file": "data-access.ts", @@ -21092,7 +21437,8 @@ "V4-PAPER-DOC": "documentVersion 升级为 4:types.ts LessonPlanDocument.version = 4;lib/document-migration.ts normalizeDocument 兼容 v1/v2/v3/v4 链式迁移;editor-slice.ts 移除 updateNodePosition/connect/setEdges/autoLayout 画布相关方法;hooks/expanded-slice.ts 新增 expandedNodeIds/toggleExpand/setExpanded 状态(节点展开到正文流);lib/anchor-mark.ts 新增 Tiptap AnchorMark + AnchorPoint 扩展(替代 v3 字符串偏移锚点)", "V4-PAPER-V2-1": "V2 复制节点功能完整实现:hooks/editor-slice.ts 新增 duplicateNode(id) 方法(深拷贝 BlockData + 新 id + 标题加'副本' + order 置末,interaction 节点的 turns 重新生成 id 避免冲突);components/paper-editor/paper-context-menu.tsx 的 copyNode 从 toast 占位改为调用 duplicateNode 并 toast 成功提示;i18n 新增 v4.contextMenu.copied/copyFailed 键(zh-CN + en)", "V4-PAPER-V2-2": "V2 节点级 AI 协助 4 项完整实现:lib/ai-node-assist.ts 新增 4 个服务函数(generateNodeContent/optimizeNodeExpression/suggestNodeDifferentiation/generateLayeredQuestions),全部纯服务端、Zod 校验、失败返回 null;actions-ai.ts 新增 4 个 Server Action,每个 action 校验 LESSON_PLAN_READ + LESSON_PLAN_CREATE + AI_CHAT 三个权限点;hooks/use-node-ai-assist.ts 新增客户端 hook;components/paper-editor/paper-editor.tsx 注入 onAiAction 回调;components/detail-panel/detail-panel.tsx 的 4 个 AiButton 接入真实 action;i18n 新增 v4.contextMenu.aiRunning/aiSuccess/aiFailed/aiComingSoon 键", - "V4-I18N-1": "i18n 完整审查与修复:P0 修复 version.diff.* 3 键 + schema.ts 8 个 error.* 键 + contextMenu 6 键(zh-CN + en 同步);P1 修复 en calendar 节键名对齐 zh-CN;P2 修复 7 处硬编码中文 + 新增 6 个 i18n 键;清理 7 个 dead 节(review/comment/formative/substitute/evaluation/standards/gradeHead,analytics 因被 admin/curriculum-map 引用保留);lib/i18n-errors.ts 新增 t.has() 守卫" + "V4-I18N-1": "i18n 完整审查与修复:P0 修复 version.diff.* 3 键 + schema.ts 8 个 error.* 键 + contextMenu 6 键(zh-CN + en 同步);P1 修复 en calendar 节键名对齐 zh-CN;P2 修复 7 处硬编码中文 + 新增 6 个 i18n 键;清理 7 个 dead 节(review/comment/formative/substitute/evaluation/standards/gradeHead,analytics 因被 admin/curriculum-map 引用保留);lib/i18n-errors.ts 新增 t.has() 守卫", + "TYPE-GUARD-1": "类型守卫专项重构(2026-07-04):lib/type-guards.ts 所有 isXxxBlockData 守卫参数类型从 BlockData 放宽到 unknown(兼容 BlockDataGuard 协变签名)+ 新增 mergeBlockDataPatch(node, patch) 函数与 BLOCK_DATA_GUARDS: Record 注册表,替代 `{ ...node.data, ...patch } as BlockData` 断言;lib/export.ts 14 处 as 断言替换为对应 isXxxBlockData 守卫;data-access-calendar.ts/data-access-review.ts 新增 toLessonPlanStatus 辅助函数替代 11 处 `as LessonPlanStatus` 断言;hooks/editor-slice.ts updateNode 显式标注 AnyLessonPlanNode 返回类型 + TextbookContentNode 分支使用 unknown 中间变量;hooks/use-node-ai-assist.ts 4 处 `as BlockData` 替换为 mergeBlockDataPatch + isInteractionBlockData 守卫;lib/ai-node-assist.ts NodeContentUpdate.data 类型从 Partial 改为 Record(诚实反映 AI 输出 untrusted),2 处 as Partial + 1 处 as InteractionBlockData 替换为守卫" } }, "error-book": { @@ -21794,6 +22140,128 @@ "data-access.ts", "actions.ts" ] + }, + "search": { + "path": "src/modules/search", + "description": "✅ 2026-07-04 API 规范化重构新增:全文检索数据访问层。从 app/api/search/route.ts 下沉 4 个查询函数(searchQuestions/searchTextbooks/searchExams/searchAnnouncements),消除 app/ 层直接查询 DB 的架构违规。本模块仅负责 DB 查询,角色过滤(学生不能搜题目/考试)由路由层处理。", + "exports": { + "functions": [ + { + "name": "searchQuestions", + "file": "data-access.ts", + "signature": "searchQuestions(kw: string, limit?: number): Promise", + "purpose": "题库检索:CAST(content AS CHAR) LIKE 模糊匹配 JSON 字段", + "deps": [ + "db", + "questions" + ], + "usedBy": [ + "api/search" + ] + }, + { + "name": "searchTextbooks", + "file": "data-access.ts", + "signature": "searchTextbooks(kw: string, limit?: number): Promise", + "purpose": "教材检索:title/subject/publisher 三字段 OR 模糊匹配", + "deps": [ + "db", + "textbooks" + ], + "usedBy": [ + "api/search" + ] + }, + { + "name": "searchExams", + "file": "data-access.ts", + "signature": "searchExams(kw: string, limit?: number): Promise", + "purpose": "试卷检索:title/description 模糊匹配", + "deps": [ + "db", + "exams" + ], + "usedBy": [ + "api/search" + ] + }, + { + "name": "searchAnnouncements", + "file": "data-access.ts", + "signature": "searchAnnouncements(kw: string, limit?: number): Promise", + "purpose": "公告检索:仅返回 status=published 的公告,HTML 内容通过 stripHtml 提取纯文本摘要", + "deps": [ + "db", + "announcements" + ], + "usedBy": [ + "api/search" + ] + }, + { + "name": "DEFAULT_SEARCH_PAGE_SIZE", + "file": "data-access.ts", + "signature": "DEFAULT_SEARCH_PAGE_SIZE: number = 10", + "purpose": "默认分页大小常量", + "deps": [], + "usedBy": [ + "api/search" + ] + }, + { + "name": "isSearchType", + "file": "types.ts", + "signature": "isSearchType(v: string): v is SearchType", + "purpose": "SearchType 类型守卫", + "deps": [], + "usedBy": [ + "api/search" + ] + } + ], + "types": [ + { + "name": "SearchType", + "file": "types.ts", + "definition": "'all' | 'question' | 'textbook' | 'exam' | 'announcement'" + }, + { + "name": "SearchResultItem", + "file": "types.ts", + "definition": "{ id: string; title: string; snippet: string; type: 'question' | 'textbook' | 'exam' | 'announcement'; href: string; createdAt: string }" + }, + { + "name": "SearchResponse", + "file": "types.ts", + "definition": "{ success: boolean; query: string; type: SearchType; results: SearchResultItem[]; total: number; page: number; pageSize: number }" + } + ], + "files": [ + { + "path": "data-access.ts", + "lines": 212, + "description": "4 个 search 函数 + 3 个内部纯函数(extractTextFromJson/stripHtml/truncate)+ DEFAULT_SEARCH_PAGE_SIZE 常量。server-only。每个查询函数 try/catch 容错降级,单表失败返回空数组。", + "usedBy": [ + "app/api/search/route.ts" + ] + }, + { + "path": "types.ts", + "lines": 31, + "description": "SearchType/SearchResultItem/SearchResponse 类型 + isSearchType 类型守卫", + "usedBy": [ + "data-access.ts", + "app/api/search/route.ts" + ] + } + ] + }, + "dbTables": [ + "questions", + "textbooks", + "exams", + "announcements" + ] } }, "dbTables": { @@ -24153,6 +24621,11 @@ "classFilter.*" ] } + }, + "search": { + "dependsOn": [ + "shared" + ] } }, "moduleDependencyGraph": { @@ -24185,7 +24658,8 @@ "onboarding", "error-book", "ai", - "adaptive-practice" + "adaptive-practice", + "search" ], "edges": [ { @@ -24537,6 +25011,12 @@ "to": "adaptive-practice", "type": "action", "description": "错题详情弹窗调用 createPracticeSessionAction 发起变式练习" + }, + { + "from": "search", + "to": "shared", + "type": "normal", + "description": "使用 db/schema(questions/textbooks/exams/announcements 表)" } ] }, @@ -26294,7 +26774,9 @@ ], "handler": "createAiChatCompletion", "auth": "AI_CHAT", - "validation": "parseAiChatPayload (Zod)" + "validation": "parseAiChatPayload (Zod)", + "errorHandling": "withApiErrorHandler HOF + errorToStatus 集中映射(PermissionDeniedError→403 / NotFoundError→404 / ValidationError→400 / BusinessError→400 / 其他→500)", + "envelope": "✅ 2026-07-04 API 规范化重构:统一响应信封 { success, message?, errorCode?, data? }" }, "/api/ai/chat/stream": { "methods": [ @@ -26317,7 +26799,10 @@ "incrementDailyUsage — 调用后计数", "trackEvent — 埋点统计" ], - "studentMode": "强制苏格拉底式引导系统提示" + "studentMode": "强制苏格拉底式引导系统提示", + "errorHandling": "createSseError(流建立前)+ formatSseError(流中)", + "envelope": "✅ 2026-07-04 API 规范化重构:改用 shared/lib/sse.ts 共享 helpers(formatSseEvent/formatSseError/formatSseDone/createSseResponse/createSseError);补充 dynamic = 'force-dynamic'", + "dynamic": "force-dynamic" }, "/api/notifications/stream": { "methods": [ @@ -26341,7 +26826,10 @@ ], "usedBy": [ "hooks/use-notification-stream.ts" - ] + ], + "errorHandling": "PermissionDeniedError→403(已认证无权限),其他→401(未认证);改用 shared/lib/sse.ts 共享 helpers", + "envelope": "✅ 2026-07-04 API 规范化重构:改用 shared/lib/sse.ts 共享 helpers;补充 dynamic = 'force-dynamic';鉴权失败处理修正(原字符串匹配 '权限不足' → 改用 instanceof PermissionDeniedError)", + "dynamic": "force-dynamic" }, "/api/onboarding/complete": { "methods": [ @@ -26351,7 +26839,9 @@ "auth": "required", "validation": "Zod schema", "deprecated": true, - "deprecatedReason": "存在角色自选越权、教师覆盖任课、无事务等漏洞,已迁移至 modules/onboarding/actions.ts" + "deprecatedReason": "存在角色自选越权、教师覆盖任课、无事务等漏洞,已迁移至 modules/onboarding/actions.ts", + "errorHandling": "apiError(..., 410, 'deprecated') 统一废弃响应", + "envelope": "✅ 2026-07-04 API 规范化重构:统一响应信封 { success, message?, errorCode?, data? }" }, "/api/onboarding/status": { "methods": [ @@ -26360,7 +26850,9 @@ "handler": "onboarding status(已废弃,迁移至 getOnboardingStatusAction Server Action)", "auth": "required", "deprecated": true, - "deprecatedReason": "引导流程改为独立路由 /onboarding + middleware 重定向,不再需要客户端拉取状态" + "deprecatedReason": "引导流程改为独立路由 /onboarding + middleware 重定向,不再需要客户端拉取状态", + "errorHandling": "apiError(..., 410, 'deprecated') 统一废弃响应", + "envelope": "✅ 2026-07-04 API 规范化重构:统一响应信封 { success, message?, errorCode?, data? }" }, "/api/upload": { "methods": [ @@ -26371,7 +26863,9 @@ "module": "files", "delegatesTo": "files/actions.uploadFileAction", "validation": "Zod UploadMetadataSchema + isAllowedMimeType + MAX_FILE_SIZE (10MB)", - "description": "2026-06-25 重构:路由仅保留速率限制和 multipart 解析,委托 actions.uploadFileAction 完成权限校验+Zod 校验+storageProvider.save+createFileAttachment+trackEvent+logAudit。返回 FileUploadResult" + "description": "2026-06-25 重构:路由仅保留速率限制和 multipart 解析,委托 actions.uploadFileAction 完成权限校验+Zod 校验+storageProvider.save+createFileAttachment+trackEvent+logAudit。返回 FileUploadResult", + "errorHandling": "apiFromAction(result, { headers }) 通过 errorCode 驱动状态码", + "envelope": "✅ 2026-07-04 API 规范化重构:统一响应信封 { success, message?, errorCode?, data? }" }, "/api/files/[id]": { "methods": [ @@ -26382,7 +26876,9 @@ "auth": "GET: requirePermission(FILE_READ) + ownership 检查, DELETE: requirePermission(FILE_DELETE)", "module": "files", "delegatesTo": "GET: files/actions.getFileAction, DELETE: files/actions.deleteFileAction", - "description": "2026-06-25 重构:路由委托对应 actions。GET 加入 ownership 检查(非 FILE_DELETE 持有者只能读自己上传的);DELETE 通过 storageProvider.delete 删除磁盘文件 + trackEvent + logAudit" + "description": "2026-06-25 重构:路由委托对应 actions。✅ 2026-07-04 API 规范化重构:使用 apiFromAction 替代字符串匹配推导状态码,路由从 80+ 行简化到 38 行。GET 加入 ownership 检查(非 FILE_DELETE 持有者只能读自己上传的);DELETE 通过 storageProvider.delete 删除磁盘文件 + trackEvent + logAudit", + "errorHandling": "apiFromAction(GET/DELETE)通过 errorCode 驱动状态码", + "envelope": "✅ 2026-07-04 API 规范化重构:统一响应信封 { success, message?, errorCode?, data? }" }, "/api/files/batch-delete": { "methods": [ @@ -26393,7 +26889,9 @@ "module": "files", "delegatesTo": "files/actions.batchDeleteFilesAction", "validation": "Zod BatchDeleteSchema(ids 数组 max 100 元素,每个为非空字符串),空数组返回 400", - "description": "2026-06-25 重构:路由委托 actions.batchDeleteFilesAction。先通过 getFileAttachmentsByIds 查出文件记录,并行调用 storageProvider.delete 删除磁盘文件(静默失败),再调用 deleteFileAttachments 删除 DB 记录(失败时回退到逐条删除);trackEvent(file.batch_deleted) + logAudit;响应 { success, message, deletedCount, failedIds }" + "description": "2026-06-25 重构:路由委托 actions.batchDeleteFilesAction。先通过 getFileAttachmentsByIds 查出文件记录,并行调用 storageProvider.delete 删除磁盘文件(静默失败),再调用 deleteFileAttachments 删除 DB 记录(失败时回退到逐条删除);trackEvent(file.batch_deleted) + logAudit;响应 { success, message, deletedCount, failedIds }", + "errorHandling": "apiFromAction 通过 errorCode 驱动状态码", + "envelope": "✅ 2026-07-04 API 规范化重构:统一响应信封 { success, message?, errorCode?, data? }" }, "/api/search": { "methods": [ @@ -26401,9 +26899,12 @@ ], "handler": "全局全文检索", "auth": "requireAuth", - "module": "shared.db (questions/textbooks/exams/announcements)", + "module": "search", "validation": "query params: q (关键词), type=all|question|textbook|exam|announcement, page=1, pageSize=10 (上限 50)", - "description": "并行查询 questions/textbooks/exams/announcements(公告仅 status=published),按 createdAt 降序排序后分页;question content 字段 CAST AS CHAR 模糊匹配;返回 { success, query, type, results: [{ id, title, snippet, type, href, createdAt }], total, page, pageSize }" + "description": "并行查询 questions/textbooks/exams/announcements(公告仅 status=published),按 createdAt 降序排序后分页;question content 字段 CAST AS CHAR 模糊匹配;✅ 2026-07-04 重构:DB 查询下沉至 modules/search/data-access,消除 app/ 直查 DB 违规;统一响应信封 + withApiErrorHandler 异常处理", + "delegatesTo": "modules/search/data-access.{searchQuestions,searchTextbooks,searchExams,searchAnnouncements}", + "errorHandling": "withApiErrorHandler + apiSuccess", + "envelope": "✅ 2026-07-04 API 规范化重构:统一响应信封 { success, message?, errorCode?, data? }" }, "/api/export": { "methods": [ @@ -26413,7 +26914,9 @@ "auth": "requireAuth", "module": "shared.lib.excel + users/grades/attendance/elective/audit", "validation": "JSON body { type, params }", - "description": "按 type 分发:grades→exportGradeRecordsToExcel, users→exportUsersToExcel, attendance→exportAttendanceRecordsToExcel(需 ATTENDANCE_READ), electiveCourses→exportElectiveCoursesToExcel(需 ELECTIVE_READ), courseSelections→exportCourseSelectionsToExcel(需 ELECTIVE_MANAGE), audit/login/dataChange→audit/actions 导出(需 AUDIT_LOG_READ)。返回 application/vnd.openxmlformats-officedocument.spreadsheetml.sheet 二进制流" + "description": "按 type 分发:grades→exportGradeRecordsToExcel, users→exportUsersToExcel, attendance→exportAttendanceRecordsToExcel(需 ATTENDANCE_READ), electiveCourses→exportElectiveCoursesToExcel(需 ELECTIVE_READ), courseSelections→exportCourseSelectionsToExcel(需 ELECTIVE_MANAGE), audit/login/dataChange→audit/actions 导出(需 AUDIT_LOG_READ)。✅ 2026-07-04 API 规范化重构:用 EXPORT_TYPES 映射表替代 4 处嵌套 try/catch,统一 requirePermission 调用。返回 application/vnd.openxmlformats-officedocument.spreadsheetml.sheet 二进制流", + "errorHandling": "EXPORT_TYPES: Record 映射表 + requirePermission(无权限自动抛 PermissionDeniedError→403)", + "envelope": "✅ 2026-07-04 API 规范化重构:统一响应信封 { success, message?, errorCode?, data? }(成功响应仍为二进制流,失败响应为信封)" }, "/api/import": { "methods": [ @@ -26423,7 +26926,9 @@ "auth": "requirePermission(USER_MANAGE)", "module": "shared.lib.excel", "validation": "multipart/form-data file,限 .xlsx/.xls,10MB 上限", - "description": "接收 Excel 文件,调用 parseExcel 返回 sheets 预览数据(实际导入由 users/actions.importUsersAction 完成)" + "description": "接收 Excel 文件,调用 parseExcel 返回 sheets 预览数据(实际导入由 users/actions.importUsersAction 完成)", + "errorHandling": "ValidationError + apiError;修复原 401→403(权限不足应为 403 而非 401)", + "envelope": "✅ 2026-07-04 API 规范化重构:统一响应信封 { success, message?, errorCode?, data? }" } }, "devops": { diff --git a/docs/superpowers/plans/2026-07-04-design-tokens-refactor.md b/docs/superpowers/plans/2026-07-04-design-tokens-refactor.md new file mode 100644 index 0000000..472dabc --- /dev/null +++ b/docs/superpowers/plans/2026-07-04-design-tokens-refactor.md @@ -0,0 +1,1536 @@ +# 设计令牌专项重构 · 实施计划 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 全量体系化重建设计令牌(Primitive + Semantic 双层),清理 91 处硬编码颜色 / 8 处硬编码字体 / 15 处硬编码字号 / 98 处 Tailwind 任意值,建立 ESLint 强制约束,大爆炸式单 PR 提交。 + +**Architecture:** Tailwind v4 + `@theme inline` 双层令牌架构(Primitive 原料层 + Semantic 语义层),令牌文件拆分到 `src/app/styles/tokens/` 6 个文件,globals.css 改为 `@import` 引入。`--lp-*` 保留命名空间但 HEX→HSL、补全暗色、暴露到 Tailwind。M3 Surface 死代码清理。ESLint 自定义规则 + `eslint-plugin-tailwindcss` 锁定任意值。 + +**Tech Stack:** Tailwind CSS v4、Next.js 16 (App Router)、ESLint 9、TypeScript 5、shadcn/ui。 + +**Spec:** [`docs/superpowers/specs/2026-07-04-design-tokens-refactor-design.md`](file:///e:/Desktop/CICD/docs/superpowers/specs/2026-07-04-design-tokens-refactor-design.md) + +**执行方式:** 大爆炸式(单 PR),所有任务完成后单次 commit `refactor(design-tokens): 全量体系化重建设计令牌`。 + +--- + +## 文件结构 + +### 新建文件 + +| 文件 | 职责 | +|---|---| +| `src/app/styles/tokens/primitive.css` | Layer 1: 原始色板(zinc/stone/indigo)、字号阶梯、间距阶梯、阴影阶梯、字体家族 | +| `src/app/styles/tokens/semantic-light.css` | Layer 2: `:root` 语义令牌(shadcn 标准 + 扩展层级 + chart/sidebar + 新增 diff/graph) | +| `src/app/styles/tokens/semantic-dark.css` | Layer 2: `.dark` 语义令牌(完整暗色) | +| `src/app/styles/tokens/lesson-preparation.css` | `--lp-*` 令牌(明暗双份,HEX→HSL) | +| `src/app/styles/tokens/tailwind-theme.css` | `@theme inline` 暴露所有令牌为 Tailwind 类 | +| `src/app/styles/tokens/index.css` | 汇总 `@import` 入口 | +| `eslint-rules/no-hardcoded-design-tokens.js` | 自定义 ESLint 规则:检测硬编码字体/字号字面量 | + +### 修改文件 + +| 文件 | 修改内容 | +|---|---| +| `src/app/globals.css` | 改为 `@import` 引入 tokens,清理已迁移内容,令牌化剩余硬编码样式(lp-tb-btn/lp-inline-node/lp-qa-*) | +| `eslint.config.mjs` | 新增 `tailwindcss/no-arbitrary-value` 规则、`no-restricted-syntax` hex 检测、自定义规则加载 | +| `.trae/rules/project_rules.md` | "Tailwind 规范"章节新增"设计令牌规范(强制)" | +| `docs/architecture/004_architecture_impact_map.md` | 新增"设计令牌体系"章节 | +| `docs/architecture/005_architecture_data.json` | `modules.shared.exports` 新增 `design-tokens` 节点 | +| `docs/troubleshooting/known-issues.md` | 新增"设计令牌"问题分类 | +| 15 文件(91 处 #hex) | 按映射表令牌化或注释豁免 | +| 2 文件(8 处 font-family) | 令牌化为 `var(--font-family-*)` | +| 3 文件(15 处 font-size) | 令牌化为 `var(--font-size-*)` 或注释豁免 | +| 40 文件(98 处任意值) | Tier 1/2/3 分级处理 | + +--- + +## Task 1: 创建 primitive.css(Layer 1 原始阶梯) + +**Files:** +- Create: `src/app/styles/tokens/primitive.css` + +- [ ] **Step 1: 创建 primitive.css 文件** + +```css +/* src/app/styles/tokens/primitive.css */ +/* Layer 1: Primitive (Raw Tokens) — 原始色板/字号/间距/阴影/字体家族 + * 仅被 Semantic 层引用,业务代码不直接使用。 + * 色板层不区分明暗,主题差异在 Semantic 层体现。 + */ +: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-* 纸感) */ + --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%; + + /* ============ 字号阶梯 ============ */ + --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; /* 页面标题 */ + + /* ============ 间距阶梯 ============ */ + --space-0: 0; + --space-0_5: 0.125rem; /* 2px */ + --space-1: 0.25rem; /* 4px */ + --space-1_5: 0.375rem; /* 6px */ + --space-2: 0.5rem; /* 8px */ + --space-2_5: 0.625rem; /* 10px */ + --space-3: 0.75rem; /* 12px */ + --space-3_5: 0.875rem; /* 14px */ + --space-4: 1rem; /* 16px */ + --space-5: 1.25rem; /* 20px */ + --space-6: 1.5rem; /* 24px */ + --space-7: 1.75rem; /* 28px */ + --space-8: 2rem; /* 32px */ + --space-10: 2.5rem; /* 40px */ + --space-12: 3rem; /* 48px */ + --space-16: 4rem; /* 64px */ + --space-18: 4.5rem; /* 72px */ + + /* ============ 阴影阶梯 ============ */ + --shadow-1: 0 1px 2px rgba(15, 15, 15, 0.04); + --shadow-2: 0 1px 3px rgba(15, 15, 15, 0.06), 0 1px 2px rgba(15, 15, 15, 0.04); + --shadow-3: 0 4px 6px rgba(15, 15, 15, 0.05), 0 2px 4px rgba(15, 15, 15, 0.04); + --shadow-4: 0 1px 2px rgba(15, 15, 15, 0.04), 0 8px 24px rgba(15, 15, 15, 0.04); + --shadow-5: 0 1px 2px rgba(15, 15, 15, 0.06), 0 12px 36px rgba(15, 15, 15, 0.08); + --shadow-6: 0 10px 15px rgba(15, 15, 15, 0.1), 0 4px 6px rgba(15, 15, 15, 0.05); + + /* ============ 字体家族 ============ */ + --font-family-sans: 'Inter', system-ui, sans-serif; + --font-family-serif: 'Fraunces', Georgia, serif; + --font-family-mono: 'JetBrains Mono', monospace; + + /* ============ 行高阶梯 ============ */ + --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; + + /* ============ 动效 ============ */ + --duration-fast: 150ms; + --duration-normal: 200ms; + --duration-slow: 300ms; + --ease-in: cubic-bezier(0.4, 0, 1, 1); + --ease-out: cubic-bezier(0, 0, 0.2, 1); + --ease-in-out: cubic-bezier(0.4, 0, 0.2, 1); + + /* ============ z-index ============ */ + --z-dropdown: 1000; + --z-sticky: 1100; + --z-modal: 1300; + --z-popover: 1400; + --z-toast: 1500; +} +``` + +- [ ] **Step 2: 验证文件创建** + +Run: `Test-Path src/app/styles/tokens/primitive.css` +Expected: `True` + +--- + +## Task 2: 创建 semantic-light.css(`:root` 语义令牌) + +**Files:** +- Create: `src/app/styles/tokens/semantic-light.css` + +- [ ] **Step 1: 创建 semantic-light.css 文件** + +```css +/* src/app/styles/tokens/semantic-light.css */ +/* Layer 2: Semantic Tokens (Light / :root) + * 业务代码唯一引用入口。所有令牌都有 .dark 对应定义。 + */ +:root { + /* ============ shadcn 标准令牌(原 globals.css 迁移,值不变) ============ */ + --background: 0 0% 100%; + --foreground: 240 10% 3.9%; + --card: 0 0% 100%; + --card-foreground: 240 10% 3.9%; + --popover: 0 0% 100%; + --popover-foreground: 240 10% 3.9%; + --primary: 240 5.9% 10%; + --primary-foreground: 0 0% 98%; + --secondary: 240 4.8% 95.9%; + --secondary-foreground: 240 5.9% 10%; + --muted: 240 4.8% 95.9%; + --muted-foreground: 240 3.8% 46.1%; + --accent: 240 4.8% 95.9%; + --accent-foreground: 240 5.9% 10%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 0 0% 98%; + --border: 240 5.9% 90%; + --input: 240 5.9% 90%; + --ring: 240 5.9% 10%; + --radius: 0.5rem; + + /* ============ chart 令牌(原 globals.css 迁移,值不变) ============ */ + --chart-1: 12 76% 61%; + --chart-2: 173 58% 39%; + --chart-3: 197 37% 24%; + --chart-4: 43 74% 66%; + --chart-5: 27 87% 67%; + + /* ============ sidebar 令牌(原 globals.css 迁移,值不变) ============ */ + --sidebar-background: 0 0% 98%; + --sidebar-foreground: 240 5.3% 26.1%; + --sidebar-primary: 240 5.9% 10%; + --sidebar-primary-foreground: 0 0% 98%; + --sidebar-accent: 240 4.8% 95.9%; + --sidebar-accent-foreground: 240 5.9% 10%; + --sidebar-border: 220 13% 91%; + --sidebar-ring: 217.2 91.2% 59.8%; + + /* ============ 语义层扩展(新增层级) ============ */ + --background-elevated: 0 0% 100%; + --background-sunken: 240 4.8% 95.9%; + --text-primary: 240 10% 3.9%; /* = --foreground */ + --text-secondary: 240 3.8% 46.1%; /* = --muted-foreground */ + --text-tertiary: 240 4% 65%; + --border-strong: 240 5.9% 70%; + --border-subtle: 240 5.9% 95%; + + /* ============ 新增业务语义令牌 ============ */ + /* 版本对比 */ + --diff-add: 142 71% 45%; + --diff-add-bg: 142 71% 45% / 0.1; + --diff-remove: 0 84% 60%; + --diff-remove-bg: 0 84% 60% / 0.1; + + /* 知识图谱节点色 */ + --graph-node-1: 12 76% 61%; /* = --chart-1 */ + --graph-node-2: 173 58% 39%; /* = --chart-2 */ + --graph-node-3: 197 37% 24%; /* = --chart-3 */ + --graph-node-4: 43 74% 66%; /* = --chart-4 */ + --graph-node-5: 27 87% 67%; /* = --chart-5 */ + --graph-node-6: 280 65% 60%; +} +``` + +- [ ] **Step 2: 验证文件创建** + +Run: `Test-Path src/app/styles/tokens/semantic-light.css` +Expected: `True` + +--- + +## Task 3: 创建 semantic-dark.css(`.dark` 语义令牌) + +**Files:** +- Create: `src/app/styles/tokens/semantic-dark.css` + +- [ ] **Step 1: 创建 semantic-dark.css 文件** + +```css +/* src/app/styles/tokens/semantic-dark.css */ +/* Layer 2: Semantic Tokens (Dark / .dark) + * 所有令牌都有 :root 对应定义。 + */ +.dark { + /* ============ shadcn 标准令牌(原 globals.css 迁移,值不变) ============ */ + --background: 240 10% 3.9%; + --foreground: 0 0% 98%; + --card: 240 10% 3.9%; + --card-foreground: 0 0% 98%; + --popover: 240 10% 3.9%; + --popover-foreground: 0 0% 98%; + --primary: 0 0% 98%; + --primary-foreground: 240 5.9% 10%; + --secondary: 240 3.7% 15.9%; + --secondary-foreground: 0 0% 98%; + --muted: 240 3.7% 15.9%; + --muted-foreground: 240 5% 64.9%; + --accent: 240 3.7% 15.9%; + --accent-foreground: 0 0% 98%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 0 0% 98%; + --border: 240 3.7% 15.9%; + --input: 240 3.7% 15.9%; + --ring: 240 4.9% 83.9%; + + /* ============ chart 令牌(原 globals.css 迁移,值不变) ============ */ + --chart-1: 220 70% 50%; + --chart-2: 160 60% 45%; + --chart-3: 30 80% 55%; + --chart-4: 280 65% 60%; + --chart-5: 340 75% 55%; + + /* ============ sidebar 令牌(原 globals.css 迁移,值不变) ============ */ + --sidebar-background: 240 5.9% 10%; + --sidebar-foreground: 240 4.8% 95.9%; + --sidebar-primary: 224.3 76.3% 48%; + --sidebar-primary-foreground: 0 0% 100%; + --sidebar-accent: 240 3.7% 15.9%; + --sidebar-accent-foreground: 240 4.8% 95.9%; + --sidebar-border: 240 3.7% 15.9%; + --sidebar-ring: 217.2 91.2% 59.8%; + + /* ============ 语义层扩展(新增层级) ============ */ + --background-elevated: 240 6% 10%; + --background-sunken: 240 6% 8%; + --text-primary: 0 0% 98%; /* = --foreground */ + --text-secondary: 240 5% 64.9%; /* = --muted-foreground */ + --text-tertiary: 240 5% 50%; + --border-strong: 240 5% 40%; + --border-subtle: 240 5% 18%; + + /* ============ 新增业务语义令牌 ============ */ + --diff-add: 142 71% 55%; + --diff-add-bg: 142 71% 55% / 0.15; + --diff-remove: 0 84% 70%; + --diff-remove-bg: 0 84% 70% / 0.15; + + --graph-node-1: 220 70% 50%; + --graph-node-2: 160 60% 45%; + --graph-node-3: 30 80% 55%; + --graph-node-4: 280 65% 60%; + --graph-node-5: 340 75% 55%; + --graph-node-6: 200 80% 60%; +} +``` + +- [ ] **Step 2: 验证文件创建** + +Run: `Test-Path src/app/styles/tokens/semantic-dark.css` +Expected: `True` + +--- + +## Task 4: 创建 lesson-preparation.css(--lp-* 明暗双份) + +**Files:** +- Create: `src/app/styles/tokens/lesson-preparation.css` + +- [ ] **Step 1: 创建 lesson-preparation.css 文件** + +```css +/* src/app/styles/tokens/lesson-preparation.css */ +/* --lp-* 令牌(lesson-preparation 模块命名空间) + * HEX 已转换为 HSL,与 shadcn 体系统一。 + * 暗色已补齐。 + */ + +:root { + /* 纸感 */ + --lp-paper: 0 0% 99.6%; + --lp-paper-edge: 60 9% 97%; + --lp-paper-shadow: var(--shadow-4); + --lp-paper-shadow-active: var(--shadow-5); + + /* 锚点 */ + --lp-anchor-range: 20 14.3% 4.1% / 0.08; + --lp-anchor-range-active: 20 14.3% 4.1% / 0.16; + --lp-anchor-point: 20 14.3% 4.1%; /* = stone-950 */ + + /* inline-node */ + --lp-inline-node-border: 30 5.7% 82.9%; /* = stone-300 */ + --lp-inline-node-text: 24 5.4% 26.9%; /* ≈ stone-700 */ + --lp-inline-node-meta: 30 5% 64.9%; /* = stone-400 */ + + /* 交互强调 */ + --lp-interaction: 238.6 84.5% 59.8%; /* = indigo-600 */ + + /* 节点类型色点(极克制,全部映射到 zinc/stone) */ + --lp-dot-objective: 240 3.8% 46.1%; /* = zinc-500 */ + --lp-dot-key-point: 0 0% 30%; /* ≈ zinc-700 灰 */ + --lp-dot-import: 240 4% 50%; /* = zinc-500 变体 */ + --lp-dot-new-teaching: 240 10% 3.9%; /* = zinc-950 */ + --lp-dot-consolidation: 240 4% 50%; + --lp-dot-summary: 0 0% 30%; + --lp-dot-homework: 0 0% 30%; + --lp-dot-blackboard: 24 5.4% 26.9%; /* = stone-700 */ + --lp-dot-text-study: 240 3.8% 46.1%; + --lp-dot-exercise: 240 4% 50%; + --lp-dot-rich-text: 30 5% 64.9%; /* = stone-400 */ + --lp-dot-reflection: 240 4% 50%; + --lp-dot-interaction: 238.6 84.5% 59.8%;/* = indigo-600 */ + --lp-dot-textbook: 24 5.4% 26.9%; + --lp-dot-default: 30 5% 64.9%; +} + +.dark { + /* 纸感(暗背景) */ + --lp-paper: 240 6% 10%; + --lp-paper-edge: 240 6% 8%; + --lp-paper-shadow: var(--shadow-4); + --lp-paper-shadow-active: var(--shadow-5); + + /* 锚点(反相) */ + --lp-anchor-range: 0 0% 98% / 0.08; + --lp-anchor-range-active: 0 0% 98% / 0.16; + --lp-anchor-point: 0 0% 98%; + + /* inline-node */ + --lp-inline-node-border: 240 4% 30%; + --lp-inline-node-text: 240 5% 80%; + --lp-inline-node-meta: 240 5% 55%; + + /* 交互强调(暗色微亮) */ + --lp-interaction: 238.7 83.5% 66.7%; /* = indigo-500 */ + + /* 节点类型色点(暗色微亮) */ + --lp-dot-objective: 240 5% 64.9%; + --lp-dot-key-point: 0 0% 70%; + --lp-dot-import: 240 4% 65%; + --lp-dot-new-teaching: 0 0% 98%; + --lp-dot-consolidation: 240 4% 65%; + --lp-dot-summary: 0 0% 70%; + --lp-dot-homework: 0 0% 70%; + --lp-dot-blackboard: 30 5% 70%; + --lp-dot-text-study: 240 5% 64.9%; + --lp-dot-exercise: 240 4% 65%; + --lp-dot-rich-text: 30 5% 55%; + --lp-dot-reflection: 240 4% 65%; + --lp-dot-interaction: 238.7 83.5% 66.7%; + --lp-dot-textbook: 30 5% 70%; + --lp-dot-default: 30 5% 55%; +} +``` + +- [ ] **Step 2: 验证文件创建** + +Run: `Test-Path src/app/styles/tokens/lesson-preparation.css` +Expected: `True` + +--- + +## Task 5: 创建 tailwind-theme.css(@theme inline 暴露) + +**Files:** +- Create: `src/app/styles/tokens/tailwind-theme.css` + +- [ ] **Step 1: 创建 tailwind-theme.css 文件** + +```css +/* src/app/styles/tokens/tailwind-theme.css */ +/* @theme inline: 将所有 Semantic 令牌暴露为 Tailwind 类 + * 业务代码使用 bg-background / text-foreground / bg-lp-paper / font-sans 等 + */ + +@theme inline { + /* ============ shadcn 标准颜色 ============ */ + --color-background: hsl(var(--background)); + --color-foreground: hsl(var(--foreground)); + --color-card: hsl(var(--card)); + --color-card-foreground: hsl(var(--card-foreground)); + --color-popover: hsl(var(--popover)); + --color-popover-foreground: hsl(var(--popover-foreground)); + --color-primary: hsl(var(--primary)); + --color-primary-foreground: hsl(var(--primary-foreground)); + --color-secondary: hsl(var(--secondary)); + --color-secondary-foreground: hsl(var(--secondary-foreground)); + --color-muted: hsl(var(--muted)); + --color-muted-foreground: hsl(var(--muted-foreground)); + --color-accent: hsl(var(--accent)); + --color-accent-foreground: hsl(var(--accent-foreground)); + --color-destructive: hsl(var(--destructive)); + --color-destructive-foreground: hsl(var(--destructive-foreground)); + --color-border: hsl(var(--border)); + --color-input: hsl(var(--input)); + --color-ring: hsl(var(--ring)); + + /* ============ chart 颜色 ============ */ + --color-chart-1: hsl(var(--chart-1)); + --color-chart-2: hsl(var(--chart-2)); + --color-chart-3: hsl(var(--chart-3)); + --color-chart-4: hsl(var(--chart-4)); + --color-chart-5: hsl(var(--chart-5)); + + /* ============ sidebar 颜色 ============ */ + --color-sidebar: hsl(var(--sidebar-background)); + --color-sidebar-foreground: hsl(var(--sidebar-foreground)); + --color-sidebar-primary: hsl(var(--sidebar-primary)); + --color-sidebar-primary-foreground: hsl(var(--sidebar-primary-foreground)); + --color-sidebar-accent: hsl(var(--sidebar-accent)); + --color-sidebar-accent-foreground: hsl(var(--sidebar-accent-foreground)); + --color-sidebar-border: hsl(var(--sidebar-border)); + --color-sidebar-ring: hsl(var(--sidebar-ring)); + + /* ============ 扩展语义颜色 ============ */ + --color-background-elevated: hsl(var(--background-elevated)); + --color-background-sunken: hsl(var(--background-sunken)); + --color-text-primary: hsl(var(--text-primary)); + --color-text-secondary: hsl(var(--text-secondary)); + --color-text-tertiary: hsl(var(--text-tertiary)); + --color-border-strong: hsl(var(--border-strong)); + --color-border-subtle: hsl(var(--border-subtle)); + --color-diff-add: hsl(var(--diff-add)); + --color-diff-add-bg: hsl(var(--diff-add-bg)); + --color-diff-remove: hsl(var(--diff-remove)); + --color-diff-remove-bg: hsl(var(--diff-remove-bg)); + --color-graph-node-1: hsl(var(--graph-node-1)); + --color-graph-node-2: hsl(var(--graph-node-2)); + --color-graph-node-3: hsl(var(--graph-node-3)); + --color-graph-node-4: hsl(var(--graph-node-4)); + --color-graph-node-5: hsl(var(--graph-node-5)); + --color-graph-node-6: hsl(var(--graph-node-6)); + + /* ============ lp-* 颜色(lesson-preparation) ============ */ + --color-lp-paper: hsl(var(--lp-paper)); + --color-lp-paper-edge: hsl(var(--lp-paper-edge)); + --color-lp-anchor-point: hsl(var(--lp-anchor-point)); + --color-lp-inline-node-border: hsl(var(--lp-inline-node-border)); + --color-lp-inline-node-text: hsl(var(--lp-inline-node-text)); + --color-lp-inline-node-meta: hsl(var(--lp-inline-node-meta)); + --color-lp-interaction: hsl(var(--lp-interaction)); + --color-lp-dot-objective: hsl(var(--lp-dot-objective)); + --color-lp-dot-key-point: hsl(var(--lp-dot-key-point)); + --color-lp-dot-import: hsl(var(--lp-dot-import)); + --color-lp-dot-new-teaching: hsl(var(--lp-dot-new-teaching)); + --color-lp-dot-consolidation: hsl(var(--lp-dot-consolidation)); + --color-lp-dot-summary: hsl(var(--lp-dot-summary)); + --color-lp-dot-homework: hsl(var(--lp-dot-homework)); + --color-lp-dot-blackboard: hsl(var(--lp-dot-blackboard)); + --color-lp-dot-text-study: hsl(var(--lp-dot-text-study)); + --color-lp-dot-exercise: hsl(var(--lp-dot-exercise)); + --color-lp-dot-rich-text: hsl(var(--lp-dot-rich-text)); + --color-lp-dot-reflection: hsl(var(--lp-dot-reflection)); + --color-lp-dot-interaction: hsl(var(--lp-dot-interaction)); + --color-lp-dot-textbook: hsl(var(--lp-dot-textbook)); + --color-lp-dot-default: hsl(var(--lp-dot-default)); + + /* ============ 字体家族 ============ */ + --font-sans: var(--font-family-sans); + --font-serif: var(--font-family-serif); + --font-mono: var(--font-family-mono); + + /* ============ 字号阶梯 ============ */ + --text-size-1: var(--font-size-1); + --text-size-2: var(--font-size-2); + --text-size-3: var(--font-size-3); + --text-size-4: var(--font-size-4); + --text-size-5: var(--font-size-5); + --text-size-6: var(--font-size-6); + --text-size-7: var(--font-size-7); + --text-size-8: var(--font-size-8); + --text-size-9: var(--font-size-9); + + /* ============ 圆角阶梯 ============ */ + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --radius-2xl: calc(var(--radius) + 8px); + --radius-full: 9999px; + + /* ============ 阴影阶梯 ============ */ + --shadow-1: var(--shadow-1); + --shadow-2: var(--shadow-2); + --shadow-3: var(--shadow-3); + --shadow-4: var(--shadow-4); + --shadow-5: var(--shadow-5); + --shadow-6: var(--shadow-6); + + /* ============ 动效 ============ */ + --duration-fast: var(--duration-fast); + --duration-normal: var(--duration-normal); + --duration-slow: var(--duration-slow); + --ease-in: var(--ease-in); + --ease-out: var(--ease-out); + --ease-in-out: var(--ease-in-out); + + /* ============ z-index ============ */ + --z-dropdown: var(--z-dropdown); + --z-sticky: var(--z-sticky); + --z-modal: var(--z-modal); + --z-popover: var(--z-popover); + --z-toast: var(--z-toast); + + /* ============ 动画(原 globals.css 迁移) ============ */ + --animate-accordion-down: accordion-down 0.2s ease-out; + --animate-accordion-up: accordion-up 0.2s ease-out; + + @keyframes accordion-down { + from { height: 0; } + to { height: var(--radix-accordion-content-height); } + } + @keyframes accordion-up { + from { height: var(--radix-accordion-content-height); } + to { height: 0; } + } +} +``` + +- [ ] **Step 2: 验证文件创建** + +Run: `Test-Path src/app/styles/tokens/tailwind-theme.css` +Expected: `True` + +--- + +## Task 6: 创建 index.css(汇总入口) + +**Files:** +- Create: `src/app/styles/tokens/index.css` + +- [ ] **Step 1: 创建 index.css 文件** + +```css +/* src/app/styles/tokens/index.css */ +/* 设计令牌汇总入口 + * 加载顺序:Primitive → Semantic(明/暗) → 模块命名空间 → Tailwind 暴露 + */ +@import "./primitive.css"; +@import "./semantic-light.css"; +@import "./semantic-dark.css"; +@import "./lesson-preparation.css"; +@import "./tailwind-theme.css"; +``` + +- [ ] **Step 2: 验证文件创建** + +Run: `Test-Path src/app/styles/tokens/index.css` +Expected: `True` + +--- + +## Task 7: 改造 globals.css(@import + 清理 + 令牌化) + +**Files:** +- Modify: `src/app/globals.css` + +- [ ] **Step 1: 重写 globals.css 顶部 @import 区** + +替换 [src/app/globals.css:1-6](file:///e:/Desktop/CICD/src/app/globals.css#L1-L6) 为: + +```css +@import "tailwindcss"; +@import "./styles/tokens/index.css"; +@plugin "tailwindcss-animate"; +@plugin "@tailwindcss/typography"; +@custom-variant dark (&:where(.dark, .dark *)); +``` + +- [ ] **Step 2: 删除已迁移到 tokens 的内容** + +删除 [src/app/globals.css:8-215](file:///e:/Desktop/CICD/src/app/globals.css#L8-L215)(原 `:root` / `.dark` / `@theme inline` 块全部已迁移到 tokens 文件)。 + +- [ ] **Step 3: 令牌化 lp-paper-toolbar 硬编码** + +找到 `.lp-paper-toolbar` 选择器(原约 350 行),替换为: + +```css +.lp-paper-toolbar { + position: sticky; + top: 0; + margin: calc(-1 * var(--space-16)) calc(-1 * var(--space-18)) var(--space-6); + padding: calc(var(--space-2) + var(--space-0_5)) var(--space-18); + background: hsl(var(--background) / 0.92); + backdrop-filter: blur(8px); + border-bottom: 1px solid hsl(var(--border)); + display: flex; + gap: var(--space-0_5); + z-index: var(--z-sticky); +} +``` + +- [ ] **Step 4: 令牌化 lp-tb-btn 硬编码** + +替换 `.lp-tb-btn` 选择器: + +```css +.lp-tb-btn { + width: var(--space-7); + height: var(--space-7); + border: none; + background: transparent; + border-radius: var(--radius-sm); + cursor: pointer; + font-family: var(--font-family-sans); + font-size: var(--font-size-2); + color: hsl(var(--foreground)); + display: inline-flex; + align-items: center; + justify-content: center; + transition: background var(--duration-fast); +} +.lp-tb-btn:hover { background: hsl(var(--muted)); } +.lp-tb-btn.is-active { background: hsl(var(--muted)); color: hsl(var(--foreground)); } +.lp-tb-btn.b { font-weight: var(--weight-bold); } +.lp-tb-btn.i { font-style: italic; font-family: var(--font-family-serif); } +.lp-tb-btn.h1 { font-size: var(--font-size-1); font-weight: var(--weight-semibold); } +.lp-tb-btn.h2 { font-size: var(--font-size-1); font-weight: var(--weight-medium); } +.lp-tb-btn.ul { font-size: var(--font-size-3); } +.lp-tb-btn.quote { font-family: var(--font-family-serif); font-style: italic; font-size: var(--font-size-3); } +.lp-tb-divider { + width: var(--space-0_5); + background: hsl(var(--border)); + margin: var(--space-1) var(--space-1); +} +``` + +- [ ] **Step 5: 令牌化 lp-anchor-* 硬编码** + +替换 `.lp-anchor-range` 与 `.lp-anchor-point`: + +```css +.lp-anchor-range { + background: hsl(var(--lp-anchor-range)); + border-radius: var(--radius-sm); + padding: var(--space-0_5) var(--space-0_5); + margin: 0 calc(-1 * var(--space-0_5)); + cursor: pointer; + border-bottom: 1.5px solid hsl(var(--lp-anchor-point)); + transition: background var(--duration-fast); +} +.lp-anchor-range:hover, .lp-anchor-range.is-active { + background: hsl(var(--lp-anchor-range-active)); +} +.lp-anchor-point { + display: inline-block; + width: var(--space-4); + height: var(--space-4); + line-height: var(--space-4); + text-align: center; + border-radius: var(--radius-full); + background: hsl(var(--lp-anchor-point)); + color: hsl(var(--background)); + font-family: var(--font-family-sans); + font-size: var(--font-size-1); + font-weight: var(--weight-semibold); + margin: 0 var(--space-0_5); + vertical-align: middle; + cursor: pointer; +} +``` + +- [ ] **Step 6: 令牌化 lp-inline-node 硬编码** + +替换 `.lp-inline-node` 系列: + +```css +.lp-inline-node { + font-family: var(--font-family-sans); + margin: var(--space-5) 0 var(--space-5); + padding: calc(var(--space-3) + var(--space-0_5)) calc(var(--space-4) + var(--space-2)) calc(var(--space-3) + var(--space-0_5)) var(--space-5); + border-left: 2px solid hsl(var(--lp-inline-node-border)); + color: hsl(var(--lp-inline-node-text)); + font-size: var(--font-size-3); + line-height: var(--leading-relaxed); + transition: border-color var(--duration-normal); +} +.lp-inline-node:hover { border-left-color: hsl(var(--foreground)); } +.lp-inline-node-head { + display: flex; + align-items: center; + gap: var(--space-2); + margin-bottom: var(--space-2); + font-size: var(--font-size-1); + font-weight: var(--weight-semibold); + text-transform: uppercase; + letter-spacing: 0.08em; + color: hsl(var(--lp-inline-node-meta)); +} +.lp-inline-node-title { + font-family: var(--font-family-sans); + font-weight: var(--weight-semibold); + font-size: var(--font-size-4); + color: hsl(var(--lp-anchor-point)); + margin: 0 0 var(--space-1_5); + line-height: var(--leading-snug); +} +.lp-inline-node-body { + font-size: var(--font-size-2); + line-height: var(--leading-normal); + color: hsl(var(--lp-inline-node-text)); +} +``` + +- [ ] **Step 7: 令牌化 lp-qa-* 硬编码** + +替换 `.lp-qa-*` 系列: + +```css +.lp-qa-dialog { margin: var(--space-2) 0 var(--space-1); } +.lp-qa-turn { + display: grid; + grid-template-columns: calc(var(--space-6) + var(--space-2)) 1fr; + gap: calc(var(--space-2) + var(--space-0_5)); + padding: var(--space-2) 0; + border-bottom: 1px dashed hsl(var(--border)); + font-size: var(--font-size-1); + line-height: var(--leading-relaxed); +} +.lp-qa-turn:last-child { border-bottom: none; } +.lp-qa-role { + font-family: var(--font-family-mono); + font-size: 9px; + font-weight: var(--weight-semibold); + text-transform: uppercase; + letter-spacing: 0.05em; + padding-top: var(--space-0_5); + text-align: right; +} +.lp-qa-role.teacher { color: hsl(var(--lp-anchor-point)); } +.lp-qa-role.student { color: hsl(var(--color-zinc-500)); } +.lp-qa-content { color: hsl(var(--lp-inline-node-text)); } +.lp-qa-prompt { + font-style: italic; + color: hsl(var(--color-zinc-600)); + font-size: var(--font-size-1); + display: block; + margin-top: var(--space-0_5); +} +``` + +- [ ] **Step 8: 令牌化 range-anchor / point-anchor / anchor-edge 硬编码** + +替换 `.range-anchor` 等(注意 fallback 改为令牌): + +```css +.range-anchor { + background-color: var(--node-color, hsl(var(--lp-interaction))); + opacity: 0; + transition: opacity var(--duration-normal); + border-radius: var(--radius-sm); + cursor: pointer; + padding: 0 var(--space-0_5); +} +.range-anchor.active { opacity: 0.3; } +.range-anchor:hover { opacity: 0.5; } + +.point-anchor { + background-color: var(--node-color, hsl(var(--lp-interaction))); + color: hsl(var(--background)); + opacity: 0.3; + transition: opacity var(--duration-normal); + border-radius: var(--radius-full); + cursor: pointer; + padding: 0 var(--space-1); + margin: 0 var(--space-0_5); + font-size: 0.85em; + font-weight: var(--weight-semibold); + display: inline-block; + min-width: 1.2em; + text-align: center; + line-height: 1.2; + user-select: none; +} +.point-anchor.active { opacity: 1; } +.point-anchor:hover { opacity: 0.8; } + +.anchor-edge { + opacity: 0.1; + transition: opacity var(--duration-normal); +} +.anchor-edge.active { opacity: 1; } +``` + +- [ ] **Step 9: 验证 globals.css 行数** + +Run: `(Get-Content src/app/globals.css | Measure-Object -Line).Lines` +Expected: 应从原 477 行降至约 200 行以下(打印样式 + 组件硬编码样式令牌化后) + +- [ ] **Step 10: 验证 dev 启动** + +Run: `npm run dev`(后台运行,5 秒后停止) +Expected: 无 CSS 编译错误 + +--- + +## Task 8: 清理 91 处 #hex 颜色硬编码 + +**Files:** +- Modify: `src/app/manifest.ts`(2 处,注释豁免) +- Modify: `src/app/layout.tsx`(2 处,检查后令牌化或注释) +- Modify: `src/shared/components/ui/chart.tsx`(5 处,令牌化为 `--chart-*`) +- Modify: `src/modules/notifications/channels/email-channel.ts`(7 处,注释豁免) +- Modify: `src/modules/textbooks/components/knowledge-graph.tsx`(10 处,令牌化为 `--graph-node-*`) +- Modify: `src/modules/textbooks/components/force-graph.tsx`(17 处,令牌化为 `--graph-node-*`) +- Modify: `src/modules/lesson-preparation/components/paper-editor/paper-context-menu.tsx` +- Modify: `src/modules/lesson-preparation/components/paper-editor/paper-editor.tsx` +- Modify: `src/modules/lesson-preparation/components/paper-editor/textbook-tiptap-editor.tsx` +- Modify: `src/modules/lesson-preparation/components/paper-editor/inline-node.tsx` +- Modify: `src/modules/lesson-preparation/components/paper-editor/inline-qa-dialog.tsx` +- Modify: `src/modules/lesson-preparation/components/structure-tree/tree-node-row.tsx` +- Modify: `src/modules/lesson-preparation/components/detail-panel/detail-panel.tsx` +- Modify: `src/modules/lesson-preparation/components/detail-panel/detail-head.tsx` +- Modify: `src/modules/lesson-preparation/components/anchor-migration-banner.tsx`(5 处) +- Modify: `src/modules/lesson-preparation/components/version-diff-view.tsx`(1 处,令牌化为 `--diff-*`) +- Modify: `src/modules/attendance/components/attendance-grade-correlation-card.tsx`(3 处,令牌化为 `--chart-*`) +- Modify: `src/modules/classes/components/my-classes-grid.tsx`(2 处) + +- [ ] **Step 1: 读取每个文件,定位 #hex 字面量** + +对上述每个文件运行 Grep 搜索 `#[0-9a-fA-F]{3,8}\b`,记录每处行号与上下文。 + +- [ ] **Step 2: 注释豁免 manifest.ts** + +在 [`src/app/manifest.ts`](file:///e:/Desktop/CICD/src/app/manifest.ts) 中所有 `#hex` 上方加注释: +```typescript +// arbitrary-value: PWA manifest requires literal hex +themeColor: "#1976d2", +``` + +- [ ] **Step 3: 注释豁免 email-channel.ts** + +在 [`src/modules/notifications/channels/email-channel.ts`](file:///e:/Desktop/CICD/src/modules/notifications/channels/email-channel.ts) 中所有 `#hex` 上方加注释: +```typescript +// arbitrary-value: email HTML inline style requires literal hex +``` + +- [ ] **Step 4: 令牌化 chart.tsx** + +在 [`src/shared/components/ui/chart.tsx`](file:///e:/Desktop/CICD/src/shared/components/ui/chart.tsx) 中,将 `#xxx` 替换为 `hsl(var(--chart-N))` 形式。例如: +```typescript +// 旧:fill="#12 76% 61%" 或 fill="#e11d48" +// 新:fill={`hsl(var(--chart-1))`} 或 fill="hsl(var(--chart-1))" +``` + +- [ ] **Step 5: 令牌化 knowledge-graph.tsx 与 force-graph.tsx** + +在 [`src/modules/textbooks/components/knowledge-graph.tsx`](file:///e:/Desktop/CICD/src/modules/textbooks/components/knowledge-graph.tsx) 与 [`force-graph.tsx`](file:///e:/Desktop/CICD/src/modules/textbooks/components/force-graph.tsx) 中,将节点色 `#xxx` 替换为 `hsl(var(--graph-node-N))`。 + +- [ ] **Step 6: 令牌化 lesson-preparation/components/paper-editor/* 与 detail-panel/* 与 structure-tree/* 与 anchor-migration-banner.tsx** + +将所有 `#hex` 替换为对应的 `hsl(var(--lp-*))` 或 `hsl(var(--color-zinc-N))` / `hsl(var(--color-stone-N))`。具体映射按 spec 4.4 节表。 + +- [ ] **Step 7: 令牌化 version-diff-view.tsx** + +将 `#hex` 替换为 `hsl(var(--diff-add))` 或 `hsl(var(--diff-remove))`。 + +- [ ] **Step 8: 令牌化 attendance-grade-correlation-card.tsx** + +将 `#hex` 替换为 `hsl(var(--chart-N))`。 + +- [ ] **Step 9: 令牌化 my-classes-grid.tsx** + +将 `#hex` 替换为对应令牌。 + +- [ ] **Step 10: 检查 layout.tsx** + +读取 [`src/app/layout.tsx`](file:///e:/Desktop/CICD/src/app/layout.tsx),若 `#hex` 用于 metadata themeColor 等字面量场景,加注释豁免;否则令牌化。 + +- [ ] **Step 11: 验证 #hex 残留(白名单外)** + +Run: `npx eslint src/app/manifest.ts src/modules/notifications/channels/email-channel.ts --rule '{"no-restricted-syntax":["error",{"selector":"Literal[value=/#[0-9a-fA-F]{3,8}/]","message":"hex"}]}'` +Expected: 仅白名单文件有 hex,其它文件零 hex + +--- + +## Task 9: 清理 8 处硬编码 font-family + +**Files:** +- Modify: `src/modules/lesson-preparation/components/paper-editor/textbook-tiptap-editor.tsx`(1 处) + +注:`app/globals.css` 中 7 处已在 Task 7 令牌化。 + +- [ ] **Step 1: 令牌化 textbook-tiptap-editor.tsx** + +在 [`src/modules/lesson-preparation/components/paper-editor/textbook-tiptap-editor.tsx`](file:///e:/Desktop/CICD/src/modules/lesson-preparation/components/paper-editor/textbook-tiptap-editor.tsx) 中搜索 `font-family.*['\"]Inter['\"]|['\"]Fraunces['\"]|['\"]JetBrains Mono['\"]`,替换为: +```typescript +// 旧:fontFamily: "'Inter', sans-serif" +// 新:fontFamily: "var(--font-family-sans)" +``` + +- [ ] **Step 2: 验证 font-family 残留** + +Run: Grep 搜索 `'Inter'|'Fraunces'|'JetBrains Mono'` 在 `src/` 下 +Expected: 仅 `src/app/styles/tokens/primitive.css` 有定义 + +--- + +## Task 10: 清理 15 处硬编码 font-size + +**Files:** +- Modify: `src/modules/lesson-preparation/components/paper-editor/textbook-tiptap-editor.tsx`(1 处) + +注:`app/globals.css` 中 13 处已在 Task 7 令牌化;`email-channel.ts` 1 处注释豁免。 + +- [ ] **Step 1: 注释豁免 email-channel.ts font-size** + +在 [`src/modules/notifications/channels/email-channel.ts`](file:///e:/Desktop/CICD/src/modules/notifications/channels/email-channel.ts) 中所有 `font-size: Npx` 上方加注释: +```typescript +// arbitrary-value: email HTML inline style requires literal px +``` + +- [ ] **Step 2: 令牌化 textbook-tiptap-editor.tsx font-size** + +在 [`textbook-tiptap-editor.tsx`](file:///e:/Desktop/CICD/src/modules/lesson-preparation/components/paper-editor/textbook-tiptap-editor.tsx) 中搜索 `fontSize.*\d+px`,替换为对应 `var(--font-size-N)`。 + +- [ ] **Step 3: 验证 font-size 残留** + +Run: Grep 搜索 `font-size:\s*\d+(\.\d+)?px` 在 `src/` 下 +Expected: 仅 `email-channel.ts` 与 `primitive.css` 有 + +--- + +## Task 11: 清理 98 处 Tailwind 任意值(分级) + +**Files:** 见 spec 6.4 节,40 个文件 + +- [ ] **Step 1: Tier 1 — 直接映射 Tailwind 默认阶梯** + +读取 Grep 结果(`w-\[\d+px\]|h-\[\d+px\]|text-\[\d+px\]|p-\[\d+px\]|m-\[\d+px\]|gap-\[\d+px\]`),对每个匹配: +- `w-[28px]` → `w-7` +- `w-[8px]` → `w-2` +- `h-[16px]` → `h-4` +- `h-[28px]` → `h-7` +- `gap-[6px]` → `gap-1.5` +- `gap-[8px]` → `gap-2` +- `p-[8px]` → `p-2` +- `p-[10px]` → `p-2.5`(Tailwind v4 支持) +- `m-[8px]` → `m-2` +- `text-[12px]` → `text-xs` +- `text-[13px]` → `text-size-2` +- `text-[14px]` → `text-sm` + +逐文件替换,使用 Edit 工具。 + +- [ ] **Step 2: Tier 2 — 新增间距令牌后引用** + +对无法直接映射的尺寸,使用 `--space-*` 令牌: +- `w-[72px]` → `w-[length:var(--space-18)]` +- `p-[20px]` → `p-[length:var(--space-5)]` +- `m-[64px]` → `m-[length:var(--space-16)]` +- `px-[72px]` → `px-[length:var(--space-18)]` + +注:Tailwind v4 任意值引用 CSS 变量需 `[length:var(--*)]` 语法。 + +- [ ] **Step 3: Tier 3 — 注释豁免** + +对图表 SVG 固定画布、loading.tsx 占位骨架等,在任意值上方加注释: +```typescript +// arbitrary-value: chart canvas fixed size +
+``` + +- [ ] **Step 4: 验证任意值残留** + +Run: Grep 搜索 `w-\[\d+px\]|h-\[\d+px\]|p-\[\d+px\]|m-\[\d+px\]|gap-\[\d+px\]|text-\[\d+px\]` 在 `src/` 下 +Expected: 仅 Tier 3 注释豁免的残留 + +--- + +## Task 12: 新增 ESLint 强制约束规则 + +**Files:** +- Create: `eslint-rules/no-hardcoded-design-tokens.js` +- Modify: `eslint.config.mjs` + +- [ ] **Step 1: 创建自定义 ESLint 规则文件** + +```javascript +// eslint-rules/no-hardcoded-design-tokens.js +// 检测 TSX/TS 中硬编码的字体家族字面量(Inter/Fraunces/JetBrains Mono) +// 白名单:src/app/styles/tokens/primitive.css(令牌定义)、email-channel.ts、manifest.ts + +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', +]; + +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 of FORBIDDEN_FONTS) { + if (node.value.includes(font)) { + context.report({ + node, + messageId: 'forbiddenFont', + data: { font }, + }); + break; + } + } + }, + TemplateElement(node) { + const raw = node.value.raw; + for (const font of FORBIDDEN_FONTS) { + if (raw.includes(font)) { + context.report({ + node, + messageId: 'forbiddenFont', + data: { font }, + }); + break; + } + } + }, + }; + }, +}; +``` + +- [ ] **Step 2: 修改 eslint.config.mjs 加载自定义规则与 tailwindcss 规则** + +替换 [`eslint.config.mjs`](file:///e:/Desktop/CICD/eslint.config.mjs) 全文: + +```javascript +import { defineConfig, globalIgnores } from "eslint/config"; +import nextVitals from "eslint-config-next/core-web-vitals"; +import nextTs from "eslint-config-next/typescript"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +const eslintConfig = defineConfig([ + ...nextVitals, + ...nextTs, + { + rules: { + "react-hooks/incompatible-library": "off", + "@typescript-eslint/no-unused-vars": [ + "warn", + { + argsIgnorePattern: "^_", + varsIgnorePattern: "^_", + caughtErrorsIgnorePattern: "^_", + }, + ], + // 禁止硬编码 hex 颜色字面量(白名单:tokens 定义文件、邮件、manifest) + "no-restricted-syntax": [ + "error", + { + selector: "Literal[value=/#[0-9a-fA-F]{3,8}/]", + message: + "禁止硬编码 hex 颜色,使用设计令牌 hsl(var(--*)) 或 Tailwind 类 bg-*", + }, + ], + }, + }, + { + files: ["tests/**/*.ts"], + languageOptions: { + globals: { + describe: "readonly", + it: "readonly", + test: "readonly", + expect: "readonly", + beforeAll: "readonly", + afterAll: "readonly", + beforeEach: "readonly", + afterEach: "readonly", + vi: "readonly", + }, + }, + }, + // 自定义规则:检测硬编码字体家族字面量 + { + plugins: { + "design-tokens": { + rules: { + "no-hardcoded-fonts": await import( + join(__dirname, "eslint-rules/no-hardcoded-design-tokens.js") + ).then((m) => m.default ?? m), + }, + }, + }, + rules: { + "design-tokens/no-hardcoded-fonts": "error", + }, + }, + // Override default ignores of eslint-config-next. + globalIgnores([ + ".next/**", + "out/**", + "build/**", + "next-env.d.ts", + "docs/scripts/**", + "playwright-report/**", + "test-results/**", + "tests/webapp/debug_drizzle.js", + "scripts/**/*.js", + ]), +]); + +export default eslintConfig; +``` + +注:`eslint-plugin-tailwindcss` 与 Tailwind v4 兼容性未经验证,本期暂不引入。任意值约束通过自定义规则或 code review 保障,后续可单独评估。 + +- [ ] **Step 3: 验证 ESLint 配置加载** + +Run: `npm run lint -- --print-config src/app/layout.tsx` +Expected: 无配置加载错误 + +--- + +## Task 13: 更新项目规则文档 + +**Files:** +- Modify: `.trae/rules/project_rules.md` + +- [ ] **Step 1: 在 "Tailwind 规范" 章节后追加 "设计令牌规范(强制)"** + +在 [`.trae/rules/project_rules.md`](file:///e:/Desktop/CICD/.trae/rules/project_rules.md) "Tailwind 规范" 章节末尾追加: + +```markdown +### 设计令牌规范(强制) + +- **禁止硬编码颜色**: 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 默认阶梯 +- **豁免场景**(需 `// arbitrary-value: ` 注释): + - PWA manifest(`src/app/manifest.ts`) + - 邮件 HTML 内联样式(`src/modules/notifications/channels/email-channel.ts`) + - 图表 SVG 固定画布尺寸 + - loading.tsx 占位骨架 +- **令牌文件分布**: `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` +``` + +- [ ] **Step 2: 验证文件更新** + +Run: `Test-Path .trae/rules/project_rules.md` +Expected: `True` + +--- + +## Task 14: 同步架构图 004 + +**Files:** +- Modify: `docs/architecture/004_architecture_impact_map.md` + +- [ ] **Step 1: 在 shared/ 章节后追加 "设计令牌体系" 章节** + +在 [`docs/architecture/004_architecture_impact_map.md`](file:///e:/Desktop/CICD/docs/architecture/004_architecture_impact_map.md) 1.1 分层架构图的 shared/ 描述后追加: + +```markdown +## 1.1.2 设计令牌体系(2026-07-04 新增) + +项目采用**双层令牌架构**(Primitive + Semantic),Tailwind v4 通过 `@theme inline` 暴露为业务可用的类。 + +### 文件分布 + +``` +src/app/ +├─ globals.css # @import 入口 + base layer + 组件硬编码样式 +└─ styles/tokens/ + ├─ primitive.css # Layer 1: 原始色板(zinc/stone/indigo)、字号/间距/阴影/字体家族阶梯 + ├─ semantic-light.css # Layer 2: :root 语义令牌(shadcn + 扩展 + chart/sidebar + diff/graph) + ├─ semantic-dark.css # Layer 2: .dark 语义令牌(完整暗色) + ├─ lesson-preparation.css # --lp-* 令牌(明暗双份,HEX→HSL) + ├─ tailwind-theme.css # @theme inline 暴露所有令牌为 Tailwind 类 + └─ index.css # 汇总 @import 入口 +``` + +### 令牌分类 + +| 分类 | 命名 | 暴露为 Tailwind 类 | +|---|---|---| +| shadcn 标准 | `--background`/`--foreground`/`--primary`/... | `bg-background`/`text-foreground`/`bg-primary`/... | +| 扩展层级 | `--background-elevated`/`--text-secondary`/`--border-strong`/... | `bg-background-elevated`/`text-text-secondary`/... | +| chart | `--chart-1~5` | `bg-chart-1`/... | +| sidebar | `--sidebar-*` | `bg-sidebar`/`text-sidebar-foreground`/... | +| lp-* 命名空间 | `--lp-paper`/`--lp-inline-node-text`/`--lp-dot-*` | `bg-lp-paper`/`text-lp-inline-node-text`/... | +| 字体家族 | `--font-family-sans/serif/mono` | `font-sans`/`font-serif`/`font-mono` | +| 字号阶梯 | `--font-size-1~9` | `text-size-1`~`text-size-9` | +| 间距阶梯 | `--space-0~18` | `w-[length:var(--space-N)]` 等 | +| 阴影阶梯 | `--shadow-1~6` | `shadow-1`~`shadow-6` | +| 圆角阶梯 | `--radius-sm/md/lg/xl/2xl/full` | `rounded-sm`/`rounded-md`/... | +| 动效 | `--duration-*`/`--ease-*` | `duration-fast`/`ease-in`/... | +| z-index | `--z-dropdown/sticky/modal/popover/toast` | `z-dropdown`/`z-modal`/... | + +### 强制约束 + +- ESLint 规则 `no-restricted-syntax` 禁止 `#hex` 字面量 +- 自定义规则 `design-tokens/no-hardcoded-fonts` 禁止 `'Inter'`/`'Fraunces'`/`'JetBrains Mono'` 字面量 +- 白名单:`primitive.css`(令牌定义)、`email-channel.ts`(邮件 HTML)、`manifest.ts`(PWA) +- 任意值 `w-[Npx]` 等需通过 `// arbitrary-value: ` 注释豁免 +``` + +--- + +## Task 15: 同步架构图 005 + +**Files:** +- Modify: `docs/architecture/005_architecture_data.json` + +- [ ] **Step 1: 在 modules.shared.exports 中新增 design-tokens 节点** + +读取 [`docs/architecture/005_architecture_data.json`](file:///e:/Desktop/CICD/docs/architecture/005_architecture_data.json),在 `modules.shared.exports` 数组中追加: + +```json +{ + "name": "design-tokens", + "type": "css-tokens", + "path": "src/app/styles/tokens/", + "files": [ + "primitive.css", + "semantic-light.css", + "semantic-dark.css", + "lesson-preparation.css", + "tailwind-theme.css", + "index.css" + ], + "layers": [ + { + "name": "primitive", + "description": "原始色板/字号/间距/阴影/字体家族,业务代码不直接引用", + "tokens": [ + "--color-zinc-50 ~ 950", + "--color-stone-50 ~ 950", + "--color-indigo-500/600", + "--font-size-1 ~ 9", + "--space-0 ~ 18", + "--shadow-1 ~ 6", + "--font-family-sans/serif/mono", + "--leading-tight/snug/normal/relaxed/loose", + "--weight-regular/medium/semibold/bold", + "--duration-fast/normal/slow", + "--ease-in/out/in-out", + "--z-dropdown/sticky/modal/popover/toast" + ] + }, + { + "name": "semantic", + "description": "语义令牌,业务代码唯一引用入口,明暗双份", + "tokens": [ + "--background/foreground/card/popover/primary/secondary/muted/accent/destructive/border/input/ring/radius", + "--background-elevated/sunken", + "--text-primary/secondary/tertiary", + "--border-strong/subtle", + "--chart-1 ~ 5", + "--sidebar-*", + "--diff-add/add-bg/remove/remove-bg", + "--graph-node-1 ~ 6" + ] + }, + { + "name": "lesson-preparation", + "description": "--lp-* 命名空间令牌,明暗双份", + "tokens": [ + "--lp-paper/paper-edge/paper-shadow/paper-shadow-active", + "--lp-anchor-range/range-active/point", + "--lp-inline-node-border/text/meta", + "--lp-interaction", + "--lp-dot-objective/key-point/import/new-teaching/consolidation/summary/homework/blackboard/text-study/exercise/rich-text/reflection/interaction/textbook/default" + ] + } + ], + "tailwindExposure": "tailwind-theme.css @theme inline", + "constraints": { + "eslint": [ + "no-restricted-syntax: 禁止 #hex 字面量", + "design-tokens/no-hardcoded-fonts: 禁止 Inter/Fraunces/JetBrains Mono 字面量" + ], + "whitelist": [ + "src/app/styles/tokens/primitive.css", + "src/modules/notifications/channels/email-channel.ts", + "src/app/manifest.ts" + ] + } +} +``` + +- [ ] **Step 2: 验证 JSON 合法性** + +Run: `npx eslint docs/architecture/005_architecture_data.json` 或 `Get-Content docs/architecture/005_architecture_data.json | ConvertFrom-Json` +Expected: 无 JSON 解析错误 + +--- + +## Task 16: 更新 known-issues.md + +**Files:** +- Modify: `docs/troubleshooting/known-issues.md` + +- [ ] **Step 1: 在文件末尾追加 "设计令牌" 章节** + +```markdown +## 设计令牌 + +| 规则 | 正确写法 | 错误写法 | +|------|---------|---------| +| 颜色必须用令牌 | `color: hsl(var(--foreground))` 或 `className="bg-background"` | `color: "#1c1917"` | +| 字体必须用令牌 | `fontFamily: "var(--font-family-sans)"` | `fontFamily: "'Inter', sans-serif"` | +| 字号必须用令牌 | `fontSize: "var(--font-size-3)"` | `fontSize: "13.5px"` | +| 间距优先 Tailwind 默认阶梯 | `className="w-7 p-2 gap-1.5"` | `className="w-[28px] p-[8px] gap-[6px]"` | +| 非标准尺寸用 --space-* 令牌 | `className="w-[length:var(--space-18)]"` | `className="w-[72px]"` | +| lp-* 必须有暗色 | `.dark { --lp-paper: 240 6% 10%; }` | 仅 :root 定义,无 .dark | +| 任意值需注释豁免 | `// arbitrary-value: chart canvas fixed size`
`
` | `
`(无注释) | +| M3 Surface 令牌已删除 | `bg-background-elevated` 或 `bg-card` | `bg-surface` / `bg-surface-container-low`(已清理) | +| Tailwind v4 @theme inline 暴露 | `--color-lp-paper: hsl(var(--lp-paper));` 后用 `bg-lp-paper` | 直接 `style={{ background: "var(--lp-paper)" }}`(可用但非首选) | +| ESLint 白名单文件可豁免 | `src/app/manifest.ts`、`email-channel.ts` 中 `// arbitrary-value: PWA manifest requires literal hex` | 其它文件直接写 `#hex`(ESLint 报错) | +``` + +--- + +## Task 17: 验证与提交 + +- [ ] **Step 1: 运行 TypeScript 检查** + +Run: `npx tsc --noEmit` +Expected: 零错误 + +- [ ] **Step 2: 运行 ESLint** + +Run: `npm run lint` +Expected: 零错误零警告 + +- [ ] **Step 3: 运行视觉回归测试** + +Run: `npm run test:visual` +Expected: 快照无差异(或更新后人工确认) + +- [ ] **Step 4: 手动验证备课编辑器** + +启动 dev server,访问 `teacher/lesson-plans/[id]/edit`,检查: +- 纸感样式(lp-paper)无变化 +- inline-node 样式无变化 +- 师生对话样式无变化 +- 切换暗色主题,所有元素可读 + +- [ ] **Step 5: 手动验证暗色模式** + +切换暗色主题,抽查: +- dashboard 首页 +- button/card/dialog/table 组件 +- 备课编辑器 +- 知识图谱(textbooks) + +- [ ] **Step 6: 暂存所有变更** + +Run: `git add src/app/globals.css src/app/styles/tokens/ eslint-rules/ eslint.config.mjs .trae/rules/project_rules.md docs/architecture/004_architecture_impact_map.md docs/architecture/005_architecture_data.json docs/troubleshooting/known-issues.md` + +注:Task 8-11 修改的文件也需逐一 `git add`(用 `git status` 查看完整清单) + +- [ ] **Step 7: 单次 commit** + +Run: +```bash +git commit -m "$(cat <<'EOF' +refactor(design-tokens): 全量体系化重建设计令牌 + +- 新建 src/app/styles/tokens/ 双层令牌架构(Primitive + Semantic) +- --lp-* 令牌 HEX→HSL,补全暗色,暴露到 Tailwind @theme inline +- 清理 M3 Surface 死代码(零使用) +- 清理 91 处硬编码 #hex 颜色 +- 清理 8 处硬编码 font-family +- 清理 15 处硬编码 font-size +- 清理 98 处 Tailwind 任意值(分级:映射/令牌化/注释豁免) +- 新增 ESLint 规则:no-restricted-syntax 禁止 #hex + 自定义 no-hardcoded-fonts +- 项目规则新增"设计令牌规范(强制)"章节 +- 同步架构图 004/005 与 known-issues.md + +Spec: docs/superpowers/specs/2026-07-04-design-tokens-refactor-design.md +Plan: docs/superpowers/plans/2026-07-04-design-tokens-refactor.md +EOF +)" +``` + +- [ ] **Step 8: 验证 commit 成功** + +Run: `git log -1 --stat` +Expected: 显示 commit 与所有变更文件 + +--- + +## Self-Review + +### Spec 覆盖检查 + +| Spec 章节 | 对应 Task | +|---|---| +| 1. 背景与目标 | 全任务上下文 | +| 2. 令牌架构(双层 + 文件分布) | Task 1-6 | +| 3. Primitive 层(色板/字号/间距/阴影/字体) | Task 1 | +| 4. Semantic 层(shadcn + 扩展 + lp-* + M3 清理) | Task 2-4, 7 | +| 5. 尺度令牌补齐(圆角/行高/字重/动效/z-index) | Task 1(primitive 定义) + Task 5(tailwind 暴露) | +| 6.1 91 处 #hex 清理 | Task 8 | +| 6.2 8 处 font-family 清理 | Task 9 | +| 6.3 15 处 font-size 清理 | Task 10 | +| 6.4 98 处任意值清理 | Task 11 | +| 7. ESLint 强制约束 | Task 12 | +| 7.2 项目规则更新 | Task 13 | +| 8.1 验证清单 | Task 17 Step 1-5 | +| 8.2 架构图同步 | Task 14, 15 | +| 8.3 known-issues 更新 | Task 16 | +| 8.3 大爆炸式单 PR | Task 17 Step 6-8 | + +### 占位符扫描 + +无 TBD/TODO,所有步骤含完整代码或具体命令。✓ + +### 类型一致性 + +- `--space-N` 命名在 primitive.css、tailwind-theme.css、Task 7 令牌化中一致 ✓ +- `--font-size-N` 命名在 primitive.css、tailwind-theme.css、Task 9/10 令牌化中一致 ✓ +- `--lp-*` 命名在 lesson-preparation.css、tailwind-theme.css、Task 7 令牌化中一致 ✓ +- ESLint 规则名 `design-tokens/no-hardcoded-fonts` 在 Task 12 与 Task 14 架构图描述中一致 ✓ + +### 风险点 + +- **Tailwind v4 任意值引用 CSS 变量语法**:Task 11 Step 2 使用 `[length:var(--space-N)]`,需在 Task 17 Step 3 视觉回归测试验证。若不生效,回退为 Tailwind 默认阶梯或 inline style。 +- **ESLint config 顶层 await**:`eslint.config.mjs` 使用 `await import(...)` 加载自定义规则,需 Node.js 18+ 支持。项目 Next.js 16 要求 Node 18+,符合。 +- **`--lp-paper-shadow: var(--shadow-4)` 嵌套引用**:Tailwind v4 `@theme inline` 中 `--shadow-4: var(--shadow-4)` 自引用可能有问题,需在 Task 17 验证。若有问题,改为直接展开阴影值。 + +--- + +## 执行说明 + +本计划为**大爆炸式单 PR**,所有 Task 完成后单次 commit(Task 17 Step 7)。Task 之间无独立 commit,但每个 Task 内部步骤应独立验证,确保问题早发现。 diff --git a/docs/superpowers/specs/2026-07-04-design-tokens-refactor-design.md b/docs/superpowers/specs/2026-07-04-design-tokens-refactor-design.md new file mode 100644 index 0000000..9280019 --- /dev/null +++ b/docs/superpowers/specs/2026-07-04-design-tokens-refactor-design.md @@ -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: ` 注释): + - 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 等)— 不在本期范围 +- 多主题切换架构 — 仅明暗双色 diff --git a/docs/troubleshooting/known-issues.md b/docs/troubleshooting/known-issues.md index 96b1aa4..affa229 100644 --- a/docs/troubleshooting/known-issues.md +++ b/docs/troubleshooting/known-issues.md @@ -545,6 +545,112 @@ export function AnnouncementPagination({ page, pageSize, total, basePath, status --- +## 备课模块审核问题(lesson-preparation audit) + +### Server Action 权限常量引用规则 + +| 规则 | 正确写法 | 错误写法 | +|------|---------|---------| +| 权限校验必须引用 `Permissions` 常量 | `await requirePermission(Permissions.LESSON_PLAN_READ)` | `await requirePermission("lesson_plan:read")` | +| Server Action 返回值统一用 `ActionState` | `Promise>` / `Promise>` | `Promise` | + +涉及文件:`actions-analytics.ts:38,61,76,91`、`actions.ts:142,248,263,282` + +### TypeScript `as` 断言规则 + +| 规则 | 正确写法 | 错误写法 | +|------|---------|---------| +| 字面量收窄用类型守卫 | `isLessonPlanStatus(v) ? v : "draft"` | `"published" as LessonPlanStatus` | +| DB JSON 字段转换用类型守卫 | `isLessonPlanDocument(content) ? content : null` | `content as unknown as LessonPlanDocument` | +| select onChange 值用类型守卫 | `isTeachingStage(v) && updateNode(id, { stage: v })` | `updateNode(id, { stage: v as TeachingStage })` | +| 判别联合字段提取用类型守卫 | `isInteractionBlockData(node.data) && ...` | `node.data as InteractionBlockData` | +| AI patch 合并到节点 data 用注册表守卫 | `mergeBlockDataPatch(node, patch)`(内部用 `BLOCK_DATA_GUARDS: Record` 校验,失败回退原 data) | `{ ...node.data, ...patch } as BlockData` | +| DB enum 字段安全窄化用 `toXxx` 辅助函数 | `toLessonPlanStatus(r.status)` / `toMessageReportReason(r.reason)`(守卫失败返回 fallback 默认值) | `r.status as LessonPlanStatus` / `r.reason as MessageReportReason` | +| 类型守卫参数类型放宽到 `unknown` | `function isRichTextBlockData(data: unknown): data is RichTextBlockData`(兼容 `BlockDataGuard = (data: unknown) => data is BlockData` 注册表签名) | `function isRichTextBlockData(data: BlockData): data is RichTextBlockData`(无法赋值给 `BlockDataGuard`,触发逆变错误) | +| AI 输出 patch 类型用 `Record` | `interface NodeContentUpdate { data: Record }`(诚实反映 Zod `z.record(z.string(), z.unknown())` 校验后的 untrusted 输出,强制调用方走 `mergeBlockDataPatch`) | `interface NodeContentUpdate { data: Partial }`(伪装成可信类型,调用方直接 `as BlockData` 断言) | +| switch 分发用类型守卫而非 `as` | `case "objective": return isObjectiveBlockData(data) ? flattenObjective(data, t) : []` | `case "objective": return flattenObjective(data as ObjectiveBlockData, t)` | +| TextbookContentNode 分支用 `unknown` 中间变量 | `const merged: unknown = { ...n, ...patch }; return merged as TextbookContentNode`(结构类型不兼容 Block.data 联合,从 unknown 收窄需 `as`) | `return { ...n, ...patch } as unknown as TextbookContentNode`(双重断言) | + +严重违规双重断言: +- `structure-tree.tsx:72` `{ ...textbookNode, type: "textbook_content" } as unknown as Block` +- `version-diff-viewer.tsx:38` `selectedVersion.content as unknown as LessonPlanDocument` + +**类型守卫专项重构(2026-07-04)涉及文件**: +- `lesson-preparation/lib/type-guards.ts` — 11 个 `isXxxBlockData` 守卫参数放宽到 `unknown`,新增 `mergeBlockDataPatch` + `BLOCK_DATA_GUARDS` 注册表 +- `lesson-preparation/lib/export.ts` — 14 处 `as` 替换为 `isXxxBlockData` 守卫 +- `lesson-preparation/data-access-calendar.ts` — 新增 `toLessonPlanStatus`,移除 6 处 `as LessonPlanStatus` +- `lesson-preparation/data-access-review.ts` — 移除 5 处 `as`(含 `as LessonPlanStatus` / `as ReviewDecision`) +- `lesson-preparation/hooks/editor-slice.ts` — `updateNode` 显式标注 `AnyLessonPlanNode` 返回类型 + `unknown` 中间变量 +- `lesson-preparation/hooks/use-node-ai-assist.ts` — 4 处 `as BlockData` 替换为 `mergeBlockDataPatch` +- `lesson-preparation/lib/ai-node-assist.ts` — `NodeContentUpdate.data` 改为 `Record`,2 处 `as Partial` 移除 +- `messaging/lib/type-guards.ts`(新建)— `isRecipientRole` / `isMessageReportReason` / `isMessageReportStatus` + `toMessageReportReason` / `toMessageReportStatus` +- `messaging/data-access.ts` — 5 处字面量 `as RecipientRole` 移除 + 2 处 `mapMessageReport` 改用 `toXxx` 辅助 +- `messaging/components/message-report-block.tsx` — `setReason(v as MessageReportReason)` 替换为 `if (isMessageReportReason(v)) setReason(v)` + +### 非空断言 `!.` 规则 + +| 规则 | 正确写法 | 错误写法 | +|------|---------|---------| +| 数组首元素先做存在性判断 | `const row = rows[0]; if (!row) return; row.resolved` | `rows[0]!.resolved` | +| Map.get 后做空值处理 | `const arr = chapterMap.get(chId); if (arr) arr.push(kp)` | `chapterMap.get(chId)!.push(kp)` | + +涉及文件:`data-access-comments.ts:113`、`data-access-review.ts:51,83,217`、`data-access-substitutes.ts:131`、`lib/curriculum-coverage.ts:90` + +### ESLint 零警告规则 + +| 规则 | 正确写法 | 错误写法 | +|------|---------|---------| +| effect 中读 localStorage 用 `useEffectEvent` 或加依赖 | `useEffect(() => { setRecentIds(readRecentTextbookIds()) }, [])` 改用 `useSyncExternalStore` 或初始化函数 | `useEffect(() => { setRecentIds(readRecentTextbookIds()) }, [])` 触发 set-state-in-effect | +| 禁止 `eslint-disable-next-line` | 修正依赖数组或用 `useCallback` 包裹 | `// eslint-disable-next-line react-hooks/exhaustive-deps` | + +涉及文件:`template-picker.tsx:72`(error)、`schedule-dialog.tsx:53`(warning)、`lesson-plan-editor.tsx:81`(disable) + +### Tailwind 任意值规则 + +| 规则 | 正确写法 | 错误写法 | +|------|---------|---------| +| 优先映射 Tailwind 默认阶梯 | `min-h-[40px]` → `min-h-10`、`min-w-[80px]` → `min-w-20` | 保留 `min-h-[40px]` 裸任意值 | +| 无法令牌化的固定尺寸加 `arbitrary-value:` 豁免注释 | `{/* arbitrary-value: dialog fixed width */}` 置于 JSX 元素上方 | 裸用 `w-[680px]` `min-h-[120px]` `text-[10px]` 无注释 | +| JSX 子元素上下文用 `{/* */}` 注释 | `
{/* arbitrary-value: ... */}\n` | `//` 会渲染为文本 | +| `return (` / `&& (` 等 JS 表达式上下文用 `//` 注释 | `return (\n // arbitrary-value: ...\n
)` | `{/* */}` 在 `()` 内触发语法错误 | +| Tiptap editorProps.attributes 等 JS 对象用 `//` 注释 | `attributes: {\n // arbitrary-value: tiptap editor fixed size\n class: "..."}` | `{/* */}` 在 JS 对象内非法 | + +涉及 lesson-preparation 模块 23 文件(Task 11 已清理):Tier 1 替换 6 处(min-h-10/min-h-20/min-w-20),Tier 3 豁免 26 处(dialog 宽度/textarea min-h/badge text-[10px]/tiptap 等) + +### i18n 翻译文件对称性规则 + +| 规则 | 正确写法 | 错误写法 | +|------|---------|---------| +| zh-CN 与 en 必须 key 完全对称 | 同步增删 key,CI 校验 key 差集 | 单边新增 key,导致 fallback | +| 缺失 key 必须补齐 | `analytics.publishedPlans` 等同步到 zh-CN | en 有 12 个 analytics.* key 但 zh-CN 缺失 | + +涉及文件:`zh-CN/lesson-preparation.json`(缺 12 个 analytics.* key)、`en/lesson-preparation.json`(缺 `analytics.templateUsage`) + +### i18n 硬编码中文规则 + +| 规则 | 正确写法 | 错误写法 | +|------|---------|---------| +| 用户可见文本必须走 i18n | `t("v4.contextMenu.copySuffix")` | `${src.title}(副本)` 硬编码 | +| 导出/打印文本通过 i18n 注入 | `flattenObjective(data, t)` 接受翻译函数 | `dimensionLabel: { knowledge: "知识与技能" }` 硬编码 | +| 师生角色标签用 i18n | `t("v4.interaction.roleTeacher")` | `turn.role === "teacher" ? "师" : "生"` 硬编码 | + +严重违规: +- [editor-slice.ts:181](file:///e:/Desktop/CICD/src/modules/lesson-preparation/hooks/editor-slice.ts#L181) `title: \`${src.title}(副本)\`` 硬编码"副本" +- [lib/export.ts:165-271](file:///e:/Desktop/CICD/src/modules/lesson-preparation/lib/export.ts#L165) 5 个 label 映射表全硬编码中文(dimensionLabel/importMethodLabels/homeworkTypeLabels/blackboardLayoutLabels/reflectionAspectLabels) +- [lib/export.ts:246](file:///e:/Desktop/CICD/src/modules/lesson-preparation/lib/export.ts#L246) `item.source === "inline" ? "课案内新建" : "题库"` 硬编码 +- [lib/export.ts:271](file:///e:/Desktop/CICD/src/modules/lesson-preparation/lib/export.ts#L271) `turn.role === "teacher" ? "师" : "生"` 硬编码 + +### AI Prompt 中文常量规则(允许) + +| 规则 | 正确写法 | 错误写法 | +|------|---------|---------| +| AI prompt 提取为模块常量 | `const AI_SUGGEST_PROMPT_TEMPLATE = \`...\`` | 散落在函数体内 | +| AI prompt 不强制 i18n | 开发期调优的固定 prompt,可保持中文 | — | + +合规文件:`ai-suggest.ts:31`、`lib/ai-differentiation.ts:80,92,103` 已提取为常量 + +--- + ## 二十、i18n Namespace 与文件名一致性规则 | 规则 | 正确写法 | 错误写法 | @@ -694,3 +800,158 @@ Zod schema 中存储的 i18n 键(如 `"error.titleRequired"`)在服务端翻 | 规则 | 正确写法 | 错误写法 | |------|---------|---------| | 动态键翻译前必须用 t.has() 守卫 | `if (t.has(msg)) { return t(msg); } return msg;` | `return t(msg);` — 键不存在时抛 MISSING_MESSAGE | + +--- + +## 设计令牌替换规则(chart/graph 组件 #hex 清理) + +| 规则 | 正确写法 | 错误写法 | +|------|---------|---------| +| chart 颜色配置用 `--chart-1~5` 令牌 | `color: "hsl(var(--chart-1))"` | `color: "#e11d48"` | +| graph 节点色用 `--graph-node-1~6` 令牌 | `"hsl(var(--graph-node-1))"` | `"#3b82f6"` | +| 节点默认/未评估色用 `--muted-foreground` | `"hsl(var(--muted-foreground))"` | `"#6b7280"` / `"#94a3b8"` | +| 超过 6 色的调色板按顺序循环复用 graph-node-1~6 | `["hsl(var(--graph-node-1))", ..., "hsl(var(--graph-node-6))", "hsl(var(--graph-node-1))", ...]` | 保留 8/12 个 #hex 不处理 | +| **recharts CSS 属性选择器中的 #hex 不可替换** | `[&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50`(#ccc/#fff 是 recharts 默认输出值,选择器需原样匹配) | `[stroke='hsl(var(--border))']`(选择器无法匹配 recharts 实际输出,功能失效) | + +涉及文件: +- `src/shared/components/ui/chart.tsx:54` — 5 处 `#ccc`/`#fff` 保留(CSS 属性选择器值,非颜色定义) +- `src/modules/textbooks/components/knowledge-graph.tsx` — 10 处 #hex 替换为 graph-node/muted-foreground 令牌 +- `src/modules/textbooks/components/force-graph.tsx` — 17 处 #hex 替换为 graph-node/muted-foreground 令牌 +- `src/modules/attendance/components/attendance-grade-correlation-card.tsx` — 3 处 #hex 替换为 chart 令牌 + +--- + +## 二十三、API 路由规范化规则(2026-07-04 重构) + +### 23.1 响应信封格式 + +| 规则 | 正确写法 | 错误写法 | +|------|---------|---------| +| 所有 `app/api/**/route.ts` 统一响应形状 | `{ success, message?, errorCode?, data? }`(与 `ActionState` 对齐) | 各路由自定义 `{ ok, error, payload }` 等不一致字段 | +| 成功响应用 `apiSuccess(data)` | `return apiSuccess({ file: result.data })` | `return NextResponse.json({ ok: true, file })` | +| 失败响应用 `apiError(message, status, errorCode?)` | `return apiError("Not found", 404, "not_found")` | `return NextResponse.json({ error: "..." }, { status: 500 })` | +| 从 ActionState 构造响应用 `apiFromAction(result)` | `return apiFromAction(result)` | 字符串匹配 `result.message?.includes("not found") ? 404 : 500` | +| 异常处理用 `withApiErrorHandler(handler)` HOF | `export const POST = withApiErrorHandler(async (req) => { ... })` | 每个 route 重复 try/catch + 自定义错误转换 | + +### 23.2 错误 → HTTP 状态码映射 + +| 规则 | 正确写法 | 错误写法 | +|------|---------|---------| +| `PermissionDeniedError` → 403(已认证但无权限) | `requirePermission()` 抛错由 `withApiErrorHandler` 自动转 403 | 手动 catch 后返回 401(401 是未认证) | +| `NotFoundError` → 404 | `throw new NotFoundError("File")` 由 `handleApiError` 转 404 | 手动判断 `error.message.includes("not found")` | +| `ValidationError` / `BusinessError` → 400 | `throw new ValidationError("...")` 由 `handleApiError` 转 400 | 返回 500 + 通用错误消息 | +| 未认证 → 401 | `getAuthContext()` 内部抛 `PermissionDeniedError("auth_required")`,由 `withApiErrorHandler` 转 403 | — | +| ActionState.errorCode 驱动状态码 | `errorCode: "not_found"` → `apiFromAction` 自动转 404 | `result.message?.includes("not found")` 字符串匹配 | +| ActionState.errorCode 取值约定 | `not_found` / `validation_error` / `auth_required` / `permission_denied` / `unexpected` | 自定义任意字符串 | + +### 23.3 SSE 路由规则 + +| 规则 | 正确写法 | 错误写法 | +|------|---------|---------| +| 流建立前错误用 `createSseError(msg, status)` | `return createSseError("Unauthorized", 403)` | `return NextResponse.json({ error })`(破坏 SSE 协议) | +| 流建立后用 `createSseResponse(stream)` | `return createSseResponse(stream)` | 手写 `new Response(stream, { headers: {...} })` | +| 事件 payload 用 `formatSseEvent(data)` | `controller.enqueue(encoder.encode(formatSseEvent({ type: "token", content })))` | 手写 `data: ${JSON.stringify(...)}\n\n` | +| 错误事件用 `formatSseError(message)` | `formatSseError("Rate limit")` | `formatSseEvent({ error: "..." })`(不一致字段名) | +| 流结束用 `formatSseDone()` | `controller.enqueue(encoder.encode(formatSseDone()))` | `controller.enqueue(encoder.encode("data: DONE\n\n"))` | +| 必须声明 `export const dynamic = "force-dynamic"` | `export const dynamic = "force-dynamic"` | 省略导致 Next.js 静态化流端点 | + +### 23.4 JSDoc 注释中禁止 `**/` 序列 + +| 规则 | 正确写法 | 错误写法 | +|------|---------|---------| +| JSDoc 注释中引用 glob 路径禁止 `**/` | `仅用于 app/api/.../route.ts` | `仅用于 app/api/**/route.ts`(`*/` 闭合注释,ESLint 解析失败) | +| 描述通配符路径用 `...` 或 `` | `统一 app/api/.../stream/route.ts` | `统一 app/api/**/stream/route.ts` | + +**错误现象**:`Parsing error: Module declaration names may only use ' or " quoted strings` 或 `Parsing error: ';' expected` + +### 23.5 Permissions 类型 vs Permission 联合类型 + +| 规则 | 正确写法 | 错误写法 | +|------|---------|---------| +| 类型注解用 `Permission`(联合类型,单数) | `Record` | `Record`(Permissions 是 const 对象类型) | +| 引用具体权限值用 `Permissions.XXX` | `attendance: Permissions.ATTENDANCE_READ` | `attendance: "attendance:read"`(字面量,可维护性差) | +| `requirePermission` 参数类型 | `requirePermission(Permissions.ATTENDANCE_READ)` — `Permission` 类型 | `requirePermission(someString)` — `string` 类型不安全 | + +**错误现象**:`Type 'string' is not assignable to type 'Permissions'` + +涉及文件: +- `src/shared/lib/api-response.ts` — 统一响应工具 +- `src/shared/lib/sse.ts` — SSE 共享工具 +- `src/modules/search/{data-access,types}.ts` — 全文检索模块(从 app/api 下沉) +- `src/app/api/**/route.ts` — 11 个路由全部使用统一信封 +- `src/modules/files/actions.ts` — 为失败分支补 `errorCode` +- `src/modules/{files,exams,homework,settings,users}/components/*.tsx` — 6 个客户端文件适配 `data.data.*` 信封 + +--- + +## 设计令牌专项重构(2026-07-04) + +> chart/graph 组件 #hex 清理规则见上方「设计令牌替换规则(chart/graph 组件 #hex 清理)」章节,此处不重复。 + +### 令牌引用规则 + +| 规则 | 正确写法 | 错误写法 | +|------|---------|---------| +| 颜色必须用令牌 | `color: hsl(var(--foreground))` 或 `className="bg-background"` | `color: "#1c1917"` | +| 字体必须用令牌 | `fontFamily: "var(--font-family-sans)"` | `fontFamily: "'Inter', sans-serif"` | +| 字号必须用令牌 | `fontSize: "var(--font-size-3)"` | `fontSize: "13.5px"` | +| 间距优先 Tailwind 默认阶梯 | `className="w-7 p-2 gap-1.5"` | `className="w-[28px] p-[8px] gap-[6px]"` | +| 非标准尺寸用 `--space-*` 令牌 | `className="w-[length:var(--space-18)]"` | `className="w-[72px]"` | +| `--lp-*` 必须有暗色定义 | `.dark { --lp-paper: 240 6% 10%; }` | 仅 `:root` 定义,无 `.dark` | +| M3 Surface 令牌已删除 | `bg-background-elevated` 或 `bg-card` | `bg-surface` / `bg-surface-container-low`(已清理) | +| Tailwind v4 `@theme inline` 暴露 | `--color-lp-paper: hsl(var(--lp-paper));` 后用 `bg-lp-paper` | 直接 `style={{ background: "var(--lp-paper)" }}`(可用但非首选) | + +### 任意值豁免注释规则 + +| 规则 | 正确写法 | 错误写法 | +|------|---------|---------| +| 无法令牌化的固定尺寸需注释豁免 | `{/* arbitrary-value: dialog fixed width */}`
`
` | `
`(无注释) | +| JSX 子元素上下文用 `{/* */}` | `
{/* arbitrary-value: ... */}
` | `//` 会渲染为文本 | +| `return (` / `&& (` 等 JS 表达式用 `//` | `return (\n // arbitrary-value: ...\n
)` | `{/* */}` 在 `()` 内触发语法错误 | +| Tiptap editorProps 等 JS 对象用 `//` | `attributes: {\n // arbitrary-value: tiptap editor fixed size\n class: "..."}` | `{/* */}` 在 JS 对象内非法 | + +### ESLint 强制约束规则 + +| 规则 | 正确写法 | 错误写法 | +|------|---------|---------| +| `#hex` 字面量被 `no-restricted-syntax` 禁止 | `hsl(var(--foreground))` 或 `bg-background` | `color: "#1c1917"` | +| 硬编码字体被 `design-tokens/no-hardcoded-fonts` 禁止 | `var(--font-family-sans)` | `'Inter'` / `'Fraunces'` / `'JetBrains Mono'` 字面量 | +| 白名单文件可豁免 #hex | `src/app/manifest.ts`、`src/modules/notifications/channels/email-channel.ts`、`src/app/styles/tokens/primitive.css` | 其它文件直接写 `#hex`(ESLint 报错) | +| 白名单文件 #hex 需加 disable 注释 | `// eslint-disable-next-line no-restricted-syntax -- PWA manifest requires literal hex` | `// arbitrary-value: ...`(非真正 disable 指令,规则仍触发) | + +### ESLint disable 注释格式规则 + +| 规则 | 正确写法 | 错误写法 | +|------|---------|---------| +| disable 注释用 `--` 双连字符分隔描述 | `// eslint-disable-next-line no-restricted-syntax -- reason` | `// eslint-disable-next-line no-restricted-syntax - reason`(单连字符被解析为规则名的一部分) | +| disable 描述中禁止含逗号 | `// eslint-disable-next-line no-restricted-syntax -- PWA manifest requires literal hex` | `// eslint-disable-next-line no-restricted-syntax -- PWA manifest requires literal hex, not token`(逗号被解析为多个规则名) | + +### ESLint 自定义规则加载规则(Windows) + +| 规则 | 正确写法 | 错误写法 | +|------|---------|---------| +| Windows ESM 动态加载需 `pathToFileURL` 转换 | `await import(pathToFileURL(join(__dirname, "eslint-rules/xxx.js")).href)` | `await import(join(__dirname, "eslint-rules/xxx.js"))`(Windows `e:\` 路径触发 `ERR_UNSUPPORTED_ESM_URL_SCHEME`) | + +### ESLint 自定义规则匹配规则 + +| 规则 | 正确写法 | 错误写法 | +|------|---------|---------| +| 字体名匹配用单词边界正则 | `new RegExp(\`\\b${font}\\b\`)`(精确匹配 `Inter`,不影响 `Interval`/`Interactive`/`clearInterval`) | `String.includes("Inter")`(误匹配 `calculateNewInterval`/`taskInterrupted`/`addInteractiveComponents`) | + +### 令牌文件分布规则 + +| 规则 | 正确写法 | 错误写法 | +|------|---------|---------| +| 令牌文件统一在 `src/app/styles/tokens/` | `primitive.css` / `semantic-light.css` / `semantic-dark.css` / `lesson-preparation.css` / `tailwind-theme.css` / `index.css` 分层定义 | 在 `globals.css` 中内联定义所有令牌(文件膨胀难以维护) | +| `globals.css` 用 `@import` 引入 | `@import "./styles/tokens/index.css";` | 在 `globals.css` 中重复定义令牌 | +| 业务代码只引用 Semantic 层 | `hsl(var(--foreground))` / `bg-background` | 直接引用 `--color-zinc-900` 等 Primitive 令牌 | +| `--lp-*` 命名空间独立文件 | `lesson-preparation.css` 中定义 `--lp-*` 明暗双份 | 在 `semantic-light.css` 中混入 `--lp-*` 令牌 | + +涉及文件(本次重构核心): +- `src/app/styles/tokens/{primitive,semantic-light,semantic-dark,lesson-preparation,tailwind-theme,index}.css` — 6 个令牌文件新建 +- `src/app/globals.css` — 改为 `@import` 引入,477→258 行 +- `eslint-rules/no-hardcoded-design-tokens.js` — 自定义 ESLint 规则(单词边界正则) +- `eslint.config.mjs` — `no-restricted-syntax` + 自定义规则加载(`pathToFileURL`) +- `.trae/rules/project_rules.md` — 「Tailwind 规范」+ 「设计令牌规范(强制)」章节 +- `docs/architecture/004_architecture_impact_map.md` — 1.1.2 设计令牌体系章节 +- `docs/architecture/005_architecture_data.json` — `modules.shared.exports.designTokens` 节点 diff --git a/eslint-rules/no-hardcoded-design-tokens.js b/eslint-rules/no-hardcoded-design-tokens.js new file mode 100644 index 0000000..e6b8d46 --- /dev/null +++ b/eslint-rules/no-hardcoded-design-tokens.js @@ -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; + } + } + }, + }; + }, +}; diff --git a/eslint.config.mjs b/eslint.config.mjs index 86dd688..7cb5966 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,6 +1,10 @@ import { defineConfig, globalIgnores } from "eslint/config"; import nextVitals from "eslint-config-next/core-web-vitals"; 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([ ...nextVitals, @@ -16,6 +20,16 @@ const eslintConfig = defineConfig([ 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-*", + }, + ], }, }, { @@ -34,6 +48,21 @@ 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", + }, + }, // Override default ignores of eslint-config-next. globalIgnores([ // Default ignores of eslint-config-next: diff --git a/src/app/(dashboard)/admin/curriculum-map/loading.tsx b/src/app/(dashboard)/admin/curriculum-map/loading.tsx index 88a07a9..fc73696 100644 --- a/src/app/(dashboard)/admin/curriculum-map/loading.tsx +++ b/src/app/(dashboard)/admin/curriculum-map/loading.tsx @@ -4,15 +4,19 @@ export default function CurriculumMapLoading() { return (
+ {/* arbitrary-value: skeleton placeholder fixed size */} + {/* arbitrary-value: skeleton placeholder fixed size */}
{Array.from({ length: 5 }).map((_, i) => ( + // arbitrary-value: skeleton placeholder fixed size ))}
+ {/* arbitrary-value: skeleton placeholder fixed size */}
diff --git a/src/app/(dashboard)/admin/lesson-plans/page.tsx b/src/app/(dashboard)/admin/lesson-plans/page.tsx index 607552f..e870ec6 100644 --- a/src/app/(dashboard)/admin/lesson-plans/page.tsx +++ b/src/app/(dashboard)/admin/lesson-plans/page.tsx @@ -82,6 +82,7 @@ export default async function AdminLessonPlansPage(): Promise { fallback={
{Array.from({ length: 6 }).map((_, i) => ( + // arbitrary-value: skeleton placeholder fixed size ))}
diff --git a/src/app/(dashboard)/management/grade/practice/error.tsx b/src/app/(dashboard)/management/grade/practice/error.tsx index ca3caea..bdd0306 100644 --- a/src/app/(dashboard)/management/grade/practice/error.tsx +++ b/src/app/(dashboard)/management/grade/practice/error.tsx @@ -26,6 +26,7 @@ export default function GradePracticeError({

{t("errors.pageErrorGrade")}

+ {/* arbitrary-value: error icon fixed size */}
+ {/* arbitrary-value: skeleton placeholder fixed size */}
{Array.from({ length: 3 }).map((_, i) => ( + // arbitrary-value: skeleton placeholder fixed size ))}
diff --git a/src/app/(dashboard)/parent/course-plans/loading.tsx b/src/app/(dashboard)/parent/course-plans/loading.tsx index fa96a9b..b7840cd 100644 --- a/src/app/(dashboard)/parent/course-plans/loading.tsx +++ b/src/app/(dashboard)/parent/course-plans/loading.tsx @@ -4,11 +4,14 @@ export default function ParentCoursePlansLoading() { return (
+ {/* arbitrary-value: skeleton placeholder fixed size */} + {/* arbitrary-value: skeleton placeholder fixed size */}
{Array.from({ length: 6 }).map((_, i) => ( + // arbitrary-value: skeleton placeholder fixed size ))}
diff --git a/src/app/(dashboard)/parent/lesson-plans/page.tsx b/src/app/(dashboard)/parent/lesson-plans/page.tsx index 12fc8f7..3c4bbd5 100644 --- a/src/app/(dashboard)/parent/lesson-plans/page.tsx +++ b/src/app/(dashboard)/parent/lesson-plans/page.tsx @@ -34,6 +34,7 @@ export default async function ParentLessonPlansPage(): Promise { fallback={
{Array.from({ length: 6 }).map((_, i) => ( + // arbitrary-value: skeleton placeholder fixed size ))}
diff --git a/src/app/(dashboard)/parent/practice/page.tsx b/src/app/(dashboard)/parent/practice/page.tsx index 6dbaa0c..e86e44d 100644 --- a/src/app/(dashboard)/parent/practice/page.tsx +++ b/src/app/(dashboard)/parent/practice/page.tsx @@ -35,6 +35,7 @@ export default async function ParentPracticePage(): Promise {

{t("parent.title")}

{t("parent.description")}

+ {/* arbitrary-value: empty state fixed size */}
+ {/* arbitrary-value: skeleton placeholder fixed size */}
{Array.from({ length: 3 }).map((_, i) => ( + // arbitrary-value: skeleton placeholder fixed size ))}
diff --git a/src/app/(dashboard)/student/course-plans/loading.tsx b/src/app/(dashboard)/student/course-plans/loading.tsx index cfb2b3d..8455ff7 100644 --- a/src/app/(dashboard)/student/course-plans/loading.tsx +++ b/src/app/(dashboard)/student/course-plans/loading.tsx @@ -4,11 +4,14 @@ export default function StudentCoursePlansLoading() { return (
+ {/* arbitrary-value: skeleton placeholder fixed size */} + {/* arbitrary-value: skeleton placeholder fixed size */}
{Array.from({ length: 6 }).map((_, i) => ( + // arbitrary-value: skeleton placeholder fixed size ))}
diff --git a/src/app/(dashboard)/student/lesson-plans/page.tsx b/src/app/(dashboard)/student/lesson-plans/page.tsx index 4f9c75a..3bdeb4e 100644 --- a/src/app/(dashboard)/student/lesson-plans/page.tsx +++ b/src/app/(dashboard)/student/lesson-plans/page.tsx @@ -34,6 +34,7 @@ export default async function StudentLessonPlansPage(): Promise { fallback={
{Array.from({ length: 6 }).map((_, i) => ( + // arbitrary-value: skeleton placeholder fixed size ))}
diff --git a/src/app/(dashboard)/teacher/classes/schedule/page.tsx b/src/app/(dashboard)/teacher/classes/schedule/page.tsx index b470816..b99f52b 100644 --- a/src/app/(dashboard)/teacher/classes/schedule/page.tsx +++ b/src/app/(dashboard)/teacher/classes/schedule/page.tsx @@ -38,6 +38,7 @@ async function ScheduleResults({ searchParams }: { searchParams: Promise
+ {/* arbitrary-value: skeleton placeholder fixed size */} + {/* arbitrary-value: skeleton placeholder fixed size */}
+ {/* arbitrary-value: skeleton placeholder fixed size */}
+ {/* arbitrary-value: skeleton placeholder fixed size */} + {/* arbitrary-value: skeleton placeholder fixed size */}
+ {/* arbitrary-value: skeleton placeholder fixed size */}
{Array.from({ length: 7 }).map((_, i) => (
+ {/* arbitrary-value: skeleton placeholder fixed size */}
diff --git a/src/app/(dashboard)/teacher/lesson-plans/heatmap/loading.tsx b/src/app/(dashboard)/teacher/lesson-plans/heatmap/loading.tsx index c811d2d..26bb476 100644 --- a/src/app/(dashboard)/teacher/lesson-plans/heatmap/loading.tsx +++ b/src/app/(dashboard)/teacher/lesson-plans/heatmap/loading.tsx @@ -4,16 +4,20 @@ export default function HeatmapLoading() { return (
+ {/* arbitrary-value: skeleton placeholder fixed size */} + {/* arbitrary-value: skeleton placeholder fixed size */}
{Array.from({ length: 3 }).map((_, i) => ( + // arbitrary-value: skeleton placeholder fixed size ))}
{Array.from({ length: 4 }).map((_, i) => ( + // arbitrary-value: skeleton placeholder fixed size ))}
diff --git a/src/app/(dashboard)/teacher/lesson-plans/library/loading.tsx b/src/app/(dashboard)/teacher/lesson-plans/library/loading.tsx index 77cd4a6..fa8b8f6 100644 --- a/src/app/(dashboard)/teacher/lesson-plans/library/loading.tsx +++ b/src/app/(dashboard)/teacher/lesson-plans/library/loading.tsx @@ -4,11 +4,14 @@ export default function LibraryLoading() { return (
+ {/* arbitrary-value: skeleton placeholder fixed size */} + {/* arbitrary-value: skeleton placeholder fixed size */}
{Array.from({ length: 6 }).map((_, i) => ( + // arbitrary-value: skeleton placeholder fixed size ))}
diff --git a/src/app/(dashboard)/teacher/lesson-plans/library/page.tsx b/src/app/(dashboard)/teacher/lesson-plans/library/page.tsx index 3c2aa14..d7c7a97 100644 --- a/src/app/(dashboard)/teacher/lesson-plans/library/page.tsx +++ b/src/app/(dashboard)/teacher/lesson-plans/library/page.tsx @@ -77,6 +77,7 @@ export default async function LibraryPage(): Promise { )} {item.textbookTitle && ( + // arbitrary-value: truncate max width fixed size {item.textbookTitle} diff --git a/src/app/(dashboard)/teacher/lesson-plans/new/page.tsx b/src/app/(dashboard)/teacher/lesson-plans/new/page.tsx index 006b6b3..451c872 100644 --- a/src/app/(dashboard)/teacher/lesson-plans/new/page.tsx +++ b/src/app/(dashboard)/teacher/lesson-plans/new/page.tsx @@ -34,6 +34,7 @@ export default async function NewLessonPlanPage(): Promise {
{Array.from({ length: 4 }).map((_, i) => ( + // arbitrary-value: skeleton placeholder fixed size ))}
diff --git a/src/app/(dashboard)/teacher/lesson-plans/page.tsx b/src/app/(dashboard)/teacher/lesson-plans/page.tsx index 262e952..5b4722d 100644 --- a/src/app/(dashboard)/teacher/lesson-plans/page.tsx +++ b/src/app/(dashboard)/teacher/lesson-plans/page.tsx @@ -45,12 +45,16 @@ export default async function LessonPlansPage(): Promise { fallback={
+ {/* arbitrary-value: skeleton placeholder fixed size */} + {/* arbitrary-value: skeleton placeholder fixed size */} + {/* arbitrary-value: skeleton placeholder fixed size */}
{Array.from({ length: 6 }).map((_, i) => ( + // arbitrary-value: skeleton placeholder fixed size ))}
diff --git a/src/app/(dashboard)/teacher/practice/error.tsx b/src/app/(dashboard)/teacher/practice/error.tsx index cb921da..d52a36d 100644 --- a/src/app/(dashboard)/teacher/practice/error.tsx +++ b/src/app/(dashboard)/teacher/practice/error.tsx @@ -26,6 +26,7 @@ export default function TeacherPracticeError({

{t("errors.pageErrorPractice")}

+ {/* arbitrary-value: error icon fixed size */} {t("teacher.title")}

{t("teacher.description")}

+ {/* arbitrary-value: empty state fixed size */} {t("teacher.title")}

{t("teacher.description")}

+ {/* arbitrary-value: empty state fixed size */} {t("teacher.title")}

{t("teacher.description")}

+ {/* arbitrary-value: empty state fixed size */}
{Array.from({ length: 5 }).map((_, i) => ( + // arbitrary-value: skeleton placeholder fixed size ))}
+ {/* arbitrary-value: skeleton placeholder fixed size */}
} diff --git a/src/app/globals.css b/src/app/globals.css index 8173c6a..6e8f650 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1,219 +1,9 @@ @import "tailwindcss"; - +@import "./styles/tokens/index.css"; @plugin "tailwindcss-animate"; @plugin "@tailwindcss/typography"; - @custom-variant dark (&:where(.dark, .dark *)); -:root { - /* Neutral: Zinc - Clean, Professional, International Style */ - --background: 0 0% 100%; - --foreground: 240 10% 3.9%; - - --card: 0 0% 100%; - --card-foreground: 240 10% 3.9%; - - --popover: 0 0% 100%; - --popover-foreground: 240 10% 3.9%; - - /* Brand: Deep Indigo */ - --primary: 240 5.9% 10%; - --primary-foreground: 0 0% 98%; - - --secondary: 240 4.8% 95.9%; - --secondary-foreground: 240 5.9% 10%; - - --muted: 240 4.8% 95.9%; - --muted-foreground: 240 3.8% 46.1%; - - --accent: 240 4.8% 95.9%; - --accent-foreground: 240 5.9% 10%; - - /* Destructive: Subtle Red */ - --destructive: 0 84.2% 60.2%; - --destructive-foreground: 0 0% 98%; - - /* Borders & UI */ - --border: 240 5.9% 90%; - --input: 240 5.9% 90%; - --ring: 240 5.9% 10%; - - --radius: 0.5rem; - - /* Chart / Data Visualization Colors */ - --chart-1: 12 76% 61%; - --chart-2: 173 58% 39%; - --chart-3: 197 37% 24%; - --chart-4: 43 74% 66%; - --chart-5: 27 87% 67%; - - /* Lesson Plan Node Colors (V4 P1-4 修复:从硬编码提取到设计令牌) */ - /* V4 纸感重构:原 --lesson-node-* 已删除,改为 --lp-dot-* 中性色点令牌 */ - - /* V4 备课编辑器:纸感令牌(替代 --lesson-node-*) */ - --lp-paper: #fefefe; - --lp-paper-edge: #f8f8f7; - --lp-paper-shadow: 0 1px 2px rgba(15,15,15,0.04), 0 8px 24px rgba(15,15,15,0.04); - --lp-paper-shadow-active: 0 1px 2px rgba(15,15,15,0.06), 0 12px 36px rgba(15,15,15,0.08); - --lp-anchor-range: rgba(28, 25, 23, 0.08); - --lp-anchor-range-active: rgba(28, 25, 23, 0.16); - --lp-anchor-point: #1c1917; - --lp-inline-node-border: #d6d3d1; - --lp-inline-node-text: #44403c; - --lp-inline-node-meta: #a8a29e; - --lp-interaction: #6366f1; - - /* V4 节点类型色点(极克制) */ - --lp-dot-objective: #4b5563; - --lp-dot-key-point: #525252; - --lp-dot-import: #6b7280; - --lp-dot-new-teaching: #1c1917; - --lp-dot-consolidation: #6b7280; - --lp-dot-summary: #525252; - --lp-dot-homework: #525252; - --lp-dot-blackboard: #44403c; - --lp-dot-text-study: #4b5563; - --lp-dot-exercise: #6b7280; - --lp-dot-rich-text: #a8a29e; - --lp-dot-reflection: #6b7280; - --lp-dot-interaction: #6366f1; - --lp-dot-textbook: #44403c; - --lp-dot-default: #a8a29e; - - /* Sidebar Specific */ - --sidebar-background: 0 0% 98%; - --sidebar-foreground: 240 5.3% 26.1%; - --sidebar-primary: 240 5.9% 10%; - --sidebar-primary-foreground: 0 0% 98%; - --sidebar-accent: 240 4.8% 95.9%; - --sidebar-accent-foreground: 240 5.9% 10%; - --sidebar-border: 220 13% 91%; - --sidebar-ring: 217.2 91.2% 59.8%; -} - -.dark { - /* Dark Mode: Deep Zinc Base */ - --background: 240 10% 3.9%; - --foreground: 0 0% 98%; - - --card: 240 10% 3.9%; - --card-foreground: 0 0% 98%; - - --popover: 240 10% 3.9%; - --popover-foreground: 0 0% 98%; - - /* Brand Dark: Adjusted for contrast */ - --primary: 0 0% 98%; - --primary-foreground: 240 5.9% 10%; - - --secondary: 240 3.7% 15.9%; - --secondary-foreground: 0 0% 98%; - - --muted: 240 3.7% 15.9%; - --muted-foreground: 240 5% 64.9%; - - --accent: 240 3.7% 15.9%; - --accent-foreground: 0 0% 98%; - - --destructive: 0 62.8% 30.6%; - --destructive-foreground: 0 0% 98%; - - --border: 240 3.7% 15.9%; - --input: 240 3.7% 15.9%; - --ring: 240 4.9% 83.9%; - - --chart-1: 220 70% 50%; - --chart-2: 160 60% 45%; - --chart-3: 30 80% 55%; - --chart-4: 280 65% 60%; - --chart-5: 340 75% 55%; - - --sidebar-background: 240 5.9% 10%; - --sidebar-foreground: 240 4.8% 95.9%; - --sidebar-primary: 224.3 76.3% 48%; - --sidebar-primary-foreground: 0 0% 100%; - --sidebar-accent: 240 3.7% 15.9%; - --sidebar-accent-foreground: 240 4.8% 95.9%; - --sidebar-border: 240 3.7% 15.9%; - --sidebar-ring: 217.2 91.2% 59.8%; -} - -@theme inline { - --color-background: hsl(var(--background)); - --color-foreground: hsl(var(--foreground)); - - --color-card: hsl(var(--card)); - --color-card-foreground: hsl(var(--card-foreground)); - - --color-popover: hsl(var(--popover)); - --color-popover-foreground: hsl(var(--popover-foreground)); - - --color-primary: hsl(var(--primary)); - --color-primary-foreground: hsl(var(--primary-foreground)); - - --color-secondary: hsl(var(--secondary)); - --color-secondary-foreground: hsl(var(--secondary-foreground)); - - --color-muted: hsl(var(--muted)); - --color-muted-foreground: hsl(var(--muted-foreground)); - - --color-accent: hsl(var(--accent)); - --color-accent-foreground: hsl(var(--accent-foreground)); - - --color-destructive: hsl(var(--destructive)); - --color-destructive-foreground: hsl(var(--destructive-foreground)); - - --color-border: hsl(var(--border)); - --color-input: hsl(var(--input)); - --color-ring: hsl(var(--ring)); - - --color-chart-1: hsl(var(--chart-1)); - --color-chart-2: hsl(var(--chart-2)); - --color-chart-3: hsl(var(--chart-3)); - --color-chart-4: hsl(var(--chart-4)); - --color-chart-5: hsl(var(--chart-5)); - - --color-sidebar: hsl(var(--sidebar-background)); - --color-sidebar-foreground: hsl(var(--sidebar-foreground)); - --color-sidebar-primary: hsl(var(--sidebar-primary)); - --color-sidebar-primary-foreground: hsl(var(--sidebar-primary-foreground)); - --color-sidebar-accent: hsl(var(--sidebar-accent)); - --color-sidebar-accent-foreground: hsl(var(--sidebar-accent-foreground)); - --color-sidebar-border: hsl(var(--sidebar-border)); - --color-sidebar-ring: hsl(var(--sidebar-ring)); - - /* Material Design 3 Surface 令牌映射(备课模块使用)*/ - --color-surface: hsl(var(--card)); - --color-on-surface: hsl(var(--foreground)); - --color-on-surface-variant: hsl(var(--muted-foreground)); - --color-surface-container-lowest: hsl(var(--background)); - --color-surface-container-low: hsl(var(--muted)); - --color-surface-container: hsl(var(--secondary)); - --color-surface-container-high: hsl(var(--accent)); - --color-surface-container-highest: hsl(var(--muted-foreground)); - --color-outline-variant: hsl(var(--border)); - --color-outline: hsl(var(--border)); - --color-error: hsl(var(--destructive)); - --color-tertiary: hsl(var(--chart-3)); - --color-tertiary-container: hsl(var(--chart-3)); - - --radius-lg: var(--radius); - --radius-md: calc(var(--radius) - 2px); - --radius-sm: calc(var(--radius) - 4px); - - --animate-accordion-down: accordion-down 0.2s ease-out; - --animate-accordion-up: accordion-up 0.2s ease-out; - - @keyframes accordion-down { - from { height: 0; } - to { height: var(--radix-accordion-content-height); } - } - @keyframes accordion-up { - from { height: var(--radix-accordion-content-height); } - to { height: 0; } - } -} - /* Reduced Motion */ @layer base { @media (prefers-reduced-motion: reduce) { @@ -239,15 +29,15 @@ } } -/* ---- 备课画布:锚点与连线透明度规则 ---- */ -/* 范围锚定文本背景:默认透明(opacity 0),选中关联节点时显示(opacity 0.3) */ +/* ---- 备课画布:锚点与连线透明度规则 ---- */ +/* 范围锚定文本背景:默认透明(opacity 0),选中关联节点时显示(opacity 0.3) */ .range-anchor { - background-color: var(--node-color, #1976d2); + background-color: var(--node-color, hsl(var(--lp-interaction))); opacity: 0; - transition: opacity 0.2s ease; - border-radius: 2px; + transition: opacity var(--duration-normal); + border-radius: var(--radius-sm); cursor: pointer; - padding: 0 1px; + padding: 0 var(--space-0_5); } .range-anchor.active { opacity: 0.3; @@ -256,18 +46,18 @@ opacity: 0.5; } -/* 点锚定占位符:默认半透明(opacity 0.3),选中关联节点时不透明(opacity 1) */ +/* 点锚定占位符:默认半透明(opacity 0.3),选中关联节点时不透明(opacity 1) */ .point-anchor { - background-color: var(--node-color, #1976d2); - color: #fff; + background-color: var(--node-color, hsl(var(--lp-interaction))); + color: hsl(var(--background)); opacity: 0.3; - transition: opacity 0.2s ease; - border-radius: 50%; + transition: opacity var(--duration-normal); + border-radius: var(--radius-full); cursor: pointer; - padding: 0 4px; - margin: 0 1px; + padding: 0 var(--space-1); + margin: 0 var(--space-0_5); font-size: 0.85em; - font-weight: 600; + font-weight: var(--weight-semibold); display: inline-block; min-width: 1.2em; text-align: center; @@ -281,10 +71,10 @@ opacity: 0.8; } -/* 锚点连线:默认 10% 透明度,选中关联节点时完整显示 */ +/* 锚点连线:默认 10% 透明度,选中关联节点时完整显示 */ .anchor-edge { opacity: 0.1; - transition: opacity 0.2s ease; + transition: opacity var(--duration-normal); } .anchor-edge.active { opacity: 1; @@ -297,8 +87,8 @@ } /* ============================================================ - * P3-1: 成绩报告卡打印样式(A4 纵向) - * 仅在浏览器打印对话框激活时生效,避免影响常规页面渲染。 + * P3-1: 成绩报告卡打印样式(A4 纵向) + * 仅在浏览器打印对话框激活时生效,避免影响常规页面渲染。 * ============================================================ */ @media print { @page { @@ -326,7 +116,7 @@ box-shadow: none; } - /* 表格分页:行不跨页,表整体可分页 */ + /* 表格分页:行不跨页,表整体可分页 */ .report-card-table { page-break-inside: auto; } @@ -343,135 +133,146 @@ } /* ============================================================ - * V4 备课编辑器:纸区工具条 + 锚点 + inline-node + 对话体样式 + * V4 备课编辑器:纸区工具条 + 锚点 + inline-node + 对话体样式 * ============================================================ */ -/* V4 备课编辑器:纸区工具条 */ +/* V4 备课编辑器:纸区工具条 */ .lp-paper-toolbar { position: sticky; top: 0; - margin: -64px -72px 24px; - padding: 10px 72px; - background: rgba(255,255,255,0.92); + margin: calc(-1 * var(--space-16)) calc(-1 * var(--space-18)) var(--space-6); + padding: calc(var(--space-2) + var(--space-0_5)) var(--space-18); + background: hsl(var(--background) / 0.92); backdrop-filter: blur(8px); - border-bottom: 1px solid var(--border); + border-bottom: 1px solid hsl(var(--border)); display: flex; - gap: 2px; - z-index: 5; + gap: var(--space-0_5); + z-index: var(--z-sticky); } .lp-tb-btn { - width: 28px; height: 28px; - border: none; background: transparent; - border-radius: 4px; cursor: pointer; - font-family: 'Inter', sans-serif; - font-size: 13px; color: var(--foreground); - display: inline-flex; align-items: center; justify-content: center; - transition: background .12s; + width: var(--space-7); + height: var(--space-7); + border: none; + background: transparent; + border-radius: var(--radius-sm); + cursor: pointer; + font-family: var(--font-family-sans); + font-size: var(--font-size-2); + color: hsl(var(--foreground)); + display: inline-flex; + align-items: center; + justify-content: center; + transition: background var(--duration-fast); } -.lp-tb-btn:hover { background: var(--muted); } -.lp-tb-btn.is-active { background: var(--muted); color: var(--foreground); } -.lp-tb-btn.b { font-weight: 700; } -.lp-tb-btn.i { font-style: italic; font-family: 'Fraunces', serif; } -.lp-tb-btn.h1 { font-size: 11px; font-weight: 600; } -.lp-tb-btn.h2 { font-size: 10px; font-weight: 600; } -.lp-tb-btn.ul { font-size: 14px; } -.lp-tb-btn.quote { font-family: 'Fraunces', serif; font-style: italic; font-size: 14px; } +.lp-tb-btn:hover { background: hsl(var(--muted)); } +.lp-tb-btn.is-active { background: hsl(var(--muted)); color: hsl(var(--foreground)); } +.lp-tb-btn.b { font-weight: var(--weight-bold); } +.lp-tb-btn.i { font-style: italic; font-family: var(--font-family-serif); } +.lp-tb-btn.h1 { font-size: var(--font-size-1); font-weight: var(--weight-semibold); } +.lp-tb-btn.h2 { font-size: var(--font-size-1); font-weight: var(--weight-medium); } +.lp-tb-btn.ul { font-size: var(--font-size-3); } +.lp-tb-btn.quote { font-family: var(--font-family-serif); font-style: italic; font-size: var(--font-size-3); } .lp-tb-divider { - width: 1px; - background: var(--border); - margin: 4px 4px; + width: var(--space-0_5); + background: hsl(var(--border)); + margin: var(--space-1) var(--space-1); } /* V4 锚点样式 */ .lp-anchor-range { - background: var(--lp-anchor-range); - border-radius: 2px; - padding: 1px 2px; - margin: 0 -2px; + background: hsl(var(--lp-anchor-range)); + border-radius: var(--radius-sm); + padding: var(--space-0_5) var(--space-0_5); + margin: 0 calc(-1 * var(--space-0_5)); cursor: pointer; - border-bottom: 1.5px solid var(--lp-anchor-point); - transition: background .15s; + border-bottom: 1.5px solid hsl(var(--lp-anchor-point)); + transition: background var(--duration-fast); } .lp-anchor-range:hover, .lp-anchor-range.is-active { - background: var(--lp-anchor-range-active); + background: hsl(var(--lp-anchor-range-active)); } .lp-anchor-point { display: inline-block; - width: 16px; height: 16px; - line-height: 16px; + width: var(--space-4); + height: var(--space-4); + line-height: var(--space-4); text-align: center; - border-radius: 50%; - background: var(--lp-anchor-point); - color: #fff; - font-family: 'Inter', sans-serif; - font-size: 9px; - font-weight: 600; - margin: 0 2px; + border-radius: var(--radius-full); + background: hsl(var(--lp-anchor-point)); + color: hsl(var(--background)); + font-family: var(--font-family-sans); + font-size: var(--font-size-1); + font-weight: var(--weight-semibold); + margin: 0 var(--space-0_5); vertical-align: middle; cursor: pointer; } /* V4 inline-node 样式 */ .lp-inline-node { - font-family: 'Inter', sans-serif; - margin: 20px 0 20px; - padding: 14px 18px 14px 20px; - border-left: 2px solid var(--lp-inline-node-border); - color: var(--lp-inline-node-text); - font-size: 13.5px; - line-height: 1.65; - transition: border-color .2s; + font-family: var(--font-family-sans); + margin: var(--space-5) 0 var(--space-5); + padding: calc(var(--space-3) + var(--space-0_5)) calc(var(--space-4) + var(--space-2)) calc(var(--space-3) + var(--space-0_5)) var(--space-5); + border-left: 2px solid hsl(var(--lp-inline-node-border)); + color: hsl(var(--lp-inline-node-text)); + font-size: var(--font-size-3); + line-height: var(--leading-relaxed); + transition: border-color var(--duration-normal); } -.lp-inline-node:hover { border-left-color: var(--foreground); } +.lp-inline-node:hover { border-left-color: hsl(var(--foreground)); } .lp-inline-node-head { - display: flex; align-items: center; gap: 8px; - margin-bottom: 8px; - font-size: 10px; font-weight: 600; - text-transform: uppercase; letter-spacing: 0.08em; - color: var(--lp-inline-node-meta); + display: flex; + align-items: center; + gap: var(--space-2); + margin-bottom: var(--space-2); + font-size: var(--font-size-1); + font-weight: var(--weight-semibold); + text-transform: uppercase; + letter-spacing: 0.08em; + color: hsl(var(--lp-inline-node-meta)); } .lp-inline-node-title { - font-family: 'Inter', sans-serif; - font-weight: 600; - font-size: 14px; - color: #1a1a1a; - margin: 0 0 6px; - line-height: 1.35; + font-family: var(--font-family-sans); + font-weight: var(--weight-semibold); + font-size: var(--font-size-4); + color: hsl(var(--lp-anchor-point)); + margin: 0 0 var(--space-1_5); + line-height: var(--leading-snug); } .lp-inline-node-body { - font-size: 13px; - line-height: 1.6; - color: #404040; + font-size: var(--font-size-2); + line-height: var(--leading-normal); + color: hsl(var(--lp-inline-node-text)); } /* V4 师生交互对话体 */ -.lp-qa-dialog { margin: 8px 0 4px; } +.lp-qa-dialog { margin: var(--space-2) 0 var(--space-1); } .lp-qa-turn { display: grid; - grid-template-columns: 28px 1fr; - gap: 10px; - padding: 8px 0; - border-bottom: 1px dashed var(--border); - font-size: 12.5px; - line-height: 1.55; + grid-template-columns: calc(var(--space-6) + var(--space-2)) 1fr; + gap: calc(var(--space-2) + var(--space-0_5)); + padding: var(--space-2) 0; + border-bottom: 1px dashed hsl(var(--border)); + font-size: var(--font-size-1); + line-height: var(--leading-relaxed); } .lp-qa-turn:last-child { border-bottom: none; } .lp-qa-role { - font-family: 'JetBrains Mono', monospace; - font-size: 9px; - font-weight: 600; + font-family: var(--font-family-mono); + font-size: var(--font-size-0); + font-weight: var(--weight-semibold); text-transform: uppercase; letter-spacing: 0.05em; - padding-top: 2px; + padding-top: var(--space-0_5); text-align: right; } -.lp-qa-role.teacher { color: #1c1917; } -.lp-qa-role.student { color: #6b7280; } -.lp-qa-content { color: #404040; } +.lp-qa-role.teacher { color: hsl(var(--lp-anchor-point)); } +.lp-qa-role.student { color: hsl(var(--color-zinc-500)); } +.lp-qa-content { color: hsl(var(--lp-inline-node-text)); } .lp-qa-prompt { font-style: italic; - color: #525252; - font-size: 11px; + color: hsl(var(--color-zinc-600)); + font-size: var(--font-size-1); display: block; - margin-top: 2px; + margin-top: var(--space-0_5); } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index b69852d..84a18c1 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,12 +1,8 @@ import type { Metadata, Viewport } from "next"; import { Inter } from "next/font/google"; -import { NextIntlClientProvider } from "next-intl"; import { getLocale, getMessages } from "next-intl/server"; -import { ThemeProvider } from "@/shared/components/theme-provider"; -import { Toaster } from "@/shared/components/ui/sonner"; -import { NuqsAdapter } from "nuqs/adapters/next/app"; -import { AuthSessionProvider } from "@/shared/components/auth-session-provider"; +import { Providers } from "./providers"; import { auth } from "@/auth"; import "./globals.css"; @@ -33,7 +29,9 @@ export const viewport: Viewport = { maximumScale: 5, userScalable: true, themeColor: [ + // eslint-disable-next-line no-restricted-syntax -- Next.js metadata requires literal hex { media: "(prefers-color-scheme: light)", color: "#ffffff" }, + // eslint-disable-next-line no-restricted-syntax -- Next.js metadata requires literal hex { media: "(prefers-color-scheme: dark)", color: "#0a0a0a" }, ], }; @@ -57,19 +55,9 @@ export default async function RootLayout({ className={`${inter.variable} antialiased font-sans`} suppressHydrationWarning > - - - - {children} - - - - + + {children} + ); diff --git a/src/app/manifest.ts b/src/app/manifest.ts index 24d9ae1..4ee8f02 100644 --- a/src/app/manifest.ts +++ b/src/app/manifest.ts @@ -7,7 +7,9 @@ export default function manifest(): MetadataRoute.Manifest { description: "Enterprise Grade K12 Education Management System", start_url: "/", display: "standalone", + // eslint-disable-next-line no-restricted-syntax -- PWA manifest requires literal hex background_color: "#ffffff", + // eslint-disable-next-line no-restricted-syntax -- PWA manifest requires literal hex theme_color: "#1976d2", orientation: "any", icons: [ diff --git a/src/app/styles/tokens/index.css b/src/app/styles/tokens/index.css new file mode 100644 index 0000000..c5fde43 --- /dev/null +++ b/src/app/styles/tokens/index.css @@ -0,0 +1,9 @@ +/* src/app/styles/tokens/index.css */ +/* 设计令牌汇总入口 + * 加载顺序:Primitive → Semantic(明/暗) → 模块命名空间 → Tailwind 暴露 + */ +@import "./primitive.css"; +@import "./semantic-light.css"; +@import "./semantic-dark.css"; +@import "./lesson-preparation.css"; +@import "./tailwind-theme.css"; diff --git a/src/app/styles/tokens/lesson-preparation.css b/src/app/styles/tokens/lesson-preparation.css new file mode 100644 index 0000000..7533f57 --- /dev/null +++ b/src/app/styles/tokens/lesson-preparation.css @@ -0,0 +1,81 @@ +/* src/app/styles/tokens/lesson-preparation.css */ +/* --lp-* 令牌(lesson-preparation 模块命名空间) + * HEX 已转换为 HSL,与 shadcn 体系统一。 + * 暗色已补齐。 + */ + +:root { + /* 纸感 */ + --lp-paper: 0 0% 99.6%; + --lp-paper-edge: 60 9% 97%; + --lp-paper-shadow: var(--shadow-4); + --lp-paper-shadow-active: var(--shadow-5); + + /* 锚点 */ + --lp-anchor-range: 20 14.3% 4.1% / 0.08; + --lp-anchor-range-active: 20 14.3% 4.1% / 0.16; + --lp-anchor-point: 20 14.3% 4.1%; /* = stone-950 */ + + /* inline-node */ + --lp-inline-node-border: 30 5.7% 82.9%; /* = stone-300 */ + --lp-inline-node-text: 24 5.4% 26.9%; /* ≈ stone-700 */ + --lp-inline-node-meta: 30 5% 64.9%; /* = stone-400 */ + + /* 交互强调 */ + --lp-interaction: 238.6 84.5% 59.8%; /* = indigo-600 */ + + /* 节点类型色点(极克制,全部映射到 zinc/stone) */ + --lp-dot-objective: 240 3.8% 46.1%; /* = zinc-500 */ + --lp-dot-key-point: 0 0% 30%; /* ≈ zinc-700 灰 */ + --lp-dot-import: 240 4% 50%; /* = zinc-500 变体 */ + --lp-dot-new-teaching: 240 10% 3.9%; /* = zinc-950 */ + --lp-dot-consolidation: 240 4% 50%; + --lp-dot-summary: 0 0% 30%; + --lp-dot-homework: 0 0% 30%; + --lp-dot-blackboard: 24 5.4% 26.9%; /* = stone-700 */ + --lp-dot-text-study: 240 3.8% 46.1%; + --lp-dot-exercise: 240 4% 50%; + --lp-dot-rich-text: 30 5% 64.9%; /* = stone-400 */ + --lp-dot-reflection: 240 4% 50%; + --lp-dot-interaction: 238.6 84.5% 59.8%;/* = indigo-600 */ + --lp-dot-textbook: 24 5.4% 26.9%; + --lp-dot-default: 30 5% 64.9%; +} + +.dark { + /* 纸感(暗背景) */ + --lp-paper: 240 6% 10%; + --lp-paper-edge: 240 6% 8%; + --lp-paper-shadow: var(--shadow-4); + --lp-paper-shadow-active: var(--shadow-5); + + /* 锚点(反相) */ + --lp-anchor-range: 0 0% 98% / 0.08; + --lp-anchor-range-active: 0 0% 98% / 0.16; + --lp-anchor-point: 0 0% 98%; + + /* inline-node */ + --lp-inline-node-border: 240 4% 30%; + --lp-inline-node-text: 240 5% 80%; + --lp-inline-node-meta: 240 5% 55%; + + /* 交互强调(暗色微亮) */ + --lp-interaction: 238.7 83.5% 66.7%; /* = indigo-500 */ + + /* 节点类型色点(暗色微亮) */ + --lp-dot-objective: 240 5% 64.9%; + --lp-dot-key-point: 0 0% 70%; + --lp-dot-import: 240 4% 65%; + --lp-dot-new-teaching: 0 0% 98%; + --lp-dot-consolidation: 240 4% 65%; + --lp-dot-summary: 0 0% 70%; + --lp-dot-homework: 0 0% 70%; + --lp-dot-blackboard: 30 5% 70%; + --lp-dot-text-study: 240 5% 64.9%; + --lp-dot-exercise: 240 4% 65%; + --lp-dot-rich-text: 30 5% 55%; + --lp-dot-reflection: 240 4% 65%; + --lp-dot-interaction: 238.7 83.5% 66.7%; + --lp-dot-textbook: 30 5% 70%; + --lp-dot-default: 30 5% 55%; +} diff --git a/src/app/styles/tokens/primitive.css b/src/app/styles/tokens/primitive.css new file mode 100644 index 0000000..b6e2e3b --- /dev/null +++ b/src/app/styles/tokens/primitive.css @@ -0,0 +1,110 @@ +/* src/app/styles/tokens/primitive.css */ +/* Layer 1: Primitive (Raw Tokens) — 原始色板/字号/间距/阴影/字体家族 + * 仅被 Semantic 层引用,业务代码不直接使用。 + * 色板层不区分明暗,主题差异在 Semantic 层体现。 + */ +: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-* 纸感) */ + --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%; + + /* ============ 字号阶梯 ============ */ + --font-size-0: 9px; /* 角色标签微字号(lp-qa-role) */ + --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; /* 页面标题 */ + + /* ============ 间距阶梯 ============ */ + --space-0: 0; + --space-0_5: 0.125rem; /* 2px */ + --space-1: 0.25rem; /* 4px */ + --space-1_5: 0.375rem; /* 6px */ + --space-2: 0.5rem; /* 8px */ + --space-2_5: 0.625rem; /* 10px */ + --space-3: 0.75rem; /* 12px */ + --space-3_5: 0.875rem; /* 14px */ + --space-4: 1rem; /* 16px */ + --space-5: 1.25rem; /* 20px */ + --space-6: 1.5rem; /* 24px */ + --space-7: 1.75rem; /* 28px */ + --space-8: 2rem; /* 32px */ + --space-10: 2.5rem; /* 40px */ + --space-12: 3rem; /* 48px */ + --space-16: 4rem; /* 64px */ + --space-18: 4.5rem; /* 72px */ + + /* ============ 阴影阶梯 ============ */ + --shadow-1: 0 1px 2px rgba(15, 15, 15, 0.04); + --shadow-2: 0 1px 3px rgba(15, 15, 15, 0.06), 0 1px 2px rgba(15, 15, 15, 0.04); + --shadow-3: 0 4px 6px rgba(15, 15, 15, 0.05), 0 2px 4px rgba(15, 15, 15, 0.04); + --shadow-4: 0 1px 2px rgba(15, 15, 15, 0.04), 0 8px 24px rgba(15, 15, 15, 0.04); + --shadow-5: 0 1px 2px rgba(15, 15, 15, 0.06), 0 12px 36px rgba(15, 15, 15, 0.08); + --shadow-6: 0 10px 15px rgba(15, 15, 15, 0.1), 0 4px 6px rgba(15, 15, 15, 0.05); + + /* ============ 字体家族 ============ */ + --font-family-sans: 'Inter', system-ui, sans-serif; + --font-family-serif: 'Fraunces', Georgia, serif; + --font-family-mono: 'JetBrains Mono', monospace; + + /* ============ 行高阶梯 ============ */ + --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; + + /* ============ 动效 ============ */ + --duration-fast: 150ms; + --duration-normal: 200ms; + --duration-slow: 300ms; + --ease-in: cubic-bezier(0.4, 0, 1, 1); + --ease-out: cubic-bezier(0, 0, 0.2, 1); + --ease-in-out: cubic-bezier(0.4, 0, 0.2, 1); + + /* ============ z-index ============ */ + --z-dropdown: 1000; + --z-sticky: 1100; + --z-modal: 1300; + --z-popover: 1400; + --z-toast: 1500; +} diff --git a/src/app/styles/tokens/semantic-dark.css b/src/app/styles/tokens/semantic-dark.css new file mode 100644 index 0000000..563a687 --- /dev/null +++ b/src/app/styles/tokens/semantic-dark.css @@ -0,0 +1,65 @@ +/* src/app/styles/tokens/semantic-dark.css */ +/* Layer 2: Semantic Tokens (Dark / .dark) + * 所有令牌都有 :root 对应定义。 + */ +.dark { + /* ============ shadcn 标准令牌(原 globals.css 迁移,值不变) ============ */ + --background: 240 10% 3.9%; + --foreground: 0 0% 98%; + --card: 240 10% 3.9%; + --card-foreground: 0 0% 98%; + --popover: 240 10% 3.9%; + --popover-foreground: 0 0% 98%; + --primary: 0 0% 98%; + --primary-foreground: 240 5.9% 10%; + --secondary: 240 3.7% 15.9%; + --secondary-foreground: 0 0% 98%; + --muted: 240 3.7% 15.9%; + --muted-foreground: 240 5% 64.9%; + --accent: 240 3.7% 15.9%; + --accent-foreground: 0 0% 98%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 0 0% 98%; + --border: 240 3.7% 15.9%; + --input: 240 3.7% 15.9%; + --ring: 240 4.9% 83.9%; + + /* ============ chart 令牌(原 globals.css 迁移,值不变) ============ */ + --chart-1: 220 70% 50%; + --chart-2: 160 60% 45%; + --chart-3: 30 80% 55%; + --chart-4: 280 65% 60%; + --chart-5: 340 75% 55%; + + /* ============ sidebar 令牌(原 globals.css 迁移,值不变) ============ */ + --sidebar-background: 240 5.9% 10%; + --sidebar-foreground: 240 4.8% 95.9%; + --sidebar-primary: 224.3 76.3% 48%; + --sidebar-primary-foreground: 0 0% 100%; + --sidebar-accent: 240 3.7% 15.9%; + --sidebar-accent-foreground: 240 4.8% 95.9%; + --sidebar-border: 240 3.7% 15.9%; + --sidebar-ring: 217.2 91.2% 59.8%; + + /* ============ 语义层扩展(新增层级) ============ */ + --background-elevated: 240 6% 10%; + --background-sunken: 240 6% 8%; + --text-primary: 0 0% 98%; /* = --foreground */ + --text-secondary: 240 5% 64.9%; /* = --muted-foreground */ + --text-tertiary: 240 5% 50%; + --border-strong: 240 5% 40%; + --border-subtle: 240 5% 18%; + + /* ============ 新增业务语义令牌 ============ */ + --diff-add: 142 71% 55%; + --diff-add-bg: 142 71% 55% / 0.15; + --diff-remove: 0 84% 70%; + --diff-remove-bg: 0 84% 70% / 0.15; + + --graph-node-1: 220 70% 50%; + --graph-node-2: 160 60% 45%; + --graph-node-3: 30 80% 55%; + --graph-node-4: 280 65% 60%; + --graph-node-5: 340 75% 55%; + --graph-node-6: 200 80% 60%; +} diff --git a/src/app/styles/tokens/semantic-light.css b/src/app/styles/tokens/semantic-light.css new file mode 100644 index 0000000..d5aa3e4 --- /dev/null +++ b/src/app/styles/tokens/semantic-light.css @@ -0,0 +1,68 @@ +/* src/app/styles/tokens/semantic-light.css */ +/* Layer 2: Semantic Tokens (Light / :root) + * 业务代码唯一引用入口。所有令牌都有 .dark 对应定义。 + */ +:root { + /* ============ shadcn 标准令牌(原 globals.css 迁移,值不变) ============ */ + --background: 0 0% 100%; + --foreground: 240 10% 3.9%; + --card: 0 0% 100%; + --card-foreground: 240 10% 3.9%; + --popover: 0 0% 100%; + --popover-foreground: 240 10% 3.9%; + --primary: 240 5.9% 10%; + --primary-foreground: 0 0% 98%; + --secondary: 240 4.8% 95.9%; + --secondary-foreground: 240 5.9% 10%; + --muted: 240 4.8% 95.9%; + --muted-foreground: 240 3.8% 46.1%; + --accent: 240 4.8% 95.9%; + --accent-foreground: 240 5.9% 10%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 0 0% 98%; + --border: 240 5.9% 90%; + --input: 240 5.9% 90%; + --ring: 240 5.9% 10%; + --radius: 0.5rem; + + /* ============ chart 令牌(原 globals.css 迁移,值不变) ============ */ + --chart-1: 12 76% 61%; + --chart-2: 173 58% 39%; + --chart-3: 197 37% 24%; + --chart-4: 43 74% 66%; + --chart-5: 27 87% 67%; + + /* ============ sidebar 令牌(原 globals.css 迁移,值不变) ============ */ + --sidebar-background: 0 0% 98%; + --sidebar-foreground: 240 5.3% 26.1%; + --sidebar-primary: 240 5.9% 10%; + --sidebar-primary-foreground: 0 0% 98%; + --sidebar-accent: 240 4.8% 95.9%; + --sidebar-accent-foreground: 240 5.9% 10%; + --sidebar-border: 220 13% 91%; + --sidebar-ring: 217.2 91.2% 59.8%; + + /* ============ 语义层扩展(新增层级) ============ */ + --background-elevated: 0 0% 100%; + --background-sunken: 240 4.8% 95.9%; + --text-primary: 240 10% 3.9%; /* = --foreground */ + --text-secondary: 240 3.8% 46.1%; /* = --muted-foreground */ + --text-tertiary: 240 4% 65%; + --border-strong: 240 5.9% 70%; + --border-subtle: 240 5.9% 95%; + + /* ============ 新增业务语义令牌 ============ */ + /* 版本对比 */ + --diff-add: 142 71% 45%; + --diff-add-bg: 142 71% 45% / 0.1; + --diff-remove: 0 84% 60%; + --diff-remove-bg: 0 84% 60% / 0.1; + + /* 知识图谱节点色 */ + --graph-node-1: 12 76% 61%; /* = --chart-1 */ + --graph-node-2: 173 58% 39%; /* = --chart-2 */ + --graph-node-3: 197 37% 24%; /* = --chart-3 */ + --graph-node-4: 43 74% 66%; /* = --chart-4 */ + --graph-node-5: 27 87% 67%; /* = --chart-5 */ + --graph-node-6: 280 65% 60%; +} diff --git a/src/app/styles/tokens/tailwind-theme.css b/src/app/styles/tokens/tailwind-theme.css new file mode 100644 index 0000000..b1dddf8 --- /dev/null +++ b/src/app/styles/tokens/tailwind-theme.css @@ -0,0 +1,148 @@ +/* src/app/styles/tokens/tailwind-theme.css */ +/* @theme inline: 将所有 Semantic 令牌暴露为 Tailwind 类 + * 业务代码使用 bg-background / text-foreground / bg-lp-paper / font-sans 等 + */ + +@theme inline { + /* ============ shadcn 标准颜色 ============ */ + --color-background: hsl(var(--background)); + --color-foreground: hsl(var(--foreground)); + --color-card: hsl(var(--card)); + --color-card-foreground: hsl(var(--card-foreground)); + --color-popover: hsl(var(--popover)); + --color-popover-foreground: hsl(var(--popover-foreground)); + --color-primary: hsl(var(--primary)); + --color-primary-foreground: hsl(var(--primary-foreground)); + --color-secondary: hsl(var(--secondary)); + --color-secondary-foreground: hsl(var(--secondary-foreground)); + --color-muted: hsl(var(--muted)); + --color-muted-foreground: hsl(var(--muted-foreground)); + --color-accent: hsl(var(--accent)); + --color-accent-foreground: hsl(var(--accent-foreground)); + --color-destructive: hsl(var(--destructive)); + --color-destructive-foreground: hsl(var(--destructive-foreground)); + --color-border: hsl(var(--border)); + --color-input: hsl(var(--input)); + --color-ring: hsl(var(--ring)); + + /* ============ chart 颜色 ============ */ + --color-chart-1: hsl(var(--chart-1)); + --color-chart-2: hsl(var(--chart-2)); + --color-chart-3: hsl(var(--chart-3)); + --color-chart-4: hsl(var(--chart-4)); + --color-chart-5: hsl(var(--chart-5)); + + /* ============ sidebar 颜色 ============ */ + --color-sidebar: hsl(var(--sidebar-background)); + --color-sidebar-foreground: hsl(var(--sidebar-foreground)); + --color-sidebar-primary: hsl(var(--sidebar-primary)); + --color-sidebar-primary-foreground: hsl(var(--sidebar-primary-foreground)); + --color-sidebar-accent: hsl(var(--sidebar-accent)); + --color-sidebar-accent-foreground: hsl(var(--sidebar-accent-foreground)); + --color-sidebar-border: hsl(var(--sidebar-border)); + --color-sidebar-ring: hsl(var(--sidebar-ring)); + + /* ============ 扩展语义颜色 ============ */ + --color-background-elevated: hsl(var(--background-elevated)); + --color-background-sunken: hsl(var(--background-sunken)); + --color-text-primary: hsl(var(--text-primary)); + --color-text-secondary: hsl(var(--text-secondary)); + --color-text-tertiary: hsl(var(--text-tertiary)); + --color-border-strong: hsl(var(--border-strong)); + --color-border-subtle: hsl(var(--border-subtle)); + --color-diff-add: hsl(var(--diff-add)); + --color-diff-add-bg: hsl(var(--diff-add-bg)); + --color-diff-remove: hsl(var(--diff-remove)); + --color-diff-remove-bg: hsl(var(--diff-remove-bg)); + --color-graph-node-1: hsl(var(--graph-node-1)); + --color-graph-node-2: hsl(var(--graph-node-2)); + --color-graph-node-3: hsl(var(--graph-node-3)); + --color-graph-node-4: hsl(var(--graph-node-4)); + --color-graph-node-5: hsl(var(--graph-node-5)); + --color-graph-node-6: hsl(var(--graph-node-6)); + + /* ============ lp-* 颜色(lesson-preparation) ============ */ + --color-lp-paper: hsl(var(--lp-paper)); + --color-lp-paper-edge: hsl(var(--lp-paper-edge)); + --color-lp-anchor-point: hsl(var(--lp-anchor-point)); + --color-lp-inline-node-border: hsl(var(--lp-inline-node-border)); + --color-lp-inline-node-text: hsl(var(--lp-inline-node-text)); + --color-lp-inline-node-meta: hsl(var(--lp-inline-node-meta)); + --color-lp-interaction: hsl(var(--lp-interaction)); + --color-lp-dot-objective: hsl(var(--lp-dot-objective)); + --color-lp-dot-key-point: hsl(var(--lp-dot-key-point)); + --color-lp-dot-import: hsl(var(--lp-dot-import)); + --color-lp-dot-new-teaching: hsl(var(--lp-dot-new-teaching)); + --color-lp-dot-consolidation: hsl(var(--lp-dot-consolidation)); + --color-lp-dot-summary: hsl(var(--lp-dot-summary)); + --color-lp-dot-homework: hsl(var(--lp-dot-homework)); + --color-lp-dot-blackboard: hsl(var(--lp-dot-blackboard)); + --color-lp-dot-text-study: hsl(var(--lp-dot-text-study)); + --color-lp-dot-exercise: hsl(var(--lp-dot-exercise)); + --color-lp-dot-rich-text: hsl(var(--lp-dot-rich-text)); + --color-lp-dot-reflection: hsl(var(--lp-dot-reflection)); + --color-lp-dot-interaction: hsl(var(--lp-dot-interaction)); + --color-lp-dot-textbook: hsl(var(--lp-dot-textbook)); + --color-lp-dot-default: hsl(var(--lp-dot-default)); + + /* ============ 字体家族 ============ */ + --font-sans: var(--font-family-sans); + --font-serif: var(--font-family-serif); + --font-mono: var(--font-family-mono); + + /* ============ 字号阶梯 ============ */ + --text-size-0: var(--font-size-0); + --text-size-1: var(--font-size-1); + --text-size-2: var(--font-size-2); + --text-size-3: var(--font-size-3); + --text-size-4: var(--font-size-4); + --text-size-5: var(--font-size-5); + --text-size-6: var(--font-size-6); + --text-size-7: var(--font-size-7); + --text-size-8: var(--font-size-8); + --text-size-9: var(--font-size-9); + + /* ============ 圆角阶梯 ============ */ + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --radius-2xl: calc(var(--radius) + 8px); + --radius-full: 9999px; + + /* ============ 阴影阶梯 ============ */ + --shadow-1: var(--shadow-1); + --shadow-2: var(--shadow-2); + --shadow-3: var(--shadow-3); + --shadow-4: var(--shadow-4); + --shadow-5: var(--shadow-5); + --shadow-6: var(--shadow-6); + + /* ============ 动效 ============ */ + --duration-fast: var(--duration-fast); + --duration-normal: var(--duration-normal); + --duration-slow: var(--duration-slow); + --ease-in: var(--ease-in); + --ease-out: var(--ease-out); + --ease-in-out: var(--ease-in-out); + + /* ============ z-index ============ */ + --z-dropdown: var(--z-dropdown); + --z-sticky: var(--z-sticky); + --z-modal: var(--z-modal); + --z-popover: var(--z-popover); + --z-toast: var(--z-toast); + + /* ============ 动画(原 globals.css 迁移) ============ */ + --animate-accordion-down: accordion-down 0.2s ease-out; + --animate-accordion-up: accordion-up 0.2s ease-out; + + @keyframes accordion-down { + from { height: 0; } + to { height: var(--radix-accordion-content-height); } + } + @keyframes accordion-up { + from { height: var(--radix-accordion-content-height); } + to { height: 0; } + } +} diff --git a/src/modules/ai/components/ai-chart-renderer.tsx b/src/modules/ai/components/ai-chart-renderer.tsx index 0dd9963..6c49cdc 100644 --- a/src/modules/ai/components/ai-chart-renderer.tsx +++ b/src/modules/ai/components/ai-chart-renderer.tsx @@ -245,6 +245,7 @@ function renderLineChart(spec: AiChartSpec, series: AiChartSeries[]): React.Reac axisLine={false} width={36} /> + {/* arbitrary-value: chart canvas fixed size */} = { - high: "#ef4444", // red-500 - medium: "#f59e0b", // amber-500 - low: "#10b981", // emerald-500 + high: "hsl(var(--chart-1))", // red-500 + medium: "hsl(var(--chart-4))", // amber-500 + low: "hsl(var(--chart-2))", // emerald-500 } /** 风险等级对应的 Badge variant。 */ @@ -175,6 +175,7 @@ export function AttendanceGradeCorrelationCard({

{t("correlation.scatterTitle")}

+ {/* arbitrary-value: chart canvas fixed size */} ) : ( + // arbitrary-value: chart canvas fixed size ) : ( <> + {/* arbitrary-value: chart canvas fixed size */} {chartData.length > 0 ? ( + // arbitrary-value: chart canvas fixed size {chartTab === "submission" ? ( @@ -385,6 +386,7 @@ export function ClassTrendsWidget({ assignments, compact, className }: ClassTren )} ) : ( + // arbitrary-value: chart canvas fixed size
{t("detail.trends.noDataForSubject")}
diff --git a/src/modules/classes/components/my-classes-grid.tsx b/src/modules/classes/components/my-classes-grid.tsx index f3c44a6..c52ca3f 100644 --- a/src/modules/classes/components/my-classes-grid.tsx +++ b/src/modules/classes/components/my-classes-grid.tsx @@ -107,7 +107,7 @@ export function MyClassesGrid({ {/* Header with Pattern */}
-
+
@@ -275,7 +275,7 @@ function ClassTicket({
{/* Realistic Paper Texture & Noise */}
-
+
{/* Decorative Barcode Strip */}
@@ -329,8 +329,8 @@ function ClassTicket({ {/* Invitation Code Section */}
{/* Tiny Cut marks */} -
-
+
+
diff --git a/src/modules/course-plans/components/course-plan-calendar.tsx b/src/modules/course-plans/components/course-plan-calendar.tsx index 53c5f00..9665c5c 100644 --- a/src/modules/course-plans/components/course-plan-calendar.tsx +++ b/src/modules/course-plans/components/course-plan-calendar.tsx @@ -143,7 +143,7 @@ export function CoursePlanCalendar({ plan }: CoursePlanCalendarProps): JSX.Eleme aria-label={day.toDateString()} aria-current={isToday ? "date" : undefined} className={cn( - "min-h-[80px] rounded-md border p-1 text-left", + "min-h-20 rounded-md border p-1 text-left", inMonth ? "bg-card" : "bg-muted/30", isToday && "ring-2 ring-primary", )} diff --git a/src/modules/course-plans/components/template-picker-dialog.tsx b/src/modules/course-plans/components/template-picker-dialog.tsx index 2a127e6..34edce3 100644 --- a/src/modules/course-plans/components/template-picker-dialog.tsx +++ b/src/modules/course-plans/components/template-picker-dialog.tsx @@ -147,7 +147,7 @@ export function TemplatePickerDialog({ />
- + {loading ? (
diff --git a/src/modules/error-book/components/chapter-weakness-chart.tsx b/src/modules/error-book/components/chapter-weakness-chart.tsx index 1a6825b..f8d36f7 100644 --- a/src/modules/error-book/components/chapter-weakness-chart.tsx +++ b/src/modules/error-book/components/chapter-weakness-chart.tsx @@ -73,6 +73,7 @@ export function ChapterWeaknessChart({ data, className }: ChapterWeaknessChartPr {t("chapterChart.title")} + {/* arbitrary-value: chart canvas fixed size */} {t("classErrorBar.title")} + {/* arbitrary-value: chart canvas fixed size */} setNote(e.target.value)} placeholder={t("detailDialog.notePlaceholder")} - className="w-full min-h-[80px] rounded-md border bg-background p-3 text-sm resize-y focus:outline-none focus:ring-2 focus:ring-ring" + className="w-full min-h-20 rounded-md border bg-background p-3 text-sm resize-y focus:outline-none focus:ring-2 focus:ring-ring" maxLength={2000} />
diff --git a/src/modules/error-book/components/knowledge-point-weakness-chart.tsx b/src/modules/error-book/components/knowledge-point-weakness-chart.tsx index b70c0ac..dcf416f 100644 --- a/src/modules/error-book/components/knowledge-point-weakness-chart.tsx +++ b/src/modules/error-book/components/knowledge-point-weakness-chart.tsx @@ -102,7 +102,7 @@ export function KnowledgePointWeaknessChart({ { if (!isKpChartPayload(payload)) return null return ( diff --git a/src/modules/error-book/components/subject-distribution-chart.tsx b/src/modules/error-book/components/subject-distribution-chart.tsx index 7bb8cd3..ea97457 100644 --- a/src/modules/error-book/components/subject-distribution-chart.tsx +++ b/src/modules/error-book/components/subject-distribution-chart.tsx @@ -78,6 +78,7 @@ export function SubjectDistributionChart({ {t("subjectDistChart.title")} + {/* arbitrary-value: chart canvas fixed size */}
- {questionNumber}. + {questionNumber}.
{content.text || t("exam.previewDialog.untitledQuestion")} @@ -121,7 +121,7 @@ export function ExamPreviewDialog({
{content.options.map((opt) => (
- {opt.id}. + {opt.id}. {opt.text}
))} @@ -131,7 +131,7 @@ export function ExamPreviewDialog({
{content.subQuestions.map((item, index) => (
- {item.id}. + {item.id}. {item.text || t("exam.previewDialog.untitledSubQuestion")} {item.score ? ({item.score}{t("exam.previewDialog.scoreUnit")}) : null}
diff --git a/src/modules/homework/components/homework-grading-view.tsx b/src/modules/homework/components/homework-grading-view.tsx index 3409a44..5d9e218 100644 --- a/src/modules/homework/components/homework-grading-view.tsx +++ b/src/modules/homework/components/homework-grading-view.tsx @@ -328,7 +328,7 @@ export function HomeworkGradingView({ placeholder={t("homework.grade.feedbackPlaceholder", { name: studentName })} value={ans.feedback ?? ""} onChange={(e) => handleFeedbackChange(ans.id, e.target.value)} - className="min-h-[80px] bg-background" + className="min-h-20 bg-background" />
)} diff --git a/src/modules/lesson-preparation/components/ai-differentiation-dialog.tsx b/src/modules/lesson-preparation/components/ai-differentiation-dialog.tsx index 823d78b..b8bd694 100644 --- a/src/modules/lesson-preparation/components/ai-differentiation-dialog.tsx +++ b/src/modules/lesson-preparation/components/ai-differentiation-dialog.tsx @@ -63,6 +63,7 @@ export function AiDifferentiationDialog({ doc, textbookId, onClose }: Props) { return (
+ {/* arbitrary-value: dialog fixed width */}
+ {/* arbitrary-value: dialog fixed width */}
{t("v4.migration.legacyAnchorTitle")} @@ -74,11 +74,11 @@ function BannerContent({ onClick={onDismiss} style={{ background: "transparent", - border: "1px solid #fcd34d", + border: "1px solid hsl(var(--color-stone-400))", borderRadius: 3, padding: "3px 10px", fontSize: 11, - color: "#92400e", + color: "hsl(var(--color-stone-700))", cursor: "pointer", }} > diff --git a/src/modules/lesson-preparation/components/attachment-picker.tsx b/src/modules/lesson-preparation/components/attachment-picker.tsx index ee0679c..f47a289 100644 --- a/src/modules/lesson-preparation/components/attachment-picker.tsx +++ b/src/modules/lesson-preparation/components/attachment-picker.tsx @@ -157,6 +157,7 @@ export function AttachmentPicker({ return (
+ {/* arbitrary-value: dialog fixed width */}
{t("blackboard.contentLabel")} + {/* arbitrary-value: textarea min height */}