feat(review): 审稿选章可用——列章端点 + 选章下拉全章可见 + 默认最近章
- 后端新增 GET /projects/{id}/chapters(列真实存在的章 + has_draft/accepted/reviewed_at),TDD 10 测
- gen:api 重生成 TS 客户端(ChapterListItem)
- 选章下拉去掉 hidden(手机/窄屏也可切章);选项以真实章为准 + 大纲结构,
无草稿章置灰(仅当前章例外),覆盖『写过但不在大纲』的章
- 无 ?chapter 时默认落到最近一个有草稿的章,而非硬编码第 1 章
This commit is contained in:
@@ -2,12 +2,17 @@ import { notFound } from "next/navigation";
|
||||
|
||||
import { ReviewReport } from "@/components/review/ReviewReport";
|
||||
import {
|
||||
fetchChapters,
|
||||
fetchDraft,
|
||||
fetchOutline,
|
||||
fetchProject,
|
||||
fetchReviews,
|
||||
} from "@/lib/api/server";
|
||||
import type { ProjectResponse } from "@/lib/api/types";
|
||||
import {
|
||||
buildReviewChapterOptions,
|
||||
defaultReviewChapter,
|
||||
} from "@/lib/review/chapterNav";
|
||||
import { latestReview } from "@/lib/review/history";
|
||||
import type { ChapterEntry } from "@/lib/workbench/chapter";
|
||||
|
||||
@@ -16,14 +21,11 @@ interface PageProps {
|
||||
searchParams: Promise<{ chapter?: string }>;
|
||||
}
|
||||
|
||||
const DEFAULT_CHAPTER_NO = 1;
|
||||
|
||||
// 审稿报告页(UX §6.4)。Server Component 取项目 + 审稿留痕历史(新→旧);
|
||||
// ReviewReport(Client)承载 SSE 重审 / 裁决 / 验收交互。
|
||||
export default async function ReviewPage({ params, searchParams }: PageProps) {
|
||||
const { id } = await params;
|
||||
const { chapter } = await searchParams;
|
||||
const chapterNo = parsePositiveInt(chapter) ?? DEFAULT_CHAPTER_NO;
|
||||
|
||||
// 仅当项目确实取不到时才判 404;审稿/草稿/大纲等次级拉取的瞬时错误不应把整页变成「找不到」。
|
||||
let project: ProjectResponse;
|
||||
@@ -33,6 +35,11 @@ export default async function ReviewPage({ params, searchParams }: PageProps) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
// 真实存在的章(含可审/已审标记,错误→[]):供选章枚举 + 决定默认章。
|
||||
const chapterList = await fetchChapters(id);
|
||||
// 无 ?chapter 时默认落到最近一个有草稿的章,而非硬编码第 1 章。
|
||||
const chapterNo = parsePositiveInt(chapter) ?? defaultReviewChapter(chapterList);
|
||||
|
||||
// 审稿留痕(GET .../reviews,失败→空历史)。
|
||||
let initialReview;
|
||||
try {
|
||||
@@ -57,6 +64,13 @@ export default async function ReviewPage({ params, searchParams }: PageProps) {
|
||||
title: c.beats?.[0],
|
||||
}));
|
||||
|
||||
// 选章下拉选项:以真实章为准(覆盖「写过但不在大纲」的章),大纲标题补短名,标注可审/已审。
|
||||
const chapterOptions = buildReviewChapterOptions(
|
||||
chapterList,
|
||||
chapters,
|
||||
chapterNo,
|
||||
);
|
||||
|
||||
return (
|
||||
// key=章号:客户端切章(?chapter=N 变化)时强制重挂载,用新章审稿/草稿刷新状态,
|
||||
// 避免 ReviewReport 内 useState 基线沿用上一章。
|
||||
@@ -67,6 +81,7 @@ export default async function ReviewPage({ params, searchParams }: PageProps) {
|
||||
initialReview={initialReview}
|
||||
initialDraft={initialDraft}
|
||||
chapters={chapters}
|
||||
chapterOptions={chapterOptions}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,7 +17,11 @@ import { AppShell } from "@/components/AppShell";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Select } from "@/components/ui/Select";
|
||||
import { reviewChapterHref, reviewChapterOptions } from "@/lib/review/chapterNav";
|
||||
import {
|
||||
reviewChapterHref,
|
||||
reviewChapterOptions,
|
||||
type ReviewChapterOption,
|
||||
} from "@/lib/review/chapterNav";
|
||||
import type { ChapterEntry } from "@/lib/workbench/chapter";
|
||||
import type { ProjectResponse, ReviewHistoryItem } from "@/lib/api/types";
|
||||
import { friendlyError } from "@/lib/errors/messages";
|
||||
@@ -74,8 +78,10 @@ interface ReviewReportProps {
|
||||
chapterNo: number;
|
||||
initialReview: ReviewHistoryItem | undefined;
|
||||
initialDraft: string;
|
||||
// 章节导航目录(大纲章节):用于报告头部的「切换审稿章节」选择器。
|
||||
// 章节导航目录(大纲章节):无 chapterOptions 时回退用。
|
||||
chapters?: ChapterEntry[];
|
||||
// 选章选项(以真实章为准,含可审/已审标记);优先于 chapters 渲染选择器。
|
||||
chapterOptions?: ReviewChapterOption[];
|
||||
}
|
||||
|
||||
// 审稿报告页主体(UX §6.4 / §8.3 / §9)。
|
||||
@@ -87,6 +93,7 @@ export function ReviewReport({
|
||||
initialReview,
|
||||
initialDraft,
|
||||
chapters,
|
||||
chapterOptions,
|
||||
}: ReviewReportProps) {
|
||||
const router = useRouter();
|
||||
const review = useReviewStream();
|
||||
@@ -460,7 +467,7 @@ export function ReviewReport({
|
||||
<Select
|
||||
controlSize="sm"
|
||||
aria-label="切换审稿章节"
|
||||
className="hidden max-w-[12rem] shrink-0 sm:block"
|
||||
className="max-w-[12rem] shrink-0"
|
||||
value={chapterNo}
|
||||
onChange={(e) =>
|
||||
router.push(
|
||||
@@ -468,8 +475,10 @@ export function ReviewReport({
|
||||
)
|
||||
}
|
||||
>
|
||||
{reviewChapterOptions(chapters ?? [], chapterNo).map((opt) => (
|
||||
<option key={opt.no} value={opt.no}>
|
||||
{(
|
||||
chapterOptions ?? reviewChapterOptions(chapters ?? [], chapterNo)
|
||||
).map((opt: ReviewChapterOption) => (
|
||||
<option key={opt.no} value={opt.no} disabled={opt.disabled}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
|
||||
90
apps/web/lib/api/schema.d.ts
vendored
90
apps/web/lib/api/schema.d.ts
vendored
@@ -93,6 +93,29 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/projects/{project_id}/chapters": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* List Chapters
|
||||
* @description 列出该项目「真实存在的章」+ 可审/已审标记(审稿页选章下拉枚举)。
|
||||
*
|
||||
* 来源 `chapters`(草稿/accepted)+ `chapter_reviews`,按章号去重升序。只读、不触 LLM。
|
||||
* 项目不存在 → 404(同其它端点,owner 走 project_repo stub 校验)。
|
||||
*/
|
||||
get: operations["list_chapters_projects__project_id__chapters_get"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/projects/{project_id}/chapters/{chapter_no}/injection": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -1123,6 +1146,33 @@ export interface components {
|
||||
*/
|
||||
count: number;
|
||||
};
|
||||
/**
|
||||
* ChapterListItem
|
||||
* @description GET /projects/:id/chapters 单项:一个真实存在的章 + 可审/已审标记(snake_case)。
|
||||
*
|
||||
* 数据来源为 `chapters`(草稿/accepted 版本)与 `chapter_reviews`,按章号去重升序。
|
||||
* `has_draft`=有非空草稿正文(可审);`accepted`=已有 accepted 版本;
|
||||
* `reviewed_at`=最近一次审稿时间(无则 null)。
|
||||
*/
|
||||
ChapterListItem: {
|
||||
/** Chapter No */
|
||||
chapter_no: number;
|
||||
/**
|
||||
* Has Draft
|
||||
* @description 是否有非空草稿正文(可审)
|
||||
*/
|
||||
has_draft: boolean;
|
||||
/**
|
||||
* Accepted
|
||||
* @description 是否已验收(存在 accepted 版本)
|
||||
*/
|
||||
accepted: boolean;
|
||||
/**
|
||||
* Reviewed At
|
||||
* @description 最近一次审稿时间;未审为 null
|
||||
*/
|
||||
reviewed_at?: string | null;
|
||||
};
|
||||
/**
|
||||
* CharacterCardView
|
||||
* @description 单张角色卡(贴 ww_agents.CharacterCard;预览 + 入库请求共用形)。
|
||||
@@ -2857,6 +2907,46 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
list_chapters_projects__project_id__chapters_get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
project_id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ChapterListItem"][];
|
||||
};
|
||||
};
|
||||
/** @description 资源不存在 */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
get_injection_projects__project_id__chapters__chapter_no__injection_get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { serverApiBase } from "./config";
|
||||
import type {
|
||||
ChapterListItem,
|
||||
CharacterListResponse,
|
||||
DraftView,
|
||||
ForeshadowBoardResponse,
|
||||
@@ -150,6 +151,18 @@ export async function fetchOutline(
|
||||
}
|
||||
}
|
||||
|
||||
// 列章(GET .../chapters,裸数组):审稿选章枚举真实存在的章 + 可审/已审标记。
|
||||
// 任何错误降级为空列表(进页不阻塞,回退到大纲/当前章导航)。
|
||||
export async function fetchChapters(
|
||||
projectId: string,
|
||||
): Promise<ChapterListItem[]> {
|
||||
try {
|
||||
return await getJson<ChapterListItem[]>(`/projects/${projectId}/chapters`);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// 提示词/模板库列表(GET /templates,全局只读;裸数组)。
|
||||
// 任何错误(含后端未上线)降级为空列表,模板页照常渲染(进页不阻塞)。
|
||||
export async function fetchTemplates(): Promise<TemplateResponse[]> {
|
||||
|
||||
@@ -33,6 +33,8 @@ export type ReviewHistoryResponse =
|
||||
export type ConflictDecision = components["schemas"]["ConflictDecision"];
|
||||
export type AcceptRequest = components["schemas"]["AcceptRequest"];
|
||||
export type AcceptResponse = components["schemas"]["AcceptResponse"];
|
||||
// 列章(GET .../chapters,裸数组):审稿选章枚举真实存在的章 + 可审/已审标记。
|
||||
export type ChapterListItem = components["schemas"]["ChapterListItem"];
|
||||
|
||||
// Scope B 多章工作流链(发起 / 续跑;进度走 GET /jobs/{id} 轮询)。
|
||||
export type ChainRunRequest = components["schemas"]["ChainRunRequest"];
|
||||
|
||||
@@ -1,8 +1,25 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { reviewChapterHref, reviewChapterOptions } from "./chapterNav";
|
||||
import {
|
||||
buildReviewChapterOptions,
|
||||
defaultReviewChapter,
|
||||
reviewChapterHref,
|
||||
reviewChapterOptions,
|
||||
} from "./chapterNav";
|
||||
import type { ChapterListItem } from "@/lib/api/types";
|
||||
import type { ChapterEntry } from "@/lib/workbench/chapter";
|
||||
|
||||
const item = (
|
||||
chapter_no: number,
|
||||
over: Partial<ChapterListItem> = {},
|
||||
): ChapterListItem => ({
|
||||
chapter_no,
|
||||
has_draft: true,
|
||||
accepted: false,
|
||||
reviewed_at: null,
|
||||
...over,
|
||||
});
|
||||
|
||||
describe("reviewChapterHref", () => {
|
||||
it("builds the review route for a chapter number", () => {
|
||||
expect(reviewChapterHref("proj-1", 3)).toBe(
|
||||
@@ -15,7 +32,7 @@ describe("reviewChapterHref", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("reviewChapterOptions", () => {
|
||||
describe("reviewChapterOptions (legacy, outline-only)", () => {
|
||||
const chapters: ChapterEntry[] = [
|
||||
{ no: 1, title: "开篇" },
|
||||
{ no: 2 },
|
||||
@@ -32,14 +49,72 @@ describe("reviewChapterOptions", () => {
|
||||
expect(opts.map((o) => o.no)).toEqual([1, 2, 3, 9]);
|
||||
});
|
||||
|
||||
it("labels each option with chapter number and optional title", () => {
|
||||
it("labels each option with chapter number and optional title (never disabled)", () => {
|
||||
const opts = reviewChapterOptions(chapters, 1);
|
||||
expect(opts[0]).toEqual({ no: 1, label: "第 1 章 · 开篇" });
|
||||
expect(opts[1]).toEqual({ no: 2, label: "第 2 章" });
|
||||
});
|
||||
|
||||
it("returns just the current chapter when there is no outline", () => {
|
||||
const opts = reviewChapterOptions([], 4);
|
||||
expect(opts).toEqual([{ no: 4, label: "第 4 章" }]);
|
||||
expect(opts[0]).toEqual({ no: 1, label: "第 1 章 · 开篇", disabled: false });
|
||||
expect(opts[1]).toEqual({ no: 2, label: "第 2 章", disabled: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildReviewChapterOptions (real chapters + outline titles)", () => {
|
||||
const outline: ChapterEntry[] = [
|
||||
{ no: 1, title: "开篇" },
|
||||
{ no: 2, title: "转折" },
|
||||
];
|
||||
|
||||
it("enumerates real chapters, sorted, with outline titles + status suffix", () => {
|
||||
const list = [
|
||||
item(1, { accepted: true }),
|
||||
item(2, { reviewed_at: "2026-01-01T00:00:00Z" }),
|
||||
];
|
||||
const opts = buildReviewChapterOptions(list, outline, 2);
|
||||
expect(opts).toEqual([
|
||||
{ no: 1, label: "第 1 章 · 开篇 · 已验收", disabled: false },
|
||||
{ no: 2, label: "第 2 章 · 转折 · 已审", disabled: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it("covers chapters written but not in the outline, and shows outline structure", () => {
|
||||
const list = [item(1), item(5)]; // 第 5 章 写过但大纲没有;大纲有第 2 章但未写
|
||||
const opts = buildReviewChapterOptions(list, outline, 1);
|
||||
expect(opts.map((o) => o.no)).toEqual([1, 2, 5]);
|
||||
expect(opts.find((o) => o.no === 5)?.label).toBe("第 5 章 · 草稿");
|
||||
// 大纲有、未写的章出现在结构里但置灰(不可审)
|
||||
expect(opts.find((o) => o.no === 2)?.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("disables chapters with no draft, but never the current chapter", () => {
|
||||
const list = [item(1), item(2, { has_draft: false })];
|
||||
const optsOnOther = buildReviewChapterOptions(list, outline, 1);
|
||||
expect(optsOnOther.find((o) => o.no === 2)?.disabled).toBe(true);
|
||||
// 当前就在第 2 章时不置灰(否则无法停留)
|
||||
const optsOnEmpty = buildReviewChapterOptions(list, outline, 2);
|
||||
expect(optsOnEmpty.find((o) => o.no === 2)?.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it("always includes the current chapter even if absent from list and outline", () => {
|
||||
const opts = buildReviewChapterOptions([item(1)], outline, 7);
|
||||
expect(opts.map((o) => o.no)).toEqual([1, 2, 7]);
|
||||
expect(opts.find((o) => o.no === 7)).toEqual({
|
||||
no: 7,
|
||||
label: "第 7 章",
|
||||
disabled: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("defaultReviewChapter", () => {
|
||||
it("prefers the latest chapter that has a draft", () => {
|
||||
const list = [item(1), item(2), item(3, { has_draft: false })];
|
||||
expect(defaultReviewChapter(list)).toBe(2);
|
||||
});
|
||||
|
||||
it("falls back to the max chapter number when none have drafts", () => {
|
||||
const list = [item(1, { has_draft: false }), item(4, { has_draft: false })];
|
||||
expect(defaultReviewChapter(list)).toBe(4);
|
||||
});
|
||||
|
||||
it("defaults to chapter 1 for an empty list", () => {
|
||||
expect(defaultReviewChapter([])).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import type { ChapterListItem } from "@/lib/api/types";
|
||||
import type { ChapterEntry } from "@/lib/workbench/chapter";
|
||||
import { buildChapterEntries } from "@/lib/workbench/chapter";
|
||||
|
||||
export interface ReviewChapterOption {
|
||||
no: number;
|
||||
label: string;
|
||||
// 有草稿正文=可审;无草稿章置灰(审稿依赖草稿,选空章会 422)。
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
export function reviewChapterHref(projectId: string, chapterNo: number): string {
|
||||
return `/projects/${projectId}/review?chapter=${chapterNo}`;
|
||||
}
|
||||
|
||||
// 旧签名:仅按大纲章 + 当前章构建(无草稿状态)。保留兼容,无 chapterList 时回退用。
|
||||
export function reviewChapterOptions(
|
||||
chapters: readonly ChapterEntry[],
|
||||
currentChapterNo: number,
|
||||
@@ -19,5 +23,58 @@ export function reviewChapterOptions(
|
||||
label: chapter.title
|
||||
? `第 ${chapter.no} 章 · ${chapter.title}`
|
||||
: `第 ${chapter.no} 章`,
|
||||
disabled: false,
|
||||
}));
|
||||
}
|
||||
|
||||
function statusSuffix(item: ChapterListItem | undefined): string {
|
||||
if (!item) return "";
|
||||
if (item.accepted) return " · 已验收";
|
||||
if (item.reviewed_at) return " · 已审";
|
||||
if (item.has_draft) return " · 草稿";
|
||||
return " · 空";
|
||||
}
|
||||
|
||||
// 新签名:以「真实存在的章」(chapterList) 为准枚举,覆盖「写过但不在大纲」的章,
|
||||
// 用大纲标题补短名,并标注可审/已审/已验收;无草稿章置灰(但当前章永不置灰)。
|
||||
export function buildReviewChapterOptions(
|
||||
chapterList: readonly ChapterListItem[],
|
||||
outline: readonly ChapterEntry[],
|
||||
currentChapterNo: number,
|
||||
): ReviewChapterOption[] {
|
||||
const titleByNo = new Map(outline.map((c) => [c.no, c.title]));
|
||||
const itemByNo = new Map(chapterList.map((c) => [c.chapter_no, c]));
|
||||
// 枚举真实写过的章(chapterList) ∪ 大纲章(outline,展示全书结构) ∪ 当前章。
|
||||
const nos = new Set<number>([
|
||||
...chapterList.map((c) => c.chapter_no),
|
||||
...outline.map((c) => c.no),
|
||||
currentChapterNo,
|
||||
]);
|
||||
return [...nos]
|
||||
.sort((a, b) => a - b)
|
||||
.map((no) => {
|
||||
const item = itemByNo.get(no);
|
||||
const title = titleByNo.get(no);
|
||||
const base = title ? `第 ${no} 章 · ${title}` : `第 ${no} 章`;
|
||||
return {
|
||||
no,
|
||||
label: base + statusSuffix(item),
|
||||
// 无草稿的章不可审(审稿依赖草稿);仅当前章例外,始终可停留。
|
||||
disabled: no !== currentChapterNo && !item?.has_draft,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// 无 ?chapter 时的默认章:优先最近一个有草稿的章,其次最大章号,兜底第 1 章。
|
||||
export function defaultReviewChapter(
|
||||
chapterList: readonly ChapterListItem[],
|
||||
): number {
|
||||
const withDraft = chapterList
|
||||
.filter((c) => c.has_draft)
|
||||
.map((c) => c.chapter_no);
|
||||
if (withDraft.length > 0) return Math.max(...withDraft);
|
||||
if (chapterList.length > 0) {
|
||||
return Math.max(...chapterList.map((c) => c.chapter_no));
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user