Major changes: - Shift from "OpenFang desktop client" to "independent AI Agent desktop app" - Add decision principle: "Is this useful for ZCLAW? Does it affect ZCLAW?" - Simplify project structure and tech stack sections - Replace OpenClaw vs OpenFang comparison with unified backend approach - Consolidate troubleshooting from scattered sections into organized FAQ - Update Hands system documentation with 8 capabilities and status - Stream
810 lines
26 KiB
TypeScript
810 lines
26 KiB
TypeScript
/**
|
|
* HandParamsForm - Dynamic form component for Hand parameters
|
|
*
|
|
* Supports all parameter types:
|
|
* - text: Text input
|
|
* - number: Number input with min/max validation
|
|
* - boolean: Toggle/checkbox
|
|
* - select: Dropdown select
|
|
* - textarea: Multi-line text
|
|
* - array: Dynamic array with add/remove items
|
|
* - object: JSON object editor
|
|
* - file: File selector
|
|
*
|
|
* Features:
|
|
* - Form validation (required, type, range, pattern)
|
|
* - Parameter presets (save/load/delete)
|
|
* - Error display below inputs
|
|
*/
|
|
|
|
import { useState, useCallback, useEffect, useMemo } from 'react';
|
|
import {
|
|
AlertCircle,
|
|
Plus,
|
|
Trash2,
|
|
Save,
|
|
FolderOpen,
|
|
Trash,
|
|
ChevronDown,
|
|
ChevronUp,
|
|
FileText,
|
|
Info,
|
|
} from 'lucide-react';
|
|
import type { HandParameter } from '../types/hands';
|
|
import { parseJsonOrDefault, safeJsonParse } from '../lib/json-utils';
|
|
|
|
// === Types ===
|
|
|
|
export interface HandParamsFormProps {
|
|
parameters: HandParameter[];
|
|
values: Record<string, unknown>;
|
|
onChange: (values: Record<string, unknown>) => void;
|
|
errors?: Record<string, string>;
|
|
disabled?: boolean;
|
|
presetKey?: string; // Key for storing/loading presets
|
|
}
|
|
|
|
export interface ParameterPreset {
|
|
id: string;
|
|
name: string;
|
|
createdAt: string;
|
|
values: Record<string, unknown>;
|
|
}
|
|
|
|
// === Validation ===
|
|
|
|
interface ValidationResult {
|
|
isValid: boolean;
|
|
error?: string;
|
|
}
|
|
|
|
function validateParameter(param: HandParameter, value: unknown): ValidationResult {
|
|
// Required check
|
|
if (param.required) {
|
|
if (value === undefined || value === null || value === '') {
|
|
return { isValid: false, error: `${param.label} 为必填项` };
|
|
}
|
|
if (Array.isArray(value) && value.length === 0) {
|
|
return { isValid: false, error: `${param.label} 为必填项` };
|
|
}
|
|
if (typeof value === 'object' && !Array.isArray(value) && Object.keys(value as Record<string, unknown>).length === 0) {
|
|
return { isValid: false, error: `${param.label} 为必填项` };
|
|
}
|
|
}
|
|
|
|
// Skip further validation if value is empty and not required
|
|
if (value === undefined || value === null || value === '') {
|
|
return { isValid: true };
|
|
}
|
|
|
|
// Type-specific validation
|
|
switch (param.type) {
|
|
case 'number':
|
|
if (typeof value !== 'number' || isNaN(value)) {
|
|
return { isValid: false, error: `${param.label} 必须是有效数字` };
|
|
}
|
|
if (param.min !== undefined && value < param.min) {
|
|
return { isValid: false, error: `${param.label} 不能小于 ${param.min}` };
|
|
}
|
|
if (param.max !== undefined && value > param.max) {
|
|
return { isValid: false, error: `${param.label} 不能大于 ${param.max}` };
|
|
}
|
|
break;
|
|
|
|
case 'text':
|
|
case 'textarea':
|
|
if (typeof value !== 'string') {
|
|
return { isValid: false, error: `${param.label} 必须是文本` };
|
|
}
|
|
if (param.pattern) {
|
|
try {
|
|
const regex = new RegExp(param.pattern);
|
|
if (!regex.test(value)) {
|
|
return { isValid: false, error: `${param.label} 格式不正确` };
|
|
}
|
|
} catch {
|
|
// Invalid regex pattern, skip validation
|
|
}
|
|
}
|
|
break;
|
|
|
|
case 'array':
|
|
if (!Array.isArray(value)) {
|
|
return { isValid: false, error: `${param.label} 必须是数组` };
|
|
}
|
|
break;
|
|
|
|
case 'object':
|
|
if (typeof value !== 'object' || Array.isArray(value)) {
|
|
return { isValid: false, error: `${param.label} 必须是对象` };
|
|
}
|
|
try {
|
|
// Try to stringify to validate JSON
|
|
JSON.stringify(value);
|
|
} catch {
|
|
return { isValid: false, error: `${param.label} 包含无效的 JSON` };
|
|
}
|
|
break;
|
|
|
|
case 'file':
|
|
if (typeof value !== 'string') {
|
|
return { isValid: false, error: `${param.label} must be a file path` };
|
|
}
|
|
break;
|
|
}
|
|
|
|
return { isValid: true };
|
|
}
|
|
|
|
// === Preset Storage ===
|
|
|
|
const PRESET_STORAGE_PREFIX = 'zclaw-hand-preset-';
|
|
|
|
function getPresetStorageKey(handId: string): string {
|
|
return `${PRESET_STORAGE_PREFIX}${handId}`;
|
|
}
|
|
|
|
function loadPresets(handId: string): ParameterPreset[] {
|
|
const stored = localStorage.getItem(getPresetStorageKey(handId));
|
|
if (stored) {
|
|
return parseJsonOrDefault<ParameterPreset[]>(stored, []);
|
|
}
|
|
return [];
|
|
}
|
|
|
|
function savePresets(handId: string, presets: ParameterPreset[]): void {
|
|
try {
|
|
localStorage.setItem(getPresetStorageKey(handId), JSON.stringify(presets));
|
|
} catch {
|
|
// Ignore storage errors
|
|
}
|
|
}
|
|
|
|
// === Sub-Components ===
|
|
|
|
interface FormFieldWrapperProps {
|
|
param: HandParameter;
|
|
error?: string;
|
|
children: React.ReactNode;
|
|
}
|
|
|
|
function FormFieldWrapper({ param, error, children }: FormFieldWrapperProps) {
|
|
return (
|
|
<div className="space-y-1.5">
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
|
{param.label}
|
|
{param.required && <span className="text-red-500 ml-1">*</span>}
|
|
</label>
|
|
{children}
|
|
{param.description && !error && (
|
|
<p className="text-xs text-gray-500 dark:text-gray-400 flex items-center gap-1">
|
|
<Info className="w-3 h-3" />
|
|
{param.description}
|
|
</p>
|
|
)}
|
|
{error && (
|
|
<p className="text-xs text-red-500 flex items-center gap-1">
|
|
<AlertCircle className="w-3 h-3" />
|
|
{error}
|
|
</p>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// === Parameter Input Components ===
|
|
|
|
interface ParamInputProps {
|
|
param: HandParameter;
|
|
value: unknown;
|
|
onChange: (value: unknown) => void;
|
|
disabled?: boolean;
|
|
error?: string;
|
|
}
|
|
|
|
function TextParamInput({ param, value, onChange, disabled, error }: ParamInputProps) {
|
|
return (
|
|
<input
|
|
type="text"
|
|
value={(value as string) ?? ''}
|
|
onChange={(e) => onChange(e.target.value)}
|
|
placeholder={param.placeholder}
|
|
disabled={disabled}
|
|
className={`w-full px-3 py-2 text-sm border rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-gray-400 disabled:opacity-50 disabled:cursor-not-allowed ${
|
|
error ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'
|
|
}`}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function NumberParamInput({ param, value, onChange, disabled, error }: ParamInputProps) {
|
|
return (
|
|
<input
|
|
type="number"
|
|
value={(value as number) ?? ''}
|
|
onChange={(e) => {
|
|
const val = e.target.value;
|
|
onChange(val === '' ? undefined : parseFloat(val));
|
|
}}
|
|
placeholder={param.placeholder}
|
|
min={param.min}
|
|
max={param.max}
|
|
disabled={disabled}
|
|
className={`w-full px-3 py-2 text-sm border rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-gray-400 disabled:opacity-50 disabled:cursor-not-allowed ${
|
|
error ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'
|
|
}`}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function BooleanParamInput({ param, value, onChange, disabled }: ParamInputProps) {
|
|
return (
|
|
<label className="flex items-center gap-2 cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={(value as boolean) ?? false}
|
|
onChange={(e) => onChange(e.target.checked)}
|
|
disabled={disabled}
|
|
className="w-4 h-4 text-gray-600 border-gray-300 rounded focus:ring-gray-400 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
/>
|
|
<span className="text-sm text-gray-600 dark:text-gray-400">
|
|
{param.placeholder || '启用'}
|
|
</span>
|
|
</label>
|
|
);
|
|
}
|
|
|
|
function SelectParamInput({ param, value, onChange, disabled, error }: ParamInputProps) {
|
|
return (
|
|
<select
|
|
value={(value as string) ?? ''}
|
|
onChange={(e) => onChange(e.target.value)}
|
|
disabled={disabled}
|
|
className={`w-full px-3 py-2 text-sm border rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed ${
|
|
error ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'
|
|
}`}
|
|
>
|
|
<option value="">{param.placeholder || '-- 请选择 --'}</option>
|
|
{param.options?.map((opt) => (
|
|
<option key={opt.value} value={opt.value}>
|
|
{opt.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
);
|
|
}
|
|
|
|
function TextareaParamInput({ param, value, onChange, disabled, error }: ParamInputProps) {
|
|
return (
|
|
<textarea
|
|
value={(value as string) ?? ''}
|
|
onChange={(e) => onChange(e.target.value)}
|
|
placeholder={param.placeholder}
|
|
disabled={disabled}
|
|
rows={3}
|
|
className={`w-full px-3 py-2 text-sm border rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed resize-y ${
|
|
error ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'
|
|
}`}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function ArrayParamInput({ param, value, onChange, disabled, error }: ParamInputProps) {
|
|
const [newItem, setNewItem] = useState('');
|
|
const items = (Array.isArray(value) ? value : []) as string[];
|
|
|
|
const handleAddItem = () => {
|
|
if (newItem.trim()) {
|
|
onChange([...items, newItem.trim()]);
|
|
setNewItem('');
|
|
}
|
|
};
|
|
|
|
const handleRemoveItem = (index: number) => {
|
|
const newItems = [...items];
|
|
newItems.splice(index, 1);
|
|
onChange(newItems);
|
|
};
|
|
|
|
const handleUpdateItem = (index: number, newValue: string) => {
|
|
const newItems = [...items];
|
|
newItems[index] = newValue;
|
|
onChange(newItems);
|
|
};
|
|
|
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
handleAddItem();
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className={`space-y-2 p-3 border rounded-lg ${error ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'}`}>
|
|
{/* Existing Items */}
|
|
{items.length > 0 && (
|
|
<div className="space-y-1.5">
|
|
{items.map((item, index) => (
|
|
<div key={index} className="flex items-center gap-2">
|
|
<input
|
|
type="text"
|
|
value={item}
|
|
onChange={(e) => handleUpdateItem(index, e.target.value)}
|
|
disabled={disabled}
|
|
className="flex-1 px-2 py-1 text-sm border border-gray-200 dark:border-gray-700 rounded bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:outline-none focus:ring-1 focus:ring-gray-400 disabled:opacity-50"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => handleRemoveItem(index)}
|
|
disabled={disabled}
|
|
className="p-1 text-red-500 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-900/20 rounded disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Add New Item */}
|
|
<div className="flex items-center gap-2">
|
|
<input
|
|
type="text"
|
|
value={newItem}
|
|
onChange={(e) => setNewItem(e.target.value)}
|
|
onKeyDown={handleKeyDown}
|
|
placeholder={param.placeholder || '添加项目...'}
|
|
disabled={disabled}
|
|
className="flex-1 px-2 py-1 text-sm border border-gray-200 dark:border-gray-700 rounded bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-50"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={handleAddItem}
|
|
disabled={disabled || !newItem.trim()}
|
|
className="p-1 text-gray-500 hover:text-gray-700 hover:bg-gray-50 dark:hover:bg-gray-900/20 rounded disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
<Plus className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
|
|
{items.length === 0 && !newItem && (
|
|
<p className="text-xs text-gray-400 text-center">No items added yet</p>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ObjectParamInput({ param, value, onChange, disabled, error }: ParamInputProps) {
|
|
const [jsonText, setJsonText] = useState('');
|
|
const [parseError, setParseError] = useState<string | null>(null);
|
|
const [isExpanded, setIsExpanded] = useState(true);
|
|
|
|
// Sync jsonText with value
|
|
useEffect(() => {
|
|
try {
|
|
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
setJsonText(JSON.stringify(value, null, 2));
|
|
setParseError(null);
|
|
} else {
|
|
setJsonText('');
|
|
}
|
|
} catch {
|
|
setJsonText('');
|
|
}
|
|
}, [value]);
|
|
|
|
const handleTextChange = (text: string) => {
|
|
setJsonText(text);
|
|
|
|
if (!text.trim()) {
|
|
onChange({});
|
|
setParseError(null);
|
|
return;
|
|
}
|
|
|
|
const result = safeJsonParse<unknown>(text);
|
|
if (result.success) {
|
|
if (typeof result.data === 'object' && !Array.isArray(result.data)) {
|
|
onChange(result.data as Record<string, unknown>);
|
|
setParseError(null);
|
|
} else {
|
|
setParseError('值必须是 JSON 对象');
|
|
}
|
|
} else {
|
|
setParseError('JSON 格式无效');
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className={`space-y-2 ${error || parseError ? 'border-red-500' : ''}`}>
|
|
<button
|
|
type="button"
|
|
onClick={() => setIsExpanded(!isExpanded)}
|
|
className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white"
|
|
>
|
|
{isExpanded ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
|
|
{isExpanded ? '收起' : '展开'} JSON 编辑器
|
|
</button>
|
|
|
|
{isExpanded && (
|
|
<textarea
|
|
value={jsonText}
|
|
onChange={(e) => handleTextChange(e.target.value)}
|
|
placeholder={param.placeholder || '{\n "key": "value"\n}'}
|
|
disabled={disabled}
|
|
rows={6}
|
|
className={`w-full px-3 py-2 text-sm border rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 font-mono disabled:opacity-50 disabled:cursor-not-allowed resize-y ${
|
|
error || parseError ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'
|
|
}`}
|
|
/>
|
|
)}
|
|
|
|
{parseError && (
|
|
<p className="text-xs text-red-500 flex items-center gap-1">
|
|
<AlertCircle className="w-3 h-3" />
|
|
{parseError}
|
|
</p>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function FileParamInput({ param, value, onChange, disabled, error }: ParamInputProps) {
|
|
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const file = e.target.files?.[0];
|
|
if (file) {
|
|
// For now, just store the file name. In a real implementation,
|
|
// you might want to read the file contents or handle upload
|
|
onChange(file.name);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className={`space-y-2 ${error ? 'border-red-500' : ''}`}>
|
|
<div className="flex items-center gap-2">
|
|
<label
|
|
className={`flex-1 flex items-center gap-2 px-3 py-2 text-sm border rounded-lg cursor-pointer transition-colors ${
|
|
disabled
|
|
? 'bg-gray-100 dark:bg-gray-800 cursor-not-allowed opacity-50'
|
|
: 'bg-white dark:bg-gray-900 hover:bg-gray-50 dark:hover:bg-gray-800'
|
|
} ${error ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'}`}
|
|
>
|
|
<FileText className="w-4 h-4 text-gray-400" />
|
|
<span className="flex-1 truncate text-gray-900 dark:text-white">
|
|
{(value as string) || param.placeholder || 'Choose file...'}
|
|
</span>
|
|
<input
|
|
type="file"
|
|
accept={param.accept}
|
|
onChange={handleFileChange}
|
|
disabled={disabled}
|
|
className="hidden"
|
|
/>
|
|
</label>
|
|
{(value as string) && (
|
|
<button
|
|
type="button"
|
|
onClick={() => onChange('')}
|
|
disabled={disabled}
|
|
className="p-2 text-red-500 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-900/20 rounded disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// === Parameter Field Component ===
|
|
|
|
function ParameterField({ param, value, onChange, disabled, externalError }: ParamInputProps & { externalError?: string }) {
|
|
const [internalError, setInternalError] = useState<string | undefined>(undefined);
|
|
|
|
const handleChange = useCallback((newValue: unknown) => {
|
|
// Validate on change
|
|
const result = validateParameter(param, newValue);
|
|
setInternalError(result.error ?? undefined);
|
|
onChange(newValue);
|
|
}, [param, onChange]);
|
|
|
|
const error = externalError || internalError;
|
|
|
|
const inputProps: ParamInputProps = { param, value, onChange: handleChange, disabled, error };
|
|
|
|
const renderInput = () => {
|
|
switch (param.type) {
|
|
case 'text':
|
|
return <TextParamInput {...inputProps} />;
|
|
case 'number':
|
|
return <NumberParamInput {...inputProps} />;
|
|
case 'boolean':
|
|
return <BooleanParamInput {...inputProps} />;
|
|
case 'select':
|
|
return <SelectParamInput {...inputProps} />;
|
|
case 'textarea':
|
|
return <TextareaParamInput {...inputProps} />;
|
|
case 'array':
|
|
return <ArrayParamInput {...inputProps} />;
|
|
case 'object':
|
|
return <ObjectParamInput {...inputProps} />;
|
|
case 'file':
|
|
return <FileParamInput {...inputProps} />;
|
|
default:
|
|
return <TextParamInput {...inputProps} />;
|
|
}
|
|
};
|
|
|
|
return (
|
|
<FormFieldWrapper param={param} error={error}>
|
|
{renderInput()}
|
|
</FormFieldWrapper>
|
|
);
|
|
}
|
|
|
|
// === Preset Manager Component ===
|
|
|
|
interface PresetManagerProps {
|
|
presetKey?: string;
|
|
currentValues: Record<string, unknown>;
|
|
onLoadPreset: (values: Record<string, unknown>) => void;
|
|
}
|
|
|
|
function PresetManager({ presetKey, currentValues, onLoadPreset }: PresetManagerProps) {
|
|
const [presets, setPresets] = useState<ParameterPreset[]>([]);
|
|
const [showSaveDialog, setShowSaveDialog] = useState(false);
|
|
const [presetName, setPresetName] = useState('');
|
|
const [showPresetList, setShowPresetList] = useState(false);
|
|
|
|
// Load presets on mount
|
|
useEffect(() => {
|
|
if (presetKey) {
|
|
setPresets(loadPresets(presetKey));
|
|
}
|
|
}, [presetKey]);
|
|
|
|
const handleSavePreset = () => {
|
|
if (!presetKey || !presetName.trim()) return;
|
|
|
|
const newPreset: ParameterPreset = {
|
|
id: `preset-${Date.now()}`,
|
|
name: presetName.trim(),
|
|
createdAt: new Date().toISOString(),
|
|
values: { ...currentValues },
|
|
};
|
|
|
|
const newPresets = [...presets, newPreset];
|
|
setPresets(newPresets);
|
|
savePresets(presetKey, newPresets);
|
|
setPresetName('');
|
|
setShowSaveDialog(false);
|
|
};
|
|
|
|
const handleLoadPreset = (preset: ParameterPreset) => {
|
|
onLoadPreset(preset.values);
|
|
setShowPresetList(false);
|
|
};
|
|
|
|
const handleDeletePreset = (presetId: string) => {
|
|
if (!presetKey) return;
|
|
const newPresets = presets.filter((p) => p.id !== presetId);
|
|
setPresets(newPresets);
|
|
savePresets(presetKey, newPresets);
|
|
};
|
|
|
|
if (!presetKey) return null;
|
|
|
|
return (
|
|
<div className="border-b border-gray-200 dark:border-gray-700 pb-4 mb-4">
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowSaveDialog(true)}
|
|
className="flex items-center gap-1.5 px-3 py-1.5 text-sm border border-gray-200 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300"
|
|
>
|
|
<Save className="w-3.5 h-3.5" />
|
|
保存预设
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowPresetList(!showPresetList)}
|
|
disabled={presets.length === 0}
|
|
className="flex items-center gap-1.5 px-3 py-1.5 text-sm border border-gray-200 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
<FolderOpen className="w-3.5 h-3.5" />
|
|
加载预设 ({presets.length})
|
|
</button>
|
|
</div>
|
|
|
|
{/* Save Dialog */}
|
|
{showSaveDialog && (
|
|
<div className="mt-3 p-3 bg-gray-50 dark:bg-gray-900 rounded-lg space-y-2">
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
|
预设名称
|
|
</label>
|
|
<div className="flex items-center gap-2">
|
|
<input
|
|
type="text"
|
|
value={presetName}
|
|
onChange={(e) => setPresetName(e.target.value)}
|
|
placeholder="我的预设..."
|
|
className="flex-1 px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-gray-400"
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter') handleSavePreset();
|
|
if (e.key === 'Escape') setShowSaveDialog(false);
|
|
}}
|
|
autoFocus
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={handleSavePreset}
|
|
disabled={!presetName.trim()}
|
|
className="px-3 py-1.5 text-sm bg-gray-700 dark:bg-gray-600 text-white rounded-md hover:bg-gray-800 dark:hover:bg-gray-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
保存
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowSaveDialog(false)}
|
|
className="px-3 py-1.5 text-sm border border-gray-200 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300"
|
|
>
|
|
取消
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Preset List */}
|
|
{showPresetList && presets.length > 0 && (
|
|
<div className="mt-3 p-3 bg-gray-50 dark:bg-gray-900 rounded-lg space-y-2">
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
|
可用预设
|
|
</label>
|
|
<div className="space-y-1.5 max-h-48 overflow-y-auto">
|
|
{presets.map((preset) => (
|
|
<div
|
|
key={preset.id}
|
|
className="flex items-center justify-between p-2 bg-white dark:bg-gray-800 rounded border border-gray-200 dark:border-gray-700"
|
|
>
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-sm font-medium text-gray-900 dark:text-white truncate">
|
|
{preset.name}
|
|
</p>
|
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
|
{new Date(preset.createdAt).toLocaleDateString()}
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center gap-1 ml-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => handleLoadPreset(preset)}
|
|
className="px-2 py-1 text-xs text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-900/20 rounded"
|
|
>
|
|
加载
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => handleDeletePreset(preset.id)}
|
|
className="p-1 text-red-500 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-900/20 rounded"
|
|
>
|
|
<Trash className="w-3.5 h-3.5" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// === Main Component ===
|
|
|
|
export function HandParamsForm({
|
|
parameters,
|
|
values,
|
|
onChange,
|
|
errors,
|
|
disabled,
|
|
presetKey,
|
|
}: HandParamsFormProps) {
|
|
// Initialize values with defaults
|
|
const initialValues = useMemo(() => {
|
|
const result: Record<string, unknown> = { ...values };
|
|
parameters.forEach((param) => {
|
|
if (result[param.name] === undefined && param.defaultValue !== undefined) {
|
|
result[param.name] = param.defaultValue;
|
|
}
|
|
});
|
|
return result;
|
|
}, [parameters, values]);
|
|
|
|
// Update parent when initialValues changes
|
|
useEffect(() => {
|
|
const hasMissingDefaults = parameters.some(
|
|
(p) => values[p.name] === undefined && p.defaultValue !== undefined
|
|
);
|
|
if (hasMissingDefaults) {
|
|
onChange(initialValues);
|
|
}
|
|
}, [initialValues, parameters, values, onChange]);
|
|
|
|
const handleFieldChange = useCallback(
|
|
(paramName: string, value: unknown) => {
|
|
onChange({
|
|
...values,
|
|
[paramName]: value,
|
|
});
|
|
},
|
|
[values, onChange]
|
|
);
|
|
|
|
const handleLoadPreset = useCallback(
|
|
(presetValues: Record<string, unknown>) => {
|
|
onChange({
|
|
...values,
|
|
...presetValues,
|
|
});
|
|
},
|
|
[values, onChange]
|
|
);
|
|
|
|
if (parameters.length === 0) {
|
|
return (
|
|
<div className="text-center py-4 text-gray-500 dark:text-gray-400 text-sm">
|
|
此自主能力无需参数配置。
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* Preset Manager */}
|
|
<PresetManager
|
|
presetKey={presetKey}
|
|
currentValues={values}
|
|
onLoadPreset={handleLoadPreset}
|
|
/>
|
|
|
|
{/* Parameter Fields - Grid Layout */}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
{parameters.map((param) => (
|
|
<div
|
|
key={param.name}
|
|
className={param.type === 'textarea' || param.type === 'object' || param.type === 'array' ? 'md:col-span-2' : ''}
|
|
>
|
|
<ParameterField
|
|
param={param}
|
|
value={values[param.name]}
|
|
onChange={(value) => handleFieldChange(param.name, value)}
|
|
disabled={disabled}
|
|
externalError={errors?.[param.name]}
|
|
/>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// === Validation Export ===
|
|
|
|
export function validateAllParameters(
|
|
parameters: HandParameter[],
|
|
values: Record<string, unknown>
|
|
): Record<string, string> {
|
|
const errors: Record<string, string> = {};
|
|
|
|
parameters.forEach((param) => {
|
|
const result = validateParameter(param, values[param.name]);
|
|
if (!result.isValid && result.error) {
|
|
errors[param.name] = result.error;
|
|
}
|
|
});
|
|
|
|
return errors;
|
|
}
|
|
|
|
export default HandParamsForm;
|