change doc

This commit is contained in:
musistudio
2025-12-28 13:43:25 +08:00
parent aa18a354bb
commit bd55450b1d
33 changed files with 4807 additions and 592 deletions

View File

@@ -16,6 +16,44 @@ import {
} from "@/components/ui/dialog";
import { Upload, Link, Trash2, Info, Download, CheckCircle2, AlertCircle, Loader2, ArrowLeft, Store, Search, Package } from "lucide-react";
import { Toast } from "@/components/ui/toast";
import { DynamicConfigForm } from "./preset/DynamicConfigForm";
// Schema 类型
interface InputOption {
label: string;
value: string | number | boolean;
description?: string;
disabled?: boolean;
}
interface DynamicOptions {
type: 'static' | 'providers' | 'models' | 'custom';
options?: InputOption[];
providerField?: string;
}
interface Condition {
field: string;
operator?: 'eq' | 'ne' | 'in' | 'nin' | 'gt' | 'lt' | 'gte' | 'lte' | 'exists';
value?: any;
}
interface RequiredInput {
id: string;
type?: 'password' | 'input' | 'select' | 'multiselect' | 'confirm' | 'editor' | 'number';
label?: string;
prompt?: string;
placeholder?: string;
options?: InputOption[] | DynamicOptions;
when?: Condition | Condition[];
defaultValue?: any;
required?: boolean;
validator?: RegExp | string;
min?: number;
max?: number;
rows?: number;
dependsOn?: string[];
}
interface PresetMetadata {
id: string;
@@ -34,9 +72,21 @@ interface PresetMetadata {
installed: boolean;
}
interface PresetConfigSection {
Providers?: Array<{
name: string;
api_base_url?: string;
models?: string[];
[key: string]: any;
}>;
[key: string]: any;
}
interface PresetDetail extends PresetMetadata {
config?: any;
requiredInputs?: Array<{ field: string; prompt?: string; placeholder?: string }>;
config?: PresetConfigSection;
schema?: RequiredInput[];
template?: any;
configMappings?: any[];
}
interface MarketPreset {
@@ -145,13 +195,13 @@ export function Presets() {
setSelectedPreset({ ...preset, ...detail });
setDetailDialogOpen(true);
// 初始化 secrets
if (detail.requiredInputs) {
const initialSecrets: Record<string, string> = {};
for (const input of detail.requiredInputs) {
initialSecrets[input.field] = '';
// 初始化默认值
if (detail.schema && detail.schema.length > 0) {
const initialValues: Record<string, any> = {};
for (const input of detail.schema) {
initialValues[input.id] = input.defaultValue ?? '';
}
setSecrets(initialSecrets);
setSecrets(initialValues);
}
} catch (error) {
console.error('Failed to load preset details:', error);
@@ -188,21 +238,27 @@ export function Presets() {
};
// 应用预设(配置敏感信息)
const handleApplyPreset = async () => {
const handleApplyPreset = async (values?: Record<string, any>) => {
try {
setIsApplying(true);
// 使用传入的values或现有的secrets
const inputValues = values || secrets;
// 验证所有必填项都已填写
if (selectedPreset?.requiredInputs) {
for (const input of selectedPreset.requiredInputs) {
if (!secrets[input.field] || secrets[input.field].trim() === '') {
setToast({ message: t('presets.please_fill_field', { field: input.field }), type: 'warning' });
if (selectedPreset?.schema && selectedPreset.schema.length > 0) {
// 验证在 DynamicConfigForm 中已完成
// 这里只做简单检查
for (const input of selectedPreset.schema) {
if (input.required !== false && !inputValues[input.id]) {
setToast({ message: t('presets.please_fill_field', { field: input.label || input.id }), type: 'warning' });
setIsApplying(false);
return;
}
}
}
await api.applyPreset(selectedPreset!.name, secrets);
await api.applyPreset(selectedPreset!.name, inputValues);
setToast({ message: t('presets.preset_applied'), type: 'success' });
setDetailDialogOpen(false);
setSecrets({});
@@ -423,23 +479,18 @@ export function Presets() {
</div>
)}
{selectedPreset?.requiredInputs && selectedPreset.requiredInputs.length > 0 && (
<div className="mt-6 space-y-4">
<h4 className="font-medium text-sm">{t('presets.required_information')}</h4>
{selectedPreset.requiredInputs.map((input) => (
<div key={input.field} className="space-y-2">
<Label htmlFor={`secret-${input.field}`}>
{input.prompt || input.field}
</Label>
<Input
id={`secret-${input.field}`}
type="password"
placeholder={input.placeholder || t('presets.please_fill_field', { field: input.field })}
value={secrets[input.field] || ''}
onChange={(e) => setSecrets({ ...secrets, [input.field]: e.target.value })}
/>
</div>
))}
{/* 配置表单 */}
{selectedPreset?.schema && selectedPreset.schema.length > 0 && (
<div className="mt-6">
<h4 className="font-medium text-sm mb-4">{t('presets.required_information')}</h4>
<DynamicConfigForm
schema={selectedPreset.schema}
presetConfig={selectedPreset.config || {}}
onSubmit={(values) => handleApplyPreset(values)}
onCancel={() => setDetailDialogOpen(false)}
isSubmitting={isApplying}
initialValues={secrets}
/>
</div>
)}
</div>
@@ -447,21 +498,6 @@ export function Presets() {
<Button variant="outline" onClick={() => setDetailDialogOpen(false)}>
{t('presets.close')}
</Button>
{selectedPreset?.requiredInputs && selectedPreset.requiredInputs.length > 0 && (
<Button onClick={handleApplyPreset} disabled={isApplying}>
{isApplying ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t('presets.applying')}
</>
) : (
<>
<CheckCircle2 className="mr-2 h-4 w-4" />
{t('presets.apply')}
</>
)}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>

View File

@@ -0,0 +1,453 @@
import { useState, useEffect } from 'react';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { CheckCircle2, Loader2 } from 'lucide-react';
// 类型定义
interface InputOption {
label: string;
value: string | number | boolean;
description?: string;
disabled?: boolean;
}
interface DynamicOptions {
type: 'static' | 'providers' | 'models' | 'custom';
options?: InputOption[];
providerField?: string;
}
interface Condition {
field: string;
operator?: 'eq' | 'ne' | 'in' | 'nin' | 'gt' | 'lt' | 'gte' | 'lte' | 'exists';
value?: any;
}
interface RequiredInput {
id: string;
type?: 'password' | 'input' | 'select' | 'multiselect' | 'confirm' | 'editor' | 'number';
label?: string;
prompt?: string;
placeholder?: string;
options?: InputOption[] | DynamicOptions;
when?: Condition | Condition[];
defaultValue?: any;
required?: boolean;
validator?: RegExp | string | ((value: any) => boolean | string);
min?: number;
max?: number;
rows?: number;
dependsOn?: string[];
}
interface PresetConfigSection {
Providers?: Array<{
name: string;
api_base_url?: string;
models?: string[];
[key: string]: any;
}>;
[key: string]: any;
}
interface DynamicConfigFormProps {
schema: RequiredInput[];
presetConfig: PresetConfigSection;
onSubmit: (values: Record<string, any>) => void;
onCancel: () => void;
isSubmitting?: boolean;
initialValues?: Record<string, any>;
}
export function DynamicConfigForm({
schema,
presetConfig,
onSubmit,
onCancel,
isSubmitting = false,
initialValues = {},
}: DynamicConfigFormProps) {
const [values, setValues] = useState<Record<string, any>>(initialValues);
const [errors, setErrors] = useState<Record<string, string>>({});
const [visibleFields, setVisibleFields] = useState<Set<string>>(new Set());
// 计算可见字段
useEffect(() => {
const updateVisibility = () => {
const visible = new Set<string>();
for (const field of schema) {
if (shouldShowField(field, values)) {
visible.add(field.id);
}
}
setVisibleFields(visible);
};
updateVisibility();
}, [values, schema]);
// 评估条件
const evaluateCondition = (condition: Condition): boolean => {
const actualValue = values[condition.field];
if (condition.operator === 'exists') {
return actualValue !== undefined && actualValue !== null;
}
if (condition.operator === 'in') {
return Array.isArray(condition.value) && condition.value.includes(actualValue);
}
if (condition.operator === 'nin') {
return Array.isArray(condition.value) && !condition.value.includes(actualValue);
}
switch (condition.operator) {
case 'eq':
return actualValue === condition.value;
case 'ne':
return actualValue !== condition.value;
case 'gt':
return actualValue > condition.value;
case 'lt':
return actualValue < condition.value;
case 'gte':
return actualValue >= condition.value;
case 'lte':
return actualValue <= condition.value;
default:
return actualValue === condition.value;
}
};
// 判断字段是否应该显示
const shouldShowField = (field: RequiredInput): boolean => {
if (!field.when) {
return true;
}
const conditions = Array.isArray(field.when) ? field.when : [field.when];
return conditions.every(condition => evaluateCondition(condition));
};
// 获取选项列表
const getOptions = (field: RequiredInput): InputOption[] => {
if (!field.options) {
return [];
}
const options = field.options as any;
if (Array.isArray(options)) {
return options as InputOption[];
}
if (options.type === 'static') {
return options.options || [];
}
if (options.type === 'providers') {
const providers = presetConfig.Providers || [];
return providers.map((p) => ({
label: p.name || p.id || String(p),
value: p.name || p.id || String(p),
description: p.api_base_url,
}));
}
if (options.type === 'models') {
const providerField = options.providerField;
if (!providerField) {
return [];
}
const providerId = String(providerField).replace(/^{{(.+)}}$/, '$1');
const selectedProvider = values[providerId];
if (!selectedProvider || !presetConfig.Providers) {
return [];
}
const provider = presetConfig.Providers.find(
(p) => p.name === selectedProvider || p.id === selectedProvider
);
if (!provider || !provider.models) {
return [];
}
return provider.models.map((model: string) => ({
label: model,
value: model,
}));
}
return [];
};
// 更新字段值
const updateValue = (fieldId: string, value: any) => {
setValues((prev) => ({
...prev,
[fieldId]: value,
}));
// 清除该字段的错误
setErrors((prev) => {
const newErrors = { ...prev };
delete newErrors[fieldId];
return newErrors;
});
};
// 验证单个字段
const validateField = (field: RequiredInput): string | null => {
const value = values[field.id];
// 检查必填
if (field.required !== false && (value === undefined || value === null || value === '')) {
return `${field.label || field.id} is required`;
}
if (!value && field.required === false) {
return null;
}
// 类型检查
if (field.type === 'number' && isNaN(Number(value))) {
return `${field.label || field.id} must be a number`;
}
if (field.type === 'number') {
const numValue = Number(value);
if (field.min !== undefined && numValue < field.min) {
return `${field.label || field.id} must be at least ${field.min}`;
}
if (field.max !== undefined && numValue > field.max) {
return `${field.label || field.id} must be at most ${field.max}`;
}
}
// 自定义验证器
if (field.validator) {
if (field.validator instanceof RegExp) {
if (!field.validator.test(String(value))) {
return `${field.label || field.id} format is invalid`;
}
} else if (typeof field.validator === 'string') {
const regex = new RegExp(field.validator);
if (!regex.test(String(value))) {
return `${field.label || field.id} format is invalid`;
}
}
}
return null;
};
// 提交表单
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
// 验证所有可见字段
const newErrors: Record<string, string> = {};
for (const field of schema) {
if (!visibleFields.has(field.id)) {
continue;
}
const error = validateField(field);
if (error) {
newErrors[field.id] = error;
}
}
if (Object.keys(newErrors).length > 0) {
setErrors(newErrors);
return;
}
onSubmit(values);
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
{schema.map((field) => {
if (!visibleFields.has(field.id)) {
return null;
}
const label = field.label || field.id;
const prompt = field.prompt;
const error = errors[field.id];
return (
<div key={field.id} className="space-y-2">
<Label htmlFor={`field-${field.id}`}>
{label}
{field.required !== false && <span className="text-red-500 ml-1">*</span>}
</Label>
{prompt && (
<p className="text-sm text-gray-600">{prompt}</p>
)}
{/* Password / Input */}
{(field.type === 'password' || field.type === 'input' || !field.type) && (
<Input
id={`field-${field.id}`}
type={field.type === 'password' ? 'password' : 'text'}
placeholder={field.placeholder}
value={values[field.id] || ''}
onChange={(e) => updateValue(field.id, e.target.value)}
disabled={isSubmitting}
/>
)}
{/* Number */}
{field.type === 'number' && (
<Input
id={`field-${field.id}`}
type="number"
placeholder={field.placeholder}
value={values[field.id] || ''}
onChange={(e) => updateValue(field.id, Number(e.target.value))}
min={field.min}
max={field.max}
disabled={isSubmitting}
/>
)}
{/* Select */}
{field.type === 'select' && (
<Select
value={values[field.id] || ''}
onValueChange={(value) => updateValue(field.id, value)}
disabled={isSubmitting}
>
<SelectTrigger id={`field-${field.id}`}>
<SelectValue placeholder={field.placeholder || `Select ${label}`} />
</SelectTrigger>
<SelectContent>
{getOptions(field).map((option) => (
<SelectItem
key={String(option.value)}
value={String(option.value)}
disabled={option.disabled}
>
<div>
<div>{option.label}</div>
{option.description && (
<div className="text-xs text-gray-500">{option.description}</div>
)}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
)}
{/* Multiselect */}
{field.type === 'multiselect' && (
<div className="space-y-2">
{getOptions(field).map((option) => (
<div key={String(option.value)} className="flex items-center space-x-2">
<Checkbox
id={`field-${field.id}-${option.value}`}
checked={Array.isArray(values[field.id]) && values[field.id].includes(option.value)}
onCheckedChange={(checked) => {
const current = Array.isArray(values[field.id]) ? values[field.id] : [];
if (checked) {
updateValue(field.id, [...current, option.value]);
} else {
updateValue(field.id, current.filter((v: any) => v !== option.value));
}
}}
disabled={isSubmitting || option.disabled}
/>
<Label
htmlFor={`field-${field.id}-${option.value}`}
className="text-sm font-normal cursor-pointer"
>
{option.label}
{option.description && (
<span className="text-gray-500 ml-2">{option.description}</span>
)}
</Label>
</div>
))}
</div>
)}
{/* Confirm */}
{field.type === 'confirm' && (
<div className="flex items-center space-x-2">
<Checkbox
id={`field-${field.id}`}
checked={values[field.id] || false}
onCheckedChange={(checked) => updateValue(field.id, checked)}
disabled={isSubmitting}
/>
<Label htmlFor={`field-${field.id}`} className="text-sm font-normal cursor-pointer">
{field.prompt || label}
</Label>
</div>
)}
{/* Editor */}
{field.type === 'editor' && (
<Textarea
id={`field-${field.id}`}
placeholder={field.placeholder}
value={values[field.id] || ''}
onChange={(e) => updateValue(field.id, e.target.value)}
rows={field.rows || 5}
disabled={isSubmitting}
/>
)}
{error && (
<p className="text-sm text-red-500">{error}</p>
)}
</div>
);
})}
<div className="flex justify-end gap-2 pt-4">
<Button
type="button"
variant="outline"
onClick={onCancel}
disabled={isSubmitting}
>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Applying...
</>
) : (
<>
<CheckCircle2 className="mr-2 h-4 w-4" />
Apply
</>
)}
</Button>
</div>
</form>
);
}