'use client'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import type { ReactNode } from 'react'; import { useEffect, useMemo, useState } from 'react'; import { Globe2, Plus, Save, ShieldCheck, Trash2, } from 'lucide-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 { getProxyRoutes } from '@/features/proxy-routes/api/proxy-routes'; 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'; import { RuleEntryModal } from './rule-entry-modal'; import { SiteApplyDrawer } from './site-apply-drawer'; import { PowTabPanel } from './pow-tab-panel'; import { RuleListSection } from './rule-list-section'; import { TabButton } from './tab-button'; import { buildCountryOptions, buildDraft, countRuleEntries, defaultRuleModalState, emptyDraft, formatCountryItem, getErrorMessage, getListFieldKey, normalizeItems, tabItems, textToList, updateDraftList, } from './helpers'; import type { FeedbackState, ListFieldKey, RuleModalState, WAFTab } from './types'; export function WAFPage() { const queryClient = useQueryClient(); const [selectedID, setSelectedID] = useState(null); const [activeTab, setActiveTab] = useState('basic'); const [draft, setDraft] = useState(emptyDraft); const [feedback, setFeedback] = useState(null); const [applyGroup, setApplyGroup] = useState(null); const [ruleModal, setRuleModal] = useState( defaultRuleModalState, ); 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 countryOptions = useMemo(() => buildCountryOptions(), []); const countryLabelMap = useMemo( () => new Map(countryOptions.map((option) => [option.code, option.label])), [countryOptions], ); 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 currentRuleCount = countRuleEntries(draft); const appliedSiteNames = selectedGroup?.is_global ? ['全部网站'] : (selectedGroup?.applied_site_ids ?? []) .map( (id) => routes.find((route) => route.id === id)?.site_name ?? `网站 #${id}`, ) .sort((left, right) => left.localeCompare(right)); const openRuleModal = () => { setRuleModal({ ...defaultRuleModalState, open: true }); }; const closeRuleModal = () => { setRuleModal(defaultRuleModalState); }; const applyRuleModal = () => { const values = ruleModal.dimension === 'ip' ? textToList(ruleModal.ipValue) : normalizeItems(ruleModal.countryValues); if (values.length === 0) { setFeedback({ tone: 'danger', message: ruleModal.dimension === 'ip' ? '请先输入 IP 或 IP 段。' : '请先选择地域。', }); return; } const listKey = getListFieldKey(ruleModal.listType, ruleModal.dimension); setDraft((current) => updateDraftList(current, listKey, (items) => normalizeItems([...items, ...values]), ), ); setFeedback({ tone: 'info', message: '名单项已添加到当前草稿,保存后生效。', }); closeRuleModal(); setActiveTab('lists'); }; const removeRuleItem = (key: ListFieldKey, value: string) => { setDraft((current) => updateDraftList(current, key, (items) => items.filter((item) => item !== value), ), ); }; const overviewItems: Array<{ label: string; value: ReactNode }> = [ { label: '当前规则组', value: selectedGroup ? selectedGroup.name : '新建规则组', }, { label: '启用状态', value: draft.enabled ? '启用中' : '已停用' }, { label: '当前规则数', value: `${currentRuleCount} 条` }, { label: '生效范围', value: selectedGroup?.is_global ? '全部网站' : `${selectedGroup?.applied_site_count ?? 0} 个网站`, }, { label: '拦截返回', value: draft.block_response_body.trim() ? `${draft.block_status_code} + 自定义页面` : `${draft.block_status_code} 状态码`, }, { label: '最后更新', value: selectedGroup?.updated_at ? new Date(selectedGroup.updated_at).toLocaleString('zh-CN') : '未保存', }, ]; return ( <>
{ setSelectedID(0); setActiveTab('basic'); setDraft({ ...emptyDraft, name: '自定义规则组' }); }} > 新建规则组 } /> {feedback ? ( ) : null}
{groups.map((group) => ( ))}
{selectedGroup && !selectedGroup.is_global ? ( setApplyGroup(selectedGroup)} > 一键应用 ) : null} saveMutation.mutate(draft)} > {saveMutation.isPending ? '保存中...' : '保存规则组'}
} >
{tabItems.map((tab) => ( setActiveTab(tab.id)} /> ))}
{activeTab === 'basic' ? (
setDraft((current) => ({ ...current, name: event.target.value, })) } /> setDraft((current) => ({ ...current, enabled: checked, })) } /> setDraft((current) => ({ ...current, remark: event.target.value, })) } />

配置总览

{selectedGroup?.is_global ? '全局' : '自定义'}
{overviewItems.map((item) => (
{item.label}
{item.value}
))}

当前应用网站

{appliedSiteNames.length > 0 ? ( appliedSiteNames.map((name) => ( {name} )) ) : ( 尚未绑定网站,可点击右上角「一键应用」进行配置。 )}
) : null} {activeTab === 'lists' ? (

黑白名单规则

添加
removeRuleItem('ip_whitelist', item)} /> removeRuleItem('ip_blacklist', item)} /> formatCountryItem(code, countryLabelMap), )} tone="whitelist" emptyText="暂无地域白名单规则。" onRemove={(item) => { const code = item.split(' ')[0] ?? item; removeRuleItem('country_whitelist', code); }} /> formatCountryItem(code, countryLabelMap), )} tone="blacklist" emptyText="暂无地域黑名单规则。" onRemove={(item) => { const code = item.split(' ')[0] ?? item; removeRuleItem('country_blacklist', code); }} />
) : null} {activeTab === 'pow' ? ( setDraft((current) => ({ ...current, pow_enabled: enabled, pow_config: config, })) } /> ) : null} {activeTab === 'block' ? (

拦截返回状态码

建议使用 403、418、451 等明确表达策略拦截含义的状态码。

setDraft((current) => ({ ...current, block_status_code: Number(event.target.value), })) } />
{[403, 418, 451, 503].map((code) => ( ))}
setDraft((current) => ({ ...current, block_response_body: event.target.value, })) } />
) : null}
{selectedGroup && !selectedGroup.is_global ? ( { if ( window.confirm( `确认删除 WAF 规则组 ${selectedGroup.name} 吗?`, ) ) { deleteMutation.mutate(selectedGroup.id); } }} > 删除 ) : null}
setRuleModal((current) => ({ ...current, ...patch, open: true })) } onSubmit={applyRuleModal} /> { if (!open) { setApplyGroup(null); } }} onSave={(ids) => { if (applyGroup) { applyMutation.mutate({ id: applyGroup.id, ids }); } }} /> ); }