Files
writer-work-flow/apps/web/lib/foreshadow/board.ts
Yaojia Wang 5fb7bfb1de feat: M3 — 伏笔账本 + 节奏引擎 + 大纲(含并发记账 bugfix)
- 伏笔账本:纯函数状态机(OPEN/PARTIAL/CLOSED/OVERDUE) + ForeshadowLedger repo;验收后到期扫描(BackgroundTask 自建 session 置 OVERDUE);登记/状态变更端点
- 节奏 + 三审齐:foreshadow-analyst + pace-checker 并入 LangGraph 并行审(REVIEW_SPECS),collect 分列落 chapter_reviews(conflicts/foreshadow_sug/pace),review SSE 加 foreshadow/pace 事件
- 大纲:outliner Agent 产 OutlineResult(含 foreshadow_windows),POST /outline 逐章 upsert outline 表;GET /foreshadow?status= 看板
- 前端:伏笔四泳道看板(OVERDUE 琥珀) + 大纲编辑器(窗口徽标) + 节奏节拍图(▁▃▅) + 审稿页消费 foreshadow/pace SSE
- bugfix(T3.8):并行三审共用请求 session 记账触发 'Session is already flushing' → foreshadow/pace 静默丢失;SqlAlchemyLedgerSink.record 改 add-only(靠端点/事务 commit),加并发回归测试
- M3 E2E:真实 DB + mock 网关零 token 走通 埋设→进展→验收后扫描 OVERDUE→看板 + 大纲含窗口 + 三审齐 SSE/留痕;E2E 暴露并钉住上述 bug
- 门禁绿:mypy 111 / pytest 228(0 xfailed) / alembic 无漂移;前端 gen:api/lint/tsc/vitest 69/build
2026-06-18 14:21:17 +02:00

142 lines
4.6 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.

// 伏笔看板纯逻辑:四泳道分组、登记/转移请求体组装、422 reason 映射。
// 对齐 C3 扩GET/POST/PATCH .../foreshadow。纯逻辑便于 node 环境单测。
import type {
ForeshadowRegisterRequest,
ForeshadowTransitionRequest,
ForeshadowView,
} from "@/lib/api/types";
// 四状态泳道UX §6.8OVERDUE 用琥珀强调(渲染层处理)。
export type ForeshadowStatus = "OPEN" | "PARTIAL" | "CLOSED" | "OVERDUE";
export const LANES: readonly ForeshadowStatus[] = [
"OPEN",
"PARTIAL",
"CLOSED",
"OVERDUE",
] as const;
export const LANE_LABELS: Record<ForeshadowStatus, string> = {
OPEN: "OPEN",
PARTIAL: "PARTIAL",
CLOSED: "CLOSED",
OVERDUE: "OVERDUE",
};
export type ForeshadowLanes = Record<ForeshadowStatus, ForeshadowView[]>;
function emptyLanes(): ForeshadowLanes {
return { OPEN: [], PARTIAL: [], CLOSED: [], OVERDUE: [] };
}
function isStatus(v: string): v is ForeshadowStatus {
return v === "OPEN" || v === "PARTIAL" || v === "CLOSED" || v === "OVERDUE";
}
// 按 status 分四泳道(未知 status 落 OPEN 兜底);保持 repo 已排序的 code 顺序。
export function groupByStatus(
items: readonly ForeshadowView[] | undefined,
): ForeshadowLanes {
const lanes = emptyLanes();
for (const item of items ?? []) {
const status = isStatus(item.status) ? item.status : "OPEN";
lanes[status] = [...lanes[status], item];
}
return lanes;
}
// 接近回收窗口判定current 在 [from, to] 内,或已超 from 但尚未 CLOSED提示安排回收
// 仅对 OPEN/PARTIAL 提示CLOSED 已回收、OVERDUE 已逾期另有强调。
export function isWindowApproaching(
item: ForeshadowView,
currentChapter: number,
): boolean {
if (item.status === "CLOSED" || item.status === "OVERDUE") return false;
const to = item.expected_close_to;
if (typeof to !== "number") return false;
const from = item.expected_close_from ?? to;
return currentChapter >= from && currentChapter <= to;
}
export interface RegisterInput {
projectId: string;
code: string;
title: string;
plantedAt?: number | null;
content?: string | null;
expectedCloseFrom?: number | null;
expectedCloseTo?: number | null;
importance?: string | null;
}
// 组装登记请求体(去空白;空可选字段省略)。
export function buildRegisterRequest(
input: RegisterInput,
): ForeshadowRegisterRequest {
const body: ForeshadowRegisterRequest = {
code: input.code.trim(),
title: input.title.trim(),
};
if (typeof input.plantedAt === "number") body.planted_at = input.plantedAt;
const content = input.content?.trim();
if (content) body.content = content;
if (typeof input.expectedCloseFrom === "number")
body.expected_close_from = input.expectedCloseFrom;
if (typeof input.expectedCloseTo === "number")
body.expected_close_to = input.expectedCloseTo;
const importance = input.importance?.trim();
if (importance) body.importance = importance;
return body;
}
export interface TransitionInput {
projectId: string;
toStatus?: ForeshadowStatus | null;
// 一条进展记录(自由 dict如 {chapter, note})。
progressEntry?: Record<string, unknown> | null;
}
// 组装转移/进展请求体(两字段皆可选;至少给一项由调用方校验)。
export function buildTransitionRequest(
input: TransitionInput,
): ForeshadowTransitionRequest {
const body: ForeshadowTransitionRequest = {};
if (input.toStatus) body.to_status = input.toStatus;
if (input.progressEntry && Object.keys(input.progressEntry).length > 0)
body.progress_entry = input.progressEntry;
return body;
}
// 422 VALIDATION 信封 details.reasonC3 扩)→ 用户文案。
export type ValidationReason =
| "duplicate"
| "invalid_transition"
| "empty_update"
| "invalid_status";
export function validationReasonMessage(reason: string | undefined): string {
switch (reason) {
case "duplicate":
return "该伏笔代号已存在,请换一个。";
case "invalid_transition":
return "非法状态转移CLOSED 为终态,不可再改)。";
case "empty_update":
return "请填写目标状态或一条进展。";
case "invalid_status":
return "无效的筛选状态。";
default:
return "操作未通过校验,请检查输入。";
}
}
// 从错误信封安全提取 details.reason。
export function extractReason(error: unknown): string | undefined {
if (typeof error !== "object" || error === null) return undefined;
const env = error as {
error?: { details?: { reason?: unknown } | null };
};
const reason = env.error?.details?.reason;
return typeof reason === "string" ? reason : undefined;
}