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
79 lines
2.4 KiB
TypeScript
79 lines
2.4 KiB
TypeScript
"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>
|
||
);
|
||
}
|