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:
Yaojia Wang
2026-07-12 17:52:40 +02:00
parent 5f28491ad6
commit 9aa0cddeaf
12 changed files with 652 additions and 18 deletions

View File

@@ -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);
});
});

View File

@@ -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;
}