From 2910a902716566a7989b1a7ff94df9279e86ce90 Mon Sep 17 00:00:00 2001 From: SpecialX <47072643+wangxiner55@users.noreply.github.com> Date: Fri, 17 Jul 2026 13:07:24 +0800 Subject: [PATCH] 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 --- .../src/lib/api/__tests__/admin.test.tsx | 196 ++++++ .../src/lib/api/__tests__/parent.test.tsx | 307 +++++++++ .../src/lib/api/__tests__/sidebar.test.tsx | 227 ++++++ .../src/lib/api/__tests__/student.test.tsx | 235 +++++++ .../src/lib/api/__tests__/teacher.test.tsx | 199 ++++++ .../src/lib/api/__tests__/topbar.test.tsx | 224 ++++++ .../src/lib/api/__tests__/universal.test.ts | 205 ++++++ apps/portal-shell/src/lib/api/admin.ts | 651 ++++++++++++++++++ apps/portal-shell/src/lib/api/index.ts | 9 +- apps/portal-shell/src/lib/api/parent.ts | 202 ++++++ apps/portal-shell/src/lib/api/sidebar.ts | 112 +++ apps/portal-shell/src/lib/api/student.ts | 292 ++++++++ apps/portal-shell/src/lib/api/teacher.ts | 257 +++++++ apps/portal-shell/src/lib/api/topbar.ts | 135 ++++ apps/portal-shell/src/lib/api/universal.ts | 321 +++++++++ .../src/widgets/admin/audit-logs/index.tsx | 199 ++++++ .../admin/audit-logs/plugin.manifest.ts | 35 + .../widgets/admin/invitation-codes/index.tsx | 231 +++++++ .../admin/invitation-codes/plugin.manifest.ts | 40 ++ .../widgets/admin/plugin-manager/index.tsx | 451 ++++++++++++ .../admin/plugin-manager/plugin.manifest.ts | 24 + .../src/widgets/admin/rbac-manager/index.tsx | 176 +++++ .../admin/rbac-manager/plugin.manifest.ts | 22 + .../widgets/admin/school-settings/index.tsx | 197 ++++++ .../admin/school-settings/plugin.manifest.ts | 35 + .../widgets/admin/user-management/index.tsx | 195 ++++++ .../admin/user-management/plugin.manifest.ts | 22 + .../widgets/parent/child-overview/index.tsx | 117 ++++ .../parent/child-overview/plugin.manifest.ts | 23 + .../widgets/parent/leave-approval/index.tsx | 185 +++++ .../parent/leave-approval/plugin.manifest.ts | 23 + .../widgets/sidebar/child-selector/index.tsx | 59 ++ .../sidebar/child-selector/plugin.manifest.ts | 21 + .../widgets/sidebar/class-selector/index.tsx | 25 +- .../widgets/sidebar/quick-actions/index.tsx | 63 ++ .../sidebar/quick-actions/plugin.manifest.ts | 20 + .../widgets/sidebar/term-switcher/index.tsx | 57 ++ .../sidebar/term-switcher/plugin.manifest.ts | 21 + .../src/widgets/student/ai-tutor/index.tsx | 168 +++++ .../student/ai-tutor/plugin.manifest.ts | 27 + .../student/elective-selector/index.tsx | 144 ++++ .../elective-selector/plugin.manifest.ts | 22 + .../src/widgets/student/error-book/index.tsx | 123 ++++ .../student/error-book/plugin.manifest.ts | 27 + .../widgets/student/learning-path/index.tsx | 148 ++++ .../student/learning-path/plugin.manifest.ts | 22 + .../teacher/lesson-plan-editor/index.tsx | 216 ++++++ .../lesson-plan-editor/plugin.manifest.ts | 23 + .../widgets/teacher/question-bank/index.tsx | 258 +++++++ .../teacher/question-bank/plugin.manifest.ts | 22 + .../teacher/scheduling-rules/index.tsx | 221 ++++++ .../scheduling-rules/plugin.manifest.ts | 22 + .../teacher/textbook-manager/index.tsx | 129 ++++ .../textbook-manager/plugin.manifest.ts | 22 + .../widgets/topbar/global-search/index.tsx | 78 +++ .../topbar/global-search/plugin.manifest.ts | 20 + .../widgets/topbar/locale-switcher/index.tsx | 66 ++ .../topbar/locale-switcher/plugin.manifest.ts | 20 + .../topbar/notification-bell/index.tsx | 25 +- .../src/widgets/topbar/user-menu/index.tsx | 26 +- .../universal/announcements-widget/index.tsx | 52 ++ .../announcements-widget/plugin.manifest.ts | 28 + .../universal/attendance-widget/index.tsx | 74 ++ .../attendance-widget/plugin.manifest.ts | 21 + .../widgets/universal/exams-widget/index.tsx | 86 +++ .../universal/exams-widget/plugin.manifest.ts | 29 + .../widgets/universal/grades-widget/index.tsx | 27 +- .../universal/homework-widget/index.tsx | 100 +++ .../homework-widget/plugin.manifest.ts | 28 + .../universal/notifications-widget/index.tsx | 87 +++ .../notifications-widget/plugin.manifest.ts | 28 + .../universal/schedule-widget/index.tsx | 70 ++ .../schedule-widget/plugin.manifest.ts | 21 + 73 files changed, 8206 insertions(+), 87 deletions(-) create mode 100644 apps/portal-shell/src/lib/api/__tests__/admin.test.tsx create mode 100644 apps/portal-shell/src/lib/api/__tests__/parent.test.tsx create mode 100644 apps/portal-shell/src/lib/api/__tests__/sidebar.test.tsx create mode 100644 apps/portal-shell/src/lib/api/__tests__/student.test.tsx create mode 100644 apps/portal-shell/src/lib/api/__tests__/teacher.test.tsx create mode 100644 apps/portal-shell/src/lib/api/__tests__/topbar.test.tsx create mode 100644 apps/portal-shell/src/lib/api/__tests__/universal.test.ts create mode 100644 apps/portal-shell/src/lib/api/admin.ts create mode 100644 apps/portal-shell/src/lib/api/parent.ts create mode 100644 apps/portal-shell/src/lib/api/sidebar.ts create mode 100644 apps/portal-shell/src/lib/api/student.ts create mode 100644 apps/portal-shell/src/lib/api/teacher.ts create mode 100644 apps/portal-shell/src/lib/api/topbar.ts create mode 100644 apps/portal-shell/src/lib/api/universal.ts create mode 100644 apps/portal-shell/src/widgets/admin/audit-logs/index.tsx create mode 100644 apps/portal-shell/src/widgets/admin/audit-logs/plugin.manifest.ts create mode 100644 apps/portal-shell/src/widgets/admin/invitation-codes/index.tsx create mode 100644 apps/portal-shell/src/widgets/admin/invitation-codes/plugin.manifest.ts create mode 100644 apps/portal-shell/src/widgets/admin/plugin-manager/index.tsx create mode 100644 apps/portal-shell/src/widgets/admin/plugin-manager/plugin.manifest.ts create mode 100644 apps/portal-shell/src/widgets/admin/rbac-manager/index.tsx create mode 100644 apps/portal-shell/src/widgets/admin/rbac-manager/plugin.manifest.ts create mode 100644 apps/portal-shell/src/widgets/admin/school-settings/index.tsx create mode 100644 apps/portal-shell/src/widgets/admin/school-settings/plugin.manifest.ts create mode 100644 apps/portal-shell/src/widgets/admin/user-management/index.tsx create mode 100644 apps/portal-shell/src/widgets/admin/user-management/plugin.manifest.ts create mode 100644 apps/portal-shell/src/widgets/parent/child-overview/index.tsx create mode 100644 apps/portal-shell/src/widgets/parent/child-overview/plugin.manifest.ts create mode 100644 apps/portal-shell/src/widgets/parent/leave-approval/index.tsx create mode 100644 apps/portal-shell/src/widgets/parent/leave-approval/plugin.manifest.ts create mode 100644 apps/portal-shell/src/widgets/sidebar/child-selector/index.tsx create mode 100644 apps/portal-shell/src/widgets/sidebar/child-selector/plugin.manifest.ts create mode 100644 apps/portal-shell/src/widgets/sidebar/quick-actions/index.tsx create mode 100644 apps/portal-shell/src/widgets/sidebar/quick-actions/plugin.manifest.ts create mode 100644 apps/portal-shell/src/widgets/sidebar/term-switcher/index.tsx create mode 100644 apps/portal-shell/src/widgets/sidebar/term-switcher/plugin.manifest.ts create mode 100644 apps/portal-shell/src/widgets/student/ai-tutor/index.tsx create mode 100644 apps/portal-shell/src/widgets/student/ai-tutor/plugin.manifest.ts create mode 100644 apps/portal-shell/src/widgets/student/elective-selector/index.tsx create mode 100644 apps/portal-shell/src/widgets/student/elective-selector/plugin.manifest.ts create mode 100644 apps/portal-shell/src/widgets/student/error-book/index.tsx create mode 100644 apps/portal-shell/src/widgets/student/error-book/plugin.manifest.ts create mode 100644 apps/portal-shell/src/widgets/student/learning-path/index.tsx create mode 100644 apps/portal-shell/src/widgets/student/learning-path/plugin.manifest.ts create mode 100644 apps/portal-shell/src/widgets/teacher/lesson-plan-editor/index.tsx create mode 100644 apps/portal-shell/src/widgets/teacher/lesson-plan-editor/plugin.manifest.ts create mode 100644 apps/portal-shell/src/widgets/teacher/question-bank/index.tsx create mode 100644 apps/portal-shell/src/widgets/teacher/question-bank/plugin.manifest.ts create mode 100644 apps/portal-shell/src/widgets/teacher/scheduling-rules/index.tsx create mode 100644 apps/portal-shell/src/widgets/teacher/scheduling-rules/plugin.manifest.ts create mode 100644 apps/portal-shell/src/widgets/teacher/textbook-manager/index.tsx create mode 100644 apps/portal-shell/src/widgets/teacher/textbook-manager/plugin.manifest.ts create mode 100644 apps/portal-shell/src/widgets/topbar/global-search/index.tsx create mode 100644 apps/portal-shell/src/widgets/topbar/global-search/plugin.manifest.ts create mode 100644 apps/portal-shell/src/widgets/topbar/locale-switcher/index.tsx create mode 100644 apps/portal-shell/src/widgets/topbar/locale-switcher/plugin.manifest.ts create mode 100644 apps/portal-shell/src/widgets/universal/announcements-widget/index.tsx create mode 100644 apps/portal-shell/src/widgets/universal/announcements-widget/plugin.manifest.ts create mode 100644 apps/portal-shell/src/widgets/universal/attendance-widget/index.tsx create mode 100644 apps/portal-shell/src/widgets/universal/attendance-widget/plugin.manifest.ts create mode 100644 apps/portal-shell/src/widgets/universal/exams-widget/index.tsx create mode 100644 apps/portal-shell/src/widgets/universal/exams-widget/plugin.manifest.ts create mode 100644 apps/portal-shell/src/widgets/universal/homework-widget/index.tsx create mode 100644 apps/portal-shell/src/widgets/universal/homework-widget/plugin.manifest.ts create mode 100644 apps/portal-shell/src/widgets/universal/notifications-widget/index.tsx create mode 100644 apps/portal-shell/src/widgets/universal/notifications-widget/plugin.manifest.ts create mode 100644 apps/portal-shell/src/widgets/universal/schedule-widget/index.tsx create mode 100644 apps/portal-shell/src/widgets/universal/schedule-widget/plugin.manifest.ts diff --git a/apps/portal-shell/src/lib/api/__tests__/admin.test.tsx b/apps/portal-shell/src/lib/api/__tests__/admin.test.tsx new file mode 100644 index 0000000..e5e4422 --- /dev/null +++ b/apps/portal-shell/src/lib/api/__tests__/admin.test.tsx @@ -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(items + total)、loading 期间 data 为 undefined + * - useCreateInvitationCode:成功返回 CreatedInvitationCode、失败抛 ApiError + */ + +function createWrapper(mocks: MockedResponse[]) { + return function Wrapper({ children }: { children: ReactNode }): ReactNode { + return {children}; + }; +} + +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", + ); + }); +}); diff --git a/apps/portal-shell/src/lib/api/__tests__/parent.test.tsx b/apps/portal-shell/src/lib/api/__tests__/parent.test.tsx new file mode 100644 index 0000000..b8fb3e7 --- /dev/null +++ b/apps/portal-shell/src/lib/api/__tests__/parent.test.tsx @@ -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 {children}; + }; +} + +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, + ); + }); +}); diff --git a/apps/portal-shell/src/lib/api/__tests__/sidebar.test.tsx b/apps/portal-shell/src/lib/api/__tests__/sidebar.test.tsx new file mode 100644 index 0000000..92cf0ad --- /dev/null +++ b/apps/portal-shell/src/lib/api/__tests__/sidebar.test.tsx @@ -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 {children}; + }; +} + +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); + }); +}); diff --git a/apps/portal-shell/src/lib/api/__tests__/student.test.tsx b/apps/portal-shell/src/lib/api/__tests__/student.test.tsx new file mode 100644 index 0000000..f4efa53 --- /dev/null +++ b/apps/portal-shell/src/lib/api/__tests__/student.test.tsx @@ -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 {children}; + }; +} + +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); + }); +}); diff --git a/apps/portal-shell/src/lib/api/__tests__/teacher.test.tsx b/apps/portal-shell/src/lib/api/__tests__/teacher.test.tsx new file mode 100644 index 0000000..622faca --- /dev/null +++ b/apps/portal-shell/src/lib/api/__tests__/teacher.test.tsx @@ -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 {children}; + }; +} + +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" }); + }); +}); diff --git a/apps/portal-shell/src/lib/api/__tests__/topbar.test.tsx b/apps/portal-shell/src/lib/api/__tests__/topbar.test.tsx new file mode 100644 index 0000000..77c683e --- /dev/null +++ b/apps/portal-shell/src/lib/api/__tests__/topbar.test.tsx @@ -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 {children}; + }; +} + +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(); + }); +}); diff --git a/apps/portal-shell/src/lib/api/__tests__/universal.test.ts b/apps/portal-shell/src/lib/api/__tests__/universal.test.ts new file mode 100644 index 0000000..ab46563 --- /dev/null +++ b/apps/portal-shell/src/lib/api/__tests__/universal.test.ts @@ -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, + }); + }); +}); diff --git a/apps/portal-shell/src/lib/api/admin.ts b/apps/portal-shell/src/lib/api/admin.ts new file mode 100644 index 0000000..46fa15a --- /dev/null +++ b/apps/portal-shell/src/lib/api/admin.ts @@ -0,0 +1,651 @@ +"use client"; + +/** + * Admin domain API(语义化 Hook) + * + * 为 widgets/admin/* 提供语义化查询/变更 Hook,封装 GraphQL DOC 与类型映射。 + * widget 通过 `import { useUsers } from "@/lib/api/admin"` 调用。 + * + * 设计: + * - 查询 Hook 返回 UseQueryResult,data 已归一化(剥离外层 query 字段) + * - 变更 Hook 返回 { run, loading, error },run 抛 ApiError 表示业务失败 + * - 分页查询返回 UseQueryResult>,保留 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; + propsSchema: Record; +} + +export interface RoleMapping { + role: string; + pluginId: string; + slot: string; + sortOrder: number; + isEnabled: boolean; + widgetProps: Record; +} + +export interface RolePluginMappingInput { + pluginId: string; + slot: string; + sortOrder: number; + isEnabled: boolean; + widgetProps: Record; +} + +export interface LayoutTemplate { + layoutId: string; + displayName: string; + description: string; + availableSlots: string[]; +} + +export interface RoleLayoutDefault { + role: string; + layoutId: string; + slotOverrides: Record; +} + +export interface PluginRegistryInput { + isActive?: boolean; + defaultProps?: Record; +} + +export interface UpdatedPluginRegistry { + pluginId: string; + isActive: boolean; + defaultProps: Record; +} + +export interface UpdatedRoleMapping { + role: string; + pluginId: string; + isEnabled: boolean; +} + +// ============================================================ +// Hooks: User management +// ============================================================ +export function useUsers( + vars: UserQueryVars, +): UseQueryResult> { + const result = useWidgetQuery< + { users: PaginatedResult }, + 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 { + const result = useWidgetQuery<{ roles: Role[] }, Record>( + GET_ROLES_DOC, + {}, + ); + return { + ...result, + data: result.data?.roles, + }; +} + +export function usePermissions(): UseQueryResult { + const result = useWidgetQuery< + { permissions: Permission[] }, + Record + >(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> { + const result = useWidgetQuery< + { auditLogs: PaginatedResult }, + { 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 { + 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; + loading: boolean; + error: unknown; +} { + const { + run: rawRun, + loading, + error, + } = useWidgetMutation< + { createInvitationCode: CreatedInvitationCode }, + { input: CreateInvitationCodeInput } + >(CREATE_INVITATION_CODE_DOC); + + const run = async ( + input: CreateInvitationCodeInput, + ): Promise => { + 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 { + const result = useWidgetQuery< + { school: School | null }, + Record + >(GET_SCHOOL_DOC, {}); + return { + ...result, + data: result.data?.school ?? null, + }; +} + +export function useUpdateSchool(): { + run: (input: SchoolInput) => Promise; + loading: boolean; + error: unknown; +} { + const { + run: rawRun, + loading, + error, + } = useWidgetMutation<{ updateSchool: School }, { input: SchoolInput }>( + UPDATE_SCHOOL_DOC, + ); + + const run = async (input: SchoolInput): Promise => { + 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 { + const result = useWidgetQuery< + { pluginRegistry: RegistryItem[] }, + Record + >(GET_PLUGIN_REGISTRY_DOC, {}); + return { + ...result, + data: result.data?.pluginRegistry, + }; +} + +export function useRolePluginMapping( + role: string, +): UseQueryResult { + const result = useWidgetQuery< + { rolePluginMapping: RoleMapping[] }, + { role: string | null } + >(GET_ROLE_PLUGIN_MAPPING_DOC, { role }); + return { + ...result, + data: result.data?.rolePluginMapping, + }; +} + +export function useLayoutTemplates(): UseQueryResult { + const result = useWidgetQuery< + { layoutTemplates: LayoutTemplate[] }, + Record + >(GET_LAYOUT_TEMPLATES_DOC, {}); + return { + ...result, + data: result.data?.layoutTemplates, + }; +} + +export function useRoleLayoutDefault( + role: string, +): UseQueryResult { + 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; + 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 => { + 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; + 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 => { + 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 }; +} diff --git a/apps/portal-shell/src/lib/api/index.ts b/apps/portal-shell/src/lib/api/index.ts index 5aa48fe..c357f1c 100644 --- a/apps/portal-shell/src/lib/api/index.ts +++ b/apps/portal-shell/src/lib/api/index.ts @@ -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"; diff --git a/apps/portal-shell/src/lib/api/parent.ts b/apps/portal-shell/src/lib/api/parent.ts new file mode 100644 index 0000000..5e2eefd --- /dev/null +++ b/apps/portal-shell/src/lib/api/parent.ts @@ -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 { + const result = useWidgetQuery< + MyChildrenOverviewQueryData, + Record + >(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 { + const variables: LeaveRequestsQueryVars = { + childId, + status, + }; + + const result = useWidgetQuery( + 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; + loading: boolean; + error: unknown; +} { + const { + run: rawRun, + loading, + error, + } = useWidgetMutation( + APPROVE_LEAVE_DOC, + ); + + const run = async (id: string): Promise => { + 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; + loading: boolean; + error: unknown; +} { + const { + run: rawRun, + loading, + error, + } = useWidgetMutation( + REJECT_LEAVE_DOC, + ); + + const run = async (id: string, reason: string): Promise => { + const data = await rawRun({ id, reason }); + if (!data?.rejectLeave) { + throw new ApiError("Failed to reject leave", "INTERNAL_ERROR"); + } + }; + + return { run, loading, error }; +} diff --git a/apps/portal-shell/src/lib/api/sidebar.ts b/apps/portal-shell/src/lib/api/sidebar.ts new file mode 100644 index 0000000..1fd47d5 --- /dev/null +++ b/apps/portal-shell/src/lib/api/sidebar.ts @@ -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 = Omit, "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, +): UseQueryResult { + const result = useWidgetQuery< + { myClasses: SidebarClass[] }, + Record + >(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, +): UseQueryResult { + const result = useWidgetQuery< + { myChildren: SidebarChild[] }, + Record + >(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, +): UseQueryResult { + const result = useWidgetQuery<{ terms: Term[] }, Record>( + GET_TERMS_DOC, + {}, + options, + ); + + return { + ...result, + data: result.data?.terms, + }; +} diff --git a/apps/portal-shell/src/lib/api/student.ts b/apps/portal-shell/src/lib/api/student.ts new file mode 100644 index 0000000..917864c --- /dev/null +++ b/apps/portal-shell/src/lib/api/student.ts @@ -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,归一化嵌套字段(如 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 { + 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; + loading: boolean; + error: unknown; +} { + const { + run: rawRun, + loading, + error, + } = useWidgetMutation<{ markErrorMastered: boolean }, { id: string }>( + MARK_ERROR_MASTERED_DOC, + ); + + const run = async (id: string): Promise => { + 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 { + 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 { + 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; + loading: boolean; + error: unknown; +} { + const { + run: rawRun, + loading, + error, + } = useWidgetMutation<{ enrollCourse: boolean }, { courseId: string }>( + ENROLL_COURSE_DOC, + ); + + const run = async (courseId: string): Promise => { + 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; + loading: boolean; + error: unknown; +} { + const { + run: rawRun, + loading, + error, + } = useWidgetMutation<{ dropCourse: boolean }, { courseId: string }>( + DROP_COURSE_DOC, + ); + + const run = async (courseId: string): Promise => { + 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 { + 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; + 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 => { + 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 }; +} diff --git a/apps/portal-shell/src/lib/api/teacher.ts b/apps/portal-shell/src/lib/api/teacher.ts new file mode 100644 index 0000000..1e3950f --- /dev/null +++ b/apps/portal-shell/src/lib/api/teacher.ts @@ -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 = Omit, "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, +): UseQueryResult { + 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, +): UseQueryResult { + 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, +): UseQueryResult { + 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, +): UseQueryResult { + 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 }; +} diff --git a/apps/portal-shell/src/lib/api/topbar.ts b/apps/portal-shell/src/lib/api/topbar.ts new file mode 100644 index 0000000..04011ec --- /dev/null +++ b/apps/portal-shell/src/lib/api/topbar.ts @@ -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; + +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 { + const result = useWidgetQuery( + 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 { + const result = useWidgetQuery( + 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 { + const result = useWidgetQuery( + SEARCH_DOC, + { keyword, limit }, + { enabled: keyword.length > 0 }, + ); + + return { + data: result.data?.search, + loading: result.loading, + error: result.error, + refetch: result.refetch, + }; +} diff --git a/apps/portal-shell/src/lib/api/universal.ts b/apps/portal-shell/src/lib/api/universal.ts new file mode 100644 index 0000000..e0d38ec --- /dev/null +++ b/apps/portal-shell/src/lib/api/universal.ts @@ -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` 约束(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 { + const result = useWidgetQuery( + 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 { + const result = useWidgetQuery( + 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 { + const result = useWidgetQuery( + 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 { + const result = useWidgetQuery( + 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 { + const result = useWidgetQuery( + 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 { + 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 { + const result = useWidgetQuery( + GET_ANNOUNCEMENTS_DOC, + { limit }, + ); + + return { + data: result.data?.announcements, + loading: result.loading, + error: result.error, + refetch: result.refetch, + }; +} diff --git a/apps/portal-shell/src/widgets/admin/audit-logs/index.tsx b/apps/portal-shell/src/widgets/admin/audit-logs/index.tsx new file mode 100644 index 0000000..64c941c --- /dev/null +++ b/apps/portal-shell/src/widgets/admin/audit-logs/index.tsx @@ -0,0 +1,199 @@ +"use client"; + +/** + * audit-logs(admin / main) + * + * 审计日志查看。展示用户操作记录,支持按 userId / action / resource 筛选 + 分页。 + * 只读,无变更操作。 + * + * 数据流:useAuditLogs → Apollo Client → apollo-router → iam 子图 auditLogs + * + * 关联:portal-shell spec §3 admin 分类、§5.6 统一 Hook + */ +import { useState } from "react"; +import { useAuditLogs } from "@/lib/api/admin"; +import { PluginSkeleton } from "@/shell/PluginLoader"; +import type { PluginProps } from "@/lib/types"; + +const ACTION_OPTIONS = [ + "create", + "update", + "delete", + "login", + "logout", + "export", +] as const; + +const RESOURCE_OPTIONS = [ + "user", + "role", + "plugin", + "class", + "exam", + "grade", + "homework", +] as const; + +const inputCls = + "rounded-button border border-rule bg-surface px-sm py-xs text-small text-ink"; + +export default function AuditLogs(props: PluginProps): React.ReactElement { + const rawPageSize = props.props.pageSize; + const pageSize = + typeof rawPageSize === "number" && rawPageSize > 0 ? rawPageSize : 20; + + const [userIdFilter, setUserIdFilter] = useState(""); + const [actionFilter, setActionFilter] = useState(""); + const [resourceFilter, setResourceFilter] = useState(""); + const [offset, setOffset] = useState(0); + const [selectedId, setSelectedId] = useState(null); + + const filter = { + userId: userIdFilter.length > 0 ? userIdFilter : null, + action: actionFilter.length > 0 ? actionFilter : null, + resource: resourceFilter.length > 0 ? resourceFilter : null, + }; + + const { data, loading } = useAuditLogs(filter, { + limit: pageSize, + offset, + }); + + const handleFilterChange = ( + setter: (v: string) => void, + ): ((e: React.ChangeEvent) => void) => { + return (e) => { + setter(e.target.value); + setOffset(0); + setSelectedId(null); + }; + }; + + if (loading && !data) { + return ; + } + + const logs = data?.items ?? []; + const total = data?.total ?? 0; + const hasPrev = offset > 0; + const hasNext = offset + pageSize < total; + const rangeEnd = Math.min(offset + pageSize, total); + const selectedLog = logs.find((l) => l.id === selectedId); + + return ( +
+

审计日志

+ +
+ + + +
+ + {logs.length === 0 ? ( +

暂无审计日志

+ ) : ( +
+ + + + + + + + + + + + {logs.map((log) => { + const isSelected = log.id === selectedId; + return ( + setSelectedId(isSelected ? null : log.id)} + className={`cursor-pointer border-b border-rule ${ + isSelected ? "bg-accent-subtle" : "bg-paper" + }`} + > + + + + + + + ); + })} + +
时间用户操作资源IP
{log.timestamp}{log.userName}{log.action} + {log.resource} + {log.resourceId ? ` / ${log.resourceId}` : ""} + {log.ip}
+
+ )} + + {selectedLog ? ( +
+

详情

+
+            {selectedLog.details || "(无详细信息)"}
+          
+
+ ) : null} + +
+ + 共 {total} 条,第 {offset + 1} - {rangeEnd} 条 + +
+ + +
+
+
+ ); +} diff --git a/apps/portal-shell/src/widgets/admin/audit-logs/plugin.manifest.ts b/apps/portal-shell/src/widgets/admin/audit-logs/plugin.manifest.ts new file mode 100644 index 0000000..d0b859e --- /dev/null +++ b/apps/portal-shell/src/widgets/admin/audit-logs/plugin.manifest.ts @@ -0,0 +1,35 @@ +/** + * audit-logs 插件清单(admin) + * + * 审计日志查看,插入 main slot。支持按用户/操作/资源筛选 + 分页。 + * 查询经 apollo-router → iam 子图 auditLogs。 + * + * 关联:portal-shell spec §3 admin 分类、§5.6 统一 Hook + */ +import type { PluginManifest } from "@/lib/types"; + +export const manifestMeta: Omit = { + pluginId: "audit-logs", + version: "0.1.0", + requiredShellVersion: "^1.0.0", + metadata: { + displayName: "审计日志", + description: "查看用户操作审计记录,支持按用户/操作/资源筛选", + category: "admin", + requiredRoles: ["admin"], + defaultSlot: "main", + defaultSize: { colSpan: 2, rowSpan: 2 }, + defaultProps: { + pageSize: 20, + }, + propsSchema: { + type: "object", + properties: { + pageSize: { + type: "number", + description: "每页显示条数(10/20/50)", + }, + }, + }, + }, +}; diff --git a/apps/portal-shell/src/widgets/admin/invitation-codes/index.tsx b/apps/portal-shell/src/widgets/admin/invitation-codes/index.tsx new file mode 100644 index 0000000..1000908 --- /dev/null +++ b/apps/portal-shell/src/widgets/admin/invitation-codes/index.tsx @@ -0,0 +1,231 @@ +"use client"; + +/** + * invitation-codes(admin / main) + * + * 邀请码管理。展示邀请码列表,支持生成新码(指定角色/有效期/次数)与撤销。 + * + * 数据流:useInvitationCodes/useCreateInvitationCode/useRevokeInvitationCode + * → Apollo Client → apollo-router → iam 子图 + * + * 关联:portal-shell spec §3 admin 分类、§5.6 统一 Hook + */ +import { useState } from "react"; +import { + useCreateInvitationCode, + useInvitationCodes, + useRevokeInvitationCode, +} from "@/lib/api/admin"; +import { PluginSkeleton } from "@/shell/PluginLoader"; +import type { PluginProps } from "@/lib/types"; + +type CodeStatus = "active" | "revoked" | "expired" | "all"; +const STATUS_OPTIONS: CodeStatus[] = ["all", "active", "revoked", "expired"]; +const STATUS_LABELS: Record = { + all: "全部", + active: "可用", + revoked: "已撤销", + expired: "已过期", +}; + +const ROLE_OPTIONS = ["teacher", "student", "parent"] as const; + +const inputCls = + "rounded-button border border-rule bg-surface px-sm py-xs text-small text-ink"; +const labelCls = "text-tiny text-ink-muted"; + +export default function InvitationCodes( + props: PluginProps, +): React.ReactElement { + const defaultMaxUses = + typeof props.props.defaultMaxUses === "number" + ? (props.props.defaultMaxUses as number) + : 1; + const defaultTtlHours = + typeof props.props.defaultTtlHours === "number" + ? (props.props.defaultTtlHours as number) + : 72; + + const [statusFilter, setStatusFilter] = useState("all"); + const [newRole, setNewRole] = useState("teacher"); + const [newMaxUses, setNewMaxUses] = useState(defaultMaxUses); + const [newTtlHours, setNewTtlHours] = useState(defaultTtlHours); + const [busyId, setBusyId] = useState(null); + const [status, setStatus] = useState(""); + + const { data, loading, refetch } = useInvitationCodes( + statusFilter === "all" ? null : statusFilter, + ); + + const { run: createCode } = useCreateInvitationCode(); + const { run: revokeCode } = useRevokeInvitationCode(); + + const handleCreate = async (): Promise => { + setStatus("生成中..."); + try { + await createCode({ + role: newRole, + maxUses: newMaxUses, + ttlHours: newTtlHours, + }); + setStatus("已生成邀请码"); + await refetch(); + } catch { + setStatus("生成失败"); + } + }; + + const handleRevoke = async (id: string): Promise => { + setBusyId(id); + try { + await revokeCode(id); + await refetch(); + } finally { + setBusyId(null); + } + }; + + const handleCopy = (code: string): void => { + if (typeof navigator === "undefined" || !navigator.clipboard) return; + void navigator.clipboard.writeText(code); + setStatus(`已复制:${code}`); + }; + + if (loading && !data) { + return ; + } + + const codes = data ?? []; + + return ( +
+

邀请码

+ {status.length > 0 ? ( +

{status}

+ ) : null} + + {/* 生成新邀请码 */} +
+

生成新邀请码

+
+
+ + +
+
+ + setNewMaxUses(Number(e.target.value))} + className={`${inputCls} w-24`} + /> +
+
+ + setNewTtlHours(Number(e.target.value))} + className={`${inputCls} w-28`} + /> +
+ +
+
+ + {/* 筛选 */} +
+ + +
+ + {/* 邀请码列表 */} + {codes.length === 0 ? ( +

暂无邀请码

+ ) : ( +
+ + + + + + + + + + + + + {codes.map((c) => { + const isBusy = busyId === c.id; + const canRevoke = c.status === "active"; + return ( + + + + + + + + + ); + })} + +
邀请码角色状态使用/上限过期时间操作
{c.code}{c.role}{c.status} + {c.usedCount} / {c.maxUses} + {c.expiresAt} +
+ + {canRevoke ? ( + + ) : null} +
+
+
+ )} +
+ ); +} diff --git a/apps/portal-shell/src/widgets/admin/invitation-codes/plugin.manifest.ts b/apps/portal-shell/src/widgets/admin/invitation-codes/plugin.manifest.ts new file mode 100644 index 0000000..fb6ecfd --- /dev/null +++ b/apps/portal-shell/src/widgets/admin/invitation-codes/plugin.manifest.ts @@ -0,0 +1,40 @@ +/** + * invitation-codes 插件清单(admin) + * + * 邀请码管理,插入 main slot。生成/查看/撤销邀请码。 + * 查询/变更经 apollo-router → iam 子图 invitationCodes。 + * + * 关联:portal-shell spec §3 admin 分类、§5.6 统一 Hook + */ +import type { PluginManifest } from "@/lib/types"; + +export const manifestMeta: Omit = { + pluginId: "invitation-codes", + version: "0.1.0", + requiredShellVersion: "^1.0.0", + metadata: { + displayName: "邀请码", + description: "生成并管理用户注册邀请码,支持角色/有效期/次数配置", + category: "admin", + requiredRoles: ["admin"], + defaultSlot: "main", + defaultSize: { colSpan: 2, rowSpan: 1 }, + defaultProps: { + defaultMaxUses: 1, + defaultTtlHours: 72, + }, + propsSchema: { + type: "object", + properties: { + defaultMaxUses: { + type: "number", + description: "默认最大使用次数(1=一次性,>1=批量)", + }, + defaultTtlHours: { + type: "number", + description: "默认有效期(小时)", + }, + }, + }, + }, +}; diff --git a/apps/portal-shell/src/widgets/admin/plugin-manager/index.tsx b/apps/portal-shell/src/widgets/admin/plugin-manager/index.tsx new file mode 100644 index 0000000..f2995f9 --- /dev/null +++ b/apps/portal-shell/src/widgets/admin/plugin-manager/index.tsx @@ -0,0 +1,451 @@ +"use client"; + +/** + * plugin-manager(admin / main) + * + * admin 配置面板。管理插件注册表、角色-插件映射、Layout 模板、用户布局覆盖。 + * 对应 portal-shell spec §6.3 配置面板。 + * + * 关联:portal-shell spec §6.3、§5.6 统一 Hook + */ +import { useEffect, useState } from "react"; +import { + useLayoutTemplates, + usePluginRegistry, + useResetUserLayoutOverride, + useRoleLayoutDefault, + useRolePluginMapping, + useUpdatePluginRegistry, + useUpdateRoleLayoutDefault, + useUpdateRolePluginMapping, +} from "@/lib/api/admin"; +import type { + RegistryItem, + RoleMapping, + RolePluginMappingInput, +} from "@/lib/api/admin"; +import { PluginSkeleton } from "@/shell/PluginLoader"; +import type { PluginProps } from "@/lib/types"; + +const ALL_ROLES = ["admin", "teacher", "student", "parent"] as const; +type TabKey = "registry" | "mapping" | "layout" | "user"; +const TAB_LABELS: Record = { + registry: "插件注册表", + mapping: "角色-插件映射", + layout: "Layout 模板", + user: "用户布局", +}; + +function RegistryTab(): React.ReactElement { + const { data, loading, refetch } = usePluginRegistry(); + + const { run: update } = useUpdatePluginRegistry(); + + const [editingId, setEditingId] = useState(null); + const [draftProps, setDraftProps] = useState(""); + const [draftError, setDraftError] = useState(""); + + const handleToggleActive = async (item: RegistryItem): Promise => { + await update(item.pluginId, { isActive: !item.isActive }); + await refetch(); + }; + + const handleStartEdit = (item: RegistryItem): void => { + setEditingId(item.pluginId); + setDraftProps(JSON.stringify(item.defaultProps, null, 2)); + setDraftError(""); + }; + + const handleSaveProps = async (pluginId: string): Promise => { + let parsed: Record; + try { + parsed = JSON.parse(draftProps) as Record; + } catch { + setDraftError("无效 JSON 格式,请检查输入"); + return; + } + setDraftError(""); + await update(pluginId, { defaultProps: parsed }); + setEditingId(null); + await refetch(); + }; + + if (loading && !data) { + return ; + } + + const items = data ?? []; + + return ( +
+ {items.length === 0 ? ( +

暂无已注册插件

+ ) : ( + items.map((item) => ( +
+
+
+

+ {item.displayName} + {item.isBuiltin ? ( + 内置 + ) : null} +

+

+ {item.pluginId} · v{item.version} · {item.category} ·{" "} + {item.defaultSlot} +

+

+ {item.description} +

+
+
+ + +
+
+ {editingId === item.pluginId ? ( +
+ +