@@ -160,7 +172,7 @@ function TabPanel({ projectId, activeTab, data }: TabPanelProps) {
) : null}
{activeTab === "foreshadow" ? (
-
+
) : null}
{activeTab === "rules" ? (
@@ -307,7 +319,32 @@ function OutlinePanel({ resource, onRetry }: OutlinePanelProps) {
);
}
-// 占位面板(伏笔/规则/文风):抽屉内一句话摘要,完整编辑走下方跳链。
+interface ForeshadowPanelProps {
+ projectId: string;
+ chapterNo: number;
+}
+
+// 伏笔速查(只读):按当前章号分「可回收 / 待回收 / 已逾期」,与右栏本章伏笔同数据源、同分类函数。
+function ForeshadowPanel({ projectId, chapterNo }: ForeshadowPanelProps) {
+ const { groups, status, reload } = useChapterForeshadow(projectId, chapterNo);
+ if (status === "loading") return
;
+ if (status === "error") {
+ return
;
+ }
+ if (chapterForeshadowTotal(groups) === 0) {
+ return
;
+ }
+ return (
+
+ );
+}
+
+// 占位面板(规则/文风):抽屉内一句话摘要,完整编辑走下方跳链。
function PlaceholderPanel({ summary }: { summary: string }) {
return (
diff --git a/apps/web/components/workbench/Workbench.tsx b/apps/web/components/workbench/Workbench.tsx
index 2970de4..e00f055 100644
--- a/apps/web/components/workbench/Workbench.tsx
+++ b/apps/web/components/workbench/Workbench.tsx
@@ -444,6 +444,7 @@ export function Workbench({
{/* WFW-6 上下文速查:视口无关的右侧 slide-over;旧侧栏/全宽页路由保留、可回滚。 */}
setContextOpen(false)}
triggerRef={contextTriggerRef}
diff --git a/apps/web/lib/foreshadow/chapterForeshadow.test.ts b/apps/web/lib/foreshadow/chapterForeshadow.test.ts
new file mode 100644
index 0000000..d4af3b1
--- /dev/null
+++ b/apps/web/lib/foreshadow/chapterForeshadow.test.ts
@@ -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 => ({
+ 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");
+ });
+});
diff --git a/apps/web/lib/foreshadow/chapterForeshadow.ts b/apps/web/lib/foreshadow/chapterForeshadow.ts
new file mode 100644
index 0000000..b664261
--- /dev/null
+++ b/apps/web/lib/foreshadow/chapterForeshadow.ts
@@ -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(
+ (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;
+}
diff --git a/apps/web/lib/foreshadow/useChapterForeshadow.test.ts b/apps/web/lib/foreshadow/useChapterForeshadow.test.ts
new file mode 100644
index 0000000..512122c
--- /dev/null
+++ b/apps/web/lib/foreshadow/useChapterForeshadow.test.ts
@@ -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 {
+ 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);
+ });
+});
diff --git a/apps/web/lib/foreshadow/useChapterForeshadow.ts b/apps/web/lib/foreshadow/useChapterForeshadow.ts
new file mode 100644
index 0000000..1330042
--- /dev/null
+++ b/apps/web/lib/foreshadow/useChapterForeshadow.ts
@@ -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(EMPTY_ITEMS);
+ const [status, setStatus] = useState("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 {
+ // 网络层失败(后端不可达/CORS):openapi-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 };
+}