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

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

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

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

View File

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