Files
writer-work-flow/apps/web/components/workbench/ChapterAssistant.tsx
Yaojia Wang 6449c3103a feat(ux): F1 注入面板可控版 — 📌置顶/✕排除/近N章步进器 + 已排除恢复
- injection.ts 加 override 纯函数(currentOverride/withPinToggled/withExcluded/withRestored/withRecentN/isPinned,钳 1..20)+ author_pin 徽标
- useInjection 加 saving 态 + togglePin/exclude/restore/setRecentN,PUT 后以服务端确定结果为准(不变量 #6);写失败可读文案、不改本地态(天然回滚)
- ChapterAssistant:每实体置顶/排除按钮 + 「近 N 章」步进器 + 「已排除」恢复区
- 13 新 vitest(override 纯函数);前端门禁绿(lint/tsc/vitest 183/build)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 12:01:09 +02:00

243 lines
8.1 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 Link from "next/link";
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 lg:block">
<AssistantContent projectId={projectId} chapterNo={chapterNo} />
</aside>
);
}
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">
<h2 className="text-sm font-semibold text-ink"></h2>
{injection.saving ? (
<span className="text-[10px] text-ink-soft"></span>
) : null}
</div>
<section className="mt-4">
<div className="flex items-center justify-between">
<h3 className="text-xs font-semibold uppercase tracking-wide text-ink-soft">
</h3>
{data ? (
<RecentStepper
value={data.recent_n}
disabled={injection.saving}
onChange={injection.setRecentN}
/>
) : null}
</div>
{injection.loading ? (
<p className="mt-2 text-xs text-ink-soft"></p>
) : injection.error ? (
<p className="mt-2 rounded border border-dashed border-line bg-bg p-3 text-xs text-ink-soft">
{injection.error}
</p>
) : selected.length === 0 ? (
<p className="mt-2 rounded border border-dashed border-line bg-bg p-3 text-xs text-ink-soft">
/
</p>
) : (
<ul className="mt-2 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-3">
<h4 className="text-[10px] uppercase tracking-wide text-ink-soft">
</h4>
<ul className="mt-1 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">
<h3 className="text-xs font-semibold uppercase tracking-wide text-ink-soft">
</h3>
<p className="mt-2 text-xs text-ink-soft">
/ / / 稿
</p>
<Link
href={`/projects/${projectId}/review?chapter=${chapterNo}`}
className="mt-2 inline-block rounded border border-cinnabar px-3 py-1.5 text-xs text-cinnabar hover:bg-[var(--color-cinnabar-wash)]"
>
稿
</Link>
</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-[10px] 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
type="button"
disabled={disabled}
aria-pressed={pinned}
title={pinned ? "取消置顶" : "置顶(强制注入)"}
onClick={() => onTogglePin(ref)}
className={`rounded px-1.5 py-0.5 text-[11px] disabled:opacity-50 ${
pinned
? "bg-[var(--color-cinnabar-wash)] text-cinnabar"
: "text-ink-soft hover:bg-panel"
}`}
>
📌
</button>
<button
type="button"
disabled={disabled}
title="排除(不注入本章)"
aria-label={`排除 ${entity.name}`}
onClick={() => onExclude(ref)}
className="rounded px-1.5 py-0.5 text-[11px] text-ink-soft hover:bg-panel disabled:opacity-50"
>
</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-[10px] 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-[10px] text-ink-soft">{kindLabel(refItem.kind)}</span>
<span className="text-xs text-ink-soft line-through">{refItem.name}</span>
<button
type="button"
disabled={disabled}
title="恢复(回到自动判定)"
onClick={() => onRestore(refItem)}
className="ml-auto rounded px-1.5 py-0.5 text-[11px] text-cinnabar hover:bg-[var(--color-cinnabar-wash)] disabled:opacity-50"
>
</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-[11px] text-ink-soft">
<span></span>
<button
type="button"
disabled={disabled || value <= RECENT_N_MIN}
aria-label="减少关联章数"
onClick={() => onChange(value - 1)}
className="rounded border border-line px-1.5 leading-tight hover:bg-panel disabled:opacity-40"
>
</button>
<span className="w-4 text-center tabular-nums text-ink">{value}</span>
<button
type="button"
disabled={disabled || value >= RECENT_N_MAX}
aria-label="增加关联章数"
onClick={() => onChange(value + 1)}
className="rounded border border-line px-1.5 leading-tight hover:bg-panel disabled:opacity-40"
>
+
</button>
<span></span>
</div>
);
}