P1: 31 widget 旧纸感令牌批量迁移到 shadcn 标准(1104 次替换) - bg-paper→bg-background / bg-surface→bg-card / text-ink→text-foreground - 保留 button.tsx 中 bg-accent(shadcn 标准 hover 语义令牌) P2: v2.0 新增组件单元测试补齐(5 文件 81 用例) - permission-bitmap: 24 用例(含 GRADE_READ 重复去重) - route-permissions: 26 用例(4 张表优先级 + AND/OR 语义) - notify: 12 用例(sonner toast 双重性质 vi.hoisted mock) - use-error-report: 9 用例(jsdom Blob vi.stubGlobal mock) - plugin-boundary: 10 用例(错误边界 + 骨架变体) P3: 错误上报端点生产替换(后端 /api/v1/log) - api-gateway: internal/log/handler.go(slog 结构化日志,64KB 限制,204 返回) - main.go: 注册 POST /api/v1/log 路由 - useErrorReport: 环境感知端点(prod→/api/v1/log,dev→/api/log) P4: E2E 测试(3 文件 30 用例) - streaming: 4 用例(React 19 use() + Suspense,act 包裹 render) - error-boundaries: 6 用例(三级错误边界层级 L1/L2/L3) - security-boundaries: 20 用例(L1 角色门禁 + L2 权限点 + L3 数据范围) - vitest setup: IS_REACT_ACT_ENVIRONMENT + jest-dom matchers 验证:typecheck 0 错误 / lint 0 错误 / build 6 路由 / 206 测试全部通过
217 lines
7.1 KiB
TypeScript
217 lines
7.1 KiB
TypeScript
"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-xl border border bg-card p-4">
|
||
<h3 className="text-heading-3 text-foreground">备课画布</h3>
|
||
<p className="text-sm text-muted-foreground">请先选择班级</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-xl border border bg-card p-4">
|
||
<div className="flex items-center justify-between">
|
||
<h3 className="text-heading-3 text-foreground">备课画布</h3>
|
||
<button
|
||
type="button"
|
||
onClick={handleNew}
|
||
className="rounded-md bg-primary px-sm py-xs text-sm text-primary-foreground"
|
||
>
|
||
新建备课
|
||
</button>
|
||
</div>
|
||
<div className="mt-sm flex gap-4">
|
||
<ul className="w-64 shrink-0 space-y-sm">
|
||
{plans.length === 0 ? (
|
||
<li className="text-sm text-muted-foreground">暂无备课记录</li>
|
||
) : (
|
||
plans.map((p) => (
|
||
<li key={p.id}>
|
||
<button
|
||
type="button"
|
||
onClick={() => handleSelect(p)}
|
||
className={`w-full rounded-md border border px-sm py-xs text-left text-sm ${
|
||
draft.id === p.id
|
||
? "bg-muted text-foreground"
|
||
: "bg-card text-foreground"
|
||
}`}
|
||
>
|
||
{p.title || "未命名备课"}
|
||
</button>
|
||
</li>
|
||
))
|
||
)}
|
||
</ul>
|
||
<div className="flex-1 space-y-4">
|
||
<label className="flex flex-col space-y-xs">
|
||
<span className="text-sm text-muted-foreground">标题</span>
|
||
<input
|
||
type="text"
|
||
value={draft.title}
|
||
onChange={(e) =>
|
||
setDraft((d) => ({ ...d, title: e.target.value }))
|
||
}
|
||
className="rounded-md border border bg-card px-sm py-xs text-sm text-foreground"
|
||
placeholder="请输入备课标题"
|
||
/>
|
||
</label>
|
||
<label className="flex flex-col space-y-xs">
|
||
<span className="text-sm text-muted-foreground">教学目标</span>
|
||
<textarea
|
||
value={draft.objectives}
|
||
onChange={(e) =>
|
||
setDraft((d) => ({ ...d, objectives: e.target.value }))
|
||
}
|
||
className="rounded-md border border bg-card px-sm py-xs text-sm text-foreground"
|
||
rows={3}
|
||
placeholder="请输入教学目标"
|
||
/>
|
||
</label>
|
||
<label className="flex flex-col space-y-xs">
|
||
<span className="text-sm text-muted-foreground">教学内容</span>
|
||
<textarea
|
||
value={draft.content}
|
||
onChange={(e) =>
|
||
setDraft((d) => ({ ...d, content: e.target.value }))
|
||
}
|
||
className="rounded-md border border bg-card px-sm py-xs text-sm text-foreground"
|
||
rows={4}
|
||
placeholder="请输入教学内容"
|
||
/>
|
||
</label>
|
||
<div className="flex flex-col space-y-xs">
|
||
<span className="text-sm text-muted-foreground">教学资源</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-md border border bg-card px-sm py-xs text-sm text-foreground"
|
||
placeholder="资源名称或链接"
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={() => handleResourceRemove(i)}
|
||
className="rounded-md bg-muted px-sm py-xs text-sm text-foreground"
|
||
>
|
||
移除
|
||
</button>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
<button
|
||
type="button"
|
||
onClick={handleResourceAdd}
|
||
className="self-start rounded-md bg-muted px-sm py-xs text-sm text-foreground"
|
||
>
|
||
添加资源
|
||
</button>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={handleSave}
|
||
disabled={saving || !draft.title.trim()}
|
||
className="rounded-md bg-primary px-md py-xs text-sm text-primary-foreground disabled:opacity-50"
|
||
>
|
||
{saving ? "保存中" : "保存"}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|