主人报障:「为什么中文搜索接口……下拉菜单里全给的英文名?你甚至不愿意带个括号
把中文名写上去」。根因不是漏写文案,而是整条链路没有「显示名」的位置:字段
声明只有 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)⇒ 建议刷新管理页点一次确认观感。
192 lines
8.0 KiB
TypeScript
192 lines
8.0 KiB
TypeScript
/** 插件表单区块的状态机(三个 hook,无 JSX)。
|
||
*
|
||
* 拆出来的理由有两条,都是硬约束:
|
||
* ① 「函数 ≤80 行」(AGENTS §10)—— 表单组件把「值同步 + 候选加载 + 提交」塞进一个函数
|
||
* 会到 170 行以上;
|
||
* ② 「快照每 30s 重取一次也不能把用户正在输入的内容重置掉」这条行为只有在**依赖键**
|
||
* 集中的一处才守得住,散在组件里迟早有人漏一个。
|
||
*/
|
||
|
||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||
import type { SchemaField } from "../schema/fieldTypes";
|
||
import { loadFormValues, planSubmit } from "../schema/values";
|
||
import { actionFailure, resolveOptionsAction } from "./actions";
|
||
import { runPluginAction } from "./panelsApi";
|
||
import { decodeOptions, type PanelFieldDto, type PanelOptionDto } from "./types";
|
||
|
||
export interface FormFlash {
|
||
tone: "ok" | "err";
|
||
text: string;
|
||
code?: string;
|
||
}
|
||
|
||
export interface FormValuesState {
|
||
/** 控件里的当前值(一律字符串,与渠道配置表单同口径)。 */
|
||
values: Record<string, string>;
|
||
/** 打开表单时的值:判断「用户把盘上已有值清空了」必须知道它原本是什么。 */
|
||
initial: Record<string, string>;
|
||
/** 盘上已有值的键(含只有掩码可看的秘密)。 */
|
||
configured: Set<string>;
|
||
/** 显式勾选清除的键(掩码秘密唯一能清除的方式)。 */
|
||
cleared: Set<string>;
|
||
/** 本次提交被拦下时点名的必填字段。 */
|
||
missing: Set<string>;
|
||
setValue: (key: string, next: string) => void;
|
||
toggleClear: (key: string, next: boolean) => void;
|
||
markMissing: (keys: readonly string[]) => void;
|
||
/** 提交成功后清掉「显式清除」勾选(否则开关会一直亮着、输入框一直禁用)。 */
|
||
resetCleared: () => void;
|
||
}
|
||
|
||
/** 表单值状态:外部值(快照 / 行内动作带回)变了才重置,其余时候保持用户输入。
|
||
*
|
||
* 用**渲染期派生 state** 而不是 useEffect 做首次装载:初次渲染(含服务端渲染)就该是
|
||
* 「打开即显示现状」,而 effect 只在客户端跑,会让首帧先画一个空表单再跳成有值的。
|
||
* `fieldsKey` / `externalKey` 用 JSON 串是因为快照每 30s 重取一次、对象身份每次都变。 */
|
||
export function useFormValues(
|
||
fields: readonly SchemaField[],
|
||
fieldsKey: string,
|
||
external: Record<string, unknown>,
|
||
): FormValuesState {
|
||
const externalKey = JSON.stringify(external);
|
||
const loadedKey = `${fieldsKey}|${externalKey}`;
|
||
const loaded = useMemo(() => loadFormValues(fields, external), [loadedKey]);
|
||
const [values, setValues] = useState<Record<string, string>>(loaded.values);
|
||
const [initial, setInitial] = useState<Record<string, string>>(loaded.values);
|
||
const [configured, setConfigured] = useState<string[]>(loaded.configured);
|
||
const [cleared, setCleared] = useState<string[]>([]);
|
||
const [missing, setMissing] = useState<string[]>([]);
|
||
const [syncedKey, setSyncedKey] = useState(loadedKey);
|
||
|
||
if (syncedKey !== loadedKey) {
|
||
setSyncedKey(loadedKey);
|
||
setValues(loaded.values);
|
||
setInitial(loaded.values);
|
||
setConfigured(loaded.configured);
|
||
setCleared([]);
|
||
setMissing([]);
|
||
}
|
||
|
||
const setValue = useCallback((key: string, next: string) => {
|
||
setValues((prev) => ({ ...prev, [key]: next }));
|
||
}, []);
|
||
const toggleClear = useCallback((key: string, next: boolean) => {
|
||
setCleared((prev) => (next ? [...new Set([...prev, key])] : prev.filter((k) => k !== key)));
|
||
}, []);
|
||
const markMissing = useCallback((keys: readonly string[]) => {
|
||
setMissing([...keys]);
|
||
}, []);
|
||
const resetCleared = useCallback(() => setCleared([]), []);
|
||
|
||
return {
|
||
values,
|
||
initial,
|
||
configured: useMemo(() => new Set(configured), [configured]),
|
||
cleared: useMemo(() => new Set(cleared), [cleared]),
|
||
missing: useMemo(() => new Set(missing), [missing]),
|
||
setValue,
|
||
toggleClear,
|
||
markMissing,
|
||
resetCleared,
|
||
};
|
||
}
|
||
|
||
export interface FieldOptionsState {
|
||
options: Record<string, readonly PanelOptionDto[]>;
|
||
/** `optionsFrom` → 失败原因(动作名 + 错误码)。取不到不是致命错误,但**要说出来**。 */
|
||
optionErrors: Record<string, string>;
|
||
}
|
||
|
||
/** 下拉候选:按 `optionsFrom` 逐条取一次(取不到就降级成文本框,由调用方在说明里写明)。
|
||
*
|
||
* 两条口径:
|
||
* - **形状兼容**由 `types.decodeOptions` 兜住:候选项可以是**裸字符串**(老声明 / 别的
|
||
* 插件还在用的老形状)或 `{value,label}` 对象,两种都归一成 `{value,label}`。控件显示
|
||
* `label`、**只有 `value` 会进表单值** —— label 一旦落盘就是把配置文件写坏。
|
||
* - **面板级只调一次**(`PluginPanelView`):表单拿它渲染下拉,表格拿它把行里的裸值翻成
|
||
* 中文显示名(`tableState.optionLabelIndex` 查表)。分两处各拉一次就是同一份候选发两次
|
||
* 请求,且表格那份会因「本区块没有字段」而拉不到来源。 */
|
||
export function useFieldOptions(pluginId: string, declared: readonly PanelFieldDto[]): FieldOptionsState {
|
||
const [options, setOptions] = useState<Record<string, readonly PanelOptionDto[]>>({});
|
||
const [optionErrors, setOptionErrors] = useState<Record<string, string>>({});
|
||
const fromKeys = Array.from(new Set(declared.map((f) => f.optionsFrom ?? "").filter((k) => k !== ""))).join("|");
|
||
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
const keys = fromKeys === "" ? [] : fromKeys.split("|");
|
||
for (const key of keys) {
|
||
const action = resolveOptionsAction(key);
|
||
if (action === "") continue;
|
||
runPluginAction(pluginId, action, {})
|
||
.then((raw) => {
|
||
if (cancelled) return;
|
||
setOptions((prev) => ({ ...prev, [key]: decodeOptions(raw) }));
|
||
setOptionErrors((prev) => ({ ...prev, [key]: "" }));
|
||
})
|
||
.catch((err: unknown) => {
|
||
if (cancelled) return;
|
||
setOptionErrors((prev) => ({ ...prev, [key]: `${action}:${actionFailure(err).code}` }));
|
||
});
|
||
}
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [pluginId, fromKeys]);
|
||
|
||
return { options, optionErrors };
|
||
}
|
||
|
||
export interface FormSubmitState {
|
||
submit: () => void;
|
||
busy: boolean;
|
||
flash: FormFlash | null;
|
||
}
|
||
|
||
export interface FormSubmitArgs {
|
||
pluginId: string;
|
||
action: string;
|
||
fields: readonly SchemaField[];
|
||
state: FormValuesState;
|
||
onReload: () => void;
|
||
log: (what: string, result: "ok" | "error") => void;
|
||
}
|
||
|
||
/** 提交:本地先跑必填与类型校验(拦下「填了却起不来」),失败**如实**报错码与文案。 */
|
||
export function useFormSubmit({ pluginId, action, fields, state, onReload, log }: FormSubmitArgs): FormSubmitState {
|
||
const [busy, setBusy] = useState(false);
|
||
const [flash, setFlash] = useState<FormFlash | null>(null);
|
||
const { values, initial, cleared, configured, markMissing, resetCleared } = state;
|
||
|
||
const submit = useCallback(() => {
|
||
if (action === "") return;
|
||
const plan = planSubmit(fields, values, initial, [...cleared], [...configured]);
|
||
if (!plan.ok) {
|
||
markMissing(plan.kind === "missing" ? plan.missing.map((f) => f.key) : []);
|
||
setFlash({
|
||
tone: "err",
|
||
text: plan.message,
|
||
code: plan.kind === "missing" ? "missing_required" : "invalid_value",
|
||
});
|
||
return;
|
||
}
|
||
markMissing([]);
|
||
setBusy(true);
|
||
setFlash(null);
|
||
runPluginAction(pluginId, action, { values: plan.patch })
|
||
.then(() => {
|
||
log(`plugin.${pluginId}/${action}`, "ok");
|
||
setFlash({ tone: "ok", text: "已提交,正在刷新页面数据…" });
|
||
resetCleared();
|
||
onReload();
|
||
})
|
||
.catch((err: unknown) => {
|
||
const failure = actionFailure(err);
|
||
log(`plugin.${pluginId}/${action}`, "error");
|
||
setFlash({ tone: "err", text: failure.message, code: failure.code });
|
||
})
|
||
.finally(() => setBusy(false));
|
||
}, [action, pluginId, fields, values, initial, cleared, configured, markMissing, resetCleared, onReload, log]);
|
||
|
||
return { submit, busy, flash };
|
||
}
|