feat(foreshadow): 写作台本章伏笔主动提醒(前端)

- 新增 chapterForeshadow 纯分类:按当前章分 可回收/待回收/已逾期(启用闲置的 isWindowApproaching)
- useChapterForeshadow hook(复用 GET /foreshadow,切章仅重分类不重拉)
- 右栏「本章参考」+ ContextDrawer 伏笔页新增「本章伏笔」实时清单,
  分色展示 + 跳看板链接 + 空态;标注『按已验收进度』
This commit is contained in:
Yaojia Wang
2026-07-12 17:53:00 +02:00
parent 9aa0cddeaf
commit b810a3fa3c
8 changed files with 507 additions and 4 deletions

View File

@@ -0,0 +1,104 @@
import { describe, expect, it } from "vitest";
import type { ForeshadowView } from "@/lib/api/types";
import {
chapterForeshadowTotal,
classifyChapterForeshadow,
foreshadowStatusLabel,
} from "./chapterForeshadow";
const view = (over: Partial<ForeshadowView>): ForeshadowView => ({
code: "F-001",
title: "线索",
status: "OPEN",
...over,
});
describe("classifyChapterForeshadow", () => {
it("puts items whose window covers the current chapter into 可回收", () => {
const groups = classifyChapterForeshadow(
[
view({ code: "F-A", status: "OPEN", expected_close_from: 40, expected_close_to: 60 }),
view({ code: "F-B", status: "PARTIAL", expected_close_to: 50 }),
],
50,
);
expect(groups.recyclable.map((v) => v.code)).toEqual(["F-A", "F-B"]);
expect(groups.pending).toEqual([]);
expect(groups.overdue).toEqual([]);
});
it("puts OPEN/PARTIAL with a later or missing window into 待回收", () => {
const groups = classifyChapterForeshadow(
[
view({ code: "F-LATER", status: "OPEN", expected_close_from: 80, expected_close_to: 90 }),
view({ code: "F-NOWIN", status: "PARTIAL" }),
],
50,
);
expect(groups.pending.map((v) => v.code)).toEqual(["F-LATER", "F-NOWIN"]);
expect(groups.recyclable).toEqual([]);
});
it("puts OVERDUE into 已逾期 even when a window would otherwise cover the chapter", () => {
const groups = classifyChapterForeshadow(
[view({ code: "F-OD", status: "OVERDUE", expected_close_from: 40, expected_close_to: 60 })],
50,
);
expect(groups.overdue.map((v) => v.code)).toEqual(["F-OD"]);
expect(groups.recyclable).toEqual([]);
});
it("drops CLOSED and unknown statuses from every group", () => {
const groups = classifyChapterForeshadow(
[
view({ code: "F-CLOSED", status: "CLOSED", expected_close_to: 60 }),
view({ code: "F-WEIRD", status: "WEIRD" }),
],
50,
);
expect(chapterForeshadowTotal(groups)).toBe(0);
});
it("handles the window boundary: inclusive at from and to, excluded just outside", () => {
const win = (code: string): ForeshadowView =>
view({ code, status: "OPEN", expected_close_from: 40, expected_close_to: 60 });
expect(classifyChapterForeshadow([win("lo")], 40).recyclable).toHaveLength(1);
expect(classifyChapterForeshadow([win("hi")], 60).recyclable).toHaveLength(1);
expect(classifyChapterForeshadow([win("before")], 39).pending).toHaveLength(1);
expect(classifyChapterForeshadow([win("after")], 61).pending).toHaveLength(1);
});
it("returns empty groups for empty / undefined input without mutating a shared object", () => {
const a = classifyChapterForeshadow(undefined, 1);
const b = classifyChapterForeshadow([], 1);
expect(chapterForeshadowTotal(a)).toBe(0);
expect(chapterForeshadowTotal(b)).toBe(0);
// 不可变:两次调用返回相互独立的空组
a.pending.push(view({}));
expect(b.pending).toEqual([]);
});
});
describe("chapterForeshadowTotal", () => {
it("sums the three lanes", () => {
const groups = classifyChapterForeshadow(
[
view({ code: "r", status: "OPEN", expected_close_to: 50 }),
view({ code: "p", status: "OPEN" }),
view({ code: "o", status: "OVERDUE" }),
],
50,
);
expect(chapterForeshadowTotal(groups)).toBe(3);
});
});
describe("foreshadowStatusLabel", () => {
it("maps known statuses to author-facing labels and falls back for unknown", () => {
expect(foreshadowStatusLabel("OPEN")).toBe("待推进");
expect(foreshadowStatusLabel("PARTIAL")).toBe("推进中");
expect(foreshadowStatusLabel("OVERDUE")).toBe("已逾期");
expect(foreshadowStatusLabel("WEIRD")).toBe("WEIRD");
});
});

View File

@@ -0,0 +1,51 @@
// 「本章伏笔」主动提醒的纯分类逻辑(写作台右栏 / 速查抽屉共用)。
// 输入全量伏笔 + 当前章号,用 board 的 isWindowApproaching + status 分三类。
// 纯函数、不可变(返回全新对象,不改动入参);便于 node 环境单测。
import type { ForeshadowView } from "@/lib/api/types";
import { isWindowApproaching, LANE_LABELS, type ForeshadowStatus } from "./board";
// 本章伏笔三分类:
// - recyclable 可回收:本章落在 [from,to] 窗口内OPEN/PARTIAL由 isWindowApproaching 判定)。
// - pending 待回收OPEN/PARTIAL 但尚未进入回收窗口(窗口在后 / 未设窗口)。
// - overdue 已逾期status==OVERDUE后端验收后扫描得出略滞后于当前草稿
// CLOSED已回收与未知 status 不进任何一类——本章无需再提醒。
export interface ChapterForeshadowGroups {
recyclable: ForeshadowView[];
pending: ForeshadowView[];
overdue: ForeshadowView[];
}
// 按当前章号把伏笔分三类。纯函数:用 reduce + 展开返回新对象,不可变。
// 种子每次新建(不复用共享常量),空输入也返回独立的新对象。
export function classifyChapterForeshadow(
items: readonly ForeshadowView[] | undefined,
currentChapter: number,
): ChapterForeshadowGroups {
return (items ?? []).reduce<ChapterForeshadowGroups>(
(groups, item) => {
if (item.status === "OVERDUE") {
return { ...groups, overdue: [...groups.overdue, item] };
}
if (isWindowApproaching(item, currentChapter)) {
return { ...groups, recyclable: [...groups.recyclable, item] };
}
if (item.status === "OPEN" || item.status === "PARTIAL") {
return { ...groups, pending: [...groups.pending, item] };
}
return groups; // CLOSED / 未知 status本章不提醒
},
{ recyclable: [], pending: [], overdue: [] },
);
}
// 三类合计条数(是否有可提醒项,便于空态判断)。
export function chapterForeshadowTotal(groups: ChapterForeshadowGroups): number {
return groups.recyclable.length + groups.pending.length + groups.overdue.length;
}
// 单条伏笔的作者向状态文案(复用看板 LANE_LABELS未知 status 回退原值)。
export function foreshadowStatusLabel(status: string): string {
return LANE_LABELS[status as ForeshadowStatus] ?? status;
}

View File

@@ -0,0 +1,79 @@
// @vitest-environment jsdom
import { renderHook, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ForeshadowView } from "@/lib/api/types";
import { useChapterForeshadow } from "./useChapterForeshadow";
// 后端客户端是 hook 的外部副作用边界,单测一律 mock。
const get = vi.fn();
vi.mock("@/lib/api/client", () => ({
api: { GET: (...a: unknown[]) => get(...a) },
}));
function makeRow(over: Partial<ForeshadowView>): ForeshadowView {
return { code: "F-001", title: "线索", status: "OPEN", ...over } as ForeshadowView;
}
describe("useChapterForeshadow", () => {
beforeEach(() => get.mockReset());
afterEach(() => vi.clearAllMocks());
it("拉取成功后按当前章号分类", async () => {
get.mockResolvedValue({
data: {
foreshadow: [
makeRow({ code: "R", status: "OPEN", expected_close_from: 40, expected_close_to: 60 }),
makeRow({ code: "P", status: "OPEN", expected_close_from: 80 }),
makeRow({ code: "O", status: "OVERDUE" }),
],
},
error: null,
});
const { result } = renderHook(() => useChapterForeshadow("p1", 50));
await waitFor(() => expect(result.current.status).toBe("ready"));
expect(result.current.groups.recyclable.map((v) => v.code)).toEqual(["R"]);
expect(result.current.groups.pending.map((v) => v.code)).toEqual(["P"]);
expect(result.current.groups.overdue.map((v) => v.code)).toEqual(["O"]);
});
it("拉取失败 → status=error、空分组", async () => {
get.mockResolvedValue({ data: null, error: { error: {} } });
const { result } = renderHook(() => useChapterForeshadow("p1", 3));
await waitFor(() => expect(result.current.status).toBe("error"));
expect(result.current.groups.recyclable).toEqual([]);
expect(result.current.groups.pending).toEqual([]);
expect(result.current.groups.overdue).toEqual([]);
});
it("切章不重新请求,仅重新分类", async () => {
get.mockResolvedValue({
data: {
foreshadow: [
makeRow({ code: "W", status: "OPEN", expected_close_from: 40, expected_close_to: 60 }),
],
},
error: null,
});
const { result, rerender } = renderHook(
({ ch }: { ch: number }) => useChapterForeshadow("p1", ch),
{ initialProps: { ch: 50 } },
);
await waitFor(() => expect(result.current.status).toBe("ready"));
expect(result.current.groups.recyclable.map((v) => v.code)).toEqual(["W"]);
// 切到窗口外的章:仍是同一份数据,重新分类进 pending且未再次 GET。
rerender({ ch: 10 });
await waitFor(() =>
expect(result.current.groups.pending.map((v) => v.code)).toEqual(["W"]),
);
expect(result.current.groups.recyclable).toEqual([]);
expect(get).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,71 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { api } from "@/lib/api/client";
import type { ForeshadowView } from "@/lib/api/types";
import {
classifyChapterForeshadow,
type ChapterForeshadowGroups,
} from "./chapterForeshadow";
// 写作台「本章伏笔」提醒的数据层拉本项目全量伏笔GET /foreshadow只读
// 再按当前章号本地分类。项目内一次拉取,切章仅重新分类(不重复请求)。
export type ChapterForeshadowStatus = "loading" | "ready" | "error";
export interface UseChapterForeshadow {
groups: ChapterForeshadowGroups;
status: ChapterForeshadowStatus;
reload: () => void;
}
const FORESHADOW_PATH = "/projects/{project_id}/foreshadow" as const;
const EMPTY_ITEMS: ForeshadowView[] = [];
export function useChapterForeshadow(
projectId: string,
chapterNo: number,
): UseChapterForeshadow {
const [items, setItems] = useState<ForeshadowView[]>(EMPTY_ITEMS);
const [status, setStatus] = useState<ChapterForeshadowStatus>("loading");
// reload 触发器:自增即重新拉取。
const [nonce, setNonce] = useState(0);
useEffect(() => {
let cancelled = false;
setStatus("loading");
void (async () => {
try {
const { data, error } = await api.GET(FORESHADOW_PATH, {
params: { path: { project_id: projectId } },
});
if (cancelled) return;
if (error || !data) {
setItems(EMPTY_ITEMS);
setStatus("error");
return;
}
setItems(data.foreshadow ?? EMPTY_ITEMS);
setStatus("ready");
} catch {
// 网络层失败(后端不可达/CORSopenapi-fetch 直接抛而非返回 error 信封。
if (cancelled) return;
setItems(EMPTY_ITEMS);
setStatus("error");
}
})();
return () => {
cancelled = true;
};
}, [projectId, nonce]);
// 切章不重拉,仅重新分类(伏笔窗口相对章号变化)。
const groups = useMemo(
() => classifyChapterForeshadow(items, chapterNo),
[items, chapterNo],
);
const reload = useCallback(() => setNonce((n) => n + 1), []);
return { groups, status, reload };
}