feat(foreshadow): 写作台本章伏笔主动提醒(前端)
- 新增 chapterForeshadow 纯分类:按当前章分 可回收/待回收/已逾期(启用闲置的 isWindowApproaching) - useChapterForeshadow hook(复用 GET /foreshadow,切章仅重分类不重拉) - 右栏「本章参考」+ ContextDrawer 伏笔页新增「本章伏笔」实时清单, 分色展示 + 跳看板链接 + 空态;标注『按已验收进度』
This commit is contained in:
@@ -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}
|
||||
</section>
|
||||
|
||||
<ChapterForeshadowSection projectId={projectId} chapterNo={chapterNo} />
|
||||
|
||||
<section className="mt-6 border-t border-line-soft pt-5">
|
||||
<PanelLabel>写完后检查</PanelLabel>
|
||||
<p className="mt-1.5 text-caption leading-6 text-ink-soft">
|
||||
@@ -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 (
|
||||
<section className="mt-6 border-t border-line-soft pt-5">
|
||||
<PanelLabel
|
||||
action={<span className="text-2xs text-muted-soft">按已验收进度</span>}
|
||||
>
|
||||
本章伏笔
|
||||
</PanelLabel>
|
||||
|
||||
{status === "loading" ? (
|
||||
<p className="mt-2 text-caption text-ink-soft">加载伏笔…</p>
|
||||
) : status === "error" ? (
|
||||
<StatusNote variant="danger" className="mt-2 text-xs" title="伏笔暂不可用">
|
||||
<button
|
||||
type="button"
|
||||
onClick={reload}
|
||||
className={buttonClass({ variant: "outline", size: "sm", className: "mt-2" })}
|
||||
>
|
||||
<RotateCcw className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
重试
|
||||
</button>
|
||||
</StatusNote>
|
||||
) : isEmpty ? (
|
||||
<p className="mt-2 text-caption leading-6 text-ink-soft">
|
||||
这一章暂无待回收或已逾期的伏笔。
|
||||
</p>
|
||||
) : (
|
||||
<div className="mt-3">
|
||||
<ChapterForeshadowList groups={groups} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Link
|
||||
href={boardHref}
|
||||
className="mt-3 inline-flex items-center gap-1 text-caption text-cinnabar hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cinnabar/35"
|
||||
>
|
||||
去伏笔看板
|
||||
<ArrowUpRight className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</Link>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
interface EntityRowProps {
|
||||
entity: InjectionEntity;
|
||||
pinned: boolean;
|
||||
|
||||
104
apps/web/components/workbench/ChapterForeshadowList.tsx
Normal file
104
apps/web/components/workbench/ChapterForeshadowList.tsx
Normal file
@@ -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 (
|
||||
<div className="space-y-3">
|
||||
{GROUP_SPECS.map((spec) => (
|
||||
<ForeshadowGroupBlock key={spec.key} spec={spec} items={groups[spec.key]} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ForeshadowGroupBlockProps {
|
||||
spec: GroupSpec;
|
||||
items: ForeshadowView[];
|
||||
}
|
||||
|
||||
function ForeshadowGroupBlock({ spec, items }: ForeshadowGroupBlockProps) {
|
||||
if (items.length === 0) return null;
|
||||
return (
|
||||
<div>
|
||||
<p className="flex items-center gap-1.5 text-2xs uppercase tracking-wide text-muted-soft">
|
||||
{spec.flag ? (
|
||||
<Flag className="h-3 w-3 text-pass" aria-hidden="true" />
|
||||
) : (
|
||||
<StatusDot tone={spec.tone} />
|
||||
)}
|
||||
<span>{spec.label}</span>
|
||||
<span className="tabular-nums text-ink-soft">{items.length}</span>
|
||||
</p>
|
||||
<ul className="mt-1.5 space-y-1">
|
||||
{items.map((item) => (
|
||||
<li
|
||||
key={item.code}
|
||||
className={cn(
|
||||
"rounded border px-2 py-1.5",
|
||||
spec.emphasize
|
||||
? "border-overdue/35 bg-overdue/10"
|
||||
: "border-line bg-bg",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="shrink-0 font-mono text-2xs text-ink-soft">
|
||||
{item.code}
|
||||
</span>
|
||||
<span className="truncate text-sm text-ink">{item.title}</span>
|
||||
<span
|
||||
className={badgeClass({
|
||||
variant: statusBadgeVariant(item.status),
|
||||
className: "ml-auto shrink-0",
|
||||
})}
|
||||
>
|
||||
{foreshadowStatusLabel(item.status)}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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({
|
||||
<TabBar activeTab={activeTab} onSelect={setActiveTab} />
|
||||
|
||||
<div className="flex-1 overflow-auto overscroll-contain px-4 py-4">
|
||||
<TabPanel projectId={projectId} activeTab={activeTab} data={data} />
|
||||
<TabPanel
|
||||
projectId={projectId}
|
||||
chapterNo={chapterNo}
|
||||
activeTab={activeTab}
|
||||
data={data}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -144,12 +155,13 @@ function TabBar({ activeTab, onSelect }: TabBarProps) {
|
||||
|
||||
interface TabPanelProps {
|
||||
projectId: string;
|
||||
chapterNo: number;
|
||||
activeTab: ContextTabKey;
|
||||
data: ReturnType<typeof useContextDrawerData>;
|
||||
}
|
||||
|
||||
// 按当前 Tab 渲染对应速查面板;每个面板都带一个「去完整页编辑 →」链接。
|
||||
function TabPanel({ projectId, activeTab, data }: TabPanelProps) {
|
||||
function TabPanel({ projectId, chapterNo, activeTab, data }: TabPanelProps) {
|
||||
const href = contextTabHref(projectId, activeTab);
|
||||
return (
|
||||
<div role="tabpanel" className="space-y-3">
|
||||
@@ -160,7 +172,7 @@ function TabPanel({ projectId, activeTab, data }: TabPanelProps) {
|
||||
<OutlinePanel resource={data.outline} onRetry={data.reloadOutline} />
|
||||
) : null}
|
||||
{activeTab === "foreshadow" ? (
|
||||
<PlaceholderPanel summary="伏笔账本:埋设/回收窗口与逾期告警在完整看板逐条管理。" />
|
||||
<ForeshadowPanel projectId={projectId} chapterNo={chapterNo} />
|
||||
) : null}
|
||||
{activeTab === "rules" ? (
|
||||
<PlaceholderPanel summary="世界硬规则:分级约束正文一致性,在规则页增删改。" />
|
||||
@@ -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 <LoadingNote />;
|
||||
if (status === "error") {
|
||||
return <ErrorNote message="伏笔暂不可用。" onRetry={reload} />;
|
||||
}
|
||||
if (chapterForeshadowTotal(groups) === 0) {
|
||||
return <EmptyNote text="这一章暂无待回收或已逾期的伏笔(按已验收进度)。" />;
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
<p className="mb-2 text-2xs uppercase tracking-wide text-muted-soft">
|
||||
本章伏笔 · 按已验收进度
|
||||
</p>
|
||||
<ChapterForeshadowList groups={groups} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 占位面板(规则/文风):抽屉内一句话摘要,完整编辑走下方跳链。
|
||||
function PlaceholderPanel({ summary }: { summary: string }) {
|
||||
return (
|
||||
<StatusNote variant="info">
|
||||
|
||||
@@ -444,6 +444,7 @@ export function Workbench({
|
||||
{/* WFW-6 上下文速查:视口无关的右侧 slide-over;旧侧栏/全宽页路由保留、可回滚。 */}
|
||||
<ContextDrawer
|
||||
projectId={project.id}
|
||||
chapterNo={chapterNo}
|
||||
open={contextOpen}
|
||||
onClose={() => setContextOpen(false)}
|
||||
triggerRef={contextTriggerRef}
|
||||
|
||||
104
apps/web/lib/foreshadow/chapterForeshadow.test.ts
Normal file
104
apps/web/lib/foreshadow/chapterForeshadow.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
51
apps/web/lib/foreshadow/chapterForeshadow.ts
Normal file
51
apps/web/lib/foreshadow/chapterForeshadow.ts
Normal 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;
|
||||
}
|
||||
79
apps/web/lib/foreshadow/useChapterForeshadow.test.ts
Normal file
79
apps/web/lib/foreshadow/useChapterForeshadow.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
71
apps/web/lib/foreshadow/useChapterForeshadow.ts
Normal file
71
apps/web/lib/foreshadow/useChapterForeshadow.ts
Normal 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 {
|
||||
// 网络层失败(后端不可达/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 };
|
||||
}
|
||||
Reference in New Issue
Block a user