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>
This commit is contained in:
Yaojia Wang
2026-06-20 12:01:09 +02:00
parent 0d473e726e
commit 6449c3103a
5 changed files with 391 additions and 34 deletions

View File

@@ -3,9 +3,13 @@
import Link from "next/link"; import Link from "next/link";
import { import {
isPinned,
kindLabel, kindLabel,
reasonLabel, reasonLabel,
RECENT_N_MAX,
RECENT_N_MIN,
type InjectionEntity, type InjectionEntity,
type InjectionEntityRef,
} from "@/lib/workbench/injection"; } from "@/lib/workbench/injection";
import { useInjection } from "@/lib/workbench/useInjection"; import { useInjection } from "@/lib/workbench/useInjection";
@@ -15,13 +19,10 @@ interface ChapterAssistantProps {
} }
// 右栏「本章助手」UX §6.3)。 // 右栏「本章助手」UX §6.3)。
// 「本章注入(透明)」读 B0 端点,列出确定性选中的设定/角色 + 入选理由徽标—— // 「本章注入(透明)」读 B0 端点,列出确定性选中的设定/角色 + 入选理由徽标
// 这是「看到的=写章用的」信任牌(不变量 #6「四审」指向审稿页入口。 // 并让作者 📌置顶 / ✕排除 / 调近 N 章(可控版)——「看到的=写章用的」信任牌(不变量 #6
// 桌面 aside 包裹;移动端经 Workbench 抽屉复用 AssistantContent。 // 桌面 aside 包裹;移动端经 Workbench 抽屉复用 AssistantContent。
export function ChapterAssistant({ export function ChapterAssistant({ projectId, chapterNo }: ChapterAssistantProps) {
projectId,
chapterNo,
}: ChapterAssistantProps) {
return ( return (
<aside className="hidden border-l border-line bg-panel py-4 lg:block"> <aside className="hidden border-l border-line bg-panel py-4 lg:block">
<AssistantContent projectId={projectId} chapterNo={chapterNo} /> <AssistantContent projectId={projectId} chapterNo={chapterNo} />
@@ -29,21 +30,34 @@ export function ChapterAssistant({
); );
} }
export function AssistantContent({ export function AssistantContent({ projectId, chapterNo }: ChapterAssistantProps) {
projectId,
chapterNo,
}: ChapterAssistantProps) {
const injection = useInjection(projectId, chapterNo); const injection = useInjection(projectId, chapterNo);
const selected = injection.data?.selected ?? []; const data = injection.data;
const selected = data?.selected ?? [];
const excluded = data?.excluded ?? [];
return ( return (
<div className="px-4"> <div className="px-4">
<h2 className="text-sm font-semibold text-ink"></h2> <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"> <section className="mt-4">
<h3 className="text-xs font-semibold uppercase tracking-wide text-ink-soft"> <div className="flex items-center justify-between">
<h3 className="text-xs font-semibold uppercase tracking-wide text-ink-soft">
</h3>
</h3>
{data ? (
<RecentStepper
value={data.recent_n}
disabled={injection.saving}
onChange={injection.setRecentN}
/>
) : null}
</div>
{injection.loading ? ( {injection.loading ? (
<p className="mt-2 text-xs text-ink-soft"></p> <p className="mt-2 text-xs text-ink-soft"></p>
@@ -53,15 +67,43 @@ export function AssistantContent({
</p> </p>
) : selected.length === 0 ? ( ) : selected.length === 0 ? (
<p className="mt-2 rounded border border-dashed border-line bg-bg p-3 text-xs text-ink-soft"> <p className="mt-2 rounded border border-dashed border-line bg-bg p-3 text-xs text-ink-soft">
/ /
</p> </p>
) : ( ) : (
<ul className="mt-2 space-y-2"> <ul className="mt-2 space-y-2">
{selected.map((entity) => ( {selected.map((entity) => (
<EntityRow key={`${entity.kind}:${entity.name}`} entity={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> </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>
<section className="mt-6"> <section className="mt-6">
@@ -82,7 +124,16 @@ export function AssistantContent({
); );
} }
function EntityRow({ entity }: { entity: InjectionEntity }) { 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 ( return (
<li className="rounded border border-line bg-bg p-2"> <li className="rounded border border-line bg-bg p-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -90,6 +141,32 @@ function EntityRow({ entity }: { entity: InjectionEntity }) {
{kindLabel(entity.kind)} {kindLabel(entity.kind)}
</span> </span>
<span className="text-sm text-ink">{entity.name}</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>
<div className="mt-1.5 flex flex-wrap gap-1"> <div className="mt-1.5 flex flex-wrap gap-1">
{(entity.reasons ?? []).map((reason) => ( {(entity.reasons ?? []).map((reason) => (
@@ -104,3 +181,62 @@ function EntityRow({ entity }: { entity: InjectionEntity }) {
</li> </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>
);
}

View File

@@ -1,6 +1,25 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { kindLabel, reasonLabel } from "./injection"; import {
currentOverride,
isPinned,
kindLabel,
reasonLabel,
withExcluded,
withPinToggled,
withRecentN,
withRestored,
type InjectionOverrideRequest,
type InjectionResponse,
} from "./injection";
const REF = { kind: "character", name: "林动" } as const;
const emptyOverride = (): InjectionOverrideRequest => ({
pinned: [],
excluded: [],
recent_n: 5,
});
describe("reasonLabel", () => { describe("reasonLabel", () => {
it("maps each known selection reason to a Chinese badge", () => { it("maps each known selection reason to a Chinese badge", () => {
@@ -8,6 +27,7 @@ describe("reasonLabel", () => {
expect(reasonLabel("main_character")).toBe("主角常驻"); expect(reasonLabel("main_character")).toBe("主角常驻");
expect(reasonLabel("recent_digest")).toBe("近章出现"); expect(reasonLabel("recent_digest")).toBe("近章出现");
expect(reasonLabel("foreshadow_window")).toBe("伏笔窗口"); expect(reasonLabel("foreshadow_window")).toBe("伏笔窗口");
expect(reasonLabel("author_pin")).toBe("作者置顶");
}); });
it("falls back to the raw code for an unknown reason", () => { it("falls back to the raw code for an unknown reason", () => {
@@ -25,3 +45,59 @@ describe("kindLabel", () => {
expect(kindLabel("mystery")).toBe("mystery"); expect(kindLabel("mystery")).toBe("mystery");
}); });
}); });
describe("currentOverride", () => {
it("extracts pinned/excluded/recent_n from a response", () => {
const data = {
project_id: "p",
chapter_no: 1,
selected: [],
recent_n: 3,
pinned: [REF],
excluded: [{ kind: "world_entity", name: "玄铁" }],
} as unknown as InjectionResponse;
expect(currentOverride(data)).toEqual({
pinned: [REF],
excluded: [{ kind: "world_entity", name: "玄铁" }],
recent_n: 3,
});
});
});
describe("withPinToggled", () => {
it("pins an unpinned ref and removes it from excluded", () => {
const next = withPinToggled(withExcluded(emptyOverride(), REF), REF);
expect(isPinned(next, REF)).toBe(true);
expect(next.excluded).toEqual([]);
});
it("unpins an already-pinned ref", () => {
const pinned = withPinToggled(emptyOverride(), REF);
const next = withPinToggled(pinned, REF);
expect(isPinned(next, REF)).toBe(false);
});
});
describe("withExcluded / withRestored", () => {
it("excludes a ref, removing it from pinned, then restores it", () => {
const pinnedThenExcluded = withExcluded(withPinToggled(emptyOverride(), REF), REF);
expect(isPinned(pinnedThenExcluded, REF)).toBe(false);
expect(pinnedThenExcluded.excluded).toEqual([REF]);
const restored = withRestored(pinnedThenExcluded, REF);
expect(restored.excluded).toEqual([]);
});
it("is idempotent when excluding twice", () => {
const once = withExcluded(emptyOverride(), REF);
const twice = withExcluded(once, REF);
expect(twice.excluded).toEqual([REF]);
});
});
describe("withRecentN", () => {
it("clamps below the minimum and above the maximum", () => {
expect(withRecentN(emptyOverride(), 0).recent_n).toBe(1);
expect(withRecentN(emptyOverride(), 999).recent_n).toBe(20);
expect(withRecentN(emptyOverride(), 7).recent_n).toBe(7);
});
});

View File

@@ -5,13 +5,21 @@ import type { components } from "@/lib/api/schema";
export type InjectionResponse = components["schemas"]["InjectionResponse"]; export type InjectionResponse = components["schemas"]["InjectionResponse"];
export type InjectionEntity = components["schemas"]["InjectionEntity"]; export type InjectionEntity = components["schemas"]["InjectionEntity"];
export type InjectionEntityRef = components["schemas"]["InjectionEntityRef"];
export type InjectionOverrideRequest =
components["schemas"]["InjectionOverrideRequest"];
// SelectionReason → 作者可读徽标(对齐 ARCH §3.4 的四来源)。 // recent_n 合法区间(与后端 RECENT_N_MIN/MAX 对齐;越界后端 422)。
export const RECENT_N_MIN = 1;
export const RECENT_N_MAX = 20;
// SelectionReason → 作者可读徽标(对齐 ARCH §3.4 的四来源 + 作者置顶)。
export const REASON_LABELS: Record<string, string> = { export const REASON_LABELS: Record<string, string> = {
explicit_beat: "本章点名", explicit_beat: "本章点名",
main_character: "主角常驻", main_character: "主角常驻",
recent_digest: "近章出现", recent_digest: "近章出现",
foreshadow_window: "伏笔窗口", foreshadow_window: "伏笔窗口",
author_pin: "作者置顶",
}; };
// EntityKind → 中文。 // EntityKind → 中文。
@@ -28,3 +36,81 @@ export function reasonLabel(reason: string): string {
export function kindLabel(kind: string): string { export function kindLabel(kind: string): string {
return KIND_LABELS[kind] ?? kind; return KIND_LABELS[kind] ?? kind;
} }
// ---- 作者覆盖B0 可控版)纯函数:从响应推导当前覆盖 + 不可变地施加单个改动 ----
const refKey = (r: InjectionEntityRef): string => `${r.kind}:${r.name}`;
const sameRef = (a: InjectionEntityRef, b: InjectionEntityRef): boolean =>
a.kind === b.kind && a.name === b.name;
// 从注入响应回显字段抽出当前覆盖PUT 回放服务端确定结果后驱动 UI
export function currentOverride(
data: InjectionResponse,
): InjectionOverrideRequest {
return {
pinned: [...(data.pinned ?? [])],
excluded: [...(data.excluded ?? [])],
recent_n: data.recent_n,
};
}
export function isPinned(
override: InjectionOverrideRequest,
ref: InjectionEntityRef,
): boolean {
return (override.pinned ?? []).some((r) => sameRef(r, ref));
}
const without = (
list: InjectionEntityRef[] | undefined,
ref: InjectionEntityRef,
): InjectionEntityRef[] => (list ?? []).filter((r) => !sameRef(r, ref));
// 切换 pin已置顶 → 取消;否则置顶并从排除列表移除(一个实体不同时 pin+排除)。
export function withPinToggled(
override: InjectionOverrideRequest,
ref: InjectionEntityRef,
): InjectionOverrideRequest {
const pinnedNow = isPinned(override, ref);
return {
...override,
pinned: pinnedNow
? without(override.pinned, ref)
: [...(override.pinned ?? []), { kind: ref.kind, name: ref.name }],
excluded: without(override.excluded, ref),
};
}
// 排除:加入排除列表,并从置顶列表移除。重复排除幂等(先去重再加)。
export function withExcluded(
override: InjectionOverrideRequest,
ref: InjectionEntityRef,
): InjectionOverrideRequest {
return {
...override,
pinned: without(override.pinned, ref),
excluded: [
...without(override.excluded, ref),
{ kind: ref.kind, name: ref.name },
],
};
}
// 恢复被排除的实体(回到纯自动选择判定)。
export function withRestored(
override: InjectionOverrideRequest,
ref: InjectionEntityRef,
): InjectionOverrideRequest {
return { ...override, excluded: without(override.excluded, ref) };
}
// 设置近况回看章数(钳到合法区间,避免后端 422
export function withRecentN(
override: InjectionOverrideRequest,
n: number,
): InjectionOverrideRequest {
const clamped = Math.max(RECENT_N_MIN, Math.min(RECENT_N_MAX, Math.round(n)));
return { ...override, recent_n: clamped };
}
export { refKey };

View File

@@ -1,24 +1,40 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { api } from "@/lib/api/client"; import { api } from "@/lib/api/client";
import type { InjectionResponse } from "./injection"; import {
currentOverride,
withExcluded,
withPinToggled,
withRecentN,
withRestored,
type InjectionEntityRef,
type InjectionOverrideRequest,
type InjectionResponse,
} from "./injection";
export interface UseInjection { export interface UseInjection {
data: InjectionResponse | null; data: InjectionResponse | null;
loading: boolean; loading: boolean;
saving: boolean;
error: string | null; error: string | null;
togglePin: (ref: InjectionEntityRef) => Promise<void>;
exclude: (ref: InjectionEntityRef) => Promise<void>;
restore: (ref: InjectionEntityRef) => Promise<void>;
setRecentN: (n: number) => Promise<void>;
} }
// 本章注入透明B0 读端点):挂载时拉一次确定性 SelectionTrace。 const INJECTION_PATH =
// 只读、无 LLM、无 commit——展示「选了谁/为什么」。失败给可读文案不抛。 "/projects/{project_id}/chapters/{chapter_no}/injection" as const;
export function useInjection(
projectId: string, // 本章注入透明B0 可控版):读确定性 SelectionTrace + 作者覆盖;
chapterNo: number, // pin/排除/recent_n 经 PUT 覆盖 → 服务端回放新的确定结果(看到的=写章用的)。
): UseInjection { // 写失败给可读文案、不改本地状态(天然回滚),不抛。
export function useInjection(projectId: string, chapterNo: number): UseInjection {
const [data, setData] = useState<InjectionResponse | null>(null); const [data, setData] = useState<InjectionResponse | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
@@ -27,10 +43,9 @@ export function useInjection(
setError(null); setError(null);
void (async () => { void (async () => {
const { data: body, error: err } = await api.GET( const { data: body, error: err } = await api.GET(INJECTION_PATH, {
"/projects/{project_id}/chapters/{chapter_no}/injection", params: { path: { project_id: projectId, chapter_no: chapterNo } },
{ params: { path: { project_id: projectId, chapter_no: chapterNo } } }, });
);
if (cancelled) return; if (cancelled) return;
if (err || !body) { if (err || !body) {
setError("注入信息暂不可用"); setError("注入信息暂不可用");
@@ -46,5 +61,49 @@ export function useInjection(
}; };
}, [projectId, chapterNo]); }, [projectId, chapterNo]);
return { data, loading, error }; const save = useCallback(
async (override: InjectionOverrideRequest) => {
setSaving(true);
setError(null);
const { data: body, error: err } = await api.PUT(INJECTION_PATH, {
params: { path: { project_id: projectId, chapter_no: chapterNo } },
body: override,
});
if (err || !body) {
setError("保存注入设置失败,请重试");
} else {
setData(body); // 以服务端确定结果为准(不变量 #6
}
setSaving(false);
},
[projectId, chapterNo],
);
const mutate = useCallback(
(fn: (o: InjectionOverrideRequest) => InjectionOverrideRequest) =>
async (): Promise<void> => {
if (!data) return;
await save(fn(currentOverride(data)));
},
[data, save],
);
const togglePin = useCallback(
(ref: InjectionEntityRef) => mutate((o) => withPinToggled(o, ref))(),
[mutate],
);
const exclude = useCallback(
(ref: InjectionEntityRef) => mutate((o) => withExcluded(o, ref))(),
[mutate],
);
const restore = useCallback(
(ref: InjectionEntityRef) => mutate((o) => withRestored(o, ref))(),
[mutate],
);
const setRecentN = useCallback(
(n: number) => mutate((o) => withRecentN(o, n))(),
[mutate],
);
return { data, loading, saving, error, togglePin, exclude, restore, setRecentN };
} }

File diff suppressed because one or more lines are too long