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

192 lines
8.0 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.
/** 插件表单区块的状态机(三个 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 };
}