- 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)。
113 lines
4.6 KiB
TypeScript
113 lines
4.6 KiB
TypeScript
import { Icon } from "../../components/Icon";
|
|
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` 的纯函数里;**呈现**复用
|
|
* `../schema/fields.tsx` 的通用控件(与插件面板同一份渲染,design/09 §5.7 C)——
|
|
* 本组件只做「渠道元数据 → 通用 schema」的适配与规则结果的分发,不再自己画控件。
|
|
*/
|
|
export interface PlatformConnFieldsProps {
|
|
platform: string;
|
|
values: Readonly<Record<string, string>>;
|
|
onChange: (key: string, value: string) => void;
|
|
/** 盘上已有值的键(含只有掩码可看的秘密):用来显示「已配置」并算作必填已满足。 */
|
|
configuredKeys: readonly string[];
|
|
/** 本次保存被拦下时点名的必填字段键(红框 + 一句话)。 */
|
|
missingKeys: readonly string[];
|
|
/** 主人显式要求清除的掩码秘密键(面板不持有原文,留空 ≠ 清除)。 */
|
|
clearedKeys: readonly string[];
|
|
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,
|
|
onChange,
|
|
configuredKeys,
|
|
missingKeys,
|
|
clearedKeys,
|
|
onToggleClear,
|
|
}: PlatformConnFieldsProps): React.ReactNode {
|
|
const fields = visibleFields(platform, values);
|
|
if (fields.length === 0) return null;
|
|
const hint = modeHint(platform, values);
|
|
const summary = modeSummary(platform, values);
|
|
// 必填清单一次算完(当前组合下哪些字段必填),避免逐字段重复判定。
|
|
const required = new Set(requiredFieldKeys(platform, values));
|
|
const missing = new Set(missingKeys);
|
|
const configured = new Set(configuredKeys);
|
|
const cleared = new Set(clearedKeys);
|
|
|
|
return (
|
|
<div className="space-y-3">
|
|
{hint ? (
|
|
<div className="flex items-start gap-1.5 rounded-[var(--mu-radius)] bg-[var(--mu-accent)]/15 px-3 py-2 text-xs leading-relaxed text-[var(--mu-accent-hi)]">
|
|
<Icon name="check" size={14} className="mt-0.5 shrink-0 text-[var(--mu-accent)]" />
|
|
<span>{hint}</span>
|
|
</div>
|
|
) : null}
|
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
|
{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>
|
|
);
|
|
}
|