问题审核整改方案.md

整改方案(可直接照着改代码)

依据:grok-问题审核意见.md(源码核验后的结论,不是主清单原文)。 原则:保守最小改动把逻辑走通;不重构状态机为双轨表、不改「两段式备课」设计;每个改动标明是否影响现有测试、是否必须 npm run build。 优先级:P0 必修(走不通 / 业务闸门洞)→ P1 建议修 → P2 可选。

同批约束(必读):P0-1(确认材料按钮)与 P0-4(上课标记)必须同一发布批次。 只做 P0-1 时,completed 仍在 TAUGHT_PLAN_STATUSES 里,教师一点「完成备课」工作台就会催「待反馈」。


总览

编号对应审核简述优先级迁移估时影响测试必须 build
P0-1A1, L2, L5材料区补「确认材料(完成备课)」+ 材料非空/G1P0否2.5h需补/改 p6 周边,现有 UI 无测是
P0-2A2, L1封 step=complete 并给 materials 补闸P0否1.5h无现成 complete 单测;建议加是(confirm 路由)
P0-3C, L3, L4, L6RANK 冻结改 maxStatus;PPT 确认位独立显示P0否2.5h改 test-p6 一条断言语义是
P0-4B, L8「已上课」与备课状态脱钩P0否(meta JSON)3.5h必改 test-s3a是
P1-DD, J.4生成上下文只注入前序课时反馈P1否2h可能改 test-s3a 上下文用例否(纯 lib,建议 build)
P1-GG复制路径统一丢掉 main_case_idP1否1h改/补 test-p6否
P1-J5J.5extract-drafts 列表隔离P1否1h若有 s2 测需对一下是
P1-J7J.7RSS fallback 默认关闭P1否0.5hdemo-s2b 显式 true,应仍过否
P1-HH画像写回乐观锁 + 文档口径P1否3htest-p8 写回用例是
P1-IIconfirmed_by 拆分P1否1.5h导出相关测若断言确认人是
P1-EE文档/schema 注释对齐并行状态机P1否1h无否
P2-FF删除「周进度反写」承诺(不实现)P2否0.5h无否
P2-J3J.3补丁 validUntil(若产品要过期)P2是3hpatches 测是
P2-J6J.6样例覆盖语义写进说明书P2否0.5h无否
P2-I-regI废弃未接的 regenerate 路由说明P2否0.5h无否
P2-H-uiH第一课明示「画像待问卷」P2否0.5h无是
P2-KK, J.8母版套用等明确不承诺P2否0无否

P0 合计约 10 小时;P0+P1 约 20 小时;含 P2(不含 validUntil)约 22 小时;含 validUntil 约 25 小时。


P0-1 材料区补「确认材料(完成备课)」按钮

改动思路

1) 后端: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" 禁用,避免点了没动。

2) 前端:确认函数 + 按钮

文件: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)。

完成后:

是否需要数据库迁移

否。

验证方式

  1. npm run build(必做,改了 client 组件 + API)。
  2. 手动:
    • 教案确认 → 生成材料草稿 → 材料页出现「确认材料(完成备课)」且可点。
    • 未生成材料时按钮禁用;直接调 API 无 materials 返回 400 need_materials。
    • 点确认后 status=completed,工作台完成率 +1,样例「发布」按钮可用,顶部五步卡出现。
    • 回归:教案确认、PPT 确认、导出 docx/pptx 路径不变。
  3. 无专门前端单测。可选:在 scripts/test-p6.ts 或新脚本对 nextStatus("materials_draft","confirm_materials")==="completed"(该断言 state 里已隐含,现有 p6 未测 confirm_materials,建议补一行)。

影响现有测试

预估工作量

2.5 小时。


P0-2 封掉 step=complete 并收口闸门

改动思路(最小、可照抄)

产品选择(本方案拍板):不保留第二条「一键完成」语义。step=complete 返回 400,引导使用 step=materials。nextStatus 的 "complete" 仍留着以免内部误用直达,但加上与 confirm_materials 相同的 rank 门槛作为兜底。

1) state.ts L78–79 改为

case "complete":
  if (statusRank(cur) < RANK.materials_draft) return cur;
  return "completed";

这样即使有人绕过路由直接调 nextStatus(x, "complete"),也不能从 not_started 跳完成。

2) 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"。

是否需要数据库迁移

否。

验证方式

  1. npm run build。
  2. 手动/curl(登录 cookie 下):
    • PUT /api/lesson-plans/:id/confirm body {"step":"complete"} → 400 complete_removed。
    • not_started 的教案即使将来误调 nextStatus(...,"complete") 也保持原状(单测)。
    • step=materials 在无材料 / 无 objectives 时 400。
  3. 建议在 scripts/test-p6.ts 的状态机段追加:
    check("complete 不能从 not_started 跳完成", nextStatus("not_started", "complete") === "not_started");
    check("materials_draft + complete → completed", nextStatus("materials_draft", "complete") === "completed");
  4. npm run p6:test、npm run p10:test。

影响现有测试

现有测试 没有 step: "complete" 请求,不应红。新增断言是加法。

预估工作量

1.5 小时。


P0-3 状态机:去掉 PPT 冻结特判,并行用 maxStatus 不回退

改动思路

不拆双状态字段、不加第八态。 七态 RANK 保留(兼容所有 statusRank 调用)。并行的「已确认 PPT」用 generation_meta.ppt_confirmed 记住,类似已有的 chaoxing_five_steps。

1) nextStatus:冻结改为 maxStatus

state.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_confirmedgenerate_pptppt_draftppt_draft
ppt_draftconfirm_pptppt_confirmedppt_confirmed
materials_draftgenerate_pptmaterials_draft(冻)materials_draft(maxStatus)
materials_draftconfirm_pptmaterials_draft(冻)materials_draft(maxStatus)
completed*completedcompleted

status 结果与旧 freeze 相同,但语义变成「不回退」而不是「拒绝 PPT」。内容路径本来就能写 PPT,这一步主要是去掉误导分支,让 canGeneratePpt 与 nextStatus 一致。

test-p6.ts L208–211「并行 PPT 不回退材料态」仍然成立,不要删,可改名为同一句。

2) GenMeta 增加 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 位,避免一次改太多导出语义。)

3) UI 文案

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 按钮保持可点(已是)。不要加「须先删材料」。

4) 不要做的

是否需要数据库迁移

否。 只写 generation_meta JSON。

验证方式

  1. npm run p6:test(必做,状态机断言)。
  2. npm run build。
  3. 手动两条路径:
    • 先 PPT 后材料:确认教案 → 生成/确认 PPT(status=ppt_confirmed)→ 生成材料(status=materials_draft)→ 确认材料(completed)。导出 PPT 仍成功。
    • 先材料后 PPT:确认教案 → 生成材料(materials_draft)→ 生成 PPT(status 仍 materials_draft,但有 ppt)→ 确认 PPT(status 仍 materials_draft,导出 PPT 成功)→ 确认材料 → completed。
  4. 回归:未确认教案不能生成 PPT/材料。

影响现有测试

预估工作量

2.5 小时。


P0-4 「完成备课」≠「已上课」:显式上课标记

改动思路(无迁移)

与五步勾选同一套路:上课时间进 generation_meta.taught_at,不加表字段(避免 migrate)。备课七态含义保持「备课进度」。

1) 类型

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。

2) pending / board / term-report

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,顺手)。

3) API 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。

4) UI

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 足够。

验证方式

  1. 改 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。

  2. npm run s3a:test
  3. npm run build
  4. 手动:
    • 只生成材料 → 工作台待反馈 不增加。
    • 确认材料 completed → 待反馈仍不增加。
    • 点「标记本课已上完」→ 待反馈 +1 → 写反馈后归零。
    • 未确认教案不能标记。

影响现有测试

会红,必须改:

quality-view 若有测试按旧 pending 数,需同步。grep isTaughtPlanStatus 全仓改完即可。

预估工作量

3.5 小时。


P0 完成后的强制验证清单

每次改完 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。


P1(建议修,不挡主链路)

P1-D 生成上下文限定前序课时(D / J.4)

P1-G 复制 main_case_id 两条路径对齐(G)

P1-J5 extract-drafts GET 隔离(J.5)

P1-J7 RSS fallback 默认关(J.7)

P1-H 画像写回乐观锁 + 口径(H)

P1-I 确认人拆分(I)

P1-E 文档与 schema 注释对齐(E)


P2(可选)

P2-F 取消周进度反写承诺(F)

删 schema L577「课时完成后反写周进度」一句,说明书同步。不实现反写(规则未定义,半套更糟)。估时 0.5h。无迁移、无测试、无 build。

P2-J3 补丁 validUntil(J.3)

仅当产品确认要自动过期再做:schema 加 validUntil DateTime?,active.ts where 加 OR: [{ validUntil: null }, { validUntil: { gt: now } }]。需要 npx prisma migrate dev。估时 3h。npm run build。

P2-J6 / P2-I-reg / P2-H-ui / P2-K

说明书写清:样例重发覆盖、regenerate 路由前端未接(以 generate+failed_items 为准)、第一课画像待问卷、母版套用不承诺。均不改行为。合计 ~1.5h。


明确不改(避免「整改」变成重构)

  1. 不把七态拆成 pptStatus + materialsStatus 两列。
  2. 不把 completed 改成「已上课」。
  3. 不自动用反馈重写已确认教案。
  4. 不强制 completed 必须有 PPT(与并行设计一致;导出自己挡)。
  5. 不实现周进度反写算法。
  6. 不改 G2「只在生成拦、确认不重查」。
  7. 不把学时超载改成硬 400。

推荐实施顺序

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。

下载此文件