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

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 };
}