feat(portal-shell): extract domain API layer and migrate 31 widgets

Task 4-10 of portal-shell data abstraction plan (M1-M2).

Add 7 domain API modules under src/lib/api/ (parent/admin/teacher/
student/universal/sidebar/topbar), each exposing semantic hooks that
wrap useWidgetQuery/useWidgetMutation and return flattened domain
models. Widget code now imports from @/lib/api instead of inlining
gql literals.

- 31 widgets migrated (gql literal count in widgets: 0)
- 7 test files (85 cases, all passing)
- topbar.useNotifications renamed to useNotificationBell to avoid
  barrel export collision with universal.useNotifications
- typecheck + lint (0 errors) + test (85/85) verified
This commit is contained in:
SpecialX
2026-07-17 13:07:24 +08:00
parent f623dcf4a7
commit 2910a90271
73 changed files with 8206 additions and 87 deletions

View File

@@ -0,0 +1,196 @@
import { describe, it, expect } from "vitest";
import { renderHook, waitFor } from "@testing-library/react";
import { MockedProvider, type MockedResponse } from "@apollo/client/testing";
import type { ReactNode } from "react";
import { useAuditLogs, useCreateInvitationCode } from "../admin";
import {
GET_AUDIT_LOGS_DOC,
CREATE_INVITATION_CODE_DOC,
} from "../operations/admin.graphql";
import { ApiError } from "../errors";
/**
* Admin domain API 单元测试spec §2.2、§9.9
*
* 覆盖:
* - useAuditLogs归一化返回 PaginatedResult<AuditLog>items + total、loading 期间 data 为 undefined
* - useCreateInvitationCode成功返回 CreatedInvitationCode、失败抛 ApiError
*/
function createWrapper(mocks: MockedResponse[]) {
return function Wrapper({ children }: { children: ReactNode }): ReactNode {
return <MockedProvider mocks={mocks}>{children}</MockedProvider>;
};
}
describe("useAuditLogs", () => {
it("返回归一化的 AuditLog[]items + total", async () => {
const logs = [
{
id: "log-1",
userId: "u-1",
userName: "Alice",
action: "login",
resource: "auth",
resourceId: "",
ip: "127.0.0.1",
timestamp: "2026-01-01T00:00:00Z",
details: "login success",
},
{
id: "log-2",
userId: "u-2",
userName: "Bob",
action: "update",
resource: "user",
resourceId: "u-1",
ip: "10.0.0.1",
timestamp: "2026-01-02T00:00:00Z",
details: "update user role",
},
];
const mocks: MockedResponse[] = [
{
request: {
query: GET_AUDIT_LOGS_DOC,
variables: {
filter: { userId: null, action: null, resource: null },
limit: 20,
offset: 0,
},
},
result: {
data: {
auditLogs: {
items: logs,
total: 2,
},
},
},
},
];
const { result } = renderHook(
() =>
useAuditLogs(
{ userId: null, action: null, resource: null },
{ limit: 20, offset: 0 },
),
{ wrapper: createWrapper(mocks) },
);
await waitFor(() => {
expect(result.current.data).toBeDefined();
});
expect(result.current.data?.items).toEqual(logs);
expect(result.current.data?.total).toBe(2);
expect(result.current.loading).toBe(false);
});
it("loading 期间 data 为 undefined", async () => {
const mocks: MockedResponse[] = [
{
request: {
query: GET_AUDIT_LOGS_DOC,
variables: {
filter: { userId: null, action: null, resource: null },
limit: 10,
offset: 0,
},
},
result: {
data: {
auditLogs: {
items: [],
total: 0,
},
},
},
},
];
const { result } = renderHook(
() =>
useAuditLogs(
{ userId: null, action: null, resource: null },
{ limit: 10, offset: 0 },
),
{ wrapper: createWrapper(mocks) },
);
// 初始 loading 期间 data 为 undefined便于 widget 显示骨架屏
expect(result.current.data).toBeUndefined();
});
});
describe("useCreateInvitationCode", () => {
it("成功返回 CreatedInvitationCode", async () => {
const input = { role: "teacher", maxUses: 5, ttlHours: 72 };
const created = {
id: "code-1",
code: "ABC123XYZ",
role: "teacher",
maxUses: 5,
expiresAt: "2026-01-04T00:00:00Z",
};
const mocks: MockedResponse[] = [
{
request: {
query: CREATE_INVITATION_CODE_DOC,
variables: { input },
},
result: {
data: {
createInvitationCode: created,
},
},
},
];
const { result } = renderHook(() => useCreateInvitationCode(), {
wrapper: createWrapper(mocks),
});
const res = await result.current.run(input);
expect(res).toEqual(created);
expect(res.code).toBe("ABC123XYZ");
expect(res.role).toBe("teacher");
});
it("失败时抛 ApiErrorcreateInvitationCode 返回 null", async () => {
const input = { role: "teacher", maxUses: 5, ttlHours: 72 };
const mocks: MockedResponse[] = [
{
request: {
query: CREATE_INVITATION_CODE_DOC,
variables: { input },
},
result: {
data: {
createInvitationCode: null,
},
},
},
];
const { result } = renderHook(() => useCreateInvitationCode(), {
wrapper: createWrapper(mocks),
});
let caughtError: unknown;
try {
await result.current.run(input);
} catch (e) {
caughtError = e;
}
expect(caughtError).toBeInstanceOf(ApiError);
expect((caughtError as ApiError).code).toBe("INTERNAL_ERROR");
expect((caughtError as ApiError).message).toContain(
"Failed to create invitation code",
);
});
});

View File

@@ -0,0 +1,307 @@
import { describe, it, expect } from "vitest";
import { renderHook, waitFor } from "@testing-library/react";
import { MockedProvider, type MockedResponse } from "@apollo/client/testing";
import type { ReactNode } from "react";
import {
useApproveLeave,
useLeaveRequests,
useParentChildren,
useRejectLeave,
type ChildSummary,
type LeaveRequest,
} from "../parent";
import {
GET_MY_CHILDREN_OVERVIEW_DOC,
GET_LEAVE_REQUESTS_DOC,
APPROVE_LEAVE_DOC,
REJECT_LEAVE_DOC,
} from "../operations/parent.graphql";
import { ApiError } from "../errors";
/**
* parent domain API 测试v2.1 M2
*
* 覆盖:
* - 查询函数返回归一化的扁平数据(从 myChildren / leaveRequests 字段提取)
* - 查询函数初始 loading 状态保留 data === undefinedwidget skeleton 触发条件)
* - 变更函数成功时不抛错、失败时抛 ApiError
*
* 关联spec §2.3、§9.9
*/
function makeWrapper(mocks: MockedResponse[]) {
return function Wrapper({ children }: { children: ReactNode }): ReactNode {
return <MockedProvider mocks={mocks}>{children}</MockedProvider>;
};
}
describe("useParentChildren", () => {
it("返回归一化的 ChildSummary[](从 myChildren 字段提取)", async () => {
const children: ChildSummary[] = [
{
id: "c-1",
name: "Tom",
grade: "三年级",
className: "1班",
avatar: "https://example.com/a.png",
recentGrades: [
{ subject: "数学", score: 95 },
{ subject: "语文", score: 88 },
],
attendance: { present: 18, total: 20 },
homeworkCompletion: { completed: 9, total: 10 },
},
{
id: "c-2",
name: "Lily",
grade: "五年级",
className: "3班",
avatar: "",
recentGrades: [],
attendance: { present: 20, total: 20 },
homeworkCompletion: { completed: 10, total: 10 },
},
];
const mocks: MockedResponse[] = [
{
request: {
query: GET_MY_CHILDREN_OVERVIEW_DOC,
variables: {},
},
result: { data: { myChildren: children } },
},
];
const wrapper = makeWrapper(mocks);
const { result } = renderHook(() => useParentChildren(), { wrapper });
await waitFor(() => expect(result.current.data).toBeDefined());
expect(result.current.data).toEqual(children);
expect(result.current.data?.length).toBe(2);
expect(result.current.data?.[0]?.name).toBe("Tom");
expect(result.current.data?.[1]?.recentGrades).toEqual([]);
});
it("返回空数组myChildren: []", async () => {
const mocks: MockedResponse[] = [
{
request: {
query: GET_MY_CHILDREN_OVERVIEW_DOC,
variables: {},
},
result: { data: { myChildren: [] } },
},
];
const wrapper = makeWrapper(mocks);
const { result } = renderHook(() => useParentChildren(), { wrapper });
await waitFor(() => expect(result.current.data).toBeDefined());
expect(result.current.data).toEqual([]);
expect(result.current.data?.length).toBe(0);
});
it("未加载时 data 为 undefined保留 skeleton 触发条件)", () => {
const mocks: MockedResponse[] = [
{
request: {
query: GET_MY_CHILDREN_OVERVIEW_DOC,
variables: {},
},
result: { data: { myChildren: [] } },
},
];
const wrapper = makeWrapper(mocks);
const { result } = renderHook(() => useParentChildren(), { wrapper });
expect(result.current.data).toBeUndefined();
expect(result.current.loading).toBe(true);
});
});
describe("useLeaveRequests", () => {
it("返回归一化的 LeaveRequest[],并按 childId/status 过滤", async () => {
const requests: LeaveRequest[] = [
{
id: "lr-1",
childName: "Tom",
type: "sick",
startDate: "2026-07-10",
endDate: "2026-07-11",
reason: "感冒",
status: "pending",
createdAt: "2026-07-09",
},
{
id: "lr-2",
childName: "Tom",
type: "personal",
startDate: "2026-07-15",
endDate: "2026-07-15",
reason: "家事",
status: "approved",
createdAt: "2026-07-14",
},
];
const mocks: MockedResponse[] = [
{
request: {
query: GET_LEAVE_REQUESTS_DOC,
variables: { childId: "c-1", status: null },
},
result: { data: { leaveRequests: requests } },
},
];
const wrapper = makeWrapper(mocks);
const { result } = renderHook(() => useLeaveRequests("c-1", null), {
wrapper,
});
await waitFor(() => expect(result.current.data).toBeDefined());
expect(result.current.data).toEqual(requests);
expect(result.current.data?.[0]?.childName).toBe("Tom");
expect(result.current.data?.[1]?.status).toBe("approved");
});
it("childId/status 均为 null 时不参与过滤", async () => {
const mocks: MockedResponse[] = [
{
request: {
query: GET_LEAVE_REQUESTS_DOC,
variables: { childId: null, status: null },
},
result: { data: { leaveRequests: [] } },
},
];
const wrapper = makeWrapper(mocks);
const { result } = renderHook(() => useLeaveRequests(null, null), {
wrapper,
});
await waitFor(() => expect(result.current.data).toBeDefined());
expect(result.current.data).toEqual([]);
});
it("按 status=pending 过滤", async () => {
const requests: LeaveRequest[] = [
{
id: "lr-3",
childName: "Lily",
type: "family",
startDate: "2026-07-20",
endDate: "2026-07-22",
reason: "探亲",
status: "pending",
createdAt: "2026-07-19",
},
];
const mocks: MockedResponse[] = [
{
request: {
query: GET_LEAVE_REQUESTS_DOC,
variables: { childId: null, status: "pending" },
},
result: { data: { leaveRequests: requests } },
},
];
const wrapper = makeWrapper(mocks);
const { result } = renderHook(() => useLeaveRequests(null, "pending"), {
wrapper,
});
await waitFor(() => expect(result.current.data).toBeDefined());
expect(result.current.data?.length).toBe(1);
expect(result.current.data?.[0]?.type).toBe("family");
});
it("未加载时 data 为 undefined保留 skeleton 触发条件)", () => {
const mocks: MockedResponse[] = [
{
request: {
query: GET_LEAVE_REQUESTS_DOC,
variables: { childId: null, status: null },
},
result: { data: { leaveRequests: [] } },
},
];
const wrapper = makeWrapper(mocks);
const { result } = renderHook(() => useLeaveRequests(null, null), {
wrapper,
});
expect(result.current.data).toBeUndefined();
expect(result.current.loading).toBe(true);
});
});
describe("useApproveLeave", () => {
it("后端返回 approveLeave 时不抛错", async () => {
const mocks: MockedResponse[] = [
{
request: {
query: APPROVE_LEAVE_DOC,
variables: { id: "lr-1" },
},
result: {
data: { approveLeave: { id: "lr-1", status: "approved" } },
},
},
];
const wrapper = makeWrapper(mocks);
const { result } = renderHook(() => useApproveLeave(), { wrapper });
await expect(result.current.run("lr-1")).resolves.toBeUndefined();
});
it("后端返回 null 时抛 ApiError", async () => {
const mocks: MockedResponse[] = [
{
request: {
query: APPROVE_LEAVE_DOC,
variables: { id: "lr-bad" },
},
result: { data: { approveLeave: null } },
},
];
const wrapper = makeWrapper(mocks);
const { result } = renderHook(() => useApproveLeave(), { wrapper });
await expect(result.current.run("lr-bad")).rejects.toThrow(ApiError);
});
});
describe("useRejectLeave", () => {
it("后端返回 rejectLeave 时不抛错", async () => {
const mocks: MockedResponse[] = [
{
request: {
query: REJECT_LEAVE_DOC,
variables: { id: "lr-2", reason: "理由不充分" },
},
result: {
data: { rejectLeave: { id: "lr-2", status: "rejected" } },
},
},
];
const wrapper = makeWrapper(mocks);
const { result } = renderHook(() => useRejectLeave(), { wrapper });
await expect(
result.current.run("lr-2", "理由不充分"),
).resolves.toBeUndefined();
});
it("后端返回 null 时抛 ApiError", async () => {
const mocks: MockedResponse[] = [
{
request: {
query: REJECT_LEAVE_DOC,
variables: { id: "lr-bad", reason: "测试" },
},
result: { data: { rejectLeave: null } },
},
];
const wrapper = makeWrapper(mocks);
const { result } = renderHook(() => useRejectLeave(), { wrapper });
await expect(result.current.run("lr-bad", "测试")).rejects.toThrow(
ApiError,
);
});
});

View File

@@ -0,0 +1,227 @@
import { describe, it, expect } from "vitest";
import { renderHook, waitFor } from "@testing-library/react";
import { MockedProvider, type MockedResponse } from "@apollo/client/testing";
import type { ReactNode } from "react";
import {
useMyClasses,
useMyChildren,
useTerms,
type SidebarClass,
type SidebarChild,
type Term,
} from "../sidebar";
import {
GET_MY_CLASSES_DOC,
GET_MY_CHILDREN_DOC,
GET_TERMS_DOC,
} from "../operations/sidebar.graphql";
/**
* sidebar domain API 测试v2.1 M2
*
* 覆盖:
* - 查询函数返回归一化的扁平数据(从 myClasses / myChildren / terms 字段提取)
* - 查询函数初始 loading 状态保留 data === undefinedwidget skeleton 触发条件)
*
* 关联spec §2.2、§9.9
*/
function makeWrapper(mocks: MockedResponse[]) {
return function Wrapper({ children }: { children: ReactNode }): ReactNode {
return <MockedProvider mocks={mocks}>{children}</MockedProvider>;
};
}
describe("useMyClasses", () => {
it("返回归一化的 SidebarClass[](从 myClasses 字段提取)", async () => {
const classes: SidebarClass[] = [
{ id: "c-1", name: "三年级 1 班" },
{ id: "c-2", name: "三年级 2 班" },
];
const mocks: MockedResponse[] = [
{
request: {
query: GET_MY_CLASSES_DOC,
variables: {},
},
result: { data: { myClasses: classes } },
},
];
const wrapper = makeWrapper(mocks);
const { result } = renderHook(() => useMyClasses(), { wrapper });
await waitFor(() => expect(result.current.data).toBeDefined());
expect(result.current.data).toEqual(classes);
expect(result.current.data?.length).toBe(2);
});
it("返回空数组myClasses: []", async () => {
const mocks: MockedResponse[] = [
{
request: {
query: GET_MY_CLASSES_DOC,
variables: {},
},
result: { data: { myClasses: [] } },
},
];
const wrapper = makeWrapper(mocks);
const { result } = renderHook(() => useMyClasses(), { wrapper });
await waitFor(() => expect(result.current.data).toBeDefined());
expect(result.current.data).toEqual([]);
expect(result.current.data?.length).toBe(0);
});
it("未加载时 data 为 undefined保留 skeleton 触发条件)", () => {
const mocks: MockedResponse[] = [
{
request: {
query: GET_MY_CLASSES_DOC,
variables: {},
},
result: { data: { myClasses: [] } },
},
];
const wrapper = makeWrapper(mocks);
const { result } = renderHook(() => useMyClasses(), { wrapper });
expect(result.current.data).toBeUndefined();
expect(result.current.loading).toBe(true);
});
});
describe("useMyChildren", () => {
it("返回归一化的 SidebarChild[](从 myChildren 字段提取)", async () => {
const children: SidebarChild[] = [
{
id: "ch-1",
name: "Tom",
grade: "三年级",
className: "1 班",
},
{
id: "ch-2",
name: "Lily",
grade: "五年级",
className: "3 班",
},
];
const mocks: MockedResponse[] = [
{
request: {
query: GET_MY_CHILDREN_DOC,
variables: {},
},
result: { data: { myChildren: children } },
},
];
const wrapper = makeWrapper(mocks);
const { result } = renderHook(() => useMyChildren(), { wrapper });
await waitFor(() => expect(result.current.data).toBeDefined());
expect(result.current.data).toEqual(children);
expect(result.current.data?.length).toBe(2);
});
it("返回空数组myChildren: []", async () => {
const mocks: MockedResponse[] = [
{
request: {
query: GET_MY_CHILDREN_DOC,
variables: {},
},
result: { data: { myChildren: [] } },
},
];
const wrapper = makeWrapper(mocks);
const { result } = renderHook(() => useMyChildren(), { wrapper });
await waitFor(() => expect(result.current.data).toBeDefined());
expect(result.current.data).toEqual([]);
});
it("未加载时 data 为 undefined保留 skeleton 触发条件)", () => {
const mocks: MockedResponse[] = [
{
request: {
query: GET_MY_CHILDREN_DOC,
variables: {},
},
result: { data: { myChildren: [] } },
},
];
const wrapper = makeWrapper(mocks);
const { result } = renderHook(() => useMyChildren(), { wrapper });
expect(result.current.data).toBeUndefined();
expect(result.current.loading).toBe(true);
});
});
describe("useTerms", () => {
it("返回归一化的 Term[](从 terms 字段提取,保留 isActive", async () => {
const terms: Term[] = [
{
id: "t-1",
name: "2026 春季",
startDate: "2026-02-01",
endDate: "2026-06-30",
isActive: false,
},
{
id: "t-2",
name: "2026 秋季",
startDate: "2026-09-01",
endDate: "2027-01-15",
isActive: true,
},
];
const mocks: MockedResponse[] = [
{
request: {
query: GET_TERMS_DOC,
variables: {},
},
result: { data: { terms } },
},
];
const wrapper = makeWrapper(mocks);
const { result } = renderHook(() => useTerms(), { wrapper });
await waitFor(() => expect(result.current.data).toBeDefined());
expect(result.current.data).toEqual(terms);
expect(result.current.data?.length).toBe(2);
});
it("返回空数组terms: []", async () => {
const mocks: MockedResponse[] = [
{
request: {
query: GET_TERMS_DOC,
variables: {},
},
result: { data: { terms: [] } },
},
];
const wrapper = makeWrapper(mocks);
const { result } = renderHook(() => useTerms(), { wrapper });
await waitFor(() => expect(result.current.data).toBeDefined());
expect(result.current.data).toEqual([]);
});
it("未加载时 data 为 undefined保留 skeleton 触发条件)", () => {
const mocks: MockedResponse[] = [
{
request: {
query: GET_TERMS_DOC,
variables: {},
},
result: { data: { terms: [] } },
},
];
const wrapper = makeWrapper(mocks);
const { result } = renderHook(() => useTerms(), { wrapper });
expect(result.current.data).toBeUndefined();
expect(result.current.loading).toBe(true);
});
});

View File

@@ -0,0 +1,235 @@
import { describe, it, expect } from "vitest";
import { renderHook, waitFor } from "@testing-library/react";
import { MockedProvider, type MockedResponse } from "@apollo/client/testing";
import type { ReactNode } from "react";
import {
useErrorBook,
useMarkErrorMastered,
useEnrollCourse,
useAiTutorSessions,
useSendAiTutorMessage,
type ErrorBookItem,
type AiSession,
} from "../student";
import {
GET_ERROR_BOOK_DOC,
MARK_ERROR_MASTERED_DOC,
ENROLL_COURSE_DOC,
GET_AI_SESSIONS_DOC,
SEND_MESSAGE_DOC,
} from "../operations/student.graphql";
import { ApiError } from "../errors";
/**
* student domain API 测试v2.1 M2
*
* 覆盖:
* - 查询函数返回归一化的扁平数据(提取嵌套字段)
* - 变更函数成功时不抛错、失败时抛 ApiError
* - 变更函数返回值结构(如 useSendAiTutorMessage 返回 AiTutorReply
*
* 关联spec §2.2、§9.9
*/
function makeWrapper(mocks: MockedResponse[]) {
return function Wrapper({ children }: { children: ReactNode }): ReactNode {
return <MockedProvider mocks={mocks}>{children}</MockedProvider>;
};
}
describe("useErrorBook", () => {
it("返回归一化的 ErrorBookItem[](从 myErrorBook 字段提取)", async () => {
const items: ErrorBookItem[] = [
{
id: "err-1",
question: "1+1=?",
myAnswer: "2",
correctAnswer: "2",
errorCount: 0,
lastErrorAt: "2026-07-01",
subject: "数学",
},
{
id: "err-2",
question: "2+2=?",
myAnswer: "5",
correctAnswer: "4",
errorCount: 1,
lastErrorAt: "2026-07-02",
subject: "数学",
},
];
const mocks: MockedResponse[] = [
{
request: {
query: GET_ERROR_BOOK_DOC,
variables: { subjectId: null, limit: 20 },
},
result: { data: { myErrorBook: items } },
},
];
const wrapper = makeWrapper(mocks);
const { result } = renderHook(() => useErrorBook(), { wrapper });
await waitFor(() => expect(result.current.data).toBeDefined());
expect(result.current.data).toEqual(items);
expect(result.current.data?.length).toBe(2);
expect(result.current.data?.[0]?.id).toBe("err-1");
});
it("未加载时 data 为 undefined保留 skeleton 触发条件)", () => {
const mocks: MockedResponse[] = [
{
request: {
query: GET_ERROR_BOOK_DOC,
variables: { subjectId: null, limit: 20 },
},
result: { data: { myErrorBook: [] } },
},
];
const wrapper = makeWrapper(mocks);
const { result } = renderHook(() => useErrorBook(), { wrapper });
expect(result.current.data).toBeUndefined();
expect(result.current.loading).toBe(true);
});
});
describe("useMarkErrorMastered", () => {
it("后端返回 true 时不抛错", async () => {
const mocks: MockedResponse[] = [
{
request: {
query: MARK_ERROR_MASTERED_DOC,
variables: { id: "err-1" },
},
result: { data: { markErrorMastered: true } },
},
];
const wrapper = makeWrapper(mocks);
const { result } = renderHook(() => useMarkErrorMastered(), { wrapper });
await expect(result.current.run("err-1")).resolves.toBeUndefined();
});
it("后端返回 false 时抛 ApiError", async () => {
const mocks: MockedResponse[] = [
{
request: {
query: MARK_ERROR_MASTERED_DOC,
variables: { id: "err-2" },
},
result: { data: { markErrorMastered: false } },
},
];
const wrapper = makeWrapper(mocks);
const { result } = renderHook(() => useMarkErrorMastered(), { wrapper });
await expect(result.current.run("err-2")).rejects.toThrow(ApiError);
});
});
describe("useEnrollCourse", () => {
it("成功调用 enrollCourse 并返回 void", async () => {
const mocks: MockedResponse[] = [
{
request: {
query: ENROLL_COURSE_DOC,
variables: { courseId: "c-1" },
},
result: { data: { enrollCourse: true } },
},
];
const wrapper = makeWrapper(mocks);
const { result } = renderHook(() => useEnrollCourse(), { wrapper });
await expect(result.current.run("c-1")).resolves.toBeUndefined();
await waitFor(() => expect(result.current.loading).toBe(false));
});
it("enrollCourse 返回 false 时抛 ApiError", async () => {
const mocks: MockedResponse[] = [
{
request: {
query: ENROLL_COURSE_DOC,
variables: { courseId: "c-full" },
},
result: { data: { enrollCourse: false } },
},
];
const wrapper = makeWrapper(mocks);
const { result } = renderHook(() => useEnrollCourse(), { wrapper });
await expect(result.current.run("c-full")).rejects.toThrow(ApiError);
});
});
describe("useAiTutorSessions", () => {
it("返回归一化的 AiSession[](从 aiTutorSessions 字段提取)", async () => {
const sessions: AiSession[] = [
{
id: "s-1",
title: "数学辅导",
lastMessage: "好的,明白了",
updatedAt: "2026-07-10",
},
];
const mocks: MockedResponse[] = [
{
request: {
query: GET_AI_SESSIONS_DOC,
variables: { limit: 10 },
},
result: { data: { aiTutorSessions: sessions } },
},
];
const wrapper = makeWrapper(mocks);
const { result } = renderHook(() => useAiTutorSessions(10), { wrapper });
await waitFor(() => expect(result.current.data).toBeDefined());
expect(result.current.data).toEqual(sessions);
expect(result.current.data?.[0]?.title).toBe("数学辅导");
});
});
describe("useSendAiTutorMessage", () => {
it("成功时返回 AiTutorReply含 sessionId 与 reply", async () => {
const mocks: MockedResponse[] = [
{
request: {
query: SEND_MESSAGE_DOC,
variables: { sessionId: null, message: "你好" },
},
result: {
data: {
sendAiTutorMessage: {
sessionId: "s-new",
reply: "你好,有什么可以帮你?",
},
},
},
},
];
const wrapper = makeWrapper(mocks);
const { result } = renderHook(() => useSendAiTutorMessage(), { wrapper });
const reply = await result.current.run(null, "你好");
expect(reply.sessionId).toBe("s-new");
expect(reply.reply).toBe("你好,有什么可以帮你?");
});
it("后端未返回数据时抛 ApiError", async () => {
const mocks: MockedResponse[] = [
{
request: {
query: SEND_MESSAGE_DOC,
variables: { sessionId: "s-1", message: "测试" },
},
result: { data: { sendAiTutorMessage: null } },
},
];
const wrapper = makeWrapper(mocks);
const { result } = renderHook(() => useSendAiTutorMessage(), { wrapper });
await expect(result.current.run("s-1", "测试")).rejects.toThrow(ApiError);
});
});

View File

@@ -0,0 +1,199 @@
import { describe, it, expect } from "vitest";
import { renderHook, waitFor } from "@testing-library/react";
import { MockedProvider, type MockedResponse } from "@apollo/client/testing";
import type { ReactNode } from "react";
import {
useLessonPlans,
useSaveLessonPlan,
useSchedulingRules,
useUpdateSchedulingRule,
type SaveLessonPlanInput,
type SchedulingRuleInput,
} from "../teacher";
import {
GET_LESSON_PLANS_DOC,
SAVE_LESSON_PLAN_DOC,
GET_SCHEDULING_RULES_DOC,
UPDATE_SCHEDULING_RULE_DOC,
} from "../operations/teacher.graphql";
import { ApiError } from "../errors";
/**
* Teacher domain API 测试v2.1 M2
*
* 覆盖:
* - 查询函数返回归一化的扁平数据(从 lessonPlans / schedulingRules 字段提取)
* - 查询函数初始 loading 状态保留 data === undefinedwidget skeleton 触发条件)
* - classId 为空时自动跳过查询enabled 默认值)
* - 变更函数成功返回 { id }、无数据时抛 ApiError
*
* 关联spec §2.2、§9.9
*/
function makeWrapper(mocks: MockedResponse[]) {
return function Wrapper({ children }: { children: ReactNode }): ReactNode {
return <MockedProvider mocks={mocks}>{children}</MockedProvider>;
};
}
const LESSON_PLANS_MOCK = [
{
id: "lp-1",
title: "第一课时",
objectives: "目标",
content: "内容",
resources: ["资源A"],
},
{
id: "lp-2",
title: "第二课时",
objectives: "",
content: "",
resources: [],
},
];
describe("useLessonPlans", () => {
it("返回归一化的 LessonPlan[](从 lessonPlans 字段提取)", async () => {
const mocks: MockedResponse[] = [
{
request: {
query: GET_LESSON_PLANS_DOC,
variables: { classId: "cls-1" },
},
result: { data: { lessonPlans: LESSON_PLANS_MOCK } },
},
];
const wrapper = makeWrapper(mocks);
const { result } = renderHook(() => useLessonPlans("cls-1"), { wrapper });
await waitFor(() => expect(result.current.data).toBeDefined());
expect(Array.isArray(result.current.data)).toBe(true);
expect(result.current.data).toEqual(LESSON_PLANS_MOCK);
expect(result.current.data?.[0]?.resources).toEqual(["资源A"]);
expect(result.current.data?.[1]?.resources).toEqual([]);
});
it("未加载时 data 为 undefined保留 skeleton 触发条件)", () => {
const mocks: MockedResponse[] = [
{
request: {
query: GET_LESSON_PLANS_DOC,
variables: { classId: "cls-1" },
},
result: { data: { lessonPlans: [] } },
},
];
const wrapper = makeWrapper(mocks);
const { result } = renderHook(() => useLessonPlans("cls-1"), { wrapper });
expect(result.current.data).toBeUndefined();
expect(result.current.loading).toBe(true);
});
it("classId 为空时跳过查询data 为 undefined", () => {
const wrapper = makeWrapper([]);
const { result } = renderHook(() => useLessonPlans(""), { wrapper });
expect(result.current.data).toBeUndefined();
});
});
describe("useSaveLessonPlan", () => {
const input: SaveLessonPlanInput = {
classId: "cls-1",
title: "新备课",
objectives: "obj",
content: "content",
resources: ["r1"],
};
it("成功返回 { id }", async () => {
const mocks: MockedResponse[] = [
{
request: {
query: SAVE_LESSON_PLAN_DOC,
variables: { input },
},
result: { data: { saveLessonPlan: { id: "lp-new" } } },
},
];
const wrapper = makeWrapper(mocks);
const { result } = renderHook(() => useSaveLessonPlan(), { wrapper });
const saved = await result.current.run(input);
expect(saved).toEqual({ id: "lp-new" });
});
it("响应无数据时抛出 ApiError", async () => {
const mocks: MockedResponse[] = [
{
request: {
query: SAVE_LESSON_PLAN_DOC,
variables: { input },
},
result: { data: { saveLessonPlan: null } },
},
];
const wrapper = makeWrapper(mocks);
const { result } = renderHook(() => useSaveLessonPlan(), { wrapper });
await expect(result.current.run(input)).rejects.toThrow(ApiError);
});
});
describe("useSchedulingRules", () => {
it("返回归一化的 SchedulingRule[](从 schedulingRules 字段提取)", async () => {
const rulesMock = [
{
id: "sr-1",
dayOfWeek: 1,
periods: "1-2",
subject: "数学",
teacherId: "t-1",
room: "A101",
},
];
const mocks: MockedResponse[] = [
{
request: {
query: GET_SCHEDULING_RULES_DOC,
variables: { classId: "cls-1" },
},
result: { data: { schedulingRules: rulesMock } },
},
];
const wrapper = makeWrapper(mocks);
const { result } = renderHook(() => useSchedulingRules("cls-1"), {
wrapper,
});
await waitFor(() => expect(result.current.data).toBeDefined());
expect(result.current.data).toEqual(rulesMock);
expect(result.current.data?.[0]?.subject).toBe("数学");
});
});
describe("useUpdateSchedulingRule", () => {
it("成功返回 { id }", async () => {
const input: SchedulingRuleInput = {
dayOfWeek: 2,
periods: "3-4",
subject: "语文",
teacherId: "t-2",
room: "B202",
};
const mocks: MockedResponse[] = [
{
request: {
query: UPDATE_SCHEDULING_RULE_DOC,
variables: { id: "sr-1", input },
},
result: { data: { updateSchedulingRule: { id: "sr-1" } } },
},
];
const wrapper = makeWrapper(mocks);
const { result } = renderHook(() => useUpdateSchedulingRule(), { wrapper });
const updated = await result.current.run({ id: "sr-1", input });
expect(updated).toEqual({ id: "sr-1" });
});
});

View File

@@ -0,0 +1,224 @@
import { describe, it, expect } from "vitest";
import { renderHook, waitFor } from "@testing-library/react";
import { MockedProvider, type MockedResponse } from "@apollo/client/testing";
import type { ReactNode } from "react";
import {
useNotificationBell,
useCurrentUser,
useGlobalSearch,
type NotificationItem,
type CurrentUser,
type SearchResult,
} from "../topbar";
import {
GET_NOTIFICATIONS_DOC,
GET_CURRENT_USER_DOC,
SEARCH_DOC,
} from "../operations/topbar.graphql";
/**
* topbar domain API 测试v2.1 M2
*
* 覆盖:
* - 查询函数返回归一化的扁平数据(从 notifications / me / search 字段提取)
* - 查询函数初始 loading 状态保留 data === undefinedwidget skeleton 触发条件)
* - useGlobalSearch 在 keyword 为空时不发起查询
*
* 关联spec §2.2、§9.9
*/
function makeWrapper(mocks: MockedResponse[]) {
return function Wrapper({ children }: { children: ReactNode }): ReactNode {
return <MockedProvider mocks={mocks}>{children}</MockedProvider>;
};
}
describe("useNotificationBell", () => {
it("返回归一化的 NotificationItem[](从 notifications 字段提取)", async () => {
const items: NotificationItem[] = [
{ id: "n-1", title: "新作业通知" },
{ id: "n-2", title: "考试提醒" },
];
const mocks: MockedResponse[] = [
{
request: {
query: GET_NOTIFICATIONS_DOC,
variables: { limit: 10 },
},
result: { data: { notifications: items } },
},
];
const wrapper = makeWrapper(mocks);
const { result } = renderHook(() => useNotificationBell(10), { wrapper });
await waitFor(() => expect(result.current.data).toBeDefined());
expect(result.current.data).toEqual(items);
expect(result.current.data?.length).toBe(2);
expect(result.current.data?.[0]?.title).toBe("新作业通知");
});
it("返回空数组notifications: []", async () => {
const mocks: MockedResponse[] = [
{
request: {
query: GET_NOTIFICATIONS_DOC,
variables: { limit: 5 },
},
result: { data: { notifications: [] } },
},
];
const wrapper = makeWrapper(mocks);
const { result } = renderHook(() => useNotificationBell(5), { wrapper });
await waitFor(() => expect(result.current.data).toBeDefined());
expect(result.current.data).toEqual([]);
expect(result.current.data?.length).toBe(0);
});
it("未加载时 data 为 undefined保留 skeleton 触发条件)", () => {
const mocks: MockedResponse[] = [
{
request: {
query: GET_NOTIFICATIONS_DOC,
variables: { limit: 10 },
},
result: { data: { notifications: [] } },
},
];
const wrapper = makeWrapper(mocks);
const { result } = renderHook(() => useNotificationBell(10), { wrapper });
expect(result.current.data).toBeUndefined();
expect(result.current.loading).toBe(true);
});
});
describe("useCurrentUser", () => {
it("返回归一化的 CurrentUser从 me 字段提取)", async () => {
const me: CurrentUser = {
id: "u-1",
name: "王老师",
email: "wang@edu.com",
role: "teacher",
};
const mocks: MockedResponse[] = [
{
request: {
query: GET_CURRENT_USER_DOC,
variables: {},
},
result: { data: { me } },
},
];
const wrapper = makeWrapper(mocks);
const { result } = renderHook(() => useCurrentUser(), { wrapper });
await waitFor(() => expect(result.current.data).toBeDefined());
expect(result.current.data).toEqual(me);
expect(result.current.data?.name).toBe("王老师");
expect(result.current.data?.role).toBe("teacher");
});
it("后端返回 me: null 时 data 为 null", async () => {
const mocks: MockedResponse[] = [
{
request: {
query: GET_CURRENT_USER_DOC,
variables: {},
},
result: { data: { me: null } },
},
];
const wrapper = makeWrapper(mocks);
const { result } = renderHook(() => useCurrentUser(), { wrapper });
await waitFor(() => expect(result.current.data).toBeNull());
expect(result.current.data).toBeNull();
});
it("未加载时 data 为 undefined保留 skeleton 触发条件)", () => {
const mocks: MockedResponse[] = [
{
request: {
query: GET_CURRENT_USER_DOC,
variables: {},
},
result: { data: { me: null } },
},
];
const wrapper = makeWrapper(mocks);
const { result } = renderHook(() => useCurrentUser(), { wrapper });
expect(result.current.data).toBeUndefined();
expect(result.current.loading).toBe(true);
});
});
describe("useGlobalSearch", () => {
it("返回归一化的 SearchResult[](从 search 字段提取)", async () => {
const results: SearchResult[] = [
{
id: "s-1",
type: "student",
title: "张三",
subtitle: "三年级 1 班",
},
{
id: "c-1",
type: "class",
title: "三年级 1 班",
subtitle: "班主任:王老师",
},
];
const mocks: MockedResponse[] = [
{
request: {
query: SEARCH_DOC,
variables: { keyword: "张", limit: 8 },
},
result: { data: { search: results } },
},
];
const wrapper = makeWrapper(mocks);
const { result } = renderHook(() => useGlobalSearch("张", 8), { wrapper });
await waitFor(() => expect(result.current.data).toBeDefined());
expect(result.current.data).toEqual(results);
expect(result.current.data?.length).toBe(2);
expect(result.current.data?.[0]?.type).toBe("student");
expect(result.current.data?.[1]?.title).toBe("三年级 1 班");
});
it("返回空数组search: []", async () => {
const mocks: MockedResponse[] = [
{
request: {
query: SEARCH_DOC,
variables: { keyword: "不存在", limit: 8 },
},
result: { data: { search: [] } },
},
];
const wrapper = makeWrapper(mocks);
const { result } = renderHook(() => useGlobalSearch("不存在", 8), {
wrapper,
});
await waitFor(() => expect(result.current.data).toBeDefined());
expect(result.current.data).toEqual([]);
});
it("keyword 为空时不发起查询data 保持 undefined", () => {
const mocks: MockedResponse[] = [
{
request: {
query: SEARCH_DOC,
variables: { keyword: "", limit: 8 },
},
result: { data: { search: [] } },
},
];
const wrapper = makeWrapper(mocks);
const { result } = renderHook(() => useGlobalSearch("", 8), { wrapper });
// enabled: false → 不查询data 保持 undefined
expect(result.current.data).toBeUndefined();
});
});

View File

@@ -0,0 +1,205 @@
/**
* Universal domain API 单元测试portal-shell spec §5.6、§9.9
*
* 覆盖:
* - useGrades归一化返回 Grade[](从 { grades: [...] } 拍平)
* - useNotifications归一化返回 NotificationList从 { notifications: { items, total } } 拍平)
* - useGradesenabled=false 时跳过查询
* - useAttendance归一化返回 AttendanceStats
*
* 使用 createElement 而非 JSX避免 .ts 文件 JSX 解析错误
* known-issues §2.17.ts 文件不支持 JSX需用 .tsx 或 createElement
*/
import { describe, it, expect } from "vitest";
import { createElement, type ReactElement, type ReactNode } from "react";
import { renderHook, waitFor } from "@testing-library/react";
import { MockedProvider } from "@apollo/client/testing";
import type { MockedResponse } from "@apollo/client/testing/core";
import { useGrades, useNotifications, useAttendance } from "../universal";
import {
GET_GRADES_DOC,
GET_NOTIFICATIONS_LIST_DOC,
GET_ATTENDANCE_DOC,
} from "../operations/universal.graphql";
function makeWrapper(mocks: MockedResponse[]) {
return function Wrapper({ children }: { children: ReactNode }): ReactElement {
return createElement(MockedProvider, { mocks }, children);
};
}
describe("useGrades", () => {
it("返回归一化的 Grade[](拍平 grades 字段)", async () => {
const mocks: MockedResponse[] = [
{
request: {
query: GET_GRADES_DOC,
variables: { classId: "cls-1" },
},
result: {
data: {
grades: [
{ studentId: "s-1", score: 90 },
{ studentId: "s-2", score: 85 },
],
},
},
},
];
const { result } = renderHook(() => useGrades("cls-1"), {
wrapper: makeWrapper(mocks),
});
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.data).toEqual([
{ studentId: "s-1", score: 90 },
{ studentId: "s-2", score: 85 },
]);
expect(result.current.error).toBeUndefined();
});
it("enabled=false 时不发起查询data 保持 undefined", async () => {
// 故意不配置 mock若 Apollo 仍发起查询MockedProvider 会抛错。
const { result } = renderHook(
() => useGrades("cls-1", { enabled: false }),
{ wrapper: makeWrapper([]) },
);
// 等待一个微任务周期,确认 Apollo 跳过查询
await new Promise((resolve) => setTimeout(resolve, 0));
expect(result.current.loading).toBe(false);
expect(result.current.data).toBeUndefined();
});
it("查询返回空数组时归一化为 []", async () => {
const mocks: MockedResponse[] = [
{
request: {
query: GET_GRADES_DOC,
variables: { classId: "cls-empty" },
},
result: { data: { grades: [] } },
},
];
const { result } = renderHook(() => useGrades("cls-empty"), {
wrapper: makeWrapper(mocks),
});
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.data).toEqual([]);
});
});
describe("useNotifications", () => {
it("返回归一化的 NotificationList含 items + total", async () => {
const mocks: MockedResponse[] = [
{
request: {
query: GET_NOTIFICATIONS_LIST_DOC,
variables: { limit: 20, offset: 0 },
},
result: {
data: {
notifications: {
items: [
{
id: "n-1",
title: "标题1",
body: "正文1",
createdAt: "2026-01-01T00:00:00Z",
type: "info",
},
{
id: "n-2",
title: "标题2",
body: "正文2",
createdAt: "2026-01-02T00:00:00Z",
type: "urgent",
},
],
total: 2,
},
},
},
},
];
const { result } = renderHook(
() => useNotifications({ limit: 20, offset: 0 }),
{ wrapper: makeWrapper(mocks) },
);
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.data).toEqual({
items: [
{
id: "n-1",
title: "标题1",
body: "正文1",
createdAt: "2026-01-01T00:00:00Z",
type: "info",
},
{
id: "n-2",
title: "标题2",
body: "正文2",
createdAt: "2026-01-02T00:00:00Z",
type: "urgent",
},
],
total: 2,
});
});
it("未加载时 data 为 undefined", () => {
const mocks: MockedResponse[] = [];
const { result } = renderHook(
() => useNotifications({ limit: 10, offset: 0 }),
{ wrapper: makeWrapper(mocks) },
);
expect(result.current.data).toBeUndefined();
});
});
describe("useAttendance", () => {
it("返回归一化的 AttendanceStats拍平 attendance 字段)", async () => {
const mocks: MockedResponse[] = [
{
request: {
query: GET_ATTENDANCE_DOC,
variables: { classId: "cls-1", termId: "t-1" },
},
result: {
data: {
attendance: {
present: 30,
absent: 2,
late: 3,
total: 35,
},
},
},
},
];
const { result } = renderHook(() => useAttendance("cls-1", "t-1"), {
wrapper: makeWrapper(mocks),
});
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.data).toEqual({
present: 30,
absent: 2,
late: 3,
total: 35,
});
});
});

View File

@@ -0,0 +1,651 @@
"use client";
/**
* Admin domain API语义化 Hook
*
* 为 widgets/admin/* 提供语义化查询/变更 Hook封装 GraphQL DOC 与类型映射。
* widget 通过 `import { useUsers } from "@/lib/api/admin"` 调用。
*
* 设计:
* - 查询 Hook 返回 UseQueryResult<TData>data 已归一化(剥离外层 query 字段)
* - 变更 Hook 返回 { run, loading, error }run 抛 ApiError 表示业务失败
* - 分页查询返回 UseQueryResult<PaginatedResult<T>>,保留 items + total
*
* 关联spec §2.2、§5.6
*/
import { useWidgetQuery } from "@/lib/useWidgetQuery";
import { useWidgetMutation } from "@/lib/useWidgetMutation";
import { ApiError } from "./errors";
import type { PaginatedResult, Pagination, UseQueryResult } from "./types";
import {
GET_USERS_DOC,
UPDATE_USER_STATUS_DOC,
UPDATE_USER_ROLE_DOC,
GET_ROLES_DOC,
GET_PERMISSIONS_DOC,
UPDATE_ROLE_PERMISSIONS_DOC,
GET_AUDIT_LOGS_DOC,
GET_INVITATION_CODES_DOC,
CREATE_INVITATION_CODE_DOC,
REVOKE_INVITATION_CODE_DOC,
GET_SCHOOL_DOC,
UPDATE_SCHOOL_DOC,
GET_PLUGIN_REGISTRY_DOC,
GET_ROLE_PLUGIN_MAPPING_DOC,
GET_LAYOUT_TEMPLATES_DOC,
GET_ROLE_LAYOUT_DEFAULT_DOC,
UPDATE_PLUGIN_REGISTRY_DOC,
UPDATE_ROLE_PLUGIN_MAPPING_DOC,
UPDATE_ROLE_LAYOUT_DEFAULT_DOC,
RESET_USER_LAYOUT_OVERRIDE_DOC,
} from "./operations/admin.graphql";
// ============================================================
// Types: User management
// ============================================================
export interface User {
id: string;
name: string;
email: string;
role: string;
status: string;
createdAt: string;
}
export type UserQueryVars = {
role: string | null;
limit: number;
offset: number;
};
// ============================================================
// Types: RBAC
// ============================================================
export interface Permission {
id: string;
name: string;
resource: string;
action: string;
description: string;
}
export interface RolePermission {
id: string;
name: string;
resource: string;
action: string;
}
export interface Role {
id: string;
name: string;
permissions: RolePermission[];
}
// ============================================================
// Types: Audit logs
// ============================================================
export interface AuditLog {
id: string;
userId: string;
userName: string;
action: string;
resource: string;
resourceId: string;
ip: string;
timestamp: string;
details: string;
}
export interface AuditLogFilter {
userId?: string | null;
action?: string | null;
resource?: string | null;
}
// ============================================================
// Types: Invitation codes
// ============================================================
export interface InvitationCode {
id: string;
code: string;
role: string;
status: string;
usedCount: number;
maxUses: number;
expiresAt: string;
createdAt: string;
createdBy: string;
}
export interface CreateInvitationCodeInput {
role: string;
maxUses: number;
ttlHours: number;
}
export type CreatedInvitationCode = Pick<
InvitationCode,
"id" | "code" | "role" | "maxUses" | "expiresAt"
>;
// ============================================================
// Types: School
// ============================================================
export interface School {
id: string;
name: string;
address: string;
phone: string;
email: string;
currentAcademicYear: string;
currentTerm: string;
semesterStart: string;
semesterEnd: string;
}
export interface SchoolInput {
name?: string;
address?: string;
phone?: string;
email?: string;
currentAcademicYear?: string;
currentTerm?: string;
semesterStart?: string;
semesterEnd?: string;
}
// ============================================================
// Types: Plugin manager
// ============================================================
export interface RegistryItem {
pluginId: string;
category: string;
version: string;
displayName: string;
description: string;
requiredRoles: string[];
isBuiltin: boolean;
isActive: boolean;
defaultSlot: string;
defaultSize: { colSpan: number; rowSpan: number };
defaultProps: Record<string, unknown>;
propsSchema: Record<string, unknown>;
}
export interface RoleMapping {
role: string;
pluginId: string;
slot: string;
sortOrder: number;
isEnabled: boolean;
widgetProps: Record<string, unknown>;
}
export interface RolePluginMappingInput {
pluginId: string;
slot: string;
sortOrder: number;
isEnabled: boolean;
widgetProps: Record<string, unknown>;
}
export interface LayoutTemplate {
layoutId: string;
displayName: string;
description: string;
availableSlots: string[];
}
export interface RoleLayoutDefault {
role: string;
layoutId: string;
slotOverrides: Record<string, unknown>;
}
export interface PluginRegistryInput {
isActive?: boolean;
defaultProps?: Record<string, unknown>;
}
export interface UpdatedPluginRegistry {
pluginId: string;
isActive: boolean;
defaultProps: Record<string, unknown>;
}
export interface UpdatedRoleMapping {
role: string;
pluginId: string;
isEnabled: boolean;
}
// ============================================================
// Hooks: User management
// ============================================================
export function useUsers(
vars: UserQueryVars,
): UseQueryResult<PaginatedResult<User>> {
const result = useWidgetQuery<
{ users: PaginatedResult<User> },
UserQueryVars
>(GET_USERS_DOC, vars);
return {
...result,
data: result.data?.users,
};
}
export function useUpdateUserStatus(): {
run: (id: string, status: string) => Promise<{ id: string; status: string }>;
loading: boolean;
error: unknown;
} {
const {
run: rawRun,
loading,
error,
} = useWidgetMutation<
{ updateUserStatus: { id: string; status: string } },
{ id: string; status: string }
>(UPDATE_USER_STATUS_DOC);
const run = async (
id: string,
status: string,
): Promise<{ id: string; status: string }> => {
const data = await rawRun({ id, status });
if (!data?.updateUserStatus) {
throw new ApiError("Failed to update user status", "INTERNAL_ERROR");
}
return data.updateUserStatus;
};
return { run, loading, error };
}
export function useUpdateUserRole(): {
run: (id: string, role: string) => Promise<{ id: string; role: string }>;
loading: boolean;
error: unknown;
} {
const {
run: rawRun,
loading,
error,
} = useWidgetMutation<
{ updateUserRole: { id: string; role: string } },
{ id: string; role: string }
>(UPDATE_USER_ROLE_DOC);
const run = async (
id: string,
role: string,
): Promise<{ id: string; role: string }> => {
const data = await rawRun({ id, role });
if (!data?.updateUserRole) {
throw new ApiError("Failed to update user role", "INTERNAL_ERROR");
}
return data.updateUserRole;
};
return { run, loading, error };
}
// ============================================================
// Hooks: RBAC
// ============================================================
export function useRoles(): UseQueryResult<Role[]> {
const result = useWidgetQuery<{ roles: Role[] }, Record<string, never>>(
GET_ROLES_DOC,
{},
);
return {
...result,
data: result.data?.roles,
};
}
export function usePermissions(): UseQueryResult<Permission[]> {
const result = useWidgetQuery<
{ permissions: Permission[] },
Record<string, never>
>(GET_PERMISSIONS_DOC, {});
return {
...result,
data: result.data?.permissions,
};
}
export function useUpdateRolePermissions(): {
run: (roleId: string, permissionIds: string[]) => Promise<{ id: string }>;
loading: boolean;
error: unknown;
} {
const {
run: rawRun,
loading,
error,
} = useWidgetMutation<
{ updateRolePermissions: { id: string } },
{ roleId: string; permissionIds: string[] }
>(UPDATE_ROLE_PERMISSIONS_DOC);
const run = async (
roleId: string,
permissionIds: string[],
): Promise<{ id: string }> => {
const data = await rawRun({ roleId, permissionIds });
if (!data?.updateRolePermissions) {
throw new ApiError("Failed to update role permissions", "INTERNAL_ERROR");
}
return data.updateRolePermissions;
};
return { run, loading, error };
}
// ============================================================
// Hooks: Audit logs
// ============================================================
export function useAuditLogs(
filter: AuditLogFilter,
pagination: Pagination,
): UseQueryResult<PaginatedResult<AuditLog>> {
const result = useWidgetQuery<
{ auditLogs: PaginatedResult<AuditLog> },
{ filter: AuditLogFilter; limit: number; offset: number }
>(GET_AUDIT_LOGS_DOC, {
filter,
limit: pagination.limit,
offset: pagination.offset,
});
return {
...result,
data: result.data?.auditLogs,
};
}
// ============================================================
// Hooks: Invitation codes
// ============================================================
export function useInvitationCodes(
status: string | null,
): UseQueryResult<InvitationCode[]> {
const result = useWidgetQuery<
{ invitationCodes: InvitationCode[] },
{ status: string | null }
>(GET_INVITATION_CODES_DOC, { status });
return {
...result,
data: result.data?.invitationCodes,
};
}
export function useCreateInvitationCode(): {
run: (input: CreateInvitationCodeInput) => Promise<CreatedInvitationCode>;
loading: boolean;
error: unknown;
} {
const {
run: rawRun,
loading,
error,
} = useWidgetMutation<
{ createInvitationCode: CreatedInvitationCode },
{ input: CreateInvitationCodeInput }
>(CREATE_INVITATION_CODE_DOC);
const run = async (
input: CreateInvitationCodeInput,
): Promise<CreatedInvitationCode> => {
const data = await rawRun({ input });
if (!data?.createInvitationCode) {
throw new ApiError("Failed to create invitation code", "INTERNAL_ERROR");
}
return data.createInvitationCode;
};
return { run, loading, error };
}
export function useRevokeInvitationCode(): {
run: (id: string) => Promise<{ id: string; status: string }>;
loading: boolean;
error: unknown;
} {
const {
run: rawRun,
loading,
error,
} = useWidgetMutation<
{ revokeInvitationCode: { id: string; status: string } },
{ id: string }
>(REVOKE_INVITATION_CODE_DOC);
const run = async (id: string): Promise<{ id: string; status: string }> => {
const data = await rawRun({ id });
if (!data?.revokeInvitationCode) {
throw new ApiError("Failed to revoke invitation code", "INTERNAL_ERROR");
}
return data.revokeInvitationCode;
};
return { run, loading, error };
}
// ============================================================
// Hooks: School settings
// ============================================================
export function useSchool(): UseQueryResult<School | null> {
const result = useWidgetQuery<
{ school: School | null },
Record<string, never>
>(GET_SCHOOL_DOC, {});
return {
...result,
data: result.data?.school ?? null,
};
}
export function useUpdateSchool(): {
run: (input: SchoolInput) => Promise<School>;
loading: boolean;
error: unknown;
} {
const {
run: rawRun,
loading,
error,
} = useWidgetMutation<{ updateSchool: School }, { input: SchoolInput }>(
UPDATE_SCHOOL_DOC,
);
const run = async (input: SchoolInput): Promise<School> => {
const data = await rawRun({ input });
if (!data?.updateSchool) {
throw new ApiError("Failed to update school", "INTERNAL_ERROR");
}
return data.updateSchool;
};
return { run, loading, error };
}
// ============================================================
// Hooks: Plugin manager
// ============================================================
export function usePluginRegistry(): UseQueryResult<RegistryItem[]> {
const result = useWidgetQuery<
{ pluginRegistry: RegistryItem[] },
Record<string, never>
>(GET_PLUGIN_REGISTRY_DOC, {});
return {
...result,
data: result.data?.pluginRegistry,
};
}
export function useRolePluginMapping(
role: string,
): UseQueryResult<RoleMapping[]> {
const result = useWidgetQuery<
{ rolePluginMapping: RoleMapping[] },
{ role: string | null }
>(GET_ROLE_PLUGIN_MAPPING_DOC, { role });
return {
...result,
data: result.data?.rolePluginMapping,
};
}
export function useLayoutTemplates(): UseQueryResult<LayoutTemplate[]> {
const result = useWidgetQuery<
{ layoutTemplates: LayoutTemplate[] },
Record<string, never>
>(GET_LAYOUT_TEMPLATES_DOC, {});
return {
...result,
data: result.data?.layoutTemplates,
};
}
export function useRoleLayoutDefault(
role: string,
): UseQueryResult<RoleLayoutDefault | null> {
const result = useWidgetQuery<
{ roleLayoutDefault: RoleLayoutDefault | null },
{ role: string | null }
>(GET_ROLE_LAYOUT_DEFAULT_DOC, { role });
return {
...result,
data: result.data?.roleLayoutDefault ?? null,
};
}
export function useUpdatePluginRegistry(): {
run: (
pluginId: string,
input: PluginRegistryInput,
) => Promise<UpdatedPluginRegistry>;
loading: boolean;
error: unknown;
} {
const {
run: rawRun,
loading,
error,
} = useWidgetMutation<
{ updatePluginRegistry: UpdatedPluginRegistry },
{ pluginId: string; input: PluginRegistryInput }
>(UPDATE_PLUGIN_REGISTRY_DOC);
const run = async (
pluginId: string,
input: PluginRegistryInput,
): Promise<UpdatedPluginRegistry> => {
const data = await rawRun({ pluginId, input });
if (!data?.updatePluginRegistry) {
throw new ApiError("Failed to update plugin registry", "INTERNAL_ERROR");
}
return data.updatePluginRegistry;
};
return { run, loading, error };
}
export function useUpdateRolePluginMapping(): {
run: (
role: string,
mappings: RolePluginMappingInput[],
) => Promise<UpdatedRoleMapping[]>;
loading: boolean;
error: unknown;
} {
const {
run: rawRun,
loading,
error,
} = useWidgetMutation<
{ updateRolePluginMapping: UpdatedRoleMapping[] },
{ role: string; mappings: RolePluginMappingInput[] }
>(UPDATE_ROLE_PLUGIN_MAPPING_DOC);
const run = async (
role: string,
mappings: RolePluginMappingInput[],
): Promise<UpdatedRoleMapping[]> => {
const data = await rawRun({ role, mappings });
if (!data?.updateRolePluginMapping) {
throw new ApiError(
"Failed to update role plugin mapping",
"INTERNAL_ERROR",
);
}
return data.updateRolePluginMapping;
};
return { run, loading, error };
}
export function useUpdateRoleLayoutDefault(): {
run: (
role: string,
layoutId: string,
) => Promise<{ role: string; layoutId: string }>;
loading: boolean;
error: unknown;
} {
const {
run: rawRun,
loading,
error,
} = useWidgetMutation<
{ updateRoleLayoutDefault: { role: string; layoutId: string } },
{ role: string; layoutId: string }
>(UPDATE_ROLE_LAYOUT_DEFAULT_DOC);
const run = async (
role: string,
layoutId: string,
): Promise<{ role: string; layoutId: string }> => {
const data = await rawRun({ role, layoutId });
if (!data?.updateRoleLayoutDefault) {
throw new ApiError(
"Failed to update role layout default",
"INTERNAL_ERROR",
);
}
return data.updateRoleLayoutDefault;
};
return { run, loading, error };
}
export function useResetUserLayoutOverride(): {
run: (userId: string) => Promise<{ userId: string }>;
loading: boolean;
error: unknown;
} {
const {
run: rawRun,
loading,
error,
} = useWidgetMutation<
{ resetUserLayoutOverride: { userId: string } },
{ userId: string }
>(RESET_USER_LAYOUT_OVERRIDE_DOC);
const run = async (userId: string): Promise<{ userId: string }> => {
const data = await rawRun({ userId });
if (!data?.resetUserLayoutOverride) {
throw new ApiError(
"Failed to reset user layout override",
"INTERNAL_ERROR",
);
}
return data.resetUserLayoutOverride;
};
return { run, loading, error };
}

View File

@@ -2,9 +2,16 @@
* API 层统一出口
*
* widget 通过 `import { useParentChildren } from "@/lib/api"` 调用。
* domain 文件在 M2 阶段逐步加入
* 7 个 domain 文件覆盖全部 31 widget
*
* 关联spec §2.2
*/
export * from "./errors";
export * from "./types";
export * from "./universal";
export * from "./sidebar";
export * from "./topbar";
export * from "./teacher";
export * from "./student";
export * from "./parent";
export * from "./admin";

View File

@@ -0,0 +1,202 @@
"use client";
/**
* Parent domain API
*
* 涵盖useParentChildren、useLeaveRequests、useApproveLeave、useRejectLeave
*
* widget 通过 `import { useParentChildren } from "@/lib/api"` 调用,
* 不再各自内嵌 gql/接口/手写类型。
*
* 关联spec §2.3、§3.3
*/
import {
GET_MY_CHILDREN_OVERVIEW_DOC,
GET_LEAVE_REQUESTS_DOC,
APPROVE_LEAVE_DOC,
REJECT_LEAVE_DOC,
} from "@/lib/api/operations/parent.graphql";
import { useWidgetQuery } from "@/lib/useWidgetQuery";
import { useWidgetMutation } from "@/lib/useWidgetMutation";
import type { UseQueryResult } from "./types";
import { ApiError } from "./errors";
// ===== 领域模型类型 =====
export interface ChildGrade {
subject: string;
score: number;
}
export interface ChildAttendance {
present: number;
total: number;
}
export interface ChildHomeworkCompletion {
completed: number;
total: number;
}
export interface ChildSummary {
id: string;
name: string;
grade: string;
className: string;
avatar: string;
recentGrades: ChildGrade[];
attendance: ChildAttendance;
homeworkCompletion: ChildHomeworkCompletion;
}
export type LeaveType = "sick" | "personal" | "family" | "other";
export type LeaveStatus = "pending" | "approved" | "rejected";
export interface LeaveRequest {
id: string;
childName: string;
type: LeaveType;
startDate: string;
endDate: string;
reason: string;
status: LeaveStatus;
createdAt: string;
}
// ===== 内部 Query/Mutation 类型 =====
type MyChildrenOverviewQueryData = {
myChildren: ChildSummary[];
};
type LeaveRequestsQueryData = {
leaveRequests: LeaveRequest[];
};
type LeaveRequestsQueryVars = {
childId: string | null;
status: string | null;
};
type ApproveLeaveMutationData = {
approveLeave: { id: string; status: string } | null;
};
type ApproveLeaveMutationVars = {
id: string;
};
type RejectLeaveMutationData = {
rejectLeave: { id: string; status: string } | null;
};
type RejectLeaveMutationVars = {
id: string;
reason: string;
};
// ===== Hooks =====
/**
* 查询家长名下所有孩子(含最近成绩、出勤、作业完成率)。
*
* 关联portal-shell spec §5.6 统一 Hook
*/
export function useParentChildren(): UseQueryResult<ChildSummary[]> {
const result = useWidgetQuery<
MyChildrenOverviewQueryData,
Record<string, never>
>(GET_MY_CHILDREN_OVERVIEW_DOC, {});
return {
data: result.data?.myChildren,
loading: result.loading,
error: result.error,
refetch: result.refetch,
};
}
/**
* 查询请假申请列表,可按孩子 ID 与状态筛选。
*
* `childId` / `status` 为空字符串或 null 时不参与过滤。
*
* 关联portal-shell spec §5.2.1 URL 驱动、§5.6 统一 Hook
*/
export function useLeaveRequests(
childId: string | null,
status: string | null,
): UseQueryResult<LeaveRequest[]> {
const variables: LeaveRequestsQueryVars = {
childId,
status,
};
const result = useWidgetQuery<LeaveRequestsQueryData, LeaveRequestsQueryVars>(
GET_LEAVE_REQUESTS_DOC,
variables,
);
return {
data: result.data?.leaveRequests,
loading: result.loading,
error: result.error,
refetch: result.refetch,
};
}
/**
* 批准请假申请。
*
* 失败(服务端返回 null 或抛错)时抛 ApiErrorwidget 层可按 code 处理。
*/
export function useApproveLeave(): {
run: (id: string) => Promise<void>;
loading: boolean;
error: unknown;
} {
const {
run: rawRun,
loading,
error,
} = useWidgetMutation<ApproveLeaveMutationData, ApproveLeaveMutationVars>(
APPROVE_LEAVE_DOC,
);
const run = async (id: string): Promise<void> => {
const data = await rawRun({ id });
if (!data?.approveLeave) {
throw new ApiError("Failed to approve leave", "INTERNAL_ERROR");
}
};
return { run, loading, error };
}
/**
* 拒绝请假申请(需填写原因)。
*
* 失败(服务端返回 null 或抛错)时抛 ApiErrorwidget 层可按 code 处理。
*/
export function useRejectLeave(): {
run: (id: string, reason: string) => Promise<void>;
loading: boolean;
error: unknown;
} {
const {
run: rawRun,
loading,
error,
} = useWidgetMutation<RejectLeaveMutationData, RejectLeaveMutationVars>(
REJECT_LEAVE_DOC,
);
const run = async (id: string, reason: string): Promise<void> => {
const data = await rawRun({ id, reason });
if (!data?.rejectLeave) {
throw new ApiError("Failed to reject leave", "INTERNAL_ERROR");
}
};
return { run, loading, error };
}

View File

@@ -0,0 +1,112 @@
"use client";
/**
* Sidebar domain APIspec §2.2
*
* 收敛 widgets/sidebar/* 中内嵌的 gql / interface / useWidgetQuery 调用,
* 暴露语义化 Hook。widget 只需 import 本文件即可。
*
* 涵盖useMyClasses、useMyChildren、useTerms
* 注quick-actions 为纯 UI 导航,无 GraphQL故不在此暴露 API。
*
* 关联portal-shell spec §2.2、§5.2.1 URL 驱动、§5.6 统一 Hook
*/
import { useWidgetQuery, type UseWidgetQueryOptions } from "../useWidgetQuery";
import type { UseQueryResult } from "./types";
import {
GET_MY_CLASSES_DOC,
GET_MY_CHILDREN_DOC,
GET_TERMS_DOC,
} from "./operations/sidebar.graphql";
/**
* 查询 options透传 enabled / pollInterval / fetchPolicy。
* 不含 fallbackData归一化后的 T[] 与 useWidgetQuery 期望的
* 原始 { field: T[] } 形状不匹配widgets 当前未使用 fallbackData
*/
type SidebarQueryOptions<T> = Omit<UseWidgetQueryOptions<T>, "fallbackData">;
// ===== 领域模型类型 =====
/** sidebar 班级下拉项(仅含 id/name与 class-selector widget 形状对齐) */
export interface SidebarClass {
id: string;
name: string;
}
/** sidebar 孩子下拉项(含 grade/className 摘要) */
export interface SidebarChild {
id: string;
name: string;
grade: string;
className: string;
}
/** 学期项 */
export interface Term {
id: string;
name: string;
startDate: string;
endDate: string;
isActive: boolean;
}
// ===== Hooks =====
/**
* 查询当前用户名下班级(用于 sidebar 班级切换)。
*
* 关联portal-shell spec §5.2.1 URL 驱动、§5.6 统一 Hook
*/
export function useMyClasses(
options?: SidebarQueryOptions<SidebarClass[]>,
): UseQueryResult<SidebarClass[]> {
const result = useWidgetQuery<
{ myClasses: SidebarClass[] },
Record<string, never>
>(GET_MY_CLASSES_DOC, {}, options);
return {
...result,
data: result.data?.myClasses,
};
}
/**
* 查询当前家长名下孩子(用于 sidebar 孩子切换)。
*
* 关联portal-shell spec §5.2.1 URL 驱动、§5.6 统一 Hook
*/
export function useMyChildren(
options?: SidebarQueryOptions<SidebarChild[]>,
): UseQueryResult<SidebarChild[]> {
const result = useWidgetQuery<
{ myChildren: SidebarChild[] },
Record<string, never>
>(GET_MY_CHILDREN_DOC, {}, options);
return {
...result,
data: result.data?.myChildren,
};
}
/**
* 查询学期列表(用于 sidebar 学期切换)。
*
* 关联portal-shell spec §5.2.1 URL 驱动、§5.6 统一 Hook
*/
export function useTerms(
options?: SidebarQueryOptions<Term[]>,
): UseQueryResult<Term[]> {
const result = useWidgetQuery<{ terms: Term[] }, Record<string, never>>(
GET_TERMS_DOC,
{},
options,
);
return {
...result,
data: result.data?.terms,
};
}

View File

@@ -0,0 +1,292 @@
"use client";
/**
* Student domain API 函数v2.1 M2
*
* 把 widgets/student/* 的内嵌 gql/interface/hook 调用收敛为语义化 API。
* widget 通过 `import { useErrorBook } from "@/lib/api/student"` 调用。
*
* 设计:
* - 查询函数返回 UseQueryResult<TData>,归一化嵌套字段(如 myErrorBook → ErrorBookItem[]
* - 变更函数返回 { run, loading, error }run 抛 ApiError 表示业务失败
*
* 关联spec §2.2、§5.6
*/
import { useWidgetQuery } from "@/lib/useWidgetQuery";
import { useWidgetMutation } from "@/lib/useWidgetMutation";
import type { UseQueryResult } from "./types";
import { ApiError } from "./errors";
import {
GET_ERROR_BOOK_DOC,
MARK_ERROR_MASTERED_DOC,
GET_LEARNING_PATH_DOC,
GET_ELECTIVE_COURSES_DOC,
ENROLL_COURSE_DOC,
DROP_COURSE_DOC,
GET_AI_SESSIONS_DOC,
SEND_MESSAGE_DOC,
} from "./operations/student.graphql";
// ===== 共享类型 =====
/** 错题本条目 */
export interface ErrorBookItem {
id: string;
question: string;
myAnswer: string;
correctAnswer: string;
errorCount: number;
lastErrorAt: string;
subject: string;
}
/** 学习节点类型 */
export type LearningNodeType = "lesson" | "practice" | "assessment";
/** 学习节点状态 */
export type LearningNodeStatus =
"locked" | "available" | "in_progress" | "completed";
/** 学习节点 */
export interface LearningNode {
id: string;
title: string;
type: LearningNodeType;
status: LearningNodeStatus;
dependencies: string[];
}
/** 学习路径(含节点列表与整体进度) */
export interface LearningPath {
nodes: LearningNode[];
progress: number;
}
/** 课程分类 */
export type CourseCategory = "必修" | "选修" | "拓展";
/** 选修课程 */
export interface Course {
id: string;
name: string;
teacher: string;
capacity: number;
enrolled: number;
schedule: string;
credits: number;
category: CourseCategory;
}
/** AI 辅导会话 */
export interface AiSession {
id: string;
title: string;
lastMessage: string;
updatedAt: string;
}
/** AI 辅导回复 */
export interface AiTutorReply {
sessionId: string;
reply: string;
}
// ===== error-book =====
export interface UseErrorBookOptions {
/** 科目筛选null 表示全部) */
subjectId?: string | null;
/** 返回条数上限 */
limit?: number;
/** 是否启用查询false 时跳过) */
enabled?: boolean;
}
/**
* 查询当前学生的错题本列表。
* 归一化:返回 ErrorBookItem[](从 myErrorBook 字段提取)。
*/
export function useErrorBook(
options: UseErrorBookOptions = {},
): UseQueryResult<ErrorBookItem[]> {
const { subjectId = null, limit = 20, enabled } = options;
const result = useWidgetQuery<
{ myErrorBook: ErrorBookItem[] },
{ subjectId: string | null; limit: number }
>(GET_ERROR_BOOK_DOC, { subjectId, limit }, { enabled });
return {
...result,
data: result.data?.myErrorBook,
};
}
/**
* 标记错题为已掌握。
* run 抛 ApiError 表示后端未确认。
*/
export function useMarkErrorMastered(): {
run: (id: string) => Promise<void>;
loading: boolean;
error: unknown;
} {
const {
run: rawRun,
loading,
error,
} = useWidgetMutation<{ markErrorMastered: boolean }, { id: string }>(
MARK_ERROR_MASTERED_DOC,
);
const run = async (id: string): Promise<void> => {
const data = await rawRun({ id });
if (!data?.markErrorMastered) {
throw new ApiError("Failed to mark error as mastered", "INTERNAL_ERROR");
}
};
return { run, loading, error };
}
// ===== learning-path =====
/**
* 查询指定科目的学习路径。
* 归一化:返回 LearningPath | undefined从 myLearningPath 字段提取)。
*/
export function useLearningPath(
subjectId: string,
options: { enabled?: boolean } = {},
): UseQueryResult<LearningPath | undefined> {
const result = useWidgetQuery<
{ myLearningPath: LearningPath },
{ subjectId: string }
>(GET_LEARNING_PATH_DOC, { subjectId }, { enabled: options.enabled });
return {
...result,
data: result.data?.myLearningPath,
};
}
// ===== elective-selector =====
/**
* 查询指定学期的可选课程列表。
* 归一化:返回 Course[](从 electiveCourses 字段提取)。
*/
export function useElectiveCourses(
termId: string,
options: { enabled?: boolean } = {},
): UseQueryResult<Course[]> {
const result = useWidgetQuery<
{ electiveCourses: Course[] },
{ termId: string }
>(GET_ELECTIVE_COURSES_DOC, { termId }, { enabled: options.enabled });
return {
...result,
data: result.data?.electiveCourses,
};
}
/**
* 选课。
* run 抛 ApiError 表示选课失败。
*/
export function useEnrollCourse(): {
run: (courseId: string) => Promise<void>;
loading: boolean;
error: unknown;
} {
const {
run: rawRun,
loading,
error,
} = useWidgetMutation<{ enrollCourse: boolean }, { courseId: string }>(
ENROLL_COURSE_DOC,
);
const run = async (courseId: string): Promise<void> => {
const data = await rawRun({ courseId });
if (!data?.enrollCourse) {
throw new ApiError("Failed to enroll course", "INTERNAL_ERROR");
}
};
return { run, loading, error };
}
/**
* 退课。
* run 抛 ApiError 表示退课失败。
*/
export function useDropCourse(): {
run: (courseId: string) => Promise<void>;
loading: boolean;
error: unknown;
} {
const {
run: rawRun,
loading,
error,
} = useWidgetMutation<{ dropCourse: boolean }, { courseId: string }>(
DROP_COURSE_DOC,
);
const run = async (courseId: string): Promise<void> => {
const data = await rawRun({ courseId });
if (!data?.dropCourse) {
throw new ApiError("Failed to drop course", "INTERNAL_ERROR");
}
};
return { run, loading, error };
}
// ===== ai-tutor =====
/**
* 查询 AI 辅导会话列表。
* 归一化:返回 AiSession[](从 aiTutorSessions 字段提取)。
*/
export function useAiTutorSessions(limit: number): UseQueryResult<AiSession[]> {
const result = useWidgetQuery<
{ aiTutorSessions: AiSession[] },
{ limit: number }
>(GET_AI_SESSIONS_DOC, { limit });
return {
...result,
data: result.data?.aiTutorSessions,
};
}
/**
* 向 AI 辅导发送消息并获取回复。
* 成功时返回 AiTutorReply含 sessionId 与 reply
* run 抛 ApiError 表示发送失败。
*/
export function useSendAiTutorMessage(): {
run: (sessionId: string | null, message: string) => Promise<AiTutorReply>;
loading: boolean;
error: unknown;
} {
const {
run: rawRun,
loading,
error,
} = useWidgetMutation<
{ sendAiTutorMessage: AiTutorReply },
{ sessionId: string | null; message: string }
>(SEND_MESSAGE_DOC);
const run = async (
sessionId: string | null,
message: string,
): Promise<AiTutorReply> => {
const data = await rawRun({ sessionId, message });
if (!data?.sendAiTutorMessage) {
throw new ApiError("Failed to send AI tutor message", "INTERNAL_ERROR");
}
return data.sendAiTutorMessage;
};
return { run, loading, error };
}

View File

@@ -0,0 +1,257 @@
"use client";
/**
* Teacher domain 语义化 APIspec §2.2
*
* 将 widgets/teacher/* 中内嵌的 gql / interface / useWidgetQuery 调用
* 收敛为本文件导出的语义化 Hook。widget 只需 import 本文件即可。
*
* 关联portal-shell spec §2.2、§5.6 统一 Hook
*/
import { useWidgetQuery, type UseWidgetQueryOptions } from "../useWidgetQuery";
import { useWidgetMutation } from "../useWidgetMutation";
import { ApiError } from "./errors";
import type { UseQueryResult } from "./types";
import {
GET_LESSON_PLANS_DOC,
SAVE_LESSON_PLAN_DOC,
GET_QUESTIONS_DOC,
GET_TEXTBOOKS_DOC,
GET_SCHEDULING_RULES_DOC,
UPDATE_SCHEDULING_RULE_DOC,
} from "./operations/teacher.graphql";
/**
* 查询 options透传 enabled / pollInterval / fetchPolicy。
* 不含 fallbackData其类型为归一化后的 T[],与 useWidgetQuery 期望的
* 原始 { field: T[] } 形状不匹配widgets 当前未使用 fallbackData
*/
type TeacherQueryOptions<T> = Omit<UseWidgetQueryOptions<T>, "fallbackData">;
// ===== Lesson Plan =====
export interface LessonPlan {
id: string;
title: string;
objectives: string;
content: string;
resources: string[];
}
export interface SaveLessonPlanInput {
classId: string;
id?: string;
title: string;
objectives: string;
content: string;
resources: string[];
}
/**
* 查询班级下的备课列表。
* classId 为空时自动跳过查询(保留 widget 的骨架屏分支)。
*/
export function useLessonPlans(
classId: string,
options?: TeacherQueryOptions<LessonPlan[]>,
): UseQueryResult<LessonPlan[]> {
const result = useWidgetQuery<
{ lessonPlans: LessonPlan[] },
{ classId: string; unitId?: string }
>(
GET_LESSON_PLANS_DOC,
{ classId },
{ ...options, enabled: options?.enabled ?? classId.length > 0 },
);
return {
...result,
data: result.data?.lessonPlans,
};
}
/**
* 保存备课(新建或更新)。
* run 抛出 ApiError 时widget 层可用 try/catch 捕获并 toast。
*/
export function useSaveLessonPlan(): {
run: (input: SaveLessonPlanInput) => Promise<{ id: string }>;
loading: boolean;
error: unknown;
} {
const {
run: rawRun,
loading,
error,
} = useWidgetMutation<
{ saveLessonPlan: { id: string } },
{ input: SaveLessonPlanInput }
>(SAVE_LESSON_PLAN_DOC);
const run = async (input: SaveLessonPlanInput): Promise<{ id: string }> => {
const data = await rawRun({ input });
if (!data?.saveLessonPlan) {
throw new ApiError("Failed to save lesson plan", "INTERNAL_ERROR");
}
return data.saveLessonPlan;
};
return { run, loading, error };
}
// ===== Question Bank =====
export interface Question {
id: string;
type: string;
difficulty: string;
content: string;
options: string[];
answer: string;
tags: string[];
}
export interface QuestionBankFilter {
type?: string;
limit?: number;
}
/**
* 查询题库下的题目列表。
* bankId 为空时自动跳过查询。
*/
export function useQuestionBank(
bankId: string,
filter?: QuestionBankFilter,
options?: TeacherQueryOptions<Question[]>,
): UseQueryResult<Question[]> {
const result = useWidgetQuery<
{ questions: Question[] },
{ bankId: string; type?: string; limit?: number }
>(
GET_QUESTIONS_DOC,
{ bankId, type: filter?.type, limit: filter?.limit },
{ ...options, enabled: options?.enabled ?? bankId.length > 0 },
);
return {
...result,
data: result.data?.questions,
};
}
// ===== Textbook =====
export interface Chapter {
id: string;
title: string;
}
export interface Textbook {
id: string;
title: string;
author: string;
publisher: string;
isbn: string;
chapters: Chapter[];
}
export interface TextbookFilter {
subjectId?: string;
grade?: string;
}
/**
* 查询教材列表,可按科目与年级筛选。
*/
export function useTextbooks(
filter?: TextbookFilter,
options?: TeacherQueryOptions<Textbook[]>,
): UseQueryResult<Textbook[]> {
const result = useWidgetQuery<
{ textbooks: Textbook[] },
{ subjectId?: string; grade?: string }
>(
GET_TEXTBOOKS_DOC,
{ subjectId: filter?.subjectId, grade: filter?.grade },
options,
);
return {
...result,
data: result.data?.textbooks,
};
}
// ===== Scheduling Rules =====
export interface SchedulingRule {
id: string;
dayOfWeek: number;
periods: string;
subject: string;
teacherId: string;
room: string;
}
export interface SchedulingRuleInput {
dayOfWeek: number;
periods: string;
subject: string;
teacherId: string;
room: string;
}
/**
* 查询班级排课规则列表。
* classId 为空时自动跳过查询。
*/
export function useSchedulingRules(
classId: string,
options?: TeacherQueryOptions<SchedulingRule[]>,
): UseQueryResult<SchedulingRule[]> {
const result = useWidgetQuery<
{ schedulingRules: SchedulingRule[] },
{ classId: string }
>(
GET_SCHEDULING_RULES_DOC,
{ classId },
{ ...options, enabled: options?.enabled ?? classId.length > 0 },
);
return {
...result,
data: result.data?.schedulingRules,
};
}
/**
* 更新单条排课规则。
* run 抛出 ApiError 时widget 层可用 try/catch 捕获并 toast。
*/
export function useUpdateSchedulingRule(): {
run: (vars: {
id: string;
input: SchedulingRuleInput;
}) => Promise<{ id: string }>;
loading: boolean;
error: unknown;
} {
const {
run: rawRun,
loading,
error,
} = useWidgetMutation<
{ updateSchedulingRule: { id: string } },
{ id: string; input: SchedulingRuleInput }
>(UPDATE_SCHEDULING_RULE_DOC);
const run = async (vars: {
id: string;
input: SchedulingRuleInput;
}): Promise<{ id: string }> => {
const data = await rawRun(vars);
if (!data?.updateSchedulingRule) {
throw new ApiError("Failed to update scheduling rule", "INTERNAL_ERROR");
}
return data.updateSchedulingRule;
};
return { run, loading, error };
}

View File

@@ -0,0 +1,135 @@
"use client";
/**
* Topbar domain API
*
* 涵盖useNotificationBell、useCurrentUser、useGlobalSearch
*
* widget 通过 `import { useNotificationBell } from "@/lib/api/topbar"` 调用,
* 不再各自内嵌 gql/接口/手写类型。
*
* 注意useNotificationBell 与 universal.useNotifications分页通知列表语义不同
* 前者是 topbar 铃铛 widget 专用(按 limit 取最近 N 条),后者是分页查询。
*
* 关联spec §2.2
*/
import {
GET_NOTIFICATIONS_DOC,
GET_CURRENT_USER_DOC,
SEARCH_DOC,
} from "@/lib/api/operations/topbar.graphql";
import { useWidgetQuery } from "@/lib/useWidgetQuery";
import type { UseQueryResult } from "./types";
// ===== 领域模型类型 =====
export interface NotificationItem {
id: string;
title: string;
}
export interface CurrentUser {
id: string;
name: string;
email: string;
role: string;
}
export interface SearchResult {
id: string;
type: string;
title: string;
subtitle: string;
}
// ===== 内部 Query 类型 =====
interface NotificationsQueryData {
notifications: NotificationItem[];
}
type NotificationsQueryVars = {
limit: number;
};
interface CurrentUserQueryData {
me: CurrentUser | null;
}
type CurrentUserQueryVars = Record<string, never>;
interface SearchQueryData {
search: SearchResult[];
}
type SearchQueryVars = {
keyword: string;
limit: number;
};
// ===== Hooks =====
/**
* 查询通知列表topbar 铃铛 widget 专用,按 limit 取最近 N 条)。
*
* 关联portal-shell spec §5.6 统一 Hook
*/
export function useNotificationBell(
limit: number,
): UseQueryResult<NotificationItem[]> {
const result = useWidgetQuery<NotificationsQueryData, NotificationsQueryVars>(
GET_NOTIFICATIONS_DOC,
{ limit },
);
return {
data: result.data?.notifications,
loading: result.loading,
error: result.error,
refetch: result.refetch,
};
}
/**
* 查询当前登录用户iam 子图 me 字段)。
*
* 关联portal-shell spec §5.6 统一 Hook
*/
export function useCurrentUser(): UseQueryResult<CurrentUser | null> {
const result = useWidgetQuery<CurrentUserQueryData, CurrentUserQueryVars>(
GET_CURRENT_USER_DOC,
{},
);
return {
data: result.data?.me,
loading: result.loading,
error: result.error,
refetch: result.refetch,
};
}
/**
* 全局搜索。
*
* `keyword` 为空时不发起查询enabled: false避免无效请求。
*
* 关联portal-shell spec §5.6 统一 Hook
*/
export function useGlobalSearch(
keyword: string,
limit: number,
): UseQueryResult<SearchResult[]> {
const result = useWidgetQuery<SearchQueryData, SearchQueryVars>(
SEARCH_DOC,
{ keyword, limit },
{ enabled: keyword.length > 0 },
);
return {
data: result.data?.search,
loading: result.loading,
error: result.error,
refetch: result.refetch,
};
}

View File

@@ -0,0 +1,321 @@
"use client";
/**
* Universal domain API
*
* 涵盖 7 个 universal widget 的查询函数:
* - useGrades / useHomework / useSchedule / useAttendance / useExams
* - useNotifications / useAnnouncements
*
* widget 通过 `import { useGrades } from "@/lib/api"` 调用,
* 不再各自内嵌 gql/接口/手写类型。
*
* universal domain 全部为查询,无 mutation。
*
* 关联spec §2.2、§5.6 统一 Hook、M8 验收
*/
import type { FetchPolicy } from "@apollo/client";
import {
GET_GRADES_DOC,
GET_HOMEWORKS_DOC,
GET_SCHEDULE_DOC,
GET_ATTENDANCE_DOC,
GET_EXAMS_DOC,
GET_NOTIFICATIONS_LIST_DOC,
GET_ANNOUNCEMENTS_DOC,
} from "@/lib/api/operations/universal.graphql";
import { useWidgetQuery } from "@/lib/useWidgetQuery";
import type { Pagination, UseQueryResult } from "./types";
// ===== 领域模型类型 =====
export interface Grade {
studentId: string;
score: number;
}
export interface Homework {
id: string;
title: string;
dueDate: string;
status: string;
}
export interface ScheduleItem {
id: string;
subject: string;
startTime: string;
endTime: string;
teacherName: string;
}
export interface AttendanceStats {
present: number;
absent: number;
late: number;
total: number;
}
export interface Exam {
id: string;
name: string;
examDate: string;
subject: string;
maxScore: number;
}
export interface Notification {
id: string;
title: string;
body: string;
createdAt: string;
type: string;
}
export interface NotificationList {
items: Notification[];
total: number;
}
export interface Announcement {
id: string;
title: string;
body: string;
author: string;
publishedAt: string;
}
// ===== 查询选项(透传 useWidgetQuery但 fallbackData 由本层处理) =====
export interface UniversalQueryOptions {
/** 是否启用查询false 时跳过) */
enabled?: boolean;
/** 轮询间隔ms */
pollInterval?: number;
/** Apollo fetchPolicy */
fetchPolicy?: FetchPolicy;
}
// ===== 内部 Query 类型codegen skipDocumentsValidation用 inline 类型) =====
// 注意Vars 使用 type alias 而非 interface以满足 useWidgetQuery 的
// `TVars extends Record<string, unknown>` 约束known-issues §2.17 TS2344
interface GradesQueryData {
grades: Grade[];
}
type GradesQueryVars = {
classId: string;
};
interface HomeworksQueryData {
homeworks: Homework[];
}
type HomeworksQueryVars = {
classId: string;
limit: number;
};
interface ScheduleQueryData {
schedule: ScheduleItem[];
}
type ScheduleQueryVars = {
classId: string;
dayOfWeek: number;
};
interface AttendanceQueryData {
attendance: AttendanceStats;
}
type AttendanceQueryVars = {
classId: string;
termId: string;
};
interface ExamsQueryData {
exams: Exam[];
}
type ExamsQueryVars = {
classId: string;
limit: number;
};
interface NotificationsListQueryData {
notifications: NotificationList;
}
type NotificationsListQueryVars = {
limit: number;
offset: number;
};
interface AnnouncementsQueryData {
announcements: Announcement[];
}
type AnnouncementsQueryVars = {
limit: number;
};
// ===== Hooks =====
/**
* 查询班级成绩列表。
*
* 关联portal-shell spec §5.6 统一 Hook、M8 验收
*/
export function useGrades(
classId: string,
options?: UniversalQueryOptions,
): UseQueryResult<Grade[]> {
const result = useWidgetQuery<GradesQueryData, GradesQueryVars>(
GET_GRADES_DOC,
{ classId },
options,
);
return {
data: result.data?.grades,
loading: result.loading,
error: result.error,
refetch: result.refetch,
};
}
/**
* 查询班级作业列表,可指定返回条数。
*
* 关联portal-shell spec §5.6 统一 Hook、M8 验收
*/
export function useHomework(
classId: string,
limit: number,
options?: UniversalQueryOptions,
): UseQueryResult<Homework[]> {
const result = useWidgetQuery<HomeworksQueryData, HomeworksQueryVars>(
GET_HOMEWORKS_DOC,
{ classId, limit },
options,
);
return {
data: result.data?.homeworks,
loading: result.loading,
error: result.error,
refetch: result.refetch,
};
}
/**
* 查询班级当日课表(按星期几过滤)。
*
* 关联portal-shell spec §5.6 统一 Hook、M8 验收
*/
export function useSchedule(
classId: string,
dayOfWeek: number,
options?: UniversalQueryOptions,
): UseQueryResult<ScheduleItem[]> {
const result = useWidgetQuery<ScheduleQueryData, ScheduleQueryVars>(
GET_SCHEDULE_DOC,
{ classId, dayOfWeek },
options,
);
return {
data: result.data?.schedule,
loading: result.loading,
error: result.error,
refetch: result.refetch,
};
}
/**
* 查询班级学期考勤统计。
*
* 关联portal-shell spec §5.6 统一 Hook、M8 验收
*/
export function useAttendance(
classId: string,
termId: string,
options?: UniversalQueryOptions,
): UseQueryResult<AttendanceStats> {
const result = useWidgetQuery<AttendanceQueryData, AttendanceQueryVars>(
GET_ATTENDANCE_DOC,
{ classId, termId },
options,
);
return {
data: result.data?.attendance,
loading: result.loading,
error: result.error,
refetch: result.refetch,
};
}
/**
* 查询班级考试列表,可指定返回条数。
*
* 关联portal-shell spec §5.6 统一 Hook、M8 验收
*/
export function useExams(
classId: string,
limit: number,
options?: UniversalQueryOptions,
): UseQueryResult<Exam[]> {
const result = useWidgetQuery<ExamsQueryData, ExamsQueryVars>(
GET_EXAMS_DOC,
{ classId, limit },
options,
);
return {
data: result.data?.exams,
loading: result.loading,
error: result.error,
refetch: result.refetch,
};
}
/**
* 查询通知列表(分页),返回 items + total。
*
* 关联portal-shell spec §5.6 统一 Hook、M8 验收
*/
export function useNotifications(
pagination: Pagination,
): UseQueryResult<NotificationList> {
const result = useWidgetQuery<
NotificationsListQueryData,
NotificationsListQueryVars
>(GET_NOTIFICATIONS_LIST_DOC, {
limit: pagination.limit,
offset: pagination.offset,
});
return {
data: result.data?.notifications,
loading: result.loading,
error: result.error,
refetch: result.refetch,
};
}
/**
* 查询公告列表,可指定返回条数。
*
* 关联portal-shell spec §5.6 统一 Hook、M8 验收
*/
export function useAnnouncements(
limit: number,
): UseQueryResult<Announcement[]> {
const result = useWidgetQuery<AnnouncementsQueryData, AnnouncementsQueryVars>(
GET_ANNOUNCEMENTS_DOC,
{ limit },
);
return {
data: result.data?.announcements,
loading: result.loading,
error: result.error,
refetch: result.refetch,
};
}

View File

@@ -0,0 +1,199 @@
"use client";
/**
* audit-logsadmin / main
*
* 审计日志查看。展示用户操作记录,支持按 userId / action / resource 筛选 + 分页。
* 只读,无变更操作。
*
* 数据流useAuditLogs → Apollo Client → apollo-router → iam 子图 auditLogs
*
* 关联portal-shell spec §3 admin 分类、§5.6 统一 Hook
*/
import { useState } from "react";
import { useAuditLogs } from "@/lib/api/admin";
import { PluginSkeleton } from "@/shell/PluginLoader";
import type { PluginProps } from "@/lib/types";
const ACTION_OPTIONS = [
"create",
"update",
"delete",
"login",
"logout",
"export",
] as const;
const RESOURCE_OPTIONS = [
"user",
"role",
"plugin",
"class",
"exam",
"grade",
"homework",
] as const;
const inputCls =
"rounded-button border border-rule bg-surface px-sm py-xs text-small text-ink";
export default function AuditLogs(props: PluginProps): React.ReactElement {
const rawPageSize = props.props.pageSize;
const pageSize =
typeof rawPageSize === "number" && rawPageSize > 0 ? rawPageSize : 20;
const [userIdFilter, setUserIdFilter] = useState<string>("");
const [actionFilter, setActionFilter] = useState<string>("");
const [resourceFilter, setResourceFilter] = useState<string>("");
const [offset, setOffset] = useState<number>(0);
const [selectedId, setSelectedId] = useState<string | null>(null);
const filter = {
userId: userIdFilter.length > 0 ? userIdFilter : null,
action: actionFilter.length > 0 ? actionFilter : null,
resource: resourceFilter.length > 0 ? resourceFilter : null,
};
const { data, loading } = useAuditLogs(filter, {
limit: pageSize,
offset,
});
const handleFilterChange = (
setter: (v: string) => void,
): ((e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => void) => {
return (e) => {
setter(e.target.value);
setOffset(0);
setSelectedId(null);
};
};
if (loading && !data) {
return <PluginSkeleton variant="table" />;
}
const logs = data?.items ?? [];
const total = data?.total ?? 0;
const hasPrev = offset > 0;
const hasNext = offset + pageSize < total;
const rangeEnd = Math.min(offset + pageSize, total);
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>
<div className="mt-sm flex flex-wrap gap-sm">
<input
type="text"
value={userIdFilter}
onChange={handleFilterChange(setUserIdFilter)}
placeholder="按用户 ID 筛选"
className={`${inputCls} w-48`}
aria-label="按用户 ID 筛选"
/>
<select
value={actionFilter}
onChange={handleFilterChange(setActionFilter)}
className={inputCls}
aria-label="按操作筛选"
>
<option value=""></option>
{ACTION_OPTIONS.map((a) => (
<option key={a} value={a}>
{a}
</option>
))}
</select>
<select
value={resourceFilter}
onChange={handleFilterChange(setResourceFilter)}
className={inputCls}
aria-label="按资源筛选"
>
<option value=""></option>
{RESOURCE_OPTIONS.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
</div>
{logs.length === 0 ? (
<p className="mt-sm text-small text-ink-muted"></p>
) : (
<div className="mt-sm overflow-x-auto">
<table className="w-full text-tiny">
<thead>
<tr className="border-b border-rule text-ink-muted">
<th className="py-xs text-left"></th>
<th className="py-xs text-left"></th>
<th className="py-xs text-left"></th>
<th className="py-xs text-left"></th>
<th className="py-xs text-left">IP</th>
</tr>
</thead>
<tbody>
{logs.map((log) => {
const isSelected = log.id === selectedId;
return (
<tr
key={log.id}
onClick={() => setSelectedId(isSelected ? null : log.id)}
className={`cursor-pointer border-b border-rule ${
isSelected ? "bg-accent-subtle" : "bg-paper"
}`}
>
<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">
{log.resource}
{log.resourceId ? ` / ${log.resourceId}` : ""}
</td>
<td className="py-xs text-ink-muted">{log.ip}</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
{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">
{selectedLog.details || "(无详细信息)"}
</pre>
</div>
) : null}
<div className="mt-sm flex items-center justify-between text-tiny text-ink-muted">
<span>
{total} {offset + 1} - {rangeEnd}
</span>
<div className="flex gap-sm">
<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"
>
</button>
<button
type="button"
disabled={!hasNext}
onClick={() => setOffset(offset + pageSize)}
className="rounded-button border border-rule bg-surface px-sm py-xs text-ink"
>
</button>
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,35 @@
/**
* audit-logs 插件清单admin
*
* 审计日志查看,插入 main slot。支持按用户/操作/资源筛选 + 分页。
* 查询经 apollo-router → iam 子图 auditLogs。
*
* 关联portal-shell spec §3 admin 分类、§5.6 统一 Hook
*/
import type { PluginManifest } from "@/lib/types";
export const manifestMeta: Omit<PluginManifest, "Component"> = {
pluginId: "audit-logs",
version: "0.1.0",
requiredShellVersion: "^1.0.0",
metadata: {
displayName: "审计日志",
description: "查看用户操作审计记录,支持按用户/操作/资源筛选",
category: "admin",
requiredRoles: ["admin"],
defaultSlot: "main",
defaultSize: { colSpan: 2, rowSpan: 2 },
defaultProps: {
pageSize: 20,
},
propsSchema: {
type: "object",
properties: {
pageSize: {
type: "number",
description: "每页显示条数10/20/50",
},
},
},
},
};

View File

@@ -0,0 +1,231 @@
"use client";
/**
* invitation-codesadmin / main
*
* 邀请码管理。展示邀请码列表,支持生成新码(指定角色/有效期/次数)与撤销。
*
* 数据流useInvitationCodes/useCreateInvitationCode/useRevokeInvitationCode
* → Apollo Client → apollo-router → iam 子图
*
* 关联portal-shell spec §3 admin 分类、§5.6 统一 Hook
*/
import { useState } from "react";
import {
useCreateInvitationCode,
useInvitationCodes,
useRevokeInvitationCode,
} from "@/lib/api/admin";
import { PluginSkeleton } from "@/shell/PluginLoader";
import type { PluginProps } from "@/lib/types";
type CodeStatus = "active" | "revoked" | "expired" | "all";
const STATUS_OPTIONS: CodeStatus[] = ["all", "active", "revoked", "expired"];
const STATUS_LABELS: Record<CodeStatus, string> = {
all: "全部",
active: "可用",
revoked: "已撤销",
expired: "已过期",
};
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";
export default function InvitationCodes(
props: PluginProps,
): React.ReactElement {
const defaultMaxUses =
typeof props.props.defaultMaxUses === "number"
? (props.props.defaultMaxUses as number)
: 1;
const defaultTtlHours =
typeof props.props.defaultTtlHours === "number"
? (props.props.defaultTtlHours as number)
: 72;
const [statusFilter, setStatusFilter] = useState<CodeStatus>("all");
const [newRole, setNewRole] = useState<string>("teacher");
const [newMaxUses, setNewMaxUses] = useState<number>(defaultMaxUses);
const [newTtlHours, setNewTtlHours] = useState<number>(defaultTtlHours);
const [busyId, setBusyId] = useState<string | null>(null);
const [status, setStatus] = useState<string>("");
const { data, loading, refetch } = useInvitationCodes(
statusFilter === "all" ? null : statusFilter,
);
const { run: createCode } = useCreateInvitationCode();
const { run: revokeCode } = useRevokeInvitationCode();
const handleCreate = async (): Promise<void> => {
setStatus("生成中...");
try {
await createCode({
role: newRole,
maxUses: newMaxUses,
ttlHours: newTtlHours,
});
setStatus("已生成邀请码");
await refetch();
} catch {
setStatus("生成失败");
}
};
const handleRevoke = async (id: string): Promise<void> => {
setBusyId(id);
try {
await revokeCode(id);
await refetch();
} finally {
setBusyId(null);
}
};
const handleCopy = (code: string): void => {
if (typeof navigator === "undefined" || !navigator.clipboard) return;
void navigator.clipboard.writeText(code);
setStatus(`已复制:${code}`);
};
if (loading && !data) {
return <PluginSkeleton variant="table" />;
}
const codes = data ?? [];
return (
<section className="rounded-card border border-rule bg-surface p-md">
<h3 className="text-heading-3 text-ink"></h3>
{status.length > 0 ? (
<p className="mt-xs text-tiny text-ink-muted">{status}</p>
) : null}
{/* 生成新邀请码 */}
<div className="mt-sm rounded-button bg-paper p-sm">
<p className={labelCls}></p>
<div className="mt-xs flex flex-wrap items-end gap-sm">
<div className="space-y-xs">
<label className={labelCls}></label>
<select
value={newRole}
onChange={(e) => setNewRole(e.target.value)}
className={inputCls}
>
{ROLE_OPTIONS.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
</div>
<div className="space-y-xs">
<label className={labelCls}></label>
<input
type="number"
min={1}
value={newMaxUses}
onChange={(e) => setNewMaxUses(Number(e.target.value))}
className={`${inputCls} w-24`}
/>
</div>
<div className="space-y-xs">
<label className={labelCls}></label>
<input
type="number"
min={1}
value={newTtlHours}
onChange={(e) => setNewTtlHours(Number(e.target.value))}
className={`${inputCls} w-28`}
/>
</div>
<button
type="button"
onClick={handleCreate}
className="rounded-button bg-accent px-md py-xs text-small text-ink-onAccent"
>
</button>
</div>
</div>
{/* 筛选 */}
<div className="mt-md flex items-center gap-sm">
<label className={labelCls}></label>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as CodeStatus)}
className={inputCls}
aria-label="按状态筛选"
>
{STATUS_OPTIONS.map((s) => (
<option key={s} value={s}>
{STATUS_LABELS[s]}
</option>
))}
</select>
</div>
{/* 邀请码列表 */}
{codes.length === 0 ? (
<p className="mt-sm text-small text-ink-muted"></p>
) : (
<div className="mt-sm overflow-x-auto">
<table className="w-full text-small">
<thead>
<tr className="border-b border-rule text-ink-muted">
<th className="py-xs text-left"></th>
<th className="py-xs text-left"></th>
<th className="py-xs text-left"></th>
<th className="py-xs text-left">使/</th>
<th className="py-xs text-left"></th>
<th className="py-xs text-left"></th>
</tr>
</thead>
<tbody>
{codes.map((c) => {
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">
{c.usedCount} / {c.maxUses}
</td>
<td className="py-xs text-ink-muted">{c.expiresAt}</td>
<td className="py-xs">
<div className="flex gap-sm">
<button
type="button"
onClick={() => handleCopy(c.code)}
className="rounded-button border border-rule bg-surface px-sm py-xs text-tiny text-ink"
>
</button>
{canRevoke ? (
<button
type="button"
disabled={isBusy}
onClick={() => handleRevoke(c.id)}
className="rounded-button bg-danger px-sm py-xs text-tiny text-ink-onAccent"
>
</button>
) : null}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</section>
);
}

View File

@@ -0,0 +1,40 @@
/**
* invitation-codes 插件清单admin
*
* 邀请码管理,插入 main slot。生成/查看/撤销邀请码。
* 查询/变更经 apollo-router → iam 子图 invitationCodes。
*
* 关联portal-shell spec §3 admin 分类、§5.6 统一 Hook
*/
import type { PluginManifest } from "@/lib/types";
export const manifestMeta: Omit<PluginManifest, "Component"> = {
pluginId: "invitation-codes",
version: "0.1.0",
requiredShellVersion: "^1.0.0",
metadata: {
displayName: "邀请码",
description: "生成并管理用户注册邀请码,支持角色/有效期/次数配置",
category: "admin",
requiredRoles: ["admin"],
defaultSlot: "main",
defaultSize: { colSpan: 2, rowSpan: 1 },
defaultProps: {
defaultMaxUses: 1,
defaultTtlHours: 72,
},
propsSchema: {
type: "object",
properties: {
defaultMaxUses: {
type: "number",
description: "默认最大使用次数1=一次性,>1=批量)",
},
defaultTtlHours: {
type: "number",
description: "默认有效期(小时)",
},
},
},
},
};

View File

@@ -0,0 +1,451 @@
"use client";
/**
* plugin-manageradmin / main
*
* admin 配置面板。管理插件注册表、角色-插件映射、Layout 模板、用户布局覆盖。
* 对应 portal-shell spec §6.3 配置面板。
*
* 关联portal-shell spec §6.3、§5.6 统一 Hook
*/
import { useEffect, useState } from "react";
import {
useLayoutTemplates,
usePluginRegistry,
useResetUserLayoutOverride,
useRoleLayoutDefault,
useRolePluginMapping,
useUpdatePluginRegistry,
useUpdateRoleLayoutDefault,
useUpdateRolePluginMapping,
} from "@/lib/api/admin";
import type {
RegistryItem,
RoleMapping,
RolePluginMappingInput,
} from "@/lib/api/admin";
import { PluginSkeleton } from "@/shell/PluginLoader";
import type { PluginProps } from "@/lib/types";
const ALL_ROLES = ["admin", "teacher", "student", "parent"] as const;
type TabKey = "registry" | "mapping" | "layout" | "user";
const TAB_LABELS: Record<TabKey, string> = {
registry: "插件注册表",
mapping: "角色-插件映射",
layout: "Layout 模板",
user: "用户布局",
};
function RegistryTab(): React.ReactElement {
const { data, loading, refetch } = usePluginRegistry();
const { run: update } = useUpdatePluginRegistry();
const [editingId, setEditingId] = useState<string | null>(null);
const [draftProps, setDraftProps] = useState<string>("");
const [draftError, setDraftError] = useState<string>("");
const handleToggleActive = async (item: RegistryItem): Promise<void> => {
await update(item.pluginId, { isActive: !item.isActive });
await refetch();
};
const handleStartEdit = (item: RegistryItem): void => {
setEditingId(item.pluginId);
setDraftProps(JSON.stringify(item.defaultProps, null, 2));
setDraftError("");
};
const handleSaveProps = async (pluginId: string): Promise<void> => {
let parsed: Record<string, unknown>;
try {
parsed = JSON.parse(draftProps) as Record<string, unknown>;
} catch {
setDraftError("无效 JSON 格式,请检查输入");
return;
}
setDraftError("");
await update(pluginId, { defaultProps: parsed });
setEditingId(null);
await refetch();
};
if (loading && !data) {
return <PluginSkeleton variant="table" />;
}
const items = data ?? [];
return (
<div className="space-y-md">
{items.length === 0 ? (
<p className="text-small text-ink-muted"></p>
) : (
items.map((item) => (
<div
key={item.pluginId}
className="rounded-card border border-rule bg-paper p-md"
>
<div className="flex items-center justify-between">
<div>
<p className="text-body text-ink">
{item.displayName}
{item.isBuiltin ? (
<span className="ml-xs text-tiny text-ink-muted"></span>
) : null}
</p>
<p className="text-tiny text-ink-muted">
{item.pluginId} · v{item.version} · {item.category} ·{" "}
{item.defaultSlot}
</p>
<p className="mt-xs text-small text-ink-muted">
{item.description}
</p>
</div>
<div className="flex items-center gap-sm">
<label className="flex items-center gap-xs text-small text-ink">
<input
type="checkbox"
checked={item.isActive}
onChange={() => handleToggleActive(item)}
/>
</label>
<button
type="button"
onClick={() =>
editingId === item.pluginId
? setEditingId(null)
: handleStartEdit(item)
}
className="rounded-button border border-rule bg-surface px-sm py-xs text-tiny text-ink"
>
{editingId === item.pluginId ? "取消" : "编辑 props"}
</button>
</div>
</div>
{editingId === item.pluginId ? (
<div className="mt-sm space-y-xs">
<label className="text-tiny text-ink-muted">
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"
/>
{draftError.length > 0 ? (
<p className="text-tiny text-ink-muted">{draftError}</p>
) : null}
<button
type="button"
onClick={() => handleSaveProps(item.pluginId)}
className="rounded-button bg-accent px-md py-xs text-tiny text-ink-onAccent"
>
</button>
</div>
) : null}
</div>
))
)}
</div>
);
}
function MappingTab(): React.ReactElement {
const [selectedRole, setSelectedRole] = useState<string>("parent");
const [drafts, setDrafts] = useState<Record<string, RoleMapping>>({});
const { data, loading, refetch } = useRolePluginMapping(selectedRole);
const { run: updateMappings } = useUpdateRolePluginMapping();
// 查询结果同步到本地草稿
useEffect(() => {
const map: Record<string, RoleMapping> = {};
for (const m of data ?? []) {
map[m.pluginId] = { ...m };
}
setDrafts(map);
}, [data]);
const updateDraft = (pluginId: string, patch: Partial<RoleMapping>): void => {
setDrafts((prev) => {
const existing = prev[pluginId];
if (existing === undefined) {
return prev;
}
return { ...prev, [pluginId]: { ...existing, ...patch } };
});
};
const handleSave = async (): Promise<void> => {
const mappings: RolePluginMappingInput[] = Object.values(drafts).map(
(m) => ({
pluginId: m.pluginId,
slot: m.slot,
sortOrder: m.sortOrder,
isEnabled: m.isEnabled,
widgetProps: m.widgetProps,
}),
);
await updateMappings(selectedRole, mappings);
await refetch();
};
const draftList = Object.values(drafts);
const selectCls =
"rounded-button border border-rule bg-surface px-sm py-xs text-small text-ink";
return (
<div className="space-y-md">
<div className="flex items-center gap-sm">
<label className="text-small text-ink-muted"></label>
<select
value={selectedRole}
onChange={(e) => setSelectedRole(e.target.value)}
className={selectCls}
>
{ALL_ROLES.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
</div>
{loading && !data ? (
<PluginSkeleton variant="table" />
) : draftList.length === 0 ? (
<p className="text-small text-ink-muted"></p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-small">
<thead>
<tr className="border-b border-rule text-ink-muted">
<th className="py-xs text-left"></th>
<th className="py-xs text-left"></th>
<th className="py-xs text-left">Slot</th>
<th className="py-xs text-left"></th>
</tr>
</thead>
<tbody>
{draftList.map((m) => (
<tr key={m.pluginId} className="border-b border-rule">
<td className="py-xs text-ink">{m.pluginId}</td>
<td className="py-xs">
<input
type="checkbox"
checked={m.isEnabled}
onChange={(e) =>
updateDraft(m.pluginId, { isEnabled: e.target.checked })
}
/>
</td>
<td className="py-xs">
<input
type="text"
value={m.slot}
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"
/>
</td>
<td className="py-xs">
<input
type="number"
value={m.sortOrder}
onChange={(e) =>
updateDraft(m.pluginId, {
sortOrder: Number(e.target.value),
})
}
className="w-full rounded-button border border-rule bg-surface px-sm py-xs text-tiny text-ink"
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<button
type="button"
onClick={handleSave}
className="rounded-button bg-accent px-md py-xs text-small text-ink-onAccent"
>
</button>
</div>
);
}
function LayoutTab(): React.ReactElement {
const [selectedRole, setSelectedRole] = useState<string>("parent");
const [status, setStatus] = useState<string>("");
const templatesResult = useLayoutTemplates();
const layoutResult = useRoleLayoutDefault(selectedRole);
const { run: updateLayout } = useUpdateRoleLayoutDefault();
const templates = templatesResult.data ?? [];
const currentLayoutId = layoutResult.data?.layoutId ?? "";
const handleSetLayout = async (layoutId: string): Promise<void> => {
setStatus("保存中...");
try {
await updateLayout(selectedRole, layoutId);
setStatus(`已保存 ${selectedRole}${layoutId}`);
await layoutResult.refetch();
} catch {
setStatus("保存失败");
}
};
if (templatesResult.loading && !templatesResult.data) {
return <PluginSkeleton variant="card" />;
}
const selectCls =
"rounded-button border border-rule bg-surface px-sm py-xs text-small text-ink";
return (
<div className="space-y-md">
{status.length > 0 ? (
<p className="text-tiny text-ink-muted">{status}</p>
) : null}
<div className="flex items-center gap-sm">
<label className="text-small text-ink-muted"></label>
<select
value={selectedRole}
onChange={(e) => {
setSelectedRole(e.target.value);
setStatus("");
}}
className={selectCls}
>
{ALL_ROLES.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
<span className="text-tiny text-ink-muted">
{currentLayoutId || "未设置"}
</span>
</div>
<div className="grid grid-cols-2 gap-md">
{templates.map((tpl) => {
const isCurrent = tpl.layoutId === currentLayoutId;
return (
<button
key={tpl.layoutId}
type="button"
onClick={() => handleSetLayout(tpl.layoutId)}
className={`flex flex-col rounded-card border p-md text-left ${
isCurrent
? "border-accent bg-accent-subtle"
: "border-rule bg-paper"
}`}
>
<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">
Slot{tpl.availableSlots.join("、")}
</p>
{isCurrent ? (
<p className="mt-xs text-tiny text-ink"></p>
) : null}
</button>
);
})}
</div>
</div>
);
}
function UserLayoutTab(): React.ReactElement {
const [userId, setUserId] = useState<string>("");
const [status, setStatus] = useState<string>("");
const { run: reset } = useResetUserLayoutOverride();
const handleReset = async (): Promise<void> => {
if (userId.trim().length === 0) {
setStatus("请输入用户 ID");
return;
}
setStatus("重置中...");
try {
await reset(userId.trim());
setStatus(`已重置用户 ${userId.trim()} 的布局覆盖`);
setUserId("");
} catch {
setStatus("重置失败");
}
};
return (
<div className="space-y-md">
<p className="text-small text-ink-muted">
使
</p>
<div className="flex items-center gap-sm">
<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"
/>
<button
type="button"
onClick={handleReset}
className="rounded-button bg-danger px-md py-xs text-small text-ink-onAccent"
>
</button>
</div>
{status.length > 0 ? (
<p className="text-tiny text-ink-muted">{status}</p>
) : null}
</div>
);
}
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">
{(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 ${
tab === key
? "bg-accent text-ink-onAccent"
: "bg-surface text-ink"
}`}
>
{TAB_LABELS[key]}
</button>
))}
</div>
<div className="mt-sm">
{tab === "registry" ? <RegistryTab /> : null}
{tab === "mapping" ? <MappingTab /> : null}
{tab === "layout" ? <LayoutTab /> : null}
{tab === "user" ? <UserLayoutTab /> : null}
</div>
</section>
);
}

View File

@@ -0,0 +1,24 @@
/**
* plugin-manager 插件清单admin
*
* 插件管理admin 配置面板),插入 main slot。
* 管理插件注册表、角色-插件映射、Layout 模板、用户布局覆盖。
* 对应 portal-shell spec §6.3。
*/
import type { PluginManifest } from "@/lib/types";
export const manifestMeta: Omit<PluginManifest, "Component"> = {
pluginId: "plugin-manager",
version: "0.1.0",
requiredShellVersion: "^1.0.0",
metadata: {
displayName: "插件管理",
description: "管理插件注册、角色映射、Layout 模板与用户布局",
category: "admin",
requiredRoles: ["admin"],
defaultSlot: "main",
defaultSize: { colSpan: 2, rowSpan: 2 },
defaultProps: {},
propsSchema: { type: "object", properties: {} },
},
};

View File

@@ -0,0 +1,176 @@
"use client";
/**
* rbac-manageradmin / main
*
* 角色权限管理。左侧角色列表,右侧权限矩阵(行=权限,列=角色)。
* 勾选/取消勾选时调用 mutation 保存该角色的权限集合。
*
* 关联portal-shell spec §5.6 统一 Hook
*/
import { useMemo, useState } from "react";
import {
usePermissions,
useRoles,
useUpdateRolePermissions,
} from "@/lib/api/admin";
import { PluginSkeleton } from "@/shell/PluginLoader";
import type { PluginProps } from "@/lib/types";
export default function RbacManager(_props: PluginProps): React.ReactElement {
const [selectedRoleId, setSelectedRoleId] = useState<string>("");
const [busyRoleId, setBusyRoleId] = useState<string | null>(null);
const rolesResult = useRoles();
const permsResult = usePermissions();
const { run: updatePermissions } = useUpdateRolePermissions();
const roles = rolesResult.data ?? [];
const permissions = permsResult.data ?? [];
// 选中角色默认取第一个
const effectiveRoleId =
selectedRoleId.length > 0
? selectedRoleId
: roles.length > 0
? (roles[0]?.id ?? "")
: "";
// 当前角色 → 权限 ID 集合
const rolePermissionMap = useMemo(() => {
const map = new Map<string, Set<string>>();
for (const role of roles) {
const set = new Set<string>();
for (const p of role.permissions) {
set.add(p.id);
}
map.set(role.id, set);
}
return map;
}, [roles]);
const handleToggle = async (
roleId: string,
permissionId: string,
): Promise<void> => {
const current = rolePermissionMap.get(roleId);
if (current === undefined) {
return;
}
const next = new Set(current);
if (next.has(permissionId)) {
next.delete(permissionId);
} else {
next.add(permissionId);
}
setBusyRoleId(roleId);
try {
await updatePermissions(roleId, Array.from(next));
await rolesResult.refetch();
} finally {
setBusyRoleId(null);
}
};
if (rolesResult.loading && !rolesResult.data) {
return <PluginSkeleton variant="table" />;
}
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>
);
}
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">
{/* 左侧角色列表 */}
<div className="w-64 flex-shrink-0">
<p className="text-small text-ink-muted"></p>
<ul className="mt-xs space-y-xs">
{roles.map((role) => {
const isSelected = role.id === effectiveRoleId;
return (
<li key={role.id}>
<button
type="button"
onClick={() => setSelectedRoleId(role.id)}
aria-pressed={isSelected}
className={`w-full rounded-button border px-sm py-xs text-left text-small ${
isSelected
? "border-accent bg-accent-subtle text-ink"
: "border-rule bg-paper text-ink"
}`}
>
{role.name}
</button>
</li>
);
})}
</ul>
</div>
{/* 右侧权限矩阵 */}
<div className="flex-1 overflow-x-auto">
{permissions.length === 0 ? (
<p className="text-small text-ink-muted"></p>
) : (
<table className="w-full text-tiny">
<thead>
<tr className="border-b border-rule text-ink-muted">
<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"
}`}
>
{role.name}
</th>
))}
</tr>
</thead>
<tbody>
{permissions.map((perm) => (
<tr key={perm.id} className="border-b border-rule">
<td className="py-xs">
<p className="text-ink">{perm.name}</p>
<p className="text-tiny text-ink-muted">
{perm.resource} / {perm.action}
</p>
</td>
{roles.map((role) => {
const checked =
rolePermissionMap.get(role.id)?.has(perm.id) ?? false;
const isBusy = busyRoleId === role.id;
return (
<td key={role.id} className="py-xs text-center">
<input
type="checkbox"
checked={checked}
disabled={isBusy}
onChange={() => handleToggle(role.id, perm.id)}
aria-label={`${role.name} - ${perm.name}`}
/>
</td>
);
})}
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,22 @@
/**
* rbac-manager 插件清单admin
*
* 角色权限管理,插入 main slot。左侧角色列表 + 右侧权限矩阵。
*/
import type { PluginManifest } from "@/lib/types";
export const manifestMeta: Omit<PluginManifest, "Component"> = {
pluginId: "rbac-manager",
version: "0.1.0",
requiredShellVersion: "^1.0.0",
metadata: {
displayName: "角色权限",
description: "管理角色与权限的映射关系",
category: "admin",
requiredRoles: ["admin"],
defaultSlot: "main",
defaultSize: { colSpan: 2, rowSpan: 1 },
defaultProps: {},
propsSchema: { type: "object", properties: {} },
},
};

View File

@@ -0,0 +1,197 @@
"use client";
/**
* school-settingsadmin / main
*
* 学校设置。展示学校基本信息(名称/地址/联系方式)与学年学期配置,
* 支持就地编辑保存。
*
* 数据流useSchool/useUpdateSchool → Apollo Client → apollo-router → iam 子图
*
* 关联portal-shell spec §3 admin 分类、§5.6 统一 Hook
*/
import { useEffect, useState } from "react";
import { useSchool, useUpdateSchool } from "@/lib/api/admin";
import type { School } from "@/lib/api/admin";
import { PluginSkeleton } from "@/shell/PluginLoader";
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";
export default function SchoolSettings(props: PluginProps): React.ReactElement {
const showAdvanced =
typeof props.props.showAdvanced === "boolean"
? (props.props.showAdvanced as boolean)
: false;
const { data, loading } = useSchool();
const { run: update } = useUpdateSchool();
const [draft, setDraft] = useState<School | null>(null);
const [status, setStatus] = useState<string>("");
useEffect(() => {
if (data) {
setDraft({ ...data });
}
}, [data]);
const handleFieldChange = (field: keyof School, value: string): void => {
setDraft((prev) => (prev === null ? prev : { ...prev, [field]: value }));
};
const handleSave = async (): Promise<void> => {
if (!draft) return;
setStatus("保存中...");
try {
await update({
name: draft.name,
address: draft.address,
phone: draft.phone,
email: draft.email,
currentAcademicYear: draft.currentAcademicYear,
currentTerm: draft.currentTerm,
semesterStart: draft.semesterStart,
semesterEnd: draft.semesterEnd,
});
setStatus("已保存");
} catch {
setStatus("保存失败");
}
};
if (loading && !data) {
return <PluginSkeleton variant="card" />;
}
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>
);
}
return (
<section className="rounded-card border border-rule bg-surface p-md">
<h3 className="text-heading-3 text-ink"></h3>
{status.length > 0 ? (
<p className="mt-xs text-tiny text-ink-muted">{status}</p>
) : null}
<div className="mt-sm grid grid-cols-2 gap-md">
<div className="space-y-xs">
<label className={labelCls}></label>
<input
type="text"
value={draft.name}
onChange={(e) => handleFieldChange("name", e.target.value)}
className={inputCls}
/>
</div>
<div className="space-y-xs">
<label className={labelCls}></label>
<input
type="text"
value={draft.phone}
onChange={(e) => handleFieldChange("phone", e.target.value)}
className={inputCls}
/>
</div>
<div className="space-y-xs">
<label className={labelCls}></label>
<input
type="email"
value={draft.email}
onChange={(e) => handleFieldChange("email", e.target.value)}
className={inputCls}
/>
</div>
<div className="space-y-xs">
<label className={labelCls}></label>
<input
type="text"
value={draft.currentAcademicYear}
onChange={(e) =>
handleFieldChange("currentAcademicYear", e.target.value)
}
placeholder="如 2026-2027"
className={inputCls}
/>
</div>
<div className="space-y-xs">
<label className={labelCls}></label>
<select
value={draft.currentTerm}
onChange={(e) => handleFieldChange("currentTerm", e.target.value)}
className={inputCls}
>
{TERM_OPTIONS.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</select>
</div>
<div className="space-y-xs">
<label className={labelCls}></label>
<input
type="date"
value={draft.semesterStart}
onChange={(e) => handleFieldChange("semesterStart", e.target.value)}
className={inputCls}
/>
</div>
<div className="space-y-xs">
<label className={labelCls}></label>
<input
type="date"
value={draft.semesterEnd}
onChange={(e) => handleFieldChange("semesterEnd", e.target.value)}
className={inputCls}
/>
</div>
<div className="space-y-xs">
<label className={labelCls}></label>
<input
type="text"
value={draft.address}
onChange={(e) => handleFieldChange("address", e.target.value)}
className={inputCls}
/>
</div>
</div>
{showAdvanced ? (
<div className="mt-md rounded-button bg-paper p-sm">
<p className="text-tiny text-ink-muted">
config-service
-
</p>
</div>
) : null}
<div className="mt-md flex justify-end gap-sm">
<button
type="button"
onClick={() => data && setDraft({ ...data })}
className="rounded-button border border-rule bg-surface px-md py-xs text-small text-ink"
>
</button>
<button
type="button"
onClick={handleSave}
className="rounded-button bg-accent px-md py-xs text-small text-ink-onAccent"
>
</button>
</div>
</section>
);
}

View File

@@ -0,0 +1,35 @@
/**
* school-settings 插件清单admin
*
* 学校设置,插入 main slot。展示并编辑学校基本信息与学年学期配置。
* 查询/变更经 apollo-router → config-service / iam 子图。
*
* 关联portal-shell spec §3 admin 分类、§5.6 统一 Hook
*/
import type { PluginManifest } from "@/lib/types";
export const manifestMeta: Omit<PluginManifest, "Component"> = {
pluginId: "school-settings",
version: "0.1.0",
requiredShellVersion: "^1.0.0",
metadata: {
displayName: "学校设置",
description: "管理学校基本信息、学年学期与教学配置",
category: "admin",
requiredRoles: ["admin"],
defaultSlot: "main",
defaultSize: { colSpan: 2, rowSpan: 1 },
defaultProps: {
showAdvanced: false,
},
propsSchema: {
type: "object",
properties: {
showAdvanced: {
type: "boolean",
description: "显示高级配置项(学区、学段、教研室)",
},
},
},
},
};

View File

@@ -0,0 +1,195 @@
"use client";
/**
* user-managementadmin / main
*
* 用户管理。展示用户列表,支持按角色/状态筛选、分页、修改用户状态与角色。
*
* 关联portal-shell spec §5.6 统一 Hook
*/
import { useState } from "react";
import {
useUsers,
useUpdateUserRole,
useUpdateUserStatus,
} from "@/lib/api/admin";
import { PluginSkeleton } from "@/shell/PluginLoader";
import type { PluginProps } from "@/lib/types";
type UserStatus = "active" | "suspended" | "deleted";
type UserRole = "admin" | "teacher" | "student" | "parent";
const PAGE_SIZE = 10;
const ROLE_OPTIONS: UserRole[] = ["admin", "teacher", "student", "parent"];
const STATUS_OPTIONS: UserStatus[] = ["active", "suspended", "deleted"];
const STATUS_LABELS: Record<UserStatus, string> = {
active: "正常",
suspended: "已停用",
deleted: "已删除",
};
export default function UserManagement(
_props: PluginProps,
): React.ReactElement {
const [roleFilter, setRoleFilter] = useState<string>("");
const [offset, setOffset] = useState<number>(0);
const [busyId, setBusyId] = useState<string | null>(null);
const { data, loading, refetch } = useUsers({
role: roleFilter.length > 0 ? roleFilter : null,
limit: PAGE_SIZE,
offset,
});
const { run: runStatus } = useUpdateUserStatus();
const { run: runRole } = useUpdateUserRole();
const handleStatusChange = async (
id: string,
status: string,
): Promise<void> => {
setBusyId(id);
try {
await runStatus(id, status);
await refetch();
} finally {
setBusyId(null);
}
};
const handleRoleChange = async (id: string, role: string): Promise<void> => {
setBusyId(id);
try {
await runRole(id, role);
await refetch();
} finally {
setBusyId(null);
}
};
const handleRoleFilterChange = (
e: React.ChangeEvent<HTMLSelectElement>,
): void => {
setRoleFilter(e.target.value);
setOffset(0);
};
if (loading && !data) {
return <PluginSkeleton variant="table" />;
}
const users = data?.items ?? [];
const total = data?.total ?? 0;
const hasPrev = offset > 0;
const hasNext = offset + PAGE_SIZE < total;
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">
<select
value={roleFilter}
onChange={handleRoleFilterChange}
className="rounded-button border border-rule bg-surface px-sm py-xs text-small text-ink"
aria-label="按角色筛选"
>
<option value=""></option>
{ROLE_OPTIONS.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
</div>
{users.length === 0 ? (
<p className="mt-sm text-small text-ink-muted"></p>
) : (
<div className="mt-sm overflow-x-auto">
<table className="w-full text-small">
<thead>
<tr className="border-b border-rule text-ink-muted">
<th className="py-xs text-left"></th>
<th className="py-xs text-left"></th>
<th className="py-xs text-left"></th>
<th className="py-xs text-left"></th>
<th className="py-xs text-left"></th>
</tr>
</thead>
<tbody>
{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>
<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"
aria-label="修改角色"
>
{ROLE_OPTIONS.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
</td>
<td className="py-xs">
<select
value={u.status}
disabled={isBusy}
onChange={(e) =>
handleStatusChange(u.id, e.target.value)
}
className="rounded-button border border-rule bg-surface px-sm py-xs text-tiny text-ink"
aria-label="修改状态"
>
{STATUS_OPTIONS.map((s) => (
<option key={s} value={s}>
{STATUS_LABELS[s] ?? s}
</option>
))}
</select>
</td>
<td className="py-xs text-ink-muted">{u.createdAt}</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
<div className="mt-sm flex items-center justify-between text-tiny text-ink-muted">
<span>
{total} {offset + 1} - {rangeEnd}
</span>
<div className="flex gap-sm">
<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"
>
</button>
<button
type="button"
disabled={!hasNext}
onClick={() => setOffset(offset + PAGE_SIZE)}
className="rounded-button border border-rule bg-surface px-sm py-xs text-ink"
>
</button>
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,22 @@
/**
* user-management 插件清单admin
*
* 用户管理,插入 main slot。展示用户列表支持筛选、分页、修改角色与状态。
*/
import type { PluginManifest } from "@/lib/types";
export const manifestMeta: Omit<PluginManifest, "Component"> = {
pluginId: "user-management",
version: "0.1.0",
requiredShellVersion: "^1.0.0",
metadata: {
displayName: "用户管理",
description: "查看并管理用户角色与状态",
category: "admin",
requiredRoles: ["admin"],
defaultSlot: "main",
defaultSize: { colSpan: 2, rowSpan: 1 },
defaultProps: {},
propsSchema: { type: "object", properties: {} },
},
};

View File

@@ -0,0 +1,117 @@
"use client";
/**
* child-overviewparent / main
*
* 多孩子总览。展示家长名下所有孩子的最近成绩、出勤率、作业完成率。
* 点击卡片写入 URL childId其他 parent 插件(如 leave-approval自动响应。
*
* 关联portal-shell spec §5.2.1 URL 驱动、§5.6 统一 Hook
*/
import { useRouter, useSearchParams } from "next/navigation";
import { useParentChildren } from "@/lib/api/parent";
import { PluginSkeleton } from "@/shell/PluginLoader";
import type { PluginProps } from "@/lib/types";
function formatRate(numerator: number, denominator: number): string {
if (denominator <= 0) {
return "0%";
}
return `${Math.round((numerator / denominator) * 100)}%`;
}
export default function ChildOverview(_props: PluginProps): React.ReactElement {
const router = useRouter();
const searchParams = useSearchParams();
const selectedChildId = searchParams.get("childId") ?? "";
const { data, loading } = useParentChildren();
if (loading && !data) {
return <PluginSkeleton variant="card" />;
}
const children = data ?? [];
const handleSelect = (childId: string): void => {
const params = new URLSearchParams(searchParams.toString());
params.set("childId", childId);
router.push(`?${params.toString()}`);
};
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>
);
}
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">
{children.map((child) => {
const isSelected = child.id === selectedChildId;
return (
<button
key={child.id}
type="button"
onClick={() => handleSelect(child.id)}
aria-pressed={isSelected}
className={`flex flex-col rounded-card border p-md text-left ${
isSelected
? "border-accent bg-accent-subtle"
: "border-rule bg-paper"
}`}
>
<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">
{child.name.charAt(0)}
</span>
<div>
<p className="text-body text-ink">{child.name}</p>
<p className="text-tiny text-ink-muted">
{child.grade} {child.className}
</p>
</div>
</div>
<div className="mt-sm space-y-xs">
<p className="text-small text-ink-muted"></p>
{child.recentGrades.length === 0 ? (
<p className="text-tiny text-ink-muted"></p>
) : (
<ul className="space-y-xs">
{child.recentGrades.map((g) => (
<li
key={g.subject}
className="flex justify-between text-tiny text-ink"
>
<span>{g.subject}</span>
<span>{g.score}</span>
</li>
))}
</ul>
)}
</div>
<div className="mt-sm flex justify-between border-t border-rule pt-xs text-tiny">
<span className="text-ink-muted">
{formatRate(child.attendance.present, child.attendance.total)}
</span>
<span className="text-ink-muted">
{formatRate(
child.homeworkCompletion.completed,
child.homeworkCompletion.total,
)}
</span>
</div>
</button>
);
})}
</div>
</section>
);
}

View File

@@ -0,0 +1,23 @@
/**
* child-overview 插件清单parent
*
* 多孩子总览,插入 main slot。展示成绩、出勤、作业完成情况。
* 点击卡片写入 URL childId驱动其他 parent 插件。
*/
import type { PluginManifest } from "@/lib/types";
export const manifestMeta: Omit<PluginManifest, "Component"> = {
pluginId: "child-overview",
version: "0.1.0",
requiredShellVersion: "^1.0.0",
metadata: {
displayName: "孩子总览",
description: "多孩子成绩、出勤、作业完成情况总览",
category: "parent",
requiredRoles: ["parent"],
defaultSlot: "main",
defaultSize: { colSpan: 2, rowSpan: 2 },
defaultProps: {},
propsSchema: { type: "object", properties: {} },
},
};

View File

@@ -0,0 +1,185 @@
"use client";
/**
* leave-approvalparent / main
*
* 请假审批。展示家长名下孩子的请假申请,支持按状态筛选。
* pending 状态可批准/拒绝拒绝需填写原因。childId 从 URL 读取过滤。
*
* 关联portal-shell spec §5.2.1 URL 驱动、§5.6 统一 Hook
*/
import { useState } from "react";
import { useSearchParams } from "next/navigation";
import {
useApproveLeave,
useLeaveRequests,
useRejectLeave,
type LeaveStatus,
type LeaveType,
} from "@/lib/api/parent";
import { PluginSkeleton } from "@/shell/PluginLoader";
import type { PluginProps } from "@/lib/types";
const TYPE_LABELS: Record<LeaveType, string> = {
sick: "病假",
personal: "事假",
family: "家庭假",
other: "其他",
};
const STATUS_LABELS: Record<LeaveStatus, string> = {
pending: "待审批",
approved: "已批准",
rejected: "已拒绝",
};
export default function LeaveApproval(_props: PluginProps): React.ReactElement {
const searchParams = useSearchParams();
const childId = searchParams.get("childId") ?? "";
const [statusFilter, setStatusFilter] = useState<string>("");
const [rejectReasons, setRejectReasons] = useState<Record<string, string>>(
{},
);
const [rejectError, setRejectError] = useState<string>("");
const [busyId, setBusyId] = useState<string | null>(null);
const { data, loading, refetch } = useLeaveRequests(
childId.length > 0 ? childId : null,
statusFilter.length > 0 ? statusFilter : null,
);
const { run: approveLeave } = useApproveLeave();
const { run: rejectLeave } = useRejectLeave();
const handleApprove = async (id: string): Promise<void> => {
setBusyId(id);
try {
await approveLeave(id);
await refetch();
} finally {
setBusyId(null);
}
};
const handleReject = async (id: string): Promise<void> => {
const reason = rejectReasons[id] ?? "";
if (reason.trim().length === 0) {
setRejectError("拒绝时请填写原因");
return;
}
setRejectError("");
setBusyId(id);
try {
await rejectLeave(id, reason);
setRejectReasons((prev) => {
const next = { ...prev };
delete next[id];
return next;
});
await refetch();
} finally {
setBusyId(null);
}
};
if (loading && !data) {
return <PluginSkeleton variant="list" />;
}
const requests = data ?? [];
return (
<section className="rounded-card border border-rule bg-surface p-md">
<div className="flex items-center justify-between">
<h3 className="text-heading-3 text-ink"></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"
aria-label="按状态筛选"
>
<option value=""></option>
<option value="pending"></option>
<option value="approved"></option>
<option value="rejected"></option>
</select>
</div>
{childId.length > 0 ? (
<p className="mt-xs text-tiny text-ink-muted"></p>
) : null}
{rejectError.length > 0 ? (
<p className="mt-xs text-tiny text-ink-muted">{rejectError}</p>
) : null}
{requests.length === 0 ? (
<p className="mt-sm text-small text-ink-muted"></p>
) : (
<ul className="mt-sm space-y-md">
{requests.map((req) => {
const isPending = req.status === "pending";
const isBusy = busyId === req.id;
const reasonValue = rejectReasons[req.id] ?? "";
return (
<li
key={req.id}
className="rounded-card border border-rule bg-paper p-md"
>
<div className="flex items-center justify-between">
<div>
<p className="text-body text-ink">{req.childName}</p>
<p className="text-tiny text-ink-muted">
{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">
{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">
{req.createdAt}
</p>
{isPending ? (
<div className="mt-sm space-y-xs">
<input
type="text"
value={reasonValue}
onChange={(e) =>
setRejectReasons((prev) => ({
...prev,
[req.id]: e.target.value,
}))
}
placeholder="拒绝原因(拒绝时必填)"
className="w-full rounded-button border border-rule bg-surface px-sm py-xs text-small text-ink"
/>
<div className="flex gap-sm">
<button
type="button"
disabled={isBusy}
onClick={() => handleApprove(req.id)}
className="rounded-button bg-accent px-md py-xs text-small text-ink-onAccent"
>
</button>
<button
type="button"
disabled={isBusy}
onClick={() => handleReject(req.id)}
className="rounded-button bg-danger px-md py-xs text-small text-ink-onAccent"
>
</button>
</div>
</div>
) : null}
</li>
);
})}
</ul>
)}
</section>
);
}

View File

@@ -0,0 +1,23 @@
/**
* leave-approval 插件清单parent
*
* 请假审批,插入 main slot。展示请假申请支持批准/拒绝。
* childId 从 URL 读取过滤。
*/
import type { PluginManifest } from "@/lib/types";
export const manifestMeta: Omit<PluginManifest, "Component"> = {
pluginId: "leave-approval",
version: "0.1.0",
requiredShellVersion: "^1.0.0",
metadata: {
displayName: "请假审批",
description: "查看并审批孩子的请假申请",
category: "parent",
requiredRoles: ["parent"],
defaultSlot: "main",
defaultSize: { colSpan: 2, rowSpan: 1 },
defaultProps: {},
propsSchema: { type: "object", properties: {} },
},
};

View File

@@ -0,0 +1,59 @@
"use client";
/**
* child-selectorsidebar / side
*
* 通过 useMyChildren 查询 apollo-router → core-edu 子图的 myChildren 数据。
* 切换孩子时写入 URL Search ParamschildId其他插件自动响应。
*
* 关联portal-shell spec §5.2.1 URL 驱动、M8 验收
*/
import { useRouter, useSearchParams } from "next/navigation";
import { useMyChildren } from "@/lib/api/sidebar";
import { PluginSkeleton } from "@/shell/PluginLoader";
import type { PluginProps } from "@/lib/types";
export default function ChildSelector(_props: PluginProps): React.ReactElement {
const router = useRouter();
const searchParams = useSearchParams();
const currentChildId = searchParams.get("childId") ?? "";
const { data, loading } = useMyChildren();
const handleSelect = (childId: string): void => {
const params = new URLSearchParams(searchParams.toString());
params.set("childId", childId);
router.push(`?${params.toString()}`);
};
if (loading && !data) {
return <PluginSkeleton variant="list" />;
}
const children = data ?? [];
const current = children.find((c) => c.id === currentChildId);
return (
<div>
<p className="text-small text-ink-muted"></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"
aria-label="选择孩子"
>
<option value=""></option>
{children.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
{current ? (
<p className="mt-xs text-tiny text-ink-muted">
{current.grade} · {current.className}
</p>
) : null}
</div>
);
}

View File

@@ -0,0 +1,21 @@
/**
* child-selector 插件清单sidebar
*
* 孩子选择器,插入 side slot。查询 apollo-router → core-edu 子图 myChildren。
* 切换 childId 时写入 URL Search Params其他插件自动响应。
*/
import type { PluginManifest } from "@/lib/types";
export const manifestMeta: Omit<PluginManifest, "Component"> = {
pluginId: "child-selector",
version: "0.1.0",
requiredShellVersion: "^1.0.0",
metadata: {
displayName: "孩子选择",
description: "切换当前孩子(写入 URL childId",
category: "sidebar",
requiredRoles: ["parent"],
defaultSlot: "side",
defaultSize: { colSpan: 1, rowSpan: 1 },
},
};

View File

@@ -3,39 +3,22 @@
/**
* class-selectorsidebar / side
*
* 通过 useWidgetQuery 查询 apollo-router → core-edu 子图的 myClasses 数据。
* 通过 useMyClasses 查询 apollo-router → core-edu 子图的 myClasses 数据。
* 切换班级时写入 URL Search ParamsclassIdgrades-widget 等插件自动响应。
*
* 关联portal-shell spec §5.2.1 URL 驱动、M8 验收
*/
import { gql } from "@apollo/client";
import { useRouter, useSearchParams } from "next/navigation";
import { useWidgetQuery } from "@/lib/useWidgetQuery";
import { useMyClasses } from "@/lib/api/sidebar";
import { PluginSkeleton } from "@/shell/PluginLoader";
import type { PluginProps } from "@/lib/types";
const GET_MY_CLASSES = gql`
query GetMyClasses {
myClasses {
id
name
}
}
`;
interface MyClassesQueryData {
myClasses: Array<{ id: string; name: string }>;
}
export default function ClassSelector(_props: PluginProps): React.ReactElement {
const router = useRouter();
const searchParams = useSearchParams();
const currentClassId = searchParams.get("classId") ?? "";
const { data, loading } = useWidgetQuery<
MyClassesQueryData,
Record<string, never>
>(GET_MY_CLASSES, {});
const { data, loading } = useMyClasses();
const handleSelect = (classId: string): void => {
const params = new URLSearchParams(searchParams.toString());
@@ -47,7 +30,7 @@ export default function ClassSelector(_props: PluginProps): React.ReactElement {
return <PluginSkeleton variant="list" />;
}
const classes = data?.myClasses ?? [];
const classes = data ?? [];
return (
<div>

View File

@@ -0,0 +1,63 @@
"use client";
/**
* quick-actionssidebar / side
*
* 快捷操作菜单,根据 role 显示不同的导航入口,使用 router.push 跳转。
* 无 GraphQL 查询,纯前端导航。
*
* 关联portal-shell spec §5.2.1、M8 验收
*/
import { useRouter } from "next/navigation";
import type { PluginProps, Role } from "@/lib/types";
interface ActionItem {
label: string;
path: string;
}
const ACTIONS_BY_ROLE: Record<Role, ActionItem[]> = {
teacher: [
{ label: "布置作业", path: "/homework/new" },
{ label: "创建考试", path: "/exam/new" },
{ label: "查看课表", path: "/schedule" },
],
student: [
{ label: "提交作业", path: "/homework/submit" },
{ label: "查看成绩", path: "/grades" },
{ label: "错题本", path: "/mistakes" },
],
parent: [
{ label: "查看孩子成绩", path: "/child/grades" },
{ label: "请假审批", path: "/leave/approval" },
],
admin: [],
};
export default function QuickActions(props: PluginProps): React.ReactElement {
const router = useRouter();
const actions = ACTIONS_BY_ROLE[props.role] ?? [];
const handleClick = (path: string): void => {
router.push(path);
};
return (
<div>
<p className="text-small text-ink-muted"></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"
>
{item.label}
</button>
</li>
))}
</ul>
</div>
);
}

View File

@@ -0,0 +1,20 @@
/**
* quick-actions 插件清单sidebar
*
* 快捷操作菜单,插入 side slot。根据 role 显示不同导航入口,纯前端导航。
*/
import type { PluginManifest } from "@/lib/types";
export const manifestMeta: Omit<PluginManifest, "Component"> = {
pluginId: "quick-actions",
version: "0.1.0",
requiredShellVersion: "^1.0.0",
metadata: {
displayName: "快捷操作",
description: "按角色显示常用操作入口",
category: "sidebar",
requiredRoles: ["teacher", "student", "parent"],
defaultSlot: "side",
defaultSize: { colSpan: 1, rowSpan: 1 },
},
};

View File

@@ -0,0 +1,57 @@
"use client";
/**
* term-switchersidebar / side
*
* 通过 useTerms 查询 apollo-router → core-edu 子图的 terms 数据。
* 切换学期时写入 URL Search ParamstermId其他插件自动响应。
* 默认选中 isActive=true 的学期。
*
* 关联portal-shell spec §5.2.1 URL 驱动、M8 验收
*/
import { useRouter, useSearchParams } from "next/navigation";
import { useTerms } from "@/lib/api/sidebar";
import { PluginSkeleton } from "@/shell/PluginLoader";
import type { PluginProps } from "@/lib/types";
export default function TermSwitcher(_props: PluginProps): React.ReactElement {
const router = useRouter();
const searchParams = useSearchParams();
const currentTermId = searchParams.get("termId") ?? "";
const { data, loading } = useTerms();
const handleSelect = (termId: string): void => {
const params = new URLSearchParams(searchParams.toString());
params.set("termId", termId);
router.push(`?${params.toString()}`);
};
if (loading && !data) {
return <PluginSkeleton variant="list" />;
}
const terms = data ?? [];
const activeTerm = terms.find((t) => t.isActive);
// URL 未指定 termId 时默认选中当前激活学期
const selectedTermId = currentTermId || activeTerm?.id || "";
return (
<div>
<p className="text-small text-ink-muted"></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"
aria-label="选择学期"
>
<option value=""></option>
{terms.map((t) => (
<option key={t.id} value={t.id}>
{t.name}
</option>
))}
</select>
</div>
);
}

View File

@@ -0,0 +1,21 @@
/**
* term-switcher 插件清单sidebar
*
* 学期切换器,插入 side slot。查询 apollo-router → core-edu 子图 terms。
* 切换 termId 时写入 URL Search Params默认选中当前激活学期。
*/
import type { PluginManifest } from "@/lib/types";
export const manifestMeta: Omit<PluginManifest, "Component"> = {
pluginId: "term-switcher",
version: "0.1.0",
requiredShellVersion: "^1.0.0",
metadata: {
displayName: "学期切换",
description: "切换当前学期(写入 URL termId",
category: "sidebar",
requiredRoles: ["teacher", "student", "parent"],
defaultSlot: "side",
defaultSize: { colSpan: 1, rowSpan: 1 },
},
};

View File

@@ -0,0 +1,168 @@
"use client";
/**
* ai-tutorstudent / main
*
* AI 辅导:会话列表 + 聊天界面,支持发送消息并接收 AI 回复。
* 跨域聚合ai + core-edu + content本层仅做 API 收敛。
*
* 关联portal-shell spec §5.2.1 URL 驱动、§5.6 统一 Hook
*/
import { useState } from "react";
import { useAiTutorSessions, useSendAiTutorMessage } from "@/lib/api/student";
import { PluginSkeleton } from "@/shell/PluginLoader";
import type { PluginProps } from "@/lib/types";
interface ChatMessage {
id: string;
role: "user" | "ai";
content: string;
}
export default function AiTutor(props: PluginProps): React.ReactElement {
const rawLimit = props.props.limit;
const limit = typeof rawLimit === "number" ? rawLimit : 10;
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [input, setInput] = useState<string>("");
const { data, loading } = useAiTutorSessions(limit);
const { run: sendMessage, loading: sending } = useSendAiTutorMessage();
const handleSend = async (): Promise<void> => {
const trimmed = input.trim();
if (trimmed.length === 0 || sending) {
return;
}
const userMsg: ChatMessage = {
id: `u-${Date.now()}`,
role: "user",
content: trimmed,
};
setMessages((prev) => [...prev, userMsg]);
setInput("");
try {
const reply = await sendMessage(activeSessionId, trimmed);
const aiMsg: ChatMessage = {
id: `a-${Date.now()}`,
role: "ai",
content: reply.reply,
};
setMessages((prev) => [...prev, aiMsg]);
if (activeSessionId === null) {
setActiveSessionId(reply.sessionId);
}
} catch {
// 失败时保留用户消息,不追加 AI 回复
}
};
const handleSelectSession = (sessionId: string): void => {
setActiveSessionId(sessionId);
setMessages([]);
};
if (loading && !data) {
return <PluginSkeleton variant="card" />;
}
const sessions = data ?? [];
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>
<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>
<ul className="mt-xs space-y-sm">
{sessions.map((session) => {
const active = activeSessionId === session.id;
return (
<li key={session.id}>
<button
type="button"
onClick={() => handleSelectSession(session.id)}
className={`w-full rounded-button px-sm py-xs text-left ${active ? "bg-accent" : "bg-paper"}`}
>
<span
className={`block text-small ${active ? "text-ink-onAccent" : "text-ink"}`}
>
{session.title}
</span>
<span
className={`mt-xs block text-tiny ${active ? "text-ink-onAccent" : "text-ink-muted"}`}
>
{session.lastMessage}
</span>
<span
className={`block text-tiny ${active ? "text-ink-onAccent" : "text-ink-muted"}`}
>
{session.updatedAt}
</span>
</button>
</li>
);
})}
</ul>
</div>
{/* 右侧消息区 */}
<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">
AI
</p>
) : (
messages.map((msg) => (
<div
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"
}
>
{msg.content}
</div>
))
)}
{sending && (
<p className="text-tiny text-ink-muted">AI ...</p>
)}
</div>
{/* 输入区 */}
<div className="mt-sm flex">
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
void handleSend();
}
}}
placeholder="输入你的问题..."
className="flex-1 rounded-button border border-rule bg-surface px-sm py-xs text-small text-ink"
/>
<button
type="button"
disabled={!canSend}
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"
}
>
</button>
</div>
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,27 @@
/**
* ai-tutor 插件清单student
*
* 学生 AI 辅导,插入 main slot。查询 apollo-router → ai 子图 aiTutorSessions。
*/
import type { PluginManifest } from "@/lib/types";
export const manifestMeta: Omit<PluginManifest, "Component"> = {
pluginId: "ai-tutor",
version: "0.1.0",
requiredShellVersion: "^1.0.0",
metadata: {
displayName: "AI 辅导",
description: "AI 辅导会话列表与聊天界面",
category: "student",
requiredRoles: ["student"],
defaultSlot: "main",
defaultSize: { colSpan: 2, rowSpan: 2 },
defaultProps: { limit: 10 },
propsSchema: {
type: "object",
properties: {
limit: { type: "number", description: "会话列表展示条数", default: 10 },
},
},
},
};

View File

@@ -0,0 +1,144 @@
"use client";
/**
* elective-selectorstudent / main
*
* 选课:查询当前学期的可选课程列表,支持选课与退课。
*
* 关联portal-shell spec §5.2.1 URL 驱动、§5.6 统一 Hook
*/
import { useSearchParams } from "next/navigation";
import { useState } from "react";
import {
useElectiveCourses,
useEnrollCourse,
useDropCourse,
type CourseCategory,
} from "@/lib/api/student";
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",
};
export default function ElectiveSelector(
_props: PluginProps,
): React.ReactElement {
const searchParams = useSearchParams();
const termId = searchParams.get("termId") ?? "";
const [enrolledIds, setEnrolledIds] = useState<Record<string, boolean>>({});
const { data, loading, refetch } = useElectiveCourses(termId, {
enabled: termId.length > 0,
});
const { run: enroll, loading: enrolling } = useEnrollCourse();
const { run: drop, loading: dropping } = useDropCourse();
const handleEnroll = async (courseId: string): Promise<void> => {
try {
await enroll(courseId);
setEnrolledIds((prev) => ({ ...prev, [courseId]: true }));
await refetch();
} catch {
// 失败时保持原状
}
};
const handleDrop = async (courseId: string): Promise<void> => {
try {
await drop(courseId);
setEnrolledIds((prev) => ({ ...prev, [courseId]: false }));
await refetch();
} catch {
// 失败时保持原状
}
};
if (loading && !data) {
return <PluginSkeleton variant="table" />;
}
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>
);
}
const courses = data ?? [];
return (
<section className="rounded-card border border-rule bg-surface p-md">
<h3 className="text-heading-3 text-ink"></h3>
{courses.length === 0 ? (
<p className="mt-sm text-small text-ink-muted"></p>
) : (
<ul className="mt-sm space-y-md">
{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"
>
<div className="flex items-center">
<span className="text-body text-ink">{course.name}</span>
<span
className={`ml-sm rounded-button px-xs py-xs text-tiny ${CATEGORY_BADGE[course.category]}`}
>
{course.category}
</span>
</div>
<div className="mt-xs flex flex-col text-small text-ink-muted">
<span>{course.teacher}</span>
<span>
{course.enrolled}/{course.capacity}
{full ? "(已满)" : ""}
</span>
<span>{course.schedule}</span>
<span>{course.credits}</span>
</div>
<div className="mt-xs">
{enrolled ? (
<button
type="button"
disabled={dropping}
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"
}
>
退
</button>
) : (
<button
type="button"
disabled={enrolling || full}
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"
}
>
{full ? "已满" : "选课"}
</button>
)}
</div>
</li>
);
})}
</ul>
)}
</section>
);
}

View File

@@ -0,0 +1,22 @@
/**
* elective-selector 插件清单student
*
* 学生选课,插入 main slot。查询 apollo-router → core-edu 子图 electiveCourses。
*/
import type { PluginManifest } from "@/lib/types";
export const manifestMeta: Omit<PluginManifest, "Component"> = {
pluginId: "elective-selector",
version: "0.1.0",
requiredShellVersion: "^1.0.0",
metadata: {
displayName: "选课",
description: "按学期展示可选课程,支持选课与退课",
category: "student",
requiredRoles: ["student"],
defaultSlot: "main",
defaultSize: { colSpan: 2, rowSpan: 1 },
defaultProps: {},
propsSchema: { type: "object", properties: {} },
},
};

View File

@@ -0,0 +1,123 @@
"use client";
/**
* error-bookstudent / main
*
* 错题本:查询当前学生的错题列表,支持按科目筛选与标记已掌握。
*
* 关联portal-shell spec §5.2.1 URL 驱动、§5.6 统一 Hook
*/
import { useMemo, useState } from "react";
import { useErrorBook, useMarkErrorMastered } from "@/lib/api/student";
import { PluginSkeleton } from "@/shell/PluginLoader";
import type { PluginProps } from "@/lib/types";
export default function ErrorBook(props: PluginProps): React.ReactElement {
const rawLimit = props.props.limit;
const limit = typeof rawLimit === "number" ? rawLimit : 20;
const [subjectFilter, setSubjectFilter] = useState<string>("");
const [masteredIds, setMasteredIds] = useState<Record<string, boolean>>({});
const { data, loading } = useErrorBook({ limit });
const { run: markMastered, loading: marking } = useMarkErrorMastered();
const allItems = data ?? [];
const subjects = useMemo(
() => Array.from(new Set(allItems.map((item) => item.subject))),
[allItems],
);
const filteredItems = subjectFilter
? allItems.filter((item) => item.subject === subjectFilter)
: allItems;
const handleMarkMastered = async (id: string): Promise<void> => {
try {
await markMastered(id);
setMasteredIds((prev) => ({ ...prev, [id]: true }));
} catch {
// 失败时不更新已掌握状态
}
};
if (loading && !data) {
return <PluginSkeleton variant="table" />;
}
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 items-center">
<label
htmlFor="error-book-subject"
className="text-small text-ink-muted"
>
</label>
<select
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"
>
<option value=""></option>
{subjects.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
</div>
{filteredItems.length === 0 ? (
<p className="mt-sm text-small text-ink-muted"></p>
) : (
<ul className="mt-sm space-y-md">
{filteredItems.map((item) => {
const mastered = masteredIds[item.id] === true;
return (
<li
key={item.id}
className="rounded-card border border-rule bg-paper p-sm"
>
<div className="flex items-center">
<span className="rounded-button bg-subtle px-sm py-xs text-tiny text-ink">
{item.subject}
</span>
<span className="ml-sm text-tiny text-ink-muted">
{item.errorCount}
</span>
<span className="ml-sm text-tiny text-ink-muted">
{item.lastErrorAt}
</span>
</div>
<p className="mt-xs text-body text-ink">{item.question}</p>
<div className="mt-xs flex flex-col">
<span className="text-small text-ink-muted">
{item.myAnswer}
</span>
<span className="text-small text-ink">
{item.correctAnswer}
</span>
</div>
<button
type="button"
disabled={mastered || marking}
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"
}
>
{mastered ? "已掌握" : "标记已掌握"}
</button>
</li>
);
})}
</ul>
)}
</section>
);
}

View File

@@ -0,0 +1,27 @@
/**
* error-book 插件清单student
*
* 学生错题本,插入 main slot。查询 apollo-router → core-edu 子图 myErrorBook。
*/
import type { PluginManifest } from "@/lib/types";
export const manifestMeta: Omit<PluginManifest, "Component"> = {
pluginId: "error-book",
version: "0.1.0",
requiredShellVersion: "^1.0.0",
metadata: {
displayName: "错题本",
description: "学生错题列表,支持按科目筛选与标记已掌握",
category: "student",
requiredRoles: ["student"],
defaultSlot: "main",
defaultSize: { colSpan: 2, rowSpan: 1 },
defaultProps: { limit: 20 },
propsSchema: {
type: "object",
properties: {
limit: { type: "number", description: "展示条数", default: 20 },
},
},
},
};

View File

@@ -0,0 +1,148 @@
"use client";
/**
* learning-pathstudent / main
*
* 学习路径:按科目展示学习节点时间线,支持节点状态可视化与进度展示。
*
* 关联portal-shell spec §5.2.1 URL 驱动、§5.6 统一 Hook
*/
import { useRouter, useSearchParams } from "next/navigation";
import {
useLearningPath,
type LearningNode,
type LearningNodeType,
type LearningNodeStatus,
} from "@/lib/api/student";
import { PluginSkeleton } from "@/shell/PluginLoader";
import type { PluginProps } from "@/lib/types";
const TYPE_LABEL: Record<LearningNodeType, string> = {
lesson: "课程",
practice: "练习",
assessment: "测评",
};
const STATUS_LABEL: Record<LearningNodeStatus, string> = {
locked: "未解锁",
available: "可学习",
in_progress: "进行中",
completed: "已完成",
};
function statusClass(status: LearningNodeStatus): string {
switch (status) {
case "locked":
return "bg-subtle text-ink-muted";
case "available":
return "border border-rule bg-surface text-ink";
case "in_progress":
return "bg-warning text-ink";
case "completed":
return "bg-success text-ink";
default:
return "bg-surface text-ink";
}
}
export default function LearningPath(_props: PluginProps): React.ReactElement {
const router = useRouter();
const searchParams = useSearchParams();
const subjectId = searchParams.get("subjectId") ?? "";
const { data, loading } = useLearningPath(subjectId, {
enabled: subjectId.length > 0,
});
const handleNavigate = (node: LearningNode): void => {
if (node.status === "locked") {
return;
}
const params = new URLSearchParams(searchParams.toString());
params.set("nodeId", node.id);
router.push(`?${params.toString()}`);
};
if (loading && !data) {
return <PluginSkeleton variant="card" />;
}
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>
);
}
const path = data;
const nodes = path?.nodes ?? [];
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>
<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>
</div>
<div className="mt-xs h-xs w-full rounded-button bg-subtle">
<div
className="h-xs rounded-button bg-accent"
style={{ width: `${progress}%` }}
/>
</div>
</div>
{nodes.length === 0 ? (
<p className="mt-sm text-small text-ink-muted"></p>
) : (
<ol className="mt-md space-y-md">
{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(
node.status,
)}`}
>
{TYPE_LABEL[node.type]}
</span>
<div className="ml-sm flex-1">
<div className="flex items-center">
<span className="text-body text-ink">{node.title}</span>
<span
className={`ml-sm rounded-button px-xs py-xs text-tiny ${statusClass(
node.status,
)}`}
>
{STATUS_LABEL[node.status]}
</span>
</div>
{node.dependencies.length > 0 && (
<p className="mt-xs text-tiny text-ink-muted">
{node.dependencies.join("、")}
</p>
)}
{clickable && (
<button
type="button"
onClick={() => handleNavigate(node)}
className="mt-xs rounded-button border border-rule bg-surface px-sm py-xs text-tiny text-ink"
>
</button>
)}
</div>
</li>
);
})}
</ol>
)}
</section>
);
}

View File

@@ -0,0 +1,22 @@
/**
* learning-path 插件清单student
*
* 学生学习路径,插入 main slot。查询 apollo-router → core-edu 子图 myLearningPath。
*/
import type { PluginManifest } from "@/lib/types";
export const manifestMeta: Omit<PluginManifest, "Component"> = {
pluginId: "learning-path",
version: "0.1.0",
requiredShellVersion: "^1.0.0",
metadata: {
displayName: "学习路径",
description: "按科目展示学习节点时间线与整体进度",
category: "student",
requiredRoles: ["student"],
defaultSlot: "main",
defaultSize: { colSpan: 2, rowSpan: 2 },
defaultProps: {},
propsSchema: { type: "object", properties: {} },
},
};

View File

@@ -0,0 +1,216 @@
"use client";
/**
* lesson-plan-editorteacher / main
*
* 备课画布:左侧备课列表 + 右侧编辑区。
* 通过 useWidgetQuery 查询 apollo-router → core-edu 子图的 lessonPlans 数据,
* 通过 useWidgetMutation 调用 saveLessonPlan 保存。
* classId 从 URL Search Params 读取class-selector 切换时自动响应)。
*
* 关联portal-shell spec §5.2.1 URL 驱动、§5.6 统一 Hook
*/
import { useSearchParams } from "next/navigation";
import { useState } from "react";
import {
useLessonPlans,
useSaveLessonPlan,
type LessonPlan,
type SaveLessonPlanInput,
} from "@/lib/api";
import { PluginSkeleton } from "@/shell/PluginLoader";
import type { PluginProps } from "@/lib/types";
function createEmptyDraft(): LessonPlan {
return { id: "", title: "", objectives: "", content: "", resources: [] };
}
export default function LessonPlanEditor(
_props: PluginProps,
): React.ReactElement {
const searchParams = useSearchParams();
const classId = searchParams.get("classId") ?? "";
const { data, loading, refetch } = useLessonPlans(classId);
const { run: saveLessonPlan, loading: saving } = useSaveLessonPlan();
const [draft, setDraft] = useState<LessonPlan>(createEmptyDraft());
if (loading && !data) {
return <PluginSkeleton variant="table" />;
}
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>
);
}
const plans = data ?? [];
const handleSelect = (plan: LessonPlan): void => {
setDraft({ ...plan, resources: [...plan.resources] });
};
const handleNew = (): void => {
setDraft(createEmptyDraft());
};
const handleResourceAdd = (): void => {
setDraft((d) => ({ ...d, resources: [...d.resources, ""] }));
};
const handleResourceChange = (index: number, value: string): void => {
setDraft((d) => {
const next = [...d.resources];
next[index] = value;
return { ...d, resources: next };
});
};
const handleResourceRemove = (index: number): void => {
setDraft((d) => ({
...d,
resources: d.resources.filter((_, i) => i !== index),
}));
};
const handleSave = async (): Promise<void> => {
if (!draft.title.trim()) {
return;
}
const input: SaveLessonPlanInput = {
classId,
id: draft.id || undefined,
title: draft.title,
objectives: draft.objectives,
content: draft.content,
resources: draft.resources.filter((r) => r.trim().length > 0),
};
try {
const saved = await saveLessonPlan(input);
setDraft((d) => ({ ...d, id: saved.id }));
await refetch();
} catch {
/* toast: 保存失败 */
}
};
return (
<section className="rounded-card border border-rule bg-surface p-md">
<div className="flex items-center justify-between">
<h3 className="text-heading-3 text-ink"></h3>
<button
type="button"
onClick={handleNew}
className="rounded-button bg-accent px-sm py-xs text-small text-ink-onAccent"
>
</button>
</div>
<div className="mt-sm flex gap-md">
<ul className="w-64 shrink-0 space-y-sm">
{plans.length === 0 ? (
<li className="text-small text-ink-muted"></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 ${
draft.id === p.id
? "bg-subtle text-ink"
: "bg-surface text-ink"
}`}
>
{p.title || "未命名备课"}
</button>
</li>
))
)}
</ul>
<div className="flex-1 space-y-md">
<label className="flex flex-col space-y-xs">
<span className="text-small text-ink-muted"></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"
placeholder="请输入备课标题"
/>
</label>
<label className="flex flex-col space-y-xs">
<span className="text-small text-ink-muted"></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"
rows={3}
placeholder="请输入教学目标"
/>
</label>
<label className="flex flex-col space-y-xs">
<span className="text-small text-ink-muted"></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"
rows={4}
placeholder="请输入教学内容"
/>
</label>
<div className="flex flex-col space-y-xs">
<span className="text-small text-ink-muted"></span>
<ul className="space-y-xs">
{draft.resources.map((r, i) => (
<li key={i} className="flex gap-xs">
<input
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"
placeholder="资源名称或链接"
/>
<button
type="button"
onClick={() => handleResourceRemove(i)}
className="rounded-button bg-subtle px-sm py-xs text-small text-ink"
>
</button>
</li>
))}
</ul>
<button
type="button"
onClick={handleResourceAdd}
className="self-start rounded-button bg-subtle px-sm py-xs text-small text-ink"
>
</button>
</div>
<button
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"
>
{saving ? "保存中" : "保存"}
</button>
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,23 @@
/**
* lesson-plan-editor 插件清单teacher / main
*
* 备课画布:左侧备课列表 + 右侧编辑区,按 classId 过滤。
* 通过 useWidgetMutation 调用 saveLessonPlan 保存备课内容。
*/
import type { PluginManifest } from "@/lib/types";
export const manifestMeta: Omit<PluginManifest, "Component"> = {
pluginId: "lesson-plan-editor",
version: "0.1.0",
requiredShellVersion: "^1.0.0",
metadata: {
displayName: "备课画布",
description: "备课内容编辑与教学资源管理(按 classId 过滤)",
category: "teacher",
requiredRoles: ["teacher"],
defaultSlot: "main",
defaultSize: { colSpan: 2, rowSpan: 2 },
defaultProps: {},
propsSchema: { type: "object", properties: {} },
},
};

View File

@@ -0,0 +1,258 @@
"use client";
/**
* question-bankteacher / main
*
* 题库管理:按题型与难度筛选题目,展示题干、选项与答案,支持新建题目。
* 通过 useWidgetQuery 查询 apollo-router → core-edu 子图的 questions 数据。
* bankId 从 URL Search Params 读取。
*
* 关联portal-shell spec §5.2.1 URL 驱动、§5.6 统一 Hook
*/
import { useSearchParams } from "next/navigation";
import { useState } from "react";
import { useQuestionBank, type Question } from "@/lib/api";
import { PluginSkeleton } from "@/shell/PluginLoader";
import type { PluginProps } from "@/lib/types";
const TYPE_LABELS: Record<string, string> = {
single_choice: "单选",
multiple_choice: "多选",
fill_blank: "填空",
short_answer: "简答",
essay: "论述",
};
const DIFFICULTY_LABELS: Record<string, string> = {
easy: "简单",
medium: "中等",
hard: "困难",
};
const QUESTION_TYPES = Object.keys(TYPE_LABELS);
const DIFFICULTIES = Object.keys(DIFFICULTY_LABELS);
interface NewQuestion {
type: string;
difficulty: string;
content: string;
answer: string;
}
function createEmptyNewQuestion(): NewQuestion {
return { type: "single_choice", difficulty: "easy", content: "", answer: "" };
}
export default function QuestionBank(_props: PluginProps): React.ReactElement {
const searchParams = useSearchParams();
const bankId = searchParams.get("bankId") ?? "";
const [typeFilter, setTypeFilter] = useState("");
const [difficultyFilter, setDifficultyFilter] = useState("");
const [showForm, setShowForm] = useState(false);
const [newQuestion, setNewQuestion] = useState<NewQuestion>(
createEmptyNewQuestion,
);
const [localQuestions, setLocalQuestions] = useState<Question[]>([]);
const { data, loading } = useQuestionBank(bankId, {
type: typeFilter || undefined,
limit: 50,
});
if (loading && !data) {
return <PluginSkeleton variant="list" />;
}
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>
);
}
const serverQuestions = data ?? [];
let questions = [...localQuestions, ...serverQuestions];
if (difficultyFilter) {
questions = questions.filter((q) => q.difficulty === difficultyFilter);
}
const handleAdd = (): void => {
if (!newQuestion.content.trim()) {
return;
}
const created: Question = {
id: `local-${Date.now()}`,
type: newQuestion.type,
difficulty: newQuestion.difficulty,
content: newQuestion.content,
options: [],
answer: newQuestion.answer,
tags: [],
};
setLocalQuestions((list) => [created, ...list]);
setNewQuestion(createEmptyNewQuestion());
setShowForm(false);
};
return (
<section className="rounded-card border border-rule bg-surface p-md">
<div className="flex items-center justify-between">
<h3 className="text-heading-3 text-ink"></h3>
<button
type="button"
onClick={() => setShowForm((v) => !v)}
className="rounded-button bg-accent px-sm py-xs text-small text-ink-onAccent"
>
{showForm ? "收起新建" : "新建题目"}
</button>
</div>
<div className="mt-sm flex gap-md">
<label className="flex flex-col space-y-xs">
<span className="text-small text-ink-muted"></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"
>
<option value=""></option>
{QUESTION_TYPES.map((t) => (
<option key={t} value={t}>
{TYPE_LABELS[t]}
</option>
))}
</select>
</label>
<label className="flex flex-col space-y-xs">
<span className="text-small text-ink-muted"></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"
>
<option value=""></option>
{DIFFICULTIES.map((d) => (
<option key={d} value={d}>
{DIFFICULTY_LABELS[d]}
</option>
))}
</select>
</label>
</div>
{showForm ? (
<div className="mt-sm space-y-md rounded-card bg-subtle p-md">
<div className="flex gap-md">
<label className="flex flex-col space-y-xs">
<span className="text-small text-ink-muted"></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"
>
{QUESTION_TYPES.map((t) => (
<option key={t} value={t}>
{TYPE_LABELS[t]}
</option>
))}
</select>
</label>
<label className="flex flex-col space-y-xs">
<span className="text-small text-ink-muted"></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"
>
{DIFFICULTIES.map((d) => (
<option key={d} value={d}>
{DIFFICULTY_LABELS[d]}
</option>
))}
</select>
</label>
</div>
<label className="flex flex-col space-y-xs">
<span className="text-small text-ink-muted"></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"
rows={3}
placeholder="请输入题干"
/>
</label>
<label className="flex flex-col space-y-xs">
<span className="text-small text-ink-muted"></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"
rows={2}
placeholder="请输入答案"
/>
</label>
<button
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"
>
</button>
</div>
) : null}
<ul className="mt-sm space-y-md">
{questions.length === 0 ? (
<li className="text-small text-ink-muted"></li>
) : (
questions.map((q) => (
<li key={q.id} className="rounded-button border border-rule p-sm">
<div className="flex flex-wrap gap-xs">
<span className="rounded-button bg-accent px-xs py-xs text-tiny text-ink-onAccent">
{TYPE_LABELS[q.type] ?? q.type}
</span>
<span className="rounded-button bg-subtle px-xs py-xs text-tiny text-ink">
{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"
>
{tag}
</span>
))}
</div>
<p className="mt-xs text-body text-ink">{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">
{String.fromCharCode(65 + i)}. {opt}
</li>
))}
</ul>
) : null}
<p className="mt-xs text-small text-ink-muted">
{q.answer}
</p>
</li>
))
)}
</ul>
</section>
);
}

View File

@@ -0,0 +1,22 @@
/**
* question-bank 插件清单teacher / main
*
* 题库管理:按题型与难度筛选题目,支持新建题目。
*/
import type { PluginManifest } from "@/lib/types";
export const manifestMeta: Omit<PluginManifest, "Component"> = {
pluginId: "question-bank",
version: "0.1.0",
requiredShellVersion: "^1.0.0",
metadata: {
displayName: "题库管理",
description: "按题型与难度筛选题目并支持新建题目",
category: "teacher",
requiredRoles: ["teacher"],
defaultSlot: "main",
defaultSize: { colSpan: 2, rowSpan: 1 },
defaultProps: {},
propsSchema: { type: "object", properties: {} },
},
};

View File

@@ -0,0 +1,221 @@
"use client";
/**
* scheduling-rulesteacher / main
*
* 排课规则:以表格展示班级排课规则,支持行内编辑并通过 useWidgetMutation 保存。
* classId 从 URL Search Params 读取class-selector 切换时自动响应)。
*
* 关联portal-shell spec §5.2.1 URL 驱动、§5.6 统一 Hook
*/
import { useSearchParams } from "next/navigation";
import { useState } from "react";
import {
useSchedulingRules,
useUpdateSchedulingRule,
type SchedulingRule,
type SchedulingRuleInput,
} from "@/lib/api";
import { PluginSkeleton } from "@/shell/PluginLoader";
import type { PluginProps } from "@/lib/types";
const DAY_NAMES = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"];
function dayName(dayOfWeek: number): string {
return DAY_NAMES[dayOfWeek - 1] ?? `${dayOfWeek}`;
}
export default function SchedulingRules(
_props: PluginProps,
): React.ReactElement {
const searchParams = useSearchParams();
const classId = searchParams.get("classId") ?? "";
const { data, loading, refetch } = useSchedulingRules(classId);
const { run: updateRule, loading: saving } = useUpdateSchedulingRule();
const [editingId, setEditingId] = useState<string | null>(null);
const [draft, setDraft] = useState<SchedulingRule | null>(null);
if (loading && !data) {
return <PluginSkeleton variant="table" />;
}
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>
);
}
const rules = data ?? [];
const handleEdit = (rule: SchedulingRule): void => {
setEditingId(rule.id);
setDraft({ ...rule });
};
const handleCancel = (): void => {
setEditingId(null);
setDraft(null);
};
const handleSave = async (): Promise<void> => {
if (!draft) {
return;
}
const input: SchedulingRuleInput = {
dayOfWeek: draft.dayOfWeek,
periods: draft.periods,
subject: draft.subject,
teacherId: draft.teacherId,
room: draft.room,
};
try {
await updateRule({ id: draft.id, input });
setEditingId(null);
setDraft(null);
await refetch();
} catch {
/* toast: 保存失败 */
}
};
return (
<section className="rounded-card border border-rule bg-surface p-md">
<h3 className="text-heading-3 text-ink"></h3>
{rules.length === 0 ? (
<p className="mt-sm text-small text-ink-muted"></p>
) : (
<table className="mt-sm w-full text-small">
<thead>
<tr className="border-b border-rule text-ink-muted">
<th className="py-xs text-left"></th>
<th className="py-xs text-left"></th>
<th className="py-xs text-left"></th>
<th className="py-xs text-left"></th>
<th className="py-xs text-left"></th>
<th className="py-xs text-left"></th>
</tr>
</thead>
<tbody>
{rules.map((rule) => {
const isEditing = editingId === rule.id;
if (isEditing && draft) {
return (
<tr key={rule.id} className="border-b border-rule">
<td className="py-xs">
<select
value={draft.dayOfWeek}
onChange={(e) =>
setDraft((d) =>
d ? { ...d, dayOfWeek: Number(e.target.value) } : d,
)
}
className="rounded-button border border-rule bg-surface px-sm py-xs text-small text-ink"
>
{DAY_NAMES.map((name, i) => (
<option key={name} value={i + 1}>
{name}
</option>
))}
</select>
</td>
<td className="py-xs">
<input
type="text"
value={draft.periods}
onChange={(e) =>
setDraft((d) =>
d ? { ...d, periods: e.target.value } : d,
)
}
className="w-full rounded-button border border-rule bg-surface px-sm py-xs text-small text-ink"
/>
</td>
<td className="py-xs">
<input
type="text"
value={draft.subject}
onChange={(e) =>
setDraft((d) =>
d ? { ...d, subject: e.target.value } : d,
)
}
className="w-full rounded-button border border-rule bg-surface px-sm py-xs text-small text-ink"
/>
</td>
<td className="py-xs">
<input
type="text"
value={draft.teacherId}
onChange={(e) =>
setDraft((d) =>
d ? { ...d, teacherId: e.target.value } : d,
)
}
className="w-full rounded-button border border-rule bg-surface px-sm py-xs text-small text-ink"
/>
</td>
<td className="py-xs">
<input
type="text"
value={draft.room}
onChange={(e) =>
setDraft((d) =>
d ? { ...d, room: e.target.value } : d,
)
}
className="w-full rounded-button border border-rule bg-surface px-sm py-xs text-small text-ink"
/>
</td>
<td className="py-xs">
<div className="flex gap-xs">
<button
type="button"
onClick={handleSave}
disabled={saving}
className="rounded-button bg-accent px-sm py-xs text-tiny text-ink-onAccent disabled:opacity-50"
>
</button>
<button
type="button"
onClick={handleCancel}
className="rounded-button bg-subtle px-sm py-xs text-tiny text-ink"
>
</button>
</div>
</td>
</tr>
);
}
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>
<td className="py-xs">
<button
type="button"
onClick={() => handleEdit(rule)}
className="rounded-button bg-subtle px-sm py-xs text-tiny text-ink"
>
</button>
</td>
</tr>
);
})}
</tbody>
</table>
)}
</section>
);
}

View File

@@ -0,0 +1,22 @@
/**
* scheduling-rules 插件清单teacher / main
*
* 排课规则:以表格展示班级排课规则,支持行内编辑并保存。
*/
import type { PluginManifest } from "@/lib/types";
export const manifestMeta: Omit<PluginManifest, "Component"> = {
pluginId: "scheduling-rules",
version: "0.1.0",
requiredShellVersion: "^1.0.0",
metadata: {
displayName: "排课规则",
description: "查看并编辑班级排课规则(按 classId 过滤)",
category: "teacher",
requiredRoles: ["teacher"],
defaultSlot: "main",
defaultSize: { colSpan: 2, rowSpan: 1 },
defaultProps: {},
propsSchema: { type: "object", properties: {} },
},
};

View File

@@ -0,0 +1,129 @@
"use client";
/**
* textbook-managerteacher / main
*
* 教材管理:按科目与年级筛选教材,点击教材查看章节列表。
* 通过 useWidgetQuery 查询 apollo-router → content 子图的 textbooks 数据。
*
* 关联portal-shell spec §5.6 统一 Hook
*/
import { useState } from "react";
import { useTextbooks } from "@/lib/api";
import { PluginSkeleton } from "@/shell/PluginLoader";
import type { PluginProps } from "@/lib/types";
export default function TextbookManager(
_props: PluginProps,
): React.ReactElement {
const [subjectInput, setSubjectInput] = useState("");
const [gradeInput, setGradeInput] = useState("");
const [appliedSubject, setAppliedSubject] = useState("");
const [appliedGrade, setAppliedGrade] = useState("");
const [selectedId, setSelectedId] = useState<string | null>(null);
const { data, loading } = useTextbooks({
subjectId: appliedSubject || undefined,
grade: appliedGrade || undefined,
});
if (loading && !data) {
return <PluginSkeleton variant="list" />;
}
const textbooks = data ?? [];
const selected = textbooks.find((t) => t.id === selectedId) ?? null;
const handleApply = (): void => {
setAppliedSubject(subjectInput.trim());
setAppliedGrade(gradeInput.trim());
setSelectedId(null);
};
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">
<label className="flex flex-col space-y-xs">
<span className="text-small text-ink-muted"> 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"
placeholder="可选,输入科目 ID"
/>
</label>
<label className="flex flex-col space-y-xs">
<span className="text-small text-ink-muted"></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"
placeholder="可选,如:三年级"
/>
</label>
<button
type="button"
onClick={handleApply}
className="self-end rounded-button bg-accent px-md py-xs text-small text-ink-onAccent"
>
</button>
</div>
<div className="mt-md flex gap-md">
<ul className="flex-1 space-y-sm">
{textbooks.length === 0 ? (
<li className="text-small text-ink-muted"></li>
) : (
textbooks.map((t) => (
<li key={t.id}>
<button
type="button"
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"
}
>
<p className="text-body text-ink">{t.title}</p>
<p className="text-small text-ink-muted">
{t.author} · {t.publisher}
</p>
<p className="text-tiny text-ink-muted">ISBN: {t.isbn}</p>
</button>
</li>
))
)}
</ul>
<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">
{selected.author} · {selected.publisher}
</p>
<h5 className="mt-md text-small text-ink"></h5>
<ol className="mt-xs space-y-xs">
{selected.chapters.map((c, i) => (
<li key={c.id} className="text-small text-ink">
{i + 1}. {c.title}
</li>
))}
</ol>
</div>
) : (
<div className="rounded-card border border-rule bg-surface p-md text-small text-ink-muted">
</div>
)}
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,22 @@
/**
* textbook-manager 插件清单teacher / main
*
* 教材管理:按科目与年级筛选教材,点击查看章节列表。
*/
import type { PluginManifest } from "@/lib/types";
export const manifestMeta: Omit<PluginManifest, "Component"> = {
pluginId: "textbook-manager",
version: "0.1.0",
requiredShellVersion: "^1.0.0",
metadata: {
displayName: "教材管理",
description: "按科目与年级筛选教材并查看章节",
category: "teacher",
requiredRoles: ["teacher"],
defaultSlot: "main",
defaultSize: { colSpan: 2, rowSpan: 1 },
defaultProps: {},
propsSchema: { type: "object", properties: {} },
},
};

View File

@@ -0,0 +1,78 @@
"use client";
/**
* global-searchtopbar / top
*
* 通过 useGlobalSearch 查询 apollo-router 的 search 数据。
* 输入关键词后显示搜索建议下拉,点击结果导航到对应页面。
*
* 关联portal-shell spec §5.6 统一 Hook、M8 验收
*/
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useGlobalSearch, type SearchResult } from "@/lib/api/topbar";
import type { PluginProps } from "@/lib/types";
const TYPE_PATH_MAP: Record<string, string> = {
student: "/students",
class: "/classes",
exam: "/exams",
homework: "/homework",
announcement: "/announcements",
};
export default function GlobalSearch(_props: PluginProps): React.ReactElement {
const router = useRouter();
const [keyword, setKeyword] = useState("");
const [open, setOpen] = useState(false);
const { data } = useGlobalSearch(keyword, 8);
const handleSelect = (item: SearchResult): void => {
const base = TYPE_PATH_MAP[item.type] ?? "/search";
router.push(`${base}/${item.id}`);
setOpen(false);
setKeyword("");
};
const results = data ?? [];
return (
<div className="relative">
<input
type="search"
value={keyword}
onChange={(e) => {
setKeyword(e.target.value);
setOpen(true);
}}
onFocus={() => setOpen(true)}
placeholder="搜索学生、班级、考试…"
aria-label="全局搜索"
className="w-72 rounded-button border border-rule bg-surface px-sm py-xs text-small text-ink"
/>
{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">
{results.length === 0 ? (
<li className="text-small text-ink-muted"></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"
>
<span className="text-small text-ink">{item.title}</span>
<span className="text-tiny text-ink-muted">
{item.subtitle}
</span>
</button>
</li>
))
)}
</ul>
) : null}
</div>
);
}

View File

@@ -0,0 +1,20 @@
/**
* global-search 插件清单topbar
*
* 全局搜索,插入 top slot。查询 apollo-router 的 search 数据。
*/
import type { PluginManifest } from "@/lib/types";
export const manifestMeta: Omit<PluginManifest, "Component"> = {
pluginId: "global-search",
version: "0.1.0",
requiredShellVersion: "^1.0.0",
metadata: {
displayName: "全局搜索",
description: "搜索学生、班级、考试等",
category: "topbar",
requiredRoles: ["teacher", "student", "parent", "admin"],
defaultSlot: "top",
defaultSize: { colSpan: 1, rowSpan: 1 },
},
};

View File

@@ -0,0 +1,66 @@
"use client";
/**
* locale-switchertopbar / top
*
* 语言切换器,通过 usePluginStore 读写 localezh-CN / en
* 无 GraphQL 查询,纯 UI 状态。
*
* 关联portal-shell spec §5.2.2、M8 验收
*/
import { useState } from "react";
import { usePluginStore } from "@/shell/PluginStore";
import type { PluginProps } from "@/lib/types";
const LOCALES = [
{ value: "zh-CN", label: "简体中文" },
{ value: "en", label: "English" },
] as const;
type LocaleValue = (typeof LOCALES)[number]["value"];
export default function LocaleSwitcher(
_props: PluginProps,
): React.ReactElement {
const locale = usePluginStore((s) => s.locale);
const setLocale = usePluginStore((s) => s.setLocale);
const [open, setOpen] = useState(false);
const handleSelect = (value: LocaleValue): void => {
setLocale(value);
setOpen(false);
};
return (
<div className="relative">
<button
type="button"
aria-label="切换语言"
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
className="rounded-button bg-surface px-sm py-xs text-small text-ink"
>
{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">
{LOCALES.map((item) => (
<li key={item.value}>
<button
type="button"
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"
}
>
{item.label}
</button>
</li>
))}
</ul>
) : null}
</div>
);
}

View File

@@ -0,0 +1,20 @@
/**
* locale-switcher 插件清单topbar
*
* 语言切换器,插入 top slot。通过 usePluginStore 读写 locale。
*/
import type { PluginManifest } from "@/lib/types";
export const manifestMeta: Omit<PluginManifest, "Component"> = {
pluginId: "locale-switcher",
version: "0.1.0",
requiredShellVersion: "^1.0.0",
metadata: {
displayName: "语言切换",
description: "切换界面语言zh-CN / en",
category: "topbar",
requiredRoles: ["teacher", "student", "parent", "admin"],
defaultSlot: "top",
defaultSize: { colSpan: 1, rowSpan: 1 },
},
};

View File

@@ -3,29 +3,15 @@
/**
* notification-belltopbar / top
*
* 通过 useWidgetQuery 查询 apollo-router → msg 子图的 notifications 数据。
* 通过 useNotificationBell 查询 apollo-router → msg 子图的 notifications 数据。
* 点击铃铛展开下拉列表。
*
* 关联portal-shell spec §5.6 统一 Hook、M8 验收
*/
import { gql } from "@apollo/client";
import { useState } from "react";
import { useWidgetQuery } from "@/lib/useWidgetQuery";
import { useNotificationBell } from "@/lib/api/topbar";
import type { PluginProps } from "@/lib/types";
const GET_NOTIFICATIONS = gql`
query GetNotifications($limit: Int) {
notifications(limit: $limit) {
id
title
}
}
`;
interface NotificationsQueryData {
notifications: Array<{ id: string; title: string }>;
}
export default function NotificationBell(
props: PluginProps,
): React.ReactElement {
@@ -33,12 +19,9 @@ export default function NotificationBell(
const limit = typeof rawLimit === "number" ? rawLimit : 10;
const [open, setOpen] = useState(false);
const { data } = useWidgetQuery<NotificationsQueryData, { limit: number }>(
GET_NOTIFICATIONS,
{ limit },
);
const { data } = useNotificationBell(limit);
const items = data?.notifications ?? [];
const items = data ?? [];
return (
<div className="relative">

View File

@@ -3,42 +3,22 @@
/**
* user-menutopbar / top
*
* 通过 useWidgetQuery 查询 apollo-router → iam 子图的 me 数据。
* 通过 useCurrentUser 查询 apollo-router → iam 子图的 me 数据。
* 展示用户头像 + 下拉菜单(昵称 / 邮箱 / 角色)。
*
* 关联portal-shell spec §5.6 统一 Hook、M8 验收
*/
import { gql } from "@apollo/client";
import { useState } from "react";
import { useWidgetQuery } from "@/lib/useWidgetQuery";
import { useCurrentUser } from "@/lib/api/topbar";
import { useAuth } from "@/providers/AuthProvider";
import type { PluginProps } from "@/lib/types";
const GET_CURRENT_USER = gql`
query GetCurrentUser {
me {
id
name
email
role
}
}
`;
interface MeQueryData {
me: { id: string; name: string; email: string; role: string } | null;
}
export default function UserMenu(_props: PluginProps): React.ReactElement {
const { user } = useAuth();
const [open, setOpen] = useState(false);
const { data } = useWidgetQuery<MeQueryData, Record<string, never>>(
GET_CURRENT_USER,
{},
);
const { data: me } = useCurrentUser();
const me = data?.me;
const displayName = me?.name ?? user.name;
const displayEmail = me?.email ?? user.email;
const displayRole = me?.role ?? user.role;

View File

@@ -0,0 +1,52 @@
"use client";
/**
* announcements-widgetuniversal / main
*
* 通过 useAnnouncements 查询 apollo-router → msg 子图的 announcements 数据。
* limit 来自插件 props默认 20
*
* 关联portal-shell spec §5.6 统一 Hook、M8 验收
*/
import { useAnnouncements } from "@/lib/api/universal";
import { PluginSkeleton } from "@/shell/PluginLoader";
import type { PluginProps } from "@/lib/types";
export default function AnnouncementsWidget(
props: PluginProps,
): React.ReactElement {
const rawLimit = props.props.limit;
const limit = typeof rawLimit === "number" ? rawLimit : 20;
const { data, loading } = useAnnouncements(limit);
if (loading && !data) {
return <PluginSkeleton variant="list" />;
}
const rows = data ?? [];
return (
<section className="rounded-card border border-rule bg-surface p-md">
<h3 className="text-heading-3 text-ink"></h3>
{rows.length === 0 ? (
<p className="mt-sm text-small text-ink-muted"></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"
>
<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">
{row.author} · {row.publishedAt}
</p>
</li>
))}
</ul>
)}
</section>
);
}

View File

@@ -0,0 +1,28 @@
/**
* announcements-widget 插件清单universal
*
* 公告列表卡片,插入 main slot。
* 通过 useWidgetQuery 查询 apollo-router → msg 子图 announcements 数据。
*/
import type { PluginManifest } from "@/lib/types";
export const manifestMeta: Omit<PluginManifest, "Component"> = {
pluginId: "announcements-widget",
version: "0.1.0",
requiredShellVersion: "^1.0.0",
metadata: {
displayName: "公告",
description: "公告列表(标题、摘要、作者、发布时间)",
category: "universal",
requiredRoles: ["teacher", "student", "parent"],
defaultSlot: "main",
defaultSize: { colSpan: 2, rowSpan: 1 },
defaultProps: { limit: 20 },
propsSchema: {
type: "object",
properties: {
limit: { type: "number", description: "展示条数", default: 20 },
},
},
},
};

View File

@@ -0,0 +1,74 @@
"use client";
/**
* attendance-widgetuniversal / main
*
* 通过 useAttendance 查询 apollo-router → core-edu 子图的 attendance 数据。
* classId 与 termId 从 URL Search Params 读取,展示考勤统计大字数字。
*
* 关联portal-shell spec §5.2.1 URL 驱动、§5.6 统一 Hook、M8 验收
*/
import { useSearchParams } from "next/navigation";
import { useAttendance } from "@/lib/api/universal";
import { PluginSkeleton } from "@/shell/PluginLoader";
import type { PluginProps } from "@/lib/types";
export default function AttendanceWidget(
_props: PluginProps,
): React.ReactElement {
const searchParams = useSearchParams();
const classId = searchParams.get("classId") ?? "";
const termId = searchParams.get("termId") ?? "";
const { data, loading } = useAttendance(classId, termId, {
enabled: classId.length > 0 && termId.length > 0,
});
if (loading && !data) {
return <PluginSkeleton variant="stats" />;
}
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>
);
}
const stats = data;
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>
);
}
const items: Array<{ label: string; value: number }> = [
{ label: "出勤", value: stats.present },
{ label: "缺席", value: stats.absent },
{ label: "迟到", value: stats.late },
{ label: "总课时", value: stats.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-md">
{items.map((item) => (
<div
key={item.label}
className="flex-1 rounded-card bg-subtle p-md text-center"
>
<p className="text-tiny text-ink-muted">{item.label}</p>
<p className="mt-xs text-heading-3 text-ink">{item.value}</p>
</div>
))}
</div>
</section>
);
}

View File

@@ -0,0 +1,21 @@
/**
* attendance-widget 插件清单universal
*
* 跨角色通用考勤统计卡片,插入 main slot。
* 通过 useWidgetQuery 查询 apollo-router → core-edu 子图 attendance 数据。
*/
import type { PluginManifest } from "@/lib/types";
export const manifestMeta: Omit<PluginManifest, "Component"> = {
pluginId: "attendance-widget",
version: "0.1.0",
requiredShellVersion: "^1.0.0",
metadata: {
displayName: "考勤",
description: "学期考勤统计(出勤/缺席/迟到/总课时)",
category: "universal",
requiredRoles: ["teacher", "student", "parent"],
defaultSlot: "main",
defaultSize: { colSpan: 2, rowSpan: 1 },
},
};

View File

@@ -0,0 +1,86 @@
"use client";
/**
* exams-widgetuniversal / main
*
* 通过 useExams 查询 apollo-router → core-edu 子图的 exams 数据。
* classId 从 URL Search Params 读取limit 来自插件 props。
* 点击考试项时写入 URL examIdrouter.push供其他插件响应。
*
* 关联portal-shell spec §5.2.1 URL 驱动、§5.6 统一 Hook、M8 验收
*/
import { useRouter, useSearchParams } from "next/navigation";
import { useExams } from "@/lib/api/universal";
import { PluginSkeleton } from "@/shell/PluginLoader";
import type { PluginProps } from "@/lib/types";
export default function ExamsWidget(props: PluginProps): React.ReactElement {
const router = useRouter();
const searchParams = useSearchParams();
const classId = searchParams.get("classId") ?? "";
const currentExamId = searchParams.get("examId") ?? "";
const rawLimit = props.props.limit;
const limit = typeof rawLimit === "number" ? rawLimit : 20;
const { data, loading } = useExams(classId, limit, {
enabled: classId.length > 0,
});
const handleSelect = (examId: string): void => {
const params = new URLSearchParams(searchParams.toString());
params.set("examId", examId);
router.push(`?${params.toString()}`);
};
if (loading && !data) {
return <PluginSkeleton variant="table" />;
}
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>
);
}
const rows = data ?? [];
return (
<section className="rounded-card border border-rule bg-surface p-md">
<h3 className="text-heading-3 text-ink"></h3>
{rows.length === 0 ? (
<p className="text-small text-ink-muted"></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";
return (
<li key={row.id}>
<button
type="button"
onClick={() => handleSelect(row.id)}
className={itemClass}
aria-pressed={isActive}
>
<div className="flex">
<span className="flex-1 text-ink">{row.name}</span>
<span className="text-ink-muted">{row.subject}</span>
</div>
<div className="mt-xs flex text-tiny text-ink-muted">
<span className="flex-1">{row.examDate}</span>
<span> {row.maxScore}</span>
</div>
</button>
</li>
);
})}
</ul>
)}
</section>
);
}

View File

@@ -0,0 +1,29 @@
/**
* exams-widget 插件清单universal
*
* 考试列表卡片,插入 main slot。
* 通过 useWidgetQuery 查询 apollo-router → core-edu 子图 exams 数据。
* 点击考试项写入 URL examId供其他插件响应。
*/
import type { PluginManifest } from "@/lib/types";
export const manifestMeta: Omit<PluginManifest, "Component"> = {
pluginId: "exams-widget",
version: "0.1.0",
requiredShellVersion: "^1.0.0",
metadata: {
displayName: "考试",
description: "班级考试列表(点击写入 URL examId",
category: "universal",
requiredRoles: ["teacher", "student", "parent"],
defaultSlot: "main",
defaultSize: { colSpan: 2, rowSpan: 1 },
defaultProps: { limit: 20 },
propsSchema: {
type: "object",
properties: {
limit: { type: "number", description: "展示条数", default: 20 },
},
},
},
};

View File

@@ -3,40 +3,25 @@
/**
* grades-widgetuniversal / main
*
* 通过 useWidgetQuery 查询 apollo-router → core-edu 子图的 grades 数据。
* 通过 useGrades 查询 apollo-router → core-edu 子图的 grades 数据。
* classId 从 URL Search Params 读取class-selector 切换时自动响应)。
*
* 关联portal-shell spec §5.2.1 URL 驱动、§5.6 统一 Hook、M8 验收
*/
import { gql } from "@apollo/client";
import { useSearchParams } from "next/navigation";
import { useWidgetQuery } from "@/lib/useWidgetQuery";
import { useGrades } from "@/lib/api/universal";
import { PluginSkeleton } from "@/shell/PluginLoader";
import type { PluginProps } from "@/lib/types";
const GET_GRADES = gql`
query GetGrades($classId: ID!) {
grades(classId: $classId) {
studentId
score
}
}
`;
interface GradesQueryData {
grades: Array<{ studentId: string; score: number }>;
}
export default function GradesWidget(props: PluginProps): React.ReactElement {
const searchParams = useSearchParams();
const classId = searchParams.get("classId") ?? "";
const rawLimit = props.props.limit;
const limit = typeof rawLimit === "number" ? rawLimit : 20;
const { data, loading } = useWidgetQuery<
GradesQueryData,
{ classId: string }
>(GET_GRADES, { classId }, { enabled: classId.length > 0 });
const { data, loading } = useGrades(classId, {
enabled: classId.length > 0,
});
if (loading && !data) {
return <PluginSkeleton variant="table" />;
@@ -51,7 +36,7 @@ export default function GradesWidget(props: PluginProps): React.ReactElement {
);
}
const rows = data?.grades ?? [];
const rows = data ?? [];
return (
<section className="rounded-card border border-rule bg-surface p-md">

View File

@@ -0,0 +1,100 @@
"use client";
/**
* homework-widgetuniversal / main
*
* 通过 useHomework 查询 apollo-router → core-edu 子图的 homeworks 数据。
* classId 从 URL Search Params 读取class-selector 切换时自动响应),
* limit 来自插件 props默认 20
*
* 关联portal-shell spec §5.2.1 URL 驱动、§5.6 统一 Hook、M8 验收
*/
import { useSearchParams } from "next/navigation";
import { useHomework } from "@/lib/api/universal";
import { PluginSkeleton } from "@/shell/PluginLoader";
import type { PluginProps } from "@/lib/types";
const STATUS_LABEL: Record<string, string> = {
pending: "待提交",
submitted: "已提交",
graded: "已评分",
};
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">
{label}
</span>
);
}
if (status === "submitted") {
return (
<span className="rounded-button bg-accent px-sm py-xs text-tiny text-ink-onAccent">
{label}
</span>
);
}
return (
<span className="rounded-button bg-subtle px-sm py-xs text-tiny text-ink-muted">
{label}
</span>
);
}
export default function HomeworkWidget(props: PluginProps): React.ReactElement {
const searchParams = useSearchParams();
const classId = searchParams.get("classId") ?? "";
const rawLimit = props.props.limit;
const limit = typeof rawLimit === "number" ? rawLimit : 20;
const { data, loading } = useHomework(classId, limit, {
enabled: classId.length > 0,
});
if (loading && !data) {
return <PluginSkeleton variant="table" />;
}
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>
);
}
const rows = data ?? [];
return (
<section className="rounded-card border border-rule bg-surface p-md">
<h3 className="text-heading-3 text-ink"></h3>
{rows.length === 0 ? (
<p className="text-small text-ink-muted"></p>
) : (
<table className="mt-sm w-full text-small">
<thead>
<tr className="border-b border-rule text-ink-muted">
<th className="py-xs text-left"></th>
<th className="py-xs text-left"></th>
<th className="py-xs text-left"></th>
</tr>
</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>
<td className="py-xs">
<StatusBadge status={row.status} />
</td>
</tr>
))}
</tbody>
</table>
)}
</section>
);
}

View File

@@ -0,0 +1,28 @@
/**
* homework-widget 插件清单universal
*
* 跨角色通用作业列表卡片,插入 main slot。
* 通过 useWidgetQuery 查询 apollo-router → core-edu 子图 homeworks 数据。
*/
import type { PluginManifest } from "@/lib/types";
export const manifestMeta: Omit<PluginManifest, "Component"> = {
pluginId: "homework-widget",
version: "0.1.0",
requiredShellVersion: "^1.0.0",
metadata: {
displayName: "作业",
description: "班级作业列表(按 classId 过滤,含状态徽章)",
category: "universal",
requiredRoles: ["teacher", "student", "parent"],
defaultSlot: "main",
defaultSize: { colSpan: 2, rowSpan: 1 },
defaultProps: { limit: 20 },
propsSchema: {
type: "object",
properties: {
limit: { type: "number", description: "展示条数", default: 20 },
},
},
},
};

View File

@@ -0,0 +1,87 @@
"use client";
/**
* notifications-widgetuniversal / main
*
* 通过 useNotifications 查询 apollo-router → msg 子图的 notifications 数据。
* 与 topbar 的 notification-bell 区别:本插件位于 main 区,展示完整列表
* 含标题、正文、时间、类型徽章bell 仅在顶栏做徽标提示。
*
* 关联portal-shell spec §5.6 统一 Hook、M8 验收
*/
import { useNotifications } from "@/lib/api/universal";
import { PluginSkeleton } from "@/shell/PluginLoader";
import type { PluginProps } from "@/lib/types";
const TYPE_LABEL: Record<string, string> = {
info: "通知",
warning: "提醒",
urgent: "紧急",
};
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">
{label}
</span>
);
}
if (type === "warning") {
return (
<span className="rounded-button bg-accent px-sm py-xs text-tiny text-ink-onAccent">
{label}
</span>
);
}
return (
<span className="rounded-button bg-subtle px-sm py-xs text-tiny text-ink-muted">
{label}
</span>
);
}
export default function NotificationsWidget(
props: PluginProps,
): React.ReactElement {
const rawLimit = props.props.limit;
const limit = typeof rawLimit === "number" ? rawLimit : 20;
const { data, loading } = useNotifications({ limit, offset: 0 });
if (loading && !data) {
return <PluginSkeleton variant="list" />;
}
const items = data?.items ?? [];
const total = data?.total ?? 0;
return (
<section className="rounded-card border border-rule bg-surface p-md">
<div className="flex">
<h3 className="flex-1 text-heading-3 text-ink"></h3>
<span className="text-small text-ink-muted"> {total} </span>
</div>
{items.length === 0 ? (
<p className="mt-sm text-small text-ink-muted"></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"
>
<div className="flex items-center gap-md">
<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>
</li>
))}
</ul>
)}
</section>
);
}

View File

@@ -0,0 +1,28 @@
/**
* notifications-widget 插件清单universal
*
* main 区通知完整列表,区别于 topbar 的 notification-bell仅徽标提示
* 通过 useWidgetQuery 查询 apollo-router → msg 子图 notifications 数据。
*/
import type { PluginManifest } from "@/lib/types";
export const manifestMeta: Omit<PluginManifest, "Component"> = {
pluginId: "notifications-widget",
version: "0.1.0",
requiredShellVersion: "^1.0.0",
metadata: {
displayName: "通知列表",
description: "main 区通知完整列表(含正文、时间、类型徽章)",
category: "universal",
requiredRoles: ["teacher", "student", "parent"],
defaultSlot: "main",
defaultSize: { colSpan: 2, rowSpan: 1 },
defaultProps: { limit: 20 },
propsSchema: {
type: "object",
properties: {
limit: { type: "number", description: "展示条数", default: 20 },
},
},
},
};

View File

@@ -0,0 +1,70 @@
"use client";
/**
* schedule-widgetuniversal / main
*
* 通过 useSchedule 查询 apollo-router → core-edu 子图的 schedule 数据。
* classId 从 URL Search Params 读取,默认展示当天课表(按星期几过滤)。
*
* 关联portal-shell spec §5.2.1 URL 驱动、§5.6 统一 Hook、M8 验收
*/
import { useSearchParams } from "next/navigation";
import { useSchedule } from "@/lib/api/universal";
import { PluginSkeleton } from "@/shell/PluginLoader";
import type { PluginProps } from "@/lib/types";
export default function ScheduleWidget(
_props: PluginProps,
): React.ReactElement {
const searchParams = useSearchParams();
const classId = searchParams.get("classId") ?? "";
const today = new Date().getDay();
const dayOfWeek = today === 0 ? 7 : today; // 1=周一 ... 7=周日
const { data, loading } = useSchedule(classId, dayOfWeek, {
enabled: classId.length > 0,
});
if (loading && !data) {
return <PluginSkeleton variant="table" />;
}
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>
);
}
const rows = [...(data ?? [])].sort((a, b) =>
a.startTime.localeCompare(b.startTime),
);
return (
<section className="rounded-card border border-rule bg-surface p-md">
<h3 className="text-heading-3 text-ink"></h3>
{rows.length === 0 ? (
<p className="text-small text-ink-muted"></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">
{row.startTime} - {row.endTime}
</span>
<span className="flex-1 text-right text-ink-muted">
{row.teacherName}
</span>
</li>
))}
</ul>
)}
</section>
);
}

View File

@@ -0,0 +1,21 @@
/**
* schedule-widget 插件清单universal
*
* 跨角色通用课表卡片,插入 main slot。
* 通过 useWidgetQuery 查询 apollo-router → core-edu 子图 schedule 数据。
*/
import type { PluginManifest } from "@/lib/types";
export const manifestMeta: Omit<PluginManifest, "Component"> = {
pluginId: "schedule-widget",
version: "0.1.0",
requiredShellVersion: "^1.0.0",
metadata: {
displayName: "课表",
description: "班级当日课表(按 classId 与星期几过滤)",
category: "universal",
requiredRoles: ["teacher", "student", "parent"],
defaultSlot: "main",
defaultSize: { colSpan: 2, rowSpan: 1 },
},
};