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:
@@ -0,0 +1,216 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* lesson-plan-editor(teacher / main)
|
||||
*
|
||||
* 备课画布:左侧备课列表 + 右侧编辑区。
|
||||
* 通过 useWidgetQuery 查询 apollo-router → core-edu 子图的 lessonPlans 数据,
|
||||
* 通过 useWidgetMutation 调用 saveLessonPlan 保存。
|
||||
* classId 从 URL Search Params 读取(class-selector 切换时自动响应)。
|
||||
*
|
||||
* 关联:portal-shell spec §5.2.1 URL 驱动、§5.6 统一 Hook
|
||||
*/
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
useLessonPlans,
|
||||
useSaveLessonPlan,
|
||||
type LessonPlan,
|
||||
type SaveLessonPlanInput,
|
||||
} from "@/lib/api";
|
||||
import { PluginSkeleton } from "@/shell/PluginLoader";
|
||||
import type { PluginProps } from "@/lib/types";
|
||||
|
||||
function createEmptyDraft(): LessonPlan {
|
||||
return { id: "", title: "", objectives: "", content: "", resources: [] };
|
||||
}
|
||||
|
||||
export default function LessonPlanEditor(
|
||||
_props: PluginProps,
|
||||
): React.ReactElement {
|
||||
const searchParams = useSearchParams();
|
||||
const classId = searchParams.get("classId") ?? "";
|
||||
|
||||
const { data, loading, refetch } = useLessonPlans(classId);
|
||||
|
||||
const { run: saveLessonPlan, loading: saving } = useSaveLessonPlan();
|
||||
|
||||
const [draft, setDraft] = useState<LessonPlan>(createEmptyDraft());
|
||||
|
||||
if (loading && !data) {
|
||||
return <PluginSkeleton variant="table" />;
|
||||
}
|
||||
|
||||
if (!classId) {
|
||||
return (
|
||||
<section className="rounded-card border border-rule bg-surface p-md">
|
||||
<h3 className="text-heading-3 text-ink">备课画布</h3>
|
||||
<p className="text-small text-ink-muted">请先选择班级</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const plans = data ?? [];
|
||||
|
||||
const handleSelect = (plan: LessonPlan): void => {
|
||||
setDraft({ ...plan, resources: [...plan.resources] });
|
||||
};
|
||||
|
||||
const handleNew = (): void => {
|
||||
setDraft(createEmptyDraft());
|
||||
};
|
||||
|
||||
const handleResourceAdd = (): void => {
|
||||
setDraft((d) => ({ ...d, resources: [...d.resources, ""] }));
|
||||
};
|
||||
|
||||
const handleResourceChange = (index: number, value: string): void => {
|
||||
setDraft((d) => {
|
||||
const next = [...d.resources];
|
||||
next[index] = value;
|
||||
return { ...d, resources: next };
|
||||
});
|
||||
};
|
||||
|
||||
const handleResourceRemove = (index: number): void => {
|
||||
setDraft((d) => ({
|
||||
...d,
|
||||
resources: d.resources.filter((_, i) => i !== index),
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSave = async (): Promise<void> => {
|
||||
if (!draft.title.trim()) {
|
||||
return;
|
||||
}
|
||||
const input: SaveLessonPlanInput = {
|
||||
classId,
|
||||
id: draft.id || undefined,
|
||||
title: draft.title,
|
||||
objectives: draft.objectives,
|
||||
content: draft.content,
|
||||
resources: draft.resources.filter((r) => r.trim().length > 0),
|
||||
};
|
||||
try {
|
||||
const saved = await saveLessonPlan(input);
|
||||
setDraft((d) => ({ ...d, id: saved.id }));
|
||||
await refetch();
|
||||
} catch {
|
||||
/* toast: 保存失败 */
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="rounded-card border border-rule bg-surface p-md">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-heading-3 text-ink">备课画布</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleNew}
|
||||
className="rounded-button bg-accent px-sm py-xs text-small text-ink-onAccent"
|
||||
>
|
||||
新建备课
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-sm flex gap-md">
|
||||
<ul className="w-64 shrink-0 space-y-sm">
|
||||
{plans.length === 0 ? (
|
||||
<li className="text-small text-ink-muted">暂无备课记录</li>
|
||||
) : (
|
||||
plans.map((p) => (
|
||||
<li key={p.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSelect(p)}
|
||||
className={`w-full rounded-button border border-rule px-sm py-xs text-left text-small ${
|
||||
draft.id === p.id
|
||||
? "bg-subtle text-ink"
|
||||
: "bg-surface text-ink"
|
||||
}`}
|
||||
>
|
||||
{p.title || "未命名备课"}
|
||||
</button>
|
||||
</li>
|
||||
))
|
||||
)}
|
||||
</ul>
|
||||
<div className="flex-1 space-y-md">
|
||||
<label className="flex flex-col space-y-xs">
|
||||
<span className="text-small text-ink-muted">标题</span>
|
||||
<input
|
||||
type="text"
|
||||
value={draft.title}
|
||||
onChange={(e) =>
|
||||
setDraft((d) => ({ ...d, title: e.target.value }))
|
||||
}
|
||||
className="rounded-button border border-rule bg-surface px-sm py-xs text-small text-ink"
|
||||
placeholder="请输入备课标题"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col space-y-xs">
|
||||
<span className="text-small text-ink-muted">教学目标</span>
|
||||
<textarea
|
||||
value={draft.objectives}
|
||||
onChange={(e) =>
|
||||
setDraft((d) => ({ ...d, objectives: e.target.value }))
|
||||
}
|
||||
className="rounded-button border border-rule bg-surface px-sm py-xs text-small text-ink"
|
||||
rows={3}
|
||||
placeholder="请输入教学目标"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col space-y-xs">
|
||||
<span className="text-small text-ink-muted">教学内容</span>
|
||||
<textarea
|
||||
value={draft.content}
|
||||
onChange={(e) =>
|
||||
setDraft((d) => ({ ...d, content: e.target.value }))
|
||||
}
|
||||
className="rounded-button border border-rule bg-surface px-sm py-xs text-small text-ink"
|
||||
rows={4}
|
||||
placeholder="请输入教学内容"
|
||||
/>
|
||||
</label>
|
||||
<div className="flex flex-col space-y-xs">
|
||||
<span className="text-small text-ink-muted">教学资源</span>
|
||||
<ul className="space-y-xs">
|
||||
{draft.resources.map((r, i) => (
|
||||
<li key={i} className="flex gap-xs">
|
||||
<input
|
||||
type="text"
|
||||
value={r}
|
||||
onChange={(e) => handleResourceChange(i, e.target.value)}
|
||||
className="w-full rounded-button border border-rule bg-surface px-sm py-xs text-small text-ink"
|
||||
placeholder="资源名称或链接"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleResourceRemove(i)}
|
||||
className="rounded-button bg-subtle px-sm py-xs text-small text-ink"
|
||||
>
|
||||
移除
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleResourceAdd}
|
||||
className="self-start rounded-button bg-subtle px-sm py-xs text-small text-ink"
|
||||
>
|
||||
添加资源
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={saving || !draft.title.trim()}
|
||||
className="rounded-button bg-accent px-md py-xs text-small text-ink-onAccent disabled:opacity-50"
|
||||
>
|
||||
{saving ? "保存中" : "保存"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user