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

@@ -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 };
}