Files
Edu/apps/portal-shell/src/widgets/topbar/global-search/index.tsx
SpecialX 2910a90271 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
2026-07-17 13:07:24 +08:00

79 lines
2.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}