/** 通用字段的**装载 / 校验 / 写回**规则(纯函数,无 React、无 IO)。 * * 与 `modules/channelFieldRules.ts` 是同一份口径的泛化版:那边带平台选择器(transport × * mode 的显隐 / 必填),这边只保留与来源无关的部分 —— 控件全部可见(插件声明面没有 * `showIn`),但**必填判定**与**三态写回**必须逐条同义,否则同一份「留空 = 不改」会在 * 两个页面给出两种结果。 * * 三态写回(对齐后端 `runtimeconfig.PatchChannel` 的补丁语义): * - 字段有值 → 写入该值(覆盖旧值); * - 显式清除(勾了清除开关)→ 写入 `""`; * - 留空且盘上原本有值 → 写入 `""`(可见字段被清空是用户意图,不许静默保留); * - 留空且盘上原本没值 → **缺位 = 保留**,不把 `""` 写进一个从没配过的键; * - **掩码秘密留空** → 一律**缺位 = 不改动**(唯一例外见下),面板不持有原文, * 空字符串不能表达「不改」也不能表达「清空」—— 要清空必须勾显式开关 * (design/09 §5.4.2 的哨兵语义)。 */ import type { SchemaField, SchemaFieldType, SchemaOption } from "./fieldTypes"; /** 表单值一律以字符串承载(与渠道配置表单同一口径):控件层只认 string, * 提交时再按声明的 type 还原成后端要的类型。 */ export type FieldValues = Readonly>; /** 后端数据面(status / 表格行)里的一条记录。 */ export type FieldData = Readonly>; export function fieldLabel(f: SchemaField): string { const label = (f.label ?? "").trim(); return label !== "" ? label : f.key; } /** 多选字段值的分隔口径(逗号 / 中文逗号 / 换行)。**只此一处**:装载、提交解析与 * 「点选候选开关」三处共用,各写一份就会出现「面板看着有值、后端收到空数组」。 */ export function splitListValue(text: string): string[] { return text.split(/[,,\n]/).map((s) => s.trim()).filter(Boolean); } /** 候选开关的取值:点一下把候选项的 **`value`(英文 id)** 加入 / 移出多选值。 * * **写进去的必须是 `value`,不是 `label`(中文显示名)** —— 这个字段的值会落进 * `web-search.json` 的降级链,后端按名字取 provider,写中文名等于写坏配置。参数收整个 * 候选项(只读它的 `value`)就是为了让这条分离在函数签名上看得见。 * 顺序 = 加入顺序(降级链按序生效),因此从值末尾追加、移出时保持其余相对次序。 */ export function toggleListOption(text: string, option: SchemaOption): string { const items = splitListValue(text); const next = items.includes(option.value) ? items.filter((x) => x !== option.value) : [...items, option.value]; return next.join(", "); } /** 敏感字段:读侧永不回显明文,写侧留空 = 不改动(design/09 §5.4.2)。 */ export function isSensitive(f: SchemaField): boolean { return f.type === "secret"; } function isPlainObject(v: unknown): v is Record { return typeof v === "object" && v !== null && !Array.isArray(v); } /** 把后端下发的值还原成控件字符串(下拉 / 文本 / 开关 / 多选 / 键值对)。 */ export function fieldValueToString(type: SchemaFieldType, v: unknown): string { if (v === null || v === undefined) return ""; switch (type) { case "switch": if (typeof v === "boolean") return v ? "true" : "false"; return v === "true" ? "true" : ""; case "multiSelect": if (Array.isArray(v)) return v.map((x) => String(x)).join(", "); return typeof v === "string" ? v : ""; case "kv": return isPlainObject(v) ? JSON.stringify(v, null, 2) : ""; case "number": return typeof v === "number" ? String(v) : typeof v === "string" ? v : ""; default: return typeof v === "string" ? v : typeof v === "number" || typeof v === "boolean" ? String(v) : ""; } } export interface LoadedForm { values: Record; /** 盘上已有值的键(含只有掩码可看的秘密):算作必填已满足,且是「留空 = 不改」的依据。 */ configured: string[]; } /** 从后端数据面装载表单初值。 * * 两条口径写死在这里: * ① 敏感字段**永不回填**(后端只下发掩码;把掩码回填进输入框 = 一次保存把 `****abcd` * 写成真凭据)。它只被记进 `configured`,于是「留空 = 不改动」在装载这一步就成立。 * ② 其余字段按声明类型还原;声明缺省值只在盘上没有该键时生效。 */ export function loadFormValues(fields: readonly SchemaField[], data: FieldData): LoadedForm { const values: Record = {}; const configured: string[] = []; for (const f of fields) { const raw = data[f.key]; const hasStored = raw !== undefined && raw !== null && raw !== "" && !(Array.isArray(raw) && raw.length === 0); if (hasStored) configured.push(f.key); if (isSensitive(f)) { values[f.key] = ""; continue; } const loaded = hasStored ? fieldValueToString(f.type, raw) : ""; if (loaded !== "") { values[f.key] = loaded; continue; } values[f.key] = f.default === undefined ? "" : fieldValueToString(f.type, f.default); } return { values, configured }; } /** 当前必填字段里**还没值**的那些。`configuredKeys` 里的键算已满足 —— 否则「已配置 * 凭据」的必填字段会在每次打开表单时被误报缺失,逼用户把秘密重打一遍。 */ export function missingRequiredFields( fields: readonly SchemaField[], values: FieldValues, configuredKeys: readonly string[] = [], ): SchemaField[] { const configured = new Set(configuredKeys); return fields.filter( (f) => f.required === true && (values[f.key] ?? "").trim() === "" && !configured.has(f.key), ); } /** 必填缺失的文案:把「保存不了」变成页面上点名的一行话。 */ export function missingRequiredMessage(fields: readonly SchemaField[]): string { const names = fields.map((f) => fieldLabel(f)).join("、"); return `缺少必填字段:${names}`; } /** 「当前必填字段已满足」的正向清单,供界面打「必填」标记。 */ export function requiredFieldKeys(fields: readonly SchemaField[]): string[] { return fields.filter((f) => f.required === true).map((f) => f.key); } /** 把控件字符串解析成后端要的类型。只有 number / kv / multiSelect 会失败。 */ export function parseFieldValue(f: SchemaField, raw: string): { ok: true; value: unknown } | { ok: false; message: string } { const text = raw.trim(); const name = fieldLabel(f); switch (f.type) { case "number": { const n = Number(text); if (!Number.isFinite(n)) return { ok: false, message: `${name} 需要一个数字(当前「${text}」)。` }; if (f.min !== undefined && n < f.min) return { ok: false, message: `${name} 不能小于 ${f.min}。` }; if (f.max !== undefined && n > f.max) return { ok: false, message: `${name} 不能大于 ${f.max}。` }; return { ok: true, value: n }; } case "switch": return { ok: true, value: text === "true" }; case "multiSelect": return { ok: true, value: splitListValue(text) }; case "kv": { let parsed: unknown; try { parsed = JSON.parse(text); } catch { return { ok: false, message: `${name} 需要 JSON 对象(形如 {"X-Header":"v"})。` }; } if (!isPlainObject(parsed)) return { ok: false, message: `${name} 需要 JSON **对象**,不是数组或单值。` }; return { ok: true, value: parsed }; } default: return { ok: true, value: text }; } } export type SubmitPlan = | { ok: true; patch: Record } | { ok: false; kind: "missing"; missing: SchemaField[]; message: string } | { ok: false; kind: "invalid"; message: string }; /** 保存时的写回补丁(三态语义见文件头注释)。 * * `initial` 是打开表单时的值(渠道页的 `initialVals` 同义):判断「用户把盘上已有值清空 * 了」必须知道它原本是什么,只有看得见的值才谈得上「显式清空」。 * `clearedKeys` 是主人显式勾选清除的键(掩码秘密唯一能清除的方式)。 */ export function planSubmit( fields: readonly SchemaField[], values: FieldValues, initial: FieldValues = {}, clearedKeys: readonly string[] = [], configuredKeys: readonly string[] = [], ): SubmitPlan { const missing = missingRequiredFields(fields, values, configuredKeys); if (missing.length > 0) { return { ok: false, kind: "missing", missing, message: missingRequiredMessage(missing) }; } const cleared = new Set(clearedKeys); const patch: Record = {}; for (const f of fields) { const raw = (values[f.key] ?? "").trim(); if (cleared.has(f.key)) { patch[f.key] = ""; continue; } if (isSensitive(f)) { // 敏感字段:只有「用户重打了一个新值」才写入;留空一律**缺位**(不改动)。 // 无论盘上有没有值都如此 —— 面板从不持有原文,凭「初始值」推算清空意图是猜的。 if (raw !== "") patch[f.key] = raw; continue; } if (raw !== "") { const parsed = parseFieldValue(f, raw); if (!parsed.ok) return { ok: false, kind: "invalid", message: parsed.message }; patch[f.key] = parsed.value; continue; } if ((initial[f.key] ?? "").trim() !== "") patch[f.key] = ""; } return { ok: true, patch }; }