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:
196
apps/portal-shell/src/lib/api/__tests__/admin.test.tsx
Normal file
196
apps/portal-shell/src/lib/api/__tests__/admin.test.tsx
Normal 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("失败时抛 ApiError(createInvitationCode 返回 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",
|
||||
);
|
||||
});
|
||||
});
|
||||
307
apps/portal-shell/src/lib/api/__tests__/parent.test.tsx
Normal file
307
apps/portal-shell/src/lib/api/__tests__/parent.test.tsx
Normal 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 === undefined(widget 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,
|
||||
);
|
||||
});
|
||||
});
|
||||
227
apps/portal-shell/src/lib/api/__tests__/sidebar.test.tsx
Normal file
227
apps/portal-shell/src/lib/api/__tests__/sidebar.test.tsx
Normal 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 === undefined(widget 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);
|
||||
});
|
||||
});
|
||||
235
apps/portal-shell/src/lib/api/__tests__/student.test.tsx
Normal file
235
apps/portal-shell/src/lib/api/__tests__/student.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
199
apps/portal-shell/src/lib/api/__tests__/teacher.test.tsx
Normal file
199
apps/portal-shell/src/lib/api/__tests__/teacher.test.tsx
Normal 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 === undefined(widget 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" });
|
||||
});
|
||||
});
|
||||
224
apps/portal-shell/src/lib/api/__tests__/topbar.test.tsx
Normal file
224
apps/portal-shell/src/lib/api/__tests__/topbar.test.tsx
Normal 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 === undefined(widget 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();
|
||||
});
|
||||
});
|
||||
205
apps/portal-shell/src/lib/api/__tests__/universal.test.ts
Normal file
205
apps/portal-shell/src/lib/api/__tests__/universal.test.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* Universal domain API 单元测试(portal-shell spec §5.6、§9.9)
|
||||
*
|
||||
* 覆盖:
|
||||
* - useGrades:归一化返回 Grade[](从 { grades: [...] } 拍平)
|
||||
* - useNotifications:归一化返回 NotificationList(从 { notifications: { items, total } } 拍平)
|
||||
* - useGrades:enabled=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,
|
||||
});
|
||||
});
|
||||
});
|
||||
651
apps/portal-shell/src/lib/api/admin.ts
Normal file
651
apps/portal-shell/src/lib/api/admin.ts
Normal 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 };
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
202
apps/portal-shell/src/lib/api/parent.ts
Normal file
202
apps/portal-shell/src/lib/api/parent.ts
Normal 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 或抛错)时抛 ApiError,widget 层可按 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 或抛错)时抛 ApiError,widget 层可按 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 };
|
||||
}
|
||||
112
apps/portal-shell/src/lib/api/sidebar.ts
Normal file
112
apps/portal-shell/src/lib/api/sidebar.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Sidebar domain API(spec §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,
|
||||
};
|
||||
}
|
||||
292
apps/portal-shell/src/lib/api/student.ts
Normal file
292
apps/portal-shell/src/lib/api/student.ts
Normal 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 };
|
||||
}
|
||||
257
apps/portal-shell/src/lib/api/teacher.ts
Normal file
257
apps/portal-shell/src/lib/api/teacher.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Teacher domain 语义化 API(spec §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 };
|
||||
}
|
||||
135
apps/portal-shell/src/lib/api/topbar.ts
Normal file
135
apps/portal-shell/src/lib/api/topbar.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
321
apps/portal-shell/src/lib/api/universal.ts
Normal file
321
apps/portal-shell/src/lib/api/universal.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user