feat(search): 面板上终于能改「结果条数上限」(原来改了会被静默丢弃)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

真缺陷(上一片登记):`maxResults` 在面板上**根本改不了** —— ① 字段表里没有这个
字段(UI 不显示输入框);② `saveProvider` 只并 provider/apiKey/baseUrl/enabled;
③ `mergeSelectionPatch` 只处理 primary/fallback ⇒ **手工塞进 values 也会被静默丢弃**
(文件不变、也不报错)。「我改了它却没变」正是要消灭的那种假话。

- `panel.go`:表单加 `{Key: FieldMaxResults, Type: sdk.FieldNumber, Min: 1}`(**SDK 本就有
  数字类型**,未自造;前端本就渲染 number ⇒ **不需要改前端**)。刻意不设 `Default`
  (设了会让一次「只改 key」的保存把默认值固化进文件、还可能盖掉 `.env`)。
- `websearch_selection.go`:`mergeSelectionPatch` 里按**三态**落 `maxResults` ——
  缺位/null ⇒ **不改动**;正整数(含整数字符串)⇒ 采用;0/负数/小数/空串/非数/
  布尔/数组 ⇒ **400 `invalid_payload`** 且**盘上一字节不动**(绝不静默忽略)。
- `reauth` 回填该值(否则一保存就把上限清掉)。
- `.env.example`:该键的示例值与「默认 5」注释同步为 **10**(代码默认已改;⚠️ `.env` 未动)。

验收(主会话复跑 + 亲自变异):**整树 `go build ./...` 通过**;三包 vet/测试绿;
端到端「面板存 20 ⇒ 模型要 50 也只给 20、要 3 给 3」(走真 action hub → 落盘 →
热重载 → WithSearchMaxResults → 真 dispatcher,未重启后端);
我用 -overlay 把 `f.MaxResults = maxResults` 摘掉 ⇒ 两条用例变红
(「面板给的值必须真的落盘」/「回填的 maxResults = 5, want 20」)。

⚠️ 已知取舍(如实登记):**清空输入框 = 400**(后端按「非数 ⇒ 报错」拒),因此面板上
没有「去掉上限」的手势 —— 要回到 `.env` 兜底得改文件;要支持「清空 = 去掉覆盖」
需另定哨兵语义,未自行发挥。
⚠️ 契约面已变更(面板声明多一个 number 字段 + saveProvider 的 values 多一个可写键 +
响应多一个回显键)⇒ 主会话登记。
This commit is contained in:
AskaEth committed 2026-10-07 11:10:56 +08:00
1 parent 8325d1bb8c
commit c68387a69a
8 files changed
+387 -31

No files matched your search

+44
View File
@@ -349,3 +349,47 @@ func contains(list []string, want string) bool {
}
return false
}
// 判据 ⑪(切片:结果条数上限真能改):表单里必须有承载「结果条数上限」的 **number** 字段。
//
// 来由是一次真机缺陷:可变源与 `.env` 都认 `maxResults`,但声明里没有这个字段 ⇒ 面板上
// 根本没有输入框(「改不了」),而手工把它塞进提交体又会被写侧静默丢弃。声明字段是这条链
// 的头一环,因此在这里钉住它的类型、边界与 key 同源。
func TestAdminPanelsDeclareMaxResultsField(t *testing.T) {
t.Parallel()
form := adminPanelsFormSection(t)
var got *sdk.Field
for i := range form.Fields {
if form.Fields[i].Key == FieldMaxResults {
got = &form.Fields[i]
}
}
if got == nil {
t.Fatalf("表单里缺 %q 字段(上限在面板上就改不了): %+v", FieldMaxResults, form.Fields)
}
if got.Type != sdk.FieldNumber {
t.Errorf("字段 %q 类型 = %q, want %q(闭集里只有 number 是数字控件)", got.Key, got.Type, sdk.FieldNumber)
}
if got.Min == nil || *got.Min < 1 {
t.Errorf("字段 %q 的 min = %v, want ≥1(0 / 负数在文件里是「未声明」,面板不该把它当成一个合法上限)",
got.Key, got.Min)
}
if strings.TrimSpace(got.Label) == "" || strings.TrimSpace(got.Placeholder) == "" {
t.Errorf("字段 %q 缺标签或说明(label=%q placeholder=%q):「它会压过 .env、是运行期上限」"+
"这件事必须在界面上说清", got.Key, got.Label, got.Placeholder)
}
// key 必须与**落盘真源**同源:声明写一个 key、文件里叫另一个 = 「我改了上限但它没变」。
raw, err := json.Marshal(runtimeconfig.WebSearchFile{MaxResults: 3})
if err != nil {
t.Fatalf("marshal: %v", err)
}
if !strings.Contains(string(raw), `"`+FieldMaxResults+`"`) {
t.Errorf("字段 key %q 与 web-search.json 的键名对不上(落盘真源 = %s)", FieldMaxResults, raw)
}
// 不得撞上条目级字段:撞了就是「我在改谁」要猜。
for _, taken := range []string{"provider", "apiKey", "baseUrl", "enabled"} {
if FieldMaxResults == taken {
t.Fatalf("文件级字段的 key 撞上了条目级字段 %q", taken)
}
}
}