'use client'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { Check, Globe2, Plus, Save, Search, ShieldCheck, Trash2 } from 'lucide-react'; import { useEffect, useMemo, useState } from 'react'; import { EmptyState } from '@/components/feedback/empty-state'; import { ErrorState } from '@/components/feedback/error-state'; import { InlineMessage } from '@/components/feedback/inline-message'; import { LoadingState } from '@/components/feedback/loading-state'; import { PageHeader } from '@/components/layout/page-header'; import { AppCard } from '@/components/ui/app-card'; import { Drawer } from '@/components/ui/drawer'; import { getProxyRoutes } from '@/features/proxy-routes/api/proxy-routes'; import type { ProxyRouteItem } from '@/features/proxy-routes/types'; import { DangerButton, PrimaryButton, ResourceField, ResourceInput, ResourceTextarea, SecondaryButton, ToggleField, } from '@/features/shared/components/resource-primitives'; import { createWAFRuleGroup, deleteWAFRuleGroup, getWAFRuleGroups, replaceWAFRuleGroupSites, updateWAFRuleGroup, } from '@/features/waf/api/waf'; import type { WAFRuleGroup, WAFRuleGroupPayload } from '@/features/waf/types'; import { cn } from '@/lib/utils/cn'; type FeedbackState = { tone: 'success' | 'danger' | 'info'; message: string; }; const emptyDraft: WAFRuleGroupPayload = { name: '', enabled: true, block_status_code: 418, block_response_body: '', ip_whitelist: [], ip_blacklist: [], country_whitelist: [], country_blacklist: [], region_whitelist: [], region_blacklist: [], remark: '', }; function getErrorMessage(error: unknown) { return error instanceof Error ? error.message : '操作失败'; } function listToText(items: string[]) { return items.join('\n'); } function textToList(text: string) { return text .split(/[\n,,\s]+/) .map((item) => item.trim()) .filter(Boolean); } function buildDraft(group: WAFRuleGroup | null): WAFRuleGroupPayload { if (!group) { return { ...emptyDraft }; } return { name: group.name, enabled: group.enabled, block_status_code: group.block_status_code || 418, block_response_body: group.block_response_body ?? '', ip_whitelist: group.ip_whitelist ?? [], ip_blacklist: group.ip_blacklist ?? [], country_whitelist: group.country_whitelist ?? [], country_blacklist: group.country_blacklist ?? [], region_whitelist: group.region_whitelist ?? [], region_blacklist: group.region_blacklist ?? [], remark: group.remark ?? '', }; } function ruleCount(group: WAFRuleGroup) { return ( group.ip_whitelist.length + group.ip_blacklist.length + group.country_whitelist.length + group.country_blacklist.length ); } function SiteApplyDrawer({ group, routes, open, onOpenChange, onSave, pending, }: { group: WAFRuleGroup | null; routes: ProxyRouteItem[]; open: boolean; onOpenChange: (open: boolean) => void; onSave: (ids: number[]) => void; pending: boolean; }) { const [keyword, setKeyword] = useState(''); const [selectedIDs, setSelectedIDs] = useState([]); useEffect(() => { setSelectedIDs(group?.applied_site_ids ?? []); setKeyword(''); }, [group, open]); const filteredRoutes = useMemo(() => { const normalized = keyword.trim().toLowerCase(); if (!normalized) { return routes; } return routes.filter((route) => [route.site_name, route.primary_domain, ...route.domains] .join(' ') .toLowerCase() .includes(normalized), ); }, [keyword, routes]); const selectedSet = useMemo(() => new Set(selectedIDs), [selectedIDs]); const toggleID = (id: number) => { setSelectedIDs((current) => current.includes(id) ? current.filter((item) => item !== id) : [...current, id].sort((left, right) => left - right), ); }; const selectFiltered = () => { const next = new Set(selectedIDs); filteredRoutes.forEach((route) => next.add(route.id)); setSelectedIDs([...next].sort((left, right) => left - right)); }; return ( onOpenChange(false)}> 取消 onSave(selectedIDs)} > {pending ? '保存中...' : '保存应用范围'} } >
setKeyword(event.target.value)} placeholder="搜索网站或域名" className="min-w-0 flex-1 bg-transparent text-sm text-[var(--foreground-primary)] outline-none placeholder:text-[var(--foreground-muted)]" />
{filteredRoutes.map((route) => ( ))}
); } export function WAFPage() { const queryClient = useQueryClient(); const [selectedID, setSelectedID] = useState(null); const [draft, setDraft] = useState(emptyDraft); const [feedback, setFeedback] = useState(null); const [applyGroup, setApplyGroup] = useState(null); const groupsQuery = useQuery({ queryKey: ['waf', 'rule-groups'], queryFn: getWAFRuleGroups, }); const routesQuery = useQuery({ queryKey: ['proxy-routes'], queryFn: getProxyRoutes, }); const groups = useMemo(() => groupsQuery.data ?? [], [groupsQuery.data]); const routes = useMemo(() => routesQuery.data ?? [], [routesQuery.data]); const selectedGroup = useMemo( () => selectedID === 0 ? null : (groups.find((group) => group.id === selectedID) ?? groups[0] ?? null), [groups, selectedID], ); useEffect(() => { if (selectedGroup) { setSelectedID(selectedGroup.id); setDraft(buildDraft(selectedGroup)); } }, [selectedGroup]); const invalidate = async () => { await Promise.all([ queryClient.invalidateQueries({ queryKey: ['waf', 'rule-groups'] }), queryClient.invalidateQueries({ queryKey: ['config-versions', 'diff'] }), ]); }; const saveMutation = useMutation({ mutationFn: (payload: WAFRuleGroupPayload) => { if (selectedGroup) { return updateWAFRuleGroup(selectedGroup.id, payload); } return createWAFRuleGroup(payload); }, onSuccess: async (group) => { setSelectedID(group.id); setFeedback({ tone: 'success', message: 'WAF 规则组已保存。' }); await invalidate(); }, onError: (error) => { setFeedback({ tone: 'danger', message: getErrorMessage(error) }); }, }); const deleteMutation = useMutation({ mutationFn: deleteWAFRuleGroup, onSuccess: async () => { setSelectedID(null); setFeedback({ tone: 'success', message: 'WAF 规则组已删除。' }); await invalidate(); }, onError: (error) => { setFeedback({ tone: 'danger', message: getErrorMessage(error) }); }, }); const applyMutation = useMutation({ mutationFn: ({ id, ids }: { id: number; ids: number[] }) => replaceWAFRuleGroupSites(id, ids), onSuccess: async () => { setApplyGroup(null); setFeedback({ tone: 'success', message: '规则组应用范围已更新。' }); await invalidate(); }, onError: (error) => { setFeedback({ tone: 'danger', message: getErrorMessage(error) }); }, }); if (groupsQuery.isLoading || routesQuery.isLoading) { return ; } if (groupsQuery.isError) { return ; } if (routesQuery.isError) { return ; } if (!selectedGroup && groups.length === 0) { return ; } const enabledCount = groups.filter((group) => group.enabled).length; const protectedSites = new Set(groups.flatMap((group) => group.applied_site_ids)); const totalRules = groups.reduce((sum, group) => sum + ruleCount(group), 0); return ( <>
{ setSelectedID(0); setDraft({ ...emptyDraft, name: '自定义规则组' }); }} > 新建规则组 } /> {feedback ? : null}

启用规则组

{enabledCount}

自定义覆盖网站

{protectedSites.size}

黑白名单条目

{totalRules}

{groups.map((group) => ( ))}
setApplyGroup(selectedGroup)}> 一键应用 ) : null } >
setDraft((current) => ({ ...current, name: event.target.value }))} /> setDraft((current) => ({ ...current, block_status_code: Number(event.target.value) })) } /> setDraft((current) => ({ ...current, enabled: checked }))} /> setDraft((current) => ({ ...current, remark: event.target.value }))} /> setDraft((current) => ({ ...current, ip_whitelist: textToList(event.target.value) })) } /> setDraft((current) => ({ ...current, ip_blacklist: textToList(event.target.value) })) } /> setDraft((current) => ({ ...current, country_whitelist: textToList(event.target.value) })) } /> setDraft((current) => ({ ...current, country_blacklist: textToList(event.target.value) })) } /> setDraft((current) => ({ ...current, block_response_body: event.target.value })) } />
{selectedGroup && !selectedGroup.is_global ? ( { if (window.confirm(`确认删除 WAF 规则组 ${selectedGroup.name} 吗?`)) { deleteMutation.mutate(selectedGroup.id); } }} > 删除 ) : null}
saveMutation.mutate(draft)} > {saveMutation.isPending ? '保存中...' : '保存规则组'}
{ if (!open) { setApplyGroup(null); } }} onSave={(ids) => { if (applyGroup) { applyMutation.mutate({ id: applyGroup.id, ids }); } }} /> ); }