主人报障「面板上点不到『应用』」→ 后端 TIDY-1 的五条端点(契约 v0.83 B20–B24)
此前只能 curl 驱动。本片补齐界面:候选表(动作/状态/摘要/理由/时间 + 行内
应用/回滚,回滚仅在 status=applied 时可点)、status 筛选、批量应用全部待定、
手动真跑一轮(两个写动作都走二次确认)。
三条契约硬口径落到界面:
- 幂等不是错误:skipped 用中性色,绝不渲染成失败;
- 超限必须如实:truncated=true 时黄牌「还有 N 条待定未处理」,且该分支绝不
渲染「已全部处理」;未处理量只由后端权威字段算得(total - 已处理三项);
- 权威键只读:状态与计数全部取自响应,不做「应该成功了」的推断。
一个刻意的偏离(有测试钉住):截断提示不用共用 Notice tone=warn —— 那个组件
的固定前缀是「待实现」,贴在「还有 N 条未处理」上会被读成「功能还没做」。
验收(主会话复跑 + 亲自变异):tsc --noEmit 0 错;vite build 通过;
渲染测试 43 checks、传输层测试 28 checks 全绿;我把 truncated 判断取反 ⇒
渲染测试红在「截断时必须显式点明命中数大于本轮处理数」,还原后 md5 一致且绿。
⚠️ 未跑真机浏览器交互(需 owner 凭据 + 真后端),证据止于渲染层与传输层。
241 lines
8.0 KiB
TypeScript
241 lines
8.0 KiB
TypeScript
import { useState } from "react";
|
||
import {
|
||
TIDY_STATUS_PENDING,
|
||
adminTidyCandidateApply,
|
||
adminTidyCandidateRevert,
|
||
adminTidyCandidates,
|
||
adminTidyCandidatesApplyBatch,
|
||
adminTidyRun,
|
||
} from "../../lib/api";
|
||
import type {
|
||
AdminTidyApplyBatch,
|
||
AdminTidyApplyResult,
|
||
AdminTidyCandidatesResponse,
|
||
AdminTidyRunResult,
|
||
} from "../../lib/types";
|
||
import { useAsyncData } from "../../lib/useAsyncData";
|
||
import { useAuditStore } from "../audit";
|
||
|
||
/**
|
||
* 记忆整理「候选」卡的**取数与写动作**半边(切片 TIDY-1),与展示半边
|
||
* (`MemoryTidyApplyCard.tsx`)分开,只为守 AGENTS §10 的文件/函数长度上限。
|
||
*
|
||
* 三条口径落在这里,而不是散在组件里:
|
||
* 1. **权威键只读**:本模块只把响应原样交给状态(`batch` / `run` / `last` / 列表),
|
||
* 从不合成计数、从不把「我点了按钮」当成「它成功了」—— 界面显示的一切都来自后端。
|
||
* 2. **筛选即查询条件**:`status` 一变就重取,且**旧数据不冒充新筛选的结果**。
|
||
* 3. **写动作四件套**(单条应用 / 单条回滚 / 批量应用 / 真跑)共用一份 busy + 失败 +
|
||
* 客户端审计收尾:失败文案取后端 `error.message`,不自己编。
|
||
*
|
||
* 后端另有权威审计(`memory.tidy.apply` / `memory.tidy.revert` / `memory.tidy.run`),
|
||
* 这里记的是面板侧可见的那一份(与既有 `memory.add` / `llm.resetFromEnv` 同惯例)。
|
||
*/
|
||
|
||
/** 二次确认展开的是哪个写动作。 */
|
||
export type TidyConfirmKind = "batch" | "run";
|
||
|
||
/** 候选列表的取数态。 */
|
||
export type TidyCandidateListState =
|
||
| { kind: "loading" }
|
||
| { kind: "ready"; data: AdminTidyCandidatesResponse }
|
||
| { kind: "error"; message: string };
|
||
|
||
/** 应用卡的完整视图态(纯数据;真实渲染测试直接喂)。 */
|
||
export interface TidyApplyViewState {
|
||
status: string;
|
||
list: TidyCandidateListState;
|
||
/** 正在进行的动作(`apply:<id>` / `revert:<id>` / `batch` / `run`),null = 空闲。 */
|
||
busy: string | null;
|
||
confirming: TidyConfirmKind | null;
|
||
/** 最近一次批量应用回执(计数的**唯一**来源)。 */
|
||
batch: AdminTidyApplyBatch | null;
|
||
/** 最近一次手动真跑回执。 */
|
||
run: AdminTidyRunResult | null;
|
||
/** 最近一次单条应用/回滚回执。 */
|
||
last: AdminTidyApplyResult | null;
|
||
/** 写动作的失败文案(后端 message)。 */
|
||
fail: string | null;
|
||
}
|
||
|
||
/** 视图态的六个交互出口(展示半边只调它们,不碰 API)。 */
|
||
export interface TidyApplyActions {
|
||
onStatusChange: (status: string) => void;
|
||
onApply: (id: string) => void;
|
||
onRevert: (id: string) => void;
|
||
/** 展开某个写动作的二次确认(此时还不发请求)。 */
|
||
onRequest: (kind: TidyConfirmKind) => void;
|
||
/** 确认落刀(批量应用 / 真跑由 `state.confirming` 决定是哪一个)。 */
|
||
onConfirm: () => void;
|
||
onCancelConfirm: () => void;
|
||
}
|
||
|
||
/** 写动作那一半的出口(`onStatusChange` 属于筛选的持有者,不在这里)。 */
|
||
type TidyWriteActions = Omit<TidyApplyActions, "onStatusChange">;
|
||
|
||
/** 一次写动作的收尾器:busy 标记 + 失败文案 + 客户端审计 + 成功收起确认。 */
|
||
type TidyAct = (label: string, audit: string, task: () => Promise<void>) => void;
|
||
|
||
/** useTidyCandidateList 取候选列表,并把「屏幕上这批数据属于哪次请求」记在
|
||
* `dataStatus` 里:切筛选时旧数据**不许**冒充新筛选的结果(在途期间只显示加载态)。
|
||
*
|
||
* `dataStatus` 在**成功与失败**两条路上都记 —— 否则新筛选请求失败时它会停在旧值,
|
||
* 界面会永远卡在「加载中」,把失败也一起藏掉。 */
|
||
function useTidyCandidateList(status: string): {
|
||
list: TidyCandidateListState;
|
||
reload: () => void;
|
||
} {
|
||
const [dataStatus, setDataStatus] = useState<string>(status);
|
||
const fetchList = (): Promise<AdminTidyCandidatesResponse> => {
|
||
const requested = status;
|
||
return adminTidyCandidates({ status: requested }).then(
|
||
(res) => {
|
||
setDataStatus(requested);
|
||
return res;
|
||
},
|
||
(e: unknown) => {
|
||
setDataStatus(requested);
|
||
throw e;
|
||
}
|
||
);
|
||
};
|
||
const { data, loading, error, reload } = useAsyncData(fetchList, [status]);
|
||
const inFlight = dataStatus !== status || (loading && data === null);
|
||
const list: TidyCandidateListState = inFlight
|
||
? { kind: "loading" }
|
||
: error
|
||
? { kind: "error", message: error }
|
||
: data
|
||
? { kind: "ready", data }
|
||
: { kind: "loading" };
|
||
return { list, reload };
|
||
}
|
||
|
||
/** runConfirmed 落刀二次确认里的那个写动作(批量应用 / 真跑),回执存进状态。 */
|
||
function runConfirmed(
|
||
kind: TidyConfirmKind,
|
||
act: TidyAct,
|
||
store: {
|
||
setBatch: (batch: AdminTidyApplyBatch) => void;
|
||
setRun: (run: AdminTidyRunResult) => void;
|
||
reload: () => void;
|
||
}
|
||
): void {
|
||
if (kind === "run") {
|
||
act("run", "memory.tidy.run", () =>
|
||
adminTidyRun().then((res) => {
|
||
store.setRun(res);
|
||
store.reload();
|
||
})
|
||
);
|
||
return;
|
||
}
|
||
act("batch", "memory.tidy.applyBatch", () =>
|
||
adminTidyCandidatesApplyBatch().then((res) => {
|
||
store.setBatch(res);
|
||
store.reload();
|
||
})
|
||
);
|
||
}
|
||
|
||
/** useTidyWrites 持有四个写动作的状态(busy / 二次确认 / 三种回执 / 失败)并把它们
|
||
* 装配成交互出口。`act` 是四者共用的收尾,四个动作一字排开只差调哪个端点。 */
|
||
function useTidyWrites(reload: () => void): {
|
||
busy: string | null;
|
||
confirming: TidyConfirmKind | null;
|
||
batch: AdminTidyApplyBatch | null;
|
||
run: AdminTidyRunResult | null;
|
||
last: AdminTidyApplyResult | null;
|
||
fail: string | null;
|
||
actions: TidyWriteActions;
|
||
reset: () => void;
|
||
} {
|
||
const [busy, setBusy] = useState<string | null>(null);
|
||
const [confirming, setConfirming] = useState<TidyConfirmKind | null>(null);
|
||
const [batch, setBatch] = useState<AdminTidyApplyBatch | null>(null);
|
||
const [run, setRun] = useState<AdminTidyRunResult | null>(null);
|
||
const [last, setLast] = useState<AdminTidyApplyResult | null>(null);
|
||
const [fail, setFail] = useState<string | null>(null);
|
||
const log = useAuditStore((s) => s.log);
|
||
|
||
const act: TidyAct = (action, audit, task) => {
|
||
setBusy(action);
|
||
setFail(null);
|
||
task()
|
||
.then(() => {
|
||
log(audit, "ok");
|
||
setConfirming(null);
|
||
})
|
||
.catch((e: unknown) => {
|
||
log(audit, "error");
|
||
setFail(e instanceof Error ? e.message : "操作失败");
|
||
})
|
||
.finally(() => setBusy(null));
|
||
};
|
||
|
||
const confirmKind = confirming;
|
||
const actions: TidyWriteActions = {
|
||
onApply: (id) =>
|
||
act(`apply:${id}`, "memory.tidy.apply", () =>
|
||
adminTidyCandidateApply(id).then((res) => {
|
||
setLast(res);
|
||
reload();
|
||
})
|
||
),
|
||
onRevert: (id) =>
|
||
act(`revert:${id}`, "memory.tidy.revert", () =>
|
||
adminTidyCandidateRevert(id).then((res) => {
|
||
setLast(res);
|
||
reload();
|
||
})
|
||
),
|
||
onRequest: (kind) => setConfirming(kind),
|
||
onCancelConfirm: () => setConfirming(null),
|
||
onConfirm: () => {
|
||
if (confirmKind) runConfirmed(confirmKind, act, { setBatch, setRun, reload });
|
||
},
|
||
};
|
||
|
||
return {
|
||
busy,
|
||
confirming,
|
||
batch,
|
||
run,
|
||
last,
|
||
fail,
|
||
actions,
|
||
reset: () => {
|
||
setLast(null);
|
||
setFail(null);
|
||
},
|
||
};
|
||
}
|
||
|
||
/** useTidyApplyState 把两半拼成候选卡的状态与出口。 */
|
||
export function useTidyApplyState(): {
|
||
state: TidyApplyViewState;
|
||
actions: TidyApplyActions;
|
||
} {
|
||
const [status, setStatus] = useState<string>(TIDY_STATUS_PENDING);
|
||
const { list, reload } = useTidyCandidateList(status);
|
||
const writes = useTidyWrites(reload);
|
||
return {
|
||
state: {
|
||
status,
|
||
list,
|
||
busy: writes.busy,
|
||
confirming: writes.confirming,
|
||
batch: writes.batch,
|
||
run: writes.run,
|
||
last: writes.last,
|
||
fail: writes.fail,
|
||
},
|
||
actions: {
|
||
...writes.actions,
|
||
onStatusChange: (next) => {
|
||
setStatus(next);
|
||
writes.reset();
|
||
},
|
||
},
|
||
};
|
||
}
|