Compare commits
18 Commits
90a66437d7
...
6854dac98f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6854dac98f | ||
|
|
164e98887e | ||
|
|
6bb3559e25 | ||
|
|
9d67f237a2 | ||
|
|
68606b84dc | ||
|
|
f0f925b195 | ||
|
|
69030eacc3 | ||
|
|
e82aff7067 | ||
|
|
a22a16c9c4 | ||
|
|
69f3c2a29b | ||
|
|
d7fcb8a329 | ||
|
|
47eb42f274 | ||
|
|
f8cabc92e6 | ||
|
|
ffe9132f7d | ||
|
|
4afbbe0fe7 | ||
|
|
ab3f9e17b1 | ||
|
|
a2849bf6b7 | ||
|
|
dd80b6815d |
@@ -238,3 +238,4 @@ T0.1 monorepo 骨架 ✅ @devops · T0.2 16 MVP 表迁移(无漂移,users st
|
||||
- [2026-06-22] @backend — **✅ T6.2+T6.3 创作工具箱通用端点稳定**(通用生成器框架后端落地,分支 `feat/t6-creation-toolbox`)。新 `ww_skills.TOOLBOX`(11 条:legacy 3 + 新 8) + `get_tool`(`toolbox_registry.py`,descriptor 在代码无迁移)。3 新端点(`routers/toolbox.py`,已注册):`GET /skills/toolbox`(描述符列表,legacy 携 `legacy_route`:`codex?gen=world|character` / `/outline`)、`POST .../skills/{tool_key}/generate`(按 `spec.tier` 经新注入缝 `get_tier_gateway_builder` 建网关 → `run_generator` 结构化预览,**不写库**仅落 ledger;未知/legacy→404、项目不存在→404、无凭据→503)、`POST .../skills/{tool_key}/ingest`(仅 `ingest!=None` 工具;world_entities 走 **continuity 预检 409 gate**(`acknowledge_conflicts` 放行) + `partition_writes` 白名单 + schema→JSONB 写库,outline 细纲按 idx 拼 beats upsert;不可入库→422)。PURE context 派发 `services/toolbox_context.build_toolbox_context`(4 策略,缺章/大纲→空节拍不报错)。response/request schema `schemas/toolbox.py`(concrete 子模型,非裸 dict)。守不变量 #2/#3/#9。TDD:注册表 7 + context 8 + 端点 11 = **+26 测**。**全仓门禁绿**:ruff/format 干净、mypy **183 Success**(隔离 cache)、alembic **无漂移**(无新迁移)、pytest **578 passed**。OpenAPI 新增 3 端点 → **@frontend 须 `cd apps/web && pnpm gen:api`**(T6 前端前置)。记 contracts C3 扩(T6.2/T6.3)。
|
||||
- [2026-06-20] @frontend — **Tier4 完成**(T4-a 全局 AI 工具条 + T4-b 本章指令直通生成,分支 `feat/ux-tier4-ai-toolbar`,未并 develop)。T4-a:新 `lib/nav/ai-tools.ts`(aiToolItems) + `components/AiToolbar.tsx` 服务端组件,AppShell 顶栏下仅项目页渲染(写本章/审稿/大纲/设定库/工具箱常驻一排,写本章=朱砂主动作),用 `--chrome` CSS 变量统一 chrome 高度(项目页 7rem=header4+toolbar3,余 4rem),6 个固定高度页改用 `calc(100vh-var(--chrome,4rem))` 适配。T4-b(全链):后端 `assemble/_build_volatile` 加 `directive` 形参(「本章指令」段领衔 volatile,**绝不入 stable_core**,守缓存前缀不变量 #9),`stream_draft` 加可选 body `DraftStreamRequest{directive}` 透传、`directive_len` 入 log 不记原文、不持久化无迁移,重生成 TS 客户端;前端新 `lib/workbench/directive.ts`(STYLE_PRESETS + composeDirective 纯函数)、`useDraftStream.start(p,no,directive?)` 非空时发 JSON body、Workbench 加可折叠「本章指令」textarea + 风格预设 chips。TDD:ai-tools 4 测 + directive 4 测 + assemble directive 测 + draft 端点 directive/向后兼容 2 测。门禁全绿:后端 ruff/format/mypy(163 文件)/**pytest 479 passed**;前端 lint/tsc/**vitest 213**/build。契约记 contracts C3 扩(T4-b)。
|
||||
- [2026-06-28] @backend/@frontend — **UI Phase G 后端依赖补齐**:`ProjectResponse` 增加 `updated_at` / `pending_review_count`,`chapters` 加 `updated_at` 迁移 `8c1d2e3f4a5b`;`SqlProjectRepo` 聚合项目自身、章节、审稿时间作为最近编辑,并按草稿是否晚于最近 accepted/review 统计待审稿章数。前端 `pnpm gen:api` 后项目库默认按最近编辑排序,新增“待审稿”筛选,作品卡显示最近编辑与待审稿徽标。契约记 `memory/contracts.md` C3 扩,UI 计划记 `docs/design/ui-improvement-plan.md` 0.16。
|
||||
- [2026-06-29] @frontend/@qa — **UI review follow-up 修复完成**:补 `lib/review/chapterNav.ts` 使新增章导航测试可运行;`ReviewSectionPanel` 改为本地 open 状态,避免用户折叠后重渲染强制弹开;`ReviewReport.hasReport` 纳入 clean review;新增 `tests/test_project_metadata_e2e.py` 用真实 DB 覆盖项目最近编辑/待审稿统计口径。验证:前端 lint/typecheck/vitest **490 passed**/build;后端 ruff/format/mypy、pytest **763 passed**;浏览器抽样 review/settings 无 console error/横向溢出。
|
||||
|
||||
37
apps/web/app/error.tsx
Normal file
37
apps/web/app/error.tsx
Normal 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
9
apps/web/app/loading.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
24
apps/web/app/not-found.tsx
Normal file
24
apps/web/app/not-found.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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" />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
// ForeshadowBoard(Client)按 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");
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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 .../draft,404→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 .../draft,404→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 {
|
||||
|
||||
@@ -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" />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
19
apps/web/components/BackendDownNotice.tsx
Normal file
19
apps/web/components/BackendDownNotice.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 trap:Tab/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>
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) => (
|
||||
<button
|
||||
type="button"
|
||||
key={p}
|
||||
aria-pressed={form.sellingPoints.includes(p)}
|
||||
onClick={() => toggleSellingPoint(p)}
|
||||
className={buttonClass({
|
||||
variant: form.sellingPoints.includes(p)
|
||||
? "outline"
|
||||
: "secondary",
|
||||
size: "sm",
|
||||
})}
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
))}
|
||||
{SELLING_POINT_PRESETS.map((p) => {
|
||||
const selected = form.sellingPoints.includes(p);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={p}
|
||||
aria-pressed={selected}
|
||||
onClick={() => toggleSellingPoint(p)}
|
||||
className={buttonClass({
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}
|
||||
</div>
|
||||
))}
|
||||
{/* 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>
|
||||
);
|
||||
|
||||
@@ -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,36 +47,44 @@ export function ChainAdjudication({
|
||||
const [drafts, setDrafts] = useState<DecisionDraft[]>([]);
|
||||
const [resuming, setResuming] = useState(false);
|
||||
|
||||
// 拉取 awaiting 章最近审稿冲突。抽成可复用回调,供进场 effect 与「重新加载」按钮共用。
|
||||
const load = useCallback(async (): Promise<boolean> => {
|
||||
setLoadState("loading");
|
||||
try {
|
||||
const { data, error } = await api.GET(
|
||||
"/projects/{project_id}/chapters/{chapter_no}/reviews",
|
||||
{
|
||||
params: {
|
||||
path: { project_id: projectId, chapter_no: chapterNo },
|
||||
},
|
||||
},
|
||||
);
|
||||
if (error || !data) {
|
||||
setLoadState("error");
|
||||
return false;
|
||||
}
|
||||
const next = normalizeConflicts(latestReview(data.reviews));
|
||||
setConflicts(next);
|
||||
setDrafts(emptyDecisions(next.length));
|
||||
setLoadState("ready");
|
||||
return true;
|
||||
} catch {
|
||||
setLoadState("error");
|
||||
return false;
|
||||
}
|
||||
}, [projectId, chapterNo]);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
setLoadState("loading");
|
||||
void (async () => {
|
||||
try {
|
||||
const { data, error } = await api.GET(
|
||||
"/projects/{project_id}/chapters/{chapter_no}/reviews",
|
||||
{
|
||||
params: {
|
||||
path: { project_id: projectId, chapter_no: chapterNo },
|
||||
},
|
||||
},
|
||||
);
|
||||
if (!active) return;
|
||||
if (error || !data) {
|
||||
setLoadState("error");
|
||||
return;
|
||||
}
|
||||
const next = normalizeConflicts(latestReview(data.reviews));
|
||||
setConflicts(next);
|
||||
setDrafts(emptyDecisions(next.length));
|
||||
setLoadState("ready");
|
||||
} catch {
|
||||
if (active) setLoadState("error");
|
||||
}
|
||||
// 进场加载:组件已卸载则丢弃结果(避免 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} 条冲突未裁决。
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 {
|
||||
}
|
||||
|
||||
// 命令面板(⌘K,UX §7):键盘触发的快速导航/动作。
|
||||
// 焦点管理(打开自动聚焦输入框、Esc 关闭、上下选择、回车执行)+ a11y(role=dialog/listbox)。
|
||||
// WAI-ARIA combobox/listbox:input role=combobox(aria-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 trap:Tab/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)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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={
|
||||
<Button
|
||||
onClick={() => setRegisterOpen(true)}
|
||||
disabled={registerOpen}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
>
|
||||
<Plus className="h-4 w-4" aria-hidden="true" />
|
||||
登记伏笔
|
||||
</Button>
|
||||
// registerOpen 时隐藏头部入口,避免与展开的表单重复(全局只一个 RegisterForm)。
|
||||
registerOpen ? undefined : (
|
||||
<Button
|
||||
onClick={() => setRegisterOpen(true)}
|
||||
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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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="grid grid-cols-1 gap-3 lg:grid-cols-2">
|
||||
{gen.entities.map((entity, i) => (
|
||||
<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">
|
||||
@@ -73,7 +157,8 @@ export function WorldGenerator({ projectId }: WorldGeneratorProps) {
|
||||
<p className="text-ink-soft">(无显式硬规则)</p>
|
||||
)}
|
||||
</article>
|
||||
))}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,79 +90,102 @@ export function OutlineEditor({
|
||||
<PageHeader
|
||||
title="大纲"
|
||||
description="按卷组织章节节拍,写作台会把当前章节拍注入生成上下文。"
|
||||
actions={
|
||||
<>
|
||||
{availableVolumes.length > 0 ? (
|
||||
<label
|
||||
htmlFor="view-vol"
|
||||
className="ml-auto 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),
|
||||
)
|
||||
}
|
||||
controlSize="sm"
|
||||
className="ml-1"
|
||||
>
|
||||
<option value="all">全部</option>
|
||||
{availableVolumes.map((v) => (
|
||||
<option key={v} value={String(v)}>
|
||||
卷 {v}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</label>
|
||||
) : null}
|
||||
<label
|
||||
htmlFor="vol"
|
||||
className={`text-xs text-ink-soft${availableVolumes.length > 0 ? "" : " ml-auto"}`}
|
||||
>
|
||||
卷号
|
||||
</label>
|
||||
<TextInput
|
||||
id="vol"
|
||||
inputMode="numeric"
|
||||
value={volume}
|
||||
onChange={(e) =>
|
||||
setVolume(Math.max(1, Number(e.target.value) || 1))
|
||||
}
|
||||
controlSize="sm"
|
||||
className="w-16"
|
||||
/>
|
||||
<Button
|
||||
disabled={generating}
|
||||
onClick={() => void generate(project.id, volume)}
|
||||
variant="primary"
|
||||
>
|
||||
<Sparkles className="h-4 w-4" aria-hidden="true" />
|
||||
{generating ? "排大纲中…" : "AI 排大纲"}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 卷/视图/生成控件独立成全宽工具栏行(窄屏自动换行,不用脆弱的 ml-auto)。 */}
|
||||
<div className="mb-4 flex flex-wrap items-center gap-2">
|
||||
{availableVolumes.length > 0 ? (
|
||||
<label
|
||||
htmlFor="view-vol"
|
||||
className="flex items-center gap-1 text-xs text-ink-soft"
|
||||
>
|
||||
查看
|
||||
<Select
|
||||
id="view-vol"
|
||||
value={viewVolume === "all" ? "all" : String(viewVolume)}
|
||||
onChange={(e) =>
|
||||
onViewVolumeChange(
|
||||
e.target.value === "all" ? "all" : Number(e.target.value),
|
||||
)
|
||||
}
|
||||
controlSize="sm"
|
||||
>
|
||||
<option value="all">全部</option>
|
||||
{availableVolumes.map((v) => (
|
||||
<option key={v} value={String(v)}>
|
||||
卷 {v}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</label>
|
||||
) : null}
|
||||
|
||||
{availableVolumes.length > 0 ? (
|
||||
<span className="ml-1 h-4 w-px bg-line" aria-hidden="true" />
|
||||
) : null}
|
||||
|
||||
<label
|
||||
htmlFor="vol"
|
||||
className="flex items-center gap-1 text-xs text-ink-soft"
|
||||
>
|
||||
生成到 卷
|
||||
<TextInput
|
||||
id="vol"
|
||||
inputMode="numeric"
|
||||
value={volume}
|
||||
onChange={(e) => onVolumeChange(Number(e.target.value))}
|
||||
controlSize="sm"
|
||||
className="w-16"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<Button
|
||||
disabled={generating}
|
||||
onClick={onGenerateClick}
|
||||
variant="primary"
|
||||
>
|
||||
<Sparkles className="h-4 w-4" aria-hidden="true" />
|
||||
{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>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -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")}
|
||||
>
|
||||
<LayoutGrid className="h-4 w-4" aria-hidden="true" />
|
||||
</ViewButton>
|
||||
<ViewButton
|
||||
label="紧凑"
|
||||
active={viewMode === "compact"}
|
||||
onClick={() => setView("compact")}
|
||||
>
|
||||
<List className="h-4 w-4" aria-hidden="true" />
|
||||
</ViewButton>
|
||||
</div>
|
||||
<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" />
|
||||
<span className="sr-only">卡片</span>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "compact",
|
||||
label: (
|
||||
<>
|
||||
<List className="h-4 w-4" aria-hidden="true" />
|
||||
<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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
{/* 前景编辑层:文字透明(由背景层显示),仅保留光标;无内部滚动(页面滚动)。 */}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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,10 +561,16 @@ export function ReviewReport({
|
||||
{group.items.length}
|
||||
</Badge>
|
||||
</span>
|
||||
<span className="font-mono text-[11px] text-ink-soft">
|
||||
{unresolvedInGroup > 0
|
||||
? `${unresolvedInGroup} 未裁决`
|
||||
: "已处理"}
|
||||
<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">
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,68 +82,64 @@ 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>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
{RULE_LEVELS.map((lv) => (
|
||||
<section key={lv}>
|
||||
<h2 className="mb-2 font-serif text-sm text-ink">
|
||||
{RULE_LEVEL_LABELS[lv]}
|
||||
<span className="ml-2 text-xs text-ink-soft">
|
||||
{groups[lv].length}
|
||||
</span>
|
||||
</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"
|
||||
/>
|
||||
) : (
|
||||
<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"
|
||||
>
|
||||
<span className="min-w-0 flex-1 break-words">
|
||||
{rule.content}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
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"
|
||||
{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 flex items-center gap-2 font-serif text-sm text-ink">
|
||||
{RULE_LEVEL_LABELS[lv]}
|
||||
<Badge variant="neutral">{groups[lv].length}</Badge>
|
||||
</h2>
|
||||
{groups[lv].length === 0 ? (
|
||||
<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={cardClass(
|
||||
"flex items-start justify-between gap-3 p-3 text-sm text-ink",
|
||||
)}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" aria-hidden="true" />
|
||||
删除
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
<span className="min-w-0 flex-1 break-words">
|
||||
{rule.content}
|
||||
</span>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
disabled={deletingId === rule.id}
|
||||
onClick={() => void remove(project.id, rule.id)}
|
||||
aria-label="删除规则"
|
||||
className="shrink-0"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" aria-hidden="true" />
|
||||
{deletingId === rule.id ? "删除中…" : "删除"}
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
|
||||
@@ -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 id(datalist 建议,仍可自由输入)。按 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,17 +126,22 @@ export function ProvidersSettings({
|
||||
|
||||
const saveRouting = async (): Promise<void> => {
|
||||
setSavingRouting(true);
|
||||
const { data, error } = await api.PUT("/settings/providers", {
|
||||
body: { tier_routing: draftsToRoutingInput(routing) },
|
||||
});
|
||||
setSavingRouting(false);
|
||||
if (error || !data) {
|
||||
toast("保存档位路由失败,请重试", "error");
|
||||
return;
|
||||
try {
|
||||
const { data, error } = await api.PUT("/settings/providers", {
|
||||
body: { tier_routing: draftsToRoutingInput(routing) },
|
||||
});
|
||||
if (error || !data) {
|
||||
toast("保存档位路由失败,请重试", "error");
|
||||
return;
|
||||
}
|
||||
setProviders(data.providers ?? []);
|
||||
setRouting(toRoutingDrafts(data.tier_routing ?? []));
|
||||
toast("档位路由已保存", "success");
|
||||
} catch {
|
||||
toast("保存档位路由失败,请检查后端连接", "error");
|
||||
} finally {
|
||||
setSavingRouting(false);
|
||||
}
|
||||
setProviders(data.providers ?? []);
|
||||
setRouting(toRoutingDrafts(data.tier_routing ?? []));
|
||||
toast("档位路由已保存", "success");
|
||||
};
|
||||
|
||||
const saveCredential = async (providerId: string): Promise<void> => {
|
||||
@@ -111,64 +151,63 @@ export function ProvidersSettings({
|
||||
return;
|
||||
}
|
||||
setSavingId(providerId);
|
||||
const { data, error } = await api.PUT("/settings/providers", {
|
||||
body: { credentials: [{ provider: providerId, api_key: apiKey }] },
|
||||
});
|
||||
setSavingId(null);
|
||||
if (error || !data) {
|
||||
toast("保存凭据失败,请重试", "error");
|
||||
return;
|
||||
try {
|
||||
const { data, error } = await api.PUT("/settings/providers", {
|
||||
body: { credentials: [{ provider: providerId, api_key: apiKey }] },
|
||||
});
|
||||
if (error || !data) {
|
||||
toast("保存凭据失败,请重试", "error");
|
||||
return;
|
||||
}
|
||||
setProviders(data.providers ?? []);
|
||||
setDrafts((prev) => ({ ...prev, [providerId]: "" }));
|
||||
toast("凭据已保存", "success");
|
||||
} catch {
|
||||
toast("保存凭据失败,请检查后端连接", "error");
|
||||
} finally {
|
||||
setSavingId(null);
|
||||
}
|
||||
setProviders(data.providers ?? []);
|
||||
setDrafts((prev) => ({ ...prev, [providerId]: "" }));
|
||||
toast("凭据已保存", "success");
|
||||
};
|
||||
|
||||
const testConnection = async (providerId: string): Promise<void> => {
|
||||
setTestingId(providerId);
|
||||
const { data, error } = await api.POST("/settings/providers/test", {
|
||||
body: { provider: providerId },
|
||||
});
|
||||
setTestingId(null);
|
||||
if (error || !data) {
|
||||
toast("测试连接失败", "error");
|
||||
return;
|
||||
try {
|
||||
const { data, error } = await api.POST("/settings/providers/test", {
|
||||
body: { provider: providerId },
|
||||
});
|
||||
if (error || !data) {
|
||||
toast("测试连接失败", "error");
|
||||
return;
|
||||
}
|
||||
setResults((prev) => ({
|
||||
...prev,
|
||||
[providerId]: { ok: data.ok, capabilities: data.capabilities },
|
||||
}));
|
||||
toast(
|
||||
data.ok ? "连接成功" : "连接未通过",
|
||||
data.ok ? "success" : "error",
|
||||
);
|
||||
} catch {
|
||||
toast("测试连接失败,请检查后端连接", "error");
|
||||
} finally {
|
||||
setTestingId(null);
|
||||
}
|
||||
setResults((prev) => ({
|
||||
...prev,
|
||||
[providerId]: { ok: data.ok, capabilities: data.capabilities },
|
||||
}));
|
||||
toast(data.ok ? "连接成功" : "连接未通过", data.ok ? "success" : "error");
|
||||
};
|
||||
|
||||
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"
|
||||
>
|
||||
<SettingsNavButton
|
||||
active={activeSection === "routing"}
|
||||
icon={<Route className="h-4 w-4" aria-hidden="true" />}
|
||||
label="档位路由"
|
||||
detail={`${routing.filter((r) => r.provider && r.model).length}/3 已配置`}
|
||||
onClick={() => setActiveSection("routing")}
|
||||
/>
|
||||
<SettingsNavButton
|
||||
active={activeSection === "oauth"}
|
||||
icon={<PlugZap className="h-4 w-4" aria-hidden="true" />}
|
||||
label="OAuth"
|
||||
detail={kimiOauth.connected ? "Kimi 已连接" : "未连接"}
|
||||
onClick={() => setActiveSection("oauth")}
|
||||
/>
|
||||
<SettingsNavButton
|
||||
active={activeSection === "keys"}
|
||||
icon={<KeyRound className="h-4 w-4" aria-hidden="true" />}
|
||||
label="API Key"
|
||||
detail={`${providers.length} 个凭据`}
|
||||
onClick={() => setActiveSection("keys")}
|
||||
/>
|
||||
<nav aria-label="设置分组" className={cardClass("sticky top-20 p-2")}>
|
||||
{SETTINGS_SECTIONS.map((section) => (
|
||||
<SettingsNavButton
|
||||
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,15 +259,23 @@ export function ProvidersSettings({
|
||||
<label className="sr-only" htmlFor={`route-model-${row.tier}`}>
|
||||
{TIER_LABELS[row.tier] ?? row.tier} 模型
|
||||
</label>
|
||||
<TextInput
|
||||
id={`route-model-${row.tier}`}
|
||||
value={row.model}
|
||||
onChange={(e) =>
|
||||
updateRoutingModel(row.tier, e.target.value)
|
||||
}
|
||||
placeholder="model"
|
||||
className="font-mono"
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<TextInput
|
||||
id={`route-model-${row.tier}`}
|
||||
value={row.model}
|
||||
onChange={(e) =>
|
||||
updateRoutingModel(row.tier, e.target.value)
|
||||
}
|
||||
placeholder="model"
|
||||
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>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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" ? (
|
||||
<p className="text-info">
|
||||
<ThinkingIndicator label="回炉中" />
|
||||
</p>
|
||||
) : refiner.status === "error" ? (
|
||||
<p className="text-conflict">回炉失败,请稍后重试。</p>
|
||||
) : refiner.result ? (
|
||||
<div className="space-y-2">
|
||||
) : (
|
||||
<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 ? (
|
||||
<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">
|
||||
|
||||
@@ -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,21 +29,31 @@ 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>
|
||||
<StyleUpload
|
||||
busy={learn.busy}
|
||||
pollStatus={learn.pollStatus === "idle" ? "idle" : learn.pollStatus}
|
||||
progress={learn.progress}
|
||||
hasFingerprint={learn.fingerprint !== null}
|
||||
onLearn={onLearn}
|
||||
<div className="flex min-h-[calc(100vh-var(--chrome,4rem))] flex-col">
|
||||
<div className="px-6 pt-6">
|
||||
<PageHeader
|
||||
title="文风指纹"
|
||||
description="上传你的代表作样本,让 AI 学出十六维文风指纹,作为后续写作与第四审的对照标尺。"
|
||||
/>
|
||||
</section>
|
||||
</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
|
||||
}
|
||||
progress={learn.progress}
|
||||
hasFingerprint={learn.fingerprint !== null}
|
||||
onLearn={onLearn}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="overflow-auto bg-bg px-6 py-6">
|
||||
<FingerprintView fingerprint={learn.fingerprint} />
|
||||
</section>
|
||||
<section className="bg-bg px-6 py-6 lg:overflow-auto">
|
||||
<FingerprintView fingerprint={learn.fingerprint} />
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,60 +44,102 @@ 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 ? (
|
||||
<RecentStepper
|
||||
value={data.recent_n}
|
||||
disabled={injection.saving}
|
||||
onChange={injection.setRecentN}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<SectionHeader
|
||||
title="本章注入(透明)"
|
||||
action={
|
||||
data ? (
|
||||
<RecentStepper
|
||||
value={data.recent_n}
|
||||
disabled={injection.saving}
|
||||
onChange={injection.setRecentN}
|
||||
/>
|
||||
) : 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 ? (
|
||||
<p className="mt-2 rounded border border-dashed border-line bg-bg p-3 text-xs text-ink-soft">
|
||||
本章暂无注入实体(先在大纲点名设定/角色,或从下方排除项恢复)。
|
||||
</p>
|
||||
) : !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>
|
||||
) : (
|
||||
<ul className="mt-2 space-y-2">
|
||||
{selected.map((entity) => (
|
||||
<EntityRow
|
||||
key={`${entity.kind}:${entity.name}`}
|
||||
entity={entity}
|
||||
pinned={isPinned(
|
||||
{ pinned: data?.pinned, excluded: data?.excluded, recent_n: data?.recent_n },
|
||||
entity,
|
||||
)}
|
||||
disabled={injection.saving}
|
||||
onTogglePin={injection.togglePin}
|
||||
onExclude={injection.exclude}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
<>
|
||||
{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>
|
||||
) : (
|
||||
<ul className="mt-2 space-y-2">
|
||||
{selected.map((entity) => (
|
||||
<EntityRow
|
||||
key={`${entity.kind}:${entity.name}`}
|
||||
entity={entity}
|
||||
pinned={isPinned(
|
||||
{ pinned: data.pinned, excluded: data.excluded, recent_n: data.recent_n },
|
||||
entity,
|
||||
)}
|
||||
disabled={injection.saving}
|
||||
onTogglePin={injection.togglePin}
|
||||
onExclude={injection.exclude}
|
||||
/>
|
||||
))}
|
||||
</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>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)。 */}
|
||||
{/* 窄屏 / 中屏(1024–1280):目录 / 本章助手经抽屉触达;≥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"
|
||||
? "保存中…"
|
||||
|
||||
@@ -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("⚑");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 ⚑ 可回收)。
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
// 不再拍平成固定的内部 code,UI 据 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→去设置 provider;422→校验文案),
|
||||
// 而非永远的通用「大纲生成失败」(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");
|
||||
},
|
||||
|
||||
45
apps/web/lib/review/chapterNav.test.ts
Normal file
45
apps/web/lib/review/chapterNav.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { reviewChapterHref, reviewChapterOptions } from "./chapterNav";
|
||||
import type { ChapterEntry } from "@/lib/workbench/chapter";
|
||||
|
||||
describe("reviewChapterHref", () => {
|
||||
it("builds the review route for a chapter number", () => {
|
||||
expect(reviewChapterHref("proj-1", 3)).toBe(
|
||||
"/projects/proj-1/review?chapter=3",
|
||||
);
|
||||
});
|
||||
|
||||
it("always targets the review page (not write)", () => {
|
||||
expect(reviewChapterHref("p", 1)).toContain("/review?chapter=1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("reviewChapterOptions", () => {
|
||||
const chapters: ChapterEntry[] = [
|
||||
{ no: 1, title: "开篇" },
|
||||
{ no: 2 },
|
||||
{ no: 3, title: "转折" },
|
||||
];
|
||||
|
||||
it("merges outline chapters with the current chapter, sorted ascending", () => {
|
||||
const opts = reviewChapterOptions(chapters, 2);
|
||||
expect(opts.map((o) => o.no)).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it("includes the current chapter even when it is outside the outline", () => {
|
||||
const opts = reviewChapterOptions(chapters, 9);
|
||||
expect(opts.map((o) => o.no)).toEqual([1, 2, 3, 9]);
|
||||
});
|
||||
|
||||
it("labels each option with chapter number and optional title", () => {
|
||||
const opts = reviewChapterOptions(chapters, 1);
|
||||
expect(opts[0]).toEqual({ no: 1, label: "第 1 章 · 开篇" });
|
||||
expect(opts[1]).toEqual({ no: 2, label: "第 2 章" });
|
||||
});
|
||||
|
||||
it("returns just the current chapter when there is no outline", () => {
|
||||
const opts = reviewChapterOptions([], 4);
|
||||
expect(opts).toEqual([{ no: 4, label: "第 4 章" }]);
|
||||
});
|
||||
});
|
||||
23
apps/web/lib/review/chapterNav.ts
Normal file
23
apps/web/lib/review/chapterNav.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { ChapterEntry } from "@/lib/workbench/chapter";
|
||||
import { buildChapterEntries } from "@/lib/workbench/chapter";
|
||||
|
||||
export interface ReviewChapterOption {
|
||||
no: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export function reviewChapterHref(projectId: string, chapterNo: number): string {
|
||||
return `/projects/${projectId}/review?chapter=${chapterNo}`;
|
||||
}
|
||||
|
||||
export function reviewChapterOptions(
|
||||
chapters: readonly ChapterEntry[],
|
||||
currentChapterNo: number,
|
||||
): ReviewChapterOption[] {
|
||||
return buildChapterEntries(chapters, currentChapterNo).map((chapter) => ({
|
||||
no: chapter.no,
|
||||
label: chapter.title
|
||||
? `第 ${chapter.no} 章 · ${chapter.title}`
|
||||
: `第 ${chapter.no} 章`,
|
||||
}));
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 }));
|
||||
}
|
||||
|
||||
41
apps/web/lib/ui/useBodyScrollLock.test.ts
Normal file
41
apps/web/lib/ui/useBodyScrollLock.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
20
apps/web/lib/ui/useBodyScrollLock.ts
Normal file
20
apps/web/lib/ui/useBodyScrollLock.ts
Normal 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]);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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: "",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
// 保存失败只设 saveError;data 与 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" } });
|
||||
|
||||
|
||||
@@ -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 {
|
||||
// 网络层失败(后端不可达/CORS):openapi-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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
160
tests/test_project_metadata_e2e.py
Normal file
160
tests/test_project_metadata_e2e.py
Normal file
@@ -0,0 +1,160 @@
|
||||
"""Project list metadata uses real DB state for activity and pending review counts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from collections.abc import AsyncIterator
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
from ww_core.domain.project_repo import SqlProjectRepo
|
||||
from ww_db import get_sessionmaker
|
||||
from ww_db.models import Chapter, ChapterReview, Project, User
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def sm() -> AsyncIterator[async_sessionmaker[AsyncSession]]:
|
||||
get_sessionmaker.cache_clear()
|
||||
maker = get_sessionmaker()
|
||||
try:
|
||||
async with maker() as probe:
|
||||
await probe.execute(select(1))
|
||||
except Exception:
|
||||
pytest.skip("postgres not reachable")
|
||||
yield maker
|
||||
await maker.kw["bind"].dispose()
|
||||
get_sessionmaker.cache_clear()
|
||||
|
||||
|
||||
async def _cleanup(
|
||||
sm: async_sessionmaker[AsyncSession], owner_id: uuid.UUID, project_id: uuid.UUID
|
||||
) -> None:
|
||||
async with sm() as session:
|
||||
await session.execute(delete(ChapterReview).where(ChapterReview.project_id == project_id))
|
||||
await session.execute(delete(Chapter).where(Chapter.project_id == project_id))
|
||||
await session.execute(delete(Project).where(Project.id == project_id))
|
||||
await session.execute(delete(User).where(User.id == owner_id))
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def _project_view(
|
||||
sm: async_sessionmaker[AsyncSession], owner_id: uuid.UUID, project_id: uuid.UUID
|
||||
):
|
||||
async with sm() as session:
|
||||
view = await SqlProjectRepo(session).get(owner_id, project_id)
|
||||
assert view is not None
|
||||
listed = await SqlProjectRepo(session).list_for_owner(owner_id)
|
||||
listed_view = next((item for item in listed if item.id == project_id), None)
|
||||
assert listed_view is not None
|
||||
assert listed_view.pending_review_count == view.pending_review_count
|
||||
assert listed_view.updated_at == view.updated_at
|
||||
return view
|
||||
|
||||
|
||||
async def test_project_metadata_tracks_review_and_acceptance_state(
|
||||
sm: async_sessionmaker[AsyncSession],
|
||||
) -> None:
|
||||
owner_id = uuid.uuid4()
|
||||
project_id = uuid.uuid4()
|
||||
base = datetime(2026, 6, 1, tzinfo=UTC)
|
||||
|
||||
await _cleanup(sm, owner_id, project_id)
|
||||
try:
|
||||
async with sm() as session:
|
||||
session.add(
|
||||
User(
|
||||
id=owner_id,
|
||||
email=f"{owner_id}@example.test",
|
||||
display_name="metadata test",
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
async with sm() as session:
|
||||
session.add(
|
||||
Project(
|
||||
id=project_id,
|
||||
owner_id=owner_id,
|
||||
title="元数据测试",
|
||||
created_at=base,
|
||||
updated_at=base,
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
async with sm() as session:
|
||||
session.add(
|
||||
Chapter(
|
||||
project_id=project_id,
|
||||
volume=1,
|
||||
chapter_no=1,
|
||||
content="草稿一",
|
||||
status="draft",
|
||||
version=1,
|
||||
created_at=base + timedelta(hours=1),
|
||||
updated_at=base + timedelta(hours=1),
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
view = await _project_view(sm, owner_id, project_id)
|
||||
assert view.pending_review_count == 1
|
||||
assert view.updated_at == base + timedelta(hours=1)
|
||||
|
||||
async with sm() as session:
|
||||
session.add(
|
||||
ChapterReview(
|
||||
project_id=project_id,
|
||||
chapter_no=1,
|
||||
chapter_version=1,
|
||||
conflicts=[],
|
||||
foreshadow_sug=[],
|
||||
created_at=base + timedelta(hours=2),
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
view = await _project_view(sm, owner_id, project_id)
|
||||
assert view.pending_review_count == 0
|
||||
assert view.updated_at == base + timedelta(hours=2)
|
||||
|
||||
async with sm() as session:
|
||||
session.add(
|
||||
Chapter(
|
||||
project_id=project_id,
|
||||
volume=1,
|
||||
chapter_no=1,
|
||||
content="终稿一",
|
||||
status="accepted",
|
||||
version=2,
|
||||
created_at=base + timedelta(hours=3),
|
||||
updated_at=base + timedelta(hours=3),
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
view = await _project_view(sm, owner_id, project_id)
|
||||
assert view.pending_review_count == 0
|
||||
assert view.updated_at == base + timedelta(hours=3)
|
||||
|
||||
async with sm() as session:
|
||||
draft = (
|
||||
await session.execute(
|
||||
select(Chapter).where(
|
||||
Chapter.project_id == project_id,
|
||||
Chapter.chapter_no == 1,
|
||||
Chapter.version == 1,
|
||||
)
|
||||
)
|
||||
).scalar_one()
|
||||
draft.content = "验收后再改草稿"
|
||||
draft.updated_at = base + timedelta(hours=4)
|
||||
await session.commit()
|
||||
|
||||
view = await _project_view(sm, owner_id, project_id)
|
||||
assert view.pending_review_count == 1
|
||||
assert view.updated_at == base + timedelta(hours=4)
|
||||
finally:
|
||||
await _cleanup(sm, owner_id, project_id)
|
||||
Reference in New Issue
Block a user