依据:
grok-问题审核意见.md(源码核验后的结论,不是主清单原文)。 原则:保守最小改动把逻辑走通;不重构状态机为双轨表、不改「两段式备课」设计;每个改动标明是否影响现有测试、是否必须npm run build。 优先级:P0 必修(走不通 / 业务闸门洞)→ P1 建议修 → P2 可选。
同批约束(必读):P0-1(确认材料按钮)与 P0-4(上课标记)必须同一发布批次。 只做 P0-1 时,completed 仍在 TAUGHT_PLAN_STATUSES 里,教师一点「完成备课」工作台就会催「待反馈」。
| 编号 | 对应审核 | 简述 | 优先级 | 迁移 | 估时 | 影响测试 | 必须 build |
|---|---|---|---|---|---|---|---|
| P0-1 | A1, L2, L5 | 材料区补「确认材料(完成备课)」+ 材料非空/G1 | P0 | 否 | 2.5h | 需补/改 p6 周边,现有 UI 无测 | 是 |
| P0-2 | A2, L1 | 封 step=complete 并给 materials 补闸 | P0 | 否 | 1.5h | 无现成 complete 单测;建议加 | 是(confirm 路由) |
| P0-3 | C, L3, L4, L6 | RANK 冻结改 maxStatus;PPT 确认位独立显示 | P0 | 否 | 2.5h | 改 test-p6 一条断言语义 | 是 |
| P0-4 | B, L8 | 「已上课」与备课状态脱钩 | P0 | 否(meta JSON) | 3.5h | 必改 test-s3a | 是 |
| P1-D | D, J.4 | 生成上下文只注入前序课时反馈 | P1 | 否 | 2h | 可能改 test-s3a 上下文用例 | 否(纯 lib,建议 build) |
| P1-G | G | 复制路径统一丢掉 main_case_id | P1 | 否 | 1h | 改/补 test-p6 | 否 |
| P1-J5 | J.5 | extract-drafts 列表隔离 | P1 | 否 | 1h | 若有 s2 测需对一下 | 是 |
| P1-J7 | J.7 | RSS fallback 默认关闭 | P1 | 否 | 0.5h | demo-s2b 显式 true,应仍过 | 否 |
| P1-H | H | 画像写回乐观锁 + 文档口径 | P1 | 否 | 3h | test-p8 写回用例 | 是 |
| P1-I | I | confirmed_by 拆分 | P1 | 否 | 1.5h | 导出相关测若断言确认人 | 是 |
| P1-E | E | 文档/schema 注释对齐并行状态机 | P1 | 否 | 1h | 无 | 否 |
| P2-F | F | 删除「周进度反写」承诺(不实现) | P2 | 否 | 0.5h | 无 | 否 |
| P2-J3 | J.3 | 补丁 validUntil(若产品要过期) | P2 | 是 | 3h | patches 测 | 是 |
| P2-J6 | J.6 | 样例覆盖语义写进说明书 | P2 | 否 | 0.5h | 无 | 否 |
| P2-I-reg | I | 废弃未接的 regenerate 路由说明 | P2 | 否 | 0.5h | 无 | 否 |
| P2-H-ui | H | 第一课明示「画像待问卷」 | P2 | 否 | 0.5h | 无 | 是 |
| P2-K | K, J.8 | 母版套用等明确不承诺 | P2 | 否 | 0 | 无 | 否 |
P0 合计约 10 小时;P0+P1 约 20 小时;含 P2(不含 validUntil)约 22 小时;含 validUntil 约 25 小时。
completed 的入口;confirm_materials 还不校验材料非空。src/app/prepare/[classId]/lesson-dev-panel.tsx(主改)src/app/api/lesson-plans/[id]/confirm/route.ts(材料非空 + G1)src/app/prepare/[classId]/sample-publish-card.tsx(完成后按钮自然亮起,可不改)step=materials 补闸(与 P0-2 共用)文件:src/app/api/lesson-plans/[id]/confirm/route.ts
在现有 if (step === "plan") { checkG1 ... } 之后,把 G1 扩大到 materials(complete 在 P0-2 处理):
if (step === "plan" || step === "materials") {
const g1 = checkG1(planPayload);
if (!g1.ok) {
return NextResponse.json(
{ error: G1_NO_OBJECTIVES_MESSAGE, code: G1_ERROR_CODE, g1 },
{ status: 400 }
);
}
}
step === "materials" 分支改为:
} else if (step === "materials") {
event = "confirm_materials";
if (!draft.materials) {
return NextResponse.json(
{ error: "须先生成材料草稿再确认", code: "need_materials" },
{ status: 400 }
);
}
confirmedPatch.materials = draft.materials;
}
学时保持警告、不硬阻断(与现设计一致)。不在这里强制 PPT(并行语义,见 E)。
nextStatus(..., "confirm_materials") 已要求 >= materials_draft(state.ts L75–77)。若当前是 ppt_confirmed 但还没生成材料,会停在原状态——前端按钮应用 status === "materials_draft" 禁用,避免点了没动。
文件:src/app/prepare/[classId]/lesson-dev-panel.tsx
仿 confirmPpt(L366–394)新增:
async function confirmMaterials() {
if (!planId || !bundle) return;
if (!bundle.g1.ok) {
setError({
code: "g1_no_objectives",
message: bundle.g1.message || "无课时目标不得完成备课",
});
return;
}
const materials = bundle.materials as { cases?: unknown[]; exercises?: unknown[] } | null;
const empty =
!materials ||
((Array.isArray(materials.exercises) ? materials.exercises.length : 0) === 0 &&
(Array.isArray(materials.cases) ? materials.cases.length : 0) === 0);
if (empty) {
setError({ code: "need_materials", message: "须先生成材料草稿再确认" });
return;
}
setError(null);
onPending("confirm-materials");
try {
const res = await api<{ plan: PlanBundle }>(`/api/lesson-plans/${planId}/confirm`, {
method: "PUT",
body: JSON.stringify({ step: "materials" }),
});
setBundle(res.plan);
setInfo("材料已确认,本课时备课完成(completed)。可导出并发布教研样例。");
onMeta({ status: res.plan.status });
} catch (e) {
const payload = e instanceof ApiError ? (e.payload as { error?: string; code?: string }) : null;
setError({
code: payload?.code,
message: payload?.error || (e instanceof Error ? e.message : "确认材料失败"),
});
} finally {
onPending("");
}
}
材料标签页按钮组(L844 的 <div className="flex gap-2">)在「生成材料草稿」旁增加:
<Button
type="button"
size="sm"
variant="secondary"
onClick={() => void confirmMaterials()}
disabled={
busy ||
!bundle?.materials ||
bundle.status !== "materials_draft" ||
Boolean(bundle.g1 && !bundle.g1.ok)
}
>
<Check className="h-3.5 w-3.5" />
确认材料(完成备课)
</Button>
在按钮下用现有 bundle.hours 做超载红字(与教案确认弹窗 L951–957 同样式即可,不必新弹窗;若要清单,复用材料列表已在 L861–882)。
完成后:
SamplePublishCard 已按 status === "completed" 解禁,无需改。ChaoxingFiveStepsCard(L558)自动出现。quality-view 完成率 / 待审会开始变动(L5 顺带缓解)。否。
npm run build(必做,改了 client 组件 + API)。need_materials。completed,工作台完成率 +1,样例「发布」按钮可用,顶部五步卡出现。scripts/test-p6.ts 或新脚本对 nextStatus("materials_draft","confirm_materials")==="completed"(该断言 state 里已隐含,现有 p6 未测 confirm_materials,建议补一行)。scripts/test-p6.ts 不覆盖 confirm 路由,现有断言应仍过。scripts/test-p10.ts 只打 step: "plan",不受影响。completed 的仍可用。2.5 小时。
step=complete 并收口闸门complete 无 rank、无 G1、无材料检查,任意状态可 completed。须登录且拥有教案,是业务闸门洞不是未鉴权。src/lib/prepare/state.ts(complete 加门槛)src/app/api/lesson-plans/[id]/confirm/route.ts产品选择(本方案拍板):不保留第二条「一键完成」语义。step=complete 返回 400,引导使用 step=materials。nextStatus 的 "complete" 仍留着以免内部误用直达,但加上与 confirm_materials 相同的 rank 门槛作为兜底。
state.ts L78–79 改为case "complete":
if (statusRank(cur) < RANK.materials_draft) return cur;
return "completed";
这样即使有人绕过路由直接调 nextStatus(x, "complete"),也不能从 not_started 跳完成。
confirm/route.ts 的 complete 分支改为拒绝替换 L100–104:
} else if (step === "complete") {
return NextResponse.json(
{
error: "请使用 step=materials 确认材料以完成备课",
code: "complete_removed",
},
{ status: 400 }
);
}
不要再走 event = "complete"。
若希望少打一次 400、把 complete 当别名(兼容万一的脚本),用下面这段 二选一,不要两套都留:
} else if (step === "complete") {
// 别名:等同 materials,走同一套校验(G1 已在上方覆盖 complete)
event = "confirm_materials";
if (!draft.materials) {
return NextResponse.json(
{ error: "须先生成材料草稿再确认", code: "need_materials" },
{ status: 400 }
);
}
confirmedPatch.materials = draft.materials;
}
并让 G1 判断包含 step === "complete"。推荐拒绝方案,全仓 grep 无前端/脚本发 step: "complete"。
否。
npm run build。PUT /api/lesson-plans/:id/confirm body {"step":"complete"} → 400 complete_removed。not_started 的教案即使将来误调 nextStatus(...,"complete") 也保持原状(单测)。step=materials 在无材料 / 无 objectives 时 400。scripts/test-p6.ts 的状态机段追加:
check("complete 不能从 not_started 跳完成", nextStatus("not_started", "complete") === "not_started");
check("materials_draft + complete → completed", nextStatus("materials_draft", "complete") === "completed");npm run p6:test、npm run p10:test。现有测试 没有 step: "complete" 请求,不应红。新增断言是加法。
1.5 小时。
canGeneratePpt 不一致;② 生成材料后 status 显示吞掉 ppt_confirmed;③ 成功文案撒谎。src/lib/prepare/state.tssrc/lib/prepare/persist.ts(bumpMeta 记 ppt 确认位)src/lib/prepare/draft.ts(GenMeta 类型)src/app/api/lesson-plans/[id]/confirm/route.ts(step=ppt 写 meta 位)src/app/prepare/[classId]/lesson-dev-panel.tsx(文案)src/app/prepare/[classId]/prepare-wizard.tsx(并行说明)scripts/test-p6.ts不拆双状态字段、不加第八态。 七态 RANK 保留(兼容所有 statusRank 调用)。并行的「已确认 PPT」用 generation_meta.ppt_confirmed 记住,类似已有的 chaoxing_five_steps。
nextStatus:冻结改为 maxStatusstate.ts L64–71 换成:
case "generate_ppt":
if (statusRank(cur) < RANK.plan_confirmed) return cur;
return maxStatus(cur, "ppt_draft");
case "confirm_ppt":
if (statusRank(cur) < RANK.ppt_draft) return cur;
return maxStatus(cur, "ppt_confirmed");
行为对照:
| 当前 | 事件 | 旧 | 新 |
|---|---|---|---|
| plan_confirmed | generate_ppt | ppt_draft | ppt_draft |
| ppt_draft | confirm_ppt | ppt_confirmed | ppt_confirmed |
| materials_draft | generate_ppt | materials_draft(冻) | materials_draft(maxStatus) |
| materials_draft | confirm_ppt | materials_draft(冻) | materials_draft(maxStatus) |
| completed | * | completed | completed |
status 结果与旧 freeze 相同,但语义变成「不回退」而不是「拒绝 PPT」。内容路径本来就能写 PPT,这一步主要是去掉误导分支,让 canGeneratePpt 与 nextStatus 一致。
test-p6.ts L208–211「并行 PPT 不回退材料态」仍然成立,不要删,可改名为同一句。
src/lib/prepare/draft.ts GenMeta:
ppt_confirmed?: boolean;
ppt_confirmed_by?: number | null;
ppt_confirmed_at?: string | null;
persist.ts 新增(或扩展 bump):
export function bumpMetaOnConfirm(
raw: string | null | undefined,
userId: number,
step?: string
): GenMeta {
const meta = parseGenMeta(raw);
meta.confirmed_by = userId;
meta.confirmed_at = new Date().toISOString();
if (step === "ppt") {
meta.ppt_confirmed = true;
meta.ppt_confirmed_by = userId;
meta.ppt_confirmed_at = meta.confirmed_at;
}
return meta;
}
confirm/route.ts L117 改为 bumpMetaOnConfirm(..., auth.user.id, step)。
(P1-I 再拆 plan/materials 确认人;本 P0 只加 ppt 位,避免一次改太多导出语义。)
confirmPpt 成功(L383):
const st = res.plan.status;
setInfo(
st === "ppt_confirmed"
? "PPT 已确认(ppt_confirmed)。导出将基于 confirmed_content。"
: "PPT 已写入确认稿。当前课时状态仍为「" + st + "」(材料/完成优先显示,PPT 确认不回退)。"
);
材料页 L859、向导 L778 改为明确句:
教案确认后可并行生成 PPT 与材料。生成材料后状态显示为材料草稿/已完成,仍可补做并确认 PPT;导出 PPT 看确认稿是否含 slides,不看状态名。
PPT 按钮保持可点(已是)。不要加「须先删材料」。
confirm_ppt 从 materials_draft 回写成 ppt_confirmed(会丢「材料已备」,破坏 A1/待审)。否。 只写 generation_meta JSON。
npm run p6:test(必做,状态机断言)。npm run build。ppt_confirmed)→ 生成材料(status=materials_draft)→ 确认材料(completed)。导出 PPT 仍成功。materials_draft)→ 生成 PPT(status 仍 materials_draft,但有 ppt)→ 确认 PPT(status 仍 materials_draft,导出 PPT 成功)→ 确认材料 → completed。test-p6.ts L209–211 继续通过(返回值不变)。test-p5.ts 只查 isLessonPlanStatus("ppt_confirmed"),无影响。2.5 小时。
TAUGHT_PLAN_STATUSES 把 completed/materials_draft 当已上课。A1 修好后会更糟(完成即催反馈)。src/lib/prepare/draft.ts(GenMeta.taught_at)src/lib/feedbacks/types.tssrc/lib/feedbacks/pending.tssrc/lib/feedbacks/board.tssrc/lib/feedbacks/term-report.ts(读 taught 的那一行)src/app/api/lesson-plans/[id]/taught/route.ts(仿 chaoxing-steps)src/app/prepare/[classId]/lesson-dev-panel.tsx 反馈区src/app/classes/[id]/feedback-panel.tsx 文案scripts/test-s3a.ts与五步勾选同一套路:上课时间进 generation_meta.taught_at,不加表字段(避免 migrate)。备课七态含义保持「备课进度」。
draft.ts GenMeta:
taught_at?: string | null;
taught_by?: number | null;
feedbacks/types.ts 替换 L27–32:
/** @deprecated 备课状态不再代表已上课。请用 isTaughtPlan。 */
export const TAUGHT_PLAN_STATUSES = ["completed", "materials_draft"] as const;
export function isTaughtPlanStatus(_status: string): boolean {
return false; // 切断旧口径;过渡期保留导出名以免漏改编译
}
export function isTaughtPlan(plan: {
status?: string;
generationMeta?: string | null;
taught_at?: string | null;
}): boolean {
if (plan.taught_at) return true;
const meta = plan.generationMeta
? (JSON.parse(plan.generationMeta) as { taught_at?: string | null })
: null;
// 实际应复用 parseGenMeta,避免裸 JSON.parse
return Boolean(meta?.taught_at);
}
不要在业务里继续用 isTaughtPlanStatus(status)。更干净的写法:把 isTaughtPlan 放到 feedbacks/types.ts,内部 import { parseGenMeta } from "@/lib/prepare/draft"(注意 draft 是否反向依赖 feedbacks——当前没有,可安全 import)。
推荐最终形态:
import { parseGenMeta } from "@/lib/prepare/draft";
export function isTaughtPlan(plan: {
generationMeta?: string | null;
}): boolean {
return Boolean(parseGenMeta(plan.generationMeta).taught_at);
}
删除(或停止调用)isTaughtPlanStatus。若担心编译面,保留函数但改为:
export function isTaughtPlanStatus(_status: string): boolean {
throw new Error("isTaughtPlanStatus 已废弃,改用 isTaughtPlan(generationMeta)");
}
单测会立刻红,正好逼改 test-s3a.ts。
pending.ts countPendingForPlans 签名改为接收 generationMeta:
export function countPendingForPlans(
plans: { classId: number; lessonId: number; status: string; generationMeta?: string | null }[],
feedbackLessonIds: Set<string>
): number {
let n = 0;
for (const p of plans) {
if (!isTaughtPlan(p)) continue;
if (!feedbackLessonIds.has(`${p.classId}:${p.lessonId}`)) n += 1;
}
return n;
}
loadPendingFeedbackCounts 的 lessonPlans select 加上 generationMeta: true。
board.ts:组装 item 时 needs_feedback: isTaughtPlan(plan) && !has。
term-report.ts L160 同样改。
quality-view.ts 里 countPendingForPlans 调用处把 generationMeta 带上(该文件已 parseGenMeta,顺手)。
PUT /api/lesson-plans/[id]/taught新建 src/app/api/lesson-plans/[id]/taught/route.ts,整体抄 chaoxing-steps/route.ts:
export async function PUT(request, { params }) {
const auth = await requireApiUser();
if (auth.error) return auth.error;
const loaded = await loadOwnedPlan(prisma, Number(params.id), auth.user);
if (loaded.error) return loaded.error;
const body = await readJsonBody<{ taught?: boolean; taught_at?: string }>(request);
const meta = parseGenMeta(loaded.row.generationMeta);
if (body?.taught === false) {
meta.taught_at = null;
meta.taught_by = null;
} else {
meta.taught_at = body?.taught_at || new Date().toISOString();
meta.taught_by = auth.user.id;
}
await prisma.lessonPlan.update({
where: { id: loaded.row.id },
data: { generationMeta: toJson(meta) },
});
return NextResponse.json({ taught_at: meta.taught_at });
}
门槛(最小):不要求 completed(教师可能先上课后补确认材料)。建议要求至少 statusRank >= plan_confirmed,避免空教案被标已上:
import { statusRank } from "@/lib/prepare/state";
if (statusRank(loaded.row.status) < 2 /* plan_confirmed */) {
return NextResponse.json({ error: "须至少确认教案后再标记已上课" }, { status: 400 });
}
不要在 confirm_materials 里自动写 taught_at。
lesson-dev-panel.tsx 反馈区(L893 起)顶部加:
<Button
type="button"
size="sm"
variant="outline"
disabled={busy || !planId || statusRank(bundle?.status ?? "") < 2}
onClick={() => void markTaught()}
>
{bundle?.generationMeta?.taught_at ? "已标记上课(再次点击可改时间)" : "标记本课已上完"}
</Button>
<p className="text-xs text-muted-foreground">
「完成备课」只表示材料已确认;待反馈统计只计算已标记上课且尚未写反馈的课时。
</p>
markTaught 调上述 API 后刷新 bundle(GET plan 或本地 patch generationMeta.taught_at)。
feedback-panel.tsx L91 文案改为:「待反馈课时 {pending}(已标记上课且尚未反馈)」。
反馈表单本身保持可填(补记历史课),不清空。
否。 若后续要按日期 SQL 过滤再加 LessonPlan.taughtAt DateTime?(那时才 migrate)。当前班级规模用 JSON 足够。
scripts/test-s3a.ts L137–149:
check("无 taught_at 不算已上课", !isTaughtPlan({ generationMeta: null }));
check("有 taught_at 算已上课", isTaughtPlan({ generationMeta: JSON.stringify({ taught_at: "2026-08-01" }) }));
const pending = countPendingForPlans(
[
{ classId: 1, lessonId: 1, status: "completed", generationMeta: JSON.stringify({ taught_at: "x" }) },
{ classId: 1, lessonId: 2, status: "completed", generationMeta: null },
{ classId: 1, lessonId: 3, status: "plan_draft", generationMeta: null },
{ classId: 1, lessonId: 4, status: "materials_draft", generationMeta: null },
],
new Set(["1:1"])
);
check("待反馈=已上课且无记录", pending === 0); // 1:1 已有反馈;其余无 taught_at
再补一条 lessonId:5 materials_draft + taught_at、无反馈 → pending=1。
npm run s3a:testnpm run build会红,必须改:
scripts/test-s3a.ts L138–149(把备课状态当已上课)。scripts/demo-s3a.ts 注释/准备数据若依赖「暂置 completed 以便待反馈计数」(约 L98–131、L285),改为写 generationMeta.taught_at。quality-view 若有测试按旧 pending 数,需同步。grep isTaughtPlanStatus 全仓改完即可。
3.5 小时。
每次改完 P0 任一项涉及 src/app/** 或 src/lib/prepare/state.ts 后:
npm run p6:test
npm run p10:test
npm run s3a:test
npm run build
四项全做完再跑一遍(build 一次即可)。P0-3 主要碰 p6;P0-4 主要碰 s3a;P0-1/P0-2 主要靠手动 + build。
src/lib/feedbacks/summarize.ts、src/lib/ai/generate.ts L303、src/lib/ai/class-gen.ts L71loadRecentClassFeedbacks(db, classId, n, { beforeLessonId }) 增加可选过滤:复用 findPreviousLessonFeedback 的章/课 sort 比较,丢掉 chapter.sortOrder/lesson.sortOrder 不早于当前课的反馈。大纲生成(无 lessonId)仍用全局最近 N 条。s3a:test 若用「后课反馈出现在前课上下文」会需要新断言;现有「按课时排序」断言(test-s3a L132)仍可过。main_case_id 两条路径对齐(G)src/lib/prepare/copy.tsskeletonDraft 返回里显式 next.main_case_id = null(或 delete)。复用分支 merge 之后再:
const merged = mergeDraftContent(existing.draftContent, skeleton);
const obj = parseDraftObject(merged);
delete (obj as { main_case_id?: unknown }).main_case_id;
对 toJson 写回。画像仍原样复制,只在复制成功 toast 写「请按 B 班修订画像后再生成」。
test-p6.ts 已有「复制不带主案例」;补一条 merge 路径(existing 含 main_case_id=9,merge 后为 null)。src/app/api/extract-drafts/route.tsif (auth.user.role !== "admin") where.createdBy = auth.user.id。src/lib/rss/fetch-feed.ts L52、src/lib/rss/ingest.ts L42、scripts/fetch-rss.ts L56、src/app/api/rss-sources/[id]/fetch/route.tsallowFallback = opts?.allowFallback === true(默认 false)。demo-s2b.ts 已显式 allowFallback: true,保持。API 文档:生产不要传 true。s2b:test / demo-s2b 应仍过writeback.ts、classes/[id]/route.ts、confirm-profile/route.ts、revise/route.tsexpected_version;current.version !== expected → 409。metrics 策略保持「问卷覆盖分布、保留 teacher_notes」,在写回 API 响应里加 teacher_notes_kept: true, metrics_replaced: true。UI 确认写回前一句「将用问卷分布替换当前 metrics」。p8:test 写回用例persist.ts bumpMeta、exporters/run.ts L173–180、plan-docx.ts / ppt.ts / materials-docx.tsplan_confirmed_by;step=plan 时写它。导出走 plan_confirmed_by ?? confirmed_by。不改旧数据。prisma/schema.prisma L504、L577(F 可一并)、设计说明书 §5.1(在 dl-hub,不在本仓则只改 schema 注释)state.ts 头注释一致的并行描述;注明 completed 不强制 PPT。删 schema L577「课时完成后反写周进度」一句,说明书同步。不实现反写(规则未定义,半套更糟)。估时 0.5h。无迁移、无测试、无 build。
validUntil(J.3)仅当产品确认要自动过期再做:schema 加 validUntil DateTime?,active.ts where 加 OR: [{ validUntil: null }, { validUntil: { gt: now } }]。需要 npx prisma migrate dev。估时 3h。npm run build。
说明书写清:样例重发覆盖、regenerate 路由前端未接(以 generate+failed_items 为准)、第一课画像待问卷、母版套用不承诺。均不改行为。合计 ~1.5h。
pptStatus + materialsStatus 两列。P0-2(封 complete,1.5h)
→ P0-3(maxStatus + 文案,2.5h,p6:test)
→ P0-1 + P0-4 同一提交(按钮 + 上课标记,6h,s3a:test + 手动)
→ npm run build
→ P1-G / P1-J7 / P1-D / P1-J5(半到一天)
→ 其余 P1/P2 按需
P0-2 先做是因为不依赖 UI,且先堵 API 洞再给教师按钮,避免有人在联调窗口打 complete。