diff --git a/apps/web/components/workbench/ChapterAssistant.tsx b/apps/web/components/workbench/ChapterAssistant.tsx index c8359ca..980344c 100644 --- a/apps/web/components/workbench/ChapterAssistant.tsx +++ b/apps/web/components/workbench/ChapterAssistant.tsx @@ -1,7 +1,9 @@ "use client"; import type { ReactNode } from "react"; +import Link from "next/link"; import { + ArrowUpRight, Minus, Pin, PinOff, @@ -15,6 +17,9 @@ import { import { ThinkingIndicator } from "@/components/ThinkingIndicator"; import { Button } from "@/components/ui/Button"; import { StatusNote } from "@/components/ui/StatusNote"; +import { ChapterForeshadowList } from "@/components/workbench/ChapterForeshadowList"; +import { chapterForeshadowTotal } from "@/lib/foreshadow/chapterForeshadow"; +import { useChapterForeshadow } from "@/lib/foreshadow/useChapterForeshadow"; import { buttonClass } from "@/lib/ui/variants"; import { isPinned, @@ -183,6 +188,8 @@ export function AssistantContent({ projectId, chapterNo }: ChapterAssistantProps ) : null} + +
写完后检查

@@ -193,6 +200,55 @@ export function AssistantContent({ projectId, chapterNo }: ChapterAssistantProps ); } +// 「本章伏笔」主动提醒:按当前章号把伏笔分可回收 / 待回收 / 已逾期三类, +// 让作者在写这章时就想起该回收哪条、哪条已逾期。只读,编辑走「伏笔看板」。 +function ChapterForeshadowSection({ projectId, chapterNo }: ChapterAssistantProps) { + const { groups, status, reload } = useChapterForeshadow(projectId, chapterNo); + const boardHref = `/projects/${projectId}/foreshadow`; + const isEmpty = chapterForeshadowTotal(groups) === 0; + + return ( +

+ 按已验收进度} + > + 本章伏笔 + + + {status === "loading" ? ( +

加载伏笔…

+ ) : status === "error" ? ( + + + + ) : isEmpty ? ( +

+ 这一章暂无待回收或已逾期的伏笔。 +

+ ) : ( +
+ +
+ )} + + + 去伏笔看板 +
+ ); +} + interface EntityRowProps { entity: InjectionEntity; pinned: boolean; diff --git a/apps/web/components/workbench/ChapterForeshadowList.tsx b/apps/web/components/workbench/ChapterForeshadowList.tsx new file mode 100644 index 0000000..2525cbb --- /dev/null +++ b/apps/web/components/workbench/ChapterForeshadowList.tsx @@ -0,0 +1,104 @@ +import { Flag } from "lucide-react"; + +import { Badge } from "@/components/ui/Badge"; +import { StatusDot, type StatusTone } from "@/components/ui/StatusDot"; +import { + chapterForeshadowTotal, + foreshadowStatusLabel, + type ChapterForeshadowGroups, +} from "@/lib/foreshadow/chapterForeshadow"; +import type { ForeshadowView } from "@/lib/api/types"; +import { badgeClass, cn, type BadgeVariant } from "@/lib/ui/variants"; + +// 「本章伏笔」分组只读清单:写作台右栏 + 速查抽屉共用(DRY)。 +// 三类各成一小块(空块不渲染);每条给状态点 + 编号 + 标题 + 状态徽标。 +// 无动画,天然尊重 reduced-motion。空态由调用方决定文案。 + +interface GroupSpec { + key: keyof ChapterForeshadowGroups; + label: string; + tone: StatusTone; + // 已逾期用琥珀强调整块外框。 + emphasize?: boolean; + // 可回收在标签前加 ⚑,提示「本章可安排回收」。 + flag?: boolean; +} + +// 顺序即优先级:可回收(现在能做)→ 已逾期(急)→ 待回收(在后,仅提示)。 +const GROUP_SPECS: readonly GroupSpec[] = [ + { key: "recyclable", label: "可回收", tone: "success", flag: true }, + { key: "overdue", label: "已逾期", tone: "warning", emphasize: true }, + { key: "pending", label: "待回收", tone: "neutral" }, +]; + +// 单条状态 → 徽标色(OVERDUE 琥珀 / PARTIAL 朱 / OPEN 中性)。 +function statusBadgeVariant(status: string): BadgeVariant { + if (status === "OVERDUE") return "warning"; + if (status === "PARTIAL") return "accent"; + return "neutral"; +} + +interface ChapterForeshadowListProps { + groups: ChapterForeshadowGroups; +} + +export function ChapterForeshadowList({ groups }: ChapterForeshadowListProps) { + if (chapterForeshadowTotal(groups) === 0) return null; + return ( +
+ {GROUP_SPECS.map((spec) => ( + + ))} +
+ ); +} + +interface ForeshadowGroupBlockProps { + spec: GroupSpec; + items: ForeshadowView[]; +} + +function ForeshadowGroupBlock({ spec, items }: ForeshadowGroupBlockProps) { + if (items.length === 0) return null; + return ( +
+

+ {spec.flag ? ( +

+
    + {items.map((item) => ( +
  • +
    + + {item.code} + + {item.title} + + {foreshadowStatusLabel(item.status)} + +
    +
  • + ))} +
+
+ ); +} diff --git a/apps/web/components/workbench/ContextDrawer.tsx b/apps/web/components/workbench/ContextDrawer.tsx index d258c96..9727f79 100644 --- a/apps/web/components/workbench/ContextDrawer.tsx +++ b/apps/web/components/workbench/ContextDrawer.tsx @@ -22,12 +22,17 @@ import { useContextDrawerData, type ContextResource, } from "@/lib/workbench/useContextDrawer"; +import { chapterForeshadowTotal } from "@/lib/foreshadow/chapterForeshadow"; +import { useChapterForeshadow } from "@/lib/foreshadow/useChapterForeshadow"; +import { ChapterForeshadowList } from "./ChapterForeshadowList"; // 速查抽屉总开关:一键关闭即回滚(触发按钮据此隐藏,旧侧栏/全宽页路由不受影响)。 export const CONTEXT_DRAWER_ENABLED = true; interface ContextDrawerProps { projectId: string; + // 当前章号:伏笔速查按此分「可回收 / 待回收 / 已逾期」。 + chapterNo: number; open: boolean; onClose: () => void; // 关闭时把焦点还给触发按钮(a11y)。 @@ -39,6 +44,7 @@ interface ContextDrawerProps { // a11y 复用命令面板/Drawer 范式:role=dialog + aria-modal + Esc 关闭 + focus trap + body scroll lock + 还原焦点。 export function ContextDrawer({ projectId, + chapterNo, open, onClose, triggerRef, @@ -98,7 +104,12 @@ export function ContextDrawer({
- +
@@ -144,12 +155,13 @@ function TabBar({ activeTab, onSelect }: TabBarProps) { interface TabPanelProps { projectId: string; + chapterNo: number; activeTab: ContextTabKey; data: ReturnType; } // 按当前 Tab 渲染对应速查面板;每个面板都带一个「去完整页编辑 →」链接。 -function TabPanel({ projectId, activeTab, data }: TabPanelProps) { +function TabPanel({ projectId, chapterNo, activeTab, data }: TabPanelProps) { const href = contextTabHref(projectId, activeTab); return (
@@ -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 }; +}