Files
writer-work-flow/apps/web/lib/workbench/injection.ts
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

117 lines
3.8 KiB
TypeScript
Raw Permalink 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.

// 本章注入透明B0/F1纯逻辑入选理由 / 实体类型 → 中文徽标。
// 纯函数,便于 node 环境单测;渲染层只管样式。
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"];
// 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 → 中文。
export const KIND_LABELS: Record<string, string> = {
character: "角色",
world_entity: "设定",
};
// 未知 code 时回退原值(不吞,便于发现新理由)。
export function reasonLabel(reason: string): string {
return REASON_LABELS[reason] ?? reason;
}
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 };