Merge branch 'feat/ui-uplift-claude-designmd' into develop

UI 提升对标 Claude DESIGN.md(P1 地基 / P2 组件 / P3 页面 / P4 无障碍,共 33 项)
+ 应用内帮助页 /help(功能介绍 + 新手上手,已按代码实现核验)
+ README 更新。前端门禁全绿(vitest 732 · lint/typecheck/build ✓)。
This commit is contained in:
Yaojia Wang
2026-07-12 04:59:14 +02:00
53 changed files with 2697 additions and 1022 deletions

View File

@@ -10,7 +10,7 @@
- **引导式立项** — 一句灵感走向连载:选题材 / 基调 / 结局走向 / 叙事视角AI 起草世界观、主角、金手指、总纲方案,一键建库。 - **引导式立项** — 一句灵感走向连载:选题材 / 基调 / 结局走向 / 叙事视角AI 起草世界观、主角、金手指、总纲方案,一键建库。
- **设定库Codex** — 结构化的单一真相源: - **设定库Codex** — 结构化的单一真相源:
- 角色卡(外貌 / 动机 / 目标 / 口癖 / 性格弧光 / 人物关系图) - 角色卡(外貌 / 动机 / 性格 / 背景 / 口癖 / 性格弧光 / 人物关系图)
- 世界观(力量体系 / 势力 / 地理 / 词条) - 世界观(力量体系 / 势力 / 地理 / 词条)
- **大纲 / 细纲** — AI 排分卷分章骨架,逐层细化到场景清单,并提示伏笔回收窗口。 - **大纲 / 细纲** — AI 排分卷分章骨架,逐层细化到场景清单,并提示伏笔回收窗口。
- **写章SSE 流式)** — 注入设定 + 文风指纹,按 genre-aware 的网文写作教条(黄金三章、章末钩子、爽点密度)流式产出本章草稿。 - **写章SSE 流式)** — 注入设定 + 文风指纹,按 genre-aware 的网文写作教条(黄金三章、章末钩子、爽点密度)流式产出本章草稿。
@@ -19,6 +19,7 @@
- **创作工具箱15 个生成器)** — 声明式 skill 框架,「加生成器 = 加一份声明」:脑洞 / 书名 / 简介 / 取名 / 金手指 / 词条 / 黄金开篇 / 细纲 / 续写 / 扩写 / 降 AI / 拆书,外加世界观 / 角色 / 大纲。 - **创作工具箱15 个生成器)** — 声明式 skill 框架,「加生成器 = 加一份声明」:脑洞 / 书名 / 简介 / 取名 / 金手指 / 词条 / 黄金开篇 / 细纲 / 续写 / 扩写 / 降 AI / 拆书,外加世界观 / 角色 / 大纲。
- **模板库** — 复用与沉淀常用创作模板。 - **模板库** — 复用与沉淀常用创作模板。
- **多 Provider LLM 网关** — 路由 / 回退 / 熔断的自建薄网关Agent 只声明能力 tier网关按配置映射到 provider+model支持 Kimi Code 订阅 OAuthdevice flow - **多 Provider LLM 网关** — 路由 / 回退 / 熔断的自建薄网关Agent 只声明能力 tier网关按配置映射到 provider+model支持 Kimi Code 订阅 OAuthdevice flow
- **沉浸式写作体验** — 纸感暖色 + 夜读双主题(`data-theme` 切换)、写作台专注模式(隐藏侧栏加宽正文)、`⌘K` 命令面板(跨页跳转 / 执行命令)、应用内帮助页 [`/help`](http://localhost:4000/help)(功能介绍 + 新手上手。UI 对标编辑部式暖奶油设计语言,全程尊重 `prefers-reduced-motion` 与 WCAG AA。
--- ---

View File

@@ -5,12 +5,26 @@
/* 纸感设计 tokenUX_SPEC §2.1 */ /* 纸感设计 tokenUX_SPEC §2.1 */
:root, :root,
[data-theme="paper"] { [data-theme="paper"] {
/* 面色阶:底 → 分隔带 → 内容卡(比底暗一步)→ 选中强调 → 浮起面板 */
--color-bg: #f5f1e8; --color-bg: #f5f1e8;
--color-surface-soft: #efe9dc;
--color-surface-card: #ece4d3;
--color-surface-strong: #e4d9c4;
--color-panel: #fbf8f1; --color-panel: #fbf8f1;
/* 文字色阶5 级):标题 → 强调段 → 正文 → 次级 → 说明/细则 */
--color-ink: #2b2620; --color-ink: #2b2620;
--color-body-strong: #3a342b;
--color-body: #4a4234;
--color-ink-soft: #6b6356; --color-ink-soft: #6b6356;
/* muted-soft 达 WCAG AA 小字≥4.5:1于 bg/card/panel/soft 各面P4-1。 */
--color-muted-soft: #6f6556;
/* 边框:主边框 + 同带内更弱分隔 */
--color-line: #e5ddcd; --color-line: #e5ddcd;
--color-line-soft: #ece4d5;
/* 朱砂 coral主色 + 按压变深 + 奶油化禁用 + 淡底 */
--color-cinnabar: #a23b2e; --color-cinnabar: #a23b2e;
--color-cinnabar-active: #8a2f24;
--color-cinnabar-disabled: #d9cdbd;
--color-cinnabar-wash: #a23b2e14; --color-cinnabar-wash: #a23b2e14;
--color-conflict: #b5543a; --color-conflict: #b5543a;
--color-overdue: #c8893a; --color-overdue: #c8893a;
@@ -18,17 +32,42 @@
--color-info: #4a5a6b; --color-info: #4a5a6b;
--color-conflict-mark: #b5543a26; --color-conflict-mark: #b5543a26;
--color-conflict-mark-strong: #b5543a8c; --color-conflict-mark-strong: #b5543a8c;
/* 暖深色 callout仅用于英雄/CTA 时刻(对标 DESIGN.md cta-band-dark非内容底。 */
--color-callout: #201d18;
--color-on-callout: #f2ede2;
--color-on-callout-soft: #b8ae9c;
--shadow-paper: #2b26200f; --shadow-paper: #2b26200f;
color-scheme: light; color-scheme: light;
} }
/* 动效 token与主题无关单处定义夜读模式不覆盖 */
:root {
--ease-standard: cubic-bezier(0.2, 0, 0, 1);
--dur-fast: 120ms;
--dur-base: 180ms;
}
[data-theme="night"] { [data-theme="night"] {
/* 面色阶:底 → 分隔带 → 浮起面板 → 内容卡(暗面向上抬)→ 选中强调 */
--color-bg: #181614; --color-bg: #181614;
--color-surface-soft: #1e1b17;
--color-surface-card: #2a2620;
--color-surface-strong: #332e26;
--color-panel: #221f1b; --color-panel: #221f1b;
/* 文字色阶5 级) */
--color-ink: #efe6d7; --color-ink: #efe6d7;
--color-body-strong: #ddd2c0;
--color-body: #cdc0ab;
--color-ink-soft: #b9ab96; --color-ink-soft: #b9ab96;
/* muted-soft 达 AA 小字≥4.5:1于夜读 bg/card/soft 各面P4-1。 */
--color-muted-soft: #9a8e7c;
/* 边框 */
--color-line: #3c352c; --color-line: #3c352c;
--color-line-soft: #2f2a23;
/* 朱砂 coral */
--color-cinnabar: #e07866; --color-cinnabar: #e07866;
--color-cinnabar-active: #c85f4d;
--color-cinnabar-disabled: #3c352c;
--color-cinnabar-wash: #e0786620; --color-cinnabar-wash: #e0786620;
--color-conflict: #ef8a72; --color-conflict: #ef8a72;
--color-overdue: #d8a65d; --color-overdue: #d8a65d;
@@ -36,6 +75,10 @@
--color-info: #8ca8ca; --color-info: #8ca8ca;
--color-conflict-mark: #ef8a7230; --color-conflict-mark: #ef8a7230;
--color-conflict-mark-strong: #ef8a7290; --color-conflict-mark-strong: #ef8a7290;
/* 夜读下 callout 为更深的凹陷暖井,仍与 bg 拉开层次。 */
--color-callout: #100e0c;
--color-on-callout: #efe6d7;
--color-on-callout-soft: #a89d8a;
--shadow-paper: #00000045; --shadow-paper: #00000045;
color-scheme: dark; color-scheme: dark;
} }
@@ -91,6 +134,21 @@ body {
} }
} }
/* Toast 入场:淡入 + 轻微上移(尊重 prefers-reduced-motion见下。 */
.toast-enter {
animation: toast-in var(--dur-base) var(--ease-standard);
}
@keyframes toast-in {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.typewriter-cursor { .typewriter-cursor {
animation: none; animation: none;
@@ -102,4 +160,7 @@ body {
animation: none; animation: none;
outline: 2px solid var(--color-conflict); outline: 2px solid var(--color-conflict);
} }
.toast-enter {
animation: none;
}
} }

298
apps/web/app/help/page.tsx Normal file
View File

@@ -0,0 +1,298 @@
import Link from "next/link";
import type { LucideIcon } from "lucide-react";
import {
BookMarked,
BookOpen,
Blocks,
CheckCircle2,
ClipboardCheck,
FileText,
Flag,
LayoutGrid,
ListTree,
Moon,
PanelLeftClose,
PenLine,
Route,
Search,
Settings,
ShieldCheck,
Sparkles,
} from "lucide-react";
import { AppShell } from "@/components/AppShell";
import { Badge } from "@/components/ui/Badge";
import { Card } from "@/components/ui/Card";
import { Eyebrow } from "@/components/ui/Eyebrow";
import { PageContainer } from "@/components/ui/PageContainer";
import { buttonClass } from "@/lib/ui/variants";
// 帮助中心:功能介绍 + 新手上手静态内容Server Component
interface Step {
icon: LucideIcon;
title: string;
body: string;
}
const STEPS: Step[] = [
{
icon: PenLine,
title: "立项,或直接开始写",
body: "从一句灵感开始:「先立项」立好核心卖点与一句话故事,再到设定库、大纲把架构搭起来;或「直接开始写」落笔即成书,边写边补设定。",
},
{
icon: Sparkles,
title: "让 AI 写这章",
body: "在写作台告诉 AI 这章想怎么写,它对着大纲给出草稿(流式出稿)。你可以续写接着写、润色选段、或整章重写——原文永远你说了算。",
},
{
icon: ClipboardCheck,
title: "四审,然后验收",
body: "写完点「审稿」AI 从一致性 / 伏笔 / 文风 / 节奏四个维度逐条检查、就地标注冲突。你裁决后验收,系统更新真相源与伏笔账本。",
},
];
interface Principle {
title: string;
body: string;
}
const PRINCIPLES: Principle[] = [
{
title: "把写小说当软件工程",
body: "先立架构(世界观 + 人物 + 大纲),再对着规格逐章生成,每章写完即测,全程维护单一真相源。不让 AI 一口气瞎写十万字。",
},
{
title: "单一真相源 · 记忆不衰减",
body: "设定、状态、伏笔集中记账AI 写每一章都按需读取相关记忆(确定性选择,非一次性塞满上下文),对抗长篇遗忘。",
},
{
title: "你始终掌控",
body: "AI 是灵感增幅器 + 质检员,不是全自动黑箱。四审只读、只给建议,任何写入都要你裁决采纳,不会静默覆盖你的原文。",
},
];
interface Feature {
icon: LucideIcon;
name: string;
desc: string;
}
interface FeatureGroup {
label: string;
features: Feature[];
}
const FEATURE_GROUPS: FeatureGroup[] = [
{
label: "写作",
features: [
{
icon: PenLine,
name: "写作台",
desc: "对着大纲逐章生成、流式出稿;续写接着写、润色选段、整章重写,随时进入专注模式沉浸写作。",
},
],
},
{
label: "资料库",
features: [
{ icon: ListTree, name: "大纲", desc: "卷 → 章 → 节拍逐层拆解;可用「细纲生成器」把某章展开为场景,入库时并入该章节拍。" },
{ icon: BookMarked, name: "设定库", desc: "世界观圣经 + 人物卡(动机、人物弧光,像接口约束行为)+ 关系图谱。" },
{ icon: Flag, name: "伏笔", desc: "「埋设 → 回收」的显式账本,逾期未收进「已逾期」泳道并标记,不再漏坑。" },
{ icon: FileText, name: "文风", desc: "抽取你的文风指纹并注入生成,抵抗机翻腔与风格漂移。" },
],
},
{
label: "审阅",
features: [
{
icon: ClipboardCheck,
name: "审稿 · 四审",
desc: "每章写完即审:一致性 / 伏笔 / 文风 / 节奏,冲突就地标注、逐条裁决后验收。",
},
{ icon: Route, name: "多章链", desc: "从指定章起循环「写章 → 四审 → 验收」批量推进,遇未决冲突暂停等你裁决再续跑。" },
],
},
{
label: "工具与全局",
features: [
{ icon: Blocks, name: "工具箱", desc: "十多个生成器:脑洞 / 书名 / 简介 / 金手指 / 词条 / 黄金开篇 / 细纲 / 拆书等;部分可一键入库,部分为预览产出。" },
{ icon: ShieldCheck, name: "规则", desc: "沉淀你的创作规则,分全局 / 题材 / 文风 / 本作四级,约束 AI 少犯同类错。" },
{ icon: LayoutGrid, name: "模板库", desc: "可复用的提示词模板,在工具箱生成器里一键填入需求 / 原文。" },
{ icon: Settings, name: "设置", desc: "写手 / 分析 / 轻量三档模型路由 + API Key / Kimi OAuth。" },
],
},
];
interface Tip {
icon: LucideIcon;
title: string;
body: string;
}
const TIPS: Tip[] = [
{ icon: Search, title: "命令面板 ⌘K", body: "任意页面按 ⌘K 快速跳转页面或执行命令。" },
{ icon: PanelLeftClose, title: "专注写作模式", body: "写作台底栏点「专注」,隐藏侧栏、加宽正文,沉浸写作。" },
{ icon: Moon, title: "夜读模式", body: "顶栏月亮图标切换暖色夜读,长时间写作更护眼。" },
{ icon: CheckCircle2, title: "原文永远你说了算", body: "AI 给的是草稿,采不采用由你定,绝不静默覆盖你的正文。" },
];
interface Faq {
q: string;
a: string;
}
const FAQS: Faq[] = [
{
q: "AI 会自动改我的正文吗?",
a: "不会。四审是只读的、只给建议;任何写入都要你在验收环节裁决,冲突未解决会阻止验收。",
},
{
q: "用的是哪个大模型?",
a: "后端是多提供商网关,写手 / 分析 / 轻量三档可各自路由Claude / DeepSeek / Kimi / GPT / Gemini / 通义 / GLM 等,可回退),在「设置 → 模型与提供商」里配。",
},
{
q: "写到几十万字会不会记忆衰减?",
a: "靠单一真相源 + 按需记忆注入:写每章只挑相关的设定、角色与最近若干章,而不是把全书塞进上下文。",
},
{
q: "我的数据在哪里?",
a: "本地 Postgres单用户原型设定/正文/审稿结果都以数据库为唯一真相源,记忆追加保留历史、不就地覆盖。",
},
];
function SectionTitle({ eyebrow, title }: { eyebrow: string; title: string }) {
return (
<div className="mb-5">
<Eyebrow className="mb-1.5">{eyebrow}</Eyebrow>
<h2 className="font-serif text-display-sm text-ink">{title}</h2>
</div>
);
}
function IconChip({ icon: Icon }: { icon: LucideIcon }) {
return (
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-[var(--color-cinnabar-wash)] text-cinnabar">
<Icon className="h-4 w-4" aria-hidden="true" />
</span>
);
}
export default function HelpPage() {
return (
<AppShell>
<PageContainer>
{/* 英雄:暖深色编辑部条(对标 DESIGN.md cta-band-dark。 */}
<section className="mb-12 rounded-xl border border-line/50 bg-callout px-8 py-12 shadow-paper sm:px-10">
<Eyebrow className="text-on-callout-soft"> · 使</Eyebrow>
<h1 className="mt-3 max-w-2xl font-serif text-display-md text-on-callout">
</h1>
<p className="mt-4 max-w-2xl text-body leading-7 text-on-callout-soft">
</p>
<div className="mt-6 flex flex-wrap gap-2">
<Link href="/write" className={buttonClass({ variant: "primary" })}>
<PenLine className="h-4 w-4" aria-hidden="true" />
</Link>
<Link href="/projects/new" className={buttonClass({ variant: "secondary" })}>
</Link>
</div>
</section>
{/* 新手三步 */}
<section className="mb-14">
<SectionTitle eyebrow="快速上手" title="三步写出第一章" />
<ol className="grid gap-4 sm:grid-cols-3">
{STEPS.map((step, i) => (
<li key={step.title}>
<Card tone="card" className="flex h-full flex-col gap-3 p-5">
<div className="flex items-center gap-3">
<IconChip icon={step.icon} />
<span className="font-mono text-caption text-muted-soft">
{i + 1}
</span>
</div>
<h3 className="font-serif text-title-md text-ink">{step.title}</h3>
<p className="text-caption leading-6 text-ink-soft">{step.body}</p>
</Card>
</li>
))}
</ol>
</section>
{/* 核心理念 */}
<section className="mb-14">
<SectionTitle eyebrow="为什么这样设计" title="三个核心理念" />
<div className="grid gap-4 sm:grid-cols-3">
{PRINCIPLES.map((p) => (
<Card key={p.title} tone="soft" flat className="p-5">
<h3 className="font-serif text-title-md text-ink">{p.title}</h3>
<p className="mt-2 text-caption leading-6 text-ink-soft">{p.body}</p>
</Card>
))}
</div>
</section>
{/* 功能一览 */}
<section className="mb-14">
<SectionTitle eyebrow="功能介绍" title="每个板块是做什么的" />
<div className="space-y-8">
{FEATURE_GROUPS.map((group) => (
<div key={group.label}>
<h3 className="mb-3 flex items-center gap-2 font-sans text-title-md text-ink">
{group.label}
<Badge variant="neutral">{group.features.length}</Badge>
</h3>
<div className="grid gap-4 sm:grid-cols-2">
{group.features.map((f) => (
<Card key={f.name} className="flex gap-3 p-4">
<IconChip icon={f.icon} />
<div className="min-w-0">
<p className="font-serif text-title-md text-ink">{f.name}</p>
<p className="mt-1 text-caption leading-6 text-ink-soft">{f.desc}</p>
</div>
</Card>
))}
</div>
</div>
))}
</div>
</section>
{/* 小技巧 */}
<section className="mb-14">
<SectionTitle eyebrow="顺手小技巧" title="用得更顺" />
<div className="grid gap-4 sm:grid-cols-2">
{TIPS.map((t) => (
<div key={t.title} className="flex gap-3">
<IconChip icon={t.icon} />
<div className="min-w-0">
<p className="font-medium text-ink">{t.title}</p>
<p className="mt-0.5 text-caption leading-6 text-ink-soft">{t.body}</p>
</div>
</div>
))}
</div>
</section>
{/* 常见问题 */}
<section>
<SectionTitle eyebrow="常见问题" title="你可能想问" />
<dl className="space-y-3">
{FAQS.map((f) => (
<Card key={f.q} as="div" className="p-5">
<dt className="font-serif text-title-md text-ink">{f.q}</dt>
<dd className="mt-2 text-caption leading-6 text-ink-soft">{f.a}</dd>
</Card>
))}
</dl>
</section>
</PageContainer>
</AppShell>
);
}

View File

@@ -1,9 +1,79 @@
import { ThinkingIndicator } from "@/components/ThinkingIndicator"; import { Card } from "@/components/ui/Card";
import { PageContainer } from "@/components/ui/PageContainer";
import { Skeleton } from "@/components/ui/Skeleton";
// 全站加载骨架:与作品库结构近似(标题条 + 工具条 + 卡片网格),
// motion-safe 脉冲,尊重 prefers-reduced-motion。
export default function Loading() { export default function Loading() {
return ( return (
<main className="flex min-h-screen items-center justify-center bg-bg px-6 py-16"> <main className="min-h-screen bg-bg">
<ThinkingIndicator label="加载中…" className="text-ink-soft" /> <PageContainer>
<div role="status" aria-label="加载中" className="space-y-8">
<span className="sr-only"></span>
<HeaderSkeleton />
<ToolbarSkeleton />
<GridSkeleton />
</div>
</PageContainer>
</main> </main>
); );
} }
// 标题区:大标题条 + 说明条 + 右侧两枚按钮位。
function HeaderSkeleton() {
return (
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="space-y-3">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-72 max-w-full" />
</div>
<div className="flex gap-2">
<Skeleton className="h-9 w-28" />
<Skeleton className="h-9 w-24" />
</div>
</div>
);
}
// 工具条:搜索框 + 三枚控件位。
function ToolbarSkeleton() {
return (
<Card flat className="grid gap-3 p-3 md:grid-cols-[1fr_auto_auto_auto]">
<Skeleton className="h-9 w-full" />
<Skeleton className="h-9 w-28" />
<Skeleton className="h-9 w-28" />
<Skeleton className="h-9 w-20" />
</Card>
);
}
// 卡片网格6 张与作品卡等高的占位卡。
function GridSkeleton() {
return (
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<CardSkeleton key={i} />
))}
</div>
);
}
// 单张作品卡骨架:标题 + 徽章 + 三行简介 + 底部时间。
function CardSkeleton() {
return (
<Card flat className="flex h-full min-h-[176px] flex-col p-6">
<div className="mb-4 flex items-start justify-between gap-3">
<div className="min-w-0 flex-1 space-y-3">
<Skeleton className="h-6 w-3/4" />
<Skeleton className="h-5 w-16 rounded-full" />
</div>
<Skeleton className="h-8 w-8 shrink-0" />
</div>
<div className="space-y-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-5/6" />
</div>
<Skeleton className="mt-auto h-3 w-28" />
</Card>
);
}

View File

@@ -4,7 +4,7 @@ import { BookOpen, PenLine, Plus } from "lucide-react";
import { AppShell } from "@/components/AppShell"; import { AppShell } from "@/components/AppShell";
import { BackendDownNotice } from "@/components/BackendDownNotice"; import { BackendDownNotice } from "@/components/BackendDownNotice";
import { ProjectLibrary } from "@/components/projects/ProjectLibrary"; import { ProjectLibrary } from "@/components/projects/ProjectLibrary";
import { EmptyState } from "@/components/ui/EmptyState"; import { PageContainer } from "@/components/ui/PageContainer";
import { PageHeader } from "@/components/ui/PageHeader"; import { PageHeader } from "@/components/ui/PageHeader";
import { fetchProjects } from "@/lib/api/server"; import { fetchProjects } from "@/lib/api/server";
import type { ProjectResponse } from "@/lib/api/types"; import type { ProjectResponse } from "@/lib/api/types";
@@ -23,7 +23,7 @@ export default async function DashboardPage() {
return ( return (
<AppShell> <AppShell>
<div className="mx-auto max-w-5xl px-6 py-10 sm:px-8"> <PageContainer>
<PageHeader <PageHeader
title="我的作品" title="我的作品"
description="从一个灵感进入正文、设定、审稿与验收闭环。" description="从一个灵感进入正文、设定、审稿与验收闭环。"
@@ -50,33 +50,36 @@ export default async function DashboardPage() {
{loadError ? ( {loadError ? (
<BackendDownNotice /> <BackendDownNotice />
) : projects.length === 0 ? ( ) : projects.length === 0 ? (
<EmptyState // 首次进入的暖深色编辑部英雄(对标 DESIGN.md cta-band-dark仅零作品时出现不干扰工作库
icon={BookOpen} <div className="rounded-xl border border-line/50 bg-callout px-8 py-14 text-center shadow-paper sm:px-12 sm:py-20">
title="还没有作品" <BookOpen className="mx-auto h-9 w-9 text-cinnabar" aria-hidden="true" />
description="直接开始写,落笔即成书;或先立项,从一句灵感搭好设定、大纲再动笔。" <h2 className="mt-5 font-serif text-display-sm text-on-callout">
action={
<div className="flex flex-wrap items-center justify-center gap-2"> </h2>
<Link <p className="mx-auto mt-3 max-w-md text-body leading-7 text-on-callout-soft">
href="/write"
className={buttonClass({ variant: "primary" })} </p>
> <div className="mt-7 flex flex-wrap items-center justify-center gap-2">
<PenLine className="h-4 w-4" aria-hidden="true" /> <Link
href="/write"
</Link> className={buttonClass({ variant: "primary", size: "lg" })}
<Link >
href="/projects/new" <PenLine className="h-4 w-4" aria-hidden="true" />
className={buttonClass({ variant: "secondary" })}
> </Link>
<Plus className="h-4 w-4" aria-hidden="true" /> <Link
href="/projects/new"
</Link> className={buttonClass({ variant: "secondary", size: "lg" })}
</div> >
} <Plus className="h-4 w-4" aria-hidden="true" />
/>
</Link>
</div>
</div>
) : ( ) : (
<ProjectLibrary projects={projects} /> <ProjectLibrary projects={projects} />
)} )}
</div> </PageContainer>
</AppShell> </AppShell>
); );
} }

View File

@@ -1,6 +1,6 @@
import Link from "next/link"; import Link from "next/link";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { Settings } from "lucide-react"; import { FileQuestion, Settings } from "lucide-react";
import type { ActiveNav } from "@/lib/nav/items"; import type { ActiveNav } from "@/lib/nav/items";
import { buttonClass, focusRing } from "@/lib/ui/variants"; import { buttonClass, focusRing } from "@/lib/ui/variants";
@@ -18,6 +18,9 @@ interface AppShellProps {
projectId?: string; projectId?: string;
// 当前激活的项目级入口键(用于高亮)。 // 当前激活的项目级入口键(用于高亮)。
activeNav?: ActiveNav; activeNav?: ActiveNav;
// 专注写作模式P3-4隐藏桌面左侧图标导航以加宽正文顶栏汉堡/命令面板仍可达导航。
// 缺省 false → 其余页面外观不变(加法式可选项)。
hideNav?: boolean;
} }
// 全局外壳:顶栏(64px) + 左导航 + 主区UX §5.1)。 // 全局外壳:顶栏(64px) + 左导航 + 主区UX §5.1)。
@@ -27,6 +30,7 @@ export function AppShell({
subtitle, subtitle,
projectId, projectId,
activeNav, activeNav,
hideNav = false,
}: AppShellProps) { }: AppShellProps) {
return ( return (
<div <div
@@ -64,6 +68,19 @@ export function AppShell({
> >
<CommandSearchButton /> <CommandSearchButton />
<ThemeToggle /> <ThemeToggle />
<Link
href="/help"
aria-label="帮助"
title="帮助"
className={buttonClass({
variant: "ghost",
size: "sm",
className: "border-transparent",
})}
>
<FileQuestion className="h-4 w-4" aria-hidden="true" />
<span className="hidden sm:inline"></span>
</Link>
<Link <Link
href="/settings/providers" href="/settings/providers"
aria-label="设置" aria-label="设置"
@@ -80,7 +97,7 @@ export function AppShell({
</nav> </nav>
</header> </header>
<div className="flex"> <div className="flex">
<LeftNav projectId={projectId} activeNav={activeNav} /> {hideNav ? null : <LeftNav projectId={projectId} activeNav={activeNav} />}
<main <main
id="main" id="main"
tabIndex={-1} tabIndex={-1}

View File

@@ -7,8 +7,12 @@ import { Button } from "@/components/ui/Button";
import { handleTabTrap } from "@/lib/a11y/focusTrap"; import { handleTabTrap } from "@/lib/a11y/focusTrap";
import { useRestoreFocus } from "@/lib/a11y/useRestoreFocus"; import { useRestoreFocus } from "@/lib/a11y/useRestoreFocus";
import { useBodyScrollLock } from "@/lib/ui/useBodyScrollLock"; import { useBodyScrollLock } from "@/lib/ui/useBodyScrollLock";
import { useMountTransition } from "@/lib/ui/useMountTransition";
import { overlayScrim } from "@/lib/ui/variants"; import { overlayScrim } from "@/lib/ui/variants";
// 抽屉进出场时长(与 --dur-base 一致)。
const DRAWER_MS = 180;
interface DrawerProps { interface DrawerProps {
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
@@ -36,11 +40,15 @@ export function Drawer({
const panelRef = useRef<HTMLDivElement>(null); const panelRef = useRef<HTMLDivElement>(null);
const closeRef = useRef<HTMLButtonElement>(null); const closeRef = useRef<HTMLButtonElement>(null);
const { shouldRender, isVisible } = useMountTransition(open, DRAWER_MS);
useBodyScrollLock(open); useBodyScrollLock(open);
useRestoreFocus(open, triggerRef); useRestoreFocus(open, triggerRef);
useEffect(() => { useEffect(() => {
if (!open) return; // 面板真正挂载shouldRender且处于打开态时才装监听/落焦——
// 因进出场延迟了卸载,若仅依赖 open 会错过挂载帧导致首焦点丢失。
if (!open || !shouldRender) return;
const onKey = (e: KeyboardEvent): void => { const onKey = (e: KeyboardEvent): void => {
if (e.key === "Escape") { if (e.key === "Escape") {
e.preventDefault(); e.preventDefault();
@@ -51,14 +59,25 @@ export function Drawer({
// 焦点陷阱首焦点落在具名关闭按钮上(而非整个面板)。 // 焦点陷阱首焦点落在具名关闭按钮上(而非整个面板)。
closeRef.current?.focus(); closeRef.current?.focus();
return () => window.removeEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey);
}, [open, onClose]); }, [open, shouldRender, onClose]);
if (!open) return null; if (!shouldRender) return null;
const sideClass = side === "left" ? "left-0 border-r" : "right-0 border-l"; const sideClass = side === "left" ? "left-0 border-r" : "right-0 border-l";
const slideClass =
side === "left"
? isVisible
? "translate-x-0"
: "-translate-x-full"
: isVisible
? "translate-x-0"
: "translate-x-full";
return ( return (
<div className={`${overlayScrim} lg:hidden`} onClick={onClose}> <div
className={`${overlayScrim} lg:hidden motion-safe:transition-opacity motion-safe:duration-base ${isVisible ? "opacity-100" : "opacity-0"}`}
onClick={onClose}
>
<div <div
ref={panelRef} ref={panelRef}
id={id} id={id}
@@ -70,7 +89,7 @@ export function Drawer({
// focus trapTab/Shift+Tab 在抽屉内循环不逃逸到背景WCAG 2.1.2)。 // focus trapTab/Shift+Tab 在抽屉内循环不逃逸到背景WCAG 2.1.2)。
if (panelRef.current) handleTabTrap(panelRef.current, e); if (panelRef.current) handleTabTrap(panelRef.current, e);
}} }}
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}`} 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 motion-safe:transition-transform motion-safe:duration-base ease-standard ${sideClass} ${slideClass}`}
> >
<div className="mb-2 flex justify-end px-2"> <div className="mb-2 flex justify-end px-2">
<Button <Button

View File

@@ -107,9 +107,9 @@ export function ProjectWizard() {
const isLast = step === WIZARD_STEPS; const isLast = step === WIZARD_STEPS;
return ( return (
<div className="mx-auto max-w-2xl rounded border border-line bg-panel p-6 shadow-paper sm:p-8"> <div className="mx-auto max-w-2xl rounded-lg border border-line bg-panel p-6 shadow-paper sm:p-8">
<div className="mb-6 flex items-center justify-between"> <div className="mb-6 flex items-center justify-between">
<h1 className="font-serif text-2xl text-ink"></h1> <h1 className="font-serif text-display-sm text-ink"></h1>
<StepDots current={step} /> <StepDots current={step} />
</div> </div>
<div className="mb-4"> <div className="mb-4">
@@ -175,7 +175,7 @@ function StepDots({ current }: { current: number }) {
{Array.from({ length: WIZARD_STEPS }, (_, i) => ( {Array.from({ length: WIZARD_STEPS }, (_, i) => (
<span <span
key={i} key={i}
className={`h-2 w-2 rounded ${ className={`h-2 w-2 rounded-full ${
i + 1 <= current ? "bg-cinnabar" : "bg-line" i + 1 <= current ? "bg-cinnabar" : "bg-line"
}`} }`}
/> />
@@ -235,7 +235,7 @@ function PlanAssistant({
const generating = status === "generating"; const generating = status === "generating";
return ( return (
<div className="my-4 rounded border border-line bg-bg p-3"> <div className="my-4 rounded-md border border-line bg-bg p-3">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<div className="min-w-0"> <div className="min-w-0">
<p className="flex items-center gap-1.5 text-sm text-ink"> <p className="flex items-center gap-1.5 text-sm text-ink">
@@ -449,7 +449,7 @@ function StepConfirm({ form }: { form: WizardForm }) {
<p className="mb-3 text-sm text-ink-soft"> <p className="mb-3 text-sm text-ink-soft">
</p> </p>
<dl className="divide-y divide-line rounded border border-line bg-bg"> <dl className="divide-y divide-line rounded-md border border-line bg-bg">
{rows.map((row) => ( {rows.map((row) => (
<div key={row.label} className="flex gap-4 px-4 py-2.5 text-sm"> <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> <dt className="w-20 shrink-0 text-ink-soft">{row.label}</dt>

View File

@@ -98,7 +98,7 @@ export function ToastProvider({ children }: { children: ReactNode }) {
const renderItem = (t: ToastItem) => ( const renderItem = (t: ToastItem) => (
<div <div
key={t.id} key={t.id}
className={`pointer-events-auto flex items-center gap-3 rounded border px-4 py-2 text-sm shadow-paper ${toastShellClass( className={`toast-enter pointer-events-auto flex items-center gap-3 rounded-md border px-4 py-2 text-sm shadow-paper ${toastShellClass(
t.kind, t.kind,
)}`} )}`}
> >

View File

@@ -5,6 +5,7 @@ import { Play } from "lucide-react";
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
import { Field } from "@/components/ui/Field"; import { Field } from "@/components/ui/Field";
import { Radio } from "@/components/ui/Radio";
import { SectionHeader } from "@/components/ui/SectionHeader"; import { SectionHeader } from "@/components/ui/SectionHeader";
import { TextInput } from "@/components/ui/TextInput"; import { TextInput } from "@/components/ui/TextInput";
import { CHAIN_KINDS, type ChainKind } from "@/lib/chain/chain"; import { CHAIN_KINDS, type ChainKind } from "@/lib/chain/chain";
@@ -58,8 +59,7 @@ export function ChainStarter({ onStart, disabled }: ChainStarterProps) {
key={kind.key} key={kind.key}
className="flex items-start gap-2 text-sm text-ink" className="flex items-start gap-2 text-sm text-ink"
> >
<input <Radio
type="radio"
name="chain-kind" name="chain-kind"
value={kind.key} value={kind.key}
checked={chainKey === kind.key} checked={chainKey === kind.key}

View File

@@ -50,7 +50,7 @@ const RelationshipGraph = dynamic(
{ {
ssr: false, ssr: false,
loading: () => ( loading: () => (
<div className="flex h-[28rem] items-center justify-center rounded border border-line bg-bg text-sm text-ink-soft"> <div className="flex h-[28rem] items-center justify-center rounded-lg border border-line bg-bg text-sm text-ink-soft">
</div> </div>
), ),
@@ -101,7 +101,7 @@ export function CodexPage({
{tab === "characters" ? ( {tab === "characters" ? (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<Card as="section" className="p-4"> <Card as="section" className="p-4">
<h3 className="mb-3 font-serif text-sm text-ink"> <h3 className="mb-3 font-serif text-title-md text-ink">
{characters.length} {characters.length}
</h3> </h3>
{characters.length > 0 ? ( {characters.length > 0 ? (
@@ -109,10 +109,10 @@ export function CodexPage({
{characters.map((c, i) => ( {characters.map((c, i) => (
<li <li
key={`${c.name}-${i}`} key={`${c.name}-${i}`}
className="rounded border border-line bg-bg p-3 text-sm" className="rounded-lg border border-line bg-bg p-3 text-sm"
> >
<div className="mb-2 flex items-center gap-2"> <div className="mb-2 flex items-center gap-2">
<span className="flex h-8 w-8 items-center justify-center rounded bg-[var(--color-cinnabar-wash)] text-cinnabar"> <span className="flex h-8 w-8 items-center justify-center rounded-md bg-[var(--color-cinnabar-wash)] text-cinnabar">
<UserRound className="h-4 w-4" aria-hidden="true" /> <UserRound className="h-4 w-4" aria-hidden="true" />
</span> </span>
<div className="min-w-0"> <div className="min-w-0">
@@ -127,7 +127,7 @@ export function CodexPage({
{c.relations.map((r, j) => ( {c.relations.map((r, j) => (
<li <li
key={`${r.name}-${r.kind}-${j}`} key={`${r.name}-${r.kind}-${j}`}
className="rounded bg-panel px-1.5 py-0.5 text-2xs" className="rounded-full bg-panel px-2 py-0.5 text-2xs"
title={r.note ?? undefined} title={r.note ?? undefined}
> >
{r.kind} · {r.name} {r.kind} · {r.name}
@@ -163,7 +163,7 @@ export function CodexPage({
</Card> </Card>
{characters.length > 0 ? ( {characters.length > 0 ? (
<Card as="section" className="p-4"> <Card as="section" className="p-4">
<h3 className="mb-3 flex items-center gap-2 font-serif text-sm text-ink"> <h3 className="mb-3 flex items-center gap-2 font-serif text-title-md text-ink">
<Share2 className="h-4 w-4 text-cinnabar" aria-hidden="true" /> <Share2 className="h-4 w-4 text-cinnabar" aria-hidden="true" />
</h3> </h3>
@@ -184,7 +184,7 @@ export function CodexPage({
{tab === "world" ? ( {tab === "world" ? (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<Card as="section" className="p-4"> <Card as="section" className="p-4">
<h3 className="mb-3 font-serif text-sm text-ink"> <h3 className="mb-3 font-serif text-title-md text-ink">
{initialWorldEntities.length} {initialWorldEntities.length}
</h3> </h3>
{initialWorldEntities.length > 0 ? ( {initialWorldEntities.length > 0 ? (
@@ -192,7 +192,7 @@ export function CodexPage({
{initialWorldEntities.map((entity, i) => ( {initialWorldEntities.map((entity, i) => (
<article <article
key={`${entity.name}-${i}`} key={`${entity.name}-${i}`}
className="rounded border border-line bg-bg p-3 text-sm" className="rounded-lg border border-line bg-bg p-3 text-sm"
> >
<header className="mb-2 flex items-center gap-2"> <header className="mb-2 flex items-center gap-2">
<Globe2 <Globe2

View File

@@ -66,6 +66,15 @@ export function useCommandPaletteOpen(): boolean {
const LIST_ID = "command-list"; const LIST_ID = "command-list";
const optionId = (cmd: Command): string => `command-option-${cmd.id}`; const optionId = (cmd: Command): string => `command-option-${cmd.id}`;
// 统一的键帽kbd样式暖纸感描边 + 等宽小字,与 NavDrawer 的 ⌘K 提示一致。
function KeyCap({ children }: { children: React.ReactNode }) {
return (
<kbd className="rounded border border-line bg-panel px-1 font-mono text-2xs text-ink-soft">
{children}
</kbd>
);
}
// 从 pathname 抽取当前 projectId/projects/<id>/...)。 // 从 pathname 抽取当前 projectId/projects/<id>/...)。
function projectIdFromPath(pathname: string): string | null { function projectIdFromPath(pathname: string): string | null {
const m = pathname.match(/^\/projects\/([^/]+)/); const m = pathname.match(/^\/projects\/([^/]+)/);
@@ -208,20 +217,26 @@ export function CommandPalette({ pathname }: CommandPaletteProps) {
if (dialogRef.current) handleTabTrap(dialogRef.current, e); if (dialogRef.current) handleTabTrap(dialogRef.current, e);
}} }}
> >
<input <div className="relative border-b border-line">
ref={inputRef} <input
value={query} ref={inputRef}
onChange={(e) => setQuery(e.target.value)} value={query}
onKeyDown={onInputKey} onChange={(e) => setQuery(e.target.value)}
placeholder="搜索命令或页面…(写本章 / 审稿 / 生成角色 / 跳转伏笔 / 搜设定)" onKeyDown={onInputKey}
role="combobox" placeholder="搜索命令或页面…(写本章 / 审稿 / 生成角色 / 跳转伏笔 / 搜设定)"
aria-label="命令搜索" role="combobox"
aria-expanded={true} aria-label="命令搜索"
aria-controls={LIST_ID} aria-expanded={true}
aria-activedescendant={activeCmd ? optionId(activeCmd) : undefined} aria-controls={LIST_ID}
aria-autocomplete="list" aria-activedescendant={activeCmd ? optionId(activeCmd) : undefined}
className={`w-full border-b border-line bg-bg px-4 py-3 text-sm text-ink ${focusRing}`} aria-autocomplete="list"
/> className={`w-full bg-bg py-3 pl-4 pr-12 text-sm text-ink ${focusRing}`}
/>
{/* 触发快捷键读数:随面板常驻,强化 ⌘K 的可发现性(纯装饰,不入 Tab 序)。 */}
<span className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2">
<KeyCap>K</KeyCap>
</span>
</div>
<ul <ul
id={LIST_ID} id={LIST_ID}
role="listbox" role="listbox"
@@ -229,7 +244,14 @@ export function CommandPalette({ pathname }: CommandPaletteProps) {
className="max-h-80 overflow-auto py-1 overscroll-contain" className="max-h-80 overflow-auto py-1 overscroll-contain"
> >
{results.length === 0 ? ( {results.length === 0 ? (
<li className="px-4 py-3 text-sm text-ink-soft"></li> <li className="px-4 py-6 text-center">
<p className="text-sm text-ink-soft">
{query.trim()}
</p>
<p className="mt-1 text-caption text-muted-soft">
稿
</p>
</li>
) : ( ) : (
results.map((cmd, i) => ( results.map((cmd, i) => (
<li <li
@@ -246,11 +268,30 @@ export function CommandPalette({ pathname }: CommandPaletteProps) {
}`} }`}
> >
<span>{cmd.title}</span> <span>{cmd.title}</span>
<span className="text-xs text-ink-soft">{cmd.group}</span> <span className="text-caption text-ink-soft">{cmd.group}</span>
</li> </li>
)) ))
)} )}
</ul> </ul>
{/* 键盘操作读数:↑↓ 选择 · ↵ 打开 · esc 关闭(常驻引导,纯装饰)。 */}
<div
aria-hidden="true"
className="flex flex-wrap items-center gap-x-4 gap-y-1 border-t border-line px-4 py-2 text-caption text-muted-soft"
>
<span className="flex items-center gap-1">
<KeyCap></KeyCap>
<KeyCap></KeyCap>
</span>
<span className="flex items-center gap-1">
<KeyCap></KeyCap>
</span>
<span className="flex items-center gap-1">
<KeyCap>esc</KeyCap>
</span>
</div>
</div> </div>
</div> </div>
); );

View File

@@ -1,5 +1,6 @@
"use client"; "use client";
import { Checkbox } from "@/components/ui/Checkbox";
import { TextInput } from "@/components/ui/TextInput"; import { TextInput } from "@/components/ui/TextInput";
import type { CharacterCardView } from "@/lib/api/types"; import type { CharacterCardView } from "@/lib/api/types";
@@ -25,12 +26,10 @@ export function CharacterCardItem({
}`} }`}
> >
<header className="mb-2 flex items-center gap-2"> <header className="mb-2 flex items-center gap-2">
<input <Checkbox
type="checkbox"
checked={selected} checked={selected}
onChange={onToggle} onChange={onToggle}
aria-label={`选择角色 ${card.name}`} aria-label={`选择角色 ${card.name}`}
className="size-4 accent-cinnabar"
/> />
{onEdit ? ( {onEdit ? (
<TextInput <TextInput

View File

@@ -217,7 +217,7 @@ export function OutlineEditor({
) : ( ) : (
volumes.map((group) => ( volumes.map((group) => (
<section key={group.volume} className="mb-6"> <section key={group.volume} className="mb-6">
<h2 className="mb-2 font-serif text-base text-ink"> <h2 className="mb-3 font-serif text-title-md text-ink">
{group.volume} {group.volume}
</h2> </h2>
<ul className="space-y-1"> <ul className="space-y-1">

View File

@@ -0,0 +1,164 @@
import Link from "next/link";
import { ChevronRight, Clock3 } from "lucide-react";
import { Badge } from "@/components/ui/Badge";
import { StatusDot } from "@/components/ui/StatusDot";
import type { ProjectResponse } from "@/lib/api/types";
import {
formatProjectUpdatedAt,
pendingReviewCount,
} from "@/lib/projects/projects";
import { cardClass, cn, focusRing } from "@/lib/ui/variants";
interface ProjectCardProps {
project: ProjectResponse;
compact?: boolean;
}
// 缺一句话故事时的中性占位——避免卡片正文塌陷成空白P3-2
const LOGLINE_PLACEHOLDER = "还没有一句话故事,进入写作台补上一句梗概。";
// 书脊/封面暖色板:全部取自现有语义 token朱砂/琥珀),仅调透明度,无硬编码 hex。
const COVER_TINTS = [
"bg-[var(--color-cinnabar-wash)] text-cinnabar",
"bg-cinnabar/10 text-cinnabar",
"bg-cinnabar/[0.16] text-cinnabar",
"bg-overdue/10 text-overdue",
"bg-overdue/[0.18] text-overdue",
"bg-surface-strong text-body-strong",
] as const;
// 由 id/title 确定性取暖色底色块djb2 变体哈希,同一本书永远同一色)。
function coverTint(seed: string): string {
let hash = 0;
for (const ch of seed) hash = (hash * 31 + (ch.codePointAt(0) ?? 0)) >>> 0;
return COVER_TINTS[hash % COVER_TINTS.length] ?? COVER_TINTS[0];
}
// 取书名首个字符作封面字Array.from 保证 CJK/emoji 不被截断)。
function coverGlyph(title: string): string {
return Array.from(title.trim())[0] ?? "书";
}
interface CoverProps {
project: ProjectResponse;
size: "sm" | "md";
}
// 书脊色块:填补此前的空白,给每本书一个稳定的视觉锚点。
function Cover({ project, size }: CoverProps) {
const tint = coverTint(project.id || project.title || "");
const box = size === "md" ? "h-12 w-12 text-title-lg" : "h-10 w-10 text-title-md";
return (
<span
aria-hidden="true"
className={cn(
"flex shrink-0 items-center justify-center rounded-md font-serif",
box,
tint,
)}
>
{coverGlyph(project.title || "")}
</span>
);
}
// 作品卡UX §6.1):书脊色块 + 书名衬线 + 一句话故事 + 元信息行。
// M1 无字数/章数端点 → 不展示该统计(避免编造 API
export function ProjectCard({ project, compact = false }: ProjectCardProps) {
const pendingCount = pendingReviewCount(project);
const updatedLabel = formatProjectUpdatedAt(project.updated_at);
const title = project.title || "未命名作品";
const logline = project.logline?.trim();
if (compact) {
return (
<Link
href={`/projects/${project.id}/write`}
className={cardClass({
tone: "card",
interactive: true,
className: cn("group flex items-center gap-4 p-4", focusRing),
})}
>
<Cover project={project} size="sm" />
<span className="min-w-0 flex-1">
<span className="block truncate font-serif text-title-md text-ink">
{title}
</span>
<span className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-caption">
<GenreBadge genre={project.genre} />
<PendingNote count={pendingCount} />
<span className="min-w-0 flex-1 truncate text-muted-soft">
{logline ?? LOGLINE_PLACEHOLDER}
</span>
</span>
<span className="mt-1 flex items-center gap-1 font-mono text-2xs text-muted-soft">
<Clock3 className="h-3 w-3" aria-hidden="true" />
{updatedLabel}
</span>
</span>
<ChevronRight
className="h-4 w-4 shrink-0 text-muted-soft transition-colors duration-fast ease-standard group-hover:text-cinnabar"
aria-hidden="true"
/>
</Link>
);
}
return (
<Link
href={`/projects/${project.id}/write`}
className={cardClass({
tone: "card",
interactive: true,
className: cn("group flex h-full flex-col gap-4 p-5", focusRing),
})}
>
<div className="flex items-center gap-3">
<Cover project={project} size="md" />
<h2 className="min-w-0 flex-1 truncate font-serif text-title-lg text-ink">
{title}
</h2>
<ChevronRight
className="h-4 w-4 shrink-0 text-muted-soft transition-colors duration-fast ease-standard group-hover:text-cinnabar"
aria-hidden="true"
/>
</div>
<p
className={cn(
"line-clamp-2 min-h-[3rem] text-body leading-6",
logline ? "text-body" : "text-muted-soft",
)}
>
{logline ?? LOGLINE_PLACEHOLDER}
</p>
<div className="mt-auto flex flex-wrap items-center gap-x-3 gap-y-2">
<GenreBadge genre={project.genre} />
<PendingNote count={pendingCount} />
<span className="ml-auto flex items-center gap-1 font-mono text-caption text-muted-soft">
<Clock3 className="h-3.5 w-3.5" aria-hidden="true" />
{updatedLabel}
</span>
</div>
</Link>
);
}
// 题材徽章:有值用中性徽章,缺失给稳定占位而非空白。
function GenreBadge({ genre }: { genre?: string | null }) {
const value = genre?.trim();
if (value) return <Badge>{value}</Badge>;
return <Badge className="border-dashed text-muted-soft"></Badge>;
}
// 待审提示:状态点 + 文字(不单靠颜色传达状态)。
function PendingNote({ count }: { count: number }) {
if (count <= 0) return null;
return (
<span className="inline-flex items-center gap-1.5 text-caption text-ink-soft">
<StatusDot tone="warning" label={`${count} 章待审`} />
{count}
</span>
);
}

View File

@@ -4,7 +4,7 @@ import Link from "next/link";
import { Clock3, LayoutGrid, List, Plus, Search } from "lucide-react"; import { Clock3, LayoutGrid, List, Plus, Search } from "lucide-react";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { ProjectCard } from "@/components/ProjectCard"; import { ProjectCard } from "@/components/projects/ProjectCard";
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
import { EmptyState } from "@/components/ui/EmptyState"; import { EmptyState } from "@/components/ui/EmptyState";
import { SegmentedControl } from "@/components/ui/SegmentedControl"; import { SegmentedControl } from "@/components/ui/SegmentedControl";
@@ -17,7 +17,7 @@ import {
type ProjectSort, type ProjectSort,
type ProjectViewMode, type ProjectViewMode,
} from "@/lib/projects/projects"; } from "@/lib/projects/projects";
import { buttonClass, cardClass } from "@/lib/ui/variants"; import { buttonClass, cardClass, cn, focusRing } from "@/lib/ui/variants";
interface ProjectLibraryProps { interface ProjectLibraryProps {
projects: ProjectResponse[]; projects: ProjectResponse[];
@@ -189,16 +189,21 @@ function NewProjectCard() {
return ( return (
<Link <Link
href="/projects/new" href="/projects/new"
className={buttonClass({ className={cardClass({
variant: "outline", tone: "soft",
className: "group h-full min-h-[176px] flex-col border-dashed text-center", flat: true,
interactive: true,
className: cn(
"group flex h-full min-h-[184px] flex-col items-center justify-center gap-3 border-dashed p-5 text-center",
focusRing,
),
})} })}
> >
<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"> <span className="flex h-12 w-12 items-center justify-center rounded-md bg-[var(--color-cinnabar-wash)] text-cinnabar transition-colors duration-fast ease-standard group-hover:bg-cinnabar group-hover:text-panel">
<Plus className="h-5 w-5" aria-hidden="true" /> <Plus className="h-5 w-5" aria-hidden="true" />
</span> </span>
<span className="font-serif text-xl text-cinnabar"></span> <span className="font-serif text-title-md text-cinnabar"></span>
<span className="mt-2 text-sm text-ink-soft"></span> <span className="text-caption text-muted-soft"></span>
</Link> </Link>
); );
} }

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { AlertTriangle, CheckCircle2, LocateFixed, Sparkles } from "lucide-react"; import { AlertTriangle, CheckCircle2, LocateFixed, Minus, Plus, Sparkles } from "lucide-react";
import { Badge } from "@/components/ui/Badge"; import { Badge } from "@/components/ui/Badge";
import { TextInput } from "@/components/ui/TextInput"; import { TextInput } from "@/components/ui/TextInput";
@@ -80,10 +80,22 @@ export function ConflictCard({
{conflict.original && conflict.replacement ? ( {conflict.original && conflict.replacement ? (
<div className="mt-2 rounded border border-pass/30 bg-bg/40 px-3 py-1.5 text-xs leading-relaxed"> <div className="mt-2 rounded border border-pass/30 bg-bg/40 px-3 py-1.5 text-xs leading-relaxed">
<span className="mr-1 text-ink-soft"></span> <span className="mr-1 text-ink-soft"></span>
{/* 非颜色线索Minus/「原文」标签,色盲也可辨(不单靠红色 + 删除线) */}
<Minus
className="mr-0.5 inline-block h-3 w-3 align-[-1px] text-conflict"
aria-hidden="true"
/>
<span className="sr-only"></span>
<span className="text-conflict line-through">{conflict.original}</span> <span className="text-conflict line-through">{conflict.original}</span>
<span className="mx-1 text-ink-soft" aria-hidden="true"> <span className="mx-1 text-ink-soft" aria-hidden="true">
</span> </span>
{/* 非颜色线索Plus/「建议」标签,色盲也可辨(不单靠绿色) */}
<Plus
className="mr-0.5 inline-block h-3 w-3 align-[-1px] text-pass"
aria-hidden="true"
/>
<span className="sr-only"></span>
<span className="text-pass">{conflict.replacement}</span> <span className="text-pass">{conflict.replacement}</span>
<span className="ml-2 text-ink-soft">稿</span> <span className="ml-2 text-ink-soft">稿</span>
</div> </div>

View File

@@ -604,7 +604,7 @@ export function ReviewReport({
: "已处理"} : "已处理"}
</span> </span>
<ChevronDown <ChevronDown
className="h-4 w-4 text-ink-soft transition-transform group-open:rotate-180" className="h-4 w-4 text-ink-soft motion-safe:transition-transform group-open:rotate-180"
aria-hidden="true" aria-hidden="true"
/> />
</span> </span>

View File

@@ -52,7 +52,7 @@ export function ReviewSectionPanel({
<Badge variant={statusVariant}>{statusLabel}</Badge> <Badge variant={statusVariant}>{statusLabel}</Badge>
<ChevronDown <ChevronDown
className={cn( className={cn(
"h-4 w-4 text-ink-soft transition-transform", "h-4 w-4 text-ink-soft motion-safe:transition-transform",
"group-open:rotate-180", "group-open:rotate-180",
)} )}
aria-hidden="true" aria-hidden="true"

View File

@@ -0,0 +1,24 @@
import { Badge } from "@/components/ui/Badge";
import type { CapabilitiesView } from "@/lib/api/types";
// 探活结果的能力徽章:结构化输出 / 前缀缓存 / 思考,各显示开关态。
export function CapabilityBadges({ caps }: { caps: CapabilitiesView }) {
const badges: { on: boolean; label: string }[] = [
{ on: caps.structured_output, label: "结构化" },
{ on: caps.prefix_cache, label: "前缀缓存" },
{ on: caps.thinking, label: "思考" },
];
return (
<div className="flex gap-1.5">
{badges.map((b) => (
<Badge
key={b.label}
variant={b.on ? "accent" : "neutral"}
className="text-2xs"
>
{b.label}
</Badge>
))}
</div>
);
}

View File

@@ -0,0 +1,105 @@
"use client";
import { CheckCircle2, PlugZap, Save, XCircle } from "lucide-react";
import { CapabilityBadges } from "@/components/settings/CapabilityBadges";
import { Badge } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button";
import { StatusDot } from "@/components/ui/StatusDot";
import { TextInput } from "@/components/ui/TextInput";
import type { CapabilitiesView } from "@/lib/api/types";
import type { KnownProvider } from "@/lib/settings/providers";
export interface TestResult {
ok: boolean;
capabilities: CapabilitiesView;
}
interface CredentialRowProps {
provider: KnownProvider;
masked: string | null;
draft: string;
saving: boolean;
testing: boolean;
result: TestResult | undefined;
onDraftChange: (value: string) => void;
onSave: () => void;
onTest: () => void;
}
// 单个 API-key 提供商行:脱敏态 + Key 输入 + 保存/测试 + 探活结果。
export function CredentialRow({
provider,
masked,
draft,
saving,
testing,
result,
onDraftChange,
onSave,
onTest,
}: CredentialRowProps) {
return (
<li className="px-4 py-4">
<div className="grid gap-3 lg:grid-cols-[10rem_8rem_minmax(12rem,1fr)_auto_auto] lg:items-center">
<span className="flex items-center gap-2 text-sm text-ink">
<StatusDot
tone={masked ? "success" : "neutral"}
label={masked ? "已配置" : "未配置"}
/>
{provider.label}
</span>
<span className="font-mono text-xs text-ink-soft">
{masked ?? "未配置"}
</span>
<label className="sr-only" htmlFor={`key-${provider.id}`}>
{provider.label} API Key
</label>
<TextInput
id={`key-${provider.id}`}
type="password"
autoComplete="off"
value={draft}
onChange={(e) => onDraftChange(e.target.value)}
placeholder={masked ? "输入新 Key 以更新" : "输入 API Key"}
/>
<Button
onClick={onSave}
disabled={saving || draft.trim().length === 0}
variant="primary"
size="sm"
>
<Save className="h-4 w-4" aria-hidden="true" />
{saving ? "保存中…" : masked ? "更新" : "添加"}
</Button>
<Button
onClick={onTest}
disabled={testing}
variant="secondary"
size="sm"
>
<PlugZap className="h-4 w-4" aria-hidden="true" />
{testing ? "测试中…" : "测试"}
</Button>
</div>
<p className="mt-2 text-xs leading-5 text-ink-soft lg:pl-[10.5rem]">
{masked
? "已保存脱敏凭据;输入新 Key 可覆盖更新。"
: "保存后再测试连接,成功后即可在档位路由中使用。"}
</p>
{result ? (
<div className="mt-2 flex flex-wrap items-center gap-2 lg:pl-[10.5rem]">
<Badge variant={result.ok ? "success" : "danger"}>
{result.ok ? (
<CheckCircle2 className="h-3 w-3" aria-hidden="true" />
) : (
<XCircle className="h-3 w-3" aria-hidden="true" />
)}
{result.ok ? "已连接" : "未连接"}
</Badge>
<CapabilityBadges caps={result.capabilities} />
</div>
) : null}
</li>
);
}

View File

@@ -0,0 +1,85 @@
"use client";
import { PlugZap } from "lucide-react";
import {
CredentialRow,
type TestResult,
} from "@/components/settings/CredentialRow";
import { EmptyState } from "@/components/ui/EmptyState";
import { SectionHeader } from "@/components/ui/SectionHeader";
import { API_KEY_PROVIDERS } from "@/lib/settings/providers";
import type { ProviderView } from "@/lib/api/types";
interface CredentialsPanelProps {
providers: ProviderView[];
drafts: Record<string, string>;
savingId: string | null;
testingId: string | null;
results: Record<string, TestResult>;
onDraftChange: (providerId: string, value: string) => void;
onSave: (providerId: string) => void;
onTest: (providerId: string) => void;
}
// 提供商凭据面板API-key 提供商列表,逐行保存/测试UX §6.10)。
export function CredentialsPanel({
providers,
drafts,
savingId,
testingId,
results,
onDraftChange,
onSave,
onTest,
}: CredentialsPanelProps) {
const maskedFor = (id: string): string | null =>
providers.find((p) => p.provider === id)?.masked_key ?? null;
return (
<section className="min-w-0">
<SectionHeader
eyebrow="凭据"
title="提供商凭据"
description="API Key 只用于后端探活和调用,列表中只显示脱敏后的已保存凭据。"
/>
<div className="mt-3 grid gap-2 text-xs text-ink-soft sm:grid-cols-3">
<StatItem label="已保存" value={providers.length} />
<StatItem label="可配置" value={API_KEY_PROVIDERS.length} />
<StatItem label="测试结果" value={Object.keys(results).length} />
</div>
{providers.length === 0 ? (
<EmptyState
icon={PlugZap}
title="还没有可用提供商"
description="至少连接一个提供商即可开始写作。求质量可选 Anthropic求性价比可选 DeepSeek。"
className="mt-4"
/>
) : null}
<ul className="mt-4 divide-y divide-line rounded border border-line bg-panel">
{API_KEY_PROVIDERS.map((prov) => (
<CredentialRow
key={prov.id}
provider={prov}
masked={maskedFor(prov.id)}
draft={drafts[prov.id] ?? ""}
saving={savingId === prov.id}
testing={testingId === prov.id}
result={results[prov.id]}
onDraftChange={(value) => onDraftChange(prov.id, value)}
onSave={() => onSave(prov.id)}
onTest={() => onTest(prov.id)}
/>
))}
</ul>
</section>
);
}
function StatItem({ label, value }: { label: string; value: number }) {
return (
<div className="rounded border border-line bg-panel px-3 py-2">
{label} <span className="font-mono text-ink">{value}</span>
</div>
);
}

View File

@@ -1,41 +1,26 @@
"use client"; "use client";
import { import { useState } from "react";
CheckCircle2,
KeyRound,
PlugZap,
Route,
Save,
XCircle,
type LucideIcon,
} from "lucide-react";
import { useState, type ReactNode } from "react";
import { useToast } from "@/components/Toast"; import { useToast } from "@/components/Toast";
import { CredentialsPanel } from "@/components/settings/CredentialsPanel";
import type { TestResult } from "@/components/settings/CredentialRow";
import { KimiCodeOauth } from "@/components/settings/KimiCodeOauth"; import { KimiCodeOauth } from "@/components/settings/KimiCodeOauth";
import { Badge } from "@/components/ui/Badge"; import { RoutingPanel } from "@/components/settings/RoutingPanel";
import { Button } from "@/components/ui/Button"; import {
import { EmptyState } from "@/components/ui/EmptyState"; SECTION_OPTIONS,
import { SectionHeader } from "@/components/ui/SectionHeader"; SettingsNav,
import { SegmentedControl } from "@/components/ui/SegmentedControl"; type SettingsSection,
import { Select } from "@/components/ui/Select"; } from "@/components/settings/SettingsNav";
import { StatusNote } from "@/components/ui/StatusNote"; import { SegmentedControl } from "@/components/ui/SegmentedControl";
import { TextInput } from "@/components/ui/TextInput"; import { api } from "@/lib/api/client";
import { api } from "@/lib/api/client";
import { cardClass } from "@/lib/ui/variants";
import { import {
API_KEY_PROVIDERS,
KNOWN_PROVIDERS,
TIER_LABELS,
applyProviderChange, applyProviderChange,
draftsToRoutingInput, draftsToRoutingInput,
toRoutingDrafts, toRoutingDrafts,
type RoutingDraft, type RoutingDraft,
} from "@/lib/settings/providers"; } from "@/lib/settings/providers";
import type { import type { ProvidersResponse } from "@/lib/api/types";
CapabilitiesView,
ProvidersResponse,
} from "@/lib/api/types";
interface ProvidersSettingsProps { interface ProvidersSettingsProps {
initial: ProvidersResponse; initial: ProvidersResponse;
@@ -43,42 +28,6 @@ interface ProvidersSettingsProps {
kimiOauth: { connected: boolean; expiresAt: string | null }; kimiOauth: { connected: boolean; expiresAt: string | null };
} }
interface TestResult {
ok: boolean;
capabilities: CapabilitiesView;
}
type SettingsSection = "routing" | "oauth" | "keys";
// 分组的单一标签源:桌面 aside 与移动 SegmentedControl 共用,消除「档位路由 / 路由」漂移。
const SETTINGS_SECTIONS: Array<{
value: SettingsSection;
label: string;
icon: LucideIcon;
}> = [
{ value: "routing", label: "档位路由", icon: Route },
{ value: "oauth", label: "OAuth", icon: PlugZap },
{ value: "keys", label: "API Key", icon: KeyRound },
];
const SECTION_OPTIONS = SETTINGS_SECTIONS.map(({ value, label }) => ({
value,
label,
}));
// 档位路由「模型」输入框的候选 model iddatalist 建议,仍可自由输入)。按 provider 分组。
const MODEL_SUGGESTIONS: Record<string, readonly string[]> = {
anthropic: ["claude-opus-4", "claude-sonnet-4", "claude-3-5-haiku"],
deepseek: ["deepseek-chat", "deepseek-reasoner"],
kimi: ["moonshot-v1-128k", "moonshot-v1-32k", "kimi-k2"],
openai: ["gpt-4o", "gpt-4o-mini", "o3-mini"],
qwen: ["qwen-max", "qwen-plus", "qwen-turbo"],
glm: ["glm-4-plus", "glm-4-air", "glm-4-flash"],
gemini: ["gemini-2.0-flash", "gemini-1.5-pro"],
"kimi-code-key": ["kimi-for-coding"],
"kimi-code": ["kimi-for-coding"],
};
// 设置页主体UX §6.10):档位路由(可编辑)+ API-key 凭据行 + Kimi Code OAuth 连接区。 // 设置页主体UX §6.10):档位路由(可编辑)+ API-key 凭据行 + Kimi Code OAuth 连接区。
export function ProvidersSettings({ export function ProvidersSettings({
initial, initial,
@@ -97,9 +46,6 @@ export function ProvidersSettings({
const [testingId, setTestingId] = useState<string | null>(null); const [testingId, setTestingId] = useState<string | null>(null);
const [results, setResults] = useState<Record<string, TestResult>>({}); const [results, setResults] = useState<Record<string, TestResult>>({});
const maskedFor = (id: string): string | null =>
providers.find((p) => p.provider === id)?.masked_key ?? null;
const sectionDetail = (value: SettingsSection): string => { const sectionDetail = (value: SettingsSection): string => {
if (value === "routing") { if (value === "routing") {
return `${routing.filter((r) => r.provider && r.model).length}/3 已配置`; return `${routing.filter((r) => r.provider && r.model).length}/3 已配置`;
@@ -194,22 +140,17 @@ export function ProvidersSettings({
} }
}; };
const updateDraft = (providerId: string, value: string): void => {
setDrafts((prev) => ({ ...prev, [providerId]: value }));
};
return ( return (
<div className="grid gap-6 lg:grid-cols-[12rem_1fr]"> <div className="grid gap-6 lg:grid-cols-[12rem_1fr]">
<aside className="hidden lg:block"> <SettingsNav
<nav aria-label="设置分组" className={cardClass("sticky top-20 p-2")}> activeSection={activeSection}
{SETTINGS_SECTIONS.map((section) => ( onSelect={setActiveSection}
<SettingsNavButton detailFor={sectionDetail}
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>
<div className="min-w-0"> <div className="min-w-0">
<SegmentedControl <SegmentedControl
@@ -221,269 +162,37 @@ export function ProvidersSettings({
/> />
{activeSection === "routing" ? ( {activeSection === "routing" ? (
<SettingsPanel> <RoutingPanel
<SectionHeader routing={routing}
title="能力档位路由" saving={savingRouting}
description="写手、分析、轻量三类能力可分别指向不同模型。保存时只提交完整填写的行。" onProviderChange={updateRouting}
/> onModelChange={updateRoutingModel}
<StatusNote className="mt-3" variant="info"> onSave={() => void saveRouting()}
稿 />
</StatusNote>
<ul className="mt-4 divide-y divide-line rounded border border-line bg-panel">
{routing.map((row) => (
<li
key={row.tier}
className="grid gap-3 px-4 py-3 text-sm md:grid-cols-[6rem_minmax(10rem,14rem)_1fr]"
>
<span className="self-center text-ink">
{TIER_LABELS[row.tier] ?? row.tier}
</span>
<label
className="sr-only"
htmlFor={`route-provider-${row.tier}`}
>
{TIER_LABELS[row.tier] ?? row.tier}
</label>
<Select
id={`route-provider-${row.tier}`}
value={row.provider}
onChange={(e) => updateRouting(row.tier, e.target.value)}
>
<option value=""></option>
{KNOWN_PROVIDERS.map((p) => (
<option key={p.id} value={p.id}>
{p.label}
</option>
))}
</Select>
<label className="sr-only" htmlFor={`route-model-${row.tier}`}>
{TIER_LABELS[row.tier] ?? row.tier}
</label>
<div className="min-w-0">
<TextInput
id={`route-model-${row.tier}`}
value={row.model}
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>
<div className="mt-3 flex justify-end">
<Button
onClick={() => void saveRouting()}
disabled={savingRouting}
variant="primary"
size="sm"
>
<Save className="h-4 w-4" aria-hidden="true" />
{savingRouting ? "保存中…" : "保存档位路由"}
</Button>
</div>
</SettingsPanel>
) : null} ) : null}
{activeSection === "oauth" ? ( {activeSection === "oauth" ? (
<SettingsPanel> <section className="min-w-0">
<KimiCodeOauth <KimiCodeOauth
initialConnected={kimiOauth.connected} initialConnected={kimiOauth.connected}
initialExpiresAt={kimiOauth.expiresAt} initialExpiresAt={kimiOauth.expiresAt}
/> />
</SettingsPanel> </section>
) : null} ) : null}
{activeSection === "keys" ? ( {activeSection === "keys" ? (
<SettingsPanel> <CredentialsPanel
<SectionHeader providers={providers}
title="提供商凭据" drafts={drafts}
description="API Key 只用于后端探活和调用,列表中只显示脱敏后的已保存凭据。" savingId={savingId}
/> testingId={testingId}
<div className="mt-3 grid gap-2 text-xs text-ink-soft sm:grid-cols-3"> results={results}
<div className="rounded border border-line bg-panel px-3 py-2"> onDraftChange={updateDraft}
<span className="font-mono text-ink">{providers.length}</span> onSave={(id) => void saveCredential(id)}
</div> onTest={(id) => void testConnection(id)}
<div className="rounded border border-line bg-panel px-3 py-2"> />
{" "}
<span className="font-mono text-ink">
{API_KEY_PROVIDERS.length}
</span>
</div>
<div className="rounded border border-line bg-panel px-3 py-2">
{" "}
<span className="font-mono text-ink">
{Object.keys(results).length}
</span>
</div>
</div>
{providers.length === 0 ? (
<EmptyState
icon={PlugZap}
title="还没有可用提供商"
description="至少连接一个提供商即可开始写作。求质量可选 Anthropic求性价比可选 DeepSeek。"
className="mt-4"
/>
) : null}
<ul className="mt-4 divide-y divide-line rounded border border-line bg-panel">
{API_KEY_PROVIDERS.map((prov) => {
const masked = maskedFor(prov.id);
const result = results[prov.id];
return (
<li key={prov.id} className="px-4 py-4">
<div className="grid gap-3 lg:grid-cols-[10rem_8rem_minmax(12rem,1fr)_auto_auto] lg:items-center">
<span className="flex items-center gap-2 text-sm text-ink">
<span
className={`h-2 w-2 rounded ${
masked ? "bg-pass" : "bg-line"
}`}
aria-hidden="true"
/>
{prov.label}
</span>
<span className="font-mono text-xs text-ink-soft">
{masked ?? "未配置"}
</span>
<label className="sr-only" htmlFor={`key-${prov.id}`}>
{prov.label} API Key
</label>
<TextInput
id={`key-${prov.id}`}
type="password"
autoComplete="off"
value={drafts[prov.id] ?? ""}
onChange={(e) =>
setDrafts((prev) => ({
...prev,
[prov.id]: e.target.value,
}))
}
placeholder={
masked ? "输入新 Key 以更新" : "输入 API Key"
}
/>
<Button
onClick={() => saveCredential(prov.id)}
disabled={
savingId === prov.id ||
(drafts[prov.id] ?? "").trim().length === 0
}
variant="primary"
size="sm"
>
<Save className="h-4 w-4" aria-hidden="true" />
{savingId === prov.id
? "保存中…"
: masked
? "更新"
: "添加"}
</Button>
<Button
onClick={() => testConnection(prov.id)}
disabled={testingId === prov.id}
variant="secondary"
size="sm"
>
<PlugZap className="h-4 w-4" aria-hidden="true" />
{testingId === prov.id ? "测试中…" : "测试"}
</Button>
</div>
<p className="mt-2 text-xs leading-5 text-ink-soft lg:pl-[10.5rem]">
{masked
? "已保存脱敏凭据;输入新 Key 可覆盖更新。"
: "保存后再测试连接,成功后即可在档位路由中使用。"}
</p>
{result ? (
<div className="mt-2 flex flex-wrap items-center gap-2 lg:pl-[10.5rem]">
<Badge variant={result.ok ? "success" : "danger"}>
{result.ok ? (
<CheckCircle2
className="h-3 w-3"
aria-hidden="true"
/>
) : (
<XCircle className="h-3 w-3" aria-hidden="true" />
)}
{result.ok ? "已连接" : "未连接"}
</Badge>
<CapabilityBadges caps={result.capabilities} />
</div>
) : null}
</li>
);
})}
</ul>
</SettingsPanel>
) : null} ) : null}
</div> </div>
</div> </div>
); );
} }
function SettingsPanel({ children }: { children: ReactNode }) {
return <section className="min-w-0">{children}</section>;
}
interface SettingsNavButtonProps {
active: boolean;
icon: ReactNode;
label: string;
detail: string;
onClick: () => void;
}
function SettingsNavButton({
active,
icon,
label,
detail,
onClick,
}: SettingsNavButtonProps) {
return (
<button
type="button"
aria-pressed={active}
onClick={onClick}
className={`mb-1 flex w-full items-start gap-2 rounded px-3 py-2 text-left transition-colors ${
active
? "bg-[var(--color-cinnabar-wash)] text-cinnabar"
: "text-ink hover:bg-bg hover:text-cinnabar"
}`}
>
<span className="mt-0.5 shrink-0">{icon}</span>
<span className="min-w-0">
<span className="block text-sm font-medium">{label}</span>
<span className="block truncate text-xs text-ink-soft">{detail}</span>
</span>
</button>
);
}
function CapabilityBadges({ caps }: { caps: CapabilitiesView }) {
const badges: { on: boolean; label: string }[] = [
{ on: caps.structured_output, label: "结构化" },
{ on: caps.prefix_cache, label: "前缀缓存" },
{ on: caps.thinking, label: "思考" },
];
return (
<div className="flex gap-1.5">
{badges.map((b) => (
<Badge
key={b.label}
variant={b.on ? "accent" : "neutral"}
className="text-2xs"
>
{b.label}
</Badge>
))}
</div>
);
}

View File

@@ -0,0 +1,126 @@
"use client";
import { Save } from "lucide-react";
import { Button } from "@/components/ui/Button";
import { SectionHeader } from "@/components/ui/SectionHeader";
import { Select } from "@/components/ui/Select";
import { StatusNote } from "@/components/ui/StatusNote";
import { TextInput } from "@/components/ui/TextInput";
import {
KNOWN_PROVIDERS,
TIER_LABELS,
type RoutingDraft,
} from "@/lib/settings/providers";
// 档位路由「模型」输入框的候选 model iddatalist 建议,仍可自由输入)。按 provider 分组。
const MODEL_SUGGESTIONS: Record<string, readonly string[]> = {
anthropic: ["claude-opus-4", "claude-sonnet-4", "claude-3-5-haiku"],
deepseek: ["deepseek-chat", "deepseek-reasoner"],
kimi: ["moonshot-v1-128k", "moonshot-v1-32k", "kimi-k2"],
openai: ["gpt-4o", "gpt-4o-mini", "o3-mini"],
qwen: ["qwen-max", "qwen-plus", "qwen-turbo"],
glm: ["glm-4-plus", "glm-4-air", "glm-4-flash"],
gemini: ["gemini-2.0-flash", "gemini-1.5-pro"],
"kimi-code-key": ["kimi-for-coding"],
"kimi-code": ["kimi-for-coding"],
};
interface RoutingPanelProps {
routing: RoutingDraft[];
saving: boolean;
onProviderChange: (tier: string, providerId: string) => void;
onModelChange: (tier: string, model: string) => void;
onSave: () => void;
}
// 能力档位路由面板:写手/分析/轻量三档各指向一个 provider+modelUX §6.10)。
export function RoutingPanel({
routing,
saving,
onProviderChange,
onModelChange,
onSave,
}: RoutingPanelProps) {
return (
<section className="min-w-0">
<SectionHeader
eyebrow="路由"
title="能力档位路由"
description="写手、分析、轻量三类能力可分别指向不同模型。保存时只提交完整填写的行。"
/>
<StatusNote className="mt-3" variant="info">
稿
</StatusNote>
<ul className="mt-4 divide-y divide-line rounded border border-line bg-panel">
{routing.map((row) => (
<RoutingRow
key={row.tier}
row={row}
onProviderChange={onProviderChange}
onModelChange={onModelChange}
/>
))}
</ul>
<div className="mt-3 flex justify-end">
<Button
onClick={onSave}
disabled={saving}
variant="primary"
size="sm"
>
<Save className="h-4 w-4" aria-hidden="true" />
{saving ? "保存中…" : "保存档位路由"}
</Button>
</div>
</section>
);
}
interface RoutingRowProps {
row: RoutingDraft;
onProviderChange: (tier: string, providerId: string) => void;
onModelChange: (tier: string, model: string) => void;
}
function RoutingRow({ row, onProviderChange, onModelChange }: RoutingRowProps) {
const tierLabel = TIER_LABELS[row.tier] ?? row.tier;
return (
<li className="grid gap-3 px-4 py-3 text-sm md:grid-cols-[6rem_minmax(10rem,14rem)_1fr]">
<span className="self-center text-ink">{tierLabel}</span>
<label className="sr-only" htmlFor={`route-provider-${row.tier}`}>
{tierLabel}
</label>
<Select
id={`route-provider-${row.tier}`}
value={row.provider}
onChange={(e) => onProviderChange(row.tier, e.target.value)}
>
<option value=""></option>
{KNOWN_PROVIDERS.map((p) => (
<option key={p.id} value={p.id}>
{p.label}
</option>
))}
</Select>
<label className="sr-only" htmlFor={`route-model-${row.tier}`}>
{tierLabel}
</label>
<div className="min-w-0">
<TextInput
id={`route-model-${row.tier}`}
value={row.model}
onChange={(e) => onModelChange(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>
);
}

View File

@@ -0,0 +1,89 @@
"use client";
import { KeyRound, PlugZap, Route, type LucideIcon } from "lucide-react";
import type { ReactNode } from "react";
import { cardClass } from "@/lib/ui/variants";
export type SettingsSection = "routing" | "oauth" | "keys";
// 分组的单一标签源:桌面 aside 与移动 SegmentedControl 共用,消除「档位路由 / 路由」漂移。
export 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 },
];
export const SECTION_OPTIONS = SETTINGS_SECTIONS.map(({ value, label }) => ({
value,
label,
}));
interface SettingsNavProps {
activeSection: SettingsSection;
onSelect: (value: SettingsSection) => void;
detailFor: (value: SettingsSection) => string;
}
// 桌面侧栏:设置分组导航(移动端由 SegmentedControl 承担)。
export function SettingsNav({
activeSection,
onSelect,
detailFor,
}: SettingsNavProps) {
return (
<aside className="hidden lg:block">
<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={detailFor(section.value)}
onClick={() => onSelect(section.value)}
/>
))}
</nav>
</aside>
);
}
interface SettingsNavButtonProps {
active: boolean;
icon: ReactNode;
label: string;
detail: string;
onClick: () => void;
}
function SettingsNavButton({
active,
icon,
label,
detail,
onClick,
}: SettingsNavButtonProps) {
return (
<button
type="button"
aria-pressed={active}
onClick={onClick}
className={`mb-1 flex w-full items-start gap-2 rounded px-3 py-2 text-left transition-colors ${
active
? "bg-[var(--color-cinnabar-wash)] text-cinnabar"
: "text-ink hover:bg-bg hover:text-cinnabar"
}`}
>
<span className="mt-0.5 shrink-0">{icon}</span>
<span className="min-w-0">
<span className="block text-sm font-medium">{label}</span>
<span className="block truncate text-xs text-ink-soft">{detail}</span>
</span>
</button>
);
}

View File

@@ -182,7 +182,7 @@ export function TemplatesManager({ initial, tools }: TemplatesManagerProps) {
</form> </form>
<section className="flex flex-col gap-3" aria-label="模板列表"> <section className="flex flex-col gap-3" aria-label="模板列表">
<h2 className="font-serif text-lg text-ink">{templates.length}</h2> <h2 className="font-serif text-title-md text-ink">{templates.length}</h2>
{templates.length === 0 ? ( {templates.length === 0 ? (
<EmptyState <EmptyState
icon={FileText} icon={FileText}
@@ -194,7 +194,7 @@ export function TemplatesManager({ initial, tools }: TemplatesManagerProps) {
{templates.map((t) => ( {templates.map((t) => (
<li <li
key={t.id} key={t.id}
className="flex items-start justify-between gap-4 rounded border border-line bg-bg p-3 text-sm" className="flex items-start justify-between gap-4 rounded-lg border border-line bg-bg p-3 text-sm"
> >
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="font-serif text-base text-ink">{t.title}</p> <p className="font-serif text-base text-ink">{t.title}</p>

View File

@@ -7,6 +7,7 @@ import { useToast } from "@/components/Toast";
import { ConflictAdjudication } from "@/components/generation/ConflictAdjudication"; import { ConflictAdjudication } from "@/components/generation/ConflictAdjudication";
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
import { Badge } from "@/components/ui/Badge"; import { Badge } from "@/components/ui/Badge";
import { Checkbox } from "@/components/ui/Checkbox";
import { Field } from "@/components/ui/Field"; import { Field } from "@/components/ui/Field";
import { StatusNote } from "@/components/ui/StatusNote"; import { StatusNote } from "@/components/ui/StatusNote";
import { TextArea } from "@/components/ui/TextArea"; import { TextArea } from "@/components/ui/TextArea";
@@ -380,8 +381,7 @@ function PreviewRow({
return ( return (
<li className="flex gap-2 rounded border border-line bg-bg p-3 text-sm"> <li className="flex gap-2 rounded border border-line bg-bg p-3 text-sm">
{selectable ? ( {selectable ? (
<input <Checkbox
type="checkbox"
checked={checked} checked={checked}
onChange={() => onToggle(index)} onChange={() => onToggle(index)}
className="mt-1" className="mt-1"

View File

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

View File

@@ -0,0 +1,20 @@
import type { InputHTMLAttributes, ReactNode } from "react";
import { checkboxClass } from "@/lib/ui/variants";
interface CheckboxProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "type"> {
label?: ReactNode;
}
// 勾选框:无 label 时只渲染受控原生 input供已有 <label>/布局包裹的调用点直接替换);
// 有 label 时自带 <label> 包裹,保留原生键盘/读屏语义。
export function Checkbox({ label, className, ...props }: CheckboxProps) {
const input = <input type="checkbox" className={checkboxClass(className)} {...props} />;
if (!label) return input;
return (
<label className="inline-flex items-center gap-2 text-sm text-ink">
{input}
<span>{label}</span>
</label>
);
}

View File

@@ -1,38 +1,117 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import type { LucideIcon } from "lucide-react"; import type { LucideIcon } from "lucide-react";
import { Card } from "@/components/ui/Card";
import { Eyebrow } from "@/components/ui/Eyebrow";
import { cn } from "@/lib/ui/variants"; import { cn } from "@/lib/ui/variants";
// 空态外框形态:
// - "inline"(默认):虚线中性框,用于列表/面板内联占位(向后兼容旧默认外观)。
// - "card":包裹在 Card(tone=soft) 内,用于独立区块的引导卡片。
// - "bare":无外框,仅内容,交由调用方自行放置(如已在卡片里)。
type EmptyStateVariant = "inline" | "card" | "bare";
type EmptyStateSize = "sm" | "md";
interface EmptyStateProps { interface EmptyStateProps {
icon: LucideIcon; icon: LucideIcon;
title: string; title: string;
description: string; description: string;
action?: ReactNode; action?: ReactNode;
/** 可选眉题,置于标题上方,用于分类/上下文提示。 */
eyebrow?: string;
/** 外框形态,默认 "inline"。 */
variant?: EmptyStateVariant;
/** 留白密度,默认 "md"。 */
size?: EmptyStateSize;
className?: string; className?: string;
} }
export function EmptyState({ const SIZE_PAD: Record<EmptyStateSize, string> = {
sm: "px-5 py-8",
md: "px-6 py-10",
};
const SIZE_CHIP: Record<EmptyStateSize, string> = {
sm: "h-10 w-10",
md: "h-11 w-11",
};
// 内容主体:图标片 + 眉题 + 标题 + 描述 + 动作。中性、编辑部化,留白克制。
function EmptyStateBody({
icon: Icon, icon: Icon,
title, title,
description, description,
action, action,
className, eyebrow,
}: EmptyStateProps) { size,
}: Pick<
EmptyStateProps,
"icon" | "title" | "description" | "action" | "eyebrow"
> & {
size: EmptyStateSize;
}) {
return ( return (
<div <div className="text-center">
className={cn( <div
"rounded border border-dashed border-line bg-panel/70 px-6 py-10 text-center", className={cn(
className, "mx-auto mb-3 flex items-center justify-center rounded-md bg-surface-soft text-muted-soft",
)} SIZE_CHIP[size],
> )}
<div className="mx-auto mb-3 flex h-10 w-10 items-center justify-center rounded bg-[var(--color-cinnabar-wash)] text-cinnabar"> >
<Icon className="h-5 w-5" aria-hidden="true" /> <Icon className="h-5 w-5" aria-hidden="true" />
</div> </div>
<h2 className="font-serif text-lg text-ink">{title}</h2> {eyebrow ? <Eyebrow className="mb-2">{eyebrow}</Eyebrow> : null}
<p className="mx-auto mt-2 max-w-md text-sm leading-6 text-ink-soft"> <h2 className="font-serif text-title-md text-ink">{title}</h2>
<p className="mx-auto mt-2 max-w-sm text-caption leading-6 text-ink-soft">
{description} {description}
</p> </p>
{action ? <div className="mt-4">{action}</div> : null} {action ? <div className="mt-4">{action}</div> : null}
</div> </div>
); );
} }
export function EmptyState({
icon,
title,
description,
action,
eyebrow,
variant = "inline",
size = "md",
className,
}: EmptyStateProps) {
const body = (
<EmptyStateBody
icon={icon}
title={title}
description={description}
action={action}
eyebrow={eyebrow}
size={size}
/>
);
if (variant === "bare") {
return <div className={cn(SIZE_PAD[size], className)}>{body}</div>;
}
if (variant === "card") {
return (
<Card tone="soft" flat className={cn(SIZE_PAD[size], className)}>
{body}
</Card>
);
}
return (
<div
className={cn(
"rounded-lg border border-dashed border-line bg-panel/60",
SIZE_PAD[size],
className,
)}
>
{body}
</div>
);
}

View File

@@ -0,0 +1,23 @@
import type { ReactNode } from "react";
import { cn } from "@/lib/ui/variants";
interface EyebrowProps {
children: ReactNode;
className?: string;
}
// 眉题/小标签无衬线、eyebrow 字号(含正字距 0.08em)、说明色、大写。
// 统一各页此前 mono/uppercase 互不一致的小标签写法。
export function Eyebrow({ children, className }: EyebrowProps) {
return (
<p
className={cn(
"font-sans text-eyebrow uppercase text-muted-soft",
className,
)}
>
{children}
</p>
);
}

View File

@@ -0,0 +1,45 @@
import { cn } from "@/lib/ui/variants";
export type MeterTone = "accent" | "success" | "warning" | "danger" | "info";
const meterTones: Record<MeterTone, string> = {
accent: "bg-cinnabar",
success: "bg-pass",
warning: "bg-overdue",
danger: "bg-conflict",
info: "bg-info",
};
interface MeterProps {
/** 0..1,超出范围自动夹取。 */
value: number;
tone?: MeterTone;
label?: string;
className?: string;
}
// 进度条:语义色填充,宽度过渡走动效 token对读屏暴露 progressbar。
export function Meter({ value, tone = "accent", label, className }: MeterProps) {
const pct = Math.max(0, Math.min(1, value)) * 100;
return (
<div
className={cn(
"h-1.5 w-full overflow-hidden rounded-full bg-line/60",
className,
)}
role="progressbar"
aria-valuenow={Math.round(pct)}
aria-valuemin={0}
aria-valuemax={100}
aria-label={label}
>
<div
className={cn(
"h-full rounded-full transition-[width] duration-base ease-standard",
meterTones[tone],
)}
style={{ width: `${pct}%` }}
/>
</div>
);
}

View File

@@ -0,0 +1,30 @@
import type { ReactNode } from "react";
import { cn } from "@/lib/ui/variants";
export type PageWidth = "prose" | "default" | "wide";
const widths: Record<PageWidth, string> = {
prose: "max-w-3xl",
default: "max-w-5xl",
wide: "max-w-6xl",
};
interface PageContainerProps {
children: ReactNode;
width?: PageWidth;
className?: string;
}
// 统一页容器:居中 + 一致的最大宽度与内边距节奏(替换各页手写的 mx-auto max-w-* px-6 py-10
export function PageContainer({
children,
width = "default",
className,
}: PageContainerProps) {
return (
<div className={cn("mx-auto px-6 py-10 sm:px-8", widths[width], className)}>
{children}
</div>
);
}

View File

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

View File

@@ -0,0 +1,20 @@
import type { InputHTMLAttributes, ReactNode } from "react";
import { radioClass } from "@/lib/ui/variants";
interface RadioProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "type"> {
label?: ReactNode;
}
// 单选钮:无 label 时只渲染受控原生 input供已有 <label>/布局包裹的调用点直接替换);
// 有 label 时自带 <label> 包裹,保留原生键盘/读屏语义。
export function Radio({ label, className, ...props }: RadioProps) {
const input = <input type="radio" className={radioClass(className)} {...props} />;
if (!label) return input;
return (
<label className="inline-flex items-center gap-2 text-sm text-ink">
{input}
<span>{label}</span>
</label>
);
}

View File

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

View File

@@ -1,6 +1,7 @@
import type { ReactNode } from "react"; import type { KeyboardEvent, ReactNode } from "react";
import { useRef } from "react";
import { cn, segmentedClass } from "@/lib/ui/variants"; import { cn, focusRing, segmentedClass, transitionUi } from "@/lib/ui/variants";
export interface SegmentOption<T extends string> { export interface SegmentOption<T extends string> {
value: T; value: T;
@@ -22,18 +23,61 @@ export function SegmentedControl<T extends string>({
ariaLabel, ariaLabel,
className, className,
}: SegmentedControlProps<T>) { }: SegmentedControlProps<T>) {
const buttonsRef = useRef<Array<HTMLButtonElement | null>>([]);
const focusOption = (index: number) => {
const clamped = (index + options.length) % options.length;
const target = options[clamped];
if (!target) return;
onChange(target.value);
buttonsRef.current[clamped]?.focus();
};
const handleKeyDown = (event: KeyboardEvent<HTMLButtonElement>, index: number) => {
switch (event.key) {
case "ArrowRight":
case "ArrowDown":
event.preventDefault();
focusOption(index + 1);
break;
case "ArrowLeft":
case "ArrowUp":
event.preventDefault();
focusOption(index - 1);
break;
case "Home":
event.preventDefault();
focusOption(0);
break;
case "End":
event.preventDefault();
focusOption(options.length - 1);
break;
default:
break;
}
};
return ( return (
<div className={segmentedClass(className)} role="group" aria-label={ariaLabel}> <div className={segmentedClass(className)} role="radiogroup" aria-label={ariaLabel}>
{options.map((option) => { {options.map((option, index) => {
const selected = option.value === value; const selected = option.value === value;
return ( return (
<button <button
key={option.value} key={option.value}
ref={(node) => {
buttonsRef.current[index] = node;
}}
type="button" type="button"
aria-pressed={selected} role="radio"
aria-checked={selected}
tabIndex={selected ? 0 : -1}
onClick={() => onChange(option.value)} onClick={() => onChange(option.value)}
onKeyDown={(event) => handleKeyDown(event, index)}
className={cn( className={cn(
"rounded px-3 py-1.5 text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cinnabar/35", "rounded-md px-3 py-1.5 text-sm",
transitionUi,
focusRing,
selected selected
? "bg-panel text-cinnabar shadow-paper" ? "bg-panel text-cinnabar shadow-paper"
: "text-ink-soft hover:text-cinnabar", : "text-ink-soft hover:text-cinnabar",

View File

@@ -0,0 +1,18 @@
import { cn } from "@/lib/ui/variants";
interface SkeletonProps {
className?: string;
}
// 加载占位:柔和线条底 + motion-safe 脉冲(尊重 prefers-reduced-motion
export function Skeleton({ className }: SkeletonProps) {
return (
<span
aria-hidden="true"
className={cn(
"block rounded-md bg-line/60 motion-safe:animate-pulse",
className,
)}
/>
);
}

View File

@@ -0,0 +1,41 @@
import { cn } from "@/lib/ui/variants";
export type StatusTone =
| "neutral"
| "accent"
| "success"
| "warning"
| "danger"
| "info";
const dotTones: Record<StatusTone, string> = {
neutral: "bg-ink-soft",
accent: "bg-cinnabar",
success: "bg-pass",
warning: "bg-overdue",
danger: "bg-conflict",
info: "bg-info",
};
interface StatusDotProps {
tone?: StatusTone;
/** 提供 label 时对读屏可见(不单靠颜色传达状态)。 */
label?: string;
className?: string;
}
// 状态点:语义色圆点,配合文案使用;有 label 时暴露给读屏。
export function StatusDot({ tone = "neutral", label, className }: StatusDotProps) {
return (
<span
className={cn(
"inline-block h-2 w-2 shrink-0 rounded-full",
dotTones[tone],
className,
)}
role={label ? "img" : undefined}
aria-label={label}
aria-hidden={label ? undefined : true}
/>
);
}

View File

@@ -0,0 +1,309 @@
"use client";
import { useEffect, useId, useRef, useState } from "react";
import {
ChevronDown,
HelpCircle,
PenLine,
RefreshCw,
Sparkles,
Square,
Wand2,
WrapText,
} from "lucide-react";
import { Button } from "@/components/ui/Button";
import { Eyebrow } from "@/components/ui/Eyebrow";
import { SectionHeader } from "@/components/ui/SectionHeader";
import { StatusNote } from "@/components/ui/StatusNote";
import { TextArea } from "@/components/ui/TextArea";
import { PLOT_PRESETS, STYLE_PRESETS, type Preset } from "@/lib/workbench/directive";
import { buttonClass, focusRing } from "@/lib/ui/variants";
interface DirectivePanelProps {
directive: string;
onDirectiveChange: (value: string) => void;
presetIds: readonly string[];
onTogglePreset: (id: string) => void;
// 空章默认展开(新手最需要看到输入框的时机);满章默认收起以让出编辑视野。
defaultOpen: boolean;
// 发送键 = 写本章:把已选文风/剧情 + 自定义要求组装后逐字流式写进正文(不改成聊天气泡)。
streaming: boolean;
onWrite: () => void;
onStop: () => void;
}
// 本章写作指令面板P0-2 / P3-3 提升):正文正上方常驻的「和 AI 说话」输入条。
// 眉题「和 AI 说话」+ 主标题「告诉 AI 这章想怎么写」+ HITL 微文案 +「?这是什么」常驻可见;
// 主 CTA「写本章」突出md 尺寸、独占右侧),空章默认展开露出输入框。
// 发送键即「写本章」,沿用 composeDirective + 文风/剧情 chips + 流式打字机写进正文。
export function DirectivePanel({
directive,
onDirectiveChange,
presetIds,
onTogglePreset,
defaultOpen,
streaming,
onWrite,
onStop,
}: DirectivePanelProps) {
const panelId = useId();
const [open, setOpen] = useState(defaultOpen);
const activeCount = presetIds.length + (directive.trim().length > 0 ? 1 : 0);
return (
<section className="border-b border-line bg-panel px-4 py-4 sm:px-6">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<Eyebrow className="mb-1"> AI </Eyebrow>
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
aria-controls={panelId}
className={`group flex min-w-0 cursor-pointer items-center gap-2 rounded text-left font-serif text-title-md text-ink transition-colors duration-fast ease-standard hover:text-cinnabar ${focusRing}`}
>
<ChevronDown
className={`h-4 w-4 shrink-0 text-muted-soft motion-safe:transition-transform duration-fast ease-standard group-hover:text-cinnabar ${
open ? "rotate-180" : ""
}`}
aria-hidden="true"
/>
<span className="truncate"> AI </span>
</button>
</div>
<div className="flex shrink-0 items-center gap-2">
{activeCount > 0 ? (
<span className="hidden rounded-full border border-line bg-bg px-2.5 py-0.5 text-2xs text-ink-soft sm:inline">
{activeCount}
</span>
) : null}
{/* 常驻主 CTA = 写本章始终可见折叠时也在md 尺寸凸显为本条主动作,逐字流式写进正文。 */}
{streaming ? (
<Button onClick={onStop} variant="danger" size="md">
<Square className="h-4 w-4" aria-hidden="true" />
</Button>
) : (
<Button onClick={onWrite} variant="primary" size="md">
<PenLine className="h-4 w-4" aria-hidden="true" />
<span className="text-2xs font-normal text-panel/75">
·
</span>
</Button>
)}
<HitlHelp />
</div>
</div>
<p className="mt-2 text-caption text-muted-soft">
AI 稿
</p>
{open ? (
<div id={panelId} className="mt-4 space-y-4">
<SectionHeader
title="你的写作要求"
description="选文风、点剧情需求、写自己的要求,只影响本次生成。"
/>
<PresetGroup
label="文风"
presets={STYLE_PRESETS}
presetIds={presetIds}
onToggle={onTogglePreset}
/>
<PresetGroup
label="剧情需求"
presets={PLOT_PRESETS}
presetIds={presetIds}
onToggle={onTogglePreset}
/>
<label className="block">
<span className="mb-1 block text-sm text-ink-soft"></span>
<TextArea
value={directive}
onChange={(e) => onDirectiveChange(e.target.value)}
rows={2}
placeholder="写点要求AI 更懂你(留空则按大纲写)"
/>
</label>
{activeCount > 0 ? (
<StatusNote variant="info">
/
</StatusNote>
) : null}
</div>
) : null}
</section>
);
}
interface AiVerbsRowProps {
streaming: boolean;
onContinue: () => void;
onRefineSelection: () => void;
onRewrite: () => void;
onToolbox: () => void;
}
// 「或让 AI:」次级动作行P1-1 / P3-3常驻贴在中央输入条下方把续写/润色/整章重写/工具箱
// 归为「同一个 AI 的其它用法」act-on-chapter 与导航分家)。用较浅面色 + 更软分隔成组,
// 与上方主指令条拉开层级。每个动词带可见范围提示P1-4。生成中禁用避免与流式写章并发
export function AiVerbsRow({
streaming,
onContinue,
onRefineSelection,
onRewrite,
onToolbox,
}: AiVerbsRowProps) {
return (
<div className="flex flex-wrap items-center gap-x-3 gap-y-2 border-b border-line-soft bg-surface-soft px-4 py-2.5 sm:px-6">
<span className="text-eyebrow uppercase tracking-wide text-muted-soft">
AI
</span>
<VerbButton
icon={WrapText}
label="续写"
hint="接着往下写"
disabled={streaming}
onClick={onContinue}
/>
<VerbButton
icon={Wand2}
label="润色选段"
hint="选中段·打磨"
disabled={streaming}
onClick={onRefineSelection}
/>
<VerbButton
icon={RefreshCw}
label="整章重写"
hint="整章·基于现有重写"
disabled={streaming}
onClick={onRewrite}
/>
<VerbButton
icon={Sparkles}
label="工具箱"
disabled={streaming}
onClick={onToolbox}
/>
</div>
);
}
interface VerbButtonProps {
icon: typeof WrapText;
label: string;
// 可见(非 hover-only范围提示让作者点前就能预判对草稿的影响P1-4。工具箱无范围提示。
hint?: string;
disabled: boolean;
onClick: () => void;
}
function VerbButton({ icon: Icon, label, hint, disabled, onClick }: VerbButtonProps) {
return (
<Button onClick={onClick} disabled={disabled} variant="secondary" size="sm">
<Icon className="h-4 w-4" aria-hidden="true" />
{label}
{hint ? <span className="text-2xs text-ink-soft">{hint}</span> : null}
</Button>
);
}
// 「?这是什么」:常驻、可再发现的说明(非一次性 coach——解释「和 AI 说话」的闭环。
// 按钮 + 轻量弹层Esc / 点击外部关闭,还原焦点。
function HitlHelp() {
const [open, setOpen] = useState(false);
const popId = useId();
const rootRef = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (!open) return;
const onKeyDown = (e: KeyboardEvent): void => {
if (e.key === "Escape") {
setOpen(false);
buttonRef.current?.focus();
}
};
const onPointerDown = (e: PointerEvent): void => {
const target = e.target;
if (target instanceof Node && !rootRef.current?.contains(target)) {
setOpen(false);
}
};
window.addEventListener("keydown", onKeyDown);
window.addEventListener("pointerdown", onPointerDown);
return () => {
window.removeEventListener("keydown", onKeyDown);
window.removeEventListener("pointerdown", onPointerDown);
};
}, [open]);
return (
<div ref={rootRef} className="relative">
<button
ref={buttonRef}
type="button"
aria-haspopup="dialog"
aria-expanded={open}
aria-controls={popId}
onClick={() => setOpen((v) => !v)}
className={buttonClass({
variant: "ghost",
size: "sm",
className: "gap-1 text-xs text-ink-soft",
})}
>
<HelpCircle className="h-4 w-4" aria-hidden="true" />
</button>
{open ? (
<div
id={popId}
role="dialog"
aria-label="怎么和 AI 说话"
className="absolute right-0 z-30 mt-2 w-72 max-w-[80vw] rounded-lg border border-line bg-panel p-4 text-sm text-ink-soft shadow-paper"
>
<p className="mb-1 font-serif text-title-md text-ink"> AI </p>
<p className="leading-6">
AI 稿稿
稿
</p>
</div>
) : null}
</div>
);
}
interface PresetGroupProps {
label: string;
presets: readonly Preset[];
presetIds: readonly string[];
onToggle: (id: string) => void;
}
// 一组预设 chips文风 / 剧情需求):点选 toggle选中高亮。id 统一进 presetIds。
function PresetGroup({ label, presets, presetIds, onToggle }: PresetGroupProps) {
return (
<div>
<p className="mb-1 text-sm text-ink-soft">{label}</p>
<div className="flex flex-wrap gap-2" role="group" aria-label={label}>
{presets.map((preset) => {
const active = presetIds.includes(preset.id);
return (
<Button
key={preset.id}
type="button"
aria-pressed={active}
onClick={() => onToggle(preset.id)}
variant={active ? "outline" : "secondary"}
size="sm"
>
{preset.label}
</Button>
);
})}
</div>
</div>
);
}

View File

@@ -1,10 +1,19 @@
"use client"; "use client";
import { Minus, Pin, PinOff, Plus, RotateCcw, Undo2, X } from "lucide-react"; import type { ReactNode } from "react";
import {
Minus,
Pin,
PinOff,
Plus,
RotateCcw,
Sparkles,
Undo2,
X,
} from "lucide-react";
import { ThinkingIndicator } from "@/components/ThinkingIndicator"; import { ThinkingIndicator } from "@/components/ThinkingIndicator";
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
import { SectionHeader } from "@/components/ui/SectionHeader";
import { StatusNote } from "@/components/ui/StatusNote"; import { StatusNote } from "@/components/ui/StatusNote";
import { buttonClass } from "@/lib/ui/variants"; import { buttonClass } from "@/lib/ui/variants";
import { import {
@@ -35,6 +44,25 @@ export function ChapterAssistant({ projectId, chapterNo }: ChapterAssistantProps
); );
} }
// 窄栏小标题P3-5font-sans + eyebrow 字号/正字距(对齐 Eyebrow 眉题的视觉),
// 但保留 <h3> 语义(不降级为 <p>,守住标题大纲 a11y。可带右侧行内控件如步进器
function PanelLabel({
children,
action,
}: {
children: ReactNode;
action?: ReactNode;
}) {
return (
<div className="flex items-start justify-between gap-3">
<h3 className="font-sans text-eyebrow uppercase tracking-wide text-muted-soft">
{children}
</h3>
{action ? <div className="shrink-0">{action}</div> : null}
</div>
);
}
export function AssistantContent({ projectId, chapterNo }: ChapterAssistantProps) { export function AssistantContent({ projectId, chapterNo }: ChapterAssistantProps) {
const injection = useInjection(projectId, chapterNo); const injection = useInjection(projectId, chapterNo);
const data = injection.data; const data = injection.data;
@@ -43,21 +71,17 @@ export function AssistantContent({ projectId, chapterNo }: ChapterAssistantProps
return ( return (
<div className="px-4"> <div className="px-4">
<SectionHeader <div className="flex items-center justify-between gap-2">
title="本章参考" <h2 className="font-sans text-title-md font-medium text-ink">
action={
injection.saving ? ( </h2>
<ThinkingIndicator {injection.saving ? (
label="保存中" <ThinkingIndicator label="保存中" className="text-xs text-ink-soft" />
className="text-xs text-ink-soft" ) : null}
/> </div>
) : null
}
/>
<section className="mt-4"> <section className="mt-5">
<SectionHeader <PanelLabel
title="AI 写这章会参考"
action={ action={
data ? ( data ? (
<RecentStepper <RecentStepper
@@ -67,10 +91,12 @@ export function AssistantContent({ projectId, chapterNo }: ChapterAssistantProps
/> />
) : null ) : null
} }
/> >
AI
</PanelLabel>
{injection.loading ? ( {injection.loading ? (
<p className="mt-2 text-xs text-ink-soft"></p> <p className="mt-2 text-caption text-ink-soft"></p>
) : !data ? ( ) : !data ? (
// 首次加载失败data 仍为空 → 满屏错误框 + 重试。 // 首次加载失败data 仍为空 → 满屏错误框 + 重试。
<StatusNote variant="danger" className="mt-2 text-xs" title="参考信息暂不可用"> <StatusNote variant="danger" className="mt-2 text-xs" title="参考信息暂不可用">
@@ -113,11 +139,15 @@ export function AssistantContent({ projectId, chapterNo }: ChapterAssistantProps
) : null} ) : null}
{selected.length === 0 ? ( {selected.length === 0 ? (
<p className="mt-2 rounded border border-dashed border-line bg-bg p-3 text-xs text-ink-soft"> // 空态占位(不加标题,避免在 h3 子小节内注入 h2 破坏标题大纲)。
AI / <div className="mt-2 flex flex-col items-center gap-2 py-4 text-center">
</p> <Sparkles className="h-5 w-5 text-muted-soft" aria-hidden="true" />
<p className="max-w-xs text-caption leading-6 text-ink-soft">
AI /
</p>
</div>
) : ( ) : (
<ul className="mt-2 space-y-2"> <ul className="mt-3 space-y-2">
{selected.map((entity) => ( {selected.map((entity) => (
<EntityRow <EntityRow
key={`${entity.kind}:${entity.name}`} key={`${entity.kind}:${entity.name}`}
@@ -137,9 +167,9 @@ export function AssistantContent({ projectId, chapterNo }: ChapterAssistantProps
)} )}
{excluded.length > 0 ? ( {excluded.length > 0 ? (
<div className="mt-3"> <div className="mt-4">
<SectionHeader title="已排除(本章不参考)" /> <PanelLabel></PanelLabel>
<ul className="mt-1 space-y-1"> <ul className="mt-2 space-y-1">
{excluded.map((ref) => ( {excluded.map((ref) => (
<ExcludedRow <ExcludedRow
key={`${ref.kind}:${ref.name}`} key={`${ref.kind}:${ref.name}`}
@@ -153,11 +183,11 @@ export function AssistantContent({ projectId, chapterNo }: ChapterAssistantProps
) : null} ) : null}
</section> </section>
<section className="mt-6"> <section className="mt-6 border-t border-line-soft pt-5">
<SectionHeader <PanelLabel></PanelLabel>
title="写完后检查" <p className="mt-1.5 text-caption leading-6 text-ink-soft">
description="写完这章AI 会把一致性、伏笔、文风、节奏各查一遍。到左栏或底栏的「审稿」逐条过。" AI 稿
/> </p>
</section> </section>
</div> </div>
); );

View File

@@ -29,7 +29,7 @@ export function ChapterList({
if (collapsed) { if (collapsed) {
return ( return (
<aside <aside
className="hidden shrink-0 border-r border-line bg-panel py-4 xl:block" className="hidden shrink-0 border-r border-line bg-surface-soft py-4 xl:block"
aria-label="目录(已收起)" aria-label="目录(已收起)"
> >
<button <button
@@ -38,7 +38,7 @@ export function ChapterList({
aria-expanded={false} aria-expanded={false}
aria-label="展开目录" aria-label="展开目录"
title="展开目录" title="展开目录"
className={`mx-auto flex h-8 w-8 items-center justify-center rounded text-ink-soft transition-colors hover:bg-[var(--color-cinnabar-wash)] hover:text-cinnabar ${focusRing}`} className={`mx-auto flex h-8 w-8 items-center justify-center rounded-md border border-line bg-panel text-ink-soft transition-colors duration-fast ease-standard hover:border-cinnabar hover:bg-[var(--color-cinnabar-wash)] hover:text-cinnabar ${focusRing}`}
> >
<PanelLeftOpen className="h-4 w-4" aria-hidden="true" /> <PanelLeftOpen className="h-4 w-4" aria-hidden="true" />
</button> </button>
@@ -46,7 +46,7 @@ export function ChapterList({
); );
} }
return ( return (
<aside className="hidden border-r border-line bg-panel py-4 xl:block"> <aside className="hidden border-r border-line bg-surface-soft py-4 xl:block">
<ChapterListContent <ChapterListContent
projectId={projectId} projectId={projectId}
chapters={chapters} chapters={chapters}
@@ -73,7 +73,7 @@ export function ChapterListContent({
return ( return (
<> <>
<div className="flex items-center justify-between px-4"> <div className="flex items-center justify-between px-4">
<h2 className="text-xs font-semibold uppercase tracking-wide text-ink-soft"> <h2 className="font-sans text-eyebrow uppercase tracking-wide text-muted-soft">
</h2> </h2>
{onCollapse ? ( {onCollapse ? (
@@ -82,7 +82,7 @@ export function ChapterListContent({
onClick={onCollapse} onClick={onCollapse}
aria-label="收起目录" aria-label="收起目录"
title="收起目录" title="收起目录"
className={`flex h-6 w-6 items-center justify-center rounded text-ink-soft transition-colors hover:bg-[var(--color-cinnabar-wash)] hover:text-cinnabar ${focusRing}`} className={`flex h-7 w-7 items-center justify-center rounded-md border border-line bg-panel text-ink-soft transition-colors duration-fast ease-standard hover:border-cinnabar hover:bg-[var(--color-cinnabar-wash)] hover:text-cinnabar ${focusRing}`}
> >
<PanelLeftClose className="h-4 w-4" aria-hidden="true" /> <PanelLeftClose className="h-4 w-4" aria-hidden="true" />
</button> </button>
@@ -98,8 +98,8 @@ export function ChapterListContent({
aria-current={active ? "page" : undefined} aria-current={active ? "page" : undefined}
className={ className={
active active
? "flex flex-col gap-0.5 border-l-2 border-cinnabar bg-[var(--color-cinnabar-wash)] px-4 py-2 text-sm text-cinnabar" ? `flex flex-col gap-0.5 border-l-2 border-cinnabar bg-[var(--color-cinnabar-wash)] px-4 py-2 text-sm font-medium text-cinnabar transition-colors duration-fast ease-standard ${focusRing}`
: "flex flex-col gap-0.5 border-l-2 border-transparent px-4 py-2 text-sm text-ink hover:border-cinnabar hover:text-cinnabar" : `flex flex-col gap-0.5 border-l-2 border-transparent px-4 py-2 text-sm text-ink transition-colors duration-fast ease-standard hover:border-line-soft hover:bg-panel/60 hover:text-cinnabar ${focusRing}`
} }
> >
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">

View File

@@ -0,0 +1,49 @@
"use client";
import type { RefObject } from "react";
import { PanelLeft, PanelRight } from "lucide-react";
import { Button } from "@/components/ui/Button";
interface MobileContextBarProps {
chapterNo: number;
onOpenChapters: () => void;
onOpenAssistant: () => void;
chapterTriggerRef: RefObject<HTMLButtonElement | null>;
assistantTriggerRef: RefObject<HTMLButtonElement | null>;
}
// 窄屏/中屏顶部上下文条:目录 / 本章参考经抽屉触达≥xl 直显三栏、此条隐藏。
export function MobileContextBar({
chapterNo,
onOpenChapters,
onOpenAssistant,
chapterTriggerRef,
assistantTriggerRef,
}: MobileContextBarProps) {
return (
<div className="flex items-center justify-between gap-2 border-b border-line bg-panel px-4 py-2 xl:hidden">
<Button
ref={chapterTriggerRef}
onClick={onOpenChapters}
variant="secondary"
size="sm"
>
<PanelLeft className="h-4 w-4" aria-hidden="true" />
</Button>
<span className="min-w-0 truncate font-mono text-xs text-ink-soft">
{chapterNo}
</span>
<Button
ref={assistantTriggerRef}
onClick={onOpenAssistant}
variant="secondary"
size="sm"
>
<PanelRight className="h-4 w-4" aria-hidden="true" />
</Button>
</div>
);
}

View File

@@ -1,32 +1,11 @@
"use client"; "use client";
import Link from "next/link"; import { useEffect, useRef, useState } from "react";
import { useEffect, useId, useRef, useState, type RefObject } from "react";
import {
BookMarked,
ChevronDown,
ClipboardCheck,
FileText,
HelpCircle,
MessagesSquare,
PanelLeft,
PanelRight,
PenLine,
RefreshCw,
Sparkles,
Square,
Wand2,
WrapText,
} from "lucide-react";
import { AppShell } from "@/components/AppShell"; import { AppShell } from "@/components/AppShell";
import { Drawer } from "@/components/Drawer"; import { Drawer } from "@/components/Drawer";
import { ThinkingIndicator } from "@/components/ThinkingIndicator";
import { useToast } from "@/components/Toast"; import { useToast } from "@/components/Toast";
import { Button } from "@/components/ui/Button";
import { SectionHeader } from "@/components/ui/SectionHeader";
import { StatusNote } from "@/components/ui/StatusNote"; import { StatusNote } from "@/components/ui/StatusNote";
import { TextArea } from "@/components/ui/TextArea";
import type { ProjectResponse } from "@/lib/api/types"; import type { ProjectResponse } from "@/lib/api/types";
import { friendlyError } from "@/lib/errors/messages"; import { friendlyError } from "@/lib/errors/messages";
import { useAutosave } from "@/lib/autosave/useAutosave"; import { useAutosave } from "@/lib/autosave/useAutosave";
@@ -35,14 +14,8 @@ import {
WORKBENCH_CHAPTER_NO, WORKBENCH_CHAPTER_NO,
type ChapterEntry, type ChapterEntry,
} from "@/lib/workbench/chapter"; } from "@/lib/workbench/chapter";
import { import { composeDirective } from "@/lib/workbench/directive";
composeDirective,
PLOT_PRESETS,
STYLE_PRESETS,
type Preset,
} from "@/lib/workbench/directive";
import { applyRefinement } from "@/lib/workbench/refineApply"; import { applyRefinement } from "@/lib/workbench/refineApply";
import { buttonClass, focusRing } from "@/lib/ui/variants";
import { ChapterList, ChapterListContent } from "./ChapterList"; import { ChapterList, ChapterListContent } from "./ChapterList";
import { ChapterAssistant, AssistantContent } from "./ChapterAssistant"; import { ChapterAssistant, AssistantContent } from "./ChapterAssistant";
import { Editor, type EditorSelection } from "./Editor"; import { Editor, type EditorSelection } from "./Editor";
@@ -50,7 +23,7 @@ import { RefinePanel } from "./RefinePanel";
import { ContinuePanel } from "./ContinuePanel"; import { ContinuePanel } from "./ContinuePanel";
import { InlineToolbox } from "./InlineToolbox"; import { InlineToolbox } from "./InlineToolbox";
import { ChapterRewritePanel } from "./ChapterRewritePanel"; import { ChapterRewritePanel } from "./ChapterRewritePanel";
import { ContextDrawer, CONTEXT_DRAWER_ENABLED } from "./ContextDrawer"; import { ContextDrawer } from "./ContextDrawer";
import { import {
AiConversationDrawer, AiConversationDrawer,
AI_CONVERSATION_ENABLED, AI_CONVERSATION_ENABLED,
@@ -60,6 +33,10 @@ import { GENRES } from "@/lib/wizard/wizard";
import { useGenreGate, toGenreDirective } from "@/lib/workbench/useGenreGate"; import { useGenreGate, toGenreDirective } from "@/lib/workbench/useGenreGate";
import { useAiConversation } from "@/lib/workbench/useAiConversation"; import { useAiConversation } from "@/lib/workbench/useAiConversation";
import { useChapterListCollapse } from "@/lib/workbench/useChapterListCollapse"; import { useChapterListCollapse } from "@/lib/workbench/useChapterListCollapse";
import { useFocusMode } from "@/lib/workbench/useFocusMode";
import { DirectivePanel, AiVerbsRow } from "./AiToolbar";
import { MobileContextBar } from "./MobileContextBar";
import { Toolbar } from "./WorkbenchToolbar";
interface WorkbenchProps { interface WorkbenchProps {
project: ProjectResponse; project: ProjectResponse;
@@ -107,6 +84,8 @@ export function Workbench({
const conversation = useAiConversation(project.id, chapterNo); const conversation = useAiConversation(project.id, chapterNo);
// P2-2桌面「目录」列可收起记忆状态收起时把宽让给正文窄轨留一个展开入口。 // P2-2桌面「目录」列可收起记忆状态收起时把宽让给正文窄轨留一个展开入口。
const chapterList = useChapterListCollapse(); const chapterList = useChapterListCollapse();
// P3-4专注写作开关记忆状态——开启隐藏左侧图标导航 + 目录 TOC让宽给正文。
const focusMode = useFocusMode();
const conversationTriggerRef = useRef<HTMLButtonElement>(null); const conversationTriggerRef = useRef<HTMLButtonElement>(null);
const toast = useToast(); const toast = useToast();
const autosave = useAutosave(project.id, chapterNo, initialText); const autosave = useAutosave(project.id, chapterNo, initialText);
@@ -116,6 +95,8 @@ export function Workbench({
const chapterTriggerRef = useRef<HTMLButtonElement>(null); const chapterTriggerRef = useRef<HTMLButtonElement>(null);
const assistantTriggerRef = useRef<HTMLButtonElement>(null); const assistantTriggerRef = useRef<HTMLButtonElement>(null);
const contextTriggerRef = useRef<HTMLButtonElement>(null); const contextTriggerRef = useRef<HTMLButtonElement>(null);
// P3-7 流式跟随:正文滚动容器引用(纯展示层,不触碰 SSE/流式数据)。
const editorScrollRef = useRef<HTMLDivElement>(null);
// 流式 token 累积进编辑器(打字机);停止/结束后已生成部分留在草稿。 // 流式 token 累积进编辑器(打字机);停止/结束后已生成部分留在草稿。
useEffect(() => { useEffect(() => {
@@ -132,6 +113,15 @@ export function Workbench({
} }
}, [stream.state.phase, stream.state.text, autosave]); }, [stream.state.phase, stream.state.text, autosave]);
// P3-7 流式跟随:流式且正文增长时把滚动容器贴到底,让新生成的段落保持可见。
// 纯展示层:只读既有 text/streaming 展示态,瞬时定位(无动画,天然尊重 prefers-reduced-motion
useEffect(() => {
if (!stream.isStreaming) return;
const el = editorScrollRef.current;
if (!el) return;
el.scrollTop = el.scrollHeight;
}, [text, stream.isStreaming]);
// 题材 + 三槽指令合并为一条写章指令题材前置effectiveGenre 无 → 仅原指令。 // 题材 + 三槽指令合并为一条写章指令题材前置effectiveGenre 无 → 仅原指令。
const startWrite = (effectiveGenre: string | null): void => { const startWrite = (effectiveGenre: string | null): void => {
const base = composeDirective(directive, presetIds); const base = composeDirective(directive, presetIds);
@@ -271,6 +261,13 @@ export function Workbench({
const wordCount = text.replace(/\s+/g, "").length; const wordCount = text.replace(/\s+/g, "").length;
const closePanel = (): void => setMobilePanel(null); const closePanel = (): void => setMobilePanel(null);
// 桌面栅格列宽:专注模式撤掉目录 TOC 列(仅正文 + 右栏);否则按目录列收起/展开取宽。
const gridCols = focusMode.focus
? "xl:grid-cols-[1fr_18rem]"
: chapterList.collapsed
? "xl:grid-cols-[2.75rem_1fr_18rem]"
: "xl:grid-cols-[12rem_1fr_18rem]";
// 流式里程碑播报(仅终结句,非逐 token完成/停止/失败时写入 role=status 区。 // 流式里程碑播报(仅终结句,非逐 token完成/停止/失败时写入 role=status 区。
const liveMessage = ((): string => { const liveMessage = ((): string => {
switch (stream.state.phase) { switch (stream.state.phase) {
@@ -294,21 +291,20 @@ export function Workbench({
subtitle={`${chapterNo}`} subtitle={`${chapterNo}`}
projectId={project.id} projectId={project.id}
activeNav="write" activeNav="write"
hideNav={focusMode.focus}
> >
<div <div
className={`grid min-h-[calc(100vh-var(--chrome,4rem))] grid-cols-1 xl:h-[calc(100vh-var(--chrome,4rem))] ${ className={`grid min-h-[calc(100vh-var(--chrome,4rem))] grid-cols-1 xl:h-[calc(100vh-var(--chrome,4rem))] ${gridCols}`}
chapterList.collapsed
? "xl:grid-cols-[2.75rem_1fr_18rem]"
: "xl:grid-cols-[12rem_1fr_18rem]"
}`}
> >
<ChapterList {focusMode.focus ? null : (
projectId={project.id} <ChapterList
chapters={chapters} projectId={project.id}
currentChapterNo={chapterNo} chapters={chapters}
collapsed={chapterList.collapsed} currentChapterNo={chapterNo}
onToggle={chapterList.toggle} collapsed={chapterList.collapsed}
/> onToggle={chapterList.toggle}
/>
)}
<section className="flex min-w-0 flex-col bg-bg"> <section className="flex min-w-0 flex-col bg-bg">
<MobileContextBar <MobileContextBar
@@ -386,7 +382,7 @@ export function Workbench({
onClose={() => setRewriteOpen(false)} onClose={() => setRewriteOpen(false)}
/> />
) : null} ) : null}
<div className="flex-1 overflow-auto px-6 py-8"> <div ref={editorScrollRef} className="flex-1 overflow-auto px-6 py-8">
{chapters.length === 0 ? ( {chapters.length === 0 ? (
<StatusNote variant="info" className="mb-4"> <StatusNote variant="info" className="mb-4">
@@ -409,6 +405,8 @@ export function Workbench({
streamError={stream.state.error} streamError={stream.state.error}
liveMessage={liveMessage} liveMessage={liveMessage}
onStop={stream.stop} onStop={stream.stop}
focus={focusMode.focus}
onToggleFocus={focusMode.toggle}
onOpenContext={() => setContextOpen(true)} onOpenContext={() => setContextOpen(true)}
contextTriggerRef={contextTriggerRef} contextTriggerRef={contextTriggerRef}
onOpenConversation={() => setConversationOpen(true)} onOpenConversation={() => setConversationOpen(true)}
@@ -467,471 +465,3 @@ export function Workbench({
</AppShell> </AppShell>
); );
} }
interface MobileContextBarProps {
chapterNo: number;
onOpenChapters: () => void;
onOpenAssistant: () => void;
chapterTriggerRef: RefObject<HTMLButtonElement | null>;
assistantTriggerRef: RefObject<HTMLButtonElement | null>;
}
function MobileContextBar({
chapterNo,
onOpenChapters,
onOpenAssistant,
chapterTriggerRef,
assistantTriggerRef,
}: MobileContextBarProps) {
return (
<div className="flex items-center justify-between gap-2 border-b border-line bg-panel px-4 py-2 xl:hidden">
<Button
ref={chapterTriggerRef}
onClick={onOpenChapters}
variant="secondary"
size="sm"
>
<PanelLeft className="h-4 w-4" aria-hidden="true" />
</Button>
<span className="min-w-0 truncate font-mono text-xs text-ink-soft">
{chapterNo}
</span>
<Button
ref={assistantTriggerRef}
onClick={onOpenAssistant}
variant="secondary"
size="sm"
>
<PanelRight className="h-4 w-4" aria-hidden="true" />
</Button>
</div>
);
}
interface DirectivePanelProps {
directive: string;
onDirectiveChange: (value: string) => void;
presetIds: readonly string[];
onTogglePreset: (id: string) => void;
// 空章默认展开(新手最需要看到输入框的时机);满章默认收起以让出编辑视野。
defaultOpen: boolean;
// 发送键 = 写本章:把已选文风/剧情 + 自定义要求组装后逐字流式写进正文(不改成聊天气泡)。
streaming: boolean;
onWrite: () => void;
onStop: () => void;
}
// 本章写作指令面板P0-2 提升):正文正上方常驻的「和 AI 说话」输入条。
// 标题「告诉 AI 这章想怎么写」+ HITL 微文案 +「?这是什么」常驻可见;空章默认展开露出输入框。
// 发送键即「写本章」,沿用 composeDirective + 文风/剧情 chips + 流式打字机写进正文。
function DirectivePanel({
directive,
onDirectiveChange,
presetIds,
onTogglePreset,
defaultOpen,
streaming,
onWrite,
onStop,
}: DirectivePanelProps) {
const panelId = useId();
const [open, setOpen] = useState(defaultOpen);
const activeCount = presetIds.length + (directive.trim().length > 0 ? 1 : 0);
return (
<section className="border-b border-line bg-panel px-4 py-3 sm:px-6">
<div className="flex items-center justify-between gap-3">
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
aria-controls={panelId}
className={`flex min-w-0 cursor-pointer items-center gap-2 rounded font-serif text-base text-ink hover:text-cinnabar ${focusRing}`}
>
<ChevronDown
className={`h-4 w-4 shrink-0 text-ink-soft transition-transform ${
open ? "rotate-180" : ""
}`}
aria-hidden="true"
/>
<span className="truncate"> AI </span>
</button>
<div className="flex shrink-0 items-center gap-2">
{activeCount > 0 ? (
<span className="rounded border border-line bg-bg px-2 py-0.5 text-xs text-ink-soft">
{activeCount}
</span>
) : null}
{/* 常驻「发送键」= 写本章始终可见折叠时也在逐字流式写进正文。带可见范围提示P1-4。 */}
{streaming ? (
<Button onClick={onStop} variant="danger" size="sm">
<Square className="h-4 w-4" aria-hidden="true" />
</Button>
) : (
<Button onClick={onWrite} variant="primary" size="sm">
<PenLine className="h-4 w-4" aria-hidden="true" />
<span className="text-2xs font-normal text-panel/75">
·
</span>
</Button>
)}
<HitlHelp />
</div>
</div>
<p className="mt-1 text-xs text-ink-soft">
AI 稿
</p>
{open ? (
<div id={panelId} className="mt-3 space-y-3">
<SectionHeader
title="你的写作要求"
description="选文风、点剧情需求、写自己的要求,只影响本次生成。"
/>
<PresetGroup
label="文风"
presets={STYLE_PRESETS}
presetIds={presetIds}
onToggle={onTogglePreset}
/>
<PresetGroup
label="剧情需求"
presets={PLOT_PRESETS}
presetIds={presetIds}
onToggle={onTogglePreset}
/>
<label className="block">
<span className="mb-1 block text-sm text-ink-soft"></span>
<TextArea
value={directive}
onChange={(e) => onDirectiveChange(e.target.value)}
rows={2}
placeholder="写点要求AI 更懂你(留空则按大纲写)"
/>
</label>
{activeCount > 0 ? (
<StatusNote variant="info">
/
</StatusNote>
) : null}
</div>
) : null}
</section>
);
}
interface AiVerbsRowProps {
streaming: boolean;
onContinue: () => void;
onRefineSelection: () => void;
onRewrite: () => void;
onToolbox: () => void;
}
// 「或让 AI:」次级动作行P1-1常驻贴在中央输入条下方把续写/润色/整章重写/工具箱
// 归为「同一个 AI 的其它用法」act-on-chapter 与导航分家。每个动词带可见范围提示P1-4
// 生成中禁用(避免与流式写章并发),触发逻辑沿用 Workbench 既有 open* 回调(仅搬家、不改行为)。
function AiVerbsRow({
streaming,
onContinue,
onRefineSelection,
onRewrite,
onToolbox,
}: AiVerbsRowProps) {
return (
<div className="flex flex-wrap items-center gap-x-3 gap-y-2 border-b border-line bg-panel px-4 py-2 sm:px-6">
<span className="text-xs text-ink-soft"> AI</span>
<VerbButton
icon={WrapText}
label="续写"
hint="接着往下写"
disabled={streaming}
onClick={onContinue}
/>
<VerbButton
icon={Wand2}
label="润色选段"
hint="选中段·打磨"
disabled={streaming}
onClick={onRefineSelection}
/>
<VerbButton
icon={RefreshCw}
label="整章重写"
hint="整章·基于现有重写"
disabled={streaming}
onClick={onRewrite}
/>
<VerbButton
icon={Sparkles}
label="工具箱"
disabled={streaming}
onClick={onToolbox}
/>
</div>
);
}
interface VerbButtonProps {
icon: typeof WrapText;
label: string;
// 可见(非 hover-only范围提示让作者点前就能预判对草稿的影响P1-4。工具箱无范围提示。
hint?: string;
disabled: boolean;
onClick: () => void;
}
function VerbButton({ icon: Icon, label, hint, disabled, onClick }: VerbButtonProps) {
return (
<Button onClick={onClick} disabled={disabled} variant="secondary" size="sm">
<Icon className="h-4 w-4" aria-hidden="true" />
{label}
{hint ? <span className="text-2xs text-ink-soft">{hint}</span> : null}
</Button>
);
}
// 「?这是什么」:常驻、可再发现的说明(非一次性 coach——解释「和 AI 说话」的闭环。
// 仿 AiToolbarMoreMenu按钮 + 轻量弹层Esc / 点击外部关闭,还原焦点。
function HitlHelp() {
const [open, setOpen] = useState(false);
const popId = useId();
const rootRef = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (!open) return;
const onKeyDown = (e: KeyboardEvent): void => {
if (e.key === "Escape") {
setOpen(false);
buttonRef.current?.focus();
}
};
const onPointerDown = (e: PointerEvent): void => {
const target = e.target;
if (target instanceof Node && !rootRef.current?.contains(target)) {
setOpen(false);
}
};
window.addEventListener("keydown", onKeyDown);
window.addEventListener("pointerdown", onPointerDown);
return () => {
window.removeEventListener("keydown", onKeyDown);
window.removeEventListener("pointerdown", onPointerDown);
};
}, [open]);
return (
<div ref={rootRef} className="relative">
<button
ref={buttonRef}
type="button"
aria-haspopup="dialog"
aria-expanded={open}
aria-controls={popId}
onClick={() => setOpen((v) => !v)}
className={buttonClass({
variant: "ghost",
size: "sm",
className: "gap-1 text-xs text-ink-soft",
})}
>
<HelpCircle className="h-4 w-4" aria-hidden="true" />
</button>
{open ? (
<div
id={popId}
role="dialog"
aria-label="怎么和 AI 说话"
className="absolute right-0 z-30 mt-2 w-72 max-w-[80vw] rounded border border-line bg-panel p-3 text-sm text-ink-soft shadow-paper"
>
<p className="mb-1 font-serif text-sm text-ink"> AI </p>
<p>
AI 稿稿
稿
</p>
</div>
) : null}
</div>
);
}
interface PresetGroupProps {
label: string;
presets: readonly Preset[];
presetIds: readonly string[];
onToggle: (id: string) => void;
}
// 一组预设 chips文风 / 剧情需求):点选 toggle选中高亮。id 统一进 presetIds。
function PresetGroup({ label, presets, presetIds, onToggle }: PresetGroupProps) {
return (
<div>
<p className="mb-1 text-sm text-ink-soft">{label}</p>
<div className="flex flex-wrap gap-2" role="group" aria-label={label}>
{presets.map((preset) => {
const active = presetIds.includes(preset.id);
return (
<Button
key={preset.id}
type="button"
aria-pressed={active}
onClick={() => onToggle(preset.id)}
variant={active ? "outline" : "secondary"}
size="sm"
>
{preset.label}
</Button>
);
})}
</div>
</div>
);
}
// 写章失败提示:友好文案(不暴露 raw code+ 可选「去设置」动作F4
function StreamErrorNote({
streamError,
}: {
streamError: { code: string; message: string };
}) {
const friendly = friendlyError(streamError.code, streamError.message);
return (
<p className="mb-2 text-sm text-conflict">
{friendly.text}
{friendly.actionHref ? (
<>
{" "}
<Link
href={friendly.actionHref}
className="underline hover:text-cinnabar"
>
{friendly.actionLabel}
</Link>
</>
) : null}
</p>
);
}
// 底栏瘦身P1-1act-on-chapter 动词与纯导航都已上移/移除,底栏只余
// 状态(字数/保存/播报)+ 流式时的「停」+ 唯一强调的「审稿」前进 CTA + 速查/对话记录抽屉入口。
interface ToolbarProps {
projectId: string;
chapterNo: number;
wordCount: number;
savedLabel: string | null;
saveStatus: ReturnType<typeof useAutosave>["status"];
streaming: boolean;
streamError: { code: string; message: string } | null;
// 流式里程碑播报句(完成/停止/失败);仅在 role=status 区出现,不逐 token 刷新。
liveMessage: string;
// 流式时的「停」:底栏 sticky滚到长正文下方也够得着中央条会滚出视野
onStop: () => void;
// 打开上下文速查抽屉WFW-6
onOpenContext: () => void;
contextTriggerRef: RefObject<HTMLButtonElement | null>;
// 打开对话记录抽屉AC-3
onOpenConversation: () => void;
conversationTriggerRef: RefObject<HTMLButtonElement | null>;
}
function Toolbar({
projectId,
chapterNo,
wordCount,
savedLabel,
saveStatus,
streaming,
streamError,
liveMessage,
onStop,
onOpenContext,
contextTriggerRef,
onOpenConversation,
conversationTriggerRef,
}: ToolbarProps) {
return (
<div className="sticky bottom-0 z-10 border-t border-line bg-panel/95 px-4 py-2 backdrop-blur sm:px-6 sm:py-3">
{streamError ? <StreamErrorNote streamError={streamError} /> : null}
<div className="grid gap-2 sm:flex sm:flex-wrap sm:items-center sm:gap-3">
<div className="flex min-w-0 flex-wrap items-center gap-2">
{streaming ? (
<Button onClick={onStop} variant="danger" size="sm">
<Square className="h-4 w-4" aria-hidden="true" />
</Button>
) : null}
{streaming ? (
// 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}
</span>
)}
{/* 终结句播报区:完成/停止/失败时写入,屏读只播报里程碑。 */}
<span className="sr-only" role="status" aria-live="polite">
{liveMessage}
</span>
{CONTEXT_DRAWER_ENABLED ? (
<Button
ref={contextTriggerRef}
onClick={onOpenContext}
variant="secondary"
size="sm"
title="打开上下文速查(设定库/大纲/伏笔/规则/文风)"
>
<BookMarked className="h-4 w-4" aria-hidden="true" />
</Button>
) : null}
{AI_CONVERSATION_ENABLED ? (
<Button
ref={conversationTriggerRef}
onClick={onOpenConversation}
variant="secondary"
size="sm"
title="打开对话记录(润色/重写/续写/工具箱往复留痕的只读日志)"
>
<MessagesSquare className="h-4 w-4" aria-hidden="true" />
</Button>
) : null}
{/* 唯一强调的「审稿」= 写→审→验收的前进 CTA导航去重后仅此一处强调左栏另有入口。 */}
<Link
href={`/projects/${projectId}/review?chapter=${chapterNo}`}
className={buttonClass({ variant: "outline", size: "sm" })}
>
<ClipboardCheck className="h-4 w-4" aria-hidden="true" />
稿
</Link>
</div>
<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"
? "保存中…"
: saveStatus === "error"
? "保存失败"
: (savedLabel ?? "尚未保存")}
</span>
</div>
</div>
);
}

View File

@@ -0,0 +1,209 @@
"use client";
import Link from "next/link";
import type { RefObject } from "react";
import {
BookMarked,
ClipboardCheck,
FileText,
Maximize2,
MessagesSquare,
Minimize2,
Square,
} from "lucide-react";
import { ThinkingIndicator } from "@/components/ThinkingIndicator";
import { Button } from "@/components/ui/Button";
import type { useAutosave } from "@/lib/autosave/useAutosave";
import { friendlyError } from "@/lib/errors/messages";
import { buttonClass } from "@/lib/ui/variants";
import { CONTEXT_DRAWER_ENABLED } from "./ContextDrawer";
import { AI_CONVERSATION_ENABLED } from "./AiConversationDrawer";
// 写章失败提示:友好文案(不暴露 raw code+ 可选「去设置」动作F4
function StreamErrorNote({
streamError,
}: {
streamError: { code: string; message: string };
}) {
const friendly = friendlyError(streamError.code, streamError.message);
return (
<p className="mb-2 text-sm text-conflict">
{friendly.text}
{friendly.actionHref ? (
<>
{" "}
<Link
href={friendly.actionHref}
className="underline hover:text-cinnabar"
>
{friendly.actionLabel}
</Link>
</>
) : null}
</p>
);
}
// 专注写作开关P3-4图标按钮进入=隐藏左侧图标导航 + 目录 TOC 加宽正文,退出还原。
// aria-pressed 表状态、aria-label 表动作,键盘可达(复用共享 Button 焦点环)。
function FocusToggle({
focus,
onToggle,
}: {
focus: boolean;
onToggle: () => void;
}) {
return (
<Button
onClick={onToggle}
variant="secondary"
size="sm"
aria-pressed={focus}
aria-label={focus ? "退出专注写作模式" : "进入专注写作模式(隐藏侧栏,加宽正文)"}
title={focus ? "退出专注写作" : "专注写作"}
>
{focus ? (
<Minimize2 className="h-4 w-4" aria-hidden="true" />
) : (
<Maximize2 className="h-4 w-4" aria-hidden="true" />
)}
</Button>
);
}
// 底栏P1-1 瘦身 + P3-6 分区):按「信息 | 操作 | 状态」三区排布。
// 信息=字数/生成中;操作=速查/对话记录/审稿/专注(+流式时的「停」);状态=保存态(右侧)。
interface ToolbarProps {
projectId: string;
chapterNo: number;
wordCount: number;
savedLabel: string | null;
saveStatus: ReturnType<typeof useAutosave>["status"];
streaming: boolean;
streamError: { code: string; message: string } | null;
// 流式里程碑播报句(完成/停止/失败);仅在 role=status 区出现,不逐 token 刷新。
liveMessage: string;
// 流式时的「停」:底栏 sticky滚到长正文下方也够得着中央条会滚出视野
onStop: () => void;
// 专注写作开关P3-4
focus: boolean;
onToggleFocus: () => void;
// 打开上下文速查抽屉WFW-6
onOpenContext: () => void;
contextTriggerRef: RefObject<HTMLButtonElement | null>;
// 打开对话记录抽屉AC-3
onOpenConversation: () => void;
conversationTriggerRef: RefObject<HTMLButtonElement | null>;
}
export function Toolbar({
projectId,
chapterNo,
wordCount,
savedLabel,
saveStatus,
streaming,
streamError,
liveMessage,
onStop,
focus,
onToggleFocus,
onOpenContext,
contextTriggerRef,
onOpenConversation,
conversationTriggerRef,
}: ToolbarProps) {
return (
<div className="sticky bottom-0 z-10 border-t border-line bg-panel/95 px-4 py-2 backdrop-blur sm:px-6 sm:py-3">
{streamError ? <StreamErrorNote streamError={streamError} /> : null}
<div className="grid gap-2 sm:flex sm:flex-wrap sm:items-center sm:gap-3">
{/* 信息区:字数 / 生成中(含流式时的「停」)。 */}
<div className="flex min-w-0 flex-wrap items-center gap-2">
{streaming ? (
<Button onClick={onStop} variant="danger" size="sm">
<Square className="h-4 w-4" aria-hidden="true" />
</Button>
) : null}
{streaming ? (
// 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}
</span>
)}
{/* 终结句播报区:完成/停止/失败时写入,屏读只播报里程碑。 */}
<span className="sr-only" role="status" aria-live="polite">
{liveMessage}
</span>
</div>
{/* 操作区:速查 / 对话记录 / 审稿 / 专注;与信息区以细分隔线拉开。 */}
<div className="flex min-w-0 flex-wrap items-center gap-2 sm:border-l sm:border-line-soft sm:pl-3">
{CONTEXT_DRAWER_ENABLED ? (
<Button
ref={contextTriggerRef}
onClick={onOpenContext}
variant="secondary"
size="sm"
title="打开上下文速查(设定库/大纲/伏笔/规则/文风)"
>
<BookMarked className="h-4 w-4" aria-hidden="true" />
</Button>
) : null}
{AI_CONVERSATION_ENABLED ? (
<Button
ref={conversationTriggerRef}
onClick={onOpenConversation}
variant="secondary"
size="sm"
title="打开对话记录(润色/重写/续写/工具箱往复留痕的只读日志)"
>
<MessagesSquare className="h-4 w-4" aria-hidden="true" />
</Button>
) : null}
<FocusToggle focus={focus} onToggle={onToggleFocus} />
{/* 唯一强调的「审稿」= 写→审→验收的前进 CTA导航去重后仅此一处强调左栏另有入口。 */}
<Link
href={`/projects/${projectId}/review?chapter=${chapterNo}`}
className={buttonClass({ variant: "outline", size: "sm" })}
>
<ClipboardCheck className="h-4 w-4" aria-hidden="true" />
稿
</Link>
</div>
{/* 状态区:保存态,右侧对齐。 */}
<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"
? "保存中…"
: saveStatus === "error"
? "保存失败"
: (savedLabel ?? "尚未保存")}
</span>
</div>
</div>
);
}

View File

@@ -0,0 +1,42 @@
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
// 双主题 token 奇偶校验P1-8paper 与 night 两块的 --color-* 变量必须成对,
// 任一模式漏配某个颜色 token 都会让该主题回退为无效变量而破面——此测试提前拦截。
const cssPath = fileURLToPath(
new URL("../../app/globals.css", import.meta.url),
);
const css = readFileSync(cssPath, "utf8");
function colorVarsInBlock(selector: string): Set<string> {
const start = css.indexOf(selector);
if (start === -1) {
throw new Error(`未找到主题块选择器:${selector}`);
}
const open = css.indexOf("{", start);
const close = css.indexOf("}", open);
const body = css.slice(open + 1, close);
const names = body.match(/--color-[\w-]+/g) ?? [];
return new Set(names);
}
describe("theme token parity", () => {
const paper = colorVarsInBlock('[data-theme="paper"]');
const night = colorVarsInBlock('[data-theme="night"]');
it("defines at least the core palette in each theme", () => {
expect(paper.size).toBeGreaterThan(10);
expect(night.size).toBeGreaterThan(10);
});
it("keeps paper and night color tokens paired (no theme drops a --color-* var)", () => {
const missingInNight = [...paper].filter((name) => !night.has(name));
const missingInPaper = [...night].filter((name) => !paper.has(name));
expect(missingInNight, "night 缺失paper 有)").toEqual([]);
expect(missingInPaper, "paper 缺失night 有)").toEqual([]);
});
});

View File

@@ -0,0 +1,53 @@
// @vitest-environment jsdom
import { act, renderHook } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useMountTransition } from "./useMountTransition";
beforeEach(() => {
vi.useFakeTimers();
// 让 rAF 同步执行,便于断言 isVisible 翻转。
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {
cb(0);
return 1;
});
vi.stubGlobal("cancelAnimationFrame", () => {});
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
describe("useMountTransition", () => {
it("renders nothing while closed", () => {
const { result } = renderHook(() => useMountTransition(false, 180));
expect(result.current.shouldRender).toBe(false);
expect(result.current.isVisible).toBe(false);
});
it("mounts and becomes visible when opened", () => {
const { result, rerender } = renderHook(
({ open }) => useMountTransition(open, 180),
{ initialProps: { open: false } },
);
act(() => rerender({ open: true }));
expect(result.current.shouldRender).toBe(true);
expect(result.current.isVisible).toBe(true);
});
it("keeps rendering during exit, then unmounts after the duration", () => {
const { result, rerender } = renderHook(
({ open }) => useMountTransition(open, 180),
{ initialProps: { open: true } },
);
act(() => rerender({ open: false }));
expect(result.current.isVisible).toBe(false);
expect(result.current.shouldRender).toBe(true);
act(() => {
vi.advanceTimersByTime(180);
});
expect(result.current.shouldRender).toBe(false);
});
});

View File

@@ -0,0 +1,29 @@
"use client";
import { useEffect, useState } from "react";
// 弹层进出场:打开即挂载并在下一帧置为可见(触发入场过渡);关闭先置不可见播放出场,
// 延迟 durationMs 后再卸载。调用方用 motion-safe: 门控过渡类——reduced-motion 下无动画,
// 仍功能正常(仅多挂载 durationMs。守住既有 focus-trap/scroll-lock它们仍由 open 驱动,
// 本 hook 只负责“延迟卸载”以便播放退场。
export function useMountTransition(
isOpen: boolean,
durationMs: number,
): { shouldRender: boolean; isVisible: boolean } {
const [shouldRender, setShouldRender] = useState(isOpen);
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
if (isOpen) {
setShouldRender(true);
// 下一帧再置可见,确保入场从初始(隐藏)态开始过渡。
const raf = requestAnimationFrame(() => setIsVisible(true));
return () => cancelAnimationFrame(raf);
}
setIsVisible(false);
const timer = window.setTimeout(() => setShouldRender(false), durationMs);
return () => window.clearTimeout(timer);
}, [isOpen, durationMs]);
return { shouldRender, isVisible };
}

View File

@@ -3,13 +3,17 @@ import { describe, expect, it } from "vitest";
import { import {
badgeClass, badgeClass,
buttonClass, buttonClass,
cardClass,
checkboxClass,
cn, cn,
focusRing, focusRing,
inputClass, inputClass,
overlayScrim, overlayScrim,
proseBody, proseBody,
radioClass,
segmentedClass, segmentedClass,
statusNoteClass, statusNoteClass,
transitionUi,
} from "./variants"; } from "./variants";
describe("ui variants", () => { describe("ui variants", () => {
@@ -25,6 +29,56 @@ describe("ui variants", () => {
expect(klass).toContain("focus-visible:ring-2"); expect(klass).toContain("focus-visible:ring-2");
}); });
it("uses layered radius tokens (button md / card lg / badge pill)", () => {
expect(buttonClass({ variant: "primary" })).toContain("rounded-md");
expect(cardClass()).toContain("rounded-lg");
expect(badgeClass()).toContain("rounded-full");
expect(inputClass()).toContain("rounded-md");
});
it("gives the primary button a press-darken and cream disabled state", () => {
const klass = buttonClass({ variant: "primary" });
expect(klass).toContain("hover:bg-cinnabar-active");
expect(klass).toContain("active:bg-cinnabar-active");
expect(klass).toContain("disabled:bg-cinnabar-disabled");
expect(klass).not.toContain("hover:bg-cinnabar/95");
});
it("shares one interaction-transition token across controls", () => {
expect(transitionUi).toContain("duration-fast");
expect(transitionUi).toContain("ease-standard");
expect(buttonClass({ variant: "primary" })).toContain(transitionUi);
expect(inputClass()).toContain(transitionUi);
});
it("builds a coral inline link button variant", () => {
const klass = buttonClass({ variant: "link" });
expect(klass).toContain("text-cinnabar");
expect(klass).toContain("underline-offset-4");
expect(klass).toContain("hover:underline");
});
it("supports a large button size for prominent CTAs", () => {
expect(buttonClass({ size: "lg" })).toContain("px-5");
});
it("layers card surface tones and can drop the shadow", () => {
expect(cardClass({ tone: "card" })).toContain("bg-surface-card");
expect(cardClass({ tone: "soft" })).toContain("bg-surface-soft");
expect(cardClass()).toContain("bg-panel");
expect(cardClass({ flat: true })).not.toContain("shadow-paper");
expect(cardClass({ interactive: true })).toContain("hover:border-cinnabar/40");
});
it("keeps the legacy string cardClass signature working", () => {
const klass = cardClass("mt-4");
expect(klass).toContain("mt-4");
expect(klass).toContain("bg-panel");
expect(klass).toContain("rounded-lg");
});
it("keeps warning badges readable on the paper background", () => { it("keeps warning badges readable on the paper background", () => {
const klass = badgeClass({ variant: "warning" }); const klass = badgeClass({ variant: "warning" });
@@ -46,6 +100,14 @@ describe("ui variants", () => {
expect(klass).toContain("px-3"); expect(klass).toContain("px-3");
}); });
it("styles checkbox and radio with accent + shared focus ring", () => {
expect(checkboxClass()).toContain("accent-cinnabar");
expect(checkboxClass()).toContain("rounded-xs");
expect(checkboxClass()).toContain("focus-visible:ring-cinnabar/35");
expect(radioClass()).toContain("rounded-full");
expect(radioClass()).toContain("accent-cinnabar");
});
it("builds segmented control chrome", () => { it("builds segmented control chrome", () => {
const klass = segmentedClass(); const klass = segmentedClass();
@@ -59,10 +121,9 @@ describe("ui variants", () => {
expect(buttonClass({ variant: "primary" })).toContain(focusRing); expect(buttonClass({ variant: "primary" })).toContain(focusRing);
}); });
it("renders manuscript body as serif 18px with airy leading", () => { it("renders manuscript body as serif prose scale", () => {
expect(proseBody).toContain("font-serif"); expect(proseBody).toContain("font-serif");
expect(proseBody).toContain("text-[18px]"); expect(proseBody).toContain("text-prose");
expect(proseBody).toContain("leading-[1.9]");
}); });
it("provides a full-screen overlay scrim below dialog chrome", () => { it("provides a full-screen overlay scrim below dialog chrome", () => {

View File

@@ -3,9 +3,12 @@ export type ButtonVariant =
| "secondary" | "secondary"
| "outline" | "outline"
| "ghost" | "ghost"
| "danger"; | "danger"
| "link";
export type ButtonSize = "sm" | "md" | "icon"; export type ButtonSize = "sm" | "md" | "lg" | "icon";
export type CardTone = "panel" | "card" | "soft";
export type BadgeVariant = export type BadgeVariant =
| "neutral" | "neutral"
@@ -23,35 +26,38 @@ export function cn(...classes: Array<string | false | null | undefined>): string
return classes.filter(Boolean).join(" "); return classes.filter(Boolean).join(" ");
} }
// 统一焦点环(与 buttonBase 现有焦点环一致),供导航/输入等可聚焦元素复用 // 统一焦点环(供按钮/输入/导航等所有可聚焦元素复用,统一到 cinnabar/35
export const focusRing = export const focusRing =
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cinnabar/35"; "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cinnabar/35";
// 手稿正文统一排版:衬线 18px、行高 1.9 // 统一交互过渡(颜色变化 + 动效 token供按钮/输入/导航复用,全局可调
export const proseBody = "font-serif text-[18px] leading-[1.9]"; export const transitionUi = "transition-colors duration-fast ease-standard";
// 手稿正文统一排版:衬线 18pxtext-prose 档)、行高 1.9。
export const proseBody = "font-serif text-prose";
// Drawer / CommandPalette 共用的全屏遮罩。 // Drawer / CommandPalette 共用的全屏遮罩。
export const overlayScrim = "fixed inset-0 z-40 bg-black/30"; export const overlayScrim = "fixed inset-0 z-40 bg-black/30";
const buttonBase = const buttonBase = `inline-flex items-center justify-center gap-1.5 rounded-md border text-sm disabled:cursor-not-allowed ${transitionUi} ${focusRing}`;
"inline-flex items-center justify-center gap-1.5 rounded border text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cinnabar/35 disabled:cursor-not-allowed disabled:opacity-45";
// 非主按钮的禁用淡化(主按钮改用奶油化禁用底,见下)。
const disabledFade = "disabled:opacity-45";
const buttonVariants: Record<ButtonVariant, string> = { const buttonVariants: Record<ButtonVariant, string> = {
primary: primary:
"border-cinnabar bg-cinnabar text-panel shadow-paper hover:bg-cinnabar/95", "border-cinnabar bg-cinnabar text-panel shadow-paper hover:bg-cinnabar-active active:bg-cinnabar-active disabled:border-cinnabar-disabled disabled:bg-cinnabar-disabled disabled:text-muted-soft",
secondary: secondary: `border-line bg-panel text-ink hover:border-cinnabar hover:text-cinnabar ${disabledFade}`,
"border-line bg-panel text-ink hover:border-cinnabar hover:text-cinnabar", outline: `border-cinnabar bg-transparent text-cinnabar hover:bg-[var(--color-cinnabar-wash)] active:bg-[var(--color-cinnabar-wash)] ${disabledFade}`,
outline: ghost: `border-transparent bg-transparent text-ink-soft hover:bg-[var(--color-cinnabar-wash)] hover:text-cinnabar ${disabledFade}`,
"border-cinnabar bg-transparent text-cinnabar hover:bg-[var(--color-cinnabar-wash)]", danger: `border-conflict bg-transparent text-conflict hover:bg-conflict/10 ${disabledFade}`,
ghost: link: `border-transparent bg-transparent text-cinnabar underline-offset-4 hover:text-cinnabar-active hover:underline ${disabledFade}`,
"border-transparent bg-transparent text-ink-soft hover:bg-[var(--color-cinnabar-wash)] hover:text-cinnabar",
danger:
"border-conflict bg-transparent text-conflict hover:bg-conflict/10",
}; };
const buttonSizes: Record<ButtonSize, string> = { const buttonSizes: Record<ButtonSize, string> = {
sm: "px-3 py-1.5 text-xs", sm: "px-3 py-1.5 text-xs",
md: "px-4 py-2", md: "px-4 py-2",
lg: "px-5 py-2.5 text-base",
icon: "h-9 w-9 p-0", icon: "h-9 w-9 p-0",
}; };
@@ -68,7 +74,7 @@ export function buttonClass({
} }
const badgeBase = const badgeBase =
"inline-flex items-center gap-1 rounded border px-2 py-0.5 text-xs leading-5"; "inline-flex items-center gap-1 rounded-full border px-2.5 py-0.5 text-xs leading-5";
const badgeVariants: Record<BadgeVariant, string> = { const badgeVariants: Record<BadgeVariant, string> = {
neutral: "border-line bg-bg text-ink-soft", neutral: "border-line bg-bg text-ink-soft",
@@ -90,8 +96,30 @@ export function badgeClass({
return cn(badgeBase, badgeVariants[variant], className); return cn(badgeBase, badgeVariants[variant], className);
} }
export function cardClass(className?: string): string { const cardTones: Record<CardTone, string> = {
return cn("rounded border border-line bg-panel shadow-paper", className); panel: "bg-panel",
card: "bg-surface-card",
soft: "bg-surface-soft",
};
interface CardClassOptions {
tone?: CardTone;
flat?: boolean;
interactive?: boolean;
className?: string;
}
// 兼容旧签名 cardClass("extra"),同时支持面色分层/去阴影/可交互卡片。
export function cardClass(opts?: string | CardClassOptions): string {
const o: CardClassOptions = typeof opts === "string" ? { className: opts } : opts ?? {};
const tone = o.tone ?? "panel";
return cn(
"rounded-lg border border-line",
cardTones[tone],
o.flat ? null : "shadow-paper",
o.interactive ? `${transitionUi} hover:border-cinnabar/40 hover:shadow-paper` : null,
o.className,
);
} }
const fieldTextBase = "block text-sm font-medium text-ink"; const fieldTextBase = "block text-sm font-medium text-ink";
@@ -108,8 +136,7 @@ export function fieldErrorClass(className?: string): string {
return cn("mt-1 text-xs leading-5 text-conflict", className); return cn("mt-1 text-xs leading-5 text-conflict", className);
} }
const inputBase = const inputBase = `w-full rounded-md border bg-bg text-ink placeholder:text-ink-soft/80 disabled:cursor-not-allowed disabled:bg-line/20 disabled:text-ink-soft ${transitionUi} ${focusRing}`;
"w-full rounded border bg-bg text-ink transition-colors placeholder:text-ink-soft/65 focus:outline-none focus-visible:ring-2 focus-visible:ring-cinnabar/30 disabled:cursor-not-allowed disabled:bg-line/20 disabled:text-ink-soft";
const inputStates: Record<InputState, string> = { const inputStates: Record<InputState, string> = {
default: "border-line focus:border-cinnabar", default: "border-line focus:border-cinnabar",
@@ -149,3 +176,15 @@ export function statusNoteClass({
export function segmentedClass(className?: string): string { export function segmentedClass(className?: string): string {
return cn("inline-flex rounded border border-line bg-bg p-1", className); return cn("inline-flex rounded border border-line bg-bg p-1", className);
} }
// 勾选/单选控件:原生 input + accent 上色,保留原生键盘/读屏语义,套统一焦点环。
const controlBoxBase =
"h-4 w-4 shrink-0 border-line accent-cinnabar disabled:cursor-not-allowed disabled:opacity-45";
export function checkboxClass(className?: string): string {
return cn(controlBoxBase, "rounded-xs", focusRing, className);
}
export function radioClass(className?: string): string {
return cn(controlBoxBase, "rounded-full", focusRing, className);
}

View File

@@ -0,0 +1,61 @@
// @vitest-environment jsdom
import { act, renderHook } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { FOCUS_MODE_KEY, useFocusMode } from "./useFocusMode";
// 本仓库 jsdom 未提供可用的 StoragesetItem/clear 缺失),装一个内存版 localStorage。
function installMemoryStorage(): Storage {
const map = new Map<string, string>();
const storage: Storage = {
get length() {
return map.size;
},
clear: () => map.clear(),
getItem: (key) => (map.has(key) ? (map.get(key) ?? null) : null),
key: (index) => Array.from(map.keys())[index] ?? null,
removeItem: (key) => map.delete(key),
setItem: (key, value) => map.set(key, String(value)),
};
Object.defineProperty(window, "localStorage", {
value: storage,
configurable: true,
});
return storage;
}
describe("useFocusMode", () => {
beforeEach(() => {
installMemoryStorage();
});
afterEach(() => {
window.localStorage.clear();
});
it("默认关闭(无存档时 focus=false", () => {
const { result } = renderHook(() => useFocusMode());
expect(result.current.focus).toBe(false);
});
it("toggle 翻转并把状态写入 localStorage", () => {
const { result } = renderHook(() => useFocusMode());
act(() => result.current.toggle());
expect(result.current.focus).toBe(true);
expect(window.localStorage.getItem(FOCUS_MODE_KEY)).toBe("1");
act(() => result.current.toggle());
expect(result.current.focus).toBe(false);
expect(window.localStorage.getItem(FOCUS_MODE_KEY)).toBe("0");
});
it("挂载时从 localStorage 恢复已开启状态", () => {
window.localStorage.setItem(FOCUS_MODE_KEY, "1");
const { result } = renderHook(() => useFocusMode());
expect(result.current.focus).toBe(true);
});
});

View File

@@ -0,0 +1,46 @@
"use client";
import { useCallback, useEffect, useState } from "react";
// 「专注写作」偏好的持久化键(对齐既有 `ww.` 前缀约定)。
export const FOCUS_MODE_KEY = "ww.workbench_focus_mode";
export interface FocusMode {
// true=专注模式:隐藏左侧图标导航 + 目录 TOC让宽给正文false=常规四栏。
focus: boolean;
toggle: () => void;
}
function persist(focus: boolean): void {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(FOCUS_MODE_KEY, focus ? "1" : "0");
} catch {
// 隐私模式/配额异常忽略:专注模式是纯 UI 偏好,写盘失败不影响写作功能。
}
}
// 记住「专注写作」开关P3-4SSR 首帧恒关(避免 hydration 不一致),
// 挂载后再从 localStorage 恢复toggle 即时写回。仅桌面≥xl改变栅格窄屏本就单列不受影响。
export function useFocusMode(): FocusMode {
const [focus, setFocus] = useState(false);
useEffect(() => {
if (typeof window === "undefined") return;
try {
setFocus(window.localStorage.getItem(FOCUS_MODE_KEY) === "1");
} catch {
// 读盘失败则维持默认关闭。
}
}, []);
const toggle = useCallback(() => {
setFocus((prev) => {
const next = !prev;
persist(next);
return next;
});
}, []);
return { focus, toggle };
}

View File

@@ -11,26 +11,83 @@ const config: Config = {
extend: { extend: {
colors: { colors: {
bg: "var(--color-bg)", bg: "var(--color-bg)",
"surface-soft": "var(--color-surface-soft)",
"surface-card": "var(--color-surface-card)",
"surface-strong": "var(--color-surface-strong)",
panel: "var(--color-panel)", panel: "var(--color-panel)",
ink: "var(--color-ink)", ink: "var(--color-ink)",
"body-strong": "var(--color-body-strong)",
body: "var(--color-body)",
"ink-soft": "var(--color-ink-soft)", "ink-soft": "var(--color-ink-soft)",
"muted-soft": "var(--color-muted-soft)",
line: "var(--color-line)", line: "var(--color-line)",
"line-soft": "var(--color-line-soft)",
cinnabar: "var(--color-cinnabar)", cinnabar: "var(--color-cinnabar)",
"cinnabar-active": "var(--color-cinnabar-active)",
"cinnabar-disabled": "var(--color-cinnabar-disabled)",
callout: "var(--color-callout)",
"on-callout": "var(--color-on-callout)",
"on-callout-soft": "var(--color-on-callout-soft)",
conflict: "var(--color-conflict)", conflict: "var(--color-conflict)",
overdue: "var(--color-overdue)", overdue: "var(--color-overdue)",
pass: "var(--color-pass)", pass: "var(--color-pass)",
info: "var(--color-info)", info: "var(--color-info)",
}, },
fontFamily: { fontFamily: {
serif: ['"Noto Serif SC"', '"Songti SC"', "serif"], // 衬线 display 与 sans 分工是品牌声音的一部分(对标 DESIGN.md
sans: ['"Noto Sans SC"', '"PingFang SC"', "system-ui", "sans-serif"], // 决策:不自托管 CJK webfont —— Noto Serif SC 全量子集达数 MB会拖慢
mono: ['"JetBrains Mono"', "ui-monospace"], // 首屏且需构建期外部拉取(违反 CSP 友好)。改用跨 OS 系统衬线兜底栈,
// 保证中文标题在任意设备可靠命中高质量衬线,零 webfont 字节。
// macOS/iOS → Songti/STSongWindows → SimSun/YaHeiLinux/Android → Noto CJK。
serif: [
'"Noto Serif SC"',
'"Source Han Serif SC"',
'"Songti SC"',
"STSong",
"SimSun",
'"Noto Serif CJK SC"',
"serif",
],
sans: [
'"Noto Sans SC"',
'"PingFang SC"',
'"Source Han Sans SC"',
'"Microsoft YaHei"',
"system-ui",
"sans-serif",
],
mono: ['"JetBrains Mono"', "ui-monospace", "monospace"],
}, },
fontSize: { fontSize: {
// 编辑部字号刻度(衬线 display 用负字距;标签用正字距 eyebrow
"2xs": ["0.6875rem", { lineHeight: "1rem" }], "2xs": ["0.6875rem", { lineHeight: "1rem" }],
eyebrow: ["0.75rem", { lineHeight: "1.4", letterSpacing: "0.08em" }],
caption: ["0.8125rem", { lineHeight: "1.4" }],
body: ["1rem", { lineHeight: "1.55" }],
prose: ["1.125rem", { lineHeight: "1.9" }],
"title-md": ["1.125rem", { lineHeight: "1.4" }],
"title-lg": ["1.375rem", { lineHeight: "1.3" }],
"display-sm": ["1.75rem", { lineHeight: "1.2", letterSpacing: "-0.01em" }],
"display-md": ["2.25rem", { lineHeight: "1.15", letterSpacing: "-0.015em" }],
"display-lg": ["3rem", { lineHeight: "1.1", letterSpacing: "-0.02em" }],
},
borderRadius: {
xs: "4px",
sm: "6px",
DEFAULT: "6px",
md: "8px",
lg: "12px",
xl: "16px",
full: "9999px",
}, },
borderRadius: { DEFAULT: "6px" },
boxShadow: { paper: "0 1px 3px var(--shadow-paper)" }, boxShadow: { paper: "0 1px 3px var(--shadow-paper)" },
transitionDuration: {
fast: "var(--dur-fast)",
base: "var(--dur-base)",
},
transitionTimingFunction: {
standard: "var(--ease-standard)",
},
maxWidth: { prose: "720px" }, maxWidth: { prose: "720px" },
}, },
}, },