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