feat(admin): 搜索提供商下拉/多选/表格显示中文名(值仍是 ASCII id)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

主人报障:「为什么中文搜索接口……下拉菜单里全给的英文名?你甚至不愿意带个括号
把中文名写上去」。根因不是漏写文案,而是整条链路没有「显示名」的位置:字段
声明只有 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)⇒ 建议刷新管理页点一次确认观感。
This commit is contained in:
AskaEth committed 2026-10-07 10:08:19 +08:00
1 parent 8051906afa
commit c46053e276
12 files changed
+500 -49

No files matched your search

+19 -6
View File
@@ -6,8 +6,9 @@
* ② 提交前先在本地跑必填校验(`schema/values.planSubmit`),拦下「填了却起不来」;
* ③ viewer 一律看不到表单(后端已在快照里裁掉,这里是第二道闸)。
*
* 状态与规则在 `formState.ts`(三个 hook),字段呈现与说明文案在 `FormFields.tsx`;
* 本文件只剩「拼装 + 提交按钮 + 反馈行」,因此每个函数都在 80 行以内。
* 状态与规则在 `formState.ts`(值状态 + 提交两个 hook;候选由面板级拉好传进来),
* 字段呈现与说明文案在 `FormFields.tsx`;本文件只剩「拼装 + 提交按钮 + 反馈行」,
* 因此每个函数都在 80 行以内。
*/
import { useMemo, type ReactNode } from "react";
@@ -17,10 +18,10 @@ import { useAuditStore } from "../audit";
import { Notice } from "../components";
import type { SchemaField } from "../schema/fieldTypes";
import { FormFieldsGrid } from "./FormFields";
import { useFieldOptions, useFormSubmit, useFormValues, type FormFlash } from "./formState";
import { useFormSubmit, useFormValues, type FormFlash } from "./formState";
import { canWrite } from "./nav";
import { SectionHeader } from "./SectionHeader";
import type { PanelFieldDto, PanelSectionDto } from "./types";
import type { PanelFieldDto, PanelOptionDto, PanelSectionDto } from "./types";
/** 声明字段 → 通用 schema(`options` 由 `optionsFrom` 拉到后由栅格填进来)。 */
export function panelFieldToSchema(f: PanelFieldDto): SchemaField {
@@ -58,9 +59,22 @@ export interface PanelFormSectionProps {
/** 行内动作「带回表单」的值(design/09 §5.2.1 示例的 reauth);`null` = 没有。
* 只认显式 `values` / `formValues`(见 `types.formValuesOfResult`),不做形状猜测。 */
prefill?: Record<string, unknown> | null;
/** 下拉候选(面板级只拉一次,见 `formState.useFieldOptions`):`optionsFrom` → 候选项。
* 本组件**不再自己拉** —— 同一份候选两处各拉一次会重复请求,且表格那份拿它做显示名。 */
options: Record<string, readonly PanelOptionDto[]>;
/** `optionsFrom` → 失败原因(取不到候选不是致命错误,但要如实说出来)。 */
optionErrors: Record<string, string>;
}
export function PanelFormSection({ pluginId, section, role, onReload, prefill = null }: PanelFormSectionProps): ReactNode {
export function PanelFormSection({
pluginId,
section,
role,
onReload,
prefill = null,
options,
optionErrors,
}: PanelFormSectionProps): ReactNode {
const log = useAuditStore((s) => s.log);
// 依赖键用 JSON 串:快照每 30s 重取一次,对象身份每次都变,直接依赖对象会把用户正在
// 输入的内容重置掉(「打着字被清空」是比不刷新更糟的体验)。
@@ -72,7 +86,6 @@ export function PanelFormSection({ pluginId, section, role, onReload, prefill =
);
const fields = useMemo(() => (section.fields ?? []).map((f) => panelFieldToSchema(f)), [fieldsKey]);
const state = useFormValues(fields, fieldsKey, external);
const { options, optionErrors } = useFieldOptions(pluginId, section.fields ?? []);
const action = section.submit?.action ?? "";
const { submit, busy, flash } = useFormSubmit({ pluginId, action, fields, state, onReload, log });
+36 -5
View File
@@ -4,20 +4,24 @@
* - `PluginPanelHost`(容器):从快照里找当前 URL 指向的面板,负责「加载中 / 取数失败 /
* 没这个面板」三种状态。找不到就是找不到 —— 未启用、`when` 不满足、或该插件根本没
* 声明这个页面,直接输 URL 也进不来(快照是按角色与 `when` 过滤后的结果);
* - `PluginPanelView`(纯呈现):只吃 `panel` + `role`,因此可以在单测里直接渲染,
* 不依赖路由、快照与浏览器环境(`--format=cjs` 的渲染测试就是打它)。
* - `PluginPanelView`(呈现 + 取候选):只吃 `panel` + `role`,因此可以在单测里直接渲染,
* 不依赖路由、快照与浏览器环境(`--format=cjs` 的渲染测试就是打它)。它另持一份
* **下拉候选**状态(面板级只拉一次);服务端渲染不跑 effect ⇒ 测试里候选是空的,
* 标签相关的断言请直接打 `PanelFormSection`(可显式传 `options`)或纯函数。
*/
import type { ReactNode } from "react";
import { useState } from "react";
import { useMemo, useState } from "react";
import { Button, EmptyState, Spinner, Tag } from "../../components/primitives";
import type { Role } from "../../lib/types";
import { Notice, PageHeader } from "../components";
import { PanelFormSection } from "./FormSection";
import { PanelLogSection, PanelStatusSection } from "./ReadOnlySections";
import { PanelTableSection } from "./TableSection";
import { useFieldOptions } from "./formState";
import { findPanelEntry, viewerVisibleSections } from "./nav";
import type { PanelEntryDto, PanelSectionDto } from "./types";
import { optionLabelIndex } from "./tableState";
import type { PanelEntryDto, PanelFieldDto, PanelOptionDto, PanelSectionDto } from "./types";
import type { PluginTarget } from "../router";
/** 区块分发(闭集):form / table / status / log 四种,未知类型如实说明而不是半渲染。 */
@@ -28,6 +32,9 @@ function SectionBlock({
onReload,
formPrefill,
onRowValues,
options,
optionErrors,
optionLabels,
}: {
pluginId: string;
section: PanelSectionDto;
@@ -35,6 +42,9 @@ function SectionBlock({
onReload: () => void;
formPrefill: Record<string, unknown> | null;
onRowValues?: (values: Record<string, unknown>) => void;
options: Record<string, readonly PanelOptionDto[]>;
optionErrors: Record<string, string>;
optionLabels: ReadonlyMap<string, string>;
}): ReactNode {
switch (section.type) {
case "form":
@@ -45,6 +55,8 @@ function SectionBlock({
role={role}
onReload={onReload}
prefill={formPrefill}
options={options}
optionErrors={optionErrors}
/>
);
case "table":
@@ -55,6 +67,7 @@ function SectionBlock({
role={role}
onReload={onReload}
onValues={onRowValues}
optionLabels={optionLabels}
/>
);
case "status":
@@ -75,16 +88,31 @@ export interface PluginPanelViewProps {
onReload: () => void;
}
/** 面板里声明过的**全部**字段(不只是当前角色可见的区块)。
*
* 候选来源(`optionsFrom`)是**声明事实**:用整个面板的字段去拉,能拉到的候选最多。
* **已知边界**:viewer 的快照在服务端就已把 form 区块整体摘掉(`paneldecl.filterPanel`),
* 因此 viewer 拿不到候选 ⇒ 表格照样显示裸 id(`cellDisplayText` 的回落),不空白、也不猜。 */
function declaredFieldsOf(panel: PanelEntryDto): PanelFieldDto[] {
return (panel.sections ?? []).flatMap((s) => s.fields ?? []);
}
/** 插件页的纯呈现:标题 + 描述 + 按角色复检后的区块列表。
*
* 行内动作「带回表单」(design/09 §5.2.1 示例的 reauth)在**这里**接线:表格返回一份
* 显式值对象时把它交给表单。只在「恰好一个 form 区块」时转交 —— 两个表单就没有归属,
* 宁可不填也不猜(猜错就是把 A 条的数据填进 B 条的表单)。 */
* 宁可不填也不猜(猜错就是把 A 条的数据填进 B 条的表单)。
*
* 下拉候选也在**这里**拉一次(`useFieldOptions`)并分发给 form 与 table:表单要它渲染
* 下拉,表格要它把行里的裸值翻成显示名。候选是 `{value,label}`,显示用 label、写回只用
* value(见 `controls.tsx` 的 SelectControl / MultiSelectControl)。 */
export function PluginPanelView({ panel, role, onReload }: PluginPanelViewProps): ReactNode {
const [prefill, setPrefill] = useState<Record<string, unknown> | null>(null);
const sections = viewerVisibleSections(panel, role);
const formSections = sections.filter((s) => s.type === "form");
const singleForm = formSections.length === 1 ? formSections[0].id : null;
const { options, optionErrors } = useFieldOptions(panel.pluginId, declaredFieldsOf(panel));
const optionLabels = useMemo(() => optionLabelIndex(options), [options]);
return (
<div className="space-y-4">
<PageHeader
@@ -101,6 +129,9 @@ export function PluginPanelView({ panel, role, onReload }: PluginPanelViewProps)
onReload={onReload}
formPrefill={section.type === "form" && section.id === singleForm ? prefill : null}
onRowValues={singleForm === null ? undefined : (values) => setPrefill(values)}
options={options}
optionErrors={optionErrors}
optionLabels={optionLabels}
/>
))}
{sections.length === 0 ? (
@@ -0,0 +1,169 @@
// 搜索提供商「值 / 显示名分离」的真实渲染测试(切片 LBL-1)。
//
// 为什么需要它:主人报障「中文搜索接口在管理后台下拉菜单里全给的英文名」——修好之后最容易
// 走样的两条恰恰是**呈现**与**写回**的分界,而这两条都是「一改就写坏配置文件」的形状:
// ① 候选 `{"value":"zhipu","label":"智谱 BigModel(zhipu)"}` ⇒ 下拉/多选里显示中文,
// 而 **option 的 value 必须还是英文 id**(value 会被写进 web-search.json,注册表按
// 名字取 provider;写成 label 就是「配置里存了中文名,后端一家都不认识」);
// ② 老形状(候选是**裸字符串**)必须仍然可用 —— 别的插件/老声明还在用它,且**绝不能
// 因为取不到标签就渲染成空白项**;
// ③ 表格行里的裸 id 要显示成中文标签,**未知 id 原样显示**(不吞、不丢、不空白)。
//
// 跑法(与 PluginPanel.render.test.ts 同):
//
// cd frontend/web \
// && ./node_modules/.bin/esbuild src/admin/plugin/ProviderOptionLabels.render.test.ts \
// --bundle --platform=node --format=cjs \
// --outfile=/tmp/cyrene-provider-option-labels.render.test.cjs \
// && node /tmp/cyrene-provider-option-labels.render.test.cjs
//
// 🔴 渲染类测试必须 `--format=cjs`:`--format=esm` 会整批崩在 bundle 深处。
// `tsc --noEmit` 会对本文件做类型检查,因此它只从项目内导入、不 import node:*。
//
// 范围说明:`PanelTableSection` 的行靠 effect 取(服务端渲染不跑 effect ⇒ 表里没有行),
// 所以表格那一档打的是它渲染单元格时用的纯函数(`cellDisplayText` / `optionLabelIndex`),
// 而不是 HTML 快照。
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { splitListValue, toggleListOption } from "../schema/values";
import { PanelFormSection } from "./FormSection";
import { cellDisplayText, optionLabelIndex } from "./tableState";
import { decodeOptions, type PanelOptionDto, type PanelSectionDto } from "./types";
let checks = 0;
function ok(cond: boolean, what: string): void {
checks += 1;
if (!cond) throw new Error(`FAILED: ${what}`);
}
function eq<T>(got: T, want: T, what: string): void {
checks += 1;
if (got !== want) throw new Error(`FAILED: ${what}\n got = ${JSON.stringify(got)}\n want = ${JSON.stringify(want)}`);
}
function has(haystack: string, needle: string): boolean {
return haystack.includes(needle);
}
/** 把 `<option value="…">文本</option>` 的**文本**取出来 —— 「显示名 ↔ 落盘值」配对的唯一硬证据。
* 属性顺序(React 可能补 `selected`)由 `[^>]*` 容错。 */
function optionText(html: string, value: string): string | null {
const m = new RegExp(`<option[^>]*\\bvalue="${value}"[^>]*>([^<]*)</option>`).exec(html);
return m === null ? null : m[1];
}
/** web-search 的 `providers` 页表单(与 `plugins/tools/io/search/panel.go` 同形):
* provider 单选下拉、fallback 多选(降级链),两者都从 `plugin:providers` 拉候选。 */
const FORM: PanelSectionDto = {
type: "form",
id: "add",
title: "添加 / 修改提供商",
fields: [
{ key: "provider", label: "提供商", type: "select", optionsFrom: "plugin:providers", required: true },
{ key: "fallback", label: "降级链", type: "multiSelect", optionsFrom: "plugin:providers", placeholder: "按顺序依次尝试" },
],
submit: { action: "plugin.saveProvider" },
values: { provider: "zhipu", fallback: "bocha" },
};
/** 后端 `plugin.providers` 动作**新**形状解出来的候选(`{value,label}`)。 */
const NEW_SHAPE: Record<string, readonly PanelOptionDto[]> = {
"plugin:providers": decodeOptions({
options: [
{ value: "zhipu", label: "智谱 BigModel(zhipu)" },
{ value: "bocha", label: "博查 Bocha(bocha)" },
],
}),
};
/** 后端**老**形状(裸字符串数组)解出来的候选 —— 向后兼容的那一半。 */
const OLD_SHAPE: Record<string, readonly PanelOptionDto[]> = {
"plugin:providers": decodeOptions({ options: ["searxng", "zhipu"] }),
};
function renderForm(options: Record<string, readonly PanelOptionDto[]>): string {
return renderToStaticMarkup(
createElement(PanelFormSection, {
pluginId: "web-search",
section: FORM,
role: "owner",
onReload: () => {},
options,
optionErrors: {},
}),
);
}
// ── ① 新形状:显示中文标签,提交/落盘的是英文 id ───────────────────────────────
{
const html = renderForm(NEW_SHAPE);
eq(optionText(html, "zhipu"), "智谱 BigModel(zhipu)", "候选的 value 是 id,显示名是中文标签");
eq(optionText(html, "bocha"), "博查 Bocha(bocha)", "第二家同样:值 → 显示名配对正确");
ok(has(html, "<option value=\"zhipu\""), "option 的 value 就是落盘的 id(不是 label)");
ok(
optionText(html, "智谱 BigModel(zhipu)") === null,
"中文标签**没有**变成任何 option 的 value(那就是「把 label 写进配置文件」的错法)",
);
ok(!has(html, 'value="智谱'), "整个表单里没有任何控件的值是中文标签(多选开关同理)");
// 多选(降级链):候选项显示中文,写回的是 id。
ok(has(html, ">智谱 BigModel(zhipu)</button>"), "多选候选项显示中文标签");
ok(has(html, 'title="zhipu"'), "多选候选项把落盘的 id 挂在 title 上(看得见、可核对)");
ok(!has(html, 'title="智谱'), "title 里也不是中文名(挂的是真正的值)");
ok(has(html, 'value="bocha"'), "多选下面那行是真正落盘的值(当前降级链 = bocha)");
ok(has(html, "英文 id"), "并如实说明「下面这行是真正落盘的值」");
}
// ── ② 老形状(裸字符串候选):仍然可用,且不渲染空白项 ────────────────────────
{
eq(
JSON.stringify(OLD_SHAPE["plugin:providers"]),
JSON.stringify([
{ value: "searxng", label: "searxng" },
{ value: "zhipu", label: "zhipu" },
]),
"裸字符串候选归一成 {value,label},label 回落成值本身",
);
const html = renderForm(OLD_SHAPE);
eq(optionText(html, "searxng"), "searxng", "老形状下拉照旧可用:没有中文标签就显示值");
ok(!has(html, "<option value=\"\">"), "没有空白项(下拉里绝不出现一条空白候选)");
ok(has(html, 'title="searxng"'), "多选项也照旧(显示值、写回值)");
}
// ── ③ 标签缺失 / 空串:一律回落显示值,绝不显示空白 ───────────────────────────
{
const decoded = decodeOptions({ options: [{ value: "bocha" }, { value: "tavily", label: "" }] });
eq(JSON.stringify(decoded), JSON.stringify([{ value: "bocha", label: "bocha" }, { value: "tavily", label: "tavily" }]), "缺 label / 空 label 都回落成 value");
const html = renderForm({ "plugin:providers": decoded });
eq(optionText(html, "bocha"), "bocha", "缺标签的下拉项显示值本身");
ok(!has(html, "<option></option>"), "不出现「有值但没字」的空 option");
}
// ── ④ 表格列:把行里的裸值翻成中文标签,未知 id 原样显示 ──────────────────────
{
const labels = optionLabelIndex(NEW_SHAPE);
eq(cellDisplayText("zhipu", labels), "智谱 BigModel(zhipu)", "表格里的 provider 显示中文标签(标签里已括注 id)");
eq(cellDisplayText("tavily", labels), "tavily", "未知 id 原样显示(不吞、不丢)");
eq(cellDisplayText("", labels), "—", "空值仍是占位符,不变成空白单元格");
eq(cellDisplayText(undefined, labels), "—", "缺字段同上");
eq(
JSON.stringify([...optionLabelIndex({ a: [{ value: "x", label: " " }], b: [{ value: "y", label: "Y" }] })]),
JSON.stringify([["y", "Y"]]),
"空白标签不进索引(免得表里出现空白单元格),跨来源合并成一张表",
);
}
// ── ⑤ 多选写回:写的是 id(不是中文标签) ─────────────────────────────────────
{
const ZHIPU = { value: "zhipu", label: "智谱 BigModel(zhipu)" };
const BOCHA = { value: "bocha", label: "博查 Bocha(bocha)" };
eq(toggleListOption("bocha", ZHIPU), "bocha, zhipu", "点选把候选项的 id 追加到降级链末尾(不是它的中文标签)");
eq(toggleListOption("zhipu, bocha", ZHIPU), "bocha", "再点一次移出该 id,其余保持次序");
eq(toggleListOption("", BOCHA), "bocha", "空值起手可加入");
eq(JSON.stringify(splitListValue("zhipu,bocha\nsearxng")), JSON.stringify(["zhipu", "bocha", "searxng"]), "逗号 / 中文逗号 / 换行都当分隔符(与提交解析同一处口径)");
}
console.log(`provider option labels render test: ${checks} checks passed`);
+26 -6
View File
@@ -2,8 +2,12 @@
*
* 复用既有 `AdminTable`(含窗口化与空/错/加载三态),所以这里只做三件事:把列声明翻成
* `AdminColumn`、把行内动作接上宿主动作端点、把反馈画出来(含 viewer 的只读说明)。
* 状态机在 `tableState.ts`;纯助手 `cellText` / `rowIdentity` 从那里复出口(单测按
* 本模块路径 import,换家不必改测试)。
* 状态机在 `tableState.ts`;纯助手 `cellText` / `rowIdentity` / `cellDisplayText` 从那里
* 复出口(单测按本模块路径 import,换家不必改测试)。
*
* 单元格的**中文显示名**:行里的 `provider` 这类裸值经 `cellDisplayText` 查一次
* `optionLabels`(面板级候选,来自声明的 `optionsFrom`)⇒ 有标签显示标签、没有就原样显示值。
* 查找**只影响显示**:行内动作照旧把原始 row(值为 id)送回后端,配置文件里永远不会出现标签。
*
* 对 viewer:后端快照已摘掉行内动作,这里**再摘一次**(纵深防御,见 `nav.canWrite`)。
*/
@@ -15,12 +19,23 @@ import { useAuditStore } from "../audit";
import { AdminTable, Notice, type AdminColumn } from "../components";
import { canWrite } from "./nav";
import { SectionHeader } from "./SectionHeader";
import { cellText, rowIdentity, useRowActionRunner, useTableRows, type PanelRow, type TableFlash } from "./tableState";
import {
cellDisplayText,
cellText,
rowIdentity,
useRowActionRunner,
useTableRows,
type PanelRow,
type TableFlash,
} from "./tableState";
import type { PanelSectionDto } from "./types";
export { cellText, rowIdentity };
export { cellText, rowIdentity, cellDisplayText };
export type { PanelRow };
/** 没有候选时的空索引(常量:避免每次渲染新建 Map 造成列定义无谓变化)。 */
const EMPTY_LABELS: ReadonlyMap<string, string> = new Map();
/** 反馈行:错误码与文案分两处(§5.4.6),成功/失败用不同色。 */
function TableFlashRow({ flash }: { flash: TableFlash | null }): ReactNode {
if (!flash) return null;
@@ -41,19 +56,24 @@ export interface PanelTableSectionProps {
onReload: () => void;
/** 行内动作返回显式值对象时回调(reauth「把该条带回表单」,见 `formValuesOfResult`)。 */
onValues?: (values: Record<string, unknown>) => void;
/** `value → label` 索引(面板级候选,见 `optionLabelIndex`):把行里的裸值翻成显示名。
* 候选来自**声明面的字段**(`optionsFrom`),viewer 的快照里没有写表单 ⇒ 取不到就显示
* 值本身,不空白、也不猜。 */
optionLabels?: ReadonlyMap<string, string>;
}
export function PanelTableSection({ pluginId, section, role, onReload, onValues }: PanelTableSectionProps): ReactNode {
export function PanelTableSection({ pluginId, section, role, onReload, onValues, optionLabels }: PanelTableSectionProps): ReactNode {
const log = useAuditStore((s) => s.log);
const { rows, loading, error, load } = useTableRows(pluginId, section.source?.action ?? "");
const { busy, flash, run } = useRowActionRunner({ pluginId, onReload, reloadRows: load, log, onValues });
const rowActions = section.rowActions ?? [];
const canAct = canWrite(role) && rowActions.length > 0;
const labels = optionLabels ?? EMPTY_LABELS;
const columns: AdminColumn<PanelRow>[] = (section.columns ?? []).map((c) => ({
key: c.key,
title: c.label ?? c.key,
render: (row) => <span className="block max-w-xs truncate">{cellText(row.data[c.key])}</span>,
render: (row) => <span className="block max-w-xs truncate">{cellDisplayText(row.data[c.key], labels)}</span>,
}));
if (canAct) {
columns.push({
+9 -1
View File
@@ -97,7 +97,15 @@ export interface FieldOptionsState {
optionErrors: Record<string, string>;
}
/** 下拉候选:按 `optionsFrom` 逐条取一次(取不到就降级成文本框,由调用方在说明里写明)。 */
/** 下拉候选:按 `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>>({});
+24 -1
View File
@@ -7,7 +7,7 @@
import { useCallback, useEffect, useState } from "react";
import { actionFailure } from "./actions";
import { runPluginAction } from "./panelsApi";
import { decodeRows, formValuesOfResult } from "./types";
import { decodeRows, formValuesOfResult, type PanelOptionDto } from "./types";
export interface PanelRow {
key: string;
@@ -33,6 +33,29 @@ export function cellText(v: unknown): string {
}
}
/** 候选项的 `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 {