主人报障:「为什么中文搜索接口……下拉菜单里全给的英文名?你甚至不愿意带个括号
把中文名写上去」。根因不是漏写文案,而是整条链路没有「显示名」的位置:字段
声明只有 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)⇒ 建议刷新管理页点一次确认观感。
170 lines
9.0 KiB
TypeScript
170 lines
9.0 KiB
TypeScript
// 搜索提供商「值 / 显示名分离」的真实渲染测试(切片 LBL-1)。
|
||
//
|
||
// 为什么需要它:主人报障「中文搜索接口在管理后台下拉菜单里全给的英文名」——修好之后最容易
|
||
// 走样的两条恰恰是**呈现**与**写回**的分界,而这两条都是「一改就写坏配置文件」的形状:
|
||
// ① 候选 `{"value":"zhipu","label":"智谱 BigModel(zhipu)"}` ⇒ 下拉/多选里显示中文,
|
||
// 而 **option 的 value 必须还是英文 id**(value 会被写进 web-search.json,注册表按
|
||
// 名字取 provider;写成 label 就是「配置里存了中文名,后端一家都不认识」);
|
||
// ② 老形状(候选是**裸字符串**)必须仍然可用 —— 别的插件/老声明还在用它,且**绝不能
|
||
// 因为取不到标签就渲染成空白项**;
|
||
// ③ 表格行里的裸 id 要显示成中文标签,**未知 id 原样显示**(不吞、不丢、不空白)。
|
||
//
|
||
// 跑法(与 PluginPanel.render.test.ts 同):
|
||
//
|
||
// cd frontend/web \
|
||
// && ./node_modules/.bin/esbuild src/admin/plugin/ProviderOptionLabels.render.test.ts \
|
||
// --bundle --platform=node --format=cjs \
|
||
// --outfile=/tmp/cyrene-provider-option-labels.render.test.cjs \
|
||
// && node /tmp/cyrene-provider-option-labels.render.test.cjs
|
||
//
|
||
// 🔴 渲染类测试必须 `--format=cjs`:`--format=esm` 会整批崩在 bundle 深处。
|
||
// `tsc --noEmit` 会对本文件做类型检查,因此它只从项目内导入、不 import node:*。
|
||
//
|
||
// 范围说明:`PanelTableSection` 的行靠 effect 取(服务端渲染不跑 effect ⇒ 表里没有行),
|
||
// 所以表格那一档打的是它渲染单元格时用的纯函数(`cellDisplayText` / `optionLabelIndex`),
|
||
// 而不是 HTML 快照。
|
||
|
||
import { createElement } from "react";
|
||
import { renderToStaticMarkup } from "react-dom/server";
|
||
import { splitListValue, toggleListOption } from "../schema/values";
|
||
import { PanelFormSection } from "./FormSection";
|
||
import { cellDisplayText, optionLabelIndex } from "./tableState";
|
||
import { decodeOptions, type PanelOptionDto, type PanelSectionDto } from "./types";
|
||
|
||
let checks = 0;
|
||
|
||
function ok(cond: boolean, what: string): void {
|
||
checks += 1;
|
||
if (!cond) throw new Error(`FAILED: ${what}`);
|
||
}
|
||
|
||
function eq<T>(got: T, want: T, what: string): void {
|
||
checks += 1;
|
||
if (got !== want) throw new Error(`FAILED: ${what}\n got = ${JSON.stringify(got)}\n want = ${JSON.stringify(want)}`);
|
||
}
|
||
|
||
function has(haystack: string, needle: string): boolean {
|
||
return haystack.includes(needle);
|
||
}
|
||
|
||
/** 把 `<option value="…">文本</option>` 的**文本**取出来 —— 「显示名 ↔ 落盘值」配对的唯一硬证据。
|
||
* 属性顺序(React 可能补 `selected`)由 `[^>]*` 容错。 */
|
||
function optionText(html: string, value: string): string | null {
|
||
const m = new RegExp(`<option[^>]*\\bvalue="${value}"[^>]*>([^<]*)</option>`).exec(html);
|
||
return m === null ? null : m[1];
|
||
}
|
||
|
||
/** web-search 的 `providers` 页表单(与 `plugins/tools/io/search/panel.go` 同形):
|
||
* provider 单选下拉、fallback 多选(降级链),两者都从 `plugin:providers` 拉候选。 */
|
||
const FORM: PanelSectionDto = {
|
||
type: "form",
|
||
id: "add",
|
||
title: "添加 / 修改提供商",
|
||
fields: [
|
||
{ key: "provider", label: "提供商", type: "select", optionsFrom: "plugin:providers", required: true },
|
||
{ key: "fallback", label: "降级链", type: "multiSelect", optionsFrom: "plugin:providers", placeholder: "按顺序依次尝试" },
|
||
],
|
||
submit: { action: "plugin.saveProvider" },
|
||
values: { provider: "zhipu", fallback: "bocha" },
|
||
};
|
||
|
||
/** 后端 `plugin.providers` 动作**新**形状解出来的候选(`{value,label}`)。 */
|
||
const NEW_SHAPE: Record<string, readonly PanelOptionDto[]> = {
|
||
"plugin:providers": decodeOptions({
|
||
options: [
|
||
{ value: "zhipu", label: "智谱 BigModel(zhipu)" },
|
||
{ value: "bocha", label: "博查 Bocha(bocha)" },
|
||
],
|
||
}),
|
||
};
|
||
|
||
/** 后端**老**形状(裸字符串数组)解出来的候选 —— 向后兼容的那一半。 */
|
||
const OLD_SHAPE: Record<string, readonly PanelOptionDto[]> = {
|
||
"plugin:providers": decodeOptions({ options: ["searxng", "zhipu"] }),
|
||
};
|
||
|
||
function renderForm(options: Record<string, readonly PanelOptionDto[]>): string {
|
||
return renderToStaticMarkup(
|
||
createElement(PanelFormSection, {
|
||
pluginId: "web-search",
|
||
section: FORM,
|
||
role: "owner",
|
||
onReload: () => {},
|
||
options,
|
||
optionErrors: {},
|
||
}),
|
||
);
|
||
}
|
||
|
||
// ── ① 新形状:显示中文标签,提交/落盘的是英文 id ───────────────────────────────
|
||
{
|
||
const html = renderForm(NEW_SHAPE);
|
||
|
||
eq(optionText(html, "zhipu"), "智谱 BigModel(zhipu)", "候选的 value 是 id,显示名是中文标签");
|
||
eq(optionText(html, "bocha"), "博查 Bocha(bocha)", "第二家同样:值 → 显示名配对正确");
|
||
ok(has(html, "<option value=\"zhipu\""), "option 的 value 就是落盘的 id(不是 label)");
|
||
ok(
|
||
optionText(html, "智谱 BigModel(zhipu)") === null,
|
||
"中文标签**没有**变成任何 option 的 value(那就是「把 label 写进配置文件」的错法)",
|
||
);
|
||
ok(!has(html, 'value="智谱'), "整个表单里没有任何控件的值是中文标签(多选开关同理)");
|
||
|
||
// 多选(降级链):候选项显示中文,写回的是 id。
|
||
ok(has(html, ">智谱 BigModel(zhipu)</button>"), "多选候选项显示中文标签");
|
||
ok(has(html, 'title="zhipu"'), "多选候选项把落盘的 id 挂在 title 上(看得见、可核对)");
|
||
ok(!has(html, 'title="智谱'), "title 里也不是中文名(挂的是真正的值)");
|
||
ok(has(html, 'value="bocha"'), "多选下面那行是真正落盘的值(当前降级链 = bocha)");
|
||
ok(has(html, "英文 id"), "并如实说明「下面这行是真正落盘的值」");
|
||
}
|
||
|
||
// ── ② 老形状(裸字符串候选):仍然可用,且不渲染空白项 ────────────────────────
|
||
{
|
||
eq(
|
||
JSON.stringify(OLD_SHAPE["plugin:providers"]),
|
||
JSON.stringify([
|
||
{ value: "searxng", label: "searxng" },
|
||
{ value: "zhipu", label: "zhipu" },
|
||
]),
|
||
"裸字符串候选归一成 {value,label},label 回落成值本身",
|
||
);
|
||
const html = renderForm(OLD_SHAPE);
|
||
eq(optionText(html, "searxng"), "searxng", "老形状下拉照旧可用:没有中文标签就显示值");
|
||
ok(!has(html, "<option value=\"\">"), "没有空白项(下拉里绝不出现一条空白候选)");
|
||
ok(has(html, 'title="searxng"'), "多选项也照旧(显示值、写回值)");
|
||
}
|
||
|
||
// ── ③ 标签缺失 / 空串:一律回落显示值,绝不显示空白 ───────────────────────────
|
||
{
|
||
const decoded = decodeOptions({ options: [{ value: "bocha" }, { value: "tavily", label: "" }] });
|
||
eq(JSON.stringify(decoded), JSON.stringify([{ value: "bocha", label: "bocha" }, { value: "tavily", label: "tavily" }]), "缺 label / 空 label 都回落成 value");
|
||
const html = renderForm({ "plugin:providers": decoded });
|
||
eq(optionText(html, "bocha"), "bocha", "缺标签的下拉项显示值本身");
|
||
ok(!has(html, "<option></option>"), "不出现「有值但没字」的空 option");
|
||
}
|
||
|
||
// ── ④ 表格列:把行里的裸值翻成中文标签,未知 id 原样显示 ──────────────────────
|
||
{
|
||
const labels = optionLabelIndex(NEW_SHAPE);
|
||
eq(cellDisplayText("zhipu", labels), "智谱 BigModel(zhipu)", "表格里的 provider 显示中文标签(标签里已括注 id)");
|
||
eq(cellDisplayText("tavily", labels), "tavily", "未知 id 原样显示(不吞、不丢)");
|
||
eq(cellDisplayText("", labels), "—", "空值仍是占位符,不变成空白单元格");
|
||
eq(cellDisplayText(undefined, labels), "—", "缺字段同上");
|
||
eq(
|
||
JSON.stringify([...optionLabelIndex({ a: [{ value: "x", label: " " }], b: [{ value: "y", label: "Y" }] })]),
|
||
JSON.stringify([["y", "Y"]]),
|
||
"空白标签不进索引(免得表里出现空白单元格),跨来源合并成一张表",
|
||
);
|
||
}
|
||
|
||
// ── ⑤ 多选写回:写的是 id(不是中文标签) ─────────────────────────────────────
|
||
{
|
||
const ZHIPU = { value: "zhipu", label: "智谱 BigModel(zhipu)" };
|
||
const BOCHA = { value: "bocha", label: "博查 Bocha(bocha)" };
|
||
eq(toggleListOption("bocha", ZHIPU), "bocha, zhipu", "点选把候选项的 id 追加到降级链末尾(不是它的中文标签)");
|
||
eq(toggleListOption("zhipu, bocha", ZHIPU), "bocha", "再点一次移出该 id,其余保持次序");
|
||
eq(toggleListOption("", BOCHA), "bocha", "空值起手可加入");
|
||
eq(JSON.stringify(splitListValue("zhipu,bocha\nsearxng")), JSON.stringify(["zhipu", "bocha", "searxng"]), "逗号 / 中文逗号 / 换行都当分隔符(与提交解析同一处口径)");
|
||
}
|
||
|
||
console.log(`provider option labels render test: ${checks} checks passed`);
|