主人报障:「为什么中文搜索接口……下拉菜单里全给的英文名?你甚至不愿意带个括号
把中文名写上去」。根因不是漏写文案,而是整条链路没有「显示名」的位置:字段
声明只有 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)⇒ 建议刷新管理页点一次确认观感。
128 lines
5.4 KiB
TypeScript
128 lines
5.4 KiB
TypeScript
/** `section.form` 区块:把插件声明的 `fields[]` 渲染成表单并提交给声明的动作。
|
||
*
|
||
* 三条硬口径:
|
||
* ① 控件来自**通用 schema 渲染**(`../schema/fields.tsx`,与渠道配置页同一份实现),
|
||
* 本组件不自己画控件、也不重复实现「留空 = 不改动」;
|
||
* ② 提交前先在本地跑必填校验(`schema/values.planSubmit`),拦下「填了却起不来」;
|
||
* ③ viewer 一律看不到表单(后端已在快照里裁掉,这里是第二道闸)。
|
||
*
|
||
* 状态与规则在 `formState.ts`(值状态 + 提交两个 hook;候选由面板级拉好传进来),
|
||
* 字段呈现与说明文案在 `FormFields.tsx`;本文件只剩「拼装 + 提交按钮 + 反馈行」,
|
||
* 因此每个函数都在 80 行以内。
|
||
*/
|
||
|
||
import { useMemo, type ReactNode } from "react";
|
||
import { Button, Surface, Tag } from "../../components/primitives";
|
||
import type { Role } from "../../lib/types";
|
||
import { useAuditStore } from "../audit";
|
||
import { Notice } from "../components";
|
||
import type { SchemaField } from "../schema/fieldTypes";
|
||
import { FormFieldsGrid } from "./FormFields";
|
||
import { useFormSubmit, useFormValues, type FormFlash } from "./formState";
|
||
import { canWrite } from "./nav";
|
||
import { SectionHeader } from "./SectionHeader";
|
||
import type { PanelFieldDto, PanelOptionDto, PanelSectionDto } from "./types";
|
||
|
||
/** 声明字段 → 通用 schema(`options` 由 `optionsFrom` 拉到后由栅格填进来)。 */
|
||
export function panelFieldToSchema(f: PanelFieldDto): SchemaField {
|
||
return {
|
||
key: f.key,
|
||
label: f.label,
|
||
type: f.type,
|
||
required: f.required,
|
||
placeholder: f.placeholder,
|
||
default: f.default,
|
||
min: f.min,
|
||
max: f.max,
|
||
};
|
||
}
|
||
|
||
/** 提交/校验反馈行:错误码与文案分两处显示(§5.4.6 的错误码 ↔ 文案分离)。 */
|
||
function FormFlashRow({ flash }: { flash: FormFlash | null }): ReactNode {
|
||
if (!flash) return null;
|
||
return (
|
||
<>
|
||
{flash.code ? <Tag tone={flash.tone === "ok" ? "ok" : "err"}>{flash.code}</Tag> : null}
|
||
<span className={`text-xs ${flash.tone === "ok" ? "text-[var(--mu-ok)]" : "text-[var(--mu-err)]"}`}>
|
||
{flash.text}
|
||
</span>
|
||
</>
|
||
);
|
||
}
|
||
|
||
export interface PanelFormSectionProps {
|
||
pluginId: string;
|
||
section: PanelSectionDto;
|
||
role: Role | null;
|
||
/** 提交成功后重取一次快照(下拉候选与 status 事实面跟着刷新)。 */
|
||
onReload: () => void;
|
||
/** 行内动作「带回表单」的值(design/09 §5.2.1 示例的 reauth);`null` = 没有。
|
||
* 只认显式 `values` / `formValues`(见 `types.formValuesOfResult`),不做形状猜测。 */
|
||
prefill?: Record<string, unknown> | null;
|
||
/** 下拉候选(面板级只拉一次,见 `formState.useFieldOptions`):`optionsFrom` → 候选项。
|
||
* 本组件**不再自己拉** —— 同一份候选两处各拉一次会重复请求,且表格那份拿它做显示名。 */
|
||
options: Record<string, readonly PanelOptionDto[]>;
|
||
/** `optionsFrom` → 失败原因(取不到候选不是致命错误,但要如实说出来)。 */
|
||
optionErrors: Record<string, string>;
|
||
}
|
||
|
||
export function PanelFormSection({
|
||
pluginId,
|
||
section,
|
||
role,
|
||
onReload,
|
||
prefill = null,
|
||
options,
|
||
optionErrors,
|
||
}: PanelFormSectionProps): ReactNode {
|
||
const log = useAuditStore((s) => s.log);
|
||
// 依赖键用 JSON 串:快照每 30s 重取一次,对象身份每次都变,直接依赖对象会把用户正在
|
||
// 输入的内容重置掉(「打着字被清空」是比不刷新更糟的体验)。
|
||
const fieldsKey = JSON.stringify(section.fields ?? []);
|
||
const prefillKey = JSON.stringify(prefill ?? null);
|
||
const external = useMemo(
|
||
() => ({ ...(section.values ?? {}), ...(prefill ?? {}) }),
|
||
[JSON.stringify(section.values ?? {}), prefillKey],
|
||
);
|
||
const fields = useMemo(() => (section.fields ?? []).map((f) => panelFieldToSchema(f)), [fieldsKey]);
|
||
const state = useFormValues(fields, fieldsKey, external);
|
||
const action = section.submit?.action ?? "";
|
||
const { submit, busy, flash } = useFormSubmit({ pluginId, action, fields, state, onReload, log });
|
||
|
||
if (!canWrite(role)) {
|
||
return (
|
||
<Surface className="space-y-2">
|
||
<SectionHeader title={section.title} hint="只读" />
|
||
<Notice tone="warn">当前角色没有写权限,本区块的表单不显示(写动作一律 owner / admin)。</Notice>
|
||
</Surface>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<Surface className="space-y-3">
|
||
<SectionHeader title={section.title} />
|
||
<FormFieldsGrid
|
||
fields={fields}
|
||
declared={section.fields ?? []}
|
||
values={state.values}
|
||
required={fields.filter((f) => f.required === true).map((f) => f.key)}
|
||
missing={state.missing}
|
||
configured={state.configured}
|
||
cleared={state.cleared}
|
||
options={options}
|
||
optionErrors={optionErrors}
|
||
onValue={state.setValue}
|
||
onToggleClear={state.toggleClear}
|
||
/>
|
||
{fields.length === 0 ? <Notice>该表单没有声明任何字段。</Notice> : null}
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<Button variant="primary" disabled={busy || action === ""} onClick={submit}>
|
||
{busy ? "提交中…" : "保存"}
|
||
</Button>
|
||
{action === "" ? <span className="text-[11px] text-[var(--mu-err)]">声明缺少 submit.action,无法提交。</span> : null}
|
||
<FormFlashRow flash={flash} />
|
||
</div>
|
||
</Surface>
|
||
);
|
||
}
|