message / frontend /src /plugins /SchemaPluginForm.tsx
hunian
feat: 重构前端控制台和工具页面
a43ac26
Raw
History Blame Contribute Delete
6.15 kB
/**
* Schema UI 表单组件
*
* 按 ui_entry.schema.fields 渲染表单,并按 ui_entry.submit 契约提交。
* 只允许使用后端 ui_entry.submit 指定的提交契约,不能由前端猜 endpoint。
* 使用 console 语义 token 保持视觉一致。
*/
import { useState } from 'react';
import { AlertCircle, Loader2, CheckCircle } from 'lucide-react';
import { UISchemaDefinition, UISubmitContract } from '@typings/plugin';
import { Button } from '@components/ui/button';
import { Input } from '@components/ui/input';
import { Label } from '@components/ui/label';
interface SchemaPluginFormProps {
pluginName: string;
schema: UISchemaDefinition;
submit: UISubmitContract;
}
export function SchemaPluginForm({ pluginName: _pluginName, schema, submit }: SchemaPluginFormProps) {
const [formData, setFormData] = useState<Record<string, any>>({});
const [isSubmitting, setIsSubmitting] = useState(false);
const [result, setResult] = useState<any>(null);
const [error, setError] = useState<string | null>(null);
const handleFieldChange = (name: string, value: any) => {
setFormData((prev) => ({ ...prev, [name]: value }));
};
const handleSubmit = async () => {
setIsSubmitting(true);
setError(null);
setResult(null);
try {
// 只使用 ui_entry.submit 契约
if (submit.method !== 'POST') {
throw new Error(`不支持的提交方法: ${submit.method}`);
}
const response = await fetch(submit.path, {
method: submit.method,
headers: {
'Content-Type': submit.content_type || 'application/json',
},
body: JSON.stringify(formData),
});
if (!response.ok) {
const errorData = await response.json().catch(() => null);
const errorMessage =
errorData?.message || errorData?.detail || `请求失败: ${response.status}`;
throw new Error(errorMessage);
}
const data = await response.json();
// 按 success_path 读取结果
if (submit.success_path) {
const successResult = submit.success_path.split('.').reduce((obj, key) => obj?.[key], data);
setResult(successResult);
} else {
setResult(data);
}
} catch (err: any) {
setError(err.message || '提交失败');
} finally {
setIsSubmitting(false);
}
};
return (
<div className="space-y-4">
{/* 表单标题 */}
<div>
<h3 className="text-sm font-medium text-console-text-primary">{schema.title}</h3>
{schema.description && (
<p className="text-sm text-console-text-secondary mt-1">{schema.description}</p>
)}
</div>
{/* 表单字段 */}
<div className="space-y-4">
<h4 className="text-sm font-medium text-console-text-primary">参数</h4>
{schema.fields.map((field) => (
<div key={field.name} className="space-y-1.5">
<Label htmlFor={field.name} className="text-sm text-console-text-primary">
{field.label}
{field.required && <span className="text-console-status-error ml-1">*</span>}
</Label>
{field.type === 'string' && field.name.includes('content') ? (
<textarea
id={field.name}
placeholder={field.placeholder}
value={formData[field.name] || ''}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => handleFieldChange(field.name, e.target.value)}
className="flex min-h-[80px] w-full rounded-console-md border border-console-border bg-console-surface-base px-3 py-2 text-sm text-console-text-primary placeholder:text-console-text-muted focus:outline-none focus:ring-2 focus:ring-console-status-info/30 focus:border-console-status-info disabled:cursor-not-allowed disabled:opacity-50"
/>
) : (
<Input
id={field.name}
type={field.type === 'number' ? 'number' : 'text'}
placeholder={field.placeholder}
value={formData[field.name] || ''}
onChange={(e) =>
handleFieldChange(
field.name,
field.type === 'number' ? Number(e.target.value) : e.target.value
)
}
/>
)}
{field.help_text && (
<p className="text-xs text-console-text-muted">{field.help_text}</p>
)}
</div>
))}
<Button onClick={handleSubmit} disabled={isSubmitting} className="w-full">
{isSubmitting ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
提交中...
</>
) : (
'提交'
)}
</Button>
</div>
{/* 错误信息 */}
{error && (
<div className="p-3 rounded-console-md border border-console-status-error bg-console-status-error-bg">
<div className="flex items-start gap-2">
<AlertCircle className="h-4 w-4 text-console-status-error mt-0.5 shrink-0" />
<div>
<p className="text-sm font-medium text-console-status-error">提交失败</p>
<p className="text-sm text-console-text-secondary mt-1">{error}</p>
</div>
</div>
</div>
)}
{/* 结果 */}
{result !== null && (
<div className="p-3 rounded-console-md border border-console-status-enabled bg-console-status-enabled-bg">
<div className="flex items-start gap-2">
<CheckCircle className="h-4 w-4 text-console-status-enabled mt-0.5 shrink-0" />
<div className="min-w-0">
<p className="text-sm font-medium text-console-status-enabled">提交成功</p>
<pre className="text-sm bg-console-surface-base text-console-text-primary p-3 rounded-console-sm mt-2 overflow-x-auto">
{typeof result === 'string' ? result : JSON.stringify(result, null, 2)}
</pre>
</div>
</div>
</div>
)}
</div>
);
}