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,129 @@
"use client";
/**
* textbook-managerteacher / main
*
* 教材管理:按科目与年级筛选教材,点击教材查看章节列表。
* 通过 useWidgetQuery 查询 apollo-router → content 子图的 textbooks 数据。
*
* 关联portal-shell spec §5.6 统一 Hook
*/
import { useState } from "react";
import { useTextbooks } from "@/lib/api";
import { PluginSkeleton } from "@/shell/PluginLoader";
import type { PluginProps } from "@/lib/types";
export default function TextbookManager(
_props: PluginProps,
): React.ReactElement {
const [subjectInput, setSubjectInput] = useState("");
const [gradeInput, setGradeInput] = useState("");
const [appliedSubject, setAppliedSubject] = useState("");
const [appliedGrade, setAppliedGrade] = useState("");
const [selectedId, setSelectedId] = useState<string | null>(null);
const { data, loading } = useTextbooks({
subjectId: appliedSubject || undefined,
grade: appliedGrade || undefined,
});
if (loading && !data) {
return <PluginSkeleton variant="list" />;
}
const textbooks = data ?? [];
const selected = textbooks.find((t) => t.id === selectedId) ?? null;
const handleApply = (): void => {
setAppliedSubject(subjectInput.trim());
setAppliedGrade(gradeInput.trim());
setSelectedId(null);
};
return (
<section className="rounded-card border border-rule bg-surface p-md">
<h3 className="text-heading-3 text-ink"></h3>
<div className="mt-sm flex gap-md">
<label className="flex flex-col space-y-xs">
<span className="text-small text-ink-muted"> ID</span>
<input
type="text"
value={subjectInput}
onChange={(e) => setSubjectInput(e.target.value)}
className="rounded-button border border-rule bg-surface px-sm py-xs text-small text-ink"
placeholder="可选,输入科目 ID"
/>
</label>
<label className="flex flex-col space-y-xs">
<span className="text-small text-ink-muted"></span>
<input
type="text"
value={gradeInput}
onChange={(e) => setGradeInput(e.target.value)}
className="rounded-button border border-rule bg-surface px-sm py-xs text-small text-ink"
placeholder="可选,如:三年级"
/>
</label>
<button
type="button"
onClick={handleApply}
className="self-end rounded-button bg-accent px-md py-xs text-small text-ink-onAccent"
>
</button>
</div>
<div className="mt-md flex gap-md">
<ul className="flex-1 space-y-sm">
{textbooks.length === 0 ? (
<li className="text-small text-ink-muted"></li>
) : (
textbooks.map((t) => (
<li key={t.id}>
<button
type="button"
onClick={() => setSelectedId(t.id)}
className={
selectedId === t.id
? "w-full rounded-button border border-rule bg-subtle p-sm text-left"
: "w-full rounded-button border border-rule bg-surface p-sm text-left"
}
>
<p className="text-body text-ink">{t.title}</p>
<p className="text-small text-ink-muted">
{t.author} · {t.publisher}
</p>
<p className="text-tiny text-ink-muted">ISBN: {t.isbn}</p>
</button>
</li>
))
)}
</ul>
<div className="flex-1">
{selected ? (
<div className="rounded-card border border-rule bg-surface p-md">
<h4 className="text-body text-ink">{selected.title}</h4>
<p className="mt-xs text-small text-ink-muted">
{selected.author} · {selected.publisher}
</p>
<h5 className="mt-md text-small text-ink"></h5>
<ol className="mt-xs space-y-xs">
{selected.chapters.map((c, i) => (
<li key={c.id} className="text-small text-ink">
{i + 1}. {c.title}
</li>
))}
</ol>
</div>
) : (
<div className="rounded-card border border-rule bg-surface p-md text-small text-ink-muted">
</div>
)}
</div>
</div>
</section>
);
}