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