Files
Edu/packages/ui-components/src/props-config-form.tsx
SpecialX 9cedf0c437 feat(portal-shell): v2.0 P0 shadcn standardization + security + streaming + error handling
- shadcn/ui 标准化:废弃纸感令牌,统一 bg-background/text-foreground 等
- Tailwind v4 + @theme inline,移除 tailwind.config.js
- React 19 use() + Suspense 流式渲染,首屏骨架秒出
- 三级错误边界:Route → Section → Widget 层层兜底
- 错误上报:useErrorReport → sendBeacon → /api/log mock 端点
- 三层安全边界:L1 角色门禁 / L2 权限点门禁 / L3 数据范围
- 权限位图 base36 压缩:67 权限点 → ~14 字符,JWT 体积减少 ≥ 99%
- notify 统一 Toast 封装,禁止业务直接 import sonner
- PluginBoundary 替代 PluginLoader(错误边界 + Suspense + Skeleton 三件套)

验证:typecheck 0 错误 / lint 0 错误 / build 6 路由生成成功
2026-07-17 16:10:05 +08:00

183 lines
5.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { ReactNode } from "react";
import { cn } from "./utils/cn";
/**
* PropsConfigForm - 基于 JSON Schema 的插件配置表单portal-shell spec §7.3
*
* 根据插件的 propsSchemaJSON Schema自动渲染配置表单
* admin 通过此表单配置插件的默认 props。
*
* 支持的字段类型:
* - string文本输入
* - number数字输入
* - integer整数输入
* - boolean复选框
* - enum下拉选择
* - object嵌套对象递归渲染
*
* v2.0 令牌迁移shadcn 标准令牌
*
* @example
* <PropsConfigForm
* schema={{ type: "object", properties: { limit: { type: "number", default: 20 } } }}
* value={{ limit: 20 }}
* onChange={(v) => console.log(v)}
* />
*/
/** JSON Schema 类型定义(与 portal-shell spec §5.1 对齐) */
export interface PropsJsonSchema {
type?: string;
properties?: Record<string, PropsJsonSchema>;
items?: PropsJsonSchema;
description?: string;
default?: unknown;
enum?: unknown[];
[key: string]: unknown;
}
export interface PropsConfigFormProps {
/** JSON Schema */
schema: PropsJsonSchema;
/** 当前值 */
value: Record<string, unknown>;
/** 值变更回调 */
onChange: (value: Record<string, unknown>) => void;
/** 自定义类名 */
className?: string;
}
export function PropsConfigForm({
schema,
value,
onChange,
className,
}: PropsConfigFormProps): ReactNode {
const properties = schema.properties;
if (!properties) {
return <p className="text-sm text-muted-foreground"></p>;
}
return (
<div className={cn("space-y-4", className)}>
{Object.entries(properties).map(([key, fieldSchema]) => (
<FieldRenderer
key={key}
name={key}
schema={fieldSchema}
value={value[key]}
onChange={(fieldValue) => {
onChange({ ...value, [key]: fieldValue });
}}
/>
))}
</div>
);
}
interface FieldRendererProps {
name: string;
schema: PropsJsonSchema;
value: unknown;
onChange: (value: unknown) => void;
}
function FieldRenderer({
name,
schema,
value,
onChange,
}: FieldRendererProps): ReactNode {
const fieldType = schema.type ?? "string";
const label = schema.description ?? name;
// enum 下拉
if (schema.enum && schema.enum.length > 0) {
return (
<label className="flex flex-col space-y-1">
<span className="text-sm text-muted-foreground">{label}</span>
<select
value={String(value ?? schema.default ?? "")}
onChange={(e) => onChange(e.target.value)}
className="rounded-md border bg-card px-2 py-1 text-sm text-foreground"
>
{schema.enum.map((opt) => (
<option key={String(opt)} value={String(opt)}>
{String(opt)}
</option>
))}
</select>
</label>
);
}
// boolean 复选框
if (fieldType === "boolean") {
return (
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={Boolean(value ?? schema.default ?? false)}
onChange={(e) => onChange(e.target.checked)}
className="rounded-md border"
/>
<span className="text-sm text-foreground">{label}</span>
</label>
);
}
// number / integer 数字输入
if (fieldType === "number" || fieldType === "integer") {
return (
<label className="flex flex-col space-y-1">
<span className="text-sm text-muted-foreground">{label}</span>
<input
type="number"
value={Number(value ?? schema.default ?? 0)}
onChange={(e) => {
const num = Number(e.target.value);
onChange(fieldType === "integer" ? Math.floor(num) : num);
}}
className="rounded-md border bg-card px-2 py-1 text-sm text-foreground"
/>
</label>
);
}
// object 嵌套递归
if (fieldType === "object" && schema.properties) {
const objValue = (value as Record<string, unknown>) ?? {};
return (
<fieldset className="rounded-xl border p-2">
<legend className="px-2 text-sm text-foreground">{label}</legend>
<div className="space-y-2">
{Object.entries(schema.properties).map(([childKey, childSchema]) => (
<FieldRenderer
key={childKey}
name={childKey}
schema={childSchema}
value={objValue[childKey]}
onChange={(childValue) => {
onChange({ ...objValue, [childKey]: childValue });
}}
/>
))}
</div>
</fieldset>
);
}
// string 默认文本输入
return (
<label className="flex flex-col space-y-1">
<span className="text-sm text-muted-foreground">{label}</span>
<input
type="text"
value={String(value ?? schema.default ?? "")}
onChange={(e) => onChange(e.target.value)}
className="rounded-md border bg-card px-2 py-1 text-sm text-foreground"
/>
</label>
);
}