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,78 @@
"use client";
/**
* global-searchtopbar / top
*
* 通过 useGlobalSearch 查询 apollo-router 的 search 数据。
* 输入关键词后显示搜索建议下拉,点击结果导航到对应页面。
*
* 关联portal-shell spec §5.6 统一 Hook、M8 验收
*/
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useGlobalSearch, type SearchResult } from "@/lib/api/topbar";
import type { PluginProps } from "@/lib/types";
const TYPE_PATH_MAP: Record<string, string> = {
student: "/students",
class: "/classes",
exam: "/exams",
homework: "/homework",
announcement: "/announcements",
};
export default function GlobalSearch(_props: PluginProps): React.ReactElement {
const router = useRouter();
const [keyword, setKeyword] = useState("");
const [open, setOpen] = useState(false);
const { data } = useGlobalSearch(keyword, 8);
const handleSelect = (item: SearchResult): void => {
const base = TYPE_PATH_MAP[item.type] ?? "/search";
router.push(`${base}/${item.id}`);
setOpen(false);
setKeyword("");
};
const results = data ?? [];
return (
<div className="relative">
<input
type="search"
value={keyword}
onChange={(e) => {
setKeyword(e.target.value);
setOpen(true);
}}
onFocus={() => setOpen(true)}
placeholder="搜索学生、班级、考试…"
aria-label="全局搜索"
className="w-72 rounded-button border border-rule bg-surface px-sm py-xs text-small text-ink"
/>
{open && keyword.length > 0 ? (
<ul className="absolute right-0 z-50 mt-sm w-72 rounded-card border border-rule bg-surface p-sm shadow-md">
{results.length === 0 ? (
<li className="text-small text-ink-muted"></li>
) : (
results.map((item) => (
<li key={`${item.type}-${item.id}`}>
<button
type="button"
onClick={() => handleSelect(item)}
className="flex w-full flex-col border-b border-rule py-xs text-left"
>
<span className="text-small text-ink">{item.title}</span>
<span className="text-tiny text-ink-muted">
{item.subtitle}
</span>
</button>
</li>
))
)}
</ul>
) : null}
</div>
);
}