feat: improve app ui and project metadata

This commit is contained in:
Yaojia Wang
2026-06-28 07:31:20 +02:00
parent 3bd464d400
commit 90a66437d7
86 changed files with 4892 additions and 1108 deletions

View File

@@ -237,3 +237,4 @@ T0.1 monorepo 骨架 ✅ @devops · T0.2 16 MVP 表迁移无漂移users st
- [2026-06-20] @frontend**UX R3 完成**(验收前清单 HITL gateUX §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`。TDD4 测。前端门禁绿lint/tsc/**vitest 205**(+4)/build。下一步按 planTier4T4-a 顶部 AI 工具条 / T4-b 本章指令框+风格预设)。 - [2026-06-20] @frontend**UX R3 完成**(验收前清单 HITL gateUX §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`。TDD4 测。前端门禁绿lint/tsc/**vitest 205**(+4)/build。下一步按 planTier4T4-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-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-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。

View File

@@ -9,6 +9,7 @@ from __future__ import annotations
import uuid import uuid
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from datetime import UTC, datetime
from typing import Any from typing import Any
from pydantic import BaseModel from pydantic import BaseModel
@@ -41,6 +42,7 @@ class FakeProjectRepo:
def __init__(self) -> None: def __init__(self) -> None:
self.rows: dict[uuid.UUID, tuple[uuid.UUID, ProjectView]] = {} 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: async def create(self, owner_id: uuid.UUID, data: ProjectCreate) -> ProjectView:
pid = uuid.uuid4() pid = uuid.uuid4()
@@ -53,6 +55,7 @@ class FakeProjectRepo:
theme=data.theme, theme=data.theme,
selling_points=list(data.selling_points), selling_points=list(data.selling_points),
structure=data.structure, structure=data.structure,
updated_at=self.now,
) )
self.rows[pid] = (owner_id, view) self.rows[pid] = (owner_id, view)
return view return view

View File

@@ -158,8 +158,11 @@ async def test_list_projects() -> None:
await client.post("/projects", json={"title": ""}) await client.post("/projects", json={"title": ""})
resp = await client.get("/projects") resp = await client.get("/projects")
assert resp.status_code == 200 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 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 @pytest.mark.asyncio
@@ -169,7 +172,10 @@ async def test_get_project_detail() -> None:
created = (await client.post("/projects", json={"title": "详情"})).json() created = (await client.post("/projects", json={"title": "详情"})).json()
resp = await client.get(f"/projects/{created['id']}") resp = await client.get(f"/projects/{created['id']}")
assert resp.status_code == 200 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 @pytest.mark.asyncio

View File

@@ -6,6 +6,7 @@ snake_case前端经 OpenAPI 生成 TS 类型消费。改字段 → 前端必
from __future__ import annotations from __future__ import annotations
import uuid import uuid
from datetime import datetime
from typing import Any, Literal from typing import Any, Literal
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -34,6 +35,8 @@ class ProjectResponse(BaseModel):
theme: str | None = None theme: str | None = None
selling_points: list[str] = Field(default_factory=list) selling_points: list[str] = Field(default_factory=list)
structure: str | None = None 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): class ProjectListResponse(BaseModel):

View File

@@ -3,7 +3,8 @@
@tailwind utilities; @tailwind utilities;
/* 纸感设计 tokenUX_SPEC §2.1 */ /* 纸感设计 tokenUX_SPEC §2.1 */
:root { :root,
[data-theme="paper"] {
--color-bg: #f5f1e8; --color-bg: #f5f1e8;
--color-panel: #fbf8f1; --color-panel: #fbf8f1;
--color-ink: #2b2620; --color-ink: #2b2620;
@@ -15,6 +16,32 @@
--color-overdue: #c8893a; --color-overdue: #c8893a;
--color-pass: #5a6b4f; --color-pass: #5a6b4f;
--color-info: #4a5a6b; --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 { body {
@@ -22,6 +49,11 @@ body {
color: var(--color-ink); color: var(--color-ink);
} }
::selection {
background: var(--color-cinnabar-wash);
color: var(--color-ink);
}
/* 流式打字机光标:朱砂闪烁;尊重 prefers-reduced-motionUX §10。 */ /* 流式打字机光标:朱砂闪烁;尊重 prefers-reduced-motionUX §10。 */
.typewriter-cursor { .typewriter-cursor {
animation: typewriter-blink 1s step-end infinite; animation: typewriter-blink 1s step-end infinite;
@@ -42,7 +74,7 @@ body {
/* 终稿正文冲突原文持续高亮朱砂淡底点锚点定位时闪烁一下UX §8.3)。 */ /* 终稿正文冲突原文持续高亮朱砂淡底点锚点定位时闪烁一下UX §8.3)。 */
.draft-mark { .draft-mark {
background-color: #b5543a26; /* --color-conflict @ ~15% */ background-color: var(--color-conflict-mark);
border-radius: 2px; border-radius: 2px;
} }
.draft-mark-flash { .draft-mark-flash {
@@ -52,10 +84,10 @@ body {
@keyframes draft-mark-flash { @keyframes draft-mark-flash {
0%, 0%,
100% { 100% {
background-color: #b5543a26; background-color: var(--color-conflict-mark);
} }
30% { 30% {
background-color: #b5543a8c; /* --color-conflict @ ~55% */ background-color: var(--color-conflict-mark-strong);
} }
} }

View File

@@ -2,6 +2,7 @@ import type { Metadata } from "next";
import "./globals.css"; import "./globals.css";
import { CommandPaletteMount } from "@/components/command/CommandPaletteMount"; import { CommandPaletteMount } from "@/components/command/CommandPaletteMount";
import { ThemeScript } from "@/components/ThemeScript";
import { ToastProvider } from "@/components/Toast"; import { ToastProvider } from "@/components/Toast";
export const metadata: Metadata = { export const metadata: Metadata = {
@@ -19,6 +20,7 @@ export default function RootLayout({
return ( return (
<html lang="zh-CN" suppressHydrationWarning> <html lang="zh-CN" suppressHydrationWarning>
<body className="font-sans antialiased" suppressHydrationWarning> <body className="font-sans antialiased" suppressHydrationWarning>
<ThemeScript />
<ToastProvider> <ToastProvider>
{children} {children}
<CommandPaletteMount /> <CommandPaletteMount />

View File

@@ -1,9 +1,13 @@
import Link from "next/link"; import Link from "next/link";
import { BookOpen, Plus } from "lucide-react";
import { AppShell } from "@/components/AppShell"; 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 { fetchProjects } from "@/lib/api/server";
import type { ProjectResponse } from "@/lib/api/types"; import type { ProjectResponse } from "@/lib/api/types";
import { buttonClass } from "@/lib/ui/variants";
// 作品库Dashboard 入口UX §6.1。Server Component 读取。 // 作品库Dashboard 入口UX §6.1。Server Component 读取。
export default async function DashboardPage() { export default async function DashboardPage() {
@@ -18,63 +22,44 @@ export default async function DashboardPage() {
return ( return (
<AppShell> <AppShell>
<div className="mx-auto max-w-5xl px-8 py-10"> <div className="mx-auto max-w-5xl px-6 py-10 sm:px-8">
<div className="mb-8 flex items-center justify-between"> <PageHeader
<h1 className="font-serif text-3xl text-ink"></h1> title="我的作品"
description="从一个灵感进入正文、设定、审稿与验收闭环。"
actions={
<Link <Link
href="/projects/new" href="/projects/new"
className="rounded bg-cinnabar px-4 py-2 text-sm text-panel hover:opacity-90" className={buttonClass({ variant: "primary" })}
> >
<Plus className="h-4 w-4" aria-hidden="true" />
</Link> </Link>
</div> }
/>
{loadError ? ( {loadError ? (
<p className="rounded border border-conflict bg-panel p-6 text-conflict"> <p className="rounded border border-conflict bg-panel p-6 text-conflict">
API API
</p> </p>
) : projects.length === 0 ? ( ) : projects.length === 0 ? (
<EmptyState /> <EmptyState
icon={BookOpen}
title="还没有作品"
description="从一句灵感开始,后续的设定库、大纲、写章和审稿都会围绕这本书展开。"
action={
<Link
href="/projects/new"
className={buttonClass({ variant: "primary" })}
>
<Plus className="h-4 w-4" aria-hidden="true" />
</Link>
}
/>
) : ( ) : (
<ul className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3"> <ProjectLibrary projects={projects} />
{projects.map((p) => (
<li key={p.id}>
<ProjectCard project={p} />
</li>
))}
<li>
<NewProjectCard />
</li>
</ul>
)} )}
</div> </div>
</AppShell> </AppShell>
); );
} }
function EmptyState() {
return (
<div className="flex flex-col items-center gap-4 rounded border border-line bg-panel py-20 text-center">
<p className="font-serif text-2xl text-ink"></p>
<p className="text-ink-soft"></p>
<Link
href="/projects/new"
className="rounded bg-cinnabar px-5 py-2 text-sm text-panel hover:opacity-90"
>
</Link>
</div>
);
}
function NewProjectCard() {
return (
<Link
href="/projects/new"
className="flex h-full min-h-[160px] flex-col items-center justify-center rounded border border-dashed border-line bg-panel text-center hover:border-cinnabar"
>
<span className="font-serif text-xl text-cinnabar"> </span>
<span className="mt-2 text-sm text-ink-soft"></span>
</Link>
);
}

View File

@@ -1,5 +1,6 @@
import { AppShell } from "@/components/AppShell"; import { AppShell } from "@/components/AppShell";
import { ProvidersSettings } from "@/components/settings/ProvidersSettings"; import { ProvidersSettings } from "@/components/settings/ProvidersSettings";
import { PageHeader } from "@/components/ui/PageHeader";
import { fetchKimiOauthStatus, fetchProviders } from "@/lib/api/server"; import { fetchKimiOauthStatus, fetchProviders } from "@/lib/api/server";
import type { ProvidersResponse } from "@/lib/api/types"; import type { ProvidersResponse } from "@/lib/api/types";
@@ -27,7 +28,11 @@ export default async function ProvidersSettingsPage() {
return ( return (
<AppShell title="设置 模型与提供商"> <AppShell title="设置 模型与提供商">
<div className="mx-auto max-w-3xl px-8 py-10"> <div className="mx-auto max-w-6xl px-4 py-8 sm:px-8 sm:py-10">
<PageHeader
title="模型与提供商"
description="配置写手、分析、轻量三类能力档位的路由,并管理 API Key 与 Kimi Code OAuth。"
/>
{loadError ? ( {loadError ? (
<p className="rounded border border-conflict bg-panel p-6 text-conflict"> <p className="rounded border border-conflict bg-panel p-6 text-conflict">
API API

View File

@@ -1,7 +1,21 @@
import Link from "next/link"; 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 type { ActiveNav } from "@/lib/nav/items";
import { buttonClass } from "@/lib/ui/variants";
interface AiToolbarProps { interface AiToolbarProps {
projectId: string; projectId: string;
@@ -9,16 +23,20 @@ interface AiToolbarProps {
} }
// T4-a · 项目内页常驻 AI 工具条顶栏下UX §3/§5 // T4-a · 项目内页常驻 AI 工具条顶栏下UX §3/§5
// 纯链接服务端组件:写本章=朱砂主动作,其余次级;激活项朱砂高亮。窄屏横向滚动 // 桌面展示完整工具条;窄屏只保留写本章/审稿,把低频入口收进更多菜单
export function AiToolbar({ projectId, activeNav }: AiToolbarProps) { export function AiToolbar({ projectId, activeNav }: AiToolbarProps) {
const primaryItems = primaryAiToolItems(projectId);
const secondaryItems = secondaryAiToolItems(projectId);
return ( return (
<nav <nav
aria-label="AI 工具条" aria-label="AI 工具条"
className="flex h-12 items-center gap-2 overflow-x-auto whitespace-nowrap border-b border-line bg-panel px-4 sm:px-6" className="flex h-12 items-center gap-2 border-b border-line bg-panel px-4 sm:px-6"
> >
{aiToolItems(projectId).map((item) => { <div className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden lg:hidden">
{primaryItems.map((item) => {
const isActive = item.key === activeNav; const isActive = item.key === activeNav;
const className = toolClassName(item.primary === true, isActive); const className = toolClassName(item.primary === true, isActive);
const Icon = toolIcon(item.key);
return ( return (
<Link <Link
key={item.href} key={item.href}
@@ -26,23 +44,50 @@ export function AiToolbar({ projectId, activeNav }: AiToolbarProps) {
aria-current={isActive ? "page" : undefined} aria-current={isActive ? "page" : undefined}
className={className} className={className}
> >
<span aria-hidden="true">{item.glyph}</span> <Icon className="h-4 w-4" aria-hidden="true" />
<span>{item.label}</span> <span>{item.label}</span>
</Link> </Link>
); );
})} })}
<AiToolbarMoreMenu items={secondaryItems} activeNav={activeNav} />
</div>
<div className="hidden items-center gap-2 overflow-x-auto whitespace-nowrap lg:flex">
{aiToolItems(projectId).map((item) => {
const isActive = item.key === activeNav;
const className = toolClassName(item.primary === true, isActive);
const Icon = toolIcon(item.key);
return (
<Link
key={item.href}
href={item.href}
aria-current={isActive ? "page" : undefined}
className={className}
>
<Icon className="h-4 w-4" aria-hidden="true" />
<span>{item.label}</span>
</Link>
);
})}
</div>
</nav> </nav>
); );
} }
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 { 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) { if (isActive) {
return `${base} border-cinnabar text-cinnabar`; return buttonClass({ variant: "outline", size: "sm" });
} }
if (isPrimary) { 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" });
} }

View File

@@ -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<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(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 (
<div ref={rootRef} className="relative">
<button
ref={buttonRef}
type="button"
aria-haspopup="menu"
aria-expanded={open}
aria-controls={menuId}
onClick={() => setOpen((value) => !value)}
className={buttonClass({ variant: "secondary", size: "sm" })}
>
<MoreHorizontal className="h-4 w-4" aria-hidden="true" />
</button>
{open ? (
<div
id={menuId}
role="menu"
className="absolute right-0 z-30 mt-2 min-w-36 rounded border border-line bg-panel p-1 shadow-paper"
>
{items.map((item) => {
const active = item.key === activeNav;
return (
<Link
key={item.href}
href={item.href}
role="menuitem"
aria-current={active ? "page" : undefined}
onClick={() => setOpen(false)}
className={cn(
"block rounded px-3 py-2 text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cinnabar/35",
active
? "bg-[var(--color-cinnabar-wash)] text-cinnabar"
: "text-ink hover:bg-bg hover:text-cinnabar",
)}
>
{item.label}
</Link>
);
})}
</div>
) : null}
</div>
);
}

View File

@@ -1,10 +1,13 @@
import Link from "next/link"; import Link from "next/link";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { Settings } from "lucide-react";
import type { ActiveNav } from "@/lib/nav/items"; import type { ActiveNav } from "@/lib/nav/items";
import { buttonClass } from "@/lib/ui/variants";
import { AiToolbar } from "./AiToolbar"; import { AiToolbar } from "./AiToolbar";
import { LeftNav } from "./LeftNav"; import { LeftNav } from "./LeftNav";
import { NavDrawer } from "./NavDrawer"; import { NavDrawer } from "./NavDrawer";
import { ThemeToggle } from "./ThemeToggle";
interface AppShellProps { interface AppShellProps {
children: ReactNode; children: ReactNode;
@@ -34,23 +37,33 @@ export function AppShell({
<NavDrawer projectId={projectId} activeNav={activeNav} /> <NavDrawer projectId={projectId} activeNav={activeNav} />
<Link <Link
href="/" href="/"
className="font-serif text-xl text-cinnabar" className="shrink-0 whitespace-nowrap font-serif text-xl text-cinnabar"
aria-label="返回作品库" aria-label="返回作品库"
> >
</Link> </Link>
{title ? ( {title ? (
<span className="font-serif text-lg text-ink">{title}</span> <span className="min-w-0 truncate font-serif text-lg text-ink">
{title}
</span>
) : null} ) : null}
{subtitle ? ( {subtitle ? (
<span className="font-mono text-xs text-ink-soft">{subtitle}</span> <span className="hidden shrink-0 font-mono text-xs text-ink-soft sm:inline">
{subtitle}
</span>
) : null} ) : null}
<nav className="ml-auto flex items-center gap-4 text-sm"> <nav className="ml-auto flex shrink-0 items-center gap-2 text-sm">
<ThemeToggle />
<Link <Link
href="/settings/providers" href="/settings/providers"
className="text-ink-soft hover:text-cinnabar" className={buttonClass({
variant: "ghost",
size: "sm",
className: "border-transparent",
})}
> >
<Settings className="h-4 w-4" aria-hidden="true" />
<span className="hidden sm:inline"></span>
</Link> </Link>
</nav> </nav>
</header> </header>
@@ -59,7 +72,7 @@ export function AppShell({
) : null} ) : null}
<div className="flex"> <div className="flex">
<LeftNav projectId={projectId} activeNav={activeNav} /> <LeftNav projectId={projectId} activeNav={activeNav} />
<main className="min-h-[calc(100vh-var(--chrome,4rem))] flex-1 bg-bg"> <main className="min-h-[calc(100vh-var(--chrome,4rem))] min-w-0 flex-1 bg-bg">
{children} {children}
</main> </main>
</div> </div>

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { useEffect, useRef, type ReactNode } from "react"; import { useEffect, useRef, type ReactNode, type RefObject } from "react";
import { handleTabTrap } from "@/lib/a11y/focusTrap"; import { handleTabTrap } from "@/lib/a11y/focusTrap";
@@ -11,6 +11,7 @@ interface DrawerProps {
side?: "left" | "right"; side?: "left" | "right";
// 无障碍标签role=dialog // 无障碍标签role=dialog
label: string; label: string;
triggerRef?: RefObject<HTMLElement | null>;
children: ReactNode; children: ReactNode;
} }
@@ -21,12 +22,21 @@ export function Drawer({
onClose, onClose,
side = "left", side = "left",
label, label,
triggerRef,
children, children,
}: DrawerProps) { }: DrawerProps) {
const panelRef = useRef<HTMLDivElement>(null); const panelRef = useRef<HTMLDivElement>(null);
const wasOpenRef = useRef(false);
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) {
if (wasOpenRef.current) {
wasOpenRef.current = false;
triggerRef?.current?.focus();
}
return;
}
wasOpenRef.current = true;
const onKey = (e: KeyboardEvent): void => { const onKey = (e: KeyboardEvent): void => {
if (e.key === "Escape") { if (e.key === "Escape") {
e.preventDefault(); e.preventDefault();
@@ -36,7 +46,7 @@ export function Drawer({
window.addEventListener("keydown", onKey); window.addEventListener("keydown", onKey);
panelRef.current?.focus(); panelRef.current?.focus();
return () => window.removeEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey);
}, [open, onClose]); }, [open, onClose, triggerRef]);
if (!open) return null; if (!open) return null;

View File

@@ -1,7 +1,9 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { Menu } from "lucide-react";
import { Button } from "@/components/ui/Button";
import type { ActiveNav } from "@/lib/nav/items"; import type { ActiveNav } from "@/lib/nav/items";
import { Drawer } from "./Drawer"; import { Drawer } from "./Drawer";
import { NavItems } from "./NavItems"; import { NavItems } from "./NavItems";
@@ -17,17 +19,16 @@ export function NavDrawer({ projectId, activeNav }: NavDrawerProps) {
return ( return (
<> <>
<button <Button
type="button"
onClick={() => setOpen(true)} onClick={() => setOpen(true)}
aria-label="打开导航" aria-label="打开导航"
aria-expanded={open} aria-expanded={open}
className="-ml-1 rounded p-2 text-ink hover:text-cinnabar lg:hidden" variant="ghost"
size="icon"
className="-ml-2 lg:hidden"
> >
<span aria-hidden="true" className="block text-lg leading-none"> <Menu className="h-5 w-5" aria-hidden="true" />
</Button>
</span>
</button>
<Drawer <Drawer
open={open} open={open}
onClose={() => setOpen(false)} onClose={() => setOpen(false)}

View File

@@ -1,6 +1,21 @@
"use client"; "use client";
import Link from "next/link"; import Link from "next/link";
import {
Blocks,
BookOpen,
Boxes,
ClipboardCheck,
Flag,
LayoutTemplate,
ListTree,
Palette,
PenLine,
Settings,
Sparkles,
Workflow,
type LucideIcon,
} from "lucide-react";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { import {
@@ -54,6 +69,22 @@ export function NavItems({ projectId, activeNav, onNavigate }: NavItemsProps) {
); );
} }
function navIcon(item: NavItem): LucideIcon {
if (item.key === "write") return PenLine;
if (item.key === "outline") return ListTree;
if (item.key === "foreshadow") return Flag;
if (item.key === "review") return ClipboardCheck;
if (item.key === "chains") return Workflow;
if (item.key === "style") return Palette;
if (item.key === "codex") return BookOpen;
if (item.key === "toolbox") return Sparkles;
if (item.key === "rules") return Boxes;
if (item.key === "skills") return Blocks;
if (item.href === "/templates") return LayoutTemplate;
if (item.href === "/settings/providers") return Settings;
return BookOpen;
}
function NavLink({ function NavLink({
item, item,
active, active,
@@ -63,18 +94,20 @@ function NavLink({
active: boolean; active: boolean;
onNavigate?: () => void; onNavigate?: () => void;
}) { }) {
const Icon = navIcon(item);
return ( return (
<li> <li>
<Link <Link
href={item.href} href={item.href}
onClick={onNavigate} onClick={onNavigate}
aria-current={active ? "page" : undefined} aria-current={active ? "page" : undefined}
className={`flex items-center gap-2 rounded px-3 py-2 text-sm ${ className={`flex items-center gap-2 rounded px-3 py-2 text-sm transition-colors ${
active active
? "border-l-2 border-cinnabar bg-[var(--color-cinnabar-wash)] text-cinnabar" ? "border-l-2 border-cinnabar bg-[var(--color-cinnabar-wash)] text-cinnabar"
: "text-ink hover:bg-[var(--color-cinnabar-wash)]" : "border-l-2 border-transparent text-ink hover:bg-[var(--color-cinnabar-wash)] hover:text-cinnabar"
}`} }`}
> >
<Icon className="h-4 w-4 shrink-0" aria-hidden="true" />
{item.label} {item.label}
</Link> </Link>
</li> </li>

View File

@@ -1,26 +1,98 @@
import Link from "next/link"; import Link from "next/link";
import { BookOpen, ChevronRight, Clock3, Sparkles } from "lucide-react";
import { Badge } from "@/components/ui/Badge";
import type { ProjectResponse } from "@/lib/api/types"; import type { ProjectResponse } from "@/lib/api/types";
import {
formatProjectUpdatedAt,
pendingReviewCount,
} from "@/lib/projects/projects";
import { cardClass } from "@/lib/ui/variants";
interface ProjectCardProps { interface ProjectCardProps {
project: ProjectResponse; project: ProjectResponse;
compact?: boolean;
} }
// 作品卡UX §6.1):书名衬线大字、题材、一句话故事。 // 作品卡UX §6.1):书名衬线大字、题材、一句话故事。
// M1 无字数/章数/待办统计端点 → 暂不展示该徽标(避免编造 API // M1 无字数/章数/待办统计端点 → 暂不展示该徽标(避免编造 API
export function ProjectCard({ project }: ProjectCardProps) { export function ProjectCard({ project, compact = false }: ProjectCardProps) {
const pendingCount = pendingReviewCount(project);
const updatedLabel = formatProjectUpdatedAt(project.updated_at);
if (compact) {
return ( return (
<Link <Link
href={`/projects/${project.id}/write`} href={`/projects/${project.id}/write`}
className="flex h-full min-h-[160px] flex-col rounded border border-line bg-panel p-6 shadow-paper hover:border-cinnabar" className={cardClass(
"group flex items-center gap-3 p-3 transition-colors hover:border-cinnabar",
)}
> >
<h2 className="font-serif text-2xl text-ink">{project.title}</h2> <span className="flex h-9 w-9 shrink-0 items-center justify-center rounded bg-[var(--color-cinnabar-wash)] text-cinnabar">
{project.genre ? ( <BookOpen className="h-4 w-4" aria-hidden="true" />
<p className="mt-2 text-sm text-ink-soft">{project.genre}</p> </span>
) : null} <span className="min-w-0 flex-1">
{project.logline ? ( <span className="block truncate font-serif text-base text-ink">
<p className="mt-3 line-clamp-3 text-sm text-ink">{project.logline}</p> {project.title || "未命名作品"}
</span>
<span className="mt-1 flex items-center gap-2 text-xs text-ink-soft">
{project.genre ? <Badge>{project.genre}</Badge> : null}
{pendingCount > 0 ? (
<Badge variant="warning">{pendingCount} </Badge>
) : null} ) : null}
<span className="truncate">{project.logline ?? "暂无一句话故事"}</span>
</span>
<span className="mt-1 block font-mono text-[11px] text-ink-soft">
{updatedLabel}
</span>
</span>
<ChevronRight
className="h-4 w-4 shrink-0 text-ink-soft transition-colors group-hover:text-cinnabar"
aria-hidden="true"
/>
</Link>
);
}
return (
<Link
href={`/projects/${project.id}/write`}
className={cardClass(
"group flex h-full min-h-[176px] flex-col p-6 transition-colors hover:border-cinnabar",
)}
>
<div className="mb-4 flex items-start justify-between gap-3">
<div className="min-w-0">
<h2 className="truncate font-serif text-2xl text-ink">
{project.title || "未命名作品"}
</h2>
<div className="mt-2 flex flex-wrap gap-2">
{project.genre ? <Badge>{project.genre}</Badge> : null}
<Badge variant="accent">
<Sparkles className="h-3 w-3" aria-hidden="true" />
</Badge>
{pendingCount > 0 ? (
<Badge variant="warning">{pendingCount} 稿</Badge>
) : null}
</div>
</div>
<span className="rounded border border-line bg-bg p-2 text-ink-soft transition-colors group-hover:border-cinnabar group-hover:text-cinnabar">
<BookOpen className="h-4 w-4" aria-hidden="true" />
</span>
</div>
{project.logline ? (
<p className="line-clamp-3 text-sm leading-6 text-ink">
{project.logline}
</p>
) : null}
<span className="mt-3 flex items-center gap-1 font-mono text-[11px] text-ink-soft">
<Clock3 className="h-3.5 w-3.5" aria-hidden="true" />
{updatedLabel}
</span>
<span className="mt-auto flex items-center gap-1 pt-4 text-xs text-cinnabar opacity-0 transition-opacity group-hover:opacity-100">
<ChevronRight className="h-3.5 w-3.5" aria-hidden="true" />
</span>
</Link> </Link>
); );
} }

View File

@@ -1,10 +1,18 @@
"use client"; "use client";
import { ArrowLeft, ArrowRight, CheckCircle2 } from "lucide-react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useState } from "react"; import { useState } from "react";
import { api } from "@/lib/api/client";
import { useToast } from "@/components/Toast"; import { useToast } from "@/components/Toast";
import { Badge } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button";
import { Field } from "@/components/ui/Field";
import { Select } from "@/components/ui/Select";
import { TextArea } from "@/components/ui/TextArea";
import { TextInput } from "@/components/ui/TextInput";
import { api } from "@/lib/api/client";
import { buttonClass } from "@/lib/ui/variants";
import { import {
GENRES, GENRES,
SELLING_POINT_PRESETS, SELLING_POINT_PRESETS,
@@ -69,14 +77,17 @@ export function ProjectWizard() {
const isLast = step === WIZARD_STEPS; const isLast = step === WIZARD_STEPS;
return ( return (
<div className="mx-auto max-w-2xl rounded border border-line bg-panel p-8 shadow-paper"> <div className="mx-auto max-w-2xl rounded border border-line bg-panel p-6 shadow-paper sm:p-8">
<div className="mb-6 flex items-center justify-between"> <div className="mb-6 flex items-center justify-between">
<h1 className="font-serif text-2xl text-ink"></h1> <h1 className="font-serif text-2xl text-ink"></h1>
<StepDots current={step} /> <StepDots current={step} />
</div> </div>
<p className="mb-4 text-sm text-ink-soft"> <div className="mb-4">
{step}/{WIZARD_STEPS} · {STEP_TITLES[step - 1]} <Badge variant="accent">
</p> {step}/{WIZARD_STEPS}
</Badge>
<p className="mt-2 text-sm text-ink-soft">{STEP_TITLES[step - 1]}</p>
</div>
<div className="min-h-[220px]"> <div className="min-h-[220px]">
{step === 1 && <StepBasics form={form} update={update} />} {step === 1 && <StepBasics form={form} update={update} />}
@@ -93,32 +104,28 @@ export function ProjectWizard() {
</div> </div>
<div className="mt-8 flex items-center justify-between"> <div className="mt-8 flex items-center justify-between">
<button <Button onClick={goBack} disabled={step === 1} variant="secondary">
type="button" <ArrowLeft className="h-4 w-4" aria-hidden="true" />
onClick={goBack}
disabled={step === 1} </Button>
className="rounded border border-line px-4 py-2 text-sm text-ink disabled:opacity-40"
>
</button>
{isLast ? ( {isLast ? (
<button <Button
type="button"
onClick={submit} onClick={submit}
disabled={submitting || !canSubmit(form)} disabled={submitting || !canSubmit(form)}
className="rounded bg-cinnabar px-5 py-2 text-sm text-panel disabled:opacity-40" variant="primary"
> >
{submitting ? "创建中…" : "完成立项 →"} <CheckCircle2 className="h-4 w-4" aria-hidden="true" />
</button> {submitting ? "创建中…" : "完成立项"}
</Button>
) : ( ) : (
<button <Button
type="button"
onClick={goNext} onClick={goNext}
disabled={!canAdvance(step, form)} disabled={!canAdvance(step, form)}
className="rounded bg-cinnabar px-5 py-2 text-sm text-panel disabled:opacity-40" variant="primary"
> >
</button> <ArrowRight className="h-4 w-4" aria-hidden="true" />
</Button>
)} )}
</div> </div>
</div> </div>
@@ -131,7 +138,7 @@ function StepDots({ current }: { current: number }) {
{Array.from({ length: WIZARD_STEPS }, (_, i) => ( {Array.from({ length: WIZARD_STEPS }, (_, i) => (
<span <span
key={i} key={i}
className={`h-2 w-2 rounded-full ${ className={`h-2 w-2 rounded ${
i + 1 <= current ? "bg-cinnabar" : "bg-line" i + 1 <= current ? "bg-cinnabar" : "bg-line"
}`} }`}
/> />
@@ -145,30 +152,11 @@ interface StepProps {
update: (patch: Partial<WizardForm>) => void; update: (patch: Partial<WizardForm>) => void;
} }
function Field({
label,
children,
}: {
label: string;
children: React.ReactNode;
}) {
return (
<label className="mb-4 block">
<span className="mb-1 block text-sm text-ink-soft">{label}</span>
{children}
</label>
);
}
const inputCls =
"w-full rounded border border-line bg-bg px-3 py-2 text-ink focus:border-cinnabar focus:outline-none";
function StepBasics({ form, update }: StepProps) { function StepBasics({ form, update }: StepProps) {
return ( return (
<div> <div>
<Field label="书名(必填)"> <Field label="书名(必填)">
<input <TextInput
className={inputCls}
value={form.title} value={form.title}
onChange={(e) => update({ title: e.target.value })} onChange={(e) => update({ title: e.target.value })}
placeholder="例:逐光而行" placeholder="例:逐光而行"
@@ -176,8 +164,7 @@ function StepBasics({ form, update }: StepProps) {
/> />
</Field> </Field>
<Field label="题材"> <Field label="题材">
<select <Select
className={inputCls}
value={form.genre} value={form.genre}
onChange={(e) => update({ genre: e.target.value })} onChange={(e) => update({ genre: e.target.value })}
> >
@@ -187,7 +174,7 @@ function StepBasics({ form, update }: StepProps) {
{g} {g}
</option> </option>
))} ))}
</select> </Select>
</Field> </Field>
</div> </div>
); );
@@ -201,8 +188,8 @@ function StepStory({
return ( return (
<div> <div>
<Field label="一句话故事logline"> <Field label="一句话故事logline">
<textarea <TextArea
className={`${inputCls} h-20 resize-none`} className="h-20 resize-none"
value={form.logline} value={form.logline}
onChange={(e) => update({ logline: e.target.value })} onChange={(e) => update({ logline: e.target.value })}
placeholder="废柴少年觉醒禁忌血脉,在仙门倾轧中逆势封神。" placeholder="废柴少年觉醒禁忌血脉,在仙门倾轧中逆势封神。"
@@ -215,11 +202,10 @@ function StepStory({
type="button" type="button"
key={s} key={s}
onClick={() => update({ structure: s })} onClick={() => update({ structure: s })}
className={`rounded border px-3 py-1.5 text-sm ${ className={buttonClass({
form.structure === s variant: form.structure === s ? "outline" : "secondary",
? "border-cinnabar bg-[var(--color-cinnabar-wash)] text-cinnabar" size: "sm",
: "border-line text-ink" })}
}`}
> >
{s} {s}
</button> </button>
@@ -235,11 +221,12 @@ function StepStory({
key={p} key={p}
aria-pressed={form.sellingPoints.includes(p)} aria-pressed={form.sellingPoints.includes(p)}
onClick={() => toggleSellingPoint(p)} onClick={() => toggleSellingPoint(p)}
className={`rounded border px-3 py-1.5 text-sm ${ className={buttonClass({
form.sellingPoints.includes(p) variant: form.sellingPoints.includes(p)
? "border-cinnabar bg-[var(--color-cinnabar-wash)] text-cinnabar" ? "outline"
: "border-line text-ink" : "secondary",
}`} size: "sm",
})}
> >
{p} {p}
</button> </button>
@@ -254,16 +241,15 @@ function StepPremise({ form, update }: StepProps) {
return ( return (
<div> <div>
<Field label="立意premise"> <Field label="立意premise">
<textarea <TextArea
className={`${inputCls} h-20 resize-none`} className="h-20 resize-none"
value={form.premise} value={form.premise}
onChange={(e) => update({ premise: e.target.value })} onChange={(e) => update({ premise: e.target.value })}
placeholder="故事的核心命题与前提。" placeholder="故事的核心命题与前提。"
/> />
</Field> </Field>
<Field label="主题theme"> <Field label="主题theme">
<input <TextInput
className={inputCls}
value={form.theme} value={form.theme}
onChange={(e) => update({ theme: e.target.value })} onChange={(e) => update({ theme: e.target.value })}
placeholder="例:抗争与代价" placeholder="例:抗争与代价"
@@ -282,8 +268,8 @@ function StepProtagonist({ form, update }: StepProps) {
M1 M1
</p> </p>
<Field label="主角 / 金手指概要"> <Field label="主角 / 金手指概要">
<textarea <TextArea
className={`${inputCls} h-28 resize-none`} className="h-28 resize-none"
value={form.protagonist} value={form.protagonist}
onChange={(e) => update({ protagonist: e.target.value })} onChange={(e) => update({ protagonist: e.target.value })}
placeholder="主角设定、金手指来源与限制……" placeholder="主角设定、金手指来源与限制……"
@@ -295,7 +281,7 @@ function StepProtagonist({ form, update }: StepProps) {
function StepWorld() { function StepWorld() {
return ( return (
<div className="rounded border border-dashed border-line bg-bg p-6 text-sm text-ink-soft"> <div className="rounded border border-dashed border-line bg-bg p-6 text-sm leading-6 text-ink-soft">
</div> </div>
); );

View File

@@ -0,0 +1,10 @@
import { themeBootstrapScript } from "@/lib/ui/theme";
export function ThemeScript() {
return (
<script
dangerouslySetInnerHTML={{ __html: themeBootstrapScript() }}
suppressHydrationWarning
/>
);
}

View File

@@ -0,0 +1,70 @@
"use client";
import { useEffect, useState } from "react";
import { Moon, Sun } from "lucide-react";
import { Button } from "@/components/ui/Button";
import {
DEFAULT_THEME_MODE,
THEME_STORAGE_KEY,
nextThemeMode,
normalizeThemeMode,
themeModeLabel,
themeToggleLabel,
type ThemeMode,
} from "@/lib/ui/theme";
function applyThemeMode(mode: ThemeMode) {
document.documentElement.dataset.theme = mode;
}
function readStoredThemeMode(): ThemeMode {
try {
return normalizeThemeMode(window.localStorage.getItem(THEME_STORAGE_KEY));
} catch {
return DEFAULT_THEME_MODE;
}
}
function writeStoredThemeMode(mode: ThemeMode) {
try {
window.localStorage.setItem(THEME_STORAGE_KEY, mode);
} catch {
// 存储不可用时仍允许本次页面会话切换主题。
}
}
export function ThemeToggle() {
const [mode, setMode] = useState<ThemeMode>(DEFAULT_THEME_MODE);
useEffect(() => {
const initial = readStoredThemeMode();
setMode(initial);
applyThemeMode(initial);
}, []);
const toggle = () => {
setMode((current) => {
const next = nextThemeMode(current);
writeStoredThemeMode(next);
applyThemeMode(next);
return next;
});
};
const Icon = mode === "night" ? Sun : Moon;
return (
<Button
onClick={toggle}
variant="ghost"
size="icon"
aria-label={themeToggleLabel(mode)}
title={themeToggleLabel(mode)}
className="border-transparent"
>
<Icon className="h-4 w-4" aria-hidden="true" />
<span className="sr-only">{themeModeLabel(mode)}</span>
</Button>
);
}

View File

@@ -143,6 +143,7 @@ export function ChainAdjudication({
<ConflictCard <ConflictCard
key={i} key={i}
index={i} index={i}
total={conflicts.length}
conflict={conflict} conflict={conflict}
draft={draft} draft={draft}
missing={false} missing={false}

View File

@@ -1,7 +1,12 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { Play } from "lucide-react";
import { Button } from "@/components/ui/Button";
import { Field } from "@/components/ui/Field";
import { SectionHeader } from "@/components/ui/SectionHeader";
import { TextInput } from "@/components/ui/TextInput";
import { CHAIN_KINDS, type ChainKind } from "@/lib/chain/chain"; import { CHAIN_KINDS, type ChainKind } from "@/lib/chain/chain";
interface ChainStarterProps { interface ChainStarterProps {
@@ -41,12 +46,10 @@ export function ChainStarter({ onStart, disabled }: ChainStarterProps) {
if (valid && !disabled) onStart(chainKey, startNo, countNo); if (valid && !disabled) onStart(chainKey, startNo, countNo);
}} }}
> >
<div> <SectionHeader
<h2 className="font-serif text-lg text-ink"></h2> title="连续写多章"
<p className="mt-1 text-sm text-ink-soft"> description="从指定章起循环「写章 → 四审 → 验收」;遇未决冲突会暂停等你裁决再续跑。"
/>
</p>
</div>
<fieldset className="flex flex-col gap-2"> <fieldset className="flex flex-col gap-2">
<legend className="text-sm text-ink-soft"></legend> <legend className="text-sm text-ink-soft"></legend>
<div className="flex flex-wrap gap-4"> <div className="flex flex-wrap gap-4">
@@ -75,37 +78,35 @@ export function ChainStarter({ onStart, disabled }: ChainStarterProps) {
</div> </div>
</fieldset> </fieldset>
<div className="flex flex-wrap gap-4"> <div className="flex flex-wrap gap-4">
<label className="block text-sm text-ink-soft"> <Field label="起始章号" className="w-32">
<TextInput
<input
type="number" type="number"
min={1} min={1}
value={start} value={start}
onChange={(e) => setStart(e.target.value)} onChange={(e) => setStart(e.target.value)}
className="mt-1 w-32 rounded border border-line bg-bg px-3 py-2 text-sm text-ink"
aria-label="起始章号" aria-label="起始章号"
/> />
</label> </Field>
<label className="block text-sm text-ink-soft"> <Field label={`连续章数1..${MAX_COUNT}`} className="w-40">
1..{MAX_COUNT} <TextInput
<input
type="number" type="number"
min={1} min={1}
max={MAX_COUNT} max={MAX_COUNT}
value={count} value={count}
onChange={(e) => setCount(e.target.value)} onChange={(e) => setCount(e.target.value)}
className="mt-1 w-32 rounded border border-line bg-bg px-3 py-2 text-sm text-ink"
aria-label="连续章数" aria-label="连续章数"
/> />
</label> </Field>
</div> </div>
<button <Button
type="submit" type="submit"
disabled={disabled || !valid} disabled={disabled || !valid}
className="self-start rounded bg-cinnabar px-4 py-2 text-sm text-white disabled:opacity-50" variant="primary"
className="self-start"
> >
<Play className="h-4 w-4" aria-hidden="true" />
{disabled ? "运行中…" : "发起多章链"} {disabled ? "运行中…" : "发起多章链"}
</button> </Button>
</form> </form>
); );
} }

View File

@@ -1,10 +1,16 @@
"use client"; "use client";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { Clock3, Globe2, Sparkles, UserRound } from "lucide-react";
import { AppShell } from "@/components/AppShell"; import { AppShell } from "@/components/AppShell";
import { CharacterGenerator } from "@/components/generation/CharacterGenerator"; import { CharacterGenerator } from "@/components/generation/CharacterGenerator";
import { WorldGenerator } from "@/components/generation/WorldGenerator"; import { WorldGenerator } from "@/components/generation/WorldGenerator";
import { Badge } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { EmptyState } from "@/components/ui/EmptyState";
import { PageHeader } from "@/components/ui/PageHeader";
import type { import type {
CharacterCardView, CharacterCardView,
ProjectResponse, ProjectResponse,
@@ -60,6 +66,10 @@ export function CodexPage({
activeNav="codex" activeNav="codex"
> >
<div className="flex h-[calc(100vh-var(--chrome,4rem))] flex-col p-4"> <div className="flex h-[calc(100vh-var(--chrome,4rem))] flex-col p-4">
<PageHeader
title="设定库"
description="人物、世界观与时间线共同构成写作时的真相源。AI 生成内容也要先预览、再确认入库。"
/>
<div className="mb-4 flex items-center gap-2" role="tablist"> <div className="mb-4 flex items-center gap-2" role="tablist">
{TABS.map((t) => ( {TABS.map((t) => (
<button <button
@@ -82,20 +92,28 @@ export function CodexPage({
<div className="min-h-0 flex-1 overflow-auto"> <div className="min-h-0 flex-1 overflow-auto">
{tab === "characters" ? ( {tab === "characters" ? (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<section className="rounded border border-line bg-panel p-3"> <Card as="section" className="p-4">
<h3 className="mb-2 font-serif text-sm text-ink"> <h3 className="mb-3 font-serif text-sm text-ink">
{characters.length} {characters.length}
</h3> </h3>
{characters.length > 0 ? ( {characters.length > 0 ? (
<ul className="flex flex-col gap-2"> <ul className="grid grid-cols-1 gap-3 lg:grid-cols-2">
{characters.map((c, i) => ( {characters.map((c, i) => (
<li <li
key={`${c.name}-${i}`} key={`${c.name}-${i}`}
className="rounded bg-bg px-2 py-1.5 text-xs text-ink-soft" className="rounded border border-line bg-bg p-3 text-sm"
> >
<span className="text-ink"> <div className="mb-2 flex items-center gap-2">
{c.name}{c.role} <span className="flex h-8 w-8 items-center justify-center rounded bg-[var(--color-cinnabar-wash)] text-cinnabar">
<UserRound className="h-4 w-4" aria-hidden="true" />
</span> </span>
<div className="min-w-0">
<p className="truncate font-serif text-base text-ink">
{c.name}
</p>
<p className="text-xs text-ink-soft">{c.role}</p>
</div>
</div>
{c.relations && c.relations.length > 0 ? ( {c.relations && c.relations.length > 0 ? (
<ul className="mt-1 flex flex-wrap gap-1"> <ul className="mt-1 flex flex-wrap gap-1">
{c.relations.map((r, j) => ( {c.relations.map((r, j) => (
@@ -113,11 +131,29 @@ export function CodexPage({
))} ))}
</ul> </ul>
) : ( ) : (
<p className="text-xs text-ink-soft"> <EmptyState
icon={UserRound}
</p> title="暂无入库人物"
description="先生成或手动整理主角、配角与关系,再让写作上下文稳定引用。"
action={
<Button
onClick={() =>
document
.getElementById("character-generator")
?.scrollIntoView({ block: "center" })
}
variant="primary"
size="sm"
>
<Sparkles className="h-4 w-4" aria-hidden="true" />
</Button>
}
className="bg-bg/50"
/>
)} )}
</section> </Card>
<div id="character-generator">
<CharacterGenerator <CharacterGenerator
projectId={project.id} projectId={project.id}
onIngested={(_, cards) => onIngested={(_, cards) =>
@@ -125,12 +161,13 @@ export function CodexPage({
} }
/> />
</div> </div>
</div>
) : null} ) : null}
{tab === "world" ? ( {tab === "world" ? (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<section className="rounded border border-line bg-panel p-3"> <Card as="section" className="p-4">
<h3 className="mb-2 font-serif text-sm text-ink"> <h3 className="mb-3 font-serif text-sm text-ink">
{initialWorldEntities.length} {initialWorldEntities.length}
</h3> </h3>
{initialWorldEntities.length > 0 ? ( {initialWorldEntities.length > 0 ? (
@@ -141,12 +178,16 @@ export function CodexPage({
className="rounded border border-line bg-bg p-3 text-sm" className="rounded border border-line bg-bg p-3 text-sm"
> >
<header className="mb-2 flex items-center gap-2"> <header className="mb-2 flex items-center gap-2">
<Globe2
className="h-4 w-4 text-cinnabar"
aria-hidden="true"
/>
<span className="font-serif text-base text-ink"> <span className="font-serif text-base text-ink">
{entity.name} {entity.name}
</span> </span>
<span className="rounded bg-panel px-2 py-0.5 text-xs text-ink-soft"> <Badge>
{entity.type} {entity.type}
</span> </Badge>
</header> </header>
{worldEntityRules(entity).length > 0 ? ( {worldEntityRules(entity).length > 0 ? (
<ul className="flex list-disc flex-col gap-1 pl-5 text-ink-soft"> <ul className="flex list-disc flex-col gap-1 pl-5 text-ink-soft">
@@ -161,19 +202,40 @@ export function CodexPage({
))} ))}
</div> </div>
) : ( ) : (
<p className="text-xs text-ink-soft"> <EmptyState
icon={Globe2}
</p> title="暂无入库世界观"
description="先沉淀硬规则、地理、势力与能力边界,减少后续章节设定漂移。"
action={
<Button
onClick={() =>
document
.getElementById("world-generator")
?.scrollIntoView({ block: "center" })
}
variant="primary"
size="sm"
>
<Sparkles className="h-4 w-4" aria-hidden="true" />
</Button>
}
className="bg-bg/50"
/>
)} )}
</section> </Card>
<div id="world-generator">
<WorldGenerator projectId={project.id} /> <WorldGenerator projectId={project.id} />
</div> </div>
</div>
) : null} ) : null}
{tab === "timeline" ? ( {tab === "timeline" ? (
<div className="rounded border border-dashed border-line bg-panel p-6 text-center text-sm text-ink-soft"> <EmptyState
线P2 + icon={Clock3}
</div> title="时间线尚未启用"
description="时间线会从章节摘要、伏笔窗口和验收记录派生,后续可用于全书一致性扫描。"
/>
) : null} ) : null}
</div> </div>
</div> </div>

View File

@@ -1,14 +1,22 @@
"use client"; "use client";
import { useMemo } from "react"; import { useMemo, useState } from "react";
import { Flag, Plus } from "lucide-react";
import { AppShell } from "@/components/AppShell"; import { AppShell } from "@/components/AppShell";
import { Badge } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button";
import { EmptyState } from "@/components/ui/EmptyState";
import { PageHeader } from "@/components/ui/PageHeader";
import type { ForeshadowView, ProjectResponse } from "@/lib/api/types"; import type { ForeshadowView, ProjectResponse } from "@/lib/api/types";
import { import {
LANES, LANES,
LANE_LABELS,
countByStatus,
groupByStatus, groupByStatus,
type ForeshadowStatus, type ForeshadowStatus,
} from "@/lib/foreshadow/board"; } from "@/lib/foreshadow/board";
import type { BadgeVariant } from "@/lib/ui/variants";
import { useForeshadow } from "@/lib/foreshadow/useForeshadow"; import { useForeshadow } from "@/lib/foreshadow/useForeshadow";
import { KanbanColumn } from "./KanbanColumn"; import { KanbanColumn } from "./KanbanColumn";
import { RegisterForm } from "./RegisterForm"; import { RegisterForm } from "./RegisterForm";
@@ -25,7 +33,9 @@ export function ForeshadowBoard({
initialItems, initialItems,
}: ForeshadowBoardProps) { }: ForeshadowBoardProps) {
const { items, busy, register, transition } = useForeshadow(initialItems); const { items, busy, register, transition } = useForeshadow(initialItems);
const [registerOpen, setRegisterOpen] = useState(false);
const lanes = useMemo(() => groupByStatus(items), [items]); const lanes = useMemo(() => groupByStatus(items), [items]);
const counts = useMemo(() => countByStatus(items), [items]);
const onTransition = ( const onTransition = (
code: string, code: string,
@@ -46,16 +56,51 @@ export function ForeshadowBoard({
projectId={project.id} projectId={project.id}
activeNav="foreshadow" activeNav="foreshadow"
> >
<div className="flex h-[calc(100vh-var(--chrome,4rem))] flex-col p-4"> <div className="flex min-h-[calc(100vh-var(--chrome,4rem))] flex-col p-4 xl:h-[calc(100vh-var(--chrome,4rem))] xl:min-h-0">
<div className="mb-4 flex items-start justify-between gap-4"> <PageHeader
<h1 className="font-serif text-lg text-ink"></h1> title="伏笔看板"
description="跟踪每条伏笔从埋设、推进到回收的状态,逾期项会单独提醒。"
actions={
<Button
onClick={() => setRegisterOpen(true)}
disabled={registerOpen}
variant="primary"
size="sm"
>
<Plus className="h-4 w-4" aria-hidden="true" />
</Button>
}
/>
{registerOpen ? (
<div className="mb-4">
<RegisterForm
projectId={project.id}
busy={busy}
onRegister={register}
open={registerOpen}
onOpenChange={setRegisterOpen}
/>
</div>
) : null}
{items.length === 0 ? (
<EmptyState
icon={Flag}
title="还没有伏笔"
description="登记第一条伏笔后,这里会按待推进、推进中、已回收、已逾期四种状态自动分栏。"
action={
<RegisterForm <RegisterForm
projectId={project.id} projectId={project.id}
busy={busy} busy={busy}
onRegister={register} onRegister={register}
/> />
</div> }
<div className="grid min-h-0 flex-1 grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-4"> className="mt-6"
/>
) : (
<>
<ForeshadowBoardSummary counts={counts} />
<div className="grid flex-none grid-cols-1 gap-3 md:grid-cols-2 xl:min-h-0 xl:flex-1 xl:grid-cols-4">
{LANES.map((status) => ( {LANES.map((status) => (
<KanbanColumn <KanbanColumn
key={status} key={status}
@@ -66,7 +111,49 @@ export function ForeshadowBoard({
/> />
))} ))}
</div> </div>
</>
)}
</div> </div>
</AppShell> </AppShell>
); );
} }
const SUMMARY_VARIANTS: Record<ForeshadowStatus, BadgeVariant> = {
OPEN: "accent",
PARTIAL: "info",
CLOSED: "success",
OVERDUE: "warning",
};
function ForeshadowBoardSummary({
counts,
}: {
counts: Record<ForeshadowStatus, number>;
}) {
const total = LANES.reduce((sum, lane) => sum + counts[lane], 0);
return (
<section
aria-label="伏笔状态概览"
className="mb-3 grid grid-cols-2 gap-2 sm:grid-cols-4"
>
{LANES.map((status) => (
<div
key={status}
className="rounded border border-line bg-panel px-3 py-2"
>
<div className="flex items-center justify-between gap-2">
<Badge variant={SUMMARY_VARIANTS[status]}>
{LANE_LABELS[status]}
</Badge>
<span className="font-mono text-sm text-ink">{counts[status]}</span>
</div>
<p className="mt-1 text-[11px] text-ink-soft">
<span className="font-mono">{status}</span>
{" · "}
{total > 0 ? `${Math.round((counts[status] / total) * 100)}%` : "0%"}
</p>
</div>
))}
</section>
);
}

View File

@@ -1,9 +1,13 @@
"use client"; "use client";
import { AlertTriangle } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { Badge } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button";
import { TextInput } from "@/components/ui/TextInput";
import type { ForeshadowView } from "@/lib/api/types"; import type { ForeshadowView } from "@/lib/api/types";
import type { ForeshadowStatus } from "@/lib/foreshadow/board"; import { LANE_LABELS, type ForeshadowStatus } from "@/lib/foreshadow/board";
interface ForeshadowCardProps { interface ForeshadowCardProps {
item: ForeshadowView; item: ForeshadowView;
@@ -44,9 +48,10 @@ export function ForeshadowCard({
<div className="flex items-baseline gap-2"> <div className="flex items-baseline gap-2">
<span className="font-mono text-xs text-cinnabar">{item.code}</span> <span className="font-mono text-xs text-cinnabar">{item.code}</span>
{overdue ? ( {overdue ? (
<span className="text-xs text-overdue" aria-label="逾期"> <Badge variant="warning" aria-label="逾期">
<AlertTriangle className="h-3 w-3" aria-hidden="true" />
</span>
</Badge>
) : null} ) : null}
</div> </div>
<p className="mt-0.5 font-serif text-sm text-ink">{item.title}</p> <p className="mt-0.5 font-serif text-sm text-ink">{item.title}</p>
@@ -72,15 +77,16 @@ export function ForeshadowCard({
{transitions.length > 0 ? ( {transitions.length > 0 ? (
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{transitions.map((to) => ( {transitions.map((to) => (
<button <Button
key={to} key={to}
type="button"
disabled={busy} disabled={busy}
onClick={() => onTransition(item.code, to, "")} onClick={() => onTransition(item.code, to, "")}
className="rounded border border-line px-2 py-0.5 text-xs text-ink hover:border-cinnabar hover:text-cinnabar disabled:opacity-40" variant="secondary"
size="sm"
className="px-2 py-0.5"
> >
{to === "CLOSED" ? "回收" : to} {to === "CLOSED" ? "回收" : LANE_LABELS[to]}
</button> </Button>
))} ))}
</div> </div>
) : null} ) : null}
@@ -89,25 +95,27 @@ export function ForeshadowCard({
<label htmlFor={`prog-${item.code}`} className="sr-only"> <label htmlFor={`prog-${item.code}`} className="sr-only">
{item.code} {item.code}
</label> </label>
<input <TextInput
id={`prog-${item.code}`} id={`prog-${item.code}`}
type="text" type="text"
value={note} value={note}
onChange={(e) => setNote(e.target.value)} onChange={(e) => setNote(e.target.value)}
placeholder="加进展23章发光" placeholder="加进展23章发光"
className="min-w-0 flex-1 rounded border border-line bg-bg px-2 py-0.5 text-xs text-ink focus:border-cinnabar focus:outline-none" controlSize="sm"
className="min-w-0 flex-1 py-0.5"
/> />
<button <Button
type="button"
disabled={busy || note.trim().length === 0} disabled={busy || note.trim().length === 0}
onClick={() => { onClick={() => {
onTransition(item.code, null, note.trim()); onTransition(item.code, null, note.trim());
setNote(""); setNote("");
}} }}
className="rounded border border-line px-2 py-0.5 text-xs text-ink hover:border-cinnabar disabled:opacity-40" variant="secondary"
size="sm"
className="px-2 py-0.5"
> >
</button> </Button>
</div> </div>
) : null} ) : null}
</div> </div>

View File

@@ -1,7 +1,8 @@
"use client"; "use client";
import type { ForeshadowView } from "@/lib/api/types"; import type { ForeshadowView } from "@/lib/api/types";
import type { ForeshadowStatus } from "@/lib/foreshadow/board"; import { LANE_LABELS, type ForeshadowStatus } from "@/lib/foreshadow/board";
import { Badge } from "@/components/ui/Badge";
import { ForeshadowCard } from "./ForeshadowCard"; import { ForeshadowCard } from "./ForeshadowCard";
interface KanbanColumnProps { interface KanbanColumnProps {
@@ -31,17 +32,23 @@ export function KanbanColumn({
<header <header
className={`flex items-center justify-between rounded-t border-b px-3 py-2 ${ className={`flex items-center justify-between rounded-t border-b px-3 py-2 ${
overdue overdue
? "border-overdue bg-overdue/10 text-overdue" ? "border-overdue bg-overdue/10 text-ink"
: "border-line bg-panel text-ink-soft" : "border-line bg-panel text-ink-soft"
}`} }`}
> >
<span className="font-mono text-xs font-semibold"> <span className="flex items-center gap-2">
{overdue ? "⚠ " : ""} {overdue ? (
{status} <Badge variant="warning">{LANE_LABELS[status]}</Badge>
) : (
<span className="text-sm font-medium text-ink">
{LANE_LABELS[status]}
</span>
)}
<span className="font-mono text-[10px] text-ink-soft">{status}</span>
</span> </span>
<span className="font-mono text-xs">{items.length}</span> <span className="font-mono text-xs">{items.length}</span>
</header> </header>
<ul className="flex-1 space-y-2 overflow-auto p-2"> <ul className="space-y-2 p-2 xl:flex-1 xl:overflow-auto">
{items.length === 0 ? ( {items.length === 0 ? (
<li className="px-1 py-3 text-xs text-ink-soft/60"></li> <li className="px-1 py-3 text-xs text-ink-soft/60"></li>
) : ( ) : (

View File

@@ -1,13 +1,18 @@
"use client"; "use client";
import { useState } from "react"; import { useState, type ReactNode } from "react";
import { Plus } from "lucide-react";
import { Button } from "@/components/ui/Button";
import { TextInput } from "@/components/ui/TextInput";
import type { RegisterInput } from "@/lib/foreshadow/board"; import type { RegisterInput } from "@/lib/foreshadow/board";
interface RegisterFormProps { interface RegisterFormProps {
projectId: string; projectId: string;
busy: boolean; busy: boolean;
onRegister: (input: RegisterInput) => Promise<boolean>; onRegister: (input: RegisterInput) => Promise<boolean>;
open?: boolean;
onOpenChange?: (open: boolean) => void;
} }
const EMPTY = { const EMPTY = {
@@ -25,9 +30,16 @@ export function RegisterForm({
projectId, projectId,
busy, busy,
onRegister, onRegister,
open: controlledOpen,
onOpenChange,
}: RegisterFormProps) { }: RegisterFormProps) {
const [open, setOpen] = useState(false); const [localOpen, setLocalOpen] = useState(false);
const [form, setForm] = useState({ ...EMPTY }); const [form, setForm] = useState({ ...EMPTY });
const open = controlledOpen ?? localOpen;
const setOpen = (next: boolean): void => {
if (controlledOpen === undefined) setLocalOpen(next);
onOpenChange?.(next);
};
const set = (key: keyof typeof EMPTY, value: string): void => const set = (key: keyof typeof EMPTY, value: string): void =>
setForm((prev) => ({ ...prev, [key]: value })); setForm((prev) => ({ ...prev, [key]: value }));
@@ -51,13 +63,10 @@ export function RegisterForm({
if (!open) { if (!open) {
return ( return (
<button <Button onClick={() => setOpen(true)} variant="primary" size="sm">
type="button" <Plus className="h-4 w-4" aria-hidden="true" />
onClick={() => setOpen(true)}
className="rounded bg-cinnabar px-3 py-1.5 text-sm text-panel hover:opacity-90" </Button>
>
</button>
); );
} }
@@ -73,99 +82,90 @@ export function RegisterForm({
className="rounded border border-line bg-panel p-4" className="rounded border border-line bg-panel p-4"
> >
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3"> <div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
<Field label="代号 *" id="f-code"> <FormSlot label="代号 *" id="f-code">
<input <TextInput
id="f-code" id="f-code"
value={form.code} value={form.code}
onChange={(e) => set("code", e.target.value)} onChange={(e) => set("code", e.target.value)}
className={inputCls}
/> />
</Field> </FormSlot>
<Field label="标题 *" id="f-title" span2> <FormSlot label="标题 *" id="f-title" span2>
<input <TextInput
id="f-title" id="f-title"
value={form.title} value={form.title}
onChange={(e) => set("title", e.target.value)} onChange={(e) => set("title", e.target.value)}
className={inputCls}
/> />
</Field> </FormSlot>
<Field label="埋设章" id="f-planted"> <FormSlot label="埋设章" id="f-planted">
<input <TextInput
id="f-planted" id="f-planted"
inputMode="numeric" inputMode="numeric"
value={form.plantedAt} value={form.plantedAt}
onChange={(e) => set("plantedAt", e.target.value)} onChange={(e) => set("plantedAt", e.target.value)}
className={inputCls}
/> />
</Field> </FormSlot>
<Field label="回收窗口起" id="f-from"> <FormSlot label="回收窗口起" id="f-from">
<input <TextInput
id="f-from" id="f-from"
inputMode="numeric" inputMode="numeric"
value={form.expectedCloseFrom} value={form.expectedCloseFrom}
onChange={(e) => set("expectedCloseFrom", e.target.value)} onChange={(e) => set("expectedCloseFrom", e.target.value)}
className={inputCls}
/> />
</Field> </FormSlot>
<Field label="回收窗口止" id="f-to"> <FormSlot label="回收窗口止" id="f-to">
<input <TextInput
id="f-to" id="f-to"
inputMode="numeric" inputMode="numeric"
value={form.expectedCloseTo} value={form.expectedCloseTo}
onChange={(e) => set("expectedCloseTo", e.target.value)} onChange={(e) => set("expectedCloseTo", e.target.value)}
className={inputCls}
/> />
</Field> </FormSlot>
<Field label="重要度" id="f-imp"> <FormSlot label="重要度" id="f-imp">
<input <TextInput
id="f-imp" id="f-imp"
value={form.importance} value={form.importance}
onChange={(e) => set("importance", e.target.value)} onChange={(e) => set("importance", e.target.value)}
placeholder="主线/支线" placeholder="主线/支线"
className={inputCls}
/> />
</Field> </FormSlot>
<Field label="线索内容" id="f-content" span3> <FormSlot label="线索内容" id="f-content" span3>
<input <TextInput
id="f-content" id="f-content"
value={form.content} value={form.content}
onChange={(e) => set("content", e.target.value)} onChange={(e) => set("content", e.target.value)}
className={inputCls}
/> />
</Field> </FormSlot>
</div> </div>
<div className="mt-3 flex items-center gap-2"> <div className="mt-3 flex items-center gap-2">
<button <Button
type="submit" type="submit"
disabled={!canSubmit} disabled={!canSubmit}
className="rounded bg-cinnabar px-3 py-1.5 text-sm text-panel hover:opacity-90 disabled:opacity-40" variant="primary"
size="sm"
> >
{busy ? "登记中…" : "登记"} {busy ? "登记中…" : "登记"}
</button> </Button>
<button <Button
type="button"
onClick={() => setOpen(false)} onClick={() => setOpen(false)}
className="rounded border border-line px-3 py-1.5 text-sm text-ink-soft hover:border-cinnabar" variant="secondary"
size="sm"
> >
</button> </Button>
</div> </div>
</form> </form>
); );
} }
const inputCls = interface FormSlotProps {
"w-full rounded border border-line bg-bg px-2 py-1 text-sm text-ink focus:border-cinnabar focus:outline-none";
interface FieldProps {
label: string; label: string;
id: string; id: string;
span2?: boolean; span2?: boolean;
span3?: boolean; span3?: boolean;
children: React.ReactNode; children: ReactNode;
} }
function Field({ label, id, span2, span3, children }: FieldProps) { function FormSlot({ label, id, span2, span3, children }: FormSlotProps) {
const span = span3 ? "col-span-2 sm:col-span-3" : span2 ? "col-span-2" : ""; const span = span3 ? "col-span-2 sm:col-span-3" : span2 ? "col-span-2" : "";
return ( return (
<div className={span}> <div className={span}>

View File

@@ -1,5 +1,6 @@
"use client"; "use client";
import { TextInput } from "@/components/ui/TextInput";
import type { CharacterCardView } from "@/lib/api/types"; import type { CharacterCardView } from "@/lib/api/types";
interface CharacterCardItemProps { interface CharacterCardItemProps {
@@ -32,11 +33,12 @@ export function CharacterCardItem({
className="size-4 accent-cinnabar" className="size-4 accent-cinnabar"
/> />
{onEdit ? ( {onEdit ? (
<input <TextInput
value={card.name} value={card.name}
onChange={(e) => onEdit({ name: e.target.value })} onChange={(e) => onEdit({ name: e.target.value })}
aria-label="角色名" aria-label="角色名"
className="flex-1 rounded border border-line bg-bg px-2 py-1 font-serif text-base text-ink" controlSize="sm"
className="min-w-0 flex-1 font-serif text-base"
/> />
) : ( ) : (
<span className="flex-1 font-serif text-base text-ink">{card.name}</span> <span className="flex-1 font-serif text-base text-ink">{card.name}</span>
@@ -61,11 +63,12 @@ export function CharacterCardItem({
<div className="mb-1 flex items-start gap-1 text-ink-soft"> <div className="mb-1 flex items-start gap-1 text-ink-soft">
<span className="shrink-0 text-ink"></span> <span className="shrink-0 text-ink"></span>
{onEdit ? ( {onEdit ? (
<input <TextInput
value={card.arc} value={card.arc}
onChange={(e) => onEdit({ arc: e.target.value })} onChange={(e) => onEdit({ arc: e.target.value })}
aria-label="人物弧光" aria-label="人物弧光"
className="flex-1 rounded border border-line bg-bg px-2 py-1 text-ink" controlSize="sm"
className="min-w-0 flex-1"
/> />
) : ( ) : (
<span>{card.arc}</span> <span>{card.arc}</span>

View File

@@ -1,7 +1,13 @@
"use client"; "use client";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { Database, Sparkles } from "lucide-react";
import { Button } from "@/components/ui/Button";
import { Field } from "@/components/ui/Field";
import { SectionHeader } from "@/components/ui/SectionHeader";
import { StatusNote } from "@/components/ui/StatusNote";
import { TextInput } from "@/components/ui/TextInput";
import type { CharacterCardView } from "@/lib/api/types"; import type { CharacterCardView } from "@/lib/api/types";
import { import {
MAX_CHARACTER_COUNT, MAX_CHARACTER_COUNT,
@@ -80,45 +86,39 @@ export function CharacterGenerator({
return ( return (
<section className="flex flex-col gap-4" aria-label="角色生成器"> <section className="flex flex-col gap-4" aria-label="角色生成器">
<div className="rounded border border-line bg-panel p-4"> <div className="rounded border border-line bg-panel p-4">
<h2 className="mb-3 font-serif text-base text-ink"></h2> <SectionHeader title="角色生成器" className="mb-3" />
<label className="mb-2 block text-sm text-ink-soft"> <Field label="一句话需求" className="mb-3">
<TextInput
<input
value={brief} value={brief}
onChange={(e) => setBrief(e.target.value)} onChange={(e) => setBrief(e.target.value)}
placeholder="如:一个亦正亦邪的剑修,背负灭门血仇" placeholder="如:一个亦正亦邪的剑修,背负灭门血仇"
className="mt-1 w-full rounded border border-line bg-bg px-3 py-2 text-sm text-ink"
/> />
</label> </Field>
<div className="flex flex-wrap items-end gap-3"> <div className="flex flex-wrap items-end gap-3">
<label className="text-sm text-ink-soft"> <Field label="数量" className="w-24">
<TextInput
<input
type="number" type="number"
min={MIN_CHARACTER_COUNT} min={MIN_CHARACTER_COUNT}
max={MAX_CHARACTER_COUNT} max={MAX_CHARACTER_COUNT}
value={count} value={count}
onChange={(e) => setCount(Number(e.target.value))} onChange={(e) => setCount(Number(e.target.value))}
className="mt-1 block w-20 rounded border border-line bg-bg px-2 py-1.5 text-sm text-ink"
/> />
</label> </Field>
<label className="flex-1 text-sm text-ink-soft"> <Field label="定位(可选)" className="min-w-[12rem] flex-1">
<TextInput
<input
value={role} value={role}
onChange={(e) => setRole(e.target.value)} onChange={(e) => setRole(e.target.value)}
placeholder="主角 / CP / 对手 / 导师 / 工具人" placeholder="主角 / CP / 对手 / 导师 / 工具人"
className="mt-1 block w-full rounded border border-line bg-bg px-2 py-1.5 text-sm text-ink"
/> />
</label> </Field>
<button <Button
type="button"
onClick={() => void onGenerate()} onClick={() => void onGenerate()}
disabled={generating || brief.trim().length === 0} disabled={generating || brief.trim().length === 0}
className="rounded bg-cinnabar px-4 py-2 text-sm text-white disabled:opacity-50" variant="primary"
> >
<Sparkles className="h-4 w-4" aria-hidden="true" />
{generating ? "生成中…" : "生成"} {generating ? "生成中…" : "生成"}
</button> </Button>
</div> </div>
</div> </div>
@@ -151,20 +151,20 @@ export function CharacterGenerator({
onCancel={() => gen.reset()} onCancel={() => gen.reset()}
/> />
) : ( ) : (
<button <Button
type="button"
onClick={() => void doIngest(false)} onClick={() => void doIngest(false)}
disabled={ingesting || selectedCards.length === 0} disabled={ingesting || selectedCards.length === 0}
className="self-start rounded bg-cinnabar px-4 py-2 text-sm text-white disabled:opacity-50" variant="primary"
> >
<Database className="h-4 w-4" aria-hidden="true" />
{ingesting ? "入库中…" : `入库选中 ${selectedCards.length}`} {ingesting ? "入库中…" : `入库选中 ${selectedCards.length}`}
</button> </Button>
)} )}
</div> </div>
) : null} ) : null}
{gen.ingestStatus === "done" && gen.created.length > 0 ? ( {gen.ingestStatus === "done" && gen.created.length > 0 ? (
<p className="text-sm text-pass">{gen.created.join("、")}</p> <StatusNote variant="success">{gen.created.join("、")}</StatusNote>
) : null} ) : null}
</section> </section>
); );

View File

@@ -1,7 +1,12 @@
"use client"; "use client";
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { Sparkles } from "lucide-react";
import { Button } from "@/components/ui/Button";
import { Field } from "@/components/ui/Field";
import { SectionHeader } from "@/components/ui/SectionHeader";
import { TextArea } from "@/components/ui/TextArea";
import { worldEntityRules } from "@/lib/generation/cards"; import { worldEntityRules } from "@/lib/generation/cards";
import { useWorldGen } from "@/lib/generation/useWorldGen"; import { useWorldGen } from "@/lib/generation/useWorldGen";
@@ -24,25 +29,23 @@ export function WorldGenerator({ projectId }: WorldGeneratorProps) {
return ( return (
<section className="flex flex-col gap-4" aria-label="世界观设计器"> <section className="flex flex-col gap-4" aria-label="世界观设计器">
<div className="rounded border border-line bg-panel p-4"> <div className="rounded border border-line bg-panel p-4">
<h2 className="mb-3 font-serif text-base text-ink"></h2> <SectionHeader title="世界观设计器" className="mb-3" />
<label className="mb-3 block text-sm text-ink-soft"> <Field label="设定方向" className="mb-3">
<TextArea
<textarea
value={brief} value={brief}
onChange={(e) => setBrief(e.target.value)} onChange={(e) => setBrief(e.target.value)}
placeholder="如:东方修真世界,灵气复苏,宗门林立,强者寿元有限" placeholder="如:东方修真世界,灵气复苏,宗门林立,强者寿元有限"
rows={3} rows={3}
className="mt-1 w-full resize-y rounded border border-line bg-bg px-3 py-2 text-sm text-ink"
/> />
</label> </Field>
<button <Button
type="button"
onClick={() => void onGenerate()} onClick={() => void onGenerate()}
disabled={generating || brief.trim().length === 0} disabled={generating || brief.trim().length === 0}
className="rounded bg-cinnabar px-4 py-2 text-sm text-white disabled:opacity-50" variant="primary"
> >
<Sparkles className="h-4 w-4" aria-hidden="true" />
{generating ? "生成中…" : "生成世界观"} {generating ? "生成中…" : "生成世界观"}
</button> </Button>
</div> </div>
{gen.status === "done" && gen.entities.length > 0 ? ( {gen.status === "done" && gen.entities.length > 0 ? (

View File

@@ -1,12 +1,15 @@
"use client"; "use client";
import Link from "next/link"; import Link from "next/link";
import { PenLine } from "lucide-react";
import { Badge } from "@/components/ui/Badge";
import type { OutlineChapterView } from "@/lib/api/types"; import type { OutlineChapterView } from "@/lib/api/types";
import { import {
isCloseWindow, isCloseWindow,
windowBadgeLabel, windowBadgeLabel,
} from "@/lib/outline/outline"; } from "@/lib/outline/outline";
import { buttonClass } from "@/lib/ui/variants";
interface OutlineChapterRowProps { interface OutlineChapterRowProps {
chapter: OutlineChapterView; chapter: OutlineChapterView;
@@ -25,31 +28,28 @@ export function OutlineChapterRow({ chapter, projectId }: OutlineChapterRowProps
</span> </span>
<Link <Link
href={`/projects/${projectId}/write?chapter=${chapter.no}`} href={`/projects/${projectId}/write?chapter=${chapter.no}`}
className="rounded border border-line px-1.5 py-0.5 text-xs text-ink-soft hover:border-cinnabar hover:text-cinnabar" className={buttonClass({ variant: "secondary", size: "sm" })}
> >
<PenLine className="h-3.5 w-3.5" aria-hidden="true" />
</Link> </Link>
{windows.map((w) => { {windows.map((w) => {
const close = isCloseWindow(chapter, w); const close = isCloseWindow(chapter, w);
return ( return (
<span <Badge
key={w.code} key={w.code}
variant={close ? "warning" : "accent"}
title={close ? "进入回收窗口,建议本章安排回收" : undefined} title={close ? "进入回收窗口,建议本章安排回收" : undefined}
className={`rounded px-1.5 py-0.5 text-xs ${
close
? "bg-overdue/15 text-overdue"
: "bg-[var(--color-cinnabar-wash)] text-cinnabar"
}`}
> >
{windowBadgeLabel(w)} {windowBadgeLabel(w)}
{close ? " · 可回收" : ""} {close ? " · 可回收" : ""}
</span> </Badge>
); );
})} })}
</div> </div>
{chapter.beats && chapter.beats.length > 0 ? ( {chapter.beats && chapter.beats.length > 0 ? (
<p className="mt-1 text-sm text-ink"> <p className="mt-1 text-sm text-ink">
<span className="text-ink-soft">beats: </span> <span className="text-ink-soft"></span>
{chapter.beats.join(" / ")} {chapter.beats.join(" / ")}
</p> </p>
) : ( ) : (

View File

@@ -1,8 +1,14 @@
"use client"; "use client";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { ListTree, Sparkles } from "lucide-react";
import { AppShell } from "@/components/AppShell"; import { AppShell } from "@/components/AppShell";
import { Button } from "@/components/ui/Button";
import { EmptyState } from "@/components/ui/EmptyState";
import { PageHeader } from "@/components/ui/PageHeader";
import { Select } from "@/components/ui/Select";
import { TextInput } from "@/components/ui/TextInput";
import type { OutlineChapterView, ProjectResponse } from "@/lib/api/types"; import type { OutlineChapterView, ProjectResponse } from "@/lib/api/types";
import { import {
distinctVolumes, distinctVolumes,
@@ -43,20 +49,29 @@ export function OutlineEditor({
activeNav="outline" activeNav="outline"
> >
<div className="flex h-[calc(100vh-var(--chrome,4rem))] flex-col p-6"> <div className="flex h-[calc(100vh-var(--chrome,4rem))] flex-col p-6">
<div className="mb-4 flex items-center gap-3"> <PageHeader
<h1 className="font-serif text-lg text-ink"></h1> title="大纲"
description="按卷组织章节节拍,写作台会把当前章节拍注入生成上下文。"
actions={
<>
{availableVolumes.length > 0 ? ( {availableVolumes.length > 0 ? (
<label htmlFor="view-vol" className="ml-auto text-xs text-ink-soft"> <label
htmlFor="view-vol"
className="ml-auto text-xs text-ink-soft"
>
<select <Select
id="view-vol" id="view-vol"
value={viewVolume === "all" ? "all" : String(viewVolume)} value={viewVolume === "all" ? "all" : String(viewVolume)}
onChange={(e) => onChange={(e) =>
setViewVolume( setViewVolume(
e.target.value === "all" ? "all" : Number(e.target.value), e.target.value === "all"
? "all"
: Number(e.target.value),
) )
} }
className="ml-1 rounded border border-line bg-bg px-2 py-1 text-sm text-ink focus:border-cinnabar focus:outline-none" controlSize="sm"
className="ml-1"
> >
<option value="all"></option> <option value="all"></option>
{availableVolumes.map((v) => ( {availableVolumes.map((v) => (
@@ -64,7 +79,7 @@ export function OutlineEditor({
{v} {v}
</option> </option>
))} ))}
</select> </Select>
</label> </label>
) : null} ) : null}
<label <label
@@ -73,22 +88,27 @@ export function OutlineEditor({
> >
</label> </label>
<input <TextInput
id="vol" id="vol"
inputMode="numeric" inputMode="numeric"
value={volume} value={volume}
onChange={(e) => setVolume(Math.max(1, Number(e.target.value) || 1))} onChange={(e) =>
className="w-16 rounded border border-line bg-bg px-2 py-1 text-sm text-ink focus:border-cinnabar focus:outline-none" setVolume(Math.max(1, Number(e.target.value) || 1))
}
controlSize="sm"
className="w-16"
/> />
<button <Button
type="button"
disabled={generating} disabled={generating}
onClick={() => void generate(project.id, volume)} onClick={() => void generate(project.id, volume)}
className="rounded bg-cinnabar px-3 py-1.5 text-sm text-panel hover:opacity-90 disabled:opacity-40" variant="primary"
> >
{generating ? "排大纲中…" : "✦ AI 排大纲"} <Sparkles className="h-4 w-4" aria-hidden="true" />
</button> {generating ? "排大纲中…" : "AI 排大纲"}
</div> </Button>
</>
}
/>
{error ? ( {error ? (
<p className="mb-4 text-sm text-conflict"> <p className="mb-4 text-sm text-conflict">
@@ -109,11 +129,30 @@ export function OutlineEditor({
<div className="min-h-0 flex-1 overflow-auto"> <div className="min-h-0 flex-1 overflow-auto">
{volumes.length === 0 ? ( {volumes.length === 0 ? (
<p className="rounded border border-dashed border-line p-6 text-sm text-ink-soft"> <EmptyState
{chapters.length > 0 && viewVolume !== "all" icon={ListTree}
? `${viewVolume} 暂无大纲。切到「全部」查看其它卷,或点「✦ AI 排大纲」为本卷生成。` title={
: "暂无大纲。点「✦ AI 排大纲」生成逐章节拍与伏笔窗口。"} chapters.length > 0 && viewVolume !== "all"
</p> ? `${viewVolume} 暂无大纲`
: "暂无大纲"
}
description={
chapters.length > 0 && viewVolume !== "all"
? "切到「全部」查看其它卷,或为当前卷生成章节节拍。"
: "点击「AI 排大纲」生成逐章节拍与伏笔窗口,再回到写作台逐章推进。"
}
action={
<Button
disabled={generating}
onClick={() => void generate(project.id, volume)}
variant="primary"
size="sm"
>
<Sparkles className="h-4 w-4" aria-hidden="true" />
{generating ? "排大纲中…" : "AI 排大纲"}
</Button>
}
/>
) : ( ) : (
volumes.map((group) => ( volumes.map((group) => (
<section key={group.volume} className="mb-6"> <section key={group.volume} className="mb-6">

View File

@@ -0,0 +1,212 @@
"use client";
import Link from "next/link";
import { Clock3, LayoutGrid, List, Plus, Search } from "lucide-react";
import { useEffect, useMemo, useState, type ReactNode } from "react";
import { ProjectCard } from "@/components/ProjectCard";
import { Button } from "@/components/ui/Button";
import { EmptyState } from "@/components/ui/EmptyState";
import { Select } from "@/components/ui/Select";
import { TextInput } from "@/components/ui/TextInput";
import type { ProjectResponse } from "@/lib/api/types";
import {
filterProjects,
type ProjectFilter,
type ProjectSort,
type ProjectViewMode,
} from "@/lib/projects/projects";
import { buttonClass, cn } from "@/lib/ui/variants";
interface ProjectLibraryProps {
projects: ProjectResponse[];
}
const VIEW_STORAGE_KEY = "ww.project_view_mode";
export function ProjectLibrary({ projects }: ProjectLibraryProps) {
const [search, setSearch] = useState("");
const [filter, setFilter] = useState<ProjectFilter>("all");
const [sort, setSort] = useState<ProjectSort>("updated_at");
const [viewMode, setViewMode] = useState<ProjectViewMode>("cards");
useEffect(() => {
const saved = window.localStorage.getItem(VIEW_STORAGE_KEY);
if (saved === "cards" || saved === "compact") setViewMode(saved);
}, []);
const setView = (value: ProjectViewMode): void => {
setViewMode(value);
window.localStorage.setItem(VIEW_STORAGE_KEY, value);
};
const visibleProjects = useMemo(
() => filterProjects(projects, { search, filter, sort }),
[projects, search, filter, sort],
);
return (
<div className="space-y-4">
<div className="grid gap-3 rounded border border-line bg-panel p-3 md:grid-cols-[1fr_auto_auto_auto] md:items-center">
<label className="relative block">
<span className="sr-only"></span>
<Search
className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-ink-soft"
aria-hidden="true"
/>
<TextInput
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="搜索标题、题材、主题或一句话故事"
className="pl-9"
/>
</label>
<Select
aria-label="筛选作品"
value={filter}
onChange={(e) => setFilter(e.target.value as ProjectFilter)}
>
<option value="all"></option>
<option value="pending_review">稿</option>
<option value="with_genre"></option>
<option value="uncategorized"></option>
</Select>
<Select
aria-label="排序作品"
value={sort}
onChange={(e) => setSort(e.target.value as ProjectSort)}
>
<option value="updated_at"></option>
<option value="title"></option>
<option value="genre"></option>
</Select>
<div
className="inline-flex justify-self-start rounded border border-line bg-bg p-1 md:justify-self-end"
role="group"
aria-label="视图密度"
>
<ViewButton
label="卡片"
active={viewMode === "cards"}
onClick={() => setView("cards")}
>
<LayoutGrid className="h-4 w-4" aria-hidden="true" />
</ViewButton>
<ViewButton
label="紧凑"
active={viewMode === "compact"}
onClick={() => setView("compact")}
>
<List className="h-4 w-4" aria-hidden="true" />
</ViewButton>
</div>
</div>
<p className="text-xs text-ink-soft">
<Clock3 className="mr-1 inline h-3.5 w-3.5" aria-hidden="true" />
{visibleProjects.length} / {projects.length}
</p>
{visibleProjects.length === 0 ? (
<EmptyState
icon={Search}
title="没有匹配的作品"
description="换一个关键词或清空筛选,也可以直接创建一本新作品。"
action={
<div className="flex flex-wrap justify-center gap-2">
<Button
onClick={() => {
setSearch("");
setFilter("all");
}}
variant="secondary"
size="sm"
>
</Button>
<Link
href="/projects/new"
className={buttonClass({ variant: "primary", size: "sm" })}
>
<Plus className="h-4 w-4" aria-hidden="true" />
</Link>
</div>
}
/>
) : viewMode === "cards" ? (
<ul className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3">
{visibleProjects.map((p) => (
<li key={p.id}>
<ProjectCard project={p} />
</li>
))}
<li>
<NewProjectCard />
</li>
</ul>
) : (
<ul className="flex flex-col gap-2">
{visibleProjects.map((p) => (
<li key={p.id}>
<ProjectCard project={p} compact />
</li>
))}
<li>
<Link
href="/projects/new"
className="flex items-center justify-center gap-2 rounded border border-dashed border-line bg-panel/70 px-4 py-3 text-sm text-cinnabar transition-colors hover:border-cinnabar hover:bg-panel"
>
<Plus className="h-4 w-4" aria-hidden="true" />
</Link>
</li>
</ul>
)}
</div>
);
}
function ViewButton({
active,
label,
children,
onClick,
}: {
active: boolean;
label: string;
children: ReactNode;
onClick: () => void;
}) {
return (
<button
type="button"
aria-label={label}
aria-pressed={active}
onClick={onClick}
className={cn(
"rounded px-2 py-1.5 text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cinnabar/35",
active
? "bg-panel text-cinnabar shadow-paper"
: "text-ink-soft hover:text-cinnabar",
)}
>
{children}
</button>
);
}
function NewProjectCard() {
return (
<Link
href="/projects/new"
className="group flex h-full min-h-[176px] flex-col items-center justify-center rounded border border-dashed border-line bg-panel/70 text-center transition-colors hover:border-cinnabar hover:bg-panel"
>
<span className="mb-3 flex h-10 w-10 items-center justify-center rounded bg-[var(--color-cinnabar-wash)] text-cinnabar transition-colors group-hover:bg-cinnabar group-hover:text-panel">
<Plus className="h-5 w-5" aria-hidden="true" />
</span>
<span className="font-serif text-xl text-cinnabar"></span>
<span className="mt-2 text-sm text-ink-soft"></span>
</Link>
);
}

View File

@@ -1,10 +1,14 @@
"use client"; "use client";
import Link from "next/link"; import Link from "next/link";
import { ArrowRight, CheckCircle2, ListTree } from "lucide-react";
import { Button } from "@/components/ui/Button";
import { StatusNote } from "@/components/ui/StatusNote";
import type { AcceptResponse } from "@/lib/api/types"; import type { AcceptResponse } from "@/lib/api/types";
import { buildAcceptPreview } from "@/lib/review/accept-preview"; import { buildAcceptPreview } from "@/lib/review/accept-preview";
import { ThinkingIndicator } from "@/components/ThinkingIndicator"; import { ThinkingIndicator } from "@/components/ThinkingIndicator";
import { buttonClass } from "@/lib/ui/variants";
interface AcceptPanelProps { interface AcceptPanelProps {
projectId: string; projectId: string;
@@ -42,40 +46,50 @@ export function AcceptPanel({
if (result) { if (result) {
return ( return (
<div className="rounded border border-pass/50 bg-panel p-4"> <div className="rounded border border-pass/50 bg-panel p-4">
<h3 className="mb-2 text-sm font-semibold text-pass"></h3> <StatusNote variant="success" title="本章已验收">
<ul className="space-y-1 text-xs text-ink-soft"> <p className="text-sm leading-6">
<li> 稿
</p>
<span className="font-mono text-ink">v{result.accepted_version}</span> </StatusNote>
</li> <dl className="mt-3 grid grid-cols-3 gap-2 text-center">
<li> <div className="rounded border border-line bg-bg/60 p-2">
{result.digest_added ? "已新增一行" : "未变更"} <dt className="text-[11px] text-ink-soft"></dt>
</li> <dd className="mt-1 font-mono text-sm text-ink">
<li> v{result.accepted_version}
</dd>
<span className="font-mono text-ink"> </div>
<div className="rounded border border-line bg-bg/60 p-2">
<dt className="text-[11px] text-ink-soft"></dt>
<dd className="mt-1 text-sm text-ink">
{result.digest_added ? "已新增" : "未变更"}
</dd>
</div>
<div className="rounded border border-line bg-bg/60 p-2">
<dt className="text-[11px] text-ink-soft"></dt>
<dd className="mt-1 font-mono text-sm text-ink">
{result.decisions_recorded} {result.decisions_recorded}
</span>{" "} </dd>
</div>
</li> </dl>
{result.review_id ? ( {result.review_id ? (
<li className="truncate"> <p className="mt-2 truncate text-[11px] text-ink-soft">
<span className="font-mono">{result.review_id}</span> <span className="font-mono">{result.review_id}</span>
</li> </p>
) : null} ) : null}
</ul>
{/* 验收闭环 → 写下一章入口(修「验收后无路可走」的断点)。 */} {/* 验收闭环 → 写下一章入口(修「验收后无路可走」的断点)。 */}
<div className="mt-3 flex flex-wrap items-center gap-2"> <div className="mt-3 flex flex-wrap items-center gap-2">
<Link <Link
href={`/projects/${projectId}/write?chapter=${chapterNo + 1}`} href={`/projects/${projectId}/write?chapter=${chapterNo + 1}`}
className="rounded bg-cinnabar px-4 py-2 text-sm text-panel hover:opacity-90" className={buttonClass({ variant: "primary" })}
> >
{chapterNo + 1} <ArrowRight className="h-4 w-4" aria-hidden="true" />
{chapterNo + 1}
</Link> </Link>
<Link <Link
href={`/projects/${projectId}/outline`} href={`/projects/${projectId}/outline`}
className="rounded border border-line px-4 py-2 text-sm text-ink hover:border-cinnabar hover:text-cinnabar" className={buttonClass({ variant: "secondary" })}
> >
<ListTree className="h-4 w-4" aria-hidden="true" />
</Link> </Link>
</div> </div>
@@ -84,20 +98,24 @@ export function AcceptPanel({
} }
return ( return (
<div className="rounded border border-line bg-panel p-4"> <div
className={`rounded border bg-panel p-4 ${
blocked ? "border-conflict/35" : "border-line"
}`}
>
{blocked ? ( {blocked ? (
<p className="mb-2 text-xs text-conflict" role="status"> <StatusNote className="mb-3" variant="danger" role="status">
{unresolvedCount} {unresolvedCount}
</p> </StatusNote>
) : ( ) : (
<> <>
<p className="mb-2 text-xs text-ink-soft"> <StatusNote className="mb-3" variant={reviewed ? "success" : "info"}>
{conflictCount === 0 {conflictCount === 0
? reviewed ? reviewed
? "无冲突,可直接验收。" ? "无冲突,可直接验收。"
: "尚未审稿——建议先点「重新审稿」确认本章无冲突,再验收。" : "尚未审稿——建议先点「重新审稿」确认本章无冲突,再验收。"
: "全部冲突已裁决,可验收本章。"} : "全部冲突已裁决,可验收本章。"}
</p> </StatusNote>
{/* R3验收前「本次将更新」清单预期落库口径以验收回执为准。 */} {/* R3验收前「本次将更新」清单预期落库口径以验收回执为准。 */}
<div className="mb-3 rounded border border-line/70 bg-bg/50 p-3"> <div className="mb-3 rounded border border-line/70 bg-bg/50 p-3">
<h4 className="mb-1.5 text-xs font-semibold text-ink"> <h4 className="mb-1.5 text-xs font-semibold text-ink">
@@ -122,19 +140,22 @@ export function AcceptPanel({
</div> </div>
</> </>
)} )}
<button <Button
type="button"
onClick={onAccept} onClick={onAccept}
disabled={blocked || accepting} disabled={blocked || accepting}
aria-disabled={blocked || accepting} aria-disabled={blocked || accepting}
className="w-full rounded bg-cinnabar px-4 py-2 text-sm text-panel hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-40" variant="primary"
className="w-full"
> >
{accepting ? ( {accepting ? (
<ThinkingIndicator label="验收中" className="justify-center" /> <ThinkingIndicator label="验收中" className="justify-center" />
) : ( ) : (
"全部处理完 → 验收本章" <>
<CheckCircle2 className="h-4 w-4" aria-hidden="true" />
</>
)} )}
</button> </Button>
</div> </div>
); );
} }

View File

@@ -16,8 +16,9 @@ export function BeatMap({ beatMap }: BeatMapProps) {
} }
const spark = toSparkline(beatMap); const spark = toSparkline(beatMap);
return ( return (
<div className="max-w-full overflow-x-auto pb-1">
<div <div
className="flex h-12 items-end gap-0.5" className="flex h-12 min-w-full items-end gap-0.5"
role="img" role="img"
aria-label={`爽点节拍图 ${spark}`} aria-label={`爽点节拍图 ${spark}`}
> >
@@ -30,5 +31,6 @@ export function BeatMap({ beatMap }: BeatMapProps) {
/> />
))} ))}
</div> </div>
</div>
); );
} }

View File

@@ -1,10 +1,16 @@
"use client"; "use client";
import type { ReviewConflict } from "@/lib/review/sse"; import { AlertTriangle, CheckCircle2, LocateFixed, Sparkles } from "lucide-react";
import { Badge } from "@/components/ui/Badge";
import { TextInput } from "@/components/ui/TextInput";
import { buttonClass } from "@/lib/ui/variants";
import type { DecisionDraft, Verdict } from "@/lib/review/decisions"; import type { DecisionDraft, Verdict } from "@/lib/review/decisions";
import type { ReviewConflict } from "@/lib/review/sse";
interface ConflictCardProps { interface ConflictCardProps {
index: number; index: number;
total: number;
conflict: ReviewConflict; conflict: ReviewConflict;
draft: DecisionDraft; draft: DecisionDraft;
missing: boolean; missing: boolean;
@@ -28,15 +34,16 @@ const VERDICT_OPTIONS: { value: Verdict; label: string; primary: boolean }[] = [
// 裁决按钮样式:激活=朱砂填充;未激活的主按钮=朱砂描边强调,次级=低对比描边。 // 裁决按钮样式:激活=朱砂填充;未激活的主按钮=朱砂描边强调,次级=低对比描边。
function verdictClass(active: boolean, primary: boolean): string { function verdictClass(active: boolean, primary: boolean): string {
if (active) return "bg-cinnabar text-panel"; if (active) return buttonClass({ variant: "primary", size: "sm" });
if (primary) return "border border-cinnabar text-cinnabar hover:bg-cinnabar/10"; if (primary) return buttonClass({ variant: "outline", size: "sm" });
return "border border-line text-ink-soft hover:border-cinnabar hover:text-ink"; return buttonClass({ variant: "secondary", size: "sm" });
} }
// 单个冲突报告卡UX §6.4):五类徽标 + where + refs + suggestion + 裁决三态。 // 单个冲突报告卡UX §6.4):五类徽标 + where + refs + suggestion + 裁决三态。
// 冲突=赭红;未决/缺判时加图标+文案不单靠色a11y §10 // 冲突=赭红;未决/缺判时加图标+文案不单靠色a11y §10
export function ConflictCard({ export function ConflictCard({
index, index,
total,
conflict, conflict,
draft, draft,
missing, missing,
@@ -60,13 +67,14 @@ export function ConflictCard({
}`} }`}
> >
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<span <Badge variant="danger" className="shrink-0">
className="shrink-0 rounded bg-[var(--color-conflict)]/10 px-2 py-0.5 text-xs text-conflict" <AlertTriangle className="h-3 w-3" aria-hidden="true" />
aria-hidden="true" {index + 1}/{total}
> </Badge>
{conflict.type} <div className="min-w-0 flex-1">
</span> <p className="text-xs text-conflict">{conflict.type}</p>
<p className="flex-1 text-sm text-ink">{conflict.suggestion}</p> <p className="mt-1 text-sm text-ink">{conflict.suggestion}</p>
</div>
</div> </div>
{conflict.original && conflict.replacement ? ( {conflict.original && conflict.replacement ? (
@@ -95,9 +103,10 @@ export function ConflictCard({
<button <button
type="button" type="button"
onClick={() => onJump(index)} onClick={() => onJump(index)}
className="rounded border border-line px-2 py-0.5 hover:border-cinnabar hover:text-cinnabar" className={buttonClass({ variant: "secondary", size: "sm" })}
> >
{conflict.where} <LocateFixed className="h-4 w-4" aria-hidden="true" />
{conflict.where}
</button> </button>
) : null} ) : null}
{conflict.refs.map((ref) => ( {conflict.refs.map((ref) => (
@@ -121,23 +130,24 @@ export function ConflictCard({
aria-pressed={active} aria-pressed={active}
disabled={rewriting} disabled={rewriting}
onClick={() => onVerdict(index, opt.value)} onClick={() => onVerdict(index, opt.value)}
className={`rounded px-3 py-1 text-xs disabled:cursor-not-allowed disabled:opacity-40 ${verdictClass( className={verdictClass(active, opt.primary)}
active,
opt.primary,
)}`}
> >
{opt.label} {opt.label}
</button> </button>
); );
})} })}
{rewriting ? ( {rewriting ? (
<span className="text-xs text-info"> AI </span> <Badge variant="info">
<Sparkles className="h-3 w-3" aria-hidden="true" />
AI
</Badge>
) : resolved ? ( ) : resolved ? (
<span className="text-xs text-pass" aria-hidden="true"> <Badge variant="success">
<CheckCircle2 className="h-3 w-3" aria-hidden="true" />
</span>
</Badge>
) : ( ) : (
<span className="text-xs text-conflict"></span> <Badge variant="danger"></Badge>
)} )}
</div> </div>
{draft.verdict === "manual" ? ( {draft.verdict === "manual" ? (
@@ -145,14 +155,16 @@ export function ConflictCard({
<label htmlFor={`note-${index}`} className="sr-only"> <label htmlFor={`note-${index}`} className="sr-only">
</label> </label>
<input <TextInput
id={`note-${index}`} id={`note-${index}`}
type="text" type="text"
value={draft.note} value={draft.note}
onChange={(e) => onNote(index, e.target.value)} onChange={(e) => onNote(index, e.target.value)}
placeholder="手改说明(可选)" placeholder="手改说明(可选)"
className="w-full rounded border border-line bg-bg px-3 py-1.5 text-sm text-ink focus:border-cinnabar focus:outline-none"
/> />
<p className="mt-1 text-xs text-ink-soft">
</p>
</div> </div>
) : null} ) : null}
</div> </div>

View File

@@ -1,8 +1,18 @@
"use client"; "use client";
import { useState } from "react"; import { CheckCircle2, CircleDashed, Flag, Plus } from "lucide-react";
import { useEffect, useState } from "react";
import { suggestForeshadowCode } from "@/lib/foreshadow/board"; import { api } from "@/lib/api/client";
import { Badge } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button";
import { Field } from "@/components/ui/Field";
import { TextArea } from "@/components/ui/TextArea";
import { TextInput } from "@/components/ui/TextInput";
import {
nextFreeForeshadowCode,
suggestForeshadowCode,
} from "@/lib/foreshadow/board";
import { useForeshadow } from "@/lib/foreshadow/useForeshadow"; import { useForeshadow } from "@/lib/foreshadow/useForeshadow";
import type { ForeshadowSuggestion } from "@/lib/review/sse"; import type { ForeshadowSuggestion } from "@/lib/review/sse";
@@ -36,11 +46,54 @@ export function ForeshadowSuggestions({
const [registered, setRegistered] = useState<Set<number>>(new Set()); const [registered, setRegistered] = useState<Set<number>>(new Set());
const [resolved, setResolved] = useState<Set<number>>(new Set()); const [resolved, setResolved] = useState<Set<number>>(new Set());
const [pending, setPending] = useState<Set<number>>(new Set()); const [pending, setPending] = useState<Set<number>>(new Set());
// 项目已存在的伏笔(用于隐藏「之前会话就已保存过」的建议):
// savedTitles = 已登记伏笔的标题savedClosedCodes = 已 CLOSED已回收的 code。
const [savedTitles, setSavedTitles] = useState<Set<string>>(new Set());
const [savedClosedCodes, setSavedClosedCodes] = useState<Set<string>>(
new Set(),
);
// 看板已占用的全部代号——预填登记代号时跳过它们,避免撞码 422。
const [savedCodes, setSavedCodes] = useState<Set<string>>(new Set());
useEffect(() => {
let active = true;
void (async () => {
const { data } = await api.GET("/projects/{project_id}/foreshadow", {
params: { path: { project_id: projectId } },
});
if (!active || !data?.foreshadow) return;
setSavedTitles(new Set(data.foreshadow.map((f) => f.title.trim())));
setSavedClosedCodes(
new Set(
data.foreshadow
.filter((f) => f.status === "CLOSED")
.map((f) => f.code),
),
);
setSavedCodes(
new Set(data.foreshadow.map((f) => f.code).filter((c) => c)),
);
})();
return () => {
active = false;
};
}, [projectId]);
// 该建议是否「已保存过」→ 从列表隐藏:
// - 本次会话已登记/回收registered/resolved
// - 新埋建议:标题已存在于看板(之前已登记)
// - 疑似回收建议:对应 code 已 CLOSED之前已回收
const isAlreadySaved = (s: ForeshadowSuggestion, i: number): boolean => {
if (s.kind === "resolved") {
return resolved.has(i) || (s.code ? savedClosedCodes.has(s.code) : false);
}
return registered.has(i) || savedTitles.has(s.title.trim());
};
const openRegister = (i: number, s: ForeshadowSuggestion): void => { const openRegister = (i: number, s: ForeshadowSuggestion): void => {
setOpenIdx(i); setOpenIdx(i);
setForm({ setForm({
code: suggestForeshadowCode(s.code, i), code: nextFreeForeshadowCode(suggestForeshadowCode(s.code, i), savedCodes),
title: s.title, title: s.title,
plantedAt: chapterNo, plantedAt: chapterNo,
content: s.note ?? s.where ?? "", content: s.note ?? s.where ?? "",
@@ -73,6 +126,9 @@ export function ForeshadowSuggestions({
markPending(i, false); markPending(i, false);
if (ok) { if (ok) {
setRegistered((prev) => new Set(prev).add(i)); setRegistered((prev) => new Set(prev).add(i));
// 并入已存集合:连续登记时下一条不再取到同一空闲码,且该标题立即隐藏。
setSavedCodes((prev) => new Set(prev).add(form.code));
setSavedTitles((prev) => new Set(prev).add(form.title.trim()));
closeForm(); closeForm();
} }
}; };
@@ -84,36 +140,42 @@ export function ForeshadowSuggestions({
if (ok) setResolved((prev) => new Set(prev).add(i)); if (ok) setResolved((prev) => new Set(prev).add(i));
}; };
// 保留原始下标 iregistered/resolved/pending/openIdx 均按 i 索引),仅过滤已保存项。
const visible = suggestions
.map((s, i) => ({ s, i }))
.filter(({ s, i }) => !isAlreadySaved(s, i));
return ( return (
<section className="mb-4"> <section className="mb-4">
<h2 className="text-xs font-semibold uppercase tracking-wide text-ink-soft"> <h2 className="text-xs font-semibold uppercase tracking-wide text-ink-soft">
(foreshadow-analyst) (foreshadow-analyst)
</h2> </h2>
{incomplete ? ( {incomplete ? (
<p className="mt-1 text-xs text-overdue"> </p> <Badge variant="warning" className="mt-1">
) : suggestions.length === 0 ? ( <CircleDashed className="h-3 w-3" aria-hidden="true" />
</Badge>
) : visible.length === 0 ? (
<p className="mt-1 text-xs text-ink-soft"></p> <p className="mt-1 text-xs text-ink-soft"></p>
) : ( ) : (
<ul className="mt-2 space-y-2"> <ul className="mt-2 space-y-2">
{suggestions.map((s, i) => { {visible.map(({ s, i }) => {
const code = s.code; const code = s.code;
const isResolved = s.kind === "resolved"; const isResolved = s.kind === "resolved";
const done = isResolved ? resolved.has(i) : registered.has(i);
const busy = pending.has(i); const busy = pending.has(i);
return ( return (
<li <li
key={i} key={i}
className="rounded border border-line bg-panel p-2 text-xs" className="rounded border border-line bg-panel p-2 text-xs"
> >
<span <Badge variant={isResolved ? "success" : "accent"}>
className={`mr-1 rounded px-1.5 py-0.5 ${ {isResolved ? (
isResolved <Flag className="h-3 w-3" aria-hidden="true" />
? "bg-pass/15 text-pass" ) : (
: "bg-[var(--color-cinnabar-wash)] text-cinnabar" <Plus className="h-3 w-3" aria-hidden="true" />
}`} )}
> {isResolved ? "疑似回收" : "新埋待确认"}
{isResolved ? "⚑ 疑似回收" : " 新埋待确认"} </Badge>
</span>
{code ? ( {code ? (
<span className="font-mono text-ink-soft"> {code}</span> <span className="font-mono text-ink-soft"> {code}</span>
) : null} ) : null}
@@ -123,21 +185,19 @@ export function ForeshadowSuggestions({
) : null} ) : null}
{s.note ? <p className="mt-0.5 text-ink-soft">{s.note}</p> : null} {s.note ? <p className="mt-0.5 text-ink-soft">{s.note}</p> : null}
{/* 作者确认动作区 */} {/* 作者确认动作区(已保存的建议已从列表过滤,故无需「已登记」态) */}
{done ? ( {isResolved ? (
<p className="mt-1.5 text-pass">
{isResolved ? "已标记回收" : "已登记"}
</p>
) : isResolved ? (
code ? ( code ? (
<button <Button
type="button"
disabled={busy} disabled={busy}
onClick={() => void markResolved(i, code)} onClick={() => void markResolved(i, code)}
className="mt-1.5 rounded border border-pass px-2 py-0.5 text-pass hover:bg-pass/10 disabled:cursor-not-allowed disabled:opacity-40" className="mt-1.5"
variant="secondary"
size="sm"
> >
{busy ? "处理中…" : "✓ 标记回收"} <CheckCircle2 className="h-4 w-4" aria-hidden="true" />
</button> {busy ? "处理中…" : "标记回收"}
</Button>
) : ( ) : (
<p className="mt-1.5 text-ink-soft"> <p className="mt-1.5 text-ink-soft">
@@ -152,13 +212,15 @@ export function ForeshadowSuggestions({
onCancel={closeForm} onCancel={closeForm}
/> />
) : ( ) : (
<button <Button
type="button"
onClick={() => openRegister(i, s)} onClick={() => openRegister(i, s)}
className="mt-1.5 rounded border border-cinnabar px-2 py-0.5 text-cinnabar hover:bg-cinnabar/10" className="mt-1.5"
variant="outline"
size="sm"
> >
<Plus className="h-4 w-4" aria-hidden="true" />
</button>
</Button>
)} )}
</li> </li>
); );
@@ -187,39 +249,28 @@ function RegisterFormInline({
}: RegisterFormInlineProps) { }: RegisterFormInlineProps) {
const canSubmit = const canSubmit =
!busy && form.code.trim().length > 0 && form.title.trim().length > 0; !busy && form.code.trim().length > 0 && form.title.trim().length > 0;
const field =
"w-full rounded border border-line bg-bg px-2 py-1 text-xs text-ink focus:border-cinnabar focus:outline-none";
return ( return (
<div className="mt-2 space-y-1.5 border-t border-line pt-2"> <div className="mt-2 space-y-1.5 border-t border-line pt-2">
<div> <Field label="编码" htmlFor="fs-reg-code">
<label htmlFor="fs-reg-code" className="text-ink-soft"> <TextInput
</label>
<input
id="fs-reg-code" id="fs-reg-code"
type="text" type="text"
value={form.code} value={form.code}
onChange={(e) => onChange({ ...form, code: e.target.value })} onChange={(e) => onChange({ ...form, code: e.target.value })}
className={field} controlSize="sm"
/> />
</div> </Field>
<div> <Field label="标题" htmlFor="fs-reg-title">
<label htmlFor="fs-reg-title" className="text-ink-soft"> <TextInput
</label>
<input
id="fs-reg-title" id="fs-reg-title"
type="text" type="text"
value={form.title} value={form.title}
onChange={(e) => onChange({ ...form, title: e.target.value })} onChange={(e) => onChange({ ...form, title: e.target.value })}
className={field} controlSize="sm"
/> />
</div> </Field>
<div> <Field label="埋设章号" htmlFor="fs-reg-planted">
<label htmlFor="fs-reg-planted" className="text-ink-soft"> <TextInput
</label>
<input
id="fs-reg-planted" id="fs-reg-planted"
type="number" type="number"
min={1} min={1}
@@ -227,38 +278,36 @@ function RegisterFormInline({
onChange={(e) => onChange={(e) =>
onChange({ ...form, plantedAt: Number(e.target.value) || 1 }) onChange({ ...form, plantedAt: Number(e.target.value) || 1 })
} }
className={field} controlSize="sm"
/> />
</div> </Field>
<div> <Field label="线索/正文(可选)" htmlFor="fs-reg-content">
<label htmlFor="fs-reg-content" className="text-ink-soft"> <TextArea
线/
</label>
<textarea
id="fs-reg-content" id="fs-reg-content"
value={form.content} value={form.content}
onChange={(e) => onChange({ ...form, content: e.target.value })} onChange={(e) => onChange({ ...form, content: e.target.value })}
rows={2} rows={2}
className={`${field} resize-y`} controlSize="sm"
/> />
</div> </Field>
<div className="flex gap-2 pt-0.5"> <div className="flex gap-2 pt-0.5">
<button <Button
type="button"
disabled={!canSubmit} disabled={!canSubmit}
onClick={onSubmit} onClick={onSubmit}
className="rounded bg-cinnabar px-2 py-0.5 text-panel hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-40" variant="primary"
size="sm"
> >
<CheckCircle2 className="h-4 w-4" aria-hidden="true" />
{busy ? "登记中…" : "确认登记"} {busy ? "登记中…" : "确认登记"}
</button> </Button>
<button <Button
type="button"
disabled={busy} disabled={busy}
onClick={onCancel} onClick={onCancel}
className="rounded border border-line px-2 py-0.5 text-ink-soft hover:border-cinnabar hover:text-ink disabled:opacity-40" variant="secondary"
size="sm"
> >
</button> </Button>
</div> </div>
</div> </div>
); );

View File

@@ -1,5 +1,8 @@
"use client"; "use client";
import { AlertTriangle, CheckCircle2, CircleDashed } from "lucide-react";
import { Badge } from "@/components/ui/Badge";
import type { PaceReport } from "@/lib/review/sse"; import type { PaceReport } from "@/lib/review/sse";
import { BeatMap } from "./BeatMap"; import { BeatMap } from "./BeatMap";
@@ -16,7 +19,10 @@ export function PacePanel({ pace, incomplete }: PacePanelProps) {
(pace-checker) (pace-checker)
</h2> </h2>
{incomplete ? ( {incomplete ? (
<p className="mt-1 text-xs text-overdue"> </p> <Badge variant="warning" className="mt-1">
<CircleDashed className="h-3 w-3" aria-hidden="true" />
</Badge>
) : pace === null ? ( ) : pace === null ? (
<p className="mt-1 text-xs text-ink-soft"></p> <p className="mt-1 text-xs text-ink-soft"></p>
) : ( ) : (
@@ -29,15 +35,24 @@ export function PacePanel({ pace, incomplete }: PacePanelProps) {
<p> <p>
{pace.hook ? ( {pace.hook ? (
<span className="text-pass"> </span> <Badge variant="success">
<CheckCircle2 className="h-3 w-3" aria-hidden="true" />
</Badge>
) : ( ) : (
<span className="text-overdue"> </span> <Badge variant="warning">
<AlertTriangle className="h-3 w-3" aria-hidden="true" />
</Badge>
)} )}
</p> </p>
{pace.water.length > 0 ? ( {pace.water.length > 0 ? (
<div> <div>
<p className="text-overdue"> </p> <Badge variant="warning">
<AlertTriangle className="h-3 w-3" aria-hidden="true" />
</Badge>
<ul className="mt-1 space-y-1"> <ul className="mt-1 space-y-1">
{pace.water.map((w, i) => ( {pace.water.map((w, i) => (
<li key={i} className="text-ink-soft"> <li key={i} className="text-ink-soft">
@@ -47,7 +62,10 @@ export function PacePanel({ pace, incomplete }: PacePanelProps) {
</ul> </ul>
</div> </div>
) : ( ) : (
<p className="text-pass"> </p> <Badge variant="success">
<CheckCircle2 className="h-3 w-3" aria-hidden="true" />
</Badge>
)} )}
</div> </div>
)} )}

View File

@@ -2,8 +2,16 @@
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import {
AlertTriangle,
CheckCircle2,
RotateCcw,
Square,
} from "lucide-react";
import { AppShell } from "@/components/AppShell"; import { AppShell } from "@/components/AppShell";
import { Badge } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button";
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";
import { import {
@@ -44,6 +52,8 @@ import { AnnotatedText } from "./AnnotatedText";
import { ConflictCard } from "./ConflictCard"; import { ConflictCard } from "./ConflictCard";
import { ForeshadowSuggestions } from "./ForeshadowSuggestions"; import { ForeshadowSuggestions } from "./ForeshadowSuggestions";
import { PacePanel } from "./PacePanel"; import { PacePanel } from "./PacePanel";
import { ReviewSectionPanel } from "./ReviewSectionPanel";
import { ReviewSummaryRail } from "./ReviewSummaryRail";
import { StylePanel } from "@/components/style/StylePanel"; import { StylePanel } from "@/components/style/StylePanel";
import { RefineView } from "@/components/style/RefineView"; import { RefineView } from "@/components/style/RefineView";
@@ -370,6 +380,13 @@ export function ReviewReport({
const resolved = allResolved(drafts); const resolved = allResolved(drafts);
const unresolved = unresolvedIndices(drafts).length; const unresolved = unresolvedIndices(drafts).length;
const reviewing = review.isReviewing; const reviewing = review.isReviewing;
const hasReport =
review.state.phase === "done" ||
review.state.sections.length > 0 ||
seededConflicts.length > 0 ||
foreshadow.length > 0 ||
pace !== null ||
style !== null;
return ( return (
<AppShell <AppShell
@@ -378,33 +395,27 @@ export function ReviewReport({
projectId={project.id} projectId={project.id}
activeNav="review" activeNav="review"
> >
<div className="grid h-[calc(100vh-var(--chrome,4rem))] grid-cols-1 lg:grid-cols-[1fr_24rem]"> <div className="flex min-h-[calc(100vh-var(--chrome,4rem))] flex-col lg:grid lg:h-[calc(100vh-var(--chrome,4rem))] lg:min-h-0 lg:grid-cols-[minmax(0,1fr)_24rem]">
{/* 左:终稿正文 + 就地标注 */} {/* 左:终稿正文 + 就地标注 */}
<section className="flex min-w-0 flex-col bg-bg"> <section className="flex min-h-[70vh] min-w-0 flex-col bg-bg lg:min-h-0">
<div className="flex items-center gap-3 border-b border-line bg-panel px-6 py-3"> <div className="flex items-center gap-3 border-b border-line bg-panel px-4 py-3 sm:px-6">
<h1 className="font-serif text-lg text-ink"> <h1 className="min-w-0 flex-1 truncate font-serif text-lg text-ink">
{chapterNo} 稿 {chapterNo} 稿
</h1> </h1>
<span className="font-mono text-xs text-ink-soft"> <span className="hidden shrink-0 font-mono text-xs text-ink-soft sm:inline">
{conflictCount} {conflictCount}
</span> </span>
<div className="ml-auto"> <div className="shrink-0">
{reviewing ? ( {reviewing ? (
<button <Button onClick={review.stop} variant="danger" size="sm">
type="button" <Square className="h-4 w-4" aria-hidden="true" />
onClick={review.stop}
className="rounded border border-conflict px-3 py-1.5 text-sm text-conflict"
>
</button> </Button>
) : ( ) : (
<button <Button onClick={onReReview} variant="primary" size="sm">
type="button" <RotateCcw className="h-4 w-4" aria-hidden="true" />
onClick={onReReview} 稿
className="rounded bg-cinnabar px-3 py-1.5 text-sm text-panel hover:opacity-90" </Button>
>
稿
</button>
)} )}
</div> </div>
</div> </div>
@@ -413,7 +424,7 @@ export function ReviewReport({
<ReviewErrorNote error={review.state.error} /> <ReviewErrorNote error={review.state.error} />
) : null} ) : null}
<div className="flex-1 overflow-auto px-6 py-6"> <div className="flex-1 overflow-auto px-4 py-4 sm:px-6 sm:py-6">
<div className="mb-2 flex items-start justify-between gap-3"> <div className="mb-2 flex items-start justify-between gap-3">
<p className="text-xs text-ink-soft"> <p className="text-xs text-ink-soft">
稿 稿 稿 稿
@@ -438,67 +449,93 @@ export function ReviewReport({
</section> </section>
{/* 右:审稿分区 + 冲突裁决 + 验收 gate */} {/* 右:审稿分区 + 冲突裁决 + 验收 gate */}
<aside className="flex flex-col overflow-auto border-l border-line bg-panel px-4 py-4"> <aside
<section className="mb-4"> className="flex flex-col border-t border-line bg-panel px-4 py-4 lg:overflow-auto lg:border-l lg:border-t-0"
<h2 className="text-xs font-semibold uppercase tracking-wide text-ink-soft"> aria-label="审稿报告与验收"
(continuity) >
</h2> <ReviewSummaryRail
conflictCount={conflictCount}
unresolvedCount={unresolved}
reviewing={reviewing}
hasReport={hasReport}
onNextUnresolved={jumpToNextUnresolved}
/>
<div className="space-y-3">
<ReviewSectionPanel
title="一致性"
subtitle="continuity"
statusLabel={
reviewing
? "审稿中"
: conflictCount > 0
? `${conflictCount} 冲突`
: hasReport
? "通过"
: "待审"
}
statusVariant={
reviewing
? "info"
: conflictCount > 0
? "danger"
: hasReport
? "success"
: "neutral"
}
defaultOpen={conflictCount > 0 || !hasReport}
>
<SectionStatusLine <SectionStatusLine
reviewing={reviewing} reviewing={reviewing}
done={review.state.phase === "done"} done={review.state.phase === "done"}
conflictCount={conflictCount} conflictCount={conflictCount}
sections={review.state.sections} sections={review.state.sections}
/> />
</section>
<section>
{conflicts.length === 0 ? ( {conflicts.length === 0 ? (
<p className="rounded border border-dashed border-line p-4 text-xs text-ink-soft"> <p className="mt-2 rounded border border-dashed border-line p-4 text-xs text-ink-soft">
{review.state.phase === "done" || seededConflicts.length === 0 {review.state.phase === "done" || seededConflicts.length === 0
? "未发现一致性冲突。" ? "未发现一致性冲突。"
: "进页未带审稿留痕,点「重新审稿」开始。"} : "进页未带审稿留痕,点「重新审稿」开始。"}
</p> </p>
) : ( ) : (
<div className="space-y-4"> <div className="mt-3 space-y-4">
{/* R2未裁决进度 + 跳到下一条未裁决 */}
<div className="flex items-center justify-between text-xs text-ink-soft">
<span>
{unresolved > 0 ? (
<span className="text-conflict">
{unresolved}/{conflictCount}
</span>
) : (
<span className="text-pass"> </span>
)}
</span>
<button
type="button"
onClick={jumpToNextUnresolved}
disabled={unresolved === 0}
className="rounded border border-line px-2 py-0.5 hover:border-cinnabar hover:text-cinnabar disabled:cursor-not-allowed disabled:opacity-40"
>
</button>
</div>
{/* B讲清裁决保存模型——「采纳/裁决」仅本地暂存,验收时一并落库 */}
<p className="rounded border border-dashed border-line/70 bg-bg/40 px-3 py-1.5 text-[11px] text-ink-soft"> <p className="rounded border border-dashed border-line/70 bg-bg/40 px-3 py-1.5 text-[11px] text-ink-soft">
稿 稿
</p> </p>
{groups.map((group) => ( {groups.map((group) => {
<div key={group.type}> const unresolvedInGroup = group.items.filter(
<h3 className="mb-2 flex items-center gap-2 text-xs font-semibold text-ink"> ({ index }) => !drafts[index]?.verdict,
).length;
return (
<details
key={group.type}
open={unresolvedInGroup > 0}
className="rounded border border-line/70 bg-bg/35 p-2"
>
<summary className="flex cursor-pointer list-none items-center justify-between gap-2 text-xs font-semibold text-ink">
<span className="flex items-center gap-2">
{group.type} {group.type}
<span className="rounded-full bg-[var(--color-conflict)]/10 px-2 py-0.5 font-mono text-conflict"> <Badge
variant={
unresolvedInGroup > 0 ? "danger" : "success"
}
>
{group.items.length} {group.items.length}
</Badge>
</span> </span>
</h3> <span className="font-mono text-[11px] text-ink-soft">
<ul className="space-y-3"> {unresolvedInGroup > 0
? `${unresolvedInGroup} 未裁决`
: "已处理"}
</span>
</summary>
<ul className="mt-3 space-y-3">
{group.items.map(({ conflict: c, index: i }) => ( {group.items.map(({ conflict: c, index: i }) => (
<ConflictCard <ConflictCard
key={i} key={i}
index={i} index={i}
total={conflictCount}
conflict={c} conflict={c}
draft={drafts[i] ?? { verdict: null, note: "" }} draft={drafts[i] ?? { verdict: null, note: "" }}
missing={missing.has(i)} missing={missing.has(i)}
@@ -511,28 +548,88 @@ export function ReviewReport({
/> />
))} ))}
</ul> </ul>
</div> </details>
))} );
})}
</div> </div>
)} )}
</section> </ReviewSectionPanel>
<div className="mt-4 border-t border-line pt-4"> <ReviewSectionPanel
title="伏笔"
subtitle="foreshadow-analyst"
statusLabel={
sectionStatus("foreshadow") === "incomplete"
? "未完成"
: foreshadow.length > 0
? `${foreshadow.length} 建议`
: "无建议"
}
statusVariant={
sectionStatus("foreshadow") === "incomplete"
? "warning"
: foreshadow.length > 0
? "accent"
: "success"
}
defaultOpen={foreshadow.length > 0}
>
<ForeshadowSuggestions <ForeshadowSuggestions
suggestions={foreshadow} suggestions={foreshadow}
incomplete={sectionStatus("foreshadow") === "incomplete"} incomplete={sectionStatus("foreshadow") === "incomplete"}
projectId={project.id} projectId={project.id}
chapterNo={chapterNo} chapterNo={chapterNo}
/> />
</ReviewSectionPanel>
<ReviewSectionPanel
title="节奏"
subtitle="pace-checker"
statusLabel={
sectionStatus("pace") === "incomplete"
? "未完成"
: pace
? "已完成"
: "无报告"
}
statusVariant={
sectionStatus("pace") === "incomplete"
? "warning"
: pace
? "info"
: "neutral"
}
defaultOpen={pace !== null}
>
<PacePanel <PacePanel
pace={pace} pace={pace}
incomplete={sectionStatus("pace") === "incomplete"} incomplete={sectionStatus("pace") === "incomplete"}
/> />
</ReviewSectionPanel>
<ReviewSectionPanel
title="文风"
subtitle="style"
statusLabel={
sectionStatus("style") === "incomplete"
? "未完成"
: style
? "已完成"
: "无报告"
}
statusVariant={
sectionStatus("style") === "incomplete"
? "warning"
: style
? "info"
: "neutral"
}
defaultOpen={style !== null}
>
<StylePanel <StylePanel
style={style} style={style}
incomplete={sectionStatus("style") === "incomplete"} incomplete={sectionStatus("style") === "incomplete"}
onRefine={onRefineSegment} onRefine={onRefineSegment}
/> />
</ReviewSectionPanel>
{refineText !== null ? ( {refineText !== null ? (
<RefineView <RefineView
projectId={project.id} projectId={project.id}
@@ -613,9 +710,15 @@ function SectionStatusLine({
return ( return (
<p className="mt-1 text-xs"> <p className="mt-1 text-xs">
{conflictCount > 0 ? ( {conflictCount > 0 ? (
<span className="text-conflict"> {conflictCount} </span> <Badge variant="danger">
<AlertTriangle className="h-3 w-3" aria-hidden="true" />
{conflictCount}
</Badge>
) : ( ) : (
<span className="text-pass"> </span> <Badge variant="success">
<CheckCircle2 className="h-3 w-3" aria-hidden="true" />
</Badge>
)} )}
</p> </p>
); );

View File

@@ -0,0 +1,54 @@
"use client";
import type { ReactNode } from "react";
import { ChevronDown } from "lucide-react";
import { Badge } from "@/components/ui/Badge";
import { cn, type BadgeVariant } from "@/lib/ui/variants";
interface ReviewSectionPanelProps {
title: string;
subtitle?: string;
statusLabel: string;
statusVariant: BadgeVariant;
children: ReactNode;
defaultOpen?: boolean;
}
export function ReviewSectionPanel({
title,
subtitle,
statusLabel,
statusVariant,
children,
defaultOpen = false,
}: ReviewSectionPanelProps) {
return (
<details
className="group rounded border border-line bg-bg/45"
open={defaultOpen}
>
<summary className="flex cursor-pointer list-none items-center justify-between gap-3 px-3 py-2">
<span className="min-w-0">
<span className="block font-serif text-sm text-ink">{title}</span>
{subtitle ? (
<span className="block truncate text-xs text-ink-soft">
{subtitle}
</span>
) : null}
</span>
<span className="flex shrink-0 items-center gap-2">
<Badge variant={statusVariant}>{statusLabel}</Badge>
<ChevronDown
className={cn(
"h-4 w-4 text-ink-soft transition-transform",
"group-open:rotate-180",
)}
aria-hidden="true"
/>
</span>
</summary>
<div className="border-t border-line px-3 py-3">{children}</div>
</details>
);
}

View File

@@ -0,0 +1,65 @@
import { AlertTriangle, CheckCircle2, CircleDashed } from "lucide-react";
import { Badge } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button";
interface ReviewSummaryRailProps {
conflictCount: number;
unresolvedCount: number;
reviewing: boolean;
hasReport: boolean;
onNextUnresolved: () => void;
}
export function ReviewSummaryRail({
conflictCount,
unresolvedCount,
reviewing,
hasReport,
onNextUnresolved,
}: ReviewSummaryRailProps) {
return (
<section className="sticky top-0 z-10 -mx-4 mb-4 border-b border-line bg-panel px-4 pb-3">
<div className="flex items-center justify-between gap-3">
<div>
<h2 className="font-serif text-base text-ink">稿</h2>
<p className="mt-1 text-xs text-ink-soft">
{reviewing
? "四审正在更新"
: hasReport
? "先处理未裁决冲突,再验收本章"
: "暂无审稿留痕,可先重新审稿"}
</p>
</div>
{reviewing ? (
<Badge variant="info">
<CircleDashed className="h-3 w-3" aria-hidden="true" />
稿
</Badge>
) : unresolvedCount > 0 ? (
<Badge variant="danger">
<AlertTriangle className="h-3 w-3" aria-hidden="true" />
{unresolvedCount}/{conflictCount}
</Badge>
) : conflictCount > 0 ? (
<Badge variant="success">
<CheckCircle2 className="h-3 w-3" aria-hidden="true" />
</Badge>
) : (
<Badge variant={hasReport ? "success" : "neutral"}>
{hasReport ? "无冲突" : "未审稿"}
</Badge>
)}
</div>
<Button
onClick={onNextUnresolved}
disabled={unresolvedCount === 0}
variant="secondary"
className="mt-3 w-full"
>
</Button>
</section>
);
}

View File

@@ -1,8 +1,15 @@
"use client"; "use client";
import { Plus, Trash2 } from "lucide-react";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { AppShell } from "@/components/AppShell"; import { AppShell } from "@/components/AppShell";
import { Button } from "@/components/ui/Button";
import { EmptyState } from "@/components/ui/EmptyState";
import { Field } from "@/components/ui/Field";
import { PageHeader } from "@/components/ui/PageHeader";
import { Select } from "@/components/ui/Select";
import { TextArea } from "@/components/ui/TextArea";
import type { ProjectResponse, RuleView } from "@/lib/api/types"; import type { ProjectResponse, RuleView } from "@/lib/api/types";
import { import {
RULE_LEVELS, RULE_LEVELS,
@@ -38,41 +45,48 @@ export function RulesPage({ project, initialRules }: RulesPageProps) {
activeNav="rules" activeNav="rules"
> >
<div className="mx-auto flex max-w-3xl flex-col gap-6 p-6"> <div className="mx-auto flex max-w-3xl flex-col gap-6 p-6">
<PageHeader
title="规则"
description="维护本作、题材、世界观和章节级硬约束,写作与审稿会共同引用这些规则。"
/>
<form <form
onSubmit={onSubmit} onSubmit={onSubmit}
className="rounded border border-line bg-panel p-4" className="rounded border border-line bg-panel p-4"
> >
<h1 className="mb-3 font-serif text-lg text-ink"></h1> <h2 className="mb-3 font-serif text-lg text-ink"></h2>
<div className="mb-3 flex items-end gap-3"> <div className="mb-3 flex items-end gap-3">
<label className="text-sm text-ink-soft"> <Field label="级别" htmlFor="rule-level">
<Select
<select id="rule-level"
value={level} value={level}
onChange={(e) => setLevel(e.target.value as RuleLevel)} onChange={(e) => setLevel(e.target.value as RuleLevel)}
className="mt-1 block rounded border border-line bg-bg px-2 py-1.5 text-sm text-ink"
> >
{RULE_LEVELS.map((lv) => ( {RULE_LEVELS.map((lv) => (
<option key={lv} value={lv}> <option key={lv} value={lv}>
{RULE_LEVEL_LABELS[lv]} {RULE_LEVEL_LABELS[lv]}
</option> </option>
))} ))}
</select> </Select>
</label> </Field>
</div> </div>
<textarea <Field label="规则内容" htmlFor="rule-content">
<TextArea
id="rule-content"
value={content} value={content}
onChange={(e) => setContent(e.target.value)} onChange={(e) => setContent(e.target.value)}
placeholder="如:本作禁用现代科技词汇;称呼一律用「道友」" placeholder="如:本作禁用现代科技词汇;称呼一律用「道友」"
rows={3} rows={3}
className="w-full resize-y rounded border border-line bg-bg px-3 py-2 text-sm text-ink"
/> />
<button </Field>
<Button
type="submit" type="submit"
disabled={busy || content.trim().length === 0} disabled={busy || content.trim().length === 0}
className="mt-3 rounded bg-cinnabar px-4 py-2 text-sm text-white disabled:opacity-50" className="mt-3"
variant="primary"
> >
<Plus className="h-4 w-4" aria-hidden="true" />
{busy ? "保存中…" : "新增规则"} {busy ? "保存中…" : "新增规则"}
</button> </Button>
</form> </form>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
@@ -85,7 +99,22 @@ export function RulesPage({ project, initialRules }: RulesPageProps) {
</span> </span>
</h2> </h2>
{groups[lv].length === 0 ? ( {groups[lv].length === 0 ? (
<p className="text-xs text-ink-soft"></p> <EmptyState
icon={Plus}
title="暂无规则"
description={`还没有${RULE_LEVEL_LABELS[lv]}规则。`}
action={
<Button
onClick={() => setLevel(lv)}
variant="secondary"
size="sm"
>
<Plus className="h-4 w-4" aria-hidden="true" />
{RULE_LEVEL_LABELS[lv]}
</Button>
}
className="px-4 py-6"
/>
) : ( ) : (
<ul className="flex flex-col gap-2"> <ul className="flex flex-col gap-2">
{groups[lv].map((rule) => ( {groups[lv].map((rule) => (
@@ -101,8 +130,9 @@ export function RulesPage({ project, initialRules }: RulesPageProps) {
disabled={busy} disabled={busy}
onClick={() => void remove(project.id, rule.id)} onClick={() => void remove(project.id, rule.id)}
aria-label="删除规则" aria-label="删除规则"
className="shrink-0 text-xs text-ink-soft hover:text-cinnabar disabled:opacity-50" className="inline-flex shrink-0 items-center gap-1 rounded border border-transparent px-2 py-1 text-xs text-ink-soft transition-colors hover:border-conflict/25 hover:bg-conflict/10 hover:text-conflict disabled:opacity-50"
> >
<Trash2 className="h-3 w-3" aria-hidden="true" />
</button> </button>
</li> </li>

View File

@@ -1,5 +1,10 @@
"use client"; "use client";
import { ExternalLink, Link2Off, PlugZap } from "lucide-react";
import { Button } from "@/components/ui/Button";
import { SectionHeader } from "@/components/ui/SectionHeader";
import { StatusNote } from "@/components/ui/StatusNote";
import { authOpenUrl, formatExpiresAt } from "@/lib/settings/kimiOauth"; import { authOpenUrl, formatExpiresAt } from "@/lib/settings/kimiOauth";
import { useKimiOauth } from "@/lib/settings/useKimiOauth"; import { useKimiOauth } from "@/lib/settings/useKimiOauth";
@@ -19,17 +24,22 @@ export function KimiCodeOauth({
const expiresLabel = formatExpiresAt(expiresAt); const expiresLabel = formatExpiresAt(expiresAt);
return ( return (
<section className="mb-10"> <section>
<h2 className="mb-1 font-serif text-xl text-ink">Kimi CodeOAuth</h2> <SectionHeader
<p className="mb-3 text-sm text-ink-soft"> title="Kimi CodeOAuth"
description={
<>
plan OAuth API Key plan OAuth API Key
<span className="font-mono"> kimi-code · kimi-for-coding</span> <span className="font-mono"> kimi-code · kimi-for-coding</span>
</p> </>
}
className="mb-3"
/>
<div className="rounded border border-line bg-panel p-4"> <div className="rounded border border-line bg-panel p-4">
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
<span <span
className={`h-2 w-2 rounded-full ${ className={`h-2 w-2 rounded ${
phase === "connected" ? "bg-pass" : "bg-line" phase === "connected" ? "bg-pass" : "bg-line"
}`} }`}
aria-hidden="true" aria-hidden="true"
@@ -45,27 +55,29 @@ export function KimiCodeOauth({
<div className="ml-auto flex gap-2"> <div className="ml-auto flex gap-2">
{phase === "connected" ? ( {phase === "connected" ? (
<button <Button
type="button"
onClick={() => void oauth.disconnect()} onClick={() => void oauth.disconnect()}
disabled={busy} disabled={busy}
className="rounded border border-line px-3 py-1.5 text-sm text-ink disabled:opacity-40" variant="secondary"
size="sm"
> >
<Link2Off className="h-4 w-4" aria-hidden="true" />
{busy ? "处理中…" : "断开"} {busy ? "处理中…" : "断开"}
</button> </Button>
) : ( ) : (
<button <Button
type="button"
onClick={() => void oauth.connect()} onClick={() => void oauth.connect()}
disabled={busy} disabled={busy}
className="rounded bg-cinnabar px-3 py-1.5 text-sm text-panel disabled:opacity-40" variant="primary"
size="sm"
> >
<PlugZap className="h-4 w-4" aria-hidden="true" />
{busy {busy
? "等待授权…" ? "等待授权…"
: phase === "error" : phase === "error"
? "重新连接 Kimi CodeOAuth" ? "重新连接 Kimi CodeOAuth"
: "连接 Kimi CodeOAuth"} : "连接 Kimi CodeOAuth"}
</button> </Button>
)} )}
</div> </div>
</div> </div>
@@ -77,10 +89,16 @@ export function KimiCodeOauth({
/> />
) : null} ) : null}
{phase !== "awaiting" ? (
<StatusNote className="mt-3" variant="info">
OAuth 访
</StatusNote>
) : null}
{phase === "error" && error ? ( {phase === "error" && error ? (
<p className="mt-3 text-sm text-conflict" role="alert"> <StatusNote className="mt-3" variant="danger" role="alert">
{error} {error}
</p> </StatusNote>
) : null} ) : null}
</div> </div>
</section> </section>
@@ -102,23 +120,22 @@ function DeviceInstructions({ userCode, openUrl }: DeviceInstructionsProps) {
return ( return (
<div className="motion-safe:transition-opacity mt-4 rounded border border-dashed border-line bg-bg p-4"> <div className="motion-safe:transition-opacity mt-4 rounded border border-dashed border-line bg-bg p-4">
<p className="mb-2 text-sm text-ink-soft"> <StatusNote className="mb-3" variant="info">
</p> </StatusNote>
<div className="grid gap-3 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center">
<output <output
aria-label="设备授权码" aria-label="设备授权码"
tabIndex={0} tabIndex={0}
className="mb-3 block select-all rounded bg-panel px-4 py-3 text-center font-mono text-2xl tracking-[0.3em] text-ink" className="block select-all rounded bg-panel px-4 py-3 text-center font-mono text-2xl tracking-[0.3em] text-ink"
> >
{userCode} {userCode}
</output> </output>
<button <Button onClick={openAuth} variant="outline" size="sm">
type="button" <ExternalLink className="h-4 w-4" aria-hidden="true" />
onClick={openAuth}
className="rounded border border-cinnabar px-3 py-1.5 text-sm text-cinnabar" </Button>
> </div>
</button>
<p className="mt-2 text-xs text-ink-soft"> <p className="mt-2 text-xs text-ink-soft">
</p> </p>

View File

@@ -1,10 +1,26 @@
"use client"; "use client";
import { useState } from "react"; import {
CheckCircle2,
KeyRound,
PlugZap,
Route,
Save,
XCircle,
} from "lucide-react";
import { useState, type ReactNode } from "react";
import { api } from "@/lib/api/client";
import { useToast } from "@/components/Toast"; import { useToast } from "@/components/Toast";
import { KimiCodeOauth } from "@/components/settings/KimiCodeOauth"; import { KimiCodeOauth } from "@/components/settings/KimiCodeOauth";
import { Badge } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button";
import { EmptyState } from "@/components/ui/EmptyState";
import { SectionHeader } from "@/components/ui/SectionHeader";
import { SegmentedControl } from "@/components/ui/SegmentedControl";
import { Select } from "@/components/ui/Select";
import { StatusNote } from "@/components/ui/StatusNote";
import { TextInput } from "@/components/ui/TextInput";
import { api } from "@/lib/api/client";
import { import {
API_KEY_PROVIDERS, API_KEY_PROVIDERS,
KNOWN_PROVIDERS, KNOWN_PROVIDERS,
@@ -30,12 +46,22 @@ interface TestResult {
capabilities: CapabilitiesView; capabilities: CapabilitiesView;
} }
type SettingsSection = "routing" | "oauth" | "keys";
const SECTION_OPTIONS: Array<{ value: SettingsSection; label: string }> = [
{ value: "routing", label: "路由" },
{ value: "oauth", label: "OAuth" },
{ value: "keys", label: "API Key" },
];
// 设置页主体UX §6.10):档位路由(可编辑)+ API-key 凭据行 + Kimi Code OAuth 连接区。 // 设置页主体UX §6.10):档位路由(可编辑)+ API-key 凭据行 + Kimi Code OAuth 连接区。
export function ProvidersSettings({ export function ProvidersSettings({
initial, initial,
kimiOauth, kimiOauth,
}: ProvidersSettingsProps) { }: ProvidersSettingsProps) {
const toast = useToast(); const toast = useToast();
const [activeSection, setActiveSection] =
useState<SettingsSection>("routing");
const [providers, setProviders] = useState(initial.providers ?? []); const [providers, setProviders] = useState(initial.providers ?? []);
const [routing, setRouting] = useState<RoutingDraft[]>( const [routing, setRouting] = useState<RoutingDraft[]>(
toRoutingDrafts(initial.tier_routing ?? []), toRoutingDrafts(initial.tier_routing ?? []),
@@ -116,26 +142,73 @@ export function ProvidersSettings({
}; };
return ( return (
<div> <div className="grid gap-6 lg:grid-cols-[12rem_1fr]">
<section className="mb-10"> <aside className="hidden lg:block">
<h2 className="mb-3 font-serif text-xl text-ink"></h2> <nav
<ul className="divide-y divide-line rounded border border-line bg-panel"> aria-label="设置分组"
className="sticky top-4 rounded border border-line bg-panel p-2"
>
<SettingsNavButton
active={activeSection === "routing"}
icon={<Route className="h-4 w-4" aria-hidden="true" />}
label="档位路由"
detail={`${routing.filter((r) => r.provider && r.model).length}/3 已配置`}
onClick={() => setActiveSection("routing")}
/>
<SettingsNavButton
active={activeSection === "oauth"}
icon={<PlugZap className="h-4 w-4" aria-hidden="true" />}
label="OAuth"
detail={kimiOauth.connected ? "Kimi 已连接" : "未连接"}
onClick={() => setActiveSection("oauth")}
/>
<SettingsNavButton
active={activeSection === "keys"}
icon={<KeyRound className="h-4 w-4" aria-hidden="true" />}
label="API Key"
detail={`${providers.length} 个凭据`}
onClick={() => setActiveSection("keys")}
/>
</nav>
</aside>
<div className="min-w-0">
<SegmentedControl
options={SECTION_OPTIONS}
value={activeSection}
onChange={setActiveSection}
ariaLabel="设置分组"
className="mb-4 w-full justify-center lg:hidden"
/>
{activeSection === "routing" ? (
<SettingsPanel>
<SectionHeader
title="能力档位路由"
description="写手、分析、轻量三类能力可分别指向不同模型。保存时只提交完整填写的行。"
/>
<StatusNote className="mt-3" variant="info">
稿
</StatusNote>
<ul className="mt-4 divide-y divide-line rounded border border-line bg-panel">
{routing.map((row) => ( {routing.map((row) => (
<li <li
key={row.tier} key={row.tier}
className="flex flex-wrap items-center gap-3 px-4 py-3 text-sm" className="grid gap-3 px-4 py-3 text-sm md:grid-cols-[6rem_minmax(10rem,14rem)_1fr]"
> >
<span className="w-20 text-ink"> <span className="self-center text-ink">
{TIER_LABELS[row.tier] ?? row.tier} {TIER_LABELS[row.tier] ?? row.tier}
</span> </span>
<label className="sr-only" htmlFor={`route-provider-${row.tier}`}> <label
className="sr-only"
htmlFor={`route-provider-${row.tier}`}
>
{TIER_LABELS[row.tier] ?? row.tier} {TIER_LABELS[row.tier] ?? row.tier}
</label> </label>
<select <Select
id={`route-provider-${row.tier}`} id={`route-provider-${row.tier}`}
value={row.provider} value={row.provider}
onChange={(e) => updateRouting(row.tier, e.target.value)} onChange={(e) => updateRouting(row.tier, e.target.value)}
className="rounded border border-line bg-bg px-2 py-1.5 text-sm text-ink focus:border-cinnabar focus:outline-none"
> >
<option value=""></option> <option value=""></option>
{KNOWN_PROVIDERS.map((p) => ( {KNOWN_PROVIDERS.map((p) => (
@@ -143,66 +216,99 @@ export function ProvidersSettings({
{p.label} {p.label}
</option> </option>
))} ))}
</select> </Select>
<label className="sr-only" htmlFor={`route-model-${row.tier}`}> <label className="sr-only" htmlFor={`route-model-${row.tier}`}>
{TIER_LABELS[row.tier] ?? row.tier} {TIER_LABELS[row.tier] ?? row.tier}
</label> </label>
<input <TextInput
id={`route-model-${row.tier}`} id={`route-model-${row.tier}`}
value={row.model} value={row.model}
onChange={(e) => updateRoutingModel(row.tier, e.target.value)} onChange={(e) =>
updateRoutingModel(row.tier, e.target.value)
}
placeholder="model" placeholder="model"
className="min-w-[10rem] flex-1 rounded border border-line bg-bg px-3 py-1.5 font-mono text-sm text-ink focus:border-cinnabar focus:outline-none" className="font-mono"
/> />
</li> </li>
))} ))}
</ul> </ul>
<div className="mt-3 flex justify-end"> <div className="mt-3 flex justify-end">
<button <Button
type="button"
onClick={() => void saveRouting()} onClick={() => void saveRouting()}
disabled={savingRouting} disabled={savingRouting}
className="rounded bg-cinnabar px-3 py-1.5 text-sm text-panel disabled:opacity-40" variant="primary"
size="sm"
> >
<Save className="h-4 w-4" aria-hidden="true" />
{savingRouting ? "保存中…" : "保存档位路由"} {savingRouting ? "保存中…" : "保存档位路由"}
</button> </Button>
</div> </div>
</section> </SettingsPanel>
) : null}
{activeSection === "oauth" ? (
<SettingsPanel>
<KimiCodeOauth <KimiCodeOauth
initialConnected={kimiOauth.connected} initialConnected={kimiOauth.connected}
initialExpiresAt={kimiOauth.expiresAt} initialExpiresAt={kimiOauth.expiresAt}
/> />
</SettingsPanel>
<section>
<h2 className="mb-3 font-serif text-xl text-ink"></h2>
{providers.length === 0 ? (
<p className="mb-4 rounded border border-dashed border-line bg-panel p-4 text-sm text-ink-soft">
Anthropic /
DeepSeek
</p>
) : null} ) : null}
<ul className="divide-y divide-line rounded border border-line bg-panel">
{activeSection === "keys" ? (
<SettingsPanel>
<SectionHeader
title="提供商凭据"
description="API Key 只用于后端探活和调用,列表中只显示脱敏后的已保存凭据。"
/>
<div className="mt-3 grid gap-2 text-xs text-ink-soft sm:grid-cols-3">
<div className="rounded border border-line bg-panel px-3 py-2">
<span className="font-mono text-ink">{providers.length}</span>
</div>
<div className="rounded border border-line bg-panel px-3 py-2">
{" "}
<span className="font-mono text-ink">
{API_KEY_PROVIDERS.length}
</span>
</div>
<div className="rounded border border-line bg-panel px-3 py-2">
{" "}
<span className="font-mono text-ink">
{Object.keys(results).length}
</span>
</div>
</div>
{providers.length === 0 ? (
<EmptyState
icon={PlugZap}
title="还没有可用提供商"
description="至少连接一个提供商即可开始写作。求质量可选 Anthropic求性价比可选 DeepSeek。"
className="mt-4"
/>
) : null}
<ul className="mt-4 divide-y divide-line rounded border border-line bg-panel">
{API_KEY_PROVIDERS.map((prov) => { {API_KEY_PROVIDERS.map((prov) => {
const masked = maskedFor(prov.id); const masked = maskedFor(prov.id);
const result = results[prov.id]; const result = results[prov.id];
return ( return (
<li key={prov.id} className="px-4 py-4"> <li key={prov.id} className="px-4 py-4">
<div className="flex flex-wrap items-center gap-3"> <div className="grid gap-3 lg:grid-cols-[10rem_8rem_minmax(12rem,1fr)_auto_auto] lg:items-center">
<span className="flex items-center gap-2 text-sm text-ink">
<span <span
className={`h-2 w-2 rounded-full ${masked ? "bg-pass" : "bg-line"}`} className={`h-2 w-2 rounded ${
masked ? "bg-pass" : "bg-line"
}`}
aria-hidden="true" aria-hidden="true"
/> />
<span className="w-28 text-sm text-ink">{prov.label}</span> {prov.label}
{masked ? ( </span>
<span className="font-mono text-xs text-ink-soft"> <span className="font-mono text-xs text-ink-soft">
{masked} {masked ?? "未配置"}
</span> </span>
) : null}
<label className="sr-only" htmlFor={`key-${prov.id}`}> <label className="sr-only" htmlFor={`key-${prov.id}`}>
{prov.label} API Key {prov.label} API Key
</label> </label>
<input <TextInput
id={`key-${prov.id}`} id={`key-${prov.id}`}
type="password" type="password"
autoComplete="off" autoComplete="off"
@@ -213,37 +319,54 @@ export function ProvidersSettings({
[prov.id]: e.target.value, [prov.id]: e.target.value,
})) }))
} }
placeholder={masked ? "输入新 Key 以更新" : "输入 API Key"} placeholder={
className="min-w-[12rem] flex-1 rounded border border-line bg-bg px-3 py-1.5 text-sm text-ink focus:border-cinnabar focus:outline-none" masked ? "输入新 Key 以更新" : "输入 API Key"
}
/> />
<button <Button
type="button"
onClick={() => saveCredential(prov.id)} onClick={() => saveCredential(prov.id)}
disabled={savingId === prov.id} disabled={
className="rounded bg-cinnabar px-3 py-1.5 text-sm text-panel disabled:opacity-40" savingId === prov.id ||
(drafts[prov.id] ?? "").trim().length === 0
}
variant="primary"
size="sm"
> >
<Save className="h-4 w-4" aria-hidden="true" />
{savingId === prov.id {savingId === prov.id
? "保存中…" ? "保存中…"
: masked : masked
? "更新凭据" ? "更新"
: "添加凭据"} : "添加"}
</button> </Button>
<button <Button
type="button"
onClick={() => testConnection(prov.id)} onClick={() => testConnection(prov.id)}
disabled={testingId === prov.id} disabled={testingId === prov.id}
className="rounded border border-line px-3 py-1.5 text-sm text-ink disabled:opacity-40" variant="secondary"
size="sm"
> >
{testingId === prov.id ? "测试中…" : "测试连接"} <PlugZap className="h-4 w-4" aria-hidden="true" />
</button> {testingId === prov.id ? "测试中…" : "测试"}
</Button>
</div> </div>
<p className="mt-2 text-xs leading-5 text-ink-soft lg:pl-[10.5rem]">
{masked
? "已保存脱敏凭据;输入新 Key 可覆盖更新。"
: "保存后再测试连接,成功后即可在档位路由中使用。"}
</p>
{result ? ( {result ? (
<div className="mt-2 flex items-center gap-2 pl-5"> <div className="mt-2 flex flex-wrap items-center gap-2 lg:pl-[10.5rem]">
<span <Badge variant={result.ok ? "success" : "danger"}>
className={`text-xs ${result.ok ? "text-pass" : "text-conflict"}`} {result.ok ? (
> <CheckCircle2
{result.ok ? "✓ 已连接" : "✗ 未连接"} className="h-3 w-3"
</span> aria-hidden="true"
/>
) : (
<XCircle className="h-3 w-3" aria-hidden="true" />
)}
{result.ok ? "已连接" : "未连接"}
</Badge>
<CapabilityBadges caps={result.capabilities} /> <CapabilityBadges caps={result.capabilities} />
</div> </div>
) : null} ) : null}
@@ -251,8 +374,49 @@ export function ProvidersSettings({
); );
})} })}
</ul> </ul>
</section> </SettingsPanel>
) : null}
</div> </div>
</div>
);
}
function SettingsPanel({ children }: { children: ReactNode }) {
return <section className="min-w-0">{children}</section>;
}
interface SettingsNavButtonProps {
active: boolean;
icon: ReactNode;
label: string;
detail: string;
onClick: () => void;
}
function SettingsNavButton({
active,
icon,
label,
detail,
onClick,
}: SettingsNavButtonProps) {
return (
<button
type="button"
aria-pressed={active}
onClick={onClick}
className={`mb-1 flex w-full items-start gap-2 rounded px-3 py-2 text-left transition-colors ${
active
? "bg-[var(--color-cinnabar-wash)] text-cinnabar"
: "text-ink hover:bg-bg hover:text-cinnabar"
}`}
>
<span className="mt-0.5 shrink-0">{icon}</span>
<span className="min-w-0">
<span className="block text-sm font-medium">{label}</span>
<span className="block truncate text-xs text-ink-soft">{detail}</span>
</span>
</button>
); );
} }
@@ -267,10 +431,10 @@ function CapabilityBadges({ caps }: { caps: CapabilitiesView }) {
{badges.map((b) => ( {badges.map((b) => (
<span <span
key={b.label} key={b.label}
className={`rounded px-2 py-0.5 text-[11px] ${ className={`rounded border px-2 py-0.5 text-[11px] ${
b.on b.on
? "bg-[var(--color-cinnabar-wash)] text-cinnabar" ? "border-cinnabar/20 bg-[var(--color-cinnabar-wash)] text-cinnabar"
: "bg-bg text-ink-soft/50" : "border-line bg-bg text-ink-soft/50"
}`} }`}
> >
{b.label} {b.label}

View File

@@ -1,6 +1,20 @@
import type { LucideIcon } from "lucide-react";
import { Blocks, Database, Pencil, ShieldCheck } from "lucide-react";
import { AppShell } from "@/components/AppShell"; import { AppShell } from "@/components/AppShell";
import { Badge } from "@/components/ui/Badge";
import { Card } from "@/components/ui/Card";
import { EmptyState } from "@/components/ui/EmptyState";
import { PageHeader } from "@/components/ui/PageHeader";
import { SectionHeader } from "@/components/ui/SectionHeader";
import { StatusNote } from "@/components/ui/StatusNote";
import type { ProjectResponse, SkillView } from "@/lib/api/types"; import type { ProjectResponse, SkillView } from "@/lib/api/types";
import { groupByScope, scopeLabel, tierLabel } from "@/lib/skills/skills"; import {
groupByScope,
scopeLabel,
summarizeSkills,
tierLabel,
} from "@/lib/skills/skills";
interface SkillsPageProps { interface SkillsPageProps {
project: ProjectResponse; project: ProjectResponse;
@@ -10,6 +24,7 @@ interface SkillsPageProps {
// 技能库UX §7只读注册表视图name/scope/tier/reads/writes。Server Component纯读 // 技能库UX §7只读注册表视图name/scope/tier/reads/writes。Server Component纯读
export function SkillsPage({ project, skills }: SkillsPageProps) { export function SkillsPage({ project, skills }: SkillsPageProps) {
const groups = groupByScope(skills); const groups = groupByScope(skills);
const summary = summarizeSkills(skills);
return ( return (
<AppShell <AppShell
@@ -18,60 +33,161 @@ export function SkillsPage({ project, skills }: SkillsPageProps) {
projectId={project.id} projectId={project.id}
activeNav="skills" activeNav="skills"
> >
<div className="mx-auto flex max-w-4xl flex-col gap-6 p-6"> <div className="mx-auto flex max-w-6xl flex-col gap-6 p-6">
<h1 className="font-serif text-lg text-ink"></h1> <PageHeader
<p className="text-xs text-ink-soft"> title="技能库"
Skill / description="声明式 Skill 注册表:每个技能声明能力档位与可读/写的表,越权产出会被丢弃并审计。"
</p> />
{groups.length === 0 ? ( <div className="grid gap-6 lg:grid-cols-[18rem_minmax(0,1fr)]">
<p className="text-sm text-ink-soft"></p> <aside className="flex flex-col gap-4 lg:sticky lg:top-[8rem] lg:self-start">
<Card as="section" className="p-4">
<SectionHeader
title="注册表概览"
description="按来源、档位和表权限汇总当前可用技能。"
/>
<dl className="mt-4 grid grid-cols-3 gap-2 text-center">
<div className="rounded border border-line bg-paper p-3">
<dt className="text-xs text-ink-soft"></dt>
<dd className="mt-1 font-serif text-2xl text-ink">
{summary.total}
</dd>
</div>
<div className="rounded border border-line bg-paper p-3">
<dt className="text-xs text-ink-soft"></dt>
<dd className="mt-1 font-serif text-2xl text-ink">
{summary.writableCount}
</dd>
</div>
<div className="rounded border border-line bg-paper p-3">
<dt className="text-xs text-ink-soft"></dt>
<dd className="mt-1 font-serif text-2xl text-ink">
{summary.readonlyCount}
</dd>
</div>
</dl>
<div className="mt-4 flex flex-wrap gap-2">
{summary.scopes.length > 0 ? (
summary.scopes.map((item) => (
<Badge key={item.key} variant="info">
{scopeLabel(item.key)} {item.count}
</Badge>
))
) : ( ) : (
groups.map((group) => ( <span className="text-xs text-ink-soft"></span>
<section key={group.scope}> )}
<h2 className="mb-2 font-serif text-sm text-ink"> </div>
{scopeLabel(group.scope)} </Card>
<span className="ml-2 text-xs text-ink-soft">
{group.skills.length} <Card as="section" className="p-4">
</span> <SectionHeader title="能力档位" />
</h2> {summary.tiers.length > 0 ? (
<ul className="flex flex-col gap-2"> <ul className="mt-3 flex flex-col gap-2 text-sm">
{group.skills.map((skill) => ( {summary.tiers.map((item) => (
<li <li
key={skill.name} key={item.key}
className="rounded border border-line bg-panel p-3 text-sm" className="flex items-center justify-between border-b border-line/70 py-2 last:border-b-0"
> >
<div className="mb-1 flex flex-wrap items-center gap-2"> <span className="text-ink">{tierLabel(item.key)}</span>
<span className="font-mono text-ink">{skill.name}</span> <span className="font-mono text-xs text-ink-soft">
<span className="rounded bg-bg px-2 py-0.5 text-xs text-ink-soft"> {item.count}
{tierLabel(skill.tier)}
</span> </span>
{skill.genre ? (
<span className="rounded bg-bg px-2 py-0.5 text-xs text-ink-soft">
{skill.genre}
</span>
) : null}
</div>
<div className="flex flex-wrap gap-4 text-xs text-ink-soft">
<span>
{skill.reads && skill.reads.length > 0
? skill.reads.join("、")
: "—"}
</span>
<span>
{skill.writes && skill.writes.length > 0
? skill.writes.join("、")
: "—"}
</span>
</div>
</li> </li>
))} ))}
</ul> </ul>
</section> ) : (
)) <p className="mt-3 text-sm text-ink-soft"></p>
)} )}
</Card>
<StatusNote title="写入边界" variant="info">
<p className="text-sm leading-6">
{formatTables(summary.writableTables)}
</p>
<p className="mt-1 text-xs leading-5 text-ink-soft">
稿
</p>
</StatusNote>
</aside>
<div className="flex min-w-0 flex-col gap-5">
{groups.length === 0 ? (
<EmptyState
icon={Blocks}
title="暂无已注册技能"
description="技能注册后会在这里按作用域展示能力档位、可读表与可写表。"
/>
) : (
<>
{groups.map((group) => (
<section key={group.scope}>
<SectionHeader
title={scopeLabel(group.scope)}
description={`${group.skills.length} 个技能`}
/>
<ul className="mt-3 grid gap-3">
{group.skills.map((skill) => (
<SkillListItem key={skill.name} skill={skill} />
))}
</ul>
</section>
))}
</>
)}
</div>
</div>
</div> </div>
</AppShell> </AppShell>
); );
} }
function SkillListItem({ skill }: { skill: SkillView }) {
return (
<li className="rounded border border-line bg-panel p-4 text-sm shadow-paper">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="break-all font-mono text-ink">{skill.name}</span>
<Badge variant="info">{tierLabel(skill.tier)}</Badge>
{skill.genre ? <Badge>{skill.genre}</Badge> : null}
</div>
<div className="mt-3 grid gap-2 text-xs text-ink-soft md:grid-cols-2">
<TableLine
icon={Database}
label="读"
tables={skill.reads ?? []}
/>
<TableLine icon={Pencil} label="写" tables={skill.writes ?? []} />
</div>
</div>
<div className="flex shrink-0 items-center gap-2 text-xs text-ink-soft">
<ShieldCheck className="h-3.5 w-3.5" aria-hidden="true" />
<span>
{skill.writes && skill.writes.length > 0 ? "受控写入" : "只读"}
</span>
</div>
</div>
</li>
);
}
function TableLine({
icon: Icon,
label,
tables,
}: {
icon: LucideIcon;
label: string;
tables: readonly string[];
}) {
return (
<span className="flex min-w-0 items-start gap-1.5">
<Icon className="mt-0.5 h-3.5 w-3.5 shrink-0" aria-hidden="true" />
<span className="shrink-0">{label}</span>
<span className="min-w-0 break-words">{formatTables(tables)}</span>
</span>
);
}
function formatTables(tables: readonly string[]): string {
return tables.length > 0 ? tables.join("、") : "—";
}

View File

@@ -1,9 +1,12 @@
"use client"; "use client";
import { useRefine } from "@/lib/style/useRefine"; import { CheckCircle2, X } from "lucide-react";
import { useEffect } from "react"; import { useEffect } from "react";
import { ThinkingIndicator } from "@/components/ThinkingIndicator"; import { ThinkingIndicator } from "@/components/ThinkingIndicator";
import { Button } from "@/components/ui/Button";
import { Badge } from "@/components/ui/Badge";
import { useRefine } from "@/lib/style/useRefine";
interface RefineViewProps { interface RefineViewProps {
projectId: string; projectId: string;
@@ -46,15 +49,17 @@ export function RefineView({
<button <button
type="button" type="button"
onClick={onClose} onClick={onClose}
className="text-ink-soft hover:text-cinnabar" className="rounded border border-transparent p-1 text-ink-soft hover:border-line hover:text-cinnabar"
aria-label="关闭回炉对比" aria-label="关闭回炉对比"
> >
<X className="h-4 w-4" aria-hidden="true" />
</button> </button>
</div> </div>
{segment.trim().length === 0 ? ( {segment.trim().length === 0 ? (
<p className="text-overdue">稿稿</p> <Badge variant="warning">
稿稿
</Badge>
) : refiner.status === "refining" ? ( ) : refiner.status === "refining" ? (
<p className="text-info"> <p className="text-info">
<ThinkingIndicator label="回炉中" /> <ThinkingIndicator label="回炉中" />
@@ -76,24 +81,21 @@ export function RefineView({
</p> </p>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<button <Button
type="button"
onClick={() => { onClick={() => {
if (refiner.result) { if (refiner.result) {
onAdopt(refiner.result.original, refiner.result.refined); onAdopt(refiner.result.original, refiner.result.refined);
} }
}} }}
className="rounded bg-cinnabar px-3 py-1.5 text-panel hover:opacity-90" variant="primary"
size="sm"
> >
<CheckCircle2 className="h-4 w-4" aria-hidden="true" />
</button> </Button>
<button <Button onClick={onClose} variant="secondary" size="sm">
type="button"
onClick={onClose}
className="rounded border border-line px-3 py-1.5 text-ink hover:border-cinnabar"
>
</button> </Button>
</div> </div>
</div> </div>
) : null} ) : null}

View File

@@ -1,5 +1,9 @@
"use client"; "use client";
import { CheckCircle2, CircleDashed, RotateCcw } from "lucide-react";
import { Badge } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button";
import type { StyleDriftReport, StyleDriftSegment } from "@/lib/review/sse"; import type { StyleDriftReport, StyleDriftSegment } from "@/lib/review/sse";
interface StylePanelProps { interface StylePanelProps {
@@ -26,7 +30,10 @@ export function StylePanel({ style, incomplete, onRefine }: StylePanelProps) {
(style-auditor) (style-auditor)
</h2> </h2>
{incomplete ? ( {incomplete ? (
<p className="mt-1 text-xs text-overdue"> </p> <Badge variant="warning" className="mt-1">
<CircleDashed className="h-3 w-3" aria-hidden="true" />
</Badge>
) : style === null ? ( ) : style === null ? (
<p className="mt-1 text-xs text-ink-soft"> <p className="mt-1 text-xs text-ink-soft">
@@ -47,7 +54,10 @@ export function StylePanel({ style, incomplete, onRefine }: StylePanelProps) {
</p> </p>
{style.segments.length === 0 ? ( {style.segments.length === 0 ? (
<p className="text-pass"> </p> <Badge variant="success">
<CheckCircle2 className="h-3 w-3" aria-hidden="true" />
</Badge>
) : ( ) : (
<ul className="space-y-2"> <ul className="space-y-2">
{style.segments.map((seg, i) => ( {style.segments.map((seg, i) => (
@@ -57,9 +67,9 @@ export function StylePanel({ style, incomplete, onRefine }: StylePanelProps) {
data-testid="drift-segment" data-testid="drift-segment"
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="font-mono text-overdue"> <Badge variant="warning" className="font-mono">
{seg.idx} {seg.idx}
</span> </Badge>
<span className="font-mono text-ink-soft"> <span className="font-mono text-ink-soft">
{seg.score}% {seg.score}%
</span> </span>
@@ -68,13 +78,14 @@ export function StylePanel({ style, incomplete, onRefine }: StylePanelProps) {
) : null} ) : null}
</div> </div>
<div className="mt-1.5 flex gap-2"> <div className="mt-1.5 flex gap-2">
<button <Button
type="button"
onClick={() => onRefine(seg)} onClick={() => onRefine(seg)}
className="rounded bg-cinnabar px-2 py-1 text-panel hover:opacity-90" variant="primary"
size="sm"
> >
<RotateCcw className="h-4 w-4" aria-hidden="true" />
</button> </Button>
</div> </div>
</li> </li>
))} ))}

View File

@@ -4,6 +4,10 @@ import { useCallback, useState, type ChangeEvent } from "react";
import { hasUsableSamples, type StyleLearnMode } from "@/lib/style/style"; import { hasUsableSamples, type StyleLearnMode } from "@/lib/style/style";
import { useToast } from "@/components/Toast"; import { useToast } from "@/components/Toast";
import { Button } from "@/components/ui/Button";
import { Field } from "@/components/ui/Field";
import { StatusNote } from "@/components/ui/StatusNote";
import { TextArea } from "@/components/ui/TextArea";
interface StyleUploadProps { interface StyleUploadProps {
busy: boolean; busy: boolean;
@@ -61,21 +65,15 @@ export function StyleUpload({
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<div> <Field label="样本正文(空行分段;可粘贴多段)" htmlFor="style-samples">
<label <TextArea
htmlFor="style-samples"
className="mb-1 block text-sm font-semibold text-ink"
>
</label>
<textarea
id="style-samples" id="style-samples"
value={text} value={text}
onChange={(e) => setText(e.target.value)} onChange={(e) => setText(e.target.value)}
placeholder="粘贴你想学习文风的章节正文…" placeholder="粘贴你想学习文风的章节正文…"
className="min-h-[30vh] w-full resize-y rounded border border-line bg-panel p-3 font-serif text-[15px] leading-[1.9] text-ink focus:border-cinnabar focus:outline-none" className="min-h-[30vh] bg-panel font-serif text-[15px] leading-[1.9]"
/> />
</div> </Field>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<label className="cursor-pointer rounded border border-line px-3 py-1.5 text-sm text-ink hover:border-cinnabar hover:text-cinnabar"> <label className="cursor-pointer rounded border border-line px-3 py-1.5 text-sm text-ink hover:border-cinnabar hover:text-cinnabar">
@@ -88,22 +86,17 @@ export function StyleUpload({
className="sr-only" className="sr-only"
/> />
</label> </label>
<button <Button type="button" onClick={submit} disabled={busy} variant="primary">
type="button"
onClick={submit}
disabled={busy}
className="rounded bg-cinnabar px-4 py-1.5 text-sm text-panel hover:opacity-90 disabled:opacity-50"
>
{hasFingerprint ? "重新学习文风" : "学习文风"} {hasFingerprint ? "重新学习文风" : "学习文风"}
</button> </Button>
</div> </div>
{pollStatus === "polling" ? ( {pollStatus === "polling" ? (
<p className="text-xs text-info" aria-live="polite"> <StatusNote variant="info" aria-live="polite">
{progress}% {progress}%
</p> </StatusNote>
) : pollStatus === "error" ? ( ) : pollStatus === "error" ? (
<p className="text-xs text-conflict"></p> <StatusNote variant="danger"></StatusNote>
) : null} ) : null}
</div> </div>
); );

View File

@@ -3,6 +3,11 @@
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { useToast } from "@/components/Toast"; import { useToast } from "@/components/Toast";
import { Button } from "@/components/ui/Button";
import { Field } from "@/components/ui/Field";
import { SectionHeader } from "@/components/ui/SectionHeader";
import { TextArea } from "@/components/ui/TextArea";
import { TextInput } from "@/components/ui/TextInput";
import { api } from "@/lib/api/client"; import { api } from "@/lib/api/client";
import type { TemplateResponse } from "@/lib/api/types"; import type { TemplateResponse } from "@/lib/api/types";
import { import {
@@ -89,56 +94,49 @@ export function TemplatesManager({ initial }: TemplatesManagerProps) {
void onCreate(); void onCreate();
}} }}
> >
<h2 className="font-serif text-lg text-ink"></h2> <SectionHeader title="新建模板" />
<label className="block text-sm text-ink-soft"> <Field label="标题" required>
* <TextInput
<input
type="text" type="text"
value={draft.title} value={draft.title}
onChange={(e) => setField("title", e.target.value)} onChange={(e) => setField("title", e.target.value)}
className="mt-1 w-full rounded border border-line bg-bg px-3 py-2 text-sm text-ink"
aria-label="标题" aria-label="标题"
/> />
</label> </Field>
<label className="block text-sm text-ink-soft"> <Field label="正文" required help="一键填入生成器的 brief/原文。">
* brief/ <TextArea
<textarea
value={draft.body} value={draft.body}
onChange={(e) => setField("body", e.target.value)} onChange={(e) => setField("body", e.target.value)}
rows={4} rows={4}
className="mt-1 w-full resize-y rounded border border-line bg-bg px-3 py-2 text-sm text-ink"
aria-label="正文" aria-label="正文"
/> />
</label> </Field>
<div className="flex flex-wrap gap-4"> <div className="flex flex-wrap gap-4">
<label className="block text-sm text-ink-soft"> <Field label="分类(可选)" className="w-full sm:w-48">
<TextInput
<input
type="text" type="text"
value={draft.category} value={draft.category}
onChange={(e) => setField("category", e.target.value)} onChange={(e) => setField("category", e.target.value)}
className="mt-1 w-48 rounded border border-line bg-bg px-3 py-2 text-sm text-ink"
aria-label="分类" aria-label="分类"
/> />
</label> </Field>
<label className="block text-sm text-ink-soft"> <Field label="关联生成器 key可选" className="w-full sm:w-48">
key <TextInput
<input
type="text" type="text"
value={draft.toolKey} value={draft.toolKey}
onChange={(e) => setField("toolKey", e.target.value)} onChange={(e) => setField("toolKey", e.target.value)}
className="mt-1 w-48 rounded border border-line bg-bg px-3 py-2 text-sm text-ink"
aria-label="关联生成器 key" aria-label="关联生成器 key"
/> />
</label> </Field>
</div> </div>
<button <Button
type="submit" type="submit"
disabled={creating || !isTemplateDraftValid(draft)} disabled={creating || !isTemplateDraftValid(draft)}
className="self-start rounded bg-cinnabar px-4 py-2 text-sm text-white disabled:opacity-50" variant="primary"
className="self-start"
> >
{creating ? "新建中…" : "新建模板"} {creating ? "新建中…" : "新建模板"}
</button> </Button>
</form> </form>
<section className="flex flex-col gap-3" aria-label="模板列表"> <section className="flex flex-col gap-3" aria-label="模板列表">
@@ -163,14 +161,16 @@ export function TemplatesManager({ initial }: TemplatesManagerProps) {
{t.body} {t.body}
</p> </p>
</div> </div>
<button <Button
type="button" type="button"
onClick={() => void onDelete(t.id)} onClick={() => void onDelete(t.id)}
className="shrink-0 rounded border border-line px-3 py-1 text-ink-soft hover:text-conflict" variant="danger"
size="sm"
className="shrink-0"
aria-label={`删除模板 ${t.title}`} aria-label={`删除模板 ${t.title}`}
> >
</button> </Button>
</li> </li>
))} ))}
</ul> </ul>

View File

@@ -1,9 +1,15 @@
"use client"; "use client";
import { useCallback, useMemo, useState } from "react"; import { useCallback, useMemo, useState } from "react";
import { ArrowLeft, Database, Sparkles } from "lucide-react";
import { useToast } from "@/components/Toast"; import { useToast } from "@/components/Toast";
import { ConflictAdjudication } from "@/components/generation/ConflictAdjudication"; import { ConflictAdjudication } from "@/components/generation/ConflictAdjudication";
import { Button } from "@/components/ui/Button";
import { Badge } from "@/components/ui/Badge";
import { Field } from "@/components/ui/Field";
import { TextArea } from "@/components/ui/TextArea";
import { TextInput } from "@/components/ui/TextInput";
import type { ToolDescriptorView, ToolInputFieldView } from "@/lib/api/types"; import type { ToolDescriptorView, ToolInputFieldView } from "@/lib/api/types";
import { import {
buildGenerateRequest, buildGenerateRequest,
@@ -115,25 +121,30 @@ export function GeneratorRunner({
return ( return (
<section <section
className="flex flex-col gap-4 rounded border border-line bg-panel p-5" className="flex flex-col gap-5 rounded border border-line bg-panel p-5 shadow-paper"
aria-label={tool.title} aria-label={tool.title}
> >
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<div> <div>
<div className="mb-2 flex flex-wrap items-center gap-2">
<h2 className="font-serif text-lg text-ink">{tool.title}</h2> <h2 className="font-serif text-lg text-ink">{tool.title}</h2>
{tool.ingestable ? (
<Badge variant="info">
<Database className="h-3 w-3" aria-hidden="true" />
</Badge>
) : null}
</div>
<p className="mt-1 text-sm text-ink-soft">{tool.subtitle}</p> <p className="mt-1 text-sm text-ink-soft">{tool.subtitle}</p>
</div> </div>
<button <Button onClick={onClose} variant="secondary" size="sm">
type="button" <ArrowLeft className="h-4 w-4" aria-hidden="true" />
onClick={onClose}
className="rounded border border-line px-3 py-1 text-sm text-ink-soft hover:text-ink"
>
</button> </Button>
</div> </div>
<form <form
className="flex flex-col gap-3" className="grid gap-3 rounded border border-line bg-bg/50 p-4"
onSubmit={(e) => { onSubmit={(e) => {
e.preventDefault(); e.preventDefault();
void onGenerate(); void onGenerate();
@@ -148,13 +159,10 @@ export function GeneratorRunner({
onChange={(v) => setField(field.name, v)} onChange={(v) => setField(field.name, v)}
/> />
))} ))}
<button <Button type="submit" disabled={generating} variant="primary">
type="submit" <Sparkles className="h-4 w-4" aria-hidden="true" />
disabled={generating}
className="self-start rounded bg-cinnabar px-4 py-2 text-sm text-white disabled:opacity-50"
>
{generating ? "生成中…" : "生成"} {generating ? "生成中…" : "生成"}
</button> </Button>
</form> </form>
{gen.genStatus === "preview" && gen.preview ? ( {gen.genStatus === "preview" && gen.preview ? (
@@ -181,18 +189,18 @@ export function GeneratorRunner({
</ul> </ul>
{canIngest && gen.ingestStatus !== "conflict" ? ( {canIngest && gen.ingestStatus !== "conflict" ? (
<button <Button
type="button"
onClick={() => void runIngest(false)} onClick={() => void runIngest(false)}
disabled={ingesting || (!singleObject && selected.size === 0)} disabled={ingesting || (!singleObject && selected.size === 0)}
className="self-start rounded border border-cinnabar px-4 py-2 text-sm text-cinnabar disabled:opacity-50" variant="outline"
> >
<Database className="h-4 w-4" aria-hidden="true" />
{ingesting {ingesting
? "入库中…" ? "入库中…"
: singleObject : singleObject
? `入库为规则至 ${table}` ? `入库为规则至 ${table}`
: `入库选中(${selected.size})至 ${table}`} : `入库选中(${selected.size})至 ${table}`}
</button> </Button>
) : null} ) : null}
{gen.ingestStatus === "conflict" && gen.conflicts ? ( {gen.ingestStatus === "conflict" && gen.conflicts ? (
@@ -223,33 +231,24 @@ interface FormFieldProps {
// 声明式控件映射type → text / textarea / numberselect 暂同 text后端未给 options // 声明式控件映射type → text / textarea / numberselect 暂同 text后端未给 options
function FormField({ field, value, onChange }: FormFieldProps) { function FormField({ field, value, onChange }: FormFieldProps) {
const labelText = field.required ? `${field.label} *` : field.label;
const common =
"mt-1 w-full rounded border border-line bg-bg px-3 py-2 text-sm text-ink";
return ( return (
<label className="block text-sm text-ink-soft"> <Field label={field.label} help={field.help} required={field.required}>
{labelText}
{field.type === "textarea" ? ( {field.type === "textarea" ? (
<textarea <TextArea
value={value} value={value}
onChange={(e) => onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}
rows={3} rows={3}
className={`${common} resize-y`}
aria-label={field.label} aria-label={field.label}
/> />
) : ( ) : (
<input <TextInput
type={field.type === "number" ? "number" : "text"} type={field.type === "number" ? "number" : "text"}
value={value} value={value}
onChange={(e) => onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}
className={common}
aria-label={field.label} aria-label={field.label}
/> />
)} )}
{field.help ? ( </Field>
<span className="mt-1 block text-xs text-ink-soft/80">{field.help}</span>
) : null}
</label>
); );
} }

View File

@@ -1,6 +1,10 @@
"use client"; "use client";
import { ArrowRight, Database, Sparkles } from "lucide-react";
import { Badge } from "@/components/ui/Badge";
import type { ToolDescriptorView } from "@/lib/api/types"; import type { ToolDescriptorView } from "@/lib/api/types";
import { cardClass } from "@/lib/ui/variants";
interface ToolCardProps { interface ToolCardProps {
tool: ToolDescriptorView; tool: ToolDescriptorView;
@@ -15,27 +19,32 @@ export function ToolCard({ tool, onOpen }: ToolCardProps) {
<button <button
type="button" type="button"
onClick={() => onOpen(tool)} onClick={() => onOpen(tool)}
className="flex h-full min-h-[140px] flex-col rounded border border-line bg-panel p-5 text-left shadow-paper transition-colors hover:border-cinnabar focus-visible:border-cinnabar focus-visible:outline-none" className={cardClass(
"group flex h-full min-h-[164px] flex-col p-5 text-left transition-colors hover:border-cinnabar focus-visible:border-cinnabar focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cinnabar/30",
)}
> >
<div className="mb-2 flex flex-wrap items-center gap-2"> <div className="mb-2 flex flex-wrap items-center gap-2">
<h2 className="font-serif text-xl text-ink">{tool.title}</h2> <h2 className="font-serif text-xl text-ink">{tool.title}</h2>
{tool.genre ? ( {tool.genre ? <Badge>{tool.genre}</Badge> : null}
<span className="rounded bg-bg px-2 py-0.5 text-xs text-ink-soft"> {tool.is_legacy ? null : <Badge variant="accent"></Badge>}
{tool.genre}
</span>
) : null}
{tool.is_legacy ? null : (
<span className="rounded bg-[var(--color-cinnabar-wash)] px-2 py-0.5 text-xs text-cinnabar">
NEW
</span>
)}
{tool.ingestable ? ( {tool.ingestable ? (
<span className="rounded bg-bg px-2 py-0.5 text-xs text-ink-soft"> <Badge variant="info">
<Database className="h-3 w-3" aria-hidden="true" />
</span> </Badge>
) : null} ) : null}
</div> </div>
<p className="line-clamp-2 text-sm text-ink-soft">{tool.subtitle}</p> <p className="line-clamp-2 text-sm text-ink-soft">{tool.subtitle}</p>
<div className="mt-auto flex items-center gap-2 pt-4 text-xs text-ink-soft">
<Sparkles className="h-3.5 w-3.5 text-cinnabar" aria-hidden="true" />
<span className="truncate">
{tool.input_fields?.[0]?.label ?? "现有页面"}
</span>
<ArrowRight
className="ml-auto h-3.5 w-3.5 text-cinnabar opacity-0 transition-opacity group-hover:opacity-100"
aria-hidden="true"
/>
</div>
</button> </button>
); );
} }

View File

@@ -2,8 +2,11 @@
import { useCallback, useMemo, useState } from "react"; import { useCallback, useMemo, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Sparkles } from "lucide-react";
import { AppShell } from "@/components/AppShell"; import { AppShell } from "@/components/AppShell";
import { EmptyState } from "@/components/ui/EmptyState";
import { PageHeader } from "@/components/ui/PageHeader";
import type { ProjectResponse, ToolDescriptorView } from "@/lib/api/types"; import type { ProjectResponse, ToolDescriptorView } from "@/lib/api/types";
import { isLegacyTool, resolveLegacyRoute } from "@/lib/toolbox/toolbox"; import { isLegacyTool, resolveLegacyRoute } from "@/lib/toolbox/toolbox";
import { ToolCard } from "./ToolCard"; import { ToolCard } from "./ToolCard";
@@ -63,13 +66,11 @@ export function ToolboxPage({
activeNav="toolbox" activeNav="toolbox"
> >
<div className="mx-auto flex max-w-5xl flex-col gap-6 p-6"> <div className="mx-auto flex max-w-5xl flex-col gap-6 p-6">
<header> <PageHeader
<h1 className="font-serif text-lg text-ink"></h1> title="创作工具箱"
<p className="mt-1 text-xs text-ink-soft"> eyebrow="generator toolbox"
/ / / / / / / description="脑洞、书名、简介、名字、金手指、词条、黄金开篇与细纲。每个工具都先生成结构化预览,可入库内容会经过一致性预检。"
/>
</p>
</header>
{active ? ( {active ? (
<GeneratorRunner <GeneratorRunner
@@ -78,7 +79,11 @@ export function ToolboxPage({
onClose={() => setActive(null)} onClose={() => setActive(null)}
/> />
) : sorted.length === 0 ? ( ) : sorted.length === 0 ? (
<p className="text-sm text-ink-soft"></p> <EmptyState
icon={Sparkles}
title="暂无可用生成器"
description="后端暂未返回工具描述符。工具箱会在描述符可用后自动渲染卡片与输入表单。"
/>
) : ( ) : (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{sorted.map((tool) => ( {sorted.map((tool) => (

View File

@@ -0,0 +1,16 @@
import type { HTMLAttributes, ReactNode } from "react";
import { badgeClass, type BadgeVariant } from "@/lib/ui/variants";
interface BadgeProps extends HTMLAttributes<HTMLSpanElement> {
children: ReactNode;
variant?: BadgeVariant;
}
export function Badge({ children, className, variant, ...props }: BadgeProps) {
return (
<span className={badgeClass({ variant, className })} {...props}>
{children}
</span>
);
}

View File

@@ -0,0 +1,36 @@
import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from "react";
import {
buttonClass,
type ButtonSize,
type ButtonVariant,
} from "@/lib/ui/variants";
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
children: ReactNode;
variant?: ButtonVariant;
size?: ButtonSize;
}
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
{
children,
className,
variant,
size,
type = "button",
...props
},
ref,
) {
return (
<button
ref={ref}
type={type}
className={buttonClass({ variant, size, className })}
{...props}
>
{children}
</button>
);
});

View File

@@ -0,0 +1,21 @@
import type { HTMLAttributes, ReactNode } from "react";
import { cardClass } from "@/lib/ui/variants";
interface CardProps extends HTMLAttributes<HTMLElement> {
children: ReactNode;
as?: "article" | "div" | "section";
}
export function Card({
as: Component = "div",
children,
className,
...props
}: CardProps) {
return (
<Component className={cardClass(className)} {...props}>
{children}
</Component>
);
}

View File

@@ -0,0 +1,38 @@
import type { ReactNode } from "react";
import type { LucideIcon } from "lucide-react";
import { cn } from "@/lib/ui/variants";
interface EmptyStateProps {
icon: LucideIcon;
title: string;
description: string;
action?: ReactNode;
className?: string;
}
export function EmptyState({
icon: Icon,
title,
description,
action,
className,
}: EmptyStateProps) {
return (
<div
className={cn(
"rounded border border-dashed border-line bg-panel/70 px-6 py-10 text-center",
className,
)}
>
<div className="mx-auto mb-3 flex h-10 w-10 items-center justify-center rounded bg-[var(--color-cinnabar-wash)] text-cinnabar">
<Icon className="h-5 w-5" aria-hidden="true" />
</div>
<h2 className="font-serif text-lg text-ink">{title}</h2>
<p className="mx-auto mt-2 max-w-md text-sm leading-6 text-ink-soft">
{description}
</p>
{action ? <div className="mt-4">{action}</div> : null}
</div>
);
}

View File

@@ -0,0 +1,40 @@
import type { ReactNode } from "react";
import {
cn,
fieldErrorClass,
fieldHelpClass,
fieldLabelClass,
} from "@/lib/ui/variants";
interface FieldProps {
label: string;
children: ReactNode;
htmlFor?: string;
help?: ReactNode;
error?: ReactNode;
required?: boolean;
className?: string;
}
export function Field({
label,
children,
htmlFor,
help,
error,
required = false,
className,
}: FieldProps) {
const labelText = required ? `${label} *` : label;
return (
<div className={cn("space-y-1.5", className)}>
<label htmlFor={htmlFor} className={fieldLabelClass()}>
{labelText}
</label>
{children}
{error ? <p className={fieldErrorClass()}>{error}</p> : null}
{!error && help ? <p className={fieldHelpClass()}>{help}</p> : null}
</div>
);
}

View File

@@ -0,0 +1,38 @@
import type { ReactNode } from "react";
interface PageHeaderProps {
title: string;
eyebrow?: string;
description?: string;
actions?: ReactNode;
}
export function PageHeader({
title,
eyebrow,
description,
actions,
}: PageHeaderProps) {
return (
<header className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
{eyebrow ? (
<p className="mb-1 font-mono text-xs uppercase tracking-wide text-ink-soft/70">
{eyebrow}
</p>
) : null}
<h1 className="font-serif text-2xl text-ink">{title}</h1>
{description ? (
<p className="mt-2 max-w-2xl text-sm leading-6 text-ink-soft">
{description}
</p>
) : null}
</div>
{actions ? (
<div className="flex shrink-0 flex-wrap items-center gap-2">
{actions}
</div>
) : null}
</header>
);
}

View File

@@ -0,0 +1,29 @@
import type { ReactNode } from "react";
import { cn } from "@/lib/ui/variants";
interface SectionHeaderProps {
title: string;
description?: ReactNode;
action?: ReactNode;
className?: string;
}
export function SectionHeader({
title,
description,
action,
className,
}: SectionHeaderProps) {
return (
<div className={cn("flex items-start justify-between gap-4", className)}>
<div className="min-w-0">
<h2 className="font-serif text-base text-ink">{title}</h2>
{description ? (
<p className="mt-1 text-sm leading-6 text-ink-soft">{description}</p>
) : null}
</div>
{action ? <div className="shrink-0">{action}</div> : null}
</div>
);
}

View File

@@ -0,0 +1,48 @@
import type { ReactNode } from "react";
import { cn, segmentedClass } from "@/lib/ui/variants";
export interface SegmentOption<T extends string> {
value: T;
label: ReactNode;
}
interface SegmentedControlProps<T extends string> {
options: Array<SegmentOption<T>>;
value: T;
onChange: (value: T) => void;
ariaLabel: string;
className?: string;
}
export function SegmentedControl<T extends string>({
options,
value,
onChange,
ariaLabel,
className,
}: SegmentedControlProps<T>) {
return (
<div className={segmentedClass(className)} role="group" aria-label={ariaLabel}>
{options.map((option) => {
const selected = option.value === value;
return (
<button
key={option.value}
type="button"
aria-pressed={selected}
onClick={() => onChange(option.value)}
className={cn(
"rounded px-3 py-1.5 text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cinnabar/35",
selected
? "bg-panel text-cinnabar shadow-paper"
: "text-ink-soft hover:text-cinnabar",
)}
>
{option.label}
</button>
);
})}
</div>
);
}

View File

@@ -0,0 +1,30 @@
import type { SelectHTMLAttributes } from "react";
import { cn, inputClass, type InputState } from "@/lib/ui/variants";
interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
controlSize?: "sm" | "md";
state?: InputState;
}
const selectSizes: Record<NonNullable<SelectProps["controlSize"]>, string> = {
sm: "px-2 py-1 text-xs",
md: "px-3 py-2 text-sm",
};
export function Select({
className,
controlSize = "md",
state,
...props
}: SelectProps) {
return (
<select
className={inputClass({
state,
className: cn(selectSizes[controlSize], className),
})}
{...props}
/>
);
}

View File

@@ -0,0 +1,46 @@
import type { HTMLAttributes, ReactNode } from "react";
import { AlertCircle, CheckCircle2, Info, TriangleAlert } from "lucide-react";
import {
cn,
statusNoteClass,
type StatusNoteVariant,
} from "@/lib/ui/variants";
interface StatusNoteProps extends HTMLAttributes<HTMLDivElement> {
children: ReactNode;
title?: string;
variant?: StatusNoteVariant;
}
export function StatusNote({
children,
title,
variant = "info",
className,
...props
}: StatusNoteProps) {
const Icon = iconForVariant(variant);
return (
<div className={statusNoteClass({ variant, className })} {...props}>
<div className="flex gap-2">
<Icon className="mt-0.5 h-4 w-4 shrink-0" aria-hidden="true" />
<div className="min-w-0">
{title ? (
<p className="font-medium leading-5 text-ink">{title}</p>
) : null}
<div className={cn(title ? "mt-1" : "", "text-current")}>
{children}
</div>
</div>
</div>
</div>
);
}
function iconForVariant(variant: StatusNoteVariant) {
if (variant === "success") return CheckCircle2;
if (variant === "warning") return TriangleAlert;
if (variant === "danger") return AlertCircle;
return Info;
}

View File

@@ -0,0 +1,30 @@
import type { TextareaHTMLAttributes } from "react";
import { cn, inputClass, type InputState } from "@/lib/ui/variants";
interface TextAreaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
controlSize?: "sm" | "md";
state?: InputState;
}
const textAreaSizes: Record<NonNullable<TextAreaProps["controlSize"]>, string> = {
sm: "px-2 py-1 text-xs leading-5",
md: "px-3 py-2 text-sm leading-6",
};
export function TextArea({
className,
controlSize = "md",
state,
...props
}: TextAreaProps) {
return (
<textarea
className={inputClass({
state,
className: cn("resize-y", textAreaSizes[controlSize], className),
})}
{...props}
/>
);
}

View File

@@ -0,0 +1,30 @@
import type { InputHTMLAttributes } from "react";
import { cn, inputClass, type InputState } from "@/lib/ui/variants";
interface TextInputProps extends InputHTMLAttributes<HTMLInputElement> {
controlSize?: "sm" | "md";
state?: InputState;
}
const inputSizes: Record<NonNullable<TextInputProps["controlSize"]>, string> = {
sm: "px-2 py-1 text-xs",
md: "px-3 py-2 text-sm",
};
export function TextInput({
className,
controlSize = "md",
state,
...props
}: TextInputProps) {
return (
<input
className={inputClass({
state,
className: cn(inputSizes[controlSize], className),
})}
{...props}
/>
);
}

View File

@@ -1,10 +1,24 @@
"use client"; "use client";
import Link from "next/link"; import Link from "next/link";
import { useEffect, useRef, useState } from "react"; import { useEffect, useId, useRef, useState, type RefObject } from "react";
import {
BookOpen,
ClipboardCheck,
FileText,
Library,
PanelLeft,
PanelRight,
PenLine,
Square,
} from "lucide-react";
import { AppShell } from "@/components/AppShell"; import { AppShell } from "@/components/AppShell";
import { Drawer } from "@/components/Drawer"; import { Drawer } from "@/components/Drawer";
import { Button } from "@/components/ui/Button";
import { SectionHeader } from "@/components/ui/SectionHeader";
import { StatusNote } from "@/components/ui/StatusNote";
import { TextArea } from "@/components/ui/TextArea";
import type { ProjectResponse } from "@/lib/api/types"; import type { ProjectResponse } from "@/lib/api/types";
import { friendlyError } from "@/lib/errors/messages"; import { friendlyError } from "@/lib/errors/messages";
import { useAutosave } from "@/lib/autosave/useAutosave"; import { useAutosave } from "@/lib/autosave/useAutosave";
@@ -14,6 +28,7 @@ import {
type ChapterEntry, type ChapterEntry,
} from "@/lib/workbench/chapter"; } from "@/lib/workbench/chapter";
import { composeDirective, STYLE_PRESETS } from "@/lib/workbench/directive"; import { composeDirective, STYLE_PRESETS } from "@/lib/workbench/directive";
import { buttonClass } from "@/lib/ui/variants";
import { ChapterList, ChapterListContent } from "./ChapterList"; import { ChapterList, ChapterListContent } from "./ChapterList";
import { ChapterAssistant, AssistantContent } from "./ChapterAssistant"; import { ChapterAssistant, AssistantContent } from "./ChapterAssistant";
import { Editor } from "./Editor"; import { Editor } from "./Editor";
@@ -50,6 +65,8 @@ export function Workbench({
const autosave = useAutosave(project.id, chapterNo, initialText); const autosave = useAutosave(project.id, chapterNo, initialText);
const stream = useDraftStream(); const stream = useDraftStream();
const lastStreamText = useRef(""); const lastStreamText = useRef("");
const chapterTriggerRef = useRef<HTMLButtonElement>(null);
const assistantTriggerRef = useRef<HTMLButtonElement>(null);
// 流式 token 累积进编辑器(打字机);停止/结束后已生成部分留在草稿。 // 流式 token 累积进编辑器(打字机);停止/结束后已生成部分留在草稿。
useEffect(() => { useEffect(() => {
@@ -95,7 +112,7 @@ export function Workbench({
projectId={project.id} projectId={project.id}
activeNav="write" activeNav="write"
> >
<div className="grid h-[calc(100vh-var(--chrome,4rem))] grid-cols-1 lg:grid-cols-[12rem_1fr_18rem]"> <div className="grid min-h-[calc(100vh-var(--chrome,4rem))] grid-cols-1 lg:h-[calc(100vh-var(--chrome,4rem))] lg:grid-cols-[12rem_1fr_18rem]">
<ChapterList <ChapterList
projectId={project.id} projectId={project.id}
chapters={chapters} chapters={chapters}
@@ -103,6 +120,13 @@ export function Workbench({
/> />
<section className="flex min-w-0 flex-col bg-bg"> <section className="flex min-w-0 flex-col bg-bg">
<MobileContextBar
chapterNo={chapterNo}
onOpenChapters={() => setMobilePanel("chapters")}
onOpenAssistant={() => setMobilePanel("assistant")}
chapterTriggerRef={chapterTriggerRef}
assistantTriggerRef={assistantTriggerRef}
/>
<DirectivePanel <DirectivePanel
directive={directive} directive={directive}
onDirectiveChange={setDirective} onDirectiveChange={setDirective}
@@ -126,8 +150,6 @@ export function Workbench({
streamError={stream.state.error} streamError={stream.state.error}
onWrite={onWrite} onWrite={onWrite}
onStop={stream.stop} onStop={stream.stop}
onOpenChapters={() => setMobilePanel("chapters")}
onOpenAssistant={() => setMobilePanel("assistant")}
/> />
</section> </section>
@@ -140,6 +162,7 @@ export function Workbench({
onClose={closePanel} onClose={closePanel}
side="left" side="left"
label="目录" label="目录"
triggerRef={chapterTriggerRef}
> >
<ChapterListContent <ChapterListContent
projectId={project.id} projectId={project.id}
@@ -152,6 +175,7 @@ export function Workbench({
onClose={closePanel} onClose={closePanel}
side="right" side="right"
label="本章助手" label="本章助手"
triggerRef={assistantTriggerRef}
> >
<AssistantContent projectId={project.id} chapterNo={chapterNo} /> <AssistantContent projectId={project.id} chapterNo={chapterNo} />
</Drawer> </Drawer>
@@ -159,6 +183,48 @@ export function Workbench({
); );
} }
interface MobileContextBarProps {
chapterNo: number;
onOpenChapters: () => void;
onOpenAssistant: () => void;
chapterTriggerRef: RefObject<HTMLButtonElement | null>;
assistantTriggerRef: RefObject<HTMLButtonElement | null>;
}
function MobileContextBar({
chapterNo,
onOpenChapters,
onOpenAssistant,
chapterTriggerRef,
assistantTriggerRef,
}: MobileContextBarProps) {
return (
<div className="flex items-center justify-between gap-2 border-b border-line bg-panel px-4 py-2 lg:hidden">
<Button
ref={chapterTriggerRef}
onClick={onOpenChapters}
variant="secondary"
size="sm"
>
<PanelLeft className="h-4 w-4" aria-hidden="true" />
</Button>
<span className="min-w-0 truncate font-mono text-xs text-ink-soft">
{chapterNo}
</span>
<Button
ref={assistantTriggerRef}
onClick={onOpenAssistant}
variant="secondary"
size="sm"
>
<PanelRight className="h-4 w-4" aria-hidden="true" />
</Button>
</div>
);
}
interface DirectivePanelProps { interface DirectivePanelProps {
directive: string; directive: string;
onDirectiveChange: (value: string) => void; onDirectiveChange: (value: string) => void;
@@ -174,42 +240,55 @@ function DirectivePanel({
presetIds, presetIds,
onTogglePreset, onTogglePreset,
}: DirectivePanelProps) { }: DirectivePanelProps) {
const panelId = useId();
const activeCount = presetIds.length + (directive.trim().length > 0 ? 1 : 0);
return ( return (
<details className="border-b border-line bg-panel px-6 py-2"> <details className="border-b border-line bg-panel px-4 py-3 sm:px-6">
<summary className="cursor-pointer text-sm text-ink-soft hover:text-cinnabar"> <summary
className="flex cursor-pointer list-none items-center justify-between gap-3 text-sm text-ink-soft hover:text-cinnabar"
aria-controls={panelId}
>
<span className="font-serif text-base text-ink"></span>
<span className="rounded border border-line bg-bg px-2 py-0.5 text-xs text-ink-soft">
{activeCount > 0 ? `${activeCount} 项指令` : "可选"}
</span>
</summary> </summary>
<div className="mt-2 space-y-2"> <div id={panelId} className="mt-3 space-y-3">
<SectionHeader
title="写作指令"
description="用于补充或覆盖本章大纲节拍,只影响本次生成。"
/>
<label className="block"> <label className="block">
<span className="sr-only"></span> <span className="sr-only"></span>
<textarea <TextArea
value={directive} value={directive}
onChange={(e) => onDirectiveChange(e.target.value)} onChange={(e) => onDirectiveChange(e.target.value)}
rows={2} rows={2}
placeholder="本章想怎么写?(可选,覆盖/补充大纲节拍)" placeholder="本章想怎么写?(可选,覆盖/补充大纲节拍)"
className="w-full resize-y rounded border border-line bg-bg px-3 py-2 text-sm text-ink placeholder:text-ink-soft focus:border-cinnabar focus:outline-none"
/> />
</label> </label>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2" role="group" aria-label="风格预设">
{STYLE_PRESETS.map((preset) => { {STYLE_PRESETS.map((preset) => {
const active = presetIds.includes(preset.id); const active = presetIds.includes(preset.id);
return ( return (
<button <Button
key={preset.id} key={preset.id}
type="button" type="button"
aria-pressed={active} aria-pressed={active}
onClick={() => onTogglePreset(preset.id)} onClick={() => onTogglePreset(preset.id)}
className={ variant={active ? "outline" : "secondary"}
active size="sm"
? "rounded-full border border-cinnabar bg-cinnabar px-3 py-1 text-xs text-panel"
: "rounded-full border border-line px-3 py-1 text-xs text-ink-soft hover:border-cinnabar hover:text-cinnabar"
}
> >
{preset.label} {preset.label}
</button> </Button>
); );
})} })}
</div> </div>
{activeCount > 0 ? (
<StatusNote variant="info">
</StatusNote>
) : null}
</div> </div>
</details> </details>
); );
@@ -250,8 +329,6 @@ interface ToolbarProps {
streamError: { code: string; message: string } | null; streamError: { code: string; message: string } | null;
onWrite: () => void; onWrite: () => void;
onStop: () => void; onStop: () => void;
onOpenChapters: () => void;
onOpenAssistant: () => void;
} }
function Toolbar({ function Toolbar({
@@ -264,36 +341,22 @@ function Toolbar({
streamError, streamError,
onWrite, onWrite,
onStop, onStop,
onOpenChapters,
onOpenAssistant,
}: ToolbarProps) { }: ToolbarProps) {
return ( return (
<div className="border-t border-line bg-panel px-4 py-3 sm:px-6"> <div className="sticky bottom-0 z-10 border-t border-line bg-panel/95 px-4 py-2 backdrop-blur sm:px-6 sm:py-3">
{streamError ? <StreamErrorNote streamError={streamError} /> : null} {streamError ? <StreamErrorNote streamError={streamError} /> : null}
<div className="flex flex-wrap items-center gap-2 sm:gap-3"> <div className="grid gap-2 sm:flex sm:flex-wrap sm:items-center sm:gap-3">
<button <div className="flex min-w-0 flex-wrap items-center gap-2">
type="button"
onClick={onOpenChapters}
className="rounded border border-line px-3 py-2 text-sm text-ink hover:border-cinnabar hover:text-cinnabar lg:hidden"
>
</button>
{streaming ? ( {streaming ? (
<button <Button onClick={onStop} variant="danger" size="sm">
type="button" <Square className="h-4 w-4" aria-hidden="true" />
onClick={onStop}
className="rounded border border-conflict px-4 py-2 text-sm text-conflict"
>
</button> </Button>
) : ( ) : (
<button <Button onClick={onWrite} variant="primary" size="sm">
type="button" <PenLine className="h-4 w-4" aria-hidden="true" />
onClick={onWrite}
className="rounded bg-cinnabar px-4 py-2 text-sm text-panel hover:opacity-90" </Button>
>
</button>
)} )}
{streaming ? ( {streaming ? (
<span className="font-mono text-xs text-cinnabar motion-safe:animate-pulse"> <span className="font-mono text-xs text-cinnabar motion-safe:animate-pulse">
@@ -306,30 +369,32 @@ function Toolbar({
)} )}
<Link <Link
href={`/projects/${projectId}/outline`} href={`/projects/${projectId}/outline`}
className="rounded border border-line px-4 py-2 text-sm text-ink hover:border-cinnabar hover:text-cinnabar" className={buttonClass({ variant: "secondary", size: "sm" })}
> >
<BookOpen className="h-4 w-4" aria-hidden="true" />
</Link> </Link>
<Link <Link
href={`/projects/${projectId}/foreshadow`} href={`/projects/${projectId}/foreshadow`}
className="rounded border border-line px-4 py-2 text-sm text-ink hover:border-cinnabar hover:text-cinnabar" className={buttonClass({
variant: "secondary",
size: "sm",
className: "hidden sm:inline-flex",
})}
> >
<Library className="h-4 w-4" aria-hidden="true" />
</Link> </Link>
<button
type="button"
onClick={onOpenAssistant}
className="rounded border border-line px-3 py-2 text-sm text-ink hover:border-cinnabar hover:text-cinnabar lg:hidden"
>
</button>
<Link <Link
href={`/projects/${projectId}/review?chapter=${chapterNo}`} href={`/projects/${projectId}/review?chapter=${chapterNo}`}
className="rounded border border-cinnabar px-4 py-2 text-sm text-cinnabar hover:bg-[var(--color-cinnabar-wash)]" className={buttonClass({ variant: "outline", size: "sm" })}
> >
稿 <ClipboardCheck className="h-4 w-4" aria-hidden="true" />
稿
</Link> </Link>
<span className="ml-auto font-mono text-xs text-ink-soft"> </div>
<span className="min-w-0 font-mono text-xs text-ink-soft sm:ml-auto">
<FileText className="mr-1 inline h-3.5 w-3.5" aria-hidden="true" />
{saveStatus === "saving" {saveStatus === "saving"
? "保存中…" ? "保存中…"
: saveStatus === "error" : saveStatus === "error"

View File

@@ -1539,6 +1539,17 @@ export interface components {
selling_points?: string[]; selling_points?: string[];
/** Structure */ /** Structure */
structure?: string | null; structure?: string | null;
/**
* Updated At
* @description 项目最近更新时间
*/
updated_at?: string | null;
/**
* Pending Review Count
* @description 待审稿章节数
* @default 0
*/
pending_review_count: number;
}; };
/** /**
* ProviderCredentialInput * ProviderCredentialInput

View File

@@ -4,9 +4,12 @@ import type { ForeshadowView } from "@/lib/api/types";
import { import {
buildRegisterRequest, buildRegisterRequest,
buildTransitionRequest, buildTransitionRequest,
countByStatus,
extractReason, extractReason,
groupByStatus, groupByStatus,
isWindowApproaching, isWindowApproaching,
LANE_LABELS,
nextFreeForeshadowCode,
suggestForeshadowCode, suggestForeshadowCode,
validationReasonMessage, validationReasonMessage,
} from "./board"; } from "./board";
@@ -24,6 +27,24 @@ describe("suggestForeshadowCode", () => {
}); });
}); });
describe("nextFreeForeshadowCode", () => {
it("keeps the preferred code when it is free", () => {
expect(nextFreeForeshadowCode("FS-04", new Set(["FS-01", "FS-02"]))).toBe(
"FS-04",
);
});
it("skips taken codes and returns the first free FS-NN", () => {
const taken = new Set(["FS-01", "FS-02", "FS-03", "FS-05", "FS-OLD"]);
// preferred FS-01 已占 → 取首个空闲 FS-04
expect(nextFreeForeshadowCode("FS-01", taken)).toBe("FS-04");
});
it("returns FS-01 when nothing is taken", () => {
expect(nextFreeForeshadowCode("FS-01", new Set())).toBe("FS-01");
});
});
const view = (over: Partial<ForeshadowView>): ForeshadowView => ({ const view = (over: Partial<ForeshadowView>): ForeshadowView => ({
code: "F-001", code: "F-001",
title: "线索", title: "线索",
@@ -53,6 +74,32 @@ describe("groupByStatus", () => {
}); });
}); });
describe("countByStatus", () => {
it("counts items in the same lane model used by the board", () => {
expect(
countByStatus([
view({ status: "OPEN" }),
view({ status: "OPEN" }),
view({ status: "PARTIAL" }),
view({ status: "CLOSED" }),
view({ status: "OVERDUE" }),
view({ status: "WEIRD" }),
]),
).toEqual({ OPEN: 3, PARTIAL: 1, CLOSED: 1, OVERDUE: 1 });
});
});
describe("LANE_LABELS", () => {
it("keeps author-facing labels distinct from status codes", () => {
expect(LANE_LABELS).toEqual({
OPEN: "待推进",
PARTIAL: "推进中",
CLOSED: "已回收",
OVERDUE: "已逾期",
});
});
});
describe("isWindowApproaching", () => { describe("isWindowApproaching", () => {
it("is true when current chapter is inside [from,to] for OPEN/PARTIAL", () => { it("is true when current chapter is inside [from,to] for OPEN/PARTIAL", () => {
const f = view({ const f = view({

View File

@@ -18,13 +18,14 @@ export const LANES: readonly ForeshadowStatus[] = [
] as const; ] as const;
export const LANE_LABELS: Record<ForeshadowStatus, string> = { export const LANE_LABELS: Record<ForeshadowStatus, string> = {
OPEN: "OPEN", OPEN: "待推进",
PARTIAL: "PARTIAL", PARTIAL: "推进中",
CLOSED: "CLOSED", CLOSED: "已回收",
OVERDUE: "OVERDUE", OVERDUE: "已逾期",
}; };
export type ForeshadowLanes = Record<ForeshadowStatus, ForeshadowView[]>; export type ForeshadowLanes = Record<ForeshadowStatus, ForeshadowView[]>;
export type ForeshadowLaneCounts = Record<ForeshadowStatus, number>;
function emptyLanes(): ForeshadowLanes { function emptyLanes(): ForeshadowLanes {
return { OPEN: [], PARTIAL: [], CLOSED: [], OVERDUE: [] }; return { OPEN: [], PARTIAL: [], CLOSED: [], OVERDUE: [] };
@@ -46,6 +47,18 @@ export function groupByStatus(
return lanes; return lanes;
} }
export function countByStatus(
items: readonly ForeshadowView[] | undefined,
): ForeshadowLaneCounts {
const lanes = groupByStatus(items);
return {
OPEN: lanes.OPEN.length,
PARTIAL: lanes.PARTIAL.length,
CLOSED: lanes.CLOSED.length,
OVERDUE: lanes.OVERDUE.length,
};
}
// 接近回收窗口判定current 在 [from, to] 内,或已超 from 但尚未 CLOSED提示安排回收 // 接近回收窗口判定current 在 [from, to] 内,或已超 from 但尚未 CLOSED提示安排回收
// 仅对 OPEN/PARTIAL 提示CLOSED 已回收、OVERDUE 已逾期另有强调。 // 仅对 OPEN/PARTIAL 提示CLOSED 已回收、OVERDUE 已逾期另有强调。
export function isWindowApproaching( export function isWindowApproaching(
@@ -70,6 +83,21 @@ export function suggestForeshadowCode(
return `FS-${String(index + 1).padStart(2, "0")}`; return `FS-${String(index + 1).padStart(2, "0")}`;
} }
// 在已占用代号taken区分大小写按原样为预填代号挑一个不冲突的值
// preferred 未被占用就用它;否则从 FS-01 起取首个空闲的 FS-<两位序号>。
// 纯函数(可单测)。避免登记时与库里已存代号撞码 → 422 duplicate。
export function nextFreeForeshadowCode(
preferred: string,
taken: ReadonlySet<string>,
): string {
if (preferred && !taken.has(preferred)) return preferred;
for (let n = 1; n < 1000; n++) {
const candidate = `FS-${String(n).padStart(2, "0")}`;
if (!taken.has(candidate)) return candidate;
}
return preferred; // 理论不可达1000 个代号全占用时退回原值
}
export interface RegisterInput { export interface RegisterInput {
projectId: string; projectId: string;
code: string; code: string;

View File

@@ -1,6 +1,10 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { aiToolItems } from "./ai-tools"; import {
aiToolItems,
primaryAiToolItems,
secondaryAiToolItems,
} from "./ai-tools";
describe("aiToolItems", () => { describe("aiToolItems", () => {
it("returns 5 items", () => { it("returns 5 items", () => {
@@ -25,4 +29,16 @@ describe("aiToolItems", () => {
expect(review?.href).toBe("/projects/p1/review"); expect(review?.href).toBe("/projects/p1/review");
expect(review?.key).toBe("review"); expect(review?.key).toBe("review");
}); });
it("keeps high-frequency tools outside the mobile more menu", () => {
expect(primaryAiToolItems("p1").map((item) => item.key)).toEqual([
"write",
"review",
]);
expect(secondaryAiToolItems("p1").map((item) => item.key)).toEqual([
"outline",
"codex",
"toolbox",
]);
});
}); });

View File

@@ -8,8 +8,6 @@ import type { ActiveNav } from "./items";
export interface AiToolItem { export interface AiToolItem {
href: string; href: string;
label: string; label: string;
// 行首字形纸感非语义aria-hidden 渲染)。
glyph: string;
// 高亮键(与 AppShell activeNav 比对)。 // 高亮键(与 AppShell activeNav 比对)。
key: ActiveNav; key: ActiveNav;
// 主动作(写本章)朱砂强调,其余次级。 // 主动作(写本章)朱砂强调,其余次级。
@@ -20,10 +18,18 @@ export interface AiToolItem {
export function aiToolItems(projectId: string): AiToolItem[] { export function aiToolItems(projectId: string): AiToolItem[] {
const base = `/projects/${projectId}`; const base = `/projects/${projectId}`;
return [ return [
{ href: `${base}/write`, label: "写本章", glyph: "✍", key: "write", primary: true }, { href: `${base}/write`, label: "写本章", key: "write", primary: true },
{ href: `${base}/review`, label: "审稿", glyph: "✦", key: "review" }, { href: `${base}/review`, label: "审稿", key: "review" },
{ href: `${base}/outline`, label: "大纲", glyph: "❡", key: "outline" }, { href: `${base}/outline`, label: "大纲", key: "outline" },
{ href: `${base}/codex`, label: "设定库", glyph: "❖", key: "codex" }, { href: `${base}/codex`, label: "设定库", key: "codex" },
{ href: `${base}/toolbox`, label: "工具箱", glyph: "✧", key: "toolbox" }, { href: `${base}/toolbox`, label: "工具箱", key: "toolbox" },
]; ];
} }
export function primaryAiToolItems(projectId: string): AiToolItem[] {
return aiToolItems(projectId).slice(0, 2);
}
export function secondaryAiToolItems(projectId: string): AiToolItem[] {
return aiToolItems(projectId).slice(2);
}

View File

@@ -0,0 +1,129 @@
import { describe, expect, it } from "vitest";
import type { ProjectResponse } from "@/lib/api/types";
import {
filterProjects,
formatProjectUpdatedAt,
pendingReviewCount,
} from "./projects";
const projects: ProjectResponse[] = [
{
id: "1",
title: "逐光而行",
genre: "玄幻",
logline: "少年逆袭",
premise: null,
theme: "抗争",
selling_points: [],
structure: null,
updated_at: "2026-06-27T10:00:00Z",
pending_review_count: 0,
},
{
id: "2",
title: "城南旧案",
genre: null,
logline: "悬疑调查",
premise: null,
theme: null,
selling_points: [],
structure: null,
updated_at: "2026-06-28T08:00:00Z",
pending_review_count: 2,
},
{
id: "3",
title: "星港来信",
genre: "科幻",
logline: "远航与归乡",
premise: null,
theme: null,
selling_points: [],
structure: null,
updated_at: "2026-06-26T09:00:00Z",
pending_review_count: 1,
},
];
describe("filterProjects", () => {
it("searches title, genre, logline and theme", () => {
expect(
filterProjects(projects, {
search: "悬疑",
filter: "all",
sort: "title",
}).map((p) => p.title),
).toEqual(["城南旧案"]);
expect(
filterProjects(projects, {
search: "抗争",
filter: "all",
sort: "title",
}).map((p) => p.title),
).toEqual(["逐光而行"]);
});
it("filters by categorization state", () => {
expect(
filterProjects(projects, {
search: "",
filter: "with_genre",
sort: "title",
}),
).toHaveLength(2);
expect(
filterProjects(projects, {
search: "",
filter: "uncategorized",
sort: "title",
}).map((p) => p.title),
).toEqual(["城南旧案"]);
});
it("filters projects waiting for review", () => {
expect(
filterProjects(projects, {
search: "",
filter: "pending_review",
sort: "title",
}).map((p) => p.title),
).toEqual(["城南旧案", "星港来信"]);
});
it("sorts by recently edited first", () => {
expect(
filterProjects(projects, {
search: "",
filter: "all",
sort: "updated_at",
}).map((p) => p.title),
).toEqual(["城南旧案", "逐光而行", "星港来信"]);
});
it("sorts by genre with title fallback", () => {
expect(
filterProjects(projects, {
search: "",
filter: "all",
sort: "genre",
}).map((p) => p.title),
).toEqual(["星港来信", "逐光而行", "城南旧案"]);
});
});
describe("project metadata helpers", () => {
it("normalizes pending review count", () => {
expect(pendingReviewCount(projects[1]!)).toBe(2);
expect(pendingReviewCount({ ...projects[1]!, pending_review_count: -1 })).toBe(0);
});
it("formats updated_at with a fallback", () => {
expect(formatProjectUpdatedAt(null)).toBe("暂无编辑记录");
expect(formatProjectUpdatedAt("bad")).toBe("暂无编辑记录");
expect(formatProjectUpdatedAt("2026-06-28T08:00:00Z")).not.toBe(
"暂无编辑记录",
);
});
});

View File

@@ -0,0 +1,80 @@
import type { ProjectResponse } from "@/lib/api/types";
export type ProjectFilter =
| "all"
| "with_genre"
| "uncategorized"
| "pending_review";
export type ProjectSort = "updated_at" | "title" | "genre";
export type ProjectViewMode = "cards" | "compact";
export interface ProjectQuery {
search: string;
filter: ProjectFilter;
sort: ProjectSort;
}
export function filterProjects(
projects: readonly ProjectResponse[],
query: ProjectQuery,
): ProjectResponse[] {
const needle = query.search.trim().toLocaleLowerCase();
const filtered = projects.filter((project) => {
if (query.filter === "with_genre" && !project.genre) return false;
if (query.filter === "uncategorized" && project.genre) return false;
if (query.filter === "pending_review" && pendingReviewCount(project) === 0) {
return false;
}
if (needle.length === 0) return true;
return [project.title, project.genre, project.logline, project.theme]
.filter((value): value is string => typeof value === "string")
.some((value) => value.toLocaleLowerCase().includes(needle));
});
return [...filtered].sort((a, b) => compareProject(a, b, query.sort));
}
function compareProject(
a: ProjectResponse,
b: ProjectResponse,
sort: ProjectSort,
): number {
if (sort === "updated_at") {
const byUpdated = timestamp(b.updated_at) - timestamp(a.updated_at);
if (byUpdated !== 0) return byUpdated;
}
if (sort === "genre") {
if (a.genre && !b.genre) return -1;
if (!a.genre && b.genre) return 1;
const byGenre = label(a.genre).localeCompare(label(b.genre), "zh-Hans-CN");
if (byGenre !== 0) return byGenre;
}
return label(a.title).localeCompare(label(b.title), "zh-Hans-CN");
}
export function pendingReviewCount(project: ProjectResponse): number {
return Math.max(0, project.pending_review_count ?? 0);
}
export function formatProjectUpdatedAt(value: string | null | undefined): string {
if (!value) return "暂无编辑记录";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "暂无编辑记录";
return new Intl.DateTimeFormat("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
}).format(date);
}
function label(value: string | null | undefined): string {
return value?.trim() || "未命名";
}
function timestamp(value: string | null | undefined): number {
if (!value) return 0;
const time = new Date(value).getTime();
return Number.isNaN(time) ? 0 : time;
}

View File

@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import type { SkillView } from "@/lib/api/types"; import type { SkillView } from "@/lib/api/types";
import { groupByScope, scopeLabel, tierLabel } from "./skills"; import { groupByScope, scopeLabel, summarizeSkills, tierLabel } from "./skills";
const skill = (over: Partial<SkillView>): SkillView => ({ const skill = (over: Partial<SkillView>): SkillView => ({
name: "x", name: "x",
@@ -36,3 +36,61 @@ describe("groupByScope", () => {
expect(groupByScope(undefined)).toEqual([]); expect(groupByScope(undefined)).toEqual([]);
}); });
}); });
describe("summarizeSkills", () => {
it("counts scopes, tiers, readonly skills and unique tables", () => {
const summary = summarizeSkills([
skill({
name: "a",
scope: "builtin",
tier: "analyst",
reads: ["projects", "outline"],
writes: [],
}),
skill({
name: "b",
scope: "custom",
tier: "writer",
reads: ["projects"],
writes: ["chapters"],
}),
skill({
name: "c",
scope: "builtin",
tier: "analyst",
reads: ["world_entities"],
writes: ["outline", "chapters"],
}),
]);
expect(summary.total).toBe(3);
expect(summary.readonlyCount).toBe(1);
expect(summary.writableCount).toBe(2);
expect(summary.scopes).toEqual([
{ key: "builtin", count: 2 },
{ key: "custom", count: 1 },
]);
expect(summary.tiers).toEqual([
{ key: "analyst", count: 2 },
{ key: "writer", count: 1 },
]);
expect(summary.readableTables).toEqual([
"outline",
"projects",
"world_entities",
]);
expect(summary.writableTables).toEqual(["chapters", "outline"]);
});
it("handles undefined", () => {
expect(summarizeSkills(undefined)).toEqual({
total: 0,
readonlyCount: 0,
writableCount: 0,
scopes: [],
tiers: [],
readableTables: [],
writableTables: [],
});
});
});

View File

@@ -27,6 +27,21 @@ export function tierLabel(tier: string): string {
export type SkillGroups = { scope: string; skills: SkillView[] }[]; export type SkillGroups = { scope: string; skills: SkillView[] }[];
export interface SkillCount {
key: string;
count: number;
}
export interface SkillsSummary {
total: number;
readonlyCount: number;
writableCount: number;
scopes: SkillCount[];
tiers: SkillCount[];
readableTables: string[];
writableTables: string[];
}
// 按 scope 分组(保持服务端 name 升序scope 顺序按首次出现。 // 按 scope 分组(保持服务端 name 升序scope 顺序按首次出现。
export function groupByScope( export function groupByScope(
skills: readonly SkillView[] | undefined, skills: readonly SkillView[] | undefined,
@@ -42,3 +57,41 @@ export function groupByScope(
} }
return order.map((scope) => ({ scope, skills: map.get(scope) ?? [] })); return order.map((scope) => ({ scope, skills: map.get(scope) ?? [] }));
} }
export function summarizeSkills(
skills: readonly SkillView[] | undefined,
): SkillsSummary {
const scopeCounts = new Map<string, number>();
const tierCounts = new Map<string, number>();
const readableTables = new Set<string>();
const writableTables = new Set<string>();
let writableCount = 0;
for (const skill of skills ?? []) {
scopeCounts.set(skill.scope, (scopeCounts.get(skill.scope) ?? 0) + 1);
tierCounts.set(skill.tier, (tierCounts.get(skill.tier) ?? 0) + 1);
for (const table of skill.reads ?? []) {
readableTables.add(table);
}
if (skill.writes && skill.writes.length > 0) {
writableCount += 1;
for (const table of skill.writes) {
writableTables.add(table);
}
}
}
return {
total: skills?.length ?? 0,
readonlyCount: (skills?.length ?? 0) - writableCount,
writableCount,
scopes: countsFromMap(scopeCounts),
tiers: countsFromMap(tierCounts),
readableTables: [...readableTables].sort(),
writableTables: [...writableTables].sort(),
};
}
function countsFromMap(map: Map<string, number>): SkillCount[] {
return [...map.entries()].map(([key, count]) => ({ key, count }));
}

View File

@@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import {
DEFAULT_THEME_MODE,
isThemeMode,
nextThemeMode,
normalizeThemeMode,
themeBootstrapScript,
themeModeLabel,
themeToggleLabel,
} from "./theme";
describe("theme mode", () => {
it("accepts only supported theme modes", () => {
expect(isThemeMode("paper")).toBe(true);
expect(isThemeMode("night")).toBe(true);
expect(isThemeMode("dark")).toBe(false);
expect(isThemeMode(null)).toBe(false);
});
it("falls back to paper for unknown stored values", () => {
expect(normalizeThemeMode("night")).toBe("night");
expect(normalizeThemeMode("")).toBe(DEFAULT_THEME_MODE);
expect(normalizeThemeMode("auto")).toBe(DEFAULT_THEME_MODE);
});
it("toggles between paper and night", () => {
expect(nextThemeMode("paper")).toBe("night");
expect(nextThemeMode("night")).toBe("paper");
});
it("returns labels for the current mode and the next action", () => {
expect(themeModeLabel("paper")).toBe("纸感模式");
expect(themeModeLabel("night")).toBe("夜读模式");
expect(themeToggleLabel("paper")).toBe("切换到夜读模式");
expect(themeToggleLabel("night")).toBe("切换到纸感模式");
});
it("builds a bootstrap script that applies the stored mode before hydration", () => {
const script = themeBootstrapScript();
expect(script).toContain("localStorage.getItem(\"ww.theme_mode\")");
expect(script).toContain("document.documentElement.dataset.theme");
expect(script).toContain("mode !== \"paper\" && mode !== \"night\"");
});
});

36
apps/web/lib/ui/theme.ts Normal file
View File

@@ -0,0 +1,36 @@
export type ThemeMode = "paper" | "night";
export const DEFAULT_THEME_MODE: ThemeMode = "paper";
export const THEME_STORAGE_KEY = "ww.theme_mode";
export function isThemeMode(value: unknown): value is ThemeMode {
return value === "paper" || value === "night";
}
export function normalizeThemeMode(value: unknown): ThemeMode {
return isThemeMode(value) ? value : DEFAULT_THEME_MODE;
}
export function nextThemeMode(current: ThemeMode): ThemeMode {
return current === "night" ? "paper" : "night";
}
export function themeModeLabel(mode: ThemeMode): string {
return mode === "night" ? "夜读模式" : "纸感模式";
}
export function themeToggleLabel(mode: ThemeMode): string {
return mode === "night" ? "切换到纸感模式" : "切换到夜读模式";
}
export function themeBootstrapScript(): string {
return `
try {
var mode = localStorage.getItem(${JSON.stringify(THEME_STORAGE_KEY)});
if (mode !== "paper" && mode !== "night") mode = ${JSON.stringify(DEFAULT_THEME_MODE)};
document.documentElement.dataset.theme = mode;
} catch (error) {
document.documentElement.dataset.theme = ${JSON.stringify(DEFAULT_THEME_MODE)};
}
`.trim();
}

View File

@@ -0,0 +1,52 @@
import { describe, expect, it } from "vitest";
import {
badgeClass,
buttonClass,
cn,
inputClass,
segmentedClass,
statusNoteClass,
} from "./variants";
describe("ui variants", () => {
it("joins classes without falsey values", () => {
expect(cn("a", false, undefined, null, "b")).toBe("a b");
});
it("builds stable primary button classes", () => {
const klass = buttonClass({ variant: "primary", size: "sm" });
expect(klass).toContain("bg-cinnabar");
expect(klass).toContain("px-3");
expect(klass).toContain("focus-visible:ring-2");
});
it("keeps warning badges readable on the paper background", () => {
const klass = badgeClass({ variant: "warning" });
expect(klass).toContain("bg-overdue/10");
expect(klass).toContain("text-ink");
});
it("marks invalid inputs with conflict styling", () => {
const klass = inputClass({ state: "error" });
expect(klass).toContain("border-conflict");
expect(klass).toContain("focus-visible:ring-conflict/25");
});
it("gives warning status notes a non-color icon slot friendly shell", () => {
const klass = statusNoteClass({ variant: "warning" });
expect(klass).toContain("border-overdue");
expect(klass).toContain("px-3");
});
it("builds segmented control chrome", () => {
const klass = segmentedClass();
expect(klass).toContain("inline-flex");
expect(klass).toContain("border-line");
});
});

141
apps/web/lib/ui/variants.ts Normal file
View File

@@ -0,0 +1,141 @@
export type ButtonVariant =
| "primary"
| "secondary"
| "outline"
| "ghost"
| "danger";
export type ButtonSize = "sm" | "md" | "icon";
export type BadgeVariant =
| "neutral"
| "accent"
| "success"
| "warning"
| "danger"
| "info";
export type InputState = "default" | "error";
export type StatusNoteVariant = "info" | "success" | "warning" | "danger";
export function cn(...classes: Array<string | false | null | undefined>): string {
return classes.filter(Boolean).join(" ");
}
const buttonBase =
"inline-flex items-center justify-center gap-1.5 rounded border text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cinnabar/35 disabled:cursor-not-allowed disabled:opacity-45";
const buttonVariants: Record<ButtonVariant, string> = {
primary:
"border-cinnabar bg-cinnabar text-panel shadow-paper hover:bg-cinnabar/95",
secondary:
"border-line bg-panel text-ink hover:border-cinnabar hover:text-cinnabar",
outline:
"border-cinnabar bg-transparent text-cinnabar hover:bg-[var(--color-cinnabar-wash)]",
ghost:
"border-transparent bg-transparent text-ink-soft hover:bg-[var(--color-cinnabar-wash)] hover:text-cinnabar",
danger:
"border-conflict bg-transparent text-conflict hover:bg-conflict/10",
};
const buttonSizes: Record<ButtonSize, string> = {
sm: "px-3 py-1.5 text-xs",
md: "px-4 py-2",
icon: "h-9 w-9 p-0",
};
export function buttonClass({
variant = "secondary",
size = "md",
className,
}: {
variant?: ButtonVariant;
size?: ButtonSize;
className?: string;
} = {}): string {
return cn(buttonBase, buttonVariants[variant], buttonSizes[size], className);
}
const badgeBase =
"inline-flex items-center gap-1 rounded border px-2 py-0.5 text-xs leading-5";
const badgeVariants: Record<BadgeVariant, string> = {
neutral: "border-line bg-bg text-ink-soft",
accent:
"border-cinnabar/20 bg-[var(--color-cinnabar-wash)] text-cinnabar",
success: "border-pass/25 bg-pass/10 text-pass",
warning: "border-overdue/35 bg-overdue/10 text-ink",
danger: "border-conflict/25 bg-conflict/10 text-conflict",
info: "border-info/25 bg-info/10 text-info",
};
export function badgeClass({
variant = "neutral",
className,
}: {
variant?: BadgeVariant;
className?: string;
} = {}): string {
return cn(badgeBase, badgeVariants[variant], className);
}
export function cardClass(className?: string): string {
return cn("rounded border border-line bg-panel shadow-paper", className);
}
const fieldTextBase = "block text-sm font-medium text-ink";
export function fieldLabelClass(className?: string): string {
return cn(fieldTextBase, className);
}
export function fieldHelpClass(className?: string): string {
return cn("mt-1 text-xs leading-5 text-ink-soft", className);
}
export function fieldErrorClass(className?: string): string {
return cn("mt-1 text-xs leading-5 text-conflict", className);
}
const inputBase =
"w-full rounded border bg-bg text-ink transition-colors placeholder:text-ink-soft/65 focus:outline-none focus-visible:ring-2 focus-visible:ring-cinnabar/30 disabled:cursor-not-allowed disabled:bg-line/20 disabled:text-ink-soft";
const inputStates: Record<InputState, string> = {
default: "border-line focus:border-cinnabar",
error: "border-conflict focus:border-conflict focus-visible:ring-conflict/25",
};
export function inputClass({
state = "default",
className,
}: {
state?: InputState;
className?: string;
} = {}): string {
return cn(inputBase, inputStates[state], className);
}
const statusNoteBase =
"rounded border px-3 py-2 text-sm leading-6";
const statusNoteVariants: Record<StatusNoteVariant, string> = {
info: "border-info/25 bg-info/10 text-info",
success: "border-pass/25 bg-pass/10 text-pass",
warning: "border-overdue/35 bg-overdue/10 text-ink",
danger: "border-conflict/25 bg-conflict/10 text-conflict",
};
export function statusNoteClass({
variant = "info",
className,
}: {
variant?: StatusNoteVariant;
className?: string;
} = {}): string {
return cn(statusNoteBase, statusNoteVariants[variant], className);
}
export function segmentedClass(className?: string): string {
return cn("inline-flex rounded border border-line bg-bg p-1", className);
}

View File

@@ -45,6 +45,7 @@ export function useInjection(projectId: string, chapterNo: number): UseInjection
setError(null); setError(null);
void (async () => { void (async () => {
try {
const { data: body, error: err } = await api.GET(INJECTION_PATH, { const { data: body, error: err } = await api.GET(INJECTION_PATH, {
params: { path: { project_id: projectId, chapter_no: chapterNo } }, params: { path: { project_id: projectId, chapter_no: chapterNo } },
}); });
@@ -55,7 +56,14 @@ export function useInjection(projectId: string, chapterNo: number): UseInjection
} else { } else {
setData(body); setData(body);
} }
setLoading(false); } catch {
// 网络层失败(后端不可达/CORSopenapi-fetch 直接抛而非返回 error 信封。
if (cancelled) return;
setError("注入信息暂不可用");
setData(null);
} finally {
if (!cancelled) setLoading(false);
}
})(); })();
return () => { return () => {
@@ -80,6 +88,9 @@ export function useInjection(projectId: string, chapterNo: number): UseInjection
} else { } else {
setData(body); // 以服务端确定结果为准(不变量 #6 setData(body); // 以服务端确定结果为准(不变量 #6
} }
} catch {
// 网络层失败:本地状态不动(天然回滚),给可读文案、不抛。
setError("保存注入设置失败,请重试");
} finally { } finally {
savingRef.current = false; savingRef.current = false;
setSaving(false); setSaving(false);

View File

@@ -1,9 +1,9 @@
packages: packages:
- . - .
allowBuilds: allowBuilds:
esbuild: set this to true or false esbuild: true
sharp: set this to true or false sharp: true
unrs-resolver: set this to true or false unrs-resolver: true
onlyBuiltDependencies: onlyBuiltDependencies:
- esbuild - esbuild
- sharp - sharp

View File

@@ -2,7 +2,11 @@ import type { Config } from "tailwindcss";
// 纸感·文学温暖主题UX_SPEC §2。颜色经 CSS 变量落地,便于后续夜读模式切换。 // 纸感·文学温暖主题UX_SPEC §2。颜色经 CSS 变量落地,便于后续夜读模式切换。
const config: Config = { const config: Config = {
content: ["./app/**/*.{ts,tsx}", "./components/**/*.{ts,tsx}"], content: [
"./app/**/*.{ts,tsx}",
"./components/**/*.{ts,tsx}",
"./lib/**/*.{ts,tsx}",
],
theme: { theme: {
extend: { extend: {
colors: { colors: {
@@ -23,7 +27,7 @@ const config: Config = {
mono: ['"JetBrains Mono"', "ui-monospace"], mono: ['"JetBrains Mono"', "ui-monospace"],
}, },
borderRadius: { DEFAULT: "6px" }, borderRadius: { DEFAULT: "6px" },
boxShadow: { paper: "0 1px 3px #2B26200F" }, boxShadow: { paper: "0 1px 3px var(--shadow-paper)" },
maxWidth: { prose: "720px" }, maxWidth: { prose: "720px" },
}, },
}, },

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,965 @@
# UI 改进计划 · 纸感工作台二次打磨
> 文档类型:前端设计与实施计划(只规划,不直接改业务契约)
> 适用范围:`apps/web`
> 上游锚点:`UX_SPEC.md §2`(纸感视觉 token/ `§6`(页面线框)/ `§7`(组件库)/ `§10`(响应式与可访问性)
> 当前基线:已完成第一轮 UI 重构,新增 `Button`/`Badge`/`Card`/`EmptyState`/`PageHeader`,主流程页面已统一图标、空状态、卡片与按钮风格;桌面与移动端无页面级横向溢出。
---
## 0. 实施记录
### 0.1 2026-06-27 第一批已完成
本轮按计划完成了第一批高收益 UI 改造,未修改后端 API / OpenAPI 契约。
| 阶段 | 状态 | 已完成内容 |
|---|---|---|
| Phase A 表单与状态组件收敛 | 部分完成 | 新增 `Field` / `TextInput` / `TextArea` / `Select` / `StatusNote` / `SectionHeader` / `SegmentedControl`;扩展 `variants.ts``inputClass` / `statusNoteClass` / `segmentedClass`;补 `variants.test.ts`。 |
| Phase A 页面替换 | 部分完成 | 已替换 `ProjectWizard``GeneratorRunner``StyleUpload``RegisterForm``RulesPage``ProvidersSettings` 中一批散落 input/select/textarea 样式。 |
| Phase B 审稿页信息架构 | 核心完成 | 新增 `ReviewSummaryRail``ReviewSectionPanel``ReviewReport` 右侧改为摘要栏 + 一致性/伏笔/节奏/文风分段面板;有冲突时优先展开一致性。 |
| Phase B 冲突裁决强化 | 部分完成 | `ConflictCard` 增加 `冲突 x/n` 编号;手改输入框增加说明;“跳到下一条未裁决”提升到右侧 sticky 摘要栏。 |
| Phase C 顶部 AI 工具条 | 完成 C1 | 新增 `AiToolbarMoreMenu`;小屏只显示“写本章 / 审稿 / 更多”,更多菜单包含“大纲 / 设定库 / 工具箱”;桌面保留完整工具条。 |
| Phase E 空状态行动化 | 部分完成 | `RulesPage` 的规则空状态增加直接添加对应级别规则的行动按钮。 |
本轮涉及的新增文件:
- `apps/web/components/ui/Field.tsx`
- `apps/web/components/ui/TextInput.tsx`
- `apps/web/components/ui/TextArea.tsx`
- `apps/web/components/ui/Select.tsx`
- `apps/web/components/ui/StatusNote.tsx`
- `apps/web/components/ui/SectionHeader.tsx`
- `apps/web/components/ui/SegmentedControl.tsx`
- `apps/web/components/AiToolbarMoreMenu.tsx`
- `apps/web/components/review/ReviewSectionPanel.tsx`
- `apps/web/components/review/ReviewSummaryRail.tsx`
已验证:
- `cd apps/web && pnpm lint`
- `cd apps/web && pnpm typecheck`
- `cd apps/web && pnpm test`468 passed
- `cd apps/web && pnpm build`
- 浏览器抽查 `http://localhost:3001/`:首页无可见横向溢出。
- 浏览器抽查 390px 宽度写作页:顶部工具条正确显示“写本章 / 审稿 / 更多”,更多菜单可打开。
### 0.2 2026-06-27 第二批已完成
第二批继续补齐了写作台与设置页,仍未修改后端 API / OpenAPI 契约。
| 阶段 | 状态 | 已完成内容 |
|---|---|---|
| Phase C 写作台三栏响应式 | 完成 C2 | 小屏正文上方增加“目录 / 助手”快捷入口;目录与本章助手继续经抽屉触达;`Drawer` 增加 `triggerRef`,关闭后焦点回到触发按钮。 |
| Phase C 本章生成控制台 | 完成 C3 | `DirectivePanel` 改为“本章生成控制台”;接入 `SectionHeader``TextArea`、统一 `Button` 预设 chips、`StatusNote`;显示当前指令数量与生成说明。 |
| Phase D 设置页控制台化 | 核心完成 | 设置页容器放宽到 `max-w-6xl``ProvidersSettings` 改为桌面左侧分组导航 + 右侧配置面板;窄屏使用 `SegmentedControl` 切换“路由 / OAuth / API Key”。 |
| Phase D Provider 行表格化 | 部分完成 | API Key 区改为更紧凑的配置行状态点、provider 名、masked key、输入框、保存、测试同一行保存按钮在空 key 时禁用;测试结果显示在行内下方。 |
第二批额外涉及的重点文件:
- `apps/web/components/workbench/Workbench.tsx`
- `apps/web/components/Drawer.tsx`
- `apps/web/components/ui/Button.tsx`
- `apps/web/app/settings/providers/page.tsx`
- `apps/web/components/settings/ProvidersSettings.tsx`
第二批已验证:
- `cd apps/web && pnpm lint`
- `cd apps/web && pnpm typecheck`
- `cd apps/web && pnpm test`468 passed
- `cd apps/web && pnpm build`
- 浏览器抽查 390px 写作页:可见“目录 / 助手”入口、“本章生成控制台”,无可见横向溢出。
- 浏览器抽查 1280px 设置页:左侧分组导航存在,当前面板正常显示,无可见横向溢出。
### 0.3 2026-06-27 第三批已完成
第三批补齐了部分空状态的直接行动入口,仍未修改后端 API / OpenAPI 契约。
| 阶段 | 状态 | 已完成内容 |
|---|---|---|
| Phase E 设定库空状态 | 部分完成 | `CodexPage` 的人物空状态增加“生成角色”按钮,世界观空状态增加“生成世界观”按钮,并滚动到对应生成器。 |
| Phase E 大纲空状态 | 完成核心 | `OutlineEditor` 的空状态增加“AI 排大纲”按钮,与页头主动作一致。 |
| Phase E 伏笔空状态 | 完成核心 | `ForeshadowBoard` 的空状态直接嵌入 `RegisterForm`,可从空状态登记第一条伏笔。 |
第三批涉及的重点文件:
- `apps/web/components/codex/CodexPage.tsx`
- `apps/web/components/outline/OutlineEditor.tsx`
- `apps/web/components/foreshadow/ForeshadowBoard.tsx`
第三批已验证:
- `cd apps/web && pnpm lint`
- `cd apps/web && pnpm typecheck`
- `cd apps/web && pnpm test`468 passed
- `cd apps/web && pnpm build`
### 0.4 2026-06-27 第四批已完成
第四批完成了项目列表筛选与密度调节的无契约版本;由于当前 `ProjectResponse` 没有更新时间 / 待审稿统计,本批只做前端可确定的搜索、轻筛选、标题/题材排序和视图密度切换。
| 阶段 | 状态 | 已完成内容 |
|---|---|---|
| Phase G 项目列表筛选 | 核心完成 | 首页新增 `ProjectLibrary` client 组件支持按标题、题材、logline、theme 搜索;支持“全部 / 有题材 / 未归类”轻筛选。 |
| Phase G 项目排序 | 部分完成 | 支持按标题、按题材排序;按题材排序时未归类作品置后。 |
| Phase G 密度调节 | 完成 | `ProjectCard` 增加 `compact` 模式;首页支持卡片 / 紧凑列表切换,并用 `localStorage` 保存偏好。 |
| Phase G 空结果状态 | 完成 | 筛选无结果时显示行动导向空状态,提供“清空筛选”和“新建作品”。 |
第四批新增 / 重点文件:
- `apps/web/components/projects/ProjectLibrary.tsx`
- `apps/web/lib/projects/projects.ts`
- `apps/web/lib/projects/projects.test.ts`
- `apps/web/components/ProjectCard.tsx`
- `apps/web/app/page.tsx`
第四批已验证:
- `cd apps/web && pnpm lint`
- `cd apps/web && pnpm typecheck`
- `cd apps/web && pnpm test`471 passed
- `cd apps/web && pnpm build`
- 浏览器抽查 390px 首页:搜索控件存在,无可见横向溢出。
### 0.5 2026-06-27 第五批已完成
第五批完成了夜读模式的第一版;仍未修改后端 API / OpenAPI 契约。
| 阶段 | 状态 | 已完成内容 |
|---|---|---|
| Phase F 主题变量 | 完成 | `globals.css` 增加 `paper` / `night` 两套 CSS tokenTailwind 阴影改为读取 `--shadow-paper`,冲突高亮也改为变量驱动。 |
| Phase F 偏好开关 | 完成 | 新增 `ThemeToggle`,接入 `AppShell` 顶栏;用 `localStorage` 保存 `ww.theme_mode`,支持纸感 / 夜读双向切换。 |
| Phase F 测试 | 完成 | 新增 `lib/ui/theme.ts` 纯逻辑与 `theme.test.ts`,覆盖主题合法性、兜底、切换和文案。 |
第五批新增 / 重点文件:
- `apps/web/components/ThemeToggle.tsx`
- `apps/web/lib/ui/theme.ts`
- `apps/web/lib/ui/theme.test.ts`
- `apps/web/app/globals.css`
- `apps/web/tailwind.config.ts`
### 0.6 2026-06-27 第六批已完成
第六批完成了夜读模式防闪增强和一批复杂表单收敛,仍未修改后端 API / OpenAPI 契约。
| 阶段 | 状态 | 已完成内容 |
|---|---|---|
| Phase F 首屏防闪 | 完成 | 新增 `ThemeScript`,在 React 水合前读取 `ww.theme_mode` 并设置 `html[data-theme]`,减少夜读模式刷新时的浅色闪烁。 |
| Phase A Kimi OAuth 收敛 | 完成核心 | `KimiCodeOauth` 改用 `SectionHeader` / `StatusNote` 展示说明、授权指引和错误状态。 |
| Phase A 生成器表单收敛 | 部分完成 | `CharacterGenerator``WorldGenerator` 改用 `Field` / `TextInput` / `TextArea` / `SectionHeader` / `StatusNote`。 |
第六批新增 / 重点文件:
- `apps/web/components/ThemeScript.tsx`
- `apps/web/components/settings/KimiCodeOauth.tsx`
- `apps/web/components/generation/CharacterGenerator.tsx`
- `apps/web/components/generation/WorldGenerator.tsx`
- `apps/web/lib/ui/theme.ts`
- `apps/web/lib/ui/theme.test.ts`
- `apps/web/app/layout.tsx`
### 0.7 2026-06-27 第七批已完成
第七批继续收敛卡片内部编辑控件,仍未修改后端 API / OpenAPI 契约。
| 阶段 | 状态 | 已完成内容 |
|---|---|---|
| Phase A 角色卡编辑控件 | 完成核心 | `CharacterCardItem` 的角色名 / 人物弧光就地编辑改用统一 `TextInput`。 |
| Phase A 伏笔卡控件 | 完成核心 | `ForeshadowCard` 的进展输入改用 `TextInput`,状态变更与“记”按钮改用统一 `Button`。 |
| Phase A 输入尺寸 | 完成 | `TextInput` 增加 `controlSize="sm" | "md"`,便于卡片、表格等紧凑区域复用。 |
第七批重点文件:
- `apps/web/components/ui/TextInput.tsx`
- `apps/web/components/generation/CharacterCardItem.tsx`
- `apps/web/components/foreshadow/ForeshadowCard.tsx`
### 0.8 2026-06-27 第八批已完成
第八批收敛低频表单页和审稿内联表单,仍未修改后端 API / OpenAPI 契约。
| 阶段 | 状态 | 已完成内容 |
|---|---|---|
| Phase A 模板页表单 | 完成核心 | `TemplatesManager` 的新建模板表单改用 `Field` / `TextInput` / `TextArea` / `SectionHeader` / `Button`;删除按钮改用统一危险按钮。 |
| Phase A 多章链表单 | 完成核心 | `ChainStarter` 的起始章号、连续章数和提交按钮改用统一组件。 |
| Phase A 大纲顶部控件 | 完成核心 | `OutlineEditor` 的卷筛选和卷号输入改用 `Select` / `TextInput` 紧凑尺寸。 |
| Phase A 审稿内联伏笔登记 | 完成核心 | `ForeshadowSuggestions` 的内联登记表单改用 `Field` / `TextInput` / `TextArea`。 |
| Phase A 控件尺寸 | 完成 | `TextArea` / `Select` 增加 `controlSize="sm" | "md"`,与 `TextInput` 保持一致。 |
第八批重点文件:
- `apps/web/components/templates/TemplatesManager.tsx`
- `apps/web/components/chain/ChainStarter.tsx`
- `apps/web/components/outline/OutlineEditor.tsx`
- `apps/web/components/review/ForeshadowSuggestions.tsx`
- `apps/web/components/ui/TextArea.tsx`
- `apps/web/components/ui/Select.tsx`
### 0.9 2026-06-27 第九批已完成
第九批完成审稿页窄屏布局微调和验收状态提示统一,仍未修改后端 API / OpenAPI 契约。
| 阶段 | 状态 | 已完成内容 |
|---|---|---|
| Phase B 审稿页窄屏布局 | 完成核心 | `ReviewReport` 在小屏改为自然纵向流:正文编辑区在上、报告/裁决/验收区在下;桌面仍保持左右两栏和右侧独立滚动。 |
| Phase B 验收区状态 | 完成核心 | `AcceptPanel` 的未决阻止、可验收、未审稿提示改用统一 `StatusNote`,阻止态边框更明确。 |
| Phase B 摘要动作 | 完成 | `ReviewSummaryRail` 的“跳到下一条未裁决”改用统一 `Button`。 |
| Phase B 节奏图溢出 | 完成 | `BeatMap` 增加内部横向滚动容器,避免小屏下节拍柱撑出页面。 |
第九批重点文件:
- `apps/web/components/review/ReviewReport.tsx`
- `apps/web/components/review/AcceptPanel.tsx`
- `apps/web/components/review/ReviewSummaryRail.tsx`
- `apps/web/components/review/BeatMap.tsx`
### 0.10 2026-06-27 第十批已完成
第十批完成当前伏笔页的视觉微修,仍未修改后端 API / OpenAPI 契约。
| 阶段 | 状态 | 已完成内容 |
|---|---|---|
| Phase E/页面微修 伏笔登记 | 完成 | `RegisterForm` 增加受控展开能力;`ForeshadowBoard` 的页头只保留“登记伏笔”按钮,表单改为页头下方完整面板,避免小屏挤在 action 区。 |
第十批重点文件:
- `apps/web/components/foreshadow/ForeshadowBoard.tsx`
- `apps/web/components/foreshadow/RegisterForm.tsx`
### 0.11 2026-06-27 第十一批已完成
第十一批继续增强伏笔页扫描效率,仍未修改后端 API / OpenAPI 契约。
| 阶段 | 状态 | 已完成内容 |
|---|---|---|
| 页面微修 伏笔概览 | 完成 | `ForeshadowBoard` 增加 OPEN / PARTIAL / CLOSED / OVERDUE 状态概览条,显示数量与占比,移动端两列、桌面四列。 |
| 伏笔纯逻辑 | 完成 | `lib/foreshadow/board.ts` 增加 `countByStatus`,与 `groupByStatus` 使用同一兜底规则;补单测。 |
第十一批重点文件:
- `apps/web/components/foreshadow/ForeshadowBoard.tsx`
- `apps/web/lib/foreshadow/board.ts`
- `apps/web/lib/foreshadow/board.test.ts`
### 0.12 2026-06-27 第十二批已完成
第十二批优化伏笔页移动端滚动体验,仍未修改后端 API / OpenAPI 契约。
| 阶段 | 状态 | 已完成内容 |
|---|---|---|
| 页面微修 伏笔移动端 | 完成 | `ForeshadowBoard` 小屏改为自然纵向滚动,桌面继续固定高度看板;`KanbanColumn` 小屏不再内部滚动,卡片顺着页面流展示。 |
第十二批重点文件:
- `apps/web/components/foreshadow/ForeshadowBoard.tsx`
- `apps/web/components/foreshadow/KanbanColumn.tsx`
### 0.13 2026-06-27 第十三批已完成
第十三批优化伏笔页状态命名可读性,仍未修改后端 API / OpenAPI 契约。
| 阶段 | 状态 | 已完成内容 |
|---|---|---|
| 页面微修 伏笔状态中文化 | 完成 | `LANE_LABELS` 改为作者可读的“待推进 / 推进中 / 已回收 / 已逾期”;看板概览、泳道标题、状态转移按钮显示中文主标签,同时保留 `OPEN` 等状态码作为小字。 |
第十三批重点文件:
- `apps/web/lib/foreshadow/board.ts`
- `apps/web/lib/foreshadow/board.test.ts`
- `apps/web/components/foreshadow/ForeshadowBoard.tsx`
- `apps/web/components/foreshadow/KanbanColumn.tsx`
- `apps/web/components/foreshadow/ForeshadowCard.tsx`
### 0.14 2026-06-28 第十四批已完成
第十四批优化技能库只读注册表的信息层级,仍未修改后端 API / OpenAPI 契约。
| 阶段 | 状态 | 已完成内容 |
|---|---|---|
| Phase E 技能库辅助信息 | 完成 | `SkillsPage` 增加注册表概览、来源计数、能力档位统计和写入边界提示;右侧注册表列表保留读/写表详情,视觉层级更清晰。 |
| 技能库纯逻辑 | 完成 | `lib/skills/skills.ts` 增加 `summarizeSkills`,统计总数、可写/只读、来源、档位与读写表集合;补单测。 |
第十四批重点文件:
- `apps/web/components/skills/SkillsPage.tsx`
- `apps/web/lib/skills/skills.ts`
- `apps/web/lib/skills/skills.test.ts`
### 0.15 2026-06-28 第十五批已完成
第十五批一次性收尾剩余可做的 UI 微修,仍未修改后端 API / OpenAPI 契约。
| 阶段 | 状态 | 已完成内容 |
|---|---|
| Phase B 审稿密度 | 完成 | 冲突列表按类型折叠;有未裁决项的分组默认展开,已处理分组默认收起,长报告扫描压力更低。 |
| Phase B 验收成功态 | 完成 | `AcceptPanel` 的成功态改为回执式布局,突出版本、摘要、裁决数量和下一章入口。 |
| Phase C 写作台底栏 | 完成 | 写作台底部工具条改为 sticky、按钮改小尺寸小屏下操作区更紧凑保存状态独立成行。 |
| Phase D 设置页说明 | 完成 | 路由面板补充提交边界说明API Key 面板增加已保存/可配置/测试结果统计和每行状态说明。 |
| Phase D OAuth 密度 | 完成 | Kimi OAuth 面板去掉多余外边距,授权码与授权按钮改为更紧凑的响应式布局,并补充加密保存说明。 |
第十五批重点文件:
- `apps/web/components/review/ReviewReport.tsx`
- `apps/web/components/review/AcceptPanel.tsx`
- `apps/web/components/workbench/Workbench.tsx`
- `apps/web/components/settings/ProvidersSettings.tsx`
- `apps/web/components/settings/KimiCodeOauth.tsx`
### 0.16 2026-06-28 后端补齐完成
用户要求把 Phase G 的后端依赖也补上,本批新增项目列表元数据契约,并完成前后端消费。
| 阶段 | 状态 | 已完成内容 |
|---|---|---|
| Phase G 后端元数据 | 完成 | `ProjectResponse` 增加 `updated_at``pending_review_count``chapters` 新增 `updated_at` 迁移,支持草稿重复保存后的真实编辑时间。 |
| Phase G 项目列表增强 | 完成 | 首页默认按“最近编辑”排序,新增“待审稿”筛选;作品卡显示最近编辑时间和待审稿徽标。 |
| 契约同步 | 完成 | 已更新 `memory/contracts.md` C3 扩展,并重生成前端 OpenAPI 类型。 |
本批重点文件:
- `packages/db/ww_db/models.py`
- `packages/db/migrations/versions/8c1d2e3f4a5b_chapters_updated_at.py`
- `packages/core/ww_core/domain/project_repo.py`
- `apps/api/ww_api/schemas/projects.py`
- `apps/web/components/projects/ProjectLibrary.tsx`
- `apps/web/components/ProjectCard.tsx`
- `apps/web/lib/projects/projects.ts`
### 0.17 剩余说明
| 阶段 | 状态 |
|---|---|
| Phase A | 已完成正文编辑器、命令面板搜索、文件上传、checkbox/radio 保留为合理特化控件。 |
| Phase B | 已完成;审稿页信息架构、窄屏布局、冲突密度和验收态均已收尾。 |
| Phase C | 已完成;顶部工具条、移动端抽屉入口、本章控制台和底部工具条均已收尾。 |
| Phase D | 已完成设置页控制台化、Provider 行、OAuth 密度和状态说明均已收尾。 |
| Phase E | 已完成;空状态行动入口与技能库概览均已收尾。 |
| Phase F | 已完成;夜读模式、防闪脚本和基础视觉 token 均已收尾。 |
| Phase G | 已完成;最近编辑排序、待审稿筛选和卡片元数据展示均已接真实后端字段。 |
结论:本轮 UI 改进计划内事项已收尾;后续建议转入代码整理、提交准备,或切换到 `PROGRESS.md` 中的 CR 整改项。
---
## 1. 目标与非目标
### 1.1 目标
1. **让长期写作更顺手**:减少写作台和审稿页的认知负担,把「下一步该做什么」放在最显眼位置。
2. **收敛表单与状态组件**:在已有 `Button`/`Badge` 基础上继续抽出输入控件与状态提示,降低样式漂移。
3. **提升复杂页面的信息层级**:设置、审稿、写作台等高密度页面要更像生产力工具,而不是卡片堆叠。
4. **强化响应式体验**:桌面保持高信息密度;平板/手机优先保留核心动作,次要入口折叠。
5. **守住纸感美术方向**:继续使用暖米白、朱砂、细线、衬线标题;避免大面积渐变、夸张阴影和营销式 hero。
### 1.2 非目标
- 不修改后端 API / OpenAPI schema。
- 不新增业务功能,例如权限、多用户、真实任务队列。
- 不改 `UX_SPEC.md` 的整体视觉方向。
- 不引入重型 UI 框架;继续使用 Tailwind + 本地轻量组件。
---
## 2. 现状评估
### 2.1 已经改善的部分
- 全局导航、项目卡片、工具箱、大纲、设定库、伏笔板、审稿页的基础视觉已经统一。
- `lucide-react` 图标已接入,旧字形符号大多已替换为语义图标。
- 统一组件已落地:
- `apps/web/components/ui/Button.tsx`
- `apps/web/components/ui/Badge.tsx`
- `apps/web/components/ui/Card.tsx`
- `apps/web/components/ui/EmptyState.tsx`
- `apps/web/components/ui/PageHeader.tsx`
- `apps/web/lib/ui/variants.ts`
- 首页、设置、新建、规则、审稿、文风、伏笔等页面已浏览器验收CSS 正常加载,无页面级横向溢出。
### 2.2 仍有改进空间
| 区域 | 问题 | 影响 |
|---|---|---|
| 写作台移动端 | 顶部 AI 工具条横向滚动,低频入口与高频入口同权 | 小屏写作时操作成本高 |
| 审稿页右侧报告 | 四审报告纵向堆叠,主次优先级还不够强 | 作者不容易第一眼知道必须先处理什么 |
| 设置页 | 表单清楚但纵向空间偏散,像普通表单而非控制台 | 配置效率一般,扫描成本偏高 |
| 表单控件 | input/select/textarea 样式仍散在各组件中 | 后续继续迭代容易样式漂移 |
| 空状态 | 已统一视觉,但行动按钮覆盖不足 | 新用户不知道下一步从哪里开始 |
| 侧栏 + 顶部工具条 | 两套导航并存时视觉权重接近 | 页面位置与高频动作的职责边界不够清楚 |
| 夜间写作 | 仅有纸感浅色主题 | 长时间写作时缺少低亮模式 |
---
## 3. 总体策略
### 3.1 组件先行
继续扩展 `apps/web/components/ui/`,先抽象可复用组件,再替换页面内散落样式。建议新增:
- `Field.tsx`label/help/error 包装。
- `TextInput.tsx`:统一 input。
- `TextArea.tsx`:统一 textarea。
- `Select.tsx`:统一 select。
- `StatusNote.tsx`:统一信息/警告/错误提示。
- `SectionHeader.tsx`:用于卡片或面板内部标题,避免滥用页面级 `h1`/`PageHeader`
- `SegmentedControl.tsx`:用于 tab / 模式切换,例如设定库 tabs、审稿视图切换。
### 3.2 页面分层
页面按三层组织:
1. **PageHeader**:页面标题、简短说明、最高优先级操作。
2. **Work Surface**:主要任务区,避免卡片套卡片。
3. **Assistive Panels**:辅助信息、状态、历史、报告,必要时折叠。
### 3.3 动作优先级
每页最多一个朱砂主按钮。其余动作按如下规则:
- 主动作:`Button variant="primary"`
- 次动作:`secondary`
- 轻量动作:`ghost`
- 状态切换:`SegmentedControl``Badge` + button。
- 危险动作:`danger`
### 3.4 响应式策略
- ≥1280保持三栏或双栏高密度布局。
- 10241279辅助面板收窄或折叠。
- <1024侧栏抽屉化顶部 AI 工具条只保留 23 个高频动作其余进入更多菜单
---
## 4. 分阶段实施计划
## Phase A · 表单与状态组件收敛(部分完成)
**目标**把散落在页面里的 input/select/textarea/status 样式统一降低后续维护成本
### A1. 新增表单基础组件
新增文件
- `apps/web/components/ui/Field.tsx`
- `apps/web/components/ui/TextInput.tsx`
- `apps/web/components/ui/TextArea.tsx`
- `apps/web/components/ui/Select.tsx`
- `apps/web/components/ui/StatusNote.tsx`
建议 API
```tsx
<Field label="书名(必填)" help="用于作品库和写作台标题">
<TextInput value={title} onChange={handleTitleChange} />
</Field>
```
```tsx
<StatusNote variant="warning" title="验收前请确认">
还有未裁决冲突,验收事务会被阻止。
</StatusNote>
```
需要替换的高收益文件
- `apps/web/components/ProjectWizard.tsx`
- `apps/web/components/settings/ProvidersSettings.tsx`
- `apps/web/components/settings/KimiCodeOauth.tsx`
- `apps/web/components/rules/RulesPage.tsx`
- `apps/web/components/foreshadow/RegisterForm.tsx`
- `apps/web/components/toolbox/GeneratorRunner.tsx`
- `apps/web/components/style/StyleUpload.tsx`
### A2. 扩展 variant helper
修改
- `apps/web/lib/ui/variants.ts`
新增
- `fieldClass`
- `inputClass`
- `statusNoteClass`
- `segmentedClass`
测试
- `apps/web/lib/ui/variants.test.ts`
新增断言
- error 状态有 `border-conflict` 和文本提示
- warning 状态不只靠颜色有图标位
- disabled/readOnly 状态视觉可区分
### A3. 验收标准
- `rg "focus:border-cinnabar|rounded border border-line bg-bg px" apps/web/components` 结果明显减少
- 所有表单控件 focus ring 一致
- `pnpm lint` / `pnpm typecheck` / `pnpm test` / `pnpm build` 全绿
---
## Phase B · 审稿页信息架构优化(核心完成)
**目标**让作者优先处理必须裁决的信息降低四审报告堆叠带来的扫描成本
### B1. 右侧报告改为分段面板
新增组件
- `apps/web/components/review/ReviewSectionPanel.tsx`
- `apps/web/components/review/ReviewSummaryRail.tsx`
结构建议
```tsx
<ReviewSummaryRail>
<ReviewSectionPanel kind="continuity" status="conflict" count={3} defaultOpen />
<ReviewSectionPanel kind="foreshadow" status="ok" />
<ReviewSectionPanel kind="pace" status="warning" />
<ReviewSectionPanel kind="style" status="incomplete" />
</ReviewSummaryRail>
```
交互规则
- 有未裁决冲突的 section 默认展开
- 无问题 section 默认折叠只显示状态摘要
- `incomplete` warning badge不用弱提示文字
- 在窄屏下报告区变成正文下方的 accordion
修改文件
- `apps/web/components/review/ReviewReport.tsx`
- `apps/web/components/review/ForeshadowSuggestions.tsx`
- `apps/web/components/review/PacePanel.tsx`
- `apps/web/components/style/StylePanel.tsx`
### B2. 冲突裁决区强化“下一步”
改进点
- 未裁决冲突卡顶部增加编号`冲突 1 / 3`
- 跳到下一条未裁决固定在报告区顶部不随列表滚走
- 所有冲突裁决完成后验收区主按钮视觉提升
- 手改输入框只在选择手改后展开且加 help 文案
修改文件
- `apps/web/components/review/ConflictCard.tsx`
- `apps/web/components/review/AcceptPanel.tsx`
- `apps/web/components/review/ReviewReport.tsx`
### B3. 验收标准
- 有冲突时首屏能看到冲突数量下一条未裁决入口至少一张冲突卡
- 无冲突时右侧报告区不显得空直接引导验收
- 键盘 tab 顺序重审 正文编辑 报告区 裁决按钮 验收
- 桌面和 390px 宽度均无页面级横向溢出
---
## Phase C · 写作台与顶部 AI 工具条优化(主体完成)
**目标**把写作台的核心动作留在手边把低频工具收进更轻的菜单
### C1. 顶部工具条分主动作和更多菜单
当前问题`写本章 / 审稿 / 大纲 / 设定库 / 工具箱` 在小屏同排滚动
改法
- 桌面保留完整工具条
- <1024 宽度只显示
- 写本章
- 审稿
- 更多
- 更多菜单内放
- 大纲
- 设定库
- 工具箱
新增组件
- `apps/web/components/AiToolbarMoreMenu.tsx`
修改文件
- `apps/web/components/AiToolbar.tsx`
- `apps/web/lib/nav/ai-tools.ts`
实现建议
- 不引入 popover 先用简单 client component + button + absolute menu
- `Esc` 关闭
- 点击菜单项后关闭
- `aria-expanded` / `aria-controls` / `role="menu"`
### C2. 写作台三栏响应式增强
当前方向
- 桌面三栏正常
- 小屏保留正文为主目录/助手转为抽屉或折叠面板
修改文件
- `apps/web/components/workbench/Workbench.tsx`
- `apps/web/components/workbench/ChapterList.tsx`
- `apps/web/components/workbench/ChapterAssistant.tsx`
改进点
- 小屏默认隐藏目录和助手
- 顶部或底部加两个 icon button目录 / 助手
- 打开后覆盖式抽屉不挤压正文
- 正文编辑区最小高度根据 viewport 调整
### C3. 本章指令区域更像“生成控制台”
改进点
- 本章指令折叠区增加 `SectionHeader`
- 风格预设 chips 改用 `SegmentedControl` 或统一小按钮
- 写本章按钮旁显示生成状态避免状态只在正文流里体现
### C4. 验收标准
- 390px 宽度下无需横向滚动即可看到写章和审稿
- 目录/助手可通过按钮打开且关闭后焦点回到触发按钮
- `prefers-reduced-motion` 下抽屉无强动画
---
## Phase D · 设置页控制台化(核心完成)
**目标**把模型与提供商页从普通表单改成高密度易扫描的配置控制台
### D1. 采用左右分组布局
当前结构
- 能力档位路由
- Kimi Code OAuth
- 提供商凭据
建议结构
```text
模型与提供商
├─ 左侧分组导航(路由 / OAuth / API Key
└─ 右侧配置面板
```
桌面
- 左侧 160px 分组导航
- 右侧表单区
窄屏
- 分组导航改为 `SegmentedControl`
修改文件
- `apps/web/app/settings/providers/page.tsx`
- `apps/web/components/settings/ProvidersSettings.tsx`
- `apps/web/components/settings/KimiCodeOauth.tsx`
### D2. 提供商行做成配置表格
改法
- 每个 provider 一行
- 状态点 + provider + masked key + key input + 操作按钮
- 操作按钮固定右侧
- 测试结果显示在行内下方而不是另起视觉块
### D3. 验收标准
- 1280 宽度下一屏可看到路由OAuth至少两个 provider
- 未配置状态清楚但不造成大面积空白
- 不输入 API Key 时保存按钮 disabled 或给明确错误
---
## Phase E · 空状态与首次使用引导(主体完成)
**目标**让新用户知道下一步而不是只看到暂无”。
### E1. EmptyState 增加 action policy
修改
- `apps/web/components/ui/EmptyState.tsx`
规范
- 空状态必须包含
- `title`
- `description`
- 至少一个推荐动作除非该动作确实不可用
优先改造
- `RulesPage`新增第一条规则
- `ForeshadowBoard`登记第一条伏笔
- `OutlineEditor`AI 排大纲
- `CodexPage`生成角色 / 生成世界观
- `SkillsPage`只读页可不加主动作但说明技能来源
### E2. 首页项目空状态增强
改进点
- 新作品为空时直接展示新建作品主按钮
- 若后续有模板库可加次动作从模板开始”。
### E3. 验收标准
- 每个空状态都能回答为什么空下一步做什么点哪里开始
- 空状态文案不超过 2 行正文避免说明书感
---
## Phase F · 夜读模式(完成第一版)
**目标**支持长时间写作场景降低夜间亮度刺激
### F1. 增加主题变量
修改
- `apps/web/app/globals.css`
- `apps/web/tailwind.config.ts`
新增暗色 token
```css
[data-theme="night"] {
--color-bg: #191715;
--color-panel: #22201d;
--color-ink: #eee4d4;
--color-ink-soft: #b7aa98;
--color-line: #3a342c;
--color-cinnabar: #d16a5b;
}
```
### F2. 增加偏好开关
实现位置
- 设置页加夜读模式开关
- AppShell 顶栏加 icon button
持久化
- 第一阶段用 `localStorage`
- 后续若有用户设置 API再同步到后端
### F3. 验收标准
- 切换后所有页面背景卡片输入框按钮badge 均可读
- 正文区域对比度仍满足 `UX_SPEC §10`
- 不影响 SSR 水合`suppressHydrationWarning` 已存在但仍要避免闪烁过大
---
## Phase G · 项目列表筛选与密度调节(核心完成)
**目标**项目变多后首页仍然可扫描
### G1. 项目列表增加轻筛选
改进点
- 搜索框按标题/题材过滤
- 筛选全部 / 最近写作 / 有审稿待处理
- 排序最近编辑 / 创建时间 / 标题
前置注意
- 当前 `ProjectResponse` 是否包含更新时间需确认若没有不改 API 的前提下只能做标题/题材过滤
- 若需要后端排序字段另起 API 契约任务不混入本 UI 计划
修改文件
- `apps/web/app/page.tsx`
- `apps/web/components/ProjectCard.tsx`
- 新增 `apps/web/components/projects/ProjectFilters.tsx`
### G2. 卡片密度调节
支持
- 标准卡片
- 紧凑列表
实现
- local state localStorage 保存视图偏好
### G3. 验收标准
- 20 个项目时首屏仍能快速定位
- 超长标题不撑破布局
- 筛选无结果时显示行动导向空状态
---
## 5. 建议执行顺序
| 顺序 | 阶段 | 价值 | 风险 | 建议批次 |
|---|---|---:|---:|---|
| 1 | Phase A 表单与状态组件收敛 | | | 1 PR |
| 2 | Phase B 审稿页信息架构 | | | 1 PR |
| 3 | Phase C 写作台/工具条响应式 | | | 1 PR |
| 4 | Phase D 设置页控制台化 | | | 1 PR |
| 5 | Phase E 空状态行动化 | | | 可并入 A/D |
| 6 | Phase F 夜读模式 | | | 独立 PR |
| 7 | Phase G 项目列表筛选 | | | API 情况决定 |
推荐先做 **A → B → C**这三步最能提升长期使用感”,且不会触碰后端契约
---
## 6. 文件级修改清单
### 6.1 新增文件
```text
apps/web/components/ui/Field.tsx
apps/web/components/ui/TextInput.tsx
apps/web/components/ui/TextArea.tsx
apps/web/components/ui/Select.tsx
apps/web/components/ui/StatusNote.tsx
apps/web/components/ui/SectionHeader.tsx
apps/web/components/ui/SegmentedControl.tsx
apps/web/components/AiToolbarMoreMenu.tsx
apps/web/components/review/ReviewSectionPanel.tsx
apps/web/components/review/ReviewSummaryRail.tsx
apps/web/components/projects/ProjectFilters.tsx
```
### 6.2 重点修改文件
```text
apps/web/lib/ui/variants.ts
apps/web/lib/ui/variants.test.ts
apps/web/app/globals.css
apps/web/tailwind.config.ts
apps/web/components/AiToolbar.tsx
apps/web/components/AppShell.tsx
apps/web/components/workbench/Workbench.tsx
apps/web/components/review/ReviewReport.tsx
apps/web/components/review/ConflictCard.tsx
apps/web/components/review/AcceptPanel.tsx
apps/web/components/settings/ProvidersSettings.tsx
apps/web/components/rules/RulesPage.tsx
apps/web/components/foreshadow/ForeshadowBoard.tsx
apps/web/components/outline/OutlineEditor.tsx
```
### 6.3 尽量不动的文件
- `apps/web/lib/api/schema.d.ts`本计划默认不改 OpenAPI不应重生成
- `apps/api/**`本计划不改后端
- `packages/**`本计划不改领域逻辑
---
## 7. 测试与验收
### 7.1 静态门禁
每个阶段完成后运行
```bash
cd apps/web
pnpm lint
pnpm typecheck
pnpm test
pnpm build
```
### 7.2 浏览器验收页面
至少覆盖
- `/`
- `/projects/new`
- `/settings/providers`
- `/projects/:id/write`
- `/projects/:id/review`
- `/projects/:id/outline`
- `/projects/:id/codex`
- `/projects/:id/foreshadow`
- `/projects/:id/rules`
- `/projects/:id/style`
- `/projects/:id/toolbox`
### 7.3 视口验收
至少覆盖
- 1280×720桌面默认
- 1024×768平板横向
- 390×844手机宽度
断言
- `document.documentElement.scrollWidth <= document.documentElement.clientWidth + 2`
- CSS stylesheet 正常加载
- 主动作在首屏可见
- 文字不溢出按钮或卡片
- 抽屉/菜单可打开关闭键盘可达
### 7.4 可访问性验收
- 所有 icon-only button 必须有 `aria-label`
- 警告/错误状态必须有图标或文字不只靠颜色
- 焦点环可见
- `Esc` 可关闭菜单/抽屉
- 尊重 `prefers-reduced-motion`
---
## 8. 风险与应对
| 风险 | 可能后果 | 应对 |
|---|---|---|
| 组件抽象过度 | 小改动变复杂 | 只抽已重复 3 次以上的模式 |
| 审稿页折叠隐藏重要信息 | 作者漏处理冲突 | 未裁决冲突默认展开并保留顶部总数 |
| 小屏菜单增加操作步骤 | 高频动作变慢 | 小屏只把低频入口收进更多写章/审稿常驻 |
| 夜读模式颜色不完整 | 某些页面不可读 | 所有颜色走 CSS 变量避免硬编码色值 |
| 项目筛选需要 API 字段 | UI 计划牵出后端契约 | 先做纯前端标题/题材过滤后端字段另开任务 |
---
## 9. 完成定义
整体计划完成时应满足
1. 表单控件状态提示页面 header空状态按钮徽标均有统一组件
2. 审稿页能清楚区分必须处理建议处理已通过
3. 写作台在 390px 宽度下可完成核心操作写本章停止审稿查看目录/助手
4. 设置页在桌面上更像配置控制台减少纵向滚动
5. 所有阶段门禁通过lint/typecheck/test/build
6. 浏览器验收覆盖桌面和平板/手机宽度无页面级横向溢出
---
## 10. 推荐第一批实施任务
建议下一次直接做以下 6 个任务控制范围且收益明显
1. 新增 `Field/TextInput/TextArea/Select/StatusNote`
2. 替换 `ProjectWizard``RulesPage``ProvidersSettings` 的表单控件
3. 新增 `ReviewSectionPanel`把审稿右侧四审状态改成 accordion
4. 新增 `AiToolbarMoreMenu`优化 <1024 宽度工具条
5. `EmptyState` 统一补推荐 action
6. 跑完整前端门禁 + 浏览器验收并记录截图结论

View File

@@ -91,6 +91,12 @@
- `PUT /projects/{id}/chapters/{no}/draft``DraftSaveRequest{text}` → 200 `DraftResponse{project_id, chapter_no, volume, status, version, length}`幂等version 固定 1、status='draft')。 - `PUT /projects/{id}/chapters/{no}/draft``DraftSaveRequest{text}` → 200 `DraftResponse{project_id, chapter_no, volume, status, version, length}`幂等version 固定 1、status='draft')。
- 网关注入缝 `get_writer_gateway`(从凭据构 `OpenAICompatAdapter`+`SqlAlchemyLedgerSink`);测试经 `app.dependency_overrides` 注 mock 网关(只需 `.stream(req)`)。 - 网关注入缝 `get_writer_gateway`(从凭据构 `OpenAICompatAdapter`+`SqlAlchemyLedgerSink`);测试经 `app.dependency_overrides` 注 mock 网关(只需 `.stream(req)`)。
- **@frontend 行动**M1 端点已全在 OpenAPI → Wave D 前先 `cd apps/web && pnpm gen:api` 重生成 `lib/api/schema.d.ts` - **@frontend 行动**M1 端点已全在 OpenAPI → Wave D 前先 `cd apps/web && pnpm gen:api` 重生成 `lib/api/schema.d.ts`
### C3 扩展(项目列表元数据, 2026-06-28· `ProjectResponse` 增加最近编辑与待审稿统计 owner @backend 状态: 稳定
- `ProjectResponse` 追加字段:`updated_at: datetime | null``pending_review_count: int >= 0`。创建、列表、详情响应共用同一 schema前端已 `pnpm gen:api`
- `updated_at` 口径:取项目自身 `projects.updated_at` 与该项目下章节 `chapters.updated_at`、审稿留痕 `chapter_reviews.created_at` 的最大值,用于作品库“最近编辑”排序。为支持草稿重复保存后的真实编辑时间,`chapters` 表新增 `updated_at` 列(迁移 `8c1d2e3f4a5b`,既有行以 `created_at` 回填)。
- `pending_review_count` 口径:按章节草稿行计数;当草稿 `updated_at` 晚于该章最近 accepted 版本和最近审稿留痕时,视为待审稿。已验收且草稿未再更新的章节不计入。
- **@frontend 已消费**:作品库增加“待审稿”筛选、“最近编辑”排序,作品卡显示最近编辑时间和待审稿徽标。
- **已落T2.4+T2.5, 2026-06-18snake_case全部挂 `projects.router`、已在 OpenAPI— M2 审/裁/验收三端点** - **已落T2.4+T2.5, 2026-06-18snake_case全部挂 `projects.router`、已在 OpenAPI— M2 审/裁/验收三端点**
- `POST /projects/{id}/chapters/{no}/review``ReviewRequest{draft?}`(空/缺→回退已存草稿无草稿→404 `NOT_FOUND`)→ **SSE** `text/event-stream`,帧:`section{name,status:"done"|"incomplete"}`(每审一条M2 仅 `continuity`) / `conflict{type,where,refs:list,suggestion}`(每冲突一条,形=C6 `Conflict` 五类) / `done{length=审项数}` / `error{code,message,request_id}`。无凭据→流前 `LLM_UNAVAILABLE`(503 JSON 信封)。续审网关 tier=analyst`get_review_gateway`)。**端点流耗尽后 `session.commit()`**(网关 ledger + collect 均只 flush - `POST /projects/{id}/chapters/{no}/review``ReviewRequest{draft?}`(空/缺→回退已存草稿无草稿→404 `NOT_FOUND`)→ **SSE** `text/event-stream`,帧:`section{name,status:"done"|"incomplete"}`(每审一条M2 仅 `continuity`) / `conflict{type,where,refs:list,suggestion}`(每冲突一条,形=C6 `Conflict` 五类) / `done{length=审项数}` / `error{code,message,request_id}`。无凭据→流前 `LLM_UNAVAILABLE`(503 JSON 信封)。续审网关 tier=analyst`get_review_gateway`)。**端点流耗尽后 `session.commit()`**(网关 ledger + collect 均只 flush
- `GET /projects/{id}/chapters/{no}/reviews``ReviewHistoryResponse{reviews:[ReviewHistoryItem{id,project_id,chapter_no,chapter_version?,conflicts:list,foreshadow_sug:list,style?,pace?,health_score?,decisions?}]}`(新→旧)。 - `GET /projects/{id}/chapters/{no}/reviews``ReviewHistoryResponse{reviews:[ReviewHistoryItem{id,project_id,chapter_no,chapter_version?,conflicts:list,foreshadow_sug:list,style?,pace?,health_score?,decisions?}]}`(新→旧)。

View File

@@ -0,0 +1,19 @@
from datetime import UTC, datetime
from ww_core.domain.project_repo import max_time
def test_max_time_handles_missing_values() -> None:
now = datetime(2026, 6, 28, tzinfo=UTC)
assert max_time(None, None) is None
assert max_time(now, None) == now
assert max_time(None, now) == now
def test_max_time_returns_latest_value() -> None:
early = datetime(2026, 6, 27, tzinfo=UTC)
late = datetime(2026, 6, 28, tzinfo=UTC)
assert max_time(early, late) == late
assert max_time(late, early) == late

View File

@@ -9,11 +9,12 @@ from __future__ import annotations
import uuid import uuid
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime
from typing import Any, Protocol from typing import Any, Protocol
from sqlalchemy import select from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from ww_db.models import Project from ww_db.models import Chapter, ChapterReview, Project
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -28,6 +29,8 @@ class ProjectView:
theme: str | None = None theme: str | None = None
selling_points: list[Any] = field(default_factory=list) selling_points: list[Any] = field(default_factory=list)
structure: str | None = None structure: str | None = None
updated_at: datetime | None = None
pending_review_count: int = 0
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -53,7 +56,12 @@ class ProjectRepo(Protocol):
async def get(self, owner_id: uuid.UUID, project_id: uuid.UUID) -> ProjectView | None: ... async def get(self, owner_id: uuid.UUID, project_id: uuid.UUID) -> ProjectView | None: ...
def _to_view(row: Project) -> ProjectView: def _to_view(
row: Project,
*,
updated_at: datetime | None = None,
pending_review_count: int = 0,
) -> ProjectView:
return ProjectView( return ProjectView(
id=row.id, id=row.id,
title=row.title, title=row.title,
@@ -63,6 +71,8 @@ def _to_view(row: Project) -> ProjectView:
theme=row.theme, theme=row.theme,
selling_points=list(row.selling_points or []), selling_points=list(row.selling_points or []),
structure=row.structure, structure=row.structure,
updated_at=updated_at or row.updated_at,
pending_review_count=pending_review_count,
) )
@@ -89,12 +99,26 @@ class SqlProjectRepo:
return _to_view(row) return _to_view(row)
async def list_for_owner(self, owner_id: uuid.UUID) -> list[ProjectView]: async def list_for_owner(self, owner_id: uuid.UUID) -> list[ProjectView]:
rows = ( projects = (
(
await self._s.execute( await self._s.execute(
select(Project).where(Project.owner_id == owner_id).order_by(Project.created_at) select(Project).where(Project.owner_id == owner_id).order_by(Project.created_at)
) )
).scalars() )
return [_to_view(r) for r in rows] .scalars()
.all()
)
project_ids = [p.id for p in projects]
pending_counts = await self._pending_review_counts(project_ids)
activity_times = await self._activity_times(project_ids)
return [
_to_view(
p,
updated_at=max_time(p.updated_at, activity_times.get(p.id)),
pending_review_count=pending_counts.get(p.id, 0),
)
for p in projects
]
async def get(self, owner_id: uuid.UUID, project_id: uuid.UUID) -> ProjectView | None: async def get(self, owner_id: uuid.UUID, project_id: uuid.UUID) -> ProjectView | None:
row = ( row = (
@@ -104,4 +128,90 @@ class SqlProjectRepo:
).scalar_one_or_none() ).scalar_one_or_none()
if row is None: if row is None:
return None return None
return _to_view(row) pending_counts = await self._pending_review_counts([project_id])
activity_times = await self._activity_times([project_id])
return _to_view(
row,
updated_at=max_time(row.updated_at, activity_times.get(project_id)),
pending_review_count=pending_counts.get(project_id, 0),
)
async def _activity_times(self, project_ids: list[uuid.UUID]) -> dict[uuid.UUID, datetime]:
if not project_ids:
return {}
chapter_rows = await self._s.execute(
select(Chapter.project_id, func.max(Chapter.updated_at))
.where(Chapter.project_id.in_(project_ids))
.group_by(Chapter.project_id)
)
review_rows = await self._s.execute(
select(ChapterReview.project_id, func.max(ChapterReview.created_at))
.where(ChapterReview.project_id.in_(project_ids))
.group_by(ChapterReview.project_id)
)
activity: dict[uuid.UUID, datetime] = {}
for project_id, updated_at in [*chapter_rows.all(), *review_rows.all()]:
if updated_at is None:
continue
latest = max_time(activity.get(project_id), updated_at)
if latest is not None:
activity[project_id] = latest
return activity
async def _pending_review_counts(self, project_ids: list[uuid.UUID]) -> dict[uuid.UUID, int]:
if not project_ids:
return {}
latest_reviews = (
select(
ChapterReview.project_id.label("project_id"),
ChapterReview.chapter_no.label("chapter_no"),
func.max(ChapterReview.created_at).label("reviewed_at"),
)
.where(ChapterReview.project_id.in_(project_ids))
.group_by(ChapterReview.project_id, ChapterReview.chapter_no)
.subquery()
)
latest_accepted = (
select(
Chapter.project_id.label("project_id"),
Chapter.chapter_no.label("chapter_no"),
func.max(Chapter.updated_at).label("accepted_at"),
)
.where(Chapter.project_id.in_(project_ids), Chapter.status == "accepted")
.group_by(Chapter.project_id, Chapter.chapter_no)
.subquery()
)
stmt = (
select(Chapter.project_id, func.count(Chapter.id))
.outerjoin(
latest_reviews,
(latest_reviews.c.project_id == Chapter.project_id)
& (latest_reviews.c.chapter_no == Chapter.chapter_no),
)
.outerjoin(
latest_accepted,
(latest_accepted.c.project_id == Chapter.project_id)
& (latest_accepted.c.chapter_no == Chapter.chapter_no),
)
.where(
Chapter.project_id.in_(project_ids),
Chapter.status == "draft",
(latest_accepted.c.accepted_at.is_(None))
| (latest_accepted.c.accepted_at < Chapter.updated_at),
(latest_reviews.c.reviewed_at.is_(None))
| (latest_reviews.c.reviewed_at < Chapter.updated_at),
)
.group_by(Chapter.project_id)
)
rows = await self._s.execute(stmt)
return {project_id: int(count) for project_id, count in rows.all()}
def max_time(left: datetime | None, right: datetime | None) -> datetime | None:
if left is None:
return right
if right is None:
return left
return max(left, right)

View File

@@ -0,0 +1,35 @@
"""Add updated_at to chapters.
Revision ID: 8c1d2e3f4a5b
Revises: 59a9198d4604
Create Date: 2026-06-28 00:00:00.000000
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "8c1d2e3f4a5b"
down_revision: str | None = "59a9198d4604"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column(
"chapters",
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
)
op.execute("UPDATE chapters SET updated_at = created_at")
def downgrade() -> None:
op.drop_column("chapters", "updated_at")

View File

@@ -165,7 +165,7 @@ class Rule(UuidPk, CreatedAt, Base):
content: Mapped[str] = mapped_column(Text, nullable=False) content: Mapped[str] = mapped_column(Text, nullable=False)
class Chapter(UuidPk, CreatedAt, Base): class Chapter(UuidPk, TimestampedMixin, Base):
__tablename__ = "chapters" __tablename__ = "chapters"
__table_args__ = ( __table_args__ = (
UniqueConstraint("project_id", "chapter_no", "version"), UniqueConstraint("project_id", "chapter_no", "version"),