告警规则表单优化:数字输入框使用本地状态管理,解决清空后回退到默认值问题

This commit is contained in:
dengqichen 2025-12-10 18:04:29 +08:00
parent a5dda91f20
commit c1e0763472
9 changed files with 342 additions and 119 deletions

View File

@ -40,7 +40,7 @@ export const Terminal: React.FC<TerminalProps> = ({
const [showSearch, setShowSearch] = useState(false); const [showSearch, setShowSearch] = useState(false);
const [currentTheme, setCurrentTheme] = useState('dark'); const [currentTheme, setCurrentTheme] = useState('dark');
const [auditShown, setAuditShown] = useState(false); const [auditShown, setAuditShown] = useState(false);
const [connectionStatus, setConnectionStatus] = useState<ConnectionStatus>('disconnected'); // 初始状态,会被实例状态覆盖 const [connectionStatus, setConnectionStatus] = useState<ConnectionStatus>('connecting'); // 初始状态为连接中,符合正常生命周期
const [errorMessage, setErrorMessage] = useState<string>(''); const [errorMessage, setErrorMessage] = useState<string>('');
const [connectedTime, setConnectedTime] = useState<Date | null>(null); const [connectedTime, setConnectedTime] = useState<Date | null>(null);
@ -103,10 +103,11 @@ export const Terminal: React.FC<TerminalProps> = ({
} }
}); });
// 延迟连接(仅在未连接时) // 延迟连接(仅在未连接或需要连接时)
const timer = setTimeout(() => { const timer = setTimeout(() => {
const currentState = instance.getState(); const currentState = instance.getState();
if (currentState.status === 'disconnected' || currentState.status === 'error') { // 在 connecting、disconnected 或 error 状态下需要建立连接
if (currentState.status === 'connecting' || currentState.status === 'disconnected' || currentState.status === 'error') {
instance.connect(); instance.connect();
} }
}, 300); }, 300);

View File

@ -22,7 +22,7 @@ export type ErrorCallback = (error: string) => void;
* *
*/ */
export abstract class BaseConnectionStrategy { export abstract class BaseConnectionStrategy {
protected status: ConnectionStatus = 'disconnected'; protected status: ConnectionStatus = 'connecting'; // 初始状态为connecting符合打开终端时的正常生命周期
protected statusListeners: Set<StatusChangeCallback> = new Set(); protected statusListeners: Set<StatusChangeCallback> = new Set();
protected messageListeners: Set<MessageCallback> = new Set(); protected messageListeners: Set<MessageCallback> = new Set();
protected errorListeners: Set<ErrorCallback> = new Set(); protected errorListeners: Set<ErrorCallback> = new Set();

View File

@ -3,7 +3,22 @@ import * as React from "react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>( const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => { ({ className, type, onChange, ...props }, ref) => {
// 为 number 类型的输入框提供智能处理,避免空值被转换为 0
const handleChange = React.useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
if (type === 'number' && onChange) {
const value = e.target.value;
// 当输入为空时,将 value 设置为空字符串,防止外部 Number() 转换为 0
// 外部应该判断空字符串并转为 undefined/null
onChange(e);
} else {
onChange?.(e);
}
},
[type, onChange]
);
return ( return (
<input <input
type={type} type={type}
@ -12,6 +27,7 @@ const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
className className
)} )}
ref={ref} ref={ref}
onChange={handleChange}
{...props} {...props}
/> />
) )

View File

@ -51,12 +51,8 @@ const DeleteDialog: React.FC<DeleteDialogProps> = ({
onSuccess(); onSuccess();
onOpenChange(false); onOpenChange(false);
} catch (error) { } catch (error) {
// 错误提示统一由全局 request 拦截器处理,这里仅记录日志
console.error('删除失败:', error); console.error('删除失败:', error);
toast({
title: '删除失败',
description: error instanceof Error ? error.message : '未知错误',
variant: 'destructive'
});
} }
}; };

View File

@ -1,7 +1,7 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { Loader2 } from 'lucide-react'; import { Loader2, Search, Check, ChevronDown } from 'lucide-react';
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@ -31,10 +31,13 @@ import {
SelectValue, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { useToast } from '@/components/ui/use-toast'; import { useToast } from '@/components/ui/use-toast';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { ScrollArea } from '@/components/ui/scroll-area';
import { cn } from '@/lib/utils';
import type { AlertRuleResponse, ServerResponse, AlertRuleRequest } from '../types'; import type { AlertRuleResponse, ServerResponse, AlertRuleRequest } from '../types';
import { AlertType, AlertTypeLabels } from '../types'; import { AlertType, AlertTypeLabels } from '../types';
import { alertRuleFormSchema, type AlertRuleFormValues } from '../schema'; import { alertRuleFormSchema, type AlertRuleFormValues } from '../schema';
import { createAlertRule, updateAlertRule, getServers } from '../service'; import { createAlertRule, updateAlertRule, getServerList } from '../service';
interface AlertRuleFormDialogProps { interface AlertRuleFormDialogProps {
open: boolean; open: boolean;
@ -52,6 +55,8 @@ export const AlertRuleFormDialog: React.FC<AlertRuleFormDialogProps> = ({
const { toast } = useToast(); const { toast } = useToast();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [servers, setServers] = useState<ServerResponse[]>([]); const [servers, setServers] = useState<ServerResponse[]>([]);
const [serverSearchValue, setServerSearchValue] = useState('');
const [serverPopoverOpen, setServerPopoverOpen] = useState(false);
const form = useForm<AlertRuleFormValues>({ const form = useForm<AlertRuleFormValues>({
resolver: zodResolver(alertRuleFormSchema), resolver: zodResolver(alertRuleFormSchema),
@ -76,8 +81,8 @@ export const AlertRuleFormDialog: React.FC<AlertRuleFormDialogProps> = ({
const loadServers = async () => { const loadServers = async () => {
try { try {
const result = await getServers({ pageNum: 0, size: 1000 }); const result = await getServerList();
setServers(result?.content || []); setServers(result || []);
} catch (error) { } catch (error) {
console.error('加载服务器列表失败:', error); console.error('加载服务器列表失败:', error);
} }
@ -168,26 +173,101 @@ export const AlertRuleFormDialog: React.FC<AlertRuleFormDialogProps> = ({
render={({ field }) => ( render={({ field }) => (
<FormItem className="col-span-2"> <FormItem className="col-span-2">
<FormLabel></FormLabel> <FormLabel></FormLabel>
<Select <Popover open={serverPopoverOpen} onOpenChange={setServerPopoverOpen}>
value={field.value === null ? 'global' : field.value.toString()} <PopoverTrigger asChild>
onValueChange={(value) => { <FormControl>
field.onChange(value === 'global' ? null : Number(value)); <Button
variant="outline"
role="combobox"
className={cn(
'w-full justify-between',
field.value === null && 'text-muted-foreground'
)}
>
{field.value === null ? (
'全局规则(适用于所有服务器)'
) : (() => {
const server = servers.find(s => s.id === field.value);
return server ? `专属规则 - ${server.serverName} (${server.hostIp})` : '选择规则范围';
})()}
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
<div className="flex items-center border-b px-3">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<input
placeholder="搜索服务器..."
className="flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground"
value={serverSearchValue}
onChange={(e) => setServerSearchValue(e.target.value)}
/>
</div>
<ScrollArea className="h-[300px]">
<div className="p-1">
{/* 全局规则选项 */}
<div
className={cn(
'relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-accent hover:text-accent-foreground',
field.value === null && 'bg-accent text-accent-foreground'
)}
onClick={() => {
field.onChange(null);
setServerSearchValue('');
setServerPopoverOpen(false);
}} }}
> >
<FormControl> <div className="flex-1 truncate">
<SelectTrigger>
<SelectValue placeholder="选择规则范围" /> </div>
</SelectTrigger> {field.value === null && (
</FormControl> <Check className="ml-2 h-4 w-4" />
<SelectContent> )}
<SelectItem value="global"></SelectItem> </div>
{servers.map((server) => (
<SelectItem key={server.id} value={server.id.toString()}> {/* 服务器列表 */}
- {server.serverName} ({server.hostIp}) {(() => {
</SelectItem> const filteredServers = servers.filter(server =>
))} server.serverName.toLowerCase().includes(serverSearchValue.toLowerCase()) ||
</SelectContent> server.hostIp.toLowerCase().includes(serverSearchValue.toLowerCase())
</Select> );
if (filteredServers.length === 0 && serverSearchValue) {
return (
<div className="p-4 text-center text-sm text-muted-foreground">
</div>
);
}
return filteredServers.map((server) => (
<div
key={server.id}
className={cn(
'relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-accent hover:text-accent-foreground',
server.id === field.value && 'bg-accent text-accent-foreground'
)}
onClick={() => {
field.onChange(server.id);
setServerSearchValue('');
setServerPopoverOpen(false);
}}
>
<div className="flex-1 truncate">
<span className="font-medium"> - {server.serverName}</span>
<span className="text-muted-foreground"> ({server.hostIp})</span>
</div>
{server.id === field.value && (
<Check className="ml-2 h-4 w-4" />
)}
</div>
));
})()}
</div>
</ScrollArea>
</PopoverContent>
</Popover>
<FormDescription> <FormDescription>
</FormDescription> </FormDescription>
@ -248,7 +328,17 @@ export const AlertRuleFormDialog: React.FC<AlertRuleFormDialogProps> = ({
<FormField <FormField
control={form.control} control={form.control}
name="durationMinutes" name="durationMinutes"
render={({ field }) => ( render={({ field }) => {
const [localValue, setLocalValue] = React.useState<string>(
field.value?.toString() ?? ''
);
// 同步外部值变化到本地状态
React.useEffect(() => {
setLocalValue(field.value?.toString() ?? '');
}, [field.value]);
return (
<FormItem> <FormItem>
<FormLabel> <FormLabel>
() <span className="text-destructive">*</span> () <span className="text-destructive">*</span>
@ -257,8 +347,23 @@ export const AlertRuleFormDialog: React.FC<AlertRuleFormDialogProps> = ({
<Input <Input
type="number" type="number"
min="1" min="1"
{...field} value={localValue}
onChange={(e) => field.onChange(Number(e.target.value))} onChange={(e) => {
const value = e.target.value;
setLocalValue(value);
// 只有在有效值时才更新表单
if (value !== '') {
field.onChange(Number(value));
}
}}
onBlur={(e) => {
// 失焦时如果为空则设置为undefined触发验证
if (localValue === '') {
field.onChange(undefined);
}
field.onBlur();
}}
name={field.name}
/> />
</FormControl> </FormControl>
<FormDescription> <FormDescription>
@ -266,14 +371,27 @@ export const AlertRuleFormDialog: React.FC<AlertRuleFormDialogProps> = ({
</FormDescription> </FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} );
}}
/> />
{/* 警告阈值 */} {/* 警告阈值 */}
<FormField <FormField
control={form.control} control={form.control}
name="warningThreshold" name="warningThreshold"
render={({ field }) => ( render={({ field }) => {
const currentAlertType = form.watch('alertType');
const isServerStatus = currentAlertType === AlertType.SERVER_STATUS;
const [localValue, setLocalValue] = React.useState<string>(
field.value?.toString() ?? ''
);
// 同步外部值变化到本地状态
React.useEffect(() => {
setLocalValue(field.value?.toString() ?? '');
}, [field.value]);
return (
<FormItem> <FormItem>
<FormLabel> <FormLabel>
<span className="text-destructive">*</span> <span className="text-destructive">*</span>
@ -284,26 +402,54 @@ export const AlertRuleFormDialog: React.FC<AlertRuleFormDialogProps> = ({
type="number" type="number"
min="0" min="0"
max="100" max="100"
step="0.1" step={isServerStatus ? "1" : "0.1"}
{...field} value={localValue}
onChange={(e) => field.onChange(Number(e.target.value))} onChange={(e) => {
const value = e.target.value;
setLocalValue(value);
// 只有在有效值时才更新表单
if (value !== '') {
field.onChange(Number(value));
}
}}
onBlur={(e) => {
// 失焦时如果为空则设置为undefined触发验证
if (localValue === '') {
field.onChange(undefined);
}
field.onBlur();
}}
name={field.name}
className="flex-1" className="flex-1"
/> />
<span className="text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground">
{AlertTypeLabels[form.watch('alertType')]?.unit || '%'} {AlertTypeLabels[currentAlertType]?.unit || '%'}
</span> </span>
</div> </div>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} );
}}
/> />
{/* 严重阈值 */} {/* 严重阈值 */}
<FormField <FormField
control={form.control} control={form.control}
name="criticalThreshold" name="criticalThreshold"
render={({ field }) => ( render={({ field }) => {
const currentAlertType = form.watch('alertType');
const isServerStatus = currentAlertType === AlertType.SERVER_STATUS;
const [localValue, setLocalValue] = React.useState<string>(
field.value?.toString() ?? ''
);
// 同步外部值变化到本地状态
React.useEffect(() => {
setLocalValue(field.value?.toString() ?? '');
}, [field.value]);
return (
<FormItem> <FormItem>
<FormLabel> <FormLabel>
<span className="text-destructive">*</span> <span className="text-destructive">*</span>
@ -314,19 +460,35 @@ export const AlertRuleFormDialog: React.FC<AlertRuleFormDialogProps> = ({
type="number" type="number"
min="0" min="0"
max="100" max="100"
step="0.1" step={isServerStatus ? "1" : "0.1"}
{...field} value={localValue}
onChange={(e) => field.onChange(Number(e.target.value))} onChange={(e) => {
const value = e.target.value;
setLocalValue(value);
// 只有在有效值时才更新表单
if (value !== '') {
field.onChange(Number(value));
}
}}
onBlur={(e) => {
// 失焦时如果为空则设置为undefined触发验证
if (localValue === '') {
field.onChange(undefined);
}
field.onBlur();
}}
name={field.name}
className="flex-1" className="flex-1"
/> />
<span className="text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground">
{AlertTypeLabels[form.watch('alertType')]?.unit || '%'} {AlertTypeLabels[currentAlertType]?.unit || '%'}
</span> </span>
</div> </div>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} );
}}
/> />
{/* 是否启用 */} {/* 是否启用 */}

View File

@ -59,6 +59,7 @@ const ServerList: React.FC = () => {
const [categories, setCategories] = useState<ServerCategoryResponse[]>([]); const [categories, setCategories] = useState<ServerCategoryResponse[]>([]);
const [servers, setServers] = useState<ServerResponse[]>([]); const [servers, setServers] = useState<ServerResponse[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [pageLoading, setPageLoading] = useState(false); // 分页加载状态
const [pageIndex, setPageIndex] = useState(0); const [pageIndex, setPageIndex] = useState(0);
const [pageSize] = useState(10); const [pageSize] = useState(10);
const [totalElements, setTotalElements] = useState(0); const [totalElements, setTotalElements] = useState(0);
@ -142,11 +143,16 @@ const ServerList: React.FC = () => {
}; };
// 加载服务器列表 // 加载服务器列表
const loadServers = async (silent: boolean = false) => { const loadServers = async (silent: boolean = false, isPagination: boolean = false) => {
// silent 为 true 时不显示全局 loading 状态(用于操作后的静默刷新) // silent 为 true 时不显示全局 loading 状态(用于操作后的静默刷新)
// isPagination 为 true 时只显示分页 loading不显示全局 loading
if (!silent) { if (!silent) {
if (isPagination) {
setPageLoading(true);
} else {
setLoading(true); setLoading(true);
} }
}
try { try {
const params = { const params = {
categoryId: selectedCategoryId, categoryId: selectedCategoryId,
@ -184,6 +190,7 @@ const ServerList: React.FC = () => {
} finally { } finally {
if (!silent) { if (!silent) {
setLoading(false); setLoading(false);
setPageLoading(false);
} }
} }
}; };
@ -335,10 +342,21 @@ const ServerList: React.FC = () => {
); );
}); });
// 监听筛选条件、分页和视图模式变化 // 监听筛选条件和视图模式变化使用全局loading
useEffect(() => { useEffect(() => {
loadServers(); loadServers(false, false); // 筛选加载使用全局loading
}, [pageIndex, pageSize, selectedCategoryId, selectedStatus, selectedOsType, viewMode]); // 筛选条件变化时重置分页到第一页
setPageIndex(0);
}, [pageSize, selectedCategoryId, selectedStatus, selectedOsType, viewMode]);
// 监听分页变化单独处理使用pageLoading
// 注意这个useEffect只处理用户主动翻页的情况
useEffect(() => {
if (viewMode === 'table' && pageIndex > 0) {
// pageIndex > 0 说明是用户翻页操作使用pageLoading
loadServers(false, true);
}
}, [pageIndex]);
// 使用statsData作为统计数据 // 使用statsData作为统计数据
const stats = statsData; const stats = statsData;
@ -607,6 +625,15 @@ const ServerList: React.FC = () => {
{/* 表格视图 */} {/* 表格视图 */}
{!loading && filteredServers.length > 0 && viewMode === 'table' && ( {!loading && filteredServers.length > 0 && viewMode === 'table' && (
<div className="relative">
{pageLoading && (
<div className="absolute inset-0 bg-background/80 backdrop-blur-sm z-10 flex items-center justify-center">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
<span>...</span>
</div>
</div>
)}
<ServerTable <ServerTable
servers={filteredServers} servers={filteredServers}
loading={loading} loading={loading}
@ -619,6 +646,7 @@ const ServerList: React.FC = () => {
isCollecting={(serverId) => collectingServerId === serverId} isCollecting={(serverId) => collectingServerId === serverId}
getOsIcon={getOsIcon} getOsIcon={getOsIcon}
/> />
</div>
)} )}
{/* 分页(仅列表模式) */} {/* 分页(仅列表模式) */}

View File

@ -101,6 +101,24 @@ export const alertRuleFormSchema = z.object({
path: ['criticalThreshold'], path: ['criticalThreshold'],
}); });
} }
// 服务器状态告警的阈值必须是整数
if (data.alertType === AlertType.SERVER_STATUS) {
if (!Number.isInteger(data.warningThreshold)) {
ctx.addIssue({
code: 'custom',
message: '服务器状态告警的警告阈值必须是整数',
path: ['warningThreshold'],
});
}
if (!Number.isInteger(data.criticalThreshold)) {
ctx.addIssue({
code: 'custom',
message: '服务器状态告警的严重阈值必须是整数',
path: ['criticalThreshold'],
});
}
}
}); });
export type AlertRuleFormValues = z.infer<typeof alertRuleFormSchema>; export type AlertRuleFormValues = z.infer<typeof alertRuleFormSchema>;

View File

@ -292,6 +292,8 @@ export enum AlertType {
DISK = 'DISK', DISK = 'DISK',
/** 网络流量 */ /** 网络流量 */
NETWORK = 'NETWORK', NETWORK = 'NETWORK',
/** 服务器状态 */
SERVER_STATUS = 'SERVER_STATUS',
} }
/** /**
@ -366,5 +368,6 @@ export const AlertTypeLabels: Record<AlertType, { label: string; unit: string; d
[AlertType.MEMORY]: { label: '内存使用率', unit: '%', description: '内存使用率告警' }, [AlertType.MEMORY]: { label: '内存使用率', unit: '%', description: '内存使用率告警' },
[AlertType.DISK]: { label: '磁盘使用率', unit: '%', description: '磁盘使用率告警' }, [AlertType.DISK]: { label: '磁盘使用率', unit: '%', description: '磁盘使用率告警' },
[AlertType.NETWORK]: { label: '网络流量', unit: 'MB/s', description: '网络流量告警' }, [AlertType.NETWORK]: { label: '网络流量', unit: 'MB/s', description: '网络流量告警' },
[AlertType.SERVER_STATUS]: { label: '服务器状态', unit: '次', description: '服务器连接状态告警' },
}; };

View File

@ -124,7 +124,6 @@ export const HttpRequestNodeDefinition: ConfigurableNodeDefinition = {
title: "超时时间(毫秒)", title: "超时时间(毫秒)",
description: "请求超时时间0表示不限制", description: "请求超时时间0表示不限制",
minimum: 1000, minimum: 1000,
maximum: 300000,
default: 30000 default: 30000
}, },
responseBodyType: { responseBodyType: {