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,
};
}