Files
Cyrene_v2/frontend/web/src/admin/plugin/FormSection.tsx
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

128 lines
5.4 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.
/** `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>
);
}