Files
Edu/apps/portal-shell/src/widgets/teacher/lesson-plan-editor/index.tsx
SpecialX f586a0b19e feat(portal-shell): v2.0 P1-P4 token migration + unit tests + prod endpoint + e2e
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 测试全部通过
2026-07-17 16:49:00 +08:00

217 lines
7.1 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";
/**
* lesson-plan-editorteacher / 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>
);
}