feat(admin): 记忆整理候选的应用/回滚操作面(切片 TIDY-FE)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

主人报障「面板上点不到『应用』」→ 后端 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:
AskaEth committed 2026-10-07 09:58:21 +08:00
1 parent aa0bf8b5a0
commit c512609c63
9 files changed
+1370

No files matched your search

@@ -0,0 +1,108 @@
import type {
AdminTidyApplyBatch,
AdminTidyApplyResult,
AdminTidyCandidatesResponse,
AdminTidyRunResult,
} from "../types";
import { request } from "./core";
/* ----------------------------------------------------------------------------
* 记忆整理**应用路径**(切片 TIDY-1)。真源:
* internal/services/gateway/admin_memory_tidy_apply.go
* internal/services/gateway/admin_memory_tidy_run.go
*
* GET /admin/memories/candidates?status=&limit= 候选列表(含 id/runId)
* POST /admin/memories/candidates/{id}/apply 应用一条(真的改记忆)
* POST /admin/memories/candidates/apply?runId=&limit= 批量应用(省略 runId = 全部待定)
* POST /admin/memories/candidates/{id}/revert 回滚一条(行永不物理删除)
* POST /admin/memories/tidy/run 手动**真跑**一轮(写候选,不自动应用)
*
* 五个都是 `writeAuth`(owner|admin):候选正文含隐私,读侧也不给 viewer。
* ⚠️ 失败走 `error.code`(tidy_unavailable / tidy_disabled / tidy_run_unavailable /
* tidy_timeout / tidy_run_failed / not_found / tidy_run_not_found),**绝不**回一份
* 空结果假装「没有候选」。`status:"skipped"` 是 200 + 幂等命中,不是错误。
* -------------------------------------------------------------------------- */
/** 候选列表的路径(五条里有三条挂在它下面)。 */
const CANDIDATES_PATH = "/admin/memories/candidates";
/** 候选列表只认这两个状态(真源:tidy_store.go / tidy_apply_store.go 的字面量)。
* 因此面板只提供这两个筛选项 —— 加一个「全部」会撒谎,因为空 `status` 在**后端**
* 是「按 pending 筛」,不是「不过滤」(见 listTidyCandidates 的默认化分支)。 */
export const TIDY_STATUS_PENDING = "pending";
export const TIDY_STATUS_APPLIED = "applied";
/** 应用/回滚**回执**的状态闭集(真源 internal/runtime/memory/tidy_apply.go 的
* `TidyApplyStatus`:applied = 真的改了/撤销了;skipped = 没改,且**不是错误**;
* failed = 尝试过并失败)。 */
export const TIDY_APPLY_STATUS_APPLIED = "applied";
export const TIDY_APPLY_STATUS_SKIPPED = "skipped";
export const TIDY_APPLY_STATUS_FAILED = "failed";
/** GET /api/v1/admin/memories/candidates?status=&limit=
*
* `limit` 缺省 100、>500 钳到 500、非整数回 400 bad_request —— **钳位与默认值都在
* 后端**,本函数只转发调用方显式给出的值(否则 URL 里不出现 `limit`)。
*
* ⚠️ 响应里的 `count` 是**返回条数**,后端没有给「待定总数」;列表被 `limit` 截断时
* 没有 `truncated` 标记。因此界面不许把这一页读成「全部待定」——「到底有多少待定」
* 的权威数字在**批量应用回执**的 `total` 里。 */
export function adminTidyCandidates(
params: { status?: string; limit?: number } = {}
): Promise<AdminTidyCandidatesResponse> {
const qs = new URLSearchParams();
if (params.status) qs.set("status", params.status);
if (params.limit && params.limit > 0) qs.set("limit", String(params.limit));
const q = qs.toString();
return request<AdminTidyCandidatesResponse>(`${CANDIDATES_PATH}${q ? `?${q}` : ""}`);
}
/** POST …/candidates/{id}/apply —— 应用**一条**候选。
*
* 幂等:已决定过的候选回 **200 + `status:"skipped"`**(不是错误,界面不许报红);
* 候选不存在回 404 not_found。 */
export function adminTidyCandidateApply(id: string): Promise<AdminTidyApplyResult> {
return request<AdminTidyApplyResult>(`${CANDIDATES_PATH}/${encodeURIComponent(id)}/apply`, {
method: "POST",
});
}
/** POST …/candidates/{id}/revert —— 回滚一条**已应用**候选(记忆行复原 + 候选退回
* pending;行永不物理删除,因此无损可逆)。候选不在已应用状态时回
* `status:"skipped"` + `reason:"not_applied"`。 */
export function adminTidyCandidateRevert(id: string): Promise<AdminTidyApplyResult> {
return request<AdminTidyApplyResult>(`${CANDIDATES_PATH}/${encodeURIComponent(id)}/revert`, {
method: "POST",
});
}
/** POST …/candidates/apply?runId=&limit= —— **批量**应用。
*
* `runId` 省略 = 应用**全部待定**(面板那一档);给出时只应用该轮产出的候选
* (查不到该轮回 404 tidy_run_not_found)。`limit` 省略 ⇒ 由后端领域侧取默认值
* (100,钳到 500)。
*
* ⚠️ 回执里的 `total` 与 `applied+skipped+failed` **可以不等**:后者只覆盖 `limit`
* 之内,前者是命中的真总数 —— 不等时 `truncated=true`,界面必须如实显示「本轮未
* 处理完 + 还剩多少」,不许画成「全部完成」。 */
export function adminTidyCandidatesApplyBatch(
opts: { runId?: string; limit?: number } = {}
): Promise<AdminTidyApplyBatch> {
const qs = new URLSearchParams();
if (opts.runId) qs.set("runId", opts.runId);
if (opts.limit && opts.limit > 0) qs.set("limit", String(opts.limit));
const q = qs.toString();
return request<AdminTidyApplyBatch>(`${CANDIDATES_PATH}/apply${q ? `?${q}` : ""}`, {
method: "POST",
});
}
/** POST /api/v1/admin/memories/tidy/run —— 手动**真跑**一轮(写候选 + 写运行留痕,
* **不自动应用**)。它是「不必等到低峰时段」的那个按钮。
*
* 失败是**诚实错误**,不是空回执:配置关闭 ⇒ 409 tidy_disabled;超时 ⇒ 503
* tidy_timeout;未接线(只接了试算)⇒ 500 tidy_run_unavailable;其余 ⇒ 500
* tidy_run_failed。审计由领域侧记 `memory.tidy.run`,本函数不重复。 */
export function adminTidyRun(): Promise<AdminTidyRunResult> {
return request<AdminTidyRunResult>("/admin/memories/tidy/run", { method: "POST" });
}