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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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