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

@@ -0,0 +1,114 @@
/** `section.form` 区块:把插件声明的 `fields[]` 渲染成表单并提交给声明的动作。
*
* 三条硬口径:
* ① 控件来自**通用 schema 渲染**(`../schema/fields.tsx`,与渠道配置页同一份实现),
* 本组件不自己画控件、也不重复实现「留空 = 不改动」;
* ② 提交前先在本地跑必填校验(`schema/values.planSubmit`),拦下「填了却起不来」;
* ③ viewer 一律看不到表单(后端已在快照里裁掉,这里是第二道闸)。
*
* 状态与规则在 `formState.ts`(三个 hook),字段呈现与说明文案在 `FormFields.tsx`;
* 本文件只剩「拼装 + 提交按钮 + 反馈行」,因此每个函数都在 80 行以内。
*/
import { useMemo, type ReactNode } from "react";
import { Button, Surface, Tag } from "../../components/primitives";
import type { Role } from "../../lib/types";
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 { canWrite } from "./nav";
import { SectionHeader } from "./SectionHeader";
import type { PanelFieldDto, PanelSectionDto } from "./types";
/** 声明字段 → 通用 schema(`options` 由 `optionsFrom` 拉到后由栅格填进来)。 */
export function panelFieldToSchema(f: PanelFieldDto): SchemaField {
return {
key: f.key,
label: f.label,
type: f.type,
required: f.required,
placeholder: f.placeholder,
default: f.default,
min: f.min,
max: f.max,
};
}
/** 提交/校验反馈行:错误码与文案分两处显示(§5.4.6 的错误码 ↔ 文案分离)。 */
function FormFlashRow({ flash }: { flash: FormFlash | null }): ReactNode {
if (!flash) return null;
return (
<>
{flash.code ? <Tag tone={flash.tone === "ok" ? "ok" : "err"}>{flash.code}</Tag> : null}
<span className={`text-xs ${flash.tone === "ok" ? "text-[var(--mu-ok)]" : "text-[var(--mu-err)]"}`}>
{flash.text}
</span>
</>
);
}
export interface PanelFormSectionProps {
pluginId: string;
section: PanelSectionDto;
role: Role | null;
/** 提交成功后重取一次快照(下拉候选与 status 事实面跟着刷新)。 */
onReload: () => void;
/** 行内动作「带回表单」的值(design/09 §5.2.1 示例的 reauth);`null` = 没有。
* 只认显式 `values` / `formValues`(见 `types.formValuesOfResult`),不做形状猜测。 */
prefill?: Record<string, unknown> | null;
}
export function PanelFormSection({ pluginId, section, role, onReload, prefill = null }: PanelFormSectionProps): ReactNode {
const log = useAuditStore((s) => s.log);
// 依赖键用 JSON 串:快照每 30s 重取一次,对象身份每次都变,直接依赖对象会把用户正在
// 输入的内容重置掉(「打着字被清空」是比不刷新更糟的体验)。
const fieldsKey = JSON.stringify(section.fields ?? []);
const prefillKey = JSON.stringify(prefill ?? null);
const external = useMemo(
() => ({ ...(section.values ?? {}), ...(prefill ?? {}) }),
[JSON.stringify(section.values ?? {}), prefillKey],
);
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 });
if (!canWrite(role)) {
return (
<Surface className="space-y-2">
<SectionHeader title={section.title} hint="只读" />
<Notice tone="warn">当前角色没有写权限,本区块的表单不显示(写动作一律 owner / admin)。</Notice>
</Surface>
);
}
return (
<Surface className="space-y-3">
<SectionHeader title={section.title} />
<FormFieldsGrid
fields={fields}
declared={section.fields ?? []}
values={state.values}
required={fields.filter((f) => f.required === true).map((f) => f.key)}
missing={state.missing}
configured={state.configured}
cleared={state.cleared}
options={options}
optionErrors={optionErrors}
onValue={state.setValue}
onToggleClear={state.toggleClear}
/>
{fields.length === 0 ? <Notice>该表单没有声明任何字段。</Notice> : null}
<div className="flex flex-wrap items-center gap-2">
<Button variant="primary" disabled={busy || action === ""} onClick={submit}>
{busy ? "提交中…" : "保存"}
</Button>
{action === "" ? <span className="text-[11px] text-[var(--mu-err)]">声明缺少 submit.action,无法提交。</span> : null}
<FormFlashRow flash={flash} />
</div>
</Surface>
);
}