mirror of
https://github.com/jeffusion/gitea-ai-assistant.git
synced 2026-03-27 10:05:50 +00:00
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:
140
frontend/src/components/ConfigFieldInput.tsx
Normal file
140
frontend/src/components/ConfigFieldInput.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
93
frontend/src/components/ConfigGroupCard.tsx
Normal file
93
frontend/src/components/ConfigGroupCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
181
frontend/src/components/ConfigManager.tsx
Normal file
181
frontend/src/components/ConfigManager.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
173
frontend/src/components/ui/select.tsx
Normal file
173
frontend/src/components/ui/select.tsx
Normal 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,
|
||||
}
|
||||
27
frontend/src/components/ui/separator.tsx
Normal file
27
frontend/src/components/ui/separator.tsx
Normal 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 }
|
||||
29
frontend/src/components/ui/switch.tsx
Normal file
29
frontend/src/components/ui/switch.tsx
Normal 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 }
|
||||
58
frontend/src/components/ui/tabs.tsx
Normal file
58
frontend/src/components/ui/tabs.tsx
Normal 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 }
|
||||
20
frontend/src/components/ui/textarea.tsx
Normal file
20
frontend/src/components/ui/textarea.tsx
Normal 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 }
|
||||
46
frontend/src/services/configService.ts
Normal file
46
frontend/src/services/configService.ts
Normal 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 });
|
||||
};
|
||||
Reference in New Issue
Block a user