fix: address ui review follow-ups
This commit is contained in:
@@ -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,余 4rem),6 个固定高度页改用 `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。TDD:ai-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/横向溢出。
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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">
|
||||
|
||||
45
apps/web/lib/review/chapterNav.test.ts
Normal file
45
apps/web/lib/review/chapterNav.test.ts
Normal 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 章" }]);
|
||||
});
|
||||
});
|
||||
23
apps/web/lib/review/chapterNav.ts
Normal file
23
apps/web/lib/review/chapterNav.ts
Normal 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
160
tests/test_project_metadata_e2e.py
Normal file
160
tests/test_project_metadata_e2e.py
Normal 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)
|
||||
Reference in New Issue
Block a user