diff --git a/apps/api/tests/test_chapters_list.py b/apps/api/tests/test_chapters_list.py new file mode 100644 index 0000000..5078c3f --- /dev/null +++ b/apps/api/tests/test_chapters_list.py @@ -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] diff --git a/apps/api/ww_api/routers/projects.py b/apps/api/ww_api/routers/projects.py index 3258833..02e3398 100644 --- a/apps/api/ww_api/routers/projects.py +++ b/apps/api/ww_api/routers/projects.py @@ -56,6 +56,7 @@ from ww_api.schemas.injection import ( from ww_api.schemas.projects import ( AcceptRequest, AcceptResponse, + ChapterListItem, DraftResponse, DraftSaveRequest, DraftStreamRequest, @@ -75,10 +76,12 @@ from ww_api.services.accept_service import ( assert_conflicts_resolved, run_accept_transaction, ) +from ww_api.services.chapter_list import ChapterLister from ww_api.services.credentials import STUB_OWNER_ID 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.project_deps import ( + get_chapter_lister, get_chapter_repo, get_clarify_gateway, get_digest_append_repo, @@ -115,6 +118,7 @@ _SSE_RESPONSE: dict[int | str, dict[str, Any]] = { ProjectRepoDep = Annotated[ProjectRepo, Depends(get_project_repo)] ChapterRepoDep = Annotated[ChapterRepo, Depends(get_chapter_repo)] +ChapterListerDep = Annotated[ChapterLister, Depends(get_chapter_lister)] GatewayDep = Annotated[Gateway, Depends(get_writer_gateway)] ReviewGatewayDep = Annotated[Gateway, Depends(get_review_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) +@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( repos: MemoryRepos, project_id: uuid.UUID, diff --git a/apps/api/ww_api/schemas/projects.py b/apps/api/ww_api/schemas/projects.py index b2f735d..89b2b10 100644 --- a/apps/api/ww_api/schemas/projects.py +++ b/apps/api/ww_api/schemas/projects.py @@ -189,6 +189,23 @@ class DraftView(BaseModel): 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)---- diff --git a/apps/api/ww_api/services/chapter_list.py b/apps/api/ww_api/services/chapter_list.py new file mode 100644 index 0000000..babf1d1 --- /dev/null +++ b/apps/api/ww_api/services/chapter_list.py @@ -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) diff --git a/apps/api/ww_api/services/project_deps.py b/apps/api/ww_api/services/project_deps.py index cea94a2..800ae8f 100644 --- a/apps/api/ww_api/services/project_deps.py +++ b/apps/api/ww_api/services/project_deps.py @@ -53,6 +53,7 @@ from ww_api.security.credentials import ( CredentialKeyError, decrypt_api_key, ) +from ww_api.services.chapter_list import ChapterLister, SqlChapterLister from ww_api.services.credentials import ( AUTH_TYPE_OAUTH, STUB_OWNER_ID, @@ -99,6 +100,13 @@ def get_chapter_repo( 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( session: Annotated[AsyncSession, Depends(get_session)], ) -> MemoryRepos: diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index dc332cc..f577e57 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -134,6 +134,21 @@ body { } } +/* 主题切换图标:显隐由 data-theme 决定(而非 JS 状态),两个图标始终在 DOM 里, + 服务端/客户端首帧结构一致——既避免水合不一致,又无首帧闪烁。 */ +.theme-icon-moon { + display: inline-block; +} +.theme-icon-sun { + display: none; +} +[data-theme="night"] .theme-icon-moon { + display: none; +} +[data-theme="night"] .theme-icon-sun { + display: inline-block; +} + /* Toast 入场:淡入 + 轻微上移(尊重 prefers-reduced-motion,见下)。 */ .toast-enter { animation: toast-in var(--dur-base) var(--ease-standard); @@ -149,7 +164,81 @@ body { } } +/* AI 工作中·三点波动(比 animate-pulse 更明显:竖向弹跳 + 亮度,供 ThinkingIndicator 用)。 */ +.ai-dot { + animation: ai-dot 1.2s ease-in-out infinite; +} +@keyframes ai-dot { + 0%, + 80%, + 100% { + transform: translateY(0); + opacity: 0.4; + } + 40% { + transform: translateY(-3px); + opacity: 1; + } +} + +/* AI 流式/处理中·不确定进度条(细条来回扫,明确传达"正在工作")。 */ +.ai-stream-track { + position: relative; + overflow: hidden; + background: var(--color-cinnabar-wash); +} +.ai-stream-track::after { + content: ""; + position: absolute; + inset-block: 0; + left: -40%; + width: 40%; + border-radius: 9999px; + background: var(--color-cinnabar); + animation: ai-stream 1.4s ease-in-out infinite; +} +@keyframes ai-stream { + 0% { + left: -40%; + width: 35%; + } + 50% { + width: 55%; + } + 100% { + left: 100%; + width: 35%; + } +} + +/* AI 构思中·占位微光(正文区首 token 前的呼吸感,比骨架更"活")。 */ +.ai-breathe { + animation: ai-breathe 1.8s ease-in-out infinite; +} +@keyframes ai-breathe { + 0%, + 100% { + opacity: 0.55; + } + 50% { + opacity: 1; + } +} + @media (prefers-reduced-motion: reduce) { + .ai-dot { + animation: none; + opacity: 0.85; + } + .ai-stream-track::after { + animation: none; + left: 0; + width: 100%; + opacity: 0.35; + } + .ai-breathe { + animation: none; + } .typewriter-cursor { animation: none; } diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index 8c2c3c0..3e12aa8 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -23,7 +23,7 @@ export default async function DashboardPage() { return ( - + ; } -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} /> ); } diff --git a/apps/web/components/ThemeToggle.tsx b/apps/web/components/ThemeToggle.tsx index 165b5f6..53f01e3 100644 --- a/apps/web/components/ThemeToggle.tsx +++ b/apps/web/components/ThemeToggle.tsx @@ -26,13 +26,6 @@ function readStoredThemeMode(): ThemeMode { } } -// 初始模式直接读 ThemeScript 在水合前写入的 ,避免夜读首帧渲染错误图标。 -// SSR 守卫:服务端无 document → 回落默认(与 ThemeScript 的默认一致)。 -function initialThemeMode(): ThemeMode { - if (typeof document === "undefined") return DEFAULT_THEME_MODE; - return normalizeThemeMode(document.documentElement.dataset.theme); -} - function writeStoredThemeMode(mode: ThemeMode) { try { window.localStorage.setItem(THEME_STORAGE_KEY, mode); @@ -42,7 +35,9 @@ function writeStoredThemeMode(mode: ThemeMode) { } export function ThemeToggle() { - const [mode, setMode] = useState(initialThemeMode); + // 首帧用默认模式(与服务端一致)避免水合不一致;挂载后以 localStorage 校正。 + // 可见的图标由 CSS 按 决定(ThemeScript 已在水合前写入),故无首帧闪烁。 + const [mode, setMode] = useState(DEFAULT_THEME_MODE); useEffect(() => { // 水合后以 localStorage 为权威源校正(dataset 已由 ThemeScript 同样依据写入,通常一致)。 @@ -60,8 +55,8 @@ export function ThemeToggle() { }); }; - const Icon = mode === "night" ? Sun : Moon; - + // 图标显隐交给 CSS(按 data-theme),两个图标始终渲染 → 首帧结构与服务端一致、无闪烁。 + // 首帧 label 用默认模式(与服务端一致),挂载后随 mode 校正为正确文案。 return ( ); diff --git a/apps/web/components/ThinkingIndicator.tsx b/apps/web/components/ThinkingIndicator.tsx index d778d11..0ac916b 100644 --- a/apps/web/components/ThinkingIndicator.tsx +++ b/apps/web/components/ThinkingIndicator.tsx @@ -17,12 +17,12 @@ export function ThinkingIndicator({ role="status" aria-live="polite" > -