Files
writer-work-flow/apps/web/components/workbench/ChapterAssistant.tsx

285 lines
9.9 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 { RotateCcw, X } from "lucide-react";
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 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>
) : !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 ? (
<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={buttonClass({ variant: "outline", size: "sm", className: "mt-2" })}
>
稿
</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>
);
}