主人报障:「为什么中文搜索接口……下拉菜单里全给的英文名?你甚至不愿意带个括号
把中文名写上去」。根因不是漏写文案,而是整条链路没有「显示名」的位置:字段
声明只有 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)⇒ 建议刷新管理页点一次确认观感。
158 lines
5.9 KiB
TypeScript
158 lines
5.9 KiB
TypeScript
/** 插件表格区块的状态与助手(无 JSX)。
|
|
*
|
|
* 拆出来的理由同 `formState.ts`:守「函数 ≤80 行」,并把「取数」与「行动作」两条
|
|
* 独立的状态机分开(各自的失败处理不同,混在一起就会长成一个巨型组件)。
|
|
*/
|
|
|
|
import { useCallback, useEffect, useState } from "react";
|
|
import { actionFailure } from "./actions";
|
|
import { runPluginAction } from "./panelsApi";
|
|
import { decodeRows, formValuesOfResult, type PanelOptionDto } from "./types";
|
|
|
|
export interface PanelRow {
|
|
key: string;
|
|
data: Record<string, unknown>;
|
|
}
|
|
|
|
export interface TableFlash {
|
|
tone: "ok" | "err";
|
|
text: string;
|
|
code?: string;
|
|
}
|
|
|
|
/** 单元格文本:标量直出,数组/对象退化成可读文本(不渲染 `[object Object]`)。 */
|
|
export function cellText(v: unknown): string {
|
|
if (v === null || v === undefined) return "—";
|
|
if (typeof v === "string") return v === "" ? "—" : v;
|
|
if (typeof v === "number" || typeof v === "boolean") return String(v);
|
|
if (Array.isArray(v)) return v.length === 0 ? "—" : v.map((x) => cellText(x)).join("、");
|
|
try {
|
|
return JSON.stringify(v);
|
|
} catch {
|
|
return "—";
|
|
}
|
|
}
|
|
|
|
/** 候选项的 `value → label` 索引(各来源合并):表格把行里的**裸值**翻成显示名用。
|
|
*
|
|
* **只用于显示**:落盘/写回的永远是 value(label 进配置文件就是写坏配置)。同值多来源
|
|
* 冲突时先到者胜(一个面板里通常只有一个来源);空值或空标签一律跳过 —— 免得在表里
|
|
* 留下一条空白单元格。 */
|
|
export function optionLabelIndex(options: Record<string, readonly PanelOptionDto[]>): Map<string, string> {
|
|
const index = new Map<string, string>();
|
|
for (const list of Object.values(options)) {
|
|
for (const o of list) {
|
|
if (o.value === "" || o.label.trim() === "") continue;
|
|
if (!index.has(o.value)) index.set(o.value, o.label);
|
|
}
|
|
}
|
|
return index;
|
|
}
|
|
|
|
/** 单元格的显示文本:值命中候选 ⇒ 显示 label(标签里括注了 id,人工核对对得上);
|
|
* **查不到 ⇒ 原样显示值本身** —— 未知 id 不吞、不丢,也绝不显示成一个空白项。 */
|
|
export function cellDisplayText(v: unknown, labels: ReadonlyMap<string, string>): string {
|
|
const text = cellText(v);
|
|
return labels.get(text) ?? text;
|
|
}
|
|
|
|
/** 行身份:插件给了真实键就用它,否则用**零填充下标** —— `AdminTable` 会按 rowKey 排序,
|
|
* 裸下标的自然序("10" < "2")会把后端给的顺序打乱。 */
|
|
export function rowIdentity(row: Record<string, unknown>, index: number): string {
|
|
for (const k of ["id", "key", "name", "provider"]) {
|
|
const v = row[k];
|
|
if (typeof v === "string" && v !== "") return `${k}:${v}`;
|
|
if (typeof v === "number") return `${k}:${v}`;
|
|
}
|
|
return `#${String(index).padStart(6, "0")}`;
|
|
}
|
|
|
|
export interface TableRowsState {
|
|
rows: PanelRow[] | null;
|
|
loading: boolean;
|
|
error: string | null;
|
|
load: () => void;
|
|
}
|
|
|
|
/** 取行:动作名不变就不重新取(`load` 的身份只跟 pluginId / action 走,30s 轮询的快照
|
|
* 重渲染不会触发重复请求)。 */
|
|
export function useTableRows(pluginId: string, action: string): TableRowsState {
|
|
const [rows, setRows] = useState<PanelRow[] | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const load = useCallback(() => {
|
|
if (action === "") {
|
|
setLoading(false);
|
|
setError("声明缺少 source.action,无法取数。");
|
|
return;
|
|
}
|
|
setLoading(true);
|
|
runPluginAction(pluginId, action, {})
|
|
.then((raw) => {
|
|
setRows(decodeRows(raw).map((data, i) => ({ key: rowIdentity(data, i), data })));
|
|
setError(null);
|
|
})
|
|
.catch((err: unknown) => setError(actionFailure(err).message))
|
|
.finally(() => setLoading(false));
|
|
}, [pluginId, action]);
|
|
|
|
useEffect(() => {
|
|
load();
|
|
}, [load]);
|
|
|
|
return { rows, loading, error, load };
|
|
}
|
|
|
|
export interface RowActionRunner {
|
|
busy: string | null;
|
|
flash: TableFlash | null;
|
|
run: (actionName: string, label: string, row: Record<string, unknown>) => void;
|
|
}
|
|
|
|
export interface RowActionRunnerArgs {
|
|
pluginId: string;
|
|
onReload: () => void;
|
|
/** 行动作结束后重取本表。 */
|
|
reloadRows: () => void;
|
|
log: (what: string, result: "ok" | "error") => void;
|
|
/** 返回显式值对象时交给表单(reauth「把该条带回表单」)。 */
|
|
onValues?: (values: Record<string, unknown>) => void;
|
|
}
|
|
|
|
/** 行内动作:一次一个在跑(`busy` 非空即禁用全部按钮,避免同一行被点两次)。 */
|
|
export function useRowActionRunner({ pluginId, onReload, reloadRows, log, onValues }: RowActionRunnerArgs): RowActionRunner {
|
|
const [busy, setBusy] = useState<string | null>(null);
|
|
const [flash, setFlash] = useState<TableFlash | null>(null);
|
|
|
|
const run = useCallback(
|
|
(actionName: string, label: string, row: Record<string, unknown>) => {
|
|
setBusy(actionName);
|
|
setFlash(null);
|
|
runPluginAction(pluginId, actionName, { row })
|
|
.then((raw) => {
|
|
log(`plugin.${pluginId}/${actionName}`, "ok");
|
|
// 「带回表单」(reauth):只认显式 values / formValues,取不到就只是刷新列表。
|
|
const values = formValuesOfResult(raw);
|
|
if (values && onValues) {
|
|
onValues(values);
|
|
setFlash({ tone: "ok", text: `${label}:已把该条带回表单,确认后保存。` });
|
|
} else {
|
|
setFlash({ tone: "ok", text: `${label}:已完成,正在刷新。` });
|
|
}
|
|
reloadRows();
|
|
onReload();
|
|
})
|
|
.catch((err: unknown) => {
|
|
const failure = actionFailure(err);
|
|
log(`plugin.${pluginId}/${actionName}`, "error");
|
|
setFlash({ tone: "err", text: failure.message, code: failure.code });
|
|
})
|
|
.finally(() => setBusy(null));
|
|
},
|
|
[pluginId, onReload, reloadRows, log, onValues],
|
|
);
|
|
|
|
return { busy, flash, run };
|
|
}
|