feat: improve app ui and project metadata
This commit is contained in:
@@ -237,3 +237,4 @@ T0.1 monorepo 骨架 ✅ @devops · T0.2 16 MVP 表迁移(无漂移,users st
|
||||
- [2026-06-20] @frontend — **UX R3 完成**(验收前清单 HITL gate,UX §7.4,纯前端无后端改,续 `feat/ux-r1-r2-review-ergonomics`)。新纯函数 `lib/review/accept-preview.ts`(`buildAcceptPreview`:据现有 client state 列「本次验收将更新」——晋升版次 + 提炼章节摘要 + 写回裁决(有冲突时) + 验收后伏笔到期扫描(可能置 OVERDUE) + 伏笔建议待登记提醒(有建议时))。**只列后端 accept 事务确实做的事**(核对 `projects.py` accept 端点:promote+digest+decisions+overdue 扫描;**不**声称改人物 latest_state/自动落库伏笔建议);带「以上为预期,最终以验收回执为准」免责。`AcceptPanel` 加 `foreshadowCount` prop + 未阻断时渲染清单,`ReviewReport` 传 `foreshadow.length`。TDD:4 测。前端门禁绿:lint/tsc/**vitest 205**(+4)/build。下一步(按 plan):Tier4(T4-a 顶部 AI 工具条 / T4-b 本章指令框+风格预设)。
|
||||
- [2026-06-22] @backend — **✅ T6.2+T6.3 创作工具箱通用端点稳定**(通用生成器框架后端落地,分支 `feat/t6-creation-toolbox`)。新 `ww_skills.TOOLBOX`(11 条:legacy 3 + 新 8) + `get_tool`(`toolbox_registry.py`,descriptor 在代码无迁移)。3 新端点(`routers/toolbox.py`,已注册):`GET /skills/toolbox`(描述符列表,legacy 携 `legacy_route`:`codex?gen=world|character` / `/outline`)、`POST .../skills/{tool_key}/generate`(按 `spec.tier` 经新注入缝 `get_tier_gateway_builder` 建网关 → `run_generator` 结构化预览,**不写库**仅落 ledger;未知/legacy→404、项目不存在→404、无凭据→503)、`POST .../skills/{tool_key}/ingest`(仅 `ingest!=None` 工具;world_entities 走 **continuity 预检 409 gate**(`acknowledge_conflicts` 放行) + `partition_writes` 白名单 + schema→JSONB 写库,outline 细纲按 idx 拼 beats upsert;不可入库→422)。PURE context 派发 `services/toolbox_context.build_toolbox_context`(4 策略,缺章/大纲→空节拍不报错)。response/request schema `schemas/toolbox.py`(concrete 子模型,非裸 dict)。守不变量 #2/#3/#9。TDD:注册表 7 + context 8 + 端点 11 = **+26 测**。**全仓门禁绿**:ruff/format 干净、mypy **183 Success**(隔离 cache)、alembic **无漂移**(无新迁移)、pytest **578 passed**。OpenAPI 新增 3 端点 → **@frontend 须 `cd apps/web && pnpm gen:api`**(T6 前端前置)。记 contracts C3 扩(T6.2/T6.3)。
|
||||
- [2026-06-20] @frontend — **Tier4 完成**(T4-a 全局 AI 工具条 + T4-b 本章指令直通生成,分支 `feat/ux-tier4-ai-toolbar`,未并 develop)。T4-a:新 `lib/nav/ai-tools.ts`(aiToolItems) + `components/AiToolbar.tsx` 服务端组件,AppShell 顶栏下仅项目页渲染(写本章/审稿/大纲/设定库/工具箱常驻一排,写本章=朱砂主动作),用 `--chrome` CSS 变量统一 chrome 高度(项目页 7rem=header4+toolbar3,余 4rem),6 个固定高度页改用 `calc(100vh-var(--chrome,4rem))` 适配。T4-b(全链):后端 `assemble/_build_volatile` 加 `directive` 形参(「本章指令」段领衔 volatile,**绝不入 stable_core**,守缓存前缀不变量 #9),`stream_draft` 加可选 body `DraftStreamRequest{directive}` 透传、`directive_len` 入 log 不记原文、不持久化无迁移,重生成 TS 客户端;前端新 `lib/workbench/directive.ts`(STYLE_PRESETS + composeDirective 纯函数)、`useDraftStream.start(p,no,directive?)` 非空时发 JSON body、Workbench 加可折叠「本章指令」textarea + 风格预设 chips。TDD:ai-tools 4 测 + directive 4 测 + assemble directive 测 + draft 端点 directive/向后兼容 2 测。门禁全绿:后端 ruff/format/mypy(163 文件)/**pytest 479 passed**;前端 lint/tsc/**vitest 213**/build。契约记 contracts C3 扩(T4-b)。
|
||||
- [2026-06-28] @backend/@frontend — **UI Phase G 后端依赖补齐**:`ProjectResponse` 增加 `updated_at` / `pending_review_count`,`chapters` 加 `updated_at` 迁移 `8c1d2e3f4a5b`;`SqlProjectRepo` 聚合项目自身、章节、审稿时间作为最近编辑,并按草稿是否晚于最近 accepted/review 统计待审稿章数。前端 `pnpm gen:api` 后项目库默认按最近编辑排序,新增“待审稿”筛选,作品卡显示最近编辑与待审稿徽标。契约记 `memory/contracts.md` C3 扩,UI 计划记 `docs/design/ui-improvement-plan.md` 0.16。
|
||||
|
||||
@@ -9,6 +9,7 @@ from __future__ import annotations
|
||||
import uuid
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel
|
||||
@@ -41,6 +42,7 @@ class FakeProjectRepo:
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.rows: dict[uuid.UUID, tuple[uuid.UUID, ProjectView]] = {}
|
||||
self.now = datetime(2026, 6, 28, tzinfo=UTC)
|
||||
|
||||
async def create(self, owner_id: uuid.UUID, data: ProjectCreate) -> ProjectView:
|
||||
pid = uuid.uuid4()
|
||||
@@ -53,6 +55,7 @@ class FakeProjectRepo:
|
||||
theme=data.theme,
|
||||
selling_points=list(data.selling_points),
|
||||
structure=data.structure,
|
||||
updated_at=self.now,
|
||||
)
|
||||
self.rows[pid] = (owner_id, view)
|
||||
return view
|
||||
|
||||
@@ -158,8 +158,11 @@ async def test_list_projects() -> None:
|
||||
await client.post("/projects", json={"title": "乙"})
|
||||
resp = await client.get("/projects")
|
||||
assert resp.status_code == 200
|
||||
titles = {p["title"] for p in resp.json()["projects"]}
|
||||
projects = resp.json()["projects"]
|
||||
titles = {p["title"] for p in projects}
|
||||
assert titles == {"甲", "乙"}
|
||||
assert all("updated_at" in p for p in projects)
|
||||
assert all(p["pending_review_count"] == 0 for p in projects)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -169,7 +172,10 @@ async def test_get_project_detail() -> None:
|
||||
created = (await client.post("/projects", json={"title": "详情"})).json()
|
||||
resp = await client.get(f"/projects/{created['id']}")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["title"] == "详情"
|
||||
body = resp.json()
|
||||
assert body["title"] == "详情"
|
||||
assert "updated_at" in body
|
||||
assert body["pending_review_count"] == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -6,6 +6,7 @@ snake_case;前端经 OpenAPI 生成 TS 类型消费。改字段 → 前端必
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
@@ -34,6 +35,8 @@ class ProjectResponse(BaseModel):
|
||||
theme: str | None = None
|
||||
selling_points: list[str] = Field(default_factory=list)
|
||||
structure: str | None = None
|
||||
updated_at: datetime | None = Field(default=None, description="项目最近更新时间")
|
||||
pending_review_count: int = Field(default=0, ge=0, description="待审稿章节数")
|
||||
|
||||
|
||||
class ProjectListResponse(BaseModel):
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
@tailwind utilities;
|
||||
|
||||
/* 纸感设计 token(UX_SPEC §2.1) */
|
||||
:root {
|
||||
:root,
|
||||
[data-theme="paper"] {
|
||||
--color-bg: #f5f1e8;
|
||||
--color-panel: #fbf8f1;
|
||||
--color-ink: #2b2620;
|
||||
@@ -15,6 +16,32 @@
|
||||
--color-overdue: #c8893a;
|
||||
--color-pass: #5a6b4f;
|
||||
--color-info: #4a5a6b;
|
||||
--color-conflict-mark: #b5543a26;
|
||||
--color-conflict-mark-strong: #b5543a8c;
|
||||
--shadow-paper: #2b26200f;
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
[data-theme="night"] {
|
||||
--color-bg: #181614;
|
||||
--color-panel: #221f1b;
|
||||
--color-ink: #efe6d7;
|
||||
--color-ink-soft: #b9ab96;
|
||||
--color-line: #3c352c;
|
||||
--color-cinnabar: #e07866;
|
||||
--color-cinnabar-wash: #e0786620;
|
||||
--color-conflict: #ef8a72;
|
||||
--color-overdue: #d8a65d;
|
||||
--color-pass: #95b47d;
|
||||
--color-info: #8ca8ca;
|
||||
--color-conflict-mark: #ef8a7230;
|
||||
--color-conflict-mark-strong: #ef8a7290;
|
||||
--shadow-paper: #00000045;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
html {
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
body {
|
||||
@@ -22,6 +49,11 @@ body {
|
||||
color: var(--color-ink);
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--color-cinnabar-wash);
|
||||
color: var(--color-ink);
|
||||
}
|
||||
|
||||
/* 流式打字机光标:朱砂闪烁;尊重 prefers-reduced-motion(UX §10)。 */
|
||||
.typewriter-cursor {
|
||||
animation: typewriter-blink 1s step-end infinite;
|
||||
@@ -42,7 +74,7 @@ body {
|
||||
|
||||
/* 终稿正文:冲突原文持续高亮(朱砂淡底),点锚点定位时闪烁一下(UX §8.3)。 */
|
||||
.draft-mark {
|
||||
background-color: #b5543a26; /* --color-conflict @ ~15% */
|
||||
background-color: var(--color-conflict-mark);
|
||||
border-radius: 2px;
|
||||
}
|
||||
.draft-mark-flash {
|
||||
@@ -52,10 +84,10 @@ body {
|
||||
@keyframes draft-mark-flash {
|
||||
0%,
|
||||
100% {
|
||||
background-color: #b5543a26;
|
||||
background-color: var(--color-conflict-mark);
|
||||
}
|
||||
30% {
|
||||
background-color: #b5543a8c; /* --color-conflict @ ~55% */
|
||||
background-color: var(--color-conflict-mark-strong);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
|
||||
import { CommandPaletteMount } from "@/components/command/CommandPaletteMount";
|
||||
import { ThemeScript } from "@/components/ThemeScript";
|
||||
import { ToastProvider } from "@/components/Toast";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
@@ -19,6 +20,7 @@ export default function RootLayout({
|
||||
return (
|
||||
<html lang="zh-CN" suppressHydrationWarning>
|
||||
<body className="font-sans antialiased" suppressHydrationWarning>
|
||||
<ThemeScript />
|
||||
<ToastProvider>
|
||||
{children}
|
||||
<CommandPaletteMount />
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import Link from "next/link";
|
||||
import { BookOpen, Plus } from "lucide-react";
|
||||
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { ProjectCard } from "@/components/ProjectCard";
|
||||
import { ProjectLibrary } from "@/components/projects/ProjectLibrary";
|
||||
import { EmptyState } from "@/components/ui/EmptyState";
|
||||
import { PageHeader } from "@/components/ui/PageHeader";
|
||||
import { fetchProjects } from "@/lib/api/server";
|
||||
import type { ProjectResponse } from "@/lib/api/types";
|
||||
import { buttonClass } from "@/lib/ui/variants";
|
||||
|
||||
// 作品库(Dashboard 入口,UX §6.1)。Server Component 读取。
|
||||
export default async function DashboardPage() {
|
||||
@@ -18,63 +22,44 @@ export default async function DashboardPage() {
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
<div className="mx-auto max-w-5xl px-8 py-10">
|
||||
<div className="mb-8 flex items-center justify-between">
|
||||
<h1 className="font-serif text-3xl text-ink">我的作品</h1>
|
||||
<div className="mx-auto max-w-5xl px-6 py-10 sm:px-8">
|
||||
<PageHeader
|
||||
title="我的作品"
|
||||
description="从一个灵感进入正文、设定、审稿与验收闭环。"
|
||||
actions={
|
||||
<Link
|
||||
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>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{loadError ? (
|
||||
<p className="rounded border border-conflict bg-panel p-6 text-conflict">
|
||||
无法连接后端服务,请确认 API 已启动后刷新。
|
||||
</p>
|
||||
) : 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">
|
||||
{projects.map((p) => (
|
||||
<li key={p.id}>
|
||||
<ProjectCard project={p} />
|
||||
</li>
|
||||
))}
|
||||
<li>
|
||||
<NewProjectCard />
|
||||
</li>
|
||||
</ul>
|
||||
<ProjectLibrary projects={projects} />
|
||||
)}
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { ProvidersSettings } from "@/components/settings/ProvidersSettings";
|
||||
import { PageHeader } from "@/components/ui/PageHeader";
|
||||
import { fetchKimiOauthStatus, fetchProviders } from "@/lib/api/server";
|
||||
import type { ProvidersResponse } from "@/lib/api/types";
|
||||
|
||||
@@ -27,7 +28,11 @@ export default async function ProvidersSettingsPage() {
|
||||
|
||||
return (
|
||||
<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 ? (
|
||||
<p className="rounded border border-conflict bg-panel p-6 text-conflict">
|
||||
无法连接后端服务,请确认 API 已启动后刷新。
|
||||
|
||||
@@ -1,7 +1,21 @@
|
||||
import Link from "next/link";
|
||||
import {
|
||||
BookOpen,
|
||||
ClipboardCheck,
|
||||
ListTree,
|
||||
PenLine,
|
||||
Sparkles,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
import { aiToolItems } from "@/lib/nav/ai-tools";
|
||||
import { AiToolbarMoreMenu } from "@/components/AiToolbarMoreMenu";
|
||||
import {
|
||||
aiToolItems,
|
||||
primaryAiToolItems,
|
||||
secondaryAiToolItems,
|
||||
} from "@/lib/nav/ai-tools";
|
||||
import type { ActiveNav } from "@/lib/nav/items";
|
||||
import { buttonClass } from "@/lib/ui/variants";
|
||||
|
||||
interface AiToolbarProps {
|
||||
projectId: string;
|
||||
@@ -9,16 +23,20 @@ interface AiToolbarProps {
|
||||
}
|
||||
|
||||
// T4-a · 项目内页常驻 AI 工具条(顶栏下,UX §3/§5)。
|
||||
// 纯链接服务端组件:写本章=朱砂主动作,其余次级;激活项朱砂高亮。窄屏横向滚动。
|
||||
// 桌面展示完整工具条;窄屏只保留写本章/审稿,把低频入口收进更多菜单。
|
||||
export function AiToolbar({ projectId, activeNav }: AiToolbarProps) {
|
||||
const primaryItems = primaryAiToolItems(projectId);
|
||||
const secondaryItems = secondaryAiToolItems(projectId);
|
||||
return (
|
||||
<nav
|
||||
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 className = toolClassName(item.primary === true, isActive);
|
||||
const Icon = toolIcon(item.key);
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
@@ -26,23 +44,50 @@ export function AiToolbar({ projectId, activeNav }: AiToolbarProps) {
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
className={className}
|
||||
>
|
||||
<span aria-hidden="true">{item.glyph}</span>
|
||||
<Icon className="h-4 w-4" aria-hidden="true" />
|
||||
<span>{item.label}</span>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
function toolIcon(key: ActiveNav): LucideIcon {
|
||||
if (key === "write") return PenLine;
|
||||
if (key === "review") return ClipboardCheck;
|
||||
if (key === "outline") return ListTree;
|
||||
if (key === "codex") return BookOpen;
|
||||
return Sparkles;
|
||||
}
|
||||
|
||||
function toolClassName(isPrimary: boolean, isActive: boolean): string {
|
||||
const base =
|
||||
"flex items-center gap-1.5 rounded border px-3 py-1.5 text-sm transition-colors";
|
||||
if (isActive) {
|
||||
return `${base} border-cinnabar text-cinnabar`;
|
||||
return buttonClass({ variant: "outline", size: "sm" });
|
||||
}
|
||||
if (isPrimary) {
|
||||
return `${base} border-cinnabar bg-cinnabar text-panel hover:border-cinnabar`;
|
||||
return buttonClass({ variant: "primary", size: "sm" });
|
||||
}
|
||||
return `${base} border-line text-ink-soft hover:border-cinnabar hover:text-cinnabar`;
|
||||
return buttonClass({ variant: "secondary", size: "sm" });
|
||||
}
|
||||
|
||||
94
apps/web/components/AiToolbarMoreMenu.tsx
Normal file
94
apps/web/components/AiToolbarMoreMenu.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
import Link from "next/link";
|
||||
import type { ReactNode } from "react";
|
||||
import { Settings } from "lucide-react";
|
||||
|
||||
import type { ActiveNav } from "@/lib/nav/items";
|
||||
import { buttonClass } from "@/lib/ui/variants";
|
||||
import { AiToolbar } from "./AiToolbar";
|
||||
import { LeftNav } from "./LeftNav";
|
||||
import { NavDrawer } from "./NavDrawer";
|
||||
import { ThemeToggle } from "./ThemeToggle";
|
||||
|
||||
interface AppShellProps {
|
||||
children: ReactNode;
|
||||
@@ -34,23 +37,33 @@ export function AppShell({
|
||||
<NavDrawer projectId={projectId} activeNav={activeNav} />
|
||||
<Link
|
||||
href="/"
|
||||
className="font-serif text-xl text-cinnabar"
|
||||
className="shrink-0 whitespace-nowrap font-serif text-xl text-cinnabar"
|
||||
aria-label="返回作品库"
|
||||
>
|
||||
墨痕
|
||||
</Link>
|
||||
{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}
|
||||
{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}
|
||||
<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
|
||||
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>
|
||||
</nav>
|
||||
</header>
|
||||
@@ -59,7 +72,7 @@ export function AppShell({
|
||||
) : null}
|
||||
<div className="flex">
|
||||
<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}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, type ReactNode } from "react";
|
||||
import { useEffect, useRef, type ReactNode, type RefObject } from "react";
|
||||
|
||||
import { handleTabTrap } from "@/lib/a11y/focusTrap";
|
||||
|
||||
@@ -11,6 +11,7 @@ interface DrawerProps {
|
||||
side?: "left" | "right";
|
||||
// 无障碍标签(role=dialog)。
|
||||
label: string;
|
||||
triggerRef?: RefObject<HTMLElement | null>;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
@@ -21,12 +22,21 @@ export function Drawer({
|
||||
onClose,
|
||||
side = "left",
|
||||
label,
|
||||
triggerRef,
|
||||
children,
|
||||
}: DrawerProps) {
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const wasOpenRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (!open) {
|
||||
if (wasOpenRef.current) {
|
||||
wasOpenRef.current = false;
|
||||
triggerRef?.current?.focus();
|
||||
}
|
||||
return;
|
||||
}
|
||||
wasOpenRef.current = true;
|
||||
const onKey = (e: KeyboardEvent): void => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
@@ -36,7 +46,7 @@ export function Drawer({
|
||||
window.addEventListener("keydown", onKey);
|
||||
panelRef.current?.focus();
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [open, onClose]);
|
||||
}, [open, onClose, triggerRef]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Menu } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import type { ActiveNav } from "@/lib/nav/items";
|
||||
import { Drawer } from "./Drawer";
|
||||
import { NavItems } from "./NavItems";
|
||||
@@ -17,17 +19,16 @@ export function NavDrawer({ projectId, activeNav }: NavDrawerProps) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
<Button
|
||||
onClick={() => setOpen(true)}
|
||||
aria-label="打开导航"
|
||||
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">
|
||||
☰
|
||||
</span>
|
||||
</button>
|
||||
<Menu className="h-5 w-5" aria-hidden="true" />
|
||||
</Button>
|
||||
<Drawer
|
||||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
|
||||
@@ -1,6 +1,21 @@
|
||||
"use client";
|
||||
|
||||
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 {
|
||||
@@ -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({
|
||||
item,
|
||||
active,
|
||||
@@ -63,18 +94,20 @@ function NavLink({
|
||||
active: boolean;
|
||||
onNavigate?: () => void;
|
||||
}) {
|
||||
const Icon = navIcon(item);
|
||||
return (
|
||||
<li>
|
||||
<Link
|
||||
href={item.href}
|
||||
onClick={onNavigate}
|
||||
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
|
||||
? "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}
|
||||
</Link>
|
||||
</li>
|
||||
|
||||
@@ -1,26 +1,98 @@
|
||||
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 {
|
||||
formatProjectUpdatedAt,
|
||||
pendingReviewCount,
|
||||
} from "@/lib/projects/projects";
|
||||
import { cardClass } from "@/lib/ui/variants";
|
||||
|
||||
interface ProjectCardProps {
|
||||
project: ProjectResponse;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
// 作品卡(UX §6.1):书名衬线大字、题材、一句话故事。
|
||||
// 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 (
|
||||
<Link
|
||||
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>
|
||||
{project.genre ? (
|
||||
<p className="mt-2 text-sm text-ink-soft">{project.genre}</p>
|
||||
) : null}
|
||||
{project.logline ? (
|
||||
<p className="mt-3 line-clamp-3 text-sm text-ink">{project.logline}</p>
|
||||
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded bg-[var(--color-cinnabar-wash)] text-cinnabar">
|
||||
<BookOpen className="h-4 w-4" aria-hidden="true" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate font-serif text-base text-ink">
|
||||
〈{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}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowLeft, ArrowRight, CheckCircle2 } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
import { api } from "@/lib/api/client";
|
||||
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 {
|
||||
GENRES,
|
||||
SELLING_POINT_PRESETS,
|
||||
@@ -69,14 +77,17 @@ export function ProjectWizard() {
|
||||
const isLast = step === WIZARD_STEPS;
|
||||
|
||||
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">
|
||||
<h1 className="font-serif text-2xl text-ink">新建作品</h1>
|
||||
<StepDots current={step} />
|
||||
</div>
|
||||
<p className="mb-4 text-sm text-ink-soft">
|
||||
步骤 {step}/{WIZARD_STEPS} · {STEP_TITLES[step - 1]}
|
||||
</p>
|
||||
<div className="mb-4">
|
||||
<Badge variant="accent">
|
||||
步骤 {step}/{WIZARD_STEPS}
|
||||
</Badge>
|
||||
<p className="mt-2 text-sm text-ink-soft">{STEP_TITLES[step - 1]}</p>
|
||||
</div>
|
||||
|
||||
<div className="min-h-[220px]">
|
||||
{step === 1 && <StepBasics form={form} update={update} />}
|
||||
@@ -93,32 +104,28 @@ export function ProjectWizard() {
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex items-center justify-between">
|
||||
<button
|
||||
type="button"
|
||||
onClick={goBack}
|
||||
disabled={step === 1}
|
||||
className="rounded border border-line px-4 py-2 text-sm text-ink disabled:opacity-40"
|
||||
>
|
||||
← 上一步
|
||||
</button>
|
||||
<Button onClick={goBack} disabled={step === 1} variant="secondary">
|
||||
<ArrowLeft className="h-4 w-4" aria-hidden="true" />
|
||||
上一步
|
||||
</Button>
|
||||
{isLast ? (
|
||||
<button
|
||||
type="button"
|
||||
<Button
|
||||
onClick={submit}
|
||||
disabled={submitting || !canSubmit(form)}
|
||||
className="rounded bg-cinnabar px-5 py-2 text-sm text-panel disabled:opacity-40"
|
||||
variant="primary"
|
||||
>
|
||||
{submitting ? "创建中…" : "完成立项 →"}
|
||||
</button>
|
||||
<CheckCircle2 className="h-4 w-4" aria-hidden="true" />
|
||||
{submitting ? "创建中…" : "完成立项"}
|
||||
</Button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
<Button
|
||||
onClick={goNext}
|
||||
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>
|
||||
@@ -131,7 +138,7 @@ function StepDots({ current }: { current: number }) {
|
||||
{Array.from({ length: WIZARD_STEPS }, (_, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={`h-2 w-2 rounded-full ${
|
||||
className={`h-2 w-2 rounded ${
|
||||
i + 1 <= current ? "bg-cinnabar" : "bg-line"
|
||||
}`}
|
||||
/>
|
||||
@@ -145,30 +152,11 @@ interface StepProps {
|
||||
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) {
|
||||
return (
|
||||
<div>
|
||||
<Field label="书名(必填)">
|
||||
<input
|
||||
className={inputCls}
|
||||
<TextInput
|
||||
value={form.title}
|
||||
onChange={(e) => update({ title: e.target.value })}
|
||||
placeholder="例:逐光而行"
|
||||
@@ -176,8 +164,7 @@ function StepBasics({ form, update }: StepProps) {
|
||||
/>
|
||||
</Field>
|
||||
<Field label="题材">
|
||||
<select
|
||||
className={inputCls}
|
||||
<Select
|
||||
value={form.genre}
|
||||
onChange={(e) => update({ genre: e.target.value })}
|
||||
>
|
||||
@@ -187,7 +174,7 @@ function StepBasics({ form, update }: StepProps) {
|
||||
{g}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Select>
|
||||
</Field>
|
||||
</div>
|
||||
);
|
||||
@@ -201,8 +188,8 @@ function StepStory({
|
||||
return (
|
||||
<div>
|
||||
<Field label="一句话故事(logline)">
|
||||
<textarea
|
||||
className={`${inputCls} h-20 resize-none`}
|
||||
<TextArea
|
||||
className="h-20 resize-none"
|
||||
value={form.logline}
|
||||
onChange={(e) => update({ logline: e.target.value })}
|
||||
placeholder="废柴少年觉醒禁忌血脉,在仙门倾轧中逆势封神。"
|
||||
@@ -215,11 +202,10 @@ function StepStory({
|
||||
type="button"
|
||||
key={s}
|
||||
onClick={() => update({ structure: s })}
|
||||
className={`rounded border px-3 py-1.5 text-sm ${
|
||||
form.structure === s
|
||||
? "border-cinnabar bg-[var(--color-cinnabar-wash)] text-cinnabar"
|
||||
: "border-line text-ink"
|
||||
}`}
|
||||
className={buttonClass({
|
||||
variant: form.structure === s ? "outline" : "secondary",
|
||||
size: "sm",
|
||||
})}
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
@@ -235,11 +221,12 @@ function StepStory({
|
||||
key={p}
|
||||
aria-pressed={form.sellingPoints.includes(p)}
|
||||
onClick={() => toggleSellingPoint(p)}
|
||||
className={`rounded border px-3 py-1.5 text-sm ${
|
||||
form.sellingPoints.includes(p)
|
||||
? "border-cinnabar bg-[var(--color-cinnabar-wash)] text-cinnabar"
|
||||
: "border-line text-ink"
|
||||
}`}
|
||||
className={buttonClass({
|
||||
variant: form.sellingPoints.includes(p)
|
||||
? "outline"
|
||||
: "secondary",
|
||||
size: "sm",
|
||||
})}
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
@@ -254,16 +241,15 @@ function StepPremise({ form, update }: StepProps) {
|
||||
return (
|
||||
<div>
|
||||
<Field label="立意(premise)">
|
||||
<textarea
|
||||
className={`${inputCls} h-20 resize-none`}
|
||||
<TextArea
|
||||
className="h-20 resize-none"
|
||||
value={form.premise}
|
||||
onChange={(e) => update({ premise: e.target.value })}
|
||||
placeholder="故事的核心命题与前提。"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="主题(theme)">
|
||||
<input
|
||||
className={inputCls}
|
||||
<TextInput
|
||||
value={form.theme}
|
||||
onChange={(e) => update({ theme: e.target.value })}
|
||||
placeholder="例:抗争与代价"
|
||||
@@ -282,8 +268,8 @@ function StepProtagonist({ form, update }: StepProps) {
|
||||
主角与金手指(M1 暂并入总纲,后续设定库独立建模)。
|
||||
</p>
|
||||
<Field label="主角 / 金手指概要">
|
||||
<textarea
|
||||
className={`${inputCls} h-28 resize-none`}
|
||||
<TextArea
|
||||
className="h-28 resize-none"
|
||||
value={form.protagonist}
|
||||
onChange={(e) => update({ protagonist: e.target.value })}
|
||||
placeholder="主角设定、金手指来源与限制……"
|
||||
@@ -295,7 +281,7 @@ function StepProtagonist({ form, update }: StepProps) {
|
||||
|
||||
function StepWorld() {
|
||||
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>
|
||||
);
|
||||
|
||||
10
apps/web/components/ThemeScript.tsx
Normal file
10
apps/web/components/ThemeScript.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { themeBootstrapScript } from "@/lib/ui/theme";
|
||||
|
||||
export function ThemeScript() {
|
||||
return (
|
||||
<script
|
||||
dangerouslySetInnerHTML={{ __html: themeBootstrapScript() }}
|
||||
suppressHydrationWarning
|
||||
/>
|
||||
);
|
||||
}
|
||||
70
apps/web/components/ThemeToggle.tsx
Normal file
70
apps/web/components/ThemeToggle.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -143,6 +143,7 @@ export function ChainAdjudication({
|
||||
<ConflictCard
|
||||
key={i}
|
||||
index={i}
|
||||
total={conflicts.length}
|
||||
conflict={conflict}
|
||||
draft={draft}
|
||||
missing={false}
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
"use client";
|
||||
|
||||
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";
|
||||
|
||||
interface ChainStarterProps {
|
||||
@@ -41,12 +46,10 @@ export function ChainStarter({ onStart, disabled }: ChainStarterProps) {
|
||||
if (valid && !disabled) onStart(chainKey, startNo, countNo);
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<h2 className="font-serif text-lg text-ink">连续写多章</h2>
|
||||
<p className="mt-1 text-sm text-ink-soft">
|
||||
从指定章起循环「写章 → 四审 → 验收」;遇未决冲突会暂停等你裁决再续跑。
|
||||
</p>
|
||||
</div>
|
||||
<SectionHeader
|
||||
title="连续写多章"
|
||||
description="从指定章起循环「写章 → 四审 → 验收」;遇未决冲突会暂停等你裁决再续跑。"
|
||||
/>
|
||||
<fieldset className="flex flex-col gap-2">
|
||||
<legend className="text-sm text-ink-soft">链类型</legend>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
@@ -75,37 +78,35 @@ export function ChainStarter({ onStart, disabled }: ChainStarterProps) {
|
||||
</div>
|
||||
</fieldset>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<label className="block text-sm text-ink-soft">
|
||||
起始章号
|
||||
<input
|
||||
<Field label="起始章号" className="w-32">
|
||||
<TextInput
|
||||
type="number"
|
||||
min={1}
|
||||
value={start}
|
||||
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="起始章号"
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-sm text-ink-soft">
|
||||
连续章数(1..{MAX_COUNT})
|
||||
<input
|
||||
</Field>
|
||||
<Field label={`连续章数(1..${MAX_COUNT})`} className="w-40">
|
||||
<TextInput
|
||||
type="number"
|
||||
min={1}
|
||||
max={MAX_COUNT}
|
||||
value={count}
|
||||
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="连续章数"
|
||||
/>
|
||||
</label>
|
||||
</Field>
|
||||
</div>
|
||||
<button
|
||||
<Button
|
||||
type="submit"
|
||||
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 ? "运行中…" : "发起多章链"}
|
||||
</button>
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { Clock3, Globe2, Sparkles, UserRound } from "lucide-react";
|
||||
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { CharacterGenerator } from "@/components/generation/CharacterGenerator";
|
||||
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 {
|
||||
CharacterCardView,
|
||||
ProjectResponse,
|
||||
@@ -60,6 +66,10 @@ export function CodexPage({
|
||||
activeNav="codex"
|
||||
>
|
||||
<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">
|
||||
{TABS.map((t) => (
|
||||
<button
|
||||
@@ -82,20 +92,28 @@ export function CodexPage({
|
||||
<div className="min-h-0 flex-1 overflow-auto">
|
||||
{tab === "characters" ? (
|
||||
<div className="flex flex-col gap-4">
|
||||
<section className="rounded border border-line bg-panel p-3">
|
||||
<h3 className="mb-2 font-serif text-sm text-ink">
|
||||
<Card as="section" className="p-4">
|
||||
<h3 className="mb-3 font-serif text-sm text-ink">
|
||||
已入库人物({characters.length})
|
||||
</h3>
|
||||
{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) => (
|
||||
<li
|
||||
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">
|
||||
{c.name}({c.role})
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<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>
|
||||
<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 ? (
|
||||
<ul className="mt-1 flex flex-wrap gap-1">
|
||||
{c.relations.map((r, j) => (
|
||||
@@ -113,11 +131,29 @@ export function CodexPage({
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-xs text-ink-soft">
|
||||
暂无入库人物,先在下方生成并入库。
|
||||
</p>
|
||||
<EmptyState
|
||||
icon={UserRound}
|
||||
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
|
||||
projectId={project.id}
|
||||
onIngested={(_, cards) =>
|
||||
@@ -125,12 +161,13 @@ export function CodexPage({
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{tab === "world" ? (
|
||||
<div className="flex flex-col gap-4">
|
||||
<section className="rounded border border-line bg-panel p-3">
|
||||
<h3 className="mb-2 font-serif text-sm text-ink">
|
||||
<Card as="section" className="p-4">
|
||||
<h3 className="mb-3 font-serif text-sm text-ink">
|
||||
已入库世界观({initialWorldEntities.length})
|
||||
</h3>
|
||||
{initialWorldEntities.length > 0 ? (
|
||||
@@ -141,12 +178,16 @@ export function CodexPage({
|
||||
className="rounded border border-line bg-bg p-3 text-sm"
|
||||
>
|
||||
<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">
|
||||
{entity.name}
|
||||
</span>
|
||||
<span className="rounded bg-panel px-2 py-0.5 text-xs text-ink-soft">
|
||||
<Badge>
|
||||
{entity.type}
|
||||
</span>
|
||||
</Badge>
|
||||
</header>
|
||||
{worldEntityRules(entity).length > 0 ? (
|
||||
<ul className="flex list-disc flex-col gap-1 pl-5 text-ink-soft">
|
||||
@@ -161,19 +202,40 @@ export function CodexPage({
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-ink-soft">
|
||||
暂无入库世界观,可在下方生成预览参考。
|
||||
</p>
|
||||
<EmptyState
|
||||
icon={Globe2}
|
||||
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} />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{tab === "timeline" ? (
|
||||
<div className="rounded border border-dashed border-line bg-panel p-6 text-center text-sm text-ink-soft">
|
||||
时间线为派生视图(P2),将由章节摘要 + 伏笔窗口自动汇总,敬请期待。
|
||||
</div>
|
||||
<EmptyState
|
||||
icon={Clock3}
|
||||
title="时间线尚未启用"
|
||||
description="时间线会从章节摘要、伏笔窗口和验收记录派生,后续可用于全书一致性扫描。"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Flag, Plus } from "lucide-react";
|
||||
|
||||
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 {
|
||||
LANES,
|
||||
LANE_LABELS,
|
||||
countByStatus,
|
||||
groupByStatus,
|
||||
type ForeshadowStatus,
|
||||
} from "@/lib/foreshadow/board";
|
||||
import type { BadgeVariant } from "@/lib/ui/variants";
|
||||
import { useForeshadow } from "@/lib/foreshadow/useForeshadow";
|
||||
import { KanbanColumn } from "./KanbanColumn";
|
||||
import { RegisterForm } from "./RegisterForm";
|
||||
@@ -25,7 +33,9 @@ export function ForeshadowBoard({
|
||||
initialItems,
|
||||
}: ForeshadowBoardProps) {
|
||||
const { items, busy, register, transition } = useForeshadow(initialItems);
|
||||
const [registerOpen, setRegisterOpen] = useState(false);
|
||||
const lanes = useMemo(() => groupByStatus(items), [items]);
|
||||
const counts = useMemo(() => countByStatus(items), [items]);
|
||||
|
||||
const onTransition = (
|
||||
code: string,
|
||||
@@ -46,16 +56,51 @@ export function ForeshadowBoard({
|
||||
projectId={project.id}
|
||||
activeNav="foreshadow"
|
||||
>
|
||||
<div className="flex h-[calc(100vh-var(--chrome,4rem))] flex-col p-4">
|
||||
<div className="mb-4 flex items-start justify-between gap-4">
|
||||
<h1 className="font-serif text-lg text-ink">伏笔看板</h1>
|
||||
<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">
|
||||
<PageHeader
|
||||
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
|
||||
projectId={project.id}
|
||||
busy={busy}
|
||||
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) => (
|
||||
<KanbanColumn
|
||||
key={status}
|
||||
@@ -66,7 +111,49 @@ export function ForeshadowBoard({
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { AlertTriangle } from "lucide-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 { ForeshadowStatus } from "@/lib/foreshadow/board";
|
||||
import { LANE_LABELS, type ForeshadowStatus } from "@/lib/foreshadow/board";
|
||||
|
||||
interface ForeshadowCardProps {
|
||||
item: ForeshadowView;
|
||||
@@ -44,9 +48,10 @@ export function ForeshadowCard({
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="font-mono text-xs text-cinnabar">{item.code}</span>
|
||||
{overdue ? (
|
||||
<span className="text-xs text-overdue" aria-label="逾期">
|
||||
⚠ 逾期
|
||||
</span>
|
||||
<Badge variant="warning" aria-label="逾期">
|
||||
<AlertTriangle className="h-3 w-3" aria-hidden="true" />
|
||||
逾期
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="mt-0.5 font-serif text-sm text-ink">{item.title}</p>
|
||||
@@ -72,15 +77,16 @@ export function ForeshadowCard({
|
||||
{transitions.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{transitions.map((to) => (
|
||||
<button
|
||||
<Button
|
||||
key={to}
|
||||
type="button"
|
||||
disabled={busy}
|
||||
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}
|
||||
</button>
|
||||
→ {to === "CLOSED" ? "回收" : LANE_LABELS[to]}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
@@ -89,25 +95,27 @@ export function ForeshadowCard({
|
||||
<label htmlFor={`prog-${item.code}`} className="sr-only">
|
||||
{item.code} 进展
|
||||
</label>
|
||||
<input
|
||||
<TextInput
|
||||
id={`prog-${item.code}`}
|
||||
type="text"
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
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
|
||||
type="button"
|
||||
<Button
|
||||
disabled={busy || note.trim().length === 0}
|
||||
onClick={() => {
|
||||
onTransition(item.code, null, note.trim());
|
||||
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>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
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";
|
||||
|
||||
interface KanbanColumnProps {
|
||||
@@ -31,17 +32,23 @@ export function KanbanColumn({
|
||||
<header
|
||||
className={`flex items-center justify-between rounded-t border-b px-3 py-2 ${
|
||||
overdue
|
||||
? "border-overdue bg-overdue/10 text-overdue"
|
||||
? "border-overdue bg-overdue/10 text-ink"
|
||||
: "border-line bg-panel text-ink-soft"
|
||||
}`}
|
||||
>
|
||||
<span className="font-mono text-xs font-semibold">
|
||||
{overdue ? "⚠ " : ""}
|
||||
{status}
|
||||
<span className="flex items-center gap-2">
|
||||
{overdue ? (
|
||||
<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 className="font-mono text-xs">{items.length}</span>
|
||||
</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 ? (
|
||||
<li className="px-1 py-3 text-xs text-ink-soft/60">—</li>
|
||||
) : (
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
"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";
|
||||
|
||||
interface RegisterFormProps {
|
||||
projectId: string;
|
||||
busy: boolean;
|
||||
onRegister: (input: RegisterInput) => Promise<boolean>;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
const EMPTY = {
|
||||
@@ -25,9 +30,16 @@ export function RegisterForm({
|
||||
projectId,
|
||||
busy,
|
||||
onRegister,
|
||||
open: controlledOpen,
|
||||
onOpenChange,
|
||||
}: RegisterFormProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [localOpen, setLocalOpen] = useState(false);
|
||||
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 =>
|
||||
setForm((prev) => ({ ...prev, [key]: value }));
|
||||
@@ -51,13 +63,10 @@ export function RegisterForm({
|
||||
|
||||
if (!open) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
className="rounded bg-cinnabar px-3 py-1.5 text-sm text-panel hover:opacity-90"
|
||||
>
|
||||
+ 登记伏笔
|
||||
</button>
|
||||
<Button onClick={() => setOpen(true)} variant="primary" size="sm">
|
||||
<Plus className="h-4 w-4" aria-hidden="true" />
|
||||
登记伏笔
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -73,99 +82,90 @@ export function RegisterForm({
|
||||
className="rounded border border-line bg-panel p-4"
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||
<Field label="代号 *" id="f-code">
|
||||
<input
|
||||
<FormSlot label="代号 *" id="f-code">
|
||||
<TextInput
|
||||
id="f-code"
|
||||
value={form.code}
|
||||
onChange={(e) => set("code", e.target.value)}
|
||||
className={inputCls}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="标题 *" id="f-title" span2>
|
||||
<input
|
||||
</FormSlot>
|
||||
<FormSlot label="标题 *" id="f-title" span2>
|
||||
<TextInput
|
||||
id="f-title"
|
||||
value={form.title}
|
||||
onChange={(e) => set("title", e.target.value)}
|
||||
className={inputCls}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="埋设章" id="f-planted">
|
||||
<input
|
||||
</FormSlot>
|
||||
<FormSlot label="埋设章" id="f-planted">
|
||||
<TextInput
|
||||
id="f-planted"
|
||||
inputMode="numeric"
|
||||
value={form.plantedAt}
|
||||
onChange={(e) => set("plantedAt", e.target.value)}
|
||||
className={inputCls}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="回收窗口起" id="f-from">
|
||||
<input
|
||||
</FormSlot>
|
||||
<FormSlot label="回收窗口起" id="f-from">
|
||||
<TextInput
|
||||
id="f-from"
|
||||
inputMode="numeric"
|
||||
value={form.expectedCloseFrom}
|
||||
onChange={(e) => set("expectedCloseFrom", e.target.value)}
|
||||
className={inputCls}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="回收窗口止" id="f-to">
|
||||
<input
|
||||
</FormSlot>
|
||||
<FormSlot label="回收窗口止" id="f-to">
|
||||
<TextInput
|
||||
id="f-to"
|
||||
inputMode="numeric"
|
||||
value={form.expectedCloseTo}
|
||||
onChange={(e) => set("expectedCloseTo", e.target.value)}
|
||||
className={inputCls}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="重要度" id="f-imp">
|
||||
<input
|
||||
</FormSlot>
|
||||
<FormSlot label="重要度" id="f-imp">
|
||||
<TextInput
|
||||
id="f-imp"
|
||||
value={form.importance}
|
||||
onChange={(e) => set("importance", e.target.value)}
|
||||
placeholder="主线/支线"
|
||||
className={inputCls}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="线索内容" id="f-content" span3>
|
||||
<input
|
||||
</FormSlot>
|
||||
<FormSlot label="线索内容" id="f-content" span3>
|
||||
<TextInput
|
||||
id="f-content"
|
||||
value={form.content}
|
||||
onChange={(e) => set("content", e.target.value)}
|
||||
className={inputCls}
|
||||
/>
|
||||
</Field>
|
||||
</FormSlot>
|
||||
</div>
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<button
|
||||
<Button
|
||||
type="submit"
|
||||
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 ? "登记中…" : "登记"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
</Button>
|
||||
<Button
|
||||
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>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
const inputCls =
|
||||
"w-full rounded border border-line bg-bg px-2 py-1 text-sm text-ink focus:border-cinnabar focus:outline-none";
|
||||
|
||||
interface FieldProps {
|
||||
interface FormSlotProps {
|
||||
label: string;
|
||||
id: string;
|
||||
span2?: 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" : "";
|
||||
return (
|
||||
<div className={span}>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { TextInput } from "@/components/ui/TextInput";
|
||||
import type { CharacterCardView } from "@/lib/api/types";
|
||||
|
||||
interface CharacterCardItemProps {
|
||||
@@ -32,11 +33,12 @@ export function CharacterCardItem({
|
||||
className="size-4 accent-cinnabar"
|
||||
/>
|
||||
{onEdit ? (
|
||||
<input
|
||||
<TextInput
|
||||
value={card.name}
|
||||
onChange={(e) => onEdit({ name: e.target.value })}
|
||||
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>
|
||||
@@ -61,11 +63,12 @@ export function CharacterCardItem({
|
||||
<div className="mb-1 flex items-start gap-1 text-ink-soft">
|
||||
<span className="shrink-0 text-ink">弧光:</span>
|
||||
{onEdit ? (
|
||||
<input
|
||||
<TextInput
|
||||
value={card.arc}
|
||||
onChange={(e) => onEdit({ arc: e.target.value })}
|
||||
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>
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
"use client";
|
||||
|
||||
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 {
|
||||
MAX_CHARACTER_COUNT,
|
||||
@@ -80,45 +86,39 @@ export function CharacterGenerator({
|
||||
return (
|
||||
<section className="flex flex-col gap-4" aria-label="角色生成器">
|
||||
<div className="rounded border border-line bg-panel p-4">
|
||||
<h2 className="mb-3 font-serif text-base text-ink">角色生成器</h2>
|
||||
<label className="mb-2 block text-sm text-ink-soft">
|
||||
一句话需求
|
||||
<input
|
||||
<SectionHeader title="角色生成器" className="mb-3" />
|
||||
<Field label="一句话需求" className="mb-3">
|
||||
<TextInput
|
||||
value={brief}
|
||||
onChange={(e) => setBrief(e.target.value)}
|
||||
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">
|
||||
<label className="text-sm text-ink-soft">
|
||||
数量
|
||||
<input
|
||||
<Field label="数量" className="w-24">
|
||||
<TextInput
|
||||
type="number"
|
||||
min={MIN_CHARACTER_COUNT}
|
||||
max={MAX_CHARACTER_COUNT}
|
||||
value={count}
|
||||
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>
|
||||
<label className="flex-1 text-sm text-ink-soft">
|
||||
定位(可选)
|
||||
<input
|
||||
</Field>
|
||||
<Field label="定位(可选)" className="min-w-[12rem] flex-1">
|
||||
<TextInput
|
||||
value={role}
|
||||
onChange={(e) => setRole(e.target.value)}
|
||||
placeholder="主角 / CP / 对手 / 导师 / 工具人"
|
||||
className="mt-1 block w-full rounded border border-line bg-bg px-2 py-1.5 text-sm text-ink"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
</Field>
|
||||
<Button
|
||||
onClick={() => void onGenerate()}
|
||||
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 ? "生成中…" : "生成"}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -151,20 +151,20 @@ export function CharacterGenerator({
|
||||
onCancel={() => gen.reset()}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
<Button
|
||||
onClick={() => void doIngest(false)}
|
||||
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} 张`}
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{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}
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
"use client";
|
||||
|
||||
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 { useWorldGen } from "@/lib/generation/useWorldGen";
|
||||
|
||||
@@ -24,25 +29,23 @@ export function WorldGenerator({ projectId }: WorldGeneratorProps) {
|
||||
return (
|
||||
<section className="flex flex-col gap-4" aria-label="世界观设计器">
|
||||
<div className="rounded border border-line bg-panel p-4">
|
||||
<h2 className="mb-3 font-serif text-base text-ink">世界观设计器</h2>
|
||||
<label className="mb-3 block text-sm text-ink-soft">
|
||||
设定方向
|
||||
<textarea
|
||||
<SectionHeader title="世界观设计器" className="mb-3" />
|
||||
<Field label="设定方向" className="mb-3">
|
||||
<TextArea
|
||||
value={brief}
|
||||
onChange={(e) => setBrief(e.target.value)}
|
||||
placeholder="如:东方修真世界,灵气复苏,宗门林立,强者寿元有限"
|
||||
rows={3}
|
||||
className="mt-1 w-full resize-y rounded border border-line bg-bg px-3 py-2 text-sm text-ink"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
</Field>
|
||||
<Button
|
||||
onClick={() => void onGenerate()}
|
||||
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 ? "生成中…" : "生成世界观"}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{gen.status === "done" && gen.entities.length > 0 ? (
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { PenLine } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import type { OutlineChapterView } from "@/lib/api/types";
|
||||
import {
|
||||
isCloseWindow,
|
||||
windowBadgeLabel,
|
||||
} from "@/lib/outline/outline";
|
||||
import { buttonClass } from "@/lib/ui/variants";
|
||||
|
||||
interface OutlineChapterRowProps {
|
||||
chapter: OutlineChapterView;
|
||||
@@ -25,31 +28,28 @@ export function OutlineChapterRow({ chapter, projectId }: OutlineChapterRowProps
|
||||
</span>
|
||||
<Link
|
||||
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>
|
||||
{windows.map((w) => {
|
||||
const close = isCloseWindow(chapter, w);
|
||||
return (
|
||||
<span
|
||||
<Badge
|
||||
key={w.code}
|
||||
variant={close ? "warning" : "accent"}
|
||||
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)}
|
||||
{close ? " · 可回收" : ""}
|
||||
</span>
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{chapter.beats && chapter.beats.length > 0 ? (
|
||||
<p className="mt-1 text-sm text-ink">
|
||||
<span className="text-ink-soft">beats: </span>
|
||||
<span className="text-ink-soft">节拍:</span>
|
||||
{chapter.beats.join(" / ")}
|
||||
</p>
|
||||
) : (
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { ListTree, Sparkles } from "lucide-react";
|
||||
|
||||
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 {
|
||||
distinctVolumes,
|
||||
@@ -43,20 +49,29 @@ export function OutlineEditor({
|
||||
activeNav="outline"
|
||||
>
|
||||
<div className="flex h-[calc(100vh-var(--chrome,4rem))] flex-col p-6">
|
||||
<div className="mb-4 flex items-center gap-3">
|
||||
<h1 className="font-serif text-lg text-ink">大纲</h1>
|
||||
<PageHeader
|
||||
title="大纲"
|
||||
description="按卷组织章节节拍,写作台会把当前章节拍注入生成上下文。"
|
||||
actions={
|
||||
<>
|
||||
{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"
|
||||
value={viewVolume === "all" ? "all" : String(viewVolume)}
|
||||
onChange={(e) =>
|
||||
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>
|
||||
{availableVolumes.map((v) => (
|
||||
@@ -64,7 +79,7 @@ export function OutlineEditor({
|
||||
卷 {v}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Select>
|
||||
</label>
|
||||
) : null}
|
||||
<label
|
||||
@@ -73,22 +88,27 @@ export function OutlineEditor({
|
||||
>
|
||||
卷号
|
||||
</label>
|
||||
<input
|
||||
<TextInput
|
||||
id="vol"
|
||||
inputMode="numeric"
|
||||
value={volume}
|
||||
onChange={(e) => setVolume(Math.max(1, Number(e.target.value) || 1))}
|
||||
className="w-16 rounded border border-line bg-bg px-2 py-1 text-sm text-ink focus:border-cinnabar focus:outline-none"
|
||||
onChange={(e) =>
|
||||
setVolume(Math.max(1, Number(e.target.value) || 1))
|
||||
}
|
||||
controlSize="sm"
|
||||
className="w-16"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
<Button
|
||||
disabled={generating}
|
||||
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 排大纲"}
|
||||
</button>
|
||||
</div>
|
||||
<Sparkles className="h-4 w-4" aria-hidden="true" />
|
||||
{generating ? "排大纲中…" : "AI 排大纲"}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{error ? (
|
||||
<p className="mb-4 text-sm text-conflict">
|
||||
@@ -109,11 +129,30 @@ export function OutlineEditor({
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-auto">
|
||||
{volumes.length === 0 ? (
|
||||
<p className="rounded border border-dashed border-line p-6 text-sm text-ink-soft">
|
||||
{chapters.length > 0 && viewVolume !== "all"
|
||||
? `卷 ${viewVolume} 暂无大纲。切到「全部」查看其它卷,或点「✦ AI 排大纲」为本卷生成。`
|
||||
: "暂无大纲。点「✦ AI 排大纲」生成逐章节拍与伏笔窗口。"}
|
||||
</p>
|
||||
<EmptyState
|
||||
icon={ListTree}
|
||||
title={
|
||||
chapters.length > 0 && viewVolume !== "all"
|
||||
? `卷 ${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) => (
|
||||
<section key={group.volume} className="mb-6">
|
||||
|
||||
212
apps/web/components/projects/ProjectLibrary.tsx
Normal file
212
apps/web/components/projects/ProjectLibrary.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,14 @@
|
||||
"use client";
|
||||
|
||||
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 { buildAcceptPreview } from "@/lib/review/accept-preview";
|
||||
import { ThinkingIndicator } from "@/components/ThinkingIndicator";
|
||||
import { buttonClass } from "@/lib/ui/variants";
|
||||
|
||||
interface AcceptPanelProps {
|
||||
projectId: string;
|
||||
@@ -42,40 +46,50 @@ export function AcceptPanel({
|
||||
if (result) {
|
||||
return (
|
||||
<div className="rounded border border-pass/50 bg-panel p-4">
|
||||
<h3 className="mb-2 text-sm font-semibold text-pass">本章已验收</h3>
|
||||
<ul className="space-y-1 text-xs text-ink-soft">
|
||||
<li>
|
||||
晋升版次:
|
||||
<span className="font-mono text-ink">v{result.accepted_version}</span>
|
||||
</li>
|
||||
<li>
|
||||
章节摘要:{result.digest_added ? "已新增一行" : "未变更"}
|
||||
</li>
|
||||
<li>
|
||||
裁决写回:
|
||||
<span className="font-mono text-ink">
|
||||
<StatusNote variant="success" title="本章已验收">
|
||||
<p className="text-sm leading-6">
|
||||
终稿已晋升为正式版本,章节摘要和裁决留痕已按验收事务写回。
|
||||
</p>
|
||||
</StatusNote>
|
||||
<dl className="mt-3 grid grid-cols-3 gap-2 text-center">
|
||||
<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">
|
||||
v{result.accepted_version}
|
||||
</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 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}
|
||||
</span>{" "}
|
||||
条
|
||||
</li>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{result.review_id ? (
|
||||
<li className="truncate">
|
||||
<p className="mt-2 truncate text-[11px] text-ink-soft">
|
||||
留痕:<span className="font-mono">{result.review_id}</span>
|
||||
</li>
|
||||
</p>
|
||||
) : null}
|
||||
</ul>
|
||||
{/* 验收闭环 → 写下一章入口(修「验收后无路可走」的断点)。 */}
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
<Link
|
||||
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
|
||||
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>
|
||||
</div>
|
||||
@@ -84,20 +98,24 @@ export function AcceptPanel({
|
||||
}
|
||||
|
||||
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 ? (
|
||||
<p className="mb-2 text-xs text-conflict" role="status">
|
||||
<StatusNote className="mb-3" variant="danger" role="status">
|
||||
尚有 {unresolvedCount} 项冲突未裁决,处理完才能验收。
|
||||
</p>
|
||||
</StatusNote>
|
||||
) : (
|
||||
<>
|
||||
<p className="mb-2 text-xs text-ink-soft">
|
||||
<StatusNote className="mb-3" variant={reviewed ? "success" : "info"}>
|
||||
{conflictCount === 0
|
||||
? reviewed
|
||||
? "无冲突,可直接验收。"
|
||||
: "尚未审稿——建议先点「重新审稿」确认本章无冲突,再验收。"
|
||||
: "全部冲突已裁决,可验收本章。"}
|
||||
</p>
|
||||
</StatusNote>
|
||||
{/* R3:验收前「本次将更新」清单(预期,落库口径以验收回执为准)。 */}
|
||||
<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">
|
||||
@@ -122,19 +140,22 @@ export function AcceptPanel({
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
<Button
|
||||
onClick={onAccept}
|
||||
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 ? (
|
||||
<ThinkingIndicator label="验收中" className="justify-center" />
|
||||
) : (
|
||||
"全部处理完 → 验收本章"
|
||||
<>
|
||||
<CheckCircle2 className="h-4 w-4" aria-hidden="true" />
|
||||
全部处理完,验收本章
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,8 +16,9 @@ export function BeatMap({ beatMap }: BeatMapProps) {
|
||||
}
|
||||
const spark = toSparkline(beatMap);
|
||||
return (
|
||||
<div className="max-w-full overflow-x-auto pb-1">
|
||||
<div
|
||||
className="flex h-12 items-end gap-0.5"
|
||||
className="flex h-12 min-w-full items-end gap-0.5"
|
||||
role="img"
|
||||
aria-label={`爽点节拍图 ${spark}`}
|
||||
>
|
||||
@@ -30,5 +31,6 @@ export function BeatMap({ beatMap }: BeatMapProps) {
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
"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 { ReviewConflict } from "@/lib/review/sse";
|
||||
|
||||
interface ConflictCardProps {
|
||||
index: number;
|
||||
total: number;
|
||||
conflict: ReviewConflict;
|
||||
draft: DecisionDraft;
|
||||
missing: boolean;
|
||||
@@ -28,15 +34,16 @@ const VERDICT_OPTIONS: { value: Verdict; label: string; primary: boolean }[] = [
|
||||
|
||||
// 裁决按钮样式:激活=朱砂填充;未激活的主按钮=朱砂描边强调,次级=低对比描边。
|
||||
function verdictClass(active: boolean, primary: boolean): string {
|
||||
if (active) return "bg-cinnabar text-panel";
|
||||
if (primary) return "border border-cinnabar text-cinnabar hover:bg-cinnabar/10";
|
||||
return "border border-line text-ink-soft hover:border-cinnabar hover:text-ink";
|
||||
if (active) return buttonClass({ variant: "primary", size: "sm" });
|
||||
if (primary) return buttonClass({ variant: "outline", size: "sm" });
|
||||
return buttonClass({ variant: "secondary", size: "sm" });
|
||||
}
|
||||
|
||||
// 单个冲突报告卡(UX §6.4):五类徽标 + where + refs + suggestion + 裁决三态。
|
||||
// 冲突=赭红;未决/缺判时加图标+文案(不单靠色,a11y §10)。
|
||||
export function ConflictCard({
|
||||
index,
|
||||
total,
|
||||
conflict,
|
||||
draft,
|
||||
missing,
|
||||
@@ -60,13 +67,14 @@ export function ConflictCard({
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<span
|
||||
className="shrink-0 rounded bg-[var(--color-conflict)]/10 px-2 py-0.5 text-xs text-conflict"
|
||||
aria-hidden="true"
|
||||
>
|
||||
⚠ {conflict.type}
|
||||
</span>
|
||||
<p className="flex-1 text-sm text-ink">{conflict.suggestion}</p>
|
||||
<Badge variant="danger" className="shrink-0">
|
||||
<AlertTriangle className="h-3 w-3" aria-hidden="true" />
|
||||
冲突 {index + 1}/{total}
|
||||
</Badge>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs text-conflict">{conflict.type}</p>
|
||||
<p className="mt-1 text-sm text-ink">{conflict.suggestion}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{conflict.original && conflict.replacement ? (
|
||||
@@ -95,9 +103,10 @@ export function ConflictCard({
|
||||
<button
|
||||
type="button"
|
||||
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>
|
||||
) : null}
|
||||
{conflict.refs.map((ref) => (
|
||||
@@ -121,23 +130,24 @@ export function ConflictCard({
|
||||
aria-pressed={active}
|
||||
disabled={rewriting}
|
||||
onClick={() => onVerdict(index, opt.value)}
|
||||
className={`rounded px-3 py-1 text-xs disabled:cursor-not-allowed disabled:opacity-40 ${verdictClass(
|
||||
active,
|
||||
opt.primary,
|
||||
)}`}
|
||||
className={verdictClass(active, opt.primary)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{rewriting ? (
|
||||
<span className="text-xs text-info">✦ AI 改写中…</span>
|
||||
<Badge variant="info">
|
||||
<Sparkles className="h-3 w-3" aria-hidden="true" />
|
||||
AI 改写中…
|
||||
</Badge>
|
||||
) : resolved ? (
|
||||
<span className="text-xs text-pass" aria-hidden="true">
|
||||
✓ 已裁决
|
||||
</span>
|
||||
<Badge variant="success">
|
||||
<CheckCircle2 className="h-3 w-3" aria-hidden="true" />
|
||||
已裁决
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-xs text-conflict">未裁决</span>
|
||||
<Badge variant="danger">未裁决</Badge>
|
||||
)}
|
||||
</div>
|
||||
{draft.verdict === "manual" ? (
|
||||
@@ -145,14 +155,16 @@ export function ConflictCard({
|
||||
<label htmlFor={`note-${index}`} className="sr-only">
|
||||
手改说明
|
||||
</label>
|
||||
<input
|
||||
<TextInput
|
||||
id={`note-${index}`}
|
||||
type="text"
|
||||
value={draft.note}
|
||||
onChange={(e) => onNote(index, e.target.value)}
|
||||
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>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
"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 type { ForeshadowSuggestion } from "@/lib/review/sse";
|
||||
|
||||
@@ -36,11 +46,54 @@ export function ForeshadowSuggestions({
|
||||
const [registered, setRegistered] = useState<Set<number>>(new Set());
|
||||
const [resolved, setResolved] = 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 => {
|
||||
setOpenIdx(i);
|
||||
setForm({
|
||||
code: suggestForeshadowCode(s.code, i),
|
||||
code: nextFreeForeshadowCode(suggestForeshadowCode(s.code, i), savedCodes),
|
||||
title: s.title,
|
||||
plantedAt: chapterNo,
|
||||
content: s.note ?? s.where ?? "",
|
||||
@@ -73,6 +126,9 @@ export function ForeshadowSuggestions({
|
||||
markPending(i, false);
|
||||
if (ok) {
|
||||
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();
|
||||
}
|
||||
};
|
||||
@@ -84,36 +140,42 @@ export function ForeshadowSuggestions({
|
||||
if (ok) setResolved((prev) => new Set(prev).add(i));
|
||||
};
|
||||
|
||||
// 保留原始下标 i(registered/resolved/pending/openIdx 均按 i 索引),仅过滤已保存项。
|
||||
const visible = suggestions
|
||||
.map((s, i) => ({ s, i }))
|
||||
.filter(({ s, i }) => !isAlreadySaved(s, i));
|
||||
|
||||
return (
|
||||
<section className="mb-4">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wide text-ink-soft">
|
||||
伏笔 (foreshadow-analyst)
|
||||
</h2>
|
||||
{incomplete ? (
|
||||
<p className="mt-1 text-xs text-overdue">◌ 未完成</p>
|
||||
) : suggestions.length === 0 ? (
|
||||
<Badge variant="warning" className="mt-1">
|
||||
<CircleDashed className="h-3 w-3" aria-hidden="true" />
|
||||
未完成
|
||||
</Badge>
|
||||
) : visible.length === 0 ? (
|
||||
<p className="mt-1 text-xs text-ink-soft">无伏笔建议。</p>
|
||||
) : (
|
||||
<ul className="mt-2 space-y-2">
|
||||
{suggestions.map((s, i) => {
|
||||
{visible.map(({ s, i }) => {
|
||||
const code = s.code;
|
||||
const isResolved = s.kind === "resolved";
|
||||
const done = isResolved ? resolved.has(i) : registered.has(i);
|
||||
const busy = pending.has(i);
|
||||
return (
|
||||
<li
|
||||
key={i}
|
||||
className="rounded border border-line bg-panel p-2 text-xs"
|
||||
>
|
||||
<span
|
||||
className={`mr-1 rounded px-1.5 py-0.5 ${
|
||||
isResolved
|
||||
? "bg-pass/15 text-pass"
|
||||
: "bg-[var(--color-cinnabar-wash)] text-cinnabar"
|
||||
}`}
|
||||
>
|
||||
{isResolved ? "⚑ 疑似回收" : "+ 新埋待确认"}
|
||||
</span>
|
||||
<Badge variant={isResolved ? "success" : "accent"}>
|
||||
{isResolved ? (
|
||||
<Flag className="h-3 w-3" aria-hidden="true" />
|
||||
) : (
|
||||
<Plus className="h-3 w-3" aria-hidden="true" />
|
||||
)}
|
||||
{isResolved ? "疑似回收" : "新埋待确认"}
|
||||
</Badge>
|
||||
{code ? (
|
||||
<span className="font-mono text-ink-soft"> {code}</span>
|
||||
) : null}
|
||||
@@ -123,21 +185,19 @@ export function ForeshadowSuggestions({
|
||||
) : null}
|
||||
{s.note ? <p className="mt-0.5 text-ink-soft">{s.note}</p> : null}
|
||||
|
||||
{/* 作者确认动作区 */}
|
||||
{done ? (
|
||||
<p className="mt-1.5 text-pass">
|
||||
✓ {isResolved ? "已标记回收" : "已登记"}
|
||||
</p>
|
||||
) : isResolved ? (
|
||||
{/* 作者确认动作区(已保存的建议已从列表过滤,故无需「已登记」态) */}
|
||||
{isResolved ? (
|
||||
code ? (
|
||||
<button
|
||||
type="button"
|
||||
<Button
|
||||
disabled={busy}
|
||||
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 ? "处理中…" : "✓ 标记回收"}
|
||||
</button>
|
||||
<CheckCircle2 className="h-4 w-4" aria-hidden="true" />
|
||||
{busy ? "处理中…" : "标记回收"}
|
||||
</Button>
|
||||
) : (
|
||||
<p className="mt-1.5 text-ink-soft">
|
||||
未关联编码,去伏笔看板处理。
|
||||
@@ -152,13 +212,15 @@ export function ForeshadowSuggestions({
|
||||
onCancel={closeForm}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
<Button
|
||||
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"
|
||||
>
|
||||
+ 登记此伏笔
|
||||
</button>
|
||||
<Plus className="h-4 w-4" aria-hidden="true" />
|
||||
登记此伏笔
|
||||
</Button>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
@@ -187,39 +249,28 @@ function RegisterFormInline({
|
||||
}: RegisterFormInlineProps) {
|
||||
const canSubmit =
|
||||
!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 (
|
||||
<div className="mt-2 space-y-1.5 border-t border-line pt-2">
|
||||
<div>
|
||||
<label htmlFor="fs-reg-code" className="text-ink-soft">
|
||||
编码
|
||||
</label>
|
||||
<input
|
||||
<Field label="编码" htmlFor="fs-reg-code">
|
||||
<TextInput
|
||||
id="fs-reg-code"
|
||||
type="text"
|
||||
value={form.code}
|
||||
onChange={(e) => onChange({ ...form, code: e.target.value })}
|
||||
className={field}
|
||||
controlSize="sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="fs-reg-title" className="text-ink-soft">
|
||||
标题
|
||||
</label>
|
||||
<input
|
||||
</Field>
|
||||
<Field label="标题" htmlFor="fs-reg-title">
|
||||
<TextInput
|
||||
id="fs-reg-title"
|
||||
type="text"
|
||||
value={form.title}
|
||||
onChange={(e) => onChange({ ...form, title: e.target.value })}
|
||||
className={field}
|
||||
controlSize="sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="fs-reg-planted" className="text-ink-soft">
|
||||
埋设章号
|
||||
</label>
|
||||
<input
|
||||
</Field>
|
||||
<Field label="埋设章号" htmlFor="fs-reg-planted">
|
||||
<TextInput
|
||||
id="fs-reg-planted"
|
||||
type="number"
|
||||
min={1}
|
||||
@@ -227,38 +278,36 @@ function RegisterFormInline({
|
||||
onChange={(e) =>
|
||||
onChange({ ...form, plantedAt: Number(e.target.value) || 1 })
|
||||
}
|
||||
className={field}
|
||||
controlSize="sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="fs-reg-content" className="text-ink-soft">
|
||||
线索/正文(可选)
|
||||
</label>
|
||||
<textarea
|
||||
</Field>
|
||||
<Field label="线索/正文(可选)" htmlFor="fs-reg-content">
|
||||
<TextArea
|
||||
id="fs-reg-content"
|
||||
value={form.content}
|
||||
onChange={(e) => onChange({ ...form, content: e.target.value })}
|
||||
rows={2}
|
||||
className={`${field} resize-y`}
|
||||
controlSize="sm"
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
<div className="flex gap-2 pt-0.5">
|
||||
<button
|
||||
type="button"
|
||||
<Button
|
||||
disabled={!canSubmit}
|
||||
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 ? "登记中…" : "确认登记"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
</Button>
|
||||
<Button
|
||||
disabled={busy}
|
||||
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>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { AlertTriangle, CheckCircle2, CircleDashed } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import type { PaceReport } from "@/lib/review/sse";
|
||||
import { BeatMap } from "./BeatMap";
|
||||
|
||||
@@ -16,7 +19,10 @@ export function PacePanel({ pace, incomplete }: PacePanelProps) {
|
||||
节奏 (pace-checker)
|
||||
</h2>
|
||||
{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 ? (
|
||||
<p className="mt-1 text-xs text-ink-soft">无节奏报告。</p>
|
||||
) : (
|
||||
@@ -29,15 +35,24 @@ export function PacePanel({ pace, incomplete }: PacePanelProps) {
|
||||
<p>
|
||||
章末钩子:
|
||||
{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>
|
||||
|
||||
{pace.water.length > 0 ? (
|
||||
<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">
|
||||
{pace.water.map((w, i) => (
|
||||
<li key={i} className="text-ink-soft">
|
||||
@@ -47,7 +62,10 @@ export function PacePanel({ pace, incomplete }: PacePanelProps) {
|
||||
</ul>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-pass">✓ 无明显注水</p>
|
||||
<Badge variant="success">
|
||||
<CheckCircle2 className="h-3 w-3" aria-hidden="true" />
|
||||
无明显注水
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -2,8 +2,16 @@
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
RotateCcw,
|
||||
Square,
|
||||
} from "lucide-react";
|
||||
|
||||
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 { friendlyError } from "@/lib/errors/messages";
|
||||
import {
|
||||
@@ -44,6 +52,8 @@ import { AnnotatedText } from "./AnnotatedText";
|
||||
import { ConflictCard } from "./ConflictCard";
|
||||
import { ForeshadowSuggestions } from "./ForeshadowSuggestions";
|
||||
import { PacePanel } from "./PacePanel";
|
||||
import { ReviewSectionPanel } from "./ReviewSectionPanel";
|
||||
import { ReviewSummaryRail } from "./ReviewSummaryRail";
|
||||
import { StylePanel } from "@/components/style/StylePanel";
|
||||
import { RefineView } from "@/components/style/RefineView";
|
||||
|
||||
@@ -370,6 +380,13 @@ export function ReviewReport({
|
||||
const resolved = allResolved(drafts);
|
||||
const unresolved = unresolvedIndices(drafts).length;
|
||||
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 (
|
||||
<AppShell
|
||||
@@ -378,33 +395,27 @@ export function ReviewReport({
|
||||
projectId={project.id}
|
||||
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">
|
||||
<div className="flex items-center gap-3 border-b border-line bg-panel px-6 py-3">
|
||||
<h1 className="font-serif text-lg text-ink">
|
||||
<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-4 py-3 sm:px-6">
|
||||
<h1 className="min-w-0 flex-1 truncate font-serif text-lg text-ink">
|
||||
第 {chapterNo} 章 审稿报告
|
||||
</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} 冲突
|
||||
</span>
|
||||
<div className="ml-auto">
|
||||
<div className="shrink-0">
|
||||
{reviewing ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={review.stop}
|
||||
className="rounded border border-conflict px-3 py-1.5 text-sm text-conflict"
|
||||
>
|
||||
<Button onClick={review.stop} variant="danger" size="sm">
|
||||
<Square className="h-4 w-4" aria-hidden="true" />
|
||||
停
|
||||
</button>
|
||||
</Button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onReReview}
|
||||
className="rounded bg-cinnabar px-3 py-1.5 text-sm text-panel hover:opacity-90"
|
||||
>
|
||||
✦ 重新审稿
|
||||
</button>
|
||||
<Button onClick={onReReview} variant="primary" size="sm">
|
||||
<RotateCcw className="h-4 w-4" aria-hidden="true" />
|
||||
重新审稿
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -413,7 +424,7 @@ export function ReviewReport({
|
||||
<ReviewErrorNote error={review.state.error} />
|
||||
) : 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">
|
||||
<p className="text-xs text-ink-soft">
|
||||
可直接在下方正文修改(改动自动保存草稿 — 摘要从终稿提炼);点冲突的「跳转」或顶部锚点定位到对应文字。
|
||||
@@ -438,67 +449,93 @@ export function ReviewReport({
|
||||
</section>
|
||||
|
||||
{/* 右:审稿分区 + 冲突裁决 + 验收 gate */}
|
||||
<aside className="flex flex-col overflow-auto border-l border-line bg-panel px-4 py-4">
|
||||
<section className="mb-4">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wide text-ink-soft">
|
||||
一致性 (continuity)
|
||||
</h2>
|
||||
<aside
|
||||
className="flex flex-col border-t border-line bg-panel px-4 py-4 lg:overflow-auto lg:border-l lg:border-t-0"
|
||||
aria-label="审稿报告与验收"
|
||||
>
|
||||
<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
|
||||
reviewing={reviewing}
|
||||
done={review.state.phase === "done"}
|
||||
conflictCount={conflictCount}
|
||||
sections={review.state.sections}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
{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
|
||||
? "未发现一致性冲突。"
|
||||
: "进页未带审稿留痕,点「重新审稿」开始。"}
|
||||
</p>
|
||||
) : (
|
||||
<div className="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:讲清裁决保存模型——「采纳/裁决」仅本地暂存,验收时一并落库 */}
|
||||
<div className="mt-3 space-y-4">
|
||||
<p className="rounded border border-dashed border-line/70 bg-bg/40 px-3 py-1.5 text-[11px] text-ink-soft">
|
||||
裁决先暂存在本地,点下方「验收本章」时与终稿一并保存落库(验收前刷新会丢失未保存的裁决)。
|
||||
裁决先暂存在本地,点下方「验收本章」时与终稿一并保存落库。
|
||||
</p>
|
||||
|
||||
{groups.map((group) => (
|
||||
<div key={group.type}>
|
||||
<h3 className="mb-2 flex items-center gap-2 text-xs font-semibold text-ink">
|
||||
{groups.map((group) => {
|
||||
const unresolvedInGroup = group.items.filter(
|
||||
({ 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}
|
||||
<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}
|
||||
</Badge>
|
||||
</span>
|
||||
</h3>
|
||||
<ul className="space-y-3">
|
||||
<span className="font-mono text-[11px] text-ink-soft">
|
||||
{unresolvedInGroup > 0
|
||||
? `${unresolvedInGroup} 未裁决`
|
||||
: "已处理"}
|
||||
</span>
|
||||
</summary>
|
||||
<ul className="mt-3 space-y-3">
|
||||
{group.items.map(({ conflict: c, index: i }) => (
|
||||
<ConflictCard
|
||||
key={i}
|
||||
index={i}
|
||||
total={conflictCount}
|
||||
conflict={c}
|
||||
draft={drafts[i] ?? { verdict: null, note: "" }}
|
||||
missing={missing.has(i)}
|
||||
@@ -511,28 +548,88 @@ export function ReviewReport({
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</details>
|
||||
);
|
||||
})}
|
||||
</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
|
||||
suggestions={foreshadow}
|
||||
incomplete={sectionStatus("foreshadow") === "incomplete"}
|
||||
projectId={project.id}
|
||||
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
|
||||
pace={pace}
|
||||
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
|
||||
style={style}
|
||||
incomplete={sectionStatus("style") === "incomplete"}
|
||||
onRefine={onRefineSegment}
|
||||
/>
|
||||
</ReviewSectionPanel>
|
||||
{refineText !== null ? (
|
||||
<RefineView
|
||||
projectId={project.id}
|
||||
@@ -613,9 +710,15 @@ function SectionStatusLine({
|
||||
return (
|
||||
<p className="mt-1 text-xs">
|
||||
{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>
|
||||
);
|
||||
|
||||
54
apps/web/components/review/ReviewSectionPanel.tsx
Normal file
54
apps/web/components/review/ReviewSectionPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
65
apps/web/components/review/ReviewSummaryRail.tsx
Normal file
65
apps/web/components/review/ReviewSummaryRail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
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 {
|
||||
RULE_LEVELS,
|
||||
@@ -38,41 +45,48 @@ export function RulesPage({ project, initialRules }: RulesPageProps) {
|
||||
activeNav="rules"
|
||||
>
|
||||
<div className="mx-auto flex max-w-3xl flex-col gap-6 p-6">
|
||||
<PageHeader
|
||||
title="规则"
|
||||
description="维护本作、题材、世界观和章节级硬约束,写作与审稿会共同引用这些规则。"
|
||||
/>
|
||||
<form
|
||||
onSubmit={onSubmit}
|
||||
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">
|
||||
<label className="text-sm text-ink-soft">
|
||||
级别
|
||||
<select
|
||||
<Field label="级别" htmlFor="rule-level">
|
||||
<Select
|
||||
id="rule-level"
|
||||
value={level}
|
||||
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) => (
|
||||
<option key={lv} value={lv}>
|
||||
{RULE_LEVEL_LABELS[lv]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</Select>
|
||||
</Field>
|
||||
</div>
|
||||
<textarea
|
||||
<Field label="规则内容" htmlFor="rule-content">
|
||||
<TextArea
|
||||
id="rule-content"
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
placeholder="如:本作禁用现代科技词汇;称呼一律用「道友」"
|
||||
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"
|
||||
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 ? "保存中…" : "新增规则"}
|
||||
</button>
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
@@ -85,7 +99,22 @@ export function RulesPage({ project, initialRules }: RulesPageProps) {
|
||||
</span>
|
||||
</h2>
|
||||
{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">
|
||||
{groups[lv].map((rule) => (
|
||||
@@ -101,8 +130,9 @@ export function RulesPage({ project, initialRules }: RulesPageProps) {
|
||||
disabled={busy}
|
||||
onClick={() => void remove(project.id, rule.id)}
|
||||
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>
|
||||
</li>
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
"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 { useKimiOauth } from "@/lib/settings/useKimiOauth";
|
||||
|
||||
@@ -19,17 +24,22 @@ export function KimiCodeOauth({
|
||||
const expiresLabel = formatExpiresAt(expiresAt);
|
||||
|
||||
return (
|
||||
<section className="mb-10">
|
||||
<h2 className="mb-1 font-serif text-xl text-ink">Kimi Code(OAuth)</h2>
|
||||
<p className="mb-3 text-sm text-ink-soft">
|
||||
<section>
|
||||
<SectionHeader
|
||||
title="Kimi Code(OAuth)"
|
||||
description={
|
||||
<>
|
||||
订阅 plan 经 OAuth 设备授权连接(无需 API Key)。连接后可在上方档位路由里选用
|
||||
<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="flex flex-wrap items-center gap-3">
|
||||
<span
|
||||
className={`h-2 w-2 rounded-full ${
|
||||
className={`h-2 w-2 rounded ${
|
||||
phase === "connected" ? "bg-pass" : "bg-line"
|
||||
}`}
|
||||
aria-hidden="true"
|
||||
@@ -45,27 +55,29 @@ export function KimiCodeOauth({
|
||||
|
||||
<div className="ml-auto flex gap-2">
|
||||
{phase === "connected" ? (
|
||||
<button
|
||||
type="button"
|
||||
<Button
|
||||
onClick={() => void oauth.disconnect()}
|
||||
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 ? "处理中…" : "断开"}
|
||||
</button>
|
||||
</Button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
<Button
|
||||
onClick={() => void oauth.connect()}
|
||||
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
|
||||
? "等待授权…"
|
||||
: phase === "error"
|
||||
? "重新连接 Kimi Code(OAuth)"
|
||||
: "连接 Kimi Code(OAuth)"}
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -77,10 +89,16 @@ export function KimiCodeOauth({
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{phase !== "awaiting" ? (
|
||||
<StatusNote className="mt-3" variant="info">
|
||||
OAuth 连接只保存加密后的访问凭据;模型选择仍在“档位路由”里统一配置。
|
||||
</StatusNote>
|
||||
) : null}
|
||||
|
||||
{phase === "error" && error ? (
|
||||
<p className="mt-3 text-sm text-conflict" role="alert">
|
||||
<StatusNote className="mt-3" variant="danger" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
</StatusNote>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
@@ -102,23 +120,22 @@ function DeviceInstructions({ userCode, openUrl }: DeviceInstructionsProps) {
|
||||
|
||||
return (
|
||||
<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">
|
||||
请在浏览器中打开授权页,并确认页面显示的设备码与下方一致:
|
||||
</p>
|
||||
<StatusNote className="mb-3" variant="info">
|
||||
请在浏览器中打开授权页,并确认页面显示的设备码与下方一致。
|
||||
</StatusNote>
|
||||
<div className="grid gap-3 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center">
|
||||
<output
|
||||
aria-label="设备授权码"
|
||||
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}
|
||||
</output>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openAuth}
|
||||
className="rounded border border-cinnabar px-3 py-1.5 text-sm text-cinnabar"
|
||||
>
|
||||
在浏览器中打开授权页
|
||||
</button>
|
||||
<Button onClick={openAuth} variant="outline" size="sm">
|
||||
<ExternalLink className="h-4 w-4" aria-hidden="true" />
|
||||
打开授权页
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-ink-soft">
|
||||
授权完成后本页会自动检测连接状态(设备码过期后请重新连接)。
|
||||
</p>
|
||||
|
||||
@@ -1,10 +1,26 @@
|
||||
"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 { 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 {
|
||||
API_KEY_PROVIDERS,
|
||||
KNOWN_PROVIDERS,
|
||||
@@ -30,12 +46,22 @@ interface TestResult {
|
||||
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 连接区。
|
||||
export function ProvidersSettings({
|
||||
initial,
|
||||
kimiOauth,
|
||||
}: ProvidersSettingsProps) {
|
||||
const toast = useToast();
|
||||
const [activeSection, setActiveSection] =
|
||||
useState<SettingsSection>("routing");
|
||||
const [providers, setProviders] = useState(initial.providers ?? []);
|
||||
const [routing, setRouting] = useState<RoutingDraft[]>(
|
||||
toRoutingDrafts(initial.tier_routing ?? []),
|
||||
@@ -116,26 +142,73 @@ export function ProvidersSettings({
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<section className="mb-10">
|
||||
<h2 className="mb-3 font-serif text-xl text-ink">能力档位路由</h2>
|
||||
<ul className="divide-y divide-line rounded border border-line bg-panel">
|
||||
<div className="grid gap-6 lg:grid-cols-[12rem_1fr]">
|
||||
<aside className="hidden lg:block">
|
||||
<nav
|
||||
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) => (
|
||||
<li
|
||||
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}
|
||||
</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} 提供商
|
||||
</label>
|
||||
<select
|
||||
<Select
|
||||
id={`route-provider-${row.tier}`}
|
||||
value={row.provider}
|
||||
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>
|
||||
{KNOWN_PROVIDERS.map((p) => (
|
||||
@@ -143,66 +216,99 @@ export function ProvidersSettings({
|
||||
{p.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Select>
|
||||
<label className="sr-only" htmlFor={`route-model-${row.tier}`}>
|
||||
{TIER_LABELS[row.tier] ?? row.tier} 模型
|
||||
</label>
|
||||
<input
|
||||
<TextInput
|
||||
id={`route-model-${row.tier}`}
|
||||
value={row.model}
|
||||
onChange={(e) => updateRoutingModel(row.tier, e.target.value)}
|
||||
onChange={(e) =>
|
||||
updateRoutingModel(row.tier, e.target.value)
|
||||
}
|
||||
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>
|
||||
))}
|
||||
</ul>
|
||||
<div className="mt-3 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
<Button
|
||||
onClick={() => void saveRouting()}
|
||||
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 ? "保存中…" : "保存档位路由"}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
</SettingsPanel>
|
||||
) : null}
|
||||
|
||||
{activeSection === "oauth" ? (
|
||||
<SettingsPanel>
|
||||
<KimiCodeOauth
|
||||
initialConnected={kimiOauth.connected}
|
||||
initialExpiresAt={kimiOauth.expiresAt}
|
||||
/>
|
||||
|
||||
<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>
|
||||
</SettingsPanel>
|
||||
) : 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) => {
|
||||
const masked = maskedFor(prov.id);
|
||||
const result = results[prov.id];
|
||||
return (
|
||||
<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
|
||||
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"
|
||||
/>
|
||||
<span className="w-28 text-sm text-ink">{prov.label}</span>
|
||||
{masked ? (
|
||||
<span className="font-mono text-xs text-ink-soft">
|
||||
{masked}
|
||||
{prov.label}
|
||||
</span>
|
||||
<span className="font-mono text-xs text-ink-soft">
|
||||
{masked ?? "未配置"}
|
||||
</span>
|
||||
) : null}
|
||||
<label className="sr-only" htmlFor={`key-${prov.id}`}>
|
||||
{prov.label} API Key
|
||||
</label>
|
||||
<input
|
||||
<TextInput
|
||||
id={`key-${prov.id}`}
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
@@ -213,37 +319,54 @@ export function ProvidersSettings({
|
||||
[prov.id]: e.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder={masked ? "输入新 Key 以更新" : "输入 API Key"}
|
||||
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"
|
||||
placeholder={
|
||||
masked ? "输入新 Key 以更新" : "输入 API Key"
|
||||
}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
<Button
|
||||
onClick={() => saveCredential(prov.id)}
|
||||
disabled={savingId === prov.id}
|
||||
className="rounded bg-cinnabar px-3 py-1.5 text-sm text-panel disabled:opacity-40"
|
||||
disabled={
|
||||
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
|
||||
? "保存中…"
|
||||
: masked
|
||||
? "更新凭据"
|
||||
: "添加凭据"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
? "更新"
|
||||
: "添加"}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => testConnection(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 ? "测试中…" : "测试连接"}
|
||||
</button>
|
||||
<PlugZap className="h-4 w-4" aria-hidden="true" />
|
||||
{testingId === prov.id ? "测试中…" : "测试"}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mt-2 text-xs leading-5 text-ink-soft lg:pl-[10.5rem]">
|
||||
{masked
|
||||
? "已保存脱敏凭据;输入新 Key 可覆盖更新。"
|
||||
: "保存后再测试连接,成功后即可在档位路由中使用。"}
|
||||
</p>
|
||||
{result ? (
|
||||
<div className="mt-2 flex items-center gap-2 pl-5">
|
||||
<span
|
||||
className={`text-xs ${result.ok ? "text-pass" : "text-conflict"}`}
|
||||
>
|
||||
{result.ok ? "✓ 已连接" : "✗ 未连接"}
|
||||
</span>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2 lg:pl-[10.5rem]">
|
||||
<Badge variant={result.ok ? "success" : "danger"}>
|
||||
{result.ok ? (
|
||||
<CheckCircle2
|
||||
className="h-3 w-3"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : (
|
||||
<XCircle className="h-3 w-3" aria-hidden="true" />
|
||||
)}
|
||||
{result.ok ? "已连接" : "未连接"}
|
||||
</Badge>
|
||||
<CapabilityBadges caps={result.capabilities} />
|
||||
</div>
|
||||
) : null}
|
||||
@@ -251,8 +374,49 @@ export function ProvidersSettings({
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
</SettingsPanel>
|
||||
) : null}
|
||||
</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) => (
|
||||
<span
|
||||
key={b.label}
|
||||
className={`rounded px-2 py-0.5 text-[11px] ${
|
||||
className={`rounded border px-2 py-0.5 text-[11px] ${
|
||||
b.on
|
||||
? "bg-[var(--color-cinnabar-wash)] text-cinnabar"
|
||||
: "bg-bg text-ink-soft/50"
|
||||
? "border-cinnabar/20 bg-[var(--color-cinnabar-wash)] text-cinnabar"
|
||||
: "border-line bg-bg text-ink-soft/50"
|
||||
}`}
|
||||
>
|
||||
{b.label}
|
||||
|
||||
@@ -1,6 +1,20 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { Blocks, Database, Pencil, ShieldCheck } from "lucide-react";
|
||||
|
||||
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 { groupByScope, scopeLabel, tierLabel } from "@/lib/skills/skills";
|
||||
import {
|
||||
groupByScope,
|
||||
scopeLabel,
|
||||
summarizeSkills,
|
||||
tierLabel,
|
||||
} from "@/lib/skills/skills";
|
||||
|
||||
interface SkillsPageProps {
|
||||
project: ProjectResponse;
|
||||
@@ -10,6 +24,7 @@ interface SkillsPageProps {
|
||||
// 技能库(UX §7):只读注册表视图(name/scope/tier/reads/writes)。Server Component(纯读)。
|
||||
export function SkillsPage({ project, skills }: SkillsPageProps) {
|
||||
const groups = groupByScope(skills);
|
||||
const summary = summarizeSkills(skills);
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
@@ -18,60 +33,161 @@ export function SkillsPage({ project, skills }: SkillsPageProps) {
|
||||
projectId={project.id}
|
||||
activeNav="skills"
|
||||
>
|
||||
<div className="mx-auto flex max-w-4xl flex-col gap-6 p-6">
|
||||
<h1 className="font-serif text-lg text-ink">技能库</h1>
|
||||
<p className="text-xs text-ink-soft">
|
||||
声明式 Skill 注册表:每个技能声明能力档位与可读/写的表(沙箱白名单,越权产出丢弃并审计)。
|
||||
</p>
|
||||
{groups.length === 0 ? (
|
||||
<p className="text-sm text-ink-soft">(暂无已注册技能)</p>
|
||||
<div className="mx-auto flex max-w-6xl flex-col gap-6 p-6">
|
||||
<PageHeader
|
||||
title="技能库"
|
||||
description="声明式 Skill 注册表:每个技能声明能力档位与可读/写的表,越权产出会被丢弃并审计。"
|
||||
/>
|
||||
<div className="grid gap-6 lg:grid-cols-[18rem_minmax(0,1fr)]">
|
||||
<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) => (
|
||||
<section key={group.scope}>
|
||||
<h2 className="mb-2 font-serif text-sm text-ink">
|
||||
{scopeLabel(group.scope)}
|
||||
<span className="ml-2 text-xs text-ink-soft">
|
||||
{group.skills.length}
|
||||
</span>
|
||||
</h2>
|
||||
<ul className="flex flex-col gap-2">
|
||||
{group.skills.map((skill) => (
|
||||
<span className="text-xs text-ink-soft">暂无来源</span>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card as="section" className="p-4">
|
||||
<SectionHeader title="能力档位" />
|
||||
{summary.tiers.length > 0 ? (
|
||||
<ul className="mt-3 flex flex-col gap-2 text-sm">
|
||||
{summary.tiers.map((item) => (
|
||||
<li
|
||||
key={skill.name}
|
||||
className="rounded border border-line bg-panel p-3 text-sm"
|
||||
key={item.key}
|
||||
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="font-mono text-ink">{skill.name}</span>
|
||||
<span className="rounded bg-bg px-2 py-0.5 text-xs text-ink-soft">
|
||||
{tierLabel(skill.tier)}
|
||||
<span className="text-ink">{tierLabel(item.key)}</span>
|
||||
<span className="font-mono text-xs text-ink-soft">
|
||||
{item.count}
|
||||
</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>
|
||||
))}
|
||||
</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>
|
||||
</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("、") : "—";
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useRefine } from "@/lib/style/useRefine";
|
||||
import { CheckCircle2, X } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
|
||||
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 {
|
||||
projectId: string;
|
||||
@@ -46,15 +49,17 @@ export function RefineView({
|
||||
<button
|
||||
type="button"
|
||||
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="关闭回炉对比"
|
||||
>
|
||||
✕
|
||||
<X className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{segment.trim().length === 0 ? (
|
||||
<p className="text-overdue">该段在终稿中为空,请先在终稿中保留该段正文。</p>
|
||||
<Badge variant="warning">
|
||||
该段在终稿中为空,请先在终稿中保留该段正文。
|
||||
</Badge>
|
||||
) : refiner.status === "refining" ? (
|
||||
<p className="text-info">
|
||||
<ThinkingIndicator label="回炉中" />
|
||||
@@ -76,24 +81,21 @@ export function RefineView({
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (refiner.result) {
|
||||
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
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded border border-line px-3 py-1.5 text-ink hover:border-cinnabar"
|
||||
>
|
||||
</Button>
|
||||
<Button onClick={onClose} variant="secondary" size="sm">
|
||||
放弃
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
"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";
|
||||
|
||||
interface StylePanelProps {
|
||||
@@ -26,7 +30,10 @@ export function StylePanel({ style, incomplete, onRefine }: StylePanelProps) {
|
||||
文风 (style-auditor)
|
||||
</h2>
|
||||
{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 ? (
|
||||
<p className="mt-1 text-xs text-ink-soft">
|
||||
无文风报告(学文风后第四审才能打分)。
|
||||
@@ -47,7 +54,10 @@ export function StylePanel({ style, incomplete, onRefine }: StylePanelProps) {
|
||||
</p>
|
||||
|
||||
{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">
|
||||
{style.segments.map((seg, i) => (
|
||||
@@ -57,9 +67,9 @@ export function StylePanel({ style, incomplete, onRefine }: StylePanelProps) {
|
||||
data-testid="drift-segment"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-overdue">
|
||||
<Badge variant="warning" className="font-mono">
|
||||
第 {seg.idx} 段
|
||||
</span>
|
||||
</Badge>
|
||||
<span className="font-mono text-ink-soft">
|
||||
相似 {seg.score}%
|
||||
</span>
|
||||
@@ -68,13 +78,14 @@ export function StylePanel({ style, incomplete, onRefine }: StylePanelProps) {
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-1.5 flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
<Button
|
||||
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>
|
||||
</li>
|
||||
))}
|
||||
|
||||
@@ -4,6 +4,10 @@ import { useCallback, useState, type ChangeEvent } from "react";
|
||||
|
||||
import { hasUsableSamples, type StyleLearnMode } from "@/lib/style/style";
|
||||
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 {
|
||||
busy: boolean;
|
||||
@@ -61,21 +65,15 @@ export function StyleUpload({
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="style-samples"
|
||||
className="mb-1 block text-sm font-semibold text-ink"
|
||||
>
|
||||
样本正文(空行分段;可粘贴多段)
|
||||
</label>
|
||||
<textarea
|
||||
<Field label="样本正文(空行分段;可粘贴多段)" htmlFor="style-samples">
|
||||
<TextArea
|
||||
id="style-samples"
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
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">
|
||||
<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"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<Button type="button" onClick={submit} disabled={busy} variant="primary">
|
||||
{hasFingerprint ? "重新学习文风" : "学习文风"}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{pollStatus === "polling" ? (
|
||||
<p className="text-xs text-info" aria-live="polite">
|
||||
<StatusNote variant="info" aria-live="polite">
|
||||
提取文风指纹中…({progress}%)
|
||||
</p>
|
||||
</StatusNote>
|
||||
) : pollStatus === "error" ? (
|
||||
<p className="text-xs text-conflict">学文风失败,请稍后重试。</p>
|
||||
<StatusNote variant="danger">学文风失败,请稍后重试。</StatusNote>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,11 @@
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
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 type { TemplateResponse } from "@/lib/api/types";
|
||||
import {
|
||||
@@ -89,56 +94,49 @@ export function TemplatesManager({ initial }: TemplatesManagerProps) {
|
||||
void onCreate();
|
||||
}}
|
||||
>
|
||||
<h2 className="font-serif text-lg text-ink">新建模板</h2>
|
||||
<label className="block text-sm text-ink-soft">
|
||||
标题 *
|
||||
<input
|
||||
<SectionHeader title="新建模板" />
|
||||
<Field label="标题" required>
|
||||
<TextInput
|
||||
type="text"
|
||||
value={draft.title}
|
||||
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="标题"
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-sm text-ink-soft">
|
||||
正文 *(一键填入生成器的 brief/原文)
|
||||
<textarea
|
||||
</Field>
|
||||
<Field label="正文" required help="一键填入生成器的 brief/原文。">
|
||||
<TextArea
|
||||
value={draft.body}
|
||||
onChange={(e) => setField("body", e.target.value)}
|
||||
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="正文"
|
||||
/>
|
||||
</label>
|
||||
</Field>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<label className="block text-sm text-ink-soft">
|
||||
分类(可选)
|
||||
<input
|
||||
<Field label="分类(可选)" className="w-full sm:w-48">
|
||||
<TextInput
|
||||
type="text"
|
||||
value={draft.category}
|
||||
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="分类"
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-sm text-ink-soft">
|
||||
关联生成器 key(可选)
|
||||
<input
|
||||
</Field>
|
||||
<Field label="关联生成器 key(可选)" className="w-full sm:w-48">
|
||||
<TextInput
|
||||
type="text"
|
||||
value={draft.toolKey}
|
||||
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"
|
||||
/>
|
||||
</label>
|
||||
</Field>
|
||||
</div>
|
||||
<button
|
||||
<Button
|
||||
type="submit"
|
||||
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 ? "新建中…" : "新建模板"}
|
||||
</button>
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<section className="flex flex-col gap-3" aria-label="模板列表">
|
||||
@@ -163,14 +161,16 @@ export function TemplatesManager({ initial }: TemplatesManagerProps) {
|
||||
{t.body}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
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}`}
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { ArrowLeft, Database, Sparkles } from "lucide-react";
|
||||
|
||||
import { useToast } from "@/components/Toast";
|
||||
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 {
|
||||
buildGenerateRequest,
|
||||
@@ -115,25 +121,30 @@ export function GeneratorRunner({
|
||||
|
||||
return (
|
||||
<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}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2">
|
||||
<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>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded border border-line px-3 py-1 text-sm text-ink-soft hover:text-ink"
|
||||
>
|
||||
<Button onClick={onClose} variant="secondary" size="sm">
|
||||
<ArrowLeft className="h-4 w-4" aria-hidden="true" />
|
||||
返回
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<form
|
||||
className="flex flex-col gap-3"
|
||||
className="grid gap-3 rounded border border-line bg-bg/50 p-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void onGenerate();
|
||||
@@ -148,13 +159,10 @@ export function GeneratorRunner({
|
||||
onChange={(v) => setField(field.name, v)}
|
||||
/>
|
||||
))}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={generating}
|
||||
className="self-start rounded bg-cinnabar px-4 py-2 text-sm text-white disabled:opacity-50"
|
||||
>
|
||||
<Button type="submit" disabled={generating} variant="primary">
|
||||
<Sparkles className="h-4 w-4" aria-hidden="true" />
|
||||
{generating ? "生成中…" : "生成"}
|
||||
</button>
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{gen.genStatus === "preview" && gen.preview ? (
|
||||
@@ -181,18 +189,18 @@ export function GeneratorRunner({
|
||||
</ul>
|
||||
|
||||
{canIngest && gen.ingestStatus !== "conflict" ? (
|
||||
<button
|
||||
type="button"
|
||||
<Button
|
||||
onClick={() => void runIngest(false)}
|
||||
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
|
||||
? "入库中…"
|
||||
: singleObject
|
||||
? `入库为规则至 ${table}`
|
||||
: `入库选中(${selected.size})至 ${table}`}
|
||||
</button>
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{gen.ingestStatus === "conflict" && gen.conflicts ? (
|
||||
@@ -223,33 +231,24 @@ interface FormFieldProps {
|
||||
|
||||
// 声明式控件映射:type → text / textarea / number(select 暂同 text,后端未给 options)。
|
||||
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 (
|
||||
<label className="block text-sm text-ink-soft">
|
||||
{labelText}
|
||||
<Field label={field.label} help={field.help} required={field.required}>
|
||||
{field.type === "textarea" ? (
|
||||
<textarea
|
||||
<TextArea
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
rows={3}
|
||||
className={`${common} resize-y`}
|
||||
aria-label={field.label}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
<TextInput
|
||||
type={field.type === "number" ? "number" : "text"}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className={common}
|
||||
aria-label={field.label}
|
||||
/>
|
||||
)}
|
||||
{field.help ? (
|
||||
<span className="mt-1 block text-xs text-ink-soft/80">{field.help}</span>
|
||||
) : null}
|
||||
</label>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowRight, Database, Sparkles } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import type { ToolDescriptorView } from "@/lib/api/types";
|
||||
import { cardClass } from "@/lib/ui/variants";
|
||||
|
||||
interface ToolCardProps {
|
||||
tool: ToolDescriptorView;
|
||||
@@ -15,27 +19,32 @@ export function ToolCard({ tool, onOpen }: ToolCardProps) {
|
||||
<button
|
||||
type="button"
|
||||
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">
|
||||
<h2 className="font-serif text-xl text-ink">{tool.title}</h2>
|
||||
{tool.genre ? (
|
||||
<span className="rounded bg-bg px-2 py-0.5 text-xs text-ink-soft">
|
||||
{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.genre ? <Badge>{tool.genre}</Badge> : null}
|
||||
{tool.is_legacy ? null : <Badge variant="accent">新</Badge>}
|
||||
{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}
|
||||
</div>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,8 +2,11 @@
|
||||
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Sparkles } from "lucide-react";
|
||||
|
||||
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 { isLegacyTool, resolveLegacyRoute } from "@/lib/toolbox/toolbox";
|
||||
import { ToolCard } from "./ToolCard";
|
||||
@@ -63,13 +66,11 @@ export function ToolboxPage({
|
||||
activeNav="toolbox"
|
||||
>
|
||||
<div className="mx-auto flex max-w-5xl flex-col gap-6 p-6">
|
||||
<header>
|
||||
<h1 className="font-serif text-lg text-ink">创作工具箱</h1>
|
||||
<p className="mt-1 text-xs text-ink-soft">
|
||||
声明驱动的生成器集合:脑洞 / 书名 / 简介 / 名字 / 金手指 / 词条 / 黄金开篇 / 细纲…
|
||||
选一个开始,结构化产物可一键入库(经一致性预检)。
|
||||
</p>
|
||||
</header>
|
||||
<PageHeader
|
||||
title="创作工具箱"
|
||||
eyebrow="generator toolbox"
|
||||
description="脑洞、书名、简介、名字、金手指、词条、黄金开篇与细纲。每个工具都先生成结构化预览,可入库内容会经过一致性预检。"
|
||||
/>
|
||||
|
||||
{active ? (
|
||||
<GeneratorRunner
|
||||
@@ -78,7 +79,11 @@ export function ToolboxPage({
|
||||
onClose={() => setActive(null)}
|
||||
/>
|
||||
) : 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">
|
||||
{sorted.map((tool) => (
|
||||
|
||||
16
apps/web/components/ui/Badge.tsx
Normal file
16
apps/web/components/ui/Badge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
36
apps/web/components/ui/Button.tsx
Normal file
36
apps/web/components/ui/Button.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
21
apps/web/components/ui/Card.tsx
Normal file
21
apps/web/components/ui/Card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
38
apps/web/components/ui/EmptyState.tsx
Normal file
38
apps/web/components/ui/EmptyState.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
40
apps/web/components/ui/Field.tsx
Normal file
40
apps/web/components/ui/Field.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
38
apps/web/components/ui/PageHeader.tsx
Normal file
38
apps/web/components/ui/PageHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
29
apps/web/components/ui/SectionHeader.tsx
Normal file
29
apps/web/components/ui/SectionHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
48
apps/web/components/ui/SegmentedControl.tsx
Normal file
48
apps/web/components/ui/SegmentedControl.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
30
apps/web/components/ui/Select.tsx
Normal file
30
apps/web/components/ui/Select.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
46
apps/web/components/ui/StatusNote.tsx
Normal file
46
apps/web/components/ui/StatusNote.tsx
Normal 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;
|
||||
}
|
||||
30
apps/web/components/ui/TextArea.tsx
Normal file
30
apps/web/components/ui/TextArea.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
30
apps/web/components/ui/TextInput.tsx
Normal file
30
apps/web/components/ui/TextInput.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,24 @@
|
||||
"use client";
|
||||
|
||||
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 { 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 { friendlyError } from "@/lib/errors/messages";
|
||||
import { useAutosave } from "@/lib/autosave/useAutosave";
|
||||
@@ -14,6 +28,7 @@ import {
|
||||
type ChapterEntry,
|
||||
} from "@/lib/workbench/chapter";
|
||||
import { composeDirective, STYLE_PRESETS } from "@/lib/workbench/directive";
|
||||
import { buttonClass } from "@/lib/ui/variants";
|
||||
import { ChapterList, ChapterListContent } from "./ChapterList";
|
||||
import { ChapterAssistant, AssistantContent } from "./ChapterAssistant";
|
||||
import { Editor } from "./Editor";
|
||||
@@ -50,6 +65,8 @@ export function Workbench({
|
||||
const autosave = useAutosave(project.id, chapterNo, initialText);
|
||||
const stream = useDraftStream();
|
||||
const lastStreamText = useRef("");
|
||||
const chapterTriggerRef = useRef<HTMLButtonElement>(null);
|
||||
const assistantTriggerRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
// 流式 token 累积进编辑器(打字机);停止/结束后已生成部分留在草稿。
|
||||
useEffect(() => {
|
||||
@@ -95,7 +112,7 @@ export function Workbench({
|
||||
projectId={project.id}
|
||||
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
|
||||
projectId={project.id}
|
||||
chapters={chapters}
|
||||
@@ -103,6 +120,13 @@ export function Workbench({
|
||||
/>
|
||||
|
||||
<section className="flex min-w-0 flex-col bg-bg">
|
||||
<MobileContextBar
|
||||
chapterNo={chapterNo}
|
||||
onOpenChapters={() => setMobilePanel("chapters")}
|
||||
onOpenAssistant={() => setMobilePanel("assistant")}
|
||||
chapterTriggerRef={chapterTriggerRef}
|
||||
assistantTriggerRef={assistantTriggerRef}
|
||||
/>
|
||||
<DirectivePanel
|
||||
directive={directive}
|
||||
onDirectiveChange={setDirective}
|
||||
@@ -126,8 +150,6 @@ export function Workbench({
|
||||
streamError={stream.state.error}
|
||||
onWrite={onWrite}
|
||||
onStop={stream.stop}
|
||||
onOpenChapters={() => setMobilePanel("chapters")}
|
||||
onOpenAssistant={() => setMobilePanel("assistant")}
|
||||
/>
|
||||
</section>
|
||||
|
||||
@@ -140,6 +162,7 @@ export function Workbench({
|
||||
onClose={closePanel}
|
||||
side="left"
|
||||
label="目录"
|
||||
triggerRef={chapterTriggerRef}
|
||||
>
|
||||
<ChapterListContent
|
||||
projectId={project.id}
|
||||
@@ -152,6 +175,7 @@ export function Workbench({
|
||||
onClose={closePanel}
|
||||
side="right"
|
||||
label="本章助手"
|
||||
triggerRef={assistantTriggerRef}
|
||||
>
|
||||
<AssistantContent projectId={project.id} chapterNo={chapterNo} />
|
||||
</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 {
|
||||
directive: string;
|
||||
onDirectiveChange: (value: string) => void;
|
||||
@@ -174,42 +240,55 @@ function DirectivePanel({
|
||||
presetIds,
|
||||
onTogglePreset,
|
||||
}: DirectivePanelProps) {
|
||||
const panelId = useId();
|
||||
const activeCount = presetIds.length + (directive.trim().length > 0 ? 1 : 0);
|
||||
return (
|
||||
<details className="border-b border-line bg-panel px-6 py-2">
|
||||
<summary className="cursor-pointer text-sm text-ink-soft hover:text-cinnabar">
|
||||
本章指令(可选)
|
||||
<details className="border-b border-line bg-panel px-4 py-3 sm:px-6">
|
||||
<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>
|
||||
<div className="mt-2 space-y-2">
|
||||
<div id={panelId} className="mt-3 space-y-3">
|
||||
<SectionHeader
|
||||
title="写作指令"
|
||||
description="用于补充或覆盖本章大纲节拍,只影响本次生成。"
|
||||
/>
|
||||
<label className="block">
|
||||
<span className="sr-only">本章指令</span>
|
||||
<textarea
|
||||
<TextArea
|
||||
value={directive}
|
||||
onChange={(e) => onDirectiveChange(e.target.value)}
|
||||
rows={2}
|
||||
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>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<div className="flex flex-wrap gap-2" role="group" aria-label="风格预设">
|
||||
{STYLE_PRESETS.map((preset) => {
|
||||
const active = presetIds.includes(preset.id);
|
||||
return (
|
||||
<button
|
||||
<Button
|
||||
key={preset.id}
|
||||
type="button"
|
||||
aria-pressed={active}
|
||||
onClick={() => onTogglePreset(preset.id)}
|
||||
className={
|
||||
active
|
||||
? "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"
|
||||
}
|
||||
variant={active ? "outline" : "secondary"}
|
||||
size="sm"
|
||||
>
|
||||
{preset.label}
|
||||
</button>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{activeCount > 0 ? (
|
||||
<StatusNote variant="info">
|
||||
写本章时会把已选预设和自由指令合并进本次生成请求。
|
||||
</StatusNote>
|
||||
) : null}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
@@ -250,8 +329,6 @@ interface ToolbarProps {
|
||||
streamError: { code: string; message: string } | null;
|
||||
onWrite: () => void;
|
||||
onStop: () => void;
|
||||
onOpenChapters: () => void;
|
||||
onOpenAssistant: () => void;
|
||||
}
|
||||
|
||||
function Toolbar({
|
||||
@@ -264,36 +341,22 @@ function Toolbar({
|
||||
streamError,
|
||||
onWrite,
|
||||
onStop,
|
||||
onOpenChapters,
|
||||
onOpenAssistant,
|
||||
}: ToolbarProps) {
|
||||
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}
|
||||
<div className="flex flex-wrap items-center gap-2 sm:gap-3">
|
||||
<button
|
||||
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>
|
||||
<div className="grid gap-2 sm:flex sm:flex-wrap sm:items-center sm:gap-3">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
{streaming ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onStop}
|
||||
className="rounded border border-conflict px-4 py-2 text-sm text-conflict"
|
||||
>
|
||||
<Button onClick={onStop} variant="danger" size="sm">
|
||||
<Square className="h-4 w-4" aria-hidden="true" />
|
||||
停
|
||||
</button>
|
||||
</Button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onWrite}
|
||||
className="rounded bg-cinnabar px-4 py-2 text-sm text-panel hover:opacity-90"
|
||||
>
|
||||
✍ 写本章
|
||||
</button>
|
||||
<Button onClick={onWrite} variant="primary" size="sm">
|
||||
<PenLine className="h-4 w-4" aria-hidden="true" />
|
||||
写本章
|
||||
</Button>
|
||||
)}
|
||||
{streaming ? (
|
||||
<span className="font-mono text-xs text-cinnabar motion-safe:animate-pulse">
|
||||
@@ -306,30 +369,32 @@ function Toolbar({
|
||||
)}
|
||||
<Link
|
||||
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
|
||||
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>
|
||||
<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
|
||||
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>
|
||||
<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 === "error"
|
||||
|
||||
11
apps/web/lib/api/schema.d.ts
vendored
11
apps/web/lib/api/schema.d.ts
vendored
@@ -1539,6 +1539,17 @@ export interface components {
|
||||
selling_points?: string[];
|
||||
/** Structure */
|
||||
structure?: string | null;
|
||||
/**
|
||||
* Updated At
|
||||
* @description 项目最近更新时间
|
||||
*/
|
||||
updated_at?: string | null;
|
||||
/**
|
||||
* Pending Review Count
|
||||
* @description 待审稿章节数
|
||||
* @default 0
|
||||
*/
|
||||
pending_review_count: number;
|
||||
};
|
||||
/**
|
||||
* ProviderCredentialInput
|
||||
|
||||
@@ -4,9 +4,12 @@ import type { ForeshadowView } from "@/lib/api/types";
|
||||
import {
|
||||
buildRegisterRequest,
|
||||
buildTransitionRequest,
|
||||
countByStatus,
|
||||
extractReason,
|
||||
groupByStatus,
|
||||
isWindowApproaching,
|
||||
LANE_LABELS,
|
||||
nextFreeForeshadowCode,
|
||||
suggestForeshadowCode,
|
||||
validationReasonMessage,
|
||||
} 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 => ({
|
||||
code: "F-001",
|
||||
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", () => {
|
||||
it("is true when current chapter is inside [from,to] for OPEN/PARTIAL", () => {
|
||||
const f = view({
|
||||
|
||||
@@ -18,13 +18,14 @@ export const LANES: readonly ForeshadowStatus[] = [
|
||||
] as const;
|
||||
|
||||
export const LANE_LABELS: Record<ForeshadowStatus, string> = {
|
||||
OPEN: "OPEN",
|
||||
PARTIAL: "PARTIAL",
|
||||
CLOSED: "CLOSED",
|
||||
OVERDUE: "OVERDUE",
|
||||
OPEN: "待推进",
|
||||
PARTIAL: "推进中",
|
||||
CLOSED: "已回收",
|
||||
OVERDUE: "已逾期",
|
||||
};
|
||||
|
||||
export type ForeshadowLanes = Record<ForeshadowStatus, ForeshadowView[]>;
|
||||
export type ForeshadowLaneCounts = Record<ForeshadowStatus, number>;
|
||||
|
||||
function emptyLanes(): ForeshadowLanes {
|
||||
return { OPEN: [], PARTIAL: [], CLOSED: [], OVERDUE: [] };
|
||||
@@ -46,6 +47,18 @@ export function groupByStatus(
|
||||
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(提示安排回收)。
|
||||
// 仅对 OPEN/PARTIAL 提示;CLOSED 已回收、OVERDUE 已逾期另有强调。
|
||||
export function isWindowApproaching(
|
||||
@@ -70,6 +83,21 @@ export function suggestForeshadowCode(
|
||||
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 {
|
||||
projectId: string;
|
||||
code: string;
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { aiToolItems } from "./ai-tools";
|
||||
import {
|
||||
aiToolItems,
|
||||
primaryAiToolItems,
|
||||
secondaryAiToolItems,
|
||||
} from "./ai-tools";
|
||||
|
||||
describe("aiToolItems", () => {
|
||||
it("returns 5 items", () => {
|
||||
@@ -25,4 +29,16 @@ describe("aiToolItems", () => {
|
||||
expect(review?.href).toBe("/projects/p1/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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,8 +8,6 @@ import type { ActiveNav } from "./items";
|
||||
export interface AiToolItem {
|
||||
href: string;
|
||||
label: string;
|
||||
// 行首字形(纸感,非语义;aria-hidden 渲染)。
|
||||
glyph: string;
|
||||
// 高亮键(与 AppShell activeNav 比对)。
|
||||
key: ActiveNav;
|
||||
// 主动作(写本章)朱砂强调,其余次级。
|
||||
@@ -20,10 +18,18 @@ export interface AiToolItem {
|
||||
export function aiToolItems(projectId: string): AiToolItem[] {
|
||||
const base = `/projects/${projectId}`;
|
||||
return [
|
||||
{ href: `${base}/write`, label: "写本章", glyph: "✍", key: "write", primary: true },
|
||||
{ href: `${base}/review`, label: "审稿", glyph: "✦", key: "review" },
|
||||
{ href: `${base}/outline`, label: "大纲", glyph: "❡", key: "outline" },
|
||||
{ href: `${base}/codex`, label: "设定库", glyph: "❖", key: "codex" },
|
||||
{ href: `${base}/toolbox`, label: "工具箱", glyph: "✧", key: "toolbox" },
|
||||
{ href: `${base}/write`, label: "写本章", key: "write", primary: true },
|
||||
{ href: `${base}/review`, label: "审稿", key: "review" },
|
||||
{ href: `${base}/outline`, label: "大纲", key: "outline" },
|
||||
{ href: `${base}/codex`, label: "设定库", key: "codex" },
|
||||
{ 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);
|
||||
}
|
||||
|
||||
129
apps/web/lib/projects/projects.test.ts
Normal file
129
apps/web/lib/projects/projects.test.ts
Normal 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(
|
||||
"暂无编辑记录",
|
||||
);
|
||||
});
|
||||
});
|
||||
80
apps/web/lib/projects/projects.ts
Normal file
80
apps/web/lib/projects/projects.ts
Normal 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;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
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 => ({
|
||||
name: "x",
|
||||
@@ -36,3 +36,61 @@ describe("groupByScope", () => {
|
||||
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: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,6 +27,21 @@ export function tierLabel(tier: string): string {
|
||||
|
||||
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 顺序按首次出现。
|
||||
export function groupByScope(
|
||||
skills: readonly SkillView[] | undefined,
|
||||
@@ -42,3 +57,41 @@ export function groupByScope(
|
||||
}
|
||||
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 }));
|
||||
}
|
||||
|
||||
46
apps/web/lib/ui/theme.test.ts
Normal file
46
apps/web/lib/ui/theme.test.ts
Normal 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
36
apps/web/lib/ui/theme.ts
Normal 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();
|
||||
}
|
||||
52
apps/web/lib/ui/variants.test.ts
Normal file
52
apps/web/lib/ui/variants.test.ts
Normal 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
141
apps/web/lib/ui/variants.ts
Normal 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);
|
||||
}
|
||||
@@ -45,6 +45,7 @@ export function useInjection(projectId: string, chapterNo: number): UseInjection
|
||||
setError(null);
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const { data: body, error: err } = await api.GET(INJECTION_PATH, {
|
||||
params: { path: { project_id: projectId, chapter_no: chapterNo } },
|
||||
});
|
||||
@@ -55,7 +56,14 @@ export function useInjection(projectId: string, chapterNo: number): UseInjection
|
||||
} else {
|
||||
setData(body);
|
||||
}
|
||||
setLoading(false);
|
||||
} catch {
|
||||
// 网络层失败(后端不可达/CORS):openapi-fetch 直接抛而非返回 error 信封。
|
||||
if (cancelled) return;
|
||||
setError("注入信息暂不可用");
|
||||
setData(null);
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
@@ -80,6 +88,9 @@ export function useInjection(projectId: string, chapterNo: number): UseInjection
|
||||
} else {
|
||||
setData(body); // 以服务端确定结果为准(不变量 #6)。
|
||||
}
|
||||
} catch {
|
||||
// 网络层失败:本地状态不动(天然回滚),给可读文案、不抛。
|
||||
setError("保存注入设置失败,请重试");
|
||||
} finally {
|
||||
savingRef.current = false;
|
||||
setSaving(false);
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
packages:
|
||||
- .
|
||||
allowBuilds:
|
||||
esbuild: set this to true or false
|
||||
sharp: set this to true or false
|
||||
unrs-resolver: set this to true or false
|
||||
esbuild: true
|
||||
sharp: true
|
||||
unrs-resolver: true
|
||||
onlyBuiltDependencies:
|
||||
- esbuild
|
||||
- sharp
|
||||
|
||||
@@ -2,7 +2,11 @@ import type { Config } from "tailwindcss";
|
||||
|
||||
// 纸感·文学温暖主题(UX_SPEC §2)。颜色经 CSS 变量落地,便于后续夜读模式切换。
|
||||
const config: Config = {
|
||||
content: ["./app/**/*.{ts,tsx}", "./components/**/*.{ts,tsx}"],
|
||||
content: [
|
||||
"./app/**/*.{ts,tsx}",
|
||||
"./components/**/*.{ts,tsx}",
|
||||
"./lib/**/*.{ts,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
@@ -23,7 +27,7 @@ const config: Config = {
|
||||
mono: ['"JetBrains Mono"', "ui-monospace"],
|
||||
},
|
||||
borderRadius: { DEFAULT: "6px" },
|
||||
boxShadow: { paper: "0 1px 3px #2B26200F" },
|
||||
boxShadow: { paper: "0 1px 3px var(--shadow-paper)" },
|
||||
maxWidth: { prose: "720px" },
|
||||
},
|
||||
},
|
||||
|
||||
File diff suppressed because one or more lines are too long
965
docs/design/ui-improvement-plan.md
Normal file
965
docs/design/ui-improvement-plan.md
Normal 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 token;Tailwind 阴影改为读取 `--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:保持三栏或双栏高密度布局。
|
||||
- 1024–1279:辅助面板收窄或折叠。
|
||||
- <1024:侧栏抽屉化,顶部 AI 工具条只保留 2–3 个高频动作,其余进入「更多」菜单。
|
||||
|
||||
---
|
||||
|
||||
## 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. 跑完整前端门禁 + 浏览器验收并记录截图结论。
|
||||
@@ -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')。
|
||||
- 网关注入缝 `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`。
|
||||
|
||||
### 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-18,snake_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)。
|
||||
- `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?}]}`(新→旧)。
|
||||
|
||||
19
packages/core/tests/test_project_repo.py
Normal file
19
packages/core/tests/test_project_repo.py
Normal 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
|
||||
@@ -9,11 +9,12 @@ from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Any, Protocol
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from ww_db.models import Project
|
||||
from ww_db.models import Chapter, ChapterReview, Project
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -28,6 +29,8 @@ class ProjectView:
|
||||
theme: str | None = None
|
||||
selling_points: list[Any] = field(default_factory=list)
|
||||
structure: str | None = None
|
||||
updated_at: datetime | None = None
|
||||
pending_review_count: int = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -53,7 +56,12 @@ class ProjectRepo(Protocol):
|
||||
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(
|
||||
id=row.id,
|
||||
title=row.title,
|
||||
@@ -63,6 +71,8 @@ def _to_view(row: Project) -> ProjectView:
|
||||
theme=row.theme,
|
||||
selling_points=list(row.selling_points or []),
|
||||
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)
|
||||
|
||||
async def list_for_owner(self, owner_id: uuid.UUID) -> list[ProjectView]:
|
||||
rows = (
|
||||
projects = (
|
||||
(
|
||||
await self._s.execute(
|
||||
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:
|
||||
row = (
|
||||
@@ -104,4 +128,90 @@ class SqlProjectRepo:
|
||||
).scalar_one_or_none()
|
||||
if row is 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)
|
||||
|
||||
@@ -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")
|
||||
@@ -165,7 +165,7 @@ class Rule(UuidPk, CreatedAt, Base):
|
||||
content: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
|
||||
|
||||
class Chapter(UuidPk, CreatedAt, Base):
|
||||
class Chapter(UuidPk, TimestampedMixin, Base):
|
||||
__tablename__ = "chapters"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("project_id", "chapter_no", "version"),
|
||||
|
||||
Reference in New Issue
Block a user