feat(admin): 前端可接入点 —— 折叠分组 + 动态插件页 + schema 渲染(PLG-3)
- 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:
1 parent
ede00d9928
commit
9e63af9063
29 files changed
+3527
-146
No files matched your search
@@ -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 };
|
||||
}
|
||||
Reference in new issue
Block a user