feat: improve app ui and project metadata

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

View File

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

View File

@@ -0,0 +1,94 @@
"use client";
import Link from "next/link";
import { MoreHorizontal } from "lucide-react";
import { useEffect, useId, useRef, useState } from "react";
import type { AiToolItem } from "@/lib/nav/ai-tools";
import type { ActiveNav } from "@/lib/nav/items";
import { buttonClass, cn } from "@/lib/ui/variants";
interface AiToolbarMoreMenuProps {
items: AiToolItem[];
activeNav?: ActiveNav;
}
export function AiToolbarMoreMenu({
items,
activeNav,
}: AiToolbarMoreMenuProps) {
const [open, setOpen] = useState(false);
const menuId = useId();
const rootRef = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (!open) return;
const onKeyDown = (event: KeyboardEvent): void => {
if (event.key === "Escape") {
setOpen(false);
buttonRef.current?.focus();
}
};
const onPointerDown = (event: PointerEvent): void => {
const target = event.target;
if (target instanceof Node && !rootRef.current?.contains(target)) {
setOpen(false);
}
};
window.addEventListener("keydown", onKeyDown);
window.addEventListener("pointerdown", onPointerDown);
return () => {
window.removeEventListener("keydown", onKeyDown);
window.removeEventListener("pointerdown", onPointerDown);
};
}, [open]);
return (
<div ref={rootRef} className="relative">
<button
ref={buttonRef}
type="button"
aria-haspopup="menu"
aria-expanded={open}
aria-controls={menuId}
onClick={() => setOpen((value) => !value)}
className={buttonClass({ variant: "secondary", size: "sm" })}
>
<MoreHorizontal className="h-4 w-4" aria-hidden="true" />
</button>
{open ? (
<div
id={menuId}
role="menu"
className="absolute right-0 z-30 mt-2 min-w-36 rounded border border-line bg-panel p-1 shadow-paper"
>
{items.map((item) => {
const active = item.key === activeNav;
return (
<Link
key={item.href}
href={item.href}
role="menuitem"
aria-current={active ? "page" : undefined}
onClick={() => setOpen(false)}
className={cn(
"block rounded px-3 py-2 text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cinnabar/35",
active
? "bg-[var(--color-cinnabar-wash)] text-cinnabar"
: "text-ink hover:bg-bg hover:text-cinnabar",
)}
>
{item.label}
</Link>
);
})}
</div>
) : null}
</div>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -16,19 +16,21 @@ export function BeatMap({ beatMap }: BeatMapProps) {
}
const spark = toSparkline(beatMap);
return (
<div
className="flex h-12 items-end gap-0.5"
role="img"
aria-label={`爽点节拍图 ${spark}`}
>
{cells.map((cell, i) => (
<div
key={i}
className="min-w-[4px] flex-1 rounded-t bg-cinnabar/70"
style={{ height: `${cell.heightPct}%` }}
title={`${i + 1}拍 强度${cell.value}`}
/>
))}
<div className="max-w-full overflow-x-auto pb-1">
<div
className="flex h-12 min-w-full items-end gap-0.5"
role="img"
aria-label={`爽点节拍图 ${spark}`}
>
{cells.map((cell, i) => (
<div
key={i}
className="min-w-[4px] flex-1 rounded-t bg-cinnabar/70"
style={{ height: `${cell.heightPct}%` }}
title={`${i + 1}拍 强度${cell.value}`}
/>
))}
</div>
</div>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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