Files
writer-work-flow/apps/web/components/workbench/ChapterAssistant.tsx
Yaojia Wang 5674158707 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)
2026-07-11 08:04:56 +02:00

315 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import type { ReactNode } from "react";
import {
Minus,
Pin,
PinOff,
Plus,
RotateCcw,
Sparkles,
Undo2,
X,
} from "lucide-react";
import { ThinkingIndicator } from "@/components/ThinkingIndicator";
import { Button } from "@/components/ui/Button";
import { StatusNote } from "@/components/ui/StatusNote";
import { buttonClass } from "@/lib/ui/variants";
import {
isPinned,
kindLabel,
reasonLabel,
RECENT_N_MAX,
RECENT_N_MIN,
type InjectionEntity,
type InjectionEntityRef,
} from "@/lib/workbench/injection";
import { useInjection } from "@/lib/workbench/useInjection";
interface ChapterAssistantProps {
projectId: string;
chapterNo: number;
}
// 右栏「本章助手」UX §6.3)。
// 「本章注入(透明)」读 B0 端点,列出确定性选中的设定/角色 + 入选理由徽标,
// 并让作者 📌置顶 / ✕排除 / 调近 N 章(可控版)——「看到的=写章用的」信任牌(不变量 #6
// 桌面 aside 包裹;移动端经 Workbench 抽屉复用 AssistantContent。
export function ChapterAssistant({ projectId, chapterNo }: ChapterAssistantProps) {
return (
<aside className="hidden border-l border-line bg-panel py-4 xl:block">
<AssistantContent projectId={projectId} chapterNo={chapterNo} />
</aside>
);
}
// 窄栏小标题P3-5font-sans + eyebrow 字号/正字距(对齐 Eyebrow 眉题的视觉),
// 但保留 <h3> 语义(不降级为 <p>,守住标题大纲 a11y。可带右侧行内控件如步进器
function PanelLabel({
children,
action,
}: {
children: ReactNode;
action?: ReactNode;
}) {
return (
<div className="flex items-start justify-between gap-3">
<h3 className="font-sans text-eyebrow uppercase tracking-wide text-muted-soft">
{children}
</h3>
{action ? <div className="shrink-0">{action}</div> : null}
</div>
);
}
export function AssistantContent({ projectId, chapterNo }: ChapterAssistantProps) {
const injection = useInjection(projectId, chapterNo);
const data = injection.data;
const selected = data?.selected ?? [];
const excluded = data?.excluded ?? [];
return (
<div className="px-4">
<div className="flex items-center justify-between gap-2">
<h2 className="font-sans text-title-md font-medium text-ink">
</h2>
{injection.saving ? (
<ThinkingIndicator label="保存中" className="text-xs text-ink-soft" />
) : null}
</div>
<section className="mt-5">
<PanelLabel
action={
data ? (
<RecentStepper
value={data.recent_n}
disabled={injection.saving}
onChange={injection.setRecentN}
/>
) : null
}
>
AI
</PanelLabel>
{injection.loading ? (
<p className="mt-2 text-caption text-ink-soft"></p>
) : !data ? (
// 首次加载失败data 仍为空 → 满屏错误框 + 重试。
<StatusNote variant="danger" className="mt-2 text-xs" title="参考信息暂不可用">
<p></p>
<button
type="button"
onClick={injection.reload}
className={buttonClass({ variant: "outline", size: "sm", className: "mt-2" })}
>
<RotateCcw className="h-3.5 w-3.5" aria-hidden="true" />
</button>
</StatusNote>
) : (
<>
{injection.saveError ? (
// 保存失败:悬浮提示,不替换列表;可关闭 + 重试上一次覆盖。
<StatusNote variant="danger" className="mt-2 text-xs" title={injection.saveError}>
<div className="mt-1 flex items-center gap-2">
<button
type="button"
onClick={() => void injection.retrySave()}
disabled={injection.saving}
className={buttonClass({ variant: "outline", size: "sm" })}
>
<RotateCcw className="h-3.5 w-3.5" aria-hidden="true" />
</button>
<button
type="button"
onClick={injection.clearSaveError}
aria-label="关闭保存失败提示"
className={buttonClass({ variant: "ghost", size: "sm" })}
>
<X className="h-3.5 w-3.5" aria-hidden="true" />
</button>
</div>
</StatusNote>
) : null}
{selected.length === 0 ? (
// 空态占位(不加标题,避免在 h3 子小节内注入 h2 破坏标题大纲)。
<div className="mt-2 flex flex-col items-center gap-2 py-4 text-center">
<Sparkles className="h-5 w-5 text-muted-soft" aria-hidden="true" />
<p className="max-w-xs text-caption leading-6 text-ink-soft">
AI /
</p>
</div>
) : (
<ul className="mt-3 space-y-2">
{selected.map((entity) => (
<EntityRow
key={`${entity.kind}:${entity.name}`}
entity={entity}
pinned={isPinned(
{ pinned: data.pinned, excluded: data.excluded, recent_n: data.recent_n },
entity,
)}
disabled={injection.saving}
onTogglePin={injection.togglePin}
onExclude={injection.exclude}
/>
))}
</ul>
)}
</>
)}
{excluded.length > 0 ? (
<div className="mt-4">
<PanelLabel></PanelLabel>
<ul className="mt-2 space-y-1">
{excluded.map((ref) => (
<ExcludedRow
key={`${ref.kind}:${ref.name}`}
refItem={ref}
disabled={injection.saving}
onRestore={injection.restore}
/>
))}
</ul>
</div>
) : null}
</section>
<section className="mt-6 border-t border-line-soft pt-5">
<PanelLabel></PanelLabel>
<p className="mt-1.5 text-caption leading-6 text-ink-soft">
AI 稿
</p>
</section>
</div>
);
}
interface EntityRowProps {
entity: InjectionEntity;
pinned: boolean;
disabled: boolean;
onTogglePin: (ref: InjectionEntityRef) => void;
onExclude: (ref: InjectionEntityRef) => void;
}
function EntityRow({ entity, pinned, disabled, onTogglePin, onExclude }: EntityRowProps) {
const ref: InjectionEntityRef = { kind: entity.kind, name: entity.name };
return (
<li className="rounded border border-line bg-bg p-2">
<div className="flex items-center gap-2">
<span className="rounded bg-panel px-1.5 py-0.5 text-2xs text-ink-soft">
{kindLabel(entity.kind)}
</span>
<span className="text-sm text-ink">{entity.name}</span>
<div className="ml-auto flex items-center gap-1">
<Button
variant="ghost"
size="icon"
disabled={disabled}
aria-pressed={pinned}
aria-label={
pinned ? `取消置顶 ${entity.name}` : `置顶 ${entity.name}(一定参考)`
}
onClick={() => onTogglePin(ref)}
className={pinned ? "text-cinnabar" : undefined}
>
{pinned ? (
<Pin className="h-4 w-4" aria-hidden="true" />
) : (
<PinOff className="h-4 w-4" aria-hidden="true" />
)}
</Button>
<Button
variant="ghost"
size="icon"
disabled={disabled}
aria-label={`排除 ${entity.name}(本章不参考)`}
onClick={() => onExclude(ref)}
>
<X className="h-4 w-4" aria-hidden="true" />
</Button>
</div>
</div>
<div className="mt-1.5 flex flex-wrap gap-1">
{(entity.reasons ?? []).map((reason) => (
<span
key={reason}
className="rounded bg-[var(--color-cinnabar-wash)] px-1.5 py-0.5 text-2xs text-cinnabar"
>
{reasonLabel(reason)}
</span>
))}
</div>
</li>
);
}
interface ExcludedRowProps {
refItem: InjectionEntityRef;
disabled: boolean;
onRestore: (ref: InjectionEntityRef) => void;
}
function ExcludedRow({ refItem, disabled, onRestore }: ExcludedRowProps) {
return (
<li className="flex items-center gap-2 rounded border border-dashed border-line px-2 py-1">
<span className="text-2xs text-ink-soft">{kindLabel(refItem.kind)}</span>
<span className="text-xs text-ink-soft line-through">{refItem.name}</span>
<Button
variant="ghost"
size="icon"
disabled={disabled}
aria-label={`恢复 ${refItem.name}(回到自动判定)`}
onClick={() => onRestore(refItem)}
className="ml-auto text-cinnabar"
>
<Undo2 className="h-4 w-4" aria-hidden="true" />
</Button>
</li>
);
}
interface RecentStepperProps {
value: number;
disabled: boolean;
onChange: (n: number) => void;
}
// 「关联近 N 章」步进器:覆盖近况摘要回看章数(钳在 [MIN, MAX])。
function RecentStepper({ value, disabled, onChange }: RecentStepperProps) {
return (
<div className="flex items-center gap-1 text-2xs text-ink-soft">
<span></span>
<Button
variant="ghost"
size="icon"
disabled={disabled || value <= RECENT_N_MIN}
aria-label="减少关联章数"
onClick={() => onChange(value - 1)}
>
<Minus className="h-4 w-4" aria-hidden="true" />
</Button>
<span className="w-4 text-center tabular-nums text-ink">{value}</span>
<Button
variant="ghost"
size="icon"
disabled={disabled || value >= RECENT_N_MAX}
aria-label="增加关联章数"
onClick={() => onChange(value + 1)}
>
<Plus className="h-4 w-4" aria-hidden="true" />
</Button>
<span></span>
</div>
);
}