feat(admin): 记忆整理候选的应用/回滚操作面(切片 TIDY-FE)
主人报障「面板上点不到『应用』」→ 后端 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 凭据 + 真后端),证据止于渲染层与传输层。
This commit is contained in:
1 parent
aa0bf8b5a0
commit
c512609c63
9 files changed
+1370
No files matched your search
@@ -8,6 +8,7 @@ import { AdminTable, Notice, PageHeader, type AdminColumn } from "../components"
|
||||
import { Button, Surface, Tag } from "../../components/primitives";
|
||||
import { MemoryTidyFactsCard } from "./ConfigFactsCards";
|
||||
import { MemoryTidyDryRunCard } from "./MemoryTidyDryRunCard";
|
||||
import { MemoryTidyApplyCard } from "./MemoryTidyApplyCard";
|
||||
|
||||
const scopeTone: Record<string, "ok" | "warn" | "accent" | "neutral"> = {
|
||||
user: "accent",
|
||||
@@ -188,6 +189,8 @@ export function MemoriesModule(): React.ReactNode {
|
||||
|
||||
<MemoryTidyDryRunCard />
|
||||
|
||||
<MemoryTidyApplyCard />
|
||||
|
||||
<MemoryTidyFactsCard />
|
||||
|
||||
<Notice>
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
// 记忆整理**候选应用卡**的真实渲染测试(切片 TIDY-1)。
|
||||
//
|
||||
// 为什么需要它:这张卡是「整理建议的执行面」,而修好之后最容易走样的三条口径
|
||||
// 都是**呈现**:
|
||||
// ① 批量回执被上限截断时,必须如实说出「命中多少 / 本轮处理多少 / 还剩多少未
|
||||
// 处理」—— 绝不能画成「全部完成」(否则主人会以为 342 条都处理完了);
|
||||
// ② `skipped` 是**幂等命中/动作不可应用**,不是错误 —— 界面**不许报红**;
|
||||
// ③ 行的两个按钮按**后端 status** 决定可用性(pending 行的回滚必须禁用),
|
||||
// 未知状态原样显示、不吞、不猜。
|
||||
//
|
||||
// 跑法(与 ConfigFactsCards.render.test.ts / MemoryTidyDryRunCard.render.test.ts 同):
|
||||
//
|
||||
// cd frontend/web \
|
||||
// && ./node_modules/.bin/esbuild src/admin/modules/MemoryTidyApplyCard.render.test.ts \
|
||||
// --bundle --platform=node --format=cjs \
|
||||
// --outfile=/tmp/cyrene-memory-tidy-apply.render.test.cjs \
|
||||
// && node /tmp/cyrene-memory-tidy-apply.render.test.cjs
|
||||
//
|
||||
// 🔴 渲染类测试必须 `--format=cjs`:`--format=esm` 会整批崩在 bundle 深处。
|
||||
// `tsc --noEmit` 会对本文件做类型检查,因此它只从项目内导入、不 import node:*。
|
||||
|
||||
import { createElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { TIDY_STATUS_APPLIED, TIDY_STATUS_PENDING } from "../../lib/api";
|
||||
import type {
|
||||
AdminTidyApplyBatch,
|
||||
AdminTidyCandidateRecord,
|
||||
AdminTidyRunResult,
|
||||
} from "../../lib/types";
|
||||
import {
|
||||
MemoryTidyApplyBody,
|
||||
TIDY_APPLY_ALL_DONE_TAG,
|
||||
TIDY_APPLY_SKIPPED_HINT,
|
||||
TIDY_APPLY_TRUNCATED_HINT,
|
||||
TIDY_CANDIDATE_LIST_ERROR_HINT,
|
||||
} from "./MemoryTidyApplyCard";
|
||||
import type { TidyApplyActions, TidyApplyViewState } from "./memoryTidyApplyState";
|
||||
|
||||
let checks = 0;
|
||||
|
||||
function ok(cond: boolean, what: string): void {
|
||||
checks += 1;
|
||||
if (!cond) throw new Error(`FAILED: ${what}`);
|
||||
}
|
||||
|
||||
function has(haystack: string, needle: string): boolean {
|
||||
return haystack.includes(needle);
|
||||
}
|
||||
|
||||
function countOf(haystack: string, needle: string): number {
|
||||
return haystack.split(needle).length - 1;
|
||||
}
|
||||
|
||||
/** 按钮的禁用属性在静态标记里的形态(`disabled:opacity-50` 这个**类名**里也有
|
||||
* "disabled" 字样,因此只能按属性形态 `disabled=""` 计数)。 */
|
||||
const DISABLED_ATTR = 'disabled=""';
|
||||
|
||||
/** 动作替身:展示体只调这些出口,测试不关心它们做什么。 */
|
||||
const NOOP_ACTIONS: TidyApplyActions = {
|
||||
onStatusChange: () => {},
|
||||
onApply: () => {},
|
||||
onRevert: () => {},
|
||||
onRequest: () => {},
|
||||
onConfirm: () => {},
|
||||
onCancelConfirm: () => {},
|
||||
};
|
||||
|
||||
const PENDING: AdminTidyCandidateRecord = {
|
||||
id: "cand-pending-1",
|
||||
action: "merge",
|
||||
status: "pending",
|
||||
sourceIds: ["mem-short"],
|
||||
targetId: "mem-long",
|
||||
personaId: "cyrene",
|
||||
scope: "persona",
|
||||
kind: "relationship",
|
||||
proposedContent: "主人常在深夜与助手聊天,有熬夜习惯",
|
||||
mergedContent: "主人常在深夜与助手聊天,有熬夜习惯,作息偏晚",
|
||||
reason: "containment_a_subset_b",
|
||||
ruleVersion: "t0.1",
|
||||
runId: "run-1",
|
||||
scoreMin: 0.95,
|
||||
scoreMax: 0.95,
|
||||
createdAt: "2026-10-05T10:18:10Z",
|
||||
};
|
||||
|
||||
const APPLIED: AdminTidyCandidateRecord = { ...PENDING, id: "cand-applied-1", status: "applied" };
|
||||
|
||||
/** 后端候选状态闭集只有 pending / applied;这个 fixture 用的是**闭集外**的值,
|
||||
* 用来钉住「未知值原样显示、不猜」。 */
|
||||
const UNKNOWN: AdminTidyCandidateRecord = { ...PENDING, id: "cand-weird-1", status: "reverted" };
|
||||
|
||||
function viewState(patch: Partial<TidyApplyViewState> = {}): TidyApplyViewState {
|
||||
return {
|
||||
status: TIDY_STATUS_PENDING,
|
||||
list: { kind: "ready", data: { candidates: [PENDING], count: 1 } },
|
||||
busy: null,
|
||||
confirming: null,
|
||||
batch: null,
|
||||
run: null,
|
||||
last: null,
|
||||
fail: null,
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
|
||||
function withCandidates(candidate: AdminTidyCandidateRecord, status = TIDY_STATUS_PENDING): TidyApplyViewState {
|
||||
return viewState({ status, list: { kind: "ready", data: { candidates: [candidate], count: 1 } } });
|
||||
}
|
||||
|
||||
function render(state: TidyApplyViewState): string {
|
||||
return renderToStaticMarkup(createElement(MemoryTidyApplyBody, { state, actions: NOOP_ACTIONS }));
|
||||
}
|
||||
|
||||
// ── ① 候选表:动作 / 状态 / 摘要 / 理由 / 时间 + 每行两个动作 ────────────────
|
||||
const pendingHtml = render(withCandidates(PENDING));
|
||||
ok(has(pendingHtml, "并入"), "merge 要显示中文标签「并入」");
|
||||
ok(has(pendingHtml, "待定"), "状态要按后端值显示「待定」");
|
||||
ok(has(pendingHtml, "作息偏晚"), "摘要要显示存活条目原文(diff 预览)");
|
||||
ok(has(pendingHtml, "containment_a_subset_b"), "必须显示机器可读理由");
|
||||
ok(has(pendingHtml, "2026-10-05 10:18"), "必须显示候选产生时间(RFC3339 裁到分)");
|
||||
ok(has(pendingHtml, "应用") && has(pendingHtml, "回滚"), "每行必须有「应用」与「回滚」两个按钮");
|
||||
|
||||
// ── ② 回滚只在 status=applied 时可用(按后端 status,不按「我记得点过」)───────
|
||||
const pendingDisabled = countOf(pendingHtml, DISABLED_ATTR);
|
||||
ok(pendingDisabled === 1, `待定行只有回滚该禁用(实际 disabled 属性出现 ${pendingDisabled} 次)`);
|
||||
const appliedHtml = render(withCandidates(APPLIED, TIDY_STATUS_APPLIED));
|
||||
ok(has(appliedHtml, "已应用"), "已应用行要显示「已应用」");
|
||||
const appliedDisabled = countOf(appliedHtml, DISABLED_ATTR);
|
||||
ok(appliedDisabled === 0, `已应用行的回滚必须可点(实际 disabled 属性出现 ${appliedDisabled} 次)`);
|
||||
const unknownHtml = render(withCandidates(UNKNOWN));
|
||||
ok(has(unknownHtml, "reverted"), "闭集外的状态值必须原样显示(绝不吞掉)");
|
||||
ok(countOf(unknownHtml, DISABLED_ATTR) === 1, "未知状态按「非已应用」处理:回滚禁用");
|
||||
|
||||
// ── ③ 批量回执:truncated 时必须如实显示「还剩多少未处理」 ──────────────────
|
||||
const BATCH_TRUNCATED: AdminTidyApplyBatch = {
|
||||
limit: 100,
|
||||
total: 342,
|
||||
truncated: true,
|
||||
applied: 90,
|
||||
skipped: 10,
|
||||
failed: 0,
|
||||
merged: 80,
|
||||
archived: 10,
|
||||
reclassified: 0,
|
||||
results: [],
|
||||
};
|
||||
const truncatedHtml = render(viewState({ batch: BATCH_TRUNCATED }));
|
||||
ok(has(truncatedHtml, TIDY_APPLY_TRUNCATED_HINT), "截断时必须显式点明「命中数大于本轮处理数」");
|
||||
ok(has(truncatedHtml, "命中待定 342 条"), "必须显示命中的**真**总数(不是 len(results))");
|
||||
ok(has(truncatedHtml, "本轮处理 100 条"), "必须显示本轮真正处理了多少条");
|
||||
ok(has(truncatedHtml, "还有 242 条待定未处理"), "必须算出并显示未处理量(342 - 100)");
|
||||
ok(!has(truncatedHtml, TIDY_APPLY_ALL_DONE_TAG), "截断时绝不得渲染「已全部处理」标记");
|
||||
ok(!has(truncatedHtml, "待实现"), "截断提示不得带「待实现」前缀(那会被读成这功能还没做)");
|
||||
|
||||
const BATCH_DONE: AdminTidyApplyBatch = { ...BATCH_TRUNCATED, limit: 100, total: 5, applied: 3, skipped: 2, failed: 0, truncated: false };
|
||||
const doneHtml = render(viewState({ batch: BATCH_DONE }));
|
||||
ok(has(doneHtml, TIDY_APPLY_ALL_DONE_TAG), "未截断时才可以说「待定候选已全部处理」");
|
||||
ok(!has(doneHtml, "未处理"), "未截断时不得出现「未处理」字样(否则那句话是假的)");
|
||||
|
||||
// ── ④ skipped 不显示为错误 ────────────────────────────────────────────────
|
||||
const skippedHtml = render(
|
||||
viewState({
|
||||
batch: BATCH_DONE,
|
||||
last: {
|
||||
candidateId: "cand-link-9",
|
||||
action: "link",
|
||||
status: "skipped",
|
||||
reason: "action_not_applyable",
|
||||
affectedIds: [],
|
||||
},
|
||||
})
|
||||
);
|
||||
ok(has(skippedHtml, TIDY_APPLY_SKIPPED_HINT), "skipped 必须显式说明「不是错误」");
|
||||
ok(has(skippedHtml, "已跳过"), "单条回执要按后端 status 显示「已跳过」");
|
||||
ok(has(skippedHtml, "reason=action_not_applyable"), "必须带上机器可读的跳过原因");
|
||||
ok(!has(skippedHtml, "text-[var(--mu-err)]"), "skipped 绝不得用错误色渲染(界面不许报红)");
|
||||
ok(!has(skippedHtml, "应用失败"), "skipped 不得被呈现成失败");
|
||||
|
||||
// 反向哨兵:确有 failed 时错误色**必须**出现 —— 否则上面那条否定断言是空的。
|
||||
const failedHtml = render(
|
||||
viewState({ batch: { ...BATCH_DONE, applied: 2, skipped: 0, failed: 1, total: 3 } })
|
||||
);
|
||||
ok(has(failedHtml, "text-[var(--mu-err)]"), "失败计数非 0 时必须出现错误色(否定断言的对照)");
|
||||
ok(countOf(failedHtml, TIDY_APPLY_ALL_DONE_TAG) === 1, "有失败但未截断:完成标记仍按 truncated=false 显示");
|
||||
|
||||
// ── ⑤ 列表读失败 ≠ 没有候选 ──────────────────────────────────────────────
|
||||
const listErrHtml = render(
|
||||
viewState({ list: { kind: "error", message: "tidy_unavailable:记忆整理候选未接线" } })
|
||||
);
|
||||
ok(has(listErrHtml, "tidy_unavailable"), "必须显示后端给的原因");
|
||||
ok(has(listErrHtml, TIDY_CANDIDATE_LIST_ERROR_HINT), "要说清「没读到」不是「没有候选」");
|
||||
ok(!has(listErrHtml, "没有待定候选"), "读失败时绝不得渲染空态文案(把失败当结论)");
|
||||
ok(has(render(viewState({ list: { kind: "loading" } })), "加载候选…"), "取数中要显示加载态");
|
||||
|
||||
// ── ⑥ 两个写动作必须先二次确认 ────────────────────────────────────────────
|
||||
const idleHtml = render(viewState());
|
||||
ok(has(idleHtml, "批量应用全部待定") && has(idleHtml, "手动真跑一轮"), "顶部必须给出两个写动作");
|
||||
ok(!has(idleHtml, "确认批量应用"), "没点按钮时不得出现确认块");
|
||||
const confirmBatchHtml = render(viewState({ confirming: "batch" }));
|
||||
ok(has(confirmBatchHtml, "确认批量应用"), "批量应用要先展开二次确认");
|
||||
ok(has(confirmBatchHtml, "行永不物理删除"), "确认文案要说清软动作与可回滚");
|
||||
const confirmRunHtml = render(viewState({ confirming: "run" }));
|
||||
ok(has(confirmRunHtml, "确认真跑一轮"), "真跑也要先二次确认");
|
||||
ok(has(confirmRunHtml, "不自动应用"), "真跑确认文案要说清「不自动应用」");
|
||||
|
||||
// ── ⑦ 手动真跑回执:建议侧计数 + 只产候选 ──────────────────────────────────
|
||||
const RUN: AdminTidyRunResult = {
|
||||
runId: "run-20261005-1018",
|
||||
trigger: "manual",
|
||||
ruleVersion: "t0.1",
|
||||
durationMs: 1234,
|
||||
partitions: 3,
|
||||
scanned: 736,
|
||||
compared: 400,
|
||||
pairs: 12,
|
||||
vetoed: 4,
|
||||
errors: 0,
|
||||
truncated: [],
|
||||
vetoReasons: { scope: 2 },
|
||||
candidatesCreated: 42,
|
||||
candidatesDuplicate: 7,
|
||||
candidatesByAction: { merge: 30, link: 12 },
|
||||
};
|
||||
const runHtml = render(viewState({ run: RUN }));
|
||||
ok(has(runHtml, "只产候选,未自动应用"), "真跑回执必须点明不自动应用");
|
||||
ok(has(runHtml, "run-20261005-1018"), "必须显示 runId(批量应用按轮次要用它)");
|
||||
ok(has(runHtml, "新增候选 42 条"), "必须显示新建候选数");
|
||||
ok(has(runHtml, "幂等去重 7 条"), "必须显示被幂等键挡下的数量");
|
||||
ok(has(runHtml, "并入=30") && has(runHtml, "只建链接=12"), "按动作分布要用人话标签");
|
||||
const runErrHtml = render(viewState({ run: { ...RUN, errors: 2 } }));
|
||||
ok(has(runErrHtml, "本轮有 2 处失败"), "真跑里有失败必须如实显示");
|
||||
|
||||
// ── ⑧ 写动作失败要显示后端文案(不自己编)──────────────────────────────────
|
||||
const failHtml = render(viewState({ fail: "tidy_disabled:记忆整理已被配置关闭" }));
|
||||
ok(has(failHtml, "tidy_disabled"), "写动作失败要显示后端 message");
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`MemoryTidyApplyCard.render: ${checks} checks passed`);
|
||||
@@ -0,0 +1,448 @@
|
||||
import {
|
||||
TIDY_APPLY_STATUS_APPLIED,
|
||||
TIDY_APPLY_STATUS_FAILED,
|
||||
TIDY_APPLY_STATUS_SKIPPED,
|
||||
TIDY_STATUS_APPLIED,
|
||||
TIDY_STATUS_PENDING,
|
||||
} from "../../lib/api";
|
||||
import type {
|
||||
AdminTidyApplyBatch,
|
||||
AdminTidyApplyResult,
|
||||
AdminTidyCandidateRecord,
|
||||
AdminTidyRunResult,
|
||||
} from "../../lib/types";
|
||||
import { Button, Surface, Tag } from "../../components/primitives";
|
||||
import { AdminTable, Notice, type AdminColumn } from "../components";
|
||||
import {
|
||||
useTidyApplyState,
|
||||
type TidyApplyActions,
|
||||
type TidyApplyViewState,
|
||||
type TidyConfirmKind,
|
||||
} from "./memoryTidyApplyState";
|
||||
|
||||
/**
|
||||
* 记忆整理「候选」卡的**展示**半边(切片 TIDY-1;取数与写动作在
|
||||
* `memoryTidyApplyState.ts`,拆开只为守 AGENTS §10 的文件行数上限)。
|
||||
*
|
||||
* 动因是实测缺口:后端已经有候选列表 / 单条应用 / 批量应用 / 单条回滚 / 手动真跑,
|
||||
* 面板却只有一张只读试算卡 —— 于是候选全 pending、一条都应用不了。本卡把「点得到」
|
||||
* 这件事补上,四条不可走样的口径:
|
||||
*
|
||||
* 1. **权威键只读**:一切状态与计数只从响应里取。界面**不**推断「应该成功了」——
|
||||
* 单条回执按 `status` 如实显示 applied / skipped / failed;批量回执按
|
||||
* `applied/skipped/failed/total/truncated` 如实显示。
|
||||
* 2. **skipped 不是错误**:幂等命中(已决定过)与 link/keep(本就没有执行语义)
|
||||
* 都是 200,面板用中性标签显示,**绝不报红**。
|
||||
* 3. **超限绝不静默**:`truncated=true` 时必须说出「命中多少 / 本轮处理多少 /
|
||||
* 还剩多少未处理」,不许画出「全部完成」的样子。
|
||||
* 4. **回滚只在已应用时可用**:按候选 `status` 决定按钮可用性(不是按「我记得刚
|
||||
* 点过应用」)。未知值一律原样显示,不猜、不吞。
|
||||
*
|
||||
* 展示与取数分层:`MemoryTidyApplyBody` 只吃状态(真实渲染测试直接喂 fixture),
|
||||
* `MemoryTidyApplyCard` 接取数钩子。
|
||||
*/
|
||||
|
||||
/** 诚实文案:这张卡会真的改记忆(与试算卡的「一行都不写」相对)。 */
|
||||
export const TIDY_APPLY_DISCLAIMER = "会真的改动记忆(软合并 / 归档 / 改分类,可回滚)";
|
||||
|
||||
/** 列表读失败时的说明:区分「没读到」与「没有候选」。 */
|
||||
export const TIDY_CANDIDATE_LIST_ERROR_HINT =
|
||||
"这是「没读到」,不是「没有候选」—— 整理写面未接线时后端回 500 tidy_unavailable,绝不回空列表";
|
||||
|
||||
/** 批量回执被上限截断时的定性(测试钉住它必须出现)。 */
|
||||
export const TIDY_APPLY_TRUNCATED_HINT = "命中数大于本轮处理数";
|
||||
|
||||
/** 批量回执「处理完了」的标记 —— 只有 `truncated=false` 时才可以出现。 */
|
||||
export const TIDY_APPLY_ALL_DONE_TAG = "待定候选已全部处理";
|
||||
|
||||
/** skipped 的定性(幂等 / 不可应用都不是失败)。 */
|
||||
export const TIDY_APPLY_SKIPPED_HINT =
|
||||
"跳过不是错误:已决定过的候选重复应用会幂等跳过,link/keep 本就没有执行语义";
|
||||
|
||||
/** 批量应用的二次确认文案(写动作走既有确认惯例)。 */
|
||||
const CONFIRM_BATCH_HINT =
|
||||
"会把全部待定候选真正落地:merge = 软合并(被吸收行标记 merged,正文一个字都不改)、archive = 软删(行保留)、reclassify = 改 kind。行永不物理删除,因此每一步都能回滚。确定继续?";
|
||||
|
||||
/** 手动真跑的二次确认文案。 */
|
||||
const CONFIRM_RUN_HINT =
|
||||
"立刻按与低峰轮同一套规则与配置跑一轮:写候选 + 写运行留痕,不自动应用任何候选。跑完在下面的待定列表里逐条决定。确定继续?";
|
||||
|
||||
const actionLabel: Record<string, string> = {
|
||||
merge: "并入",
|
||||
link: "只建链接",
|
||||
archive: "归档",
|
||||
reclassify: "改分类",
|
||||
};
|
||||
|
||||
const actionTone: Record<string, "ok" | "warn" | "accent" | "neutral"> = {
|
||||
merge: "warn",
|
||||
link: "accent",
|
||||
archive: "neutral",
|
||||
reclassify: "neutral",
|
||||
};
|
||||
|
||||
/** 候选状态的中文标签(真源只有 pending / applied;未知值原样显示)。 */
|
||||
const statusLabel: Record<string, string> = {
|
||||
pending: "待定",
|
||||
applied: "已应用",
|
||||
};
|
||||
|
||||
const statusTone: Record<string, "ok" | "warn" | "accent" | "neutral"> = {
|
||||
pending: "accent",
|
||||
applied: "ok",
|
||||
};
|
||||
|
||||
/** 未知值原样显示(后端加值不能丢信息)。 */
|
||||
function label(table: Record<string, string>, value: string): string {
|
||||
return table[value] ?? value;
|
||||
}
|
||||
|
||||
/** shortTime 把 RFC3339 裁成「日期 + 时分」(秒与时区对判读候选新旧没有信息量)。 */
|
||||
function shortTime(iso: string): string {
|
||||
return iso.length >= 16 ? `${iso.slice(0, 10)} ${iso.slice(11, 16)}` : iso;
|
||||
}
|
||||
|
||||
/** WarnNote 是**黄牌提示**:截断 / 未处理完这类「真实但不致命」的话用它。
|
||||
*
|
||||
* ⚠️ 刻意**不用**共用的 `Notice tone="warn"` —— 那个组件会把 tone 渲染成固定前缀
|
||||
* 「待实现」,贴在「本轮还有 N 条未处理」上会被读成「这功能还没做」(措辞失实)。
|
||||
* 真失败仍走 `Notice tone="err"`(它的「错误」前缀是对的)。 */
|
||||
function WarnNote({ children }: { children: React.ReactNode }): React.ReactNode {
|
||||
return (
|
||||
<div className="rounded-[var(--mu-radius)] border border-[var(--mu-warn)]/40 bg-[var(--mu-warn)]/10 px-3 py-2 text-xs text-[var(--mu-text-mid)]">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 候选动作标签。 */
|
||||
function ActionTag({ action }: { action: string }): React.ReactNode {
|
||||
return <Tag tone={actionTone[action] ?? "neutral"}>{label(actionLabel, action)}</Tag>;
|
||||
}
|
||||
|
||||
/** 候选状态标签(只按后端给的值渲染,不回推)。 */
|
||||
function StatusTag({ status }: { status: string }): React.ReactNode {
|
||||
return <Tag tone={statusTone[status] ?? "neutral"}>{label(statusLabel, status)}</Tag>;
|
||||
}
|
||||
|
||||
/** 摘要单元格:优先存活条目原文,其次被吸收条目原文,截断显示(title 给全文)。 */
|
||||
function SummaryCell({ candidate }: { candidate: AdminTidyCandidateRecord }): React.ReactNode {
|
||||
const text = candidate.mergedContent || candidate.proposedContent || "";
|
||||
if (!text) {
|
||||
return <span className="text-[11px] text-[var(--mu-text-lo)]">(该动作无正文预览)</span>;
|
||||
}
|
||||
return (
|
||||
<span className="block max-w-md truncate text-xs text-[var(--mu-text-mid)]" title={text}>
|
||||
{text}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** 一行的两个动作按钮:**回滚只在 status=applied 时可用**。 */
|
||||
function CandidateActions({
|
||||
candidate,
|
||||
busy,
|
||||
onApply,
|
||||
onRevert,
|
||||
}: {
|
||||
candidate: AdminTidyCandidateRecord;
|
||||
busy: string | null;
|
||||
onApply: (id: string) => void;
|
||||
onRevert: (id: string) => void;
|
||||
}): React.ReactNode {
|
||||
const applied = candidate.status === TIDY_STATUS_APPLIED;
|
||||
const working = busy !== null;
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button size="sm" variant="primary" disabled={working} onClick={() => onApply(candidate.id)}>
|
||||
{busy === `apply:${candidate.id}` ? "应用中…" : "应用"}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={working || !applied}
|
||||
title={applied ? "把这条候选的改动复原(候选退回待定)" : "只有已应用的候选才能回滚"}
|
||||
onClick={() => onRevert(candidate.id)}
|
||||
>
|
||||
{busy === `revert:${candidate.id}` ? "回滚中…" : "回滚"}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 单条应用/回滚的回执(按后端 `status` 如实分色;未知状态原样显示)。 */
|
||||
function SingleReceipt({ result }: { result: AdminTidyApplyResult }): React.ReactNode {
|
||||
const applied = result.status === TIDY_APPLY_STATUS_APPLIED;
|
||||
const skipped = result.status === TIDY_APPLY_STATUS_SKIPPED;
|
||||
const failed = result.status === TIDY_APPLY_STATUS_FAILED;
|
||||
const tone = applied ? "ok" : failed ? "err" : "neutral";
|
||||
const text = applied
|
||||
? `已应用(改动 ${result.affectedIds.length} 行)`
|
||||
: skipped
|
||||
? "已跳过(幂等命中或不可应用,不是错误)"
|
||||
: failed
|
||||
? "应用失败"
|
||||
: `未知状态 ${result.status}`;
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs">
|
||||
<Tag tone={tone}>{text}</Tag>
|
||||
<span className="font-mono text-[11px] text-[var(--mu-text-lo)]">{result.candidateId}</span>
|
||||
{result.reason ? (
|
||||
<span className="font-mono text-[11px] text-[var(--mu-text-lo)]">
|
||||
reason={result.reason}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="text-[11px] text-[var(--mu-text-lo)]">
|
||||
受影响行:{result.affectedIds.length ? result.affectedIds.join("、") : "无"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 批量应用回执:计数全部取自响应,`truncated` 时如实说「还剩多少未处理」。 */
|
||||
function BatchReceipt({ batch }: { batch: AdminTidyApplyBatch }): React.ReactNode {
|
||||
const processed = batch.applied + batch.skipped + batch.failed;
|
||||
const remaining = batch.total - processed;
|
||||
return (
|
||||
<div className="space-y-2 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2">
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs">
|
||||
<Tag tone="accent">批量应用回执</Tag>
|
||||
<span className="text-[var(--mu-text-mid)]">
|
||||
命中待定 {batch.total} 条 · 本轮处理 {processed} 条 · 单轮上限 {batch.limit}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3 text-xs text-[var(--mu-text-mid)]">
|
||||
<span>
|
||||
应用 <b>{batch.applied}</b>
|
||||
</span>
|
||||
<span>
|
||||
跳过 <b>{batch.skipped}</b>
|
||||
</span>
|
||||
<span>
|
||||
失败 <b>{batch.failed}</b>
|
||||
</span>
|
||||
<span className="text-[11px] text-[var(--mu-text-lo)]">
|
||||
其中合并 {batch.merged} / 归档 {batch.archived} / 改分类 {batch.reclassified}
|
||||
</span>
|
||||
</div>
|
||||
{batch.truncated ? (
|
||||
<WarnNote>
|
||||
{TIDY_APPLY_TRUNCATED_HINT}:命中 {batch.total} 条,本轮只处理 {processed} 条(单轮上限{" "}
|
||||
{batch.limit}),还有 {remaining} 条待定未处理 —— 再点一次继续。
|
||||
</WarnNote>
|
||||
) : (
|
||||
<Tag tone="ok">{TIDY_APPLY_ALL_DONE_TAG}</Tag>
|
||||
)}
|
||||
{batch.skipped > 0 ? (
|
||||
<span className="block text-[11px] text-[var(--mu-text-lo)]">
|
||||
{TIDY_APPLY_SKIPPED_HINT}(本轮 {batch.skipped} 条)。
|
||||
</span>
|
||||
) : null}
|
||||
{batch.failed > 0 ? (
|
||||
<Notice tone="err">
|
||||
失败 {batch.failed} 条(失败隔离:其余候选照常处理,逐条原因见后端审计 memory.tidy.apply)。
|
||||
</Notice>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 手动真跑回执:它是**建议侧**计数 —— 只产候选,未自动应用。 */
|
||||
function RunReceipt({ run }: { run: AdminTidyRunResult }): React.ReactNode {
|
||||
const byAction = Object.entries(run.candidatesByAction ?? {}).sort((a, b) =>
|
||||
a[0] < b[0] ? -1 : 1
|
||||
);
|
||||
const byActionText = byAction.length
|
||||
? byAction.map(([action, count]) => `${label(actionLabel, action)}=${count}`).join(" / ")
|
||||
: "无";
|
||||
return (
|
||||
<div className="space-y-1 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2">
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs">
|
||||
<Tag tone="ok">真跑完成(只产候选,未自动应用)</Tag>
|
||||
<span className="font-mono text-[11px] text-[var(--mu-text-lo)]">runId={run.runId}</span>
|
||||
</div>
|
||||
<div className="text-xs text-[var(--mu-text-mid)]">
|
||||
新增候选 {run.candidatesCreated} 条({byActionText})· 幂等去重 {run.candidatesDuplicate} 条 ·
|
||||
扫描 {run.scanned} / 比对 {run.compared} / 召回 {run.pairs} 对 / 否决 {run.vetoed} · 用时{" "}
|
||||
{run.durationMs} ms · 规则 {run.ruleVersion} · 分区 {run.partitions}
|
||||
</div>
|
||||
{run.errors > 0 ? (
|
||||
<Notice tone="err">
|
||||
本轮有 {run.errors} 处失败(分区或候选写入):已写下的候选仍然有效。
|
||||
</Notice>
|
||||
) : null}
|
||||
{run.truncated && run.truncated.length > 0 ? (
|
||||
<WarnNote>
|
||||
本轮被护栏截断的分区:{run.truncated.join("、")} —— 这一轮看到的比库里少。
|
||||
</WarnNote>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 二次确认块(批量应用 / 真跑共用;写动作先确认再发请求)。 */
|
||||
function ConfirmBlock({
|
||||
kind,
|
||||
busy,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: {
|
||||
kind: TidyConfirmKind;
|
||||
busy: boolean;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}): React.ReactNode {
|
||||
const isBatch = kind === "batch";
|
||||
return (
|
||||
<div className="space-y-2 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] p-3">
|
||||
<p className="text-xs text-[var(--mu-text-mid)]">
|
||||
{isBatch ? CONFIRM_BATCH_HINT : CONFIRM_RUN_HINT}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant={isBatch ? "danger" : "primary"} disabled={busy} onClick={onConfirm}>
|
||||
{busy ? "处理中…" : isBatch ? "确认批量应用" : "确认真跑一轮"}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" disabled={busy} onClick={onCancel}>
|
||||
取消
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 顶部条:状态筛选 + 两个写动作(含二次确认)。 */
|
||||
function Toolbar({
|
||||
state,
|
||||
actions,
|
||||
}: {
|
||||
state: TidyApplyViewState;
|
||||
actions: TidyApplyActions;
|
||||
}): React.ReactNode {
|
||||
const working = state.busy !== null;
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<select
|
||||
value={state.status}
|
||||
onChange={(e) => actions.onStatusChange(e.target.value)}
|
||||
className="h-9 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 text-sm outline-none focus:ring-1 focus:ring-[var(--mu-accent)]"
|
||||
>
|
||||
<option value={TIDY_STATUS_PENDING}>待定(可应用)</option>
|
||||
<option value={TIDY_STATUS_APPLIED}>已应用(可回滚)</option>
|
||||
</select>
|
||||
<span className="text-[11px] text-[var(--mu-text-lo)]">
|
||||
筛选就是后端查询条件本身:只有 pending / applied 两个真值(后端把空筛选按 pending 处理,因此这里不提供「全部」)
|
||||
</span>
|
||||
<span className="ml-auto flex items-center gap-2">
|
||||
<Button size="sm" variant="soft" disabled={working} onClick={() => actions.onRequest("batch")}>
|
||||
批量应用全部待定
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" disabled={working} onClick={() => actions.onRequest("run")}>
|
||||
手动真跑一轮
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
{state.confirming ? (
|
||||
<ConfirmBlock
|
||||
kind={state.confirming}
|
||||
busy={working}
|
||||
onConfirm={actions.onConfirm}
|
||||
onCancel={actions.onCancelConfirm}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 候选表(取数三态:加载 / 失败 / 就绪;空态按当前筛选给不同文案)。 */
|
||||
function CandidatesSection({
|
||||
state,
|
||||
actions,
|
||||
}: {
|
||||
state: TidyApplyViewState;
|
||||
actions: TidyApplyActions;
|
||||
}): React.ReactNode {
|
||||
const { list } = state;
|
||||
if (list.kind === "loading") {
|
||||
return <div className="px-3 py-6 text-center text-xs text-[var(--mu-text-lo)]">加载候选…</div>;
|
||||
}
|
||||
if (list.kind === "error") {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<Notice tone="err">读取候选失败:{list.message}</Notice>
|
||||
<span className="text-[11px] text-[var(--mu-text-lo)]">{TIDY_CANDIDATE_LIST_ERROR_HINT}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const columns: AdminColumn<AdminTidyCandidateRecord>[] = [
|
||||
{ key: "action", title: "动作", render: (c) => <ActionTag action={c.action} /> },
|
||||
{ key: "status", title: "状态", render: (c) => <StatusTag status={c.status} /> },
|
||||
{ key: "summary", title: "摘要", render: (c) => <SummaryCell candidate={c} /> },
|
||||
{
|
||||
key: "reason",
|
||||
title: "理由",
|
||||
render: (c) => <span className="font-mono text-[11px] text-[var(--mu-text-lo)]">{c.reason}</span>,
|
||||
},
|
||||
{
|
||||
key: "createdAt",
|
||||
title: "时间",
|
||||
render: (c) => (
|
||||
<span className="font-mono text-[11px] text-[var(--mu-text-lo)]">{shortTime(c.createdAt)}</span>
|
||||
),
|
||||
},
|
||||
{ key: "ops", title: "操作", render: (c) => <CandidateActions candidate={c} busy={state.busy} onApply={actions.onApply} onRevert={actions.onRevert} /> },
|
||||
];
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<AdminTable
|
||||
columns={columns}
|
||||
rows={list.data.candidates}
|
||||
loading={false}
|
||||
error={null}
|
||||
emptyTitle={
|
||||
state.status === TIDY_STATUS_APPLIED
|
||||
? "没有已应用的候选:先在「待定」列表里点「应用」"
|
||||
: "没有待定候选:点「手动真跑一轮」产出候选(低峰时段那一轮还没到也能跑)"
|
||||
}
|
||||
rowKey={(c) => c.id}
|
||||
/>
|
||||
<span className="block text-[11px] text-[var(--mu-text-lo)]">
|
||||
本页返回 {list.data.count} 条(后端列表默认上限 100、钳到 500;「到底有多少待定」的权威数字在批量回执的命中数里,本页不把它冒充成全部)。
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** MemoryTidyApplyBody 是候选卡的纯展示体(测试直接喂状态与动作替身)。 */
|
||||
export function MemoryTidyApplyBody({
|
||||
state,
|
||||
actions,
|
||||
}: {
|
||||
state: TidyApplyViewState;
|
||||
actions: TidyApplyActions;
|
||||
}): React.ReactNode {
|
||||
return (
|
||||
<Surface className="space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="text-sm font-semibold">记忆整理候选(应用 / 回滚)</h3>
|
||||
<Tag tone="accent">{TIDY_APPLY_DISCLAIMER}</Tag>
|
||||
<Tag tone="warn">写操作 · 已审计</Tag>
|
||||
<span className="text-xs text-[var(--mu-text-lo)]">
|
||||
GET/POST /api/v1/admin/memories/candidates…
|
||||
</span>
|
||||
</div>
|
||||
<Toolbar state={state} actions={actions} />
|
||||
{state.fail ? <Notice tone="err">操作失败:{state.fail}</Notice> : null}
|
||||
{state.batch ? <BatchReceipt batch={state.batch} /> : null}
|
||||
{state.run ? <RunReceipt run={state.run} /> : null}
|
||||
{state.last ? <SingleReceipt result={state.last} /> : null}
|
||||
<CandidatesSection state={state} actions={actions} />
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
|
||||
/** MemoryTidyApplyCard 是记忆管理页挂的候选卡(取数 + 写动作走 useTidyApplyState)。 */
|
||||
export function MemoryTidyApplyCard(): React.ReactNode {
|
||||
const { state, actions } = useTidyApplyState();
|
||||
return <MemoryTidyApplyBody state={state} actions={actions} />;
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
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();
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user