diff --git a/frontend/src/components/ConfigFieldInput.tsx b/frontend/src/components/ConfigFieldInput.tsx new file mode 100644 index 0000000..41a5e4a --- /dev/null +++ b/frontend/src/components/ConfigFieldInput.tsx @@ -0,0 +1,140 @@ + +import type { ConfigFieldDto } from '@/services/configService'; +import { Input } from '@/components/ui/input'; +import { Switch } from '@/components/ui/switch'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Textarea } from '@/components/ui/textarea'; +import { Badge } from '@/components/ui/badge'; +import { Label } from '@/components/ui/label'; +import { Lock } from 'lucide-react'; + +interface ConfigFieldInputProps { + field: ConfigFieldDto; + value: any; + onChange: (value: any) => void; +} + +export function ConfigFieldInput({ field, value, onChange }: ConfigFieldInputProps) { + const isReadonly = !!field.readonly; + + const renderInput = () => { + const baseInputClasses = "bg-zinc-900/50 border-white/10 focus-visible:ring-primary focus-visible:border-primary transition-all duration-200" + (isReadonly ? " opacity-50 cursor-not-allowed" : ""); + switch (field.type) { + case 'boolean': + return ( + + ); + case 'enum': + return ( + + + + + + {field.enumValues?.map((val) => ( + + {val} + + ))} + + + ); + case 'text': + return ( + onChange(e.target.value)} + placeholder={field.sensitive && field.hasValue ? '••••••••' : ''} + className={`min-h-[100px] ${baseInputClasses}`} + disabled={isReadonly} + /> + ); + case 'number': + return ( + onChange(e.target.value)} + placeholder={field.sensitive && field.hasValue ? '••••••••' : ''} + min={field.min} + max={field.max} + disabled={isReadonly} + className={baseInputClasses} + /> + ); + case 'string': + case 'url': + default: + return ( + onChange(e.target.value)} + placeholder={field.sensitive && field.hasValue ? '••••••••' : ''} + disabled={isReadonly} + className={baseInputClasses} + /> + ); + } + }; + + const getSourceBadge = () => { + switch (field.source) { + case 'override': + return 覆盖值; + case 'env': + return 环境变量; + case 'default': + default: + return 默认值; + } + }; + + return ( + + + + + {field.label || field.envKey} + {getSourceBadge()} + + + {field.description} + + + + $ {field.envKey} + + + + + + {renderInput()} + + {isReadonly && ( + + + 只读配置,请通过环境变量文件修改 + + )} + + {!isReadonly && field.readonlyWarning && ( + + + {field.readonlyWarning} + + )} + + + + ); +} diff --git a/frontend/src/components/ConfigGroupCard.tsx b/frontend/src/components/ConfigGroupCard.tsx new file mode 100644 index 0000000..3b1102a --- /dev/null +++ b/frontend/src/components/ConfigGroupCard.tsx @@ -0,0 +1,93 @@ + +import type { ConfigGroupDto } from '@/services/configService'; +import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { ConfigFieldInput } from './ConfigFieldInput'; +import { + RotateCcw, Link, Bot, Bell, Settings, Shield, FileCheck, Brain, + type LucideIcon, +} from 'lucide-react'; + +const ICON_MAP: Record = { + link: Link, + bot: Bot, + bell: Bell, + settings: Settings, + shield: Shield, + 'file-check': FileCheck, + brain: Brain, +}; + +interface ConfigGroupCardProps { + group: ConfigGroupDto; + localConfig: Record; + onFieldChange: (envKey: string, value: any) => void; + onReset: (keys: string[]) => void; + isResetting: boolean; +} + +export function ConfigGroupCard({ + group, + localConfig, + onFieldChange, + onReset, + isResetting, +}: ConfigGroupCardProps) { + const hasOverride = group.fields.some((f) => f.source === 'override'); + + const handleReset = () => { + // Only reset fields that actually have overrides + const keysToReset = group.fields + .filter((f) => f.source === 'override') + .map((f) => f.envKey); + + if (keysToReset.length > 0) { + onReset(keysToReset); + } + }; + + return ( + + + + + {(() => { + const Icon = ICON_MAP[group.icon]; + return Icon ? : {group.icon}; + })()} + + + + {group.label} + + + {group.description} + + + + {hasOverride && ( + + + 重置组配置 + + )} + + + {group.fields.map((field) => ( + onFieldChange(field.envKey, val)} + /> + ))} + + + ); +} diff --git a/frontend/src/components/ConfigManager.tsx b/frontend/src/components/ConfigManager.tsx new file mode 100644 index 0000000..feae516 --- /dev/null +++ b/frontend/src/components/ConfigManager.tsx @@ -0,0 +1,181 @@ +import { useState, useEffect } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { fetchConfig, updateConfig, resetConfig } from '@/services/configService'; +import type { ConfigResponse } from '@/services/configService'; +import { ConfigGroupCard } from './ConfigGroupCard'; +import { Button } from '@/components/ui/button'; +import { Skeleton } from '@/components/ui/skeleton'; +import { Save, AlertCircle, RotateCcw } from 'lucide-react'; +import { toast } from 'sonner'; + +export function ConfigManager() { + const queryClient = useQueryClient(); + const [localConfig, setLocalConfig] = useState>({}); + const [hasChanges, setHasChanges] = useState(false); + + const { data, isLoading, isError, error } = useQuery({ + queryKey: ['config'], + queryFn: fetchConfig, + }); + + // Initialize local config from fetched data + useEffect(() => { + if (data) { + const initialState: Record = {}; + data.groups.forEach((group) => { + group.fields.forEach((field) => { + if (field.sensitive && field.hasValue) { + initialState[field.envKey] = '••••••••'; + } else { + // For boolean, keep as boolean. For others, string/number. + // If value is undefined or null, use empty string. + if (field.type === 'boolean') { + initialState[field.envKey] = field.value === 'true' || field.value === true; + } else { + initialState[field.envKey] = field.value ?? ''; + } + } + }); + }); + setLocalConfig(initialState); + setHasChanges(false); + } + }, [data]); + + const saveMutation = useMutation({ + mutationFn: (configData: Record) => updateConfig(configData), + onSuccess: () => { + toast.success('配置已成功保存'); + queryClient.invalidateQueries({ queryKey: ['config'] }); + setHasChanges(false); + }, + onError: (err: Error) => { + toast.error(`保存失败: ${err.message}`); + }, + }); + + const resetMutation = useMutation({ + mutationFn: (keys: string[]) => resetConfig(keys), + onSuccess: () => { + toast.success('配置已重置'); + queryClient.invalidateQueries({ queryKey: ['config'] }); + }, + onError: (err: Error) => { + toast.error(`重置失败: ${err.message}`); + }, + }); + + const handleFieldChange = (envKey: string, value: any) => { + setLocalConfig((prev) => ({ + ...prev, + [envKey]: value, + })); + setHasChanges(true); + }; + + const handleSave = () => { + const payload: Record = {}; + + for (const [key, val] of Object.entries(localConfig)) { + if (typeof val === 'boolean') { + payload[key] = val ? 'true' : 'false'; + } else { + payload[key] = val === undefined || val === null ? '' : String(val); + } + } + + saveMutation.mutate(payload); + }; + + const handleResetGroup = (keys: string[]) => { + if (confirm('确定要重置这些配置到默认值吗?这将立即生效并重载关联设置。')) { + resetMutation.mutate(keys); + } + }; + + const handleResetAll = () => { + if (!data) return; + const allOverrideKeys = data.groups + .flatMap((g) => g.fields) + .filter((f) => f.source === 'override') + .map((f) => f.envKey); + if (allOverrideKeys.length === 0) return; + if (confirm('确定要重置所有配置到默认值吗?这将立即生效。')) { + resetMutation.mutate(allOverrideKeys); + } + }; + + const hasOverrides = data?.groups.some((g) => + g.fields.some((f) => f.source === 'override') + ) ?? false; + + if (isLoading) { + return ( + + + + + + + + + ); + } + + if (isError) { + return ( + + + 加载配置失败: {error.message} + + ); + } + + return ( + + {/* 固定在顶部的操作栏 */} + + + + + 全部重置 + + + {saveMutation.isPending ? ( + + 保存中... + + ) : ( + <> + + 保存配置 + > + )} + + + + + + {data?.groups.map((group) => ( + + ))} + + + ); +} diff --git a/frontend/src/components/ui/select.tsx b/frontend/src/components/ui/select.tsx new file mode 100644 index 0000000..ec23c07 --- /dev/null +++ b/frontend/src/components/ui/select.tsx @@ -0,0 +1,173 @@ +import * as React from "react" +import * as SelectPrimitive from "@radix-ui/react-select" +import { Check, ChevronDown, ChevronUp } from "lucide-react" + +import { cn } from "@/lib/utils" + +function Select({ ...props }: React.ComponentProps) { + return +} + +function SelectGroup({ ...props }: React.ComponentProps) { + return +} + +function SelectValue({ ...props }: React.ComponentProps) { + return +} + +function SelectTrigger({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + span]:line-clamp-1", + className + )} + {...props} + > + {children} + + + + + ) +} + +function SelectScrollUpButton({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function SelectScrollDownButton({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function SelectContent({ + className, + children, + position = "popper", + ...props +}: React.ComponentProps) { + return ( + + + + + {children} + + + + + ) +} + +function SelectLabel({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function SelectItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function SelectSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + Select, + SelectGroup, + SelectValue, + SelectTrigger, + SelectContent, + SelectLabel, + SelectItem, + SelectSeparator, + SelectScrollUpButton, + SelectScrollDownButton, +} diff --git a/frontend/src/components/ui/separator.tsx b/frontend/src/components/ui/separator.tsx new file mode 100644 index 0000000..d4f181b --- /dev/null +++ b/frontend/src/components/ui/separator.tsx @@ -0,0 +1,27 @@ +import * as React from "react" +import * as SeparatorPrimitive from "@radix-ui/react-separator" + +import { cn } from "@/lib/utils" + +function Separator({ + className, + orientation = "horizontal", + decorative = true, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { Separator } diff --git a/frontend/src/components/ui/switch.tsx b/frontend/src/components/ui/switch.tsx new file mode 100644 index 0000000..b5c0b36 --- /dev/null +++ b/frontend/src/components/ui/switch.tsx @@ -0,0 +1,29 @@ +import * as React from "react" +import * as SwitchPrimitive from "@radix-ui/react-switch" + +import { cn } from "@/lib/utils" + +function Switch({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +export { Switch } diff --git a/frontend/src/components/ui/tabs.tsx b/frontend/src/components/ui/tabs.tsx new file mode 100644 index 0000000..fddf7ce --- /dev/null +++ b/frontend/src/components/ui/tabs.tsx @@ -0,0 +1,58 @@ +import * as React from "react" +import * as TabsPrimitive from "@radix-ui/react-tabs" + +import { cn } from "@/lib/utils" + +function Tabs({ ...props }: React.ComponentProps) { + return +} + +function TabsList({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function TabsTrigger({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function TabsContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { Tabs, TabsList, TabsTrigger, TabsContent } diff --git a/frontend/src/components/ui/textarea.tsx b/frontend/src/components/ui/textarea.tsx new file mode 100644 index 0000000..b3639ac --- /dev/null +++ b/frontend/src/components/ui/textarea.tsx @@ -0,0 +1,20 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Textarea({ className, ...props }: React.ComponentProps<"textarea">) { + return ( + + ) +} + +export { Textarea } diff --git a/frontend/src/services/configService.ts b/frontend/src/services/configService.ts new file mode 100644 index 0000000..0324a23 --- /dev/null +++ b/frontend/src/services/configService.ts @@ -0,0 +1,46 @@ +import api from '@/lib/api'; + +export type ConfigSource = 'default' | 'env' | 'override'; +export type ConfigFieldType = 'string' | 'number' | 'boolean' | 'url' | 'text' | 'enum'; + +export interface ConfigFieldDto { + envKey: string; + label: string; + description: string; + type: ConfigFieldType; + sensitive: boolean; + readonly?: boolean; + readonlyWarning?: string; + enumValues?: string[]; + min?: number; + max?: number; + defaultValue?: string | number | boolean; + value: string | number | boolean | undefined; + hasValue: boolean; + source: ConfigSource; +} + +export interface ConfigGroupDto { + key: string; + label: string; + description: string; + icon: string; + fields: ConfigFieldDto[]; +} + +export interface ConfigResponse { + groups: ConfigGroupDto[]; +} + +export const fetchConfig = async (): Promise => { + const response = await api.get('/config'); + return response.data; +}; + +export const updateConfig = async (configData: Record): Promise => { + await api.put('/config', configData); +}; + +export const resetConfig = async (keys: string[]): Promise => { + await api.post('/config/reset', { keys }); +};