主人报障:「为什么中文搜索接口……下拉菜单里全给的英文名?你甚至不愿意带个括号
把中文名写上去」。根因不是漏写文案,而是整条链路没有「显示名」的位置:字段
声明只有 optionsFrom,候选动作回的就是 KnownNames() 的纯 id 数组。
- 后端:候选改为对象数组 {value,label};label 表与接入位表同源(键用
NameSearXNG 等常量),未知 id 原样回。value 逐字等于 KnownNames()。
- 前端:下拉/多选「显示 label、写 value」;表格单元格按 value→label 索引显示,
查不到回落显示原值。多选从裸输入框改成「候选开关 + 落盘值输入框」。
解码器本就兼容裸字符串/{value,label}/{id} 三形状 ⇒ 其他插件零影响。
中文名逐字:SearXNG 本地兜底(searxng)/ 智谱 BigModel(zhipu)/
阿里云 IQS 信息查询(aliyun_iqs)/ 博查 Bocha(bocha)/
火山引擎·豆包搜索(volcengine)/ 百度千帆 AI 搜索(baidu_qianfan)。
验收(主会话复跑 + 亲自变异):go build ./... + vet 绿;search 包测试绿;
tsc --noEmit 0 错;新渲染测试 26 checks + 既有 plugin panel 55 / data 90 /
schema values 40 / channel config 70 全绿。我用 sed 把 values.ts 的
option.value 全换成 option.label ⇒ 渲染测试红(「点选把候选项的 id 追加到
降级链末尾」got="bocha, 智谱 BigModel(zhipu)")—— 这正是会把配置文件写坏的
错法;还原后 md5 一致且绿。
⚠️ 契约面变更(我在 Docs/api-client.md 登记):plugin.providers 候选响应形状
字符串数组 → 对象数组;label 仅供显示、value 才是落盘值。
⚠️ 已知边界:viewer 角色看不到 form 区块 ⇒ 表格对 viewer 仍显示裸 id(未做)。
⚠️ 真机浏览器点击未跑(项目无 jsdom)⇒ 建议刷新管理页点一次确认观感。
211 lines
9.6 KiB
TypeScript
211 lines
9.6 KiB
TypeScript
/** 通用字段的**装载 / 校验 / 写回**规则(纯函数,无 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<Record<string, string>>;
|
||
|
||
/** 后端数据面(status / 表格行)里的一条记录。 */
|
||
export type FieldData = Readonly<Record<string, unknown>>;
|
||
|
||
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<string, unknown> {
|
||
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<string, string>;
|
||
/** 盘上已有值的键(含只有掩码可看的秘密):算作必填已满足,且是「留空 = 不改」的依据。 */
|
||
configured: string[];
|
||
}
|
||
|
||
/** 从后端数据面装载表单初值。
|
||
*
|
||
* 两条口径写死在这里:
|
||
* ① 敏感字段**永不回填**(后端只下发掩码;把掩码回填进输入框 = 一次保存把 `****abcd`
|
||
* 写成真凭据)。它只被记进 `configured`,于是「留空 = 不改动」在装载这一步就成立。
|
||
* ② 其余字段按声明类型还原;声明缺省值只在盘上没有该键时生效。 */
|
||
export function loadFormValues(fields: readonly SchemaField[], data: FieldData): LoadedForm {
|
||
const values: Record<string, string> = {};
|
||
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<string, unknown> }
|
||
| { 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<string, unknown> = {};
|
||
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 };
|
||
}
|