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:
78
apps/portal-shell/src/widgets/topbar/global-search/index.tsx
Normal file
78
apps/portal-shell/src/widgets/topbar/global-search/index.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* global-search(topbar / 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user