feat(admin): 前端可接入点 —— 折叠分组 + 动态插件页 + schema 渲染(PLG-3)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- sections.tsx 的分组升级为可折叠(CollapsibleGroup);新增 plugins 分组由
  GET /api/v1/admin/panels 动态填充(navModel.ts 纯函数可单测)
- 插件页走动态段 /admin/plugin/<pluginId>/<panelId>,不进 router.ts 的静态白名单
- 把既有三段式(字段声明表 + 纯函数规则 + 渲染器,含「留空=不改」掩码语义)泛化成
  schema → 控件(src/admin/schema/),复用它渲染插件表单/表格/事实面/日志
- 可测判据:新增插件页不得改动 sections.tsx / router.ts / AdminApp.tsx 的静态清单
- when 不满足 ⇒ 入口不出现;viewer 只看到只读区块

我的复跑:npx tsc --noEmit 通过;npx vite build 通过(3.67s);50 个前端脚本式测试全部
打印 checks passed(含本片新增 pluginData 90 / PluginPanel.render 55 / navModel 62 / schema values 40)。
This commit is contained in:
AskaEth committed 2026-10-06 09:04:51 +08:00
1 parent ede00d9928
commit 9e63af9063
29 files changed
+3527 -146

No files matched your search

+192
View File
@@ -0,0 +1,192 @@
/** 通用字段的**装载 / 校验 / 写回**规则(纯函数,无 React、无 IO)。
*
* 与 `modules/channelFieldRules.ts` 是同一份口径的泛化版:那边带平台选择器(transport ×
* mode 的显隐 / 必填),这边只保留与来源无关的部分 —— 控件全部可见(插件声明面没有
* `showIn`),但**必填判定**与**三态写回**必须逐条同义,否则同一份「留空 = 不改」会在
* 两个页面给出两种结果。
*
* 三态写回(对齐后端 `runtimeconfig.PatchChannel` 的补丁语义):
* - 字段有值 → 写入该值(覆盖旧值);
* - 显式清除(勾了清除开关)→ 写入 `""`;
* - 留空且盘上原本有值 → 写入 `""`(可见字段被清空是用户意图,不许静默保留);
* - 留空且盘上原本没值 → **缺位 = 保留**,不把 `""` 写进一个从没配过的键;
* - **掩码秘密留空** → 一律**缺位 = 不改动**(唯一例外见下),面板不持有原文,
* 空字符串不能表达「不改」也不能表达「清空」—— 要清空必须勾显式开关
* (design/09 §5.4.2 的哨兵语义)。
*/
import type { SchemaField, SchemaFieldType } from "./fieldTypes";
/** 表单值一律以字符串承载(与渠道配置表单同一口径):控件层只认 string,
* 提交时再按声明的 type 还原成后端要的类型。 */
export type FieldValues = Readonly<Record<string, string>>;
/** 后端数据面(status / 表格行)里的一条记录。 */
export type FieldData = Readonly<Record<string, unknown>>;
export function fieldLabel(f: SchemaField): string {
const label = (f.label ?? "").trim();
return label !== "" ? label : f.key;
}
/** 敏感字段:读侧永不回显明文,写侧留空 = 不改动(design/09 §5.4.2)。 */
export function isSensitive(f: SchemaField): boolean {
return f.type === "secret";
}
function isPlainObject(v: unknown): v is Record<string, unknown> {
return typeof v === "object" && v !== null && !Array.isArray(v);
}
/** 把后端下发的值还原成控件字符串(下拉 / 文本 / 开关 / 多选 / 键值对)。 */
export function fieldValueToString(type: SchemaFieldType, v: unknown): string {
if (v === null || v === undefined) return "";
switch (type) {
case "switch":
if (typeof v === "boolean") return v ? "true" : "false";
return v === "true" ? "true" : "";
case "multiSelect":
if (Array.isArray(v)) return v.map((x) => String(x)).join(", ");
return typeof v === "string" ? v : "";
case "kv":
return isPlainObject(v) ? JSON.stringify(v, null, 2) : "";
case "number":
return typeof v === "number" ? String(v) : typeof v === "string" ? v : "";
default:
return typeof v === "string" ? v : typeof v === "number" || typeof v === "boolean" ? String(v) : "";
}
}
export interface LoadedForm {
values: Record<string, string>;
/** 盘上已有值的键(含只有掩码可看的秘密):算作必填已满足,且是「留空 = 不改」的依据。 */
configured: string[];
}
/** 从后端数据面装载表单初值。
*
* 两条口径写死在这里:
* ① 敏感字段**永不回填**(后端只下发掩码;把掩码回填进输入框 = 一次保存把 `****abcd`
* 写成真凭据)。它只被记进 `configured`,于是「留空 = 不改动」在装载这一步就成立。
* ② 其余字段按声明类型还原;声明缺省值只在盘上没有该键时生效。 */
export function loadFormValues(fields: readonly SchemaField[], data: FieldData): LoadedForm {
const values: Record<string, string> = {};
const configured: string[] = [];
for (const f of fields) {
const raw = data[f.key];
const hasStored = raw !== undefined && raw !== null && raw !== "" && !(Array.isArray(raw) && raw.length === 0);
if (hasStored) configured.push(f.key);
if (isSensitive(f)) {
values[f.key] = "";
continue;
}
const loaded = hasStored ? fieldValueToString(f.type, raw) : "";
if (loaded !== "") {
values[f.key] = loaded;
continue;
}
values[f.key] = f.default === undefined ? "" : fieldValueToString(f.type, f.default);
}
return { values, configured };
}
/** 当前必填字段里**还没值**的那些。`configuredKeys` 里的键算已满足 —— 否则「已配置
* 凭据」的必填字段会在每次打开表单时被误报缺失,逼用户把秘密重打一遍。 */
export function missingRequiredFields(
fields: readonly SchemaField[],
values: FieldValues,
configuredKeys: readonly string[] = [],
): SchemaField[] {
const configured = new Set(configuredKeys);
return fields.filter(
(f) => f.required === true && (values[f.key] ?? "").trim() === "" && !configured.has(f.key),
);
}
/** 必填缺失的文案:把「保存不了」变成页面上点名的一行话。 */
export function missingRequiredMessage(fields: readonly SchemaField[]): string {
const names = fields.map((f) => fieldLabel(f)).join("、");
return `缺少必填字段:${names}`;
}
/** 「当前必填字段已满足」的正向清单,供界面打「必填」标记。 */
export function requiredFieldKeys(fields: readonly SchemaField[]): string[] {
return fields.filter((f) => f.required === true).map((f) => f.key);
}
/** 把控件字符串解析成后端要的类型。只有 number / kv / multiSelect 会失败。 */
export function parseFieldValue(f: SchemaField, raw: string): { ok: true; value: unknown } | { ok: false; message: string } {
const text = raw.trim();
const name = fieldLabel(f);
switch (f.type) {
case "number": {
const n = Number(text);
if (!Number.isFinite(n)) return { ok: false, message: `${name} 需要一个数字(当前「${text}」)。` };
if (f.min !== undefined && n < f.min) return { ok: false, message: `${name} 不能小于 ${f.min}。` };
if (f.max !== undefined && n > f.max) return { ok: false, message: `${name} 不能大于 ${f.max}。` };
return { ok: true, value: n };
}
case "switch":
return { ok: true, value: text === "true" };
case "multiSelect":
return { ok: true, value: text.split(/[,,\n]/).map((s) => s.trim()).filter(Boolean) };
case "kv": {
let parsed: unknown;
try {
parsed = JSON.parse(text);
} catch {
return { ok: false, message: `${name} 需要 JSON 对象(形如 {"X-Header":"v"})。` };
}
if (!isPlainObject(parsed)) return { ok: false, message: `${name} 需要 JSON **对象**,不是数组或单值。` };
return { ok: true, value: parsed };
}
default:
return { ok: true, value: text };
}
}
export type SubmitPlan =
| { ok: true; patch: Record<string, unknown> }
| { ok: false; kind: "missing"; missing: SchemaField[]; message: string }
| { ok: false; kind: "invalid"; message: string };
/** 保存时的写回补丁(三态语义见文件头注释)。
*
* `initial` 是打开表单时的值(渠道页的 `initialVals` 同义):判断「用户把盘上已有值清空
* 了」必须知道它原本是什么,只有看得见的值才谈得上「显式清空」。
* `clearedKeys` 是主人显式勾选清除的键(掩码秘密唯一能清除的方式)。 */
export function planSubmit(
fields: readonly SchemaField[],
values: FieldValues,
initial: FieldValues = {},
clearedKeys: readonly string[] = [],
configuredKeys: readonly string[] = [],
): SubmitPlan {
const missing = missingRequiredFields(fields, values, configuredKeys);
if (missing.length > 0) {
return { ok: false, kind: "missing", missing, message: missingRequiredMessage(missing) };
}
const cleared = new Set(clearedKeys);
const patch: Record<string, unknown> = {};
for (const f of fields) {
const raw = (values[f.key] ?? "").trim();
if (cleared.has(f.key)) {
patch[f.key] = "";
continue;
}
if (isSensitive(f)) {
// 敏感字段:只有「用户重打了一个新值」才写入;留空一律**缺位**(不改动)。
// 无论盘上有没有值都如此 —— 面板从不持有原文,凭「初始值」推算清空意图是猜的。
if (raw !== "") patch[f.key] = raw;
continue;
}
if (raw !== "") {
const parsed = parseFieldValue(f, raw);
if (!parsed.ok) return { ok: false, kind: "invalid", message: parsed.message };
patch[f.key] = parsed.value;
continue;
}
if ((initial[f.key] ?? "").trim() !== "") patch[f.key] = "";
}
return { ok: true, patch };
}