import type { AdminMemoryTidyTuningPatch, AdminMemoryTidyTuningSpec, AdminMemoryTidyTuningView, } from "../../lib/api/adminMemories"; /* ---------------------------------------------------------------------------- * 记忆整理「面板调优」卡的**纯逻辑**(切片 MEMPANEL / M4):表单草稿 ↔ 三态补丁。 * * 与呈现分开(与 memoryTidyApplyState.ts 同一手法):这一半不依赖 React,可以直接 * 用 node 跑断言(见 memoryTidyTuning.test.ts)。 * * 三条不可走样的口径: * 1. **取值规则来自后端的 specs**(类型/单位/上下界/兜底来源):面板不硬编码第二套 * 枚举或上下界 —— 否则就会出现「面板允许、后端 400」的漂移。本文件只负责把 * spec 翻成**草稿的校验规则**与中文文案。 * 2. **键缺位 ≠ 清空**:草稿里没动过的键**不进补丁**(= 后端不动它);把一个原本 * 有值的键清空 ⇒ 发 `null`(= 后端删掉该键,回到下一档兜底)。两者在盘上的结果 * 完全不同,因此这里必须分开。 * 3. **坏值就地报错**:非法草稿**不产生补丁**(界面据此拦下保存),而不是发出去等 * 后端 400 —— 后端那道门仍在(它是权威),这只是不让主人白跑一趟。 * -------------------------------------------------------------------------- */ /** `true` / `false` / 三个中文标签:调优键的中文名(面板文案)。 */ export const TUNING_LABELS: Record = { intervalSec: "平时整理间隔", quietIntervalSec: "静默时段整理间隔", quietStartHour: "静默时段开始(本地钟点)", quietEndHour: "静默时段结束(本地钟点)", ingestGate: "同步入库闸(写入时当场审查)", autoApply: "轮末自动落地(高置信候选直接改记忆)", tempTtlSec: "临时记忆存活时长", }; /** 兜底来源的中文说明(后端 specs 的 `fallback` 只有三族)。 */ export function fallbackLabel(spec: AdminMemoryTidyTuningSpec): string { if (spec.fallback.startsWith("env:")) { return `跟随 .env 的 ${spec.fallback.slice("env:".length)}`; } if (spec.fallback.startsWith("panel:")) { return `跟随面板里的 ${spec.fallback.slice("panel:".length)}`; } return "跟随代码默认"; } /** 单位的中文说明。 */ export function unitLabel(spec: AdminMemoryTidyTuningSpec): string { if (spec.unit === "sec") return "秒"; if (spec.unit === "hour") return "本地钟点(0-23)"; return ""; } /** 秒数的可读提示(面板不改变单位,只帮主人把 10800 读成 3 小时)。 */ export function secondsHint(seconds: number): string { if (!Number.isFinite(seconds) || seconds <= 0) return ""; if (seconds % 3600 === 0) return `≈ ${seconds / 3600} 小时`; if (seconds % 60 === 0) return `≈ ${seconds / 60} 分钟`; return `≈ ${seconds} 秒`; } /** 表单草稿:每个键一个字符串(bool 键用 "true"/"false",空串 = 跟随兜底/清空)。 */ export type TuningDraft = Record; /** 草稿的校验结果。 */ export interface TuningDraftCheck { /** 键 → 就地错误文案(空对象 = 可以保存)。 */ errors: Record; /** 真正要提交的三态补丁(只含改动过的键)。 */ patch: AdminMemoryTidyTuningPatch; } /** draftFromView 把盘上的值铺成表单草稿(键缺位 ⇒ 空串 = 跟随兜底)。 */ export function draftFromView(view: AdminMemoryTidyTuningView): TuningDraft { const draft: TuningDraft = {}; for (const spec of view.specs) { const value = view.values[spec.key]; if (value === undefined || value === null) { draft[spec.key] = ""; continue; } draft[spec.key] = typeof value === "boolean" ? (value ? "true" : "false") : String(value); } return draft; } /** fieldError 校验**单个**键的草稿值;返回空串表示合法(空 = 沿用兜底,也合法)。 */ export function fieldError(spec: AdminMemoryTidyTuningSpec, raw: string): string { const text = raw.trim(); if (text === "") return ""; if (spec.kind === "bool") { return text === "true" || text === "false" ? "" : `${TUNING_LABELS[spec.key] ?? spec.key} 只能选「开 / 关 / 跟随兜底」`; } if (!/^-?\d+$/.test(text)) { return `${TUNING_LABELS[spec.key] ?? spec.key} 必须是整数(秒),收到「${text}」`; } const n = Number(text); if (spec.min !== undefined && n < spec.min) { return `${TUNING_LABELS[spec.key] ?? spec.key} 必须 ≥ ${spec.min}${unitLabel(spec)}`; } if (spec.max !== undefined && n > spec.max) { return `${TUNING_LABELS[spec.key] ?? spec.key} 必须 ≤ ${spec.max}${unitLabel(spec)}`; } return ""; } /** buildTuningPatch 是「草稿 → 三态补丁」的唯一实现点(口径 2、3)。 */ export function buildTuningPatch( view: AdminMemoryTidyTuningView, draft: TuningDraft ): TuningDraftCheck { const errors: Record = {}; const patch: AdminMemoryTidyTuningPatch = {}; for (const spec of view.specs) { const raw = draft[spec.key] ?? ""; const err = fieldError(spec, raw); if (err) { errors[spec.key] = err; continue; } const current = view.values[spec.key]; const text = raw.trim(); if (text === "") { // 原本就没有值 ⇒ 一个字节都不必动(后端同样把「键缺位」当「不改动」)。 if (current === undefined || current === null) continue; // 原本有值、现在清空 ⇒ **清除哨兵**(删掉该键,回到下一档兜底)。 patch[spec.key] = null; continue; } if (spec.kind === "bool") { const next = text === "true"; if (typeof current === "boolean" && current === next) continue; patch[spec.key] = next; continue; } const next = Number(text); if (typeof current === "number" && current === next) continue; patch[spec.key] = next; } return { errors, patch }; } /** patchKeys 返回补丁里出现的键(供回执文案与日志/审计展示,顺序稳定)。 */ export function patchKeys(patch: AdminMemoryTidyTuningPatch): string[] { return Object.keys(patch).sort(); } /** patchSummary 把补丁说成人话(保存后回执用,例如「平时整理间隔、同步入库闸」)。 */ export function patchSummary(patch: AdminMemoryTidyTuningPatch): string { const keys = patchKeys(patch); if (keys.length === 0) return "没有需要改的键"; return keys.map((k) => TUNING_LABELS[k] ?? k).join("、"); }