Files
Cyrene_v2/frontend/web/src/admin/schema/values.ts
T
AskaEth c46053e276
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(admin): 搜索提供商下拉/多选/表格显示中文名(值仍是 ASCII id)
主人报障:「为什么中文搜索接口……下拉菜单里全给的英文名?你甚至不愿意带个括号
把中文名写上去」。根因不是漏写文案,而是整条链路没有「显示名」的位置:字段
声明只有 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)⇒ 建议刷新管理页点一次确认观感。
2026-10-07 10:08:19 +08:00

211 lines
9.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/** 通用字段的**装载 / 校验 / 写回**规则(纯函数,无 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 };
}