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

@@ -5,13 +5,21 @@ import type { components } from "@/lib/api/schema";
export type InjectionResponse = components["schemas"]["InjectionResponse"];
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> = {
explicit_beat: "本章点名",
main_character: "主角常驻",
recent_digest: "近章出现",
foreshadow_window: "伏笔窗口",
author_pin: "作者置顶",
};
// EntityKind → 中文。
@@ -28,3 +36,81 @@ export function reasonLabel(reason: string): string {
export function kindLabel(kind: string): string {
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 };