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:
213
apps/api/tests/test_chapters_list.py
Normal file
213
apps/api/tests/test_chapters_list.py
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
"""GET /projects/:id/chapters 列章端点 + 纯聚合逻辑测试(审稿选章枚举)。
|
||||||
|
|
||||||
|
分两层,与本仓「真 PG 行为交 tests/ E2E、单测保持确定性」纪律一致:
|
||||||
|
- **纯聚合** `build_chapter_list`:喂造好的 chapter/review 行,断言 has_draft/accepted/
|
||||||
|
reviewed_at、去重、按章号升序、空正文草稿不计(这是端点真正的业务逻辑,脱库直测)。
|
||||||
|
- **端点集成**:FastAPI client + 覆盖 `get_chapter_lister` 为 Fake(同 test_projects.py 风格),
|
||||||
|
断言 404、空项目 []、字段透传、升序;网关无关(本端点不触 LLM)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
from cryptography.fernet import Fernet
|
||||||
|
from fakes_projects import FakeProjectRepo
|
||||||
|
from ww_api.services.chapter_list import (
|
||||||
|
ChapterRow,
|
||||||
|
ReviewRow,
|
||||||
|
build_chapter_list,
|
||||||
|
)
|
||||||
|
from ww_api.services.credentials import STUB_OWNER_ID
|
||||||
|
from ww_core.domain.project_repo import ProjectView
|
||||||
|
from ww_shared import ErrorCode
|
||||||
|
|
||||||
|
# ---- 纯聚合逻辑(build_chapter_list)----
|
||||||
|
|
||||||
|
|
||||||
|
def _dt(day: int) -> datetime:
|
||||||
|
return datetime(2026, 7, day, tzinfo=UTC)
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_chapter_list_marks_draft_accepted_and_reviewed() -> None:
|
||||||
|
# 三态各出现:草稿章(1) / 已验收章(2) / 已审章(3, 有 review 时间)。
|
||||||
|
chapter_rows = [
|
||||||
|
ChapterRow(chapter_no=1, status="draft", content="草稿正文"),
|
||||||
|
ChapterRow(chapter_no=2, status="draft", content="待验收"),
|
||||||
|
ChapterRow(chapter_no=2, status="accepted", content="终稿"),
|
||||||
|
ChapterRow(chapter_no=3, status="draft", content="被审的草稿"),
|
||||||
|
]
|
||||||
|
review_rows = [ReviewRow(chapter_no=3, created_at=_dt(5))]
|
||||||
|
|
||||||
|
items = build_chapter_list(chapter_rows, review_rows)
|
||||||
|
|
||||||
|
by_no = {it.chapter_no: it for it in items}
|
||||||
|
assert by_no[1].has_draft is True
|
||||||
|
assert by_no[1].accepted is False
|
||||||
|
assert by_no[1].reviewed_at is None
|
||||||
|
assert by_no[2].accepted is True
|
||||||
|
assert by_no[2].has_draft is True
|
||||||
|
assert by_no[3].reviewed_at == _dt(5)
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_chapter_list_empty_returns_empty() -> None:
|
||||||
|
assert build_chapter_list([], []) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_chapter_list_sorted_ascending_and_deduped() -> None:
|
||||||
|
# 乱序 + 同章多版本行 → 去重 + 章号升序。
|
||||||
|
chapter_rows = [
|
||||||
|
ChapterRow(chapter_no=3, status="draft", content="c3"),
|
||||||
|
ChapterRow(chapter_no=1, status="draft", content="c1"),
|
||||||
|
ChapterRow(chapter_no=1, status="accepted", content="c1a"),
|
||||||
|
ChapterRow(chapter_no=2, status="accepted", content="c2"),
|
||||||
|
]
|
||||||
|
items = build_chapter_list(chapter_rows, [])
|
||||||
|
assert [it.chapter_no for it in items] == [1, 2, 3]
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_chapter_list_reviewed_at_is_latest() -> None:
|
||||||
|
# 同章多次审稿 → reviewed_at 取最近一次。
|
||||||
|
chapter_rows = [ChapterRow(chapter_no=1, status="draft", content="x")]
|
||||||
|
review_rows = [
|
||||||
|
ReviewRow(chapter_no=1, created_at=_dt(3)),
|
||||||
|
ReviewRow(chapter_no=1, created_at=_dt(9)),
|
||||||
|
ReviewRow(chapter_no=1, created_at=_dt(6)),
|
||||||
|
]
|
||||||
|
items = build_chapter_list(chapter_rows, review_rows)
|
||||||
|
assert items[0].reviewed_at == _dt(9)
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_chapter_list_empty_content_draft_not_marked() -> None:
|
||||||
|
# 空/纯空白正文的草稿行不算「有草稿正文」(与 GET .../draft 语义一致)。
|
||||||
|
chapter_rows = [
|
||||||
|
ChapterRow(chapter_no=1, status="draft", content=" "),
|
||||||
|
ChapterRow(chapter_no=1, status="draft", content=None),
|
||||||
|
]
|
||||||
|
items = build_chapter_list(chapter_rows, [])
|
||||||
|
assert items[0].chapter_no == 1
|
||||||
|
assert items[0].has_draft is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_chapter_list_review_only_chapter_appears() -> None:
|
||||||
|
# 有审稿但无 chapters 行(作者以 body.draft 送审未存草稿)→ 该章仍枚举、标已审。
|
||||||
|
items = build_chapter_list([], [ReviewRow(chapter_no=7, created_at=_dt(2))])
|
||||||
|
assert len(items) == 1
|
||||||
|
assert items[0].chapter_no == 7
|
||||||
|
assert items[0].has_draft is False
|
||||||
|
assert items[0].accepted is False
|
||||||
|
assert items[0].reviewed_at == _dt(2)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- 端点集成(FastAPI client + Fake lister)----
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeChapterLister:
|
||||||
|
"""实现 ChapterLister Protocol 的内存版:回放预置的列章结果。"""
|
||||||
|
|
||||||
|
def __init__(self, items_by_project: dict[uuid.UUID, list[object]] | None = None) -> None:
|
||||||
|
self.items_by_project = items_by_project or {}
|
||||||
|
|
||||||
|
async def list_chapters(self, project_id: uuid.UUID) -> list[object]:
|
||||||
|
return self.items_by_project.get(project_id, [])
|
||||||
|
|
||||||
|
|
||||||
|
def _make_client(
|
||||||
|
*,
|
||||||
|
project_repo: FakeProjectRepo,
|
||||||
|
lister: _FakeChapterLister,
|
||||||
|
) -> httpx.AsyncClient:
|
||||||
|
import os
|
||||||
|
|
||||||
|
os.environ.setdefault("CREDENTIAL_ENC_KEY", Fernet.generate_key().decode())
|
||||||
|
from ww_api.main import create_app
|
||||||
|
from ww_api.services.project_deps import get_chapter_lister, get_project_repo
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
|
app.dependency_overrides[get_project_repo] = lambda: project_repo
|
||||||
|
app.dependency_overrides[get_chapter_lister] = lambda: lister
|
||||||
|
transport = httpx.ASGITransport(app=app)
|
||||||
|
return httpx.AsyncClient(transport=transport, base_url="http://test")
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_project(project_repo: FakeProjectRepo) -> uuid.UUID:
|
||||||
|
pid = uuid.uuid4()
|
||||||
|
project_repo.rows[pid] = (STUB_OWNER_ID, ProjectView(id=pid, title="测试"))
|
||||||
|
return pid
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_chapters_returns_items_with_fields() -> None:
|
||||||
|
from ww_api.schemas.projects import ChapterListItem
|
||||||
|
|
||||||
|
project_repo = FakeProjectRepo()
|
||||||
|
pid = _seed_project(project_repo)
|
||||||
|
lister = _FakeChapterLister(
|
||||||
|
{
|
||||||
|
pid: [
|
||||||
|
ChapterListItem(chapter_no=1, has_draft=True, accepted=False, reviewed_at=None),
|
||||||
|
ChapterListItem(chapter_no=2, has_draft=True, accepted=True, reviewed_at=_dt(4)),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
client = _make_client(project_repo=project_repo, lister=lister)
|
||||||
|
async with client:
|
||||||
|
resp = await client.get(f"/projects/{pid}/chapters")
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert isinstance(body, list)
|
||||||
|
assert body[0] == {
|
||||||
|
"chapter_no": 1,
|
||||||
|
"has_draft": True,
|
||||||
|
"accepted": False,
|
||||||
|
"reviewed_at": None,
|
||||||
|
}
|
||||||
|
assert body[1]["chapter_no"] == 2
|
||||||
|
assert body[1]["accepted"] is True
|
||||||
|
assert body[1]["reviewed_at"].startswith("2026-07-04")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_chapters_empty_project_returns_empty_list() -> None:
|
||||||
|
project_repo = FakeProjectRepo()
|
||||||
|
pid = _seed_project(project_repo)
|
||||||
|
client = _make_client(project_repo=project_repo, lister=_FakeChapterLister())
|
||||||
|
async with client:
|
||||||
|
resp = await client.get(f"/projects/{pid}/chapters")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json() == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_chapters_unknown_project_404() -> None:
|
||||||
|
client = _make_client(project_repo=FakeProjectRepo(), lister=_FakeChapterLister())
|
||||||
|
async with client:
|
||||||
|
resp = await client.get(f"/projects/{uuid.uuid4()}/chapters")
|
||||||
|
assert resp.status_code == 404
|
||||||
|
assert resp.json()["error"]["code"] == ErrorCode.NOT_FOUND
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_chapters_ascending_order() -> None:
|
||||||
|
from ww_api.schemas.projects import ChapterListItem
|
||||||
|
|
||||||
|
project_repo = FakeProjectRepo()
|
||||||
|
pid = _seed_project(project_repo)
|
||||||
|
lister = _FakeChapterLister(
|
||||||
|
{
|
||||||
|
pid: [
|
||||||
|
ChapterListItem(chapter_no=1, has_draft=True, accepted=False, reviewed_at=None),
|
||||||
|
ChapterListItem(chapter_no=2, has_draft=False, accepted=True, reviewed_at=None),
|
||||||
|
ChapterListItem(chapter_no=5, has_draft=True, accepted=False, reviewed_at=None),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
client = _make_client(project_repo=project_repo, lister=lister)
|
||||||
|
async with client:
|
||||||
|
resp = await client.get(f"/projects/{pid}/chapters")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert [it["chapter_no"] for it in resp.json()] == [1, 2, 5]
|
||||||
@@ -56,6 +56,7 @@ from ww_api.schemas.injection import (
|
|||||||
from ww_api.schemas.projects import (
|
from ww_api.schemas.projects import (
|
||||||
AcceptRequest,
|
AcceptRequest,
|
||||||
AcceptResponse,
|
AcceptResponse,
|
||||||
|
ChapterListItem,
|
||||||
DraftResponse,
|
DraftResponse,
|
||||||
DraftSaveRequest,
|
DraftSaveRequest,
|
||||||
DraftStreamRequest,
|
DraftStreamRequest,
|
||||||
@@ -75,10 +76,12 @@ from ww_api.services.accept_service import (
|
|||||||
assert_conflicts_resolved,
|
assert_conflicts_resolved,
|
||||||
run_accept_transaction,
|
run_accept_transaction,
|
||||||
)
|
)
|
||||||
|
from ww_api.services.chapter_list import ChapterLister
|
||||||
from ww_api.services.credentials import STUB_OWNER_ID
|
from ww_api.services.credentials import STUB_OWNER_ID
|
||||||
from ww_api.services.digest_extraction import extract_digest_facts
|
from ww_api.services.digest_extraction import extract_digest_facts
|
||||||
from ww_api.services.foreshadow_scan import SessionFactory, run_overdue_scan
|
from ww_api.services.foreshadow_scan import SessionFactory, run_overdue_scan
|
||||||
from ww_api.services.project_deps import (
|
from ww_api.services.project_deps import (
|
||||||
|
get_chapter_lister,
|
||||||
get_chapter_repo,
|
get_chapter_repo,
|
||||||
get_clarify_gateway,
|
get_clarify_gateway,
|
||||||
get_digest_append_repo,
|
get_digest_append_repo,
|
||||||
@@ -115,6 +118,7 @@ _SSE_RESPONSE: dict[int | str, dict[str, Any]] = {
|
|||||||
|
|
||||||
ProjectRepoDep = Annotated[ProjectRepo, Depends(get_project_repo)]
|
ProjectRepoDep = Annotated[ProjectRepo, Depends(get_project_repo)]
|
||||||
ChapterRepoDep = Annotated[ChapterRepo, Depends(get_chapter_repo)]
|
ChapterRepoDep = Annotated[ChapterRepo, Depends(get_chapter_repo)]
|
||||||
|
ChapterListerDep = Annotated[ChapterLister, Depends(get_chapter_lister)]
|
||||||
GatewayDep = Annotated[Gateway, Depends(get_writer_gateway)]
|
GatewayDep = Annotated[Gateway, Depends(get_writer_gateway)]
|
||||||
ReviewGatewayDep = Annotated[Gateway, Depends(get_review_gateway)]
|
ReviewGatewayDep = Annotated[Gateway, Depends(get_review_gateway)]
|
||||||
DigestGatewayDep = Annotated[Gateway, Depends(get_digest_gateway)]
|
DigestGatewayDep = Annotated[Gateway, Depends(get_digest_gateway)]
|
||||||
@@ -166,6 +170,28 @@ async def get_project(project_id: uuid.UUID, repo: ProjectRepoDep) -> ProjectRes
|
|||||||
return _to_response(view)
|
return _to_response(view)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{project_id}/chapters", responses=_NOT_FOUND)
|
||||||
|
async def list_chapters(
|
||||||
|
project_id: uuid.UUID,
|
||||||
|
project_repo: ProjectRepoDep,
|
||||||
|
lister: ChapterListerDep,
|
||||||
|
) -> list[ChapterListItem]:
|
||||||
|
"""列出该项目「真实存在的章」+ 可审/已审标记(审稿页选章下拉枚举)。
|
||||||
|
|
||||||
|
来源 `chapters`(草稿/accepted)+ `chapter_reviews`,按章号去重升序。只读、不触 LLM。
|
||||||
|
项目不存在 → 404(同其它端点,owner 走 project_repo stub 校验)。
|
||||||
|
"""
|
||||||
|
if await project_repo.get(STUB_OWNER_ID, project_id) is None:
|
||||||
|
raise AppError(ErrorCode.NOT_FOUND, f"project {project_id} not found")
|
||||||
|
items = await lister.list_chapters(project_id)
|
||||||
|
log.info(
|
||||||
|
"chapters_listed",
|
||||||
|
project_id=str(project_id),
|
||||||
|
chapter_count=len(items),
|
||||||
|
)
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
async def _injection_response(
|
async def _injection_response(
|
||||||
repos: MemoryRepos,
|
repos: MemoryRepos,
|
||||||
project_id: uuid.UUID,
|
project_id: uuid.UUID,
|
||||||
|
|||||||
@@ -189,6 +189,23 @@ class DraftView(BaseModel):
|
|||||||
length: int
|
length: int
|
||||||
|
|
||||||
|
|
||||||
|
# ---- 列章(审稿选章枚举)----
|
||||||
|
|
||||||
|
|
||||||
|
class ChapterListItem(BaseModel):
|
||||||
|
"""GET /projects/:id/chapters 单项:一个真实存在的章 + 可审/已审标记(snake_case)。
|
||||||
|
|
||||||
|
数据来源为 `chapters`(草稿/accepted 版本)与 `chapter_reviews`,按章号去重升序。
|
||||||
|
`has_draft`=有非空草稿正文(可审);`accepted`=已有 accepted 版本;
|
||||||
|
`reviewed_at`=最近一次审稿时间(无则 null)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
chapter_no: int
|
||||||
|
has_draft: bool = Field(description="是否有非空草稿正文(可审)")
|
||||||
|
accepted: bool = Field(description="是否已验收(存在 accepted 版本)")
|
||||||
|
reviewed_at: datetime | None = Field(default=None, description="最近一次审稿时间;未审为 null")
|
||||||
|
|
||||||
|
|
||||||
# ---- 审稿(T2.5)----
|
# ---- 审稿(T2.5)----
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
109
apps/api/ww_api/services/chapter_list.py
Normal file
109
apps/api/ww_api/services/chapter_list.py
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
"""列章只读服务:枚举项目「真实存在的章」+ 可审/已审标记(审稿选章下拉用)。
|
||||||
|
|
||||||
|
数据来源:`chapters`(草稿/accepted 版本)+ `chapter_reviews`。核心业务逻辑是把这两张表
|
||||||
|
的行**聚合**为「按章号去重、升序」的列章视图,抽成纯函数 `build_chapter_list` 以便脱库直测
|
||||||
|
(真 PG 行为交 tests/ E2E,符合本仓单测确定性纪律)。`SqlChapterLister` 只负责取行 + 调聚合。
|
||||||
|
|
||||||
|
不变量:只读、不写库、不触 LLM;按 project_id 隔离(owner 校验在端点走 project_repo,同其它端点)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Protocol
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from ww_core.domain.chapter_repo import ACCEPTED_STATUS, DRAFT_STATUS
|
||||||
|
from ww_db.models import Chapter, ChapterReview
|
||||||
|
|
||||||
|
from ww_api.schemas.projects import ChapterListItem
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ChapterRow:
|
||||||
|
"""`chapters` 行的最小读投影(聚合所需字段)。"""
|
||||||
|
|
||||||
|
chapter_no: int
|
||||||
|
status: str
|
||||||
|
content: str | None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ReviewRow:
|
||||||
|
"""`chapter_reviews` 行的最小读投影(章号 + 审稿时间)。"""
|
||||||
|
|
||||||
|
chapter_no: int
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
def build_chapter_list(
|
||||||
|
chapter_rows: list[ChapterRow], review_rows: list[ReviewRow]
|
||||||
|
) -> list[ChapterListItem]:
|
||||||
|
"""把 chapters/reviews 行聚合为按章号去重、升序的列章视图(纯函数,无副作用)。
|
||||||
|
|
||||||
|
- `has_draft`:该章存在 status='draft' 且正文非空白的行(与 GET .../draft「空草稿=无」一致)。
|
||||||
|
- `accepted`:该章存在 status='accepted' 行。
|
||||||
|
- `reviewed_at`:该章审稿行 created_at 的最大值(无审稿则 None)。
|
||||||
|
- 章号来自两表并集(含仅审稿未存草稿的章),去重后升序。
|
||||||
|
"""
|
||||||
|
has_draft: dict[int, bool] = {}
|
||||||
|
accepted: dict[int, bool] = {}
|
||||||
|
reviewed_at: dict[int, datetime] = {}
|
||||||
|
|
||||||
|
for row in chapter_rows:
|
||||||
|
if row.status == DRAFT_STATUS and row.content is not None and row.content.strip():
|
||||||
|
has_draft[row.chapter_no] = True
|
||||||
|
if row.status == ACCEPTED_STATUS:
|
||||||
|
accepted[row.chapter_no] = True
|
||||||
|
|
||||||
|
for review in review_rows:
|
||||||
|
current = reviewed_at.get(review.chapter_no)
|
||||||
|
if current is None or review.created_at > current:
|
||||||
|
reviewed_at[review.chapter_no] = review.created_at
|
||||||
|
|
||||||
|
chapter_nos = sorted({r.chapter_no for r in chapter_rows} | {r.chapter_no for r in review_rows})
|
||||||
|
return [
|
||||||
|
ChapterListItem(
|
||||||
|
chapter_no=no,
|
||||||
|
has_draft=has_draft.get(no, False),
|
||||||
|
accepted=accepted.get(no, False),
|
||||||
|
reviewed_at=reviewed_at.get(no),
|
||||||
|
)
|
||||||
|
for no in chapter_nos
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class ChapterLister(Protocol):
|
||||||
|
"""列章只读接口(按 project_id 隔离)——端点依赖此协议,测试可注入 fake。"""
|
||||||
|
|
||||||
|
async def list_chapters(self, project_id: uuid.UUID) -> list[ChapterListItem]: ...
|
||||||
|
|
||||||
|
|
||||||
|
class SqlChapterLister:
|
||||||
|
"""SQLAlchemy 实现:两条只读查询取行 → 交纯聚合。单项目章数有界,聚合放 Python 侧(KISS)。"""
|
||||||
|
|
||||||
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
|
self._s = session
|
||||||
|
|
||||||
|
async def list_chapters(self, project_id: uuid.UUID) -> list[ChapterListItem]:
|
||||||
|
chapter_result = await self._s.execute(
|
||||||
|
select(Chapter.chapter_no, Chapter.status, Chapter.content).where(
|
||||||
|
Chapter.project_id == project_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
review_result = await self._s.execute(
|
||||||
|
select(ChapterReview.chapter_no, ChapterReview.created_at).where(
|
||||||
|
ChapterReview.project_id == project_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
chapter_rows = [
|
||||||
|
ChapterRow(chapter_no=r.chapter_no, status=r.status, content=r.content)
|
||||||
|
for r in chapter_result.all()
|
||||||
|
]
|
||||||
|
review_rows = [
|
||||||
|
ReviewRow(chapter_no=r.chapter_no, created_at=r.created_at) for r in review_result.all()
|
||||||
|
]
|
||||||
|
return build_chapter_list(chapter_rows, review_rows)
|
||||||
@@ -53,6 +53,7 @@ from ww_api.security.credentials import (
|
|||||||
CredentialKeyError,
|
CredentialKeyError,
|
||||||
decrypt_api_key,
|
decrypt_api_key,
|
||||||
)
|
)
|
||||||
|
from ww_api.services.chapter_list import ChapterLister, SqlChapterLister
|
||||||
from ww_api.services.credentials import (
|
from ww_api.services.credentials import (
|
||||||
AUTH_TYPE_OAUTH,
|
AUTH_TYPE_OAUTH,
|
||||||
STUB_OWNER_ID,
|
STUB_OWNER_ID,
|
||||||
@@ -99,6 +100,13 @@ def get_chapter_repo(
|
|||||||
return SqlChapterRepo(session)
|
return SqlChapterRepo(session)
|
||||||
|
|
||||||
|
|
||||||
|
def get_chapter_lister(
|
||||||
|
session: Annotated[AsyncSession, Depends(get_session)],
|
||||||
|
) -> ChapterLister:
|
||||||
|
"""列章只读服务(GET /projects/:id/chapters)。只读、不写库;测试注入 fake lister。"""
|
||||||
|
return SqlChapterLister(session)
|
||||||
|
|
||||||
|
|
||||||
def get_memory_repos(
|
def get_memory_repos(
|
||||||
session: Annotated[AsyncSession, Depends(get_session)],
|
session: Annotated[AsyncSession, Depends(get_session)],
|
||||||
) -> MemoryRepos:
|
) -> MemoryRepos:
|
||||||
|
|||||||
@@ -2,12 +2,17 @@ import { notFound } from "next/navigation";
|
|||||||
|
|
||||||
import { ReviewReport } from "@/components/review/ReviewReport";
|
import { ReviewReport } from "@/components/review/ReviewReport";
|
||||||
import {
|
import {
|
||||||
|
fetchChapters,
|
||||||
fetchDraft,
|
fetchDraft,
|
||||||
fetchOutline,
|
fetchOutline,
|
||||||
fetchProject,
|
fetchProject,
|
||||||
fetchReviews,
|
fetchReviews,
|
||||||
} from "@/lib/api/server";
|
} from "@/lib/api/server";
|
||||||
import type { ProjectResponse } from "@/lib/api/types";
|
import type { ProjectResponse } from "@/lib/api/types";
|
||||||
|
import {
|
||||||
|
buildReviewChapterOptions,
|
||||||
|
defaultReviewChapter,
|
||||||
|
} from "@/lib/review/chapterNav";
|
||||||
import { latestReview } from "@/lib/review/history";
|
import { latestReview } from "@/lib/review/history";
|
||||||
import type { ChapterEntry } from "@/lib/workbench/chapter";
|
import type { ChapterEntry } from "@/lib/workbench/chapter";
|
||||||
|
|
||||||
@@ -16,14 +21,11 @@ interface PageProps {
|
|||||||
searchParams: Promise<{ chapter?: string }>;
|
searchParams: Promise<{ chapter?: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_CHAPTER_NO = 1;
|
|
||||||
|
|
||||||
// 审稿报告页(UX §6.4)。Server Component 取项目 + 审稿留痕历史(新→旧);
|
// 审稿报告页(UX §6.4)。Server Component 取项目 + 审稿留痕历史(新→旧);
|
||||||
// ReviewReport(Client)承载 SSE 重审 / 裁决 / 验收交互。
|
// ReviewReport(Client)承载 SSE 重审 / 裁决 / 验收交互。
|
||||||
export default async function ReviewPage({ params, searchParams }: PageProps) {
|
export default async function ReviewPage({ params, searchParams }: PageProps) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const { chapter } = await searchParams;
|
const { chapter } = await searchParams;
|
||||||
const chapterNo = parsePositiveInt(chapter) ?? DEFAULT_CHAPTER_NO;
|
|
||||||
|
|
||||||
// 仅当项目确实取不到时才判 404;审稿/草稿/大纲等次级拉取的瞬时错误不应把整页变成「找不到」。
|
// 仅当项目确实取不到时才判 404;审稿/草稿/大纲等次级拉取的瞬时错误不应把整页变成「找不到」。
|
||||||
let project: ProjectResponse;
|
let project: ProjectResponse;
|
||||||
@@ -33,6 +35,11 @@ export default async function ReviewPage({ params, searchParams }: PageProps) {
|
|||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 真实存在的章(含可审/已审标记,错误→[]):供选章枚举 + 决定默认章。
|
||||||
|
const chapterList = await fetchChapters(id);
|
||||||
|
// 无 ?chapter 时默认落到最近一个有草稿的章,而非硬编码第 1 章。
|
||||||
|
const chapterNo = parsePositiveInt(chapter) ?? defaultReviewChapter(chapterList);
|
||||||
|
|
||||||
// 审稿留痕(GET .../reviews,失败→空历史)。
|
// 审稿留痕(GET .../reviews,失败→空历史)。
|
||||||
let initialReview;
|
let initialReview;
|
||||||
try {
|
try {
|
||||||
@@ -57,6 +64,13 @@ export default async function ReviewPage({ params, searchParams }: PageProps) {
|
|||||||
title: c.beats?.[0],
|
title: c.beats?.[0],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// 选章下拉选项:以真实章为准(覆盖「写过但不在大纲」的章),大纲标题补短名,标注可审/已审。
|
||||||
|
const chapterOptions = buildReviewChapterOptions(
|
||||||
|
chapterList,
|
||||||
|
chapters,
|
||||||
|
chapterNo,
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
// key=章号:客户端切章(?chapter=N 变化)时强制重挂载,用新章审稿/草稿刷新状态,
|
// key=章号:客户端切章(?chapter=N 变化)时强制重挂载,用新章审稿/草稿刷新状态,
|
||||||
// 避免 ReviewReport 内 useState 基线沿用上一章。
|
// 避免 ReviewReport 内 useState 基线沿用上一章。
|
||||||
@@ -67,6 +81,7 @@ export default async function ReviewPage({ params, searchParams }: PageProps) {
|
|||||||
initialReview={initialReview}
|
initialReview={initialReview}
|
||||||
initialDraft={initialDraft}
|
initialDraft={initialDraft}
|
||||||
chapters={chapters}
|
chapters={chapters}
|
||||||
|
chapterOptions={chapterOptions}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,11 @@ import { AppShell } from "@/components/AppShell";
|
|||||||
import { Badge } from "@/components/ui/Badge";
|
import { Badge } from "@/components/ui/Badge";
|
||||||
import { Button } from "@/components/ui/Button";
|
import { Button } from "@/components/ui/Button";
|
||||||
import { Select } from "@/components/ui/Select";
|
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 { ChapterEntry } from "@/lib/workbench/chapter";
|
||||||
import type { ProjectResponse, ReviewHistoryItem } from "@/lib/api/types";
|
import type { ProjectResponse, ReviewHistoryItem } from "@/lib/api/types";
|
||||||
import { friendlyError } from "@/lib/errors/messages";
|
import { friendlyError } from "@/lib/errors/messages";
|
||||||
@@ -74,8 +78,10 @@ interface ReviewReportProps {
|
|||||||
chapterNo: number;
|
chapterNo: number;
|
||||||
initialReview: ReviewHistoryItem | undefined;
|
initialReview: ReviewHistoryItem | undefined;
|
||||||
initialDraft: string;
|
initialDraft: string;
|
||||||
// 章节导航目录(大纲章节):用于报告头部的「切换审稿章节」选择器。
|
// 章节导航目录(大纲章节):无 chapterOptions 时回退用。
|
||||||
chapters?: ChapterEntry[];
|
chapters?: ChapterEntry[];
|
||||||
|
// 选章选项(以真实章为准,含可审/已审标记);优先于 chapters 渲染选择器。
|
||||||
|
chapterOptions?: ReviewChapterOption[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 审稿报告页主体(UX §6.4 / §8.3 / §9)。
|
// 审稿报告页主体(UX §6.4 / §8.3 / §9)。
|
||||||
@@ -87,6 +93,7 @@ export function ReviewReport({
|
|||||||
initialReview,
|
initialReview,
|
||||||
initialDraft,
|
initialDraft,
|
||||||
chapters,
|
chapters,
|
||||||
|
chapterOptions,
|
||||||
}: ReviewReportProps) {
|
}: ReviewReportProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const review = useReviewStream();
|
const review = useReviewStream();
|
||||||
@@ -460,7 +467,7 @@ export function ReviewReport({
|
|||||||
<Select
|
<Select
|
||||||
controlSize="sm"
|
controlSize="sm"
|
||||||
aria-label="切换审稿章节"
|
aria-label="切换审稿章节"
|
||||||
className="hidden max-w-[12rem] shrink-0 sm:block"
|
className="max-w-[12rem] shrink-0"
|
||||||
value={chapterNo}
|
value={chapterNo}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
router.push(
|
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}
|
{opt.label}
|
||||||
</option>
|
</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;
|
patch?: never;
|
||||||
trace?: 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": {
|
"/projects/{project_id}/chapters/{chapter_no}/injection": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -1123,6 +1146,33 @@ export interface components {
|
|||||||
*/
|
*/
|
||||||
count: number;
|
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
|
* CharacterCardView
|
||||||
* @description 单张角色卡(贴 ww_agents.CharacterCard;预览 + 入库请求共用形)。
|
* @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: {
|
get_injection_projects__project_id__chapters__chapter_no__injection_get: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { serverApiBase } from "./config";
|
import { serverApiBase } from "./config";
|
||||||
import type {
|
import type {
|
||||||
|
ChapterListItem,
|
||||||
CharacterListResponse,
|
CharacterListResponse,
|
||||||
DraftView,
|
DraftView,
|
||||||
ForeshadowBoardResponse,
|
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,全局只读;裸数组)。
|
// 提示词/模板库列表(GET /templates,全局只读;裸数组)。
|
||||||
// 任何错误(含后端未上线)降级为空列表,模板页照常渲染(进页不阻塞)。
|
// 任何错误(含后端未上线)降级为空列表,模板页照常渲染(进页不阻塞)。
|
||||||
export async function fetchTemplates(): Promise<TemplateResponse[]> {
|
export async function fetchTemplates(): Promise<TemplateResponse[]> {
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ export type ReviewHistoryResponse =
|
|||||||
export type ConflictDecision = components["schemas"]["ConflictDecision"];
|
export type ConflictDecision = components["schemas"]["ConflictDecision"];
|
||||||
export type AcceptRequest = components["schemas"]["AcceptRequest"];
|
export type AcceptRequest = components["schemas"]["AcceptRequest"];
|
||||||
export type AcceptResponse = components["schemas"]["AcceptResponse"];
|
export type AcceptResponse = components["schemas"]["AcceptResponse"];
|
||||||
|
// 列章(GET .../chapters,裸数组):审稿选章枚举真实存在的章 + 可审/已审标记。
|
||||||
|
export type ChapterListItem = components["schemas"]["ChapterListItem"];
|
||||||
|
|
||||||
// Scope B 多章工作流链(发起 / 续跑;进度走 GET /jobs/{id} 轮询)。
|
// Scope B 多章工作流链(发起 / 续跑;进度走 GET /jobs/{id} 轮询)。
|
||||||
export type ChainRunRequest = components["schemas"]["ChainRunRequest"];
|
export type ChainRunRequest = components["schemas"]["ChainRunRequest"];
|
||||||
|
|||||||
@@ -1,8 +1,25 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
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";
|
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", () => {
|
describe("reviewChapterHref", () => {
|
||||||
it("builds the review route for a chapter number", () => {
|
it("builds the review route for a chapter number", () => {
|
||||||
expect(reviewChapterHref("proj-1", 3)).toBe(
|
expect(reviewChapterHref("proj-1", 3)).toBe(
|
||||||
@@ -15,7 +32,7 @@ describe("reviewChapterHref", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("reviewChapterOptions", () => {
|
describe("reviewChapterOptions (legacy, outline-only)", () => {
|
||||||
const chapters: ChapterEntry[] = [
|
const chapters: ChapterEntry[] = [
|
||||||
{ no: 1, title: "开篇" },
|
{ no: 1, title: "开篇" },
|
||||||
{ no: 2 },
|
{ no: 2 },
|
||||||
@@ -32,14 +49,72 @@ describe("reviewChapterOptions", () => {
|
|||||||
expect(opts.map((o) => o.no)).toEqual([1, 2, 3, 9]);
|
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);
|
const opts = reviewChapterOptions(chapters, 1);
|
||||||
expect(opts[0]).toEqual({ no: 1, label: "第 1 章 · 开篇" });
|
expect(opts[0]).toEqual({ no: 1, label: "第 1 章 · 开篇", disabled: false });
|
||||||
expect(opts[1]).toEqual({ no: 2, label: "第 2 章" });
|
expect(opts[1]).toEqual({ no: 2, label: "第 2 章", disabled: false });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns just the current chapter when there is no outline", () => {
|
describe("buildReviewChapterOptions (real chapters + outline titles)", () => {
|
||||||
const opts = reviewChapterOptions([], 4);
|
const outline: ChapterEntry[] = [
|
||||||
expect(opts).toEqual([{ no: 4, label: "第 4 章" }]);
|
{ 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 type { ChapterEntry } from "@/lib/workbench/chapter";
|
||||||
import { buildChapterEntries } from "@/lib/workbench/chapter";
|
import { buildChapterEntries } from "@/lib/workbench/chapter";
|
||||||
|
|
||||||
export interface ReviewChapterOption {
|
export interface ReviewChapterOption {
|
||||||
no: number;
|
no: number;
|
||||||
label: string;
|
label: string;
|
||||||
|
// 有草稿正文=可审;无草稿章置灰(审稿依赖草稿,选空章会 422)。
|
||||||
|
disabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function reviewChapterHref(projectId: string, chapterNo: number): string {
|
export function reviewChapterHref(projectId: string, chapterNo: number): string {
|
||||||
return `/projects/${projectId}/review?chapter=${chapterNo}`;
|
return `/projects/${projectId}/review?chapter=${chapterNo}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 旧签名:仅按大纲章 + 当前章构建(无草稿状态)。保留兼容,无 chapterList 时回退用。
|
||||||
export function reviewChapterOptions(
|
export function reviewChapterOptions(
|
||||||
chapters: readonly ChapterEntry[],
|
chapters: readonly ChapterEntry[],
|
||||||
currentChapterNo: number,
|
currentChapterNo: number,
|
||||||
@@ -19,5 +23,58 @@ export function reviewChapterOptions(
|
|||||||
label: chapter.title
|
label: chapter.title
|
||||||
? `第 ${chapter.no} 章 · ${chapter.title}`
|
? `第 ${chapter.no} 章 · ${chapter.title}`
|
||||||
: `第 ${chapter.no} 章`,
|
: `第 ${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