fix: address ui review follow-ups

This commit is contained in:
Yaojia Wang
2026-06-29 12:06:04 +02:00
parent 90a66437d7
commit dd80b6815d
7 changed files with 244 additions and 3 deletions

View File

@@ -238,3 +238,4 @@ T0.1 monorepo 骨架 ✅ @devops · T0.2 16 MVP 表迁移无漂移users st
- [2026-06-22] @backend**✅ T6.2+T6.3 创作工具箱通用端点稳定**(通用生成器框架后端落地,分支 `feat/t6-creation-toolbox`)。新 `ww_skills.TOOLBOX`(11 条legacy 3 + 新 8) + `get_tool``toolbox_registry.py`descriptor 在代码无迁移。3 新端点(`routers/toolbox.py`,已注册):`GET /skills/toolbox`描述符列表legacy 携 `legacy_route``codex?gen=world|character` / `/outline`)、`POST .../skills/{tool_key}/generate`(按 `spec.tier` 经新注入缝 `get_tier_gateway_builder` 建网关 → `run_generator` 结构化预览,**不写库**仅落 ledger未知/legacy→404、项目不存在→404、无凭据→503`POST .../skills/{tool_key}/ingest`(仅 `ingest!=None` 工具world_entities 走 **continuity 预检 409 gate**(`acknowledge_conflicts` 放行) + `partition_writes` 白名单 + schema→JSONB 写库outline 细纲按 idx 拼 beats upsert不可入库→422。PURE context 派发 `services/toolbox_context.build_toolbox_context`4 策略,缺章/大纲→空节拍不报错。response/request schema `schemas/toolbox.py`concrete 子模型,非裸 dict。守不变量 #2/#3/#9。TDD注册表 7 + context 8 + 端点 11 = **+26 测**。**全仓门禁绿**ruff/format 干净、mypy **183 Success**(隔离 cache、alembic **无漂移**无新迁移、pytest **578 passed**。OpenAPI 新增 3 端点 → **@frontend`cd apps/web && pnpm gen:api`**T6 前端前置)。记 contracts C3 扩T6.2/T6.3)。
- [2026-06-20] @frontend**Tier4 完成**T4-a 全局 AI 工具条 + T4-b 本章指令直通生成,分支 `feat/ux-tier4-ai-toolbar`,未并 develop。T4-a`lib/nav/ai-tools.ts`(aiToolItems) + `components/AiToolbar.tsx` 服务端组件AppShell 顶栏下仅项目页渲染(写本章/审稿/大纲/设定库/工具箱常驻一排,写本章=朱砂主动作),用 `--chrome` CSS 变量统一 chrome 高度(项目页 7rem=header4+toolbar3余 4rem6 个固定高度页改用 `calc(100vh-var(--chrome,4rem))` 适配。T4-b全链后端 `assemble/_build_volatile``directive` 形参(「本章指令」段领衔 volatile**绝不入 stable_core**,守缓存前缀不变量 #9`stream_draft` 加可选 body `DraftStreamRequest{directive}` 透传、`directive_len` 入 log 不记原文、不持久化无迁移,重生成 TS 客户端;前端新 `lib/workbench/directive.ts`(STYLE_PRESETS + composeDirective 纯函数)、`useDraftStream.start(p,no,directive?)` 非空时发 JSON body、Workbench 加可折叠「本章指令」textarea + 风格预设 chips。TDDai-tools 4 测 + directive 4 测 + assemble directive 测 + draft 端点 directive/向后兼容 2 测。门禁全绿:后端 ruff/format/mypy(163 文件)/**pytest 479 passed**;前端 lint/tsc/**vitest 213**/build。契约记 contracts C3 扩T4-b
- [2026-06-28] @backend/@frontend**UI Phase G 后端依赖补齐**`ProjectResponse` 增加 `updated_at` / `pending_review_count``chapters``updated_at` 迁移 `8c1d2e3f4a5b``SqlProjectRepo` 聚合项目自身、章节、审稿时间作为最近编辑,并按草稿是否晚于最近 accepted/review 统计待审稿章数。前端 `pnpm gen:api` 后项目库默认按最近编辑排序,新增“待审稿”筛选,作品卡显示最近编辑与待审稿徽标。契约记 `memory/contracts.md` C3 扩UI 计划记 `docs/design/ui-improvement-plan.md` 0.16。
- [2026-06-29] @frontend/@qa**UI review follow-up 修复完成**:补 `lib/review/chapterNav.ts` 使新增章导航测试可运行;`ReviewSectionPanel` 改为本地 open 状态,避免用户折叠后重渲染强制弹开;`ReviewReport.hasReport` 纳入 clean review新增 `tests/test_project_metadata_e2e.py` 用真实 DB 覆盖项目最近编辑/待审稿统计口径。验证:前端 lint/typecheck/vitest **490 passed**/build后端 ruff/format/mypy、pytest **763 passed**;浏览器抽样 review/settings 无 console error/横向溢出。

View File

@@ -381,6 +381,7 @@ export function ReviewReport({
const unresolved = unresolvedIndices(drafts).length;
const reviewing = review.isReviewing;
const hasReport =
initialReview !== undefined ||
review.state.phase === "done" ||
review.state.sections.length > 0 ||
seededConflicts.length > 0 ||

View File

@@ -1,6 +1,6 @@
"use client";
import type { ReactNode } from "react";
import { useEffect, useRef, useState, type ReactNode } from "react";
import { ChevronDown } from "lucide-react";
import { Badge } from "@/components/ui/Badge";
@@ -23,10 +23,21 @@ export function ReviewSectionPanel({
children,
defaultOpen = false,
}: ReviewSectionPanelProps) {
const [open, setOpen] = useState(defaultOpen);
const previousDefaultOpen = useRef(defaultOpen);
useEffect(() => {
if (defaultOpen && !previousDefaultOpen.current) {
setOpen(true);
}
previousDefaultOpen.current = defaultOpen;
}, [defaultOpen]);
return (
<details
className="group rounded border border-line bg-bg/45"
open={defaultOpen}
open={open}
onToggle={(event) => setOpen(event.currentTarget.open)}
>
<summary className="flex cursor-pointer list-none items-center justify-between gap-3 px-3 py-2">
<span className="min-w-0">

View File

@@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest";
import { reviewChapterHref, reviewChapterOptions } from "./chapterNav";
import type { ChapterEntry } from "@/lib/workbench/chapter";
describe("reviewChapterHref", () => {
it("builds the review route for a chapter number", () => {
expect(reviewChapterHref("proj-1", 3)).toBe(
"/projects/proj-1/review?chapter=3",
);
});
it("always targets the review page (not write)", () => {
expect(reviewChapterHref("p", 1)).toContain("/review?chapter=1");
});
});
describe("reviewChapterOptions", () => {
const chapters: ChapterEntry[] = [
{ no: 1, title: "开篇" },
{ no: 2 },
{ no: 3, title: "转折" },
];
it("merges outline chapters with the current chapter, sorted ascending", () => {
const opts = reviewChapterOptions(chapters, 2);
expect(opts.map((o) => o.no)).toEqual([1, 2, 3]);
});
it("includes the current chapter even when it is outside the outline", () => {
const opts = reviewChapterOptions(chapters, 9);
expect(opts.map((o) => o.no)).toEqual([1, 2, 3, 9]);
});
it("labels each option with chapter number and optional title", () => {
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 章" }]);
});
});

View File

@@ -0,0 +1,23 @@
import type { ChapterEntry } from "@/lib/workbench/chapter";
import { buildChapterEntries } from "@/lib/workbench/chapter";
export interface ReviewChapterOption {
no: number;
label: string;
}
export function reviewChapterHref(projectId: string, chapterNo: number): string {
return `/projects/${projectId}/review?chapter=${chapterNo}`;
}
export function reviewChapterOptions(
chapters: readonly ChapterEntry[],
currentChapterNo: number,
): ReviewChapterOption[] {
return buildChapterEntries(chapters, currentChapterNo).map((chapter) => ({
no: chapter.no,
label: chapter.title
? `${chapter.no} 章 · ${chapter.title}`
: `${chapter.no}`,
}));
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,160 @@
"""Project list metadata uses real DB state for activity and pending review counts."""
from __future__ import annotations
import uuid
from collections.abc import AsyncIterator
from datetime import UTC, datetime, timedelta
import pytest
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from ww_core.domain.project_repo import SqlProjectRepo
from ww_db import get_sessionmaker
from ww_db.models import Chapter, ChapterReview, Project, User
@pytest.fixture
async def sm() -> AsyncIterator[async_sessionmaker[AsyncSession]]:
get_sessionmaker.cache_clear()
maker = get_sessionmaker()
try:
async with maker() as probe:
await probe.execute(select(1))
except Exception:
pytest.skip("postgres not reachable")
yield maker
await maker.kw["bind"].dispose()
get_sessionmaker.cache_clear()
async def _cleanup(
sm: async_sessionmaker[AsyncSession], owner_id: uuid.UUID, project_id: uuid.UUID
) -> None:
async with sm() as session:
await session.execute(delete(ChapterReview).where(ChapterReview.project_id == project_id))
await session.execute(delete(Chapter).where(Chapter.project_id == project_id))
await session.execute(delete(Project).where(Project.id == project_id))
await session.execute(delete(User).where(User.id == owner_id))
await session.commit()
async def _project_view(
sm: async_sessionmaker[AsyncSession], owner_id: uuid.UUID, project_id: uuid.UUID
):
async with sm() as session:
view = await SqlProjectRepo(session).get(owner_id, project_id)
assert view is not None
listed = await SqlProjectRepo(session).list_for_owner(owner_id)
listed_view = next((item for item in listed if item.id == project_id), None)
assert listed_view is not None
assert listed_view.pending_review_count == view.pending_review_count
assert listed_view.updated_at == view.updated_at
return view
async def test_project_metadata_tracks_review_and_acceptance_state(
sm: async_sessionmaker[AsyncSession],
) -> None:
owner_id = uuid.uuid4()
project_id = uuid.uuid4()
base = datetime(2026, 6, 1, tzinfo=UTC)
await _cleanup(sm, owner_id, project_id)
try:
async with sm() as session:
session.add(
User(
id=owner_id,
email=f"{owner_id}@example.test",
display_name="metadata test",
)
)
await session.commit()
async with sm() as session:
session.add(
Project(
id=project_id,
owner_id=owner_id,
title="元数据测试",
created_at=base,
updated_at=base,
)
)
await session.commit()
async with sm() as session:
session.add(
Chapter(
project_id=project_id,
volume=1,
chapter_no=1,
content="草稿一",
status="draft",
version=1,
created_at=base + timedelta(hours=1),
updated_at=base + timedelta(hours=1),
)
)
await session.commit()
view = await _project_view(sm, owner_id, project_id)
assert view.pending_review_count == 1
assert view.updated_at == base + timedelta(hours=1)
async with sm() as session:
session.add(
ChapterReview(
project_id=project_id,
chapter_no=1,
chapter_version=1,
conflicts=[],
foreshadow_sug=[],
created_at=base + timedelta(hours=2),
)
)
await session.commit()
view = await _project_view(sm, owner_id, project_id)
assert view.pending_review_count == 0
assert view.updated_at == base + timedelta(hours=2)
async with sm() as session:
session.add(
Chapter(
project_id=project_id,
volume=1,
chapter_no=1,
content="终稿一",
status="accepted",
version=2,
created_at=base + timedelta(hours=3),
updated_at=base + timedelta(hours=3),
)
)
await session.commit()
view = await _project_view(sm, owner_id, project_id)
assert view.pending_review_count == 0
assert view.updated_at == base + timedelta(hours=3)
async with sm() as session:
draft = (
await session.execute(
select(Chapter).where(
Chapter.project_id == project_id,
Chapter.chapter_no == 1,
Chapter.version == 1,
)
)
).scalar_one()
draft.content = "验收后再改草稿"
draft.updated_at = base + timedelta(hours=4)
await session.commit()
view = await _project_view(sm, owner_id, project_id)
assert view.pending_review_count == 1
assert view.updated_at == base + timedelta(hours=4)
finally:
await _cleanup(sm, owner_id, project_id)