diff --git a/PROGRESS.md b/PROGRESS.md index 5d5968f..db97928 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -237,3 +237,4 @@ T0.1 monorepo 骨架 ✅ @devops · T0.2 16 MVP 表迁移(无漂移,users st - [2026-06-20] @frontend — **UX R3 完成**(验收前清单 HITL gate,UX §7.4,纯前端无后端改,续 `feat/ux-r1-r2-review-ergonomics`)。新纯函数 `lib/review/accept-preview.ts`(`buildAcceptPreview`:据现有 client state 列「本次验收将更新」——晋升版次 + 提炼章节摘要 + 写回裁决(有冲突时) + 验收后伏笔到期扫描(可能置 OVERDUE) + 伏笔建议待登记提醒(有建议时))。**只列后端 accept 事务确实做的事**(核对 `projects.py` accept 端点:promote+digest+decisions+overdue 扫描;**不**声称改人物 latest_state/自动落库伏笔建议);带「以上为预期,最终以验收回执为准」免责。`AcceptPanel` 加 `foreshadowCount` prop + 未阻断时渲染清单,`ReviewReport` 传 `foreshadow.length`。TDD:4 测。前端门禁绿:lint/tsc/**vitest 205**(+4)/build。下一步(按 plan):Tier4(T4-a 顶部 AI 工具条 / T4-b 本章指令框+风格预设)。 - [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。 diff --git a/apps/api/tests/fakes_projects.py b/apps/api/tests/fakes_projects.py index 77e1637..c9dc403 100644 --- a/apps/api/tests/fakes_projects.py +++ b/apps/api/tests/fakes_projects.py @@ -9,6 +9,7 @@ from __future__ import annotations import uuid from collections.abc import AsyncIterator from contextlib import asynccontextmanager +from datetime import UTC, datetime from typing import Any from pydantic import BaseModel @@ -41,6 +42,7 @@ class FakeProjectRepo: def __init__(self) -> None: self.rows: dict[uuid.UUID, tuple[uuid.UUID, ProjectView]] = {} + self.now = datetime(2026, 6, 28, tzinfo=UTC) async def create(self, owner_id: uuid.UUID, data: ProjectCreate) -> ProjectView: pid = uuid.uuid4() @@ -53,6 +55,7 @@ class FakeProjectRepo: theme=data.theme, selling_points=list(data.selling_points), structure=data.structure, + updated_at=self.now, ) self.rows[pid] = (owner_id, view) return view diff --git a/apps/api/tests/test_projects.py b/apps/api/tests/test_projects.py index 1937314..1504933 100644 --- a/apps/api/tests/test_projects.py +++ b/apps/api/tests/test_projects.py @@ -158,8 +158,11 @@ async def test_list_projects() -> None: await client.post("/projects", json={"title": "乙"}) resp = await client.get("/projects") assert resp.status_code == 200 - titles = {p["title"] for p in resp.json()["projects"]} + projects = resp.json()["projects"] + titles = {p["title"] for p in projects} assert titles == {"甲", "乙"} + assert all("updated_at" in p for p in projects) + assert all(p["pending_review_count"] == 0 for p in projects) @pytest.mark.asyncio @@ -169,7 +172,10 @@ async def test_get_project_detail() -> None: created = (await client.post("/projects", json={"title": "详情"})).json() resp = await client.get(f"/projects/{created['id']}") assert resp.status_code == 200 - assert resp.json()["title"] == "详情" + body = resp.json() + assert body["title"] == "详情" + assert "updated_at" in body + assert body["pending_review_count"] == 0 @pytest.mark.asyncio diff --git a/apps/api/ww_api/schemas/projects.py b/apps/api/ww_api/schemas/projects.py index da92ec1..ec7b6cf 100644 --- a/apps/api/ww_api/schemas/projects.py +++ b/apps/api/ww_api/schemas/projects.py @@ -6,6 +6,7 @@ snake_case;前端经 OpenAPI 生成 TS 类型消费。改字段 → 前端必 from __future__ import annotations import uuid +from datetime import datetime from typing import Any, Literal from pydantic import BaseModel, Field @@ -34,6 +35,8 @@ class ProjectResponse(BaseModel): theme: str | None = None selling_points: list[str] = Field(default_factory=list) structure: str | None = None + updated_at: datetime | None = Field(default=None, description="项目最近更新时间") + pending_review_count: int = Field(default=0, ge=0, description="待审稿章节数") class ProjectListResponse(BaseModel): diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index deeb5bb..60fd9c7 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -3,7 +3,8 @@ @tailwind utilities; /* 纸感设计 token(UX_SPEC §2.1) */ -:root { +:root, +[data-theme="paper"] { --color-bg: #f5f1e8; --color-panel: #fbf8f1; --color-ink: #2b2620; @@ -15,6 +16,32 @@ --color-overdue: #c8893a; --color-pass: #5a6b4f; --color-info: #4a5a6b; + --color-conflict-mark: #b5543a26; + --color-conflict-mark-strong: #b5543a8c; + --shadow-paper: #2b26200f; + color-scheme: light; +} + +[data-theme="night"] { + --color-bg: #181614; + --color-panel: #221f1b; + --color-ink: #efe6d7; + --color-ink-soft: #b9ab96; + --color-line: #3c352c; + --color-cinnabar: #e07866; + --color-cinnabar-wash: #e0786620; + --color-conflict: #ef8a72; + --color-overdue: #d8a65d; + --color-pass: #95b47d; + --color-info: #8ca8ca; + --color-conflict-mark: #ef8a7230; + --color-conflict-mark-strong: #ef8a7290; + --shadow-paper: #00000045; + color-scheme: dark; +} + +html { + background: var(--color-bg); } body { @@ -22,6 +49,11 @@ body { color: var(--color-ink); } +::selection { + background: var(--color-cinnabar-wash); + color: var(--color-ink); +} + /* 流式打字机光标:朱砂闪烁;尊重 prefers-reduced-motion(UX §10)。 */ .typewriter-cursor { animation: typewriter-blink 1s step-end infinite; @@ -42,7 +74,7 @@ body { /* 终稿正文:冲突原文持续高亮(朱砂淡底),点锚点定位时闪烁一下(UX §8.3)。 */ .draft-mark { - background-color: #b5543a26; /* --color-conflict @ ~15% */ + background-color: var(--color-conflict-mark); border-radius: 2px; } .draft-mark-flash { @@ -52,10 +84,10 @@ body { @keyframes draft-mark-flash { 0%, 100% { - background-color: #b5543a26; + background-color: var(--color-conflict-mark); } 30% { - background-color: #b5543a8c; /* --color-conflict @ ~55% */ + background-color: var(--color-conflict-mark-strong); } } diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index 8ce7d67..90611c2 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -2,6 +2,7 @@ import type { Metadata } from "next"; import "./globals.css"; import { CommandPaletteMount } from "@/components/command/CommandPaletteMount"; +import { ThemeScript } from "@/components/ThemeScript"; import { ToastProvider } from "@/components/Toast"; export const metadata: Metadata = { @@ -19,6 +20,7 @@ export default function RootLayout({ return ( + {children} diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index 14bb366..ea774bb 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -1,9 +1,13 @@ import Link from "next/link"; +import { BookOpen, Plus } from "lucide-react"; import { AppShell } from "@/components/AppShell"; -import { ProjectCard } from "@/components/ProjectCard"; +import { ProjectLibrary } from "@/components/projects/ProjectLibrary"; +import { EmptyState } from "@/components/ui/EmptyState"; +import { PageHeader } from "@/components/ui/PageHeader"; import { fetchProjects } from "@/lib/api/server"; import type { ProjectResponse } from "@/lib/api/types"; +import { buttonClass } from "@/lib/ui/variants"; // 作品库(Dashboard 入口,UX §6.1)。Server Component 读取。 export default async function DashboardPage() { @@ -18,63 +22,44 @@ export default async function DashboardPage() { return ( -
-
-

我的作品

- - + 新建作品 - -
+
+ +
); } - -function EmptyState() { - return ( -
-

还没有作品

-

从一句灵感开始你的第一本书。

- - + 新建作品 - -
- ); -} - -function NewProjectCard() { - return ( - - + 新建 - 从一句灵感开始一本书 - - ); -} diff --git a/apps/web/app/settings/providers/page.tsx b/apps/web/app/settings/providers/page.tsx index 7b511ab..0c855fc 100644 --- a/apps/web/app/settings/providers/page.tsx +++ b/apps/web/app/settings/providers/page.tsx @@ -1,5 +1,6 @@ import { AppShell } from "@/components/AppShell"; import { ProvidersSettings } from "@/components/settings/ProvidersSettings"; +import { PageHeader } from "@/components/ui/PageHeader"; import { fetchKimiOauthStatus, fetchProviders } from "@/lib/api/server"; import type { ProvidersResponse } from "@/lib/api/types"; @@ -27,7 +28,11 @@ export default async function ProvidersSettingsPage() { return ( -
+
+ {loadError ? (

无法连接后端服务,请确认 API 已启动后刷新。 diff --git a/apps/web/components/AiToolbar.tsx b/apps/web/components/AiToolbar.tsx index 7d86e82..2851ff2 100644 --- a/apps/web/components/AiToolbar.tsx +++ b/apps/web/components/AiToolbar.tsx @@ -1,7 +1,21 @@ import Link from "next/link"; +import { + BookOpen, + ClipboardCheck, + ListTree, + PenLine, + Sparkles, + type LucideIcon, +} from "lucide-react"; -import { aiToolItems } from "@/lib/nav/ai-tools"; +import { AiToolbarMoreMenu } from "@/components/AiToolbarMoreMenu"; +import { + aiToolItems, + primaryAiToolItems, + secondaryAiToolItems, +} from "@/lib/nav/ai-tools"; import type { ActiveNav } from "@/lib/nav/items"; +import { buttonClass } from "@/lib/ui/variants"; interface AiToolbarProps { projectId: string; @@ -9,40 +23,71 @@ interface AiToolbarProps { } // T4-a · 项目内页常驻 AI 工具条(顶栏下,UX §3/§5)。 -// 纯链接服务端组件:写本章=朱砂主动作,其余次级;激活项朱砂高亮。窄屏横向滚动。 +// 桌面展示完整工具条;窄屏只保留写本章/审稿,把低频入口收进更多菜单。 export function AiToolbar({ projectId, activeNav }: AiToolbarProps) { + const primaryItems = primaryAiToolItems(projectId); + const secondaryItems = secondaryAiToolItems(projectId); return (

); } +function toolIcon(key: ActiveNav): LucideIcon { + if (key === "write") return PenLine; + if (key === "review") return ClipboardCheck; + if (key === "outline") return ListTree; + if (key === "codex") return BookOpen; + return Sparkles; +} + function toolClassName(isPrimary: boolean, isActive: boolean): string { - const base = - "flex items-center gap-1.5 rounded border px-3 py-1.5 text-sm transition-colors"; if (isActive) { - return `${base} border-cinnabar text-cinnabar`; + return buttonClass({ variant: "outline", size: "sm" }); } if (isPrimary) { - return `${base} border-cinnabar bg-cinnabar text-panel hover:border-cinnabar`; + return buttonClass({ variant: "primary", size: "sm" }); } - return `${base} border-line text-ink-soft hover:border-cinnabar hover:text-cinnabar`; + return buttonClass({ variant: "secondary", size: "sm" }); } diff --git a/apps/web/components/AiToolbarMoreMenu.tsx b/apps/web/components/AiToolbarMoreMenu.tsx new file mode 100644 index 0000000..96ae2f7 --- /dev/null +++ b/apps/web/components/AiToolbarMoreMenu.tsx @@ -0,0 +1,94 @@ +"use client"; + +import Link from "next/link"; +import { MoreHorizontal } from "lucide-react"; +import { useEffect, useId, useRef, useState } from "react"; + +import type { AiToolItem } from "@/lib/nav/ai-tools"; +import type { ActiveNav } from "@/lib/nav/items"; +import { buttonClass, cn } from "@/lib/ui/variants"; + +interface AiToolbarMoreMenuProps { + items: AiToolItem[]; + activeNav?: ActiveNav; +} + +export function AiToolbarMoreMenu({ + items, + activeNav, +}: AiToolbarMoreMenuProps) { + const [open, setOpen] = useState(false); + const menuId = useId(); + const rootRef = useRef(null); + const buttonRef = useRef(null); + + useEffect(() => { + if (!open) return; + + const onKeyDown = (event: KeyboardEvent): void => { + if (event.key === "Escape") { + setOpen(false); + buttonRef.current?.focus(); + } + }; + + const onPointerDown = (event: PointerEvent): void => { + const target = event.target; + if (target instanceof Node && !rootRef.current?.contains(target)) { + setOpen(false); + } + }; + + window.addEventListener("keydown", onKeyDown); + window.addEventListener("pointerdown", onPointerDown); + return () => { + window.removeEventListener("keydown", onKeyDown); + window.removeEventListener("pointerdown", onPointerDown); + }; + }, [open]); + + return ( +
+ + {open ? ( + + ) : null} +
+ ); +} diff --git a/apps/web/components/AppShell.tsx b/apps/web/components/AppShell.tsx index 372e7a5..8f6a39f 100644 --- a/apps/web/components/AppShell.tsx +++ b/apps/web/components/AppShell.tsx @@ -1,10 +1,13 @@ import Link from "next/link"; import type { ReactNode } from "react"; +import { Settings } from "lucide-react"; import type { ActiveNav } from "@/lib/nav/items"; +import { buttonClass } from "@/lib/ui/variants"; import { AiToolbar } from "./AiToolbar"; import { LeftNav } from "./LeftNav"; import { NavDrawer } from "./NavDrawer"; +import { ThemeToggle } from "./ThemeToggle"; interface AppShellProps { children: ReactNode; @@ -34,23 +37,33 @@ export function AppShell({ 墨痕 {title ? ( - {title} + + {title} + ) : null} {subtitle ? ( - {subtitle} + + {subtitle} + ) : null} -