Compare commits

..

18 Commits

Author SHA1 Message Date
Yaojia Wang
6854dac98f feat(web): 命令面板 combobox+可见入口 + sticky 导航壳 + 焦点环统一 + 抽屉锁滚动/关闭按钮 + 主题首帧修复 + 设置徽标/datalist 2026-06-30 08:56:23 +02:00
Yaojia Wang
164e98887e fix(web): 链相位语义色/失败可重试 + 模板填入中文标签/删除可撤销/工具卡文案 + 模板库 PageHeader 2026-06-30 08:56:23 +02:00
Yaojia Wang
6bb3559e25 fix(web): 规则删除按操作隔离 busy(deletingId/adding) + 全空规则页折叠为单一引导 2026-06-30 08:56:23 +02:00
Yaojia Wang
9d67f237a2 feat(web): 生成器 ThinkingIndicator/空结果态/数量校验 + 文风页 PageHeader + Codex 分段控件 + RefineView role 修正 2026-06-30 08:56:23 +02:00
Yaojia Wang
68606b84dc fix(web): 大纲生成只替换目标卷+重排确认+窗口徽标用lucide + 伏笔看板中文化/单一登记入口/Field表单 2026-06-30 08:56:23 +02:00
Yaojia Wang
f0f925b195 fix(web): 审稿面板去重标题/去英文 slug + 伏笔待登记数实时递减 + 移动端决策栏 + 对比度修复 2026-06-30 08:56:23 +02:00
Yaojia Wang
69030eacc3 fix(web): 写作台流式/保存 live region + 图标按钮换 lucide 无障碍 + 三栏改 xl 断点 + 手稿字号统一 2026-06-30 08:56:23 +02:00
Yaojia Wang
e82aff7067 feat(web): 立项向导确认步/故事结构分段控件/书名必填提示 + 作品卡键盘焦点可见 + 作品库分段切换 2026-06-30 08:56:22 +02:00
Yaojia Wang
a22a16c9c4 feat(web): 前端共享基础(focusRing/proseBody/text-2xs + useBodyScrollLock + Toast撤销 + error/not-found/loading路由边界) 2026-06-30 08:35:52 +02:00
Yaojia Wang
69f3c2a29b fix(web): 技能页 bg-paper/删除虚构创作中徽标/设置页 try-finally/文风页移动端定高 grid 2026-06-29 16:52:43 +02:00
Yaojia Wang
d7fcb8a329 refactor(web): 采用 Button/Card 原语替换多处手搓样式 2026-06-29 16:52:43 +02:00
Yaojia Wang
47eb42f274 fix(web): 后端不可用统一用 BackendDownNotice 替代 404/手搓提示 2026-06-29 16:52:43 +02:00
Yaojia Wang
f8cabc92e6 feat(web): 世界观预览补「仅预览」说明与按卡复制 2026-06-29 16:52:43 +02:00
Yaojia Wang
ffe9132f7d fix(web): 大纲错误透传 FriendlyError, 不再向用户暴露内部 code 2026-06-29 16:52:43 +02:00
Yaojia Wang
4afbbe0fe7 fix(web): 注入面板保存失败不再清空已加载实体并支持重试 2026-06-29 16:52:43 +02:00
Yaojia Wang
ab3f9e17b1 fix(web): 审稿页支持切换章节并打通验收后审下一章 2026-06-29 16:52:43 +02:00
Yaojia Wang
a2849bf6b7 feat(web): Field 标签自动关联控件 + 全局壳无障碍(skip link/设置可访问名/抽屉焦点归还) 2026-06-29 16:52:43 +02:00
Yaojia Wang
dd80b6815d fix: address ui review follow-ups 2026-06-29 12:06:04 +02:00
83 changed files with 2371 additions and 845 deletions

View File

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

37
apps/web/app/error.tsx Normal file
View File

@@ -0,0 +1,37 @@
"use client";
import Link from "next/link";
import { TriangleAlert } from "lucide-react";
import { EmptyState } from "@/components/ui/EmptyState";
import { Button } from "@/components/ui/Button";
import { buttonClass } from "@/lib/ui/variants";
interface ErrorBoundaryProps {
error: Error & { digest?: string };
reset: () => void;
}
export default function Error({ reset }: ErrorBoundaryProps) {
return (
<main className="flex min-h-screen items-center justify-center bg-bg px-6 py-16">
<div className="w-full max-w-md">
<EmptyState
icon={TriangleAlert}
title="出错了"
description="刚才的操作没能完成。可以重试,或先回到作品库。"
action={
<div className="flex items-center justify-center gap-2">
<Button variant="primary" onClick={reset}>
</Button>
<Link href="/" className={buttonClass({ variant: "secondary" })}>
</Link>
</div>
}
/>
</div>
</main>
);
}

9
apps/web/app/loading.tsx Normal file
View File

@@ -0,0 +1,9 @@
import { ThinkingIndicator } from "@/components/ThinkingIndicator";
export default function Loading() {
return (
<main className="flex min-h-screen items-center justify-center bg-bg px-6 py-16">
<ThinkingIndicator label="加载中…" className="text-ink-soft" />
</main>
);
}

View File

@@ -0,0 +1,24 @@
import Link from "next/link";
import { FileQuestion } from "lucide-react";
import { EmptyState } from "@/components/ui/EmptyState";
import { buttonClass } from "@/lib/ui/variants";
export default function NotFound() {
return (
<main className="flex min-h-screen items-center justify-center bg-bg px-6 py-16">
<div className="w-full max-w-md">
<EmptyState
icon={FileQuestion}
title="页面不存在"
description="你要找的内容可能已被移动或从未存在。回到作品库继续创作吧。"
action={
<Link href="/" className={buttonClass({ variant: "primary" })}>
</Link>
}
/>
</div>
</main>
);
}

View File

@@ -2,6 +2,7 @@ import Link from "next/link";
import { BookOpen, Plus } from "lucide-react";
import { AppShell } from "@/components/AppShell";
import { BackendDownNotice } from "@/components/BackendDownNotice";
import { ProjectLibrary } from "@/components/projects/ProjectLibrary";
import { EmptyState } from "@/components/ui/EmptyState";
import { PageHeader } from "@/components/ui/PageHeader";
@@ -38,9 +39,7 @@ export default async function DashboardPage() {
/>
{loadError ? (
<p className="rounded border border-conflict bg-panel p-6 text-conflict">
API
</p>
<BackendDownNotice />
) : projects.length === 0 ? (
<EmptyState
icon={BookOpen}

View File

@@ -1,5 +1,4 @@
import { notFound } from "next/navigation";
import { BackendDownNotice } from "@/components/BackendDownNotice";
import { CodexPage } from "@/components/codex/CodexPage";
import {
fetchCharacters,
@@ -37,6 +36,6 @@ export default async function CodexRoutePage({
/>
);
} catch {
notFound();
return <BackendDownNotice className="m-6" />;
}
}

View File

@@ -1,7 +1,9 @@
import { notFound } from "next/navigation";
import { BackendDownNotice } from "@/components/BackendDownNotice";
import { ForeshadowBoard } from "@/components/foreshadow/ForeshadowBoard";
import { fetchForeshadow, fetchProject } from "@/lib/api/server";
import type { ForeshadowView, ProjectResponse } from "@/lib/api/types";
interface PageProps {
params: Promise<{ id: string }>;
@@ -11,13 +13,30 @@ interface PageProps {
// ForeshadowBoardClient按 status 分四泳道并承载登记/状态变更交互。
export default async function ForeshadowPage({ params }: PageProps) {
const { id } = await params;
// 仅当项目确实不存在404才判 notFound后端不可用等其它失败降级为「后端不可达」提示
// 不把整页变成「找不到页面」。
let project: ProjectResponse;
try {
const project = await fetchProject(id);
const board = await fetchForeshadow(id);
return (
<ForeshadowBoard project={project} initialItems={board.foreshadow ?? []} />
);
} catch {
notFound();
project = await fetchProject(id);
} catch (err) {
if (isNotFoundError(err)) notFound();
return <BackendDownNotice className="m-6" />;
}
// 伏笔看板GET .../foreshadow。瞬时错误降级为空看板不阻塞进页。
let initialItems: ForeshadowView[] = [];
try {
const board = await fetchForeshadow(id);
initialItems = board.foreshadow ?? [];
} catch {
initialItems = [];
}
return <ForeshadowBoard project={project} initialItems={initialItems} />;
}
// fetchProject 抛错信息形如「请求失败 404: ...」;据此区分「项目不存在」与「后端不可用」。
function isNotFoundError(err: unknown): boolean {
return err instanceof Error && err.message.includes("请求失败 404");
}

View File

@@ -1,7 +1,9 @@
import { notFound } from "next/navigation";
import { BackendDownNotice } from "@/components/BackendDownNotice";
import { OutlineEditor } from "@/components/outline/OutlineEditor";
import { fetchOutline, fetchProject } from "@/lib/api/server";
import type { ProjectResponse } from "@/lib/api/types";
interface PageProps {
params: Promise<{ id: string }>;
@@ -12,13 +14,22 @@ interface PageProps {
// 仍按需触发 POST .../outline 生成/覆盖所显章节。
export default async function OutlinePage({ params }: PageProps) {
const { id } = await params;
// 仅当项目确实不存在404才判 notFound后端不可用等其它失败降级为「后端不可达」提示
// 不把整页变成「找不到页面」。次级拉取(大纲)的瞬时错误已在 fetchOutline 内降级为空。
let project: ProjectResponse;
try {
const project = await fetchProject(id);
const initialChapters = await fetchOutline(id);
return (
<OutlineEditor project={project} initialChapters={initialChapters} />
);
} catch {
notFound();
project = await fetchProject(id);
} catch (err) {
if (isNotFoundError(err)) notFound();
return <BackendDownNotice className="m-6" />;
}
const initialChapters = await fetchOutline(id);
return <OutlineEditor project={project} initialChapters={initialChapters} />;
}
// fetchProject 抛错信息形如「请求失败 404: ...」;据此区分「项目不存在」与「后端不可用」。
function isNotFoundError(err: unknown): boolean {
return err instanceof Error && err.message.includes("请求失败 404");
}

View File

@@ -1,8 +1,15 @@
import { notFound } from "next/navigation";
import { ReviewReport } from "@/components/review/ReviewReport";
import { fetchDraft, fetchProject, fetchReviews } from "@/lib/api/server";
import {
fetchDraft,
fetchOutline,
fetchProject,
fetchReviews,
} from "@/lib/api/server";
import type { ProjectResponse } from "@/lib/api/types";
import { latestReview } from "@/lib/review/history";
import type { ChapterEntry } from "@/lib/workbench/chapter";
interface PageProps {
params: Promise<{ id: string }>;
@@ -17,22 +24,51 @@ export default async function ReviewPage({ params, searchParams }: PageProps) {
const { id } = await params;
const { chapter } = await searchParams;
const chapterNo = parsePositiveInt(chapter) ?? DEFAULT_CHAPTER_NO;
// 仅当项目确实取不到时才判 404审稿/草稿/大纲等次级拉取的瞬时错误不应把整页变成「找不到」。
let project: ProjectResponse;
try {
const project = await fetchProject(id);
const history = await fetchReviews(id, chapterNo);
// 终稿初值取已存草稿GET .../draft404→null→"");否则 final_text 为空 → accept 422。
const draft = await fetchDraft(id, chapterNo);
return (
<ReviewReport
project={project}
chapterNo={chapterNo}
initialReview={latestReview(history.reviews)}
initialDraft={draft?.content ?? ""}
/>
);
project = await fetchProject(id);
} catch {
notFound();
}
// 审稿留痕GET .../reviews失败→空历史
let initialReview;
try {
const history = await fetchReviews(id, chapterNo);
initialReview = latestReview(history.reviews);
} catch {
initialReview = undefined;
}
// 终稿初值取已存草稿GET .../draft404→null→"");否则 final_text 为空 → accept 422。
let initialDraft = "";
try {
const draft = await fetchDraft(id, chapterNo);
initialDraft = draft?.content ?? "";
} catch {
initialDraft = "";
}
// 大纲章节作章节导航目录fetchOutline 已对空/错误降级为 []);首个节拍当短标题。
const chapters: ChapterEntry[] = (await fetchOutline(id)).map((c) => ({
no: c.no,
title: c.beats?.[0],
}));
return (
// key=章号:客户端切章(?chapter=N 变化)时强制重挂载,用新章审稿/草稿刷新状态,
// 避免 ReviewReport 内 useState 基线沿用上一章。
<ReviewReport
key={chapterNo}
project={project}
chapterNo={chapterNo}
initialReview={initialReview}
initialDraft={initialDraft}
chapters={chapters}
/>
);
}
function parsePositiveInt(raw: string | undefined): number | null {

View File

@@ -1,5 +1,4 @@
import { notFound } from "next/navigation";
import { BackendDownNotice } from "@/components/BackendDownNotice";
import { ToolboxPage } from "@/components/toolbox/ToolboxPage";
import { fetchProject, fetchToolbox } from "@/lib/api/server";
@@ -29,6 +28,6 @@ export default async function ToolboxRoutePage({
/>
);
} catch {
notFound();
return <BackendDownNotice className="m-6" />;
}
}

View File

@@ -1,4 +1,5 @@
import { AppShell } from "@/components/AppShell";
import { BackendDownNotice } from "@/components/BackendDownNotice";
import { ProvidersSettings } from "@/components/settings/ProvidersSettings";
import { PageHeader } from "@/components/ui/PageHeader";
import { fetchKimiOauthStatus, fetchProviders } from "@/lib/api/server";
@@ -34,9 +35,7 @@ export default async function ProvidersSettingsPage() {
description="配置写手、分析、轻量三类能力档位的路由,并管理 API Key 与 Kimi Code OAuth。"
/>
{loadError ? (
<p className="rounded border border-conflict bg-panel p-6 text-conflict">
API
</p>
<BackendDownNotice />
) : (
<ProvidersSettings initial={initial} kimiOauth={kimiOauth} />
)}

View File

@@ -1,14 +1,23 @@
import { AppShell } from "@/components/AppShell";
import { TemplatesManager } from "@/components/templates/TemplatesManager";
import { fetchTemplates } from "@/lib/api/server";
import type { TemplateResponse } from "@/lib/api/types";
import { PageHeader } from "@/components/ui/PageHeader";
import { StatusNote } from "@/components/ui/StatusNote";
import { fetchTemplates, fetchToolbox } from "@/lib/api/server";
import type { TemplateResponse, ToolDescriptorView } from "@/lib/api/types";
// 提示词/模板库F3全局单用户本地版。Server Component 预取列表CRUD 在客户端组件。
// 提示词/模板库F3全局单用户本地版。Server Component 预取列表 + 工具描述符(供「关联工具」下拉);
// CRUD 在客户端组件。fetchToolbox 任何错误内部降级为空列表,不阻塞进页。
export default async function TemplatesPage() {
let initial: TemplateResponse[] = [];
let tools: ToolDescriptorView[] = [];
let loadError = false;
try {
initial = await fetchTemplates();
const [templates, toolbox] = await Promise.all([
fetchTemplates(),
fetchToolbox(),
]);
initial = templates;
tools = toolbox.tools ?? [];
} catch {
loadError = true;
}
@@ -16,15 +25,17 @@ export default async function TemplatesPage() {
return (
<AppShell title="提示词 / 模板库">
<div className="mx-auto max-w-3xl px-8 py-10">
<p className="mb-6 text-xs text-ink-soft">
brief /
</p>
<PageHeader
title="提示词 / 模板库"
eyebrow="prompt templates"
description="保存常用提示词,复用时一键填入生成器的需求 / 原文输入。仅本地、单用户,不做分享。"
/>
{loadError ? (
<p className="rounded border border-conflict bg-panel p-6 text-conflict">
API
</p>
<StatusNote variant="danger" title="无法连接后端服务">
<p> API </p>
</StatusNote>
) : (
<TemplatesManager initial={initial} />
<TemplatesManager initial={initial} tools={tools} />
)}
</div>
</AppShell>

View File

@@ -30,7 +30,7 @@ export function AiToolbar({ projectId, activeNav }: AiToolbarProps) {
return (
<nav
aria-label="AI 工具条"
className="flex h-12 items-center gap-2 border-b border-line bg-panel px-4 sm:px-6"
className="sticky top-16 z-20 flex h-12 items-center gap-2 border-b border-line bg-panel px-4 sm:px-6"
>
<div className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden lg:hidden">
{primaryItems.map((item) => {

View File

@@ -6,7 +6,7 @@ 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";
import { buttonClass, cn, focusRing } from "@/lib/ui/variants";
interface AiToolbarMoreMenuProps {
items: AiToolItem[];
@@ -52,7 +52,7 @@ export function AiToolbarMoreMenu({
<button
ref={buttonRef}
type="button"
aria-haspopup="menu"
aria-haspopup="true"
aria-expanded={open}
aria-controls={menuId}
onClick={() => setOpen((value) => !value)}
@@ -62,9 +62,10 @@ export function AiToolbarMoreMenu({
</button>
{open ? (
<div
// 诚实的 Tab-only 折叠面板:普通导航链接,不冒充 ARIA menu无 roving tabindex/方向键)。
<nav
id={menuId}
role="menu"
aria-label="更多工具"
className="absolute right-0 z-30 mt-2 min-w-36 rounded border border-line bg-panel p-1 shadow-paper"
>
{items.map((item) => {
@@ -73,11 +74,11 @@ export function AiToolbarMoreMenu({
<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",
"block rounded px-3 py-2 text-sm transition-colors",
focusRing,
active
? "bg-[var(--color-cinnabar-wash)] text-cinnabar"
: "text-ink hover:bg-bg hover:text-cinnabar",
@@ -87,7 +88,7 @@ export function AiToolbarMoreMenu({
</Link>
);
})}
</div>
</nav>
) : null}
</div>
);

View File

@@ -3,8 +3,9 @@ import type { ReactNode } from "react";
import { Settings } from "lucide-react";
import type { ActiveNav } from "@/lib/nav/items";
import { buttonClass } from "@/lib/ui/variants";
import { buttonClass, focusRing } from "@/lib/ui/variants";
import { AiToolbar } from "./AiToolbar";
import { CommandSearchButton } from "./command/CommandPaletteMount";
import { LeftNav } from "./LeftNav";
import { NavDrawer } from "./NavDrawer";
import { ThemeToggle } from "./ThemeToggle";
@@ -33,11 +34,17 @@ export function AppShell({
className="min-h-screen"
style={{ ["--chrome" as string]: projectId ? "7rem" : "4rem" }}
>
<header className="flex h-16 items-center gap-4 border-b border-line bg-panel px-4 sm:px-6">
<a
href="#main"
className="sr-only focus:not-sr-only focus:absolute focus:left-4 focus:top-4 focus:z-50 focus:rounded focus:border focus:border-line focus:bg-panel focus:px-3 focus:py-2 focus:text-sm focus:text-ink focus:shadow-paper"
>
</a>
<header className="sticky top-0 z-30 flex h-16 items-center gap-4 border-b border-line bg-panel px-4 sm:px-6">
<NavDrawer projectId={projectId} activeNav={activeNav} />
<Link
href="/"
className="shrink-0 whitespace-nowrap font-serif text-xl text-cinnabar"
className={`shrink-0 whitespace-nowrap rounded font-serif text-xl text-cinnabar ${focusRing}`}
aria-label="返回作品库"
>
@@ -52,10 +59,16 @@ export function AppShell({
{subtitle}
</span>
) : null}
<nav className="ml-auto flex shrink-0 items-center gap-2 text-sm">
<nav
aria-label="快捷操作"
className="ml-auto flex shrink-0 items-center gap-2 text-sm"
>
<CommandSearchButton />
<ThemeToggle />
<Link
href="/settings/providers"
aria-label="设置"
title="设置"
className={buttonClass({
variant: "ghost",
size: "sm",
@@ -72,7 +85,11 @@ export function AppShell({
) : null}
<div className="flex">
<LeftNav projectId={projectId} activeNav={activeNav} />
<main className="min-h-[calc(100vh-var(--chrome,4rem))] min-w-0 flex-1 bg-bg">
<main
id="main"
tabIndex={-1}
className="min-h-[calc(100vh-var(--chrome,4rem))] min-w-0 flex-1 bg-bg"
>
{children}
</main>
</div>

View File

@@ -0,0 +1,19 @@
import { StatusNote } from "./ui/StatusNote";
interface BackendDownNoticeProps {
className?: string;
}
// 后端不可达时的统一提示(多个页面 backend-down 分支复用)。
export function BackendDownNotice({ className }: BackendDownNoticeProps) {
return (
<StatusNote
variant="danger"
title="无法连接后端服务"
role="alert"
className={className}
>
API
</StatusNote>
);
}

View File

@@ -1,8 +1,12 @@
"use client";
import { useEffect, useRef, type ReactNode, type RefObject } from "react";
import { X } from "lucide-react";
import { Button } from "@/components/ui/Button";
import { handleTabTrap } from "@/lib/a11y/focusTrap";
import { useBodyScrollLock } from "@/lib/ui/useBodyScrollLock";
import { overlayScrim } from "@/lib/ui/variants";
interface DrawerProps {
open: boolean;
@@ -15,7 +19,7 @@ interface DrawerProps {
children: ReactNode;
}
// 移动端侧滑抽屉(<lg遮罩点击 / Esc 关闭 + 打开聚焦面板)。
// 移动端侧滑抽屉(<lg遮罩点击 / Esc 关闭 + 打开聚焦具名关闭按钮)。
// 复用命令面板的 a11y 模式CommandPalette桌面用静态布局不渲染本抽屉。
export function Drawer({
open,
@@ -26,8 +30,11 @@ export function Drawer({
children,
}: DrawerProps) {
const panelRef = useRef<HTMLDivElement>(null);
const closeRef = useRef<HTMLButtonElement>(null);
const wasOpenRef = useRef(false);
useBodyScrollLock(open);
useEffect(() => {
if (!open) {
if (wasOpenRef.current) {
@@ -44,7 +51,8 @@ export function Drawer({
}
};
window.addEventListener("keydown", onKey);
panelRef.current?.focus();
// 焦点陷阱首焦点落在具名关闭按钮上(而非整个面板)。
closeRef.current?.focus();
return () => window.removeEventListener("keydown", onKey);
}, [open, onClose, triggerRef]);
@@ -53,10 +61,9 @@ export function Drawer({
const sideClass = side === "left" ? "left-0 border-r" : "right-0 border-l";
return (
<div className="fixed inset-0 z-50 bg-black/30 lg:hidden" onClick={onClose}>
<div className={`${overlayScrim} lg:hidden`} onClick={onClose}>
<div
ref={panelRef}
tabIndex={-1}
role="dialog"
aria-modal="true"
aria-label={label}
@@ -65,8 +72,19 @@ export function Drawer({
// focus trapTab/Shift+Tab 在抽屉内循环不逃逸到背景WCAG 2.1.2)。
if (panelRef.current) handleTabTrap(panelRef.current, e);
}}
className={`absolute top-0 h-full w-64 max-w-[80vw] overflow-auto border-line bg-panel py-4 shadow-paper outline-none ${sideClass}`}
className={`absolute top-0 h-full w-64 max-w-[80vw] overflow-auto overscroll-contain border-line bg-panel py-4 shadow-paper outline-none ${sideClass}`}
>
<div className="mb-2 flex justify-end px-2">
<Button
ref={closeRef}
onClick={onClose}
aria-label={`关闭${label}`}
variant="ghost"
size="icon"
>
<X className="h-5 w-5" aria-hidden="true" />
</Button>
</div>
{children}
</div>
</div>

View File

@@ -11,7 +11,7 @@ interface LeftNavProps {
export function LeftNav({ projectId, activeNav }: LeftNavProps) {
return (
<nav
className="hidden w-44 shrink-0 border-r border-line bg-panel py-4 lg:block"
className="sticky top-[var(--chrome)] hidden max-h-[calc(100vh-var(--chrome))] w-44 shrink-0 self-start overflow-y-auto border-r border-line bg-panel py-4 lg:block"
aria-label="主导航"
>
<NavItems projectId={projectId} activeNav={activeNav} />

View File

@@ -1,10 +1,12 @@
"use client";
import { useState } from "react";
import { Menu } from "lucide-react";
import { useRef, useState } from "react";
import { Menu, Search } from "lucide-react";
import { Button } from "@/components/ui/Button";
import { openCommandPalette } from "@/components/command/CommandPalette";
import type { ActiveNav } from "@/lib/nav/items";
import { focusRing } from "@/lib/ui/variants";
import { Drawer } from "./Drawer";
import { NavItems } from "./NavItems";
@@ -16,10 +18,17 @@ interface NavDrawerProps {
// 移动端导航:汉堡按钮 + 左侧抽屉(仅 <lg桌面用 LeftNav 静态侧栏。UX §5.1。
export function NavDrawer({ projectId, activeNav }: NavDrawerProps) {
const [open, setOpen] = useState(false);
const triggerRef = useRef<HTMLButtonElement>(null);
const openSearch = (): void => {
setOpen(false);
openCommandPalette();
};
return (
<>
<Button
ref={triggerRef}
onClick={() => setOpen(true)}
aria-label="打开导航"
aria-expanded={open}
@@ -34,7 +43,21 @@ export function NavDrawer({ projectId, activeNav }: NavDrawerProps) {
onClose={() => setOpen(false)}
side="left"
label="主导航"
triggerRef={triggerRef}
>
<div className="px-2 pb-2">
<button
type="button"
onClick={openSearch}
className={`flex w-full items-center gap-2 rounded border border-line bg-bg px-3 py-2 text-sm text-ink-soft transition-colors hover:text-cinnabar ${focusRing}`}
>
<Search className="h-4 w-4 shrink-0" aria-hidden="true" />
<kbd className="ml-auto rounded border border-line bg-panel px-1 font-mono text-2xs">
K
</kbd>
</button>
</div>
<NavItems
projectId={projectId}
activeNav={activeNav}

View File

@@ -25,6 +25,7 @@ import {
type ActiveNav,
type NavItem,
} from "@/lib/nav/items";
import { focusRing } from "@/lib/ui/variants";
interface NavItemsProps {
// 项目内页传 projectId → 展开项目级入口(大纲/伏笔/审稿UX §3.1)。
@@ -101,7 +102,7 @@ function NavLink({
href={item.href}
onClick={onNavigate}
aria-current={active ? "page" : undefined}
className={`flex items-center gap-2 rounded px-3 py-2 text-sm transition-colors ${
className={`flex items-center gap-2 rounded px-3 py-2 text-sm transition-colors ${focusRing} ${
active
? "border-l-2 border-cinnabar bg-[var(--color-cinnabar-wash)] text-cinnabar"
: "border-l-2 border-transparent text-ink hover:bg-[var(--color-cinnabar-wash)] hover:text-cinnabar"

View File

@@ -1,5 +1,5 @@
import Link from "next/link";
import { BookOpen, ChevronRight, Clock3, Sparkles } from "lucide-react";
import { BookOpen, ChevronRight, Clock3 } from "lucide-react";
import { Badge } from "@/components/ui/Badge";
import type { ProjectResponse } from "@/lib/api/types";
@@ -7,7 +7,7 @@ import {
formatProjectUpdatedAt,
pendingReviewCount,
} from "@/lib/projects/projects";
import { cardClass } from "@/lib/ui/variants";
import { cardClass, focusRing } from "@/lib/ui/variants";
interface ProjectCardProps {
project: ProjectResponse;
@@ -24,7 +24,7 @@ export function ProjectCard({ project, compact = false }: ProjectCardProps) {
<Link
href={`/projects/${project.id}/write`}
className={cardClass(
"group flex items-center gap-3 p-3 transition-colors hover:border-cinnabar",
`group flex items-center gap-3 p-3 transition-colors hover:border-cinnabar ${focusRing}`,
)}
>
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded bg-[var(--color-cinnabar-wash)] text-cinnabar">
@@ -57,7 +57,7 @@ export function ProjectCard({ project, compact = false }: ProjectCardProps) {
<Link
href={`/projects/${project.id}/write`}
className={cardClass(
"group flex h-full min-h-[176px] flex-col p-6 transition-colors hover:border-cinnabar",
`group flex h-full min-h-[176px] flex-col p-6 transition-colors hover:border-cinnabar ${focusRing}`,
)}
>
<div className="mb-4 flex items-start justify-between gap-3">
@@ -67,12 +67,8 @@ export function ProjectCard({ project, compact = false }: ProjectCardProps) {
</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>
<Badge variant="warning">{pendingCount} </Badge>
) : null}
</div>
</div>
@@ -89,7 +85,7 @@ export function ProjectCard({ project, compact = false }: ProjectCardProps) {
<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">
<span className="mt-auto flex items-center gap-1 pt-4 text-xs text-cinnabar opacity-0 motion-safe:transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100">
<ChevronRight className="h-3.5 w-3.5" aria-hidden="true" />
</span>

View File

@@ -1,13 +1,14 @@
"use client";
import { ArrowLeft, ArrowRight, CheckCircle2 } from "lucide-react";
import { ArrowLeft, ArrowRight, Check, CheckCircle2 } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useEffect, useRef, useState } from "react";
import { useToast } from "@/components/Toast";
import { Badge } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button";
import { Field } from "@/components/ui/Field";
import { SegmentedControl } from "@/components/ui/SegmentedControl";
import { Select } from "@/components/ui/Select";
import { TextArea } from "@/components/ui/TextArea";
import { TextInput } from "@/components/ui/TextInput";
@@ -16,6 +17,7 @@ import { buttonClass } from "@/lib/ui/variants";
import {
GENRES,
SELLING_POINT_PRESETS,
STEP_TITLES,
STRUCTURES,
WIZARD_STEPS,
canAdvance,
@@ -26,14 +28,6 @@ import {
type WizardForm,
} from "@/lib/wizard/wizard";
const STEP_TITLES = [
"书名 + 题材",
"一句话故事 + 卖点 + 结构",
"立意 / 总纲",
"主角 / 金手指",
"基础世界观",
];
// 立项向导UX §6.2。Client Component收集字段 → POST /projects → 进工作台。
export function ProjectWizard() {
const router = useRouter();
@@ -42,6 +36,17 @@ export function ProjectWizard() {
const [form, setForm] = useState<WizardForm>(emptyWizardForm);
const [submitting, setSubmitting] = useState(false);
// 换步后把焦点移到新步标题,方便键盘/读屏用户感知步骤变化;首帧不抢第 1 步输入框的 autoFocus。
const stepHeadingRef = useRef<HTMLParagraphElement>(null);
const isFirstRender = useRef(true);
useEffect(() => {
if (isFirstRender.current) {
isFirstRender.current = false;
return;
}
stepHeadingRef.current?.focus();
}, [step]);
const update = (patch: Partial<WizardForm>): void =>
setForm((prev) => ({ ...prev, ...patch }));
@@ -86,7 +91,13 @@ export function ProjectWizard() {
<Badge variant="accent">
{step}/{WIZARD_STEPS}
</Badge>
<p className="mt-2 text-sm text-ink-soft">{STEP_TITLES[step - 1]}</p>
<p
ref={stepHeadingRef}
tabIndex={-1}
className="mt-2 text-sm text-ink-soft focus-visible:outline-none"
>
{STEP_TITLES[step - 1]}
</p>
</div>
<div className="min-h-[220px]">
@@ -100,7 +111,7 @@ export function ProjectWizard() {
)}
{step === 3 && <StepPremise form={form} update={update} />}
{step === 4 && <StepProtagonist form={form} update={update} />}
{step === 5 && <StepWorld />}
{step === 5 && <StepConfirm form={form} />}
</div>
<div className="mt-8 flex items-center justify-between">
@@ -153,9 +164,14 @@ interface StepProps {
}
function StepBasics({ form, update }: StepProps) {
const titleMissing = form.title.trim().length === 0;
return (
<div>
<Field label="书名(必填)">
<Field
label="书名"
required
help={titleMissing ? "请先填写书名才能继续" : undefined}
>
<TextInput
value={form.title}
onChange={(e) => update({ title: e.target.value })}
@@ -196,41 +212,37 @@ function StepStory({
/>
</Field>
<Field label="故事结构">
<div className="flex gap-2">
{STRUCTURES.map((s) => (
<button
type="button"
key={s}
onClick={() => update({ structure: s })}
className={buttonClass({
variant: form.structure === s ? "outline" : "secondary",
size: "sm",
})}
>
{s}
</button>
))}
</div>
<SegmentedControl
ariaLabel="故事结构"
className="flex-wrap"
value={form.structure}
onChange={(structure) => update({ structure })}
options={STRUCTURES.map((s) => ({ value: s, label: s }))}
/>
</Field>
<fieldset>
<legend className="mb-1 block text-sm text-ink-soft"></legend>
<div className="flex flex-wrap gap-2">
{SELLING_POINT_PRESETS.map((p) => (
{SELLING_POINT_PRESETS.map((p) => {
const selected = form.sellingPoints.includes(p);
return (
<button
type="button"
key={p}
aria-pressed={form.sellingPoints.includes(p)}
aria-pressed={selected}
onClick={() => toggleSellingPoint(p)}
className={buttonClass({
variant: form.sellingPoints.includes(p)
? "outline"
: "secondary",
variant: selected ? "primary" : "secondary",
size: "sm",
})}
>
{selected ? (
<Check className="h-3.5 w-3.5" aria-hidden="true" />
) : null}
{p}
</button>
))}
);
})}
</div>
</fieldset>
</div>
@@ -279,10 +291,31 @@ function StepProtagonist({ form, update }: StepProps) {
);
}
function StepWorld() {
// 第 5 步:确认/概览——回显已填字段供复核后提交(取代旧的空「世界观后续里程碑」说明)。
function StepConfirm({ form }: { form: WizardForm }) {
const rows: Array<{ label: string; value: string }> = [
{ label: "书名", value: form.title.trim() || "未填写" },
{ label: "一句话故事", value: form.logline.trim() || "未填写" },
{ label: "题材", value: form.genre || "未选择" },
{ label: "故事结构", value: form.structure || "未选择" },
{
label: "核心卖点",
value: form.sellingPoints.length > 0 ? form.sellingPoints.join("、") : "未选择",
},
];
return (
<div className="rounded border border-dashed border-line bg-bg p-6 text-sm leading-6 text-ink-soft">
<div>
<p className="mb-3 text-sm text-ink-soft">
</p>
<dl className="divide-y divide-line rounded border border-line bg-bg">
{rows.map((row) => (
<div key={row.label} className="flex gap-4 px-4 py-2.5 text-sm">
<dt className="w-20 shrink-0 text-ink-soft">{row.label}</dt>
<dd className="min-w-0 flex-1 text-ink">{row.value}</dd>
</div>
))}
</dl>
</div>
);
}

View File

@@ -26,6 +26,13 @@ function readStoredThemeMode(): ThemeMode {
}
}
// 初始模式直接读 ThemeScript 在水合前写入的 <html data-theme>,避免夜读首帧渲染错误图标。
// SSR 守卫:服务端无 document → 回落默认(与 ThemeScript 的默认一致)。
function initialThemeMode(): ThemeMode {
if (typeof document === "undefined") return DEFAULT_THEME_MODE;
return normalizeThemeMode(document.documentElement.dataset.theme);
}
function writeStoredThemeMode(mode: ThemeMode) {
try {
window.localStorage.setItem(THEME_STORAGE_KEY, mode);
@@ -35,9 +42,10 @@ function writeStoredThemeMode(mode: ThemeMode) {
}
export function ThemeToggle() {
const [mode, setMode] = useState<ThemeMode>(DEFAULT_THEME_MODE);
const [mode, setMode] = useState<ThemeMode>(initialThemeMode);
useEffect(() => {
// 水合后以 localStorage 为权威源校正dataset 已由 ThemeScript 同样依据写入,通常一致)。
const initial = readStoredThemeMode();
setMode(initial);
applyThemeMode(initial);

View File

@@ -11,19 +11,51 @@ import {
type ReactNode,
} from "react";
import { buttonClass } from "@/lib/ui/variants";
type ToastKind = "info" | "error" | "success";
interface ToastAction {
label: string;
onClick: () => void;
}
interface ToastOptions {
action?: ToastAction;
durationMs?: number;
}
interface ToastItem {
id: number;
message: string;
kind: ToastKind;
action?: ToastAction;
}
type ShowToast = (message: string, kind?: ToastKind) => void;
type ShowToast = (
message: string,
kind?: ToastKind,
options?: ToastOptions,
) => void;
const ToastContext = createContext<ShowToast | null>(null);
// 普通提示 4s 自动消失;错误更需被看到,默认延长到 7s。
const TOAST_TTL_MS = 4000;
const ERROR_TOAST_TTL_MS = 7000;
// 屏读前缀:让无视觉的用户也能区分提示性质。
const SR_PREFIX: Record<ToastKind, string> = {
error: "错误:",
success: "成功:",
info: "",
};
function toastShellClass(kind: ToastKind): string {
if (kind === "error") return "border-conflict bg-panel text-conflict";
if (kind === "success") return "border-pass bg-panel text-pass";
return "border-line bg-panel text-ink";
}
export function ToastProvider({ children }: { children: ReactNode }) {
const [items, setItems] = useState<ToastItem[]>([]);
@@ -32,13 +64,20 @@ export function ToastProvider({ children }: { children: ReactNode }) {
// 在途定时器集合:卸载时统一清理,避免 setState-after-unmount 泄漏。
const timersRef = useRef<Set<ReturnType<typeof setTimeout>>>(new Set());
const show = useCallback<ShowToast>((message, kind = "info") => {
const dismiss = useCallback((id: number) => {
setItems((prev) => prev.filter((t) => t.id !== id));
}, []);
const show = useCallback<ShowToast>((message, kind = "info", options) => {
const id = nextIdRef.current++;
setItems((prev) => [...prev, { id, message, kind }]);
setItems((prev) => [...prev, { id, message, kind, action: options?.action }]);
const ttl =
options?.durationMs ??
(kind === "error" ? ERROR_TOAST_TTL_MS : TOAST_TTL_MS);
const timer = setTimeout(() => {
timersRef.current.delete(timer);
setItems((prev) => prev.filter((t) => t.id !== id));
}, TOAST_TTL_MS);
}, ttl);
timersRef.current.add(timer);
}, []);
@@ -53,28 +92,46 @@ export function ToastProvider({ children }: { children: ReactNode }) {
const value = useMemo(() => show, [show]);
const errorItems = items.filter((t) => t.kind === "error");
const politeItems = items.filter((t) => t.kind !== "error");
const renderItem = (t: ToastItem) => (
<div
key={t.id}
className={`pointer-events-auto flex items-center gap-3 rounded border px-4 py-2 text-sm shadow-paper ${toastShellClass(
t.kind,
)}`}
>
<span className="min-w-0">
<span className="sr-only">{SR_PREFIX[t.kind]}</span>
{t.message}
</span>
{t.action ? (
<button
type="button"
className={buttonClass({ variant: "ghost", size: "sm" })}
onClick={() => {
t.action?.onClick();
dismiss(t.id);
}}
>
{t.action.label}
</button>
) : null}
</div>
);
return (
<ToastContext.Provider value={value}>
{children}
<div
className="pointer-events-none fixed bottom-6 right-6 z-50 flex flex-col gap-2"
aria-live="polite"
role="status"
>
{items.map((t) => (
<div
key={t.id}
className={`pointer-events-auto rounded border px-4 py-2 text-sm shadow-paper ${
t.kind === "error"
? "border-conflict bg-panel text-conflict"
: t.kind === "success"
? "border-pass bg-panel text-pass"
: "border-line bg-panel text-ink"
}`}
>
{t.message}
{/* error 用 assertive 立即播报;其余 info/success 用 polite 礼貌播报。两区共用同一锚点容器避免重叠。 */}
<div className="pointer-events-none fixed bottom-6 right-6 z-50 flex flex-col gap-2">
<div className="flex flex-col gap-2" aria-live="assertive" role="alert">
{errorItems.map(renderItem)}
</div>
<div className="flex flex-col gap-2" aria-live="polite" role="status">
{politeItems.map(renderItem)}
</div>
))}
</div>
</ToastContext.Provider>
);

View File

@@ -4,6 +4,8 @@ import { useCallback, useEffect, useState } from "react";
import { ConflictCard } from "@/components/review/ConflictCard";
import { useToast } from "@/components/Toast";
import { Button } from "@/components/ui/Button";
import { StatusNote } from "@/components/ui/StatusNote";
import { api } from "@/lib/api/client";
import { buildResumeRequest } from "@/lib/chain/chain";
import { friendlyFromApiError } from "@/lib/errors/messages";
@@ -45,10 +47,9 @@ export function ChainAdjudication({
const [drafts, setDrafts] = useState<DecisionDraft[]>([]);
const [resuming, setResuming] = useState(false);
useEffect(() => {
let active = true;
// 拉取 awaiting 章最近审稿冲突。抽成可复用回调,供进场 effect 与「重新加载」按钮共用。
const load = useCallback(async (): Promise<boolean> => {
setLoadState("loading");
void (async () => {
try {
const { data, error } = await api.GET(
"/projects/{project_id}/chapters/{chapter_no}/reviews",
@@ -58,23 +59,32 @@ export function ChainAdjudication({
},
},
);
if (!active) return;
if (error || !data) {
setLoadState("error");
return;
return false;
}
const next = normalizeConflicts(latestReview(data.reviews));
setConflicts(next);
setDrafts(emptyDecisions(next.length));
setLoadState("ready");
return true;
} catch {
if (active) setLoadState("error");
setLoadState("error");
return false;
}
}, [projectId, chapterNo]);
useEffect(() => {
let active = true;
void (async () => {
// 进场加载:组件已卸载则丢弃结果(避免 setState-after-unmount
if (!active) return;
await load();
})();
return () => {
active = false;
};
}, [projectId, chapterNo]);
}, [load]);
const onVerdict = useCallback((index: number, verdict: Verdict): void => {
setDrafts((prev) => setVerdict(prev, index, verdict));
@@ -115,9 +125,17 @@ export function ChainAdjudication({
}
if (loadState === "error") {
return (
<p className="text-sm text-conflict">
{chapterNo} 稿
</p>
<StatusNote variant="danger" title={`加载第 ${chapterNo} 章审稿冲突失败`}>
<p></p>
<Button
variant="outline"
size="sm"
onClick={() => void load()}
className="mt-3"
>
</Button>
</StatusNote>
);
}
@@ -159,14 +177,14 @@ export function ChainAdjudication({
</ul>
)}
<button
type="button"
<Button
variant="primary"
onClick={() => void onResume()}
disabled={resuming || !allResolved(drafts)}
className="self-start rounded bg-cinnabar px-4 py-2 text-sm text-white disabled:opacity-50"
className="self-start"
>
{resuming ? "续跑中…" : "裁决完成,续跑"}
</button>
</Button>
{pending.length > 0 ? (
<p className="text-xs text-conflict">
{pending.length}

View File

@@ -4,6 +4,8 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import { AppShell } from "@/components/AppShell";
import { useToast } from "@/components/Toast";
import { Button } from "@/components/ui/Button";
import { StatusNote } from "@/components/ui/StatusNote";
import { api } from "@/lib/api/client";
import { chainPhase, chainResultView, type ChainKind } from "@/lib/chain/chain";
import { friendlyFromApiError } from "@/lib/errors/messages";
@@ -77,6 +79,11 @@ export function ChainPage({ project }: ChainPageProps) {
if (jobId) poll.poll(jobId);
}, [jobId, poll]);
// 链失败重试:重启轮询同一 job覆盖轮询/网络抖动这类瞬时失败;图从检查点续)。
const onRetry = useCallback((): void => {
if (jobId) poll.poll(jobId);
}, [jobId, poll]);
const showProgress = jobId !== null && result !== null;
// 链运行中/等待裁决时禁止重复发起。
const busy =
@@ -112,13 +119,25 @@ export function ChainPage({ project }: ChainPageProps) {
) : null}
{phase === "failed" ? (
<p className="text-sm text-conflict">
{poll.error ?? "链运行失败,请重试。"}
</p>
<StatusNote variant="danger" title="链运行失败">
<p>{poll.error ?? "请稍后重试。"}</p>
{jobId !== null ? (
<Button
variant="outline"
size="sm"
onClick={onRetry}
className="mt-3"
>
</Button>
) : null}
</StatusNote>
) : null}
{phase === "completed" ? (
<p className="text-sm text-pass"></p>
<StatusNote variant="success" title="本链已全部跑完">
<p></p>
</StatusNote>
) : null}
</div>
</AppShell>

View File

@@ -1,5 +1,8 @@
"use client";
import { Badge } from "@/components/ui/Badge";
import { Card } from "@/components/ui/Card";
import type { BadgeVariant } from "@/lib/ui/variants";
import type { ChainPhase, ChainResultView } from "@/lib/chain/chain";
interface ChainProgressProps {
@@ -15,32 +18,40 @@ const PHASE_LABEL: Record<ChainPhase, string> = {
failed: "已失败",
};
// 相位 → 语义色:失败用危险色而非朱砂品牌色,运行/等待/完成各取信息/警示/成功。
const PHASE_VARIANT: Record<ChainPhase, BadgeVariant> = {
running: "info",
awaiting: "warning",
completed: "success",
failed: "danger",
};
// 链进度视图:相位徽标 + 进度条 + 已写章号清单token/正文绝不展示result 只含元数据)。
export function ChainProgress({ phase, progress, result }: ChainProgressProps) {
return (
<section
className="flex flex-col gap-3 rounded border border-line bg-panel p-5"
<Card
as="section"
className="flex flex-col gap-3 p-5"
aria-label="链进度"
>
<div className="flex items-center justify-between gap-3">
<h2 className="font-serif text-lg text-ink"></h2>
<span
className="rounded bg-cinnabar/10 px-2 py-0.5 text-xs text-cinnabar"
aria-live="polite"
>
<Badge variant={PHASE_VARIANT[phase]} aria-live="polite">
{PHASE_LABEL[phase]}
</span>
</Badge>
</div>
<div
className="h-2 w-full overflow-hidden rounded bg-bg"
role="progressbar"
aria-label="链写章进度"
aria-valuenow={progress}
aria-valuemin={0}
aria-valuemax={100}
aria-valuetext={`已完成 ${result.written.length}`}
>
<div
className="h-full bg-cinnabar transition-all"
className="h-full bg-cinnabar motion-safe:transition-all"
style={{ width: `${progress}%` }}
/>
</div>
@@ -58,6 +69,6 @@ export function ChainProgress({ phase, progress, result }: ChainProgressProps) {
{result.awaitingChapter}
</p>
) : null}
</section>
</Card>
);
}

View File

@@ -11,6 +11,7 @@ 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 { SegmentedControl } from "@/components/ui/SegmentedControl";
import type {
CharacterCardView,
ProjectResponse,
@@ -70,24 +71,13 @@ export function CodexPage({
title="设定库"
description="人物、世界观与时间线共同构成写作时的真相源。AI 生成内容也要先预览、再确认入库。"
/>
<div className="mb-4 flex items-center gap-2" role="tablist">
{TABS.map((t) => (
<button
key={t.key}
type="button"
role="tab"
aria-selected={tab === t.key}
onClick={() => setTab(t.key)}
className={`rounded px-3 py-1.5 text-sm ${
tab === t.key
? "border-b-2 border-cinnabar text-cinnabar"
: "text-ink-soft hover:text-ink"
}`}
>
{t.label}
</button>
))}
</div>
<SegmentedControl
className="mb-4 w-fit"
ariaLabel="设定库分区"
value={tab}
onChange={setTab}
options={TABS.map((t) => ({ value: t.key, label: t.label }))}
/>
<div className="min-h-0 flex-1 overflow-auto">
{tab === "characters" ? (
@@ -119,7 +109,7 @@ export function CodexPage({
{c.relations.map((r, j) => (
<li
key={`${r.name}-${r.kind}-${j}`}
className="rounded bg-panel px-1.5 py-0.5 text-[11px]"
className="rounded bg-panel px-1.5 py-0.5 text-2xs"
title={r.note ?? undefined}
>
{r.kind} · {r.name}

View File

@@ -1,6 +1,12 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
useSyncExternalStore,
} from "react";
import { useRouter } from "next/navigation";
import {
@@ -13,6 +19,52 @@ import {
} from "@/lib/command/palette";
import { handleTabTrap } from "@/lib/a11y/focusTrap";
import { api } from "@/lib/api/client";
import { useBodyScrollLock } from "@/lib/ui/useBodyScrollLock";
import { focusRing, overlayScrim } from "@/lib/ui/variants";
// 命令面板开关的极简外部 store⌘K 与可见入口(顶栏按钮 / 移动抽屉)共享同一开态。
// 用 useSyncExternalStore 订阅,避免把 open 提升到 RootLayout context 造成全树重渲染。
let paletteOpen = false;
const paletteListeners = new Set<() => void>();
function emitPalette(): void {
for (const listener of paletteListeners) listener();
}
export function setCommandPaletteOpen(next: boolean): void {
if (paletteOpen === next) return;
paletteOpen = next;
emitPalette();
}
export function openCommandPalette(): void {
setCommandPaletteOpen(true);
}
export function toggleCommandPalette(): void {
setCommandPaletteOpen(!paletteOpen);
}
function subscribePalette(callback: () => void): () => void {
paletteListeners.add(callback);
return () => paletteListeners.delete(callback);
}
function getPaletteSnapshot(): boolean {
return paletteOpen;
}
// SSR 快照恒为 false命令面板永不在服务端渲染开态避免水合不一致。
export function useCommandPaletteOpen(): boolean {
return useSyncExternalStore(
subscribePalette,
getPaletteSnapshot,
() => false,
);
}
const LIST_ID = "command-list";
const optionId = (cmd: Command): string => `command-option-${cmd.id}`;
// 从 pathname 抽取当前 projectId/projects/<id>/...)。
function projectIdFromPath(pathname: string): string | null {
@@ -25,14 +77,17 @@ interface CommandPaletteProps {
}
// 命令面板⌘KUX §7键盘触发的快速导航/动作。
// 焦点管理打开自动聚焦输入框、Esc 关闭、上下选择、回车执行)+ a11yrole=dialog/listbox
// WAI-ARIA combobox/listboxinput role=comboboxaria-expanded/controls/activedescendant
// 列表 role=listbox每项稳定 id + role=option。焦点管理打开聚焦输入框、关闭归还触发元素。
export function CommandPalette({ pathname }: CommandPaletteProps) {
const router = useRouter();
const [open, setOpen] = useState(false);
const open = useCommandPaletteOpen();
const [query, setQuery] = useState("");
const [highlight, setHighlight] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const dialogRef = useRef<HTMLDivElement>(null);
// 打开前的焦点元素关闭时归还焦点WCAG 2.4.3)。
const restoreFocusRef = useRef<HTMLElement | null>(null);
// 工具箱生成器(命令面板发现性):首次打开惰性拉取一次,失败静默降级为空。
const [tools, setTools] = useState<ToolCommandInfo[]>([]);
const toolsLoadedRef = useRef(false);
@@ -51,7 +106,7 @@ export function CommandPalette({ pathname }: CommandPaletteProps) {
);
const close = useCallback((): void => {
setOpen(false);
setCommandPaletteOpen(false);
setQuery("");
setHighlight(0);
}, []);
@@ -65,21 +120,29 @@ export function CommandPalette({ pathname }: CommandPaletteProps) {
[close, router],
);
useBodyScrollLock(open);
// ⌘K / Ctrl+K 全局开关。
useEffect(() => {
const onKey = (e: KeyboardEvent): void => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
e.preventDefault();
setOpen((prev) => !prev);
toggleCommandPalette();
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, []);
// 打开时聚焦输入框;切查询重置高亮
// 打开时记住先前焦点并聚焦输入框;关闭后把焦点归还触发元素
useEffect(() => {
if (open) inputRef.current?.focus();
if (open) {
restoreFocusRef.current = document.activeElement as HTMLElement | null;
inputRef.current?.focus();
return;
}
restoreFocusRef.current?.focus();
restoreFocusRef.current = null;
}, [open]);
// 首次打开(项目内)惰性拉工具箱生成器列表,供生成 action-gen-<key> 命令。
@@ -110,6 +173,8 @@ export function CommandPalette({ pathname }: CommandPaletteProps) {
if (!open) return null;
const activeCmd = results[highlight];
const onInputKey = (e: React.KeyboardEvent): void => {
if (e.key === "Escape") {
e.preventDefault();
@@ -128,7 +193,7 @@ export function CommandPalette({ pathname }: CommandPaletteProps) {
return (
<div
className="fixed inset-0 z-[60] flex items-start justify-center bg-black/30 pt-[15vh] motion-safe:transition-opacity"
className={`${overlayScrim} flex items-start justify-center pt-[15vh] motion-safe:transition-opacity`}
onClick={close}
>
<div
@@ -136,7 +201,7 @@ export function CommandPalette({ pathname }: CommandPaletteProps) {
role="dialog"
aria-modal="true"
aria-label="命令面板"
className="w-full max-w-lg overflow-hidden rounded-lg border border-line bg-panel shadow-paper"
className="w-full max-w-lg overflow-hidden rounded border border-line bg-panel shadow-paper"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
// focus trapTab/Shift+Tab 在对话框内循环不逃逸到背景WCAG 2.1.2)。
@@ -149,15 +214,19 @@ export function CommandPalette({ pathname }: CommandPaletteProps) {
onChange={(e) => setQuery(e.target.value)}
onKeyDown={onInputKey}
placeholder="搜索命令或页面…(写本章 / 审稿 / 生成角色 / 跳转伏笔 / 搜设定)"
role="combobox"
aria-label="命令搜索"
aria-controls="command-list"
className="w-full border-b border-line bg-bg px-4 py-3 text-sm text-ink outline-none"
aria-expanded={true}
aria-controls={LIST_ID}
aria-activedescendant={activeCmd ? optionId(activeCmd) : undefined}
aria-autocomplete="list"
className={`w-full border-b border-line bg-bg px-4 py-3 text-sm text-ink ${focusRing}`}
/>
<ul
id="command-list"
id={LIST_ID}
role="listbox"
aria-label="命令列表"
className="max-h-80 overflow-auto py-1"
className="max-h-80 overflow-auto py-1 overscroll-contain"
>
{results.length === 0 ? (
<li className="px-4 py-3 text-sm text-ink-soft"></li>
@@ -165,6 +234,7 @@ export function CommandPalette({ pathname }: CommandPaletteProps) {
results.map((cmd, i) => (
<li
key={cmd.id}
id={optionId(cmd)}
role="option"
aria-selected={i === highlight}
onMouseEnter={() => setHighlight(i)}

View File

@@ -1,8 +1,10 @@
"use client";
import { Search } from "lucide-react";
import { usePathname } from "next/navigation";
import { CommandPalette } from "./CommandPalette";
import { buttonClass } from "@/lib/ui/variants";
import { CommandPalette, openCommandPalette } from "./CommandPalette";
// 全局挂载命令面板⌘K读当前 pathname 注入项目上下文。
// 放在 RootLayout 内,对所有页面生效。
@@ -10,3 +12,27 @@ export function CommandPaletteMount() {
const pathname = usePathname();
return <CommandPalette pathname={pathname ?? "/"} />;
}
// 命令面板的可见入口(顶栏常驻):让不知道 ⌘K 的用户也能发现搜索。
// 与 ⌘K 触发同一外部 store移动端折叠为图标。
export function CommandSearchButton() {
return (
<button
type="button"
onClick={() => openCommandPalette()}
aria-label="搜索命令或页面"
title="搜索命令或页面⌘K"
className={buttonClass({
variant: "ghost",
size: "sm",
className: "border-transparent",
})}
>
<Search className="h-4 w-4" aria-hidden="true" />
<span className="hidden sm:inline"></span>
<kbd className="hidden rounded border border-line bg-bg px-1 font-mono text-2xs text-ink-soft sm:inline">
K
</kbd>
</button>
);
}

View File

@@ -6,6 +6,7 @@ import { Flag, Plus } from "lucide-react";
import { AppShell } from "@/components/AppShell";
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 { ForeshadowView, ProjectResponse } from "@/lib/api/types";
@@ -61,15 +62,17 @@ export function ForeshadowBoard({
title="伏笔看板"
description="跟踪每条伏笔从埋设、推进到回收的状态,逾期项会单独提醒。"
actions={
// registerOpen 时隐藏头部入口,避免与展开的表单重复(全局只一个 RegisterForm
registerOpen ? undefined : (
<Button
onClick={() => setRegisterOpen(true)}
disabled={registerOpen}
variant="primary"
size="sm"
>
<Plus className="h-4 w-4" aria-hidden="true" />
</Button>
)
}
/>
{registerOpen ? (
@@ -89,11 +92,16 @@ export function ForeshadowBoard({
title="还没有伏笔"
description="登记第一条伏笔后,这里会按待推进、推进中、已回收、已逾期四种状态自动分栏。"
action={
<RegisterForm
projectId={project.id}
busy={busy}
onRegister={register}
/>
registerOpen ? undefined : (
<Button
onClick={() => setRegisterOpen(true)}
variant="primary"
size="sm"
>
<Plus className="h-4 w-4" aria-hidden="true" />
</Button>
)
}
className="mt-6"
/>
@@ -137,22 +145,18 @@ function ForeshadowBoardSummary({
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"
>
<Card key={status} data-status={status} className="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>
{" · "}
<p className="mt-1 text-2xs text-ink-soft">
{" "}
{total > 0 ? `${Math.round((counts[status] / total) * 100)}%` : "0%"}
</p>
</div>
</Card>
))}
</section>
);

View File

@@ -26,8 +26,9 @@ export function KanbanColumn({
const overdue = status === "OVERDUE";
return (
<section
data-status={status}
className="flex min-w-0 flex-col rounded border border-line bg-bg"
aria-label={`${status} 泳道`}
aria-label={`${LANE_LABELS[status]} 泳道`}
>
<header
className={`flex items-center justify-between rounded-t border-b px-3 py-2 ${
@@ -44,7 +45,6 @@ export function KanbanColumn({
{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>

View File

@@ -1,10 +1,13 @@
"use client";
import { useState, type ReactNode } from "react";
import { useState } from "react";
import { Plus } from "lucide-react";
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 { cardClass } from "@/lib/ui/variants";
import type { RegisterInput } from "@/lib/foreshadow/board";
interface RegisterFormProps {
@@ -70,8 +73,9 @@ export function RegisterForm({
);
}
const canSubmit =
form.code.trim().length > 0 && form.title.trim().length > 0 && !busy;
const missingRequired =
form.code.trim().length === 0 || form.title.trim().length === 0;
const canSubmit = !missingRequired && !busy;
return (
<form
@@ -79,62 +83,56 @@ export function RegisterForm({
e.preventDefault();
void submit();
}}
className="rounded border border-line bg-panel p-4"
className={cardClass("p-4")}
>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
<FormSlot label="代号 *" id="f-code">
<Field label="代号" required>
<TextInput
id="f-code"
value={form.code}
onChange={(e) => set("code", e.target.value)}
/>
</FormSlot>
<FormSlot label="标题 *" id="f-title" span2>
</Field>
<Field label="标题" required className="col-span-2">
<TextInput
id="f-title"
value={form.title}
onChange={(e) => set("title", e.target.value)}
/>
</FormSlot>
<FormSlot label="埋设章" id="f-planted">
</Field>
<Field label="埋设章">
<TextInput
id="f-planted"
inputMode="numeric"
value={form.plantedAt}
onChange={(e) => set("plantedAt", e.target.value)}
/>
</FormSlot>
<FormSlot label="回收窗口起" id="f-from">
</Field>
<Field label="回收窗口起">
<TextInput
id="f-from"
inputMode="numeric"
value={form.expectedCloseFrom}
onChange={(e) => set("expectedCloseFrom", e.target.value)}
/>
</FormSlot>
<FormSlot label="回收窗口止" id="f-to">
</Field>
<Field label="回收窗口止">
<TextInput
id="f-to"
inputMode="numeric"
value={form.expectedCloseTo}
onChange={(e) => set("expectedCloseTo", e.target.value)}
/>
</FormSlot>
<FormSlot label="重要度" id="f-imp">
</Field>
<Field label="重要度">
<TextInput
id="f-imp"
value={form.importance}
onChange={(e) => set("importance", e.target.value)}
placeholder="主线/支线"
/>
</FormSlot>
<FormSlot label="线索内容" id="f-content" span3>
<TextInput
id="f-content"
</Field>
<Field label="线索内容" className="col-span-2 sm:col-span-3">
<TextArea
rows={2}
value={form.content}
onChange={(e) => set("content", e.target.value)}
/>
</FormSlot>
</Field>
</div>
<div className="mt-3 flex items-center gap-2">
<Button
@@ -145,38 +143,17 @@ export function RegisterForm({
>
{busy ? "登记中…" : "登记"}
</Button>
<Button
onClick={() => setOpen(false)}
variant="secondary"
size="sm"
>
<Button onClick={() => setOpen(false)} variant="secondary" size="sm">
</Button>
{missingRequired && !busy ? (
<span className="text-xs text-ink-soft"></span>
) : null}
</div>
</form>
);
}
interface FormSlotProps {
label: string;
id: string;
span2?: boolean;
span3?: boolean;
children: ReactNode;
}
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}>
<label htmlFor={id} className="block text-xs text-ink-soft">
{label}
</label>
<div className="mt-0.5">{children}</div>
</div>
);
}
function toInt(raw: string): number | null {
const trimmed = raw.trim();
if (trimmed.length === 0) return null;

View File

@@ -3,6 +3,7 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { Database, Sparkles } from "lucide-react";
import { ThinkingIndicator } from "@/components/ThinkingIndicator";
import { Button } from "@/components/ui/Button";
import { Field } from "@/components/ui/Field";
import { SectionHeader } from "@/components/ui/SectionHeader";
@@ -10,6 +11,7 @@ import { StatusNote } from "@/components/ui/StatusNote";
import { TextInput } from "@/components/ui/TextInput";
import type { CharacterCardView } from "@/lib/api/types";
import {
clampCharacterCount,
MAX_CHARACTER_COUNT,
MIN_CHARACTER_COUNT,
updateCard,
@@ -95,13 +97,19 @@ export function CharacterGenerator({
/>
</Field>
<div className="flex flex-wrap items-end gap-3">
<Field label="数量" className="w-24">
<Field
label="数量"
className="w-28"
help={`${MIN_CHARACTER_COUNT}${MAX_CHARACTER_COUNT}`}
>
<TextInput
type="number"
min={MIN_CHARACTER_COUNT}
max={MAX_CHARACTER_COUNT}
value={count}
onChange={(e) => setCount(Number(e.target.value))}
onChange={(e) =>
setCount(clampCharacterCount(Number(e.target.value)))
}
/>
</Field>
<Field label="定位(可选)" className="min-w-[12rem] flex-1">
@@ -113,7 +121,12 @@ export function CharacterGenerator({
</Field>
<Button
onClick={() => void onGenerate()}
disabled={generating || brief.trim().length === 0}
disabled={
generating ||
brief.trim().length === 0 ||
count < MIN_CHARACTER_COUNT ||
count > MAX_CHARACTER_COUNT
}
variant="primary"
>
<Sparkles className="h-4 w-4" aria-hidden="true" />
@@ -122,6 +135,12 @@ export function CharacterGenerator({
</div>
</div>
{generating ? (
<div className="rounded border border-line bg-panel/50 p-4 text-cinnabar">
<ThinkingIndicator label="生成中" />
</div>
) : null}
{gen.genStatus === "preview" && drafts.length > 0 ? (
<div className="flex flex-col gap-3">
<p className="text-xs text-ink-soft">

View File

@@ -1,5 +1,7 @@
"use client";
import { ThinkingIndicator } from "@/components/ThinkingIndicator";
import { Button } from "@/components/ui/Button";
import type { IngestConflicts } from "@/lib/generation/cards";
interface ConflictAdjudicationProps {
@@ -56,23 +58,28 @@ export function ConflictAdjudication({
))}
</ul>
<div className="flex gap-2">
<button
<Button
type="button"
variant="danger"
onClick={onAcknowledge}
disabled={busy}
className="rounded bg-conflict px-3 py-1.5 text-sm text-white disabled:opacity-50"
>
</button>
<button
</Button>
<Button
type="button"
variant="secondary"
onClick={onCancel}
disabled={busy}
className="rounded border border-line px-3 py-1.5 text-sm text-ink disabled:opacity-50"
>
</button>
</Button>
</div>
{busy ? (
<div className="mt-3 text-cinnabar">
<ThinkingIndicator label="入库中" />
</div>
) : null}
</div>
);
}

View File

@@ -1,14 +1,20 @@
"use client";
import { useCallback, useState } from "react";
import { Sparkles } from "lucide-react";
import { Copy, Globe2, RotateCcw, Sparkles } from "lucide-react";
import { useToast } from "@/components/Toast";
import { ThinkingIndicator } from "@/components/ThinkingIndicator";
import { Button } from "@/components/ui/Button";
import { EmptyState } from "@/components/ui/EmptyState";
import { Field } from "@/components/ui/Field";
import { SectionHeader } from "@/components/ui/SectionHeader";
import { StatusNote } from "@/components/ui/StatusNote";
import { TextArea } from "@/components/ui/TextArea";
import { worldEntityRules } from "@/lib/generation/cards";
import type { WorldEntityCardView } from "@/lib/api/types";
import { useWorldGen } from "@/lib/generation/useWorldGen";
import { cardClass } from "@/lib/ui/variants";
interface WorldGeneratorProps {
projectId: string;
@@ -16,14 +22,34 @@ interface WorldGeneratorProps {
// 世界观设计器UX §6.5):一句话需求 → 预览实体卡type/name/rules
// 本期世界观入库端点未建(仅预览);预览结果供作者参考/复制。
// 复制文案:名称 + 硬规则正文(供作者粘贴到「规则」或「设定库」)。
function entityClipboardText(entity: WorldEntityCardView): string {
const rules = worldEntityRules(entity);
const body = rules.length > 0 ? rules.join("\n") : "(无显式硬规则)";
return `${entity.name}${entity.type}\n${body}`;
}
export function WorldGenerator({ projectId }: WorldGeneratorProps) {
const gen = useWorldGen();
const toast = useToast();
const [brief, setBrief] = useState("");
const onGenerate = useCallback(async () => {
await gen.generate(projectId, brief);
}, [gen, projectId, brief]);
const onCopy = useCallback(
async (entity: WorldEntityCardView): Promise<void> => {
try {
await navigator.clipboard.writeText(entityClipboardText(entity));
toast("已复制到剪贴板。", "success");
} catch {
toast("复制失败,请手动选择文本。", "error");
}
},
[toast],
);
const generating = gen.status === "generating";
return (
@@ -48,12 +74,59 @@ export function WorldGenerator({ projectId }: WorldGeneratorProps) {
</Button>
</div>
{generating ? (
<div className="rounded border border-line bg-panel/50 p-4 text-cinnabar">
<ThinkingIndicator label="生成中" />
</div>
) : null}
{gen.status === "error" ? (
<StatusNote variant="danger">
<span className="flex flex-wrap items-center gap-3">
<span></span>
<Button
type="button"
variant="secondary"
size="sm"
onClick={() => void onGenerate()}
>
<RotateCcw className="h-4 w-4" aria-hidden="true" />
</Button>
</span>
</StatusNote>
) : null}
{gen.status === "done" && gen.entities.length === 0 ? (
<EmptyState
icon={Globe2}
title="本次未生成可用实体"
description="可换一个更具体的设定方向,或补充世界的硬规则与边界后再试。"
action={
<Button
type="button"
variant="primary"
size="sm"
onClick={() => void onGenerate()}
disabled={brief.trim().length === 0}
>
<Sparkles className="h-4 w-4" aria-hidden="true" />
</Button>
}
/>
) : null}
{gen.status === "done" && gen.entities.length > 0 ? (
<div className="flex flex-col gap-3">
<StatusNote variant="info">
</StatusNote>
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
{gen.entities.map((entity, i) => (
<article
key={`${entity.name}-${i}`}
className="rounded border border-line bg-panel p-3 text-sm"
className={cardClass("p-3 text-sm")}
>
<header className="mb-2 flex items-center gap-2">
<span className="font-serif text-base text-ink">
@@ -62,6 +135,17 @@ export function WorldGenerator({ projectId }: WorldGeneratorProps) {
<span className="rounded bg-bg px-2 py-0.5 text-xs text-ink-soft">
{entity.type}
</span>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => void onCopy(entity)}
className="ml-auto"
aria-label={`复制 ${entity.name}`}
>
<Copy className="h-4 w-4" aria-hidden="true" />
</Button>
</header>
{worldEntityRules(entity).length > 0 ? (
<ul className="flex list-disc flex-col gap-1 pl-5 text-ink-soft">
@@ -75,6 +159,7 @@ export function WorldGenerator({ projectId }: WorldGeneratorProps) {
</article>
))}
</div>
</div>
) : null}
</section>
);

View File

@@ -1,7 +1,7 @@
"use client";
import Link from "next/link";
import { PenLine } from "lucide-react";
import { Flag, PenLine } from "lucide-react";
import { Badge } from "@/components/ui/Badge";
import type { OutlineChapterView } from "@/lib/api/types";
@@ -16,7 +16,7 @@ interface OutlineChapterRowProps {
projectId: string;
}
// 单章大纲行UX §6.7):章号 + beats + 伏笔窗口徽标(+ 「写此章」入口。
// 单章大纲行UX §6.7):章号 + beats + 伏笔窗口徽标(lucide Flag 图标+ 「写此章」入口。
// 接近回收窗口的徽标用琥珀 + 「可回收」提示a11y图标 + 文案,不单靠色)。
export function OutlineChapterRow({ chapter, projectId }: OutlineChapterRowProps) {
const windows = chapter.foreshadow_windows ?? [];
@@ -41,6 +41,7 @@ export function OutlineChapterRow({ chapter, projectId }: OutlineChapterRowProps
variant={close ? "warning" : "accent"}
title={close ? "进入回收窗口,建议本章安排回收" : undefined}
>
<Flag className="h-4 w-4" aria-hidden="true" />
{windowBadgeLabel(w)}
{close ? " · 可回收" : ""}
</Badge>

View File

@@ -8,6 +8,7 @@ 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 { StatusNote } from "@/components/ui/StatusNote";
import { TextInput } from "@/components/ui/TextInput";
import type { OutlineChapterView, ProjectResponse } from "@/lib/api/types";
import {
@@ -31,15 +32,52 @@ export function OutlineEditor({
initialChapters,
}: OutlineEditorProps) {
const { chapters, status, error, generate } = useOutline(initialChapters);
// 生成目标卷与「正在看的卷」共用一个心智:选具体卷即把生成目标设为该卷。
const [volume, setVolume] = useState(1);
// 视图卷过滤("全部" 或某卷)——只影响展示,与生成的目标卷 `volume` 解耦。
const [viewVolume, setViewVolume] = useState<ViewVolume>("all");
// 「重新排大纲」破坏性覆盖的两段式确认(仅目标卷已有章节时出现)。
const [confirming, setConfirming] = useState(false);
const availableVolumes = useMemo(() => distinctVolumes(chapters), [chapters]);
const volumes = useMemo(
() => groupByVolume(filterByViewVolume(chapters, viewVolume)),
[chapters, viewVolume],
);
const generating = status === "generating";
// 目标卷已有章节 → 生成是破坏性替换,文案/确认都要变。
const targetHasChapters = useMemo(
() => filterByViewVolume(chapters, volume).length > 0,
[chapters, volume],
);
// 选具体卷时把生成目标对齐到所看卷,消除「看卷 A、却生成卷 B」的易混。
const onViewVolumeChange = (next: ViewVolume): void => {
setViewVolume(next);
setConfirming(false);
if (next !== "all") setVolume(next);
};
const onVolumeChange = (next: number): void => {
setVolume(Math.max(1, next || 1));
setConfirming(false);
};
const runGenerate = (): void => {
setConfirming(false);
void generate(project.id, volume);
};
// 安全卷直接生成;破坏性卷先进入两段式确认。
const onGenerateClick = (): void => {
if (targetHasChapters) {
setConfirming(true);
return;
}
runGenerate();
};
const generateLabel = generating
? "排大纲中…"
: `${targetHasChapters ? "重新排大纲" : "AI 排大纲"} · 卷 ${volume}`;
return (
<AppShell
@@ -52,26 +90,25 @@ export function OutlineEditor({
<PageHeader
title="大纲"
description="按卷组织章节节拍,写作台会把当前章节拍注入生成上下文。"
actions={
<>
/>
{/* 卷/视图/生成控件独立成全宽工具栏行(窄屏自动换行,不用脆弱的 ml-auto。 */}
<div className="mb-4 flex flex-wrap items-center gap-2">
{availableVolumes.length > 0 ? (
<label
htmlFor="view-vol"
className="ml-auto text-xs text-ink-soft"
className="flex items-center gap-1 text-xs text-ink-soft"
>
<Select
id="view-vol"
value={viewVolume === "all" ? "all" : String(viewVolume)}
onChange={(e) =>
setViewVolume(
e.target.value === "all"
? "all"
: Number(e.target.value),
onViewVolumeChange(
e.target.value === "all" ? "all" : Number(e.target.value),
)
}
controlSize="sm"
className="ml-1"
>
<option value="all"></option>
{availableVolumes.map((v) => (
@@ -82,49 +119,73 @@ export function OutlineEditor({
</Select>
</label>
) : null}
{availableVolumes.length > 0 ? (
<span className="ml-1 h-4 w-px bg-line" aria-hidden="true" />
) : null}
<label
htmlFor="vol"
className={`text-xs text-ink-soft${availableVolumes.length > 0 ? "" : " ml-auto"}`}
className="flex items-center gap-1 text-xs text-ink-soft"
>
</label>
<TextInput
id="vol"
inputMode="numeric"
value={volume}
onChange={(e) =>
setVolume(Math.max(1, Number(e.target.value) || 1))
}
onChange={(e) => onVolumeChange(Number(e.target.value))}
controlSize="sm"
className="w-16"
/>
</label>
<Button
disabled={generating}
onClick={() => void generate(project.id, volume)}
onClick={onGenerateClick}
variant="primary"
>
<Sparkles className="h-4 w-4" aria-hidden="true" />
{generating ? "排大纲中…" : "AI 排大纲"}
{generateLabel}
</Button>
</>
}
/>
</div>
{confirming ? (
<StatusNote
variant="warning"
title={`${volume} 已有章节`}
className="mb-4"
>
<p> {volume} </p>
<div className="mt-2 flex flex-wrap gap-2">
<Button onClick={runGenerate} variant="danger" size="sm">
{volume}
</Button>
<Button
onClick={() => setConfirming(false)}
variant="secondary"
size="sm"
>
</Button>
</div>
</StatusNote>
) : null}
{error ? (
<p className="mb-4 text-sm text-conflict">
{error.code}{error.message}
{error.code === "LLM_UNAVAILABLE" ? (
<StatusNote variant="danger" title="大纲生成失败" className="mb-4">
{error.text}
{error.actionHref ? (
<>
{" "}
<a
href="/settings/providers"
href={error.actionHref}
className="underline hover:text-cinnabar"
>
{error.actionLabel}
</a>
</>
) : null}
</p>
</StatusNote>
) : null}
<div className="min-h-0 flex-1 overflow-auto">
@@ -144,12 +205,12 @@ export function OutlineEditor({
action={
<Button
disabled={generating}
onClick={() => void generate(project.id, volume)}
onClick={onGenerateClick}
variant="primary"
size="sm"
>
<Sparkles className="h-4 w-4" aria-hidden="true" />
{generating ? "排大纲中…" : "AI 排大纲"}
{generateLabel}
</Button>
}
/>

View File

@@ -2,11 +2,12 @@
import Link from "next/link";
import { Clock3, LayoutGrid, List, Plus, Search } from "lucide-react";
import { useEffect, useMemo, useState, type ReactNode } from "react";
import { useEffect, useMemo, useState } from "react";
import { ProjectCard } from "@/components/ProjectCard";
import { Button } from "@/components/ui/Button";
import { EmptyState } from "@/components/ui/EmptyState";
import { SegmentedControl } from "@/components/ui/SegmentedControl";
import { Select } from "@/components/ui/Select";
import { TextInput } from "@/components/ui/TextInput";
import type { ProjectResponse } from "@/lib/api/types";
@@ -16,7 +17,7 @@ import {
type ProjectSort,
type ProjectViewMode,
} from "@/lib/projects/projects";
import { buttonClass, cn } from "@/lib/ui/variants";
import { buttonClass, cardClass } from "@/lib/ui/variants";
interface ProjectLibraryProps {
projects: ProjectResponse[];
@@ -47,7 +48,11 @@ export function ProjectLibrary({ projects }: ProjectLibraryProps) {
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">
<div
className={cardClass(
"grid gap-3 p-3 md:grid-cols-[1fr_auto_auto_auto] md:items-center",
)}
>
<label className="relative block">
<span className="sr-only"></span>
<Search
@@ -80,29 +85,39 @@ export function ProjectLibrary({ projects }: ProjectLibraryProps) {
<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")}
>
<SegmentedControl
ariaLabel="视图密度"
className="justify-self-start md:justify-self-end"
value={viewMode}
onChange={setView}
options={[
{
value: "cards",
label: (
<>
<LayoutGrid className="h-4 w-4" aria-hidden="true" />
</ViewButton>
<ViewButton
label="紧凑"
active={viewMode === "compact"}
onClick={() => setView("compact")}
>
<span className="sr-only"></span>
</>
),
},
{
value: "compact",
label: (
<>
<List className="h-4 w-4" aria-hidden="true" />
</ViewButton>
</div>
<span className="sr-only"></span>
</>
),
},
]}
/>
</div>
<p className="text-xs text-ink-soft">
<p
className="text-xs text-ink-soft"
role="status"
aria-live="polite"
>
<Clock3 className="mr-1 inline h-3.5 w-3.5" aria-hidden="true" />
{visibleProjects.length} / {projects.length}
</p>
@@ -155,7 +170,10 @@ export function ProjectLibrary({ projects }: ProjectLibraryProps) {
<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"
className={buttonClass({
variant: "outline",
className: "w-full border-dashed px-4 py-3",
})}
>
<Plus className="h-4 w-4" aria-hidden="true" />
@@ -167,40 +185,14 @@ export function ProjectLibrary({ projects }: ProjectLibraryProps) {
);
}
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"
className={buttonClass({
variant: "outline",
className: "group h-full min-h-[176px] flex-col border-dashed text-center",
})}
>
<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" />

View File

@@ -1,13 +1,14 @@
"use client";
import Link from "next/link";
import { ArrowRight, CheckCircle2, ListTree } from "lucide-react";
import { ArrowRight, CheckCircle2, ClipboardCheck, 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 { reviewChapterHref } from "@/lib/review/chapterNav";
import { buttonClass } from "@/lib/ui/variants";
interface AcceptPanelProps {
@@ -85,6 +86,13 @@ export function AcceptPanel({
<ArrowRight className="h-4 w-4" aria-hidden="true" />
{chapterNo + 1}
</Link>
<Link
href={reviewChapterHref(projectId, chapterNo + 1)}
className={buttonClass({ variant: "secondary" })}
>
<ClipboardCheck className="h-4 w-4" aria-hidden="true" />
{chapterNo + 1}
</Link>
<Link
href={`/projects/${projectId}/outline`}
className={buttonClass({ variant: "secondary" })}
@@ -134,7 +142,7 @@ export function AcceptPanel({
</li>
))}
</ul>
<p className="mt-2 text-[11px] text-ink-soft/70">
<p className="mt-2 text-2xs text-ink-soft">
</p>
</div>

View File

@@ -1,8 +1,12 @@
"use client";
import { useRef, type ReactNode, type RefObject } from "react";
import Link from "next/link";
import { PenLine } from "lucide-react";
import { EmptyState } from "@/components/ui/EmptyState";
import { locateInDraft } from "@/lib/review/locate";
import { buttonClass, proseBody } from "@/lib/ui/variants";
import type { ReviewConflict } from "@/lib/review/sse";
interface AnnotatedTextProps {
@@ -12,14 +16,16 @@ interface AnnotatedTextProps {
// 当前聚焦的冲突下标(来自报告卡「跳转」/锚点),用于锚点高亮联动。
focusedIndex: number | null;
onAnchorClick: (index: number) => void;
// 空待审正文时,引导回写作页起草本章。
projectId: string;
chapterNo: number;
// 转发给可编辑 textarea供父组件 setSelectionRange 定位/高亮问题区域。
editorRef?: RefObject<HTMLTextAreaElement | null>;
}
// 高亮叠层编辑器:背景层渲染带 <mark> 高亮的正文,前景透明 textarea 负责编辑。
// 两层必须共享同一盒模型/排版常量,否则高亮与文字像素错位。
const TYPO =
"box-border w-full font-serif text-[17px] leading-[1.9] tracking-normal whitespace-pre-wrap break-words";
// 两层必须共享同一盒模型/排版常量,否则高亮与文字像素错位;正文排版与写作页 Editor 一致proseBody
const TYPO = `box-border w-full whitespace-pre-wrap break-words ${proseBody}`;
// 把终稿按已定位的冲突区间切成 [纯文本 | <mark> | …]<mark> 带 id 供父组件滚动定位。
// 区间按 start 排序,重叠区间保留先到者(跳过后者),避免标签嵌套。
@@ -61,6 +67,8 @@ export function AnnotatedText({
conflicts,
focusedIndex,
onAnchorClick,
projectId,
chapterNo,
editorRef,
}: AnnotatedTextProps) {
const backdropRef = useRef<HTMLDivElement>(null);
@@ -69,6 +77,26 @@ export function AnnotatedText({
const anchorable = conflicts
.map((c, i) => ({ c, i }))
.filter(({ c }) => locateInDraft(value, c) !== null);
// 空待审正文:不渲染高亮叠层,直接引导回写作页起草本章(避免对着空编辑器发懵)。
if (!value) {
return (
<EmptyState
icon={PenLine}
title="本章还没有待审正文"
description="先到写作页起草本章,写完回到这里审稿、裁决与验收。"
action={
<Link
href={`/projects/${projectId}/write?chapter=${chapterNo}`}
className={buttonClass({ variant: "primary" })}
>
<PenLine className="h-4 w-4" aria-hidden="true" />
</Link>
}
/>
);
}
return (
<div className="flex flex-col gap-3">
{anchorable.length > 0 ? (
@@ -104,13 +132,7 @@ export function AnnotatedText({
aria-hidden="true"
className={`${TYPO} pointer-events-none relative select-none text-ink`}
>
{value ? (
segments
) : (
<span className="text-ink-soft/60">
</span>
)}
{segments}
{"\n"}
</div>
{/* 前景编辑层:文字透明(由背景层显示),仅保留光标;无内部滚动(页面滚动)。 */}

View File

@@ -12,7 +12,7 @@ interface BeatMapProps {
export function BeatMap({ beatMap }: BeatMapProps) {
const cells = toBeatCells(beatMap);
if (cells.length === 0) {
return <span className="text-xs text-ink-soft/60"></span>;
return <span className="text-xs text-ink-soft"></span>;
}
const spark = toSparkline(beatMap);
return (

View File

@@ -22,6 +22,9 @@ interface ForeshadowSuggestionsProps {
incomplete: boolean;
projectId: string;
chapterNo: number;
// 把「仍待处理(未登记/未回收)」的建议数上抛给报告页:
// 喂 AcceptPanel 的验收预览与伏笔分区徽标,随登记/回收操作实时递减。
onRemainingChange?: (count: number) => void;
}
interface RegisterFormState {
@@ -39,6 +42,7 @@ export function ForeshadowSuggestions({
incomplete,
projectId,
chapterNo,
onRemainingChange,
}: ForeshadowSuggestionsProps) {
const fs = useForeshadow([]);
const [openIdx, setOpenIdx] = useState<number | null>(null);
@@ -145,20 +149,24 @@ export function ForeshadowSuggestions({
.map((s, i) => ({ s, i }))
.filter(({ s, i }) => !isAlreadySaved(s, i));
// 上抛「仍待处理」建议数(已登记/已回收/历史已存的都已从 visible 过滤),
// 让报告页的验收预览与伏笔徽标随作者操作递减(父传入稳定 setter
const remaining = visible.length;
useEffect(() => {
onRemainingChange?.(remaining);
}, [remaining, onRemainingChange]);
return (
<section className="mb-4">
<h2 className="text-xs font-semibold uppercase tracking-wide text-ink-soft">
(foreshadow-analyst)
</h2>
<section>
{incomplete ? (
<Badge variant="warning" className="mt-1">
<Badge variant="warning">
<CircleDashed className="h-3 w-3" aria-hidden="true" />
</Badge>
) : visible.length === 0 ? (
<p className="mt-1 text-xs text-ink-soft"></p>
<p className="text-xs text-ink-soft"></p>
) : (
<ul className="mt-2 space-y-2">
<ul className="space-y-2">
{visible.map(({ s, i }) => {
const code = s.code;
const isResolved = s.kind === "resolved";

View File

@@ -14,19 +14,16 @@ interface PacePanelProps {
// 节奏报告区UX §6.4 ④):节拍图 ▁▃▅ + 注水段列表 + 章末钩子徽标。只读建议。
export function PacePanel({ pace, incomplete }: PacePanelProps) {
return (
<section className="mb-4">
<h2 className="text-xs font-semibold uppercase tracking-wide text-ink-soft">
(pace-checker)
</h2>
<section>
{incomplete ? (
<Badge variant="warning" className="mt-1">
<Badge variant="warning">
<CircleDashed className="h-3 w-3" aria-hidden="true" />
</Badge>
) : pace === null ? (
<p className="mt-1 text-xs text-ink-soft"></p>
<p className="text-xs text-ink-soft"></p>
) : (
<div className="mt-2 space-y-2 text-xs">
<div className="space-y-2 text-xs">
<div>
<p className="mb-1 text-ink-soft"></p>
<BeatMap beatMap={pace.beat_map} />

View File

@@ -2,9 +2,13 @@
import { useEffect, useMemo, useRef, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import {
AlertTriangle,
CheckCircle2,
ChevronDown,
ClipboardCheck,
LocateFixed,
RotateCcw,
Square,
} from "lucide-react";
@@ -12,6 +16,9 @@ import {
import { AppShell } from "@/components/AppShell";
import { Badge } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button";
import { Select } from "@/components/ui/Select";
import { reviewChapterHref, reviewChapterOptions } from "@/lib/review/chapterNav";
import type { ChapterEntry } from "@/lib/workbench/chapter";
import type { ProjectResponse, ReviewHistoryItem } from "@/lib/api/types";
import { friendlyError } from "@/lib/errors/messages";
import {
@@ -62,6 +69,8 @@ interface ReviewReportProps {
chapterNo: number;
initialReview: ReviewHistoryItem | undefined;
initialDraft: string;
// 章节导航目录(大纲章节):用于报告头部的「切换审稿章节」选择器。
chapters?: ChapterEntry[];
}
// 审稿报告页主体UX §6.4 / §8.3 / §9
@@ -72,7 +81,9 @@ export function ReviewReport({
chapterNo,
initialReview,
initialDraft,
chapters,
}: ReviewReportProps) {
const router = useRouter();
const review = useReviewStream();
const accept = useAccept();
const conflictFix = useRefine(); // 采纳无补丁冲突时,临时调 AI 重写定位段
@@ -93,6 +104,11 @@ export function ReviewReport({
const editorRef = useRef<HTMLTextAreaElement>(null);
// 回炉中漂移段在终稿里定位到的原文null=未打开 RefineView。内容锚定位非位置 idx。
const [refineText, setRefineText] = useState<string | null>(null);
// 伏笔「仍待处理」数:由 ForeshadowSuggestions 上抛(已登记/已回收的已扣除)。
// null=尚未上报,回退到原始建议数,避免首帧闪「无建议」。
const [foreshadowRemaining, setForeshadowRemaining] = useState<number | null>(
null,
);
const seededRef = useRef(false);
// 进页一次性把历史留痕(冲突 + 伏笔建议 + 节奏)种入流状态(无需重审即可裁决/查看)。
@@ -135,6 +151,8 @@ export function ReviewReport({
const foreshadow = review.state.foreshadow;
const pace = review.state.pace;
const style = review.state.style;
// 伏笔徽标/验收预览统一用「仍待处理数」(随登记/回收递减);未上报前回退原始建议数。
const foreshadowOpen = foreshadowRemaining ?? foreshadow.length;
const sectionStatus = (name: string): string | undefined =>
review.state.sections.find((s) => s.name === name)?.status;
@@ -381,6 +399,7 @@ export function ReviewReport({
const unresolved = unresolvedIndices(drafts).length;
const reviewing = review.isReviewing;
const hasReport =
initialReview !== undefined ||
review.state.phase === "done" ||
review.state.sections.length > 0 ||
seededConflicts.length > 0 ||
@@ -402,6 +421,23 @@ export function ReviewReport({
<h1 className="min-w-0 flex-1 truncate font-serif text-lg text-ink">
{chapterNo} 稿
</h1>
<Select
controlSize="sm"
aria-label="切换审稿章节"
className="hidden max-w-[12rem] shrink-0 sm:block"
value={chapterNo}
onChange={(e) =>
router.push(
reviewChapterHref(project.id, Number(e.target.value)),
)
}
>
{reviewChapterOptions(chapters ?? [], chapterNo).map((opt) => (
<option key={opt.no} value={opt.no}>
{opt.label}
</option>
))}
</Select>
<span className="hidden shrink-0 font-mono text-xs text-ink-soft sm:inline">
{conflictCount}
</span>
@@ -443,6 +479,8 @@ export function ReviewReport({
conflicts={conflicts}
focusedIndex={focusedIndex}
onAnchorClick={focusRegion}
projectId={project.id}
chapterNo={chapterNo}
editorRef={editorRef}
/>
</div>
@@ -464,7 +502,6 @@ export function ReviewReport({
<div className="space-y-3">
<ReviewSectionPanel
title="一致性"
subtitle="continuity"
statusLabel={
reviewing
? "审稿中"
@@ -511,7 +548,7 @@ export function ReviewReport({
<details
key={group.type}
open={unresolvedInGroup > 0}
className="rounded border border-line/70 bg-bg/35 p-2"
className="group 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">
@@ -524,11 +561,17 @@ export function ReviewReport({
{group.items.length}
</Badge>
</span>
<span className="font-mono text-[11px] text-ink-soft">
<span className="flex items-center gap-1.5">
<span className="font-mono text-2xs text-ink-soft">
{unresolvedInGroup > 0
? `${unresolvedInGroup} 未裁决`
: "已处理"}
</span>
<ChevronDown
className="h-4 w-4 text-ink-soft transition-transform group-open:rotate-180"
aria-hidden="true"
/>
</span>
</summary>
<ul className="mt-3 space-y-3">
{group.items.map(({ conflict: c, index: i }) => (
@@ -557,18 +600,17 @@ export function ReviewReport({
<ReviewSectionPanel
title="伏笔"
subtitle="foreshadow-analyst"
statusLabel={
sectionStatus("foreshadow") === "incomplete"
? "未完成"
: foreshadow.length > 0
? `${foreshadow.length} 建议`
: foreshadowOpen > 0
? `${foreshadowOpen} 待登记`
: "无建议"
}
statusVariant={
sectionStatus("foreshadow") === "incomplete"
? "warning"
: foreshadow.length > 0
: foreshadowOpen > 0
? "accent"
: "success"
}
@@ -579,11 +621,11 @@ export function ReviewReport({
incomplete={sectionStatus("foreshadow") === "incomplete"}
projectId={project.id}
chapterNo={chapterNo}
onRemainingChange={setForeshadowRemaining}
/>
</ReviewSectionPanel>
<ReviewSectionPanel
title="节奏"
subtitle="pace-checker"
statusLabel={
sectionStatus("pace") === "incomplete"
? "未完成"
@@ -607,7 +649,6 @@ export function ReviewReport({
</ReviewSectionPanel>
<ReviewSectionPanel
title="文风"
subtitle="style"
statusLabel={
sectionStatus("style") === "incomplete"
? "未完成"
@@ -641,20 +682,52 @@ export function ReviewReport({
) : null}
</div>
<section className="mt-4">
<section id="accept-panel" className="mt-4 scroll-mt-4">
<AcceptPanel
projectId={project.id}
chapterNo={chapterNo}
reviewed={initialReview !== undefined || review.state.phase === "done"}
conflictCount={conflictCount}
unresolvedCount={resolved ? 0 : unresolved}
foreshadowCount={foreshadow.length}
foreshadowCount={foreshadowOpen}
accepting={accept.status === "accepting"}
result={accept.result}
onAccept={() => void onAccept()}
/>
</section>
</aside>
{/* 移动端(<lg 单列)快捷条:编辑器在上、审稿/验收在下,滚一整屏才够得着——
固定底栏直接「跳到未裁决」或「去验收」,免去长滚。桌面端隐藏(右栏常驻可见)。 */}
{hasReport && !accept.result ? (
<div className="fixed inset-x-0 bottom-0 z-30 flex items-center gap-2 border-t border-line bg-panel/95 px-4 py-2 backdrop-blur supports-[backdrop-filter]:bg-panel/80 lg:hidden">
<span className="flex-1 truncate text-xs text-ink-soft">
{unresolved > 0 ? `${unresolved} 项冲突待裁决` : "可验收本章"}
</span>
{unresolved > 0 ? (
<Button
onClick={jumpToNextUnresolved}
variant="secondary"
size="sm"
>
<LocateFixed className="h-4 w-4" aria-hidden="true" />
</Button>
) : null}
<Button
onClick={() =>
document
.getElementById("accept-panel")
?.scrollIntoView({ behavior: "smooth", block: "center" })
}
variant="primary"
size="sm"
>
<ClipboardCheck className="h-4 w-4" aria-hidden="true" />
</Button>
</div>
) : null}
</div>
</AppShell>
);

View File

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

View File

@@ -4,6 +4,7 @@ import { Plus, Trash2 } from "lucide-react";
import { useMemo, useState } from "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 { Field } from "@/components/ui/Field";
@@ -18,6 +19,7 @@ import {
type RuleLevel,
} from "@/lib/rules/rules";
import { useRules } from "@/lib/rules/useRules";
import { cardClass } from "@/lib/ui/variants";
interface RulesPageProps {
project: ProjectResponse;
@@ -26,7 +28,7 @@ interface RulesPageProps {
// 规则页UX §7四级规则列表 + 新增(乐观 + 回滚)。
export function RulesPage({ project, initialRules }: RulesPageProps) {
const { items, busy, add, remove } = useRules(initialRules);
const { items, adding, deletingId, add, remove } = useRules(initialRules);
const [level, setLevel] = useState<RuleLevel>("project");
const [content, setContent] = useState("");
const groups = useMemo(() => groupByLevel(items), [items]);
@@ -80,61 +82,56 @@ export function RulesPage({ project, initialRules }: RulesPageProps) {
</Field>
<Button
type="submit"
disabled={busy || content.trim().length === 0}
disabled={adding || content.trim().length === 0}
className="mt-3"
variant="primary"
>
<Plus className="h-4 w-4" aria-hidden="true" />
{busy ? "保存中…" : "新增规则"}
{adding ? "保存中…" : "新增规则"}
</Button>
</form>
{items.length === 0 ? (
<EmptyState
icon={Plus}
title="还没有任何规则"
description="从上方表单添加第一条,写作与审稿都会共同引用这些规则。"
/>
) : (
<div className="flex flex-col gap-4">
{RULE_LEVELS.map((lv) => (
<section key={lv}>
<h2 className="mb-2 font-serif text-sm text-ink">
<h2 className="mb-2 flex items-center gap-2 font-serif text-sm text-ink">
{RULE_LEVEL_LABELS[lv]}
<span className="ml-2 text-xs text-ink-soft">
{groups[lv].length}
</span>
<Badge variant="neutral">{groups[lv].length}</Badge>
</h2>
{groups[lv].length === 0 ? (
<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"
/>
<p className="px-1 text-xs text-ink-soft">
{RULE_LEVEL_LABELS[lv]}
</p>
) : (
<ul className="flex flex-col gap-2">
{groups[lv].map((rule) => (
<li
key={rule.id}
className="flex items-start justify-between gap-3 rounded border border-line bg-panel px-3 py-2 text-sm text-ink"
className={cardClass(
"flex items-start justify-between gap-3 p-3 text-sm text-ink",
)}
>
<span className="min-w-0 flex-1 break-words">
{rule.content}
</span>
<button
type="button"
disabled={busy}
<Button
variant="danger"
size="sm"
disabled={deletingId === rule.id}
onClick={() => void remove(project.id, rule.id)}
aria-label="删除规则"
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"
className="shrink-0"
>
<Trash2 className="h-3 w-3" aria-hidden="true" />
</button>
{deletingId === rule.id ? "删除中…" : "删除"}
</Button>
</li>
))}
</ul>
@@ -142,6 +139,7 @@ export function RulesPage({ project, initialRules }: RulesPageProps) {
</section>
))}
</div>
)}
</div>
</AppShell>
);

View File

@@ -7,6 +7,7 @@ import {
Route,
Save,
XCircle,
type LucideIcon,
} from "lucide-react";
import { useState, type ReactNode } from "react";
@@ -21,6 +22,7 @@ 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 { cardClass } from "@/lib/ui/variants";
import {
API_KEY_PROVIDERS,
KNOWN_PROVIDERS,
@@ -48,12 +50,35 @@ interface TestResult {
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" },
// 分组的单一标签源:桌面 aside 与移动 SegmentedControl 共用,消除「档位路由 / 路由」漂移。
const SETTINGS_SECTIONS: Array<{
value: SettingsSection;
label: string;
icon: LucideIcon;
}> = [
{ value: "routing", label: "档位路由", icon: Route },
{ value: "oauth", label: "OAuth", icon: PlugZap },
{ value: "keys", label: "API Key", icon: KeyRound },
];
const SECTION_OPTIONS = SETTINGS_SECTIONS.map(({ value, label }) => ({
value,
label,
}));
// 档位路由「模型」输入框的候选 model iddatalist 建议,仍可自由输入)。按 provider 分组。
const MODEL_SUGGESTIONS: Record<string, readonly string[]> = {
anthropic: ["claude-opus-4", "claude-sonnet-4", "claude-3-5-haiku"],
deepseek: ["deepseek-chat", "deepseek-reasoner"],
kimi: ["moonshot-v1-128k", "moonshot-v1-32k", "kimi-k2"],
openai: ["gpt-4o", "gpt-4o-mini", "o3-mini"],
qwen: ["qwen-max", "qwen-plus", "qwen-turbo"],
glm: ["glm-4-plus", "glm-4-air", "glm-4-flash"],
gemini: ["gemini-2.0-flash", "gemini-1.5-pro"],
"kimi-code-key": ["kimi-for-coding"],
"kimi-code": ["kimi-for-coding"],
};
// 设置页主体UX §6.10):档位路由(可编辑)+ API-key 凭据行 + Kimi Code OAuth 连接区。
export function ProvidersSettings({
initial,
@@ -75,6 +100,16 @@ export function ProvidersSettings({
const maskedFor = (id: string): string | null =>
providers.find((p) => p.provider === id)?.masked_key ?? null;
const sectionDetail = (value: SettingsSection): string => {
if (value === "routing") {
return `${routing.filter((r) => r.provider && r.model).length}/3 已配置`;
}
if (value === "oauth") {
return kimiOauth.connected ? "Kimi 已连接" : "未连接";
}
return `${providers.length} 个凭据`;
};
const updateRouting = (tier: string, providerId: string): void => {
setRouting((prev) =>
prev.map((d) =>
@@ -91,10 +126,10 @@ export function ProvidersSettings({
const saveRouting = async (): Promise<void> => {
setSavingRouting(true);
try {
const { data, error } = await api.PUT("/settings/providers", {
body: { tier_routing: draftsToRoutingInput(routing) },
});
setSavingRouting(false);
if (error || !data) {
toast("保存档位路由失败,请重试", "error");
return;
@@ -102,6 +137,11 @@ export function ProvidersSettings({
setProviders(data.providers ?? []);
setRouting(toRoutingDrafts(data.tier_routing ?? []));
toast("档位路由已保存", "success");
} catch {
toast("保存档位路由失败,请检查后端连接", "error");
} finally {
setSavingRouting(false);
}
};
const saveCredential = async (providerId: string): Promise<void> => {
@@ -111,10 +151,10 @@ export function ProvidersSettings({
return;
}
setSavingId(providerId);
try {
const { data, error } = await api.PUT("/settings/providers", {
body: { credentials: [{ provider: providerId, api_key: apiKey }] },
});
setSavingId(null);
if (error || !data) {
toast("保存凭据失败,请重试", "error");
return;
@@ -122,14 +162,19 @@ export function ProvidersSettings({
setProviders(data.providers ?? []);
setDrafts((prev) => ({ ...prev, [providerId]: "" }));
toast("凭据已保存", "success");
} catch {
toast("保存凭据失败,请检查后端连接", "error");
} finally {
setSavingId(null);
}
};
const testConnection = async (providerId: string): Promise<void> => {
setTestingId(providerId);
try {
const { data, error } = await api.POST("/settings/providers/test", {
body: { provider: providerId },
});
setTestingId(null);
if (error || !data) {
toast("测试连接失败", "error");
return;
@@ -138,37 +183,31 @@ export function ProvidersSettings({
...prev,
[providerId]: { ok: data.ok, capabilities: data.capabilities },
}));
toast(data.ok ? "连接成功" : "连接未通过", data.ok ? "success" : "error");
toast(
data.ok ? "连接成功" : "连接未通过",
data.ok ? "success" : "error",
);
} catch {
toast("测试连接失败,请检查后端连接", "error");
} finally {
setTestingId(null);
}
};
return (
<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"
>
<nav aria-label="设置分组" className={cardClass("sticky top-20 p-2")}>
{SETTINGS_SECTIONS.map((section) => (
<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")}
key={section.value}
active={activeSection === section.value}
icon={<section.icon className="h-4 w-4" aria-hidden="true" />}
label={section.label}
detail={sectionDetail(section.value)}
onClick={() => setActiveSection(section.value)}
/>
))}
</nav>
</aside>
@@ -220,6 +259,7 @@ export function ProvidersSettings({
<label className="sr-only" htmlFor={`route-model-${row.tier}`}>
{TIER_LABELS[row.tier] ?? row.tier}
</label>
<div className="min-w-0">
<TextInput
id={`route-model-${row.tier}`}
value={row.model}
@@ -227,8 +267,15 @@ export function ProvidersSettings({
updateRoutingModel(row.tier, e.target.value)
}
placeholder="model"
className="font-mono"
className="w-full font-mono"
list={`route-models-${row.tier}`}
/>
<datalist id={`route-models-${row.tier}`}>
{(MODEL_SUGGESTIONS[row.provider] ?? []).map((m) => (
<option key={m} value={m} />
))}
</datalist>
</div>
</li>
))}
</ul>
@@ -429,16 +476,13 @@ function CapabilityBadges({ caps }: { caps: CapabilitiesView }) {
return (
<div className="flex gap-1.5">
{badges.map((b) => (
<span
<Badge
key={b.label}
className={`rounded border px-2 py-0.5 text-[11px] ${
b.on
? "border-cinnabar/20 bg-[var(--color-cinnabar-wash)] text-cinnabar"
: "border-line bg-bg text-ink-soft/50"
}`}
variant={b.on ? "accent" : "neutral"}
className="text-2xs"
>
{b.label}
</span>
</Badge>
))}
</div>
);

View File

@@ -46,19 +46,19 @@ export function SkillsPage({ project, skills }: SkillsPageProps) {
description="按来源、档位和表权限汇总当前可用技能。"
/>
<dl className="mt-4 grid grid-cols-3 gap-2 text-center">
<div className="rounded border border-line bg-paper p-3">
<div className="rounded border border-line bg-bg 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">
<div className="rounded border border-line bg-bg 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">
<div className="rounded border border-line bg-bg p-3">
<dt className="text-xs text-ink-soft"></dt>
<dd className="mt-1 font-serif text-2xl text-ink">
{summary.readonlyCount}

View File

@@ -41,33 +41,42 @@ export function RefineView({
return (
<div
className="rounded border border-cinnabar bg-panel p-3 text-xs"
role="dialog"
role="region"
aria-label="回炉对比"
>
<div className="mb-2 flex items-center justify-between">
<h3 className="font-semibold text-ink"></h3>
<button
<Button
type="button"
variant="ghost"
size="icon"
onClick={onClose}
className="rounded border border-transparent p-1 text-ink-soft hover:border-line hover:text-cinnabar"
aria-label="关闭回炉对比"
aria-label="关闭"
>
<X className="h-4 w-4" aria-hidden="true" />
</button>
</Button>
</div>
{segment.trim().length === 0 ? (
<Badge variant="warning">
稿稿
</Badge>
) : refiner.status === "refining" ? (
) : (
<div role="status" aria-live="polite">
{refiner.status === "refining" ? (
<p className="text-info">
<ThinkingIndicator label="回炉中" />
</p>
) : refiner.status === "error" ? (
<p className="text-conflict"></p>
) : refiner.result ? (
<div className="space-y-2">
<span className="sr-only"></span>
) : null}
</div>
)}
{segment.trim().length > 0 && refiner.result ? (
<div className="mt-2 space-y-2">
<div>
<p className="mb-1 text-ink-soft"></p>
<p className="whitespace-pre-wrap rounded border border-line bg-bg p-2 text-ink-soft line-through">

View File

@@ -1,6 +1,8 @@
"use client";
import { AppShell } from "@/components/AppShell";
import { PageHeader } from "@/components/ui/PageHeader";
import { SectionHeader } from "@/components/ui/SectionHeader";
import type { ProjectResponse } from "@/lib/api/types";
import { useStyleLearn } from "@/lib/style/useStyleLearn";
import type { Fingerprint, StyleLearnMode } from "@/lib/style/style";
@@ -27,22 +29,32 @@ export function StylePage({ project, initialFingerprint }: StylePageProps) {
projectId={project.id}
activeNav="style"
>
<div className="grid h-[calc(100vh-var(--chrome,4rem))] grid-cols-1 lg:grid-cols-[28rem_1fr]">
<section className="flex flex-col overflow-auto border-r border-line bg-panel px-6 py-6">
<h1 className="mb-3 font-serif text-lg text-ink"></h1>
<div className="flex min-h-[calc(100vh-var(--chrome,4rem))] flex-col">
<div className="px-6 pt-6">
<PageHeader
title="文风指纹"
description="上传你的代表作样本,让 AI 学出十六维文风指纹,作为后续写作与第四审的对照标尺。"
/>
</div>
<div className="flex flex-1 flex-col lg:grid lg:min-h-0 lg:grid-cols-[28rem_1fr]">
<section className="flex flex-col border-line bg-panel px-6 py-6 lg:overflow-auto lg:border-r">
<SectionHeader title="学习文风" className="mb-3" />
<StyleUpload
busy={learn.busy}
pollStatus={learn.pollStatus === "idle" ? "idle" : learn.pollStatus}
pollStatus={
learn.pollStatus === "idle" ? "idle" : learn.pollStatus
}
progress={learn.progress}
hasFingerprint={learn.fingerprint !== null}
onLearn={onLearn}
/>
</section>
<section className="overflow-auto bg-bg px-6 py-6">
<section className="bg-bg px-6 py-6 lg:overflow-auto">
<FingerprintView fingerprint={learn.fingerprint} />
</section>
</div>
</div>
</AppShell>
);
}

View File

@@ -4,6 +4,7 @@ import { CheckCircle2, CircleDashed, RotateCcw } from "lucide-react";
import { Badge } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button";
import { cardClass } from "@/lib/ui/variants";
import type { StyleDriftReport, StyleDriftSegment } from "@/lib/review/sse";
interface StylePanelProps {
@@ -27,7 +28,7 @@ export function StylePanel({ style, incomplete, onRefine }: StylePanelProps) {
return (
<section className="mb-4">
<h2 className="text-xs font-semibold uppercase tracking-wide text-ink-soft">
(style-auditor)
</h2>
{incomplete ? (
<Badge variant="warning" className="mt-1">
@@ -63,7 +64,7 @@ export function StylePanel({ style, incomplete, onRefine }: StylePanelProps) {
{style.segments.map((seg, i) => (
<li
key={i}
className="rounded border border-line bg-panel p-2"
className={cardClass("p-2")}
data-testid="drift-segment"
>
<div className="flex items-center gap-2">

View File

@@ -8,6 +8,7 @@ import { Button } from "@/components/ui/Button";
import { Field } from "@/components/ui/Field";
import { StatusNote } from "@/components/ui/StatusNote";
import { TextArea } from "@/components/ui/TextArea";
import { buttonClass } from "@/lib/ui/variants";
interface StyleUploadProps {
busy: boolean;
@@ -76,7 +77,7 @@ export function StyleUpload({
</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">
<label className={buttonClass({ variant: "secondary", className: "cursor-pointer" })}>
<input
type="file"

View File

@@ -2,33 +2,42 @@
import { useCallback, useState } from "react";
import { FileText } from "lucide-react";
import { useToast } from "@/components/Toast";
import { Button } from "@/components/ui/Button";
import { EmptyState } from "@/components/ui/EmptyState";
import { Field } from "@/components/ui/Field";
import { SectionHeader } from "@/components/ui/SectionHeader";
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 type { TemplateResponse } from "@/lib/api/types";
import type { TemplateResponse, ToolDescriptorView } from "@/lib/api/types";
import {
buildTemplateCreateRequest,
EMPTY_TEMPLATE_DRAFT,
isTemplateDraftValid,
type TemplateDraft,
} from "@/lib/templates/templates";
import { toolKeyOptions } from "@/lib/toolbox/toolbox";
import { cardClass } from "@/lib/ui/variants";
interface TemplatesManagerProps {
// 初始模板列表Server Component 预取;失败给空数组 + loadError 文案)。
initial: TemplateResponse[];
// 工具描述符(供「关联工具」下拉:展示中文标题、值用 key。空 → 退化为「不关联」单选。
tools: ToolDescriptorView[];
}
// 提示词/模板库管理F3单用户本地版列出 / 新建 / 删除。
// 列表本地维护乐观更新失败回滚CRUD 走 OpenAPI 客户端。分享/市场不做(需多租户)。
export function TemplatesManager({ initial }: TemplatesManagerProps) {
export function TemplatesManager({ initial, tools }: TemplatesManagerProps) {
const toast = useToast();
const [templates, setTemplates] = useState<TemplateResponse[]>(initial);
const [draft, setDraft] = useState<TemplateDraft>(EMPTY_TEMPLATE_DRAFT);
const [creating, setCreating] = useState(false);
const toolOptions = toolKeyOptions(tools);
const setField = useCallback(
(field: keyof TemplateDraft, value: string): void => {
@@ -61,33 +70,60 @@ export function TemplatesManager({ initial }: TemplatesManagerProps) {
}
}, [draft, toast]);
// 撤销删除:以原模板内容重新入库(后端无恢复接口,新建即可;新 id 不影响用途)。
const restore = useCallback(
async (removed: TemplateResponse): Promise<void> => {
try {
const { data, error } = await api.POST("/templates", {
body: {
title: removed.title,
body: removed.body,
category: removed.category ?? null,
tool_key: removed.tool_key ?? null,
},
});
if (error || !data) {
toast("撤销失败,请重新新建。", "error");
return;
}
setTemplates((cur) => [data, ...cur]);
} catch {
toast("撤销请求异常,请检查网络。", "error");
}
},
[toast],
);
const onDelete = useCallback(
async (id: string): Promise<void> => {
async (removed: TemplateResponse): Promise<void> => {
const prev = templates;
// 乐观删除:先移除,失败回滚。
setTemplates((cur) => cur.filter((t) => t.id !== id));
setTemplates((cur) => cur.filter((t) => t.id !== removed.id));
try {
const { error } = await api.DELETE("/templates/{template_id}", {
params: { path: { template_id: id } },
params: { path: { template_id: removed.id } },
});
if (error) {
setTemplates(prev);
toast("删除模板失败,请重试。", "error");
return;
}
toast("已删除模板。", "success");
// 成功后给「撤销」action第三参误删可一键以原内容重新入库。
toast(`已删除模板「${removed.title}」。`, "success", {
action: { label: "撤销", onClick: () => void restore(removed) },
});
} catch {
setTemplates(prev);
toast("删除模板请求异常,请检查网络。", "error");
}
},
[templates, toast],
[templates, toast, restore],
);
return (
<div className="flex flex-col gap-6">
<form
className="flex flex-col gap-3 rounded border border-line bg-panel p-5"
className={cardClass("flex flex-col gap-3 p-5")}
aria-label="新建模板"
onSubmit={(e) => {
e.preventDefault();
@@ -120,13 +156,19 @@ export function TemplatesManager({ initial }: TemplatesManagerProps) {
aria-label="分类"
/>
</Field>
<Field label="关联生成器 key(可选)" className="w-full sm:w-48">
<TextInput
type="text"
<Field label="关联工具(可选)" className="w-full sm:w-48">
<Select
value={draft.toolKey}
onChange={(e) => setField("toolKey", e.target.value)}
aria-label="关联生成器 key"
/>
aria-label="关联工具"
>
<option value=""></option>
{toolOptions.map((opt) => (
<option key={opt.key} value={opt.key}>
{opt.title}
</option>
))}
</Select>
</Field>
</div>
<Button
@@ -142,7 +184,11 @@ export function TemplatesManager({ initial }: TemplatesManagerProps) {
<section className="flex flex-col gap-3" aria-label="模板列表">
<h2 className="font-serif text-lg text-ink">{templates.length}</h2>
{templates.length === 0 ? (
<p className="text-sm text-ink-soft"></p>
<EmptyState
icon={FileText}
title="还没有模板"
description="用上面的表单保存一段常用提示词,之后在生成器里就能一键填入需求 / 原文。"
/>
) : (
<ul className="flex flex-col gap-2">
{templates.map((t) => (
@@ -163,7 +209,7 @@ export function TemplatesManager({ initial }: TemplatesManagerProps) {
</div>
<Button
type="button"
onClick={() => void onDelete(t.id)}
onClick={() => void onDelete(t)}
variant="danger"
size="sm"
className="shrink-0"

View File

@@ -8,8 +8,10 @@ import { ConflictAdjudication } from "@/components/generation/ConflictAdjudicati
import { Button } from "@/components/ui/Button";
import { Badge } from "@/components/ui/Badge";
import { Field } from "@/components/ui/Field";
import { StatusNote } from "@/components/ui/StatusNote";
import { TextArea } from "@/components/ui/TextArea";
import { TextInput } from "@/components/ui/TextInput";
import { ThinkingIndicator } from "@/components/ThinkingIndicator";
import type { ToolDescriptorView, ToolInputFieldView } from "@/lib/api/types";
import {
buildGenerateRequest,
@@ -50,11 +52,17 @@ export function GeneratorRunner({
setValues((prev) => ({ ...prev, [name]: value }));
}, []);
// 「从模板填入」的目标字段brief / text无则不渲染
// 「从模板填入」的目标字段brief / text无则不渲染匹配按 name展示用中文 label。
const fillTarget = useMemo(
() => templateFillTarget(tool.input_fields),
[tool.input_fields],
);
const fillLabel = useMemo(
() =>
(tool.input_fields ?? []).find((f) => f.name === fillTarget)?.label ??
fillTarget,
[tool.input_fields, fillTarget],
);
const onGenerate = useCallback(async (): Promise<void> => {
const missing = missingRequiredFields(tool.input_fields, values);
@@ -150,7 +158,11 @@ export function GeneratorRunner({
void onGenerate();
}}
>
<TemplateFiller targetField={fillTarget} onFill={setField} />
<TemplateFiller
targetField={fillTarget}
targetLabel={fillLabel}
onFill={setField}
/>
{(tool.input_fields ?? []).map((field) => (
<FormField
key={field.name}
@@ -163,6 +175,12 @@ export function GeneratorRunner({
<Sparkles className="h-4 w-4" aria-hidden="true" />
{generating ? "生成中…" : "生成"}
</Button>
{generating ? (
<ThinkingIndicator
label="生成中,长文可能需要十几秒…"
className="text-cinnabar"
/>
) : null}
</form>
{gen.genStatus === "preview" && gen.preview ? (
@@ -213,9 +231,9 @@ export function GeneratorRunner({
) : null}
{gen.ingestStatus === "done" ? (
<p className="text-sm text-ink-soft">
{gen.created.length}
</p>
<StatusNote variant="success" title={`已入库 ${gen.created.length}`}>
<p></p>
</StatusNote>
) : null}
</div>
) : null}

View File

@@ -3,19 +3,27 @@
import { useCallback, useEffect, useState } from "react";
import { useToast } from "@/components/Toast";
import { Button } from "@/components/ui/Button";
import { api } from "@/lib/api/client";
import type { TemplateResponse } from "@/lib/api/types";
import { buttonClass } from "@/lib/ui/variants";
interface TemplateFillerProps {
// 可填入的目标输入字段名(如 brief / text空 → 不渲染(无可填字段)。
// 可填入的目标输入字段名(如 brief / text空 → 不渲染(无可填字段)。匹配/回写按此 name。
targetField: string | null;
// 目标字段的中文展示名(如「需求」「原文」);缺省回退到 name。仅用于展示不暴露英文 key。
targetLabel?: string | null;
// 选定模板 → 把其 body 填进目标字段(纯前端,不改生成器后端)。
onFill: (field: string, body: string) => void;
}
// 「从模板填入」F3纯前端进场懒加载模板库选一个把 body 填进生成器的 brief/原文输入。
// 与生成器后端解耦——只读 /templates 并回写本地表单值。无模板/无目标字段则不渲染。
export function TemplateFiller({ targetField, onFill }: TemplateFillerProps) {
export function TemplateFiller({
targetField,
targetLabel,
onFill,
}: TemplateFillerProps) {
const toast = useToast();
const [templates, setTemplates] = useState<TemplateResponse[] | null>(null);
const [open, setOpen] = useState(false);
@@ -41,16 +49,18 @@ export function TemplateFiller({ targetField, onFill }: TemplateFillerProps) {
if (!targetField) return null;
const field = targetField;
const fieldLabel = targetLabel ?? targetField;
if (!open) {
return (
<button
type="button"
<Button
variant="secondary"
size="sm"
onClick={() => setOpen(true)}
className="self-start rounded border border-line px-3 py-1 text-sm text-ink-soft hover:text-ink"
className="self-start"
>
</button>
</Button>
);
}
@@ -60,14 +70,14 @@ export function TemplateFiller({ targetField, onFill }: TemplateFillerProps) {
aria-label="从模板填入"
>
<div className="flex items-center justify-between">
<span className="text-sm text-ink-soft">{field}</span>
<button
type="button"
<span className="text-sm text-ink-soft">{fieldLabel}</span>
<Button
variant="ghost"
size="sm"
onClick={() => setOpen(false)}
className="text-xs text-ink-soft hover:text-ink"
>
</button>
</Button>
</div>
{templates === null ? (
<p className="text-sm text-ink-soft"></p>
@@ -84,7 +94,10 @@ export function TemplateFiller({ targetField, onFill }: TemplateFillerProps) {
setOpen(false);
toast(`已填入模板「${t.title}」。`, "success");
}}
className="w-full rounded border border-line px-3 py-2 text-left text-sm text-ink hover:border-cinnabar"
className={buttonClass({
variant: "secondary",
className: "w-full justify-start text-left text-ink",
})}
>
<span className="font-serif">{t.title}</span>
{t.tool_key ? (

View File

@@ -4,6 +4,7 @@ import { ArrowRight, Database, Sparkles } from "lucide-react";
import { Badge } from "@/components/ui/Badge";
import type { ToolDescriptorView } from "@/lib/api/types";
import { isLegacyTool } from "@/lib/toolbox/toolbox";
import { cardClass } from "@/lib/ui/variants";
interface ToolCardProps {
@@ -15,12 +16,17 @@ interface ToolCardProps {
// 工具箱卡片(对齐 ProjectCard 纸感视觉):标题 + 副标题 + 题材徽标 + NEW/可入库标。
// 纯展示;点击交由 ToolboxPage 分派legacy 跳转 / 新工具开 runner
export function ToolCard({ tool, onOpen }: ToolCardProps) {
const legacy = isLegacyTool(tool);
// legacy 工具跳现有页面;新工具进结构化预览流,提示语据此分支(不暴露内部 key
const hint = legacy
? "前往现有页面"
: `${tool.input_fields?.[0]?.label ?? "需求"} → 结构化预览`;
return (
<button
type="button"
onClick={() => onOpen(tool)}
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",
"group flex h-full min-h-[164px] flex-col p-5 text-left motion-safe: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">
@@ -37,11 +43,9 @@ export function ToolCard({ tool, onOpen }: ToolCardProps) {
<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>
<span className="truncate">{hint}</span>
<ArrowRight
className="ml-auto h-3.5 w-3.5 text-cinnabar opacity-0 transition-opacity group-hover:opacity-100"
className="ml-auto h-3.5 w-3.5 text-cinnabar opacity-0 motion-safe:transition-opacity group-hover:opacity-100"
aria-hidden="true"
/>
</div>

View File

@@ -1,4 +1,6 @@
import type { ReactNode } from "react";
"use client";
import { cloneElement, isValidElement, useId, type ReactNode } from "react";
import {
cn,
@@ -26,13 +28,20 @@ export function Field({
required = false,
className,
}: FieldProps) {
const autoId = useId();
const id = htmlFor ?? autoId;
const labelText = required ? `${label} *` : label;
// 自动把 id 注入唯一子控件,建立 label↔控件关联仅当子控件自身未带 id
const control =
isValidElement<{ id?: string }>(children) && children.props.id == null
? cloneElement(children, { id })
: children;
return (
<div className={cn("space-y-1.5", className)}>
<label htmlFor={htmlFor} className={fieldLabelClass()}>
<label htmlFor={id} className={fieldLabelClass()}>
{labelText}
</label>
{children}
{control}
{error ? <p className={fieldErrorClass()}>{error}</p> : null}
{!error && help ? <p className={fieldHelpClass()}>{help}</p> : null}
</div>

View File

@@ -1,7 +1,13 @@
"use client";
import Link from "next/link";
import { Minus, Pin, PinOff, Plus, RotateCcw, Undo2, X } from "lucide-react";
import { ThinkingIndicator } from "@/components/ThinkingIndicator";
import { Button } from "@/components/ui/Button";
import { SectionHeader } from "@/components/ui/SectionHeader";
import { StatusNote } from "@/components/ui/StatusNote";
import { buttonClass } from "@/lib/ui/variants";
import {
isPinned,
kindLabel,
@@ -24,7 +30,7 @@ interface ChapterAssistantProps {
// 桌面 aside 包裹;移动端经 Workbench 抽屉复用 AssistantContent。
export function ChapterAssistant({ projectId, chapterNo }: ChapterAssistantProps) {
return (
<aside className="hidden border-l border-line bg-panel py-4 lg:block">
<aside className="hidden border-l border-line bg-panel py-4 xl:block">
<AssistantContent projectId={projectId} chapterNo={chapterNo} />
</aside>
);
@@ -38,34 +44,76 @@ export function AssistantContent({ projectId, chapterNo }: ChapterAssistantProps
return (
<div className="px-4">
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold text-ink"></h2>
{injection.saving ? (
<span className="text-[10px] text-ink-soft"></span>
) : null}
</div>
<SectionHeader
title="本章助手"
action={
injection.saving ? (
<ThinkingIndicator
label="助手保存中"
className="text-xs text-ink-soft"
/>
) : null
}
/>
<section className="mt-4">
<div className="flex items-center justify-between">
<h3 className="text-xs font-semibold uppercase tracking-wide text-ink-soft">
</h3>
{data ? (
<SectionHeader
title="本章注入(透明)"
action={
data ? (
<RecentStepper
value={data.recent_n}
disabled={injection.saving}
onChange={injection.setRecentN}
/>
) : null}
</div>
) : null
}
/>
{injection.loading ? (
<p className="mt-2 text-xs text-ink-soft"></p>
) : injection.error ? (
<p className="mt-2 rounded border border-dashed border-line bg-bg p-3 text-xs text-ink-soft">
{injection.error}
</p>
) : selected.length === 0 ? (
) : !data ? (
// 首次加载失败data 仍为空 → 满屏错误框 + 重试。
<StatusNote variant="danger" className="mt-2 text-xs" title="注入信息暂不可用">
<p></p>
<button
type="button"
onClick={injection.reload}
className={buttonClass({ variant: "outline", size: "sm", className: "mt-2" })}
>
<RotateCcw className="h-3.5 w-3.5" aria-hidden="true" />
</button>
</StatusNote>
) : (
<>
{injection.saveError ? (
// 保存失败:悬浮提示,不替换列表;可关闭 + 重试上一次覆盖。
<StatusNote variant="danger" className="mt-2 text-xs" title={injection.saveError}>
<div className="mt-1 flex items-center gap-2">
<button
type="button"
onClick={() => void injection.retrySave()}
disabled={injection.saving}
className={buttonClass({ variant: "outline", size: "sm" })}
>
<RotateCcw className="h-3.5 w-3.5" aria-hidden="true" />
</button>
<button
type="button"
onClick={injection.clearSaveError}
aria-label="关闭保存失败提示"
className={buttonClass({ variant: "ghost", size: "sm" })}
>
<X className="h-3.5 w-3.5" aria-hidden="true" />
</button>
</div>
</StatusNote>
) : null}
{selected.length === 0 ? (
<p className="mt-2 rounded border border-dashed border-line bg-bg p-3 text-xs text-ink-soft">
/
</p>
@@ -76,7 +124,7 @@ export function AssistantContent({ projectId, chapterNo }: ChapterAssistantProps
key={`${entity.kind}:${entity.name}`}
entity={entity}
pinned={isPinned(
{ pinned: data?.pinned, excluded: data?.excluded, recent_n: data?.recent_n },
{ pinned: data.pinned, excluded: data.excluded, recent_n: data.recent_n },
entity,
)}
disabled={injection.saving}
@@ -86,12 +134,12 @@ export function AssistantContent({ projectId, chapterNo }: ChapterAssistantProps
))}
</ul>
)}
</>
)}
{excluded.length > 0 ? (
<div className="mt-3">
<h4 className="text-[10px] uppercase tracking-wide text-ink-soft">
</h4>
<SectionHeader title="已排除(不注入)" />
<ul className="mt-1 space-y-1">
{excluded.map((ref) => (
<ExcludedRow
@@ -107,15 +155,13 @@ export function AssistantContent({ projectId, chapterNo }: ChapterAssistantProps
</section>
<section className="mt-6">
<h3 className="text-xs font-semibold uppercase tracking-wide text-ink-soft">
</h3>
<p className="mt-2 text-xs text-ink-soft">
/ / / 稿
</p>
<SectionHeader
title="四审"
description="一致性 / 伏笔 / 文风 / 节奏,在审稿页逐条裁决。"
/>
<Link
href={`/projects/${projectId}/review?chapter=${chapterNo}`}
className="mt-2 inline-block rounded border border-cinnabar px-3 py-1.5 text-xs text-cinnabar hover:bg-[var(--color-cinnabar-wash)]"
className={buttonClass({ variant: "outline", size: "sm", className: "mt-2" })}
>
稿
</Link>
@@ -137,42 +183,44 @@ function EntityRow({ entity, pinned, disabled, onTogglePin, onExclude }: EntityR
return (
<li className="rounded border border-line bg-bg p-2">
<div className="flex items-center gap-2">
<span className="rounded bg-panel px-1.5 py-0.5 text-[10px] text-ink-soft">
<span className="rounded bg-panel px-1.5 py-0.5 text-2xs text-ink-soft">
{kindLabel(entity.kind)}
</span>
<span className="text-sm text-ink">{entity.name}</span>
<div className="ml-auto flex items-center gap-1">
<button
type="button"
<Button
variant="ghost"
size="icon"
disabled={disabled}
aria-pressed={pinned}
title={pinned ? "取消置顶" : "置顶(强制注入)"}
aria-label={
pinned ? `取消置顶 ${entity.name}` : `置顶 ${entity.name}(强制注入)`
}
onClick={() => onTogglePin(ref)}
className={`rounded px-1.5 py-0.5 text-[11px] disabled:opacity-50 ${
pinned
? "bg-[var(--color-cinnabar-wash)] text-cinnabar"
: "text-ink-soft hover:bg-panel"
}`}
className={pinned ? "text-cinnabar" : undefined}
>
📌
</button>
<button
type="button"
{pinned ? (
<Pin className="h-4 w-4" aria-hidden="true" />
) : (
<PinOff className="h-4 w-4" aria-hidden="true" />
)}
</Button>
<Button
variant="ghost"
size="icon"
disabled={disabled}
title="排除(不注入本章)"
aria-label={`排除 ${entity.name}`}
aria-label={`排除 ${entity.name}(不注入本章)`}
onClick={() => onExclude(ref)}
className="rounded px-1.5 py-0.5 text-[11px] text-ink-soft hover:bg-panel disabled:opacity-50"
>
</button>
<X className="h-4 w-4" aria-hidden="true" />
</Button>
</div>
</div>
<div className="mt-1.5 flex flex-wrap gap-1">
{(entity.reasons ?? []).map((reason) => (
<span
key={reason}
className="rounded bg-[var(--color-cinnabar-wash)] px-1.5 py-0.5 text-[10px] text-cinnabar"
className="rounded bg-[var(--color-cinnabar-wash)] px-1.5 py-0.5 text-2xs text-cinnabar"
>
{reasonLabel(reason)}
</span>
@@ -191,17 +239,18 @@ interface ExcludedRowProps {
function ExcludedRow({ refItem, disabled, onRestore }: ExcludedRowProps) {
return (
<li className="flex items-center gap-2 rounded border border-dashed border-line px-2 py-1">
<span className="text-[10px] text-ink-soft">{kindLabel(refItem.kind)}</span>
<span className="text-2xs text-ink-soft">{kindLabel(refItem.kind)}</span>
<span className="text-xs text-ink-soft line-through">{refItem.name}</span>
<button
type="button"
<Button
variant="ghost"
size="icon"
disabled={disabled}
title="恢复(回到自动判定)"
aria-label={`恢复 ${refItem.name}(回到自动判定)`}
onClick={() => onRestore(refItem)}
className="ml-auto rounded px-1.5 py-0.5 text-[11px] text-cinnabar hover:bg-[var(--color-cinnabar-wash)] disabled:opacity-50"
className="ml-auto text-cinnabar"
>
</button>
<Undo2 className="h-4 w-4" aria-hidden="true" />
</Button>
</li>
);
}
@@ -215,27 +264,27 @@ interface RecentStepperProps {
// 「关联近 N 章」步进器:覆盖近况摘要回看章数(钳在 [MIN, MAX])。
function RecentStepper({ value, disabled, onChange }: RecentStepperProps) {
return (
<div className="flex items-center gap-1 text-[11px] text-ink-soft">
<div className="flex items-center gap-1 text-2xs text-ink-soft">
<span></span>
<button
type="button"
<Button
variant="ghost"
size="icon"
disabled={disabled || value <= RECENT_N_MIN}
aria-label="减少关联章数"
onClick={() => onChange(value - 1)}
className="rounded border border-line px-1.5 leading-tight hover:bg-panel disabled:opacity-40"
>
</button>
<Minus className="h-4 w-4" aria-hidden="true" />
</Button>
<span className="w-4 text-center tabular-nums text-ink">{value}</span>
<button
type="button"
<Button
variant="ghost"
size="icon"
disabled={disabled || value >= RECENT_N_MAX}
aria-label="增加关联章数"
onClick={() => onChange(value + 1)}
className="rounded border border-line px-1.5 leading-tight hover:bg-panel disabled:opacity-40"
>
+
</button>
<Plus className="h-4 w-4" aria-hidden="true" />
</Button>
<span></span>
</div>
);

View File

@@ -17,7 +17,7 @@ export function ChapterList({
currentChapterNo,
}: ChapterListProps) {
return (
<aside className="hidden border-r border-line bg-panel py-4 lg:block">
<aside className="hidden border-r border-line bg-panel py-4 xl:block">
<ChapterListContent
projectId={projectId}
chapters={chapters}

View File

@@ -2,6 +2,8 @@
import { useEffect, useRef } from "react";
import { cn, focusRing, proseBody } from "@/lib/ui/variants";
interface EditorProps {
value: string;
onChange: (value: string) => void;
@@ -36,7 +38,11 @@ export function Editor({ value, onChange, streaming }: EditorProps) {
readOnly={streaming}
aria-busy={streaming}
placeholder="夜色如墨……点击「写本章」让 AI 起草,或直接在此书写。"
className="block min-h-[60vh] w-full resize-none overflow-hidden bg-transparent font-serif text-[18px] leading-[1.9] text-ink placeholder:text-ink-soft/50 focus:outline-none"
className={cn(
proseBody,
"block min-h-[60vh] w-full resize-none overflow-hidden rounded bg-transparent text-ink placeholder:text-ink-soft/50",
focusRing,
)}
/>
{streaming ? (
<span

View File

@@ -4,6 +4,7 @@ import Link from "next/link";
import { useEffect, useId, useRef, useState, type RefObject } from "react";
import {
BookOpen,
ChevronDown,
ClipboardCheck,
FileText,
Library,
@@ -15,6 +16,7 @@ import {
import { AppShell } from "@/components/AppShell";
import { Drawer } from "@/components/Drawer";
import { ThinkingIndicator } from "@/components/ThinkingIndicator";
import { Button } from "@/components/ui/Button";
import { SectionHeader } from "@/components/ui/SectionHeader";
import { StatusNote } from "@/components/ui/StatusNote";
@@ -102,9 +104,27 @@ export function Workbench({
autosave.onChange(value);
};
const wordCount = text.length;
// 字数统计非空白字符(与中文读者直觉一致:标点/空格不计入正文体量)。
const wordCount = text.replace(/\s+/g, "").length;
const closePanel = (): void => setMobilePanel(null);
// 流式里程碑播报(仅终结句,非逐 token完成/停止/失败时写入 role=status 区。
const liveMessage = ((): string => {
switch (stream.state.phase) {
case "done":
return `本章生成完成,共 ${wordCount}`;
case "aborted":
return "已停止";
case "error":
return stream.state.error
? friendlyError(stream.state.error.code, stream.state.error.message)
.text
: "本章生成失败,请稍后重试。";
default:
return "";
}
})();
return (
<AppShell
title={`${project.title}`}
@@ -112,7 +132,7 @@ export function Workbench({
projectId={project.id}
activeNav="write"
>
<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]">
<div className="grid min-h-[calc(100vh-var(--chrome,4rem))] grid-cols-1 xl:h-[calc(100vh-var(--chrome,4rem))] xl:grid-cols-[12rem_1fr_18rem]">
<ChapterList
projectId={project.id}
chapters={chapters}
@@ -148,6 +168,7 @@ export function Workbench({
saveStatus={autosave.status}
streaming={stream.isStreaming}
streamError={stream.state.error}
liveMessage={liveMessage}
onWrite={onWrite}
onStop={stream.stop}
/>
@@ -156,7 +177,7 @@ export function Workbench({
<ChapterAssistant projectId={project.id} chapterNo={chapterNo} />
</div>
{/* 移动端:目录 / 本章助手经抽屉触达(桌面已在栅格里,抽屉 lg:hidden。 */}
{/* 窄屏 / 中屏(10241280):目录 / 本章助手经抽屉触达≥xl 已在三栏栅格里直显。 */}
<Drawer
open={mobilePanel === "chapters"}
onClose={closePanel}
@@ -199,7 +220,7 @@ function MobileContextBar({
assistantTriggerRef,
}: MobileContextBarProps) {
return (
<div className="flex items-center justify-between gap-2 border-b border-line bg-panel px-4 py-2 lg:hidden">
<div className="flex items-center justify-between gap-2 border-b border-line bg-panel px-4 py-2 xl:hidden">
<Button
ref={chapterTriggerRef}
onClick={onOpenChapters}
@@ -243,12 +264,18 @@ function DirectivePanel({
const panelId = useId();
const activeCount = presetIds.length + (directive.trim().length > 0 ? 1 : 0);
return (
<details className="border-b border-line bg-panel px-4 py-3 sm:px-6">
<details className="group 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="flex items-center gap-2 font-serif text-base text-ink">
<ChevronDown
className="h-4 w-4 text-ink-soft transition-transform group-open:rotate-180"
aria-hidden="true"
/>
</span>
<span className="rounded border border-line bg-bg px-2 py-0.5 text-xs text-ink-soft">
{activeCount > 0 ? `${activeCount} 项指令` : "可选"}
</span>
@@ -327,6 +354,8 @@ interface ToolbarProps {
saveStatus: ReturnType<typeof useAutosave>["status"];
streaming: boolean;
streamError: { code: string; message: string } | null;
// 流式里程碑播报句(完成/停止/失败);仅在 role=status 区出现,不逐 token 刷新。
liveMessage: string;
onWrite: () => void;
onStop: () => void;
}
@@ -339,6 +368,7 @@ function Toolbar({
saveStatus,
streaming,
streamError,
liveMessage,
onWrite,
onStop,
}: ToolbarProps) {
@@ -359,14 +389,26 @@ function Toolbar({
</Button>
)}
{streaming ? (
<span className="font-mono text-xs text-cinnabar motion-safe:animate-pulse">
{wordCount.toLocaleString("en-US")}
// ThinkingIndicator 自带 role=status开始时播报一次「生成中」
// 易变字数 aria-hidden仅供视觉不逐 token 打扰屏读。
<span className="inline-flex items-center gap-2">
<ThinkingIndicator label="生成中" className="text-xs text-cinnabar" />
<span
aria-hidden="true"
className="font-mono text-xs text-cinnabar"
>
{wordCount}
</span>
</span>
) : (
<span className="font-mono text-xs text-ink-soft">
{wordCount.toLocaleString("en-US")}
{wordCount}
</span>
)}
{/* 终结句播报区:完成/停止/失败时写入,屏读只播报里程碑。 */}
<span className="sr-only" role="status" aria-live="polite">
{liveMessage}
</span>
<Link
href={`/projects/${projectId}/outline`}
className={buttonClass({ variant: "secondary", size: "sm" })}
@@ -393,7 +435,11 @@ function Toolbar({
稿
</Link>
</div>
<span className="min-w-0 font-mono text-xs text-ink-soft sm:ml-auto">
<span
role="status"
aria-live="polite"
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"
? "保存中…"

View File

@@ -83,17 +83,21 @@ describe("filterByViewVolume", () => {
});
describe("windowBadgeLabel", () => {
it("renders a range, half-open, or bare badge", () => {
it("renders a range, half-open, or bare badge without any glyph", () => {
expect(
windowBadgeLabel(win({ expected_close_from: 40, expected_close_to: 60 })),
).toBe("F-012 (40-60)");
expect(windowBadgeLabel(win({ expected_close_to: 60 }))).toBe(
"⚑ F-012 (≤60)",
);
).toBe("F-012 (40-60)");
expect(windowBadgeLabel(win({ expected_close_to: 60 }))).toBe("F-012 (≤60)");
expect(windowBadgeLabel(win({ expected_close_from: 40 }))).toBe(
"F-012 (≥40)",
"F-012 (≥40)",
);
expect(windowBadgeLabel(win({}))).toBe("F-012");
expect(windowBadgeLabel(win({}))).toBe("F-012");
});
it("never embeds the raw ⚑ glyph (icon comes from the render layer)", () => {
expect(
windowBadgeLabel(win({ expected_close_from: 40, expected_close_to: 60 })),
).not.toContain("⚑");
});
});

View File

@@ -47,16 +47,17 @@ export function filterByViewVolume(
return all.filter((ch) => ch.volume === view);
}
// 伏笔窗口徽标文案`⚑ F-012 (40-60)` / `⚑ F-012 (≤60)` / `⚑ F-012`。
// 伏笔窗口徽标文案(纯文本,旗标图标由渲染层的 lucide Flag 承载,不掺字形):
// `F-012 (40-60)` / `F-012 (≤60)` / `F-012 (≥40)` / `F-012`。
export function windowBadgeLabel(window: ForeshadowWindowView): string {
const from = window.expected_close_from;
const to = window.expected_close_to;
if (typeof from === "number" && typeof to === "number") {
return `${window.code} (${from}-${to})`;
return `${window.code} (${from}-${to})`;
}
if (typeof to === "number") return `${window.code} (≤${to})`;
if (typeof from === "number") return `${window.code} (≥${from})`;
return `${window.code}`;
if (typeof to === "number") return `${window.code} (≤${to})`;
if (typeof from === "number") return `${window.code} (≥${from})`;
return window.code;
}
// 接近回收:本章号落在窗口 [from,to] 内提示「本章可回收」UX §6.7 ⚑ 可回收)。

View File

@@ -11,8 +11,8 @@ const toast = vi.fn();
vi.mock("@/lib/api/client", () => ({ api: { POST: (...a: unknown[]) => post(...a) } }));
vi.mock("@/components/Toast", () => ({ useToast: () => toast }));
function makeChapter(no: number): OutlineChapterView {
return { no, volume: 1 };
function makeChapter(no: number, volume = 1): OutlineChapterView {
return { no, volume };
}
describe("useOutline", () => {
@@ -51,6 +51,25 @@ describe("useOutline", () => {
expect(toast).toHaveBeenCalledWith("大纲已生成", "success");
});
it("生成只替换目标卷,保留其它卷的已存章节", async () => {
const initial = [makeChapter(1, 1), makeChapter(2, 1), makeChapter(80, 2)];
const regenerated = [makeChapter(1, 1), makeChapter(2, 1), makeChapter(3, 1)];
post.mockResolvedValue({ data: { chapters: regenerated }, error: null });
const { result } = renderHook(() => useOutline(initial));
await act(async () => {
await result.current.generate("p1", 1);
});
// 卷 2 的第 80 章不被整列覆盖;卷 1 替换为新生成的三章。
expect(result.current.chapters).toEqual([
makeChapter(80, 2),
makeChapter(1, 1),
makeChapter(2, 1),
makeChapter(3, 1),
]);
});
it("data.chapters 为空时章节回退为空数组", async () => {
post.mockResolvedValue({ data: { chapters: null }, error: null });
const { result } = renderHook(() => useOutline([]));
@@ -61,7 +80,7 @@ describe("useOutline", () => {
expect(result.current.chapters).toEqual([]);
});
it("后端返回业务错误:status=error 并 surface 友好文案", async () => {
it("后端返回业务错误:透传 friendly 文案 + 跳转动作(不再固定 OUTLINE_FAILED", async () => {
post.mockResolvedValue({
data: null,
error: { error: { code: "LLM_UNAVAILABLE" } },
@@ -72,18 +91,36 @@ describe("useOutline", () => {
});
expect(result.current.status).toBe("error");
expect(result.current.error?.code).toBe("OUTLINE_FAILED");
expect(result.current.error?.message).toContain("provider");
// LLM_UNAVAILABLE → 透传 provider 文案 + 「去设置提供商」链接。
expect(result.current.error?.text).toContain("provider");
expect(result.current.error?.actionHref).toBe("/settings/providers");
expect(result.current.error?.actionLabel).toBe("去设置提供商");
expect(toast).toHaveBeenCalledWith(expect.any(String), "error");
});
it("data 为空且无 error 也走错误分支", async () => {
it("校验错误422 detail 信封):透传 VALIDATION 文案且无跳转动作", async () => {
post.mockResolvedValue({
data: null,
error: { detail: [{ msg: "字段缺失" }] },
});
const { result } = renderHook(() => useOutline([]));
await act(async () => {
await result.current.generate("p1", 1);
});
expect(result.current.status).toBe("error");
expect(result.current.error?.text).toBeTruthy();
expect(result.current.error?.actionHref).toBeUndefined();
expect(result.current.error?.actionLabel).toBeUndefined();
});
it("data 为空且无 error 也走错误分支(兜底文案、无跳转)", async () => {
post.mockResolvedValue({ data: null, error: null });
const { result } = renderHook(() => useOutline([]));
await act(async () => {
await result.current.generate("p1", 1);
});
expect(result.current.status).toBe("error");
expect(result.current.error?.code).toBe("OUTLINE_FAILED");
expect(result.current.error?.text).toBeTruthy();
expect(result.current.error?.actionHref).toBeUndefined();
});
});

View File

@@ -4,20 +4,20 @@ import { useCallback, useState } from "react";
import { api } from "@/lib/api/client";
import { useToast } from "@/components/Toast";
import { friendlyFromApiError } from "@/lib/errors/messages";
import {
friendlyFromApiError,
type FriendlyError,
} from "@/lib/errors/messages";
import type { OutlineChapterView } from "@/lib/api/types";
export type OutlineStatus = "idle" | "generating" | "ready" | "error";
export interface OutlineError {
code: string;
message: string;
}
export interface UseOutline {
chapters: OutlineChapterView[];
status: OutlineStatus;
error: OutlineError | null;
// 直接透传 friendlyFromApiError 的结果(含 text + 可选 actionHref/actionLabel
// 不再拍平成固定的内部 codeUI 据 actionHref 是否存在决定显示跳转链接。
error: FriendlyError | null;
generate: (projectId: string, volume: number) => Promise<void>;
}
@@ -28,7 +28,7 @@ export function useOutline(initial: OutlineChapterView[]): UseOutline {
const [status, setStatus] = useState<OutlineStatus>(
initial.length > 0 ? "ready" : "idle",
);
const [error, setError] = useState<OutlineError | null>(null);
const [error, setError] = useState<FriendlyError | null>(null);
const toast = useToast();
const generate = useCallback<UseOutline["generate"]>(
@@ -45,13 +45,19 @@ export function useOutline(initial: OutlineChapterView[]): UseOutline {
if (apiError || !data) {
// surface 真实错因(如 LLM_UNAVAILABLE→去设置 provider422→校验文案
// 而非永远的通用「大纲生成失败」QA #2原 env.error.code 漏读 422 的 detail 信封)。
// 直接透传 friendly保留 actionHref/actionLabel不再拍平成固定 OUTLINE_FAILED。
const friendly = friendlyFromApiError(apiError);
setError({ code: "OUTLINE_FAILED", message: friendly.text });
setError(friendly);
setStatus("error");
toast(friendly.text, "error");
return;
}
setChapters(data.chapters ?? []);
// 只替换目标卷的条目保留其它卷「AI 排大纲」是单卷操作,整列覆盖会误删别卷)。
const generated = data.chapters ?? [];
setChapters((prev) => [
...prev.filter((ch) => ch.volume !== volume),
...generated,
]);
setStatus("ready");
toast("大纲已生成", "success");
},

View File

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

View File

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

View File

@@ -23,10 +23,11 @@ describe("useRules", () => {
});
afterEach(() => vi.clearAllMocks());
it("初始 items = 传入值,busy=false", () => {
it("初始 items = 传入值,adding=false、deletingId=null", () => {
const { result } = renderHook(() => useRules(seed));
expect(result.current.items).toEqual(seed);
expect(result.current.busy).toBe(false);
expect(result.current.adding).toBe(false);
expect(result.current.deletingId).toBeNull();
});
it("add 空白正文:弹错、不请求、返回 false", async () => {
@@ -52,7 +53,7 @@ describe("useRules", () => {
expect(ok).toBe(true);
expect(result.current.items).toEqual([...seed, authoritative]);
expect(result.current.busy).toBe(false);
expect(result.current.adding).toBe(false);
expect(toast).toHaveBeenCalledWith("已新增规则", "success");
});
@@ -80,6 +81,48 @@ describe("useRules", () => {
expect(toast).toHaveBeenCalledWith("已删除规则", "success");
});
it("add 进行中adding=true 而 deletingId 仍为 null互不冻结", async () => {
let resolvePost: (v: { data: RuleView; error: null }) => void = () => {};
post.mockReturnValue(
new Promise((res) => {
resolvePost = res;
}),
);
const { result } = renderHook(() => useRules(seed));
let done: Promise<boolean> = Promise.resolve(false);
act(() => {
done = result.current.add("p1", "global", "新规则");
});
expect(result.current.adding).toBe(true);
expect(result.current.deletingId).toBeNull();
await act(async () => {
resolvePost({ data: { id: "r1", level: "global", content: "新规则" }, error: null });
await done;
});
expect(result.current.adding).toBe(false);
});
it("remove 进行中deletingId=目标 id 而 adding 仍为 false只冻结该行", async () => {
let resolveDel: (v: { error: null }) => void = () => {};
del.mockReturnValue(
new Promise((res) => {
resolveDel = res;
}),
);
const { result } = renderHook(() => useRules(seed));
let done: Promise<boolean> = Promise.resolve(false);
act(() => {
done = result.current.remove("p1", "r0");
});
expect(result.current.deletingId).toBe("r0");
expect(result.current.adding).toBe(false);
await act(async () => {
resolveDel({ error: null });
await done;
});
expect(result.current.deletingId).toBeNull();
});
it("remove 后端 error回滚、弹错、返回 false", async () => {
del.mockResolvedValue({ error: { detail: "boom" } });
const { result } = renderHook(() => useRules(seed));

View File

@@ -9,7 +9,10 @@ import { buildRuleCreateRequest, hasUsableContent, type RuleLevel } from "./rule
export interface UseRules {
items: RuleView[];
busy: boolean;
// 正在新增(仅冻结新增表单,不影响删除)。
adding: boolean;
// 正在删除的那一条 id仅冻结该行删除按钮整页其余照常可操作
deletingId: string | null;
// 新增一条规则(乐观追加 + 失败回滚 + Toast
add: (
projectId: string,
@@ -21,9 +24,11 @@ export interface UseRules {
}
// 规则页UX §7列出 + 新增规则。乐观追加,失败回滚(仿 useForeshadow
// add 与 remove 用各自独立的进行中标志,避免删一条就冻结整页。
export function useRules(initial: RuleView[]): UseRules {
const [items, setItems] = useState<RuleView[]>(initial);
const [busy, setBusy] = useState(false);
const [adding, setAdding] = useState(false);
const [deletingId, setDeletingId] = useState<string | null>(null);
const toast = useToast();
const add = useCallback<UseRules["add"]>(
@@ -40,7 +45,7 @@ export function useRules(initial: RuleView[]): UseRules {
content: content.trim(),
};
setItems((prev) => [...prev, optimistic]);
setBusy(true);
setAdding(true);
try {
const { data, error } = await api.POST(
"/projects/{project_id}/rules",
@@ -59,7 +64,7 @@ export function useRules(initial: RuleView[]): UseRules {
toast("已新增规则", "success");
return true;
} finally {
setBusy(false);
setAdding(false);
}
},
[items, toast],
@@ -69,7 +74,7 @@ export function useRules(initial: RuleView[]): UseRules {
async (projectId, ruleId) => {
const snapshot = items;
setItems((prev) => prev.filter((r) => r.id !== ruleId));
setBusy(true);
setDeletingId(ruleId);
try {
const { error } = await api.DELETE(
"/projects/{project_id}/rules/{rule_id}",
@@ -83,11 +88,11 @@ export function useRules(initial: RuleView[]): UseRules {
toast("已删除规则", "success");
return true;
} finally {
setBusy(false);
setDeletingId(null);
}
},
[items, toast],
);
return { items, busy, add, remove };
return { items, adding, deletingId, add, remove };
}

View File

@@ -10,6 +10,7 @@ import {
previewRows,
resolveLegacyRoute,
templateFillTarget,
toolKeyOptions,
} from "./toolbox";
const field = (over: Partial<ToolInputFieldView> = {}): ToolInputFieldView => ({
@@ -210,6 +211,22 @@ describe("isLegacyTool", () => {
isLegacyTool(tool({ is_legacy: false, legacy_route: "/x/{id}" })),
).toBe(false);
});
it("maps descriptors to key/title options", () => {
expect(
toolKeyOptions([
tool({ key: "idea", title: "脑洞生成器" }),
tool({ key: "title", title: "书名生成器" }),
]),
).toEqual([
{ key: "idea", title: "脑洞生成器" },
{ key: "title", title: "书名生成器" },
]);
});
it("returns empty array for undefined tools", () => {
expect(toolKeyOptions(undefined)).toEqual([]);
});
});
describe("templateFillTarget", () => {

View File

@@ -320,3 +320,17 @@ export function resolveLegacyRoute(
export function isLegacyTool(tool: Readonly<ToolDescriptorView>): boolean {
return tool.is_legacy && !!tool.legacy_route;
}
// —— 「关联工具」下拉选项 ——
// 工具描述符 → 下拉选项value=key 用于 tool_key 关联label=中文标题供展示,不暴露英文 key
// 模板库「关联生成器」字段据此从纯文本框升级为 Select。
export interface ToolKeyOption {
key: string;
title: string;
}
export function toolKeyOptions(
tools: readonly ToolDescriptorView[] | undefined,
): ToolKeyOption[] {
return (tools ?? []).map((tool) => ({ key: tool.key, title: tool.title }));
}

View File

@@ -0,0 +1,41 @@
// @vitest-environment jsdom
import { renderHook } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import { useBodyScrollLock } from "./useBodyScrollLock";
describe("useBodyScrollLock", () => {
afterEach(() => {
document.body.style.overflow = "";
});
it("active 时锁定 body 滚动", () => {
renderHook(() => useBodyScrollLock(true));
expect(document.body.style.overflow).toBe("hidden");
});
it("active 为假时不改动 overflow", () => {
document.body.style.overflow = "scroll";
renderHook(() => useBodyScrollLock(false));
expect(document.body.style.overflow).toBe("scroll");
});
it("卸载时恢复原 overflow 值", () => {
document.body.style.overflow = "auto";
const { unmount } = renderHook(() => useBodyScrollLock(true));
expect(document.body.style.overflow).toBe("hidden");
unmount();
expect(document.body.style.overflow).toBe("auto");
});
it("active 由真转假时恢复原值", () => {
document.body.style.overflow = "visible";
const { rerender } = renderHook(
({ active }: { active: boolean }) => useBodyScrollLock(active),
{ initialProps: { active: true } },
);
expect(document.body.style.overflow).toBe("hidden");
rerender({ active: false });
expect(document.body.style.overflow).toBe("visible");
});
});

View File

@@ -0,0 +1,20 @@
"use client";
import { useEffect } from "react";
// 弹层Drawer / CommandPalette / Modal打开时锁定 body 滚动,关闭后恢复原值。
// SSR 守卫:服务端无 document直接跳过。
export function useBodyScrollLock(active: boolean): void {
useEffect(() => {
if (!active) return;
if (typeof document === "undefined") return;
const { body } = document;
const previousOverflow = body.style.overflow;
body.style.overflow = "hidden";
return () => {
body.style.overflow = previousOverflow;
};
}, [active]);
}

View File

@@ -4,7 +4,10 @@ import {
badgeClass,
buttonClass,
cn,
focusRing,
inputClass,
overlayScrim,
proseBody,
segmentedClass,
statusNoteClass,
} from "./variants";
@@ -49,4 +52,22 @@ describe("ui variants", () => {
expect(klass).toContain("inline-flex");
expect(klass).toContain("border-line");
});
it("shares the cinnabar focus ring with the button base", () => {
expect(focusRing).toContain("focus-visible:ring-2");
expect(focusRing).toContain("focus-visible:ring-cinnabar/35");
expect(buttonClass({ variant: "primary" })).toContain(focusRing);
});
it("renders manuscript body as serif 18px with airy leading", () => {
expect(proseBody).toContain("font-serif");
expect(proseBody).toContain("text-[18px]");
expect(proseBody).toContain("leading-[1.9]");
});
it("provides a full-screen overlay scrim below dialog chrome", () => {
expect(overlayScrim).toContain("fixed");
expect(overlayScrim).toContain("inset-0");
expect(overlayScrim).toContain("z-40");
});
});

View File

@@ -23,6 +23,16 @@ export function cn(...classes: Array<string | false | null | undefined>): string
return classes.filter(Boolean).join(" ");
}
// 统一焦点环(与 buttonBase 现有焦点环一致),供导航/输入等可聚焦元素复用。
export const focusRing =
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cinnabar/35";
// 手稿正文统一排版:衬线 18px、行高 1.9。
export const proseBody = "font-serif text-[18px] leading-[1.9]";
// Drawer / CommandPalette 共用的全屏遮罩。
export const overlayScrim = "fixed inset-0 z-40 bg-black/30";
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";

View File

@@ -5,11 +5,22 @@ import {
canSubmit,
clampStep,
emptyWizardForm,
STEP_TITLES,
toCreateRequest,
WIZARD_STEPS,
type WizardForm,
} from "./wizard";
describe("wizard step metadata", () => {
it("provides one title per step", () => {
expect(STEP_TITLES).toHaveLength(WIZARD_STEPS);
});
it("ends on a confirmation step rather than an empty world step", () => {
expect(STEP_TITLES[WIZARD_STEPS - 1]).toBe("确认与提交");
});
});
describe("wizard step gating", () => {
it("blocks advancing past step 1 without a title", () => {
expect(canAdvance(1, emptyWizardForm)).toBe(false);

View File

@@ -14,6 +14,15 @@ export interface WizardForm {
export const WIZARD_STEPS = 5;
// 各步标题(与 WIZARD_STEPS 一一对应)。第 5 步为「确认/概览」:回显已填字段供复核后提交。
export const STEP_TITLES = [
"书名 + 题材",
"一句话故事 + 卖点 + 结构",
"立意 / 总纲",
"主角 / 金手指",
"确认与提交",
] as const;
export const emptyWizardForm: WizardForm = {
title: "",
genre: "",

View File

@@ -43,7 +43,8 @@ describe("useInjection", () => {
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.data).toEqual(body);
expect(result.current.error).toBeNull();
expect(result.current.loadError).toBeNull();
expect(result.current.saveError).toBeNull();
});
it("挂载后端返回 error给可读文案、data 置空", async () => {
@@ -52,7 +53,7 @@ describe("useInjection", () => {
const { result } = renderHook(() => useInjection("p1", 3));
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.error).toBe("注入信息暂不可用");
expect(result.current.loadError).toBe("注入信息暂不可用");
expect(result.current.data).toBeNull();
});
@@ -62,7 +63,7 @@ describe("useInjection", () => {
const { result } = renderHook(() => useInjection("p1", 3));
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.error).toBe("注入信息暂不可用");
expect(result.current.loadError).toBe("注入信息暂不可用");
expect(result.current.data).toBeNull();
});
@@ -81,7 +82,7 @@ describe("useInjection", () => {
expect(put).toHaveBeenCalledTimes(1);
expect(result.current.data).toEqual(updated);
expect(result.current.saving).toBe(false);
expect(result.current.error).toBeNull();
expect(result.current.saveError).toBeNull();
});
it("exclude / restore / setRecentN 各自经 PUT 提交覆盖", async () => {
@@ -121,7 +122,7 @@ describe("useInjection", () => {
await result.current.togglePin(CHAR);
});
expect(result.current.error).toBe("保存注入设置失败,请重试");
expect(result.current.saveError).toBe("保存注入设置失败,请重试");
expect(result.current.data).toEqual(loaded);
});
@@ -137,10 +138,60 @@ describe("useInjection", () => {
await result.current.togglePin(CHAR);
});
expect(result.current.error).toBe("保存注入设置失败,请重试");
expect(result.current.saveError).toBe("保存注入设置失败,请重试");
expect(result.current.data).toEqual(loaded);
});
it("保存失败不清空 data且 loadError 与 saveError 互不影响", async () => {
const loaded = makeResponse({ recent_n: 4 });
get.mockResolvedValue({ data: loaded, error: null });
put.mockResolvedValue({ data: null, error: { detail: "422" } });
const { result } = renderHook(() => useInjection("p1", 3));
await waitFor(() => expect(result.current.loading).toBe(false));
// 加载成功 → loadError 为空。
expect(result.current.loadError).toBeNull();
await act(async () => {
await result.current.togglePin(CHAR);
});
// 保存失败只设 saveErrordata 与 loadError 不受影响。
expect(result.current.saveError).toBe("保存注入设置失败,请重试");
expect(result.current.loadError).toBeNull();
expect(result.current.data).toEqual(loaded);
// clearSaveError 关闭悬浮提示data 仍在。
act(() => result.current.clearSaveError());
expect(result.current.saveError).toBeNull();
expect(result.current.data).toEqual(loaded);
});
it("retrySave 重放上一次失败的覆盖,成功后回放 data、清空 saveError", async () => {
const loaded = makeResponse({ recent_n: 4 });
get.mockResolvedValue({ data: loaded, error: null });
put.mockResolvedValueOnce({ data: null, error: { detail: "422" } });
const updated = makeResponse({ recent_n: 4, pinned: [{ ...CHAR }] });
put.mockResolvedValueOnce({ data: updated, error: null });
const { result } = renderHook(() => useInjection("p1", 3));
await waitFor(() => expect(result.current.loading).toBe(false));
await act(async () => {
await result.current.togglePin(CHAR);
});
expect(result.current.saveError).toBe("保存注入设置失败,请重试");
await act(async () => {
await result.current.retrySave();
});
expect(put).toHaveBeenCalledTimes(2);
expect(result.current.saveError).toBeNull();
expect(result.current.data).toEqual(updated);
});
it("data 未就绪时 mutate 早返回、不发 PUT", async () => {
get.mockResolvedValue({ data: null, error: { detail: "fail" } });

View File

@@ -18,7 +18,12 @@ export interface UseInjection {
data: InjectionResponse | null;
loading: boolean;
saving: boolean;
error: string | null;
// 首次加载失败(满屏错误框)与每次保存失败(悬浮提示)独立追踪,互不覆盖。
loadError: string | null;
saveError: string | null;
clearSaveError: () => void;
reload: () => void;
retrySave: () => Promise<void>;
togglePin: (ref: InjectionEntityRef) => Promise<void>;
exclude: (ref: InjectionEntityRef) => Promise<void>;
restore: (ref: InjectionEntityRef) => Promise<void>;
@@ -35,14 +40,20 @@ export function useInjection(projectId: string, chapterNo: number): UseInjection
const [data, setData] = useState<InjectionResponse | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
const [saveError, setSaveError] = useState<string | null>(null);
// 重载计数loadError「重试」自增 → 触发 effect 重新拉取(不需要把 loader 提到 effect 外)。
const [reloadNonce, setReloadNonce] = useState(0);
// 并发锁:保存在途时 ref 同步置位,拦下连点/快速切换造成的乱序覆盖state 异步不可靠)。
const savingRef = useRef(false);
// 记下最近一次提交的覆盖,供 saveError「重试」重放不重复构造 override
const lastOverrideRef = useRef<InjectionOverrideRequest | null>(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
setLoadError(null);
setSaveError(null);
void (async () => {
try {
@@ -51,7 +62,7 @@ export function useInjection(projectId: string, chapterNo: number): UseInjection
});
if (cancelled) return;
if (err || !body) {
setError("注入信息暂不可用");
setLoadError("注入信息暂不可用");
setData(null);
} else {
setData(body);
@@ -59,7 +70,7 @@ export function useInjection(projectId: string, chapterNo: number): UseInjection
} catch {
// 网络层失败(后端不可达/CORSopenapi-fetch 直接抛而非返回 error 信封。
if (cancelled) return;
setError("注入信息暂不可用");
setLoadError("注入信息暂不可用");
setData(null);
} finally {
if (!cancelled) setLoading(false);
@@ -69,28 +80,30 @@ export function useInjection(projectId: string, chapterNo: number): UseInjection
return () => {
cancelled = true;
};
}, [projectId, chapterNo]);
}, [projectId, chapterNo, reloadNonce]);
const save = useCallback(
async (override: InjectionOverrideRequest) => {
// 已有保存在途 → 早返回(避免并发 PUT 互相覆盖、回放结果乱序)。
if (savingRef.current) return;
lastOverrideRef.current = override;
savingRef.current = true;
setSaving(true);
setError(null);
setSaveError(null);
try {
const { data: body, error: err } = await api.PUT(INJECTION_PATH, {
params: { path: { project_id: projectId, chapter_no: chapterNo } },
body: override,
});
if (err || !body) {
setError("保存注入设置失败,请重试");
// 保存失败:保留已加载的 data仅设保存态错误天然回滚 + 可重试)。
setSaveError("保存注入设置失败,请重试");
} else {
setData(body); // 以服务端确定结果为准(不变量 #6
}
} catch {
// 网络层失败:本地状态不动(天然回滚),给可读文案、不抛。
setError("保存注入设置失败,请重试");
setSaveError("保存注入设置失败,请重试");
} finally {
savingRef.current = false;
setSaving(false);
@@ -124,6 +137,26 @@ export function useInjection(projectId: string, chapterNo: number): UseInjection
(n: number) => mutate((o) => withRecentN(o, n))(),
[mutate],
);
const clearSaveError = useCallback(() => setSaveError(null), []);
const reload = useCallback(() => setReloadNonce((n) => n + 1), []);
const retrySave = useCallback(async () => {
const override = lastOverrideRef.current;
if (!override) return;
await save(override);
}, [save]);
return { data, loading, saving, error, togglePin, exclude, restore, setRecentN };
return {
data,
loading,
saving,
loadError,
saveError,
clearSaveError,
reload,
retrySave,
togglePin,
exclude,
restore,
setRecentN,
};
}

View File

@@ -26,6 +26,9 @@ const config: Config = {
sans: ['"Noto Sans SC"', '"PingFang SC"', "system-ui", "sans-serif"],
mono: ['"JetBrains Mono"', "ui-monospace"],
},
fontSize: {
"2xs": ["0.6875rem", { lineHeight: "1rem" }],
},
borderRadius: { DEFAULT: "6px" },
boxShadow: { paper: "0 1px 3px var(--shadow-paper)" },
maxWidth: { prose: "720px" },

File diff suppressed because one or more lines are too long

View File

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