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)⇒ 建议刷新管理页点一次确认观感。
This commit is contained in:
1 parent
8051906afa
commit
c46053e276
12 files changed
+500
-49
No files matched your search
@@ -2,7 +2,6 @@ package search
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -174,19 +173,76 @@ func TestBuildRowsStates(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 判据 ⑦:下拉选项 = 本期内置的全部已登记家(稳定排序),且**含尚未配置的家**
|
||||
// 判据 ⑦:下拉候选 = 本期内置的全部已登记家(稳定排序),且**含尚未配置的家**
|
||||
// —— 表单的用途就是第一次配置,只列已配置的会让用户无从选起。
|
||||
func TestProviderOptionsAreAllKnownNames(t *testing.T) {
|
||||
//
|
||||
// 本判据同时钉住「值 / 显示名分离」(主人 2026-10-06 报障:下拉里全是英文名):
|
||||
// - `value` 必须**逐字**等于 KnownNames()(它会被写进 web-search.json、被注册表按名取用);
|
||||
// - `label` 必须非空(否则下拉里是一条空白项),且**括注了 id**(表格/下拉里同时看得见
|
||||
// 中文名与值,人工核对才对得上)。
|
||||
func TestProviderOptionsCarryValuesAndLabels(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := ProviderOptions()
|
||||
want := append([]string(nil), KnownNames()...)
|
||||
sort.Strings(want)
|
||||
if strings.Join(got, ",") != strings.Join(want, ",") {
|
||||
t.Fatalf("ProviderOptions() = %v, want %v", got, want)
|
||||
want := KnownNames()
|
||||
if strings.Join(providerOptionValues(got), ",") != strings.Join(want, ",") {
|
||||
t.Fatalf("候选的 value = %v, want %v(值必须逐字等于 KnownNames())", providerOptionValues(got), want)
|
||||
}
|
||||
if len(got) == 0 {
|
||||
t.Fatal("选项为空:表单的下拉一上来就选不了东西")
|
||||
}
|
||||
for _, o := range got {
|
||||
if strings.TrimSpace(o.Label) == "" {
|
||||
t.Errorf("%q 没有显示名:下拉里会是一条空白项", o.Value)
|
||||
}
|
||||
if !strings.Contains(o.Label, o.Value) {
|
||||
t.Errorf("%q 的显示名 %q 里没有括注 id(面板上就看不到真正落盘的值了)", o.Value, o.Label)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 判据 ⑦b:标签表与接入位表**必须逐家对齐**两件事 —— 表里的键都是已登记的家(写错的名字
|
||||
// 会被静默忽略,标签就白加了),且每家都给了**非空**标签(判据⑦只对 ProviderOptions 的
|
||||
// 现存成员成立;这里直接查表,防的是「标签写了个不存在的 id」)。
|
||||
func TestProviderLabelsCoverEveryKnownName(t *testing.T) {
|
||||
t.Parallel()
|
||||
labels := providerLabels()
|
||||
for name, label := range labels {
|
||||
if !Known(name) {
|
||||
t.Errorf("标签表里的键 %q 不是本期内置的接入位(这个标签永远不会被用到)", name)
|
||||
}
|
||||
if strings.TrimSpace(label) == "" {
|
||||
t.Errorf("%q 的标签是空白", name)
|
||||
}
|
||||
}
|
||||
for _, name := range KnownNames() {
|
||||
if _, ok := labels[name]; !ok {
|
||||
t.Errorf("已登记的接入位 %q 没有标签:下拉与表格里只会显示英文 id", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 判据 ⑦c:**未知 id 原样透传** —— 新增接入位而忘加标签(或以后有人手写一个名字)时,
|
||||
// 面板显示成 id,不崩、不丢、也不编一个假中文名。
|
||||
func TestProviderLabelFallsBackToID(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, name := range []string{"tavily", "zhipu_x", "", " ", "ZHIPU_X"} {
|
||||
if got := ProviderLabel(name); got != name {
|
||||
t.Errorf("ProviderLabel(%q) = %q, want 原样回", name, got)
|
||||
}
|
||||
}
|
||||
// 大小写与首尾空白不敏感(与 Known / Configured 同口径):配置里写 `Zhipu` 也认。
|
||||
if got, want := ProviderLabel(" Zhipu "), providerLabels()[NameZhipu]; got != want {
|
||||
t.Errorf("ProviderLabel(的大小写/空白容忍口径) = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// providerOptionValues 取出候选的**值**(落盘真源),丢掉显示名。
|
||||
func providerOptionValues(opts []ProviderOption) []string {
|
||||
out := make([]string, 0, len(opts))
|
||||
for _, o := range opts {
|
||||
out = append(out, o.Value)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// 判据 ⑧:动作名是与插件管理器的**对外契约**(设计示例里的那四个字面量)。
|
||||
|
||||
@@ -79,9 +79,62 @@ func providerState(in ProviderInput) string {
|
||||
return StateOK
|
||||
}
|
||||
|
||||
// ProviderOptions 返回 `optionsFrom: plugin:providers` 的选项值 —— 本期**全部已登记**的
|
||||
// provider 名(含尚未配置的家),稳定排序。
|
||||
// ProviderOption 是下拉候选的一项。**值 / 显示名分离**是本类型的全部意义:
|
||||
//
|
||||
// - `Value` 是**唯一真源** —— 它会被写进 `$DATA_DIR/config/plugins/web-search.json`,
|
||||
// 并被注册表按名字取用(`Registry.Lookup`),大小写与下划线不许动;
|
||||
// - `Label` 只是**给人看**的显示名(中文名 + 括注 id),任何写盘路径都不得使用它。
|
||||
//
|
||||
// 为什么需要这个类型(此前只回 `["searxng", …]`):面板的候选只回 ASCII id 时,管理页的
|
||||
// 下拉与表格里全是英文名(主人 2026-10-06 报障:「全给的英文名,你甚至不愿意带个括号把
|
||||
// 中文名写上去」)。但候选是**声明面**,不能因此把中文写进配置 —— 所以值仍回 id,
|
||||
// 中文名另走一路(`Label`),由前端只负责显示。
|
||||
type ProviderOption struct {
|
||||
// Value 是 provider 名,逐字等于 `KnownNames()` 的输出。
|
||||
Value string `json:"value"`
|
||||
// Label 是显示名,形如「智谱 BigModel(zhipu)」:中文名 + 括注 id,人工核对时能对上。
|
||||
Label string `json:"label"`
|
||||
}
|
||||
|
||||
// providerLabels 是 id → 显示名。键用 provider 名常量(与 registry.definitions() 同源),
|
||||
// 免得「新加一家但标签表用字面量写」时两边各写一份、悄悄分叉。
|
||||
//
|
||||
// 这里**不**给未知 id 兜一个假名字:`ProviderLabel` 一律原样回(见下)。
|
||||
func providerLabels() map[string]string {
|
||||
return map[string]string{
|
||||
NameSearXNG: "SearXNG 本地兜底(searxng)",
|
||||
NameZhipu: "智谱 BigModel(zhipu)",
|
||||
NameAliyunIQS: "阿里云 IQS 信息查询(aliyun_iqs)",
|
||||
NameBocha: "博查 Bocha(bocha)",
|
||||
NameVolcengine: "火山引擎·豆包搜索(volcengine)",
|
||||
NameBaiduQianfan: "百度千帆 AI 搜索(baidu_qianfan)",
|
||||
}
|
||||
}
|
||||
|
||||
// ProviderLabel 给出一个 provider 名的显示名:标签表里有就用它(大小写与首尾空白不敏感,
|
||||
// 与 `Known` 同口径),**取不到一律原样回** —— 新增接入位而忘了加标签时,面板显示成 id,
|
||||
// 不崩、不丢,也不编一个假中文名出来。空白名照原样回空白。
|
||||
func ProviderLabel(name string) string {
|
||||
label := strings.TrimSpace(providerLabels()[strings.ToLower(strings.TrimSpace(name))])
|
||||
if label == "" {
|
||||
return name
|
||||
}
|
||||
return label
|
||||
}
|
||||
|
||||
// ProviderOptions 返回 `optionsFrom: plugin:providers` 的候选项 —— 本期**全部已登记**的
|
||||
// provider(含尚未配置的家),稳定排序,每项带值 + 显示名。
|
||||
//
|
||||
// 为什么给全部而不是「已配置的家」:表单的用途就是**新增**一家,只列已配置的会让
|
||||
// 「第一次配置」无从选起。可用性不靠下拉限制,靠列表的 `state` 列如实呈现。
|
||||
func ProviderOptions() []string { return KnownNames() }
|
||||
//
|
||||
// 宿主把返回值原样放进动作响应(`{"options":[{"value":…,"label":…}]}`),前端只拿 `label`
|
||||
// 显示、只把 `value` 写回。
|
||||
func ProviderOptions() []ProviderOption {
|
||||
names := KnownNames()
|
||||
out := make([]ProviderOption, 0, len(names))
|
||||
for _, name := range names {
|
||||
out = append(out, ProviderOption{Value: name, Label: ProviderLabel(name)})
|
||||
}
|
||||
return out
|
||||
}
|
||||
Reference in new issue
Block a user