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:
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)
|
||||
@@ -35,7 +35,7 @@ const RESOURCE_OPTIONS = [
|
||||
] as const;
|
||||
|
||||
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 {
|
||||
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);
|
||||
|
||||
return (
|
||||
<section className="rounded-card border border-rule bg-surface p-md">
|
||||
<h3 className="text-heading-3 text-ink">审计日志</h3>
|
||||
<section className="rounded-xl border border bg-card p-4">
|
||||
<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
|
||||
type="text"
|
||||
value={userIdFilter}
|
||||
@@ -122,12 +122,12 @@ export default function AuditLogs(props: PluginProps): React.ReactElement {
|
||||
</div>
|
||||
|
||||
{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">
|
||||
<table className="w-full text-tiny">
|
||||
<table className="w-full text-xs">
|
||||
<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>
|
||||
@@ -142,18 +142,20 @@ export default function AuditLogs(props: PluginProps): React.ReactElement {
|
||||
<tr
|
||||
key={log.id}
|
||||
onClick={() => setSelectedId(isSelected ? null : log.id)}
|
||||
className={`cursor-pointer border-b border-rule ${
|
||||
isSelected ? "bg-accent-subtle" : "bg-paper"
|
||||
className={`cursor-pointer border-b border ${
|
||||
isSelected ? "bg-primary-subtle" : "bg-background"
|
||||
}`}
|
||||
>
|
||||
<td className="py-xs text-ink-muted">{log.timestamp}</td>
|
||||
<td className="py-xs text-ink">{log.userName}</td>
|
||||
<td className="py-xs text-ink">{log.action}</td>
|
||||
<td className="py-xs text-ink">
|
||||
<td className="py-xs text-muted-foreground">
|
||||
{log.timestamp}
|
||||
</td>
|
||||
<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.resourceId ? ` / ${log.resourceId}` : ""}
|
||||
</td>
|
||||
<td className="py-xs text-ink-muted">{log.ip}</td>
|
||||
<td className="py-xs text-muted-foreground">{log.ip}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
@@ -163,24 +165,24 @@ export default function AuditLogs(props: PluginProps): React.ReactElement {
|
||||
)}
|
||||
|
||||
{selectedLog ? (
|
||||
<div className="mt-sm rounded-button bg-paper p-sm">
|
||||
<p className="text-tiny text-ink-muted">详情</p>
|
||||
<pre className="mt-xs overflow-x-auto whitespace-pre-wrap break-words text-tiny text-ink">
|
||||
<div className="mt-sm rounded-md bg-background p-2">
|
||||
<p className="text-xs text-muted-foreground">详情</p>
|
||||
<pre className="mt-xs overflow-x-auto whitespace-pre-wrap break-words text-xs text-foreground">
|
||||
{selectedLog.details || "(无详细信息)"}
|
||||
</pre>
|
||||
</div>
|
||||
) : 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>
|
||||
共 {total} 条,第 {offset + 1} - {rangeEnd} 条
|
||||
</span>
|
||||
<div className="flex gap-sm">
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={!hasPrev}
|
||||
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>
|
||||
@@ -188,7 +190,7 @@ export default function AuditLogs(props: PluginProps): React.ReactElement {
|
||||
type="button"
|
||||
disabled={!hasNext}
|
||||
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>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"use client";
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* invitation-codes(admin / main)
|
||||
@@ -31,8 +31,8 @@ const STATUS_LABELS: Record<CodeStatus, string> = {
|
||||
const ROLE_OPTIONS = ["teacher", "student", "parent"] as const;
|
||||
|
||||
const inputCls =
|
||||
"rounded-button border border-rule bg-surface px-sm py-xs text-small text-ink";
|
||||
const labelCls = "text-tiny text-ink-muted";
|
||||
"rounded-md border border bg-card px-sm py-xs text-sm text-foreground";
|
||||
const labelCls = "text-xs text-muted-foreground";
|
||||
|
||||
export default function InvitationCodes(
|
||||
props: PluginProps,
|
||||
@@ -98,16 +98,16 @@ export default function InvitationCodes(
|
||||
const codes = data ?? [];
|
||||
|
||||
return (
|
||||
<section className="rounded-card border border-rule bg-surface p-md">
|
||||
<h3 className="text-heading-3 text-ink">邀请码</h3>
|
||||
<section className="rounded-xl border border bg-card p-4">
|
||||
<h3 className="text-heading-3 text-foreground">邀请码</h3>
|
||||
{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}
|
||||
|
||||
{/* 生成新邀请码 */}
|
||||
<div className="mt-sm rounded-button bg-paper p-sm">
|
||||
<div className="mt-sm rounded-md bg-background p-2">
|
||||
<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">
|
||||
<label className={labelCls}>角色</label>
|
||||
<select
|
||||
@@ -145,7 +145,7 @@ export default function InvitationCodes(
|
||||
<button
|
||||
type="button"
|
||||
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>
|
||||
@@ -153,7 +153,7 @@ export default function InvitationCodes(
|
||||
</div>
|
||||
|
||||
{/* 筛选 */}
|
||||
<div className="mt-md flex items-center gap-sm">
|
||||
<div className="mt-md flex items-center gap-2">
|
||||
<label className={labelCls}>状态</label>
|
||||
<select
|
||||
value={statusFilter}
|
||||
@@ -171,12 +171,12 @@ export default function InvitationCodes(
|
||||
|
||||
{/* 邀请码列表 */}
|
||||
{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">
|
||||
<table className="w-full text-small">
|
||||
<table className="w-full text-sm">
|
||||
<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>
|
||||
@@ -190,20 +190,24 @@ export default function InvitationCodes(
|
||||
const isBusy = busyId === c.id;
|
||||
const canRevoke = c.status === "active";
|
||||
return (
|
||||
<tr key={c.id} className="border-b border-rule">
|
||||
<td className="py-xs font-mono text-ink">{c.code}</td>
|
||||
<td className="py-xs text-ink">{c.role}</td>
|
||||
<td className="py-xs text-ink">{c.status}</td>
|
||||
<td className="py-xs text-ink">
|
||||
<tr key={c.id} className="border-b border">
|
||||
<td className="py-xs font-mono text-foreground">
|
||||
{c.code}
|
||||
</td>
|
||||
<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}
|
||||
</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">
|
||||
<div className="flex gap-sm">
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
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>
|
||||
@@ -212,7 +216,7 @@ export default function InvitationCodes(
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
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>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"use client";
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* plugin-manager(admin / main)
|
||||
@@ -77,33 +77,35 @@ function RegistryTab(): React.ReactElement {
|
||||
const items = data ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-md">
|
||||
<div className="space-y-4">
|
||||
{items.length === 0 ? (
|
||||
<p className="text-small text-ink-muted">暂无已注册插件</p>
|
||||
<p className="text-sm text-muted-foreground">暂无已注册插件</p>
|
||||
) : (
|
||||
items.map((item) => (
|
||||
<div
|
||||
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>
|
||||
<p className="text-body text-ink">
|
||||
<p className="text-body text-foreground">
|
||||
{item.displayName}
|
||||
{item.isBuiltin ? (
|
||||
<span className="ml-xs text-tiny text-ink-muted">内置</span>
|
||||
<span className="ml-xs text-xs text-muted-foreground">
|
||||
内置
|
||||
</span>
|
||||
) : null}
|
||||
</p>
|
||||
<p className="text-tiny text-ink-muted">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{item.pluginId} · v{item.version} · {item.category} ·{" "}
|
||||
{item.defaultSlot}
|
||||
</p>
|
||||
<p className="mt-xs text-small text-ink-muted">
|
||||
<p className="mt-xs text-sm text-muted-foreground">
|
||||
{item.description}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-sm">
|
||||
<label className="flex items-center gap-xs text-small text-ink">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="flex items-center gap-xs text-sm text-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={item.isActive}
|
||||
@@ -118,7 +120,7 @@ function RegistryTab(): React.ReactElement {
|
||||
? setEditingId(null)
|
||||
: 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"}
|
||||
</button>
|
||||
@@ -126,22 +128,22 @@ function RegistryTab(): React.ReactElement {
|
||||
</div>
|
||||
{editingId === item.pluginId ? (
|
||||
<div className="mt-sm space-y-xs">
|
||||
<label className="text-tiny text-ink-muted">
|
||||
<label className="text-xs text-muted-foreground">
|
||||
defaultProps (JSON)
|
||||
</label>
|
||||
<textarea
|
||||
value={draftProps}
|
||||
onChange={(e) => setDraftProps(e.target.value)}
|
||||
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 ? (
|
||||
<p className="text-tiny text-ink-muted">{draftError}</p>
|
||||
<p className="text-xs text-muted-foreground">{draftError}</p>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
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>
|
||||
@@ -197,12 +199,12 @@ function MappingTab(): React.ReactElement {
|
||||
|
||||
const draftList = Object.values(drafts);
|
||||
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 (
|
||||
<div className="space-y-md">
|
||||
<div className="flex items-center gap-sm">
|
||||
<label className="text-small text-ink-muted">角色</label>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm text-muted-foreground">角色</label>
|
||||
<select
|
||||
value={selectedRole}
|
||||
onChange={(e) => setSelectedRole(e.target.value)}
|
||||
@@ -218,12 +220,12 @@ function MappingTab(): React.ReactElement {
|
||||
{loading && !data ? (
|
||||
<PluginSkeleton variant="table" />
|
||||
) : draftList.length === 0 ? (
|
||||
<p className="text-small text-ink-muted">该角色暂无插件映射</p>
|
||||
<p className="text-sm text-muted-foreground">该角色暂无插件映射</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-small">
|
||||
<table className="w-full text-sm">
|
||||
<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">Slot</th>
|
||||
@@ -232,8 +234,8 @@ function MappingTab(): React.ReactElement {
|
||||
</thead>
|
||||
<tbody>
|
||||
{draftList.map((m) => (
|
||||
<tr key={m.pluginId} className="border-b border-rule">
|
||||
<td className="py-xs text-ink">{m.pluginId}</td>
|
||||
<tr key={m.pluginId} className="border-b border">
|
||||
<td className="py-xs text-foreground">{m.pluginId}</td>
|
||||
<td className="py-xs">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -250,7 +252,7 @@ function MappingTab(): React.ReactElement {
|
||||
onChange={(e) =>
|
||||
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 className="py-xs">
|
||||
@@ -262,7 +264,7 @@ function MappingTab(): React.ReactElement {
|
||||
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>
|
||||
</tr>
|
||||
@@ -274,7 +276,7 @@ function MappingTab(): React.ReactElement {
|
||||
<button
|
||||
type="button"
|
||||
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>
|
||||
@@ -311,15 +313,15 @@ function LayoutTab(): React.ReactElement {
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="space-y-md">
|
||||
<div className="space-y-4">
|
||||
{status.length > 0 ? (
|
||||
<p className="text-tiny text-ink-muted">{status}</p>
|
||||
<p className="text-xs text-muted-foreground">{status}</p>
|
||||
) : null}
|
||||
<div className="flex items-center gap-sm">
|
||||
<label className="text-small text-ink-muted">角色</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm text-muted-foreground">角色</label>
|
||||
<select
|
||||
value={selectedRole}
|
||||
onChange={(e) => {
|
||||
@@ -334,11 +336,11 @@ function LayoutTab(): React.ReactElement {
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="text-tiny text-ink-muted">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
当前默认:{currentLayoutId || "未设置"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-md">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{templates.map((tpl) => {
|
||||
const isCurrent = tpl.layoutId === currentLayoutId;
|
||||
return (
|
||||
@@ -346,19 +348,19 @@ function LayoutTab(): React.ReactElement {
|
||||
key={tpl.layoutId}
|
||||
type="button"
|
||||
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
|
||||
? "border-accent bg-accent-subtle"
|
||||
: "border-rule bg-paper"
|
||||
? "border-accent bg-primary-subtle"
|
||||
: "border bg-background"
|
||||
}`}
|
||||
>
|
||||
<p className="text-body text-ink">{tpl.displayName}</p>
|
||||
<p className="text-tiny text-ink-muted">{tpl.description}</p>
|
||||
<p className="mt-xs text-tiny text-ink-muted">
|
||||
<p className="text-body text-foreground">{tpl.displayName}</p>
|
||||
<p className="text-xs text-muted-foreground">{tpl.description}</p>
|
||||
<p className="mt-xs text-xs text-muted-foreground">
|
||||
可用 Slot:{tpl.availableSlots.join("、")}
|
||||
</p>
|
||||
{isCurrent ? (
|
||||
<p className="mt-xs text-tiny text-ink">当前默认</p>
|
||||
<p className="mt-xs text-xs text-foreground">当前默认</p>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
@@ -390,28 +392,28 @@ function UserLayoutTab(): React.ReactElement {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-md">
|
||||
<p className="text-small text-ink-muted">
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
重置用户的自定义布局覆盖,使其回到角色默认布局。
|
||||
</p>
|
||||
<div className="flex items-center gap-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={userId}
|
||||
onChange={(e) => setUserId(e.target.value)}
|
||||
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
|
||||
type="button"
|
||||
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>
|
||||
</div>
|
||||
{status.length > 0 ? (
|
||||
<p className="text-tiny text-ink-muted">{status}</p>
|
||||
<p className="text-xs text-muted-foreground">{status}</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
@@ -421,19 +423,19 @@ export default function PluginManager(_props: PluginProps): React.ReactElement {
|
||||
const [tab, setTab] = useState<TabKey>("registry");
|
||||
|
||||
return (
|
||||
<section className="rounded-card border border-rule bg-surface p-md">
|
||||
<h3 className="text-heading-3 text-ink">插件管理</h3>
|
||||
<div className="mt-sm flex gap-sm border-b border-rule">
|
||||
<section className="rounded-xl border border bg-card p-4">
|
||||
<h3 className="text-heading-3 text-foreground">插件管理</h3>
|
||||
<div className="mt-sm flex gap-2 border-b border">
|
||||
{(Object.keys(TAB_LABELS) as TabKey[]).map((key) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
onClick={() => setTab(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
|
||||
? "bg-accent text-ink-onAccent"
|
||||
: "bg-surface text-ink"
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-card text-foreground"
|
||||
}`}
|
||||
>
|
||||
{TAB_LABELS[key]}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"use client";
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* rbac-manager(admin / main)
|
||||
@@ -79,20 +79,20 @@ export default function RbacManager(_props: PluginProps): React.ReactElement {
|
||||
|
||||
if (roles.length === 0) {
|
||||
return (
|
||||
<section className="rounded-card border border-rule bg-surface p-md">
|
||||
<h3 className="text-heading-3 text-ink">角色权限</h3>
|
||||
<p className="mt-sm text-small text-ink-muted">暂无角色数据</p>
|
||||
<section className="rounded-xl border border bg-card p-4">
|
||||
<h3 className="text-heading-3 text-foreground">角色权限</h3>
|
||||
<p className="mt-sm text-sm text-muted-foreground">暂无角色数据</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="rounded-card border border-rule bg-surface p-md">
|
||||
<h3 className="text-heading-3 text-ink">角色权限</h3>
|
||||
<div className="mt-sm flex gap-md">
|
||||
<section className="rounded-xl border border bg-card p-4">
|
||||
<h3 className="text-heading-3 text-foreground">角色权限</h3>
|
||||
<div className="mt-sm flex gap-4">
|
||||
{/* 左侧角色列表 */}
|
||||
<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">
|
||||
{roles.map((role) => {
|
||||
const isSelected = role.id === effectiveRoleId;
|
||||
@@ -102,10 +102,10 @@ export default function RbacManager(_props: PluginProps): React.ReactElement {
|
||||
type="button"
|
||||
onClick={() => setSelectedRoleId(role.id)}
|
||||
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
|
||||
? "border-accent bg-accent-subtle text-ink"
|
||||
: "border-rule bg-paper text-ink"
|
||||
? "border-accent bg-primary-subtle text-foreground"
|
||||
: "border bg-background text-foreground"
|
||||
}`}
|
||||
>
|
||||
{role.name}
|
||||
@@ -119,19 +119,19 @@ export default function RbacManager(_props: PluginProps): React.ReactElement {
|
||||
{/* 右侧权限矩阵 */}
|
||||
<div className="flex-1 overflow-x-auto">
|
||||
{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>
|
||||
<tr className="border-b border-rule text-ink-muted">
|
||||
<tr className="border-b border text-muted-foreground">
|
||||
<th className="py-xs text-left">权限</th>
|
||||
{roles.map((role) => (
|
||||
<th
|
||||
key={role.id}
|
||||
className={`py-xs text-center ${
|
||||
role.id === effectiveRoleId
|
||||
? "text-ink"
|
||||
: "text-ink-muted"
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{role.name}
|
||||
@@ -141,10 +141,10 @@ export default function RbacManager(_props: PluginProps): React.ReactElement {
|
||||
</thead>
|
||||
<tbody>
|
||||
{permissions.map((perm) => (
|
||||
<tr key={perm.id} className="border-b border-rule">
|
||||
<tr key={perm.id} className="border-b border">
|
||||
<td className="py-xs">
|
||||
<p className="text-ink">{perm.name}</p>
|
||||
<p className="text-tiny text-ink-muted">
|
||||
<p className="text-foreground">{perm.name}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{perm.resource} / {perm.action}
|
||||
</p>
|
||||
</td>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"use client";
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* school-settings(admin / main)
|
||||
@@ -18,8 +18,8 @@ import type { PluginProps } from "@/lib/types";
|
||||
|
||||
const TERM_OPTIONS = ["第一学期", "第二学期", "暑假", "寒假"] as const;
|
||||
const inputCls =
|
||||
"w-full rounded-button border border-rule bg-surface px-sm py-xs text-small text-ink";
|
||||
const labelCls = "text-tiny text-ink-muted";
|
||||
"w-full rounded-md border border bg-card px-sm py-xs text-sm text-foreground";
|
||||
const labelCls = "text-xs text-muted-foreground";
|
||||
|
||||
export default function SchoolSettings(props: PluginProps): React.ReactElement {
|
||||
const showAdvanced =
|
||||
@@ -70,21 +70,21 @@ export default function SchoolSettings(props: PluginProps): React.ReactElement {
|
||||
|
||||
if (!draft) {
|
||||
return (
|
||||
<section className="rounded-card border border-rule bg-surface p-md">
|
||||
<h3 className="text-heading-3 text-ink">学校设置</h3>
|
||||
<p className="mt-sm text-small text-ink-muted">暂无学校数据</p>
|
||||
<section className="rounded-xl border border bg-card p-4">
|
||||
<h3 className="text-heading-3 text-foreground">学校设置</h3>
|
||||
<p className="mt-sm text-sm text-muted-foreground">暂无学校数据</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="rounded-card border border-rule bg-surface p-md">
|
||||
<h3 className="text-heading-3 text-ink">学校设置</h3>
|
||||
<section className="rounded-xl border border bg-card p-4">
|
||||
<h3 className="text-heading-3 text-foreground">学校设置</h3>
|
||||
{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}
|
||||
|
||||
<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">
|
||||
<label className={labelCls}>学校名称</label>
|
||||
<input
|
||||
@@ -168,26 +168,26 @@ export default function SchoolSettings(props: PluginProps): React.ReactElement {
|
||||
</div>
|
||||
|
||||
{showAdvanced ? (
|
||||
<div className="mt-md rounded-button bg-paper p-sm">
|
||||
<p className="text-tiny text-ink-muted">
|
||||
<div className="mt-md rounded-md bg-background p-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
高级配置:学区划分、学段映射、教研室组织等由 config-service
|
||||
角色-插件映射控制。
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-md flex justify-end gap-sm">
|
||||
<div className="mt-md flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
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
|
||||
type="button"
|
||||
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>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"use client";
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* user-management(admin / main)
|
||||
@@ -87,13 +87,13 @@ export default function UserManagement(
|
||||
const rangeEnd = Math.min(offset + PAGE_SIZE, total);
|
||||
|
||||
return (
|
||||
<section className="rounded-card border border-rule bg-surface p-md">
|
||||
<h3 className="text-heading-3 text-ink">用户管理</h3>
|
||||
<div className="mt-sm flex gap-sm">
|
||||
<section className="rounded-xl border border bg-card p-4">
|
||||
<h3 className="text-heading-3 text-foreground">用户管理</h3>
|
||||
<div className="mt-sm flex gap-2">
|
||||
<select
|
||||
value={roleFilter}
|
||||
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="按角色筛选"
|
||||
>
|
||||
<option value="">全部角色</option>
|
||||
@@ -106,12 +106,12 @@ export default function UserManagement(
|
||||
</div>
|
||||
|
||||
{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">
|
||||
<table className="w-full text-small">
|
||||
<table className="w-full text-sm">
|
||||
<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>
|
||||
@@ -123,15 +123,15 @@ export default function UserManagement(
|
||||
{users.map((u) => {
|
||||
const isBusy = busyId === u.id;
|
||||
return (
|
||||
<tr key={u.id} className="border-b border-rule">
|
||||
<td className="py-xs text-ink">{u.name}</td>
|
||||
<td className="py-xs text-ink">{u.email}</td>
|
||||
<tr key={u.id} className="border-b border">
|
||||
<td className="py-xs text-foreground">{u.name}</td>
|
||||
<td className="py-xs text-foreground">{u.email}</td>
|
||||
<td className="py-xs">
|
||||
<select
|
||||
value={u.role}
|
||||
disabled={isBusy}
|
||||
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="修改角色"
|
||||
>
|
||||
{ROLE_OPTIONS.map((r) => (
|
||||
@@ -148,7 +148,7 @@ export default function UserManagement(
|
||||
onChange={(e) =>
|
||||
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="修改状态"
|
||||
>
|
||||
{STATUS_OPTIONS.map((s) => (
|
||||
@@ -158,7 +158,9 @@ export default function UserManagement(
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
<td className="py-xs text-ink-muted">{u.createdAt}</td>
|
||||
<td className="py-xs text-muted-foreground">
|
||||
{u.createdAt}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
@@ -167,16 +169,16 @@ export default function UserManagement(
|
||||
</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>
|
||||
共 {total} 条,第 {offset + 1} - {rangeEnd} 条
|
||||
</span>
|
||||
<div className="flex gap-sm">
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={!hasPrev}
|
||||
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>
|
||||
@@ -184,7 +186,7 @@ export default function UserManagement(
|
||||
type="button"
|
||||
disabled={!hasNext}
|
||||
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>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"use client";
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* child-overview(parent / main)
|
||||
@@ -41,17 +41,17 @@ export default function ChildOverview(_props: PluginProps): React.ReactElement {
|
||||
|
||||
if (children.length === 0) {
|
||||
return (
|
||||
<section className="rounded-card border border-rule bg-surface p-md">
|
||||
<h3 className="text-heading-3 text-ink">孩子总览</h3>
|
||||
<p className="mt-sm text-small text-ink-muted">暂无孩子信息</p>
|
||||
<section className="rounded-xl border border bg-card p-4">
|
||||
<h3 className="text-heading-3 text-foreground">孩子总览</h3>
|
||||
<p className="mt-sm text-sm text-muted-foreground">暂无孩子信息</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="rounded-card border border-rule bg-surface p-md">
|
||||
<h3 className="text-heading-3 text-ink">孩子总览</h3>
|
||||
<div className="mt-sm grid grid-cols-2 gap-md">
|
||||
<section className="rounded-xl border border bg-card p-4">
|
||||
<h3 className="text-heading-3 text-foreground">孩子总览</h3>
|
||||
<div className="mt-sm grid grid-cols-2 gap-4">
|
||||
{children.map((child) => {
|
||||
const isSelected = child.id === selectedChildId;
|
||||
return (
|
||||
@@ -60,33 +60,33 @@ export default function ChildOverview(_props: PluginProps): React.ReactElement {
|
||||
type="button"
|
||||
onClick={() => handleSelect(child.id)}
|
||||
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
|
||||
? "border-accent bg-accent-subtle"
|
||||
: "border-rule bg-paper"
|
||||
? "border-accent bg-primary-subtle"
|
||||
: "border bg-background"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-sm">
|
||||
<span className="flex h-10 w-10 items-center justify-center rounded-full bg-accent text-ink-onAccent">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex h-10 w-10 items-center justify-center rounded-full bg-primary text-primary-foreground">
|
||||
{child.name.charAt(0)}
|
||||
</span>
|
||||
<div>
|
||||
<p className="text-body text-ink">{child.name}</p>
|
||||
<p className="text-tiny text-ink-muted">
|
||||
<p className="text-body text-foreground">{child.name}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{child.grade} {child.className}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<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 ? (
|
||||
<p className="text-tiny text-ink-muted">暂无成绩</p>
|
||||
<p className="text-xs text-muted-foreground">暂无成绩</p>
|
||||
) : (
|
||||
<ul className="space-y-xs">
|
||||
{child.recentGrades.map((g) => (
|
||||
<li
|
||||
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.score}</span>
|
||||
@@ -95,12 +95,12 @@ export default function ChildOverview(_props: PluginProps): React.ReactElement {
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-sm flex justify-between border-t border-rule pt-xs text-tiny">
|
||||
<span className="text-ink-muted">
|
||||
<div className="mt-sm flex justify-between border-t border pt-xs text-xs">
|
||||
<span className="text-muted-foreground">
|
||||
出勤:
|
||||
{formatRate(child.attendance.present, child.attendance.total)}
|
||||
</span>
|
||||
<span className="text-ink-muted">
|
||||
<span className="text-muted-foreground">
|
||||
作业:
|
||||
{formatRate(
|
||||
child.homeworkCompletion.completed,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"use client";
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* leave-approval(parent / main)
|
||||
@@ -89,13 +89,13 @@ export default function LeaveApproval(_props: PluginProps): React.ReactElement {
|
||||
const requests = data ?? [];
|
||||
|
||||
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">
|
||||
<h3 className="text-heading-3 text-ink">请假审批</h3>
|
||||
<h3 className="text-heading-3 text-foreground">请假审批</h3>
|
||||
<select
|
||||
value={statusFilter}
|
||||
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="按状态筛选"
|
||||
>
|
||||
<option value="">全部状态</option>
|
||||
@@ -106,16 +106,16 @@ export default function LeaveApproval(_props: PluginProps): React.ReactElement {
|
||||
</div>
|
||||
|
||||
{childId.length > 0 ? (
|
||||
<p className="mt-xs text-tiny text-ink-muted">已按选中孩子过滤</p>
|
||||
<p className="mt-xs text-xs text-muted-foreground">已按选中孩子过滤</p>
|
||||
) : null}
|
||||
{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}
|
||||
|
||||
{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) => {
|
||||
const isPending = req.status === "pending";
|
||||
const isBusy = busyId === req.id;
|
||||
@@ -123,22 +123,24 @@ export default function LeaveApproval(_props: PluginProps): React.ReactElement {
|
||||
return (
|
||||
<li
|
||||
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>
|
||||
<p className="text-body text-ink">{req.childName}</p>
|
||||
<p className="text-tiny text-ink-muted">
|
||||
<p className="text-body text-foreground">{req.childName}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{TYPE_LABELS[req.type] ?? req.type} · {req.startDate} ~{" "}
|
||||
{req.endDate}
|
||||
</p>
|
||||
</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}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-sm text-small text-ink-muted">{req.reason}</p>
|
||||
<p className="mt-xs text-tiny text-ink-muted">
|
||||
<p className="mt-sm text-sm text-muted-foreground">
|
||||
{req.reason}
|
||||
</p>
|
||||
<p className="mt-xs text-xs text-muted-foreground">
|
||||
提交于 {req.createdAt}
|
||||
</p>
|
||||
{isPending ? (
|
||||
@@ -153,14 +155,14 @@ export default function LeaveApproval(_props: PluginProps): React.ReactElement {
|
||||
}))
|
||||
}
|
||||
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
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
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>
|
||||
@@ -168,7 +170,7 @@ export default function LeaveApproval(_props: PluginProps): React.ReactElement {
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
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>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"use client";
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* child-selector(sidebar / side)
|
||||
@@ -35,11 +35,11 @@ export default function ChildSelector(_props: PluginProps): React.ReactElement {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="text-small text-ink-muted">孩子</p>
|
||||
<p className="text-sm text-muted-foreground">孩子</p>
|
||||
<select
|
||||
value={currentChildId}
|
||||
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="选择孩子"
|
||||
>
|
||||
<option value="">请选择孩子</option>
|
||||
@@ -50,7 +50,7 @@ export default function ChildSelector(_props: PluginProps): React.ReactElement {
|
||||
))}
|
||||
</select>
|
||||
{current ? (
|
||||
<p className="mt-xs text-tiny text-ink-muted">
|
||||
<p className="mt-xs text-xs text-muted-foreground">
|
||||
{current.grade} · {current.className}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"use client";
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* class-selector(sidebar / side)
|
||||
@@ -34,11 +34,11 @@ export default function ClassSelector(_props: PluginProps): React.ReactElement {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="text-small text-ink-muted">班级</p>
|
||||
<p className="text-sm text-muted-foreground">班级</p>
|
||||
<select
|
||||
value={currentClassId}
|
||||
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="选择班级"
|
||||
>
|
||||
<option value="">请选择班级</option>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"use client";
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* quick-actions(sidebar / side)
|
||||
@@ -44,14 +44,14 @@ export default function QuickActions(props: PluginProps): React.ReactElement {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="text-small text-ink-muted">快捷操作</p>
|
||||
<p className="text-sm text-muted-foreground">快捷操作</p>
|
||||
<ul className="mt-xs space-y-sm">
|
||||
{actions.map((item) => (
|
||||
<li key={item.path}>
|
||||
<button
|
||||
type="button"
|
||||
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}
|
||||
</button>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"use client";
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* term-switcher(sidebar / side)
|
||||
@@ -38,11 +38,11 @@ export default function TermSwitcher(_props: PluginProps): React.ReactElement {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="text-small text-ink-muted">学期</p>
|
||||
<p className="text-sm text-muted-foreground">学期</p>
|
||||
<select
|
||||
value={selectedTermId}
|
||||
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="选择学期"
|
||||
>
|
||||
<option value="">请选择学期</option>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"use client";
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* ai-tutor(student / main)
|
||||
@@ -70,12 +70,12 @@ export default function AiTutor(props: PluginProps): React.ReactElement {
|
||||
const canSend = !sending && input.trim().length > 0;
|
||||
|
||||
return (
|
||||
<section className="rounded-card border border-rule bg-surface p-md">
|
||||
<h3 className="text-heading-3 text-ink">AI 辅导</h3>
|
||||
<section className="rounded-xl border border bg-card p-4">
|
||||
<h3 className="text-heading-3 text-foreground">AI 辅导</h3>
|
||||
<div className="mt-sm flex">
|
||||
{/* 左侧会话列表 */}
|
||||
<div className="flex w-64 flex-col border-r border-rule pr-sm">
|
||||
<p className="text-small text-ink-muted">会话列表</p>
|
||||
<div className="flex w-64 flex-col border-r border pr-sm">
|
||||
<p className="text-sm text-muted-foreground">会话列表</p>
|
||||
<ul className="mt-xs space-y-sm">
|
||||
{sessions.map((session) => {
|
||||
const active = activeSessionId === session.id;
|
||||
@@ -84,20 +84,20 @@ export default function AiTutor(props: PluginProps): React.ReactElement {
|
||||
<button
|
||||
type="button"
|
||||
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
|
||||
className={`block text-small ${active ? "text-ink-onAccent" : "text-ink"}`}
|
||||
className={`block text-sm ${active ? "text-primary-foreground" : "text-foreground"}`}
|
||||
>
|
||||
{session.title}
|
||||
</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}
|
||||
</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}
|
||||
</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-1 space-y-sm">
|
||||
{messages.length === 0 ? (
|
||||
<p className="text-small text-ink-muted">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
请输入问题开始与 AI 辅导对话
|
||||
</p>
|
||||
) : (
|
||||
@@ -121,8 +121,8 @@ export default function AiTutor(props: PluginProps): React.ReactElement {
|
||||
key={msg.id}
|
||||
className={
|
||||
msg.role === "user"
|
||||
? "ml-sm rounded-button bg-accent px-sm py-xs text-small text-ink-onAccent"
|
||||
: "rounded-button bg-paper px-sm py-xs text-small text-ink"
|
||||
? "ml-sm rounded-md bg-primary px-sm py-xs text-sm text-primary-foreground"
|
||||
: "rounded-md bg-background px-sm py-xs text-sm text-foreground"
|
||||
}
|
||||
>
|
||||
{msg.content}
|
||||
@@ -130,7 +130,7 @@ export default function AiTutor(props: PluginProps): React.ReactElement {
|
||||
))
|
||||
)}
|
||||
{sending && (
|
||||
<p className="text-tiny text-ink-muted">AI 正在回复...</p>
|
||||
<p className="text-xs text-muted-foreground">AI 正在回复...</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -146,7 +146,7 @@ export default function AiTutor(props: PluginProps): React.ReactElement {
|
||||
}
|
||||
}}
|
||||
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
|
||||
type="button"
|
||||
@@ -154,8 +154,8 @@ export default function AiTutor(props: PluginProps): React.ReactElement {
|
||||
onClick={() => void handleSend()}
|
||||
className={
|
||||
canSend
|
||||
? "ml-sm rounded-button bg-accent px-sm py-xs text-small text-ink-onAccent"
|
||||
: "ml-sm rounded-button bg-subtle px-sm py-xs text-small text-ink-muted"
|
||||
? "ml-sm rounded-md bg-primary px-sm py-xs text-sm text-primary-foreground"
|
||||
: "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)
|
||||
@@ -19,9 +19,9 @@ import { PluginSkeleton } from "@/shell/PluginLoader";
|
||||
import type { PluginProps } from "@/lib/types";
|
||||
|
||||
const CATEGORY_BADGE: Record<CourseCategory, string> = {
|
||||
必修: "bg-accent text-ink-onAccent",
|
||||
选修: "bg-subtle text-ink",
|
||||
拓展: "bg-accent-subtle text-ink",
|
||||
必修: "bg-primary text-primary-foreground",
|
||||
选修: "bg-muted text-foreground",
|
||||
拓展: "bg-primary-subtle text-foreground",
|
||||
};
|
||||
|
||||
export default function ElectiveSelector(
|
||||
@@ -64,9 +64,9 @@ export default function ElectiveSelector(
|
||||
|
||||
if (!termId) {
|
||||
return (
|
||||
<section className="rounded-card border border-rule bg-surface p-md">
|
||||
<h3 className="text-heading-3 text-ink">选课</h3>
|
||||
<p className="text-small text-ink-muted">请先选择学期</p>
|
||||
<section className="rounded-xl border border bg-card p-4">
|
||||
<h3 className="text-heading-3 text-foreground">选课</h3>
|
||||
<p className="text-sm text-muted-foreground">请先选择学期</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -74,29 +74,31 @@ export default function ElectiveSelector(
|
||||
const courses = data ?? [];
|
||||
|
||||
return (
|
||||
<section className="rounded-card border border-rule bg-surface p-md">
|
||||
<h3 className="text-heading-3 text-ink">选课</h3>
|
||||
<section className="rounded-xl border border bg-card p-4">
|
||||
<h3 className="text-heading-3 text-foreground">选课</h3>
|
||||
{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) => {
|
||||
const enrolled = enrolledIds[course.id] === true;
|
||||
const full = course.enrolled >= course.capacity;
|
||||
return (
|
||||
<li
|
||||
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">
|
||||
<span className="text-body text-ink">{course.name}</span>
|
||||
<span className="text-body text-foreground">
|
||||
{course.name}
|
||||
</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}
|
||||
</span>
|
||||
</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.enrolled}/{course.capacity}
|
||||
@@ -113,8 +115,8 @@ export default function ElectiveSelector(
|
||||
onClick={() => void handleDrop(course.id)}
|
||||
className={
|
||||
dropping
|
||||
? "rounded-button bg-subtle px-sm py-xs text-tiny text-ink-muted"
|
||||
: "rounded-button bg-danger px-sm py-xs text-tiny text-ink-onAccent"
|
||||
? "rounded-md bg-muted px-sm py-xs text-xs text-muted-foreground"
|
||||
: "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)}
|
||||
className={
|
||||
enrolling || full
|
||||
? "rounded-button bg-subtle px-sm py-xs text-tiny text-ink-muted"
|
||||
: "rounded-button bg-accent px-sm py-xs text-tiny text-ink-onAccent"
|
||||
? "rounded-md bg-muted px-sm py-xs text-xs text-muted-foreground"
|
||||
: "rounded-md bg-primary px-sm py-xs text-xs text-primary-foreground"
|
||||
}
|
||||
>
|
||||
{full ? "已满" : "选课"}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"use client";
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* error-book(student / main)
|
||||
@@ -46,12 +46,12 @@ export default function ErrorBook(props: PluginProps): React.ReactElement {
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="rounded-card border border-rule bg-surface p-md">
|
||||
<h3 className="text-heading-3 text-ink">错题本</h3>
|
||||
<section className="rounded-xl border border bg-card p-4">
|
||||
<h3 className="text-heading-3 text-foreground">错题本</h3>
|
||||
<div className="mt-sm flex items-center">
|
||||
<label
|
||||
htmlFor="error-book-subject"
|
||||
className="text-small text-ink-muted"
|
||||
className="text-sm text-muted-foreground"
|
||||
>
|
||||
科目
|
||||
</label>
|
||||
@@ -59,7 +59,7 @@ export default function ErrorBook(props: PluginProps): React.ReactElement {
|
||||
id="error-book-subject"
|
||||
value={subjectFilter}
|
||||
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>
|
||||
{subjects.map((s) => (
|
||||
@@ -71,33 +71,35 @@ export default function ErrorBook(props: PluginProps): React.ReactElement {
|
||||
</div>
|
||||
|
||||
{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) => {
|
||||
const mastered = masteredIds[item.id] === true;
|
||||
return (
|
||||
<li
|
||||
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">
|
||||
<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}
|
||||
</span>
|
||||
<span className="ml-sm text-tiny text-ink-muted">
|
||||
<span className="ml-sm text-xs text-muted-foreground">
|
||||
错误 {item.errorCount} 次
|
||||
</span>
|
||||
<span className="ml-sm text-tiny text-ink-muted">
|
||||
<span className="ml-sm text-xs text-muted-foreground">
|
||||
最后错误:{item.lastErrorAt}
|
||||
</span>
|
||||
</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">
|
||||
<span className="text-small text-ink-muted">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
我的答案:{item.myAnswer}
|
||||
</span>
|
||||
<span className="text-small text-ink">
|
||||
<span className="text-sm text-foreground">
|
||||
正确答案:{item.correctAnswer}
|
||||
</span>
|
||||
</div>
|
||||
@@ -107,8 +109,8 @@ export default function ErrorBook(props: PluginProps): React.ReactElement {
|
||||
onClick={() => void handleMarkMastered(item.id)}
|
||||
className={
|
||||
mastered
|
||||
? "mt-xs rounded-button bg-subtle px-sm py-xs text-tiny text-ink-muted"
|
||||
: "mt-xs rounded-button bg-accent px-sm py-xs text-tiny text-ink-onAccent"
|
||||
? "mt-xs rounded-md bg-muted px-sm py-xs text-xs text-muted-foreground"
|
||||
: "mt-xs rounded-md bg-primary px-sm py-xs text-xs text-primary-foreground"
|
||||
}
|
||||
>
|
||||
{mastered ? "已掌握" : "标记已掌握"}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"use client";
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* learning-path(student / main)
|
||||
@@ -33,15 +33,15 @@ const STATUS_LABEL: Record<LearningNodeStatus, string> = {
|
||||
function statusClass(status: LearningNodeStatus): string {
|
||||
switch (status) {
|
||||
case "locked":
|
||||
return "bg-subtle text-ink-muted";
|
||||
return "bg-muted text-muted-foreground";
|
||||
case "available":
|
||||
return "border border-rule bg-surface text-ink";
|
||||
return "border border bg-card text-foreground";
|
||||
case "in_progress":
|
||||
return "bg-warning text-ink";
|
||||
return "bg-warning text-foreground";
|
||||
case "completed":
|
||||
return "bg-success text-ink";
|
||||
return "bg-success text-foreground";
|
||||
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) {
|
||||
return (
|
||||
<section className="rounded-card border border-rule bg-surface p-md">
|
||||
<h3 className="text-heading-3 text-ink">学习路径</h3>
|
||||
<p className="text-small text-ink-muted">请先选择科目</p>
|
||||
<section className="rounded-xl border border bg-card p-4">
|
||||
<h3 className="text-heading-3 text-foreground">学习路径</h3>
|
||||
<p className="text-sm text-muted-foreground">请先选择科目</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -81,32 +81,32 @@ export default function LearningPath(_props: PluginProps): React.ReactElement {
|
||||
const progress = path?.progress ?? 0;
|
||||
|
||||
return (
|
||||
<section className="rounded-card border border-rule bg-surface p-md">
|
||||
<h3 className="text-heading-3 text-ink">学习路径</h3>
|
||||
<section className="rounded-xl border border bg-card p-4">
|
||||
<h3 className="text-heading-3 text-foreground">学习路径</h3>
|
||||
|
||||
<div className="mt-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-small text-ink-muted">整体进度</span>
|
||||
<span className="text-small text-ink">{progress}%</span>
|
||||
<span className="text-sm text-muted-foreground">整体进度</span>
|
||||
<span className="text-sm text-foreground">{progress}%</span>
|
||||
</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
|
||||
className="h-xs rounded-button bg-accent"
|
||||
className="h-xs rounded-md bg-primary"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{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) => {
|
||||
const clickable = node.status !== "locked";
|
||||
return (
|
||||
<li key={node.id} className="flex items-start">
|
||||
<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,
|
||||
)}`}
|
||||
>
|
||||
@@ -114,9 +114,11 @@ export default function LearningPath(_props: PluginProps): React.ReactElement {
|
||||
</span>
|
||||
<div className="ml-sm flex-1">
|
||||
<div className="flex items-center">
|
||||
<span className="text-body text-ink">{node.title}</span>
|
||||
<span className="text-body text-foreground">
|
||||
{node.title}
|
||||
</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,
|
||||
)}`}
|
||||
>
|
||||
@@ -124,7 +126,7 @@ export default function LearningPath(_props: PluginProps): React.ReactElement {
|
||||
</span>
|
||||
</div>
|
||||
{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("、")}
|
||||
</p>
|
||||
)}
|
||||
@@ -132,7 +134,7 @@ export default function LearningPath(_props: PluginProps): React.ReactElement {
|
||||
<button
|
||||
type="button"
|
||||
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>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"use client";
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* lesson-plan-editor(teacher / main)
|
||||
@@ -43,9 +43,9 @@ export default function LessonPlanEditor(
|
||||
|
||||
if (!classId) {
|
||||
return (
|
||||
<section className="rounded-card border border-rule bg-surface p-md">
|
||||
<h3 className="text-heading-3 text-ink">备课画布</h3>
|
||||
<p className="text-small text-ink-muted">请先选择班级</p>
|
||||
<section className="rounded-xl border border bg-card p-4">
|
||||
<h3 className="text-heading-3 text-foreground">备课画布</h3>
|
||||
<p className="text-sm text-muted-foreground">请先选择班级</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -101,31 +101,31 @@ export default function LessonPlanEditor(
|
||||
};
|
||||
|
||||
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">
|
||||
<h3 className="text-heading-3 text-ink">备课画布</h3>
|
||||
<h3 className="text-heading-3 text-foreground">备课画布</h3>
|
||||
<button
|
||||
type="button"
|
||||
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>
|
||||
</div>
|
||||
<div className="mt-sm flex gap-md">
|
||||
<div className="mt-sm flex gap-4">
|
||||
<ul className="w-64 shrink-0 space-y-sm">
|
||||
{plans.length === 0 ? (
|
||||
<li className="text-small text-ink-muted">暂无备课记录</li>
|
||||
<li className="text-sm text-muted-foreground">暂无备课记录</li>
|
||||
) : (
|
||||
plans.map((p) => (
|
||||
<li key={p.id}>
|
||||
<button
|
||||
type="button"
|
||||
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
|
||||
? "bg-subtle text-ink"
|
||||
: "bg-surface text-ink"
|
||||
? "bg-muted text-foreground"
|
||||
: "bg-card text-foreground"
|
||||
}`}
|
||||
>
|
||||
{p.title || "未命名备课"}
|
||||
@@ -134,45 +134,45 @@ export default function LessonPlanEditor(
|
||||
))
|
||||
)}
|
||||
</ul>
|
||||
<div className="flex-1 space-y-md">
|
||||
<div className="flex-1 space-y-4">
|
||||
<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
|
||||
type="text"
|
||||
value={draft.title}
|
||||
onChange={(e) =>
|
||||
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="请输入备课标题"
|
||||
/>
|
||||
</label>
|
||||
<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
|
||||
value={draft.objectives}
|
||||
onChange={(e) =>
|
||||
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}
|
||||
placeholder="请输入教学目标"
|
||||
/>
|
||||
</label>
|
||||
<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
|
||||
value={draft.content}
|
||||
onChange={(e) =>
|
||||
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}
|
||||
placeholder="请输入教学内容"
|
||||
/>
|
||||
</label>
|
||||
<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">
|
||||
{draft.resources.map((r, i) => (
|
||||
<li key={i} className="flex gap-xs">
|
||||
@@ -180,13 +180,13 @@ export default function LessonPlanEditor(
|
||||
type="text"
|
||||
value={r}
|
||||
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="资源名称或链接"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
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>
|
||||
@@ -196,7 +196,7 @@ export default function LessonPlanEditor(
|
||||
<button
|
||||
type="button"
|
||||
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>
|
||||
@@ -205,7 +205,7 @@ export default function LessonPlanEditor(
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
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 ? "保存中" : "保存"}
|
||||
</button>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"use client";
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* question-bank(teacher / main)
|
||||
@@ -66,9 +66,9 @@ export default function QuestionBank(_props: PluginProps): React.ReactElement {
|
||||
|
||||
if (!bankId) {
|
||||
return (
|
||||
<section className="rounded-card border border-rule bg-surface p-md">
|
||||
<h3 className="text-heading-3 text-ink">题库管理</h3>
|
||||
<p className="text-small text-ink-muted">请先选择题库</p>
|
||||
<section className="rounded-xl border border bg-card p-4">
|
||||
<h3 className="text-heading-3 text-foreground">题库管理</h3>
|
||||
<p className="text-sm text-muted-foreground">请先选择题库</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -98,25 +98,25 @@ export default function QuestionBank(_props: PluginProps): React.ReactElement {
|
||||
};
|
||||
|
||||
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">
|
||||
<h3 className="text-heading-3 text-ink">题库管理</h3>
|
||||
<h3 className="text-heading-3 text-foreground">题库管理</h3>
|
||||
<button
|
||||
type="button"
|
||||
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 ? "收起新建" : "新建题目"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-sm flex gap-md">
|
||||
<div className="mt-sm flex gap-4">
|
||||
<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
|
||||
value={typeFilter}
|
||||
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>
|
||||
{QUESTION_TYPES.map((t) => (
|
||||
@@ -127,11 +127,11 @@ export default function QuestionBank(_props: PluginProps): React.ReactElement {
|
||||
</select>
|
||||
</label>
|
||||
<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
|
||||
value={difficultyFilter}
|
||||
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>
|
||||
{DIFFICULTIES.map((d) => (
|
||||
@@ -144,16 +144,16 @@ export default function QuestionBank(_props: PluginProps): React.ReactElement {
|
||||
</div>
|
||||
|
||||
{showForm ? (
|
||||
<div className="mt-sm space-y-md rounded-card bg-subtle p-md">
|
||||
<div className="flex gap-md">
|
||||
<div className="mt-sm space-y-4 rounded-xl bg-muted p-4">
|
||||
<div className="flex gap-4">
|
||||
<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
|
||||
value={newQuestion.type}
|
||||
onChange={(e) =>
|
||||
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) => (
|
||||
<option key={t} value={t}>
|
||||
@@ -163,13 +163,13 @@ export default function QuestionBank(_props: PluginProps): React.ReactElement {
|
||||
</select>
|
||||
</label>
|
||||
<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
|
||||
value={newQuestion.difficulty}
|
||||
onChange={(e) =>
|
||||
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) => (
|
||||
<option key={d} value={d}>
|
||||
@@ -180,25 +180,25 @@ export default function QuestionBank(_props: PluginProps): React.ReactElement {
|
||||
</label>
|
||||
</div>
|
||||
<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
|
||||
value={newQuestion.content}
|
||||
onChange={(e) =>
|
||||
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}
|
||||
placeholder="请输入题干"
|
||||
/>
|
||||
</label>
|
||||
<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
|
||||
value={newQuestion.answer}
|
||||
onChange={(e) =>
|
||||
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}
|
||||
placeholder="请输入答案"
|
||||
/>
|
||||
@@ -207,46 +207,46 @@ export default function QuestionBank(_props: PluginProps): React.ReactElement {
|
||||
type="button"
|
||||
onClick={handleAdd}
|
||||
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>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<ul className="mt-sm space-y-md">
|
||||
<ul className="mt-sm space-y-4">
|
||||
{questions.length === 0 ? (
|
||||
<li className="text-small text-ink-muted">暂无题目</li>
|
||||
<li className="text-sm text-muted-foreground">暂无题目</li>
|
||||
) : (
|
||||
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">
|
||||
<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}
|
||||
</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}
|
||||
</span>
|
||||
{q.tags.map((tag) => (
|
||||
<span
|
||||
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}
|
||||
</span>
|
||||
))}
|
||||
</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 ? (
|
||||
<ul className="mt-xs space-y-xs">
|
||||
{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}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
<p className="mt-xs text-small text-ink-muted">
|
||||
<p className="mt-xs text-sm text-muted-foreground">
|
||||
答案:{q.answer}
|
||||
</p>
|
||||
</li>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"use client";
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* scheduling-rules(teacher / main)
|
||||
@@ -44,9 +44,9 @@ export default function SchedulingRules(
|
||||
|
||||
if (!classId) {
|
||||
return (
|
||||
<section className="rounded-card border border-rule bg-surface p-md">
|
||||
<h3 className="text-heading-3 text-ink">排课规则</h3>
|
||||
<p className="text-small text-ink-muted">请先选择班级</p>
|
||||
<section className="rounded-xl border border bg-card p-4">
|
||||
<h3 className="text-heading-3 text-foreground">排课规则</h3>
|
||||
<p className="text-sm text-muted-foreground">请先选择班级</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -85,14 +85,14 @@ export default function SchedulingRules(
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="rounded-card border border-rule bg-surface p-md">
|
||||
<h3 className="text-heading-3 text-ink">排课规则</h3>
|
||||
<section className="rounded-xl border border bg-card p-4">
|
||||
<h3 className="text-heading-3 text-foreground">排课规则</h3>
|
||||
{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>
|
||||
<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>
|
||||
@@ -106,7 +106,7 @@ export default function SchedulingRules(
|
||||
const isEditing = editingId === rule.id;
|
||||
if (isEditing && draft) {
|
||||
return (
|
||||
<tr key={rule.id} className="border-b border-rule">
|
||||
<tr key={rule.id} className="border-b border">
|
||||
<td className="py-xs">
|
||||
<select
|
||||
value={draft.dayOfWeek}
|
||||
@@ -115,7 +115,7 @@ export default function SchedulingRules(
|
||||
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) => (
|
||||
<option key={name} value={i + 1}>
|
||||
@@ -133,7 +133,7 @@ export default function SchedulingRules(
|
||||
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 className="py-xs">
|
||||
@@ -145,7 +145,7 @@ export default function SchedulingRules(
|
||||
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 className="py-xs">
|
||||
@@ -157,7 +157,7 @@ export default function SchedulingRules(
|
||||
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 className="py-xs">
|
||||
@@ -169,7 +169,7 @@ export default function SchedulingRules(
|
||||
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 className="py-xs">
|
||||
@@ -178,14 +178,14 @@ export default function SchedulingRules(
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
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
|
||||
type="button"
|
||||
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>
|
||||
@@ -195,17 +195,19 @@ export default function SchedulingRules(
|
||||
);
|
||||
}
|
||||
return (
|
||||
<tr key={rule.id} className="border-b border-rule">
|
||||
<td className="py-xs text-ink">{dayName(rule.dayOfWeek)}</td>
|
||||
<td className="py-xs text-ink">{rule.periods}</td>
|
||||
<td className="py-xs text-ink">{rule.subject}</td>
|
||||
<td className="py-xs text-ink">{rule.teacherId}</td>
|
||||
<td className="py-xs text-ink">{rule.room}</td>
|
||||
<tr key={rule.id} className="border-b border">
|
||||
<td className="py-xs text-foreground">
|
||||
{dayName(rule.dayOfWeek)}
|
||||
</td>
|
||||
<td className="py-xs text-foreground">{rule.periods}</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">
|
||||
<button
|
||||
type="button"
|
||||
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>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"use client";
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* textbook-manager(teacher / main)
|
||||
@@ -41,43 +41,43 @@ export default function TextbookManager(
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="rounded-card border border-rule bg-surface p-md">
|
||||
<h3 className="text-heading-3 text-ink">教材管理</h3>
|
||||
<section className="rounded-xl border border bg-card p-4">
|
||||
<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">
|
||||
<span className="text-small text-ink-muted">科目 ID</span>
|
||||
<span className="text-sm text-muted-foreground">科目 ID</span>
|
||||
<input
|
||||
type="text"
|
||||
value={subjectInput}
|
||||
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"
|
||||
/>
|
||||
</label>
|
||||
<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
|
||||
type="text"
|
||||
value={gradeInput}
|
||||
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="可选,如:三年级"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
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>
|
||||
</div>
|
||||
|
||||
<div className="mt-md flex gap-md">
|
||||
<div className="mt-md flex gap-4">
|
||||
<ul className="flex-1 space-y-sm">
|
||||
{textbooks.length === 0 ? (
|
||||
<li className="text-small text-ink-muted">暂无教材数据</li>
|
||||
<li className="text-sm text-muted-foreground">暂无教材数据</li>
|
||||
) : (
|
||||
textbooks.map((t) => (
|
||||
<li key={t.id}>
|
||||
@@ -86,15 +86,17 @@ export default function TextbookManager(
|
||||
onClick={() => setSelectedId(t.id)}
|
||||
className={
|
||||
selectedId === t.id
|
||||
? "w-full rounded-button border border-rule bg-subtle p-sm text-left"
|
||||
: "w-full rounded-button border border-rule bg-surface p-sm text-left"
|
||||
? "w-full rounded-md border border bg-muted p-2 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-small text-ink-muted">
|
||||
<p className="text-body text-foreground">{t.title}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t.author} · {t.publisher}
|
||||
</p>
|
||||
<p className="text-tiny text-ink-muted">ISBN: {t.isbn}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
ISBN: {t.isbn}
|
||||
</p>
|
||||
</button>
|
||||
</li>
|
||||
))
|
||||
@@ -103,22 +105,22 @@ export default function TextbookManager(
|
||||
|
||||
<div className="flex-1">
|
||||
{selected ? (
|
||||
<div className="rounded-card border border-rule bg-surface p-md">
|
||||
<h4 className="text-body text-ink">{selected.title}</h4>
|
||||
<p className="mt-xs text-small text-ink-muted">
|
||||
<div className="rounded-xl border border bg-card p-4">
|
||||
<h4 className="text-body text-foreground">{selected.title}</h4>
|
||||
<p className="mt-xs text-sm text-muted-foreground">
|
||||
{selected.author} · {selected.publisher}
|
||||
</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">
|
||||
{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}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</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>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"use client";
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* global-search(topbar / top)
|
||||
@@ -49,22 +49,22 @@ export default function GlobalSearch(_props: PluginProps): React.ReactElement {
|
||||
onFocus={() => setOpen(true)}
|
||||
placeholder="搜索学生、班级、考试…"
|
||||
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 ? (
|
||||
<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 ? (
|
||||
<li className="text-small text-ink-muted">暂无结果</li>
|
||||
<li className="text-sm text-muted-foreground">暂无结果</li>
|
||||
) : (
|
||||
results.map((item) => (
|
||||
<li key={`${item.type}-${item.id}`}>
|
||||
<button
|
||||
type="button"
|
||||
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-tiny text-ink-muted">
|
||||
<span className="text-sm text-foreground">{item.title}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{item.subtitle}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"use client";
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* locale-switcher(topbar / top)
|
||||
@@ -38,12 +38,12 @@ export default function LocaleSwitcher(
|
||||
aria-label="切换语言"
|
||||
aria-expanded={open}
|
||||
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}
|
||||
</button>
|
||||
{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) => (
|
||||
<li key={item.value}>
|
||||
<button
|
||||
@@ -51,8 +51,8 @@ export default function LocaleSwitcher(
|
||||
onClick={() => handleSelect(item.value)}
|
||||
className={
|
||||
item.value === locale
|
||||
? "w-full rounded-button bg-accent px-sm py-xs text-left text-small text-ink-onAccent"
|
||||
: "w-full rounded-button px-sm py-xs text-left text-small text-ink"
|
||||
? "w-full rounded-md bg-primary px-sm py-xs text-left text-sm text-primary-foreground"
|
||||
: "w-full rounded-md px-sm py-xs text-left text-sm text-foreground"
|
||||
}
|
||||
>
|
||||
{item.label}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"use client";
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* notification-bell(topbar / top)
|
||||
@@ -30,24 +30,24 @@ export default function NotificationBell(
|
||||
aria-label="通知"
|
||||
aria-expanded={open}
|
||||
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>
|
||||
{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}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
{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 ? (
|
||||
<li className="text-small text-ink-muted">暂无通知</li>
|
||||
<li className="text-sm text-muted-foreground">暂无通知</li>
|
||||
) : (
|
||||
items.map((n) => (
|
||||
<li
|
||||
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}
|
||||
</li>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"use client";
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* user-menu(topbar / top)
|
||||
@@ -30,23 +30,23 @@ export default function UserMenu(_props: PluginProps): React.ReactElement {
|
||||
aria-label="用户菜单"
|
||||
aria-expanded={open}
|
||||
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
|
||||
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()}
|
||||
</span>
|
||||
<span className="text-small">{displayName}</span>
|
||||
<span className="text-sm">{displayName}</span>
|
||||
</button>
|
||||
{open ? (
|
||||
<ul className="absolute right-0 z-50 mt-sm w-56 rounded-card border border-rule bg-surface p-sm shadow-md">
|
||||
<li className="border-b border-rule py-xs">
|
||||
<p className="text-small text-ink">{displayName}</p>
|
||||
<p className="text-tiny text-ink-muted">{displayEmail}</p>
|
||||
<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 py-xs">
|
||||
<p className="text-sm text-foreground">{displayName}</p>
|
||||
<p className="text-xs text-muted-foreground">{displayEmail}</p>
|
||||
</li>
|
||||
<li className="py-xs text-small text-ink-muted">
|
||||
<li className="py-xs text-sm text-muted-foreground">
|
||||
角色:{displayRole}
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"use client";
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* announcements-widget(universal / main)
|
||||
@@ -27,20 +27,20 @@ export default function AnnouncementsWidget(
|
||||
const rows = data ?? [];
|
||||
|
||||
return (
|
||||
<section className="rounded-card border border-rule bg-surface p-md">
|
||||
<h3 className="text-heading-3 text-ink">公告</h3>
|
||||
<section className="rounded-xl border border bg-card p-4">
|
||||
<h3 className="text-heading-3 text-foreground">公告</h3>
|
||||
{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">
|
||||
{rows.map((row) => (
|
||||
<li
|
||||
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="mt-xs text-tiny text-ink-muted">{row.body}</p>
|
||||
<p className="mt-xs text-tiny text-ink-muted">
|
||||
<p className="text-foreground">{row.title}</p>
|
||||
<p className="mt-xs text-xs text-muted-foreground">{row.body}</p>
|
||||
<p className="mt-xs text-xs text-muted-foreground">
|
||||
{row.author} · {row.publishedAt}
|
||||
</p>
|
||||
</li>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"use client";
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* attendance-widget(universal / main)
|
||||
@@ -30,9 +30,9 @@ export default function AttendanceWidget(
|
||||
|
||||
if (!classId || !termId) {
|
||||
return (
|
||||
<section className="rounded-card border border-rule bg-surface p-md">
|
||||
<h3 className="text-heading-3 text-ink">考勤</h3>
|
||||
<p className="text-small text-ink-muted">请先选择班级与学期</p>
|
||||
<section className="rounded-xl border border bg-card p-4">
|
||||
<h3 className="text-heading-3 text-foreground">考勤</h3>
|
||||
<p className="text-sm text-muted-foreground">请先选择班级与学期</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -41,9 +41,9 @@ export default function AttendanceWidget(
|
||||
|
||||
if (!stats) {
|
||||
return (
|
||||
<section className="rounded-card border border-rule bg-surface p-md">
|
||||
<h3 className="text-heading-3 text-ink">考勤</h3>
|
||||
<p className="text-small text-ink-muted">暂无考勤数据</p>
|
||||
<section className="rounded-xl border border bg-card p-4">
|
||||
<h3 className="text-heading-3 text-foreground">考勤</h3>
|
||||
<p className="text-sm text-muted-foreground">暂无考勤数据</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -56,16 +56,16 @@ export default function AttendanceWidget(
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="rounded-card border border-rule bg-surface p-md">
|
||||
<h3 className="text-heading-3 text-ink">考勤</h3>
|
||||
<div className="mt-sm flex gap-md">
|
||||
<section className="rounded-xl border border bg-card p-4">
|
||||
<h3 className="text-heading-3 text-foreground">考勤</h3>
|
||||
<div className="mt-sm flex gap-4">
|
||||
{items.map((item) => (
|
||||
<div
|
||||
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="mt-xs text-heading-3 text-ink">{item.value}</p>
|
||||
<p className="text-xs text-muted-foreground">{item.label}</p>
|
||||
<p className="mt-xs text-heading-3 text-foreground">{item.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"use client";
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* exams-widget(universal / main)
|
||||
@@ -38,9 +38,9 @@ export default function ExamsWidget(props: PluginProps): React.ReactElement {
|
||||
|
||||
if (!classId) {
|
||||
return (
|
||||
<section className="rounded-card border border-rule bg-surface p-md">
|
||||
<h3 className="text-heading-3 text-ink">考试</h3>
|
||||
<p className="text-small text-ink-muted">请先选择班级</p>
|
||||
<section className="rounded-xl border border bg-card p-4">
|
||||
<h3 className="text-heading-3 text-foreground">考试</h3>
|
||||
<p className="text-sm text-muted-foreground">请先选择班级</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -48,17 +48,17 @@ export default function ExamsWidget(props: PluginProps): React.ReactElement {
|
||||
const rows = data ?? [];
|
||||
|
||||
return (
|
||||
<section className="rounded-card border border-rule bg-surface p-md">
|
||||
<h3 className="text-heading-3 text-ink">考试</h3>
|
||||
<section className="rounded-xl border border bg-card p-4">
|
||||
<h3 className="text-heading-3 text-foreground">考试</h3>
|
||||
{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">
|
||||
{rows.map((row) => {
|
||||
const isActive = row.id === currentExamId;
|
||||
const itemClass = isActive
|
||||
? "w-full rounded-button border border-rule bg-subtle px-sm py-xs text-left text-small"
|
||||
: "w-full rounded-button border border-rule bg-surface 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-md border border bg-card px-sm py-xs text-left text-sm";
|
||||
return (
|
||||
<li key={row.id}>
|
||||
<button
|
||||
@@ -68,10 +68,10 @@ export default function ExamsWidget(props: PluginProps): React.ReactElement {
|
||||
aria-pressed={isActive}
|
||||
>
|
||||
<div className="flex">
|
||||
<span className="flex-1 text-ink">{row.name}</span>
|
||||
<span className="text-ink-muted">{row.subject}</span>
|
||||
<span className="flex-1 text-foreground">{row.name}</span>
|
||||
<span className="text-muted-foreground">{row.subject}</span>
|
||||
</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>满分 {row.maxScore}</span>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"use client";
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* grades-widget(universal / main)
|
||||
@@ -29,9 +29,9 @@ export default function GradesWidget(props: PluginProps): React.ReactElement {
|
||||
|
||||
if (!classId) {
|
||||
return (
|
||||
<section className="rounded-card border border-rule bg-surface p-md">
|
||||
<h3 className="text-heading-3 text-ink">成绩</h3>
|
||||
<p className="text-small text-ink-muted">请先选择班级</p>
|
||||
<section className="rounded-xl border border bg-card p-4">
|
||||
<h3 className="text-heading-3 text-foreground">成绩</h3>
|
||||
<p className="text-sm text-muted-foreground">请先选择班级</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -39,23 +39,25 @@ export default function GradesWidget(props: PluginProps): React.ReactElement {
|
||||
const rows = data ?? [];
|
||||
|
||||
return (
|
||||
<section className="rounded-card border border-rule bg-surface p-md">
|
||||
<h3 className="text-heading-3 text-ink">成绩</h3>
|
||||
<section className="rounded-xl border border bg-card p-4">
|
||||
<h3 className="text-heading-3 text-foreground">成绩</h3>
|
||||
{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>
|
||||
<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-right">分数</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.slice(0, limit).map((row) => (
|
||||
<tr key={row.studentId} className="border-b border-rule">
|
||||
<td className="py-xs text-ink">{row.studentId}</td>
|
||||
<td className="py-xs text-right text-ink">{row.score}</td>
|
||||
<tr key={row.studentId} className="border-b border">
|
||||
<td className="py-xs text-foreground">{row.studentId}</td>
|
||||
<td className="py-xs text-right text-foreground">
|
||||
{row.score}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"use client";
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* homework-widget(universal / main)
|
||||
@@ -24,20 +24,20 @@ function StatusBadge({ status }: { status: string }): React.ReactElement {
|
||||
const label = STATUS_LABEL[status] ?? status;
|
||||
if (status === "graded") {
|
||||
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}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (status === "submitted") {
|
||||
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}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
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}
|
||||
</span>
|
||||
);
|
||||
@@ -59,9 +59,9 @@ export default function HomeworkWidget(props: PluginProps): React.ReactElement {
|
||||
|
||||
if (!classId) {
|
||||
return (
|
||||
<section className="rounded-card border border-rule bg-surface p-md">
|
||||
<h3 className="text-heading-3 text-ink">作业</h3>
|
||||
<p className="text-small text-ink-muted">请先选择班级</p>
|
||||
<section className="rounded-xl border border bg-card p-4">
|
||||
<h3 className="text-heading-3 text-foreground">作业</h3>
|
||||
<p className="text-sm text-muted-foreground">请先选择班级</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -69,14 +69,14 @@ export default function HomeworkWidget(props: PluginProps): React.ReactElement {
|
||||
const rows = data ?? [];
|
||||
|
||||
return (
|
||||
<section className="rounded-card border border-rule bg-surface p-md">
|
||||
<h3 className="text-heading-3 text-ink">作业</h3>
|
||||
<section className="rounded-xl border border bg-card p-4">
|
||||
<h3 className="text-heading-3 text-foreground">作业</h3>
|
||||
{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>
|
||||
<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>
|
||||
@@ -84,9 +84,9 @@ export default function HomeworkWidget(props: PluginProps): React.ReactElement {
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr key={row.id} className="border-b border-rule">
|
||||
<td className="py-xs text-ink">{row.title}</td>
|
||||
<td className="py-xs text-ink">{row.dueDate}</td>
|
||||
<tr key={row.id} className="border-b border">
|
||||
<td className="py-xs text-foreground">{row.title}</td>
|
||||
<td className="py-xs text-foreground">{row.dueDate}</td>
|
||||
<td className="py-xs">
|
||||
<StatusBadge status={row.status} />
|
||||
</td>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"use client";
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* notifications-widget(universal / main)
|
||||
@@ -23,20 +23,20 @@ function TypeBadge({ type }: { type: string }): React.ReactElement {
|
||||
const label = TYPE_LABEL[type] ?? type;
|
||||
if (type === "urgent") {
|
||||
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}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (type === "warning") {
|
||||
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}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
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}
|
||||
</span>
|
||||
);
|
||||
@@ -58,26 +58,28 @@ export default function NotificationsWidget(
|
||||
const total = data?.total ?? 0;
|
||||
|
||||
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">
|
||||
<h3 className="flex-1 text-heading-3 text-ink">通知</h3>
|
||||
<span className="text-small text-ink-muted">共 {total} 条</span>
|
||||
<h3 className="flex-1 text-heading-3 text-foreground">通知</h3>
|
||||
<span className="text-sm text-muted-foreground">共 {total} 条</span>
|
||||
</div>
|
||||
{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">
|
||||
{items.map((item) => (
|
||||
<li
|
||||
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>
|
||||
<TypeBadge type={item.type} />
|
||||
</div>
|
||||
<p className="mt-xs text-tiny text-ink-muted">{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.body}</p>
|
||||
<p className="mt-xs text-xs text-muted-foreground">
|
||||
{item.createdAt}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"use client";
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* schedule-widget(universal / main)
|
||||
@@ -31,9 +31,9 @@ export default function ScheduleWidget(
|
||||
|
||||
if (!classId) {
|
||||
return (
|
||||
<section className="rounded-card border border-rule bg-surface p-md">
|
||||
<h3 className="text-heading-3 text-ink">课表</h3>
|
||||
<p className="text-small text-ink-muted">请先选择班级</p>
|
||||
<section className="rounded-xl border border bg-card p-4">
|
||||
<h3 className="text-heading-3 text-foreground">课表</h3>
|
||||
<p className="text-sm text-muted-foreground">请先选择班级</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -43,22 +43,19 @@ export default function ScheduleWidget(
|
||||
);
|
||||
|
||||
return (
|
||||
<section className="rounded-card border border-rule bg-surface p-md">
|
||||
<h3 className="text-heading-3 text-ink">今日课表</h3>
|
||||
<section className="rounded-xl border border bg-card p-4">
|
||||
<h3 className="text-heading-3 text-foreground">今日课表</h3>
|
||||
{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">
|
||||
{rows.map((row) => (
|
||||
<li
|
||||
key={row.id}
|
||||
className="flex border-b border-rule py-xs text-small"
|
||||
>
|
||||
<span className="flex-1 text-ink">{row.subject}</span>
|
||||
<span className="flex-1 text-ink-muted">
|
||||
<li key={row.id} className="flex border-b border py-xs text-sm">
|
||||
<span className="flex-1 text-foreground">{row.subject}</span>
|
||||
<span className="flex-1 text-muted-foreground">
|
||||
{row.startTime} - {row.endTime}
|
||||
</span>
|
||||
<span className="flex-1 text-right text-ink-muted">
|
||||
<span className="flex-1 text-right text-muted-foreground">
|
||||
{row.teacherName}
|
||||
</span>
|
||||
</li>
|
||||
|
||||
Reference in New Issue
Block a user