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
@@ -1,14 +1,21 @@
|
||||
import { Icon } from "../../components/Icon";
|
||||
import { Switch } from "../../components/primitives";
|
||||
import { type PlatformField } from "./channelFields";
|
||||
import { modeHint, modeSummary, requiredFieldKeys, visibleFields } from "./channelFieldRules";
|
||||
import {
|
||||
FieldShell,
|
||||
MaskedSecretFooter,
|
||||
SchemaControl,
|
||||
} from "../schema/fields";
|
||||
import type { SchemaField, SchemaFieldType } from "../schema/fieldTypes";
|
||||
|
||||
/** qq-obv11 这类「字段含义随 transport × mode 变化」的平台,字段不能平铺 —— 平铺会
|
||||
* 同时制造「填了用不上」和「没填起不来」两种事故。这里只渲染当前组合下真正生效的字段,
|
||||
* 并逐字段给出「谁连谁 / 空值代表什么 / 现在是否必填」。
|
||||
*
|
||||
* 校验规则(必填、清空、写回补丁)全在 `channelFieldRules.ts` 的纯函数里,本组件不判断
|
||||
* 规则、只呈现结果,单测因此不需要渲染 React。 */
|
||||
* 校验规则(必填、清空、写回补丁)全在 `channelFieldRules.ts` 的纯函数里;**呈现**复用
|
||||
* `../schema/fields.tsx` 的通用控件(与插件面板同一份渲染,design/09 §5.7 C)——
|
||||
* 本组件只做「渠道元数据 → 通用 schema」的适配与规则结果的分发,不再自己画控件。
|
||||
*/
|
||||
export interface PlatformConnFieldsProps {
|
||||
platform: string;
|
||||
values: Readonly<Record<string, string>>;
|
||||
@@ -22,6 +29,22 @@ export interface PlatformConnFieldsProps {
|
||||
onToggleClear: (key: string, cleared: boolean) => void;
|
||||
}
|
||||
|
||||
/** 渠道字段声明 → 通用 schema。分支顺序与旧渲染器一致(有候选先给下拉),
|
||||
* 因此 `channelConfigForm.render.test.ts` 断言的 HTML 形状逐字不变。 */
|
||||
export function platformFieldToSchema(f: PlatformField): SchemaField {
|
||||
const options = f.options ?? [];
|
||||
const type: SchemaFieldType = options.length > 0 ? "select" : f.secret === true ? "secret" : "text";
|
||||
return {
|
||||
key: f.key,
|
||||
label: f.label,
|
||||
type,
|
||||
placeholder: f.placeholder,
|
||||
options,
|
||||
default: f.defaultValue,
|
||||
guide: f.guide,
|
||||
};
|
||||
}
|
||||
|
||||
export function PlatformConnFields({
|
||||
platform,
|
||||
values,
|
||||
@@ -50,114 +73,40 @@ export function PlatformConnFields({
|
||||
</div>
|
||||
) : null}
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
{fields.map((f) => (
|
||||
<ConnField
|
||||
key={f.key}
|
||||
field={f}
|
||||
value={values[f.key] ?? ""}
|
||||
onChange={(v) => onChange(f.key, v)}
|
||||
required={required.has(f.key)}
|
||||
missing={missing.has(f.key)}
|
||||
summary={summary}
|
||||
alreadyConfigured={configured.has(f.key)}
|
||||
clearRequested={cleared.has(f.key)}
|
||||
onToggleClear={(next) => onToggleClear(f.key, next)}
|
||||
/>
|
||||
))}
|
||||
{fields.map((f) => {
|
||||
const isRequired = required.has(f.key);
|
||||
const isMissing = missing.has(f.key);
|
||||
const clearRequested = cleared.has(f.key);
|
||||
// 掩码秘密:面板只看到「已配置」,原文拿不回来 —— 留空保持原值,要删掉必须显式开关。
|
||||
const maskedSecret = f.secret === true && configured.has(f.key);
|
||||
return (
|
||||
<FieldShell
|
||||
key={f.key}
|
||||
label={f.label}
|
||||
required={isRequired}
|
||||
missing={isMissing}
|
||||
guide={f.guide}
|
||||
missingHint={`当前接入方式(${summary})下必填,留空适配器起不来。`}
|
||||
footer={
|
||||
<MaskedSecretFooter
|
||||
fieldKey={f.key}
|
||||
masked={maskedSecret}
|
||||
clearRequested={clearRequested}
|
||||
onToggleClear={(next) => onToggleClear(f.key, next)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<SchemaControl
|
||||
field={platformFieldToSchema(f)}
|
||||
value={values[f.key] ?? ""}
|
||||
onChange={(v) => onChange(f.key, v)}
|
||||
disabled={clearRequested}
|
||||
invalid={isMissing && isRequired}
|
||||
/>
|
||||
</FieldShell>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 一个字段:标题 + 必填/可选标记 + 输入(下拉或文本框)+ 一行中文说明 + 状态行。 */
|
||||
function ConnField({
|
||||
field,
|
||||
value,
|
||||
onChange,
|
||||
required,
|
||||
missing,
|
||||
summary,
|
||||
alreadyConfigured,
|
||||
clearRequested,
|
||||
onToggleClear,
|
||||
}: {
|
||||
field: PlatformField;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
required: boolean;
|
||||
missing: boolean;
|
||||
/** 当前 transport + mode 摘要,只用于错误文案。 */
|
||||
summary: string;
|
||||
alreadyConfigured: boolean;
|
||||
clearRequested: boolean;
|
||||
onToggleClear: (next: boolean) => void;
|
||||
}): React.ReactNode {
|
||||
// 掩码秘密:面板只看到「已配置」,原文拿不回来 —— 留空保持原值,要删掉必须显式开关。
|
||||
const maskedSecret = field.secret === true && alreadyConfigured;
|
||||
const options = field.options ?? [];
|
||||
const unknownOption = value !== "" && options.length > 0 && !options.some((o) => o.value === value);
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<span className="flex flex-wrap items-center gap-1.5 text-xs text-[var(--mu-text-lo)]">
|
||||
{field.label}
|
||||
{required ? (
|
||||
<span className="rounded-[var(--mu-radius-xs)] bg-[var(--mu-err)]/15 px-1.5 py-0.5 text-[10px] font-medium text-[var(--mu-err)]">
|
||||
必填
|
||||
</span>
|
||||
) : (
|
||||
<span className="rounded-[var(--mu-radius-xs)] bg-[var(--mu-surface-5)] px-1.5 py-0.5 text-[10px] font-medium text-[var(--mu-text-lo)]">
|
||||
可选
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{options.length > 0 ? (
|
||||
<select
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
aria-label={field.label}
|
||||
className={missing && required ? inputClsError : inputCls}
|
||||
>
|
||||
{unknownOption ? <option value={value}>{value}(不在候选内,保存时原样保留)</option> : null}
|
||||
{options.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
type={field.secret ? "password" : "text"}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
disabled={clearRequested}
|
||||
className={missing && required ? inputClsError : inputCls}
|
||||
/>
|
||||
)}
|
||||
<span className="block text-[11px] leading-relaxed text-[var(--mu-text-lo)]">{field.guide}</span>
|
||||
{missing && required ? (
|
||||
<span className="block text-[11px] text-[var(--mu-err)]">
|
||||
当前接入方式({summary})下必填,留空适配器起不来。
|
||||
</span>
|
||||
) : null}
|
||||
{maskedSecret ? (
|
||||
<span className="block space-y-1">
|
||||
<span className="block text-[11px] text-[var(--mu-text-lo)]">
|
||||
已配置(后端只下发掩码,面板不持有原文):留空 = 不改动。
|
||||
</span>
|
||||
<Switch
|
||||
checked={clearRequested}
|
||||
onChange={onToggleClear}
|
||||
label={`清除已配置的 ${field.key}(保存后生效)`}
|
||||
/>
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const inputCls =
|
||||
"w-full rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-[var(--mu-accent)]";
|
||||
|
||||
const inputClsError =
|
||||
"w-full rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-sm outline-none ring-1 ring-[var(--mu-err)] focus:ring-[var(--mu-err)]";
|
||||
Reference in new issue
Block a user