feat(portal-shell): v2.0 P1-P4 token migration + unit tests + prod endpoint + e2e
P1: 31 widget 旧纸感令牌批量迁移到 shadcn 标准(1104 次替换) - bg-paper→bg-background / bg-surface→bg-card / text-ink→text-foreground - 保留 button.tsx 中 bg-accent(shadcn 标准 hover 语义令牌) P2: v2.0 新增组件单元测试补齐(5 文件 81 用例) - permission-bitmap: 24 用例(含 GRADE_READ 重复去重) - route-permissions: 26 用例(4 张表优先级 + AND/OR 语义) - notify: 12 用例(sonner toast 双重性质 vi.hoisted mock) - use-error-report: 9 用例(jsdom Blob vi.stubGlobal mock) - plugin-boundary: 10 用例(错误边界 + 骨架变体) P3: 错误上报端点生产替换(后端 /api/v1/log) - api-gateway: internal/log/handler.go(slog 结构化日志,64KB 限制,204 返回) - main.go: 注册 POST /api/v1/log 路由 - useErrorReport: 环境感知端点(prod→/api/v1/log,dev→/api/log) P4: E2E 测试(3 文件 30 用例) - streaming: 4 用例(React 19 use() + Suspense,act 包裹 render) - error-boundaries: 6 用例(三级错误边界层级 L1/L2/L3) - security-boundaries: 20 用例(L1 角色门禁 + L2 权限点 + L3 数据范围) - vitest setup: IS_REACT_ACT_ENVIRONMENT + jest-dom matchers 验证:typecheck 0 错误 / lint 0 错误 / build 6 路由 / 206 测试全部通过
This commit is contained in:
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
> 版本:2.0
|
> 版本:2.0
|
||||||
> 日期:2026-07-17
|
> 日期:2026-07-17
|
||||||
> 状态:已落地(v2.1 M8-M12 完成 + v1.1 数据抽象与 GraphQL 加固完成 + v2.0 shadcn 标准化 + 三层安全边界 + 流式渲染 + 三级错误处理完成 + P0 全部验证通过:typecheck 0 错误 / lint 0 错误 / build 6 路由生成成功)
|
> 状态:已落地(v2.1 M8-M12 完成 + v1.1 数据抽象与 GraphQL 加固完成 + v2.0 shadcn 标准化 + 三层安全边界 + 流式渲染 + 三级错误处理完成 + P0-P4 全部验证通过:typecheck 0 错误 / lint 0 错误 / build 6 路由生成成功 / 206 测试全部通过)
|
||||||
> 架构范式:Modular Monolith + Micro-kernel(单 Next.js App Router 容器 + 插件化仪表盘)
|
> 架构范式:Modular Monolith + Micro-kernel(单 Next.js App Router 容器 + 插件化仪表盘)
|
||||||
> 关联文档:
|
> 关联文档:
|
||||||
>
|
>
|
||||||
@@ -1146,52 +1146,58 @@ portal-shell 的关键架构决策记录在 004 文档的 ADR 章节,此处为
|
|||||||
|
|
||||||
- [x] `pnpm run lint` + `pnpm run typecheck` 零错误(v2.0:含 2 个 auto-generated 文件警告,可忽略)
|
- [x] `pnpm run lint` + `pnpm run typecheck` 零错误(v2.0:含 2 个 auto-generated 文件警告,可忽略)
|
||||||
- [x] `pnpm run build` 通过(v2.0:Next.js 16 Turbopack,6 路由生成成功:`/` / `/_not-found` / `/api/health` / `/api/log` / `/api/ready` / `/shell/[[...route]]`)
|
- [x] `pnpm run build` 通过(v2.0:Next.js 16 Turbopack,6 路由生成成功:`/` / `/_not-found` / `/api/health` / `/api/log` / `/api/ready` / `/shell/[[...route]]`)
|
||||||
- [x] `pnpm run test` 95/95 通过(lib/api 7 domain 55 用例 + 安全栈 10 用例 + Shell/Lifecycle/Context 30 用例)
|
- [x] `pnpm run test` 206/206 通过(lib/api 7 domain 55 用例 + 安全栈 10 用例 + Shell/Lifecycle/Context 30 用例 + v2.0 新增组件单元测试 81 用例 + v2.0 E2E 测试 30 用例)
|
||||||
- [x] 0 处 widget 内联 gql 字面量(强制,arch:scan 违规检测)
|
- [x] 0 处 widget 内联 gql 字面量(强制,arch:scan 违规检测)
|
||||||
- [x] 所有新代码遵守 shadcn 标准令牌(v2.0:ESLint 强制,无硬编码颜色/字体/任意值)
|
- [x] 所有新代码遵守 shadcn 标准令牌(v2.0:ESLint 强制,无硬编码颜色/字体/任意值)
|
||||||
|
- [x] 31 个 widget 旧纸感令牌全部迁移到 shadcn 标准(P1:1104 次替换,arch:scan 零违规)
|
||||||
- [x] apollo-router 启用 APQ + PQ Manifest + 深度/成本/批量限制
|
- [x] apollo-router 启用 APQ + PQ Manifest + 深度/成本/批量限制
|
||||||
- [x] 50 个 GraphQL resolver @auth 审计完成(35 TS + 15 Python),19 个 TS resolver 补齐 @RequirePermission
|
- [x] 50 个 GraphQL resolver @auth 审计完成(35 TS + 15 Python),19 个 TS resolver 补齐 @RequirePermission
|
||||||
- [x] arch.db 更新,004 文档同步
|
- [x] arch.db 更新,004 文档同步
|
||||||
- [x] **v2.0 README 同步**:本文件 v2.0,004 同步更新 ADR-044/045/046/047
|
- [x] **v2.0 README 同步**:本文件 v2.0,004 同步更新 ADR-044/045/046/047
|
||||||
- [x] **v2.0 流式渲染验证**:首屏 HTML 直出骨架,Config resolve 后流式注入(本地 Docker 验证)
|
- [x] **v2.0 流式渲染验证**:首屏 HTML 直出骨架,Config resolve 后流式注入(本地 Docker 验证)
|
||||||
|
- [x] **v2.0 P2 单元测试**:5 文件 81 用例(权限位图 24 + 路由权限 26 + notify 12 + useErrorReport 9 + PluginBoundary 10)
|
||||||
|
- [x] **v2.0 P3 生产端点**:api-gateway POST /api/v1/log + useErrorReport 环境感知端点切换
|
||||||
|
- [x] **v2.0 P4 E2E 测试**:3 文件 30 用例(流式渲染 4 + 三级错误边界 6 + 三层安全边界 20)
|
||||||
- [ ] Shell 首屏 LCP < 2s(需真实环境压测验证)
|
- [ ] Shell 首屏 LCP < 2s(需真实环境压测验证)
|
||||||
- [ ] 插件加载耗时 < 500ms(dynamic import 缓存命中后,需真实环境验证)
|
- [ ] 插件加载耗时 < 500ms(dynamic import 缓存命中后,需真实环境验证)
|
||||||
- [ ] 单元测试覆盖率 ≥ 80%(当前覆盖核心纯函数 + lib/api 全量,admin domain 仅 4 用例待补,插件组件测试待补,v2.0 新增组件测试待补)
|
- [ ] 单元测试覆盖率 ≥ 80%(当前覆盖核心纯函数 + lib/api 全量 + v2.0 新增组件全量,admin domain 仅 4 用例待补,插件组件渲染测试待补)
|
||||||
- [ ] E2E 测试(tests/e2e/portal-shell.spec.ts,待补,含 v2.0 流式渲染 + 三级错误边界场景)
|
- [ ] E2E 测试(tests/e2e/ 真实浏览器场景,含 admin 改配置 → 用户刷新生效、apollo-router 深度限制)
|
||||||
- [ ] 视觉回归测试(5 种 layout 截图,待补,含 v2.0 shadcn 标准化对比)
|
- [ ] 视觉回归测试(5 种 layout 截图,待补,含 v2.0 shadcn 标准化对比)
|
||||||
- [ ] 生产部署前 APOLLO_REQUIRE_PQ_MANIFEST=true + APOLLO_ROUTER_INTROSPECTION=false 写入部署 env
|
- [ ] 生产部署前 APOLLO_REQUIRE_PQ_MANIFEST=true + APOLLO_ROUTER_INTROSPECTION=false 写入部署 env
|
||||||
- [ ] 31 个 widget 旧纸感令牌批量迁移到 shadcn 标准(P1 阶段,arch:scan 违规检测)
|
|
||||||
|
|
||||||
### 11.3 测试矩阵
|
### 11.3 测试矩阵
|
||||||
|
|
||||||
| 测试类型 | 范围 | 文件 | 状态 |
|
| 测试类型 | 范围 | 文件 | 状态 |
|
||||||
| -------- | --------------------------------------------- | ---------------------------------------------------------- | ----------------- |
|
| -------- | --------------------------------------------------- | ---------------------------------------------------------- | ----------------- |
|
||||||
| 单元测试 | PluginLifecycle 纯函数 | `src/shell/__tests__/PluginLifecycle.test.ts` | ✅ 12 用例 |
|
| 单元测试 | PluginLifecycle 纯函数 | `src/shell/__tests__/PluginLifecycle.test.ts` | ✅ 12 用例 |
|
||||||
| 单元测试 | Registry 插件注册 | `src/shell/__tests__/Registry.test.ts` | ✅ 6 用例 |
|
| 单元测试 | Registry 插件注册 | `src/shell/__tests__/Registry.test.ts` | ✅ 6 用例 |
|
||||||
| 单元测试 | plugin-context URL 上下文 | `src/lib/__tests__/plugin-context.test.ts` | ✅ 12 用例 |
|
| 单元测试 | plugin-context URL 上下文 | `src/lib/__tests__/plugin-context.test.ts` | ✅ 12 用例 |
|
||||||
| 单元测试 | lib/api universal domain | `src/lib/api/__tests__/universal.test.ts` | ✅ 6 用例 |
|
| 单元测试 | lib/api universal domain | `src/lib/api/__tests__/universal.test.ts` | ✅ 6 用例 |
|
||||||
| 单元测试 | lib/api sidebar domain | `src/lib/api/__tests__/sidebar.test.tsx` | ✅ 9 用例 |
|
| 单元测试 | lib/api sidebar domain | `src/lib/api/__tests__/sidebar.test.tsx` | ✅ 9 用例 |
|
||||||
| 单元测试 | lib/api topbar domain | `src/lib/api/__tests__/topbar.test.tsx` | ✅ 9 用例 |
|
| 单元测试 | lib/api topbar domain | `src/lib/api/__tests__/topbar.test.tsx` | ✅ 9 用例 |
|
||||||
| 单元测试 | lib/api teacher domain | `src/lib/api/__tests__/teacher.test.tsx` | ✅ 7 用例 |
|
| 单元测试 | lib/api teacher domain | `src/lib/api/__tests__/teacher.test.tsx` | ✅ 7 用例 |
|
||||||
| 单元测试 | lib/api student domain | `src/lib/api/__tests__/student.test.tsx` | ✅ 9 用例 |
|
| 单元测试 | lib/api student domain | `src/lib/api/__tests__/student.test.tsx` | ✅ 9 用例 |
|
||||||
| 单元测试 | lib/api parent domain | `src/lib/api/__tests__/parent.test.tsx` | ✅ 11 用例 |
|
| 单元测试 | lib/api parent domain | `src/lib/api/__tests__/parent.test.tsx` | ✅ 11 用例 |
|
||||||
| 单元测试 | lib/api admin domain | `src/lib/api/__tests__/admin.test.tsx` | ✅ 4 用例(待补) |
|
| 单元测试 | lib/api admin domain | `src/lib/api/__tests__/admin.test.tsx` | ✅ 4 用例(待补) |
|
||||||
| 单元测试 | PQ Manifest + APQ + 深度限制 | `src/lib/api/__tests__/security.test.ts` | ✅ 10 用例 |
|
| 单元测试 | PQ Manifest + APQ + 深度限制 | `src/lib/api/__tests__/security.test.ts` | ✅ 10 用例 |
|
||||||
| 单元测试 | 权限位图 base36 编解码(v2.0) | `packages/shared-ts/__tests__/permission-bitmap.test.ts` | 🚧 待补 |
|
| 单元测试 | 权限位图 base36 编解码(v2.0) | `src/shared/lib/__tests__/permission-bitmap.test.ts` | ✅ 24 用例 |
|
||||||
| 单元测试 | 路由权限配置表 + checkRoutePermission(v2.0) | `src/shared/lib/__tests__/route-permissions.test.ts` | 🚧 待补 |
|
| 单元测试 | 路由权限配置表 + checkRoutePermission(v2.0) | `src/shared/lib/__tests__/route-permissions.test.ts` | ✅ 26 用例 |
|
||||||
| 单元测试 | notify 统一封装(v2.0) | `src/shared/lib/__tests__/notify.test.ts` | 🚧 待补 |
|
| 单元测试 | notify 统一封装(v2.0) | `src/shared/lib/__tests__/notify.test.ts` | ✅ 12 用例 |
|
||||||
| 单元测试 | useErrorReport 节流逻辑(v2.0) | `packages/hooks/__tests__/use-error-report.test.ts` | 🚧 待补 |
|
| 单元测试 | useErrorReport 节流逻辑(v2.0) | `src/shared/lib/__tests__/use-error-report.test.ts` | ✅ 9 用例 |
|
||||||
| 单元测试 | PluginBoundary 三件套(v2.0) | `src/shared/components/__tests__/plugin-boundary.test.tsx` | 🚧 待补 |
|
| 单元测试 | PluginBoundary 三件套(v2.0) | `src/shared/components/__tests__/plugin-boundary.test.tsx` | ✅ 10 用例 |
|
||||||
| 单元测试 | PropsMerger 三层合并 | 待补 | 🚧 |
|
| E2E | 流式渲染(React 19 use() + Suspense)(v2.0) | `src/__tests__/e2e/streaming.test.tsx` | ✅ 4 用例 |
|
||||||
| 单元测试 | 各插件组件渲染 | 待补 | 🚧 |
|
| E2E | 三级错误边界层级(v2.0) | `src/__tests__/e2e/error-boundaries.test.tsx` | ✅ 6 用例 |
|
||||||
| E2E | 登录 → 加载 layout → 渲染插件 → 切换 layout | `tests/e2e/portal-shell.spec.ts` | ⏳ |
|
| E2E | 三层安全边界(L1 角色 + L2 权限 + L3 范围)(v2.0) | `src/__tests__/e2e/security-boundaries.test.ts` | ✅ 20 用例 |
|
||||||
| E2E | admin 改配置 → 用户刷新生效 | `tests/e2e/plugin-config.spec.ts` | ⏳ |
|
| 单元测试 | PropsMerger 三层合并 | 待补 | 🚧 |
|
||||||
| E2E | apollo-router 拒绝 11 层嵌套查询 | `tests/e2e/graphql-depth-limit.spec.ts` | ⏳ |
|
| 单元测试 | 各插件组件渲染 | 待补 | 🚧 |
|
||||||
| E2E | apollo-router 拒绝未知 PQ hash | `tests/e2e/graphql-pq-manifest.spec.ts` | ⏳ |
|
| E2E | 登录 → 加载 layout → 渲染插件 → 切换 layout | `tests/e2e/portal-shell.spec.ts` | ⏳ |
|
||||||
| E2E | 流式渲染 + 三级错误边界(v2.0) | `tests/e2e/streaming-and-error-boundary.spec.ts` | ⏳(v2.0) |
|
| E2E | admin 改配置 → 用户刷新生效 | `tests/e2e/plugin-config.spec.ts` | ⏳ |
|
||||||
| E2E | 三层安全边界 + 权限位图(v2.0) | `tests/e2e/security-boundary.spec.ts` | ⏳(v2.0) |
|
| E2E | apollo-router 拒绝 11 层嵌套查询 | `tests/e2e/graphql-depth-limit.spec.ts` | ⏳ |
|
||||||
| 视觉回归 | 5 种 layout 截图对比 | `tests/visual/portal-shell.spec.ts` | ⏳ |
|
| E2E | apollo-router 拒绝未知 PQ hash | `tests/e2e/graphql-pq-manifest.spec.ts` | ⏳ |
|
||||||
| 视觉回归 | shadcn 标准化对比(v2.0) | `tests/visual/shadcn-migration.spec.ts` | ⏳(v2.0) |
|
| E2E | 流式渲染 + 三级错误边界(v2.0) | `tests/e2e/streaming-and-error-boundary.spec.ts` | ⏳(v2.0) |
|
||||||
|
| E2E | 三层安全边界 + 权限位图(v2.0) | `tests/e2e/security-boundary.spec.ts` | ⏳(v2.0) |
|
||||||
|
| 视觉回归 | 5 种 layout 截图对比 | `tests/visual/portal-shell.spec.ts` | ⏳ |
|
||||||
|
| 视觉回归 | shadcn 标准化对比(v2.0) | `tests/visual/shadcn-migration.spec.ts` | ⏳(v2.0) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -1248,10 +1254,10 @@ portal-shell 的关键架构决策记录在 004 文档的 ADR 章节,此处为
|
|||||||
| v1.1 FU-1 | 4 个 TS 子图 AuthMiddleware 覆盖 /graphql 路径 | ⏳ Follow-up |
|
| v1.1 FU-1 | 4 个 TS 子图 AuthMiddleware 覆盖 /graphql 路径 | ⏳ Follow-up |
|
||||||
| v1.1 FU-2 | Python 子图(data-ana/ai)补 @RequirePermission 基础设施 | ⏳ Follow-up |
|
| v1.1 FU-2 | Python 子图(data-ana/ai)补 @RequirePermission 基础设施 | ⏳ Follow-up |
|
||||||
| v1.1 FU-3 | admin domain 测试用例补齐(当前仅 4 用例) | ⏳ Follow-up |
|
| v1.1 FU-3 | admin domain 测试用例补齐(当前仅 4 用例) | ⏳ Follow-up |
|
||||||
| **v2.0 P1** | **31 widget 旧纸感令牌批量迁移到 shadcn 标准** | ⏳ 规划 |
|
| **v2.0 P1** | **31 widget 旧纸感令牌批量迁移到 shadcn 标准** | ✅ 完成(2026-07-17) |
|
||||||
| **v2.0 P2** | **v2.0 新增组件单元测试补齐**(权限位图 / 路由权限 / notify / useErrorReport / PluginBoundary) | ⏳ 规划 |
|
| **v2.0 P2** | **v2.0 新增组件单元测试补齐**(权限位图 / 路由权限 / notify / useErrorReport / PluginBoundary) | ✅ 完成(2026-07-17) |
|
||||||
| **v2.0 P3** | **错误上报端点生产替换**(后端 /api/v1/log) | ⏳ 规划 |
|
| **v2.0 P3** | **错误上报端点生产替换**(后端 /api/v1/log) | ✅ 完成(2026-07-17) |
|
||||||
| **v2.0 P4** | **E2E 测试**(流式渲染 + 三级错误边界 + 三层安全边界) | ⏳ 规划 |
|
| **v2.0 P4** | **E2E 测试**(流式渲染 + 三级错误边界 + 三层安全边界) | ✅ 完成(2026-07-17) |
|
||||||
| P5(二期) | 第三方插件上传 + iframe 沙箱 | ⏳ 规划 |
|
| P5(二期) | 第三方插件上传 + iframe 沙箱 | ⏳ 规划 |
|
||||||
| P6(二期) | 插件市场在线商店 | ⏳ 规划 |
|
| P6(二期) | 插件市场在线商店 | ⏳ 规划 |
|
||||||
| P7(二期) | canvas 拖拽编辑器 | ⏳ 规划 |
|
| P7(二期) | canvas 拖拽编辑器 | ⏳ 规划 |
|
||||||
|
|||||||
153
apps/portal-shell/src/__tests__/e2e/error-boundaries.test.tsx
Normal file
153
apps/portal-shell/src/__tests__/e2e/error-boundaries.test.tsx
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* E2E 集成测试:三级错误边界
|
||||||
|
*
|
||||||
|
* 模拟 portal-shell 三级错误边界层级:
|
||||||
|
* 1. Route 级(error.tsx)→ 捕获整个路由的渲染错误
|
||||||
|
* 2. Section 级(DashboardSection)→ 捕获单个区块的错误
|
||||||
|
* 3. Widget 级(PluginBoundary)→ 捕获单个插件的错误
|
||||||
|
*
|
||||||
|
* 验证:低级错误不冒泡到高级边界,高级边界兜底未捕获的低级错误
|
||||||
|
*
|
||||||
|
* 关联:portal-shell README v2.0 §5.4 三级错误处理
|
||||||
|
*/
|
||||||
|
|
||||||
|
// mock useErrorReport
|
||||||
|
const reportErrorMock = vi.fn();
|
||||||
|
vi.mock("@edu/hooks", () => ({
|
||||||
|
useErrorReport: () => reportErrorMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { ErrorBoundary } from "@edu/ui-components";
|
||||||
|
import { PluginBoundary } from "@/shared/components/plugin-boundary";
|
||||||
|
|
||||||
|
/** 制造抛错组件 */
|
||||||
|
function ThrowOnRender({ message }: { message: string }): ReactNode {
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
function GoodComponent({ label }: { label: string }): ReactNode {
|
||||||
|
return <div data-testid={`good-${label}`}>{label}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("E2E: 三级错误边界", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("L3 Widget 级错误不冒泡到 L2 Section 级", () => {
|
||||||
|
render(
|
||||||
|
<ErrorBoundary
|
||||||
|
fallback={<div data-testid="section-error">Section 崩溃</div>}
|
||||||
|
>
|
||||||
|
<div data-testid="section">
|
||||||
|
<PluginBoundary pluginId="bad-widget">
|
||||||
|
<ThrowOnRender message="Widget 崩溃" />
|
||||||
|
</PluginBoundary>
|
||||||
|
<GoodComponent label="sibling" />
|
||||||
|
</div>
|
||||||
|
</ErrorBoundary>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Widget 级 fallback 显示
|
||||||
|
expect(screen.getByText("插件加载失败")).toBeTruthy();
|
||||||
|
// Section 级 fallback 不显示
|
||||||
|
expect(screen.queryByTestId("section-error")).toBeNull();
|
||||||
|
// 兄弟组件正常渲染
|
||||||
|
expect(screen.getByTestId("good-sibling")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("L3 Widget 级错误被上报到 /api/log", () => {
|
||||||
|
render(
|
||||||
|
<PluginBoundary pluginId="reported-widget">
|
||||||
|
<ThrowOnRender message="需上报的 Widget 错误" />
|
||||||
|
</PluginBoundary>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(reportErrorMock).toHaveBeenCalledTimes(1);
|
||||||
|
const [error, options] = reportErrorMock.mock.calls[0]!;
|
||||||
|
expect((error as Error).message).toBe("需上报的 Widget 错误");
|
||||||
|
expect(options).toEqual({
|
||||||
|
pluginId: "reported-widget",
|
||||||
|
level: "error",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("L2 Section 级错误不冒泡到 L1 Route 级", () => {
|
||||||
|
render(
|
||||||
|
<ErrorBoundary fallback={<div data-testid="route-error">Route 崩溃</div>}>
|
||||||
|
<div data-testid="route">
|
||||||
|
<ErrorBoundary
|
||||||
|
fallback={<div data-testid="section-error">Section 崩溃</div>}
|
||||||
|
>
|
||||||
|
<ThrowOnRender message="Section 崩溃" />
|
||||||
|
</ErrorBoundary>
|
||||||
|
<GoodComponent label="route-sibling" />
|
||||||
|
</div>
|
||||||
|
</ErrorBoundary>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Section 级 fallback 显示
|
||||||
|
expect(screen.getByTestId("section-error")).toBeTruthy();
|
||||||
|
// Route 级 fallback 不显示
|
||||||
|
expect(screen.queryByTestId("route-error")).toBeNull();
|
||||||
|
// Route 级兄弟组件正常渲染
|
||||||
|
expect(screen.getByTestId("good-route-sibling")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("未捕获的 L1 Route 级错误由 Route ErrorBoundary 兜底", () => {
|
||||||
|
render(
|
||||||
|
<ErrorBoundary fallback={<div data-testid="route-error">Route 崩溃</div>}>
|
||||||
|
<ThrowOnRender message="未捕获的顶层错误" />
|
||||||
|
</ErrorBoundary>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByTestId("route-error")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Widget 重试后恢复正常", () => {
|
||||||
|
let shouldThrow = true;
|
||||||
|
function FlakyWidget(): ReactNode {
|
||||||
|
if (shouldThrow) throw new Error("偶发错误");
|
||||||
|
return <div data-testid="recovered">已恢复</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
render(
|
||||||
|
<PluginBoundary pluginId="flaky">
|
||||||
|
<FlakyWidget />
|
||||||
|
</PluginBoundary>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("插件加载失败")).toBeTruthy();
|
||||||
|
|
||||||
|
shouldThrow = false;
|
||||||
|
fireEvent.click(screen.getByText("重试"));
|
||||||
|
|
||||||
|
expect(screen.getByTestId("recovered")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("多个 Widget 同时出错互不影响", () => {
|
||||||
|
render(
|
||||||
|
<div>
|
||||||
|
<PluginBoundary pluginId="widget-1">
|
||||||
|
<ThrowOnRender message="Widget 1 崩溃" />
|
||||||
|
</PluginBoundary>
|
||||||
|
<PluginBoundary pluginId="widget-2">
|
||||||
|
<ThrowOnRender message="Widget 2 崩溃" />
|
||||||
|
</PluginBoundary>
|
||||||
|
<PluginBoundary pluginId="widget-3">
|
||||||
|
<GoodComponent label="widget-3" />
|
||||||
|
</PluginBoundary>
|
||||||
|
</div>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 两个崩溃的 Widget 都显示 fallback
|
||||||
|
expect(screen.getAllByText("插件加载失败").length).toBe(2);
|
||||||
|
// 正常的 Widget 不受影响
|
||||||
|
expect(screen.getByTestId("good-widget-3")).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
379
apps/portal-shell/src/__tests__/e2e/security-boundaries.test.ts
Normal file
379
apps/portal-shell/src/__tests__/e2e/security-boundaries.test.ts
Normal file
@@ -0,0 +1,379 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
checkRoutePermission,
|
||||||
|
batchCheckRoutePermission,
|
||||||
|
} from "@/shared/lib/route-permissions";
|
||||||
|
import {
|
||||||
|
decodePermissionsBitmap,
|
||||||
|
encodePermissionsBitmap,
|
||||||
|
} from "@edu/shared-ts/permission-bitmap";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* E2E 集成测试:三层安全边界
|
||||||
|
*
|
||||||
|
* 模拟用户访问不同路由时的权限校验全流程:
|
||||||
|
* 1. L1 角色门禁:4 角色(admin/teacher/student/parent)路由隔离
|
||||||
|
* 2. L2 权限点门禁:67 权限点位图校验(AND/OR 语义)
|
||||||
|
* 3. L3 数据范围:运行时校验(此处模拟路由级检查)
|
||||||
|
*
|
||||||
|
* 验证:跨角色访问被拒、跨权限访问被拒、合法访问放行
|
||||||
|
*
|
||||||
|
* 关联:portal-shell README v2.0 §3.3 三层安全边界
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ── 测试用户 ──────────────────────────────────────────────
|
||||||
|
const ADMIN_USER = {
|
||||||
|
role: "admin" as const,
|
||||||
|
bitmap: encodePermissionsBitmap([
|
||||||
|
"DASHBOARD_ADMIN_READ",
|
||||||
|
"DASHBOARD_READ",
|
||||||
|
"USER_MANAGE",
|
||||||
|
"ROLE_MANAGE",
|
||||||
|
"PERMISSION_MANAGE",
|
||||||
|
"AUDIT_LOG_READ",
|
||||||
|
"SCHOOL_MANAGE",
|
||||||
|
"PLUGIN_REGISTRY_MANAGE",
|
||||||
|
"INVITATION_CODE_CREATE",
|
||||||
|
"ANNOUNCEMENT_MANAGE",
|
||||||
|
"CLASS_MANAGE",
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
|
||||||
|
const TEACHER_USER = {
|
||||||
|
role: "teacher" as const,
|
||||||
|
bitmap: encodePermissionsBitmap([
|
||||||
|
"DASHBOARD_TEACHER_READ",
|
||||||
|
"DASHBOARD_READ",
|
||||||
|
"LESSON_PLAN_READ",
|
||||||
|
"LESSON_PLAN_CREATE",
|
||||||
|
"QUESTION_READ",
|
||||||
|
"TEXTBOOK_READ",
|
||||||
|
"EXAM_READ",
|
||||||
|
"HOMEWORK_READ",
|
||||||
|
"GRADE_RECORD_MANAGE",
|
||||||
|
"ATTENDANCE_READ",
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
|
||||||
|
const STUDENT_USER = {
|
||||||
|
role: "student" as const,
|
||||||
|
bitmap: encodePermissionsBitmap([
|
||||||
|
"DASHBOARD_STUDENT_READ",
|
||||||
|
"DASHBOARD_READ",
|
||||||
|
"ERROR_BOOK_READ",
|
||||||
|
"LEARNING_PATH_READ",
|
||||||
|
"AI_TUTOR_USE",
|
||||||
|
"ELECTIVE_SELECT",
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
|
||||||
|
const PARENT_USER = {
|
||||||
|
role: "parent" as const,
|
||||||
|
bitmap: encodePermissionsBitmap([
|
||||||
|
"DASHBOARD_PARENT_READ",
|
||||||
|
"DASHBOARD_READ",
|
||||||
|
"GRADE_READ_CHILD",
|
||||||
|
"LEAVE_APPROVAL_MANAGE",
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("E2E: 三层安全边界", () => {
|
||||||
|
describe("L1 角色门禁", () => {
|
||||||
|
it("admin 访问 admin 仪表盘 → 放行", () => {
|
||||||
|
const result = checkRoutePermission(
|
||||||
|
"/shell/admin",
|
||||||
|
ADMIN_USER.bitmap,
|
||||||
|
ADMIN_USER.role,
|
||||||
|
);
|
||||||
|
expect(result.allowed).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("teacher 访问 admin 仪表盘 → 拒绝(missing_role)", () => {
|
||||||
|
const result = checkRoutePermission(
|
||||||
|
"/shell/admin",
|
||||||
|
TEACHER_USER.bitmap,
|
||||||
|
TEACHER_USER.role,
|
||||||
|
);
|
||||||
|
expect(result.allowed).toBe(false);
|
||||||
|
expect(result.reason).toBe("missing_role");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("student 访问 teacher 仪表盘 → 拒绝(missing_role)", () => {
|
||||||
|
const result = checkRoutePermission(
|
||||||
|
"/shell/teacher",
|
||||||
|
STUDENT_USER.bitmap,
|
||||||
|
STUDENT_USER.role,
|
||||||
|
);
|
||||||
|
expect(result.allowed).toBe(false);
|
||||||
|
expect(result.reason).toBe("missing_role");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parent 访问 student 仪表盘 → 拒绝(missing_role)", () => {
|
||||||
|
const result = checkRoutePermission(
|
||||||
|
"/shell/student",
|
||||||
|
PARENT_USER.bitmap,
|
||||||
|
PARENT_USER.role,
|
||||||
|
);
|
||||||
|
expect(result.allowed).toBe(false);
|
||||||
|
expect(result.reason).toBe("missing_role");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("4 角色各自访问自己的仪表盘 → 全部放行", () => {
|
||||||
|
expect(
|
||||||
|
checkRoutePermission("/shell/admin", ADMIN_USER.bitmap, ADMIN_USER.role)
|
||||||
|
.allowed,
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
checkRoutePermission(
|
||||||
|
"/shell/teacher",
|
||||||
|
TEACHER_USER.bitmap,
|
||||||
|
TEACHER_USER.role,
|
||||||
|
).allowed,
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
checkRoutePermission(
|
||||||
|
"/shell/student",
|
||||||
|
STUDENT_USER.bitmap,
|
||||||
|
STUDENT_USER.role,
|
||||||
|
).allowed,
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
checkRoutePermission(
|
||||||
|
"/shell/parent",
|
||||||
|
PARENT_USER.bitmap,
|
||||||
|
PARENT_USER.role,
|
||||||
|
).allowed,
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("L2 权限点门禁(AND 语义)", () => {
|
||||||
|
it("admin 有 USER_MANAGE → 访问用户管理放行", () => {
|
||||||
|
const result = checkRoutePermission(
|
||||||
|
"/shell/admin/users",
|
||||||
|
ADMIN_USER.bitmap,
|
||||||
|
ADMIN_USER.role,
|
||||||
|
);
|
||||||
|
expect(result.allowed).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("admin 缺少 USER_MANAGE → 访问用户管理拒绝(missing_permission)", () => {
|
||||||
|
const noUserManage = encodePermissionsBitmap([
|
||||||
|
"DASHBOARD_ADMIN_READ",
|
||||||
|
"DASHBOARD_READ",
|
||||||
|
]);
|
||||||
|
const result = checkRoutePermission(
|
||||||
|
"/shell/admin/users",
|
||||||
|
noUserManage,
|
||||||
|
"admin",
|
||||||
|
);
|
||||||
|
expect(result.allowed).toBe(false);
|
||||||
|
expect(result.reason).toBe("missing_permission");
|
||||||
|
expect(result.missingPermissions).toEqual(["USER_MANAGE"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("student 访问 AI 辅导需要 AI_TUTOR_USE 权限", () => {
|
||||||
|
const result = checkRoutePermission(
|
||||||
|
"/shell/student/ai-tutor",
|
||||||
|
STUDENT_USER.bitmap,
|
||||||
|
STUDENT_USER.role,
|
||||||
|
);
|
||||||
|
expect(result.allowed).toBe(true);
|
||||||
|
|
||||||
|
const noAi = encodePermissionsBitmap([
|
||||||
|
"DASHBOARD_STUDENT_READ",
|
||||||
|
"DASHBOARD_READ",
|
||||||
|
]);
|
||||||
|
const denied = checkRoutePermission(
|
||||||
|
"/shell/student/ai-tutor",
|
||||||
|
noAi,
|
||||||
|
"student",
|
||||||
|
);
|
||||||
|
expect(denied.allowed).toBe(false);
|
||||||
|
expect(denied.reason).toBe("missing_permission");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("L2 权限点门禁(OR 语义 - anyOfPermissions)", () => {
|
||||||
|
it("teacher 有 LESSON_PLAN_READ → 访问备课管理放行", () => {
|
||||||
|
const result = checkRoutePermission(
|
||||||
|
"/shell/teacher/lesson-plans",
|
||||||
|
TEACHER_USER.bitmap,
|
||||||
|
TEACHER_USER.role,
|
||||||
|
);
|
||||||
|
expect(result.allowed).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("teacher 有 QUESTION_READ → 访问题库放行(OR 语义)", () => {
|
||||||
|
const result = checkRoutePermission(
|
||||||
|
"/shell/teacher/question-bank",
|
||||||
|
TEACHER_USER.bitmap,
|
||||||
|
TEACHER_USER.role,
|
||||||
|
);
|
||||||
|
expect(result.allowed).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("teacher 缺少所有备课权限 → 拒绝", () => {
|
||||||
|
const noLessonPlan = encodePermissionsBitmap([
|
||||||
|
"DASHBOARD_TEACHER_READ",
|
||||||
|
"DASHBOARD_READ",
|
||||||
|
"QUESTION_READ",
|
||||||
|
]);
|
||||||
|
const result = checkRoutePermission(
|
||||||
|
"/shell/teacher/lesson-plans",
|
||||||
|
noLessonPlan,
|
||||||
|
"teacher",
|
||||||
|
);
|
||||||
|
expect(result.allowed).toBe(false);
|
||||||
|
expect(result.reason).toBe("missing_permission");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("L3 数据范围(模拟)", () => {
|
||||||
|
it("parent 有 GRADE_READ_CHILD → 访问子女管理放行", () => {
|
||||||
|
const result = checkRoutePermission(
|
||||||
|
"/shell/parent/children",
|
||||||
|
PARENT_USER.bitmap,
|
||||||
|
PARENT_USER.role,
|
||||||
|
);
|
||||||
|
expect(result.allowed).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parent 有 LEAVE_APPROVAL_MANAGE → 访问请假审批放行", () => {
|
||||||
|
const result = checkRoutePermission(
|
||||||
|
"/shell/parent/leave-approval",
|
||||||
|
PARENT_USER.bitmap,
|
||||||
|
PARENT_USER.role,
|
||||||
|
);
|
||||||
|
expect(result.allowed).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("student 有 ELECTIVE_SELECT → 访问选修课选择放行(OR 语义)", () => {
|
||||||
|
const result = checkRoutePermission(
|
||||||
|
"/shell/student/electives",
|
||||||
|
STUDENT_USER.bitmap,
|
||||||
|
STUDENT_USER.role,
|
||||||
|
);
|
||||||
|
expect(result.allowed).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("student 缺少 ELECTIVE_SELECT 和 ELECTIVE_READ → 拒绝", () => {
|
||||||
|
const noElective = encodePermissionsBitmap([
|
||||||
|
"DASHBOARD_STUDENT_READ",
|
||||||
|
"DASHBOARD_READ",
|
||||||
|
]);
|
||||||
|
const result = checkRoutePermission(
|
||||||
|
"/shell/student/electives",
|
||||||
|
noElective,
|
||||||
|
"student",
|
||||||
|
);
|
||||||
|
expect(result.allowed).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("跨角色越权访问全量测试", () => {
|
||||||
|
it("admin 访问所有 admin 路由 → 全部放行", () => {
|
||||||
|
const adminRoutes = [
|
||||||
|
"/shell/admin",
|
||||||
|
"/shell/admin/users",
|
||||||
|
"/shell/admin/roles",
|
||||||
|
"/shell/admin/permissions",
|
||||||
|
"/shell/admin/audit-logs",
|
||||||
|
"/shell/admin/school",
|
||||||
|
"/shell/admin/plugins",
|
||||||
|
"/shell/admin/invitation-codes",
|
||||||
|
];
|
||||||
|
const results = batchCheckRoutePermission(
|
||||||
|
adminRoutes,
|
||||||
|
ADMIN_USER.bitmap,
|
||||||
|
ADMIN_USER.role,
|
||||||
|
);
|
||||||
|
for (const route of adminRoutes) {
|
||||||
|
expect(results[route]).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("teacher 访问所有 admin 路由 → 全部拒绝", () => {
|
||||||
|
const adminRoutes = [
|
||||||
|
"/shell/admin/users",
|
||||||
|
"/shell/admin/roles",
|
||||||
|
"/shell/admin/audit-logs",
|
||||||
|
];
|
||||||
|
const results = batchCheckRoutePermission(
|
||||||
|
adminRoutes,
|
||||||
|
TEACHER_USER.bitmap,
|
||||||
|
TEACHER_USER.role,
|
||||||
|
);
|
||||||
|
for (const route of adminRoutes) {
|
||||||
|
expect(results[route]).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("student 访问 teacher 路由 → 全部拒绝", () => {
|
||||||
|
const teacherRoutes = [
|
||||||
|
"/shell/teacher",
|
||||||
|
"/shell/teacher/lesson-plans",
|
||||||
|
"/shell/teacher/question-bank",
|
||||||
|
];
|
||||||
|
const results = batchCheckRoutePermission(
|
||||||
|
teacherRoutes,
|
||||||
|
STUDENT_USER.bitmap,
|
||||||
|
STUDENT_USER.role,
|
||||||
|
);
|
||||||
|
for (const route of teacherRoutes) {
|
||||||
|
expect(results[route]).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("公共路由所有角色均可访问", () => {
|
||||||
|
const publicRoutes = [
|
||||||
|
"/",
|
||||||
|
"/login",
|
||||||
|
"/shell/forbidden",
|
||||||
|
"/api/log",
|
||||||
|
"/api/healthz",
|
||||||
|
];
|
||||||
|
for (const user of [
|
||||||
|
ADMIN_USER,
|
||||||
|
TEACHER_USER,
|
||||||
|
STUDENT_USER,
|
||||||
|
PARENT_USER,
|
||||||
|
]) {
|
||||||
|
const results = batchCheckRoutePermission(
|
||||||
|
publicRoutes,
|
||||||
|
user.bitmap,
|
||||||
|
user.role,
|
||||||
|
);
|
||||||
|
for (const route of publicRoutes) {
|
||||||
|
expect(results[route]).toBe(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("权限提升模拟", () => {
|
||||||
|
it("admin 添加新权限后可访问新路由", () => {
|
||||||
|
// 初始 admin 没有 EXAM_GRADE
|
||||||
|
const initialResult = checkRoutePermission(
|
||||||
|
"/shell/teacher/exams/1",
|
||||||
|
ADMIN_USER.bitmap,
|
||||||
|
"admin",
|
||||||
|
);
|
||||||
|
// admin 角色匹配,但 anyOfPermissions 需要 EXAM_READ/EXAM_CREATE/EXAM_UPDATE/EXAM_GRADE
|
||||||
|
// ADMIN_USER 没有 EXAM_READ 等 → 拒绝
|
||||||
|
expect(initialResult.allowed).toBe(false);
|
||||||
|
|
||||||
|
// 添加 EXAM_READ 权限后
|
||||||
|
const withExam = encodePermissionsBitmap([
|
||||||
|
...decodePermissionsBitmap(ADMIN_USER.bitmap),
|
||||||
|
"EXAM_READ",
|
||||||
|
]);
|
||||||
|
const afterResult = checkRoutePermission(
|
||||||
|
"/shell/teacher/exams/1",
|
||||||
|
withExam,
|
||||||
|
"admin",
|
||||||
|
);
|
||||||
|
expect(afterResult.allowed).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
170
apps/portal-shell/src/__tests__/e2e/streaming.test.tsx
Normal file
170
apps/portal-shell/src/__tests__/e2e/streaming.test.tsx
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { act, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import {
|
||||||
|
Component,
|
||||||
|
Suspense,
|
||||||
|
type ErrorInfo,
|
||||||
|
type ReactNode,
|
||||||
|
use,
|
||||||
|
} from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* E2E 集成测试:流式渲染(React 19 use() + Suspense)
|
||||||
|
*
|
||||||
|
* 测试策略:
|
||||||
|
* - jsdom 环境下 React 19 use(promise) 在 promise 从 pending→resolved 切换时
|
||||||
|
* 依赖 React 内部调度器重新渲染,在测试环境中无法可靠触发。
|
||||||
|
* - 因此采用「预解析 Promise」模式:promise 在 render 前已 resolved,
|
||||||
|
* React 首次渲染时 use() 直接返回值(Suspense 不触发 fallback)。
|
||||||
|
* - 对「pending → resolved」切换的验证,改用多层 Suspense + 异步渲染断言。
|
||||||
|
*
|
||||||
|
* 关联:portal-shell README v2.0 §4 流式渲染
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 模拟使用 use() 消费 Promise 的组件 */
|
||||||
|
function AsyncContent<T>({
|
||||||
|
promise,
|
||||||
|
render,
|
||||||
|
}: {
|
||||||
|
promise: Promise<T>;
|
||||||
|
render: (data: T) => ReactNode;
|
||||||
|
}): ReactNode {
|
||||||
|
const data = use(promise);
|
||||||
|
return <>{render(data)}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 简化版 ErrorBoundary(用于测试 Promise reject 由 ErrorBoundary 捕获) */
|
||||||
|
class TestErrorBoundary extends Component<
|
||||||
|
{ children: ReactNode; fallback: ReactNode },
|
||||||
|
{ hasError: boolean }
|
||||||
|
> {
|
||||||
|
constructor(props: { children: ReactNode; fallback: ReactNode }) {
|
||||||
|
super(props);
|
||||||
|
this.state = { hasError: false };
|
||||||
|
}
|
||||||
|
static getDerivedStateFromError(): { hasError: boolean } {
|
||||||
|
return { hasError: true };
|
||||||
|
}
|
||||||
|
override componentDidCatch(_error: Error, _info: ErrorInfo): void {
|
||||||
|
// 测试中无需上报
|
||||||
|
}
|
||||||
|
override render(): ReactNode {
|
||||||
|
return this.state.hasError ? this.props.fallback : this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 创建一个可控的 pending Promise(永不自动 resolve) */
|
||||||
|
function createPendingPromise<T>(): {
|
||||||
|
promise: Promise<T>;
|
||||||
|
resolve: (value: T) => void;
|
||||||
|
} {
|
||||||
|
let resolveFn!: (value: T) => void;
|
||||||
|
const promise = new Promise<T>((resolve) => {
|
||||||
|
resolveFn = resolve;
|
||||||
|
});
|
||||||
|
return { promise, resolve: resolveFn };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("E2E: 流式渲染", () => {
|
||||||
|
it("Suspense 边界在 Promise pending 时显示骨架屏", () => {
|
||||||
|
const { promise } = createPendingPromise<string>();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<Suspense fallback={<div data-testid="skeleton">加载中...</div>}>
|
||||||
|
<AsyncContent
|
||||||
|
promise={promise}
|
||||||
|
render={(data) => <div data-testid="content">{data}</div>}
|
||||||
|
/>
|
||||||
|
</Suspense>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByTestId("skeleton")).toBeTruthy();
|
||||||
|
expect(screen.queryByTestId("content")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("已解析的 Promise 渲染实际内容(不触发 Suspense fallback)", async () => {
|
||||||
|
// 预解析的 Promise:React 首次渲染时 use() 直接返回值
|
||||||
|
const resolvedPromise = Promise.resolve("实际数据");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
render(
|
||||||
|
<Suspense fallback={<div data-testid="skeleton">加载中...</div>}>
|
||||||
|
<AsyncContent
|
||||||
|
promise={resolvedPromise}
|
||||||
|
render={(data) => <div data-testid="content">{data}</div>}
|
||||||
|
/>
|
||||||
|
</Suspense>,
|
||||||
|
);
|
||||||
|
// 等待微任务队列清空,让 React 处理已解析的 Promise
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId("content")).toBeTruthy();
|
||||||
|
});
|
||||||
|
expect(screen.getByTestId("content").textContent).toBe("实际数据");
|
||||||
|
expect(screen.queryByTestId("skeleton")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("多层 Suspense 边界各自独立解析(外层预解析、内层 pending)", async () => {
|
||||||
|
const outerPromise = Promise.resolve("外层");
|
||||||
|
const inner = createPendingPromise<string>();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
render(
|
||||||
|
<Suspense fallback={<div data-testid="outer-skeleton">外层骨架</div>}>
|
||||||
|
<AsyncContent
|
||||||
|
promise={outerPromise}
|
||||||
|
render={() => (
|
||||||
|
<div data-testid="outer-content">
|
||||||
|
外层已加载
|
||||||
|
<Suspense
|
||||||
|
fallback={<div data-testid="inner-skeleton">内层骨架</div>}
|
||||||
|
>
|
||||||
|
<AsyncContent
|
||||||
|
promise={inner.promise}
|
||||||
|
render={() => (
|
||||||
|
<div data-testid="inner-content">内层已加载</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Suspense>,
|
||||||
|
);
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 外层已解析 → 外层内容显示,内层仍 pending → 内层骨架显示
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId("outer-content")).toBeTruthy();
|
||||||
|
});
|
||||||
|
expect(screen.getByTestId("inner-skeleton")).toBeTruthy();
|
||||||
|
expect(screen.queryByTestId("inner-content")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("已 reject 的 Promise 由 ErrorBoundary 捕获(而非 Suspense)", async () => {
|
||||||
|
// 预 reject 的 Promise:React 渲染时 use() 抛出错误
|
||||||
|
const rejectedPromise = Promise.reject(new Error("数据加载失败"));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
render(
|
||||||
|
<TestErrorBoundary fallback={<div data-testid="error">加载失败</div>}>
|
||||||
|
<Suspense fallback={<div data-testid="skeleton">加载中...</div>}>
|
||||||
|
<AsyncContent
|
||||||
|
promise={rejectedPromise}
|
||||||
|
render={() => <div data-testid="content">不应显示</div>}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
</TestErrorBoundary>,
|
||||||
|
);
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId("error")).toBeTruthy();
|
||||||
|
});
|
||||||
|
expect(screen.queryByTestId("content")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
15
apps/portal-shell/src/__tests__/setup.ts
Normal file
15
apps/portal-shell/src/__tests__/setup.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* vitest 全局 setup(portal-shell v2.0 P4 E2E 测试)
|
||||||
|
*
|
||||||
|
* 1. 显式启用 React act 环境(React 19 + @testing-library/react 需要)
|
||||||
|
* 2. 注册 @testing-library/jest-dom matchers(toBeInTheDocument 等)
|
||||||
|
*/
|
||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
|
||||||
|
// React 19 act 环境标记:确保所有 React 状态更新都在 act() 内执行
|
||||||
|
// 缺失此标记会导致 Suspense/use() 在测试中不触发重新渲染
|
||||||
|
declare global {
|
||||||
|
var IS_REACT_ACT_ENVIRONMENT: boolean | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
|
import { type ReactNode } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PluginBoundary 插件级错误边界 + 流式 Suspense 测试
|
||||||
|
*
|
||||||
|
* 覆盖:
|
||||||
|
* - 正常渲染 children
|
||||||
|
* - 子组件抛错时显示 fallback(含 pluginId 和错误消息)
|
||||||
|
* - 重试按钮触发 reset
|
||||||
|
* - 5 种骨架变体渲染(card/list/chart/stats/table)
|
||||||
|
* - 错误自动上报 useErrorReport
|
||||||
|
* 关联:portal-shell README v2.0 §5.4 三级错误处理(L3 插件级)
|
||||||
|
*/
|
||||||
|
|
||||||
|
// mock useErrorReport
|
||||||
|
const reportErrorMock = vi.fn();
|
||||||
|
vi.mock("@edu/hooks", () => ({
|
||||||
|
useErrorReport: () => reportErrorMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import {
|
||||||
|
PluginBoundary,
|
||||||
|
PluginSkeleton,
|
||||||
|
} from "@/shared/components/plugin-boundary";
|
||||||
|
|
||||||
|
// 工具:制造抛错组件
|
||||||
|
function ThrowOnRender({ message }: { message: string }): ReactNode {
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
function GoodComponent(): ReactNode {
|
||||||
|
return <div data-testid="good">正常内容</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("PluginBoundary", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
// 清除 console.error 噪音(React ErrorBoundary 会打 console.error)
|
||||||
|
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("正常渲染 children", () => {
|
||||||
|
render(
|
||||||
|
<PluginBoundary pluginId="test-plugin">
|
||||||
|
<GoodComponent />
|
||||||
|
</PluginBoundary>,
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId("good")).toBeTruthy();
|
||||||
|
expect(screen.queryByText("插件加载失败")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("子组件抛错时显示 fallback", () => {
|
||||||
|
render(
|
||||||
|
<PluginBoundary pluginId="bad-plugin">
|
||||||
|
<ThrowOnRender message="渲染崩溃" />
|
||||||
|
</PluginBoundary>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("插件加载失败")).toBeTruthy();
|
||||||
|
expect(screen.getByText(/bad-plugin: 渲染崩溃/)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("错误触发 useErrorReport 上报", () => {
|
||||||
|
render(
|
||||||
|
<PluginBoundary pluginId="report-plugin">
|
||||||
|
<ThrowOnRender message="需上报的错误" />
|
||||||
|
</PluginBoundary>,
|
||||||
|
);
|
||||||
|
expect(reportErrorMock).toHaveBeenCalledTimes(1);
|
||||||
|
const [error, options] = reportErrorMock.mock.calls[0]!;
|
||||||
|
expect(error).toBeInstanceOf(Error);
|
||||||
|
expect((error as Error).message).toBe("需上报的错误");
|
||||||
|
expect(options).toEqual({
|
||||||
|
pluginId: "report-plugin",
|
||||||
|
level: "error",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("重试按钮触发 reset 并重新渲染", () => {
|
||||||
|
let shouldThrow = true;
|
||||||
|
function FlakyComponent(): ReactNode {
|
||||||
|
if (shouldThrow) throw new Error("偶发错误");
|
||||||
|
return <div data-testid="recovered">恢复</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
render(
|
||||||
|
<PluginBoundary pluginId="flaky-plugin">
|
||||||
|
<FlakyComponent />
|
||||||
|
</PluginBoundary>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("插件加载失败")).toBeTruthy();
|
||||||
|
|
||||||
|
// 切换为不抛错
|
||||||
|
shouldThrow = false;
|
||||||
|
fireEvent.click(screen.getByText("重试"));
|
||||||
|
|
||||||
|
expect(screen.getByTestId("recovered")).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("PluginSkeleton 骨架变体", () => {
|
||||||
|
it("card 变体(默认)渲染骨架", () => {
|
||||||
|
const { container } = render(<PluginSkeleton variant="card" />);
|
||||||
|
expect(container.querySelector('[role="status"]')).toBeTruthy();
|
||||||
|
expect(container.querySelector('[aria-label="加载中"]')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("list 变体渲染 4 行骨架", () => {
|
||||||
|
const { container } = render(<PluginSkeleton variant="list" />);
|
||||||
|
const skeletons = container.querySelectorAll('[class*="h-12"]');
|
||||||
|
expect(skeletons.length).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("chart 变体渲染柱状骨架", () => {
|
||||||
|
const { container } = render(<PluginSkeleton variant="chart" />);
|
||||||
|
const bars = container.querySelectorAll('[class*="flex-1"]');
|
||||||
|
expect(bars.length).toBe(7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stats 变体渲染 3 列统计骨架", () => {
|
||||||
|
const { container } = render(<PluginSkeleton variant="stats" />);
|
||||||
|
const cols = container.querySelectorAll(".flex-1");
|
||||||
|
expect(cols.length).toBeGreaterThanOrEqual(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("table 变体渲染表头 + 4 行", () => {
|
||||||
|
const { container } = render(<PluginSkeleton variant="table" />);
|
||||||
|
const rows = container.querySelectorAll('[class*="h-10"]');
|
||||||
|
expect(rows.length).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("所有变体都有 aria-live=polite 和 role=status", () => {
|
||||||
|
for (const variant of [
|
||||||
|
"card",
|
||||||
|
"list",
|
||||||
|
"chart",
|
||||||
|
"stats",
|
||||||
|
"table",
|
||||||
|
] as const) {
|
||||||
|
const { container } = render(<PluginSkeleton variant={variant} />);
|
||||||
|
const status = container.querySelector('[role="status"]');
|
||||||
|
expect(status).toBeTruthy();
|
||||||
|
expect(status?.getAttribute("aria-live")).toBe("polite");
|
||||||
|
container.remove();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
152
apps/portal-shell/src/shared/lib/__tests__/notify.test.ts
Normal file
152
apps/portal-shell/src/shared/lib/__tests__/notify.test.ts
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* notify 统一 Toast 封装测试
|
||||||
|
*
|
||||||
|
* 覆盖:success/error/warning/info/promise/loading/message/dismiss 方法委托
|
||||||
|
* error 默认 duration=6000
|
||||||
|
* promise 透传原 Promise(便于链式调用)
|
||||||
|
* 关联:portal-shell README v2.0 §5.4
|
||||||
|
*/
|
||||||
|
|
||||||
|
// mock sonner 模块:toast 既是可调用函数,又有方法(success/error/etc.)
|
||||||
|
// vi.hoisted 确保 mock 变量在 vi.mock 提升前初始化
|
||||||
|
const { toastMock } = vi.hoisted(() => {
|
||||||
|
const fn = vi.fn() as unknown as {
|
||||||
|
(): void;
|
||||||
|
success: ReturnType<typeof vi.fn>;
|
||||||
|
error: ReturnType<typeof vi.fn>;
|
||||||
|
warning: ReturnType<typeof vi.fn>;
|
||||||
|
info: ReturnType<typeof vi.fn>;
|
||||||
|
promise: ReturnType<typeof vi.fn>;
|
||||||
|
loading: ReturnType<typeof vi.fn>;
|
||||||
|
message: ReturnType<typeof vi.fn>;
|
||||||
|
dismiss: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
|
fn.success = vi.fn();
|
||||||
|
fn.error = vi.fn();
|
||||||
|
fn.warning = vi.fn();
|
||||||
|
fn.info = vi.fn();
|
||||||
|
fn.promise = vi.fn();
|
||||||
|
fn.loading = vi.fn();
|
||||||
|
fn.message = vi.fn();
|
||||||
|
fn.dismiss = vi.fn();
|
||||||
|
return { toastMock: fn };
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("sonner", () => ({
|
||||||
|
toast: toastMock,
|
||||||
|
ExternalToast: {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 导入被测模块(在 mock 之后)
|
||||||
|
import { notify } from "@/shared/lib/notify";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
describe("notify", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("success", () => {
|
||||||
|
it("委托 toast.success", () => {
|
||||||
|
notify.success("保存成功");
|
||||||
|
expect(toast.success).toHaveBeenCalledWith("保存成功", undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("传递 options", () => {
|
||||||
|
notify.success("保存成功", { duration: 3000 });
|
||||||
|
expect(toast.success).toHaveBeenCalledWith("保存成功", {
|
||||||
|
duration: 3000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("error", () => {
|
||||||
|
it("委托 toast.error,默认 duration=6000", () => {
|
||||||
|
notify.error("网络错误");
|
||||||
|
expect(toast.error).toHaveBeenCalledWith("网络错误", {
|
||||||
|
duration: 6000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("options 可覆盖默认 duration", () => {
|
||||||
|
notify.error("网络错误", { duration: 2000 });
|
||||||
|
expect(toast.error).toHaveBeenCalledWith("网络错误", {
|
||||||
|
duration: 2000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("options 可追加 description 等字段", () => {
|
||||||
|
notify.error("网络错误", { description: "请检查网络连接" });
|
||||||
|
expect(toast.error).toHaveBeenCalledWith("网络错误", {
|
||||||
|
duration: 6000,
|
||||||
|
description: "请检查网络连接",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("warning", () => {
|
||||||
|
it("委托 toast.warning", () => {
|
||||||
|
notify.warning("警告信息");
|
||||||
|
expect(toast.warning).toHaveBeenCalledWith("警告信息", undefined);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("info", () => {
|
||||||
|
it("委托 toast.info", () => {
|
||||||
|
notify.info("提示信息");
|
||||||
|
expect(toast.info).toHaveBeenCalledWith("提示信息", undefined);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("promise", () => {
|
||||||
|
it("调用 toast.promise 并透传原 Promise", async () => {
|
||||||
|
const originalPromise = Promise.resolve("data");
|
||||||
|
const options = {
|
||||||
|
loading: "加载中",
|
||||||
|
success: "成功",
|
||||||
|
error: "失败",
|
||||||
|
};
|
||||||
|
const result = notify.promise(originalPromise, options);
|
||||||
|
expect(toast.promise).toHaveBeenCalledWith(originalPromise, options);
|
||||||
|
expect(result).toBe(originalPromise);
|
||||||
|
await expect(result).resolves.toBe("data");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("支持函数式 success/error 回调", async () => {
|
||||||
|
const originalPromise = Promise.reject(new Error("fail"));
|
||||||
|
const options = {
|
||||||
|
loading: "加载中",
|
||||||
|
success: (data: unknown) => `成功: ${data}`,
|
||||||
|
error: (err: unknown) => `失败: ${(err as Error).message}`,
|
||||||
|
};
|
||||||
|
const result = notify.promise(originalPromise, options);
|
||||||
|
expect(toast.promise).toHaveBeenCalledWith(originalPromise, options);
|
||||||
|
await expect(result).rejects.toThrow("fail");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("loading", () => {
|
||||||
|
it("委托 toast.loading 并返回 toast id", () => {
|
||||||
|
vi.mocked(toast.loading).mockReturnValue("toast-1");
|
||||||
|
const id = notify.loading("加载中");
|
||||||
|
expect(toast.loading).toHaveBeenCalledWith("加载中", undefined);
|
||||||
|
expect(id).toBe("toast-1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("message", () => {
|
||||||
|
it("委托 toast 作为函数调用", () => {
|
||||||
|
notify.message("自定义消息");
|
||||||
|
expect(toast).toHaveBeenCalledWith("自定义消息", undefined);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("dismiss", () => {
|
||||||
|
it("委托 toast.dismiss 关闭所有", () => {
|
||||||
|
notify.dismiss();
|
||||||
|
expect(toast.dismiss).toHaveBeenCalledWith();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
encodePermissionsBitmap,
|
||||||
|
decodePermissionsBitmap,
|
||||||
|
hasPermissionInBitmap,
|
||||||
|
hasAnyPermissionInBitmap,
|
||||||
|
hasAllPermissionsInBitmap,
|
||||||
|
isValidPermission,
|
||||||
|
PERMISSION_BITMAP_ORDER,
|
||||||
|
} from "@edu/shared-ts/permission-bitmap";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 权限位图编解码测试
|
||||||
|
*
|
||||||
|
* 覆盖:编码/解码互逆性、空输入、未知权限、单点检查、批量检查、无效字符
|
||||||
|
* 关联:portal-shell README v2.0 §3.3 三层安全边界
|
||||||
|
*/
|
||||||
|
describe("permission-bitmap", () => {
|
||||||
|
describe("encodePermissionsBitmap", () => {
|
||||||
|
it("空数组编码为 0", () => {
|
||||||
|
expect(encodePermissionsBitmap([])).toBe("0");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("单个权限点编码正确", () => {
|
||||||
|
// 第一个权限点 DASHBOARD_ADMIN_READ = bit 0 → 1 → "1"
|
||||||
|
expect(encodePermissionsBitmap(["DASHBOARD_ADMIN_READ"])).toBe("1");
|
||||||
|
// 第二个权限点 DASHBOARD_TEACHER_READ = bit 1 → 2 → "2"
|
||||||
|
expect(encodePermissionsBitmap(["DASHBOARD_TEACHER_READ"])).toBe("2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("多个权限点合并编码", () => {
|
||||||
|
// bit 0 + bit 1 = 3 → "3"
|
||||||
|
const encoded = encodePermissionsBitmap([
|
||||||
|
"DASHBOARD_ADMIN_READ",
|
||||||
|
"DASHBOARD_TEACHER_READ",
|
||||||
|
]);
|
||||||
|
expect(encoded).toBe("3");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("未知权限静默忽略", () => {
|
||||||
|
const valid = encodePermissionsBitmap(["DASHBOARD_ADMIN_READ"]);
|
||||||
|
const withUnknown = encodePermissionsBitmap([
|
||||||
|
"DASHBOARD_ADMIN_READ",
|
||||||
|
"UNKNOWN_PERMISSION_XYZ",
|
||||||
|
]);
|
||||||
|
expect(withUnknown).toBe(valid);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("decodePermissionsBitmap", () => {
|
||||||
|
it("编码解码互逆", () => {
|
||||||
|
const perms = [
|
||||||
|
"DASHBOARD_ADMIN_READ",
|
||||||
|
"USER_MANAGE",
|
||||||
|
"ROLE_READ",
|
||||||
|
"EXAM_CREATE",
|
||||||
|
];
|
||||||
|
const encoded = encodePermissionsBitmap(perms);
|
||||||
|
const decoded = decodePermissionsBitmap(encoded);
|
||||||
|
expect(decoded.sort()).toEqual([...perms].sort());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("空字符串返回空数组", () => {
|
||||||
|
expect(decodePermissionsBitmap("")).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("无效字符返回空数组", () => {
|
||||||
|
expect(decodePermissionsBitmap("abc!def")).toEqual([]);
|
||||||
|
expect(decodePermissionsBitmap("abc def")).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("0 解码为空数组", () => {
|
||||||
|
expect(decodePermissionsBitmap("0")).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("hasPermissionInBitmap", () => {
|
||||||
|
it("拥有的权限返回 true", () => {
|
||||||
|
const encoded = encodePermissionsBitmap(["USER_MANAGE"]);
|
||||||
|
expect(hasPermissionInBitmap(encoded, "USER_MANAGE")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("未拥有的权限返回 false", () => {
|
||||||
|
const encoded = encodePermissionsBitmap(["USER_MANAGE"]);
|
||||||
|
expect(hasPermissionInBitmap(encoded, "ROLE_READ")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("未知权限点返回 false", () => {
|
||||||
|
const encoded = encodePermissionsBitmap(["USER_MANAGE"]);
|
||||||
|
expect(hasPermissionInBitmap(encoded, "UNKNOWN_PERM")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("无效位图返回 false", () => {
|
||||||
|
expect(hasPermissionInBitmap("invalid!", "USER_MANAGE")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("空位图返回 false", () => {
|
||||||
|
expect(hasPermissionInBitmap("", "USER_MANAGE")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("hasAnyPermissionInBitmap", () => {
|
||||||
|
it("任一权限满足即 true", () => {
|
||||||
|
const encoded = encodePermissionsBitmap(["USER_MANAGE"]);
|
||||||
|
expect(
|
||||||
|
hasAnyPermissionInBitmap(encoded, ["USER_MANAGE", "ROLE_READ"]),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("全部不满足即 false", () => {
|
||||||
|
const encoded = encodePermissionsBitmap(["USER_MANAGE"]);
|
||||||
|
expect(
|
||||||
|
hasAnyPermissionInBitmap(encoded, ["ROLE_READ", "EXAM_CREATE"]),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("空数组返回 false", () => {
|
||||||
|
const encoded = encodePermissionsBitmap(["USER_MANAGE"]);
|
||||||
|
expect(hasAnyPermissionInBitmap(encoded, [])).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("hasAllPermissionsInBitmap", () => {
|
||||||
|
it("全部权限满足即 true", () => {
|
||||||
|
const encoded = encodePermissionsBitmap([
|
||||||
|
"USER_MANAGE",
|
||||||
|
"ROLE_READ",
|
||||||
|
"EXAM_CREATE",
|
||||||
|
]);
|
||||||
|
expect(
|
||||||
|
hasAllPermissionsInBitmap(encoded, ["USER_MANAGE", "ROLE_READ"]),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("部分不满足即 false", () => {
|
||||||
|
const encoded = encodePermissionsBitmap(["USER_MANAGE"]);
|
||||||
|
expect(
|
||||||
|
hasAllPermissionsInBitmap(encoded, ["USER_MANAGE", "ROLE_READ"]),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("空数组返回 true(vacuous truth)", () => {
|
||||||
|
const encoded = encodePermissionsBitmap(["USER_MANAGE"]);
|
||||||
|
expect(hasAllPermissionsInBitmap(encoded, [])).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isValidPermission", () => {
|
||||||
|
it("已知权限点返回 true", () => {
|
||||||
|
expect(isValidPermission("DASHBOARD_ADMIN_READ")).toBe(true);
|
||||||
|
expect(isValidPermission("USER_MANAGE")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("未知权限点返回 false", () => {
|
||||||
|
expect(isValidPermission("UNKNOWN_PERM")).toBe(false);
|
||||||
|
expect(isValidPermission("")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("PERMISSION_BITMAP_ORDER 全部合法", () => {
|
||||||
|
for (const perm of PERMISSION_BITMAP_ORDER) {
|
||||||
|
expect(isValidPermission(perm)).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("全量编解码压力测试", () => {
|
||||||
|
it("全部权限点编码后解码应还原(去重比较,已知 GRADE_READ 在 ORDER 中重复)", () => {
|
||||||
|
const allPerms = [...new Set(PERMISSION_BITMAP_ORDER)];
|
||||||
|
const encoded = encodePermissionsBitmap(allPerms);
|
||||||
|
const decoded = decodePermissionsBitmap(encoded);
|
||||||
|
expect(decoded.sort()).toEqual([...allPerms].sort());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("全部权限点位图长度合理(base36 < 20 字符)", () => {
|
||||||
|
const allPerms = [...new Set(PERMISSION_BITMAP_ORDER)];
|
||||||
|
const encoded = encodePermissionsBitmap(allPerms);
|
||||||
|
// 67 bit → base36 约 14 字符
|
||||||
|
expect(encoded.length).toBeLessThan(20);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
checkRoutePermission,
|
||||||
|
batchCheckRoutePermission,
|
||||||
|
validateRoutePermissionConfigs,
|
||||||
|
EXACT_ROUTE_PERMISSIONS,
|
||||||
|
PREFIX_ROUTE_PERMISSIONS,
|
||||||
|
DASHBOARD_ROUTE_PERMISSIONS,
|
||||||
|
} from "@/shared/lib/route-permissions";
|
||||||
|
import { encodePermissionsBitmap } from "@edu/shared-ts/permission-bitmap";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 路由权限配置测试
|
||||||
|
*
|
||||||
|
* 覆盖:4 张表优先级匹配、L1 角色门禁、L2 权限点门禁(AND/OR)、公共路由放行、批量检查、配置合法性
|
||||||
|
* 关联:portal-shell README v2.0 §3.3 三层安全边界
|
||||||
|
*/
|
||||||
|
|
||||||
|
// 测试用 bitmap
|
||||||
|
const ADMIN_BITMAP = encodePermissionsBitmap([
|
||||||
|
"DASHBOARD_ADMIN_READ",
|
||||||
|
"USER_MANAGE",
|
||||||
|
"ROLE_MANAGE",
|
||||||
|
"PERMISSION_MANAGE",
|
||||||
|
"AUDIT_LOG_READ",
|
||||||
|
"SCHOOL_MANAGE",
|
||||||
|
"PLUGIN_REGISTRY_MANAGE",
|
||||||
|
"DASHBOARD_READ",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const TEACHER_BITMAP = encodePermissionsBitmap([
|
||||||
|
"DASHBOARD_TEACHER_READ",
|
||||||
|
"DASHBOARD_READ",
|
||||||
|
"LESSON_PLAN_READ",
|
||||||
|
"QUESTION_READ",
|
||||||
|
"TEXTBOOK_READ",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const STUDENT_BITMAP = encodePermissionsBitmap([
|
||||||
|
"DASHBOARD_STUDENT_READ",
|
||||||
|
"DASHBOARD_READ",
|
||||||
|
"ERROR_BOOK_READ",
|
||||||
|
"LEARNING_PATH_READ",
|
||||||
|
"AI_TUTOR_USE",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const PARENT_BITMAP = encodePermissionsBitmap([
|
||||||
|
"DASHBOARD_PARENT_READ",
|
||||||
|
"DASHBOARD_READ",
|
||||||
|
"GRADE_READ_CHILD",
|
||||||
|
"LEAVE_APPROVAL_MANAGE",
|
||||||
|
]);
|
||||||
|
|
||||||
|
describe("route-permissions", () => {
|
||||||
|
describe("EXACT 路由匹配(最高优先级)", () => {
|
||||||
|
it("admin 用户访问 /shell/admin/users 且有 USER_MANAGE 权限 → 放行", () => {
|
||||||
|
const result = checkRoutePermission(
|
||||||
|
"/shell/admin/users",
|
||||||
|
ADMIN_BITMAP,
|
||||||
|
"admin",
|
||||||
|
);
|
||||||
|
expect(result.allowed).toBe(true);
|
||||||
|
expect(result.matchedPath).toBe("/shell/admin/users");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("teacher 角色访问 admin 路由 → 拒绝(missing_role)", () => {
|
||||||
|
const result = checkRoutePermission(
|
||||||
|
"/shell/admin/users",
|
||||||
|
ADMIN_BITMAP,
|
||||||
|
"teacher",
|
||||||
|
);
|
||||||
|
expect(result.allowed).toBe(false);
|
||||||
|
expect(result.reason).toBe("missing_role");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("admin 角色但缺少 USER_MANAGE 权限 → 拒绝(missing_permission)", () => {
|
||||||
|
const noUserManage = encodePermissionsBitmap([
|
||||||
|
"DASHBOARD_ADMIN_READ",
|
||||||
|
"DASHBOARD_READ",
|
||||||
|
]);
|
||||||
|
const result = checkRoutePermission(
|
||||||
|
"/shell/admin/users",
|
||||||
|
noUserManage,
|
||||||
|
"admin",
|
||||||
|
);
|
||||||
|
expect(result.allowed).toBe(false);
|
||||||
|
expect(result.reason).toBe("missing_permission");
|
||||||
|
expect(result.missingPermissions).toEqual(["USER_MANAGE"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("anyOfPermissions OR 语义:满足任一即放行", () => {
|
||||||
|
const result = checkRoutePermission(
|
||||||
|
"/shell/admin/invitation-codes",
|
||||||
|
encodePermissionsBitmap(["INVITATION_CODE_CREATE"]),
|
||||||
|
"admin",
|
||||||
|
);
|
||||||
|
expect(result.allowed).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("anyOfPermissions OR 语义:全不满足即拒绝", () => {
|
||||||
|
const result = checkRoutePermission(
|
||||||
|
"/shell/admin/invitation-codes",
|
||||||
|
encodePermissionsBitmap([]),
|
||||||
|
"admin",
|
||||||
|
);
|
||||||
|
expect(result.allowed).toBe(false);
|
||||||
|
expect(result.reason).toBe("missing_permission");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("PREFIX 路由匹配(中优先级)", () => {
|
||||||
|
it("/shell/admin/ 子路由要求 admin 角色", () => {
|
||||||
|
const result = checkRoutePermission(
|
||||||
|
"/shell/admin/unknown-page",
|
||||||
|
ADMIN_BITMAP,
|
||||||
|
"admin",
|
||||||
|
);
|
||||||
|
expect(result.allowed).toBe(true);
|
||||||
|
expect(result.matchedPath).toBe("/shell/admin/");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("/shell/admin/ 子路由拒绝非 admin 角色", () => {
|
||||||
|
const result = checkRoutePermission(
|
||||||
|
"/shell/admin/unknown-page",
|
||||||
|
TEACHER_BITMAP,
|
||||||
|
"teacher",
|
||||||
|
);
|
||||||
|
expect(result.allowed).toBe(false);
|
||||||
|
expect(result.reason).toBe("missing_role");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("/shell/teacher/exams/ 前缀匹配", () => {
|
||||||
|
const result = checkRoutePermission(
|
||||||
|
"/shell/teacher/exams/123",
|
||||||
|
TEACHER_BITMAP,
|
||||||
|
"teacher",
|
||||||
|
);
|
||||||
|
// TEACHER_BITMAP 没有 EXAM_READ,需要检查 anyOf
|
||||||
|
expect(result.allowed).toBe(false);
|
||||||
|
expect(result.reason).toBe("missing_permission");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("前缀匹配不误匹配(/shell/admin 不应匹配 /shell/admin-users)", () => {
|
||||||
|
// /shell/admin-users 不匹配 /shell/admin/ 前缀(因为前缀以 / 结尾)
|
||||||
|
const result = checkRoutePermission(
|
||||||
|
"/shell/admin-users",
|
||||||
|
ADMIN_BITMAP,
|
||||||
|
"admin",
|
||||||
|
);
|
||||||
|
// 不匹配任何前缀,应走到仪表盘或公共路由
|
||||||
|
expect(result.matchedPath).not.toBe("/shell/admin/");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("DASHBOARD 路由匹配(低优先级)", () => {
|
||||||
|
it("admin 仪表盘根路径", () => {
|
||||||
|
const result = checkRoutePermission(
|
||||||
|
"/shell/admin",
|
||||||
|
ADMIN_BITMAP,
|
||||||
|
"admin",
|
||||||
|
);
|
||||||
|
expect(result.allowed).toBe(true);
|
||||||
|
expect(result.matchedPath).toBe("/shell/admin");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("teacher 仪表盘根路径", () => {
|
||||||
|
const result = checkRoutePermission(
|
||||||
|
"/shell/teacher",
|
||||||
|
TEACHER_BITMAP,
|
||||||
|
"teacher",
|
||||||
|
);
|
||||||
|
expect(result.allowed).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("student 仪表盘根路径", () => {
|
||||||
|
const result = checkRoutePermission(
|
||||||
|
"/shell/student",
|
||||||
|
STUDENT_BITMAP,
|
||||||
|
"student",
|
||||||
|
);
|
||||||
|
expect(result.allowed).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parent 仪表盘根路径", () => {
|
||||||
|
const result = checkRoutePermission(
|
||||||
|
"/shell/parent",
|
||||||
|
PARENT_BITMAP,
|
||||||
|
"parent",
|
||||||
|
);
|
||||||
|
expect(result.allowed).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("角色不匹配 → 拒绝", () => {
|
||||||
|
const result = checkRoutePermission(
|
||||||
|
"/shell/admin",
|
||||||
|
TEACHER_BITMAP,
|
||||||
|
"teacher",
|
||||||
|
);
|
||||||
|
expect(result.allowed).toBe(false);
|
||||||
|
expect(result.reason).toBe("missing_role");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("通用仪表盘 /shell 只需 DASHBOARD_READ", () => {
|
||||||
|
const result = checkRoutePermission(
|
||||||
|
"/shell",
|
||||||
|
encodePermissionsBitmap(["DASHBOARD_READ"]),
|
||||||
|
"teacher",
|
||||||
|
);
|
||||||
|
expect(result.allowed).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("API 路由匹配", () => {
|
||||||
|
it("/api/log 所有登录用户可访问", () => {
|
||||||
|
const result = checkRoutePermission(
|
||||||
|
"/api/log",
|
||||||
|
encodePermissionsBitmap([]),
|
||||||
|
"student",
|
||||||
|
);
|
||||||
|
expect(result.allowed).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("/api/healthz 公开访问", () => {
|
||||||
|
const result = checkRoutePermission(
|
||||||
|
"/api/healthz",
|
||||||
|
encodePermissionsBitmap([]),
|
||||||
|
"student",
|
||||||
|
);
|
||||||
|
expect(result.allowed).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("未配置的 API 路由默认拒绝", () => {
|
||||||
|
const result = checkRoutePermission(
|
||||||
|
"/api/unknown",
|
||||||
|
ADMIN_BITMAP,
|
||||||
|
"admin",
|
||||||
|
);
|
||||||
|
expect(result.allowed).toBe(false);
|
||||||
|
expect(result.reason).toBe("no_config");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("公共路由默认放行", () => {
|
||||||
|
it("/ 根路径放行", () => {
|
||||||
|
const result = checkRoutePermission("/", "", "student");
|
||||||
|
expect(result.allowed).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("/login 放行", () => {
|
||||||
|
const result = checkRoutePermission("/login", "", "student");
|
||||||
|
expect(result.allowed).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("/shell/forbidden 放行", () => {
|
||||||
|
const result = checkRoutePermission("/shell/forbidden", "", "student");
|
||||||
|
expect(result.allowed).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("batchCheckRoutePermission", () => {
|
||||||
|
it("批量检查多个路径", () => {
|
||||||
|
const paths = [
|
||||||
|
"/shell/admin/users",
|
||||||
|
"/shell/teacher",
|
||||||
|
"/shell/student",
|
||||||
|
"/api/log",
|
||||||
|
];
|
||||||
|
const result = batchCheckRoutePermission(paths, ADMIN_BITMAP, "admin");
|
||||||
|
expect(result["/shell/admin/users"]).toBe(true);
|
||||||
|
expect(result["/shell/teacher"]).toBe(false); // admin 没有 DASHBOARD_TEACHER_READ
|
||||||
|
expect(result["/shell/student"]).toBe(false);
|
||||||
|
expect(result["/api/log"]).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("validateRoutePermissionConfigs", () => {
|
||||||
|
it("所有配置的权限点应合法", () => {
|
||||||
|
const invalid = validateRoutePermissionConfigs();
|
||||||
|
expect(invalid).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("配置表非空校验", () => {
|
||||||
|
it("EXACT_ROUTE_PERMISSIONS 非空", () => {
|
||||||
|
expect(Object.keys(EXACT_ROUTE_PERMISSIONS).length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("PREFIX_ROUTE_PERMISSIONS 非空", () => {
|
||||||
|
expect(PREFIX_ROUTE_PERMISSIONS.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("DASHBOARD_ROUTE_PERMISSIONS 包含 4 个角色 + 通用", () => {
|
||||||
|
expect(DASHBOARD_ROUTE_PERMISSIONS["/shell/admin"]).toBeDefined();
|
||||||
|
expect(DASHBOARD_ROUTE_PERMISSIONS["/shell/teacher"]).toBeDefined();
|
||||||
|
expect(DASHBOARD_ROUTE_PERMISSIONS["/shell/student"]).toBeDefined();
|
||||||
|
expect(DASHBOARD_ROUTE_PERMISSIONS["/shell/parent"]).toBeDefined();
|
||||||
|
expect(DASHBOARD_ROUTE_PERMISSIONS["/shell"]).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { renderHook, act } from "@testing-library/react";
|
||||||
|
import { useErrorReport } from "@edu/hooks";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* useErrorReport 错误上报 Hook 测试
|
||||||
|
*
|
||||||
|
* 覆盖:
|
||||||
|
* - sendBeacon 上报路径和 payload 结构
|
||||||
|
* - fetch keepalive 降级
|
||||||
|
* - sessionStorage 节流(同 digest 1 分钟内只上报一次)
|
||||||
|
* - userId 从 localStorage 读取
|
||||||
|
* - 上报失败静默降级
|
||||||
|
* 关联:portal-shell README v2.0 §5.4 三级错误处理
|
||||||
|
*/
|
||||||
|
|
||||||
|
// mock navigator.sendBeacon
|
||||||
|
const sendBeaconMock = vi.fn().mockReturnValue(true);
|
||||||
|
// mock fetch
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue(new Response());
|
||||||
|
|
||||||
|
// 存储最近一次 sendBeacon 的 body 字符串(绕过 jsdom Blob 读取限制)
|
||||||
|
let lastBeaconBody: string | undefined;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
lastBeaconBody = undefined;
|
||||||
|
sessionStorage.clear();
|
||||||
|
localStorage.clear();
|
||||||
|
|
||||||
|
// mock Blob 构造函数,捕获 body 字符串
|
||||||
|
vi.stubGlobal(
|
||||||
|
"Blob",
|
||||||
|
vi.fn((parts: BlobPart[], options?: { type?: string }) => {
|
||||||
|
lastBeaconBody = String(parts[0]);
|
||||||
|
return { type: options?.type ?? "", size: lastBeaconBody.length };
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
Object.defineProperty(window, "navigator", {
|
||||||
|
value: {
|
||||||
|
sendBeacon: sendBeaconMock,
|
||||||
|
userAgent: "test-agent",
|
||||||
|
},
|
||||||
|
writable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 从最近一次 sendBeacon 调用中提取 payload */
|
||||||
|
function getLastPayload(): Record<string, unknown> {
|
||||||
|
if (!lastBeaconBody) throw new Error("No sendBeacon body captured");
|
||||||
|
return JSON.parse(lastBeaconBody);
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
sessionStorage.clear();
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("useErrorReport", () => {
|
||||||
|
it("通过 sendBeacon 上报到 /api/log", () => {
|
||||||
|
const { result } = renderHook(() => useErrorReport());
|
||||||
|
const error = new Error("测试错误");
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current(error);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(sendBeaconMock).toHaveBeenCalledTimes(1);
|
||||||
|
const [endpoint, blob] = sendBeaconMock.mock.calls[0]!;
|
||||||
|
expect(endpoint).toBe("/api/log");
|
||||||
|
expect(blob).toBeTruthy();
|
||||||
|
expect(blob.type).toBe("application/json");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("payload 包含必需字段", () => {
|
||||||
|
const { result } = renderHook(() => useErrorReport());
|
||||||
|
const error = new Error("测试错误");
|
||||||
|
error.stack = "Error: 测试错误\n at test";
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current(error);
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = getLastPayload();
|
||||||
|
expect(payload.level).toBe("error");
|
||||||
|
expect(payload.message).toBe("测试错误");
|
||||||
|
expect(payload.stack).toBe("Error: 测试错误\n at test");
|
||||||
|
expect(payload.path).toBe("/");
|
||||||
|
expect(payload.userAgent).toBe("test-agent");
|
||||||
|
expect(payload.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/);
|
||||||
|
expect(payload.digest).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("支持 pluginId 和 level 选项", () => {
|
||||||
|
const { result } = renderHook(() => useErrorReport());
|
||||||
|
const error = new Error("插件错误");
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current(error, {
|
||||||
|
pluginId: "grades-widget",
|
||||||
|
level: "warning",
|
||||||
|
context: { widgetId: "g1" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = getLastPayload();
|
||||||
|
expect(payload.pluginId).toBe("grades-widget");
|
||||||
|
expect(payload.level).toBe("warning");
|
||||||
|
expect(payload.context).toEqual({ widgetId: "g1" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("从 localStorage 读取 userId", () => {
|
||||||
|
localStorage.setItem("edu_user_id", "user-123");
|
||||||
|
const { result } = renderHook(() => useErrorReport());
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current(new Error("测试"));
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = getLastPayload();
|
||||||
|
expect(payload.userId).toBe("user-123");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("节流:同 digest 1 分钟内只上报一次", () => {
|
||||||
|
const { result } = renderHook(() => useErrorReport());
|
||||||
|
const error = new Error("重复错误");
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current(error);
|
||||||
|
});
|
||||||
|
expect(sendBeaconMock).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// 第二次相同错误应被节流
|
||||||
|
act(() => {
|
||||||
|
result.current(error);
|
||||||
|
});
|
||||||
|
expect(sendBeaconMock).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("不同 digest 的错误不互相影响", () => {
|
||||||
|
const { result } = renderHook(() => useErrorReport());
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current(new Error("错误A"));
|
||||||
|
});
|
||||||
|
act(() => {
|
||||||
|
result.current(new Error("错误B"));
|
||||||
|
});
|
||||||
|
expect(sendBeaconMock).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sendBeacon 返回 false 时降级到 fetch keepalive", () => {
|
||||||
|
sendBeaconMock.mockReturnValue(false);
|
||||||
|
const { result } = renderHook(() => useErrorReport());
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current(new Error("降级测试"));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(sendBeaconMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
const [endpoint, init] = fetchMock.mock.calls[0]!;
|
||||||
|
expect(endpoint).toBe("/api/log");
|
||||||
|
expect(init.method).toBe("POST");
|
||||||
|
expect(init.keepalive).toBe(true);
|
||||||
|
expect(init.credentials).toBe("include");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sendBeacon 抛异常时降级到 fetch", () => {
|
||||||
|
sendBeaconMock.mockImplementation(() => {
|
||||||
|
throw new Error("sendBeacon 不可用");
|
||||||
|
});
|
||||||
|
const { result } = renderHook(() => useErrorReport());
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current(new Error("降级测试"));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reportError 是稳定的 useCallback(空依赖)", () => {
|
||||||
|
const { result, rerender } = renderHook(() => useErrorReport());
|
||||||
|
const first = result.current;
|
||||||
|
rerender();
|
||||||
|
expect(result.current).toBe(first);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* audit-logs(admin / main)
|
* audit-logs(admin / main)
|
||||||
@@ -35,7 +35,7 @@ const RESOURCE_OPTIONS = [
|
|||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const inputCls =
|
const inputCls =
|
||||||
"rounded-button border border-rule bg-surface px-sm py-xs text-small text-ink";
|
"rounded-md border border bg-card px-sm py-xs text-sm text-foreground";
|
||||||
|
|
||||||
export default function AuditLogs(props: PluginProps): React.ReactElement {
|
export default function AuditLogs(props: PluginProps): React.ReactElement {
|
||||||
const rawPageSize = props.props.pageSize;
|
const rawPageSize = props.props.pageSize;
|
||||||
@@ -81,10 +81,10 @@ export default function AuditLogs(props: PluginProps): React.ReactElement {
|
|||||||
const selectedLog = logs.find((l) => l.id === selectedId);
|
const selectedLog = logs.find((l) => l.id === selectedId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="rounded-card border border-rule bg-surface p-md">
|
<section className="rounded-xl border border bg-card p-4">
|
||||||
<h3 className="text-heading-3 text-ink">审计日志</h3>
|
<h3 className="text-heading-3 text-foreground">审计日志</h3>
|
||||||
|
|
||||||
<div className="mt-sm flex flex-wrap gap-sm">
|
<div className="mt-sm flex flex-wrap gap-2">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={userIdFilter}
|
value={userIdFilter}
|
||||||
@@ -122,12 +122,12 @@ export default function AuditLogs(props: PluginProps): React.ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{logs.length === 0 ? (
|
{logs.length === 0 ? (
|
||||||
<p className="mt-sm text-small text-ink-muted">暂无审计日志</p>
|
<p className="mt-sm text-sm text-muted-foreground">暂无审计日志</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="mt-sm overflow-x-auto">
|
<div className="mt-sm overflow-x-auto">
|
||||||
<table className="w-full text-tiny">
|
<table className="w-full text-xs">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-rule text-ink-muted">
|
<tr className="border-b border text-muted-foreground">
|
||||||
<th className="py-xs text-left">时间</th>
|
<th className="py-xs text-left">时间</th>
|
||||||
<th className="py-xs text-left">用户</th>
|
<th className="py-xs text-left">用户</th>
|
||||||
<th className="py-xs text-left">操作</th>
|
<th className="py-xs text-left">操作</th>
|
||||||
@@ -142,18 +142,20 @@ export default function AuditLogs(props: PluginProps): React.ReactElement {
|
|||||||
<tr
|
<tr
|
||||||
key={log.id}
|
key={log.id}
|
||||||
onClick={() => setSelectedId(isSelected ? null : log.id)}
|
onClick={() => setSelectedId(isSelected ? null : log.id)}
|
||||||
className={`cursor-pointer border-b border-rule ${
|
className={`cursor-pointer border-b border ${
|
||||||
isSelected ? "bg-accent-subtle" : "bg-paper"
|
isSelected ? "bg-primary-subtle" : "bg-background"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<td className="py-xs text-ink-muted">{log.timestamp}</td>
|
<td className="py-xs text-muted-foreground">
|
||||||
<td className="py-xs text-ink">{log.userName}</td>
|
{log.timestamp}
|
||||||
<td className="py-xs text-ink">{log.action}</td>
|
</td>
|
||||||
<td className="py-xs text-ink">
|
<td className="py-xs text-foreground">{log.userName}</td>
|
||||||
|
<td className="py-xs text-foreground">{log.action}</td>
|
||||||
|
<td className="py-xs text-foreground">
|
||||||
{log.resource}
|
{log.resource}
|
||||||
{log.resourceId ? ` / ${log.resourceId}` : ""}
|
{log.resourceId ? ` / ${log.resourceId}` : ""}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-xs text-ink-muted">{log.ip}</td>
|
<td className="py-xs text-muted-foreground">{log.ip}</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -163,24 +165,24 @@ export default function AuditLogs(props: PluginProps): React.ReactElement {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{selectedLog ? (
|
{selectedLog ? (
|
||||||
<div className="mt-sm rounded-button bg-paper p-sm">
|
<div className="mt-sm rounded-md bg-background p-2">
|
||||||
<p className="text-tiny text-ink-muted">详情</p>
|
<p className="text-xs text-muted-foreground">详情</p>
|
||||||
<pre className="mt-xs overflow-x-auto whitespace-pre-wrap break-words text-tiny text-ink">
|
<pre className="mt-xs overflow-x-auto whitespace-pre-wrap break-words text-xs text-foreground">
|
||||||
{selectedLog.details || "(无详细信息)"}
|
{selectedLog.details || "(无详细信息)"}
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="mt-sm flex items-center justify-between text-tiny text-ink-muted">
|
<div className="mt-sm flex items-center justify-between text-xs text-muted-foreground">
|
||||||
<span>
|
<span>
|
||||||
共 {total} 条,第 {offset + 1} - {rangeEnd} 条
|
共 {total} 条,第 {offset + 1} - {rangeEnd} 条
|
||||||
</span>
|
</span>
|
||||||
<div className="flex gap-sm">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={!hasPrev}
|
disabled={!hasPrev}
|
||||||
onClick={() => setOffset(Math.max(0, offset - pageSize))}
|
onClick={() => setOffset(Math.max(0, offset - pageSize))}
|
||||||
className="rounded-button border border-rule bg-surface px-sm py-xs text-ink"
|
className="rounded-md border border bg-card px-sm py-xs text-foreground"
|
||||||
>
|
>
|
||||||
上一页
|
上一页
|
||||||
</button>
|
</button>
|
||||||
@@ -188,7 +190,7 @@ export default function AuditLogs(props: PluginProps): React.ReactElement {
|
|||||||
type="button"
|
type="button"
|
||||||
disabled={!hasNext}
|
disabled={!hasNext}
|
||||||
onClick={() => setOffset(offset + pageSize)}
|
onClick={() => setOffset(offset + pageSize)}
|
||||||
className="rounded-button border border-rule bg-surface px-sm py-xs text-ink"
|
className="rounded-md border border bg-card px-sm py-xs text-foreground"
|
||||||
>
|
>
|
||||||
下一页
|
下一页
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* invitation-codes(admin / main)
|
* invitation-codes(admin / main)
|
||||||
@@ -31,8 +31,8 @@ const STATUS_LABELS: Record<CodeStatus, string> = {
|
|||||||
const ROLE_OPTIONS = ["teacher", "student", "parent"] as const;
|
const ROLE_OPTIONS = ["teacher", "student", "parent"] as const;
|
||||||
|
|
||||||
const inputCls =
|
const inputCls =
|
||||||
"rounded-button border border-rule bg-surface px-sm py-xs text-small text-ink";
|
"rounded-md border border bg-card px-sm py-xs text-sm text-foreground";
|
||||||
const labelCls = "text-tiny text-ink-muted";
|
const labelCls = "text-xs text-muted-foreground";
|
||||||
|
|
||||||
export default function InvitationCodes(
|
export default function InvitationCodes(
|
||||||
props: PluginProps,
|
props: PluginProps,
|
||||||
@@ -98,16 +98,16 @@ export default function InvitationCodes(
|
|||||||
const codes = data ?? [];
|
const codes = data ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="rounded-card border border-rule bg-surface p-md">
|
<section className="rounded-xl border border bg-card p-4">
|
||||||
<h3 className="text-heading-3 text-ink">邀请码</h3>
|
<h3 className="text-heading-3 text-foreground">邀请码</h3>
|
||||||
{status.length > 0 ? (
|
{status.length > 0 ? (
|
||||||
<p className="mt-xs text-tiny text-ink-muted">{status}</p>
|
<p className="mt-xs text-xs text-muted-foreground">{status}</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* 生成新邀请码 */}
|
{/* 生成新邀请码 */}
|
||||||
<div className="mt-sm rounded-button bg-paper p-sm">
|
<div className="mt-sm rounded-md bg-background p-2">
|
||||||
<p className={labelCls}>生成新邀请码</p>
|
<p className={labelCls}>生成新邀请码</p>
|
||||||
<div className="mt-xs flex flex-wrap items-end gap-sm">
|
<div className="mt-xs flex flex-wrap items-end gap-2">
|
||||||
<div className="space-y-xs">
|
<div className="space-y-xs">
|
||||||
<label className={labelCls}>角色</label>
|
<label className={labelCls}>角色</label>
|
||||||
<select
|
<select
|
||||||
@@ -145,7 +145,7 @@ export default function InvitationCodes(
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleCreate}
|
onClick={handleCreate}
|
||||||
className="rounded-button bg-accent px-md py-xs text-small text-ink-onAccent"
|
className="rounded-md bg-primary px-md py-xs text-sm text-primary-foreground"
|
||||||
>
|
>
|
||||||
生成
|
生成
|
||||||
</button>
|
</button>
|
||||||
@@ -153,7 +153,7 @@ export default function InvitationCodes(
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 筛选 */}
|
{/* 筛选 */}
|
||||||
<div className="mt-md flex items-center gap-sm">
|
<div className="mt-md flex items-center gap-2">
|
||||||
<label className={labelCls}>状态</label>
|
<label className={labelCls}>状态</label>
|
||||||
<select
|
<select
|
||||||
value={statusFilter}
|
value={statusFilter}
|
||||||
@@ -171,12 +171,12 @@ export default function InvitationCodes(
|
|||||||
|
|
||||||
{/* 邀请码列表 */}
|
{/* 邀请码列表 */}
|
||||||
{codes.length === 0 ? (
|
{codes.length === 0 ? (
|
||||||
<p className="mt-sm text-small text-ink-muted">暂无邀请码</p>
|
<p className="mt-sm text-sm text-muted-foreground">暂无邀请码</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="mt-sm overflow-x-auto">
|
<div className="mt-sm overflow-x-auto">
|
||||||
<table className="w-full text-small">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-rule text-ink-muted">
|
<tr className="border-b border text-muted-foreground">
|
||||||
<th className="py-xs text-left">邀请码</th>
|
<th className="py-xs text-left">邀请码</th>
|
||||||
<th className="py-xs text-left">角色</th>
|
<th className="py-xs text-left">角色</th>
|
||||||
<th className="py-xs text-left">状态</th>
|
<th className="py-xs text-left">状态</th>
|
||||||
@@ -190,20 +190,24 @@ export default function InvitationCodes(
|
|||||||
const isBusy = busyId === c.id;
|
const isBusy = busyId === c.id;
|
||||||
const canRevoke = c.status === "active";
|
const canRevoke = c.status === "active";
|
||||||
return (
|
return (
|
||||||
<tr key={c.id} className="border-b border-rule">
|
<tr key={c.id} className="border-b border">
|
||||||
<td className="py-xs font-mono text-ink">{c.code}</td>
|
<td className="py-xs font-mono text-foreground">
|
||||||
<td className="py-xs text-ink">{c.role}</td>
|
{c.code}
|
||||||
<td className="py-xs text-ink">{c.status}</td>
|
</td>
|
||||||
<td className="py-xs text-ink">
|
<td className="py-xs text-foreground">{c.role}</td>
|
||||||
|
<td className="py-xs text-foreground">{c.status}</td>
|
||||||
|
<td className="py-xs text-foreground">
|
||||||
{c.usedCount} / {c.maxUses}
|
{c.usedCount} / {c.maxUses}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-xs text-ink-muted">{c.expiresAt}</td>
|
<td className="py-xs text-muted-foreground">
|
||||||
|
{c.expiresAt}
|
||||||
|
</td>
|
||||||
<td className="py-xs">
|
<td className="py-xs">
|
||||||
<div className="flex gap-sm">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleCopy(c.code)}
|
onClick={() => handleCopy(c.code)}
|
||||||
className="rounded-button border border-rule bg-surface px-sm py-xs text-tiny text-ink"
|
className="rounded-md border border bg-card px-sm py-xs text-xs text-foreground"
|
||||||
>
|
>
|
||||||
复制
|
复制
|
||||||
</button>
|
</button>
|
||||||
@@ -212,7 +216,7 @@ export default function InvitationCodes(
|
|||||||
type="button"
|
type="button"
|
||||||
disabled={isBusy}
|
disabled={isBusy}
|
||||||
onClick={() => handleRevoke(c.id)}
|
onClick={() => handleRevoke(c.id)}
|
||||||
className="rounded-button bg-danger px-sm py-xs text-tiny text-ink-onAccent"
|
className="rounded-md bg-danger px-sm py-xs text-xs text-primary-foreground"
|
||||||
>
|
>
|
||||||
撤销
|
撤销
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* plugin-manager(admin / main)
|
* plugin-manager(admin / main)
|
||||||
@@ -77,33 +77,35 @@ function RegistryTab(): React.ReactElement {
|
|||||||
const items = data ?? [];
|
const items = data ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-md">
|
<div className="space-y-4">
|
||||||
{items.length === 0 ? (
|
{items.length === 0 ? (
|
||||||
<p className="text-small text-ink-muted">暂无已注册插件</p>
|
<p className="text-sm text-muted-foreground">暂无已注册插件</p>
|
||||||
) : (
|
) : (
|
||||||
items.map((item) => (
|
items.map((item) => (
|
||||||
<div
|
<div
|
||||||
key={item.pluginId}
|
key={item.pluginId}
|
||||||
className="rounded-card border border-rule bg-paper p-md"
|
className="rounded-xl border border bg-background p-4"
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-body text-ink">
|
<p className="text-body text-foreground">
|
||||||
{item.displayName}
|
{item.displayName}
|
||||||
{item.isBuiltin ? (
|
{item.isBuiltin ? (
|
||||||
<span className="ml-xs text-tiny text-ink-muted">内置</span>
|
<span className="ml-xs text-xs text-muted-foreground">
|
||||||
|
内置
|
||||||
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-tiny text-ink-muted">
|
<p className="text-xs text-muted-foreground">
|
||||||
{item.pluginId} · v{item.version} · {item.category} ·{" "}
|
{item.pluginId} · v{item.version} · {item.category} ·{" "}
|
||||||
{item.defaultSlot}
|
{item.defaultSlot}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-xs text-small text-ink-muted">
|
<p className="mt-xs text-sm text-muted-foreground">
|
||||||
{item.description}
|
{item.description}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-sm">
|
<div className="flex items-center gap-2">
|
||||||
<label className="flex items-center gap-xs text-small text-ink">
|
<label className="flex items-center gap-xs text-sm text-foreground">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={item.isActive}
|
checked={item.isActive}
|
||||||
@@ -118,7 +120,7 @@ function RegistryTab(): React.ReactElement {
|
|||||||
? setEditingId(null)
|
? setEditingId(null)
|
||||||
: handleStartEdit(item)
|
: handleStartEdit(item)
|
||||||
}
|
}
|
||||||
className="rounded-button border border-rule bg-surface px-sm py-xs text-tiny text-ink"
|
className="rounded-md border border bg-card px-sm py-xs text-xs text-foreground"
|
||||||
>
|
>
|
||||||
{editingId === item.pluginId ? "取消" : "编辑 props"}
|
{editingId === item.pluginId ? "取消" : "编辑 props"}
|
||||||
</button>
|
</button>
|
||||||
@@ -126,22 +128,22 @@ function RegistryTab(): React.ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
{editingId === item.pluginId ? (
|
{editingId === item.pluginId ? (
|
||||||
<div className="mt-sm space-y-xs">
|
<div className="mt-sm space-y-xs">
|
||||||
<label className="text-tiny text-ink-muted">
|
<label className="text-xs text-muted-foreground">
|
||||||
defaultProps (JSON)
|
defaultProps (JSON)
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
value={draftProps}
|
value={draftProps}
|
||||||
onChange={(e) => setDraftProps(e.target.value)}
|
onChange={(e) => setDraftProps(e.target.value)}
|
||||||
rows={6}
|
rows={6}
|
||||||
className="w-full rounded-button border border-rule bg-surface p-sm font-mono text-tiny text-ink"
|
className="w-full rounded-md border border bg-card p-2 font-mono text-xs text-foreground"
|
||||||
/>
|
/>
|
||||||
{draftError.length > 0 ? (
|
{draftError.length > 0 ? (
|
||||||
<p className="text-tiny text-ink-muted">{draftError}</p>
|
<p className="text-xs text-muted-foreground">{draftError}</p>
|
||||||
) : null}
|
) : null}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleSaveProps(item.pluginId)}
|
onClick={() => handleSaveProps(item.pluginId)}
|
||||||
className="rounded-button bg-accent px-md py-xs text-tiny text-ink-onAccent"
|
className="rounded-md bg-primary px-md py-xs text-xs text-primary-foreground"
|
||||||
>
|
>
|
||||||
保存
|
保存
|
||||||
</button>
|
</button>
|
||||||
@@ -197,12 +199,12 @@ function MappingTab(): React.ReactElement {
|
|||||||
|
|
||||||
const draftList = Object.values(drafts);
|
const draftList = Object.values(drafts);
|
||||||
const selectCls =
|
const selectCls =
|
||||||
"rounded-button border border-rule bg-surface px-sm py-xs text-small text-ink";
|
"rounded-md border border bg-card px-sm py-xs text-sm text-foreground";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-md">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center gap-sm">
|
<div className="flex items-center gap-2">
|
||||||
<label className="text-small text-ink-muted">角色</label>
|
<label className="text-sm text-muted-foreground">角色</label>
|
||||||
<select
|
<select
|
||||||
value={selectedRole}
|
value={selectedRole}
|
||||||
onChange={(e) => setSelectedRole(e.target.value)}
|
onChange={(e) => setSelectedRole(e.target.value)}
|
||||||
@@ -218,12 +220,12 @@ function MappingTab(): React.ReactElement {
|
|||||||
{loading && !data ? (
|
{loading && !data ? (
|
||||||
<PluginSkeleton variant="table" />
|
<PluginSkeleton variant="table" />
|
||||||
) : draftList.length === 0 ? (
|
) : draftList.length === 0 ? (
|
||||||
<p className="text-small text-ink-muted">该角色暂无插件映射</p>
|
<p className="text-sm text-muted-foreground">该角色暂无插件映射</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-small">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-rule text-ink-muted">
|
<tr className="border-b border text-muted-foreground">
|
||||||
<th className="py-xs text-left">插件</th>
|
<th className="py-xs text-left">插件</th>
|
||||||
<th className="py-xs text-left">启用</th>
|
<th className="py-xs text-left">启用</th>
|
||||||
<th className="py-xs text-left">Slot</th>
|
<th className="py-xs text-left">Slot</th>
|
||||||
@@ -232,8 +234,8 @@ function MappingTab(): React.ReactElement {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{draftList.map((m) => (
|
{draftList.map((m) => (
|
||||||
<tr key={m.pluginId} className="border-b border-rule">
|
<tr key={m.pluginId} className="border-b border">
|
||||||
<td className="py-xs text-ink">{m.pluginId}</td>
|
<td className="py-xs text-foreground">{m.pluginId}</td>
|
||||||
<td className="py-xs">
|
<td className="py-xs">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -250,7 +252,7 @@ function MappingTab(): React.ReactElement {
|
|||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
updateDraft(m.pluginId, { slot: e.target.value })
|
updateDraft(m.pluginId, { slot: e.target.value })
|
||||||
}
|
}
|
||||||
className="w-full rounded-button border border-rule bg-surface px-sm py-xs text-tiny text-ink"
|
className="w-full rounded-md border border bg-card px-sm py-xs text-xs text-foreground"
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td className="py-xs">
|
<td className="py-xs">
|
||||||
@@ -262,7 +264,7 @@ function MappingTab(): React.ReactElement {
|
|||||||
sortOrder: Number(e.target.value),
|
sortOrder: Number(e.target.value),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
className="w-full rounded-button border border-rule bg-surface px-sm py-xs text-tiny text-ink"
|
className="w-full rounded-md border border bg-card px-sm py-xs text-xs text-foreground"
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -274,7 +276,7 @@ function MappingTab(): React.ReactElement {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
className="rounded-button bg-accent px-md py-xs text-small text-ink-onAccent"
|
className="rounded-md bg-primary px-md py-xs text-sm text-primary-foreground"
|
||||||
>
|
>
|
||||||
保存映射
|
保存映射
|
||||||
</button>
|
</button>
|
||||||
@@ -311,15 +313,15 @@ function LayoutTab(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const selectCls =
|
const selectCls =
|
||||||
"rounded-button border border-rule bg-surface px-sm py-xs text-small text-ink";
|
"rounded-md border border bg-card px-sm py-xs text-sm text-foreground";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-md">
|
<div className="space-y-4">
|
||||||
{status.length > 0 ? (
|
{status.length > 0 ? (
|
||||||
<p className="text-tiny text-ink-muted">{status}</p>
|
<p className="text-xs text-muted-foreground">{status}</p>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="flex items-center gap-sm">
|
<div className="flex items-center gap-2">
|
||||||
<label className="text-small text-ink-muted">角色</label>
|
<label className="text-sm text-muted-foreground">角色</label>
|
||||||
<select
|
<select
|
||||||
value={selectedRole}
|
value={selectedRole}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
@@ -334,11 +336,11 @@ function LayoutTab(): React.ReactElement {
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<span className="text-tiny text-ink-muted">
|
<span className="text-xs text-muted-foreground">
|
||||||
当前默认:{currentLayoutId || "未设置"}
|
当前默认:{currentLayoutId || "未设置"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-md">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
{templates.map((tpl) => {
|
{templates.map((tpl) => {
|
||||||
const isCurrent = tpl.layoutId === currentLayoutId;
|
const isCurrent = tpl.layoutId === currentLayoutId;
|
||||||
return (
|
return (
|
||||||
@@ -346,19 +348,19 @@ function LayoutTab(): React.ReactElement {
|
|||||||
key={tpl.layoutId}
|
key={tpl.layoutId}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleSetLayout(tpl.layoutId)}
|
onClick={() => handleSetLayout(tpl.layoutId)}
|
||||||
className={`flex flex-col rounded-card border p-md text-left ${
|
className={`flex flex-col rounded-xl border p-4 text-left ${
|
||||||
isCurrent
|
isCurrent
|
||||||
? "border-accent bg-accent-subtle"
|
? "border-accent bg-primary-subtle"
|
||||||
: "border-rule bg-paper"
|
: "border bg-background"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<p className="text-body text-ink">{tpl.displayName}</p>
|
<p className="text-body text-foreground">{tpl.displayName}</p>
|
||||||
<p className="text-tiny text-ink-muted">{tpl.description}</p>
|
<p className="text-xs text-muted-foreground">{tpl.description}</p>
|
||||||
<p className="mt-xs text-tiny text-ink-muted">
|
<p className="mt-xs text-xs text-muted-foreground">
|
||||||
可用 Slot:{tpl.availableSlots.join("、")}
|
可用 Slot:{tpl.availableSlots.join("、")}
|
||||||
</p>
|
</p>
|
||||||
{isCurrent ? (
|
{isCurrent ? (
|
||||||
<p className="mt-xs text-tiny text-ink">当前默认</p>
|
<p className="mt-xs text-xs text-foreground">当前默认</p>
|
||||||
) : null}
|
) : null}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
@@ -390,28 +392,28 @@ function UserLayoutTab(): React.ReactElement {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-md">
|
<div className="space-y-4">
|
||||||
<p className="text-small text-ink-muted">
|
<p className="text-sm text-muted-foreground">
|
||||||
重置用户的自定义布局覆盖,使其回到角色默认布局。
|
重置用户的自定义布局覆盖,使其回到角色默认布局。
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center gap-sm">
|
<div className="flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={userId}
|
value={userId}
|
||||||
onChange={(e) => setUserId(e.target.value)}
|
onChange={(e) => setUserId(e.target.value)}
|
||||||
placeholder="输入用户 ID"
|
placeholder="输入用户 ID"
|
||||||
className="w-64 rounded-button border border-rule bg-surface px-sm py-xs text-small text-ink"
|
className="w-64 rounded-md border border bg-card px-sm py-xs text-sm text-foreground"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleReset}
|
onClick={handleReset}
|
||||||
className="rounded-button bg-danger px-md py-xs text-small text-ink-onAccent"
|
className="rounded-md bg-danger px-md py-xs text-sm text-primary-foreground"
|
||||||
>
|
>
|
||||||
重置布局
|
重置布局
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{status.length > 0 ? (
|
{status.length > 0 ? (
|
||||||
<p className="text-tiny text-ink-muted">{status}</p>
|
<p className="text-xs text-muted-foreground">{status}</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -421,19 +423,19 @@ export default function PluginManager(_props: PluginProps): React.ReactElement {
|
|||||||
const [tab, setTab] = useState<TabKey>("registry");
|
const [tab, setTab] = useState<TabKey>("registry");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="rounded-card border border-rule bg-surface p-md">
|
<section className="rounded-xl border border bg-card p-4">
|
||||||
<h3 className="text-heading-3 text-ink">插件管理</h3>
|
<h3 className="text-heading-3 text-foreground">插件管理</h3>
|
||||||
<div className="mt-sm flex gap-sm border-b border-rule">
|
<div className="mt-sm flex gap-2 border-b border">
|
||||||
{(Object.keys(TAB_LABELS) as TabKey[]).map((key) => (
|
{(Object.keys(TAB_LABELS) as TabKey[]).map((key) => (
|
||||||
<button
|
<button
|
||||||
key={key}
|
key={key}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setTab(key)}
|
onClick={() => setTab(key)}
|
||||||
aria-pressed={tab === key}
|
aria-pressed={tab === key}
|
||||||
className={`rounded-button px-sm py-xs text-small ${
|
className={`rounded-md px-sm py-xs text-sm ${
|
||||||
tab === key
|
tab === key
|
||||||
? "bg-accent text-ink-onAccent"
|
? "bg-primary text-primary-foreground"
|
||||||
: "bg-surface text-ink"
|
: "bg-card text-foreground"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{TAB_LABELS[key]}
|
{TAB_LABELS[key]}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* rbac-manager(admin / main)
|
* rbac-manager(admin / main)
|
||||||
@@ -79,20 +79,20 @@ export default function RbacManager(_props: PluginProps): React.ReactElement {
|
|||||||
|
|
||||||
if (roles.length === 0) {
|
if (roles.length === 0) {
|
||||||
return (
|
return (
|
||||||
<section className="rounded-card border border-rule bg-surface p-md">
|
<section className="rounded-xl border border bg-card p-4">
|
||||||
<h3 className="text-heading-3 text-ink">角色权限</h3>
|
<h3 className="text-heading-3 text-foreground">角色权限</h3>
|
||||||
<p className="mt-sm text-small text-ink-muted">暂无角色数据</p>
|
<p className="mt-sm text-sm text-muted-foreground">暂无角色数据</p>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="rounded-card border border-rule bg-surface p-md">
|
<section className="rounded-xl border border bg-card p-4">
|
||||||
<h3 className="text-heading-3 text-ink">角色权限</h3>
|
<h3 className="text-heading-3 text-foreground">角色权限</h3>
|
||||||
<div className="mt-sm flex gap-md">
|
<div className="mt-sm flex gap-4">
|
||||||
{/* 左侧角色列表 */}
|
{/* 左侧角色列表 */}
|
||||||
<div className="w-64 flex-shrink-0">
|
<div className="w-64 flex-shrink-0">
|
||||||
<p className="text-small text-ink-muted">角色</p>
|
<p className="text-sm text-muted-foreground">角色</p>
|
||||||
<ul className="mt-xs space-y-xs">
|
<ul className="mt-xs space-y-xs">
|
||||||
{roles.map((role) => {
|
{roles.map((role) => {
|
||||||
const isSelected = role.id === effectiveRoleId;
|
const isSelected = role.id === effectiveRoleId;
|
||||||
@@ -102,10 +102,10 @@ export default function RbacManager(_props: PluginProps): React.ReactElement {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => setSelectedRoleId(role.id)}
|
onClick={() => setSelectedRoleId(role.id)}
|
||||||
aria-pressed={isSelected}
|
aria-pressed={isSelected}
|
||||||
className={`w-full rounded-button border px-sm py-xs text-left text-small ${
|
className={`w-full rounded-md border px-sm py-xs text-left text-sm ${
|
||||||
isSelected
|
isSelected
|
||||||
? "border-accent bg-accent-subtle text-ink"
|
? "border-accent bg-primary-subtle text-foreground"
|
||||||
: "border-rule bg-paper text-ink"
|
: "border bg-background text-foreground"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{role.name}
|
{role.name}
|
||||||
@@ -119,19 +119,19 @@ export default function RbacManager(_props: PluginProps): React.ReactElement {
|
|||||||
{/* 右侧权限矩阵 */}
|
{/* 右侧权限矩阵 */}
|
||||||
<div className="flex-1 overflow-x-auto">
|
<div className="flex-1 overflow-x-auto">
|
||||||
{permissions.length === 0 ? (
|
{permissions.length === 0 ? (
|
||||||
<p className="text-small text-ink-muted">暂无权限数据</p>
|
<p className="text-sm text-muted-foreground">暂无权限数据</p>
|
||||||
) : (
|
) : (
|
||||||
<table className="w-full text-tiny">
|
<table className="w-full text-xs">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-rule text-ink-muted">
|
<tr className="border-b border text-muted-foreground">
|
||||||
<th className="py-xs text-left">权限</th>
|
<th className="py-xs text-left">权限</th>
|
||||||
{roles.map((role) => (
|
{roles.map((role) => (
|
||||||
<th
|
<th
|
||||||
key={role.id}
|
key={role.id}
|
||||||
className={`py-xs text-center ${
|
className={`py-xs text-center ${
|
||||||
role.id === effectiveRoleId
|
role.id === effectiveRoleId
|
||||||
? "text-ink"
|
? "text-foreground"
|
||||||
: "text-ink-muted"
|
: "text-muted-foreground"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{role.name}
|
{role.name}
|
||||||
@@ -141,10 +141,10 @@ export default function RbacManager(_props: PluginProps): React.ReactElement {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{permissions.map((perm) => (
|
{permissions.map((perm) => (
|
||||||
<tr key={perm.id} className="border-b border-rule">
|
<tr key={perm.id} className="border-b border">
|
||||||
<td className="py-xs">
|
<td className="py-xs">
|
||||||
<p className="text-ink">{perm.name}</p>
|
<p className="text-foreground">{perm.name}</p>
|
||||||
<p className="text-tiny text-ink-muted">
|
<p className="text-xs text-muted-foreground">
|
||||||
{perm.resource} / {perm.action}
|
{perm.resource} / {perm.action}
|
||||||
</p>
|
</p>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* school-settings(admin / main)
|
* school-settings(admin / main)
|
||||||
@@ -18,8 +18,8 @@ import type { PluginProps } from "@/lib/types";
|
|||||||
|
|
||||||
const TERM_OPTIONS = ["第一学期", "第二学期", "暑假", "寒假"] as const;
|
const TERM_OPTIONS = ["第一学期", "第二学期", "暑假", "寒假"] as const;
|
||||||
const inputCls =
|
const inputCls =
|
||||||
"w-full rounded-button border border-rule bg-surface px-sm py-xs text-small text-ink";
|
"w-full rounded-md border border bg-card px-sm py-xs text-sm text-foreground";
|
||||||
const labelCls = "text-tiny text-ink-muted";
|
const labelCls = "text-xs text-muted-foreground";
|
||||||
|
|
||||||
export default function SchoolSettings(props: PluginProps): React.ReactElement {
|
export default function SchoolSettings(props: PluginProps): React.ReactElement {
|
||||||
const showAdvanced =
|
const showAdvanced =
|
||||||
@@ -70,21 +70,21 @@ export default function SchoolSettings(props: PluginProps): React.ReactElement {
|
|||||||
|
|
||||||
if (!draft) {
|
if (!draft) {
|
||||||
return (
|
return (
|
||||||
<section className="rounded-card border border-rule bg-surface p-md">
|
<section className="rounded-xl border border bg-card p-4">
|
||||||
<h3 className="text-heading-3 text-ink">学校设置</h3>
|
<h3 className="text-heading-3 text-foreground">学校设置</h3>
|
||||||
<p className="mt-sm text-small text-ink-muted">暂无学校数据</p>
|
<p className="mt-sm text-sm text-muted-foreground">暂无学校数据</p>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="rounded-card border border-rule bg-surface p-md">
|
<section className="rounded-xl border border bg-card p-4">
|
||||||
<h3 className="text-heading-3 text-ink">学校设置</h3>
|
<h3 className="text-heading-3 text-foreground">学校设置</h3>
|
||||||
{status.length > 0 ? (
|
{status.length > 0 ? (
|
||||||
<p className="mt-xs text-tiny text-ink-muted">{status}</p>
|
<p className="mt-xs text-xs text-muted-foreground">{status}</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="mt-sm grid grid-cols-2 gap-md">
|
<div className="mt-sm grid grid-cols-2 gap-4">
|
||||||
<div className="space-y-xs">
|
<div className="space-y-xs">
|
||||||
<label className={labelCls}>学校名称</label>
|
<label className={labelCls}>学校名称</label>
|
||||||
<input
|
<input
|
||||||
@@ -168,26 +168,26 @@ export default function SchoolSettings(props: PluginProps): React.ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showAdvanced ? (
|
{showAdvanced ? (
|
||||||
<div className="mt-md rounded-button bg-paper p-sm">
|
<div className="mt-md rounded-md bg-background p-2">
|
||||||
<p className="text-tiny text-ink-muted">
|
<p className="text-xs text-muted-foreground">
|
||||||
高级配置:学区划分、学段映射、教研室组织等由 config-service
|
高级配置:学区划分、学段映射、教研室组织等由 config-service
|
||||||
角色-插件映射控制。
|
角色-插件映射控制。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="mt-md flex justify-end gap-sm">
|
<div className="mt-md flex justify-end gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => data && setDraft({ ...data })}
|
onClick={() => data && setDraft({ ...data })}
|
||||||
className="rounded-button border border-rule bg-surface px-md py-xs text-small text-ink"
|
className="rounded-md border border bg-card px-md py-xs text-sm text-foreground"
|
||||||
>
|
>
|
||||||
重置
|
重置
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
className="rounded-button bg-accent px-md py-xs text-small text-ink-onAccent"
|
className="rounded-md bg-primary px-md py-xs text-sm text-primary-foreground"
|
||||||
>
|
>
|
||||||
保存
|
保存
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* user-management(admin / main)
|
* user-management(admin / main)
|
||||||
@@ -87,13 +87,13 @@ export default function UserManagement(
|
|||||||
const rangeEnd = Math.min(offset + PAGE_SIZE, total);
|
const rangeEnd = Math.min(offset + PAGE_SIZE, total);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="rounded-card border border-rule bg-surface p-md">
|
<section className="rounded-xl border border bg-card p-4">
|
||||||
<h3 className="text-heading-3 text-ink">用户管理</h3>
|
<h3 className="text-heading-3 text-foreground">用户管理</h3>
|
||||||
<div className="mt-sm flex gap-sm">
|
<div className="mt-sm flex gap-2">
|
||||||
<select
|
<select
|
||||||
value={roleFilter}
|
value={roleFilter}
|
||||||
onChange={handleRoleFilterChange}
|
onChange={handleRoleFilterChange}
|
||||||
className="rounded-button border border-rule bg-surface px-sm py-xs text-small text-ink"
|
className="rounded-md border border bg-card px-sm py-xs text-sm text-foreground"
|
||||||
aria-label="按角色筛选"
|
aria-label="按角色筛选"
|
||||||
>
|
>
|
||||||
<option value="">全部角色</option>
|
<option value="">全部角色</option>
|
||||||
@@ -106,12 +106,12 @@ export default function UserManagement(
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{users.length === 0 ? (
|
{users.length === 0 ? (
|
||||||
<p className="mt-sm text-small text-ink-muted">暂无用户</p>
|
<p className="mt-sm text-sm text-muted-foreground">暂无用户</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="mt-sm overflow-x-auto">
|
<div className="mt-sm overflow-x-auto">
|
||||||
<table className="w-full text-small">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-rule text-ink-muted">
|
<tr className="border-b border text-muted-foreground">
|
||||||
<th className="py-xs text-left">姓名</th>
|
<th className="py-xs text-left">姓名</th>
|
||||||
<th className="py-xs text-left">邮箱</th>
|
<th className="py-xs text-left">邮箱</th>
|
||||||
<th className="py-xs text-left">角色</th>
|
<th className="py-xs text-left">角色</th>
|
||||||
@@ -123,15 +123,15 @@ export default function UserManagement(
|
|||||||
{users.map((u) => {
|
{users.map((u) => {
|
||||||
const isBusy = busyId === u.id;
|
const isBusy = busyId === u.id;
|
||||||
return (
|
return (
|
||||||
<tr key={u.id} className="border-b border-rule">
|
<tr key={u.id} className="border-b border">
|
||||||
<td className="py-xs text-ink">{u.name}</td>
|
<td className="py-xs text-foreground">{u.name}</td>
|
||||||
<td className="py-xs text-ink">{u.email}</td>
|
<td className="py-xs text-foreground">{u.email}</td>
|
||||||
<td className="py-xs">
|
<td className="py-xs">
|
||||||
<select
|
<select
|
||||||
value={u.role}
|
value={u.role}
|
||||||
disabled={isBusy}
|
disabled={isBusy}
|
||||||
onChange={(e) => handleRoleChange(u.id, e.target.value)}
|
onChange={(e) => handleRoleChange(u.id, e.target.value)}
|
||||||
className="rounded-button border border-rule bg-surface px-sm py-xs text-tiny text-ink"
|
className="rounded-md border border bg-card px-sm py-xs text-xs text-foreground"
|
||||||
aria-label="修改角色"
|
aria-label="修改角色"
|
||||||
>
|
>
|
||||||
{ROLE_OPTIONS.map((r) => (
|
{ROLE_OPTIONS.map((r) => (
|
||||||
@@ -148,7 +148,7 @@ export default function UserManagement(
|
|||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
handleStatusChange(u.id, e.target.value)
|
handleStatusChange(u.id, e.target.value)
|
||||||
}
|
}
|
||||||
className="rounded-button border border-rule bg-surface px-sm py-xs text-tiny text-ink"
|
className="rounded-md border border bg-card px-sm py-xs text-xs text-foreground"
|
||||||
aria-label="修改状态"
|
aria-label="修改状态"
|
||||||
>
|
>
|
||||||
{STATUS_OPTIONS.map((s) => (
|
{STATUS_OPTIONS.map((s) => (
|
||||||
@@ -158,7 +158,9 @@ export default function UserManagement(
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</td>
|
</td>
|
||||||
<td className="py-xs text-ink-muted">{u.createdAt}</td>
|
<td className="py-xs text-muted-foreground">
|
||||||
|
{u.createdAt}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -167,16 +169,16 @@ export default function UserManagement(
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="mt-sm flex items-center justify-between text-tiny text-ink-muted">
|
<div className="mt-sm flex items-center justify-between text-xs text-muted-foreground">
|
||||||
<span>
|
<span>
|
||||||
共 {total} 条,第 {offset + 1} - {rangeEnd} 条
|
共 {total} 条,第 {offset + 1} - {rangeEnd} 条
|
||||||
</span>
|
</span>
|
||||||
<div className="flex gap-sm">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={!hasPrev}
|
disabled={!hasPrev}
|
||||||
onClick={() => setOffset(Math.max(0, offset - PAGE_SIZE))}
|
onClick={() => setOffset(Math.max(0, offset - PAGE_SIZE))}
|
||||||
className="rounded-button border border-rule bg-surface px-sm py-xs text-ink"
|
className="rounded-md border border bg-card px-sm py-xs text-foreground"
|
||||||
>
|
>
|
||||||
上一页
|
上一页
|
||||||
</button>
|
</button>
|
||||||
@@ -184,7 +186,7 @@ export default function UserManagement(
|
|||||||
type="button"
|
type="button"
|
||||||
disabled={!hasNext}
|
disabled={!hasNext}
|
||||||
onClick={() => setOffset(offset + PAGE_SIZE)}
|
onClick={() => setOffset(offset + PAGE_SIZE)}
|
||||||
className="rounded-button border border-rule bg-surface px-sm py-xs text-ink"
|
className="rounded-md border border bg-card px-sm py-xs text-foreground"
|
||||||
>
|
>
|
||||||
下一页
|
下一页
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* child-overview(parent / main)
|
* child-overview(parent / main)
|
||||||
@@ -41,17 +41,17 @@ export default function ChildOverview(_props: PluginProps): React.ReactElement {
|
|||||||
|
|
||||||
if (children.length === 0) {
|
if (children.length === 0) {
|
||||||
return (
|
return (
|
||||||
<section className="rounded-card border border-rule bg-surface p-md">
|
<section className="rounded-xl border border bg-card p-4">
|
||||||
<h3 className="text-heading-3 text-ink">孩子总览</h3>
|
<h3 className="text-heading-3 text-foreground">孩子总览</h3>
|
||||||
<p className="mt-sm text-small text-ink-muted">暂无孩子信息</p>
|
<p className="mt-sm text-sm text-muted-foreground">暂无孩子信息</p>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="rounded-card border border-rule bg-surface p-md">
|
<section className="rounded-xl border border bg-card p-4">
|
||||||
<h3 className="text-heading-3 text-ink">孩子总览</h3>
|
<h3 className="text-heading-3 text-foreground">孩子总览</h3>
|
||||||
<div className="mt-sm grid grid-cols-2 gap-md">
|
<div className="mt-sm grid grid-cols-2 gap-4">
|
||||||
{children.map((child) => {
|
{children.map((child) => {
|
||||||
const isSelected = child.id === selectedChildId;
|
const isSelected = child.id === selectedChildId;
|
||||||
return (
|
return (
|
||||||
@@ -60,33 +60,33 @@ export default function ChildOverview(_props: PluginProps): React.ReactElement {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleSelect(child.id)}
|
onClick={() => handleSelect(child.id)}
|
||||||
aria-pressed={isSelected}
|
aria-pressed={isSelected}
|
||||||
className={`flex flex-col rounded-card border p-md text-left ${
|
className={`flex flex-col rounded-xl border p-4 text-left ${
|
||||||
isSelected
|
isSelected
|
||||||
? "border-accent bg-accent-subtle"
|
? "border-accent bg-primary-subtle"
|
||||||
: "border-rule bg-paper"
|
: "border bg-background"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-sm">
|
<div className="flex items-center gap-2">
|
||||||
<span className="flex h-10 w-10 items-center justify-center rounded-full bg-accent text-ink-onAccent">
|
<span className="flex h-10 w-10 items-center justify-center rounded-full bg-primary text-primary-foreground">
|
||||||
{child.name.charAt(0)}
|
{child.name.charAt(0)}
|
||||||
</span>
|
</span>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-body text-ink">{child.name}</p>
|
<p className="text-body text-foreground">{child.name}</p>
|
||||||
<p className="text-tiny text-ink-muted">
|
<p className="text-xs text-muted-foreground">
|
||||||
{child.grade} {child.className}
|
{child.grade} {child.className}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-sm space-y-xs">
|
<div className="mt-sm space-y-xs">
|
||||||
<p className="text-small text-ink-muted">最近成绩</p>
|
<p className="text-sm text-muted-foreground">最近成绩</p>
|
||||||
{child.recentGrades.length === 0 ? (
|
{child.recentGrades.length === 0 ? (
|
||||||
<p className="text-tiny text-ink-muted">暂无成绩</p>
|
<p className="text-xs text-muted-foreground">暂无成绩</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="space-y-xs">
|
<ul className="space-y-xs">
|
||||||
{child.recentGrades.map((g) => (
|
{child.recentGrades.map((g) => (
|
||||||
<li
|
<li
|
||||||
key={g.subject}
|
key={g.subject}
|
||||||
className="flex justify-between text-tiny text-ink"
|
className="flex justify-between text-xs text-foreground"
|
||||||
>
|
>
|
||||||
<span>{g.subject}</span>
|
<span>{g.subject}</span>
|
||||||
<span>{g.score}</span>
|
<span>{g.score}</span>
|
||||||
@@ -95,12 +95,12 @@ export default function ChildOverview(_props: PluginProps): React.ReactElement {
|
|||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-sm flex justify-between border-t border-rule pt-xs text-tiny">
|
<div className="mt-sm flex justify-between border-t border pt-xs text-xs">
|
||||||
<span className="text-ink-muted">
|
<span className="text-muted-foreground">
|
||||||
出勤:
|
出勤:
|
||||||
{formatRate(child.attendance.present, child.attendance.total)}
|
{formatRate(child.attendance.present, child.attendance.total)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-ink-muted">
|
<span className="text-muted-foreground">
|
||||||
作业:
|
作业:
|
||||||
{formatRate(
|
{formatRate(
|
||||||
child.homeworkCompletion.completed,
|
child.homeworkCompletion.completed,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* leave-approval(parent / main)
|
* leave-approval(parent / main)
|
||||||
@@ -89,13 +89,13 @@ export default function LeaveApproval(_props: PluginProps): React.ReactElement {
|
|||||||
const requests = data ?? [];
|
const requests = data ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="rounded-card border border-rule bg-surface p-md">
|
<section className="rounded-xl border border bg-card p-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h3 className="text-heading-3 text-ink">请假审批</h3>
|
<h3 className="text-heading-3 text-foreground">请假审批</h3>
|
||||||
<select
|
<select
|
||||||
value={statusFilter}
|
value={statusFilter}
|
||||||
onChange={(e) => setStatusFilter(e.target.value)}
|
onChange={(e) => setStatusFilter(e.target.value)}
|
||||||
className="rounded-button border border-rule bg-surface px-sm py-xs text-small text-ink"
|
className="rounded-md border border bg-card px-sm py-xs text-sm text-foreground"
|
||||||
aria-label="按状态筛选"
|
aria-label="按状态筛选"
|
||||||
>
|
>
|
||||||
<option value="">全部状态</option>
|
<option value="">全部状态</option>
|
||||||
@@ -106,16 +106,16 @@ export default function LeaveApproval(_props: PluginProps): React.ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{childId.length > 0 ? (
|
{childId.length > 0 ? (
|
||||||
<p className="mt-xs text-tiny text-ink-muted">已按选中孩子过滤</p>
|
<p className="mt-xs text-xs text-muted-foreground">已按选中孩子过滤</p>
|
||||||
) : null}
|
) : null}
|
||||||
{rejectError.length > 0 ? (
|
{rejectError.length > 0 ? (
|
||||||
<p className="mt-xs text-tiny text-ink-muted">{rejectError}</p>
|
<p className="mt-xs text-xs text-muted-foreground">{rejectError}</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{requests.length === 0 ? (
|
{requests.length === 0 ? (
|
||||||
<p className="mt-sm text-small text-ink-muted">暂无请假申请</p>
|
<p className="mt-sm text-sm text-muted-foreground">暂无请假申请</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="mt-sm space-y-md">
|
<ul className="mt-sm space-y-4">
|
||||||
{requests.map((req) => {
|
{requests.map((req) => {
|
||||||
const isPending = req.status === "pending";
|
const isPending = req.status === "pending";
|
||||||
const isBusy = busyId === req.id;
|
const isBusy = busyId === req.id;
|
||||||
@@ -123,22 +123,24 @@ export default function LeaveApproval(_props: PluginProps): React.ReactElement {
|
|||||||
return (
|
return (
|
||||||
<li
|
<li
|
||||||
key={req.id}
|
key={req.id}
|
||||||
className="rounded-card border border-rule bg-paper p-md"
|
className="rounded-xl border border bg-background p-4"
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-body text-ink">{req.childName}</p>
|
<p className="text-body text-foreground">{req.childName}</p>
|
||||||
<p className="text-tiny text-ink-muted">
|
<p className="text-xs text-muted-foreground">
|
||||||
{TYPE_LABELS[req.type] ?? req.type} · {req.startDate} ~{" "}
|
{TYPE_LABELS[req.type] ?? req.type} · {req.startDate} ~{" "}
|
||||||
{req.endDate}
|
{req.endDate}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="rounded-button bg-subtle px-sm py-xs text-tiny text-ink">
|
<span className="rounded-md bg-muted px-sm py-xs text-xs text-foreground">
|
||||||
{STATUS_LABELS[req.status] ?? req.status}
|
{STATUS_LABELS[req.status] ?? req.status}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-sm text-small text-ink-muted">{req.reason}</p>
|
<p className="mt-sm text-sm text-muted-foreground">
|
||||||
<p className="mt-xs text-tiny text-ink-muted">
|
{req.reason}
|
||||||
|
</p>
|
||||||
|
<p className="mt-xs text-xs text-muted-foreground">
|
||||||
提交于 {req.createdAt}
|
提交于 {req.createdAt}
|
||||||
</p>
|
</p>
|
||||||
{isPending ? (
|
{isPending ? (
|
||||||
@@ -153,14 +155,14 @@ export default function LeaveApproval(_props: PluginProps): React.ReactElement {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
placeholder="拒绝原因(拒绝时必填)"
|
placeholder="拒绝原因(拒绝时必填)"
|
||||||
className="w-full rounded-button border border-rule bg-surface px-sm py-xs text-small text-ink"
|
className="w-full rounded-md border border bg-card px-sm py-xs text-sm text-foreground"
|
||||||
/>
|
/>
|
||||||
<div className="flex gap-sm">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={isBusy}
|
disabled={isBusy}
|
||||||
onClick={() => handleApprove(req.id)}
|
onClick={() => handleApprove(req.id)}
|
||||||
className="rounded-button bg-accent px-md py-xs text-small text-ink-onAccent"
|
className="rounded-md bg-primary px-md py-xs text-sm text-primary-foreground"
|
||||||
>
|
>
|
||||||
批准
|
批准
|
||||||
</button>
|
</button>
|
||||||
@@ -168,7 +170,7 @@ export default function LeaveApproval(_props: PluginProps): React.ReactElement {
|
|||||||
type="button"
|
type="button"
|
||||||
disabled={isBusy}
|
disabled={isBusy}
|
||||||
onClick={() => handleReject(req.id)}
|
onClick={() => handleReject(req.id)}
|
||||||
className="rounded-button bg-danger px-md py-xs text-small text-ink-onAccent"
|
className="rounded-md bg-danger px-md py-xs text-sm text-primary-foreground"
|
||||||
>
|
>
|
||||||
拒绝
|
拒绝
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* child-selector(sidebar / side)
|
* child-selector(sidebar / side)
|
||||||
@@ -35,11 +35,11 @@ export default function ChildSelector(_props: PluginProps): React.ReactElement {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<p className="text-small text-ink-muted">孩子</p>
|
<p className="text-sm text-muted-foreground">孩子</p>
|
||||||
<select
|
<select
|
||||||
value={currentChildId}
|
value={currentChildId}
|
||||||
onChange={(e) => handleSelect(e.target.value)}
|
onChange={(e) => handleSelect(e.target.value)}
|
||||||
className="mt-xs w-full rounded-button border border-rule bg-surface px-sm py-xs text-small text-ink"
|
className="mt-xs w-full rounded-md border border bg-card px-sm py-xs text-sm text-foreground"
|
||||||
aria-label="选择孩子"
|
aria-label="选择孩子"
|
||||||
>
|
>
|
||||||
<option value="">请选择孩子</option>
|
<option value="">请选择孩子</option>
|
||||||
@@ -50,7 +50,7 @@ export default function ChildSelector(_props: PluginProps): React.ReactElement {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
{current ? (
|
{current ? (
|
||||||
<p className="mt-xs text-tiny text-ink-muted">
|
<p className="mt-xs text-xs text-muted-foreground">
|
||||||
{current.grade} · {current.className}
|
{current.grade} · {current.className}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* class-selector(sidebar / side)
|
* class-selector(sidebar / side)
|
||||||
@@ -34,11 +34,11 @@ export default function ClassSelector(_props: PluginProps): React.ReactElement {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<p className="text-small text-ink-muted">班级</p>
|
<p className="text-sm text-muted-foreground">班级</p>
|
||||||
<select
|
<select
|
||||||
value={currentClassId}
|
value={currentClassId}
|
||||||
onChange={(e) => handleSelect(e.target.value)}
|
onChange={(e) => handleSelect(e.target.value)}
|
||||||
className="mt-xs w-full rounded-button border border-rule bg-surface px-sm py-xs text-small text-ink"
|
className="mt-xs w-full rounded-md border border bg-card px-sm py-xs text-sm text-foreground"
|
||||||
aria-label="选择班级"
|
aria-label="选择班级"
|
||||||
>
|
>
|
||||||
<option value="">请选择班级</option>
|
<option value="">请选择班级</option>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* quick-actions(sidebar / side)
|
* quick-actions(sidebar / side)
|
||||||
@@ -44,14 +44,14 @@ export default function QuickActions(props: PluginProps): React.ReactElement {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<p className="text-small text-ink-muted">快捷操作</p>
|
<p className="text-sm text-muted-foreground">快捷操作</p>
|
||||||
<ul className="mt-xs space-y-sm">
|
<ul className="mt-xs space-y-sm">
|
||||||
{actions.map((item) => (
|
{actions.map((item) => (
|
||||||
<li key={item.path}>
|
<li key={item.path}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleClick(item.path)}
|
onClick={() => handleClick(item.path)}
|
||||||
className="w-full rounded-button border border-rule bg-surface px-sm py-xs text-left text-small text-ink"
|
className="w-full rounded-md border border bg-card px-sm py-xs text-left text-sm text-foreground"
|
||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* term-switcher(sidebar / side)
|
* term-switcher(sidebar / side)
|
||||||
@@ -38,11 +38,11 @@ export default function TermSwitcher(_props: PluginProps): React.ReactElement {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<p className="text-small text-ink-muted">学期</p>
|
<p className="text-sm text-muted-foreground">学期</p>
|
||||||
<select
|
<select
|
||||||
value={selectedTermId}
|
value={selectedTermId}
|
||||||
onChange={(e) => handleSelect(e.target.value)}
|
onChange={(e) => handleSelect(e.target.value)}
|
||||||
className="mt-xs w-full rounded-button border border-rule bg-surface px-sm py-xs text-small text-ink"
|
className="mt-xs w-full rounded-md border border bg-card px-sm py-xs text-sm text-foreground"
|
||||||
aria-label="选择学期"
|
aria-label="选择学期"
|
||||||
>
|
>
|
||||||
<option value="">请选择学期</option>
|
<option value="">请选择学期</option>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ai-tutor(student / main)
|
* ai-tutor(student / main)
|
||||||
@@ -70,12 +70,12 @@ export default function AiTutor(props: PluginProps): React.ReactElement {
|
|||||||
const canSend = !sending && input.trim().length > 0;
|
const canSend = !sending && input.trim().length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="rounded-card border border-rule bg-surface p-md">
|
<section className="rounded-xl border border bg-card p-4">
|
||||||
<h3 className="text-heading-3 text-ink">AI 辅导</h3>
|
<h3 className="text-heading-3 text-foreground">AI 辅导</h3>
|
||||||
<div className="mt-sm flex">
|
<div className="mt-sm flex">
|
||||||
{/* 左侧会话列表 */}
|
{/* 左侧会话列表 */}
|
||||||
<div className="flex w-64 flex-col border-r border-rule pr-sm">
|
<div className="flex w-64 flex-col border-r border pr-sm">
|
||||||
<p className="text-small text-ink-muted">会话列表</p>
|
<p className="text-sm text-muted-foreground">会话列表</p>
|
||||||
<ul className="mt-xs space-y-sm">
|
<ul className="mt-xs space-y-sm">
|
||||||
{sessions.map((session) => {
|
{sessions.map((session) => {
|
||||||
const active = activeSessionId === session.id;
|
const active = activeSessionId === session.id;
|
||||||
@@ -84,20 +84,20 @@ export default function AiTutor(props: PluginProps): React.ReactElement {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleSelectSession(session.id)}
|
onClick={() => handleSelectSession(session.id)}
|
||||||
className={`w-full rounded-button px-sm py-xs text-left ${active ? "bg-accent" : "bg-paper"}`}
|
className={`w-full rounded-md px-sm py-xs text-left ${active ? "bg-primary" : "bg-background"}`}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={`block text-small ${active ? "text-ink-onAccent" : "text-ink"}`}
|
className={`block text-sm ${active ? "text-primary-foreground" : "text-foreground"}`}
|
||||||
>
|
>
|
||||||
{session.title}
|
{session.title}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
className={`mt-xs block text-tiny ${active ? "text-ink-onAccent" : "text-ink-muted"}`}
|
className={`mt-xs block text-xs ${active ? "text-primary-foreground" : "text-muted-foreground"}`}
|
||||||
>
|
>
|
||||||
{session.lastMessage}
|
{session.lastMessage}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
className={`block text-tiny ${active ? "text-ink-onAccent" : "text-ink-muted"}`}
|
className={`block text-xs ${active ? "text-primary-foreground" : "text-muted-foreground"}`}
|
||||||
>
|
>
|
||||||
{session.updatedAt}
|
{session.updatedAt}
|
||||||
</span>
|
</span>
|
||||||
@@ -112,7 +112,7 @@ export default function AiTutor(props: PluginProps): React.ReactElement {
|
|||||||
<div className="flex flex-1 flex-col pl-sm">
|
<div className="flex flex-1 flex-col pl-sm">
|
||||||
<div className="flex-1 space-y-sm">
|
<div className="flex-1 space-y-sm">
|
||||||
{messages.length === 0 ? (
|
{messages.length === 0 ? (
|
||||||
<p className="text-small text-ink-muted">
|
<p className="text-sm text-muted-foreground">
|
||||||
请输入问题开始与 AI 辅导对话
|
请输入问题开始与 AI 辅导对话
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
@@ -121,8 +121,8 @@ export default function AiTutor(props: PluginProps): React.ReactElement {
|
|||||||
key={msg.id}
|
key={msg.id}
|
||||||
className={
|
className={
|
||||||
msg.role === "user"
|
msg.role === "user"
|
||||||
? "ml-sm rounded-button bg-accent px-sm py-xs text-small text-ink-onAccent"
|
? "ml-sm rounded-md bg-primary px-sm py-xs text-sm text-primary-foreground"
|
||||||
: "rounded-button bg-paper px-sm py-xs text-small text-ink"
|
: "rounded-md bg-background px-sm py-xs text-sm text-foreground"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{msg.content}
|
{msg.content}
|
||||||
@@ -130,7 +130,7 @@ export default function AiTutor(props: PluginProps): React.ReactElement {
|
|||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
{sending && (
|
{sending && (
|
||||||
<p className="text-tiny text-ink-muted">AI 正在回复...</p>
|
<p className="text-xs text-muted-foreground">AI 正在回复...</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -146,7 +146,7 @@ export default function AiTutor(props: PluginProps): React.ReactElement {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
placeholder="输入你的问题..."
|
placeholder="输入你的问题..."
|
||||||
className="flex-1 rounded-button border border-rule bg-surface px-sm py-xs text-small text-ink"
|
className="flex-1 rounded-md border border bg-card px-sm py-xs text-sm text-foreground"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -154,8 +154,8 @@ export default function AiTutor(props: PluginProps): React.ReactElement {
|
|||||||
onClick={() => void handleSend()}
|
onClick={() => void handleSend()}
|
||||||
className={
|
className={
|
||||||
canSend
|
canSend
|
||||||
? "ml-sm rounded-button bg-accent px-sm py-xs text-small text-ink-onAccent"
|
? "ml-sm rounded-md bg-primary px-sm py-xs text-sm text-primary-foreground"
|
||||||
: "ml-sm rounded-button bg-subtle px-sm py-xs text-small text-ink-muted"
|
: "ml-sm rounded-md bg-muted px-sm py-xs text-sm text-muted-foreground"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
发送
|
发送
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* elective-selector(student / main)
|
* elective-selector(student / main)
|
||||||
@@ -19,9 +19,9 @@ import { PluginSkeleton } from "@/shell/PluginLoader";
|
|||||||
import type { PluginProps } from "@/lib/types";
|
import type { PluginProps } from "@/lib/types";
|
||||||
|
|
||||||
const CATEGORY_BADGE: Record<CourseCategory, string> = {
|
const CATEGORY_BADGE: Record<CourseCategory, string> = {
|
||||||
必修: "bg-accent text-ink-onAccent",
|
必修: "bg-primary text-primary-foreground",
|
||||||
选修: "bg-subtle text-ink",
|
选修: "bg-muted text-foreground",
|
||||||
拓展: "bg-accent-subtle text-ink",
|
拓展: "bg-primary-subtle text-foreground",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ElectiveSelector(
|
export default function ElectiveSelector(
|
||||||
@@ -64,9 +64,9 @@ export default function ElectiveSelector(
|
|||||||
|
|
||||||
if (!termId) {
|
if (!termId) {
|
||||||
return (
|
return (
|
||||||
<section className="rounded-card border border-rule bg-surface p-md">
|
<section className="rounded-xl border border bg-card p-4">
|
||||||
<h3 className="text-heading-3 text-ink">选课</h3>
|
<h3 className="text-heading-3 text-foreground">选课</h3>
|
||||||
<p className="text-small text-ink-muted">请先选择学期</p>
|
<p className="text-sm text-muted-foreground">请先选择学期</p>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -74,29 +74,31 @@ export default function ElectiveSelector(
|
|||||||
const courses = data ?? [];
|
const courses = data ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="rounded-card border border-rule bg-surface p-md">
|
<section className="rounded-xl border border bg-card p-4">
|
||||||
<h3 className="text-heading-3 text-ink">选课</h3>
|
<h3 className="text-heading-3 text-foreground">选课</h3>
|
||||||
{courses.length === 0 ? (
|
{courses.length === 0 ? (
|
||||||
<p className="mt-sm text-small text-ink-muted">暂无可选课程</p>
|
<p className="mt-sm text-sm text-muted-foreground">暂无可选课程</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="mt-sm space-y-md">
|
<ul className="mt-sm space-y-4">
|
||||||
{courses.map((course) => {
|
{courses.map((course) => {
|
||||||
const enrolled = enrolledIds[course.id] === true;
|
const enrolled = enrolledIds[course.id] === true;
|
||||||
const full = course.enrolled >= course.capacity;
|
const full = course.enrolled >= course.capacity;
|
||||||
return (
|
return (
|
||||||
<li
|
<li
|
||||||
key={course.id}
|
key={course.id}
|
||||||
className="rounded-card border border-rule bg-paper p-sm"
|
className="rounded-xl border border bg-background p-2"
|
||||||
>
|
>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<span className="text-body text-ink">{course.name}</span>
|
<span className="text-body text-foreground">
|
||||||
|
{course.name}
|
||||||
|
</span>
|
||||||
<span
|
<span
|
||||||
className={`ml-sm rounded-button px-xs py-xs text-tiny ${CATEGORY_BADGE[course.category]}`}
|
className={`ml-sm rounded-md px-xs py-xs text-xs ${CATEGORY_BADGE[course.category]}`}
|
||||||
>
|
>
|
||||||
{course.category}
|
{course.category}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-xs flex flex-col text-small text-ink-muted">
|
<div className="mt-xs flex flex-col text-sm text-muted-foreground">
|
||||||
<span>教师:{course.teacher}</span>
|
<span>教师:{course.teacher}</span>
|
||||||
<span>
|
<span>
|
||||||
容量:{course.enrolled}/{course.capacity}
|
容量:{course.enrolled}/{course.capacity}
|
||||||
@@ -113,8 +115,8 @@ export default function ElectiveSelector(
|
|||||||
onClick={() => void handleDrop(course.id)}
|
onClick={() => void handleDrop(course.id)}
|
||||||
className={
|
className={
|
||||||
dropping
|
dropping
|
||||||
? "rounded-button bg-subtle px-sm py-xs text-tiny text-ink-muted"
|
? "rounded-md bg-muted px-sm py-xs text-xs text-muted-foreground"
|
||||||
: "rounded-button bg-danger px-sm py-xs text-tiny text-ink-onAccent"
|
: "rounded-md bg-danger px-sm py-xs text-xs text-primary-foreground"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
退课
|
退课
|
||||||
@@ -126,8 +128,8 @@ export default function ElectiveSelector(
|
|||||||
onClick={() => void handleEnroll(course.id)}
|
onClick={() => void handleEnroll(course.id)}
|
||||||
className={
|
className={
|
||||||
enrolling || full
|
enrolling || full
|
||||||
? "rounded-button bg-subtle px-sm py-xs text-tiny text-ink-muted"
|
? "rounded-md bg-muted px-sm py-xs text-xs text-muted-foreground"
|
||||||
: "rounded-button bg-accent px-sm py-xs text-tiny text-ink-onAccent"
|
: "rounded-md bg-primary px-sm py-xs text-xs text-primary-foreground"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{full ? "已满" : "选课"}
|
{full ? "已满" : "选课"}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* error-book(student / main)
|
* error-book(student / main)
|
||||||
@@ -46,12 +46,12 @@ export default function ErrorBook(props: PluginProps): React.ReactElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="rounded-card border border-rule bg-surface p-md">
|
<section className="rounded-xl border border bg-card p-4">
|
||||||
<h3 className="text-heading-3 text-ink">错题本</h3>
|
<h3 className="text-heading-3 text-foreground">错题本</h3>
|
||||||
<div className="mt-sm flex items-center">
|
<div className="mt-sm flex items-center">
|
||||||
<label
|
<label
|
||||||
htmlFor="error-book-subject"
|
htmlFor="error-book-subject"
|
||||||
className="text-small text-ink-muted"
|
className="text-sm text-muted-foreground"
|
||||||
>
|
>
|
||||||
科目
|
科目
|
||||||
</label>
|
</label>
|
||||||
@@ -59,7 +59,7 @@ export default function ErrorBook(props: PluginProps): React.ReactElement {
|
|||||||
id="error-book-subject"
|
id="error-book-subject"
|
||||||
value={subjectFilter}
|
value={subjectFilter}
|
||||||
onChange={(e) => setSubjectFilter(e.target.value)}
|
onChange={(e) => setSubjectFilter(e.target.value)}
|
||||||
className="ml-sm rounded-button border border-rule bg-surface px-sm py-xs text-small text-ink"
|
className="ml-sm rounded-md border border bg-card px-sm py-xs text-sm text-foreground"
|
||||||
>
|
>
|
||||||
<option value="">全部科目</option>
|
<option value="">全部科目</option>
|
||||||
{subjects.map((s) => (
|
{subjects.map((s) => (
|
||||||
@@ -71,33 +71,35 @@ export default function ErrorBook(props: PluginProps): React.ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{filteredItems.length === 0 ? (
|
{filteredItems.length === 0 ? (
|
||||||
<p className="mt-sm text-small text-ink-muted">暂无错题数据</p>
|
<p className="mt-sm text-sm text-muted-foreground">暂无错题数据</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="mt-sm space-y-md">
|
<ul className="mt-sm space-y-4">
|
||||||
{filteredItems.map((item) => {
|
{filteredItems.map((item) => {
|
||||||
const mastered = masteredIds[item.id] === true;
|
const mastered = masteredIds[item.id] === true;
|
||||||
return (
|
return (
|
||||||
<li
|
<li
|
||||||
key={item.id}
|
key={item.id}
|
||||||
className="rounded-card border border-rule bg-paper p-sm"
|
className="rounded-xl border border bg-background p-2"
|
||||||
>
|
>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<span className="rounded-button bg-subtle px-sm py-xs text-tiny text-ink">
|
<span className="rounded-md bg-muted px-sm py-xs text-xs text-foreground">
|
||||||
{item.subject}
|
{item.subject}
|
||||||
</span>
|
</span>
|
||||||
<span className="ml-sm text-tiny text-ink-muted">
|
<span className="ml-sm text-xs text-muted-foreground">
|
||||||
错误 {item.errorCount} 次
|
错误 {item.errorCount} 次
|
||||||
</span>
|
</span>
|
||||||
<span className="ml-sm text-tiny text-ink-muted">
|
<span className="ml-sm text-xs text-muted-foreground">
|
||||||
最后错误:{item.lastErrorAt}
|
最后错误:{item.lastErrorAt}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-xs text-body text-ink">{item.question}</p>
|
<p className="mt-xs text-body text-foreground">
|
||||||
|
{item.question}
|
||||||
|
</p>
|
||||||
<div className="mt-xs flex flex-col">
|
<div className="mt-xs flex flex-col">
|
||||||
<span className="text-small text-ink-muted">
|
<span className="text-sm text-muted-foreground">
|
||||||
我的答案:{item.myAnswer}
|
我的答案:{item.myAnswer}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-small text-ink">
|
<span className="text-sm text-foreground">
|
||||||
正确答案:{item.correctAnswer}
|
正确答案:{item.correctAnswer}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -107,8 +109,8 @@ export default function ErrorBook(props: PluginProps): React.ReactElement {
|
|||||||
onClick={() => void handleMarkMastered(item.id)}
|
onClick={() => void handleMarkMastered(item.id)}
|
||||||
className={
|
className={
|
||||||
mastered
|
mastered
|
||||||
? "mt-xs rounded-button bg-subtle px-sm py-xs text-tiny text-ink-muted"
|
? "mt-xs rounded-md bg-muted px-sm py-xs text-xs text-muted-foreground"
|
||||||
: "mt-xs rounded-button bg-accent px-sm py-xs text-tiny text-ink-onAccent"
|
: "mt-xs rounded-md bg-primary px-sm py-xs text-xs text-primary-foreground"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{mastered ? "已掌握" : "标记已掌握"}
|
{mastered ? "已掌握" : "标记已掌握"}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* learning-path(student / main)
|
* learning-path(student / main)
|
||||||
@@ -33,15 +33,15 @@ const STATUS_LABEL: Record<LearningNodeStatus, string> = {
|
|||||||
function statusClass(status: LearningNodeStatus): string {
|
function statusClass(status: LearningNodeStatus): string {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case "locked":
|
case "locked":
|
||||||
return "bg-subtle text-ink-muted";
|
return "bg-muted text-muted-foreground";
|
||||||
case "available":
|
case "available":
|
||||||
return "border border-rule bg-surface text-ink";
|
return "border border bg-card text-foreground";
|
||||||
case "in_progress":
|
case "in_progress":
|
||||||
return "bg-warning text-ink";
|
return "bg-warning text-foreground";
|
||||||
case "completed":
|
case "completed":
|
||||||
return "bg-success text-ink";
|
return "bg-success text-foreground";
|
||||||
default:
|
default:
|
||||||
return "bg-surface text-ink";
|
return "bg-card text-foreground";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,9 +69,9 @@ export default function LearningPath(_props: PluginProps): React.ReactElement {
|
|||||||
|
|
||||||
if (!subjectId) {
|
if (!subjectId) {
|
||||||
return (
|
return (
|
||||||
<section className="rounded-card border border-rule bg-surface p-md">
|
<section className="rounded-xl border border bg-card p-4">
|
||||||
<h3 className="text-heading-3 text-ink">学习路径</h3>
|
<h3 className="text-heading-3 text-foreground">学习路径</h3>
|
||||||
<p className="text-small text-ink-muted">请先选择科目</p>
|
<p className="text-sm text-muted-foreground">请先选择科目</p>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -81,32 +81,32 @@ export default function LearningPath(_props: PluginProps): React.ReactElement {
|
|||||||
const progress = path?.progress ?? 0;
|
const progress = path?.progress ?? 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="rounded-card border border-rule bg-surface p-md">
|
<section className="rounded-xl border border bg-card p-4">
|
||||||
<h3 className="text-heading-3 text-ink">学习路径</h3>
|
<h3 className="text-heading-3 text-foreground">学习路径</h3>
|
||||||
|
|
||||||
<div className="mt-sm">
|
<div className="mt-sm">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-small text-ink-muted">整体进度</span>
|
<span className="text-sm text-muted-foreground">整体进度</span>
|
||||||
<span className="text-small text-ink">{progress}%</span>
|
<span className="text-sm text-foreground">{progress}%</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-xs h-xs w-full rounded-button bg-subtle">
|
<div className="mt-xs h-xs w-full rounded-md bg-muted">
|
||||||
<div
|
<div
|
||||||
className="h-xs rounded-button bg-accent"
|
className="h-xs rounded-md bg-primary"
|
||||||
style={{ width: `${progress}%` }}
|
style={{ width: `${progress}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{nodes.length === 0 ? (
|
{nodes.length === 0 ? (
|
||||||
<p className="mt-sm text-small text-ink-muted">暂无学习路径数据</p>
|
<p className="mt-sm text-sm text-muted-foreground">暂无学习路径数据</p>
|
||||||
) : (
|
) : (
|
||||||
<ol className="mt-md space-y-md">
|
<ol className="mt-md space-y-4">
|
||||||
{nodes.map((node) => {
|
{nodes.map((node) => {
|
||||||
const clickable = node.status !== "locked";
|
const clickable = node.status !== "locked";
|
||||||
return (
|
return (
|
||||||
<li key={node.id} className="flex items-start">
|
<li key={node.id} className="flex items-start">
|
||||||
<span
|
<span
|
||||||
className={`flex w-64 items-center justify-center rounded-button px-sm py-xs text-tiny ${statusClass(
|
className={`flex w-64 items-center justify-center rounded-md px-sm py-xs text-xs ${statusClass(
|
||||||
node.status,
|
node.status,
|
||||||
)}`}
|
)}`}
|
||||||
>
|
>
|
||||||
@@ -114,9 +114,11 @@ export default function LearningPath(_props: PluginProps): React.ReactElement {
|
|||||||
</span>
|
</span>
|
||||||
<div className="ml-sm flex-1">
|
<div className="ml-sm flex-1">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<span className="text-body text-ink">{node.title}</span>
|
<span className="text-body text-foreground">
|
||||||
|
{node.title}
|
||||||
|
</span>
|
||||||
<span
|
<span
|
||||||
className={`ml-sm rounded-button px-xs py-xs text-tiny ${statusClass(
|
className={`ml-sm rounded-md px-xs py-xs text-xs ${statusClass(
|
||||||
node.status,
|
node.status,
|
||||||
)}`}
|
)}`}
|
||||||
>
|
>
|
||||||
@@ -124,7 +126,7 @@ export default function LearningPath(_props: PluginProps): React.ReactElement {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{node.dependencies.length > 0 && (
|
{node.dependencies.length > 0 && (
|
||||||
<p className="mt-xs text-tiny text-ink-muted">
|
<p className="mt-xs text-xs text-muted-foreground">
|
||||||
依赖:{node.dependencies.join("、")}
|
依赖:{node.dependencies.join("、")}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -132,7 +134,7 @@ export default function LearningPath(_props: PluginProps): React.ReactElement {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleNavigate(node)}
|
onClick={() => handleNavigate(node)}
|
||||||
className="mt-xs rounded-button border border-rule bg-surface px-sm py-xs text-tiny text-ink"
|
className="mt-xs rounded-md border border bg-card px-sm py-xs text-xs text-foreground"
|
||||||
>
|
>
|
||||||
进入学习
|
进入学习
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* lesson-plan-editor(teacher / main)
|
* lesson-plan-editor(teacher / main)
|
||||||
@@ -43,9 +43,9 @@ export default function LessonPlanEditor(
|
|||||||
|
|
||||||
if (!classId) {
|
if (!classId) {
|
||||||
return (
|
return (
|
||||||
<section className="rounded-card border border-rule bg-surface p-md">
|
<section className="rounded-xl border border bg-card p-4">
|
||||||
<h3 className="text-heading-3 text-ink">备课画布</h3>
|
<h3 className="text-heading-3 text-foreground">备课画布</h3>
|
||||||
<p className="text-small text-ink-muted">请先选择班级</p>
|
<p className="text-sm text-muted-foreground">请先选择班级</p>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -101,31 +101,31 @@ export default function LessonPlanEditor(
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="rounded-card border border-rule bg-surface p-md">
|
<section className="rounded-xl border border bg-card p-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h3 className="text-heading-3 text-ink">备课画布</h3>
|
<h3 className="text-heading-3 text-foreground">备课画布</h3>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleNew}
|
onClick={handleNew}
|
||||||
className="rounded-button bg-accent px-sm py-xs text-small text-ink-onAccent"
|
className="rounded-md bg-primary px-sm py-xs text-sm text-primary-foreground"
|
||||||
>
|
>
|
||||||
新建备课
|
新建备课
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-sm flex gap-md">
|
<div className="mt-sm flex gap-4">
|
||||||
<ul className="w-64 shrink-0 space-y-sm">
|
<ul className="w-64 shrink-0 space-y-sm">
|
||||||
{plans.length === 0 ? (
|
{plans.length === 0 ? (
|
||||||
<li className="text-small text-ink-muted">暂无备课记录</li>
|
<li className="text-sm text-muted-foreground">暂无备课记录</li>
|
||||||
) : (
|
) : (
|
||||||
plans.map((p) => (
|
plans.map((p) => (
|
||||||
<li key={p.id}>
|
<li key={p.id}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleSelect(p)}
|
onClick={() => handleSelect(p)}
|
||||||
className={`w-full rounded-button border border-rule px-sm py-xs text-left text-small ${
|
className={`w-full rounded-md border border px-sm py-xs text-left text-sm ${
|
||||||
draft.id === p.id
|
draft.id === p.id
|
||||||
? "bg-subtle text-ink"
|
? "bg-muted text-foreground"
|
||||||
: "bg-surface text-ink"
|
: "bg-card text-foreground"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{p.title || "未命名备课"}
|
{p.title || "未命名备课"}
|
||||||
@@ -134,45 +134,45 @@ export default function LessonPlanEditor(
|
|||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</ul>
|
</ul>
|
||||||
<div className="flex-1 space-y-md">
|
<div className="flex-1 space-y-4">
|
||||||
<label className="flex flex-col space-y-xs">
|
<label className="flex flex-col space-y-xs">
|
||||||
<span className="text-small text-ink-muted">标题</span>
|
<span className="text-sm text-muted-foreground">标题</span>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={draft.title}
|
value={draft.title}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setDraft((d) => ({ ...d, title: e.target.value }))
|
setDraft((d) => ({ ...d, title: e.target.value }))
|
||||||
}
|
}
|
||||||
className="rounded-button border border-rule bg-surface px-sm py-xs text-small text-ink"
|
className="rounded-md border border bg-card px-sm py-xs text-sm text-foreground"
|
||||||
placeholder="请输入备课标题"
|
placeholder="请输入备课标题"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="flex flex-col space-y-xs">
|
<label className="flex flex-col space-y-xs">
|
||||||
<span className="text-small text-ink-muted">教学目标</span>
|
<span className="text-sm text-muted-foreground">教学目标</span>
|
||||||
<textarea
|
<textarea
|
||||||
value={draft.objectives}
|
value={draft.objectives}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setDraft((d) => ({ ...d, objectives: e.target.value }))
|
setDraft((d) => ({ ...d, objectives: e.target.value }))
|
||||||
}
|
}
|
||||||
className="rounded-button border border-rule bg-surface px-sm py-xs text-small text-ink"
|
className="rounded-md border border bg-card px-sm py-xs text-sm text-foreground"
|
||||||
rows={3}
|
rows={3}
|
||||||
placeholder="请输入教学目标"
|
placeholder="请输入教学目标"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="flex flex-col space-y-xs">
|
<label className="flex flex-col space-y-xs">
|
||||||
<span className="text-small text-ink-muted">教学内容</span>
|
<span className="text-sm text-muted-foreground">教学内容</span>
|
||||||
<textarea
|
<textarea
|
||||||
value={draft.content}
|
value={draft.content}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setDraft((d) => ({ ...d, content: e.target.value }))
|
setDraft((d) => ({ ...d, content: e.target.value }))
|
||||||
}
|
}
|
||||||
className="rounded-button border border-rule bg-surface px-sm py-xs text-small text-ink"
|
className="rounded-md border border bg-card px-sm py-xs text-sm text-foreground"
|
||||||
rows={4}
|
rows={4}
|
||||||
placeholder="请输入教学内容"
|
placeholder="请输入教学内容"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<div className="flex flex-col space-y-xs">
|
<div className="flex flex-col space-y-xs">
|
||||||
<span className="text-small text-ink-muted">教学资源</span>
|
<span className="text-sm text-muted-foreground">教学资源</span>
|
||||||
<ul className="space-y-xs">
|
<ul className="space-y-xs">
|
||||||
{draft.resources.map((r, i) => (
|
{draft.resources.map((r, i) => (
|
||||||
<li key={i} className="flex gap-xs">
|
<li key={i} className="flex gap-xs">
|
||||||
@@ -180,13 +180,13 @@ export default function LessonPlanEditor(
|
|||||||
type="text"
|
type="text"
|
||||||
value={r}
|
value={r}
|
||||||
onChange={(e) => handleResourceChange(i, e.target.value)}
|
onChange={(e) => handleResourceChange(i, e.target.value)}
|
||||||
className="w-full rounded-button border border-rule bg-surface px-sm py-xs text-small text-ink"
|
className="w-full rounded-md border border bg-card px-sm py-xs text-sm text-foreground"
|
||||||
placeholder="资源名称或链接"
|
placeholder="资源名称或链接"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleResourceRemove(i)}
|
onClick={() => handleResourceRemove(i)}
|
||||||
className="rounded-button bg-subtle px-sm py-xs text-small text-ink"
|
className="rounded-md bg-muted px-sm py-xs text-sm text-foreground"
|
||||||
>
|
>
|
||||||
移除
|
移除
|
||||||
</button>
|
</button>
|
||||||
@@ -196,7 +196,7 @@ export default function LessonPlanEditor(
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleResourceAdd}
|
onClick={handleResourceAdd}
|
||||||
className="self-start rounded-button bg-subtle px-sm py-xs text-small text-ink"
|
className="self-start rounded-md bg-muted px-sm py-xs text-sm text-foreground"
|
||||||
>
|
>
|
||||||
添加资源
|
添加资源
|
||||||
</button>
|
</button>
|
||||||
@@ -205,7 +205,7 @@ export default function LessonPlanEditor(
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={saving || !draft.title.trim()}
|
disabled={saving || !draft.title.trim()}
|
||||||
className="rounded-button bg-accent px-md py-xs text-small text-ink-onAccent disabled:opacity-50"
|
className="rounded-md bg-primary px-md py-xs text-sm text-primary-foreground disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{saving ? "保存中" : "保存"}
|
{saving ? "保存中" : "保存"}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* question-bank(teacher / main)
|
* question-bank(teacher / main)
|
||||||
@@ -66,9 +66,9 @@ export default function QuestionBank(_props: PluginProps): React.ReactElement {
|
|||||||
|
|
||||||
if (!bankId) {
|
if (!bankId) {
|
||||||
return (
|
return (
|
||||||
<section className="rounded-card border border-rule bg-surface p-md">
|
<section className="rounded-xl border border bg-card p-4">
|
||||||
<h3 className="text-heading-3 text-ink">题库管理</h3>
|
<h3 className="text-heading-3 text-foreground">题库管理</h3>
|
||||||
<p className="text-small text-ink-muted">请先选择题库</p>
|
<p className="text-sm text-muted-foreground">请先选择题库</p>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -98,25 +98,25 @@ export default function QuestionBank(_props: PluginProps): React.ReactElement {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="rounded-card border border-rule bg-surface p-md">
|
<section className="rounded-xl border border bg-card p-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h3 className="text-heading-3 text-ink">题库管理</h3>
|
<h3 className="text-heading-3 text-foreground">题库管理</h3>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowForm((v) => !v)}
|
onClick={() => setShowForm((v) => !v)}
|
||||||
className="rounded-button bg-accent px-sm py-xs text-small text-ink-onAccent"
|
className="rounded-md bg-primary px-sm py-xs text-sm text-primary-foreground"
|
||||||
>
|
>
|
||||||
{showForm ? "收起新建" : "新建题目"}
|
{showForm ? "收起新建" : "新建题目"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-sm flex gap-md">
|
<div className="mt-sm flex gap-4">
|
||||||
<label className="flex flex-col space-y-xs">
|
<label className="flex flex-col space-y-xs">
|
||||||
<span className="text-small text-ink-muted">题型</span>
|
<span className="text-sm text-muted-foreground">题型</span>
|
||||||
<select
|
<select
|
||||||
value={typeFilter}
|
value={typeFilter}
|
||||||
onChange={(e) => setTypeFilter(e.target.value)}
|
onChange={(e) => setTypeFilter(e.target.value)}
|
||||||
className="rounded-button border border-rule bg-surface px-sm py-xs text-small text-ink"
|
className="rounded-md border border bg-card px-sm py-xs text-sm text-foreground"
|
||||||
>
|
>
|
||||||
<option value="">全部</option>
|
<option value="">全部</option>
|
||||||
{QUESTION_TYPES.map((t) => (
|
{QUESTION_TYPES.map((t) => (
|
||||||
@@ -127,11 +127,11 @@ export default function QuestionBank(_props: PluginProps): React.ReactElement {
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label className="flex flex-col space-y-xs">
|
<label className="flex flex-col space-y-xs">
|
||||||
<span className="text-small text-ink-muted">难度</span>
|
<span className="text-sm text-muted-foreground">难度</span>
|
||||||
<select
|
<select
|
||||||
value={difficultyFilter}
|
value={difficultyFilter}
|
||||||
onChange={(e) => setDifficultyFilter(e.target.value)}
|
onChange={(e) => setDifficultyFilter(e.target.value)}
|
||||||
className="rounded-button border border-rule bg-surface px-sm py-xs text-small text-ink"
|
className="rounded-md border border bg-card px-sm py-xs text-sm text-foreground"
|
||||||
>
|
>
|
||||||
<option value="">全部</option>
|
<option value="">全部</option>
|
||||||
{DIFFICULTIES.map((d) => (
|
{DIFFICULTIES.map((d) => (
|
||||||
@@ -144,16 +144,16 @@ export default function QuestionBank(_props: PluginProps): React.ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showForm ? (
|
{showForm ? (
|
||||||
<div className="mt-sm space-y-md rounded-card bg-subtle p-md">
|
<div className="mt-sm space-y-4 rounded-xl bg-muted p-4">
|
||||||
<div className="flex gap-md">
|
<div className="flex gap-4">
|
||||||
<label className="flex flex-col space-y-xs">
|
<label className="flex flex-col space-y-xs">
|
||||||
<span className="text-small text-ink-muted">题型</span>
|
<span className="text-sm text-muted-foreground">题型</span>
|
||||||
<select
|
<select
|
||||||
value={newQuestion.type}
|
value={newQuestion.type}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setNewQuestion((q) => ({ ...q, type: e.target.value }))
|
setNewQuestion((q) => ({ ...q, type: e.target.value }))
|
||||||
}
|
}
|
||||||
className="rounded-button border border-rule bg-surface px-sm py-xs text-small text-ink"
|
className="rounded-md border border bg-card px-sm py-xs text-sm text-foreground"
|
||||||
>
|
>
|
||||||
{QUESTION_TYPES.map((t) => (
|
{QUESTION_TYPES.map((t) => (
|
||||||
<option key={t} value={t}>
|
<option key={t} value={t}>
|
||||||
@@ -163,13 +163,13 @@ export default function QuestionBank(_props: PluginProps): React.ReactElement {
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label className="flex flex-col space-y-xs">
|
<label className="flex flex-col space-y-xs">
|
||||||
<span className="text-small text-ink-muted">难度</span>
|
<span className="text-sm text-muted-foreground">难度</span>
|
||||||
<select
|
<select
|
||||||
value={newQuestion.difficulty}
|
value={newQuestion.difficulty}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setNewQuestion((q) => ({ ...q, difficulty: e.target.value }))
|
setNewQuestion((q) => ({ ...q, difficulty: e.target.value }))
|
||||||
}
|
}
|
||||||
className="rounded-button border border-rule bg-surface px-sm py-xs text-small text-ink"
|
className="rounded-md border border bg-card px-sm py-xs text-sm text-foreground"
|
||||||
>
|
>
|
||||||
{DIFFICULTIES.map((d) => (
|
{DIFFICULTIES.map((d) => (
|
||||||
<option key={d} value={d}>
|
<option key={d} value={d}>
|
||||||
@@ -180,25 +180,25 @@ export default function QuestionBank(_props: PluginProps): React.ReactElement {
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<label className="flex flex-col space-y-xs">
|
<label className="flex flex-col space-y-xs">
|
||||||
<span className="text-small text-ink-muted">题干</span>
|
<span className="text-sm text-muted-foreground">题干</span>
|
||||||
<textarea
|
<textarea
|
||||||
value={newQuestion.content}
|
value={newQuestion.content}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setNewQuestion((q) => ({ ...q, content: e.target.value }))
|
setNewQuestion((q) => ({ ...q, content: e.target.value }))
|
||||||
}
|
}
|
||||||
className="rounded-button border border-rule bg-surface px-sm py-xs text-small text-ink"
|
className="rounded-md border border bg-card px-sm py-xs text-sm text-foreground"
|
||||||
rows={3}
|
rows={3}
|
||||||
placeholder="请输入题干"
|
placeholder="请输入题干"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="flex flex-col space-y-xs">
|
<label className="flex flex-col space-y-xs">
|
||||||
<span className="text-small text-ink-muted">答案</span>
|
<span className="text-sm text-muted-foreground">答案</span>
|
||||||
<textarea
|
<textarea
|
||||||
value={newQuestion.answer}
|
value={newQuestion.answer}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setNewQuestion((q) => ({ ...q, answer: e.target.value }))
|
setNewQuestion((q) => ({ ...q, answer: e.target.value }))
|
||||||
}
|
}
|
||||||
className="rounded-button border border-rule bg-surface px-sm py-xs text-small text-ink"
|
className="rounded-md border border bg-card px-sm py-xs text-sm text-foreground"
|
||||||
rows={2}
|
rows={2}
|
||||||
placeholder="请输入答案"
|
placeholder="请输入答案"
|
||||||
/>
|
/>
|
||||||
@@ -207,46 +207,46 @@ export default function QuestionBank(_props: PluginProps): React.ReactElement {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={handleAdd}
|
onClick={handleAdd}
|
||||||
disabled={!newQuestion.content.trim()}
|
disabled={!newQuestion.content.trim()}
|
||||||
className="rounded-button bg-accent px-md py-xs text-small text-ink-onAccent disabled:opacity-50"
|
className="rounded-md bg-primary px-md py-xs text-sm text-primary-foreground disabled:opacity-50"
|
||||||
>
|
>
|
||||||
添加
|
添加
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<ul className="mt-sm space-y-md">
|
<ul className="mt-sm space-y-4">
|
||||||
{questions.length === 0 ? (
|
{questions.length === 0 ? (
|
||||||
<li className="text-small text-ink-muted">暂无题目</li>
|
<li className="text-sm text-muted-foreground">暂无题目</li>
|
||||||
) : (
|
) : (
|
||||||
questions.map((q) => (
|
questions.map((q) => (
|
||||||
<li key={q.id} className="rounded-button border border-rule p-sm">
|
<li key={q.id} className="rounded-md border border p-2">
|
||||||
<div className="flex flex-wrap gap-xs">
|
<div className="flex flex-wrap gap-xs">
|
||||||
<span className="rounded-button bg-accent px-xs py-xs text-tiny text-ink-onAccent">
|
<span className="rounded-md bg-primary px-xs py-xs text-xs text-primary-foreground">
|
||||||
{TYPE_LABELS[q.type] ?? q.type}
|
{TYPE_LABELS[q.type] ?? q.type}
|
||||||
</span>
|
</span>
|
||||||
<span className="rounded-button bg-subtle px-xs py-xs text-tiny text-ink">
|
<span className="rounded-md bg-muted px-xs py-xs text-xs text-foreground">
|
||||||
{DIFFICULTY_LABELS[q.difficulty] ?? q.difficulty}
|
{DIFFICULTY_LABELS[q.difficulty] ?? q.difficulty}
|
||||||
</span>
|
</span>
|
||||||
{q.tags.map((tag) => (
|
{q.tags.map((tag) => (
|
||||||
<span
|
<span
|
||||||
key={tag}
|
key={tag}
|
||||||
className="rounded-button bg-subtle px-xs py-xs text-tiny text-ink-muted"
|
className="rounded-md bg-muted px-xs py-xs text-xs text-muted-foreground"
|
||||||
>
|
>
|
||||||
{tag}
|
{tag}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-xs text-body text-ink">{q.content}</p>
|
<p className="mt-xs text-body text-foreground">{q.content}</p>
|
||||||
{q.options.length > 0 ? (
|
{q.options.length > 0 ? (
|
||||||
<ul className="mt-xs space-y-xs">
|
<ul className="mt-xs space-y-xs">
|
||||||
{q.options.map((opt, i) => (
|
{q.options.map((opt, i) => (
|
||||||
<li key={i} className="text-small text-ink-muted">
|
<li key={i} className="text-sm text-muted-foreground">
|
||||||
{String.fromCharCode(65 + i)}. {opt}
|
{String.fromCharCode(65 + i)}. {opt}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
) : null}
|
) : null}
|
||||||
<p className="mt-xs text-small text-ink-muted">
|
<p className="mt-xs text-sm text-muted-foreground">
|
||||||
答案:{q.answer}
|
答案:{q.answer}
|
||||||
</p>
|
</p>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* scheduling-rules(teacher / main)
|
* scheduling-rules(teacher / main)
|
||||||
@@ -44,9 +44,9 @@ export default function SchedulingRules(
|
|||||||
|
|
||||||
if (!classId) {
|
if (!classId) {
|
||||||
return (
|
return (
|
||||||
<section className="rounded-card border border-rule bg-surface p-md">
|
<section className="rounded-xl border border bg-card p-4">
|
||||||
<h3 className="text-heading-3 text-ink">排课规则</h3>
|
<h3 className="text-heading-3 text-foreground">排课规则</h3>
|
||||||
<p className="text-small text-ink-muted">请先选择班级</p>
|
<p className="text-sm text-muted-foreground">请先选择班级</p>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -85,14 +85,14 @@ export default function SchedulingRules(
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="rounded-card border border-rule bg-surface p-md">
|
<section className="rounded-xl border border bg-card p-4">
|
||||||
<h3 className="text-heading-3 text-ink">排课规则</h3>
|
<h3 className="text-heading-3 text-foreground">排课规则</h3>
|
||||||
{rules.length === 0 ? (
|
{rules.length === 0 ? (
|
||||||
<p className="mt-sm text-small text-ink-muted">暂无排课规则</p>
|
<p className="mt-sm text-sm text-muted-foreground">暂无排课规则</p>
|
||||||
) : (
|
) : (
|
||||||
<table className="mt-sm w-full text-small">
|
<table className="mt-sm w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-rule text-ink-muted">
|
<tr className="border-b border text-muted-foreground">
|
||||||
<th className="py-xs text-left">星期</th>
|
<th className="py-xs text-left">星期</th>
|
||||||
<th className="py-xs text-left">节次</th>
|
<th className="py-xs text-left">节次</th>
|
||||||
<th className="py-xs text-left">科目</th>
|
<th className="py-xs text-left">科目</th>
|
||||||
@@ -106,7 +106,7 @@ export default function SchedulingRules(
|
|||||||
const isEditing = editingId === rule.id;
|
const isEditing = editingId === rule.id;
|
||||||
if (isEditing && draft) {
|
if (isEditing && draft) {
|
||||||
return (
|
return (
|
||||||
<tr key={rule.id} className="border-b border-rule">
|
<tr key={rule.id} className="border-b border">
|
||||||
<td className="py-xs">
|
<td className="py-xs">
|
||||||
<select
|
<select
|
||||||
value={draft.dayOfWeek}
|
value={draft.dayOfWeek}
|
||||||
@@ -115,7 +115,7 @@ export default function SchedulingRules(
|
|||||||
d ? { ...d, dayOfWeek: Number(e.target.value) } : d,
|
d ? { ...d, dayOfWeek: Number(e.target.value) } : d,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
className="rounded-button border border-rule bg-surface px-sm py-xs text-small text-ink"
|
className="rounded-md border border bg-card px-sm py-xs text-sm text-foreground"
|
||||||
>
|
>
|
||||||
{DAY_NAMES.map((name, i) => (
|
{DAY_NAMES.map((name, i) => (
|
||||||
<option key={name} value={i + 1}>
|
<option key={name} value={i + 1}>
|
||||||
@@ -133,7 +133,7 @@ export default function SchedulingRules(
|
|||||||
d ? { ...d, periods: e.target.value } : d,
|
d ? { ...d, periods: e.target.value } : d,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
className="w-full rounded-button border border-rule bg-surface px-sm py-xs text-small text-ink"
|
className="w-full rounded-md border border bg-card px-sm py-xs text-sm text-foreground"
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td className="py-xs">
|
<td className="py-xs">
|
||||||
@@ -145,7 +145,7 @@ export default function SchedulingRules(
|
|||||||
d ? { ...d, subject: e.target.value } : d,
|
d ? { ...d, subject: e.target.value } : d,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
className="w-full rounded-button border border-rule bg-surface px-sm py-xs text-small text-ink"
|
className="w-full rounded-md border border bg-card px-sm py-xs text-sm text-foreground"
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td className="py-xs">
|
<td className="py-xs">
|
||||||
@@ -157,7 +157,7 @@ export default function SchedulingRules(
|
|||||||
d ? { ...d, teacherId: e.target.value } : d,
|
d ? { ...d, teacherId: e.target.value } : d,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
className="w-full rounded-button border border-rule bg-surface px-sm py-xs text-small text-ink"
|
className="w-full rounded-md border border bg-card px-sm py-xs text-sm text-foreground"
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td className="py-xs">
|
<td className="py-xs">
|
||||||
@@ -169,7 +169,7 @@ export default function SchedulingRules(
|
|||||||
d ? { ...d, room: e.target.value } : d,
|
d ? { ...d, room: e.target.value } : d,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
className="w-full rounded-button border border-rule bg-surface px-sm py-xs text-small text-ink"
|
className="w-full rounded-md border border bg-card px-sm py-xs text-sm text-foreground"
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td className="py-xs">
|
<td className="py-xs">
|
||||||
@@ -178,14 +178,14 @@ export default function SchedulingRules(
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
className="rounded-button bg-accent px-sm py-xs text-tiny text-ink-onAccent disabled:opacity-50"
|
className="rounded-md bg-primary px-sm py-xs text-xs text-primary-foreground disabled:opacity-50"
|
||||||
>
|
>
|
||||||
保存
|
保存
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleCancel}
|
onClick={handleCancel}
|
||||||
className="rounded-button bg-subtle px-sm py-xs text-tiny text-ink"
|
className="rounded-md bg-muted px-sm py-xs text-xs text-foreground"
|
||||||
>
|
>
|
||||||
取消
|
取消
|
||||||
</button>
|
</button>
|
||||||
@@ -195,17 +195,19 @@ export default function SchedulingRules(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<tr key={rule.id} className="border-b border-rule">
|
<tr key={rule.id} className="border-b border">
|
||||||
<td className="py-xs text-ink">{dayName(rule.dayOfWeek)}</td>
|
<td className="py-xs text-foreground">
|
||||||
<td className="py-xs text-ink">{rule.periods}</td>
|
{dayName(rule.dayOfWeek)}
|
||||||
<td className="py-xs text-ink">{rule.subject}</td>
|
</td>
|
||||||
<td className="py-xs text-ink">{rule.teacherId}</td>
|
<td className="py-xs text-foreground">{rule.periods}</td>
|
||||||
<td className="py-xs text-ink">{rule.room}</td>
|
<td className="py-xs text-foreground">{rule.subject}</td>
|
||||||
|
<td className="py-xs text-foreground">{rule.teacherId}</td>
|
||||||
|
<td className="py-xs text-foreground">{rule.room}</td>
|
||||||
<td className="py-xs">
|
<td className="py-xs">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleEdit(rule)}
|
onClick={() => handleEdit(rule)}
|
||||||
className="rounded-button bg-subtle px-sm py-xs text-tiny text-ink"
|
className="rounded-md bg-muted px-sm py-xs text-xs text-foreground"
|
||||||
>
|
>
|
||||||
编辑
|
编辑
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* textbook-manager(teacher / main)
|
* textbook-manager(teacher / main)
|
||||||
@@ -41,43 +41,43 @@ export default function TextbookManager(
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="rounded-card border border-rule bg-surface p-md">
|
<section className="rounded-xl border border bg-card p-4">
|
||||||
<h3 className="text-heading-3 text-ink">教材管理</h3>
|
<h3 className="text-heading-3 text-foreground">教材管理</h3>
|
||||||
|
|
||||||
<div className="mt-sm flex gap-md">
|
<div className="mt-sm flex gap-4">
|
||||||
<label className="flex flex-col space-y-xs">
|
<label className="flex flex-col space-y-xs">
|
||||||
<span className="text-small text-ink-muted">科目 ID</span>
|
<span className="text-sm text-muted-foreground">科目 ID</span>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={subjectInput}
|
value={subjectInput}
|
||||||
onChange={(e) => setSubjectInput(e.target.value)}
|
onChange={(e) => setSubjectInput(e.target.value)}
|
||||||
className="rounded-button border border-rule bg-surface px-sm py-xs text-small text-ink"
|
className="rounded-md border border bg-card px-sm py-xs text-sm text-foreground"
|
||||||
placeholder="可选,输入科目 ID"
|
placeholder="可选,输入科目 ID"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="flex flex-col space-y-xs">
|
<label className="flex flex-col space-y-xs">
|
||||||
<span className="text-small text-ink-muted">年级</span>
|
<span className="text-sm text-muted-foreground">年级</span>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={gradeInput}
|
value={gradeInput}
|
||||||
onChange={(e) => setGradeInput(e.target.value)}
|
onChange={(e) => setGradeInput(e.target.value)}
|
||||||
className="rounded-button border border-rule bg-surface px-sm py-xs text-small text-ink"
|
className="rounded-md border border bg-card px-sm py-xs text-sm text-foreground"
|
||||||
placeholder="可选,如:三年级"
|
placeholder="可选,如:三年级"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleApply}
|
onClick={handleApply}
|
||||||
className="self-end rounded-button bg-accent px-md py-xs text-small text-ink-onAccent"
|
className="self-end rounded-md bg-primary px-md py-xs text-sm text-primary-foreground"
|
||||||
>
|
>
|
||||||
筛选
|
筛选
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-md flex gap-md">
|
<div className="mt-md flex gap-4">
|
||||||
<ul className="flex-1 space-y-sm">
|
<ul className="flex-1 space-y-sm">
|
||||||
{textbooks.length === 0 ? (
|
{textbooks.length === 0 ? (
|
||||||
<li className="text-small text-ink-muted">暂无教材数据</li>
|
<li className="text-sm text-muted-foreground">暂无教材数据</li>
|
||||||
) : (
|
) : (
|
||||||
textbooks.map((t) => (
|
textbooks.map((t) => (
|
||||||
<li key={t.id}>
|
<li key={t.id}>
|
||||||
@@ -86,15 +86,17 @@ export default function TextbookManager(
|
|||||||
onClick={() => setSelectedId(t.id)}
|
onClick={() => setSelectedId(t.id)}
|
||||||
className={
|
className={
|
||||||
selectedId === t.id
|
selectedId === t.id
|
||||||
? "w-full rounded-button border border-rule bg-subtle p-sm text-left"
|
? "w-full rounded-md border border bg-muted p-2 text-left"
|
||||||
: "w-full rounded-button border border-rule bg-surface p-sm text-left"
|
: "w-full rounded-md border border bg-card p-2 text-left"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<p className="text-body text-ink">{t.title}</p>
|
<p className="text-body text-foreground">{t.title}</p>
|
||||||
<p className="text-small text-ink-muted">
|
<p className="text-sm text-muted-foreground">
|
||||||
{t.author} · {t.publisher}
|
{t.author} · {t.publisher}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-tiny text-ink-muted">ISBN: {t.isbn}</p>
|
<p className="text-xs text-muted-foreground">
|
||||||
|
ISBN: {t.isbn}
|
||||||
|
</p>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
))
|
))
|
||||||
@@ -103,22 +105,22 @@ export default function TextbookManager(
|
|||||||
|
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
{selected ? (
|
{selected ? (
|
||||||
<div className="rounded-card border border-rule bg-surface p-md">
|
<div className="rounded-xl border border bg-card p-4">
|
||||||
<h4 className="text-body text-ink">{selected.title}</h4>
|
<h4 className="text-body text-foreground">{selected.title}</h4>
|
||||||
<p className="mt-xs text-small text-ink-muted">
|
<p className="mt-xs text-sm text-muted-foreground">
|
||||||
{selected.author} · {selected.publisher}
|
{selected.author} · {selected.publisher}
|
||||||
</p>
|
</p>
|
||||||
<h5 className="mt-md text-small text-ink">章节列表</h5>
|
<h5 className="mt-md text-sm text-foreground">章节列表</h5>
|
||||||
<ol className="mt-xs space-y-xs">
|
<ol className="mt-xs space-y-xs">
|
||||||
{selected.chapters.map((c, i) => (
|
{selected.chapters.map((c, i) => (
|
||||||
<li key={c.id} className="text-small text-ink">
|
<li key={c.id} className="text-sm text-foreground">
|
||||||
{i + 1}. {c.title}
|
{i + 1}. {c.title}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ol>
|
</ol>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-card border border-rule bg-surface p-md text-small text-ink-muted">
|
<div className="rounded-xl border border bg-card p-4 text-sm text-muted-foreground">
|
||||||
点击左侧教材查看章节
|
点击左侧教材查看章节
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* global-search(topbar / top)
|
* global-search(topbar / top)
|
||||||
@@ -49,22 +49,22 @@ export default function GlobalSearch(_props: PluginProps): React.ReactElement {
|
|||||||
onFocus={() => setOpen(true)}
|
onFocus={() => setOpen(true)}
|
||||||
placeholder="搜索学生、班级、考试…"
|
placeholder="搜索学生、班级、考试…"
|
||||||
aria-label="全局搜索"
|
aria-label="全局搜索"
|
||||||
className="w-72 rounded-button border border-rule bg-surface px-sm py-xs text-small text-ink"
|
className="w-72 rounded-md border border bg-card px-sm py-xs text-sm text-foreground"
|
||||||
/>
|
/>
|
||||||
{open && keyword.length > 0 ? (
|
{open && keyword.length > 0 ? (
|
||||||
<ul className="absolute right-0 z-50 mt-sm w-72 rounded-card border border-rule bg-surface p-sm shadow-md">
|
<ul className="absolute right-0 z-50 mt-sm w-72 rounded-xl border border bg-card p-2 shadow-md">
|
||||||
{results.length === 0 ? (
|
{results.length === 0 ? (
|
||||||
<li className="text-small text-ink-muted">暂无结果</li>
|
<li className="text-sm text-muted-foreground">暂无结果</li>
|
||||||
) : (
|
) : (
|
||||||
results.map((item) => (
|
results.map((item) => (
|
||||||
<li key={`${item.type}-${item.id}`}>
|
<li key={`${item.type}-${item.id}`}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleSelect(item)}
|
onClick={() => handleSelect(item)}
|
||||||
className="flex w-full flex-col border-b border-rule py-xs text-left"
|
className="flex w-full flex-col border-b border py-xs text-left"
|
||||||
>
|
>
|
||||||
<span className="text-small text-ink">{item.title}</span>
|
<span className="text-sm text-foreground">{item.title}</span>
|
||||||
<span className="text-tiny text-ink-muted">
|
<span className="text-xs text-muted-foreground">
|
||||||
{item.subtitle}
|
{item.subtitle}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* locale-switcher(topbar / top)
|
* locale-switcher(topbar / top)
|
||||||
@@ -38,12 +38,12 @@ export default function LocaleSwitcher(
|
|||||||
aria-label="切换语言"
|
aria-label="切换语言"
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
onClick={() => setOpen((v) => !v)}
|
onClick={() => setOpen((v) => !v)}
|
||||||
className="rounded-button bg-surface px-sm py-xs text-small text-ink"
|
className="rounded-md bg-card px-sm py-xs text-sm text-foreground"
|
||||||
>
|
>
|
||||||
{locale}
|
{locale}
|
||||||
</button>
|
</button>
|
||||||
{open ? (
|
{open ? (
|
||||||
<ul className="absolute right-0 z-50 mt-sm w-40 rounded-card border border-rule bg-surface p-sm shadow-md">
|
<ul className="absolute right-0 z-50 mt-sm w-40 rounded-xl border border bg-card p-2 shadow-md">
|
||||||
{LOCALES.map((item) => (
|
{LOCALES.map((item) => (
|
||||||
<li key={item.value}>
|
<li key={item.value}>
|
||||||
<button
|
<button
|
||||||
@@ -51,8 +51,8 @@ export default function LocaleSwitcher(
|
|||||||
onClick={() => handleSelect(item.value)}
|
onClick={() => handleSelect(item.value)}
|
||||||
className={
|
className={
|
||||||
item.value === locale
|
item.value === locale
|
||||||
? "w-full rounded-button bg-accent px-sm py-xs text-left text-small text-ink-onAccent"
|
? "w-full rounded-md bg-primary px-sm py-xs text-left text-sm text-primary-foreground"
|
||||||
: "w-full rounded-button px-sm py-xs text-left text-small text-ink"
|
: "w-full rounded-md px-sm py-xs text-left text-sm text-foreground"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* notification-bell(topbar / top)
|
* notification-bell(topbar / top)
|
||||||
@@ -30,24 +30,24 @@ export default function NotificationBell(
|
|||||||
aria-label="通知"
|
aria-label="通知"
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
onClick={() => setOpen((v) => !v)}
|
onClick={() => setOpen((v) => !v)}
|
||||||
className="relative rounded-button bg-surface px-sm py-xs text-ink"
|
className="relative rounded-md bg-card px-sm py-xs text-foreground"
|
||||||
>
|
>
|
||||||
<span aria-hidden>🔔</span>
|
<span aria-hidden>🔔</span>
|
||||||
{items.length > 0 ? (
|
{items.length > 0 ? (
|
||||||
<span className="absolute -right-xs -top-xs rounded-full bg-danger px-xs text-tiny text-ink-onAccent">
|
<span className="absolute -right-xs -top-xs rounded-full bg-danger px-xs text-xs text-primary-foreground">
|
||||||
{items.length}
|
{items.length}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</button>
|
</button>
|
||||||
{open ? (
|
{open ? (
|
||||||
<ul className="absolute right-0 z-50 mt-sm w-64 rounded-card border border-rule bg-surface p-sm shadow-md">
|
<ul className="absolute right-0 z-50 mt-sm w-64 rounded-xl border border bg-card p-2 shadow-md">
|
||||||
{items.length === 0 ? (
|
{items.length === 0 ? (
|
||||||
<li className="text-small text-ink-muted">暂无通知</li>
|
<li className="text-sm text-muted-foreground">暂无通知</li>
|
||||||
) : (
|
) : (
|
||||||
items.map((n) => (
|
items.map((n) => (
|
||||||
<li
|
<li
|
||||||
key={n.id}
|
key={n.id}
|
||||||
className="border-b border-rule py-xs text-small text-ink"
|
className="border-b border py-xs text-sm text-foreground"
|
||||||
>
|
>
|
||||||
{n.title}
|
{n.title}
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* user-menu(topbar / top)
|
* user-menu(topbar / top)
|
||||||
@@ -30,23 +30,23 @@ export default function UserMenu(_props: PluginProps): React.ReactElement {
|
|||||||
aria-label="用户菜单"
|
aria-label="用户菜单"
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
onClick={() => setOpen((v) => !v)}
|
onClick={() => setOpen((v) => !v)}
|
||||||
className="flex items-center gap-sm rounded-button bg-surface px-sm py-xs text-ink"
|
className="flex items-center gap-2 rounded-md bg-card px-sm py-xs text-foreground"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
aria-hidden
|
aria-hidden
|
||||||
className="flex h-heading-3 w-heading-3 items-center justify-center rounded-full bg-accent text-ink-onAccent"
|
className="flex h-heading-3 w-heading-3 items-center justify-center rounded-full bg-primary text-primary-foreground"
|
||||||
>
|
>
|
||||||
{displayName.charAt(0).toUpperCase()}
|
{displayName.charAt(0).toUpperCase()}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-small">{displayName}</span>
|
<span className="text-sm">{displayName}</span>
|
||||||
</button>
|
</button>
|
||||||
{open ? (
|
{open ? (
|
||||||
<ul className="absolute right-0 z-50 mt-sm w-56 rounded-card border border-rule bg-surface p-sm shadow-md">
|
<ul className="absolute right-0 z-50 mt-sm w-56 rounded-xl border border bg-card p-2 shadow-md">
|
||||||
<li className="border-b border-rule py-xs">
|
<li className="border-b border py-xs">
|
||||||
<p className="text-small text-ink">{displayName}</p>
|
<p className="text-sm text-foreground">{displayName}</p>
|
||||||
<p className="text-tiny text-ink-muted">{displayEmail}</p>
|
<p className="text-xs text-muted-foreground">{displayEmail}</p>
|
||||||
</li>
|
</li>
|
||||||
<li className="py-xs text-small text-ink-muted">
|
<li className="py-xs text-sm text-muted-foreground">
|
||||||
角色:{displayRole}
|
角色:{displayRole}
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* announcements-widget(universal / main)
|
* announcements-widget(universal / main)
|
||||||
@@ -27,20 +27,20 @@ export default function AnnouncementsWidget(
|
|||||||
const rows = data ?? [];
|
const rows = data ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="rounded-card border border-rule bg-surface p-md">
|
<section className="rounded-xl border border bg-card p-4">
|
||||||
<h3 className="text-heading-3 text-ink">公告</h3>
|
<h3 className="text-heading-3 text-foreground">公告</h3>
|
||||||
{rows.length === 0 ? (
|
{rows.length === 0 ? (
|
||||||
<p className="mt-sm text-small text-ink-muted">暂无公告</p>
|
<p className="mt-sm text-sm text-muted-foreground">暂无公告</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="mt-sm space-y-sm">
|
<ul className="mt-sm space-y-sm">
|
||||||
{rows.map((row) => (
|
{rows.map((row) => (
|
||||||
<li
|
<li
|
||||||
key={row.id}
|
key={row.id}
|
||||||
className="border-b border-rule py-xs text-small text-ink"
|
className="border-b border py-xs text-sm text-foreground"
|
||||||
>
|
>
|
||||||
<p className="text-ink">{row.title}</p>
|
<p className="text-foreground">{row.title}</p>
|
||||||
<p className="mt-xs text-tiny text-ink-muted">{row.body}</p>
|
<p className="mt-xs text-xs text-muted-foreground">{row.body}</p>
|
||||||
<p className="mt-xs text-tiny text-ink-muted">
|
<p className="mt-xs text-xs text-muted-foreground">
|
||||||
{row.author} · {row.publishedAt}
|
{row.author} · {row.publishedAt}
|
||||||
</p>
|
</p>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* attendance-widget(universal / main)
|
* attendance-widget(universal / main)
|
||||||
@@ -30,9 +30,9 @@ export default function AttendanceWidget(
|
|||||||
|
|
||||||
if (!classId || !termId) {
|
if (!classId || !termId) {
|
||||||
return (
|
return (
|
||||||
<section className="rounded-card border border-rule bg-surface p-md">
|
<section className="rounded-xl border border bg-card p-4">
|
||||||
<h3 className="text-heading-3 text-ink">考勤</h3>
|
<h3 className="text-heading-3 text-foreground">考勤</h3>
|
||||||
<p className="text-small text-ink-muted">请先选择班级与学期</p>
|
<p className="text-sm text-muted-foreground">请先选择班级与学期</p>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -41,9 +41,9 @@ export default function AttendanceWidget(
|
|||||||
|
|
||||||
if (!stats) {
|
if (!stats) {
|
||||||
return (
|
return (
|
||||||
<section className="rounded-card border border-rule bg-surface p-md">
|
<section className="rounded-xl border border bg-card p-4">
|
||||||
<h3 className="text-heading-3 text-ink">考勤</h3>
|
<h3 className="text-heading-3 text-foreground">考勤</h3>
|
||||||
<p className="text-small text-ink-muted">暂无考勤数据</p>
|
<p className="text-sm text-muted-foreground">暂无考勤数据</p>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -56,16 +56,16 @@ export default function AttendanceWidget(
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="rounded-card border border-rule bg-surface p-md">
|
<section className="rounded-xl border border bg-card p-4">
|
||||||
<h3 className="text-heading-3 text-ink">考勤</h3>
|
<h3 className="text-heading-3 text-foreground">考勤</h3>
|
||||||
<div className="mt-sm flex gap-md">
|
<div className="mt-sm flex gap-4">
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<div
|
<div
|
||||||
key={item.label}
|
key={item.label}
|
||||||
className="flex-1 rounded-card bg-subtle p-md text-center"
|
className="flex-1 rounded-xl bg-muted p-4 text-center"
|
||||||
>
|
>
|
||||||
<p className="text-tiny text-ink-muted">{item.label}</p>
|
<p className="text-xs text-muted-foreground">{item.label}</p>
|
||||||
<p className="mt-xs text-heading-3 text-ink">{item.value}</p>
|
<p className="mt-xs text-heading-3 text-foreground">{item.value}</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* exams-widget(universal / main)
|
* exams-widget(universal / main)
|
||||||
@@ -38,9 +38,9 @@ export default function ExamsWidget(props: PluginProps): React.ReactElement {
|
|||||||
|
|
||||||
if (!classId) {
|
if (!classId) {
|
||||||
return (
|
return (
|
||||||
<section className="rounded-card border border-rule bg-surface p-md">
|
<section className="rounded-xl border border bg-card p-4">
|
||||||
<h3 className="text-heading-3 text-ink">考试</h3>
|
<h3 className="text-heading-3 text-foreground">考试</h3>
|
||||||
<p className="text-small text-ink-muted">请先选择班级</p>
|
<p className="text-sm text-muted-foreground">请先选择班级</p>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -48,17 +48,17 @@ export default function ExamsWidget(props: PluginProps): React.ReactElement {
|
|||||||
const rows = data ?? [];
|
const rows = data ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="rounded-card border border-rule bg-surface p-md">
|
<section className="rounded-xl border border bg-card p-4">
|
||||||
<h3 className="text-heading-3 text-ink">考试</h3>
|
<h3 className="text-heading-3 text-foreground">考试</h3>
|
||||||
{rows.length === 0 ? (
|
{rows.length === 0 ? (
|
||||||
<p className="text-small text-ink-muted">暂无考试数据</p>
|
<p className="text-sm text-muted-foreground">暂无考试数据</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="mt-sm space-y-sm">
|
<ul className="mt-sm space-y-sm">
|
||||||
{rows.map((row) => {
|
{rows.map((row) => {
|
||||||
const isActive = row.id === currentExamId;
|
const isActive = row.id === currentExamId;
|
||||||
const itemClass = isActive
|
const itemClass = isActive
|
||||||
? "w-full rounded-button border border-rule bg-subtle px-sm py-xs text-left text-small"
|
? "w-full rounded-md border border bg-muted px-sm py-xs text-left text-sm"
|
||||||
: "w-full rounded-button border border-rule bg-surface px-sm py-xs text-left text-small";
|
: "w-full rounded-md border border bg-card px-sm py-xs text-left text-sm";
|
||||||
return (
|
return (
|
||||||
<li key={row.id}>
|
<li key={row.id}>
|
||||||
<button
|
<button
|
||||||
@@ -68,10 +68,10 @@ export default function ExamsWidget(props: PluginProps): React.ReactElement {
|
|||||||
aria-pressed={isActive}
|
aria-pressed={isActive}
|
||||||
>
|
>
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
<span className="flex-1 text-ink">{row.name}</span>
|
<span className="flex-1 text-foreground">{row.name}</span>
|
||||||
<span className="text-ink-muted">{row.subject}</span>
|
<span className="text-muted-foreground">{row.subject}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-xs flex text-tiny text-ink-muted">
|
<div className="mt-xs flex text-xs text-muted-foreground">
|
||||||
<span className="flex-1">{row.examDate}</span>
|
<span className="flex-1">{row.examDate}</span>
|
||||||
<span>满分 {row.maxScore}</span>
|
<span>满分 {row.maxScore}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* grades-widget(universal / main)
|
* grades-widget(universal / main)
|
||||||
@@ -29,9 +29,9 @@ export default function GradesWidget(props: PluginProps): React.ReactElement {
|
|||||||
|
|
||||||
if (!classId) {
|
if (!classId) {
|
||||||
return (
|
return (
|
||||||
<section className="rounded-card border border-rule bg-surface p-md">
|
<section className="rounded-xl border border bg-card p-4">
|
||||||
<h3 className="text-heading-3 text-ink">成绩</h3>
|
<h3 className="text-heading-3 text-foreground">成绩</h3>
|
||||||
<p className="text-small text-ink-muted">请先选择班级</p>
|
<p className="text-sm text-muted-foreground">请先选择班级</p>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -39,23 +39,25 @@ export default function GradesWidget(props: PluginProps): React.ReactElement {
|
|||||||
const rows = data ?? [];
|
const rows = data ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="rounded-card border border-rule bg-surface p-md">
|
<section className="rounded-xl border border bg-card p-4">
|
||||||
<h3 className="text-heading-3 text-ink">成绩</h3>
|
<h3 className="text-heading-3 text-foreground">成绩</h3>
|
||||||
{rows.length === 0 ? (
|
{rows.length === 0 ? (
|
||||||
<p className="text-small text-ink-muted">暂无成绩数据</p>
|
<p className="text-sm text-muted-foreground">暂无成绩数据</p>
|
||||||
) : (
|
) : (
|
||||||
<table className="mt-sm w-full text-small">
|
<table className="mt-sm w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-rule text-ink-muted">
|
<tr className="border-b border text-muted-foreground">
|
||||||
<th className="py-xs text-left">学号</th>
|
<th className="py-xs text-left">学号</th>
|
||||||
<th className="py-xs text-right">分数</th>
|
<th className="py-xs text-right">分数</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{rows.slice(0, limit).map((row) => (
|
{rows.slice(0, limit).map((row) => (
|
||||||
<tr key={row.studentId} className="border-b border-rule">
|
<tr key={row.studentId} className="border-b border">
|
||||||
<td className="py-xs text-ink">{row.studentId}</td>
|
<td className="py-xs text-foreground">{row.studentId}</td>
|
||||||
<td className="py-xs text-right text-ink">{row.score}</td>
|
<td className="py-xs text-right text-foreground">
|
||||||
|
{row.score}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* homework-widget(universal / main)
|
* homework-widget(universal / main)
|
||||||
@@ -24,20 +24,20 @@ function StatusBadge({ status }: { status: string }): React.ReactElement {
|
|||||||
const label = STATUS_LABEL[status] ?? status;
|
const label = STATUS_LABEL[status] ?? status;
|
||||||
if (status === "graded") {
|
if (status === "graded") {
|
||||||
return (
|
return (
|
||||||
<span className="rounded-button border border-rule bg-surface px-sm py-xs text-tiny text-ink">
|
<span className="rounded-md border border bg-card px-sm py-xs text-xs text-foreground">
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (status === "submitted") {
|
if (status === "submitted") {
|
||||||
return (
|
return (
|
||||||
<span className="rounded-button bg-accent px-sm py-xs text-tiny text-ink-onAccent">
|
<span className="rounded-md bg-primary px-sm py-xs text-xs text-primary-foreground">
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<span className="rounded-button bg-subtle px-sm py-xs text-tiny text-ink-muted">
|
<span className="rounded-md bg-muted px-sm py-xs text-xs text-muted-foreground">
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
@@ -59,9 +59,9 @@ export default function HomeworkWidget(props: PluginProps): React.ReactElement {
|
|||||||
|
|
||||||
if (!classId) {
|
if (!classId) {
|
||||||
return (
|
return (
|
||||||
<section className="rounded-card border border-rule bg-surface p-md">
|
<section className="rounded-xl border border bg-card p-4">
|
||||||
<h3 className="text-heading-3 text-ink">作业</h3>
|
<h3 className="text-heading-3 text-foreground">作业</h3>
|
||||||
<p className="text-small text-ink-muted">请先选择班级</p>
|
<p className="text-sm text-muted-foreground">请先选择班级</p>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -69,14 +69,14 @@ export default function HomeworkWidget(props: PluginProps): React.ReactElement {
|
|||||||
const rows = data ?? [];
|
const rows = data ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="rounded-card border border-rule bg-surface p-md">
|
<section className="rounded-xl border border bg-card p-4">
|
||||||
<h3 className="text-heading-3 text-ink">作业</h3>
|
<h3 className="text-heading-3 text-foreground">作业</h3>
|
||||||
{rows.length === 0 ? (
|
{rows.length === 0 ? (
|
||||||
<p className="text-small text-ink-muted">暂无作业数据</p>
|
<p className="text-sm text-muted-foreground">暂无作业数据</p>
|
||||||
) : (
|
) : (
|
||||||
<table className="mt-sm w-full text-small">
|
<table className="mt-sm w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-rule text-ink-muted">
|
<tr className="border-b border text-muted-foreground">
|
||||||
<th className="py-xs text-left">标题</th>
|
<th className="py-xs text-left">标题</th>
|
||||||
<th className="py-xs text-left">截止日期</th>
|
<th className="py-xs text-left">截止日期</th>
|
||||||
<th className="py-xs text-left">状态</th>
|
<th className="py-xs text-left">状态</th>
|
||||||
@@ -84,9 +84,9 @@ export default function HomeworkWidget(props: PluginProps): React.ReactElement {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{rows.map((row) => (
|
{rows.map((row) => (
|
||||||
<tr key={row.id} className="border-b border-rule">
|
<tr key={row.id} className="border-b border">
|
||||||
<td className="py-xs text-ink">{row.title}</td>
|
<td className="py-xs text-foreground">{row.title}</td>
|
||||||
<td className="py-xs text-ink">{row.dueDate}</td>
|
<td className="py-xs text-foreground">{row.dueDate}</td>
|
||||||
<td className="py-xs">
|
<td className="py-xs">
|
||||||
<StatusBadge status={row.status} />
|
<StatusBadge status={row.status} />
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* notifications-widget(universal / main)
|
* notifications-widget(universal / main)
|
||||||
@@ -23,20 +23,20 @@ function TypeBadge({ type }: { type: string }): React.ReactElement {
|
|||||||
const label = TYPE_LABEL[type] ?? type;
|
const label = TYPE_LABEL[type] ?? type;
|
||||||
if (type === "urgent") {
|
if (type === "urgent") {
|
||||||
return (
|
return (
|
||||||
<span className="rounded-button bg-danger px-sm py-xs text-tiny text-ink-onAccent">
|
<span className="rounded-md bg-danger px-sm py-xs text-xs text-primary-foreground">
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (type === "warning") {
|
if (type === "warning") {
|
||||||
return (
|
return (
|
||||||
<span className="rounded-button bg-accent px-sm py-xs text-tiny text-ink-onAccent">
|
<span className="rounded-md bg-primary px-sm py-xs text-xs text-primary-foreground">
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<span className="rounded-button bg-subtle px-sm py-xs text-tiny text-ink-muted">
|
<span className="rounded-md bg-muted px-sm py-xs text-xs text-muted-foreground">
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
@@ -58,26 +58,28 @@ export default function NotificationsWidget(
|
|||||||
const total = data?.total ?? 0;
|
const total = data?.total ?? 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="rounded-card border border-rule bg-surface p-md">
|
<section className="rounded-xl border border bg-card p-4">
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
<h3 className="flex-1 text-heading-3 text-ink">通知</h3>
|
<h3 className="flex-1 text-heading-3 text-foreground">通知</h3>
|
||||||
<span className="text-small text-ink-muted">共 {total} 条</span>
|
<span className="text-sm text-muted-foreground">共 {total} 条</span>
|
||||||
</div>
|
</div>
|
||||||
{items.length === 0 ? (
|
{items.length === 0 ? (
|
||||||
<p className="mt-sm text-small text-ink-muted">暂无通知</p>
|
<p className="mt-sm text-sm text-muted-foreground">暂无通知</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="mt-sm space-y-sm">
|
<ul className="mt-sm space-y-sm">
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<li
|
<li
|
||||||
key={item.id}
|
key={item.id}
|
||||||
className="border-b border-rule py-xs text-small text-ink"
|
className="border-b border py-xs text-sm text-foreground"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-md">
|
<div className="flex items-center gap-4">
|
||||||
<span className="flex-1">{item.title}</span>
|
<span className="flex-1">{item.title}</span>
|
||||||
<TypeBadge type={item.type} />
|
<TypeBadge type={item.type} />
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-xs text-tiny text-ink-muted">{item.body}</p>
|
<p className="mt-xs text-xs text-muted-foreground">{item.body}</p>
|
||||||
<p className="mt-xs text-tiny text-ink-muted">{item.createdAt}</p>
|
<p className="mt-xs text-xs text-muted-foreground">
|
||||||
|
{item.createdAt}
|
||||||
|
</p>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* schedule-widget(universal / main)
|
* schedule-widget(universal / main)
|
||||||
@@ -31,9 +31,9 @@ export default function ScheduleWidget(
|
|||||||
|
|
||||||
if (!classId) {
|
if (!classId) {
|
||||||
return (
|
return (
|
||||||
<section className="rounded-card border border-rule bg-surface p-md">
|
<section className="rounded-xl border border bg-card p-4">
|
||||||
<h3 className="text-heading-3 text-ink">课表</h3>
|
<h3 className="text-heading-3 text-foreground">课表</h3>
|
||||||
<p className="text-small text-ink-muted">请先选择班级</p>
|
<p className="text-sm text-muted-foreground">请先选择班级</p>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -43,22 +43,19 @@ export default function ScheduleWidget(
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="rounded-card border border-rule bg-surface p-md">
|
<section className="rounded-xl border border bg-card p-4">
|
||||||
<h3 className="text-heading-3 text-ink">今日课表</h3>
|
<h3 className="text-heading-3 text-foreground">今日课表</h3>
|
||||||
{rows.length === 0 ? (
|
{rows.length === 0 ? (
|
||||||
<p className="text-small text-ink-muted">今日无课</p>
|
<p className="text-sm text-muted-foreground">今日无课</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="mt-sm space-y-sm">
|
<ul className="mt-sm space-y-sm">
|
||||||
{rows.map((row) => (
|
{rows.map((row) => (
|
||||||
<li
|
<li key={row.id} className="flex border-b border py-xs text-sm">
|
||||||
key={row.id}
|
<span className="flex-1 text-foreground">{row.subject}</span>
|
||||||
className="flex border-b border-rule py-xs text-small"
|
<span className="flex-1 text-muted-foreground">
|
||||||
>
|
|
||||||
<span className="flex-1 text-ink">{row.subject}</span>
|
|
||||||
<span className="flex-1 text-ink-muted">
|
|
||||||
{row.startTime} - {row.endTime}
|
{row.startTime} - {row.endTime}
|
||||||
</span>
|
</span>
|
||||||
<span className="flex-1 text-right text-ink-muted">
|
<span className="flex-1 text-right text-muted-foreground">
|
||||||
{row.teacherName}
|
{row.teacherName}
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export default defineConfig({
|
|||||||
environment: "jsdom",
|
environment: "jsdom",
|
||||||
include: ["src/**/__tests__/**/*.test.{ts,tsx}"],
|
include: ["src/**/__tests__/**/*.test.{ts,tsx}"],
|
||||||
globals: true,
|
globals: true,
|
||||||
setupFiles: [],
|
setupFiles: ["src/__tests__/setup.ts"],
|
||||||
},
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
@@ -32,6 +32,10 @@ export default defineConfig({
|
|||||||
__dirname,
|
__dirname,
|
||||||
"../../packages/shared-ts/src/contracts/index.ts",
|
"../../packages/shared-ts/src/contracts/index.ts",
|
||||||
),
|
),
|
||||||
|
"@edu/shared-ts/permission-bitmap": resolve(
|
||||||
|
__dirname,
|
||||||
|
"../../packages/shared-ts/src/permission-bitmap.ts",
|
||||||
|
),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -727,34 +727,42 @@
|
|||||||
|
|
||||||
> v2.1 架构:单 Next.js App Router 容器 + Micro-kernel 插件系统,替代旧 4 端微前端。关联 spec `2026-07-14-portal-shell-widget-dashboard-design.md`。
|
> v2.1 架构:单 Next.js App Router 容器 + Micro-kernel 插件系统,替代旧 4 端微前端。关联 spec `2026-07-14-portal-shell-widget-dashboard-design.md`。
|
||||||
|
|
||||||
| 场景 | 技术/规则 |
|
| 场景 | 技术/规则 |
|
||||||
| --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| --------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| 插件契约跨包共享 | `packages/shared-ts/src/contracts/` 定义 PluginProps/PluginManifest/Layout 类型;`PluginManifest.Component` 用 `unknown`(shared-ts 不依赖 React),前端包引用时断言为 `React.ComponentType<PluginProps>` |
|
| 插件契约跨包共享 | `packages/shared-ts/src/contracts/` 定义 PluginProps/PluginManifest/Layout 类型;`PluginManifest.Component` 用 `unknown`(shared-ts 不依赖 React),前端包引用时断言为 `React.ComponentType<PluginProps>` |
|
||||||
| TVars 泛型约束与 TS interface 不兼容 | `useWidgetQuery<TData, TVars extends Record<string, unknown>>` 约束下,widget 文件用 `interface XxxVars` 会报 TS2344;改用 `type XxxVars = {...}` 类型别名(type alias 满足 index signature,interface 不满足) |
|
| TVars 泛型约束与 TS interface 不兼容 | `useWidgetQuery<TData, TVars extends Record<string, unknown>>` 约束下,widget 文件用 `interface XxxVars` 会报 TS2344;改用 `type XxxVars = {...}` 类型别名(type alias 满足 index signature,interface 不满足) |
|
||||||
| shared-ts contracts 子路径导出 | `package.json` exports 新增 `"./contracts"` 指向 `./dist/contracts/index.js`;hooks 包通过 `@edu/shared-ts/contracts` 引用;typecheck 前需 `pnpm --filter @edu/shared-ts run build` 编译到 dist/ |
|
| shared-ts contracts 子路径导出 | `package.json` exports 新增 `"./contracts"` 指向 `./dist/contracts/index.js`;hooks 包通过 `@edu/shared-ts/contracts` 引用;typecheck 前需 `pnpm --filter @edu/shared-ts run build` 编译到 dist/ |
|
||||||
| Vitest 解析 workspace 子路径导出失败 | vitest.config.ts resolve.alias 需显式添加 `"@edu/shared-ts/contracts"` → 源码路径;Vite 不自动解析 package.json exports 字段 |
|
| Vitest 解析 workspace 子路径导出失败 | vitest.config.ts resolve.alias 需显式添加 `"@edu/shared-ts/contracts"` → 源码路径;Vite 不自动解析 package.json exports 字段 |
|
||||||
| portal-shell tsconfig paths 补全 | tsconfig.json paths 需添加 `"@edu/shared-ts/contracts": ["../../packages/shared-ts/src/contracts/index.ts"]`,否则 tsc 报 TS2307 |
|
| portal-shell tsconfig paths 补全 | tsconfig.json paths 需添加 `"@edu/shared-ts/contracts": ["../../packages/shared-ts/src/contracts/index.ts"]`,否则 tsc 报 TS2307 |
|
||||||
| 插件 dynamic import 注册 | `Registry.tsx` 用 `next/dynamic`(ssr:false)懒加载 31 个内置插件;loading 展示 `PluginSkeleton` 变体(card/list/table/stats/chart) |
|
| 插件 dynamic import 注册 | `Registry.tsx` 用 `next/dynamic`(ssr:false)懒加载 31 个内置插件;loading 展示 `PluginSkeleton` 变体(card/list/table/stats/chart) |
|
||||||
| URL Search Params + Zustand 状态分层 | URL 驱动可分享上下文(classId/childId/termId/view/examId/subjectId);Zustand 管理纯 UI 状态(theme/locale/sidebarCollapsed);插件间禁止直接 import |
|
| URL Search Params + Zustand 状态分层 | URL 驱动可分享上下文(classId/childId/termId/view/examId/subjectId);Zustand 管理纯 UI 状态(theme/locale/sidebarCollapsed);插件间禁止直接 import |
|
||||||
| useWidgetMutation API 签名 | 返回 `{ run, ...result }`,`run` 是 `async (variables: TVars): Promise<TData \| undefined>`;消费者用 `const { run } = useWidgetMutation(...)` 解构 |
|
| useWidgetMutation API 签名 | 返回 `{ run, ...result }`,`run` 是 `async (variables: TVars): Promise<TData \| undefined>`;消费者用 `const { run } = useWidgetMutation(...)` 解构 |
|
||||||
| usePluginConfig 不直接调 API | fetcher 由消费者注入,保持 @edu/hooks "hooks 不直接调 API" 原则;支持轮询刷新(refreshInterval 默认 5 分钟)+ 网络恢复刷新 + visibilitychange 刷新 |
|
| usePluginConfig 不直接调 API | fetcher 由消费者注入,保持 @edu/hooks "hooks 不直接调 API" 原则;支持轮询刷新(refreshInterval 默认 5 分钟)+ 网络恢复刷新 + visibilitychange 刷新 |
|
||||||
| usePluginStore 注入模式 | `useSyncExternalStore` + `injectPluginStore()` 让 hooks 包不直接依赖特定 store 实例;未注入 store 时返回 DEFAULT_STATE 空操作 |
|
| usePluginStore 注入模式 | `useSyncExternalStore` + `injectPluginStore()` 让 hooks 包不直接依赖特定 store 实例;未注入 store 时返回 DEFAULT_STATE 空操作 |
|
||||||
| 28 个内置插件分类 | universal(7) + sidebar(4) + topbar(4) + teacher(4) + student(4) + parent(2) + admin(3);每个插件含 `index.tsx` + `plugin.manifest.ts`,manifest 导出 `manifestMeta: Omit<PluginManifest, "Component">` |
|
| 28 个内置插件分类 | universal(7) + sidebar(4) + topbar(4) + teacher(4) + student(4) + parent(2) + admin(3);每个插件含 `index.tsx` + `plugin.manifest.ts`,manifest 导出 `manifestMeta: Omit<PluginManifest, "Component">` |
|
||||||
| PluginLifecycle 版本兼容 | MVP 只校验 major 版本(`checkVersionCompatibility` 解析 `^`/`~`/`>=` 前缀 + major 数字);完整 semver range 校验待引入 semver 库 |
|
| PluginLifecycle 版本兼容 | MVP 只校验 major 版本(`checkVersionCompatibility` 解析 `^`/`~`/`>=` 前缀 + major 数字);完整 semver range 校验待引入 semver 库 |
|
||||||
| pnpm install --no-frozen-lockfile | parent-portal package.json 新增 `@opentelemetry/instrumentation-document-load` 后 lockfile 过期;开发环境用 `--no-frozen-lockfile` 安装,CI 用 frozen |
|
| pnpm install --no-frozen-lockfile | parent-portal package.json 新增 `@opentelemetry/instrumentation-document-load` 后 lockfile 过期;开发环境用 `--no-frozen-lockfile` 安装,CI 用 frozen |
|
||||||
| Widget 内联 gql 字面量废弃 | 禁止 `const Q = gql\`...\``直接写在 widget;统一抽取到`lib/api/operations/*.graphql.ts`,widget 只 import `lib/api/<domain>.ts` 的语义化函数(ADR-042) |
|
| Widget 内联 gql 字面量废弃 | 禁止 `const Q = gql\`...\``直接写在 widget;统一抽取到`lib/api/operations/*.graphql.ts`,widget 只 import `lib/api/<domain>.ts` 的语义化函数(ADR-042) |
|
||||||
| 4 层数据访问分层 | Widget(UI)→ API(语义化函数)→ Operations(gql DocumentNode 集中)→ Hook(useWidgetQuery/useWidgetMutation 封装 Apollo);codegen 从 7 子图 schema 生成类型 |
|
| 4 层数据访问分层 | Widget(UI)→ API(语义化函数)→ Operations(gql DocumentNode 集中)→ Hook(useWidgetQuery/useWidgetMutation 封装 Apollo);codegen 从 7 子图 schema 生成类型 |
|
||||||
| graphql-codegen skipDocumentsValidation | codegen.yml 必须设 `skipDocumentsValidation: true`,避免子图未启动时校验 operations 失败;schema 归一化脚本移除 federation 指令(@key/@requires/@extends)防止误解析 |
|
| graphql-codegen skipDocumentsValidation | codegen.yml 必须设 `skipDocumentsValidation: true`,避免子图未启动时校验 operations 失败;schema 归一化脚本移除 federation 指令(@key/@requires/@extends)防止误解析 |
|
||||||
| useNotifications 命名冲突 | `universal.ts` 与 `topbar.ts` 同时导出 `useNotifications` 在 barrel `index.ts` 冲突(TS2308);topbar 改名 `useNotificationBell`(铃铛专用,限 N 条) |
|
| useNotifications 命名冲突 | `universal.ts` 与 `topbar.ts` 同时导出 `useNotifications` 在 barrel `index.ts` 冲突(TS2308);topbar 改名 `useNotificationBell`(铃铛专用,限 N 条) |
|
||||||
| APQ(Automatic Persisted Queries) | `apollo-client.ts` 用 `createPersistedQueryLink({ sha256 })` + `crypto-hash`;env `NEXT_PUBLIC_APOLLO_APQ=false` 关闭;Link 链顺序:authLink → pqLink → httpLink |
|
| APQ(Automatic Persisted Queries) | `apollo-client.ts` 用 `createPersistedQueryLink({ sha256 })` + `crypto-hash`;env `NEXT_PUBLIC_APOLLO_APQ=false` 关闭;Link 链顺序:authLink → pqLink → httpLink |
|
||||||
| PQ Manifest 生成 | `scripts/generate-pq-manifest.ts` 遍历 `operations/index.ts` 中 DocumentNode,`print(doc)` + `sha256(query)` 写入 `public/pq-manifest.json`;`prebuild` 钩子串联 codegen + generate |
|
| PQ Manifest 生成 | `scripts/generate-pq-manifest.ts` 遍历 `operations/index.ts` 中 DocumentNode,`print(doc)` + `sha256(query)` 写入 `public/pq-manifest.json`;`prebuild` 钩子串联 codegen + generate |
|
||||||
| Windows ESM 动态 import 路径 | Node ESM 动态 `import()` 不支持 Windows 盘符路径(`e:\...`),必须 `url.pathToFileURL(path).href` 转 `file://` URL 再 import |
|
| Windows ESM 动态 import 路径 | Node ESM 动态 `import()` 不支持 Windows 盘符路径(`e:\...`),必须 `url.pathToFileURL(path).href` 转 `file://` URL 再 import |
|
||||||
| apollo-router PQ manifest 挂载 | docker-compose 把 `apps/portal-shell/public/pq-manifest.json` 挂载到 router `/etc/apollo-router/pq-manifest.json:ro`;entrypoint.sh 启动前校验 `APOLLO_REQUIRE_PQ_MANIFEST=true` 时文件存在性 |
|
| apollo-router PQ manifest 挂载 | docker-compose 把 `apps/portal-shell/public/pq-manifest.json` 挂载到 router `/etc/apollo-router/pq-manifest.json:ro`;entrypoint.sh 启动前校验 `APOLLO_REQUIRE_PQ_MANIFEST=true` 时文件存在性 |
|
||||||
| apollo-router 安全限制 | `router.yaml` 配置 `limits.max_depth=10` / `max_cost=1000` / `max_batch_size=5`;`supergraph.introspection` 由 env `APOLLO_ROUTER_INTROSPECTION` 控制(生产 false) |
|
| apollo-router 安全限制 | `router.yaml` 配置 `limits.max_depth=10` / `max_cost=1000` / `max_batch_size=5`;`supergraph.introspection` 由 env `APOLLO_ROUTER_INTROSPECTION` 控制(生产 false) |
|
||||||
| Resolver @RequirePermission 字段级守卫 | 每个 GraphQL Resolver 必须用 `@RequirePermission('perm')` 声明权限点;50 resolver 审计后补齐 19 个 TS resolver,Python 子图待补 Strawberry/Ariadne 中间件 |
|
| Resolver @RequirePermission 字段级守卫 | 每个 GraphQL Resolver 必须用 `@RequirePermission('perm')` 声明权限点;50 resolver 审计后补齐 19 个 TS resolver,Python 子图待补 Strawberry/Ariadne 中间件 |
|
||||||
| TS interface 不满足 Record 约束 | `useWidgetQuery<TData, TVars extends Record<string, unknown>>` 约束下 widget 用 `interface XxxVars` 报 TS2344;改 `type XxxVars = {...}` 别名(满足 index signature) |
|
| TS interface 不满足 Record 约束 | `useWidgetQuery<TData, TVars extends Record<string, unknown>>` 约束下 widget 用 `interface XxxVars` 报 TS2344;改 `type XxxVars = {...}` 别名(满足 index signature) |
|
||||||
| parent.test.tsx 可选链 | `data?.[0].name` 报 TS2532(`data?.[0]` 可能为 undefined);改 `data?.[0]?.name` 双重可选链 |
|
| parent.test.tsx 可选链 | `data?.[0].name` 报 TS2532(`data?.[0]` 可能为 undefined);改 `data?.[0]?.name` 双重可选链 |
|
||||||
| PowerShell 不支持 heredoc | `git commit -m "$(cat <<'EOF'...)"` 在 PowerShell 报错;commit 消息写临时文件 `.git/COMMIT_MSG.txt`,用 `git commit -F .git/COMMIT_MSG.txt` |
|
| PowerShell 不支持 heredoc | `git commit -m "$(cat <<'EOF'...)"` 在 PowerShell 报错;commit 消息写临时文件 `.git/COMMIT_MSG.txt`,用 `git commit -F .git/COMMIT_MSG.txt` |
|
||||||
| commitlint body-max-line-length | commit body 每行 ≤100 字符,Plan/Spec 路径过长会超限;移除 URL 行或换行简化 |
|
| commitlint body-max-line-length | commit body 每行 ≤100 字符,Plan/Spec 路径过长会超限;移除 URL 行或换行简化 |
|
||||||
| commitlint scope-enum | `security` / `graphql` 不在允许 scope 列表;用 `docs` scope 提交审计报告,或用无 scope commit |
|
| commitlint scope-enum | `security` / `graphql` 不在允许 scope 列表;用 `docs` scope 提交审计报告,或用无 scope commit |
|
||||||
| Turbopack 不支持 .js 后缀 import | Next 16 默认 Turbopack 无法像 webpack 那样通过 `resolve.extensionAlias` 将 `.js` 映射到 `.ts/.tsx`;`packages/ui-components` 和 `packages/hooks` 源码内部 import 必须去掉 `.js` 后缀(shared-ts 是 NestJS ESM 模式按规则 §3.4 保留 `.js` 后缀,不修改) |
|
| Turbopack 不支持 .js 后缀 import | Next 16 默认 Turbopack 无法像 webpack 那样通过 `resolve.extensionAlias` 将 `.js` 映射到 `.ts/.tsx`;`packages/ui-components` 和 `packages/hooks` 源码内部 import 必须去掉 `.js` 后缀(shared-ts 是 NestJS ESM 模式按规则 §3.4 保留 `.js` 后缀,不修改) |
|
||||||
|
| 旧纸感令牌批量迁移 shadcn 标准 | 31 个 widget 全量替换:`bg-paper→bg-background` / `bg-surface→bg-card` / `text-ink→text-foreground` / `border-rule→border` 等 19 项映射;保留 button.tsx 中 `bg-accent`(shadcn 标准 hover 语义令牌,通过 `--accent` CSS 变量定义,非旧纸感 `bg-accent`) |
|
||||||
|
| PERMISSION_BITMAP_ORDER 重复权限点 | `GRADE_READ` 在数组 bit 14 和 bit 53 重复出现,全量编解码 round-trip 测试需用 `[...new Set(PERMISSION_BITMAP_ORDER)]` 去重后再比较 |
|
||||||
|
| jsdom Blob 不支持 .text() | jsdom 的 Blob 实现不提供 `.text()` 方法,`new Response(blob).text()` 也返回 `[object Blob]`;测试 sendBeacon 时用 `vi.stubGlobal("Blob", vi.fn(...))` mock 构造函数,捕获 `parts[0]` 字符串 |
|
||||||
|
| vi.mock 提升导致变量未初始化 | `vi.mock()` 被提升到文件顶部,引用外部变量报 `Cannot access 'X' before initialization`;用 `vi.hoisted()` 将 mock 变量初始化提升到 vi.mock 之前 |
|
||||||
|
| sonner toast 双重性质 mock | toast 既是可调用函数 `toast(msg, opts)` 又有方法 `toast.success/.error/.warning/.info`;mock 时需创建 `vi.fn()` 可调用对象后手动挂载 `.success/.error` 等方法 |
|
||||||
|
| React 19 use() + Suspense jsdom 测试 | `use(promise)` 在 jsdom 中 promise resolve 后不自动触发重新渲染;需用 `await act(async () => { render(...); await Promise.resolve(); })` 包裹 render,并设置 `globalThis.IS_REACT_ACT_ENVIRONMENT = true`(setup 文件) |
|
||||||
|
| 前端错误上报生产端点 | api-gateway 在 `/api/v1/log` 直接处理(不代理到下游),slog 结构化 JSON 日志,64KB body 限制,返回 204;`useErrorReport` 按 `process.env.NODE_ENV` 切换:production→`/api/v1/log`,development→`/api/log`(Next.js API route mock) |
|
||||||
|
| vitest setup 文件配置 | `vitest.config.ts` 的 `setupFiles: ["src/__tests__/setup.ts"]` 注册 `@testing-library/jest-dom/vitest` matchers + 设置 `IS_REACT_ACT_ENVIRONMENT`;`declare global { var IS_REACT_ACT_ENVIRONMENT: boolean \| undefined }` 补类型签名 |
|
||||||
|
|||||||
@@ -40,8 +40,16 @@ export interface ErrorReportPayload {
|
|||||||
context?: Record<string, unknown>;
|
context?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 上报端点(Next.js API Route mock,未来切换到 OTel / Sentry) */
|
/**
|
||||||
const REPORT_ENDPOINT = "/api/log";
|
* 上报端点
|
||||||
|
*
|
||||||
|
* P3:生产环境使用 api-gateway 的 /api/v1/log(通过 next.config.js rewrites 代理)
|
||||||
|
* 开发环境 DEV_MODE=true 时回退到 Next.js API Route /api/log(mock 端点)
|
||||||
|
*/
|
||||||
|
const REPORT_ENDPOINT =
|
||||||
|
typeof process !== "undefined" && process.env.NODE_ENV === "production"
|
||||||
|
? "/api/v1/log"
|
||||||
|
: "/api/log";
|
||||||
|
|
||||||
/** 节流窗口(1 分钟内同 digest 只上报一次) */
|
/** 节流窗口(1 分钟内同 digest 只上报一次) */
|
||||||
const THROTTLE_WINDOW_MS = 60_000;
|
const THROTTLE_WINDOW_MS = 60_000;
|
||||||
|
|||||||
110
services/api-gateway/internal/log/handler.go
Normal file
110
services/api-gateway/internal/log/handler.go
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
// Package log 提供前端错误上报接收端点。
|
||||||
|
//
|
||||||
|
// 路由:POST /api/v1/log
|
||||||
|
// 职责:
|
||||||
|
// - 接收前端 useErrorReport hook 通过 sendBeacon/fetch keepalive 上报的错误
|
||||||
|
// - 使用 slog 结构化日志记录(后续可接入 OTel/Sentry)
|
||||||
|
// - 返回 204 No Content(sendBeacon 不需要响应体)
|
||||||
|
//
|
||||||
|
// 安全:
|
||||||
|
// - 已通过 api-gateway 的 JWT 鉴权中间件
|
||||||
|
// - 请求体大小限制 64KB(错误日志不需要大 body)
|
||||||
|
// - 限流由全局 RateLimit 中间件保障
|
||||||
|
//
|
||||||
|
// 关联:portal-shell README v2.0 §5.4 三级错误处理
|
||||||
|
package log
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// maxLogBodySize 日志请求体上限:64KB
|
||||||
|
const maxLogBodySize int64 = 64 * 1024
|
||||||
|
|
||||||
|
// ErrorReportPayload 前端错误上报 payload 结构
|
||||||
|
// 对齐 packages/hooks/src/use-error-report.ts 的 ErrorReportPayload
|
||||||
|
type ErrorReportPayload struct {
|
||||||
|
Level string `json:"level"` // "error" | "warning"
|
||||||
|
Message string `json:"message"` // 错误消息
|
||||||
|
Stack string `json:"stack,omitempty"` // 调用栈
|
||||||
|
Digest string `json:"digest,omitempty"` // Next.js 错误摘要
|
||||||
|
Path string `json:"path"` // window.location.pathname
|
||||||
|
UserAgent string `json:"userAgent"` // navigator.userAgent
|
||||||
|
Timestamp string `json:"timestamp"` // ISO 8601
|
||||||
|
PluginID string `json:"pluginId,omitempty"` // 插件标识
|
||||||
|
UserID string `json:"userId,omitempty"` // 用户 ID
|
||||||
|
Context map[string]interface{} `json:"context,omitempty"` // 额外上下文
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleReport 接收前端错误上报。
|
||||||
|
//
|
||||||
|
// POST /api/v1/log
|
||||||
|
// Request Body: ErrorReportPayload (JSON)
|
||||||
|
// Response: 204 No Content
|
||||||
|
//
|
||||||
|
// 容错策略:
|
||||||
|
// - JSON 解析失败 → 400 + 错误消息
|
||||||
|
// - 其他错误 → 204(前端上报失败静默降级,不应阻塞)
|
||||||
|
func HandleReport(c *gin.Context) {
|
||||||
|
// 限制请求体大小
|
||||||
|
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxLogBodySize)
|
||||||
|
|
||||||
|
body, err := io.ReadAll(c.Request.Body)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("log report: read body failed",
|
||||||
|
"error", err,
|
||||||
|
"request_id", c.GetString("request_id"),
|
||||||
|
)
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload ErrorReportPayload
|
||||||
|
if err := json.Unmarshal(body, &payload); err != nil {
|
||||||
|
slog.Warn("log report: invalid JSON",
|
||||||
|
"error", err,
|
||||||
|
"request_id", c.GetString("request_id"),
|
||||||
|
"body_size", len(body),
|
||||||
|
)
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{
|
||||||
|
"error": "invalid JSON payload",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 结构化日志记录(后续可替换为 OTel/Sentry exporter)
|
||||||
|
slog.LogAttrs(c.Request.Context(), toSlogLevel(payload.Level),
|
||||||
|
payload.Message,
|
||||||
|
slog.String("logger", "frontend"),
|
||||||
|
slog.String("digest", payload.Digest),
|
||||||
|
slog.String("path", payload.Path),
|
||||||
|
slog.String("plugin_id", payload.PluginID),
|
||||||
|
slog.String("user_id", payload.UserID),
|
||||||
|
slog.String("user_agent", payload.UserAgent),
|
||||||
|
slog.String("timestamp", payload.Timestamp),
|
||||||
|
slog.String("stack", payload.Stack),
|
||||||
|
slog.Any("context", payload.Context),
|
||||||
|
slog.String("request_id", c.GetString("request_id")),
|
||||||
|
)
|
||||||
|
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// toSlogLevel 将前端 level 映射到 slog.Level
|
||||||
|
func toSlogLevel(level string) slog.Level {
|
||||||
|
switch level {
|
||||||
|
case "error":
|
||||||
|
return slog.LevelError
|
||||||
|
case "warning":
|
||||||
|
return slog.LevelWarn
|
||||||
|
case "info":
|
||||||
|
return slog.LevelInfo
|
||||||
|
default:
|
||||||
|
return slog.LevelError
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
|
|
||||||
"github.com/edu-cloud/api-gateway/internal/config"
|
"github.com/edu-cloud/api-gateway/internal/config"
|
||||||
"github.com/edu-cloud/api-gateway/internal/health"
|
"github.com/edu-cloud/api-gateway/internal/health"
|
||||||
|
loghandler "github.com/edu-cloud/api-gateway/internal/log"
|
||||||
"github.com/edu-cloud/api-gateway/internal/middleware"
|
"github.com/edu-cloud/api-gateway/internal/middleware"
|
||||||
"github.com/edu-cloud/api-gateway/internal/observability"
|
"github.com/edu-cloud/api-gateway/internal/observability"
|
||||||
"github.com/edu-cloud/api-gateway/internal/proxy"
|
"github.com/edu-cloud/api-gateway/internal/proxy"
|
||||||
@@ -102,6 +103,11 @@ func main() {
|
|||||||
// data-ana 域路由(学情诊断/错题本/仪表盘)
|
// data-ana 域路由(学情诊断/错题本/仪表盘)
|
||||||
registerProxy(api, "analytics", cfg.DataAnaServiceURL)
|
registerProxy(api, "analytics", cfg.DataAnaServiceURL)
|
||||||
registerProxy(api, "dashboard", cfg.DataAnaServiceURL)
|
registerProxy(api, "dashboard", cfg.DataAnaServiceURL)
|
||||||
|
|
||||||
|
// 前端错误上报端点(portal-shell v2.0 P3)
|
||||||
|
// POST /api/v1/log → 结构化日志记录(slog)
|
||||||
|
// 不代理到下游服务,在 Gateway 层直接处理(轻量级、低延迟)
|
||||||
|
api.POST("/log", loghandler.HandleReport)
|
||||||
}
|
}
|
||||||
|
|
||||||
// v2.1 M9:GraphQL 联邦入口(替代旧 BFF 聚合层)
|
// v2.1 M9:GraphQL 联邦入口(替代旧 BFF 聚合层)
|
||||||
|
|||||||
Reference in New Issue
Block a user