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