diff --git a/src/App.tsx b/src/App.tsx index 81a2bc1..23b62b0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -44,6 +44,7 @@ function App() { const [userQuota, setUserQuota] = useState(null); const [statsLoading, setStatsLoading] = useState(false); const [statsLoadFailed, setStatsLoadFailed] = useState(false); // 新增:记录加载失败状态 + const [statsError, setStatsError] = useState(null); // 更新检查状态 const [updateInfo, setUpdateInfo] = useState(null); @@ -84,6 +85,7 @@ function App() { try { setStatsLoading(true); setStatsLoadFailed(false); + setStatsError(null); const [quota, stats] = await Promise.all([getUserQuota(), getUsageStats()]); setUserQuota(quota); setUsageStats(stats); @@ -91,10 +93,12 @@ function App() { } catch (error) { console.error('Failed to load statistics:', error); setStatsLoadFailed(true); + const message = error instanceof Error ? error.message : '请检查网络连接后重试'; + setStatsError(message); toast({ title: '加载统计数据失败', - description: error instanceof Error ? error.message : '请检查网络连接后重试', + description: message, variant: 'destructive', duration: 5000, }); @@ -162,6 +166,12 @@ function App() { return () => clearTimeout(timer); }, [checkAppUpdates]); + // 当凭证变更时,重置统计状态以便重新加载 + useEffect(() => { + setStatsLoadFailed(false); + setStatsError(null); + }, [globalConfig?.user_id, globalConfig?.system_token]); + // 智能预加载:只要有凭证就立即预加载统计数据 useEffect(() => { // 条件:配置已加载 + 有凭证 + 还没有统计数据 + 不在加载中 + 没有失败过 @@ -236,6 +246,8 @@ function App() { usageStats={usageStats} userQuota={userQuota} statsLoading={statsLoading} + statsLoadFailed={statsLoadFailed} + statsError={statsError} onLoadStatistics={loadStatistics} /> )} diff --git a/src/components/ToolConfigManager.tsx b/src/components/ToolConfigManager.tsx index 64c1f11..e04712f 100644 --- a/src/components/ToolConfigManager.tsx +++ b/src/components/ToolConfigManager.tsx @@ -1,25 +1,9 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; -import { - DndContext, - DragEndEvent, - PointerSensor, - closestCenter, - useSensor, - useSensors, -} from '@dnd-kit/core'; -import { - SortableContext, - arrayMove, - useSortable, - verticalListSortingStrategy, -} from '@dnd-kit/sortable'; -import { CSS } from '@dnd-kit/utilities'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; -import { Switch } from '@/components/ui/switch'; import { Dialog, DialogContent, @@ -44,101 +28,37 @@ import { saveClaudeSettings, saveCodexSettings, saveGeminiSettings, + type GeminiEnvConfig, type CodexSettingsPayload, type GeminiSettingsPayload, - type GeminiEnvConfig, type JsonObject, - type JsonSchema, - type JsonValue, } from '@/lib/tauri-commands'; import { useToast } from '@/hooks/use-toast'; -import { GripVertical, Loader2, Plus, RefreshCw, Save, Trash2 } from 'lucide-react'; +import { Loader2, Plus, RefreshCw, Save, Trash2 } from 'lucide-react'; import { SecretInput } from '@/components/SecretInput'; -import { cn } from '@/lib/utils'; - -type JSONSchema = JsonSchema & { - type?: string | string[]; - description?: string; - properties?: Record; - items?: JSONSchema | JSONSchema[]; - enum?: (string | number)[]; - const?: unknown; - $ref?: string; - additionalProperties?: boolean | JSONSchema; - default?: unknown; - anyOf?: JSONSchema[]; - allOf?: JSONSchema[]; - oneOf?: JSONSchema[]; - patternProperties?: Record; - examples?: unknown[]; - required?: string[]; - $defs?: Record; - 'x-secret'?: boolean; -}; - -interface SchemaOption { - key: string; - description: string; - schema?: JSONSchema; - typeLabel: string; -} - -interface SchemaFieldProps { - schema?: JSONSchema; - value: JsonValue | undefined; - onChange: (value: JsonValue) => void; - onDelete?: () => void; - path: (string | number)[]; - rootSchema: JSONSchema | null; - isRequired?: boolean; - showDescription?: boolean; - inline?: boolean; - rootValue: JsonObject; -} - -interface DiffEntry { - path: string; - type: 'added' | 'removed' | 'changed'; - before?: JsonValue; - after?: JsonValue; -} - -export interface ToolConfigManagerProps { - title: string; - description: string; - loadSchema: () => Promise; - loadSettings: () => Promise; - saveSettings: (settings: JsonObject) => Promise; - emptyHint?: string; - refreshSignal?: number; - externalDirty?: boolean; - onResetExternalChanges?: () => void; - computeExternalDiffs?: () => DiffEntry[]; -} - -const DEFAULT_DESCRIPTION = '未提供描述'; - -type CustomFieldType = 'string' | 'number' | 'boolean' | 'object' | 'array'; - -const CUSTOM_FIELD_TYPE_OPTIONS: { label: string; value: CustomFieldType }[] = [ - { label: 'string', value: 'string' }, - { label: 'number', value: 'number' }, - { label: 'boolean', value: 'boolean' }, - { label: 'object', value: 'object' }, - { label: 'array', value: 'array' }, -]; - -const GEMINI_ENV_DEFAULT: GeminiEnvConfig = { - apiKey: '', - baseUrl: '', - model: 'gemini-2.5-pro', -}; - -const cloneGeminiEnv = (env?: GeminiEnvConfig): GeminiEnvConfig => ({ - apiKey: env?.apiKey ?? '', - baseUrl: env?.baseUrl ?? '', - model: env?.model ?? 'gemini-2.5-pro', -}); +import { SchemaField } from './tool-config/Fields'; +import { + CUSTOM_FIELD_TYPE_OPTIONS, + DEFAULT_DESCRIPTION, + GEMINI_ENV_DEFAULT, + cloneGeminiEnv, + type SchemaOption, + type CustomFieldType, + type DiffEntry, + type JSONSchema, + type ToolConfigManagerProps, +} from './tool-config/types'; +import { + buildDiffEntries, + cloneJsonObject, + formatJson, + getDefaultValue, + getEffectiveType, + getTypeLabel, + isCompoundField, + resolveSchema, + createSchemaForType, +} from './tool-config/utils'; export function ToolConfigManager({ title, @@ -911,1008 +831,3 @@ export function GeminiConfigManager({ refreshSignal }: { refreshSignal?: number ); } - -function SchemaField({ - schema, - value, - onChange, - onDelete, - path, - rootSchema, - isRequired, - showDescription = true, - inline = false, - rootValue, -}: SchemaFieldProps) { - const resolvedSchema = resolveSchema(schema, rootSchema); - const description = resolvedSchema?.description ?? DEFAULT_DESCRIPTION; - const effectiveType = getEffectiveType(resolvedSchema, value); - - if (effectiveType === 'object') { - return ( - - ); - } - - if (effectiveType === 'array') { - return ( - - ); - } - - if (effectiveType === 'boolean') { - return ( - - ); - } - - if (effectiveType === 'number' || effectiveType === 'integer') { - return ( - - ); - } - - if (effectiveType === 'string') { - return ( - - ); - } - - return ( - - ); -} - -function StringField({ - schema, - value, - onChange, - description, - showDescription = true, - inline = false, - rootValue, -}: { - schema?: JSONSchema; - value: JsonValue | undefined; - onChange: (value: JsonValue) => void; - description: string; - showDescription?: boolean; - inline?: boolean; - rootValue: JsonObject; -}) { - const currentValue = typeof value === 'string' ? value : ''; - const enumValues = schema?.enum?.map((item) => String(item)) ?? []; - const derivedOptions = - schema?.['x-key-source'] && isJsonObject(rootValue) - ? Object.keys(getObjectFromPath(rootValue, schema['x-key-source'] as string) ?? {}) - : []; - const selectOptions = enumValues.length > 0 ? enumValues : derivedOptions; - const hasSelectOptions = selectOptions.length > 0; - const matchedOption = - hasSelectOptions && selectOptions.includes(currentValue) ? currentValue : undefined; - const CUSTOM_OPTION_VALUE = '__custom__'; - const isCustomSelected = hasSelectOptions ? !matchedOption : false; - const shouldShowInput = !hasSelectOptions || isCustomSelected; - const isSecretField = Boolean(schema?.['x-secret']); - const renderTextInput = (inputClassName: string, parentIsRelative = false) => { - if (isSecretField) { - return ( - onChange(next)} - placeholder="请输入自定义内容" - toggleLabel={`切换${schema?.title ?? '字段'}可见性`} - withWrapper={!parentIsRelative} - wrapperClassName={parentIsRelative ? undefined : 'w-full'} - /> - ); - } - - return ( - onChange(event.target.value)} - placeholder="请输入自定义内容" - /> - ); - }; - - const renderSelect = (triggerClass: string) => ( - - ); - - if (inline) { - const selectClass = isCustomSelected ? 'w-fit min-w-[140px]' : 'flex-1 min-w-0'; - const inlineContainerClass = cn('flex w-full items-center gap-3 min-w-0', { - relative: isSecretField, - }); - return ( -
- {hasSelectOptions && renderSelect(selectClass)} - {shouldShowInput && renderTextInput('flex-1 min-w-0', isSecretField)} -
- ); - } - - return ( -
-
- {hasSelectOptions && - renderSelect(isCustomSelected ? 'w-fit min-w-[140px]' : 'flex-1 min-w-[200px]')} - {shouldShowInput && renderTextInput('flex-1 min-w-[200px]', isSecretField)} -
- {showDescription &&

{description}

} -
- ); -} - -function NumberField({ - value, - onChange, - description, - showDescription = true, - inline = false, -}: { - value: JsonValue | undefined; - onChange: (value: JsonValue) => void; - description: string; - showDescription?: boolean; - inline?: boolean; -}) { - const currentValue = typeof value === 'number' && Number.isFinite(value) ? value : ''; - - if (inline) { - return ( -
- onChange(event.target.value === '' ? 0 : Number(event.target.value))} - placeholder="请输入数字" - /> -
- ); - } - - return ( -
-
- onChange(event.target.value === '' ? 0 : Number(event.target.value))} - placeholder="请输入数字" - /> -
- {showDescription &&

{description}

} -
- ); -} - -function BooleanField({ - value, - onChange, - description, - showDescription = true, - inline = false, -}: { - value: JsonValue | undefined; - onChange: (value: JsonValue) => void; - description: string; - showDescription?: boolean; - inline?: boolean; -}) { - const currentValue = typeof value === 'boolean' ? value : false; - - if (inline) { - return ( -
- onChange(checked)} /> - {currentValue ? '启用' : '禁用'} -
- ); - } - - return ( -
-
- onChange(checked)} /> - {currentValue ? '启用' : '禁用'} -
- {showDescription &&

{description}

} -
- ); -} - -function ArrayField({ - schema, - value, - onChange, - path, - rootSchema, - description, - rootValue, -}: { - schema: JSONSchema; - value: JsonValue | undefined; - onChange: (value: JsonValue) => void; - path: (string | number)[]; - rootSchema: JSONSchema | null; - description: string; - rootValue: JsonObject; -}) { - const itemsSchema = - Array.isArray(schema.items) && schema.items.length > 0 - ? schema.items[0] - : (schema.items as JSONSchema | undefined); - const resolvedItemsSchema = resolveSchema(itemsSchema, rootSchema); - const currentValue = Array.isArray(value) ? value : []; - const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } })); - const itemIds: string[] = currentValue.map((_, index) => formatPath([...path, index])); - const shouldShowDescription = - description && description !== DEFAULT_DESCRIPTION && description.trim().length > 0; - - const handleItemChange = (index: number, newValue: JsonValue) => { - const next = [...currentValue]; - next[index] = newValue; - onChange(next); - }; - - const handleRemoveItem = (index: number) => { - const next = currentValue.filter((_, idx) => idx !== index); - onChange(next); - }; - - const handleAddItem = () => { - const next = [...currentValue, getDefaultValue(resolvedItemsSchema)]; - onChange(next); - }; - - const handleDragEnd = (event: DragEndEvent) => { - const { active, over } = event; - if (!over || active.id === over.id) { - return; - } - const oldIndex = itemIds.indexOf(active.id as string); - const newIndex = itemIds.indexOf(over.id as string); - if (oldIndex === -1 || newIndex === -1) { - return; - } - onChange(arrayMove(currentValue, oldIndex, newIndex)); - }; - - return ( -
- {shouldShowDescription &&

{description}

} - {currentValue.length === 0 ? ( -
- 当前数组为空 -
- ) : ( - - -
- {currentValue.map((item, index) => ( - handleItemChange(index, newValue)} - onDelete={() => handleRemoveItem(index)} - path={[...path, index]} - rootSchema={rootSchema} - rootValue={rootValue} - /> - ))} -
-
-
- )} - -
- ); -} - -interface SortableArrayItemProps { - id: string; - schema?: JSONSchema; - value: JsonValue; - onChange: (value: JsonValue) => void; - onDelete: () => void; - path: (string | number)[]; - rootSchema: JSONSchema | null; - rootValue: JsonObject; -} - -function SortableArrayItem({ - id, - schema, - value, - onChange, - onDelete, - path, - rootSchema, - rootValue, -}: SortableArrayItemProps) { - const resolvedSchema = resolveSchema(schema, rootSchema); - const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ - id, - }); - - const style = { - transform: CSS.Transform.toString(transform), - transition, - }; - - return ( -
- -
- -
- -
- ); -} - -function ObjectField({ - schema, - value, - onChange, - path, - rootSchema, - description, - rootValue, -}: { - schema: JSONSchema; - value: JsonValue | undefined; - onChange: (value: JsonValue) => void; - path: (string | number)[]; - rootSchema: JSONSchema | null; - description: string; - rootValue: JsonObject; -}) { - const objectValue = isJsonObject(value) ? value : {}; - const requiredKeys = schema.required ?? []; - const currentKeys = Object.keys(objectValue); - const keys = Array.from(new Set([...currentKeys, ...requiredKeys])); - const schemaDefinedKeys = schema.properties ? Object.keys(schema.properties) : []; - const availableSchemaKeys = schemaDefinedKeys - .filter((key) => !currentKeys.includes(key) && !requiredKeys.includes(key)) - .sort((a, b) => a.localeCompare(b)); - const availableSchemaOptions = availableSchemaKeys.map((optionKey) => { - const optionSchema = resolveObjectChildSchema(schema, optionKey, rootSchema); - return { - key: optionKey, - description: optionSchema?.description ?? DEFAULT_DESCRIPTION, - }; - }); - const isEnvObject = path.length === 1 && path[0] === 'env'; - - const handleChildChange = (key: string, newValue: JsonValue) => { - const next = { ...objectValue, [key]: newValue }; - onChange(next); - }; - - const handleDeleteChild = (key: string) => { - const next = { ...objectValue }; - delete next[key]; - onChange(next); - }; - - const canAddCustomField = schema.additionalProperties !== false || !!schema.patternProperties; - const [customKey, setCustomKey] = useState(''); - const [customFieldType, setCustomFieldType] = useState('string'); - const [schemaKeyToAdd, setSchemaKeyToAdd] = useState(''); - - const handleAddCustomField = () => { - const normalizedKey = customKey.trim(); - if (!normalizedKey) { - return; - } - if (objectValue[normalizedKey] !== undefined) { - return; - } - const templateSchema = - resolveObjectChildSchema(schema, normalizedKey, rootSchema) ?? - createSchemaForType(customFieldType); - const next = { ...objectValue, [normalizedKey]: getDefaultValue(templateSchema) }; - onChange(next); - setCustomKey(''); - setCustomFieldType('string'); - }; - - const handleAddSchemaField = () => { - if (!schemaKeyToAdd) { - return; - } - if (objectValue[schemaKeyToAdd] !== undefined) { - return; - } - const templateSchema = resolveObjectChildSchema(schema, schemaKeyToAdd, rootSchema); - const next = { ...objectValue, [schemaKeyToAdd]: getDefaultValue(templateSchema) }; - onChange(next); - setSchemaKeyToAdd(''); - }; - - return ( -
-

{description}

-
- {keys.length === 0 && ( -
- 尚未设置任何子选项 -
- )} - {keys.map((key) => { - const resolvedChildSchema = resolveObjectChildSchema(schema, key, rootSchema); - const isRequired = requiredKeys.includes(key); - const childType = getEffectiveType(resolvedChildSchema, objectValue[key]); - const childIsCompound = isCompoundField(resolvedChildSchema, objectValue[key]); - - return ( -
-
-
- {key} - - {getTypeLabel(resolvedChildSchema, objectValue[key])} - -
-
- {!childIsCompound && ( -
- handleChildChange(key, newValue)} - path={[...path, key]} - rootSchema={rootSchema} - isRequired={isRequired} - showDescription={false} - rootValue={rootValue} - /> -
- )} - {!isRequired && ( - - )} -
-
- {childIsCompound ? ( - handleChildChange(key, newValue)} - path={[...path, key]} - rootSchema={rootSchema} - isRequired={isRequired} - onDelete={!isRequired ? () => handleDeleteChild(key) : undefined} - rootValue={rootValue} - /> - ) : ( -

- {resolvedChildSchema?.description ?? DEFAULT_DESCRIPTION} -

- )} -
- ); - })} - {availableSchemaOptions.length > 0 && ( -
-

- {isEnvObject ? '选择环境变量' : '从 Schema 添加子选项'} -

-
- - -
-
- )} - {canAddCustomField && ( -
-

自定义子选项

-
- setCustomKey(event.target.value)} - placeholder="新增子选项名" - /> - - -
-
- )} -
-
- ); -} - -function resolveObjectChildSchema( - schema: JSONSchema, - key: string, - rootSchema: JSONSchema | null, -): JSONSchema | undefined { - if (schema.properties && schema.properties[key]) { - return resolveSchema(schema.properties[key], rootSchema); - } - - if (schema.patternProperties) { - for (const [pattern, patternSchema] of Object.entries(schema.patternProperties)) { - try { - const regex = new RegExp(pattern); - if (regex.test(key)) { - return resolveSchema(patternSchema, rootSchema); - } - } catch { - // 忽略非法正则 - } - } - } - - if (isJsonSchemaObject(schema.additionalProperties)) { - return resolveSchema(schema.additionalProperties, rootSchema); - } - - return undefined; -} - -function getObjectFromPath(root: JsonObject, path: string): JsonObject | undefined { - const segments = path - .split('.') - .map((segment) => segment.trim()) - .filter(Boolean); - let current: JsonValue | undefined = root; - for (const segment of segments) { - if (!isJsonObject(current)) { - return undefined; - } - current = current[segment]; - } - return isJsonObject(current) ? current : undefined; -} - -function FallbackJsonField({ - value, - onChange, - description, - allowDelete, - onDelete, - showDescription = true, - inline = false, -}: { - value: JsonValue | undefined; - onChange: (value: JsonValue) => void; - description: string; - allowDelete?: boolean; - onDelete?: () => void; - showDescription?: boolean; - inline?: boolean; -}) { - const input = ( - { - try { - const parsed = JSON.parse(event.target.value); - onChange(parsed as JsonValue); - } catch { - onChange(event.target.value); - } - }} - placeholder="请输入值或 JSON" - /> - ); - - if (inline) { - return ( -
- {input} - {allowDelete && onDelete && ( - - )} -
- ); - } - - return ( -
-
- {input} - {allowDelete && onDelete && ( - - )} -
- {showDescription &&

{description}

} -
- ); -} - -function resolveSchema( - schema: JSONSchema | undefined, - rootSchema: JSONSchema | null, -): JSONSchema | undefined { - if (!schema) { - return undefined; - } - if (schema.$ref && rootSchema) { - const resolved = resolveRef(rootSchema, schema.$ref); - if (resolved) { - const { $ref: _ref, ...rest } = schema; - return { ...resolved, ...rest }; - } - } - return schema; -} - -function resolveRef(schema: JSONSchema, ref: string): JSONSchema | undefined { - if (!ref.startsWith('#/')) { - return undefined; - } - const path = ref - .substring(2) - .split('/') - .map((segment) => segment.replace(/~1/g, '/').replace(/~0/g, '~')); - - let current: unknown = schema; - for (const segment of path) { - if (current && typeof current === 'object' && segment in current) { - current = (current as Record)[segment]; - } else { - return undefined; - } - } - return current as JSONSchema; -} - -function getTypeLabel(schema?: JSONSchema, value?: JsonValue): string { - const type = getPrimaryType(schema) ?? inferValueType(value); - if (!type) { - return 'string'; - } - return type; -} - -function getPrimaryType(schema?: JSONSchema): string | undefined { - if (!schema) { - return undefined; - } - if (Array.isArray(schema.type)) { - return schema.type[0]; - } - return schema.type; -} - -function getEffectiveType(schema?: JSONSchema, value?: JsonValue): string | undefined { - const schemaType = getPrimaryType(schema); - if (schemaType) { - return schemaType; - } - return inferValueType(value); -} - -function inferValueType(value: JsonValue | undefined): string | undefined { - if (value === null || value === undefined) { - return undefined; - } - if (Array.isArray(value)) { - return 'array'; - } - if (typeof value === 'object') { - return 'object'; - } - if (typeof value === 'boolean') { - return 'boolean'; - } - if (typeof value === 'number') { - return 'number'; - } - if (typeof value === 'string') { - return 'string'; - } - return undefined; -} - -function isCompoundField(schema?: JSONSchema, value?: JsonValue) { - const type = getEffectiveType(schema, value); - if (!type) { - return Array.isArray(value) || isJsonObject(value); - } - return type === 'object' || type === 'array'; -} - -function getDefaultValue(schema?: JSONSchema): JsonValue { - if (!schema) { - return ''; - } - - if (schema.default !== undefined) { - return cloneJsonValue(schema.default as JsonValue); - } - - const type = getPrimaryType(schema); - switch (type) { - case 'object': - return {}; - case 'array': - return []; - case 'boolean': - return false; - case 'number': - case 'integer': - return 0; - default: - if (schema.enum && schema.enum.length > 0) { - return schema.enum[0] as JsonValue; - } - return ''; - } -} - -function createSchemaForType(type?: CustomFieldType): JSONSchema | undefined { - if (!type) { - return undefined; - } - if (type === 'number') { - return { type: 'number' }; - } - return { type }; -} - -function isJsonObject(value: JsonValue | undefined): value is JsonObject { - return !!value && typeof value === 'object' && !Array.isArray(value); -} - -function isJsonSchemaObject(value: unknown): value is JSONSchema { - return !!value && typeof value === 'object' && !Array.isArray(value); -} - -function cloneJsonObject(value: T): T { - return JSON.parse(JSON.stringify(value)) as T; -} - -function cloneJsonValue(value: T): T { - return JSON.parse(JSON.stringify(value)) as T; -} - -function safeStringify(value: JsonValue | undefined) { - try { - if (value === undefined) { - return ''; - } - if (typeof value === 'string') { - return value; - } - return JSON.stringify(value, null, 2); - } catch { - return ''; - } -} - -function formatJson(value: JsonValue | undefined) { - const text = safeStringify(value); - return text || '—'; -} - -function buildDiffEntries( - path: (string | number)[], - original: JsonValue | undefined, - current: JsonValue | undefined, - diffs: DiffEntry[], -) { - if (original === undefined && current === undefined) { - return; - } - - if (original === undefined && current !== undefined) { - diffs.push({ - path: formatPath(path), - type: 'added', - after: cloneJsonValue(current as JsonValue), - }); - return; - } - - if (original !== undefined && current === undefined) { - diffs.push({ - path: formatPath(path), - type: 'removed', - before: cloneJsonValue(original as JsonValue), - }); - return; - } - - if (isJsonObject(original) && isJsonObject(current)) { - const keys = new Set([...Object.keys(original), ...Object.keys(current)]); - keys.forEach((key) => { - buildDiffEntries([...path, key], original[key], current[key], diffs); - }); - return; - } - - if (Array.isArray(original) && Array.isArray(current)) { - const maxLength = Math.max(original.length, current.length); - for (let index = 0; index < maxLength; index++) { - buildDiffEntries([...path, index], original[index], current[index], diffs); - } - return; - } - - if (JSON.stringify(original) !== JSON.stringify(current)) { - diffs.push({ - path: formatPath(path), - type: 'changed', - before: cloneJsonValue(original as JsonValue), - after: cloneJsonValue(current as JsonValue), - }); - } -} - -function formatPath(path: (string | number)[]): string { - if (path.length === 0) { - return '(root)'; - } - return path.reduce((acc, segment) => { - if (typeof segment === 'number') { - return `${acc}[${segment}]`; - } - return acc ? `${acc}.${segment}` : String(segment); - }, ''); -} diff --git a/src/components/tool-config/Fields.tsx b/src/components/tool-config/Fields.tsx new file mode 100644 index 0000000..ec0d3e2 --- /dev/null +++ b/src/components/tool-config/Fields.tsx @@ -0,0 +1,806 @@ +import { useState } from 'react'; +import { + DndContext, + DragEndEvent, + PointerSensor, + closestCenter, + useSensor, + useSensors, +} from '@dnd-kit/core'; +import { + SortableContext, + arrayMove, + useSortable, + verticalListSortingStrategy, +} from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Switch } from '@/components/ui/switch'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { GripVertical, Plus, Trash2 } from 'lucide-react'; +import { SecretInput } from '@/components/SecretInput'; +import { cn } from '@/lib/utils'; +import type { JsonObject, JsonValue } from '@/lib/tauri-commands'; +import { + CUSTOM_FIELD_TYPE_OPTIONS, + DEFAULT_DESCRIPTION, + type CustomFieldType, + type JSONSchema, + type SchemaFieldProps, +} from './types'; +import { + getDefaultValue, + getEffectiveType, + isCompoundField, + isJsonObject, + resolveSchema, + formatPath, + getObjectFromPath, + createSchemaForType, + getTypeLabel, + safeStringify, +} from './utils'; + +export function SchemaField({ + schema, + value, + onChange, + onDelete, + path, + rootSchema, + isRequired, + showDescription = true, + inline = false, + rootValue, +}: SchemaFieldProps) { + const resolvedSchema = resolveSchema(schema, rootSchema); + const description = resolvedSchema?.description ?? DEFAULT_DESCRIPTION; + const effectiveType = getEffectiveType(resolvedSchema, value); + + if (effectiveType === 'object') { + return ( + + ); + } + + if (effectiveType === 'array') { + return ( + + ); + } + + if (effectiveType === 'boolean') { + return ( + + ); + } + + if (effectiveType === 'number' || effectiveType === 'integer') { + return ( + + ); + } + + if (effectiveType === 'string') { + return ( + + ); + } + + return ( + + ); +} + +function StringField({ + schema, + value, + onChange, + description, + showDescription = true, + inline = false, + rootValue, +}: { + schema?: JSONSchema; + value: JsonValue | undefined; + onChange: (value: JsonValue) => void; + description: string; + showDescription?: boolean; + inline?: boolean; + rootValue: JsonObject; +}) { + const currentValue = typeof value === 'string' ? value : ''; + const enumValues = schema?.enum?.map((item) => String(item)) ?? []; + const derivedOptions = + schema?.['x-key-source'] && isJsonObject(rootValue) + ? Object.keys(getObjectFromPath(rootValue, schema['x-key-source'] as string) ?? {}) + : []; + const selectOptions = enumValues.length > 0 ? enumValues : derivedOptions; + const hasSelectOptions = selectOptions.length > 0; + const matchedOption = + hasSelectOptions && selectOptions.includes(currentValue) ? currentValue : undefined; + const CUSTOM_OPTION_VALUE = '__custom__'; + const isCustomSelected = hasSelectOptions ? !matchedOption : false; + const shouldShowInput = !hasSelectOptions || isCustomSelected; + const isSecretField = Boolean(schema?.['x-secret']); + const renderTextInput = (inputClassName: string, parentIsRelative = false) => { + if (isSecretField) { + return ( + onChange(next)} + placeholder="请输入自定义内容" + toggleLabel={`切换${schema?.title ?? '字段'}可见性`} + withWrapper={!parentIsRelative} + wrapperClassName={parentIsRelative ? undefined : 'w-full'} + /> + ); + } + + return ( + onChange(event.target.value)} + placeholder="请输入自定义内容" + /> + ); + }; + + const renderSelect = (triggerClass: string) => ( + + ); + + if (inline) { + const selectClass = isCustomSelected ? 'w-fit min-w-[140px]' : 'flex-1 min-w-0'; + const inlineContainerClass = cn('flex w-full items-center gap-3 min-w-0', { + relative: isSecretField, + }); + return ( +
+ {hasSelectOptions && renderSelect(selectClass)} + {shouldShowInput && renderTextInput('flex-1 min-w-0', isSecretField)} +
+ ); + } + + return ( +
+
+ {hasSelectOptions && + renderSelect(isCustomSelected ? 'w-fit min-w-[140px]' : 'flex-1 min-w-[200px]')} + {shouldShowInput && renderTextInput('flex-1 min-w-[200px]', isSecretField)} +
+ {showDescription &&

{description}

} +
+ ); +} + +function NumberField({ + value, + onChange, + description, + showDescription = true, + inline = false, +}: { + value: JsonValue | undefined; + onChange: (value: JsonValue) => void; + description: string; + showDescription?: boolean; + inline?: boolean; +}) { + const currentValue = typeof value === 'number' && Number.isFinite(value) ? value : ''; + + if (inline) { + return ( +
+ onChange(event.target.value === '' ? 0 : Number(event.target.value))} + placeholder="请输入数字" + /> +
+ ); + } + + return ( +
+
+ onChange(event.target.value === '' ? 0 : Number(event.target.value))} + placeholder="请输入数字" + /> +
+ {showDescription &&

{description}

} +
+ ); +} + +function BooleanField({ + value, + onChange, + description, + showDescription = true, + inline = false, +}: { + value: JsonValue | undefined; + onChange: (value: JsonValue) => void; + description: string; + showDescription?: boolean; + inline?: boolean; +}) { + const currentValue = typeof value === 'boolean' ? value : false; + + if (inline) { + return ( +
+ onChange(checked)} /> + {currentValue ? '启用' : '禁用'} +
+ ); + } + + return ( +
+
+ onChange(checked)} /> + {currentValue ? '启用' : '禁用'} +
+ {showDescription &&

{description}

} +
+ ); +} + +function ArrayField({ + schema, + value, + onChange, + path, + rootSchema, + description, + rootValue, +}: { + schema: JSONSchema; + value: JsonValue | undefined; + onChange: (value: JsonValue) => void; + path: (string | number)[]; + rootSchema: JSONSchema | null; + description: string; + rootValue: JsonObject; +}) { + const itemsSchema = + Array.isArray(schema.items) && schema.items.length > 0 + ? schema.items[0] + : (schema.items as JSONSchema | undefined); + const resolvedItemsSchema = resolveSchema(itemsSchema, rootSchema); + const currentValue = Array.isArray(value) ? value : []; + const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } })); + const itemIds: string[] = currentValue.map((_, index) => formatPath([...path, index])); + const shouldShowDescription = + description && description !== DEFAULT_DESCRIPTION && description.trim().length > 0; + + const handleItemChange = (index: number, newValue: JsonValue) => { + const next = [...currentValue]; + next[index] = newValue; + onChange(next); + }; + + const handleRemoveItem = (index: number) => { + const next = currentValue.filter((_, idx) => idx !== index); + onChange(next); + }; + + const handleAddItem = () => { + const next = [...currentValue, getDefaultValue(resolvedItemsSchema)]; + onChange(next); + }; + + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + if (!over || active.id === over.id) { + return; + } + const oldIndex = itemIds.indexOf(active.id as string); + const newIndex = itemIds.indexOf(over.id as string); + if (oldIndex === -1 || newIndex === -1) { + return; + } + onChange(arrayMove(currentValue, oldIndex, newIndex)); + }; + + return ( +
+ {shouldShowDescription &&

{description}

} + {currentValue.length === 0 ? ( +
+ 当前数组为空 +
+ ) : ( + + +
+ {currentValue.map((item, index) => ( + handleItemChange(index, newValue)} + onDelete={() => handleRemoveItem(index)} + path={[...path, index]} + rootSchema={rootSchema} + rootValue={rootValue} + /> + ))} +
+
+
+ )} + +
+ ); +} + +interface SortableArrayItemProps { + id: string; + schema?: JSONSchema; + value: JsonValue; + onChange: (value: JsonValue) => void; + onDelete: () => void; + path: (string | number)[]; + rootSchema: JSONSchema | null; + rootValue: JsonObject; +} + +function SortableArrayItem({ + id, + schema, + value, + onChange, + onDelete, + path, + rootSchema, + rootValue, +}: SortableArrayItemProps) { + const resolvedSchema = resolveSchema(schema, rootSchema); + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id, + }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + }; + + return ( +
+ +
+ +
+ +
+ ); +} + +function ObjectField({ + schema, + value, + onChange, + path, + rootSchema, + description, + rootValue, +}: { + schema: JSONSchema; + value: JsonValue | undefined; + onChange: (value: JsonValue) => void; + path: (string | number)[]; + rootSchema: JSONSchema | null; + description: string; + rootValue: JsonObject; +}) { + const objectValue = isJsonObject(value) ? value : {}; + const requiredKeys = schema.required ?? []; + const currentKeys = Object.keys(objectValue); + const keys = Array.from(new Set([...currentKeys, ...requiredKeys])); + const schemaDefinedKeys = schema.properties ? Object.keys(schema.properties) : []; + const availableSchemaKeys = schemaDefinedKeys + .filter((key) => !currentKeys.includes(key) && !requiredKeys.includes(key)) + .sort((a, b) => a.localeCompare(b)); + const availableSchemaOptions = availableSchemaKeys.map((optionKey) => { + const optionSchema = resolveObjectChildSchema(schema, optionKey, rootSchema); + return { + key: optionKey, + description: optionSchema?.description ?? DEFAULT_DESCRIPTION, + }; + }); + const isEnvObject = path.length === 1 && path[0] === 'env'; + const canAddCustomField = schema.additionalProperties !== false || !!schema.patternProperties; + const [customKey, setCustomKey] = useState(''); + const [customFieldType, setCustomFieldType] = useState('string'); + const [schemaKeyToAdd, setSchemaKeyToAdd] = useState(''); + + const handleChildChange = (key: string, newValue: JsonValue) => { + const next = { ...objectValue, [key]: newValue }; + onChange(next); + }; + + const handleDeleteChild = (key: string) => { + const next = { ...objectValue }; + delete next[key]; + onChange(next); + }; + + const handleAddSchemaField = () => { + if (!schemaKeyToAdd) { + return; + } + if (objectValue[schemaKeyToAdd] !== undefined) { + return; + } + const templateSchema = resolveObjectChildSchema(schema, schemaKeyToAdd, rootSchema); + const next = { ...objectValue, [schemaKeyToAdd]: getDefaultValue(templateSchema) }; + onChange(next); + setSchemaKeyToAdd(''); + }; + + const handleAddCustomField = () => { + const normalizedKey = customKey.trim(); + if (!normalizedKey) { + return; + } + if (objectValue[normalizedKey] !== undefined) { + return; + } + const templateSchema = + resolveObjectChildSchema(schema, normalizedKey, rootSchema) ?? + createSchemaForType(customFieldType); + const next = { ...objectValue, [normalizedKey]: getDefaultValue(templateSchema) }; + onChange(next); + setCustomKey(''); + setCustomFieldType('string'); + }; + + return ( +
+

{description}

+
+ {keys.length === 0 && ( +
+ 尚未设置任何子选项 +
+ )} + {keys.map((key) => { + const resolvedChildSchema = resolveObjectChildSchema(schema, key, rootSchema); + const isRequired = requiredKeys.includes(key); + const childType = getEffectiveType(resolvedChildSchema, objectValue[key]); + const childIsCompound = isCompoundField(resolvedChildSchema, objectValue[key]); + + return ( +
+
+
+ {key} + + {getTypeLabel(resolvedChildSchema, objectValue[key])} + +
+
+ {!childIsCompound && ( +
+ handleChildChange(key, newValue)} + path={[...path, key]} + rootSchema={rootSchema} + isRequired={isRequired} + showDescription={false} + rootValue={rootValue} + /> +
+ )} + {!isRequired && ( + + )} +
+
+ {childIsCompound ? ( + handleChildChange(key, newValue)} + path={[...path, key]} + rootSchema={rootSchema} + isRequired={isRequired} + onDelete={!isRequired ? () => handleDeleteChild(key) : undefined} + rootValue={rootValue} + /> + ) : ( +

+ {resolvedChildSchema?.description ?? DEFAULT_DESCRIPTION} +

+ )} +
+ ); + })} + {availableSchemaOptions.length > 0 && ( +
+

+ {isEnvObject ? '选择环境变量' : '从 Schema 添加子选项'} +

+
+ + +
+
+ )} + {canAddCustomField && ( +
+

自定义子选项

+
+ setCustomKey(event.target.value)} + placeholder="新增子选项名" + /> + + +
+
+ )} +
+
+ ); +} + +function resolveObjectChildSchema( + schema: JSONSchema, + key: string, + rootSchema: JSONSchema | null, +): JSONSchema | undefined { + if (schema.properties && schema.properties[key]) { + return resolveSchema(schema.properties[key], rootSchema); + } + + if (schema.patternProperties) { + for (const [pattern, patternSchema] of Object.entries(schema.patternProperties)) { + try { + const regex = new RegExp(pattern); + if (regex.test(key)) { + return resolveSchema(patternSchema, rootSchema); + } + } catch { + // 忽略非法正则 + } + } + } + + if (isJsonSchemaObject(schema.additionalProperties)) { + return resolveSchema(schema.additionalProperties, rootSchema); + } + + return undefined; +} + +function FallbackJsonField({ + value, + onChange, + description, + allowDelete, + onDelete, + showDescription = true, + inline = false, +}: { + value: JsonValue | undefined; + onChange: (value: JsonValue) => void; + description: string; + allowDelete?: boolean; + onDelete?: () => void; + showDescription?: boolean; + inline?: boolean; +}) { + const input = ( + { + try { + const parsed = JSON.parse(event.target.value); + onChange(parsed as JsonValue); + } catch { + onChange(event.target.value); + } + }} + placeholder="请输入值或 JSON" + /> + ); + + if (inline) { + return ( +
+ {input} + {allowDelete && onDelete && ( + + )} +
+ ); + } + + return ( +
+
+ {input} + {allowDelete && onDelete && ( + + )} +
+ {showDescription &&

{description}

} +
+ ); +} diff --git a/src/components/tool-config/types.ts b/src/components/tool-config/types.ts new file mode 100644 index 0000000..60d674a --- /dev/null +++ b/src/components/tool-config/types.ts @@ -0,0 +1,85 @@ +import type { JsonObject, JsonSchema, JsonValue, GeminiEnvConfig } from '@/lib/tauri-commands'; + +export type JSONSchema = JsonSchema & { + type?: string | string[]; + description?: string; + properties?: Record; + items?: JSONSchema | JSONSchema[]; + enum?: (string | number)[]; + const?: unknown; + $ref?: string; + additionalProperties?: boolean | JSONSchema; + default?: unknown; + anyOf?: JSONSchema[]; + allOf?: JSONSchema[]; + oneOf?: JSONSchema[]; + patternProperties?: Record; + examples?: unknown[]; + required?: string[]; + $defs?: Record; + 'x-secret'?: boolean; +}; + +export interface SchemaOption { + key: string; + description: string; + schema?: JSONSchema; + typeLabel: string; +} + +export interface SchemaFieldProps { + schema?: JSONSchema; + value: JsonValue | undefined; + onChange: (value: JsonValue) => void; + onDelete?: () => void; + path: (string | number)[]; + rootSchema: JSONSchema | null; + isRequired?: boolean; + showDescription?: boolean; + inline?: boolean; + rootValue: JsonObject; +} + +export interface DiffEntry { + path: string; + type: 'added' | 'removed' | 'changed'; + before?: JsonValue; + after?: JsonValue; +} + +export interface ToolConfigManagerProps { + title: string; + description: string; + loadSchema: () => Promise; + loadSettings: () => Promise; + saveSettings: (settings: JsonObject) => Promise; + emptyHint?: string; + refreshSignal?: number; + externalDirty?: boolean; + onResetExternalChanges?: () => void; + computeExternalDiffs?: () => DiffEntry[]; +} + +export type CustomFieldType = 'string' | 'number' | 'boolean' | 'object' | 'array'; + +export const CUSTOM_FIELD_TYPE_OPTIONS: { label: string; value: CustomFieldType }[] = [ + { label: 'string', value: 'string' }, + { label: 'number', value: 'number' }, + { label: 'boolean', value: 'boolean' }, + { label: 'object', value: 'object' }, + { label: 'array', value: 'array' }, +]; + +export const DEFAULT_DESCRIPTION = '未提供描述'; + +export const GEMINI_ENV_DEFAULT: GeminiEnvConfig = { + apiKey: '', + baseUrl: '', + model: 'gemini-2.5-pro', +}; + +export const cloneGeminiEnv = (env?: GeminiEnvConfig): GeminiEnvConfig => ({ + apiKey: env?.apiKey ?? '', + baseUrl: env?.baseUrl ?? '', + model: env?.model ?? 'gemini-2.5-pro', +}); diff --git a/src/components/tool-config/utils.ts b/src/components/tool-config/utils.ts new file mode 100644 index 0000000..f563a1f --- /dev/null +++ b/src/components/tool-config/utils.ts @@ -0,0 +1,249 @@ +import type { JsonObject, JsonValue } from '@/lib/tauri-commands'; +import type { CustomFieldType, DiffEntry, JSONSchema } from './types'; + +export function resolveSchema( + schema: JSONSchema | undefined, + rootSchema: JSONSchema | null, +): JSONSchema | undefined { + if (!schema) { + return undefined; + } + if (schema.$ref && rootSchema) { + const resolved = resolveRef(rootSchema, schema.$ref); + if (resolved) { + const { $ref: _ref, ...rest } = schema; + return { ...resolved, ...rest }; + } + } + return schema; +} + +export function resolveRef(schema: JSONSchema, ref: string): JSONSchema | undefined { + if (!ref.startsWith('#/')) { + return undefined; + } + const path = ref + .substring(2) + .split('/') + .map((segment) => segment.replace(/~1/g, '/').replace(/~0/g, '~')); + + let current: unknown = schema; + for (const segment of path) { + if (current && typeof current === 'object' && segment in current) { + current = (current as Record)[segment]; + } else { + return undefined; + } + } + return current as JSONSchema; +} + +export function getTypeLabel(schema?: JSONSchema, value?: JsonValue): string { + const type = getPrimaryType(schema) ?? inferValueType(value); + if (!type) { + return 'string'; + } + return type; +} + +export function getPrimaryType(schema?: JSONSchema): string | undefined { + if (!schema) { + return undefined; + } + if (Array.isArray(schema.type)) { + return schema.type[0]; + } + return schema.type; +} + +export function getEffectiveType(schema?: JSONSchema, value?: JsonValue): string | undefined { + const schemaType = getPrimaryType(schema); + if (schemaType) { + return schemaType; + } + return inferValueType(value); +} + +export function inferValueType(value: JsonValue | undefined): string | undefined { + if (value === null || value === undefined) { + return undefined; + } + if (Array.isArray(value)) { + return 'array'; + } + if (typeof value === 'object') { + return 'object'; + } + if (typeof value === 'boolean') { + return 'boolean'; + } + if (typeof value === 'number') { + return 'number'; + } + if (typeof value === 'string') { + return 'string'; + } + return undefined; +} + +export function isCompoundField(schema?: JSONSchema, value?: JsonValue) { + const type = getEffectiveType(schema, value); + if (!type) { + return Array.isArray(value) || isJsonObject(value); + } + return type === 'object' || type === 'array'; +} + +export function getDefaultValue(schema?: JSONSchema): JsonValue { + if (!schema) { + return ''; + } + + if (schema.default !== undefined) { + return cloneJsonValue(schema.default as JsonValue); + } + + const type = getPrimaryType(schema); + switch (type) { + case 'object': + return {}; + case 'array': + return []; + case 'boolean': + return false; + case 'number': + case 'integer': + return 0; + default: + if (schema.enum && schema.enum.length > 0) { + return schema.enum[0] as JsonValue; + } + return ''; + } +} + +export function createSchemaForType(type?: CustomFieldType): JSONSchema | undefined { + if (!type) { + return undefined; + } + if (type === 'number') { + return { type: 'number' }; + } + return { type }; +} + +export function isJsonObject(value: JsonValue | undefined): value is JsonObject { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + +export function isJsonSchemaObject(value: unknown): value is JSONSchema { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + +export function cloneJsonObject(value: T): T { + return JSON.parse(JSON.stringify(value)) as T; +} + +export function cloneJsonValue(value: T): T { + return JSON.parse(JSON.stringify(value)) as T; +} + +export function safeStringify(value: JsonValue | undefined) { + try { + if (value === undefined) { + return ''; + } + if (typeof value === 'string') { + return value; + } + return JSON.stringify(value, null, 2); + } catch { + return ''; + } +} + +export function formatJson(value: JsonValue | undefined) { + const text = safeStringify(value); + return text || '—'; +} + +export function buildDiffEntries( + path: (string | number)[], + original: JsonValue | undefined, + current: JsonValue | undefined, + diffs: DiffEntry[], +) { + if (original === undefined && current === undefined) { + return; + } + + if (original === undefined && current !== undefined) { + diffs.push({ + path: formatPath(path), + type: 'added', + after: cloneJsonValue(current as JsonValue), + }); + return; + } + + if (original !== undefined && current === undefined) { + diffs.push({ + path: formatPath(path), + type: 'removed', + before: cloneJsonValue(original as JsonValue), + }); + return; + } + + if (isJsonObject(original) && isJsonObject(current)) { + const keys = new Set([...Object.keys(original), ...Object.keys(current)]); + keys.forEach((key) => { + buildDiffEntries([...path, key], original[key], current[key], diffs); + }); + return; + } + + if (Array.isArray(original) && Array.isArray(current)) { + const maxLength = Math.max(original.length, current.length); + for (let index = 0; index < maxLength; index++) { + buildDiffEntries([...path, index], original[index], current[index], diffs); + } + return; + } + + if (JSON.stringify(original) !== JSON.stringify(current)) { + diffs.push({ + path: formatPath(path), + type: 'changed', + before: cloneJsonValue(original as JsonValue), + after: cloneJsonValue(current as JsonValue), + }); + } +} + +export function formatPath(path: (string | number)[]): string { + if (path.length === 0) { + return '(root)'; + } + return path.reduce((acc, segment) => { + if (typeof segment === 'number') { + return `${acc}[${segment}]`; + } + return acc ? `${acc}.${segment}` : String(segment); + }, ''); +} + +export function getObjectFromPath(root: JsonObject, path: string): JsonObject | undefined { + const segments = path + .split('.') + .map((segment) => segment.trim()) + .filter(Boolean); + let current: JsonValue | undefined = root; + for (const segment of segments) { + if (!isJsonObject(current)) { + return undefined; + } + current = current[segment]; + } + return isJsonObject(current) ? current : undefined; +} diff --git a/src/hooks/useProfileLoader.ts b/src/hooks/useProfileLoader.ts index b1ed062..5bf065d 100644 --- a/src/hooks/useProfileLoader.ts +++ b/src/hooks/useProfileLoader.ts @@ -74,6 +74,7 @@ export function useProfileLoader( setProfiles(profileData); setActiveConfigs(configData); + return { profiles: profileData, activeConfigs: configData }; }, [tools, profileTransform]); return { diff --git a/src/pages/ConfigurationPage/hooks/useConfigManagement.ts b/src/pages/ConfigurationPage/hooks/useConfigManagement.ts index faa4883..08afbea 100644 --- a/src/pages/ConfigurationPage/hooks/useConfigManagement.ts +++ b/src/pages/ConfigurationPage/hooks/useConfigManagement.ts @@ -93,19 +93,16 @@ export function useConfigManagement(tools: ToolStatus[]) { return { success: false, message: '选择自定义端点时必须填写有效的 Base URL' }; } - // 确保拥有最新的配置数据 - let currentConfig = activeConfigs[selectedTool]; - if (!currentConfig) { - // 重新加载以获取最新配置 - await loadAllProfiles(); - currentConfig = activeConfigs[selectedTool]; - } + // 确保拥有最新的配置数据,避免使用陈旧状态 + const latest = await loadAllProfiles(); + const effectiveProfiles = latest?.profiles[selectedTool] ?? profiles[selectedTool] ?? []; + const effectiveConfig = latest?.activeConfigs[selectedTool] ?? activeConfigs[selectedTool]; - // 检查是否会覆盖现有配置 - const existingProfiles = profiles[selectedTool] || []; const hasRealConfig = - currentConfig && currentConfig.api_key !== '未配置' && currentConfig.base_url !== '未配置'; - const willOverride = profileName ? existingProfiles.includes(profileName) : hasRealConfig; + effectiveConfig && + effectiveConfig.api_key !== '未配置' && + effectiveConfig.base_url !== '未配置'; + const willOverride = profileName ? effectiveProfiles.includes(profileName) : hasRealConfig; if (willOverride) { return { success: false, message: '', needsConfirmation: true }; diff --git a/src/pages/DashboardPage/hooks/useDashboard.ts b/src/pages/DashboardPage/hooks/useDashboard.ts index 8a76fc5..77267e0 100644 --- a/src/pages/DashboardPage/hooks/useDashboard.ts +++ b/src/pages/DashboardPage/hooks/useDashboard.ts @@ -1,4 +1,4 @@ -import { useCallback, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { checkAllUpdates, type ToolStatus, @@ -143,6 +143,15 @@ export function useDashboard(initialTools: ToolStatus[]) { }); }, []); // 空依赖数组,因为使用了函数式更新 + // 组件卸载时清理定时器,避免潜在的状态更新警告 + useEffect(() => { + return () => { + if (updateMessageTimeoutRef.current) { + clearTimeout(updateMessageTimeoutRef.current); + } + }; + }, []); + return { tools, updating, diff --git a/src/pages/InstallationPage/hooks/useInstallation.ts b/src/pages/InstallationPage/hooks/useInstallation.ts index a8616c4..a5f1608 100644 --- a/src/pages/InstallationPage/hooks/useInstallation.ts +++ b/src/pages/InstallationPage/hooks/useInstallation.ts @@ -9,10 +9,13 @@ import type { NodeEnvironment } from '@/components/dialogs/MirrorStaleDialog'; export function useInstallation(_tools: ToolStatus[]) { const [installing, setInstalling] = useState(null); const [nodeEnv, setNodeEnv] = useState(null); - const [installMethods, setInstallMethods] = useState>({ - 'claude-code': 'official', - codex: navigator.userAgent.includes('Mac') ? 'brew' : 'npm', - 'gemini-cli': 'npm', + const [installMethods, setInstallMethods] = useState>(() => { + const isMac = typeof navigator !== 'undefined' && navigator.userAgent.includes('Mac'); + return { + 'claude-code': 'official', + codex: isMac ? 'brew' : 'npm', + 'gemini-cli': 'npm', + }; }); const [mirrorStaleDialog, setMirrorStaleDialog] = useState({ open: false, @@ -40,7 +43,7 @@ export function useInstallation(_tools: ToolStatus[]) { // 获取可用的安装方法 const getAvailableInstallMethods = useCallback( (toolId: string): Array<{ value: string; label: string; disabled?: boolean }> => { - const isMac = navigator.userAgent.includes('Mac'); + const isMac = typeof navigator !== 'undefined' && navigator.userAgent.includes('Mac'); if (toolId === 'claude-code') { return [ diff --git a/src/pages/ProfileSwitchPage/hooks/useProfileManagement.ts b/src/pages/ProfileSwitchPage/hooks/useProfileManagement.ts index 276cc0a..a226680 100644 --- a/src/pages/ProfileSwitchPage/hooks/useProfileManagement.ts +++ b/src/pages/ProfileSwitchPage/hooks/useProfileManagement.ts @@ -151,12 +151,16 @@ export function useProfileManagement( // 尝试重新加载所有配置,确保与后端同步 try { - await loadAllProfiles(); + const latest = await loadAllProfiles(); + const deletedWasActive = + latest.activeConfigs[toolId]?.profile_name === profile || + activeConfigs[toolId]?.profile_name === profile; - // 如果删除的是当前正在使用的配置,重新获取当前配置 - if (activeConfigs[toolId]?.profile_name === profile) { + // 如果删除的是当前正在使用的配置,确保UI展示的生效配置同步更新 + if (deletedWasActive) { try { - const newActiveConfig = await getActiveConfig(toolId); + const newActiveConfig = + latest.activeConfigs[toolId] ?? (await getActiveConfig(toolId)); setActiveConfigs((prev) => ({ ...prev, [toolId]: newActiveConfig })); } catch (error) { console.error('Failed to reload active config', error); diff --git a/src/pages/StatisticsPage/index.tsx b/src/pages/StatisticsPage/index.tsx index 63e8df9..2d99377 100644 --- a/src/pages/StatisticsPage/index.tsx +++ b/src/pages/StatisticsPage/index.tsx @@ -1,6 +1,7 @@ import { Button } from '@/components/ui/button'; import { Card, CardContent } from '@/components/ui/card'; import { BarChart3, Settings as SettingsIcon, RefreshCw, Loader2 } from 'lucide-react'; +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { PageContainer } from '@/components/layout/PageContainer'; import { QuotaCard } from '@/components/QuotaCard'; import { TodayStatsCard } from '@/components/TodayStatsCard'; @@ -12,6 +13,8 @@ interface StatisticsPageProps { usageStats: UsageStatsResult | null; userQuota: UserQuotaResult | null; statsLoading: boolean; + statsLoadFailed: boolean; + statsError?: string | null; onLoadStatistics: () => void; } @@ -20,6 +23,8 @@ export function StatisticsPage({ usageStats, userQuota, statsLoading, + statsLoadFailed, + statsError, onLoadStatistics, }: StatisticsPageProps) { const hasCredentials = globalConfig?.user_id && globalConfig?.system_token; @@ -56,6 +61,34 @@ export function StatisticsPage({ ) : (
+ {statsLoadFailed && ( + + +
+ 统计数据获取失败 + {statsError || '请检查网络或凭证设置后重试'} +
+ +
+ )} +