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:
@@ -1,6 +1,25 @@
|
||||
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", () => {
|
||||
it("maps each known selection reason to a Chinese badge", () => {
|
||||
@@ -8,6 +27,7 @@ describe("reasonLabel", () => {
|
||||
expect(reasonLabel("main_character")).toBe("主角常驻");
|
||||
expect(reasonLabel("recent_digest")).toBe("近章出现");
|
||||
expect(reasonLabel("foreshadow_window")).toBe("伏笔窗口");
|
||||
expect(reasonLabel("author_pin")).toBe("作者置顶");
|
||||
});
|
||||
|
||||
it("falls back to the raw code for an unknown reason", () => {
|
||||
@@ -25,3 +45,59 @@ describe("kindLabel", () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -1,24 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
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 {
|
||||
data: InjectionResponse | null;
|
||||
loading: boolean;
|
||||
saving: boolean;
|
||||
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。
|
||||
// 只读、无 LLM、无 commit——展示「选了谁/为什么」。失败给可读文案不抛。
|
||||
export function useInjection(
|
||||
projectId: string,
|
||||
chapterNo: number,
|
||||
): UseInjection {
|
||||
const INJECTION_PATH =
|
||||
"/projects/{project_id}/chapters/{chapter_no}/injection" as const;
|
||||
|
||||
// 本章注入透明(B0 可控版):读确定性 SelectionTrace + 作者覆盖;
|
||||
// pin/排除/recent_n 经 PUT 覆盖 → 服务端回放新的确定结果(看到的=写章用的)。
|
||||
// 写失败给可读文案、不改本地状态(天然回滚),不抛。
|
||||
export function useInjection(projectId: string, chapterNo: number): UseInjection {
|
||||
const [data, setData] = useState<InjectionResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -27,10 +43,9 @@ export function useInjection(
|
||||
setError(null);
|
||||
|
||||
void (async () => {
|
||||
const { data: body, error: err } = await api.GET(
|
||||
"/projects/{project_id}/chapters/{chapter_no}/injection",
|
||||
{ params: { path: { project_id: projectId, chapter_no: chapterNo } } },
|
||||
);
|
||||
const { data: body, error: err } = await api.GET(INJECTION_PATH, {
|
||||
params: { path: { project_id: projectId, chapter_no: chapterNo } },
|
||||
});
|
||||
if (cancelled) return;
|
||||
if (err || !body) {
|
||||
setError("注入信息暂不可用");
|
||||
@@ -46,5 +61,49 @@ export function useInjection(
|
||||
};
|
||||
}, [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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user