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,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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user