feat: improve app ui and project metadata
This commit is contained in:
@@ -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" });
|
||||
}
|
||||
|
||||
94
apps/web/components/AiToolbarMoreMenu.tsx
Normal file
94
apps/web/components/AiToolbarMoreMenu.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { MoreHorizontal } from "lucide-react";
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
|
||||
import type { AiToolItem } from "@/lib/nav/ai-tools";
|
||||
import type { ActiveNav } from "@/lib/nav/items";
|
||||
import { buttonClass, cn } from "@/lib/ui/variants";
|
||||
|
||||
interface AiToolbarMoreMenuProps {
|
||||
items: AiToolItem[];
|
||||
activeNav?: ActiveNav;
|
||||
}
|
||||
|
||||
export function AiToolbarMoreMenu({
|
||||
items,
|
||||
activeNav,
|
||||
}: AiToolbarMoreMenuProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const menuId = useId();
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent): void => {
|
||||
if (event.key === "Escape") {
|
||||
setOpen(false);
|
||||
buttonRef.current?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
const onPointerDown = (event: PointerEvent): void => {
|
||||
const target = event.target;
|
||||
if (target instanceof Node && !rootRef.current?.contains(target)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
window.addEventListener("pointerdown", onPointerDown);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKeyDown);
|
||||
window.removeEventListener("pointerdown", onPointerDown);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className="relative">
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
aria-controls={menuId}
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
className={buttonClass({ variant: "secondary", size: "sm" })}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" aria-hidden="true" />
|
||||
更多
|
||||
</button>
|
||||
{open ? (
|
||||
<div
|
||||
id={menuId}
|
||||
role="menu"
|
||||
className="absolute right-0 z-30 mt-2 min-w-36 rounded border border-line bg-panel p-1 shadow-paper"
|
||||
>
|
||||
{items.map((item) => {
|
||||
const active = item.key === activeNav;
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
role="menuitem"
|
||||
aria-current={active ? "page" : undefined}
|
||||
onClick={() => setOpen(false)}
|
||||
className={cn(
|
||||
"block rounded px-3 py-2 text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cinnabar/35",
|
||||
active
|
||||
? "bg-[var(--color-cinnabar-wash)] text-cinnabar"
|
||||
: "text-ink hover:bg-bg hover:text-cinnabar",
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
import Link from "next/link";
|
||||
import type { ReactNode } from "react";
|
||||
import { Settings } from "lucide-react";
|
||||
|
||||
import type { ActiveNav } from "@/lib/nav/items";
|
||||
import { buttonClass } from "@/lib/ui/variants";
|
||||
import { AiToolbar } from "./AiToolbar";
|
||||
import { LeftNav } from "./LeftNav";
|
||||
import { NavDrawer } from "./NavDrawer";
|
||||
import { ThemeToggle } from "./ThemeToggle";
|
||||
|
||||
interface AppShellProps {
|
||||
children: ReactNode;
|
||||
@@ -34,23 +37,33 @@ export function AppShell({
|
||||
<NavDrawer projectId={projectId} activeNav={activeNav} />
|
||||
<Link
|
||||
href="/"
|
||||
className="font-serif text-xl text-cinnabar"
|
||||
className="shrink-0 whitespace-nowrap font-serif text-xl text-cinnabar"
|
||||
aria-label="返回作品库"
|
||||
>
|
||||
墨痕
|
||||
</Link>
|
||||
{title ? (
|
||||
<span className="font-serif text-lg text-ink">{title}</span>
|
||||
<span className="min-w-0 truncate font-serif text-lg text-ink">
|
||||
{title}
|
||||
</span>
|
||||
) : null}
|
||||
{subtitle ? (
|
||||
<span className="font-mono text-xs text-ink-soft">{subtitle}</span>
|
||||
<span className="hidden shrink-0 font-mono text-xs text-ink-soft sm:inline">
|
||||
{subtitle}
|
||||
</span>
|
||||
) : null}
|
||||
<nav className="ml-auto flex items-center gap-4 text-sm">
|
||||
<nav className="ml-auto flex shrink-0 items-center gap-2 text-sm">
|
||||
<ThemeToggle />
|
||||
<Link
|
||||
href="/settings/providers"
|
||||
className="text-ink-soft hover:text-cinnabar"
|
||||
className={buttonClass({
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
className: "border-transparent",
|
||||
})}
|
||||
>
|
||||
设置
|
||||
<Settings className="h-4 w-4" aria-hidden="true" />
|
||||
<span className="hidden sm:inline">设置</span>
|
||||
</Link>
|
||||
</nav>
|
||||
</header>
|
||||
@@ -59,7 +72,7 @@ export function AppShell({
|
||||
) : null}
|
||||
<div className="flex">
|
||||
<LeftNav projectId={projectId} activeNav={activeNav} />
|
||||
<main className="min-h-[calc(100vh-var(--chrome,4rem))] flex-1 bg-bg">
|
||||
<main className="min-h-[calc(100vh-var(--chrome,4rem))] min-w-0 flex-1 bg-bg">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, type ReactNode } from "react";
|
||||
import { useEffect, useRef, type ReactNode, type RefObject } from "react";
|
||||
|
||||
import { handleTabTrap } from "@/lib/a11y/focusTrap";
|
||||
|
||||
@@ -11,6 +11,7 @@ interface DrawerProps {
|
||||
side?: "left" | "right";
|
||||
// 无障碍标签(role=dialog)。
|
||||
label: string;
|
||||
triggerRef?: RefObject<HTMLElement | null>;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
@@ -21,12 +22,21 @@ export function Drawer({
|
||||
onClose,
|
||||
side = "left",
|
||||
label,
|
||||
triggerRef,
|
||||
children,
|
||||
}: DrawerProps) {
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const wasOpenRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (!open) {
|
||||
if (wasOpenRef.current) {
|
||||
wasOpenRef.current = false;
|
||||
triggerRef?.current?.focus();
|
||||
}
|
||||
return;
|
||||
}
|
||||
wasOpenRef.current = true;
|
||||
const onKey = (e: KeyboardEvent): void => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
@@ -36,7 +46,7 @@ export function Drawer({
|
||||
window.addEventListener("keydown", onKey);
|
||||
panelRef.current?.focus();
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [open, onClose]);
|
||||
}, [open, onClose, triggerRef]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Menu } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import type { ActiveNav } from "@/lib/nav/items";
|
||||
import { Drawer } from "./Drawer";
|
||||
import { NavItems } from "./NavItems";
|
||||
@@ -17,17 +19,16 @@ export function NavDrawer({ projectId, activeNav }: NavDrawerProps) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
<Button
|
||||
onClick={() => setOpen(true)}
|
||||
aria-label="打开导航"
|
||||
aria-expanded={open}
|
||||
className="-ml-1 rounded p-2 text-ink hover:text-cinnabar lg:hidden"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="-ml-2 lg:hidden"
|
||||
>
|
||||
<span aria-hidden="true" className="block text-lg leading-none">
|
||||
☰
|
||||
</span>
|
||||
</button>
|
||||
<Menu className="h-5 w-5" aria-hidden="true" />
|
||||
</Button>
|
||||
<Drawer
|
||||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
|
||||
@@ -1,6 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import {
|
||||
Blocks,
|
||||
BookOpen,
|
||||
Boxes,
|
||||
ClipboardCheck,
|
||||
Flag,
|
||||
LayoutTemplate,
|
||||
ListTree,
|
||||
Palette,
|
||||
PenLine,
|
||||
Settings,
|
||||
Sparkles,
|
||||
Workflow,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
import {
|
||||
@@ -54,6 +69,22 @@ export function NavItems({ projectId, activeNav, onNavigate }: NavItemsProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function navIcon(item: NavItem): LucideIcon {
|
||||
if (item.key === "write") return PenLine;
|
||||
if (item.key === "outline") return ListTree;
|
||||
if (item.key === "foreshadow") return Flag;
|
||||
if (item.key === "review") return ClipboardCheck;
|
||||
if (item.key === "chains") return Workflow;
|
||||
if (item.key === "style") return Palette;
|
||||
if (item.key === "codex") return BookOpen;
|
||||
if (item.key === "toolbox") return Sparkles;
|
||||
if (item.key === "rules") return Boxes;
|
||||
if (item.key === "skills") return Blocks;
|
||||
if (item.href === "/templates") return LayoutTemplate;
|
||||
if (item.href === "/settings/providers") return Settings;
|
||||
return BookOpen;
|
||||
}
|
||||
|
||||
function NavLink({
|
||||
item,
|
||||
active,
|
||||
@@ -63,18 +94,20 @@ function NavLink({
|
||||
active: boolean;
|
||||
onNavigate?: () => void;
|
||||
}) {
|
||||
const Icon = navIcon(item);
|
||||
return (
|
||||
<li>
|
||||
<Link
|
||||
href={item.href}
|
||||
onClick={onNavigate}
|
||||
aria-current={active ? "page" : undefined}
|
||||
className={`flex items-center gap-2 rounded px-3 py-2 text-sm ${
|
||||
className={`flex items-center gap-2 rounded px-3 py-2 text-sm transition-colors ${
|
||||
active
|
||||
? "border-l-2 border-cinnabar bg-[var(--color-cinnabar-wash)] text-cinnabar"
|
||||
: "text-ink hover:bg-[var(--color-cinnabar-wash)]"
|
||||
: "border-l-2 border-transparent text-ink hover:bg-[var(--color-cinnabar-wash)] hover:text-cinnabar"
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-4 w-4 shrink-0" aria-hidden="true" />
|
||||
{item.label}
|
||||
</Link>
|
||||
</li>
|
||||
|
||||
@@ -1,26 +1,98 @@
|
||||
import Link from "next/link";
|
||||
import { BookOpen, ChevronRight, Clock3, Sparkles } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import type { ProjectResponse } from "@/lib/api/types";
|
||||
import {
|
||||
formatProjectUpdatedAt,
|
||||
pendingReviewCount,
|
||||
} from "@/lib/projects/projects";
|
||||
import { cardClass } from "@/lib/ui/variants";
|
||||
|
||||
interface ProjectCardProps {
|
||||
project: ProjectResponse;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
// 作品卡(UX §6.1):书名衬线大字、题材、一句话故事。
|
||||
// M1 无字数/章数/待办统计端点 → 暂不展示该徽标(避免编造 API)。
|
||||
export function ProjectCard({ project }: ProjectCardProps) {
|
||||
export function ProjectCard({ project, compact = false }: ProjectCardProps) {
|
||||
const pendingCount = pendingReviewCount(project);
|
||||
const updatedLabel = formatProjectUpdatedAt(project.updated_at);
|
||||
if (compact) {
|
||||
return (
|
||||
<Link
|
||||
href={`/projects/${project.id}/write`}
|
||||
className={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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowLeft, ArrowRight, CheckCircle2 } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
import { api } from "@/lib/api/client";
|
||||
import { useToast } from "@/components/Toast";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Field } from "@/components/ui/Field";
|
||||
import { Select } from "@/components/ui/Select";
|
||||
import { TextArea } from "@/components/ui/TextArea";
|
||||
import { TextInput } from "@/components/ui/TextInput";
|
||||
import { api } from "@/lib/api/client";
|
||||
import { buttonClass } from "@/lib/ui/variants";
|
||||
import {
|
||||
GENRES,
|
||||
SELLING_POINT_PRESETS,
|
||||
@@ -69,14 +77,17 @@ export function ProjectWizard() {
|
||||
const isLast = step === WIZARD_STEPS;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl rounded border border-line bg-panel p-8 shadow-paper">
|
||||
<div className="mx-auto max-w-2xl rounded border border-line bg-panel p-6 shadow-paper sm:p-8">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<h1 className="font-serif text-2xl text-ink">新建作品</h1>
|
||||
<StepDots current={step} />
|
||||
</div>
|
||||
<p className="mb-4 text-sm text-ink-soft">
|
||||
步骤 {step}/{WIZARD_STEPS} · {STEP_TITLES[step - 1]}
|
||||
</p>
|
||||
<div className="mb-4">
|
||||
<Badge variant="accent">
|
||||
步骤 {step}/{WIZARD_STEPS}
|
||||
</Badge>
|
||||
<p className="mt-2 text-sm text-ink-soft">{STEP_TITLES[step - 1]}</p>
|
||||
</div>
|
||||
|
||||
<div className="min-h-[220px]">
|
||||
{step === 1 && <StepBasics form={form} update={update} />}
|
||||
@@ -93,32 +104,28 @@ export function ProjectWizard() {
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex items-center justify-between">
|
||||
<button
|
||||
type="button"
|
||||
onClick={goBack}
|
||||
disabled={step === 1}
|
||||
className="rounded border border-line px-4 py-2 text-sm text-ink disabled:opacity-40"
|
||||
>
|
||||
← 上一步
|
||||
</button>
|
||||
<Button onClick={goBack} disabled={step === 1} variant="secondary">
|
||||
<ArrowLeft className="h-4 w-4" aria-hidden="true" />
|
||||
上一步
|
||||
</Button>
|
||||
{isLast ? (
|
||||
<button
|
||||
type="button"
|
||||
<Button
|
||||
onClick={submit}
|
||||
disabled={submitting || !canSubmit(form)}
|
||||
className="rounded bg-cinnabar px-5 py-2 text-sm text-panel disabled:opacity-40"
|
||||
variant="primary"
|
||||
>
|
||||
{submitting ? "创建中…" : "完成立项 →"}
|
||||
</button>
|
||||
<CheckCircle2 className="h-4 w-4" aria-hidden="true" />
|
||||
{submitting ? "创建中…" : "完成立项"}
|
||||
</Button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
<Button
|
||||
onClick={goNext}
|
||||
disabled={!canAdvance(step, form)}
|
||||
className="rounded bg-cinnabar px-5 py-2 text-sm text-panel disabled:opacity-40"
|
||||
variant="primary"
|
||||
>
|
||||
下一步 →
|
||||
</button>
|
||||
下一步
|
||||
<ArrowRight className="h-4 w-4" aria-hidden="true" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -131,7 +138,7 @@ function StepDots({ current }: { current: number }) {
|
||||
{Array.from({ length: WIZARD_STEPS }, (_, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={`h-2 w-2 rounded-full ${
|
||||
className={`h-2 w-2 rounded ${
|
||||
i + 1 <= current ? "bg-cinnabar" : "bg-line"
|
||||
}`}
|
||||
/>
|
||||
@@ -145,30 +152,11 @@ interface StepProps {
|
||||
update: (patch: Partial<WizardForm>) => void;
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<label className="mb-4 block">
|
||||
<span className="mb-1 block text-sm text-ink-soft">{label}</span>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
const inputCls =
|
||||
"w-full rounded border border-line bg-bg px-3 py-2 text-ink focus:border-cinnabar focus:outline-none";
|
||||
|
||||
function StepBasics({ form, update }: StepProps) {
|
||||
return (
|
||||
<div>
|
||||
<Field label="书名(必填)">
|
||||
<input
|
||||
className={inputCls}
|
||||
<TextInput
|
||||
value={form.title}
|
||||
onChange={(e) => update({ title: e.target.value })}
|
||||
placeholder="例:逐光而行"
|
||||
@@ -176,8 +164,7 @@ function StepBasics({ form, update }: StepProps) {
|
||||
/>
|
||||
</Field>
|
||||
<Field label="题材">
|
||||
<select
|
||||
className={inputCls}
|
||||
<Select
|
||||
value={form.genre}
|
||||
onChange={(e) => update({ genre: e.target.value })}
|
||||
>
|
||||
@@ -187,7 +174,7 @@ function StepBasics({ form, update }: StepProps) {
|
||||
{g}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Select>
|
||||
</Field>
|
||||
</div>
|
||||
);
|
||||
@@ -201,8 +188,8 @@ function StepStory({
|
||||
return (
|
||||
<div>
|
||||
<Field label="一句话故事(logline)">
|
||||
<textarea
|
||||
className={`${inputCls} h-20 resize-none`}
|
||||
<TextArea
|
||||
className="h-20 resize-none"
|
||||
value={form.logline}
|
||||
onChange={(e) => update({ logline: e.target.value })}
|
||||
placeholder="废柴少年觉醒禁忌血脉,在仙门倾轧中逆势封神。"
|
||||
@@ -215,11 +202,10 @@ function StepStory({
|
||||
type="button"
|
||||
key={s}
|
||||
onClick={() => update({ structure: s })}
|
||||
className={`rounded border px-3 py-1.5 text-sm ${
|
||||
form.structure === s
|
||||
? "border-cinnabar bg-[var(--color-cinnabar-wash)] text-cinnabar"
|
||||
: "border-line text-ink"
|
||||
}`}
|
||||
className={buttonClass({
|
||||
variant: form.structure === s ? "outline" : "secondary",
|
||||
size: "sm",
|
||||
})}
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
@@ -235,11 +221,12 @@ function StepStory({
|
||||
key={p}
|
||||
aria-pressed={form.sellingPoints.includes(p)}
|
||||
onClick={() => toggleSellingPoint(p)}
|
||||
className={`rounded border px-3 py-1.5 text-sm ${
|
||||
form.sellingPoints.includes(p)
|
||||
? "border-cinnabar bg-[var(--color-cinnabar-wash)] text-cinnabar"
|
||||
: "border-line text-ink"
|
||||
}`}
|
||||
className={buttonClass({
|
||||
variant: form.sellingPoints.includes(p)
|
||||
? "outline"
|
||||
: "secondary",
|
||||
size: "sm",
|
||||
})}
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
@@ -254,16 +241,15 @@ function StepPremise({ form, update }: StepProps) {
|
||||
return (
|
||||
<div>
|
||||
<Field label="立意(premise)">
|
||||
<textarea
|
||||
className={`${inputCls} h-20 resize-none`}
|
||||
<TextArea
|
||||
className="h-20 resize-none"
|
||||
value={form.premise}
|
||||
onChange={(e) => update({ premise: e.target.value })}
|
||||
placeholder="故事的核心命题与前提。"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="主题(theme)">
|
||||
<input
|
||||
className={inputCls}
|
||||
<TextInput
|
||||
value={form.theme}
|
||||
onChange={(e) => update({ theme: e.target.value })}
|
||||
placeholder="例:抗争与代价"
|
||||
@@ -282,8 +268,8 @@ function StepProtagonist({ form, update }: StepProps) {
|
||||
主角与金手指(M1 暂并入总纲,后续设定库独立建模)。
|
||||
</p>
|
||||
<Field label="主角 / 金手指概要">
|
||||
<textarea
|
||||
className={`${inputCls} h-28 resize-none`}
|
||||
<TextArea
|
||||
className="h-28 resize-none"
|
||||
value={form.protagonist}
|
||||
onChange={(e) => update({ protagonist: e.target.value })}
|
||||
placeholder="主角设定、金手指来源与限制……"
|
||||
@@ -295,7 +281,7 @@ function StepProtagonist({ form, update }: StepProps) {
|
||||
|
||||
function StepWorld() {
|
||||
return (
|
||||
<div className="rounded border border-dashed border-line bg-bg p-6 text-sm text-ink-soft">
|
||||
<div className="rounded border border-dashed border-line bg-bg p-6 text-sm leading-6 text-ink-soft">
|
||||
基础世界观将在「设定库」里建模(后续里程碑)。现在可直接完成立项,进入工作台开始写第一章。
|
||||
</div>
|
||||
);
|
||||
|
||||
10
apps/web/components/ThemeScript.tsx
Normal file
10
apps/web/components/ThemeScript.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { themeBootstrapScript } from "@/lib/ui/theme";
|
||||
|
||||
export function ThemeScript() {
|
||||
return (
|
||||
<script
|
||||
dangerouslySetInnerHTML={{ __html: themeBootstrapScript() }}
|
||||
suppressHydrationWarning
|
||||
/>
|
||||
);
|
||||
}
|
||||
70
apps/web/components/ThemeToggle.tsx
Normal file
70
apps/web/components/ThemeToggle.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Moon, Sun } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import {
|
||||
DEFAULT_THEME_MODE,
|
||||
THEME_STORAGE_KEY,
|
||||
nextThemeMode,
|
||||
normalizeThemeMode,
|
||||
themeModeLabel,
|
||||
themeToggleLabel,
|
||||
type ThemeMode,
|
||||
} from "@/lib/ui/theme";
|
||||
|
||||
function applyThemeMode(mode: ThemeMode) {
|
||||
document.documentElement.dataset.theme = mode;
|
||||
}
|
||||
|
||||
function readStoredThemeMode(): ThemeMode {
|
||||
try {
|
||||
return normalizeThemeMode(window.localStorage.getItem(THEME_STORAGE_KEY));
|
||||
} catch {
|
||||
return DEFAULT_THEME_MODE;
|
||||
}
|
||||
}
|
||||
|
||||
function writeStoredThemeMode(mode: ThemeMode) {
|
||||
try {
|
||||
window.localStorage.setItem(THEME_STORAGE_KEY, mode);
|
||||
} catch {
|
||||
// 存储不可用时仍允许本次页面会话切换主题。
|
||||
}
|
||||
}
|
||||
|
||||
export function ThemeToggle() {
|
||||
const [mode, setMode] = useState<ThemeMode>(DEFAULT_THEME_MODE);
|
||||
|
||||
useEffect(() => {
|
||||
const initial = readStoredThemeMode();
|
||||
setMode(initial);
|
||||
applyThemeMode(initial);
|
||||
}, []);
|
||||
|
||||
const toggle = () => {
|
||||
setMode((current) => {
|
||||
const next = nextThemeMode(current);
|
||||
writeStoredThemeMode(next);
|
||||
applyThemeMode(next);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const Icon = mode === "night" ? Sun : Moon;
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={toggle}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={themeToggleLabel(mode)}
|
||||
title={themeToggleLabel(mode)}
|
||||
className="border-transparent"
|
||||
>
|
||||
<Icon className="h-4 w-4" aria-hidden="true" />
|
||||
<span className="sr-only">{themeModeLabel(mode)}</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -143,6 +143,7 @@ export function ChainAdjudication({
|
||||
<ConflictCard
|
||||
key={i}
|
||||
index={i}
|
||||
total={conflicts.length}
|
||||
conflict={conflict}
|
||||
draft={draft}
|
||||
missing={false}
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Play } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Field } from "@/components/ui/Field";
|
||||
import { SectionHeader } from "@/components/ui/SectionHeader";
|
||||
import { TextInput } from "@/components/ui/TextInput";
|
||||
import { CHAIN_KINDS, type ChainKind } from "@/lib/chain/chain";
|
||||
|
||||
interface ChainStarterProps {
|
||||
@@ -41,12 +46,10 @@ export function ChainStarter({ onStart, disabled }: ChainStarterProps) {
|
||||
if (valid && !disabled) onStart(chainKey, startNo, countNo);
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<h2 className="font-serif text-lg text-ink">连续写多章</h2>
|
||||
<p className="mt-1 text-sm text-ink-soft">
|
||||
从指定章起循环「写章 → 四审 → 验收」;遇未决冲突会暂停等你裁决再续跑。
|
||||
</p>
|
||||
</div>
|
||||
<SectionHeader
|
||||
title="连续写多章"
|
||||
description="从指定章起循环「写章 → 四审 → 验收」;遇未决冲突会暂停等你裁决再续跑。"
|
||||
/>
|
||||
<fieldset className="flex flex-col gap-2">
|
||||
<legend className="text-sm text-ink-soft">链类型</legend>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
@@ -75,37 +78,35 @@ export function ChainStarter({ onStart, disabled }: ChainStarterProps) {
|
||||
</div>
|
||||
</fieldset>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<label className="block text-sm text-ink-soft">
|
||||
起始章号
|
||||
<input
|
||||
<Field label="起始章号" className="w-32">
|
||||
<TextInput
|
||||
type="number"
|
||||
min={1}
|
||||
value={start}
|
||||
onChange={(e) => setStart(e.target.value)}
|
||||
className="mt-1 w-32 rounded border border-line bg-bg px-3 py-2 text-sm text-ink"
|
||||
aria-label="起始章号"
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-sm text-ink-soft">
|
||||
连续章数(1..{MAX_COUNT})
|
||||
<input
|
||||
</Field>
|
||||
<Field label={`连续章数(1..${MAX_COUNT})`} className="w-40">
|
||||
<TextInput
|
||||
type="number"
|
||||
min={1}
|
||||
max={MAX_COUNT}
|
||||
value={count}
|
||||
onChange={(e) => setCount(e.target.value)}
|
||||
className="mt-1 w-32 rounded border border-line bg-bg px-3 py-2 text-sm text-ink"
|
||||
aria-label="连续章数"
|
||||
/>
|
||||
</label>
|
||||
</Field>
|
||||
</div>
|
||||
<button
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={disabled || !valid}
|
||||
className="self-start rounded bg-cinnabar px-4 py-2 text-sm text-white disabled:opacity-50"
|
||||
variant="primary"
|
||||
className="self-start"
|
||||
>
|
||||
<Play className="h-4 w-4" aria-hidden="true" />
|
||||
{disabled ? "运行中…" : "发起多章链"}
|
||||
</button>
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { Clock3, Globe2, Sparkles, UserRound } from "lucide-react";
|
||||
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { CharacterGenerator } from "@/components/generation/CharacterGenerator";
|
||||
import { WorldGenerator } from "@/components/generation/WorldGenerator";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { EmptyState } from "@/components/ui/EmptyState";
|
||||
import { PageHeader } from "@/components/ui/PageHeader";
|
||||
import type {
|
||||
CharacterCardView,
|
||||
ProjectResponse,
|
||||
@@ -60,6 +66,10 @@ export function CodexPage({
|
||||
activeNav="codex"
|
||||
>
|
||||
<div className="flex h-[calc(100vh-var(--chrome,4rem))] flex-col p-4">
|
||||
<PageHeader
|
||||
title="设定库"
|
||||
description="人物、世界观与时间线共同构成写作时的真相源。AI 生成内容也要先预览、再确认入库。"
|
||||
/>
|
||||
<div className="mb-4 flex items-center gap-2" role="tablist">
|
||||
{TABS.map((t) => (
|
||||
<button
|
||||
@@ -82,20 +92,28 @@ export function CodexPage({
|
||||
<div className="min-h-0 flex-1 overflow-auto">
|
||||
{tab === "characters" ? (
|
||||
<div className="flex flex-col gap-4">
|
||||
<section className="rounded border border-line bg-panel p-3">
|
||||
<h3 className="mb-2 font-serif text-sm text-ink">
|
||||
<Card as="section" className="p-4">
|
||||
<h3 className="mb-3 font-serif text-sm text-ink">
|
||||
已入库人物({characters.length})
|
||||
</h3>
|
||||
{characters.length > 0 ? (
|
||||
<ul className="flex flex-col gap-2">
|
||||
<ul className="grid grid-cols-1 gap-3 lg:grid-cols-2">
|
||||
{characters.map((c, i) => (
|
||||
<li
|
||||
key={`${c.name}-${i}`}
|
||||
className="rounded bg-bg px-2 py-1.5 text-xs text-ink-soft"
|
||||
className="rounded border border-line bg-bg p-3 text-sm"
|
||||
>
|
||||
<span className="text-ink">
|
||||
{c.name}({c.role})
|
||||
</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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { TextInput } from "@/components/ui/TextInput";
|
||||
import type { ForeshadowView } from "@/lib/api/types";
|
||||
import type { ForeshadowStatus } from "@/lib/foreshadow/board";
|
||||
import { LANE_LABELS, type ForeshadowStatus } from "@/lib/foreshadow/board";
|
||||
|
||||
interface ForeshadowCardProps {
|
||||
item: ForeshadowView;
|
||||
@@ -44,9 +48,10 @@ export function ForeshadowCard({
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="font-mono text-xs text-cinnabar">{item.code}</span>
|
||||
{overdue ? (
|
||||
<span className="text-xs text-overdue" aria-label="逾期">
|
||||
⚠ 逾期
|
||||
</span>
|
||||
<Badge variant="warning" aria-label="逾期">
|
||||
<AlertTriangle className="h-3 w-3" aria-hidden="true" />
|
||||
逾期
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="mt-0.5 font-serif text-sm text-ink">{item.title}</p>
|
||||
@@ -72,15 +77,16 @@ export function ForeshadowCard({
|
||||
{transitions.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{transitions.map((to) => (
|
||||
<button
|
||||
<Button
|
||||
key={to}
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => onTransition(item.code, to, "")}
|
||||
className="rounded border border-line px-2 py-0.5 text-xs text-ink hover:border-cinnabar hover:text-cinnabar disabled:opacity-40"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="px-2 py-0.5"
|
||||
>
|
||||
→ {to === "CLOSED" ? "回收" : to}
|
||||
</button>
|
||||
→ {to === "CLOSED" ? "回收" : LANE_LABELS[to]}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
@@ -89,25 +95,27 @@ export function ForeshadowCard({
|
||||
<label htmlFor={`prog-${item.code}`} className="sr-only">
|
||||
{item.code} 进展
|
||||
</label>
|
||||
<input
|
||||
<TextInput
|
||||
id={`prog-${item.code}`}
|
||||
type="text"
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
placeholder="加进展(如:23章发光)"
|
||||
className="min-w-0 flex-1 rounded border border-line bg-bg px-2 py-0.5 text-xs text-ink focus:border-cinnabar focus:outline-none"
|
||||
controlSize="sm"
|
||||
className="min-w-0 flex-1 py-0.5"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
<Button
|
||||
disabled={busy || note.trim().length === 0}
|
||||
onClick={() => {
|
||||
onTransition(item.code, null, note.trim());
|
||||
setNote("");
|
||||
}}
|
||||
className="rounded border border-line px-2 py-0.5 text-xs text-ink hover:border-cinnabar disabled:opacity-40"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="px-2 py-0.5"
|
||||
>
|
||||
记
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import type { ForeshadowView } from "@/lib/api/types";
|
||||
import type { ForeshadowStatus } from "@/lib/foreshadow/board";
|
||||
import { LANE_LABELS, type ForeshadowStatus } from "@/lib/foreshadow/board";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { ForeshadowCard } from "./ForeshadowCard";
|
||||
|
||||
interface KanbanColumnProps {
|
||||
@@ -31,17 +32,23 @@ export function KanbanColumn({
|
||||
<header
|
||||
className={`flex items-center justify-between rounded-t border-b px-3 py-2 ${
|
||||
overdue
|
||||
? "border-overdue bg-overdue/10 text-overdue"
|
||||
? "border-overdue bg-overdue/10 text-ink"
|
||||
: "border-line bg-panel text-ink-soft"
|
||||
}`}
|
||||
>
|
||||
<span className="font-mono text-xs font-semibold">
|
||||
{overdue ? "⚠ " : ""}
|
||||
{status}
|
||||
<span className="flex items-center gap-2">
|
||||
{overdue ? (
|
||||
<Badge variant="warning">{LANE_LABELS[status]}</Badge>
|
||||
) : (
|
||||
<span className="text-sm font-medium text-ink">
|
||||
{LANE_LABELS[status]}
|
||||
</span>
|
||||
)}
|
||||
<span className="font-mono text-[10px] text-ink-soft">{status}</span>
|
||||
</span>
|
||||
<span className="font-mono text-xs">{items.length}</span>
|
||||
</header>
|
||||
<ul className="flex-1 space-y-2 overflow-auto p-2">
|
||||
<ul className="space-y-2 p-2 xl:flex-1 xl:overflow-auto">
|
||||
{items.length === 0 ? (
|
||||
<li className="px-1 py-3 text-xs text-ink-soft/60">—</li>
|
||||
) : (
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { Plus } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { TextInput } from "@/components/ui/TextInput";
|
||||
import type { RegisterInput } from "@/lib/foreshadow/board";
|
||||
|
||||
interface RegisterFormProps {
|
||||
projectId: string;
|
||||
busy: boolean;
|
||||
onRegister: (input: RegisterInput) => Promise<boolean>;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
const EMPTY = {
|
||||
@@ -25,9 +30,16 @@ export function RegisterForm({
|
||||
projectId,
|
||||
busy,
|
||||
onRegister,
|
||||
open: controlledOpen,
|
||||
onOpenChange,
|
||||
}: RegisterFormProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [localOpen, setLocalOpen] = useState(false);
|
||||
const [form, setForm] = useState({ ...EMPTY });
|
||||
const open = controlledOpen ?? localOpen;
|
||||
const setOpen = (next: boolean): void => {
|
||||
if (controlledOpen === undefined) setLocalOpen(next);
|
||||
onOpenChange?.(next);
|
||||
};
|
||||
|
||||
const set = (key: keyof typeof EMPTY, value: string): void =>
|
||||
setForm((prev) => ({ ...prev, [key]: value }));
|
||||
@@ -51,13 +63,10 @@ export function RegisterForm({
|
||||
|
||||
if (!open) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
className="rounded bg-cinnabar px-3 py-1.5 text-sm text-panel hover:opacity-90"
|
||||
>
|
||||
+ 登记伏笔
|
||||
</button>
|
||||
<Button onClick={() => setOpen(true)} variant="primary" size="sm">
|
||||
<Plus className="h-4 w-4" aria-hidden="true" />
|
||||
登记伏笔
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -73,99 +82,90 @@ export function RegisterForm({
|
||||
className="rounded border border-line bg-panel p-4"
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||
<Field label="代号 *" id="f-code">
|
||||
<input
|
||||
<FormSlot label="代号 *" id="f-code">
|
||||
<TextInput
|
||||
id="f-code"
|
||||
value={form.code}
|
||||
onChange={(e) => set("code", e.target.value)}
|
||||
className={inputCls}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="标题 *" id="f-title" span2>
|
||||
<input
|
||||
</FormSlot>
|
||||
<FormSlot label="标题 *" id="f-title" span2>
|
||||
<TextInput
|
||||
id="f-title"
|
||||
value={form.title}
|
||||
onChange={(e) => set("title", e.target.value)}
|
||||
className={inputCls}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="埋设章" id="f-planted">
|
||||
<input
|
||||
</FormSlot>
|
||||
<FormSlot label="埋设章" id="f-planted">
|
||||
<TextInput
|
||||
id="f-planted"
|
||||
inputMode="numeric"
|
||||
value={form.plantedAt}
|
||||
onChange={(e) => set("plantedAt", e.target.value)}
|
||||
className={inputCls}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="回收窗口起" id="f-from">
|
||||
<input
|
||||
</FormSlot>
|
||||
<FormSlot label="回收窗口起" id="f-from">
|
||||
<TextInput
|
||||
id="f-from"
|
||||
inputMode="numeric"
|
||||
value={form.expectedCloseFrom}
|
||||
onChange={(e) => set("expectedCloseFrom", e.target.value)}
|
||||
className={inputCls}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="回收窗口止" id="f-to">
|
||||
<input
|
||||
</FormSlot>
|
||||
<FormSlot label="回收窗口止" id="f-to">
|
||||
<TextInput
|
||||
id="f-to"
|
||||
inputMode="numeric"
|
||||
value={form.expectedCloseTo}
|
||||
onChange={(e) => set("expectedCloseTo", e.target.value)}
|
||||
className={inputCls}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="重要度" id="f-imp">
|
||||
<input
|
||||
</FormSlot>
|
||||
<FormSlot label="重要度" id="f-imp">
|
||||
<TextInput
|
||||
id="f-imp"
|
||||
value={form.importance}
|
||||
onChange={(e) => set("importance", e.target.value)}
|
||||
placeholder="主线/支线"
|
||||
className={inputCls}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="线索内容" id="f-content" span3>
|
||||
<input
|
||||
</FormSlot>
|
||||
<FormSlot label="线索内容" id="f-content" span3>
|
||||
<TextInput
|
||||
id="f-content"
|
||||
value={form.content}
|
||||
onChange={(e) => set("content", e.target.value)}
|
||||
className={inputCls}
|
||||
/>
|
||||
</Field>
|
||||
</FormSlot>
|
||||
</div>
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<button
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!canSubmit}
|
||||
className="rounded bg-cinnabar px-3 py-1.5 text-sm text-panel hover:opacity-90 disabled:opacity-40"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
>
|
||||
{busy ? "登记中…" : "登记"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setOpen(false)}
|
||||
className="rounded border border-line px-3 py-1.5 text-sm text-ink-soft hover:border-cinnabar"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
const inputCls =
|
||||
"w-full rounded border border-line bg-bg px-2 py-1 text-sm text-ink focus:border-cinnabar focus:outline-none";
|
||||
|
||||
interface FieldProps {
|
||||
interface FormSlotProps {
|
||||
label: string;
|
||||
id: string;
|
||||
span2?: boolean;
|
||||
span3?: boolean;
|
||||
children: React.ReactNode;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function Field({ label, id, span2, span3, children }: FieldProps) {
|
||||
function FormSlot({ label, id, span2, span3, children }: FormSlotProps) {
|
||||
const span = span3 ? "col-span-2 sm:col-span-3" : span2 ? "col-span-2" : "";
|
||||
return (
|
||||
<div className={span}>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { TextInput } from "@/components/ui/TextInput";
|
||||
import type { CharacterCardView } from "@/lib/api/types";
|
||||
|
||||
interface CharacterCardItemProps {
|
||||
@@ -32,11 +33,12 @@ export function CharacterCardItem({
|
||||
className="size-4 accent-cinnabar"
|
||||
/>
|
||||
{onEdit ? (
|
||||
<input
|
||||
<TextInput
|
||||
value={card.name}
|
||||
onChange={(e) => onEdit({ name: e.target.value })}
|
||||
aria-label="角色名"
|
||||
className="flex-1 rounded border border-line bg-bg px-2 py-1 font-serif text-base text-ink"
|
||||
controlSize="sm"
|
||||
className="min-w-0 flex-1 font-serif text-base"
|
||||
/>
|
||||
) : (
|
||||
<span className="flex-1 font-serif text-base text-ink">{card.name}</span>
|
||||
@@ -61,11 +63,12 @@ export function CharacterCardItem({
|
||||
<div className="mb-1 flex items-start gap-1 text-ink-soft">
|
||||
<span className="shrink-0 text-ink">弧光:</span>
|
||||
{onEdit ? (
|
||||
<input
|
||||
<TextInput
|
||||
value={card.arc}
|
||||
onChange={(e) => onEdit({ arc: e.target.value })}
|
||||
aria-label="人物弧光"
|
||||
className="flex-1 rounded border border-line bg-bg px-2 py-1 text-ink"
|
||||
controlSize="sm"
|
||||
className="min-w-0 flex-1"
|
||||
/>
|
||||
) : (
|
||||
<span>{card.arc}</span>
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Database, Sparkles } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Field } from "@/components/ui/Field";
|
||||
import { SectionHeader } from "@/components/ui/SectionHeader";
|
||||
import { StatusNote } from "@/components/ui/StatusNote";
|
||||
import { TextInput } from "@/components/ui/TextInput";
|
||||
import type { CharacterCardView } from "@/lib/api/types";
|
||||
import {
|
||||
MAX_CHARACTER_COUNT,
|
||||
@@ -80,45 +86,39 @@ export function CharacterGenerator({
|
||||
return (
|
||||
<section className="flex flex-col gap-4" aria-label="角色生成器">
|
||||
<div className="rounded border border-line bg-panel p-4">
|
||||
<h2 className="mb-3 font-serif text-base text-ink">角色生成器</h2>
|
||||
<label className="mb-2 block text-sm text-ink-soft">
|
||||
一句话需求
|
||||
<input
|
||||
<SectionHeader title="角色生成器" className="mb-3" />
|
||||
<Field label="一句话需求" className="mb-3">
|
||||
<TextInput
|
||||
value={brief}
|
||||
onChange={(e) => setBrief(e.target.value)}
|
||||
placeholder="如:一个亦正亦邪的剑修,背负灭门血仇"
|
||||
className="mt-1 w-full rounded border border-line bg-bg px-3 py-2 text-sm text-ink"
|
||||
/>
|
||||
</label>
|
||||
</Field>
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<label className="text-sm text-ink-soft">
|
||||
数量
|
||||
<input
|
||||
<Field label="数量" className="w-24">
|
||||
<TextInput
|
||||
type="number"
|
||||
min={MIN_CHARACTER_COUNT}
|
||||
max={MAX_CHARACTER_COUNT}
|
||||
value={count}
|
||||
onChange={(e) => setCount(Number(e.target.value))}
|
||||
className="mt-1 block w-20 rounded border border-line bg-bg px-2 py-1.5 text-sm text-ink"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex-1 text-sm text-ink-soft">
|
||||
定位(可选)
|
||||
<input
|
||||
</Field>
|
||||
<Field label="定位(可选)" className="min-w-[12rem] flex-1">
|
||||
<TextInput
|
||||
value={role}
|
||||
onChange={(e) => setRole(e.target.value)}
|
||||
placeholder="主角 / CP / 对手 / 导师 / 工具人"
|
||||
className="mt-1 block w-full rounded border border-line bg-bg px-2 py-1.5 text-sm text-ink"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
</Field>
|
||||
<Button
|
||||
onClick={() => void onGenerate()}
|
||||
disabled={generating || brief.trim().length === 0}
|
||||
className="rounded bg-cinnabar px-4 py-2 text-sm text-white disabled:opacity-50"
|
||||
variant="primary"
|
||||
>
|
||||
<Sparkles className="h-4 w-4" aria-hidden="true" />
|
||||
{generating ? "生成中…" : "生成"}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -151,20 +151,20 @@ export function CharacterGenerator({
|
||||
onCancel={() => gen.reset()}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
<Button
|
||||
onClick={() => void doIngest(false)}
|
||||
disabled={ingesting || selectedCards.length === 0}
|
||||
className="self-start rounded bg-cinnabar px-4 py-2 text-sm text-white disabled:opacity-50"
|
||||
variant="primary"
|
||||
>
|
||||
<Database className="h-4 w-4" aria-hidden="true" />
|
||||
{ingesting ? "入库中…" : `入库选中 ${selectedCards.length} 张`}
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{gen.ingestStatus === "done" && gen.created.length > 0 ? (
|
||||
<p className="text-sm text-pass">已入库:{gen.created.join("、")}</p>
|
||||
<StatusNote variant="success">已入库:{gen.created.join("、")}</StatusNote>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { Sparkles } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Field } from "@/components/ui/Field";
|
||||
import { SectionHeader } from "@/components/ui/SectionHeader";
|
||||
import { TextArea } from "@/components/ui/TextArea";
|
||||
import { worldEntityRules } from "@/lib/generation/cards";
|
||||
import { useWorldGen } from "@/lib/generation/useWorldGen";
|
||||
|
||||
@@ -24,25 +29,23 @@ export function WorldGenerator({ projectId }: WorldGeneratorProps) {
|
||||
return (
|
||||
<section className="flex flex-col gap-4" aria-label="世界观设计器">
|
||||
<div className="rounded border border-line bg-panel p-4">
|
||||
<h2 className="mb-3 font-serif text-base text-ink">世界观设计器</h2>
|
||||
<label className="mb-3 block text-sm text-ink-soft">
|
||||
设定方向
|
||||
<textarea
|
||||
<SectionHeader title="世界观设计器" className="mb-3" />
|
||||
<Field label="设定方向" className="mb-3">
|
||||
<TextArea
|
||||
value={brief}
|
||||
onChange={(e) => setBrief(e.target.value)}
|
||||
placeholder="如:东方修真世界,灵气复苏,宗门林立,强者寿元有限"
|
||||
rows={3}
|
||||
className="mt-1 w-full resize-y rounded border border-line bg-bg px-3 py-2 text-sm text-ink"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
</Field>
|
||||
<Button
|
||||
onClick={() => void onGenerate()}
|
||||
disabled={generating || brief.trim().length === 0}
|
||||
className="rounded bg-cinnabar px-4 py-2 text-sm text-white disabled:opacity-50"
|
||||
variant="primary"
|
||||
>
|
||||
<Sparkles className="h-4 w-4" aria-hidden="true" />
|
||||
{generating ? "生成中…" : "生成世界观"}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{gen.status === "done" && gen.entities.length > 0 ? (
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { PenLine } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import type { OutlineChapterView } from "@/lib/api/types";
|
||||
import {
|
||||
isCloseWindow,
|
||||
windowBadgeLabel,
|
||||
} from "@/lib/outline/outline";
|
||||
import { buttonClass } from "@/lib/ui/variants";
|
||||
|
||||
interface OutlineChapterRowProps {
|
||||
chapter: OutlineChapterView;
|
||||
@@ -25,31 +28,28 @@ export function OutlineChapterRow({ chapter, projectId }: OutlineChapterRowProps
|
||||
</span>
|
||||
<Link
|
||||
href={`/projects/${projectId}/write?chapter=${chapter.no}`}
|
||||
className="rounded border border-line px-1.5 py-0.5 text-xs text-ink-soft hover:border-cinnabar hover:text-cinnabar"
|
||||
className={buttonClass({ variant: "secondary", size: "sm" })}
|
||||
>
|
||||
✍ 写此章
|
||||
<PenLine className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
写此章
|
||||
</Link>
|
||||
{windows.map((w) => {
|
||||
const close = isCloseWindow(chapter, w);
|
||||
return (
|
||||
<span
|
||||
<Badge
|
||||
key={w.code}
|
||||
variant={close ? "warning" : "accent"}
|
||||
title={close ? "进入回收窗口,建议本章安排回收" : undefined}
|
||||
className={`rounded px-1.5 py-0.5 text-xs ${
|
||||
close
|
||||
? "bg-overdue/15 text-overdue"
|
||||
: "bg-[var(--color-cinnabar-wash)] text-cinnabar"
|
||||
}`}
|
||||
>
|
||||
{windowBadgeLabel(w)}
|
||||
{close ? " · 可回收" : ""}
|
||||
</span>
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{chapter.beats && chapter.beats.length > 0 ? (
|
||||
<p className="mt-1 text-sm text-ink">
|
||||
<span className="text-ink-soft">beats: </span>
|
||||
<span className="text-ink-soft">节拍:</span>
|
||||
{chapter.beats.join(" / ")}
|
||||
</p>
|
||||
) : (
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { ListTree, Sparkles } from "lucide-react";
|
||||
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { EmptyState } from "@/components/ui/EmptyState";
|
||||
import { PageHeader } from "@/components/ui/PageHeader";
|
||||
import { Select } from "@/components/ui/Select";
|
||||
import { TextInput } from "@/components/ui/TextInput";
|
||||
import type { OutlineChapterView, ProjectResponse } from "@/lib/api/types";
|
||||
import {
|
||||
distinctVolumes,
|
||||
@@ -43,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">
|
||||
|
||||
212
apps/web/components/projects/ProjectLibrary.tsx
Normal file
212
apps/web/components/projects/ProjectLibrary.tsx
Normal file
@@ -0,0 +1,212 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { Clock3, LayoutGrid, List, Plus, Search } from "lucide-react";
|
||||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
|
||||
import { ProjectCard } from "@/components/ProjectCard";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { EmptyState } from "@/components/ui/EmptyState";
|
||||
import { Select } from "@/components/ui/Select";
|
||||
import { TextInput } from "@/components/ui/TextInput";
|
||||
import type { ProjectResponse } from "@/lib/api/types";
|
||||
import {
|
||||
filterProjects,
|
||||
type ProjectFilter,
|
||||
type ProjectSort,
|
||||
type ProjectViewMode,
|
||||
} from "@/lib/projects/projects";
|
||||
import { buttonClass, cn } from "@/lib/ui/variants";
|
||||
|
||||
interface ProjectLibraryProps {
|
||||
projects: ProjectResponse[];
|
||||
}
|
||||
|
||||
const VIEW_STORAGE_KEY = "ww.project_view_mode";
|
||||
|
||||
export function ProjectLibrary({ projects }: ProjectLibraryProps) {
|
||||
const [search, setSearch] = useState("");
|
||||
const [filter, setFilter] = useState<ProjectFilter>("all");
|
||||
const [sort, setSort] = useState<ProjectSort>("updated_at");
|
||||
const [viewMode, setViewMode] = useState<ProjectViewMode>("cards");
|
||||
|
||||
useEffect(() => {
|
||||
const saved = window.localStorage.getItem(VIEW_STORAGE_KEY);
|
||||
if (saved === "cards" || saved === "compact") setViewMode(saved);
|
||||
}, []);
|
||||
|
||||
const setView = (value: ProjectViewMode): void => {
|
||||
setViewMode(value);
|
||||
window.localStorage.setItem(VIEW_STORAGE_KEY, value);
|
||||
};
|
||||
|
||||
const visibleProjects = useMemo(
|
||||
() => filterProjects(projects, { search, filter, sort }),
|
||||
[projects, search, filter, sort],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-3 rounded border border-line bg-panel p-3 md:grid-cols-[1fr_auto_auto_auto] md:items-center">
|
||||
<label className="relative block">
|
||||
<span className="sr-only">搜索作品</span>
|
||||
<Search
|
||||
className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-ink-soft"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<TextInput
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="搜索标题、题材、主题或一句话故事"
|
||||
className="pl-9"
|
||||
/>
|
||||
</label>
|
||||
<Select
|
||||
aria-label="筛选作品"
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value as ProjectFilter)}
|
||||
>
|
||||
<option value="all">全部作品</option>
|
||||
<option value="pending_review">待审稿</option>
|
||||
<option value="with_genre">有题材</option>
|
||||
<option value="uncategorized">未归类</option>
|
||||
</Select>
|
||||
<Select
|
||||
aria-label="排序作品"
|
||||
value={sort}
|
||||
onChange={(e) => setSort(e.target.value as ProjectSort)}
|
||||
>
|
||||
<option value="updated_at">最近编辑</option>
|
||||
<option value="title">按标题</option>
|
||||
<option value="genre">按题材</option>
|
||||
</Select>
|
||||
<div
|
||||
className="inline-flex justify-self-start rounded border border-line bg-bg p-1 md:justify-self-end"
|
||||
role="group"
|
||||
aria-label="视图密度"
|
||||
>
|
||||
<ViewButton
|
||||
label="卡片"
|
||||
active={viewMode === "cards"}
|
||||
onClick={() => setView("cards")}
|
||||
>
|
||||
<LayoutGrid className="h-4 w-4" aria-hidden="true" />
|
||||
</ViewButton>
|
||||
<ViewButton
|
||||
label="紧凑"
|
||||
active={viewMode === "compact"}
|
||||
onClick={() => setView("compact")}
|
||||
>
|
||||
<List className="h-4 w-4" aria-hidden="true" />
|
||||
</ViewButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-ink-soft">
|
||||
<Clock3 className="mr-1 inline h-3.5 w-3.5" aria-hidden="true" />
|
||||
显示 {visibleProjects.length} / {projects.length} 个作品
|
||||
</p>
|
||||
|
||||
{visibleProjects.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Search}
|
||||
title="没有匹配的作品"
|
||||
description="换一个关键词或清空筛选,也可以直接创建一本新作品。"
|
||||
action={
|
||||
<div className="flex flex-wrap justify-center gap-2">
|
||||
<Button
|
||||
onClick={() => {
|
||||
setSearch("");
|
||||
setFilter("all");
|
||||
}}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
>
|
||||
清空筛选
|
||||
</Button>
|
||||
<Link
|
||||
href="/projects/new"
|
||||
className={buttonClass({ variant: "primary", size: "sm" })}
|
||||
>
|
||||
<Plus className="h-4 w-4" aria-hidden="true" />
|
||||
新建作品
|
||||
</Link>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
) : viewMode === "cards" ? (
|
||||
<ul className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{visibleProjects.map((p) => (
|
||||
<li key={p.id}>
|
||||
<ProjectCard project={p} />
|
||||
</li>
|
||||
))}
|
||||
<li>
|
||||
<NewProjectCard />
|
||||
</li>
|
||||
</ul>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-2">
|
||||
{visibleProjects.map((p) => (
|
||||
<li key={p.id}>
|
||||
<ProjectCard project={p} compact />
|
||||
</li>
|
||||
))}
|
||||
<li>
|
||||
<Link
|
||||
href="/projects/new"
|
||||
className="flex items-center justify-center gap-2 rounded border border-dashed border-line bg-panel/70 px-4 py-3 text-sm text-cinnabar transition-colors hover:border-cinnabar hover:bg-panel"
|
||||
>
|
||||
<Plus className="h-4 w-4" aria-hidden="true" />
|
||||
新建作品
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ViewButton({
|
||||
active,
|
||||
label,
|
||||
children,
|
||||
onClick,
|
||||
}: {
|
||||
active: boolean;
|
||||
label: string;
|
||||
children: ReactNode;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
aria-pressed={active}
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"rounded px-2 py-1.5 text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cinnabar/35",
|
||||
active
|
||||
? "bg-panel text-cinnabar shadow-paper"
|
||||
: "text-ink-soft hover:text-cinnabar",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function NewProjectCard() {
|
||||
return (
|
||||
<Link
|
||||
href="/projects/new"
|
||||
className="group flex h-full min-h-[176px] flex-col items-center justify-center rounded border border-dashed border-line bg-panel/70 text-center transition-colors hover:border-cinnabar hover:bg-panel"
|
||||
>
|
||||
<span className="mb-3 flex h-10 w-10 items-center justify-center rounded bg-[var(--color-cinnabar-wash)] text-cinnabar transition-colors group-hover:bg-cinnabar group-hover:text-panel">
|
||||
<Plus className="h-5 w-5" aria-hidden="true" />
|
||||
</span>
|
||||
<span className="font-serif text-xl text-cinnabar">新建</span>
|
||||
<span className="mt-2 text-sm text-ink-soft">从一句灵感开始一本书</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { ArrowRight, CheckCircle2, ListTree } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { StatusNote } from "@/components/ui/StatusNote";
|
||||
import type { AcceptResponse } from "@/lib/api/types";
|
||||
import { buildAcceptPreview } from "@/lib/review/accept-preview";
|
||||
import { ThinkingIndicator } from "@/components/ThinkingIndicator";
|
||||
import { buttonClass } from "@/lib/ui/variants";
|
||||
|
||||
interface AcceptPanelProps {
|
||||
projectId: string;
|
||||
@@ -42,40 +46,50 @@ export function AcceptPanel({
|
||||
if (result) {
|
||||
return (
|
||||
<div className="rounded border border-pass/50 bg-panel p-4">
|
||||
<h3 className="mb-2 text-sm font-semibold text-pass">本章已验收</h3>
|
||||
<ul className="space-y-1 text-xs text-ink-soft">
|
||||
<li>
|
||||
晋升版次:
|
||||
<span className="font-mono text-ink">v{result.accepted_version}</span>
|
||||
</li>
|
||||
<li>
|
||||
章节摘要:{result.digest_added ? "已新增一行" : "未变更"}
|
||||
</li>
|
||||
<li>
|
||||
裁决写回:
|
||||
<span className="font-mono text-ink">
|
||||
<StatusNote variant="success" title="本章已验收">
|
||||
<p className="text-sm leading-6">
|
||||
终稿已晋升为正式版本,章节摘要和裁决留痕已按验收事务写回。
|
||||
</p>
|
||||
</StatusNote>
|
||||
<dl className="mt-3 grid grid-cols-3 gap-2 text-center">
|
||||
<div className="rounded border border-line bg-bg/60 p-2">
|
||||
<dt className="text-[11px] text-ink-soft">版次</dt>
|
||||
<dd className="mt-1 font-mono text-sm text-ink">
|
||||
v{result.accepted_version}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="rounded border border-line bg-bg/60 p-2">
|
||||
<dt className="text-[11px] text-ink-soft">摘要</dt>
|
||||
<dd className="mt-1 text-sm text-ink">
|
||||
{result.digest_added ? "已新增" : "未变更"}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="rounded border border-line bg-bg/60 p-2">
|
||||
<dt className="text-[11px] text-ink-soft">裁决</dt>
|
||||
<dd className="mt-1 font-mono text-sm text-ink">
|
||||
{result.decisions_recorded}
|
||||
</span>{" "}
|
||||
条
|
||||
</li>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import type { ReviewConflict } from "@/lib/review/sse";
|
||||
import { AlertTriangle, CheckCircle2, LocateFixed, Sparkles } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { TextInput } from "@/components/ui/TextInput";
|
||||
import { buttonClass } from "@/lib/ui/variants";
|
||||
import type { DecisionDraft, Verdict } from "@/lib/review/decisions";
|
||||
import type { ReviewConflict } from "@/lib/review/sse";
|
||||
|
||||
interface ConflictCardProps {
|
||||
index: number;
|
||||
total: number;
|
||||
conflict: ReviewConflict;
|
||||
draft: DecisionDraft;
|
||||
missing: boolean;
|
||||
@@ -28,15 +34,16 @@ const VERDICT_OPTIONS: { value: Verdict; label: string; primary: boolean }[] = [
|
||||
|
||||
// 裁决按钮样式:激活=朱砂填充;未激活的主按钮=朱砂描边强调,次级=低对比描边。
|
||||
function verdictClass(active: boolean, primary: boolean): string {
|
||||
if (active) return "bg-cinnabar text-panel";
|
||||
if (primary) return "border border-cinnabar text-cinnabar hover:bg-cinnabar/10";
|
||||
return "border border-line text-ink-soft hover:border-cinnabar hover:text-ink";
|
||||
if (active) return buttonClass({ variant: "primary", size: "sm" });
|
||||
if (primary) return buttonClass({ variant: "outline", size: "sm" });
|
||||
return buttonClass({ variant: "secondary", size: "sm" });
|
||||
}
|
||||
|
||||
// 单个冲突报告卡(UX §6.4):五类徽标 + where + refs + suggestion + 裁决三态。
|
||||
// 冲突=赭红;未决/缺判时加图标+文案(不单靠色,a11y §10)。
|
||||
export function ConflictCard({
|
||||
index,
|
||||
total,
|
||||
conflict,
|
||||
draft,
|
||||
missing,
|
||||
@@ -60,13 +67,14 @@ export function ConflictCard({
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<span
|
||||
className="shrink-0 rounded bg-[var(--color-conflict)]/10 px-2 py-0.5 text-xs text-conflict"
|
||||
aria-hidden="true"
|
||||
>
|
||||
⚠ {conflict.type}
|
||||
</span>
|
||||
<p className="flex-1 text-sm text-ink">{conflict.suggestion}</p>
|
||||
<Badge variant="danger" className="shrink-0">
|
||||
<AlertTriangle className="h-3 w-3" aria-hidden="true" />
|
||||
冲突 {index + 1}/{total}
|
||||
</Badge>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs text-conflict">{conflict.type}</p>
|
||||
<p className="mt-1 text-sm text-ink">{conflict.suggestion}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{conflict.original && conflict.replacement ? (
|
||||
@@ -95,9 +103,10 @@ export function ConflictCard({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onJump(index)}
|
||||
className="rounded border border-line px-2 py-0.5 hover:border-cinnabar hover:text-cinnabar"
|
||||
className={buttonClass({ variant: "secondary", size: "sm" })}
|
||||
>
|
||||
▸ 跳转 {conflict.where}
|
||||
<LocateFixed className="h-4 w-4" aria-hidden="true" />
|
||||
跳转 {conflict.where}
|
||||
</button>
|
||||
) : null}
|
||||
{conflict.refs.map((ref) => (
|
||||
@@ -121,23 +130,24 @@ export function ConflictCard({
|
||||
aria-pressed={active}
|
||||
disabled={rewriting}
|
||||
onClick={() => onVerdict(index, opt.value)}
|
||||
className={`rounded px-3 py-1 text-xs disabled:cursor-not-allowed disabled:opacity-40 ${verdictClass(
|
||||
active,
|
||||
opt.primary,
|
||||
)}`}
|
||||
className={verdictClass(active, opt.primary)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{rewriting ? (
|
||||
<span className="text-xs text-info">✦ AI 改写中…</span>
|
||||
<Badge variant="info">
|
||||
<Sparkles className="h-3 w-3" aria-hidden="true" />
|
||||
AI 改写中…
|
||||
</Badge>
|
||||
) : resolved ? (
|
||||
<span className="text-xs text-pass" aria-hidden="true">
|
||||
✓ 已裁决
|
||||
</span>
|
||||
<Badge variant="success">
|
||||
<CheckCircle2 className="h-3 w-3" aria-hidden="true" />
|
||||
已裁决
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-xs text-conflict">未裁决</span>
|
||||
<Badge variant="danger">未裁决</Badge>
|
||||
)}
|
||||
</div>
|
||||
{draft.verdict === "manual" ? (
|
||||
@@ -145,14 +155,16 @@ export function ConflictCard({
|
||||
<label htmlFor={`note-${index}`} className="sr-only">
|
||||
手改说明
|
||||
</label>
|
||||
<input
|
||||
<TextInput
|
||||
id={`note-${index}`}
|
||||
type="text"
|
||||
value={draft.note}
|
||||
onChange={(e) => onNote(index, e.target.value)}
|
||||
placeholder="手改说明(可选)"
|
||||
className="w-full rounded border border-line bg-bg px-3 py-1.5 text-sm text-ink focus:border-cinnabar focus:outline-none"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-ink-soft">
|
||||
选择手改后,说明会随验收裁决一起保存,正文请在左侧直接修改。
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { CheckCircle2, CircleDashed, Flag, Plus } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { suggestForeshadowCode } from "@/lib/foreshadow/board";
|
||||
import { api } from "@/lib/api/client";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Field } from "@/components/ui/Field";
|
||||
import { TextArea } from "@/components/ui/TextArea";
|
||||
import { TextInput } from "@/components/ui/TextInput";
|
||||
import {
|
||||
nextFreeForeshadowCode,
|
||||
suggestForeshadowCode,
|
||||
} from "@/lib/foreshadow/board";
|
||||
import { useForeshadow } from "@/lib/foreshadow/useForeshadow";
|
||||
import type { ForeshadowSuggestion } from "@/lib/review/sse";
|
||||
|
||||
@@ -36,11 +46,54 @@ export function ForeshadowSuggestions({
|
||||
const [registered, setRegistered] = useState<Set<number>>(new Set());
|
||||
const [resolved, setResolved] = useState<Set<number>>(new Set());
|
||||
const [pending, setPending] = useState<Set<number>>(new Set());
|
||||
// 项目已存在的伏笔(用于隐藏「之前会话就已保存过」的建议):
|
||||
// savedTitles = 已登记伏笔的标题;savedClosedCodes = 已 CLOSED(已回收)的 code。
|
||||
const [savedTitles, setSavedTitles] = useState<Set<string>>(new Set());
|
||||
const [savedClosedCodes, setSavedClosedCodes] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
// 看板已占用的全部代号——预填登记代号时跳过它们,避免撞码 422。
|
||||
const [savedCodes, setSavedCodes] = useState<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
void (async () => {
|
||||
const { data } = await api.GET("/projects/{project_id}/foreshadow", {
|
||||
params: { path: { project_id: projectId } },
|
||||
});
|
||||
if (!active || !data?.foreshadow) return;
|
||||
setSavedTitles(new Set(data.foreshadow.map((f) => f.title.trim())));
|
||||
setSavedClosedCodes(
|
||||
new Set(
|
||||
data.foreshadow
|
||||
.filter((f) => f.status === "CLOSED")
|
||||
.map((f) => f.code),
|
||||
),
|
||||
);
|
||||
setSavedCodes(
|
||||
new Set(data.foreshadow.map((f) => f.code).filter((c) => c)),
|
||||
);
|
||||
})();
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [projectId]);
|
||||
|
||||
// 该建议是否「已保存过」→ 从列表隐藏:
|
||||
// - 本次会话已登记/回收(registered/resolved)
|
||||
// - 新埋建议:标题已存在于看板(之前已登记)
|
||||
// - 疑似回收建议:对应 code 已 CLOSED(之前已回收)
|
||||
const isAlreadySaved = (s: ForeshadowSuggestion, i: number): boolean => {
|
||||
if (s.kind === "resolved") {
|
||||
return resolved.has(i) || (s.code ? savedClosedCodes.has(s.code) : false);
|
||||
}
|
||||
return registered.has(i) || savedTitles.has(s.title.trim());
|
||||
};
|
||||
|
||||
const openRegister = (i: number, s: ForeshadowSuggestion): void => {
|
||||
setOpenIdx(i);
|
||||
setForm({
|
||||
code: suggestForeshadowCode(s.code, i),
|
||||
code: nextFreeForeshadowCode(suggestForeshadowCode(s.code, i), savedCodes),
|
||||
title: s.title,
|
||||
plantedAt: chapterNo,
|
||||
content: s.note ?? s.where ?? "",
|
||||
@@ -73,6 +126,9 @@ export function ForeshadowSuggestions({
|
||||
markPending(i, false);
|
||||
if (ok) {
|
||||
setRegistered((prev) => new Set(prev).add(i));
|
||||
// 并入已存集合:连续登记时下一条不再取到同一空闲码,且该标题立即隐藏。
|
||||
setSavedCodes((prev) => new Set(prev).add(form.code));
|
||||
setSavedTitles((prev) => new Set(prev).add(form.title.trim()));
|
||||
closeForm();
|
||||
}
|
||||
};
|
||||
@@ -84,36 +140,42 @@ export function ForeshadowSuggestions({
|
||||
if (ok) setResolved((prev) => new Set(prev).add(i));
|
||||
};
|
||||
|
||||
// 保留原始下标 i(registered/resolved/pending/openIdx 均按 i 索引),仅过滤已保存项。
|
||||
const visible = suggestions
|
||||
.map((s, i) => ({ s, i }))
|
||||
.filter(({ s, i }) => !isAlreadySaved(s, i));
|
||||
|
||||
return (
|
||||
<section className="mb-4">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wide text-ink-soft">
|
||||
伏笔 (foreshadow-analyst)
|
||||
</h2>
|
||||
{incomplete ? (
|
||||
<p className="mt-1 text-xs text-overdue">◌ 未完成</p>
|
||||
) : suggestions.length === 0 ? (
|
||||
<Badge variant="warning" className="mt-1">
|
||||
<CircleDashed className="h-3 w-3" aria-hidden="true" />
|
||||
未完成
|
||||
</Badge>
|
||||
) : visible.length === 0 ? (
|
||||
<p className="mt-1 text-xs text-ink-soft">无伏笔建议。</p>
|
||||
) : (
|
||||
<ul className="mt-2 space-y-2">
|
||||
{suggestions.map((s, i) => {
|
||||
{visible.map(({ s, i }) => {
|
||||
const code = s.code;
|
||||
const isResolved = s.kind === "resolved";
|
||||
const done = isResolved ? resolved.has(i) : registered.has(i);
|
||||
const busy = pending.has(i);
|
||||
return (
|
||||
<li
|
||||
key={i}
|
||||
className="rounded border border-line bg-panel p-2 text-xs"
|
||||
>
|
||||
<span
|
||||
className={`mr-1 rounded px-1.5 py-0.5 ${
|
||||
isResolved
|
||||
? "bg-pass/15 text-pass"
|
||||
: "bg-[var(--color-cinnabar-wash)] text-cinnabar"
|
||||
}`}
|
||||
>
|
||||
{isResolved ? "⚑ 疑似回收" : "+ 新埋待确认"}
|
||||
</span>
|
||||
<Badge variant={isResolved ? "success" : "accent"}>
|
||||
{isResolved ? (
|
||||
<Flag className="h-3 w-3" aria-hidden="true" />
|
||||
) : (
|
||||
<Plus className="h-3 w-3" aria-hidden="true" />
|
||||
)}
|
||||
{isResolved ? "疑似回收" : "新埋待确认"}
|
||||
</Badge>
|
||||
{code ? (
|
||||
<span className="font-mono text-ink-soft"> {code}</span>
|
||||
) : null}
|
||||
@@ -123,21 +185,19 @@ export function ForeshadowSuggestions({
|
||||
) : null}
|
||||
{s.note ? <p className="mt-0.5 text-ink-soft">{s.note}</p> : null}
|
||||
|
||||
{/* 作者确认动作区 */}
|
||||
{done ? (
|
||||
<p className="mt-1.5 text-pass">
|
||||
✓ {isResolved ? "已标记回收" : "已登记"}
|
||||
</p>
|
||||
) : isResolved ? (
|
||||
{/* 作者确认动作区(已保存的建议已从列表过滤,故无需「已登记」态) */}
|
||||
{isResolved ? (
|
||||
code ? (
|
||||
<button
|
||||
type="button"
|
||||
<Button
|
||||
disabled={busy}
|
||||
onClick={() => void markResolved(i, code)}
|
||||
className="mt-1.5 rounded border border-pass px-2 py-0.5 text-pass hover:bg-pass/10 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
className="mt-1.5"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
>
|
||||
{busy ? "处理中…" : "✓ 标记回收"}
|
||||
</button>
|
||||
<CheckCircle2 className="h-4 w-4" aria-hidden="true" />
|
||||
{busy ? "处理中…" : "标记回收"}
|
||||
</Button>
|
||||
) : (
|
||||
<p className="mt-1.5 text-ink-soft">
|
||||
未关联编码,去伏笔看板处理。
|
||||
@@ -152,13 +212,15 @@ export function ForeshadowSuggestions({
|
||||
onCancel={closeForm}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
<Button
|
||||
onClick={() => openRegister(i, s)}
|
||||
className="mt-1.5 rounded border border-cinnabar px-2 py-0.5 text-cinnabar hover:bg-cinnabar/10"
|
||||
className="mt-1.5"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
+ 登记此伏笔
|
||||
</button>
|
||||
<Plus className="h-4 w-4" aria-hidden="true" />
|
||||
登记此伏笔
|
||||
</Button>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
@@ -187,39 +249,28 @@ function RegisterFormInline({
|
||||
}: RegisterFormInlineProps) {
|
||||
const canSubmit =
|
||||
!busy && form.code.trim().length > 0 && form.title.trim().length > 0;
|
||||
const field =
|
||||
"w-full rounded border border-line bg-bg px-2 py-1 text-xs text-ink focus:border-cinnabar focus:outline-none";
|
||||
return (
|
||||
<div className="mt-2 space-y-1.5 border-t border-line pt-2">
|
||||
<div>
|
||||
<label htmlFor="fs-reg-code" className="text-ink-soft">
|
||||
编码
|
||||
</label>
|
||||
<input
|
||||
<Field label="编码" htmlFor="fs-reg-code">
|
||||
<TextInput
|
||||
id="fs-reg-code"
|
||||
type="text"
|
||||
value={form.code}
|
||||
onChange={(e) => onChange({ ...form, code: e.target.value })}
|
||||
className={field}
|
||||
controlSize="sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="fs-reg-title" className="text-ink-soft">
|
||||
标题
|
||||
</label>
|
||||
<input
|
||||
</Field>
|
||||
<Field label="标题" htmlFor="fs-reg-title">
|
||||
<TextInput
|
||||
id="fs-reg-title"
|
||||
type="text"
|
||||
value={form.title}
|
||||
onChange={(e) => onChange({ ...form, title: e.target.value })}
|
||||
className={field}
|
||||
controlSize="sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="fs-reg-planted" className="text-ink-soft">
|
||||
埋设章号
|
||||
</label>
|
||||
<input
|
||||
</Field>
|
||||
<Field label="埋设章号" htmlFor="fs-reg-planted">
|
||||
<TextInput
|
||||
id="fs-reg-planted"
|
||||
type="number"
|
||||
min={1}
|
||||
@@ -227,38 +278,36 @@ function RegisterFormInline({
|
||||
onChange={(e) =>
|
||||
onChange({ ...form, plantedAt: Number(e.target.value) || 1 })
|
||||
}
|
||||
className={field}
|
||||
controlSize="sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="fs-reg-content" className="text-ink-soft">
|
||||
线索/正文(可选)
|
||||
</label>
|
||||
<textarea
|
||||
</Field>
|
||||
<Field label="线索/正文(可选)" htmlFor="fs-reg-content">
|
||||
<TextArea
|
||||
id="fs-reg-content"
|
||||
value={form.content}
|
||||
onChange={(e) => onChange({ ...form, content: e.target.value })}
|
||||
rows={2}
|
||||
className={`${field} resize-y`}
|
||||
controlSize="sm"
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
<div className="flex gap-2 pt-0.5">
|
||||
<button
|
||||
type="button"
|
||||
<Button
|
||||
disabled={!canSubmit}
|
||||
onClick={onSubmit}
|
||||
className="rounded bg-cinnabar px-2 py-0.5 text-panel hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
>
|
||||
<CheckCircle2 className="h-4 w-4" aria-hidden="true" />
|
||||
{busy ? "登记中…" : "确认登记"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
</Button>
|
||||
<Button
|
||||
disabled={busy}
|
||||
onClick={onCancel}
|
||||
className="rounded border border-line px-2 py-0.5 text-ink-soft hover:border-cinnabar hover:text-ink disabled:opacity-40"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { AlertTriangle, CheckCircle2, CircleDashed } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import type { PaceReport } from "@/lib/review/sse";
|
||||
import { BeatMap } from "./BeatMap";
|
||||
|
||||
@@ -16,7 +19,10 @@ export function PacePanel({ pace, incomplete }: PacePanelProps) {
|
||||
节奏 (pace-checker)
|
||||
</h2>
|
||||
{incomplete ? (
|
||||
<p className="mt-1 text-xs text-overdue">◌ 未完成</p>
|
||||
<Badge variant="warning" className="mt-1">
|
||||
<CircleDashed className="h-3 w-3" aria-hidden="true" />
|
||||
未完成
|
||||
</Badge>
|
||||
) : pace === null ? (
|
||||
<p className="mt-1 text-xs text-ink-soft">无节奏报告。</p>
|
||||
) : (
|
||||
@@ -29,15 +35,24 @@ export function PacePanel({ pace, incomplete }: PacePanelProps) {
|
||||
<p>
|
||||
章末钩子:
|
||||
{pace.hook ? (
|
||||
<span className="text-pass">✓ 有</span>
|
||||
<Badge variant="success">
|
||||
<CheckCircle2 className="h-3 w-3" aria-hidden="true" />
|
||||
有
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-overdue">⚠ 无(建议补悬念)</span>
|
||||
<Badge variant="warning">
|
||||
<AlertTriangle className="h-3 w-3" aria-hidden="true" />
|
||||
无(建议补悬念)
|
||||
</Badge>
|
||||
)}
|
||||
</p>
|
||||
|
||||
{pace.water.length > 0 ? (
|
||||
<div>
|
||||
<p className="text-overdue">⚠ 注水段</p>
|
||||
<Badge variant="warning">
|
||||
<AlertTriangle className="h-3 w-3" aria-hidden="true" />
|
||||
注水段
|
||||
</Badge>
|
||||
<ul className="mt-1 space-y-1">
|
||||
{pace.water.map((w, i) => (
|
||||
<li key={i} className="text-ink-soft">
|
||||
@@ -47,7 +62,10 @@ export function PacePanel({ pace, incomplete }: PacePanelProps) {
|
||||
</ul>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-pass">✓ 无明显注水</p>
|
||||
<Badge variant="success">
|
||||
<CheckCircle2 className="h-3 w-3" aria-hidden="true" />
|
||||
无明显注水
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -2,8 +2,16 @@
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
RotateCcw,
|
||||
Square,
|
||||
} from "lucide-react";
|
||||
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import type { ProjectResponse, ReviewHistoryItem } from "@/lib/api/types";
|
||||
import { friendlyError } from "@/lib/errors/messages";
|
||||
import {
|
||||
@@ -44,6 +52,8 @@ import { AnnotatedText } from "./AnnotatedText";
|
||||
import { ConflictCard } from "./ConflictCard";
|
||||
import { ForeshadowSuggestions } from "./ForeshadowSuggestions";
|
||||
import { PacePanel } from "./PacePanel";
|
||||
import { ReviewSectionPanel } from "./ReviewSectionPanel";
|
||||
import { ReviewSummaryRail } from "./ReviewSummaryRail";
|
||||
import { StylePanel } from "@/components/style/StylePanel";
|
||||
import { RefineView } from "@/components/style/RefineView";
|
||||
|
||||
@@ -370,6 +380,13 @@ export function ReviewReport({
|
||||
const resolved = allResolved(drafts);
|
||||
const unresolved = unresolvedIndices(drafts).length;
|
||||
const reviewing = review.isReviewing;
|
||||
const hasReport =
|
||||
review.state.phase === "done" ||
|
||||
review.state.sections.length > 0 ||
|
||||
seededConflicts.length > 0 ||
|
||||
foreshadow.length > 0 ||
|
||||
pace !== null ||
|
||||
style !== null;
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
@@ -378,33 +395,27 @@ export function ReviewReport({
|
||||
projectId={project.id}
|
||||
activeNav="review"
|
||||
>
|
||||
<div className="grid h-[calc(100vh-var(--chrome,4rem))] grid-cols-1 lg:grid-cols-[1fr_24rem]">
|
||||
<div className="flex min-h-[calc(100vh-var(--chrome,4rem))] flex-col lg:grid lg:h-[calc(100vh-var(--chrome,4rem))] lg:min-h-0 lg:grid-cols-[minmax(0,1fr)_24rem]">
|
||||
{/* 左:终稿正文 + 就地标注 */}
|
||||
<section className="flex min-w-0 flex-col bg-bg">
|
||||
<div className="flex items-center gap-3 border-b border-line bg-panel px-6 py-3">
|
||||
<h1 className="font-serif text-lg text-ink">
|
||||
<section className="flex min-h-[70vh] min-w-0 flex-col bg-bg lg:min-h-0">
|
||||
<div className="flex items-center gap-3 border-b border-line bg-panel px-4 py-3 sm:px-6">
|
||||
<h1 className="min-w-0 flex-1 truncate font-serif text-lg text-ink">
|
||||
第 {chapterNo} 章 审稿报告
|
||||
</h1>
|
||||
<span className="font-mono text-xs text-ink-soft">
|
||||
<span className="hidden shrink-0 font-mono text-xs text-ink-soft sm:inline">
|
||||
{conflictCount} 冲突
|
||||
</span>
|
||||
<div className="ml-auto">
|
||||
<div className="shrink-0">
|
||||
{reviewing ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={review.stop}
|
||||
className="rounded border border-conflict px-3 py-1.5 text-sm text-conflict"
|
||||
>
|
||||
<Button onClick={review.stop} variant="danger" size="sm">
|
||||
<Square className="h-4 w-4" aria-hidden="true" />
|
||||
停
|
||||
</button>
|
||||
</Button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onReReview}
|
||||
className="rounded bg-cinnabar px-3 py-1.5 text-sm text-panel hover:opacity-90"
|
||||
>
|
||||
✦ 重新审稿
|
||||
</button>
|
||||
<Button onClick={onReReview} variant="primary" size="sm">
|
||||
<RotateCcw className="h-4 w-4" aria-hidden="true" />
|
||||
重新审稿
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -413,7 +424,7 @@ export function ReviewReport({
|
||||
<ReviewErrorNote error={review.state.error} />
|
||||
) : null}
|
||||
|
||||
<div className="flex-1 overflow-auto px-6 py-6">
|
||||
<div className="flex-1 overflow-auto px-4 py-4 sm:px-6 sm:py-6">
|
||||
<div className="mb-2 flex items-start justify-between gap-3">
|
||||
<p className="text-xs text-ink-soft">
|
||||
可直接在下方正文修改(改动自动保存草稿 — 摘要从终稿提炼);点冲突的「跳转」或顶部锚点定位到对应文字。
|
||||
@@ -438,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>
|
||||
);
|
||||
|
||||
54
apps/web/components/review/ReviewSectionPanel.tsx
Normal file
54
apps/web/components/review/ReviewSectionPanel.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { cn, type BadgeVariant } from "@/lib/ui/variants";
|
||||
|
||||
interface ReviewSectionPanelProps {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
statusLabel: string;
|
||||
statusVariant: BadgeVariant;
|
||||
children: ReactNode;
|
||||
defaultOpen?: boolean;
|
||||
}
|
||||
|
||||
export function ReviewSectionPanel({
|
||||
title,
|
||||
subtitle,
|
||||
statusLabel,
|
||||
statusVariant,
|
||||
children,
|
||||
defaultOpen = false,
|
||||
}: ReviewSectionPanelProps) {
|
||||
return (
|
||||
<details
|
||||
className="group rounded border border-line bg-bg/45"
|
||||
open={defaultOpen}
|
||||
>
|
||||
<summary className="flex cursor-pointer list-none items-center justify-between gap-3 px-3 py-2">
|
||||
<span className="min-w-0">
|
||||
<span className="block font-serif text-sm text-ink">{title}</span>
|
||||
{subtitle ? (
|
||||
<span className="block truncate text-xs text-ink-soft">
|
||||
{subtitle}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="flex shrink-0 items-center gap-2">
|
||||
<Badge variant={statusVariant}>{statusLabel}</Badge>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"h-4 w-4 text-ink-soft transition-transform",
|
||||
"group-open:rotate-180",
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
</summary>
|
||||
<div className="border-t border-line px-3 py-3">{children}</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
65
apps/web/components/review/ReviewSummaryRail.tsx
Normal file
65
apps/web/components/review/ReviewSummaryRail.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { AlertTriangle, CheckCircle2, CircleDashed } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
|
||||
interface ReviewSummaryRailProps {
|
||||
conflictCount: number;
|
||||
unresolvedCount: number;
|
||||
reviewing: boolean;
|
||||
hasReport: boolean;
|
||||
onNextUnresolved: () => void;
|
||||
}
|
||||
|
||||
export function ReviewSummaryRail({
|
||||
conflictCount,
|
||||
unresolvedCount,
|
||||
reviewing,
|
||||
hasReport,
|
||||
onNextUnresolved,
|
||||
}: ReviewSummaryRailProps) {
|
||||
return (
|
||||
<section className="sticky top-0 z-10 -mx-4 mb-4 border-b border-line bg-panel px-4 pb-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="font-serif text-base text-ink">审稿摘要</h2>
|
||||
<p className="mt-1 text-xs text-ink-soft">
|
||||
{reviewing
|
||||
? "四审正在更新"
|
||||
: hasReport
|
||||
? "先处理未裁决冲突,再验收本章"
|
||||
: "暂无审稿留痕,可先重新审稿"}
|
||||
</p>
|
||||
</div>
|
||||
{reviewing ? (
|
||||
<Badge variant="info">
|
||||
<CircleDashed className="h-3 w-3" aria-hidden="true" />
|
||||
审稿中
|
||||
</Badge>
|
||||
) : unresolvedCount > 0 ? (
|
||||
<Badge variant="danger">
|
||||
<AlertTriangle className="h-3 w-3" aria-hidden="true" />
|
||||
{unresolvedCount}/{conflictCount} 待裁决
|
||||
</Badge>
|
||||
) : conflictCount > 0 ? (
|
||||
<Badge variant="success">
|
||||
<CheckCircle2 className="h-3 w-3" aria-hidden="true" />
|
||||
已裁决
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant={hasReport ? "success" : "neutral"}>
|
||||
{hasReport ? "无冲突" : "未审稿"}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
onClick={onNextUnresolved}
|
||||
disabled={unresolvedCount === 0}
|
||||
variant="secondary"
|
||||
className="mt-3 w-full"
|
||||
>
|
||||
跳到下一条未裁决
|
||||
</Button>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { EmptyState } from "@/components/ui/EmptyState";
|
||||
import { Field } from "@/components/ui/Field";
|
||||
import { PageHeader } from "@/components/ui/PageHeader";
|
||||
import { Select } from "@/components/ui/Select";
|
||||
import { TextArea } from "@/components/ui/TextArea";
|
||||
import type { ProjectResponse, RuleView } from "@/lib/api/types";
|
||||
import {
|
||||
RULE_LEVELS,
|
||||
@@ -38,41 +45,48 @@ export function RulesPage({ project, initialRules }: RulesPageProps) {
|
||||
activeNav="rules"
|
||||
>
|
||||
<div className="mx-auto flex max-w-3xl flex-col gap-6 p-6">
|
||||
<PageHeader
|
||||
title="规则"
|
||||
description="维护本作、题材、世界观和章节级硬约束,写作与审稿会共同引用这些规则。"
|
||||
/>
|
||||
<form
|
||||
onSubmit={onSubmit}
|
||||
className="rounded border border-line bg-panel p-4"
|
||||
>
|
||||
<h1 className="mb-3 font-serif text-lg text-ink">新增规则</h1>
|
||||
<h2 className="mb-3 font-serif text-lg text-ink">新增规则</h2>
|
||||
<div className="mb-3 flex items-end gap-3">
|
||||
<label className="text-sm text-ink-soft">
|
||||
级别
|
||||
<select
|
||||
<Field label="级别" htmlFor="rule-level">
|
||||
<Select
|
||||
id="rule-level"
|
||||
value={level}
|
||||
onChange={(e) => setLevel(e.target.value as RuleLevel)}
|
||||
className="mt-1 block rounded border border-line bg-bg px-2 py-1.5 text-sm text-ink"
|
||||
>
|
||||
{RULE_LEVELS.map((lv) => (
|
||||
<option key={lv} value={lv}>
|
||||
{RULE_LEVEL_LABELS[lv]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</Select>
|
||||
</Field>
|
||||
</div>
|
||||
<textarea
|
||||
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>
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { ExternalLink, Link2Off, PlugZap } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { SectionHeader } from "@/components/ui/SectionHeader";
|
||||
import { StatusNote } from "@/components/ui/StatusNote";
|
||||
import { authOpenUrl, formatExpiresAt } from "@/lib/settings/kimiOauth";
|
||||
import { useKimiOauth } from "@/lib/settings/useKimiOauth";
|
||||
|
||||
@@ -19,17 +24,22 @@ export function KimiCodeOauth({
|
||||
const expiresLabel = formatExpiresAt(expiresAt);
|
||||
|
||||
return (
|
||||
<section className="mb-10">
|
||||
<h2 className="mb-1 font-serif text-xl text-ink">Kimi Code(OAuth)</h2>
|
||||
<p className="mb-3 text-sm text-ink-soft">
|
||||
订阅 plan 经 OAuth 设备授权连接(无需 API Key)。连接后可在上方档位路由里选用
|
||||
<span className="font-mono"> kimi-code · kimi-for-coding</span>。
|
||||
</p>
|
||||
<section>
|
||||
<SectionHeader
|
||||
title="Kimi Code(OAuth)"
|
||||
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 Code(OAuth)"
|
||||
: "连接 Kimi Code(OAuth)"}
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -77,10 +89,16 @@ export function KimiCodeOauth({
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{phase !== "awaiting" ? (
|
||||
<StatusNote className="mt-3" variant="info">
|
||||
OAuth 连接只保存加密后的访问凭据;模型选择仍在“档位路由”里统一配置。
|
||||
</StatusNote>
|
||||
) : null}
|
||||
|
||||
{phase === "error" && error ? (
|
||||
<p className="mt-3 text-sm text-conflict" role="alert">
|
||||
<StatusNote className="mt-3" variant="danger" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
</StatusNote>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
@@ -102,23 +120,22 @@ function DeviceInstructions({ userCode, openUrl }: DeviceInstructionsProps) {
|
||||
|
||||
return (
|
||||
<div className="motion-safe:transition-opacity mt-4 rounded border border-dashed border-line bg-bg p-4">
|
||||
<p className="mb-2 text-sm text-ink-soft">
|
||||
请在浏览器中打开授权页,并确认页面显示的设备码与下方一致:
|
||||
</p>
|
||||
<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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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("、") : "—";
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useRefine } from "@/lib/style/useRefine";
|
||||
import { CheckCircle2, X } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { ThinkingIndicator } from "@/components/ThinkingIndicator";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { useRefine } from "@/lib/style/useRefine";
|
||||
|
||||
interface RefineViewProps {
|
||||
projectId: string;
|
||||
@@ -46,15 +49,17 @@ export function RefineView({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="text-ink-soft hover:text-cinnabar"
|
||||
className="rounded border border-transparent p-1 text-ink-soft hover:border-line hover:text-cinnabar"
|
||||
aria-label="关闭回炉对比"
|
||||
>
|
||||
✕
|
||||
<X className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{segment.trim().length === 0 ? (
|
||||
<p className="text-overdue">该段在终稿中为空,请先在终稿中保留该段正文。</p>
|
||||
<Badge variant="warning">
|
||||
该段在终稿中为空,请先在终稿中保留该段正文。
|
||||
</Badge>
|
||||
) : refiner.status === "refining" ? (
|
||||
<p className="text-info">
|
||||
<ThinkingIndicator label="回炉中" />
|
||||
@@ -76,24 +81,21 @@ export function RefineView({
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (refiner.result) {
|
||||
onAdopt(refiner.result.original, refiner.result.refined);
|
||||
}
|
||||
}}
|
||||
className="rounded bg-cinnabar px-3 py-1.5 text-panel hover:opacity-90"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
>
|
||||
<CheckCircle2 className="h-4 w-4" aria-hidden="true" />
|
||||
采纳合入正文
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded border border-line px-3 py-1.5 text-ink hover:border-cinnabar"
|
||||
>
|
||||
</Button>
|
||||
<Button onClick={onClose} variant="secondary" size="sm">
|
||||
放弃
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { CheckCircle2, CircleDashed, RotateCcw } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import type { StyleDriftReport, StyleDriftSegment } from "@/lib/review/sse";
|
||||
|
||||
interface StylePanelProps {
|
||||
@@ -26,7 +30,10 @@ export function StylePanel({ style, incomplete, onRefine }: StylePanelProps) {
|
||||
文风 (style-auditor)
|
||||
</h2>
|
||||
{incomplete ? (
|
||||
<p className="mt-1 text-xs text-overdue">◌ 未完成</p>
|
||||
<Badge variant="warning" className="mt-1">
|
||||
<CircleDashed className="h-3 w-3" aria-hidden="true" />
|
||||
未完成
|
||||
</Badge>
|
||||
) : style === null ? (
|
||||
<p className="mt-1 text-xs text-ink-soft">
|
||||
无文风报告(学文风后第四审才能打分)。
|
||||
@@ -47,7 +54,10 @@ export function StylePanel({ style, incomplete, onRefine }: StylePanelProps) {
|
||||
</p>
|
||||
|
||||
{style.segments.length === 0 ? (
|
||||
<p className="text-pass">✓ 无明显文风漂移</p>
|
||||
<Badge variant="success">
|
||||
<CheckCircle2 className="h-3 w-3" aria-hidden="true" />
|
||||
无明显文风漂移
|
||||
</Badge>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{style.segments.map((seg, i) => (
|
||||
@@ -57,9 +67,9 @@ export function StylePanel({ style, incomplete, onRefine }: StylePanelProps) {
|
||||
data-testid="drift-segment"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-overdue">
|
||||
<Badge variant="warning" className="font-mono">
|
||||
第 {seg.idx} 段
|
||||
</span>
|
||||
</Badge>
|
||||
<span className="font-mono text-ink-soft">
|
||||
相似 {seg.score}%
|
||||
</span>
|
||||
@@ -68,13 +78,14 @@ export function StylePanel({ style, incomplete, onRefine }: StylePanelProps) {
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-1.5 flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
<Button
|
||||
onClick={() => onRefine(seg)}
|
||||
className="rounded bg-cinnabar px-2 py-1 text-panel hover:opacity-90"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" aria-hidden="true" />
|
||||
一键回炉
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
|
||||
@@ -4,6 +4,10 @@ import { useCallback, useState, type ChangeEvent } from "react";
|
||||
|
||||
import { hasUsableSamples, type StyleLearnMode } from "@/lib/style/style";
|
||||
import { useToast } from "@/components/Toast";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Field } from "@/components/ui/Field";
|
||||
import { StatusNote } from "@/components/ui/StatusNote";
|
||||
import { TextArea } from "@/components/ui/TextArea";
|
||||
|
||||
interface StyleUploadProps {
|
||||
busy: boolean;
|
||||
@@ -61,21 +65,15 @@ export function StyleUpload({
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="style-samples"
|
||||
className="mb-1 block text-sm font-semibold text-ink"
|
||||
>
|
||||
样本正文(空行分段;可粘贴多段)
|
||||
</label>
|
||||
<textarea
|
||||
<Field label="样本正文(空行分段;可粘贴多段)" htmlFor="style-samples">
|
||||
<TextArea
|
||||
id="style-samples"
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
placeholder="粘贴你想学习文风的章节正文…"
|
||||
className="min-h-[30vh] w-full resize-y rounded border border-line bg-panel p-3 font-serif text-[15px] leading-[1.9] text-ink focus:border-cinnabar focus:outline-none"
|
||||
className="min-h-[30vh] bg-panel font-serif text-[15px] leading-[1.9]"
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="cursor-pointer rounded border border-line px-3 py-1.5 text-sm text-ink hover:border-cinnabar hover:text-cinnabar">
|
||||
@@ -88,22 +86,17 @@ export function StyleUpload({
|
||||
className="sr-only"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={submit}
|
||||
disabled={busy}
|
||||
className="rounded bg-cinnabar px-4 py-1.5 text-sm text-panel hover:opacity-90 disabled:opacity-50"
|
||||
>
|
||||
<Button type="button" onClick={submit} disabled={busy} variant="primary">
|
||||
{hasFingerprint ? "重新学习文风" : "学习文风"}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{pollStatus === "polling" ? (
|
||||
<p className="text-xs text-info" aria-live="polite">
|
||||
<StatusNote variant="info" aria-live="polite">
|
||||
提取文风指纹中…({progress}%)
|
||||
</p>
|
||||
</StatusNote>
|
||||
) : pollStatus === "error" ? (
|
||||
<p className="text-xs text-conflict">学文风失败,请稍后重试。</p>
|
||||
<StatusNote variant="danger">学文风失败,请稍后重试。</StatusNote>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,11 @@
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
import { useToast } from "@/components/Toast";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Field } from "@/components/ui/Field";
|
||||
import { SectionHeader } from "@/components/ui/SectionHeader";
|
||||
import { TextArea } from "@/components/ui/TextArea";
|
||||
import { TextInput } from "@/components/ui/TextInput";
|
||||
import { api } from "@/lib/api/client";
|
||||
import type { TemplateResponse } from "@/lib/api/types";
|
||||
import {
|
||||
@@ -89,56 +94,49 @@ export function TemplatesManager({ initial }: TemplatesManagerProps) {
|
||||
void onCreate();
|
||||
}}
|
||||
>
|
||||
<h2 className="font-serif text-lg text-ink">新建模板</h2>
|
||||
<label className="block text-sm text-ink-soft">
|
||||
标题 *
|
||||
<input
|
||||
<SectionHeader title="新建模板" />
|
||||
<Field label="标题" required>
|
||||
<TextInput
|
||||
type="text"
|
||||
value={draft.title}
|
||||
onChange={(e) => setField("title", e.target.value)}
|
||||
className="mt-1 w-full rounded border border-line bg-bg px-3 py-2 text-sm text-ink"
|
||||
aria-label="标题"
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-sm text-ink-soft">
|
||||
正文 *(一键填入生成器的 brief/原文)
|
||||
<textarea
|
||||
</Field>
|
||||
<Field label="正文" required help="一键填入生成器的 brief/原文。">
|
||||
<TextArea
|
||||
value={draft.body}
|
||||
onChange={(e) => setField("body", e.target.value)}
|
||||
rows={4}
|
||||
className="mt-1 w-full resize-y rounded border border-line bg-bg px-3 py-2 text-sm text-ink"
|
||||
aria-label="正文"
|
||||
/>
|
||||
</label>
|
||||
</Field>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<label className="block text-sm text-ink-soft">
|
||||
分类(可选)
|
||||
<input
|
||||
<Field label="分类(可选)" className="w-full sm:w-48">
|
||||
<TextInput
|
||||
type="text"
|
||||
value={draft.category}
|
||||
onChange={(e) => setField("category", e.target.value)}
|
||||
className="mt-1 w-48 rounded border border-line bg-bg px-3 py-2 text-sm text-ink"
|
||||
aria-label="分类"
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-sm text-ink-soft">
|
||||
关联生成器 key(可选)
|
||||
<input
|
||||
</Field>
|
||||
<Field label="关联生成器 key(可选)" className="w-full sm:w-48">
|
||||
<TextInput
|
||||
type="text"
|
||||
value={draft.toolKey}
|
||||
onChange={(e) => setField("toolKey", e.target.value)}
|
||||
className="mt-1 w-48 rounded border border-line bg-bg px-3 py-2 text-sm text-ink"
|
||||
aria-label="关联生成器 key"
|
||||
/>
|
||||
</label>
|
||||
</Field>
|
||||
</div>
|
||||
<button
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={creating || !isTemplateDraftValid(draft)}
|
||||
className="self-start rounded bg-cinnabar px-4 py-2 text-sm text-white disabled:opacity-50"
|
||||
variant="primary"
|
||||
className="self-start"
|
||||
>
|
||||
{creating ? "新建中…" : "新建模板"}
|
||||
</button>
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<section className="flex flex-col gap-3" aria-label="模板列表">
|
||||
@@ -163,14 +161,16 @@ export function TemplatesManager({ initial }: TemplatesManagerProps) {
|
||||
{t.body}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => void onDelete(t.id)}
|
||||
className="shrink-0 rounded border border-line px-3 py-1 text-ink-soft hover:text-conflict"
|
||||
variant="danger"
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
aria-label={`删除模板 ${t.title}`}
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { ArrowLeft, Database, Sparkles } from "lucide-react";
|
||||
|
||||
import { useToast } from "@/components/Toast";
|
||||
import { ConflictAdjudication } from "@/components/generation/ConflictAdjudication";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { Field } from "@/components/ui/Field";
|
||||
import { TextArea } from "@/components/ui/TextArea";
|
||||
import { TextInput } from "@/components/ui/TextInput";
|
||||
import type { ToolDescriptorView, ToolInputFieldView } from "@/lib/api/types";
|
||||
import {
|
||||
buildGenerateRequest,
|
||||
@@ -115,25 +121,30 @@ export function GeneratorRunner({
|
||||
|
||||
return (
|
||||
<section
|
||||
className="flex flex-col gap-4 rounded border border-line bg-panel p-5"
|
||||
className="flex flex-col gap-5 rounded border border-line bg-panel p-5 shadow-paper"
|
||||
aria-label={tool.title}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<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 / number(select 暂同 text,后端未给 options)。
|
||||
function FormField({ field, value, onChange }: FormFieldProps) {
|
||||
const labelText = field.required ? `${field.label} *` : field.label;
|
||||
const common =
|
||||
"mt-1 w-full rounded border border-line bg-bg px-3 py-2 text-sm text-ink";
|
||||
return (
|
||||
<label className="block text-sm text-ink-soft">
|
||||
{labelText}
|
||||
<Field label={field.label} help={field.help} required={field.required}>
|
||||
{field.type === "textarea" ? (
|
||||
<textarea
|
||||
<TextArea
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
rows={3}
|
||||
className={`${common} resize-y`}
|
||||
aria-label={field.label}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
<TextInput
|
||||
type={field.type === "number" ? "number" : "text"}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className={common}
|
||||
aria-label={field.label}
|
||||
/>
|
||||
)}
|
||||
{field.help ? (
|
||||
<span className="mt-1 block text-xs text-ink-soft/80">{field.help}</span>
|
||||
) : null}
|
||||
</label>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowRight, Database, Sparkles } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import type { ToolDescriptorView } from "@/lib/api/types";
|
||||
import { cardClass } from "@/lib/ui/variants";
|
||||
|
||||
interface ToolCardProps {
|
||||
tool: ToolDescriptorView;
|
||||
@@ -15,27 +19,32 @@ export function ToolCard({ tool, onOpen }: ToolCardProps) {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpen(tool)}
|
||||
className="flex h-full min-h-[140px] flex-col rounded border border-line bg-panel p-5 text-left shadow-paper transition-colors hover:border-cinnabar focus-visible:border-cinnabar focus-visible:outline-none"
|
||||
className={cardClass(
|
||||
"group flex h-full min-h-[164px] flex-col p-5 text-left transition-colors hover:border-cinnabar focus-visible:border-cinnabar focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cinnabar/30",
|
||||
)}
|
||||
>
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2">
|
||||
<h2 className="font-serif text-xl text-ink">{tool.title}</h2>
|
||||
{tool.genre ? (
|
||||
<span className="rounded bg-bg px-2 py-0.5 text-xs text-ink-soft">
|
||||
{tool.genre}
|
||||
</span>
|
||||
) : null}
|
||||
{tool.is_legacy ? null : (
|
||||
<span className="rounded bg-[var(--color-cinnabar-wash)] px-2 py-0.5 text-xs text-cinnabar">
|
||||
NEW
|
||||
</span>
|
||||
)}
|
||||
{tool.genre ? <Badge>{tool.genre}</Badge> : null}
|
||||
{tool.is_legacy ? null : <Badge variant="accent">新</Badge>}
|
||||
{tool.ingestable ? (
|
||||
<span className="rounded bg-bg px-2 py-0.5 text-xs text-ink-soft">
|
||||
<Badge variant="info">
|
||||
<Database className="h-3 w-3" aria-hidden="true" />
|
||||
可入库
|
||||
</span>
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="line-clamp-2 text-sm text-ink-soft">{tool.subtitle}</p>
|
||||
<div className="mt-auto flex items-center gap-2 pt-4 text-xs text-ink-soft">
|
||||
<Sparkles className="h-3.5 w-3.5 text-cinnabar" aria-hidden="true" />
|
||||
<span className="truncate">
|
||||
{tool.input_fields?.[0]?.label ?? "现有页面"} → 结构化预览
|
||||
</span>
|
||||
<ArrowRight
|
||||
className="ml-auto h-3.5 w-3.5 text-cinnabar opacity-0 transition-opacity group-hover:opacity-100"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,8 +2,11 @@
|
||||
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Sparkles } from "lucide-react";
|
||||
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { EmptyState } from "@/components/ui/EmptyState";
|
||||
import { PageHeader } from "@/components/ui/PageHeader";
|
||||
import type { ProjectResponse, ToolDescriptorView } from "@/lib/api/types";
|
||||
import { isLegacyTool, resolveLegacyRoute } from "@/lib/toolbox/toolbox";
|
||||
import { ToolCard } from "./ToolCard";
|
||||
@@ -63,13 +66,11 @@ export function ToolboxPage({
|
||||
activeNav="toolbox"
|
||||
>
|
||||
<div className="mx-auto flex max-w-5xl flex-col gap-6 p-6">
|
||||
<header>
|
||||
<h1 className="font-serif text-lg text-ink">创作工具箱</h1>
|
||||
<p className="mt-1 text-xs text-ink-soft">
|
||||
声明驱动的生成器集合:脑洞 / 书名 / 简介 / 名字 / 金手指 / 词条 / 黄金开篇 / 细纲…
|
||||
选一个开始,结构化产物可一键入库(经一致性预检)。
|
||||
</p>
|
||||
</header>
|
||||
<PageHeader
|
||||
title="创作工具箱"
|
||||
eyebrow="generator toolbox"
|
||||
description="脑洞、书名、简介、名字、金手指、词条、黄金开篇与细纲。每个工具都先生成结构化预览,可入库内容会经过一致性预检。"
|
||||
/>
|
||||
|
||||
{active ? (
|
||||
<GeneratorRunner
|
||||
@@ -78,7 +79,11 @@ export function ToolboxPage({
|
||||
onClose={() => setActive(null)}
|
||||
/>
|
||||
) : sorted.length === 0 ? (
|
||||
<p className="text-sm text-ink-soft">(暂无可用生成器)</p>
|
||||
<EmptyState
|
||||
icon={Sparkles}
|
||||
title="暂无可用生成器"
|
||||
description="后端暂未返回工具描述符。工具箱会在描述符可用后自动渲染卡片与输入表单。"
|
||||
/>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{sorted.map((tool) => (
|
||||
|
||||
16
apps/web/components/ui/Badge.tsx
Normal file
16
apps/web/components/ui/Badge.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { HTMLAttributes, ReactNode } from "react";
|
||||
|
||||
import { badgeClass, type BadgeVariant } from "@/lib/ui/variants";
|
||||
|
||||
interface BadgeProps extends HTMLAttributes<HTMLSpanElement> {
|
||||
children: ReactNode;
|
||||
variant?: BadgeVariant;
|
||||
}
|
||||
|
||||
export function Badge({ children, className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<span className={badgeClass({ variant, className })} {...props}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
36
apps/web/components/ui/Button.tsx
Normal file
36
apps/web/components/ui/Button.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from "react";
|
||||
|
||||
import {
|
||||
buttonClass,
|
||||
type ButtonSize,
|
||||
type ButtonVariant,
|
||||
} from "@/lib/ui/variants";
|
||||
|
||||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
children: ReactNode;
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
}
|
||||
|
||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
|
||||
{
|
||||
children,
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
type = "button",
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
type={type}
|
||||
className={buttonClass({ variant, size, className })}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
});
|
||||
21
apps/web/components/ui/Card.tsx
Normal file
21
apps/web/components/ui/Card.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { HTMLAttributes, ReactNode } from "react";
|
||||
|
||||
import { cardClass } from "@/lib/ui/variants";
|
||||
|
||||
interface CardProps extends HTMLAttributes<HTMLElement> {
|
||||
children: ReactNode;
|
||||
as?: "article" | "div" | "section";
|
||||
}
|
||||
|
||||
export function Card({
|
||||
as: Component = "div",
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: CardProps) {
|
||||
return (
|
||||
<Component className={cardClass(className)} {...props}>
|
||||
{children}
|
||||
</Component>
|
||||
);
|
||||
}
|
||||
38
apps/web/components/ui/EmptyState.tsx
Normal file
38
apps/web/components/ui/EmptyState.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/ui/variants";
|
||||
|
||||
interface EmptyStateProps {
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
description: string;
|
||||
action?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function EmptyState({
|
||||
icon: Icon,
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
className,
|
||||
}: EmptyStateProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded border border-dashed border-line bg-panel/70 px-6 py-10 text-center",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="mx-auto mb-3 flex h-10 w-10 items-center justify-center rounded bg-[var(--color-cinnabar-wash)] text-cinnabar">
|
||||
<Icon className="h-5 w-5" aria-hidden="true" />
|
||||
</div>
|
||||
<h2 className="font-serif text-lg text-ink">{title}</h2>
|
||||
<p className="mx-auto mt-2 max-w-md text-sm leading-6 text-ink-soft">
|
||||
{description}
|
||||
</p>
|
||||
{action ? <div className="mt-4">{action}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
40
apps/web/components/ui/Field.tsx
Normal file
40
apps/web/components/ui/Field.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import {
|
||||
cn,
|
||||
fieldErrorClass,
|
||||
fieldHelpClass,
|
||||
fieldLabelClass,
|
||||
} from "@/lib/ui/variants";
|
||||
|
||||
interface FieldProps {
|
||||
label: string;
|
||||
children: ReactNode;
|
||||
htmlFor?: string;
|
||||
help?: ReactNode;
|
||||
error?: ReactNode;
|
||||
required?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Field({
|
||||
label,
|
||||
children,
|
||||
htmlFor,
|
||||
help,
|
||||
error,
|
||||
required = false,
|
||||
className,
|
||||
}: FieldProps) {
|
||||
const labelText = required ? `${label} *` : label;
|
||||
return (
|
||||
<div className={cn("space-y-1.5", className)}>
|
||||
<label htmlFor={htmlFor} className={fieldLabelClass()}>
|
||||
{labelText}
|
||||
</label>
|
||||
{children}
|
||||
{error ? <p className={fieldErrorClass()}>{error}</p> : null}
|
||||
{!error && help ? <p className={fieldHelpClass()}>{help}</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
38
apps/web/components/ui/PageHeader.tsx
Normal file
38
apps/web/components/ui/PageHeader.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
interface PageHeaderProps {
|
||||
title: string;
|
||||
eyebrow?: string;
|
||||
description?: string;
|
||||
actions?: ReactNode;
|
||||
}
|
||||
|
||||
export function PageHeader({
|
||||
title,
|
||||
eyebrow,
|
||||
description,
|
||||
actions,
|
||||
}: PageHeaderProps) {
|
||||
return (
|
||||
<header className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
{eyebrow ? (
|
||||
<p className="mb-1 font-mono text-xs uppercase tracking-wide text-ink-soft/70">
|
||||
{eyebrow}
|
||||
</p>
|
||||
) : null}
|
||||
<h1 className="font-serif text-2xl text-ink">{title}</h1>
|
||||
{description ? (
|
||||
<p className="mt-2 max-w-2xl text-sm leading-6 text-ink-soft">
|
||||
{description}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
{actions ? (
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-2">
|
||||
{actions}
|
||||
</div>
|
||||
) : null}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
29
apps/web/components/ui/SectionHeader.tsx
Normal file
29
apps/web/components/ui/SectionHeader.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { cn } from "@/lib/ui/variants";
|
||||
|
||||
interface SectionHeaderProps {
|
||||
title: string;
|
||||
description?: ReactNode;
|
||||
action?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SectionHeader({
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
className,
|
||||
}: SectionHeaderProps) {
|
||||
return (
|
||||
<div className={cn("flex items-start justify-between gap-4", className)}>
|
||||
<div className="min-w-0">
|
||||
<h2 className="font-serif text-base text-ink">{title}</h2>
|
||||
{description ? (
|
||||
<p className="mt-1 text-sm leading-6 text-ink-soft">{description}</p>
|
||||
) : null}
|
||||
</div>
|
||||
{action ? <div className="shrink-0">{action}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
48
apps/web/components/ui/SegmentedControl.tsx
Normal file
48
apps/web/components/ui/SegmentedControl.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { cn, segmentedClass } from "@/lib/ui/variants";
|
||||
|
||||
export interface SegmentOption<T extends string> {
|
||||
value: T;
|
||||
label: ReactNode;
|
||||
}
|
||||
|
||||
interface SegmentedControlProps<T extends string> {
|
||||
options: Array<SegmentOption<T>>;
|
||||
value: T;
|
||||
onChange: (value: T) => void;
|
||||
ariaLabel: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SegmentedControl<T extends string>({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
ariaLabel,
|
||||
className,
|
||||
}: SegmentedControlProps<T>) {
|
||||
return (
|
||||
<div className={segmentedClass(className)} role="group" aria-label={ariaLabel}>
|
||||
{options.map((option) => {
|
||||
const selected = option.value === value;
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
aria-pressed={selected}
|
||||
onClick={() => onChange(option.value)}
|
||||
className={cn(
|
||||
"rounded px-3 py-1.5 text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cinnabar/35",
|
||||
selected
|
||||
? "bg-panel text-cinnabar shadow-paper"
|
||||
: "text-ink-soft hover:text-cinnabar",
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
30
apps/web/components/ui/Select.tsx
Normal file
30
apps/web/components/ui/Select.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { SelectHTMLAttributes } from "react";
|
||||
|
||||
import { cn, inputClass, type InputState } from "@/lib/ui/variants";
|
||||
|
||||
interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
|
||||
controlSize?: "sm" | "md";
|
||||
state?: InputState;
|
||||
}
|
||||
|
||||
const selectSizes: Record<NonNullable<SelectProps["controlSize"]>, string> = {
|
||||
sm: "px-2 py-1 text-xs",
|
||||
md: "px-3 py-2 text-sm",
|
||||
};
|
||||
|
||||
export function Select({
|
||||
className,
|
||||
controlSize = "md",
|
||||
state,
|
||||
...props
|
||||
}: SelectProps) {
|
||||
return (
|
||||
<select
|
||||
className={inputClass({
|
||||
state,
|
||||
className: cn(selectSizes[controlSize], className),
|
||||
})}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
46
apps/web/components/ui/StatusNote.tsx
Normal file
46
apps/web/components/ui/StatusNote.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import type { HTMLAttributes, ReactNode } from "react";
|
||||
import { AlertCircle, CheckCircle2, Info, TriangleAlert } from "lucide-react";
|
||||
|
||||
import {
|
||||
cn,
|
||||
statusNoteClass,
|
||||
type StatusNoteVariant,
|
||||
} from "@/lib/ui/variants";
|
||||
|
||||
interface StatusNoteProps extends HTMLAttributes<HTMLDivElement> {
|
||||
children: ReactNode;
|
||||
title?: string;
|
||||
variant?: StatusNoteVariant;
|
||||
}
|
||||
|
||||
export function StatusNote({
|
||||
children,
|
||||
title,
|
||||
variant = "info",
|
||||
className,
|
||||
...props
|
||||
}: StatusNoteProps) {
|
||||
const Icon = iconForVariant(variant);
|
||||
return (
|
||||
<div className={statusNoteClass({ variant, className })} {...props}>
|
||||
<div className="flex gap-2">
|
||||
<Icon className="mt-0.5 h-4 w-4 shrink-0" aria-hidden="true" />
|
||||
<div className="min-w-0">
|
||||
{title ? (
|
||||
<p className="font-medium leading-5 text-ink">{title}</p>
|
||||
) : null}
|
||||
<div className={cn(title ? "mt-1" : "", "text-current")}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function iconForVariant(variant: StatusNoteVariant) {
|
||||
if (variant === "success") return CheckCircle2;
|
||||
if (variant === "warning") return TriangleAlert;
|
||||
if (variant === "danger") return AlertCircle;
|
||||
return Info;
|
||||
}
|
||||
30
apps/web/components/ui/TextArea.tsx
Normal file
30
apps/web/components/ui/TextArea.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { TextareaHTMLAttributes } from "react";
|
||||
|
||||
import { cn, inputClass, type InputState } from "@/lib/ui/variants";
|
||||
|
||||
interface TextAreaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
|
||||
controlSize?: "sm" | "md";
|
||||
state?: InputState;
|
||||
}
|
||||
|
||||
const textAreaSizes: Record<NonNullable<TextAreaProps["controlSize"]>, string> = {
|
||||
sm: "px-2 py-1 text-xs leading-5",
|
||||
md: "px-3 py-2 text-sm leading-6",
|
||||
};
|
||||
|
||||
export function TextArea({
|
||||
className,
|
||||
controlSize = "md",
|
||||
state,
|
||||
...props
|
||||
}: TextAreaProps) {
|
||||
return (
|
||||
<textarea
|
||||
className={inputClass({
|
||||
state,
|
||||
className: cn("resize-y", textAreaSizes[controlSize], className),
|
||||
})}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
30
apps/web/components/ui/TextInput.tsx
Normal file
30
apps/web/components/ui/TextInput.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { InputHTMLAttributes } from "react";
|
||||
|
||||
import { cn, inputClass, type InputState } from "@/lib/ui/variants";
|
||||
|
||||
interface TextInputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||
controlSize?: "sm" | "md";
|
||||
state?: InputState;
|
||||
}
|
||||
|
||||
const inputSizes: Record<NonNullable<TextInputProps["controlSize"]>, string> = {
|
||||
sm: "px-2 py-1 text-xs",
|
||||
md: "px-3 py-2 text-sm",
|
||||
};
|
||||
|
||||
export function TextInput({
|
||||
className,
|
||||
controlSize = "md",
|
||||
state,
|
||||
...props
|
||||
}: TextInputProps) {
|
||||
return (
|
||||
<input
|
||||
className={inputClass({
|
||||
state,
|
||||
className: cn(inputSizes[controlSize], className),
|
||||
})}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useId, useRef, useState, type RefObject } from "react";
|
||||
import {
|
||||
BookOpen,
|
||||
ClipboardCheck,
|
||||
FileText,
|
||||
Library,
|
||||
PanelLeft,
|
||||
PanelRight,
|
||||
PenLine,
|
||||
Square,
|
||||
} from "lucide-react";
|
||||
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { Drawer } from "@/components/Drawer";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { SectionHeader } from "@/components/ui/SectionHeader";
|
||||
import { StatusNote } from "@/components/ui/StatusNote";
|
||||
import { TextArea } from "@/components/ui/TextArea";
|
||||
import type { ProjectResponse } from "@/lib/api/types";
|
||||
import { friendlyError } from "@/lib/errors/messages";
|
||||
import { useAutosave } from "@/lib/autosave/useAutosave";
|
||||
@@ -14,6 +28,7 @@ import {
|
||||
type ChapterEntry,
|
||||
} from "@/lib/workbench/chapter";
|
||||
import { composeDirective, STYLE_PRESETS } from "@/lib/workbench/directive";
|
||||
import { buttonClass } from "@/lib/ui/variants";
|
||||
import { ChapterList, ChapterListContent } from "./ChapterList";
|
||||
import { ChapterAssistant, AssistantContent } from "./ChapterAssistant";
|
||||
import { Editor } from "./Editor";
|
||||
@@ -50,6 +65,8 @@ export function Workbench({
|
||||
const autosave = useAutosave(project.id, chapterNo, initialText);
|
||||
const stream = useDraftStream();
|
||||
const lastStreamText = useRef("");
|
||||
const chapterTriggerRef = useRef<HTMLButtonElement>(null);
|
||||
const assistantTriggerRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
// 流式 token 累积进编辑器(打字机);停止/结束后已生成部分留在草稿。
|
||||
useEffect(() => {
|
||||
@@ -95,7 +112,7 @@ export function Workbench({
|
||||
projectId={project.id}
|
||||
activeNav="write"
|
||||
>
|
||||
<div className="grid h-[calc(100vh-var(--chrome,4rem))] grid-cols-1 lg:grid-cols-[12rem_1fr_18rem]">
|
||||
<div className="grid min-h-[calc(100vh-var(--chrome,4rem))] grid-cols-1 lg:h-[calc(100vh-var(--chrome,4rem))] lg:grid-cols-[12rem_1fr_18rem]">
|
||||
<ChapterList
|
||||
projectId={project.id}
|
||||
chapters={chapters}
|
||||
@@ -103,6 +120,13 @@ export function Workbench({
|
||||
/>
|
||||
|
||||
<section className="flex min-w-0 flex-col bg-bg">
|
||||
<MobileContextBar
|
||||
chapterNo={chapterNo}
|
||||
onOpenChapters={() => setMobilePanel("chapters")}
|
||||
onOpenAssistant={() => setMobilePanel("assistant")}
|
||||
chapterTriggerRef={chapterTriggerRef}
|
||||
assistantTriggerRef={assistantTriggerRef}
|
||||
/>
|
||||
<DirectivePanel
|
||||
directive={directive}
|
||||
onDirectiveChange={setDirective}
|
||||
@@ -126,8 +150,6 @@ export function Workbench({
|
||||
streamError={stream.state.error}
|
||||
onWrite={onWrite}
|
||||
onStop={stream.stop}
|
||||
onOpenChapters={() => setMobilePanel("chapters")}
|
||||
onOpenAssistant={() => setMobilePanel("assistant")}
|
||||
/>
|
||||
</section>
|
||||
|
||||
@@ -140,6 +162,7 @@ export function Workbench({
|
||||
onClose={closePanel}
|
||||
side="left"
|
||||
label="目录"
|
||||
triggerRef={chapterTriggerRef}
|
||||
>
|
||||
<ChapterListContent
|
||||
projectId={project.id}
|
||||
@@ -152,6 +175,7 @@ export function Workbench({
|
||||
onClose={closePanel}
|
||||
side="right"
|
||||
label="本章助手"
|
||||
triggerRef={assistantTriggerRef}
|
||||
>
|
||||
<AssistantContent projectId={project.id} chapterNo={chapterNo} />
|
||||
</Drawer>
|
||||
@@ -159,6 +183,48 @@ export function Workbench({
|
||||
);
|
||||
}
|
||||
|
||||
interface MobileContextBarProps {
|
||||
chapterNo: number;
|
||||
onOpenChapters: () => void;
|
||||
onOpenAssistant: () => void;
|
||||
chapterTriggerRef: RefObject<HTMLButtonElement | null>;
|
||||
assistantTriggerRef: RefObject<HTMLButtonElement | null>;
|
||||
}
|
||||
|
||||
function MobileContextBar({
|
||||
chapterNo,
|
||||
onOpenChapters,
|
||||
onOpenAssistant,
|
||||
chapterTriggerRef,
|
||||
assistantTriggerRef,
|
||||
}: MobileContextBarProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-2 border-b border-line bg-panel px-4 py-2 lg:hidden">
|
||||
<Button
|
||||
ref={chapterTriggerRef}
|
||||
onClick={onOpenChapters}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
>
|
||||
<PanelLeft className="h-4 w-4" aria-hidden="true" />
|
||||
目录
|
||||
</Button>
|
||||
<span className="min-w-0 truncate font-mono text-xs text-ink-soft">
|
||||
第 {chapterNo} 章
|
||||
</span>
|
||||
<Button
|
||||
ref={assistantTriggerRef}
|
||||
onClick={onOpenAssistant}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
>
|
||||
<PanelRight className="h-4 w-4" aria-hidden="true" />
|
||||
助手
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface DirectivePanelProps {
|
||||
directive: string;
|
||||
onDirectiveChange: (value: string) => void;
|
||||
@@ -174,42 +240,55 @@ function DirectivePanel({
|
||||
presetIds,
|
||||
onTogglePreset,
|
||||
}: DirectivePanelProps) {
|
||||
const panelId = useId();
|
||||
const activeCount = presetIds.length + (directive.trim().length > 0 ? 1 : 0);
|
||||
return (
|
||||
<details className="border-b border-line bg-panel px-6 py-2">
|
||||
<summary className="cursor-pointer text-sm text-ink-soft hover:text-cinnabar">
|
||||
本章指令(可选)
|
||||
<details className="border-b border-line bg-panel px-4 py-3 sm:px-6">
|
||||
<summary
|
||||
className="flex cursor-pointer list-none items-center justify-between gap-3 text-sm text-ink-soft hover:text-cinnabar"
|
||||
aria-controls={panelId}
|
||||
>
|
||||
<span className="font-serif text-base text-ink">本章生成控制台</span>
|
||||
<span className="rounded border border-line bg-bg px-2 py-0.5 text-xs text-ink-soft">
|
||||
{activeCount > 0 ? `${activeCount} 项指令` : "可选"}
|
||||
</span>
|
||||
</summary>
|
||||
<div className="mt-2 space-y-2">
|
||||
<div id={panelId} className="mt-3 space-y-3">
|
||||
<SectionHeader
|
||||
title="写作指令"
|
||||
description="用于补充或覆盖本章大纲节拍,只影响本次生成。"
|
||||
/>
|
||||
<label className="block">
|
||||
<span className="sr-only">本章指令</span>
|
||||
<textarea
|
||||
<TextArea
|
||||
value={directive}
|
||||
onChange={(e) => onDirectiveChange(e.target.value)}
|
||||
rows={2}
|
||||
placeholder="本章想怎么写?(可选,覆盖/补充大纲节拍)"
|
||||
className="w-full resize-y rounded border border-line bg-bg px-3 py-2 text-sm text-ink placeholder:text-ink-soft focus:border-cinnabar focus:outline-none"
|
||||
/>
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<div className="flex flex-wrap gap-2" role="group" aria-label="风格预设">
|
||||
{STYLE_PRESETS.map((preset) => {
|
||||
const active = presetIds.includes(preset.id);
|
||||
return (
|
||||
<button
|
||||
<Button
|
||||
key={preset.id}
|
||||
type="button"
|
||||
aria-pressed={active}
|
||||
onClick={() => onTogglePreset(preset.id)}
|
||||
className={
|
||||
active
|
||||
? "rounded-full border border-cinnabar bg-cinnabar px-3 py-1 text-xs text-panel"
|
||||
: "rounded-full border border-line px-3 py-1 text-xs text-ink-soft hover:border-cinnabar hover:text-cinnabar"
|
||||
}
|
||||
variant={active ? "outline" : "secondary"}
|
||||
size="sm"
|
||||
>
|
||||
{preset.label}
|
||||
</button>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{activeCount > 0 ? (
|
||||
<StatusNote variant="info">
|
||||
写本章时会把已选预设和自由指令合并进本次生成请求。
|
||||
</StatusNote>
|
||||
) : null}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
@@ -250,8 +329,6 @@ interface ToolbarProps {
|
||||
streamError: { code: string; message: string } | null;
|
||||
onWrite: () => void;
|
||||
onStop: () => void;
|
||||
onOpenChapters: () => void;
|
||||
onOpenAssistant: () => void;
|
||||
}
|
||||
|
||||
function Toolbar({
|
||||
@@ -264,36 +341,22 @@ function Toolbar({
|
||||
streamError,
|
||||
onWrite,
|
||||
onStop,
|
||||
onOpenChapters,
|
||||
onOpenAssistant,
|
||||
}: ToolbarProps) {
|
||||
return (
|
||||
<div className="border-t border-line bg-panel px-4 py-3 sm:px-6">
|
||||
<div className="sticky bottom-0 z-10 border-t border-line bg-panel/95 px-4 py-2 backdrop-blur sm:px-6 sm:py-3">
|
||||
{streamError ? <StreamErrorNote streamError={streamError} /> : null}
|
||||
<div className="flex flex-wrap items-center gap-2 sm:gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenChapters}
|
||||
className="rounded border border-line px-3 py-2 text-sm text-ink hover:border-cinnabar hover:text-cinnabar lg:hidden"
|
||||
>
|
||||
目录
|
||||
</button>
|
||||
<div className="grid gap-2 sm:flex sm:flex-wrap sm:items-center sm:gap-3">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
{streaming ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onStop}
|
||||
className="rounded border border-conflict px-4 py-2 text-sm text-conflict"
|
||||
>
|
||||
<Button onClick={onStop} variant="danger" size="sm">
|
||||
<Square className="h-4 w-4" aria-hidden="true" />
|
||||
停
|
||||
</button>
|
||||
</Button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onWrite}
|
||||
className="rounded bg-cinnabar px-4 py-2 text-sm text-panel hover:opacity-90"
|
||||
>
|
||||
✍ 写本章
|
||||
</button>
|
||||
<Button onClick={onWrite} variant="primary" size="sm">
|
||||
<PenLine className="h-4 w-4" aria-hidden="true" />
|
||||
写本章
|
||||
</Button>
|
||||
)}
|
||||
{streaming ? (
|
||||
<span className="font-mono text-xs text-cinnabar motion-safe:animate-pulse">
|
||||
@@ -306,30 +369,32 @@ function Toolbar({
|
||||
)}
|
||||
<Link
|
||||
href={`/projects/${projectId}/outline`}
|
||||
className="rounded border border-line px-4 py-2 text-sm text-ink hover:border-cinnabar hover:text-cinnabar"
|
||||
className={buttonClass({ variant: "secondary", size: "sm" })}
|
||||
>
|
||||
<BookOpen className="h-4 w-4" aria-hidden="true" />
|
||||
大纲
|
||||
</Link>
|
||||
<Link
|
||||
href={`/projects/${projectId}/foreshadow`}
|
||||
className="rounded border border-line px-4 py-2 text-sm text-ink hover:border-cinnabar hover:text-cinnabar"
|
||||
className={buttonClass({
|
||||
variant: "secondary",
|
||||
size: "sm",
|
||||
className: "hidden sm:inline-flex",
|
||||
})}
|
||||
>
|
||||
<Library className="h-4 w-4" aria-hidden="true" />
|
||||
伏笔
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenAssistant}
|
||||
className="rounded border border-line px-3 py-2 text-sm text-ink hover:border-cinnabar hover:text-cinnabar lg:hidden"
|
||||
>
|
||||
助手
|
||||
</button>
|
||||
<Link
|
||||
href={`/projects/${projectId}/review?chapter=${chapterNo}`}
|
||||
className="rounded border border-cinnabar px-4 py-2 text-sm text-cinnabar hover:bg-[var(--color-cinnabar-wash)]"
|
||||
className={buttonClass({ variant: "outline", size: "sm" })}
|
||||
>
|
||||
审稿 →
|
||||
<ClipboardCheck className="h-4 w-4" aria-hidden="true" />
|
||||
审稿
|
||||
</Link>
|
||||
<span className="ml-auto font-mono text-xs text-ink-soft">
|
||||
</div>
|
||||
<span className="min-w-0 font-mono text-xs text-ink-soft sm:ml-auto">
|
||||
<FileText className="mr-1 inline h-3.5 w-3.5" aria-hidden="true" />
|
||||
{saveStatus === "saving"
|
||||
? "保存中…"
|
||||
: saveStatus === "error"
|
||||
|
||||
Reference in New Issue
Block a user