/** 插件表格区块的状态与助手(无 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; } 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): Map { const index = new Map(); 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 { const text = cellText(v); return labels.get(text) ?? text; } /** 行身份:插件给了真实键就用它,否则用**零填充下标** —— `AdminTable` 会按 rowKey 排序, * 裸下标的自然序("10" < "2")会把后端给的顺序打乱。 */ export function rowIdentity(row: Record, 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(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(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) => void; } export interface RowActionRunnerArgs { pluginId: string; onReload: () => void; /** 行动作结束后重取本表。 */ reloadRows: () => void; log: (what: string, result: "ok" | "error") => void; /** 返回显式值对象时交给表单(reauth「把该条带回表单」)。 */ onValues?: (values: Record) => void; } /** 行内动作:一次一个在跑(`busy` 非空即禁用全部按钮,避免同一行被点两次)。 */ export function useRowActionRunner({ pluginId, onReload, reloadRows, log, onValues }: RowActionRunnerArgs): RowActionRunner { const [busy, setBusy] = useState(null); const [flash, setFlash] = useState(null); const run = useCallback( (actionName: string, label: string, row: Record) => { 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 }; }