feat(frontend): add config management page with UI components

Add comprehensive configuration management UI:
- ConfigManager: Main page with grouped config display
- ConfigGroupCard: Expandable cards for each config group
- ConfigFieldInput: Smart input based on field type
  - Text, URL, password (masked), number, boolean, enum, textarea

UI Components added:
- Select, Switch, Tabs, Textarea, Separator from shadcn/ui

Features:
- Real-time field validation
- Source indicator (default/env/override)
- Save/reset functionality with toast notifications
- Responsive layout with collapsible groups
This commit is contained in:
jeffusion
2026-03-03 16:32:21 +08:00
parent d375a4c82d
commit f223e35cbb
9 changed files with 767 additions and 0 deletions

View File

@@ -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 (
<Switch
checked={Boolean(value)}
onCheckedChange={onChange}
disabled={isReadonly}
className={`data-[state=checked]:bg-primary ${isReadonly ? 'opacity-50 cursor-not-allowed' : ''}`}
/>
);
case 'enum':
return (
<Select
value={value !== undefined && value !== null ? String(value) : ''}
onValueChange={onChange}
disabled={isReadonly}
>
<SelectTrigger className={`w-full ${baseInputClasses}`}>
<SelectValue placeholder="请选择..." />
</SelectTrigger>
<SelectContent className="bg-zinc-950 border-white/10">
{field.enumValues?.map((val) => (
<SelectItem key={val} value={val} className="focus:bg-zinc-900 focus:text-primary">
{val}
</SelectItem>
))}
</SelectContent>
</Select>
);
case 'text':
return (
<Textarea
value={value !== undefined && value !== null ? String(value) : ''}
onChange={(e) => onChange(e.target.value)}
placeholder={field.sensitive && field.hasValue ? '••••••••' : ''}
className={`min-h-[100px] ${baseInputClasses}`}
disabled={isReadonly}
/>
);
case 'number':
return (
<Input
type="number"
value={value !== undefined && value !== null ? String(value) : ''}
onChange={(e) => 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 (
<Input
type={field.type === 'url' ? 'url' : 'text'}
value={value !== undefined && value !== null ? String(value) : ''}
onChange={(e) => onChange(e.target.value)}
placeholder={field.sensitive && field.hasValue ? '••••••••' : ''}
disabled={isReadonly}
className={baseInputClasses}
/>
);
}
};
const getSourceBadge = () => {
switch (field.source) {
case 'override':
return <Badge className="ml-2 bg-primary/20 text-primary border-primary/30 tech-glow hover:bg-primary/30 transition-colors"></Badge>;
case 'env':
return <Badge variant="secondary" className="ml-2 bg-amber-500/20 text-amber-500 border-amber-500/30 hover:bg-amber-500/30"></Badge>;
case 'default':
default:
return <Badge variant="outline" className="ml-2 border-zinc-600 text-zinc-400"></Badge>;
}
};
return (
<div className="flex flex-col py-5 px-1 gap-3 hover:bg-zinc-900/30 transition-colors rounded-lg">
<div className="flex flex-col md:flex-row md:items-start justify-between gap-4">
<div className="flex flex-col space-y-1.5 flex-1">
<div className="flex items-center">
<Label className="text-base font-semibold text-zinc-100">{field.label || field.envKey}</Label>
{getSourceBadge()}
</div>
<div className="text-sm text-zinc-400 leading-relaxed">
{field.description}
</div>
<div className="pt-1">
<span className="font-mono text-xs px-2 py-0.5 rounded-md bg-primary/10 text-primary border border-primary/20 inline-flex items-center">
$ {field.envKey}
</span>
</div>
</div>
<div className="flex-1 w-full max-w-xl flex flex-col gap-2">
{renderInput()}
{isReadonly && (
<div className="text-xs text-zinc-500 flex items-center gap-1.5 pt-1">
<Lock className="w-3.5 h-3.5" />
</div>
)}
{!isReadonly && field.readonlyWarning && (
<div className="text-xs text-amber-500 flex items-center gap-1.5 pt-1">
<span className="w-1.5 h-1.5 rounded-full bg-amber-500 animate-pulse" />
{field.readonlyWarning}
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -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<string, LucideIcon> = {
link: Link,
bot: Bot,
bell: Bell,
settings: Settings,
shield: Shield,
'file-check': FileCheck,
brain: Brain,
};
interface ConfigGroupCardProps {
group: ConfigGroupDto;
localConfig: Record<string, any>;
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 (
<Card className="mb-8 glass-panel border-white/10 shadow-xl overflow-hidden group">
<CardHeader className="flex flex-row items-center justify-between pb-4 space-y-0 border-b border-white/5 bg-zinc-950/30">
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-xl bg-primary/10 flex items-center justify-center border border-primary/20 tech-glow group-hover:bg-primary/20 transition-all duration-300">
{(() => {
const Icon = ICON_MAP[group.icon];
return Icon ? <Icon className="h-5 w-5 text-primary" /> : <span className="text-primary">{group.icon}</span>;
})()}
</div>
<div className="space-y-1">
<CardTitle className="text-xl font-bold text-zinc-100 tracking-tight">
{group.label}
</CardTitle>
<CardDescription className="text-zinc-400">
{group.description}
</CardDescription>
</div>
</div>
{hasOverride && (
<Button
variant="outline"
size="sm"
onClick={handleReset}
disabled={isResetting}
className="border-rose-500/30 text-rose-500 hover:bg-rose-500/10 hover:text-rose-400 hover:border-rose-500/50 transition-colors"
>
<RotateCcw className="w-4 h-4 mr-2" />
</Button>
)}
</CardHeader>
<CardContent className="divide-y divide-white/5 p-6 bg-zinc-950/20">
{group.fields.map((field) => (
<ConfigFieldInput
key={field.envKey}
field={field}
value={localConfig[field.envKey]}
onChange={(val) => onFieldChange(field.envKey, val)}
/>
))}
</CardContent>
</Card>
);
}

View File

@@ -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<Record<string, any>>({});
const [hasChanges, setHasChanges] = useState(false);
const { data, isLoading, isError, error } = useQuery<ConfigResponse, Error>({
queryKey: ['config'],
queryFn: fetchConfig,
});
// Initialize local config from fetched data
useEffect(() => {
if (data) {
const initialState: Record<string, any> = {};
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<string, string>) => 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<string, string> = {};
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 (
<div className="space-y-6">
<div className="flex justify-between items-center mb-6">
<Skeleton className="h-10 w-48 bg-zinc-900/50" />
<Skeleton className="h-10 w-24 bg-zinc-900/50" />
</div>
<Skeleton className="h-[300px] w-full rounded-xl bg-zinc-900/50 border border-white/5" />
<Skeleton className="h-[300px] w-full rounded-xl bg-zinc-900/50 border border-white/5" />
</div>
);
}
if (isError) {
return (
<div className="p-4 bg-rose-500/10 border border-rose-500/20 text-rose-500 rounded-lg flex items-center gap-3 glass-panel">
<AlertCircle className="w-5 h-5 text-rose-500" />
<div className="font-medium tracking-wide">: {error.message}</div>
</div>
);
}
return (
<div className="min-h-screen pb-12">
{/* 固定在顶部的操作栏 */}
<div className="sticky top-0 z-10 bg-zinc-950/80 backdrop-blur-xl border-b border-white/10 py-3 px-4 md:px-6 lg:px-8 shadow-2xl">
<div className="flex items-center justify-end gap-3 max-w-5xl mx-auto">
<Button
variant="outline"
onClick={handleResetAll}
disabled={!hasOverrides || resetMutation.isPending}
className="border-white/10 text-zinc-400 hover:text-zinc-100 hover:bg-white/5 transition-colors"
>
<RotateCcw className="w-4 h-4 mr-2" />
</Button>
<Button
onClick={handleSave}
disabled={!hasChanges || saveMutation.isPending}
className="min-w-[130px] bg-primary text-zinc-950 font-bold hover:bg-primary/90 tech-glow transition-all"
>
{saveMutation.isPending ? (
<span className="flex items-center gap-2">
<span className="size-4 animate-spin rounded-full border-2 border-zinc-950 border-t-transparent" /> ...
</span>
) : (
<>
<Save className="w-4 h-4 mr-2" />
</>
)}
</Button>
</div>
</div>
<div className="max-w-5xl mx-auto space-y-8 mt-6 px-4 md:px-6 lg:px-8">
{data?.groups.map((group) => (
<ConfigGroupCard
key={group.key}
group={group}
localConfig={localConfig}
onFieldChange={handleFieldChange}
onReset={handleResetGroup}
isResetting={resetMutation.isPending}
/>
))}
</div>
</div>
);
}

View File

@@ -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<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({ ...props }: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
}
function SelectValue({ ...props }: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger>) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] dark:bg-input/30 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 flex h-9 w-full items-center justify-between rounded-md border bg-transparent px-3 py-2 text-sm shadow-xs transition-[color,box-shadow] outline-none disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
)
}
function SelectContent({
className,
children,
position = "popper",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex w-full cursor-default items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-muted -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}

View File

@@ -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<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className
)}
{...props}
/>
)
}
export { Separator }

View File

@@ -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<typeof SwitchPrimitive.Root>) {
return (
<SwitchPrimitive.Root
data-slot="switch"
className={cn(
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-xs transition-all outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className={cn(
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitive.Root>
)
}
export { Switch }

View File

@@ -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<typeof TabsPrimitive.Root>) {
return <TabsPrimitive.Root data-slot="tabs" {...props} />
}
function TabsList({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.List>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
className={cn(
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
className
)}
{...props}
/>
)
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
className
)}
{...props}
/>
)
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className
)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent }

View File

@@ -0,0 +1,20 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex min-h-[80px] w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{...props}
/>
)
}
export { Textarea }

View File

@@ -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<ConfigResponse> => {
const response = await api.get<ConfigResponse>('/config');
return response.data;
};
export const updateConfig = async (configData: Record<string, string>): Promise<void> => {
await api.put('/config', configData);
};
export const resetConfig = async (keys: string[]): Promise<void> => {
await api.post('/config/reset', { keys });
};