chore: import deployable mai-bot source tree
This commit is contained in:
487
dashboard/src/components/dynamic-form/DynamicField.tsx
Normal file
487
dashboard/src/components/dynamic-form/DynamicField.tsx
Normal file
@@ -0,0 +1,487 @@
|
||||
import * as React from "react"
|
||||
import * as LucideIcons from "lucide-react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { KeyValueEditor } from "@/components/ui/key-value-editor"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Slider } from "@/components/ui/slider"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { resolveFieldLabel } from "@/lib/config-label"
|
||||
import type { FieldSchema } from "@/types/config-schema"
|
||||
|
||||
export interface DynamicFieldProps {
|
||||
schema: FieldSchema
|
||||
value: unknown
|
||||
onChange: (value: unknown) => void
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
fieldPath?: string // 用于 Hook 系统(未来使用)
|
||||
}
|
||||
|
||||
/**
|
||||
* DynamicField - 根据字段类型和 x-widget 渲染对应的 shadcn/ui 组件
|
||||
*
|
||||
* 渲染逻辑:
|
||||
* 1. x-widget 优先:如果 schema 有 x-widget,使用对应组件
|
||||
* 2. type 回退:如果没有 x-widget,根据 type 选择默认组件
|
||||
*/
|
||||
export const DynamicField: React.FC<DynamicFieldProps> = ({
|
||||
schema,
|
||||
value,
|
||||
onChange,
|
||||
}) => {
|
||||
const { i18n } = useTranslation()
|
||||
const fieldLabel = resolveFieldLabel(schema, i18n.language)
|
||||
const isNumericField = schema.type === 'integer' || schema.type === 'number'
|
||||
|
||||
const parseNumericValue = (rawValue: unknown, fallbackValue: unknown = 0) => {
|
||||
if (typeof rawValue === 'number' && Number.isFinite(rawValue)) {
|
||||
return rawValue
|
||||
}
|
||||
|
||||
if (typeof rawValue === 'string') {
|
||||
const parsedValue = parseFloat(rawValue)
|
||||
if (Number.isFinite(parsedValue)) {
|
||||
return schema.type === 'integer' ? Math.trunc(parsedValue) : parsedValue
|
||||
}
|
||||
}
|
||||
|
||||
if (fallbackValue !== rawValue) {
|
||||
return parseNumericValue(fallbackValue, 0)
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
const renderPrimitiveArrayEditor = () => {
|
||||
const itemType = schema.items?.type ?? 'string'
|
||||
const arrayValue = Array.isArray(value)
|
||||
? value
|
||||
: Array.isArray(schema.default)
|
||||
? schema.default
|
||||
: []
|
||||
|
||||
const textareaValue = arrayValue.map((item) => String(item ?? '')).join('\n')
|
||||
|
||||
return (
|
||||
<Textarea
|
||||
value={textareaValue}
|
||||
onChange={(e) => {
|
||||
const nextItems = e.target.value
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0)
|
||||
.map((line) => {
|
||||
if (itemType === 'integer') {
|
||||
return parseInt(line, 10) || 0
|
||||
}
|
||||
if (itemType === 'number') {
|
||||
return parseFloat(line) || 0
|
||||
}
|
||||
if (itemType === 'boolean') {
|
||||
return line === 'true'
|
||||
}
|
||||
return line
|
||||
})
|
||||
onChange(nextItems)
|
||||
}}
|
||||
rows={Math.max(4, arrayValue.length || 4)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const renderObjectEditor = () => {
|
||||
const objectValue =
|
||||
value && typeof value === 'object' && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: {}
|
||||
|
||||
return (
|
||||
<KeyValueEditor
|
||||
value={objectValue}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染字段图标
|
||||
*/
|
||||
const renderIcon = () => {
|
||||
if (!schema['x-icon']) return null
|
||||
|
||||
const IconComponent = LucideIcons[schema['x-icon'] as keyof typeof LucideIcons] as React.ComponentType<{ className?: string }> | undefined
|
||||
if (!IconComponent) return null
|
||||
|
||||
return <IconComponent className="h-4 w-4" />
|
||||
}
|
||||
|
||||
const optionDescriptions = schema['x-option-descriptions'] ?? {}
|
||||
const hasOptionDescriptions = Object.keys(optionDescriptions).length > 0
|
||||
const descriptionDisplay = schema['x-description-display'] ?? 'label-hover'
|
||||
const fieldDescription = schema.description
|
||||
const inlineDescription = descriptionDisplay === 'inline' && !hasOptionDescriptions ? fieldDescription : ''
|
||||
|
||||
const renderDescriptionTooltip = (trigger: React.ReactElement, side: 'top' | 'right' = 'top') => {
|
||||
if (!fieldDescription) return trigger
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={150}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
{trigger}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side={side}
|
||||
align="start"
|
||||
className="max-w-80 whitespace-pre-line bg-background text-foreground border shadow-lg"
|
||||
>
|
||||
{fieldDescription}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
const renderFieldHeader = () => (
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
|
||||
{(() => {
|
||||
const label = (
|
||||
<Label
|
||||
className={cn(
|
||||
"inline-flex min-w-0 items-center gap-1.5 text-[15px] leading-6",
|
||||
descriptionDisplay === 'label-hover' && fieldDescription && "cursor-help",
|
||||
schema.advanced
|
||||
? "text-sky-700 dark:text-sky-300"
|
||||
: "text-foreground",
|
||||
)}
|
||||
>
|
||||
{renderIcon()}
|
||||
<span className="break-words">{fieldLabel}</span>
|
||||
{schema.required && <span className="text-destructive">*</span>}
|
||||
</Label>
|
||||
)
|
||||
|
||||
return descriptionDisplay === 'label-hover'
|
||||
? renderDescriptionTooltip(label)
|
||||
: label
|
||||
})()}
|
||||
{descriptionDisplay === 'icon' && fieldDescription && (
|
||||
renderDescriptionTooltip(
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${fieldLabel} 说明`}
|
||||
className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<LucideIcons.CircleAlert className="h-4 w-4" />
|
||||
</button>,
|
||||
'right',
|
||||
)
|
||||
)}
|
||||
{inlineDescription && (
|
||||
<span className="text-[13px] leading-6 text-muted-foreground whitespace-pre-line">
|
||||
{inlineDescription}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
/**
|
||||
* 根据 x-widget 或 type 选择并渲染对应的输入组件
|
||||
*/
|
||||
const renderInputComponent = () => {
|
||||
const widget = schema['x-widget']
|
||||
const type = schema.type
|
||||
const resolvedWidget =
|
||||
isNumericField && (widget === 'input' || widget === 'number' || !widget)
|
||||
? 'number'
|
||||
: widget
|
||||
|
||||
// x-widget 优先
|
||||
if (resolvedWidget) {
|
||||
switch (resolvedWidget) {
|
||||
case 'slider':
|
||||
return renderSlider()
|
||||
case 'input':
|
||||
return renderTextInput()
|
||||
case 'number':
|
||||
return renderNumberInput()
|
||||
case 'password':
|
||||
return renderTextInput('password')
|
||||
case 'switch':
|
||||
return renderSwitch()
|
||||
case 'textarea':
|
||||
return renderTextarea()
|
||||
case 'select':
|
||||
return renderSelect()
|
||||
case 'custom':
|
||||
if (type === 'array' && schema.items && schema.items.type !== 'object') {
|
||||
return renderPrimitiveArrayEditor()
|
||||
}
|
||||
if (type === 'object') {
|
||||
return renderObjectEditor()
|
||||
}
|
||||
return (
|
||||
<div className="rounded-md border border-dashed border-muted-foreground/25 bg-muted/10 p-4 text-center text-sm text-muted-foreground">
|
||||
Custom field requires Hook
|
||||
</div>
|
||||
)
|
||||
default:
|
||||
// 未知的 x-widget,回退到 type
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// type 回退
|
||||
switch (type) {
|
||||
case 'boolean':
|
||||
return renderSwitch()
|
||||
case 'number':
|
||||
case 'integer':
|
||||
return renderNumberInput()
|
||||
case 'string':
|
||||
return renderTextInput()
|
||||
case 'select':
|
||||
return renderSelect()
|
||||
case 'array':
|
||||
if (!schema.items || schema.items.type === 'object') {
|
||||
return (
|
||||
<div className="rounded-md border border-dashed border-muted-foreground/25 bg-muted/10 p-4 text-center text-sm text-muted-foreground">
|
||||
Complex array requires Hook
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return renderPrimitiveArrayEditor()
|
||||
case 'object':
|
||||
return renderObjectEditor()
|
||||
case 'textarea':
|
||||
return renderTextarea()
|
||||
default:
|
||||
return (
|
||||
<div className="rounded-md border border-dashed border-muted-foreground/25 bg-muted/10 p-4 text-center text-sm text-muted-foreground">
|
||||
Unknown field type: {type}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染 Switch 组件(用于 boolean 类型)
|
||||
* 使用水平布局:标签+描述在左,开关在右
|
||||
*/
|
||||
const renderSwitch = () => {
|
||||
const checked = Boolean(value)
|
||||
return (
|
||||
<div className="flex min-w-0 items-center justify-between gap-4 py-2">
|
||||
<div className="min-w-0 pr-4">
|
||||
{renderFieldHeader()}
|
||||
</div>
|
||||
<Switch
|
||||
checked={checked}
|
||||
onCheckedChange={(checked) => onChange(checked)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染 Slider 组件(用于 number 类型 + x-widget: slider)
|
||||
*/
|
||||
const renderSlider = () => {
|
||||
const numValue = parseNumericValue(value, schema.default)
|
||||
const min = schema.minValue ?? 0
|
||||
const max = schema.maxValue ?? 100
|
||||
const step = schema.step ?? 1
|
||||
|
||||
return (
|
||||
<div className="min-w-0 space-y-2">
|
||||
<Slider
|
||||
value={[numValue]}
|
||||
onValueChange={(values) => onChange(values[0])}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>{min}</span>
|
||||
<span className="font-medium text-foreground">{numValue}</span>
|
||||
<span>{max}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染 Input[type="number"] 组件(用于 number/integer 类型)
|
||||
*/
|
||||
const renderNumberInput = () => {
|
||||
const numValue = parseNumericValue(value, schema.default)
|
||||
const min = schema.minValue
|
||||
const max = schema.maxValue
|
||||
const step = schema.step ?? (schema.type === 'integer' ? 1 : 0.1)
|
||||
|
||||
return (
|
||||
<Input
|
||||
type="number"
|
||||
value={numValue}
|
||||
onChange={(e) => {
|
||||
const nextValue = schema.type === 'integer'
|
||||
? parseInt(e.target.value, 10)
|
||||
: parseFloat(e.target.value)
|
||||
onChange(Number.isFinite(nextValue) ? nextValue : 0)
|
||||
}}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染 Input[type="text"] 组件(用于 string 类型)
|
||||
*/
|
||||
const renderTextInput = (type: 'password' | 'text' = 'text') => {
|
||||
const strValue =
|
||||
typeof value === 'string'
|
||||
? value
|
||||
: value === null || value === undefined
|
||||
? String(schema.default ?? '')
|
||||
: String(value)
|
||||
return (
|
||||
<Input
|
||||
type={type}
|
||||
value={strValue}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染 Textarea 组件(用于 textarea 类型或 x-widget: textarea)
|
||||
*/
|
||||
const renderTextarea = () => {
|
||||
const strValue = typeof value === 'string' ? value : (schema.default as string ?? '')
|
||||
const minHeight = typeof schema['x-textarea-min-height'] === 'number'
|
||||
? schema['x-textarea-min-height']
|
||||
: undefined
|
||||
const rows = typeof schema['x-textarea-rows'] === 'number'
|
||||
? schema['x-textarea-rows']
|
||||
: 4
|
||||
|
||||
return (
|
||||
<Textarea
|
||||
value={strValue}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
rows={rows}
|
||||
minHeight={minHeight}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染 Select 组件(用于 select 类型或 x-widget: select)
|
||||
*/
|
||||
const renderSelect = () => {
|
||||
const strValue = typeof value === 'string' ? value : (schema.default as string ?? '')
|
||||
const options = schema.options ?? []
|
||||
|
||||
if (options.length === 0) {
|
||||
return (
|
||||
<div className="rounded-md border border-dashed border-muted-foreground/25 bg-muted/10 p-4 text-center text-sm text-muted-foreground">
|
||||
No options available for select
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Select value={strValue} onValueChange={(val) => onChange(val)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={`Select ${fieldLabel}`} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{hasOptionDescriptions ? (
|
||||
<TooltipProvider delayDuration={150}>
|
||||
{options.map((option) => {
|
||||
const description = optionDescriptions[option]
|
||||
return description ? (
|
||||
<Tooltip key={option}>
|
||||
<TooltipTrigger asChild>
|
||||
<SelectItem value={option} title={description}>
|
||||
{option}
|
||||
</SelectItem>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="right"
|
||||
align="center"
|
||||
className="max-w-72 bg-background text-foreground border shadow-lg"
|
||||
>
|
||||
{description}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<SelectItem key={option} value={option}>
|
||||
{option}
|
||||
</SelectItem>
|
||||
)
|
||||
})}
|
||||
</TooltipProvider>
|
||||
) : (
|
||||
options.map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{option}
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
|
||||
// 判断当前字段是否为 Switch/Boolean 类型(独立处理布局)
|
||||
const isBoolean =
|
||||
schema['x-widget'] === 'switch' ||
|
||||
(!schema['x-widget'] && schema.type === 'boolean')
|
||||
const supportsInlineRight =
|
||||
schema['x-layout'] === 'inline-right' &&
|
||||
['input', 'number', 'password', 'select', undefined].includes(schema['x-widget']) &&
|
||||
['string', 'number', 'integer', 'select'].includes(schema.type)
|
||||
|
||||
// Switch/Boolean 字段自带完整布局,直接返回
|
||||
if (isBoolean) {
|
||||
return renderInputComponent()
|
||||
}
|
||||
|
||||
if (supportsInlineRight) {
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col gap-2 py-2 sm:flex-row sm:items-center"
|
||||
style={{ '--field-input-width': schema['x-input-width'] ?? '12rem' } as React.CSSProperties}
|
||||
>
|
||||
<div className="min-w-0 sm:shrink-0">
|
||||
{renderFieldHeader()}
|
||||
</div>
|
||||
<div className="min-w-20 flex-1 sm:ml-auto sm:max-w-[var(--field-input-width)]">
|
||||
{renderInputComponent()}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-w-0 space-y-2">
|
||||
{renderFieldHeader()}
|
||||
|
||||
{/* Input component */}
|
||||
{renderInputComponent()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user