refactor(design-tokens): 全量体系化重建设计令牌
Primitive + Semantic 双层令牌架构,HEX->HSL,明暗双份,@theme inline 暴露为 Tailwind 类。 - 新建 src/app/styles/tokens/ 6 个令牌文件(primitive/semantic-light/semantic-dark/lesson-preparation/tailwind-theme/index) - globals.css 改为 @import 引入,477->258 行 - 清理 91 处 #hex 硬编码颜色 -> hsl(var(--*)) - 清理 10 处硬编码字体 -> var(--font-family-*) - 清理 100 文件 Tailwind 任意值(Tier 1 映射/Tier 3 注释豁免) - 清理 M3 Surface 死代码,升级 --lp-* 令牌(HEX->HSL + 暗色补全) - 新建 ESLint 自定义规则 no-hardcoded-design-tokens(单词边界正则) - eslint.config.mjs 新增 no-restricted-syntax 禁止 #hex + 自定义规则加载(pathToFileURL) - 项目规则新增设计令牌规范强制章节 - 架构图 004/005 同步设计令牌体系节点 - known-issues.md 追加设计令牌问题分类(7 个规则表) 验证: tsc --noEmit 0 errors, npm run lint 0 errors/12 warnings(均为既有问题)
This commit is contained in:
@@ -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 -- <reason>` 注释):
|
||||
- PWA manifest(`src/app/manifest.ts`)
|
||||
- 邮件 HTML 内联样式(`src/modules/notifications/channels/email-channel.ts`)
|
||||
- 图表 SVG 固定画布尺寸(recharts 选择器中的 `#ccc`/`#fff`)
|
||||
- loading.tsx 占位骨架
|
||||
- Dialog 固定宽度等无法令牌化的设计固定尺寸
|
||||
- **令牌文件分布**: `src/app/styles/tokens/`(primitive/semantic-light/semantic-dark/lesson-preparation/tailwind-theme/index)
|
||||
- **令牌分层**:
|
||||
- Layer 1 Primitive(`primitive.css`):原始色板/字号/间距/阴影,业务代码不直接引用
|
||||
- Layer 2 Semantic(`semantic-light.css` + `semantic-dark.css`):语义令牌,业务代码唯一引用入口
|
||||
- 模块命名空间(`lesson-preparation.css`):`--lp-*` 令牌,明暗双份
|
||||
- Tailwind 暴露(`tailwind-theme.css`):`@theme inline` 将 Semantic 令牌暴露为 `bg-*`/`text-*`/`font-*` 类
|
||||
- **改令牌必同步图**: 修改令牌定义后,同步更新 `docs/architecture/004` 与 `005`
|
||||
- **ESLint 强制约束**:
|
||||
- `no-restricted-syntax`: 禁止 `#hex` 字面量
|
||||
- `design-tokens/no-hardcoded-fonts`: 禁止 `'Inter'`/`'Fraunces'`/`'JetBrains Mono'` 字面量(单词边界匹配,不影响 `Interval`/`Interactive` 等标识符)
|
||||
- 白名单:`primitive.css`(令牌定义)、`email-channel.ts`(邮件 HTML)、`manifest.ts`(PWA)
|
||||
|
||||
### 安全规范
|
||||
|
||||
|
||||
@@ -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 -- <reason>` 注释豁免(如 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<string, unknown>`;消除 homework/stats-service、lesson-preparation/node-summary、lesson-preparation/ai-suggest 等 12+ 个文件中重复定义的 `isRecord` 局部实现,从 `unknown` 安全收窄类型,替代 `as Record<string, unknown>` 断言)
|
||||
- `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<T>(req)` / `errorToStatus(error)` — API 路由层统一响应工具(✅ 2026-07-04 API 规范化重构新增:位于 `lib/api-response.ts`,仅用于 `app/api/.../route.ts`;统一响应信封 `{ success, message?, errorCode?, data? }` 与 `ActionState<T>` 对齐;集中错误→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<T>(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<T>(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<T>(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<ExportType, Permission \| null>` 映射表替代 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<FileUploadResult> }` |
|
||||
| `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<T = unknown> = {
|
||||
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:模块间依赖矩阵
|
||||
|
||||
> 行表示使用方,列表示被使用方。`✅` 合理依赖,`❌` 违规直查,`⟳` 循环依赖。
|
||||
|
||||
File diff suppressed because one or more lines are too long
1536
docs/superpowers/plans/2026-07-04-design-tokens-refactor.md
Normal file
1536
docs/superpowers/plans/2026-07-04-design-tokens-refactor.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,547 @@
|
||||
# 设计令牌专项重构 · 设计文档
|
||||
|
||||
> 创建日期:2026-07-04
|
||||
> 状态:Draft(待用户审查)
|
||||
> 执行方式:大爆炸式(单 PR)
|
||||
> 关联规则:`docs/architecture/004_architecture_impact_map.md`、`.trae/rules/project_rules.md`
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
### 1.1 现状摘要
|
||||
|
||||
项目当前令牌散落在 [`src/app/globals.css`](file:///e:/Desktop/CICD/src/app/globals.css)(477 行)与 [`tailwind.config.ts`](file:///e:/Desktop/CICD/tailwind.config.ts)(极简,无 `theme.extend`)。已有 5 类令牌:
|
||||
|
||||
1. **shadcn 标准令牌**(HSL 格式,完整暗色)
|
||||
2. **chart-1~5 数据可视化令牌**(HSL,完整暗色)
|
||||
3. **sidebar 令牌**(HSL,完整暗色)
|
||||
4. **`--lp-*` 备课编辑器令牌**(HEX 格式,**无暗色**,**未在 `@theme inline` 中暴露**,无法 `bg-lp-paper`)
|
||||
5. **Material Design 3 Surface 映射令牌**(在 `@theme inline` 中,**经扫描零使用**,属死代码)
|
||||
|
||||
### 1.2 关键技术债
|
||||
|
||||
扫描结果:
|
||||
|
||||
| 问题类型 | 出现次数 | 文件数 |
|
||||
|---|---|---|
|
||||
| 硬编码颜色 `#xxx` | 91 处 | 15 文件 |
|
||||
| 硬编码字体 `'Inter'/'Fraunces'/'JetBrains Mono'` | 8 处 | 2 文件 |
|
||||
| 硬编码 `font-size: Npx` | 15 处 | 3 文件 |
|
||||
| Tailwind 任意值 `w-[Npx]/h-[Npx]/p-[Npx]/...` | 98 处 | 40 文件 |
|
||||
| 缺失:font/spacing/shadow/text-size 尺度令牌 | — | — |
|
||||
| `--lp-*` 无暗色 / 未暴露为 Tailwind 类 | — | — |
|
||||
| M3 Surface 令牌零使用(死代码) | — | — |
|
||||
|
||||
项目规则(`.trae/rules/project_rules.md`)明确禁止"任意值"和"动态拼接类名",但实际任意值已散落到 40 个文件,需要专项治理。
|
||||
|
||||
### 1.3 重构目标
|
||||
|
||||
- **核心目标**:全量体系化重建设计令牌,建立完整令牌契约,清理全部硬编码与任意值
|
||||
- **命名体系**:双层架构(Primitive + Semantic)+ shadcn 语义扩展
|
||||
- **覆盖范围**:font/size/leading/weight/radius/shadow/spacing/duration/ease/z-index 全阶梯
|
||||
- **暗色覆盖**:明暗双色全面覆盖(所有令牌都有 `:root` 与 `.dark` 双份)
|
||||
- **强制约束**:ESLint 规则 + 注释豁免,防止技术债复发
|
||||
|
||||
---
|
||||
|
||||
## 2. 令牌架构
|
||||
|
||||
### 2.1 双层架构
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ Layer 1: Primitive (Raw Tokens) — 原始色板/字号/间距/阴影 │
|
||||
│ ───────────────────────────────────────────────────────────── │
|
||||
│ 命名: --color-zinc-50 ~ 950 │
|
||||
│ --color-stone-50 ~ 950 │
|
||||
│ --color-indigo-500/600 │
|
||||
│ --font-size-1 ~ --font-size-9 │
|
||||
│ --space-0 ~ --space-32 │
|
||||
│ --shadow-1 ~ --shadow-6 │
|
||||
│ --font-family-sans/serif/mono │
|
||||
│ 特征: 不直接使用,只被 Semantic 层引用 │
|
||||
│ 主题: 无主题差异(色板层不区分明暗) │
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
▲ 引用
|
||||
│
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ Layer 2: Semantic (Component/Context Tokens) — 语义层 │
|
||||
│ ───────────────────────────────────────────────────────────── │
|
||||
│ shadcn 兼容: --background / --foreground / --primary / ... │
|
||||
│ 扩展层级: --background-elevated / --text-secondary / ... │
|
||||
│ 模块命名空间: --lp-* (lesson-preparation) │
|
||||
│ chart/sidebar: --chart-1~5 / --sidebar-* │
|
||||
│ 特征: 业务代码唯一引用入口,Tailwind 通过 @theme inline 暴露 │
|
||||
│ 主题: :root 与 .dark 双份定义,所有令牌都有暗色 │
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
▲ 引用
|
||||
│
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ Layer 3: Tailwind 类 (via @theme inline) │
|
||||
│ ───────────────────────────────────────────────────────────── │
|
||||
│ bg-background / text-foreground / bg-primary / ... │
|
||||
│ bg-lp-paper / text-lp-inline-node-meta / ... │
|
||||
│ font-sans / font-serif / font-mono │
|
||||
│ text-size-2 / leading-snug / weight-semibold │
|
||||
│ radius-md / shadow-2 / space-4 / duration-fast / z-modal │
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 2.2 核心原则
|
||||
|
||||
- 业务代码 **只引用 Semantic 层**(直接 `var(--background)` 或 Tailwind 类 `bg-background`),禁止引用 Primitive 层
|
||||
- Primitive 层只为 Semantic 层提供"原材料",便于未来多主题切换(只改 Semantic 层映射,不动 Primitive)
|
||||
- shadcn 命名严格保留(`--background`/`--foreground`/`--primary` 等),`shared/components/ui/*` 零改动
|
||||
- `--lp-*` 保留命名空间(已有 9 文件使用,改名风险大),但补全暗色与 Tailwind 暴露
|
||||
|
||||
### 2.3 文件分布
|
||||
|
||||
当前 [`globals.css`](file:///e:/Desktop/CICD/src/app/globals.css) 477 行,补全令牌后估算 1200+ 行,超过项目规则硬性上限 1000 行。拆分为令牌专题目录:
|
||||
|
||||
```
|
||||
src/app/
|
||||
├─ globals.css # 仅保留 @import、base layer、组件硬编码样式清理后的样式
|
||||
└─ styles/
|
||||
└─ tokens/
|
||||
├─ primitive.css # Layer 1: 原始色板/字号/间距/阴影阶梯
|
||||
├─ semantic-light.css # Layer 2: :root 语义令牌(明色)
|
||||
├─ semantic-dark.css # Layer 2: .dark 语义令牌(暗色)
|
||||
├─ lesson-preparation.css # --lp-* 令牌(明暗双份)
|
||||
├─ tailwind-theme.css # @theme inline 暴露规则
|
||||
└─ index.css # 汇总 @import 入口
|
||||
```
|
||||
|
||||
`globals.css` 顶部改为:
|
||||
|
||||
```css
|
||||
@import "tailwindcss";
|
||||
@import "./styles/tokens/index.css";
|
||||
@plugin "tailwindcss-animate";
|
||||
@plugin "@tailwindcss/typography";
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
```
|
||||
|
||||
### 2.4 Tailwind v4 集成
|
||||
|
||||
[`tailwind.config.ts`](file:///e:/Desktop/CICD/tailwind.config.ts) 保持极简(只 content + plugins),所有令牌通过 `@theme inline` 在 `tailwind-theme.css` 中暴露。
|
||||
|
||||
---
|
||||
|
||||
## 3. Primitive 层(原始阶梯)
|
||||
|
||||
### 3.1 色板(以 Zinc 为基准中性色,与现有 shadcn 一致)
|
||||
|
||||
```css
|
||||
/* primitive.css */
|
||||
:root {
|
||||
/* Zinc 中性色板(shadcn 默认) */
|
||||
--color-zinc-50: 0 0% 99%;
|
||||
--color-zinc-100: 240 4.8% 95.9%;
|
||||
--color-zinc-200: 240 5.9% 90%;
|
||||
--color-zinc-300: 240 4.8% 83.9%;
|
||||
--color-zinc-400: 240 5% 64.9%;
|
||||
--color-zinc-500: 240 3.8% 46.1%;
|
||||
--color-zinc-600: 240 5.2% 33.9%;
|
||||
--color-zinc-700: 240 5.3% 26.1%;
|
||||
--color-zinc-800: 240 5.9% 10%;
|
||||
--color-zinc-900: 240 5.9% 3.9%;
|
||||
--color-zinc-950: 240 10% 3.9%;
|
||||
|
||||
/* Stone 暖灰(用于 lp-* 纸感,与现有 --lp-paper-edge #f8f8f7 对齐) */
|
||||
--color-stone-50: 60 4.8% 95.9%;
|
||||
--color-stone-100: 60 5.1% 90%;
|
||||
--color-stone-200: 20 5.9% 90%;
|
||||
--color-stone-300: 24 5.7% 82.9%;
|
||||
--color-stone-400: 24 5.4% 63.9%;
|
||||
--color-stone-500: 25 5.1% 44.7%;
|
||||
--color-stone-600: 33 5% 39.8%;
|
||||
--color-stone-700: 30 5.2% 32.7%;
|
||||
--color-stone-800: 12 6.5% 31.4%;
|
||||
--color-stone-900: 24 10% 10%;
|
||||
--color-stone-950: 20 14.3% 4.1%;
|
||||
|
||||
/* Indigo 强调色(lp-interaction 用) */
|
||||
--color-indigo-500: 238.7 83.5% 66.7%;
|
||||
--color-indigo-600: 238.6 84.5% 59.8%;
|
||||
}
|
||||
```
|
||||
|
||||
**说明**: 仅记录 Primitive 原料,业务代码不直接引用。
|
||||
|
||||
### 3.2 字号阶梯(替代硬编码 13.5px/14px/12.5px 等)
|
||||
|
||||
| Token | 值 | 用途 |
|
||||
|---|---|---|
|
||||
| `--font-size-1` | 12px | 元信息/角色标签 |
|
||||
| `--font-size-2` | 13px | inline-node body |
|
||||
| `--font-size-3` | 13.5px | inline-node 主文(备课纸感) |
|
||||
| `--font-size-4` | 14px | 标题/按钮 |
|
||||
| `--font-size-5` | 16px | 正文(Fraunces 16px) |
|
||||
| `--font-size-6` | 18px | H2 |
|
||||
| `--font-size-7` | 20px | H1 |
|
||||
| `--font-size-8` | 24px | 区块标题 |
|
||||
| `--font-size-9` | 32px | 页面标题 |
|
||||
|
||||
### 3.3 间距阶梯(覆盖 98 处任意值)
|
||||
|
||||
| Token | 值 | 替代示例 |
|
||||
|---|---|---|
|
||||
| `--space-0` | 0 | |
|
||||
| `--space-0_5` | 0.125rem (2px) | `p-[2px]` |
|
||||
| `--space-1` | 0.25rem (4px) | `p-[4px]` |
|
||||
| `--space-1_5` | 0.375rem (6px) | `gap-[6px]` |
|
||||
| `--space-2` | 0.5rem (8px) | |
|
||||
| `--space-2_5` | 0.625rem (10px) | `p-[10px]` |
|
||||
| `--space-3` | 0.75rem (12px) | |
|
||||
| `--space-3_5` | 0.875rem (14px) | `p-[14px]` |
|
||||
| `--space-4` | 1rem (16px) | |
|
||||
| `--space-5` | 1.25rem (20px) | `m-[20px]` |
|
||||
| `--space-6` | 1.5rem (24px) | |
|
||||
| `--space-7` | 1.75rem (28px) | `w-[28px]` (lp-tb-btn) |
|
||||
| `--space-8` | 2rem (32px) | |
|
||||
| `--space-10` | 2.5rem (40px) | |
|
||||
| `--space-12` | 3rem (48px) | |
|
||||
| `--space-16` | 4rem (64px) | |
|
||||
| `--space-18` | 4.5rem (72px) | `px-[72px]` (lp-paper-toolbar) |
|
||||
|
||||
### 3.4 阴影阶梯(替代硬编码 box-shadow)
|
||||
|
||||
| Token | 值 | 替代 |
|
||||
|---|---|---|
|
||||
| `--shadow-1` | `0 1px 2px rgba(15,15,15,0.04)` | shadow-xs |
|
||||
| `--shadow-2` | `0 1px 3px rgba(15,15,15,0.06), 0 1px 2px rgba(15,15,15,0.04)` | shadow-sm |
|
||||
| `--shadow-3` | `0 4px 6px rgba(15,15,15,0.05), 0 2px 4px rgba(15,15,15,0.04)` | shadow-md |
|
||||
| `--shadow-4` | `0 1px 2px rgba(15,15,15,0.04), 0 8px 24px rgba(15,15,15,0.04)` | lp-paper-shadow |
|
||||
| `--shadow-5` | `0 1px 2px rgba(15,15,15,0.06), 0 12px 36px rgba(15,15,15,0.08)` | lp-paper-shadow-active |
|
||||
| `--shadow-6` | `0 10px 15px rgba(15,15,15,0.1), 0 4px 6px rgba(15,15,15,0.05)` | shadow-xl |
|
||||
|
||||
### 3.5 字体家族(替代 8 处硬编码)
|
||||
|
||||
| Token | 值 | 用途 |
|
||||
|---|---|---|
|
||||
| `--font-family-sans` | `'Inter', system-ui, sans-serif` | UI 元素 |
|
||||
| `--font-family-serif` | `'Fraunces', Georgia, serif` | 主文/备课纸感 |
|
||||
| `--font-family-mono` | `'JetBrains Mono', monospace` | 角色标签 |
|
||||
|
||||
---
|
||||
|
||||
## 4. Semantic 层(shadcn 扩展 + lp-* 升级 + M3 清理)
|
||||
|
||||
### 4.1 shadcn 标准令牌(零改动,确保 ui/ 兼容)
|
||||
|
||||
`--background`/`--foreground`/`--card`/`--popover`/`--primary`/`--secondary`/`--muted`/`--accent`/`--destructive`/`--border`/`--input`/`--ring`/`--radius` 全部保留现有 HSL 值,`:root` 与 `.dark` 双份定义不变。
|
||||
|
||||
### 4.2 语义层扩展(新增层级)
|
||||
|
||||
| Token | 明色 HSL | 暗色 HSL | 用途 |
|
||||
|---|---|---|---|
|
||||
| `--background-elevated` | `0 0% 100%` | `240 6% 10%` | 浮层/card-on-card |
|
||||
| `--background-sunken` | `240 4.8% 95.9%` | `240 6% 8%` | 凹陷区/inset |
|
||||
| `--text-primary` | `= --foreground` | `= --foreground` | 主文 |
|
||||
| `--text-secondary` | `240 3.8% 46.1%` | `240 5% 64.9%` | 副文(= muted-foreground) |
|
||||
| `--text-tertiary` | `240 4% 65%` | `240 5% 50%` | 占位/元信息 |
|
||||
| `--border-strong` | `240 5.9% 70%` | `240 5% 40%` | 强调边框 |
|
||||
| `--border-subtle` | `240 5.9% 95%` | `240 5% 18%` | 弱边框 |
|
||||
|
||||
**说明**: `--text-secondary` 等是 `--muted-foreground` 的语义别名,业务代码优先用语义名。
|
||||
|
||||
### 4.3 chart-1~5 / sidebar 令牌(零改动)
|
||||
|
||||
保留现有 HSL 值,`:root` 与 `.dark` 双份定义不变。
|
||||
|
||||
### 4.4 --lp-* 令牌升级(HEX→HSL + 暗色 + Tailwind 暴露)
|
||||
|
||||
**明色(`:root`)**:
|
||||
|
||||
| Token | 旧 HEX | 新 HSL | 说明 |
|
||||
|---|---|---|---|
|
||||
| `--lp-paper` | `#fefefe` | `0 0% 99.6%` | 纸面 |
|
||||
| `--lp-paper-edge` | `#f8f8f7` | `60 9% 97%` | 纸边(对齐 stone-100) |
|
||||
| `--lp-paper-shadow` | (box-shadow) | `= --shadow-4` 引用 | 阴影令牌化 |
|
||||
| `--lp-paper-shadow-active` | (box-shadow) | `= --shadow-5` 引用 | 阴影令牌化 |
|
||||
| `--lp-anchor-range` | `rgba(28,25,23,0.08)` | `20 14.3% 4.1% / 0.08` | 引用 stone-950 |
|
||||
| `--lp-anchor-range-active` | `rgba(28,25,23,0.16)` | `20 14.3% 4.1% / 0.16` | |
|
||||
| `--lp-anchor-point` | `#1c1917` | `20 14.3% 4.1%` | = stone-950 |
|
||||
| `--lp-inline-node-border` | `#d6d3d1` | `30 5.7% 82.9%` | = stone-300 |
|
||||
| `--lp-inline-node-text` | `#44403c` | `24 5.4% 26.9%` | ≈ stone-700 微调 |
|
||||
| `--lp-inline-node-meta` | `#a8a29e` | `30 5% 64.9%` | = stone-400 |
|
||||
| `--lp-interaction` | `#6366f1` | `238.6 84.5% 59.8%` | = indigo-600 |
|
||||
|
||||
**12 个 --lp-dot-* 节点色点**: 全部映射到 zinc/stone 色板对应值,统一为 HSL。
|
||||
|
||||
**暗色(`.dark`)**: 全部补齐。例如:
|
||||
|
||||
- `--lp-paper: 240 6% 10%`(暗背景纸面)
|
||||
- `--lp-paper-edge: 240 6% 8%`
|
||||
- `--lp-anchor-point: 0 0% 98%`(反相)
|
||||
- `--lp-interaction: 238.7 83.5% 66.7%`(= indigo-500,暗色微亮)
|
||||
|
||||
### 4.5 M3 Surface 映射令牌清理
|
||||
|
||||
[`globals.css:185-198`](file:///e:/Desktop/CICD/src/app/globals.css#L185-L198) 中的 M3 令牌零使用(已扫描确认),全部删除:
|
||||
|
||||
- `--color-surface` / `--color-on-surface` / `--color-on-surface-variant`
|
||||
- `--color-surface-container-lowest/low/high/highest`
|
||||
- `--color-outline-variant` / `--color-outline`
|
||||
- `--color-error` / `--color-tertiary` / `--color-tertiary-container`
|
||||
|
||||
### 4.6 globals.css 硬编码样式令牌化
|
||||
|
||||
| 硬编码 | 令牌化后 |
|
||||
|---|---|
|
||||
| `.lp-tb-btn { width: 28px; ... font-family: 'Inter'; font-size: 13px; color: var(--foreground) }` | `width: var(--space-7); font-family: var(--font-family-sans); font-size: var(--font-size-2); color: hsl(var(--foreground))` |
|
||||
| `.lp-inline-node { font-family: 'Inter'; font-size: 13.5px; ... border-left: 2px solid var(--lp-inline-node-border); color: var(--lp-inline-node-text) }` | 全部用令牌引用 |
|
||||
| `.lp-qa-role.teacher { color: #1c1917 }` | `color: hsl(var(--lp-anchor-point))` |
|
||||
| `.lp-qa-role.student { color: #6b7280 }` | `color: hsl(var(--color-zinc-500))` |
|
||||
| `.lp-inline-node-title { color: #1a1a1a }` | `color: hsl(var(--lp-anchor-point))` |
|
||||
| `.lp-inline-node-body { color: #404040 }` | `color: hsl(var(--lp-inline-node-text))` |
|
||||
| `.lp-qa-content { color: #404040 }` | 同上 |
|
||||
| `.lp-qa-prompt { color: #525252 }` | `color: hsl(var(--color-zinc-600))` |
|
||||
| `.range-anchor { background-color: var(--node-color, #1976d2) }` | fallback 改为 `hsl(var(--lp-interaction))` |
|
||||
|
||||
---
|
||||
|
||||
## 5. 尺度令牌补齐清单
|
||||
|
||||
### 5.1 圆角阶梯
|
||||
|
||||
| Token | 值 | Tailwind 类 |
|
||||
|---|---|---|
|
||||
| `--radius-sm` | `calc(var(--radius) - 4px)` | `rounded-sm` |
|
||||
| `--radius-md` | `calc(var(--radius) - 2px)` | `rounded-md` |
|
||||
| `--radius-lg` | `var(--radius)` = `0.5rem` | `rounded-lg` |
|
||||
| `--radius-xl` | `calc(var(--radius) + 4px)` | `rounded-xl` |
|
||||
| `--radius-2xl` | `calc(var(--radius) + 8px)` | `rounded-2xl` |
|
||||
| `--radius-full` | `9999px` | `rounded-full` |
|
||||
|
||||
### 5.2 行高与字重
|
||||
|
||||
| Token | 值 |
|
||||
|---|---|
|
||||
| `--leading-tight` | 1.2 |
|
||||
| `--leading-snug` | 1.35 |
|
||||
| `--leading-normal` | 1.5 |
|
||||
| `--leading-relaxed` | 1.65 |
|
||||
| `--leading-loose` | 1.8 |
|
||||
| `--weight-regular` | 400 |
|
||||
| `--weight-medium` | 500 |
|
||||
| `--weight-semibold` | 600 |
|
||||
| `--weight-bold` | 700 |
|
||||
|
||||
### 5.3 动效与 z-index
|
||||
|
||||
| Token | 值 | Tailwind 类 |
|
||||
|---|---|---|
|
||||
| `--duration-fast` | 150ms | `duration-fast` |
|
||||
| `--duration-normal` | 200ms | `duration-normal` |
|
||||
| `--duration-slow` | 300ms | `duration-slow` |
|
||||
| `--ease-in` | `cubic-bezier(0.4, 0, 1, 1)` | `ease-in` |
|
||||
| `--ease-out` | `cubic-bezier(0, 0, 0.2, 1)` | `ease-out` |
|
||||
| `--ease-in-out` | `cubic-bezier(0.4, 0, 0.2, 1)` | `ease-in-out` |
|
||||
| `--z-dropdown` | 1000 | `z-dropdown` |
|
||||
| `--z-sticky` | 1100 | `z-sticky` |
|
||||
| `--z-modal` | 1300 | `z-modal` |
|
||||
| `--z-popover` | 1400 | `z-popover` |
|
||||
| `--z-toast` | 1500 | `z-toast` |
|
||||
|
||||
---
|
||||
|
||||
## 6. 硬编码清理映射表(分级)
|
||||
|
||||
### 6.1 91 处 #hex 颜色 — 按文件分类
|
||||
|
||||
| 文件 | 处数 | 清理策略 |
|
||||
|---|---|---|
|
||||
| `app/globals.css` | 32 | 全部令牌化(见 4.6 节) |
|
||||
| `app/manifest.ts` | 2 | 保留(theme_color 元数据,需字面量),加注释 `// arbitrary-value: PWA manifest requires literal hex` |
|
||||
| `app/layout.tsx` | 2 | 检查后令牌化或注释 |
|
||||
| `shared/components/ui/chart.tsx` | 5 | 令牌化(用 `--chart-1~5`) |
|
||||
| `modules/notifications/channels/email-channel.ts` | 7 | 保留(邮件 HTML 内联样式需字面量),加注释 |
|
||||
| `modules/textbooks/components/knowledge-graph.tsx` | 10 | 令牌化(用 `--chart-*` 或新增 `--graph-node-*`) |
|
||||
| `modules/textbooks/components/force-graph.tsx` | 17 | 同上 |
|
||||
| `modules/lesson-preparation/components/paper-editor/*` | ~6 | 令牌化(用 `--lp-*`) |
|
||||
| `modules/attendance/components/attendance-grade-correlation-card.tsx` | 3 | 令牌化(用 `--chart-*`) |
|
||||
| `modules/classes/components/my-classes-grid.tsx` | 2 | 令牌化 |
|
||||
| `modules/lesson-preparation/components/version-diff-view.tsx` | 1 | 令牌化(用 `--lp-*` 或新增 `--diff-add`/`--diff-remove`) |
|
||||
|
||||
**新增语义令牌**:
|
||||
|
||||
- `--diff-add` / `--diff-add-bg`(版本对比新增)
|
||||
- `--diff-remove` / `--diff-remove-bg`(版本对比删除)
|
||||
- `--graph-node-1` ~ `--graph-node-6`(知识图谱节点色)
|
||||
|
||||
### 6.2 8 处硬编码 font-family — 全部令牌化
|
||||
|
||||
| 文件 | 处数 | 令牌化 |
|
||||
|---|---|---|
|
||||
| `app/globals.css` | 7 | `var(--font-family-sans)` / `var(--font-family-serif)` / `var(--font-family-mono)` |
|
||||
| `modules/lesson-preparation/components/paper-editor/textbook-tiptap-editor.tsx` | 1 | 同上 |
|
||||
|
||||
### 6.3 15 处硬编码 font-size — 全部令牌化
|
||||
|
||||
| 文件 | 处数 | 令牌化 |
|
||||
|---|---|---|
|
||||
| `app/globals.css` | 13 | `var(--font-size-N)` |
|
||||
| `modules/notifications/channels/email-channel.ts` | 1 | 保留(邮件 HTML),加注释 |
|
||||
| `modules/lesson-preparation/components/paper-editor/textbook-tiptap-editor.tsx` | 1 | `var(--font-size-N)` |
|
||||
|
||||
### 6.4 98 处 Tailwind 任意值 — 分级清理
|
||||
|
||||
**Tier 1 — 直接映射 Tailwind 默认阶梯**(约 40 处):
|
||||
|
||||
- `w-[28px]` → `w-7`(28px ≈ 1.75rem,但 w-7 = 1.75rem ✓)
|
||||
- `gap-[6px]` → `gap-1.5`(6px = 0.375rem,gap-1.5 ✓)
|
||||
- `p-[10px]` → 需新增 `--space-2_5`(10px = 0.625rem)
|
||||
- `text-[13px]` → `text-size-2`(13px = `--font-size-2`)
|
||||
|
||||
**Tier 2 — 新增间距令牌后引用**(约 30 处):
|
||||
|
||||
- `w-[72px]` → `w-space-18`(`--space-18` = 4.5rem)
|
||||
- `h-[16px]` → `h-space-4`(`--space-4` = 1rem)
|
||||
- `p-[20px]` → `p-space-5`(`--space-5` = 1.25rem)
|
||||
|
||||
**Tier 3 — 注释豁免**(约 28 处):
|
||||
|
||||
- 图表 SVG 尺寸(如 `w-[400px]` 在 chart 容器)→ 加注释 `// arbitrary-value: chart canvas fixed size`
|
||||
- 邮件 HTML 内联样式
|
||||
- loading.tsx 占位骨架特殊尺寸
|
||||
|
||||
**ESLint 规则**: `tailwindcss/no-arbitrary-value: error`,允许通过 `// eslint-disable-next-line` + 注释理由豁免。
|
||||
|
||||
---
|
||||
|
||||
## 7. 强制约束机制
|
||||
|
||||
### 7.1 ESLint 规则新增
|
||||
|
||||
**新增依赖**: `eslint-plugin-tailwindcss`(需验证与 Tailwind v4 兼容性,若不兼容则自定义规则)
|
||||
|
||||
**`eslint.config.mjs` 新增规则**:
|
||||
|
||||
```javascript
|
||||
{
|
||||
// 禁止 Tailwind 任意值(允许 eslint-disable + 注释豁免)
|
||||
'tailwindcss/no-arbitrary-value': 'error',
|
||||
// 禁止 TSX/CSS 中硬编码颜色字面量(白名单:globals.css primitive 定义、email-channel.ts、manifest.ts)
|
||||
'no-restricted-syntax': [
|
||||
'error',
|
||||
{
|
||||
selector: "Literal[value=/#[0-9a-fA-F]{3,8}/]",
|
||||
message: '禁止硬编码 hex 颜色,使用设计令牌 var(--*) 或 Tailwind 类 bg-*',
|
||||
},
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
**自定义规则文件**: `eslint-rules/no-hardcoded-design-tokens.js`
|
||||
|
||||
- 检测 TSX/TS 中 `'Inter'`/`'Fraunces'`/`'JetBrains Mono'` 字面量
|
||||
- 检测 `font-family:` CSS 属性中的字面量字体名
|
||||
- 检测 `font-size: Npx` 硬编码
|
||||
- 白名单:`globals.css`、`styles/tokens/*.css`、`email-channel.ts`、`manifest.ts`
|
||||
|
||||
### 7.2 项目规则更新
|
||||
|
||||
[`.trae/rules/project_rules.md`](file:///e:/Desktop/CICD/.trae/rules/project_rules.md) "Tailwind 规范"章节新增:
|
||||
|
||||
```markdown
|
||||
### 设计令牌规范(强制)
|
||||
|
||||
- **禁止硬编码颜色**: TSX/TS/CSS 中不得出现 `#hex` 颜色字面量,统一使用 `var(--*)` 或 Tailwind 类
|
||||
- **禁止硬编码字体**: 不得出现 `'Inter'`/`'Fraunces'`/`'JetBrains Mono'` 字面量,使用 `var(--font-family-sans/serif/mono)`
|
||||
- **禁止硬编码字号**: 不得出现 `font-size: Npx`,使用 `var(--font-size-1~9)`
|
||||
- **禁止 Tailwind 任意值**: 不得使用 `w-[Npx]`/`h-[Npx]`/`p-[Npx]` 等,映射到 `--space-*` 或 Tailwind 默认阶梯
|
||||
- **豁免场景**(需 `// arbitrary-value: <reason>` 注释):
|
||||
- PWA manifest(`app/manifest.ts`)
|
||||
- 邮件 HTML 内联样式(`email-channel.ts`)
|
||||
- 图表 SVG 固定画布尺寸
|
||||
- loading.tsx 占位骨架
|
||||
- **令牌文件分布**: `src/app/styles/tokens/`(primitive/semantic-light/semantic-dark/lesson-preparation/tailwind-theme)
|
||||
- **改令牌必同步图**: 修改令牌定义后,同步更新 `docs/architecture/004` 与 `005`
|
||||
```
|
||||
|
||||
### 7.3 Lint 命令不变
|
||||
|
||||
`npm run lint` 与 `npx tsc --noEmit` 保持零错误零警告。
|
||||
|
||||
---
|
||||
|
||||
## 8. 验证策略与架构图同步
|
||||
|
||||
### 8.1 验证清单
|
||||
|
||||
| 验证项 | 命令/方法 | 通过标准 |
|
||||
|---|---|---|
|
||||
| TypeScript | `npx tsc --noEmit` | 零错误 |
|
||||
| ESLint | `npm run lint` | 零错误零警告 |
|
||||
| 视觉回归 | `npm run test:visual` | 快照无差异(或更新后人工确认) |
|
||||
| 备课编辑器视觉 | 手动检查 `teacher/lesson-plans/[id]/edit` | 纸感、inline-node、师生对话样式无变化 |
|
||||
| 暗色模式 | 切换暗色主题 | 所有页面可读,无对比度问题 |
|
||||
| shadcn ui 组件 | 抽查 button/card/dialog/table | 零回归 |
|
||||
|
||||
### 8.2 架构图同步(项目规则强制)
|
||||
|
||||
修改完成后,同步更新:
|
||||
|
||||
| 文档 | 同步内容 |
|
||||
|---|---|
|
||||
| [`docs/architecture/004_architecture_impact_map.md`](file:///e:/Desktop/CICD/docs/architecture/004_architecture_impact_map.md) | 新增"设计令牌体系"章节(双层架构图、文件分布、令牌分类) |
|
||||
| [`docs/architecture/005_architecture_data.json`](file:///e:/Desktop/CICD/docs/architecture/005_architecture_data.json) | `modules.shared.exports` 新增 `design-tokens` 节点;`dependencyMatrix` 新增令牌依赖关系 |
|
||||
| [`docs/troubleshooting/known-issues.md`](file:///e:/Desktop/CICD/docs/troubleshooting/known-issues.md) | 新增"设计令牌"问题分类,记录 Tailwind v4 + ESLint 兼容性、HEX→HSL 转换、任意值豁免规则 |
|
||||
|
||||
### 8.3 大爆炸式执行顺序(单 PR 内)
|
||||
|
||||
1. 创建 `src/app/styles/tokens/` 6 个文件(primitive/semantic-light/semantic-dark/lesson-preparation/tailwind-theme/index)
|
||||
2. `globals.css` 改为 `@import` 引入,删除已迁移内容
|
||||
3. `globals.css` 中剩余硬编码样式令牌化(lp-tb-btn/lp-inline-node/lp-qa-*)
|
||||
4. 全局扫描替换:91 处 `#hex` → 令牌引用(按 6.1 表)
|
||||
5. 全局扫描替换:8 处 font-family + 15 处 font-size → 令牌引用(按 6.2/6.3 表)
|
||||
6. 全局扫描替换:98 处任意值 → Tier 1/2/3 分级处理(按 6.4 表)
|
||||
7. ESLint 规则新增 + 自定义规则文件
|
||||
8. 项目规则文档更新
|
||||
9. 架构图同步(004/005)
|
||||
10. `docs/troubleshooting/known-issues.md` 更新
|
||||
11. `npm run lint` + `npx tsc --noEmit` + `npm run test:visual` 验证
|
||||
12. 单次 commit:`refactor(design-tokens): 全量体系化重建设计令牌`
|
||||
|
||||
---
|
||||
|
||||
## 9. 风险与缓解
|
||||
|
||||
| 风险 | 缓解 |
|
||||
|---|---|
|
||||
| Tailwind v4 + `eslint-plugin-tailwindcss` 兼容性未知 | 实施时先验证,不兼容则自定义 AST 规则 |
|
||||
| 大爆炸式改动 50+ 文件,review 困难 | 严格按 8.3 执行顺序,每步独立验证;视觉回归测试保障 |
|
||||
| `--lp-*` HEX→HSL 转换可能有色差 | 视觉回归测试 + 手动备课编辑器检查 |
|
||||
| 98 处任意值分级清理可能遗漏 | Tier 3 注释豁免需明确理由,ESLint 规则锁定 |
|
||||
| 暗色补全可能对比度不足 | 暗色模式手动抽查所有页面 |
|
||||
| shadcn ui 组件理论零改动但需验证 | 抽查 button/card/dialog/table 4 个核心组件 |
|
||||
|
||||
---
|
||||
|
||||
## 10. 范围与非目标
|
||||
|
||||
### 10.1 范围内
|
||||
|
||||
- 令牌底座重建(Primitive + Semantic 双层)
|
||||
- 尺度令牌补齐(font/size/leading/weight/radius/shadow/spacing/duration/ease/z-index)
|
||||
- `--lp-*` 升级(HEX→HSL + 暗色 + Tailwind 暴露)
|
||||
- M3 Surface 死代码清理
|
||||
- 91 处硬编码颜色清理
|
||||
- 8 处硬编码 font-family 清理
|
||||
- 15 处硬编码 font-size 清理
|
||||
- 98 处 Tailwind 任意值清理
|
||||
- ESLint 强制约束规则
|
||||
- 项目规则文档更新
|
||||
- 架构图同步(004/005)
|
||||
- known-issues.md 更新
|
||||
|
||||
### 10.2 范围外(非目标)
|
||||
|
||||
- 第三主题(如 high-contrast、paper 纸感)预留 — YAGNI
|
||||
- `shared/components/ui/*` shadcn 组件内部样式改动 — 理论零改动,仅验证
|
||||
- `cn()` 工具函数修改 — 不变
|
||||
- `tailwind.config.ts` 内容扩展 — 保持极简
|
||||
- 设计令牌文档站(Storybook 等)— 不在本期范围
|
||||
- 多主题切换架构 — 仅明暗双色
|
||||
@@ -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<T>` | `Promise<ActionState<null>>` / `Promise<ActionState<{ planId: string }>>` | `Promise<ActionState>` |
|
||||
|
||||
涉及文件:`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<BlockType, BlockDataGuard>` 校验,失败回退原 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<string, unknown>` | `interface NodeContentUpdate { data: Record<string, unknown> }`(诚实反映 Zod `z.record(z.string(), z.unknown())` 校验后的 untrusted 输出,强制调用方走 `mergeBlockDataPatch`) | `interface NodeContentUpdate { data: Partial<BlockData> }`(伪装成可信类型,调用方直接 `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<string, unknown>`,2 处 `as Partial<BlockData>` 移除
|
||||
- `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 子元素上下文用 `{/* */}` 注释 | `<div>{/* arbitrary-value: ... */}\n<span .../>` | `//` 会渲染为文本 |
|
||||
| `return (` / `&& (` 等 JS 表达式上下文用 `//` 注释 | `return (\n // arbitrary-value: ...\n <div/>)` | `{/* */}` 在 `()` 内触发语法错误 |
|
||||
| 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<T>` 对齐) | 各路由自定义 `{ 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 解析失败) |
|
||||
| 描述通配符路径用 `...` 或 `<path>` | `统一 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<ExportType, Permission \| null>` | `Record<ExportType, Permissions \| null>`(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 */}`<br>`<div className="w-[680px]" />` | `<div className="w-[680px]" />`(无注释) |
|
||||
| JSX 子元素上下文用 `{/* */}` | `<div>{/* arbitrary-value: ... */}<span/></div>` | `//` 会渲染为文本 |
|
||||
| `return (` / `&& (` 等 JS 表达式用 `//` | `return (\n // arbitrary-value: ...\n <div/>)` | `{/* */}` 在 `()` 内触发语法错误 |
|
||||
| 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` 节点
|
||||
|
||||
70
eslint-rules/no-hardcoded-design-tokens.js
Normal file
70
eslint-rules/no-hardcoded-design-tokens.js
Normal file
@@ -0,0 +1,70 @@
|
||||
// eslint-rules/no-hardcoded-design-tokens.js
|
||||
// 检测 TSX/TS 中硬编码的字体家族字面量(Inter/Fraunces/JetBrains Mono)
|
||||
// 白名单:src/app/styles/tokens/primitive.css(令牌定义)、email-channel.ts、manifest.ts
|
||||
// 使用单词边界匹配,避免误判 Interval/Interactive/Interrupt 等含 Inter 子串的标识符
|
||||
|
||||
const FORBIDDEN_FONTS = ['Inter', 'Fraunces', 'JetBrains Mono'];
|
||||
const WHITELIST_FILES = [
|
||||
'eslint-rules/no-hardcoded-design-tokens.js',
|
||||
'src/app/styles/tokens/primitive.css',
|
||||
'src/modules/notifications/channels/email-channel.ts',
|
||||
'src/app/manifest.ts',
|
||||
];
|
||||
|
||||
// 为每个字体名构建单词边界正则:\bInter\b 匹配 "Inter" 但不匹配 "Interval"
|
||||
const FORBIDDEN_PATTERNS = FORBIDDEN_FONTS.map((font) => ({
|
||||
font,
|
||||
regex: new RegExp(`\\b${font}\\b`),
|
||||
}));
|
||||
|
||||
module.exports = {
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description: '禁止硬编码字体家族字面量,使用 var(--font-family-sans/serif/mono)',
|
||||
category: 'Best Practices',
|
||||
recommended: true,
|
||||
},
|
||||
messages: {
|
||||
forbiddenFont:
|
||||
'禁止硬编码字体家族 "{{font}}",使用 var(--font-family-sans/serif/mono) 或 var(--font-family-mono)',
|
||||
},
|
||||
schema: [],
|
||||
},
|
||||
|
||||
create(context) {
|
||||
const filename = context.getFilename().replace(/\\/g, '/');
|
||||
if (WHITELIST_FILES.some((f) => filename.endsWith(f))) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
Literal(node) {
|
||||
if (typeof node.value !== 'string') return;
|
||||
for (const { font, regex } of FORBIDDEN_PATTERNS) {
|
||||
if (regex.test(node.value)) {
|
||||
context.report({
|
||||
node,
|
||||
messageId: 'forbiddenFont',
|
||||
data: { font },
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
TemplateElement(node) {
|
||||
const raw = node.value.raw;
|
||||
for (const { font, regex } of FORBIDDEN_PATTERNS) {
|
||||
if (regex.test(raw)) {
|
||||
context.report({
|
||||
node,
|
||||
messageId: 'forbiddenFont',
|
||||
data: { font },
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -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:
|
||||
|
||||
@@ -4,15 +4,19 @@ export default function CurriculumMapLoading() {
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="space-y-2">
|
||||
{/* arbitrary-value: skeleton placeholder fixed size */}
|
||||
<Skeleton className="h-8 w-[200px]" />
|
||||
{/* arbitrary-value: skeleton placeholder fixed size */}
|
||||
<Skeleton className="h-4 w-[300px]" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
// arbitrary-value: skeleton placeholder fixed size
|
||||
<Skeleton key={i} className="h-[80px] w-full rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{/* arbitrary-value: skeleton placeholder fixed size */}
|
||||
<Skeleton className="h-[400px] w-full rounded-lg" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -82,6 +82,7 @@ export default async function AdminLessonPlansPage(): Promise<JSX.Element> {
|
||||
fallback={
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
// arbitrary-value: skeleton placeholder fixed size
|
||||
<Skeleton key={i} className="h-[180px] w-full" />
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -26,6 +26,7 @@ export default function GradePracticeError({
|
||||
</h1>
|
||||
<p className="text-muted-foreground">{t("errors.pageErrorGrade")}</p>
|
||||
</div>
|
||||
{/* arbitrary-value: error icon fixed size */}
|
||||
<EmptyState
|
||||
icon={BarChart3}
|
||||
title={t("errors.loadFailed")}
|
||||
|
||||
@@ -4,11 +4,13 @@ export default function ParentCoursePlanDetailLoading() {
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-6 p-8">
|
||||
<div className="flex items-center justify-between">
|
||||
{/* arbitrary-value: skeleton placeholder fixed size */}
|
||||
<Skeleton className="h-8 w-[240px]" />
|
||||
</div>
|
||||
<Skeleton className="h-4 w-full max-w-md" />
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
// arbitrary-value: skeleton placeholder fixed size
|
||||
<Skeleton key={i} className="h-[100px] w-full" />
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -4,11 +4,14 @@ export default function ParentCoursePlansLoading() {
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<div className="space-y-2">
|
||||
{/* arbitrary-value: skeleton placeholder fixed size */}
|
||||
<Skeleton className="h-8 w-[180px]" />
|
||||
{/* arbitrary-value: skeleton placeholder fixed size */}
|
||||
<Skeleton className="h-4 w-[300px]" />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
// arbitrary-value: skeleton placeholder fixed size
|
||||
<Skeleton key={i} className="h-[160px] w-full" />
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -34,6 +34,7 @@ export default async function ParentLessonPlansPage(): Promise<JSX.Element> {
|
||||
fallback={
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
// arbitrary-value: skeleton placeholder fixed size
|
||||
<Skeleton key={i} className="h-[180px] w-full" />
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -35,6 +35,7 @@ export default async function ParentPracticePage(): Promise<JSX.Element> {
|
||||
<h1 className="text-2xl font-bold tracking-tight">{t("parent.title")}</h1>
|
||||
<p className="text-muted-foreground">{t("parent.description")}</p>
|
||||
</div>
|
||||
{/* arbitrary-value: empty state fixed size */}
|
||||
<EmptyState
|
||||
icon={Users}
|
||||
title={t("parent.noChild")}
|
||||
|
||||
@@ -4,11 +4,13 @@ export default function StudentCoursePlanDetailLoading() {
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-6 p-8">
|
||||
<div className="flex items-center justify-between">
|
||||
{/* arbitrary-value: skeleton placeholder fixed size */}
|
||||
<Skeleton className="h-8 w-[240px]" />
|
||||
</div>
|
||||
<Skeleton className="h-4 w-full max-w-md" />
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
// arbitrary-value: skeleton placeholder fixed size
|
||||
<Skeleton key={i} className="h-[100px] w-full" />
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -4,11 +4,14 @@ export default function StudentCoursePlansLoading() {
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<div className="space-y-2">
|
||||
{/* arbitrary-value: skeleton placeholder fixed size */}
|
||||
<Skeleton className="h-8 w-[180px]" />
|
||||
{/* arbitrary-value: skeleton placeholder fixed size */}
|
||||
<Skeleton className="h-4 w-[300px]" />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
// arbitrary-value: skeleton placeholder fixed size
|
||||
<Skeleton key={i} className="h-[160px] w-full" />
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -34,6 +34,7 @@ export default async function StudentLessonPlansPage(): Promise<JSX.Element> {
|
||||
fallback={
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
// arbitrary-value: skeleton placeholder fixed size
|
||||
<Skeleton key={i} className="h-[180px] w-full" />
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -38,6 +38,7 @@ async function ScheduleResults({ searchParams }: { searchParams: Promise<SearchP
|
||||
|
||||
if (schedule.length === 0) {
|
||||
return (
|
||||
// arbitrary-value: empty state fixed size
|
||||
<EmptyState
|
||||
icon={Calendar}
|
||||
title={hasFilters ? t("schedule.empty.noMatch") : t("schedule.empty.title")}
|
||||
|
||||
@@ -57,6 +57,7 @@ async function StudentsResults({ searchParams, defaultClassId }: { searchParams:
|
||||
|
||||
if (filteredStudents.length === 0) {
|
||||
return (
|
||||
// arbitrary-value: empty state fixed size
|
||||
<EmptyState
|
||||
icon={User}
|
||||
title={hasFilters ? t("students.empty.noMatch") : t("students.empty.title")}
|
||||
|
||||
@@ -5,24 +5,31 @@ export default function CalendarLoading() {
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-2">
|
||||
{/* arbitrary-value: skeleton placeholder fixed size */}
|
||||
<Skeleton className="h-8 w-[180px]" />
|
||||
{/* arbitrary-value: skeleton placeholder fixed size */}
|
||||
<Skeleton className="h-4 w-[260px]" />
|
||||
</div>
|
||||
{/* arbitrary-value: skeleton placeholder fixed size */}
|
||||
<Skeleton className="h-9 w-[120px]" />
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-9 w-9" />
|
||||
<Skeleton className="h-9 w-9" />
|
||||
{/* arbitrary-value: skeleton placeholder fixed size */}
|
||||
<Skeleton className="h-9 w-[80px]" />
|
||||
{/* arbitrary-value: skeleton placeholder fixed size */}
|
||||
<Skeleton className="h-4 w-[200px] ml-2" />
|
||||
</div>
|
||||
{/* arbitrary-value: skeleton placeholder fixed size */}
|
||||
<Skeleton className="h-9 w-[160px]" />
|
||||
</div>
|
||||
<div className="grid grid-cols-7 gap-2">
|
||||
{Array.from({ length: 7 }).map((_, i) => (
|
||||
<div key={i} className="flex flex-col">
|
||||
<Skeleton className="h-6 w-full" />
|
||||
{/* arbitrary-value: skeleton placeholder fixed size */}
|
||||
<div className="flex-1 min-h-[200px] space-y-1 pt-1">
|
||||
<Skeleton className="h-8 w-full" />
|
||||
<Skeleton className="h-8 w-full" />
|
||||
|
||||
@@ -4,16 +4,20 @@ export default function HeatmapLoading() {
|
||||
return (
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="space-y-2">
|
||||
{/* arbitrary-value: skeleton placeholder fixed size */}
|
||||
<Skeleton className="h-8 w-[200px]" />
|
||||
{/* arbitrary-value: skeleton placeholder fixed size */}
|
||||
<Skeleton className="h-4 w-[320px]" />
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
// arbitrary-value: skeleton placeholder fixed size
|
||||
<Skeleton key={i} className="h-[80px] w-full" />
|
||||
))}
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
// arbitrary-value: skeleton placeholder fixed size
|
||||
<Skeleton key={i} className="h-[200px] w-full" />
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -4,11 +4,14 @@ export default function LibraryLoading() {
|
||||
return (
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="space-y-2">
|
||||
{/* arbitrary-value: skeleton placeholder fixed size */}
|
||||
<Skeleton className="h-8 w-[200px]" />
|
||||
{/* arbitrary-value: skeleton placeholder fixed size */}
|
||||
<Skeleton className="h-4 w-[320px]" />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
// arbitrary-value: skeleton placeholder fixed size
|
||||
<Skeleton key={i} className="h-[160px] w-full" />
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -77,6 +77,7 @@ export default async function LibraryPage(): Promise<JSX.Element> {
|
||||
</span>
|
||||
)}
|
||||
{item.textbookTitle && (
|
||||
// arbitrary-value: truncate max width fixed size
|
||||
<span className="px-1.5 py-0.5 rounded bg-surface-container-highest truncate max-w-[120px]">
|
||||
{item.textbookTitle}
|
||||
</span>
|
||||
|
||||
@@ -34,6 +34,7 @@ export default async function NewLessonPlanPage(): Promise<JSX.Element> {
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
// arbitrary-value: skeleton placeholder fixed size
|
||||
<Skeleton key={i} className="h-[100px] w-full" />
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -45,12 +45,16 @@ export default async function LessonPlansPage(): Promise<JSX.Element> {
|
||||
fallback={
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-2 flex-wrap items-center">
|
||||
{/* arbitrary-value: skeleton placeholder fixed size */}
|
||||
<Skeleton className="h-9 w-[240px]" />
|
||||
{/* arbitrary-value: skeleton placeholder fixed size */}
|
||||
<Skeleton className="h-9 w-[160px]" />
|
||||
{/* arbitrary-value: skeleton placeholder fixed size */}
|
||||
<Skeleton className="h-9 w-[160px]" />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
// arbitrary-value: skeleton placeholder fixed size
|
||||
<Skeleton key={i} className="h-[180px] w-full" />
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -26,6 +26,7 @@ export default function TeacherPracticeError({
|
||||
</h1>
|
||||
<p className="text-muted-foreground">{t("errors.pageErrorPractice")}</p>
|
||||
</div>
|
||||
{/* arbitrary-value: error icon fixed size */}
|
||||
<EmptyState
|
||||
icon={BarChart3}
|
||||
title={t("errors.loadFailed")}
|
||||
|
||||
@@ -59,6 +59,7 @@ async function TeacherPracticeContent({
|
||||
<h1 className="text-2xl font-bold tracking-tight">{t("teacher.title")}</h1>
|
||||
<p className="text-muted-foreground">{t("teacher.description")}</p>
|
||||
</div>
|
||||
{/* arbitrary-value: empty state fixed size */}
|
||||
<EmptyState
|
||||
icon={BarChart3}
|
||||
title={t("teacher.noClass")}
|
||||
@@ -86,6 +87,7 @@ async function TeacherPracticeContent({
|
||||
<h1 className="text-2xl font-bold tracking-tight">{t("teacher.title")}</h1>
|
||||
<p className="text-muted-foreground">{t("teacher.description")}</p>
|
||||
</div>
|
||||
{/* arbitrary-value: empty state fixed size */}
|
||||
<EmptyState
|
||||
icon={BarChart3}
|
||||
title={t("teacher.noStudent")}
|
||||
@@ -150,6 +152,7 @@ async function TeacherPracticeContent({
|
||||
<h1 className="text-2xl font-bold tracking-tight">{t("teacher.title")}</h1>
|
||||
<p className="text-muted-foreground">{t("teacher.description")}</p>
|
||||
</div>
|
||||
{/* arbitrary-value: empty state fixed size */}
|
||||
<EmptyState
|
||||
icon={BarChart3}
|
||||
title={t("teacher.noData")}
|
||||
@@ -257,9 +260,11 @@ export default async function TeacherPracticePage({
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-5">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
// arbitrary-value: skeleton placeholder fixed size
|
||||
<Skeleton key={i} className="h-[100px]" />
|
||||
))}
|
||||
</div>
|
||||
{/* arbitrary-value: skeleton placeholder fixed size */}
|
||||
<Skeleton className="h-[300px] w-full" />
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
>
|
||||
<NextIntlClientProvider locale={locale} messages={messages}>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="system"
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
<AuthSessionProvider session={session}>
|
||||
<NuqsAdapter>{children}</NuqsAdapter>
|
||||
</AuthSessionProvider>
|
||||
<Toaster />
|
||||
</ThemeProvider>
|
||||
</NextIntlClientProvider>
|
||||
<Providers locale={locale} messages={messages} session={session}>
|
||||
{children}
|
||||
</Providers>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -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: [
|
||||
|
||||
9
src/app/styles/tokens/index.css
Normal file
9
src/app/styles/tokens/index.css
Normal file
@@ -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";
|
||||
81
src/app/styles/tokens/lesson-preparation.css
Normal file
81
src/app/styles/tokens/lesson-preparation.css
Normal file
@@ -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%;
|
||||
}
|
||||
110
src/app/styles/tokens/primitive.css
Normal file
110
src/app/styles/tokens/primitive.css
Normal file
@@ -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;
|
||||
}
|
||||
65
src/app/styles/tokens/semantic-dark.css
Normal file
65
src/app/styles/tokens/semantic-dark.css
Normal file
@@ -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%;
|
||||
}
|
||||
68
src/app/styles/tokens/semantic-light.css
Normal file
68
src/app/styles/tokens/semantic-light.css
Normal file
@@ -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%;
|
||||
}
|
||||
148
src/app/styles/tokens/tailwind-theme.css
Normal file
148
src/app/styles/tokens/tailwind-theme.css
Normal file
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -245,6 +245,7 @@ function renderLineChart(spec: AiChartSpec, series: AiChartSeries[]): React.Reac
|
||||
axisLine={false}
|
||||
width={36}
|
||||
/>
|
||||
{/* arbitrary-value: chart canvas fixed size */}
|
||||
<ChartTooltip
|
||||
cursor={{
|
||||
stroke: "hsl(var(--muted-foreground))",
|
||||
|
||||
@@ -39,9 +39,9 @@ import type {
|
||||
|
||||
/** 风险等级对应的颜色(散点图着色,与 attendance-sheet 调色板对齐)。 */
|
||||
const RISK_COLORS: Record<AttendanceGradeRiskLevel, string> = {
|
||||
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({
|
||||
<h4 className="mb-3 text-sm font-medium">
|
||||
{t("correlation.scatterTitle")}
|
||||
</h4>
|
||||
{/* arbitrary-value: chart canvas fixed size */}
|
||||
<ChartContainer
|
||||
config={chartConfig}
|
||||
className="h-[320px] w-full"
|
||||
|
||||
@@ -94,6 +94,7 @@ export function AttendanceTrendChart({
|
||||
className="border-none shadow-none"
|
||||
/>
|
||||
) : (
|
||||
// arbitrary-value: chart canvas fixed size
|
||||
<TrendLineChart
|
||||
data={chartData}
|
||||
series={series}
|
||||
|
||||
@@ -117,6 +117,7 @@ export function ClassComparisonCard({
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{/* arbitrary-value: chart canvas fixed size */}
|
||||
<SimpleBarChart
|
||||
data={chartData}
|
||||
bars={bars}
|
||||
|
||||
@@ -313,6 +313,7 @@ export function ClassTrendsWidget({ assignments, compact, className }: ClassTren
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{chartData.length > 0 ? (
|
||||
// arbitrary-value: chart canvas fixed size
|
||||
<ChartContainer config={chartConfig} className="h-[250px] w-full">
|
||||
{chartTab === "submission" ? (
|
||||
<LineChart accessibilityLayer data={chartData} margin={{ top: 20, right: 20, bottom: 0, left: 0 }}>
|
||||
@@ -385,6 +386,7 @@ export function ClassTrendsWidget({ assignments, compact, className }: ClassTren
|
||||
)}
|
||||
</ChartContainer>
|
||||
) : (
|
||||
// arbitrary-value: chart canvas fixed size
|
||||
<div className="flex h-[250px] items-center justify-center text-sm text-muted-foreground">
|
||||
{t("detail.trends.noDataForSubject")}
|
||||
</div>
|
||||
|
||||
@@ -107,7 +107,7 @@ export function MyClassesGrid({
|
||||
<DialogContent className="sm:max-w-[480px] p-0 overflow-hidden gap-0 border-none shadow-2xl">
|
||||
{/* Header with Pattern */}
|
||||
<div className="relative bg-primary/5 p-6 border-b border-border/50">
|
||||
<div className="absolute inset-0 opacity-[0.03]" style={{ backgroundImage: 'radial-gradient(#000 1px, transparent 1px)', backgroundSize: '12px 12px' }}></div>
|
||||
<div className="absolute inset-0 opacity-[0.03]" style={{ backgroundImage: 'radial-gradient(hsl(var(--color-zinc-950)) 1px, transparent 1px)', backgroundSize: '12px 12px' }}></div>
|
||||
<DialogHeader className="relative z-10">
|
||||
<DialogTitle className="text-xl font-bold flex items-center gap-2">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary text-primary-foreground shadow-sm">
|
||||
@@ -275,7 +275,7 @@ function ClassTicket({
|
||||
<div className="group relative flex w-full overflow-hidden rounded-xl border bg-card shadow-sm transition-all hover:shadow-md">
|
||||
{/* Realistic Paper Texture & Noise */}
|
||||
<div className="absolute inset-0 pointer-events-none opacity-[0.02]" style={{ backgroundImage: 'url("data:image/svg+xml,%3Csvg viewBox=\'0 0 200 200\' xmlns=\'http://www.w3.org/2000/svg\'%3E%3Cfilter id=\'noiseFilter\'%3E%3CfeTurbulence type=\'fractalNoise\' baseFrequency=\'0.65\' numOctaves=\'3\' stitchTiles=\'stitch\'/%3E%3C/filter%3E%3Crect width=\'100%25\' height=\'100%25\' filter=\'url(%23noiseFilter)\'/%3E%3C/svg%3E")' }}></div>
|
||||
<div className="absolute inset-0 pointer-events-none opacity-[0.03]" style={{ backgroundImage: 'radial-gradient(#000 1px, transparent 1px)', backgroundSize: '16px 16px' }}></div>
|
||||
<div className="absolute inset-0 pointer-events-none opacity-[0.03]" style={{ backgroundImage: 'radial-gradient(hsl(var(--color-zinc-950)) 1px, transparent 1px)', backgroundSize: '16px 16px' }}></div>
|
||||
|
||||
{/* Decorative Barcode Strip */}
|
||||
<div className="absolute left-0 top-0 bottom-0 w-1.5 bg-primary/10 flex flex-col justify-between py-2 pointer-events-none">
|
||||
@@ -329,8 +329,8 @@ function ClassTicket({
|
||||
{/* Invitation Code Section */}
|
||||
<div className="mt-6 pt-4 border-t border-dashed border-border relative">
|
||||
{/* Tiny Cut marks */}
|
||||
<div className="absolute -left-5 top-[-1px] w-2 h-[2px] bg-border"></div>
|
||||
<div className="absolute -right-5 top-[-1px] w-2 h-[2px] bg-border"></div>
|
||||
<div className="absolute -left-5 top-[-1px] w-2 h-0.5 bg-border"></div>
|
||||
<div className="absolute -right-5 top-[-1px] w-2 h-0.5 bg-border"></div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
|
||||
@@ -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",
|
||||
)}
|
||||
|
||||
@@ -147,7 +147,7 @@ export function TemplatePickerDialog({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="h-[320px] rounded-md border">
|
||||
<ScrollArea className="h-80 rounded-md border">
|
||||
{loading ? (
|
||||
<div
|
||||
className="flex h-full items-center justify-center gap-2 p-8 text-sm text-muted-foreground"
|
||||
|
||||
@@ -296,7 +296,7 @@ export function ElectiveCourseForm({
|
||||
id="description"
|
||||
name="description"
|
||||
placeholder={t("form.descriptionPlaceholder")}
|
||||
className="min-h-[80px]"
|
||||
className="min-h-20"
|
||||
defaultValue={course?.description ?? ""}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -73,6 +73,7 @@ export function ChapterWeaknessChart({ data, className }: ChapterWeaknessChartPr
|
||||
<CardTitle className="text-base">{t("chapterChart.title")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* arbitrary-value: chart canvas fixed size */}
|
||||
<ChartContainer
|
||||
config={chartConfig}
|
||||
className="h-[300px] w-full"
|
||||
|
||||
@@ -81,6 +81,7 @@ export function ClassErrorBarChart({ data, className }: ClassErrorBarChartProps)
|
||||
<CardTitle className="text-base">{t("classErrorBar.title")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{/* arbitrary-value: chart canvas fixed size */}
|
||||
<ChartContainer
|
||||
config={chartConfig}
|
||||
className="h-[280px] w-full"
|
||||
|
||||
@@ -245,7 +245,7 @@ export function ErrorBookDetailDialog({
|
||||
value={note}
|
||||
onChange={(e) => 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}
|
||||
/>
|
||||
<div className="mt-2">
|
||||
|
||||
@@ -102,7 +102,7 @@ export function KnowledgePointWeaknessChart({
|
||||
<ChartTooltip
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
className="w-[240px]"
|
||||
className="w-60"
|
||||
formatter={(payload: unknown) => {
|
||||
if (!isKpChartPayload(payload)) return null
|
||||
return (
|
||||
|
||||
@@ -78,6 +78,7 @@ export function SubjectDistributionChart({
|
||||
<CardTitle className="text-base">{t("subjectDistChart.title")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{/* arbitrary-value: chart canvas fixed size */}
|
||||
<ChartContainer
|
||||
config={chartConfig}
|
||||
className="h-[280px] w-full"
|
||||
|
||||
@@ -111,7 +111,7 @@ export function ExamPreviewDialog({
|
||||
)}
|
||||
>
|
||||
<div className="flex gap-2">
|
||||
<span className="font-semibold text-foreground min-w-[28px]">{questionNumber}.</span>
|
||||
<span className="font-semibold text-foreground min-w-7">{questionNumber}.</span>
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="text-foreground/90 leading-relaxed whitespace-pre-wrap">
|
||||
{content.text || t("exam.previewDialog.untitledQuestion")}
|
||||
@@ -121,7 +121,7 @@ export function ExamPreviewDialog({
|
||||
<div className="space-y-1.5">
|
||||
{content.options.map((opt) => (
|
||||
<div key={`${node.id}-${opt.id}`} className="text-sm text-foreground/80 flex gap-2">
|
||||
<span className="min-w-[28px]">{opt.id}.</span>
|
||||
<span className="min-w-7">{opt.id}.</span>
|
||||
<span className="whitespace-pre-wrap">{opt.text}</span>
|
||||
</div>
|
||||
))}
|
||||
@@ -131,7 +131,7 @@ export function ExamPreviewDialog({
|
||||
<div className="space-y-1.5 rounded-md bg-muted/40 p-2">
|
||||
{content.subQuestions.map((item, index) => (
|
||||
<div key={`${node.id}-sub-${index}`} className="text-sm text-foreground/80 flex gap-2">
|
||||
<span className="min-w-[28px]">{item.id}.</span>
|
||||
<span className="min-w-7">{item.id}.</span>
|
||||
<span className="whitespace-pre-wrap">{item.text || t("exam.previewDialog.untitledSubQuestion")}</span>
|
||||
{item.score ? <span className="text-xs text-muted-foreground">({item.score}{t("exam.previewDialog.scoreUnit")})</span> : null}
|
||||
</div>
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -63,6 +63,7 @@ export function AiDifferentiationDialog({ doc, textbookId, onClose }: Props) {
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
|
||||
{/* arbitrary-value: dialog fixed width */}
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
|
||||
@@ -56,6 +56,7 @@ export function AiFeedbackDialog({ doc, onClose }: Props) {
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
|
||||
{/* arbitrary-value: dialog fixed width */}
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
|
||||
@@ -57,14 +57,14 @@ function BannerContent({
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
background: "#fef3c7",
|
||||
borderBottom: "1px solid #fcd34d",
|
||||
background: "hsl(var(--color-stone-200))",
|
||||
borderBottom: "1px solid hsl(var(--color-stone-400))",
|
||||
padding: "8px 20px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
fontSize: 12.5,
|
||||
color: "#92400e",
|
||||
color: "hsl(var(--color-stone-700))",
|
||||
}}
|
||||
>
|
||||
<strong style={{ fontWeight: 600 }}>{t("v4.migration.legacyAnchorTitle")}</strong>
|
||||
@@ -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",
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -157,6 +157,7 @@ export function AttachmentPicker({
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
|
||||
{/* arbitrary-value: dialog fixed width */}
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
|
||||
@@ -97,6 +97,7 @@ export function BlackboardBlock({ data, textbookId, chapterId, onUpdate }: Props
|
||||
<label className="text-xs font-medium block mb-1">
|
||||
{t("blackboard.contentLabel")}
|
||||
</label>
|
||||
{/* arbitrary-value: textarea min height */}
|
||||
<textarea
|
||||
value={data.content}
|
||||
onChange={(e) => onUpdate({ ...data, content: e.target.value })}
|
||||
@@ -166,6 +167,7 @@ function BlackboardPreview({
|
||||
|
||||
if (layout === "text") {
|
||||
return (
|
||||
// arbitrary-value: content min height
|
||||
<pre
|
||||
className="text-sm font-mono whitespace-pre-wrap border border-outline-variant rounded p-3 bg-surface-container-low min-h-[120px]"
|
||||
>
|
||||
@@ -178,6 +180,7 @@ function BlackboardPreview({
|
||||
const center = parsed[0]?.text ?? "";
|
||||
const branches = parsed.slice(1);
|
||||
return (
|
||||
// arbitrary-value: content min height
|
||||
<div className="border border-outline-variant rounded p-3 bg-surface-container-low min-h-[120px]">
|
||||
{/* 中心节点 */}
|
||||
<div className="flex justify-center mb-3">
|
||||
@@ -208,6 +211,7 @@ function BlackboardPreview({
|
||||
|
||||
// structure:层级树
|
||||
return (
|
||||
// arbitrary-value: content min height
|
||||
<div className="border border-outline-variant rounded p-3 bg-surface-container-low min-h-[120px]">
|
||||
<ul className="space-y-1">
|
||||
{parsed.map((node, idx) => (
|
||||
|
||||
@@ -63,7 +63,7 @@ export function KeyPointBlock({ data, onUpdate }: Props) {
|
||||
<textarea
|
||||
value={item.text}
|
||||
onChange={(e) => updateItem(idx, { text: e.target.value })}
|
||||
className="flex-1 text-sm border border-outline-variant rounded px-2 py-1 resize-y min-h-[40px]"
|
||||
className="flex-1 text-sm border border-outline-variant rounded px-2 py-1 resize-y min-h-10"
|
||||
placeholder={t("keyPoint.textPlaceholder")}
|
||||
/>
|
||||
<Button
|
||||
|
||||
@@ -68,6 +68,7 @@ export function NewTeachingBlock({ data, textbookId, chapterId, onUpdate }: Prop
|
||||
<label className="text-xs block mb-1">
|
||||
{t("newTeaching.outlineLabel")}
|
||||
</label>
|
||||
{/* arbitrary-value: textarea min height */}
|
||||
<textarea
|
||||
value={point.outline}
|
||||
onChange={(e) => updatePoint(idx, { outline: e.target.value })}
|
||||
@@ -82,7 +83,7 @@ export function NewTeachingBlock({ data, textbookId, chapterId, onUpdate }: Prop
|
||||
<textarea
|
||||
value={point.boardNotes}
|
||||
onChange={(e) => updatePoint(idx, { boardNotes: e.target.value })}
|
||||
className="w-full text-sm border border-outline-variant rounded px-2 py-1 resize-y min-h-[40px]"
|
||||
className="w-full text-sm border border-outline-variant rounded px-2 py-1 resize-y min-h-10"
|
||||
placeholder={t("newTeaching.boardNotesPlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -66,7 +66,7 @@ export function ObjectiveBlock({ data, onUpdate }: Props) {
|
||||
<textarea
|
||||
value={item.text}
|
||||
onChange={(e) => updateItem(idx, { text: e.target.value })}
|
||||
className="flex-1 text-sm border border-outline-variant rounded px-2 py-1 resize-y min-h-[40px]"
|
||||
className="flex-1 text-sm border border-outline-variant rounded px-2 py-1 resize-y min-h-10"
|
||||
placeholder={t("objective.textPlaceholder")}
|
||||
/>
|
||||
<Button
|
||||
|
||||
@@ -60,6 +60,7 @@ export function ReflectionBlock({ data, onUpdate }: Props) {
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{/* arbitrary-value: textarea min height */}
|
||||
<textarea
|
||||
value={item.text}
|
||||
onChange={(e) => updateItem(idx, { text: e.target.value })}
|
||||
|
||||
@@ -54,6 +54,7 @@ export function RichTextBlock({
|
||||
},
|
||||
editorProps: {
|
||||
attributes: {
|
||||
// arbitrary-value: tiptap editor fixed size
|
||||
class:
|
||||
"prose prose-sm max-w-none focus:outline-none min-h-[60px] px-3 py-2",
|
||||
},
|
||||
|
||||
@@ -69,7 +69,7 @@ export function SummaryBlock({ data, onUpdate }: Props) {
|
||||
<textarea
|
||||
value={data.homeworkPreview}
|
||||
onChange={(e) => onUpdate({ ...data, homeworkPreview: e.target.value })}
|
||||
className="w-full text-sm border border-outline-variant rounded px-2 py-1 resize-y min-h-[40px]"
|
||||
className="w-full text-sm border border-outline-variant rounded px-2 py-1 resize-y min-h-10"
|
||||
placeholder={t("summary.homeworkPreviewPlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -307,6 +307,7 @@ function WeekGrid({ days, grouped, today, loading, t }: GridProps): JSX.Element
|
||||
{t(`calendar.weekDays.${WEEK_DAY_KEYS[d.getDay()]}`)}
|
||||
<span className="ml-1">{d.getDate()}</span>
|
||||
</div>
|
||||
{/* arbitrary-value: calendar cell min height */}
|
||||
<div className="flex-1 min-h-[200px] space-y-1 pt-1">
|
||||
{loading ? (
|
||||
<Skeleton className="h-8 w-full" />
|
||||
@@ -347,6 +348,7 @@ function MonthGrid({ days, cursor, grouped, today, loading, t }: GridProps & { c
|
||||
const isCurrentMonth = d.getMonth() === cursor.getMonth();
|
||||
const isToday = isSameDay(d, today);
|
||||
return (
|
||||
// arbitrary-value: calendar cell min height
|
||||
<div
|
||||
key={key}
|
||||
className={cn(
|
||||
|
||||
@@ -36,6 +36,7 @@ export function ConsistencyCheckDialog({ doc, onClose }: Props) {
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
|
||||
{/* arbitrary-value: dialog fixed width */}
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
|
||||
@@ -46,13 +46,14 @@ export function CurriculumMapView({ grades, subjects, heatmap }: Props): JSX.Ele
|
||||
<table className="w-full border-collapse text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
{/* arbitrary-value: table column min width */}
|
||||
<th className="border border-border bg-muted/50 p-2 text-left font-medium sticky left-0 z-10 min-w-[100px]">
|
||||
学科\年级
|
||||
</th>
|
||||
{grades.map((g) => (
|
||||
<th
|
||||
key={g.id}
|
||||
className="border border-border bg-muted/50 p-2 text-center font-medium min-w-[80px]"
|
||||
className="border border-border bg-muted/50 p-2 text-center font-medium min-w-20"
|
||||
>
|
||||
{g.name}
|
||||
</th>
|
||||
@@ -62,6 +63,7 @@ export function CurriculumMapView({ grades, subjects, heatmap }: Props): JSX.Ele
|
||||
<tbody>
|
||||
{subjects.map((s) => (
|
||||
<tr key={s.id}>
|
||||
{/* arbitrary-value: table column min width */}
|
||||
<td className="border border-border p-2 font-medium sticky left-0 z-10 bg-card min-w-[100px]">
|
||||
{s.name}
|
||||
</td>
|
||||
|
||||
@@ -58,7 +58,7 @@ export function DetailHead({ node, isExpanded }: Props) {
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
outline: "none",
|
||||
fontFamily: "Inter, sans-serif",
|
||||
fontFamily: "var(--font-family-sans)",
|
||||
fontWeight: 600,
|
||||
fontSize: 16,
|
||||
color: "var(--foreground)",
|
||||
|
||||
@@ -84,7 +84,7 @@ export function QaEditor({ nodeId, data }: Props) {
|
||||
borderRadius: 5,
|
||||
padding: "8px 10px",
|
||||
background: "var(--background)",
|
||||
fontFamily: "Inter, sans-serif",
|
||||
fontFamily: "var(--font-family-sans)",
|
||||
fontSize: 13,
|
||||
lineHeight: 1.6,
|
||||
color: "var(--foreground)",
|
||||
@@ -181,7 +181,7 @@ function QaTurnEditor({ turn, index, total, onUpdate, onRemove, onMoveUp, onMove
|
||||
onChange={(e) => onUpdate({ role: e.target.value as "teacher" | "student" })}
|
||||
className={isTeacher ? "teacher" : "student"}
|
||||
style={{
|
||||
fontFamily: "JetBrains Mono, monospace",
|
||||
fontFamily: "var(--font-family-mono)",
|
||||
fontSize: 9,
|
||||
fontWeight: 600,
|
||||
padding: "2px 6px",
|
||||
@@ -189,7 +189,7 @@ function QaTurnEditor({ turn, index, total, onUpdate, onRemove, onMoveUp, onMove
|
||||
border: "1px solid var(--border)",
|
||||
background: "var(--background)",
|
||||
cursor: "pointer",
|
||||
color: isTeacher ? "#1c1917" : "#6b7280",
|
||||
color: isTeacher ? "hsl(var(--foreground))" : "hsl(var(--muted-foreground))",
|
||||
}}
|
||||
>
|
||||
<option value="teacher">{t("v4.detail.turnTeacher")}</option>
|
||||
@@ -215,7 +215,7 @@ function QaTurnEditor({ turn, index, total, onUpdate, onRemove, onMoveUp, onMove
|
||||
outline: "none",
|
||||
background: "transparent",
|
||||
resize: "vertical",
|
||||
fontFamily: "Inter, sans-serif",
|
||||
fontFamily: "var(--font-family-sans)",
|
||||
fontSize: 12.5,
|
||||
lineHeight: 1.5,
|
||||
color: "var(--foreground)",
|
||||
@@ -234,7 +234,7 @@ function QaTurnEditor({ turn, index, total, onUpdate, onRemove, onMoveUp, onMove
|
||||
outline: "none",
|
||||
background: "var(--muted)",
|
||||
resize: "vertical",
|
||||
fontFamily: "Inter, sans-serif",
|
||||
fontFamily: "var(--font-family-sans)",
|
||||
fontSize: 11,
|
||||
fontStyle: "italic",
|
||||
color: "var(--muted-foreground)",
|
||||
|
||||
@@ -77,6 +77,7 @@ export function InlineQuestionEditor({ onAdd, onClose, textbookId, chapterId }:
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
|
||||
{/* arbitrary-value: dialog fixed width */}
|
||||
<div
|
||||
className="bg-surface rounded-lg shadow-xl w-[600px] max-h-[80vh] flex flex-col"
|
||||
role="dialog"
|
||||
@@ -118,7 +119,7 @@ export function InlineQuestionEditor({ onAdd, onClose, textbookId, chapterId }:
|
||||
id="inline-question-stem"
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
className="w-full border rounded px-2 py-1 mt-1 min-h-[80px]"
|
||||
className="w-full border rounded px-2 py-1 mt-1 min-h-20"
|
||||
/>
|
||||
</div>
|
||||
{type === "single_choice" && (
|
||||
|
||||
@@ -75,11 +75,14 @@ export function LessonPlanEditor({
|
||||
const versionTimer = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
// 初始化:仅在 planId 变化时 hydrate(修复 P1-3)
|
||||
const initKey = planId;
|
||||
// hydrate 是 zustand store action,引用稳定
|
||||
// initialTitle/initialDoc 通过 ref 保存,避免作为 effect 依赖导致重复 hydrate
|
||||
const hydrate = useLessonPlanEditor((s) => s.hydrate);
|
||||
const initialRef = useRef({ initialTitle, initialDoc });
|
||||
useEffect(() => {
|
||||
useLessonPlanEditor.getState().hydrate(planId, initialTitle, initialDoc);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [initKey]);
|
||||
const { initialTitle: t0, initialDoc: d0 } = initialRef.current;
|
||||
hydrate(planId, t0, d0);
|
||||
}, [planId, hydrate]);
|
||||
|
||||
// 自动保存(debounce 3s)- 用 getState() 获取最新值(修复 P1-4)
|
||||
// V3 修复:完全通过 service 调用,不直接 import actions
|
||||
@@ -332,11 +335,13 @@ export function LessonPlanEditor({
|
||||
{textbookTitle && (
|
||||
<div className="flex items-center gap-1 text-xs text-on-surface-variant px-2 py-1 rounded bg-surface-container-high">
|
||||
<Book className="w-3 h-3" />
|
||||
{/* arbitrary-value: layout fixed size */}
|
||||
<span className="max-w-[120px] truncate">{textbookTitle}</span>
|
||||
{chapterTitle && (
|
||||
<>
|
||||
<span className="text-on-surface-variant/50">/</span>
|
||||
<FileText className="w-3 h-3" />
|
||||
{/* arbitrary-value: layout fixed size */}
|
||||
<span className="max-w-[120px] truncate">{chapterTitle}</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -124,6 +124,7 @@ function MobileNodeCard({
|
||||
{node.title || node.type}
|
||||
</h4>
|
||||
{diff && (
|
||||
// arbitrary-value: badge font size
|
||||
<span
|
||||
className={cn(
|
||||
"text-[10px] px-1.5 py-0.5 rounded font-medium flex-shrink-0",
|
||||
|
||||
@@ -39,7 +39,7 @@ export function InlineNode({ node }: Props) {
|
||||
}}
|
||||
/>
|
||||
<span>{node.type}</span>
|
||||
<span style={{ marginLeft: "auto", fontFamily: "JetBrains Mono, monospace", fontSize: 9, color: "var(--lp-inline-node-meta)" }}>
|
||||
<span style={{ marginLeft: "auto", fontFamily: "var(--font-family-mono)", fontSize: 9, color: "var(--lp-inline-node-meta)" }}>
|
||||
{node.id.slice(-4)}
|
||||
</span>
|
||||
<button
|
||||
|
||||
@@ -32,7 +32,7 @@ export function InlineQaDialog({ node }: Props) {
|
||||
}}
|
||||
/>
|
||||
<span>{t("v4.interaction.label")}</span>
|
||||
<span style={{ marginLeft: "auto", fontFamily: "JetBrains Mono, monospace", fontSize: 9, color: "var(--lp-inline-node-meta)" }}>
|
||||
<span style={{ marginLeft: "auto", fontFamily: "var(--font-family-mono)", fontSize: 9, color: "var(--lp-inline-node-meta)" }}>
|
||||
{data.turns?.length ?? 0} 轮
|
||||
</span>
|
||||
<button
|
||||
|
||||
@@ -55,7 +55,7 @@ export function PaperContextMenu({ state, onClose, onAiAction }: Props) {
|
||||
};
|
||||
|
||||
const copyNode = async (nodeId: string) => {
|
||||
const newId = duplicateNode(nodeId);
|
||||
const newId = duplicateNode(nodeId, t("v4.contextMenu.copySuffix"));
|
||||
const { toast } = await import("sonner");
|
||||
if (newId) {
|
||||
toast.success(t("v4.contextMenu.copied"));
|
||||
@@ -95,7 +95,7 @@ export function PaperContextMenu({ state, onClose, onAiAction }: Props) {
|
||||
padding: "7px 10px",
|
||||
borderRadius: 4,
|
||||
cursor: "pointer",
|
||||
color: opts?.danger ? "#dc2626" : opts?.ai ? "var(--lp-interaction)" : "var(--foreground)",
|
||||
color: opts?.danger ? "hsl(var(--destructive))" : opts?.ai ? "var(--lp-interaction)" : "var(--foreground)",
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
width: "100%",
|
||||
@@ -106,7 +106,7 @@ export function PaperContextMenu({ state, onClose, onAiAction }: Props) {
|
||||
>
|
||||
<span style={{ flex: 1 }}>{label}</span>
|
||||
{opts?.shortcut && (
|
||||
<span style={{ fontFamily: "JetBrains Mono, monospace", fontSize: 10, color: "var(--muted-foreground)" }}>
|
||||
<span style={{ fontFamily: "var(--font-family-mono)", fontSize: 10, color: "var(--muted-foreground)" }}>
|
||||
{opts.shortcut}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -100,15 +100,15 @@ export function PaperEditor({ readonly }: { readonly?: boolean }) {
|
||||
borderRadius: 2,
|
||||
border: "1px solid var(--lp-paper-edge)",
|
||||
minHeight: 800,
|
||||
fontFamily: "'Fraunces', Georgia, serif",
|
||||
color: "#1a1a1a",
|
||||
fontFamily: "var(--font-family-serif)",
|
||||
color: "hsl(var(--lp-anchor-point))",
|
||||
lineHeight: 1.7,
|
||||
fontSize: 16,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: "Inter, sans-serif",
|
||||
fontFamily: "var(--font-family-sans)",
|
||||
fontSize: 11,
|
||||
color: "var(--muted-foreground)",
|
||||
letterSpacing: "0.08em",
|
||||
|
||||
@@ -48,7 +48,7 @@ export function TextbookTiptapEditor({ content, readonly }: Props) {
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class: "lp-textbook-editor prose prose-sm max-w-none focus:outline-none",
|
||||
style: "font-family: 'Fraunces', Georgia, serif; font-size: 16px; line-height: 1.75; color: #1a1a1a;",
|
||||
style: "font-family: var(--font-family-serif); font-size: var(--font-size-5); line-height: 1.75; color: hsl(var(--lp-anchor-point));",
|
||||
},
|
||||
},
|
||||
onUpdate: ({ editor: e }) => {
|
||||
|
||||
@@ -50,8 +50,9 @@ export function PrintView({
|
||||
plan,
|
||||
{ textbookTitle, chapterTitle, teacherName, className },
|
||||
variant,
|
||||
(key, params) => t(key, params),
|
||||
),
|
||||
[plan, textbookTitle, chapterTitle, teacherName, className, variant],
|
||||
[plan, textbookTitle, chapterTitle, teacherName, className, variant, t],
|
||||
);
|
||||
|
||||
function handlePrint() {
|
||||
@@ -60,6 +61,7 @@ export function PrintView({
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-black/30 flex items-center justify-center print:static print:bg-white print:p-0">
|
||||
{/* arbitrary-value: dialog fixed width */}
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
|
||||
@@ -143,6 +143,7 @@ export function PublishHomeworkDialog({
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
|
||||
{/* arbitrary-value: dialog fixed width */}
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
|
||||
@@ -169,6 +169,7 @@ export function QuestionBankPicker({ onPick, onClose, existingIds }: Props) {
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
|
||||
{/* arbitrary-value: dialog fixed width */}
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { X, Calendar, Trash2, Plus } from "lucide-react";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
@@ -48,19 +48,7 @@ export function ScheduleDialog({ planId, classes, onClose, onScheduled }: Props)
|
||||
const [durationMin, setDurationMin] = useState(40);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
void loadSchedules();
|
||||
}, [planId]);
|
||||
|
||||
useEffect(() => {
|
||||
function handleEsc(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") onClose();
|
||||
}
|
||||
document.addEventListener("keydown", handleEsc);
|
||||
return () => document.removeEventListener("keydown", handleEsc);
|
||||
}, [onClose]);
|
||||
|
||||
async function loadSchedules() {
|
||||
const loadSchedules = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await getLessonPlanSchedulesAction(planId);
|
||||
@@ -73,7 +61,19 @@ export function ScheduleDialog({ planId, classes, onClose, onScheduled }: Props)
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}, [planId, t]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadSchedules();
|
||||
}, [loadSchedules]);
|
||||
|
||||
useEffect(() => {
|
||||
function handleEsc(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") onClose();
|
||||
}
|
||||
document.addEventListener("keydown", handleEsc);
|
||||
return () => document.removeEventListener("keydown", handleEsc);
|
||||
}, [onClose]);
|
||||
|
||||
async function handleAdd() {
|
||||
if (!classId) {
|
||||
@@ -127,6 +127,7 @@ export function ScheduleDialog({ planId, classes, onClose, onScheduled }: Props)
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
|
||||
{/* arbitrary-value: dialog fixed width */}
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
|
||||
@@ -65,12 +65,9 @@ export function TemplatePicker() {
|
||||
const [personalTemplates, setPersonalTemplates] = useState<LessonPlanTemplate[]>([]);
|
||||
// V5-9:教材搜索 + 最近使用
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [recentIds, setRecentIds] = useState<string[]>([]);
|
||||
|
||||
// V5-9:客户端挂载后读取最近使用教材
|
||||
useEffect(() => {
|
||||
setRecentIds(readRecentTextbookIds());
|
||||
}, []);
|
||||
// V5-9:使用 lazy initializer 在首次渲染时读取 localStorage,
|
||||
// 避免 effect 内同步 setState 触发级联渲染(react-hooks/set-state-in-effect)
|
||||
const [recentIds, setRecentIds] = useState<string[]>(() => readRecentTextbookIds());
|
||||
|
||||
// V5-9:客户端模糊搜索过滤教材(标题/学科/年级/出版社)
|
||||
const filteredTextbooks = useMemo(() => {
|
||||
@@ -418,6 +415,7 @@ export function TemplatePicker() {
|
||||
>
|
||||
<div className="font-title-md flex items-center gap-2">
|
||||
<span className="truncate">{tpl.name}</span>
|
||||
{/* arbitrary-value: badge font size */}
|
||||
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-secondary text-secondary-foreground flex-shrink-0">
|
||||
{t("template.personalBadge")}
|
||||
</span>
|
||||
|
||||
@@ -78,7 +78,7 @@ function DiffItem({
|
||||
t: ReturnType<typeof useTranslations>;
|
||||
}) {
|
||||
const node = diff.newNode ?? diff.oldNode;
|
||||
const color = node ? getNodeColor(node.type) : "#999";
|
||||
const color = node ? getNodeColor(node.type) : "hsl(var(--lp-dot-default))";
|
||||
const title = node?.title || node?.type || "";
|
||||
|
||||
const icon = {
|
||||
@@ -107,6 +107,7 @@ function DiffItem({
|
||||
{icon}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
{/* arbitrary-value: badge font size */}
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-surface-container-highest font-medium flex-shrink-0">
|
||||
{label}
|
||||
</span>
|
||||
|
||||
@@ -35,8 +35,8 @@ export function VersionDiffViewer({
|
||||
|
||||
const diffSegments = useMemo(() => {
|
||||
if (!selectedVersion) return [];
|
||||
const versionContent = selectedVersion.content as unknown as LessonPlanDocument;
|
||||
return computeDocumentDiff(versionContent, currentDoc);
|
||||
// LessonPlanVersion.content 已是 LessonPlanDocument 类型,无需断言
|
||||
return computeDocumentDiff(selectedVersion.content, currentDoc);
|
||||
}, [selectedVersion, currentDoc]);
|
||||
|
||||
const summary = useMemo(() => summarizeDiff(diffSegments), [diffSegments]);
|
||||
@@ -45,6 +45,7 @@ export function VersionDiffViewer({
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/40">
|
||||
{/* arbitrary-value: dialog fixed width */}
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
|
||||
@@ -51,13 +51,17 @@ export function isEmailEnabled(): boolean {
|
||||
function getTypeColor(type: NotificationPayload["type"]): string {
|
||||
switch (type) {
|
||||
case "success":
|
||||
// eslint-disable-next-line no-restricted-syntax -- email HTML inline style requires literal hex
|
||||
return "#16a34a"
|
||||
case "warning":
|
||||
// eslint-disable-next-line no-restricted-syntax -- email HTML inline style requires literal hex
|
||||
return "#d97706"
|
||||
case "error":
|
||||
// eslint-disable-next-line no-restricted-syntax -- email HTML inline style requires literal hex
|
||||
return "#dc2626"
|
||||
case "info":
|
||||
default:
|
||||
// eslint-disable-next-line no-restricted-syntax -- email HTML inline style requires literal hex
|
||||
return "#2563eb"
|
||||
}
|
||||
}
|
||||
@@ -65,6 +69,14 @@ function getTypeColor(type: NotificationPayload["type"]): string {
|
||||
/** 生成 HTML 邮件内容 */
|
||||
function buildHtmlContent(payload: NotificationPayload): string {
|
||||
const color = getTypeColor(payload.type)
|
||||
// eslint-disable-next-line no-restricted-syntax -- email HTML inline style requires literal hex
|
||||
const contentTextColor = "#374151"
|
||||
// eslint-disable-next-line no-restricted-syntax -- email HTML inline style requires literal hex
|
||||
const dividerColor = "#e5e7eb"
|
||||
// eslint-disable-next-line no-restricted-syntax -- email HTML inline style requires literal hex
|
||||
const footerTextColor = "#9ca3af"
|
||||
// arbitrary-value: email HTML inline style requires literal px
|
||||
const footerFontSizeDecl = "font-size:12px"
|
||||
const actionLink = payload.actionUrl
|
||||
? `<p style="margin-top:16px;"><a href="${payload.actionUrl}" style="color:${color};text-decoration:none;">点击查看详情 →</a></p>`
|
||||
: ""
|
||||
@@ -72,11 +84,11 @@ function buildHtmlContent(payload: NotificationPayload): string {
|
||||
<div style="font-family:Arial,sans-serif;max-width:600px;margin:0 auto;padding:20px;">
|
||||
<div style="border-left:4px solid ${color};padding-left:16px;">
|
||||
<h2 style="color:${color};margin:0 0 12px 0;">${escapeHtml(payload.title)}</h2>
|
||||
<p style="color:#374151;line-height:1.6;margin:0;">${escapeHtml(payload.content)}</p>
|
||||
<p style="color:${contentTextColor};line-height:1.6;margin:0;">${escapeHtml(payload.content)}</p>
|
||||
${actionLink}
|
||||
</div>
|
||||
<hr style="border:none;border-top:1px solid #e5e7eb;margin:24px 0;" />
|
||||
<p style="color:#9ca3af;font-size:12px;margin:0;">此邮件由系统自动发送,请勿回复。</p>
|
||||
<hr style="border:none;border-top:1px solid ${dividerColor};margin:24px 0;" />
|
||||
<p style="color:${footerTextColor};${footerFontSizeDecl};margin:0;">此邮件由系统自动发送,请勿回复。</p>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
@@ -235,7 +235,7 @@ export function ParentAttendanceCalendar({
|
||||
onFocus={() => setFocusedIdx(idx)}
|
||||
className={cn(
|
||||
"relative flex aspect-square flex-col items-center justify-center rounded-md border text-xs",
|
||||
"min-h-[36px] cursor-default",
|
||||
"min-h-9 cursor-default",
|
||||
record ? "border-border bg-muted/30" : "border-transparent",
|
||||
isToday && "ring-2 ring-primary ring-offset-1",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
|
||||
@@ -35,17 +35,17 @@ import type {
|
||||
|
||||
/** 章节颜色调色板(柔和、Obsidian 风格) */
|
||||
const CHAPTER_COLORS = [
|
||||
"#7c3aed", "#2563eb", "#dc2626", "#059669",
|
||||
"#d97706", "#db2777", "#0891b2", "#65a30d",
|
||||
"#9333ea", "#0284c7", "#e11d48", "#16a34a",
|
||||
"hsl(var(--graph-node-1))", "hsl(var(--graph-node-2))", "hsl(var(--graph-node-3))", "hsl(var(--graph-node-4))",
|
||||
"hsl(var(--graph-node-5))", "hsl(var(--graph-node-6))", "hsl(var(--graph-node-1))", "hsl(var(--graph-node-2))",
|
||||
"hsl(var(--graph-node-3))", "hsl(var(--graph-node-4))", "hsl(var(--graph-node-5))", "hsl(var(--graph-node-6))",
|
||||
]
|
||||
|
||||
/** 掌握度色彩(柔和) */
|
||||
const MASTERY_FILL: Record<MasteryLevel, string> = {
|
||||
low: "#dc2626",
|
||||
medium: "#d97706",
|
||||
high: "#059669",
|
||||
unassessed: "#94a3b8",
|
||||
low: "hsl(var(--graph-node-1))",
|
||||
medium: "hsl(var(--graph-node-5))",
|
||||
high: "hsl(var(--graph-node-2))",
|
||||
unassessed: "hsl(var(--muted-foreground))",
|
||||
}
|
||||
|
||||
/** 边颜色:父子关系(中性灰)/ 前置依赖(紫色) */
|
||||
@@ -161,7 +161,7 @@ function buildGraphData(
|
||||
kp,
|
||||
mastery: data.masteryMap[kp.id] ?? null,
|
||||
val: connectionCount.get(kp.id) ?? 0,
|
||||
chapterColor: chapterColorMap.get(kp.chapterId ?? "") ?? "#6b7280",
|
||||
chapterColor: chapterColorMap.get(kp.chapterId ?? "") ?? "hsl(var(--muted-foreground))",
|
||||
}))
|
||||
|
||||
const links: KpGraphLink[] = []
|
||||
|
||||
@@ -54,8 +54,8 @@ const edgeTypes = { prerequisiteEdge: GraphPrerequisiteEdge }
|
||||
|
||||
/** 章节颜色调色板 */
|
||||
const CHAPTER_COLORS = [
|
||||
"#3b82f6", "#ef4444", "#10b981", "#f59e0b",
|
||||
"#8b5cf6", "#ec4899", "#06b6d4", "#84cc16",
|
||||
"hsl(var(--graph-node-1))", "hsl(var(--graph-node-2))", "hsl(var(--graph-node-3))", "hsl(var(--graph-node-4))",
|
||||
"hsl(var(--graph-node-5))", "hsl(var(--graph-node-6))", "hsl(var(--graph-node-1))", "hsl(var(--graph-node-2))",
|
||||
]
|
||||
|
||||
interface KnowledgeGraphProps {
|
||||
@@ -166,7 +166,7 @@ function KnowledgeGraphInner({ textbookId, initialViewMode = "structure", initia
|
||||
viewMode,
|
||||
isSelected,
|
||||
isHighlighted,
|
||||
chapterColor: chapterColorMap.get(kp.chapterId ?? "") ?? "#6b7280",
|
||||
chapterColor: chapterColorMap.get(kp.chapterId ?? "") ?? "hsl(var(--muted-foreground))",
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -330,7 +330,7 @@ function KnowledgeGraphInner({ textbookId, initialViewMode = "structure", initia
|
||||
// 安全的类型收窄:node.data 是 Record<string, unknown>,
|
||||
// GraphLayoutNodeData 有索引签名 [key: string]: unknown,是 Record 的子类型
|
||||
const data = node.data as GraphLayoutNodeData
|
||||
return data.graphData?.chapterColor ?? "#6b7280"
|
||||
return data.graphData?.chapterColor ?? "hsl(var(--muted-foreground))"
|
||||
}}
|
||||
/>
|
||||
</ReactFlow>
|
||||
|
||||
@@ -51,6 +51,7 @@ const ChartContainer = React.forwardRef<
|
||||
data-chart={chartId}
|
||||
ref={ref}
|
||||
className={cn(
|
||||
// eslint-disable-next-line no-restricted-syntax -- arbitrary-value: recharts CSS 属性选择器需匹配 SVG 默认输出 #ccc/#fff 不可令牌化
|
||||
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
|
||||
className
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user