'use client'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import type { ReactNode } from 'react'; import { useEffect, useMemo, useState } from 'react'; import { Check, Globe2, ListFilter, type LucideIcon, Plus, Save, Search, 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 { AppModal } from '@/components/ui/app-modal'; 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; }; type WAFTab = 'basic' | 'lists' | 'block'; type RuleListType = 'whitelist' | 'blacklist'; type RuleDimension = 'ip' | 'country'; type ListFieldKey = | 'ip_whitelist' | 'ip_blacklist' | 'country_whitelist' | 'country_blacklist'; type CountryOption = { code: string; zhName: string; label: string; searchText: string; }; type RuleModalState = { open: boolean; listType: RuleListType; dimension: RuleDimension; ipValue: string; countryValues: string[]; }; type RuleListRenderable = Pick< WAFRuleGroupPayload, | 'ip_whitelist' | 'ip_blacklist' | 'country_whitelist' | 'country_blacklist' | 'region_whitelist' | 'region_blacklist' >; 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: '', }; const defaultRuleModalState: RuleModalState = { open: false, listType: 'whitelist', dimension: 'ip', ipValue: '', countryValues: [], }; const tabItems: Array<{ id: WAFTab; label: string; }> = [ { id: 'basic', label: '基本信息', }, { id: 'lists', label: '黑白名单', }, { id: 'block', label: '拦截返回', }, ]; function getErrorMessage(error: unknown) { return error instanceof Error ? error.message : '操作失败'; } function textToList(text: string) { return text .split(/[\n,,\s]+/) .map((item) => item.trim()) .filter(Boolean); } function normalizeItems(items: string[]) { return Array.from( new Set(items.map((item) => item.trim()).filter(Boolean)), ).sort((left, right) => left.localeCompare(right)); } 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 countRuleEntries(group: RuleListRenderable) { return ( group.ip_whitelist.length + group.ip_blacklist.length + group.country_whitelist.length + group.country_blacklist.length + group.region_whitelist.length + group.region_blacklist.length ); } function buildCountryOptions() { const zhDisplayNames = new Intl.DisplayNames(['zh-CN'], { type: 'region' }); const enDisplayNames = new Intl.DisplayNames(['en'], { type: 'region' }); const options: CountryOption[] = []; for (let first = 65; first <= 90; first += 1) { for (let second = 65; second <= 90; second += 1) { const code = String.fromCharCode(first, second); const zhName = zhDisplayNames.of(code); const enName = enDisplayNames.of(code); if ( !zhName || zhName === code || /未知/.test(zhName) || !enName || enName === code || /Unknown/.test(enName) ) { continue; } options.push({ code, zhName, label: `${code} ${zhName}`, searchText: `${code} ${zhName} ${enName}`.toLowerCase(), }); } } return options.sort((left, right) => left.code.localeCompare(right.code)); } function getListFieldKey( listType: RuleListType, dimension: RuleDimension, ): ListFieldKey { if (dimension === 'ip') { return listType === 'whitelist' ? 'ip_whitelist' : 'ip_blacklist'; } return listType === 'whitelist' ? 'country_whitelist' : 'country_blacklist'; } function updateDraftList( draft: WAFRuleGroupPayload, key: ListFieldKey, updater: (items: string[]) => string[], ) { switch (key) { case 'ip_whitelist': return { ...draft, ip_whitelist: updater(draft.ip_whitelist) }; case 'ip_blacklist': return { ...draft, ip_blacklist: updater(draft.ip_blacklist) }; case 'country_whitelist': return { ...draft, country_whitelist: updater(draft.country_whitelist) }; case 'country_blacklist': return { ...draft, country_blacklist: updater(draft.country_blacklist) }; } } function formatCountryItem(code: string, labelMap: Map) { return labelMap.get(code) ?? code; } function StatCard({ icon: Icon, label, value, hint, }: { icon: LucideIcon; label: string; value: string; hint?: string; }) { return (

{label}

{value}

{hint && (

{hint}

)}
); } function TabButton({ label, active, onClick, }: { label: string; active: boolean; onClick: () => void; }) { return ( ); } function RuleChip({ label, tone, onRemove, }: { label: string; tone: 'whitelist' | 'blacklist'; onRemove: () => void; }) { return ( {label} ); } function RuleListSection({ title, description, items, tone, emptyText, onRemove, }: { title: string; description: string; items: string[]; tone: 'whitelist' | 'blacklist'; emptyText: string; onRemove: (item: string) => void; }) { return (

{title}

{description}

{items.length}
{items.length > 0 ? (
{items.map((item) => ( onRemove(item)} /> ))}
) : (
{emptyText}
)}
); } function RuleEntryModal({ state, countryOptions, pending, onClose, onChange, onSubmit, }: { state: RuleModalState; countryOptions: CountryOption[]; pending: boolean; onClose: () => void; onChange: (patch: Partial) => void; onSubmit: () => void; }) { const [keyword, setKeyword] = useState(''); useEffect(() => { if (!state.open) { return; } setKeyword(''); }, [state.dimension, state.open]); const selectedCountrySet = useMemo( () => new Set(state.countryValues), [state.countryValues], ); const filteredCountries = useMemo(() => { const normalized = keyword.trim().toLowerCase(); return countryOptions .filter((option) => !normalized || option.searchText.includes(normalized)) .sort((left, right) => { const leftSelected = selectedCountrySet.has(left.code) ? 1 : 0; const rightSelected = selectedCountrySet.has(right.code) ? 1 : 0; return ( rightSelected - leftSelected || left.code.localeCompare(right.code) ); }); }, [countryOptions, keyword, selectedCountrySet]); const toggleCountry = (code: string) => { const values = selectedCountrySet.has(code) ? state.countryValues.filter((item) => item !== code) : normalizeItems([...state.countryValues, code]); onChange({ countryValues: values }); }; const selectFiltered = () => { onChange({ countryValues: normalizeItems([ ...state.countryValues, ...filteredCountries.map((option) => option.code), ]), }); }; const clearCountries = () => onChange({ countryValues: [] }); const typeLabel = state.listType === 'whitelist' ? '白名单' : '黑名单'; const dimensionLabel = state.dimension === 'ip' ? 'IP' : '地域'; return ( 取消 {pending ? '处理中...' : '添加到草稿'} } >
{[ { value: 'whitelist', label: '白名单' }, { value: 'blacklist', label: '黑名单' }, ].map((option) => ( ))}
{[ { value: 'ip', label: 'IP' }, { value: 'country', label: '地域' }, ].map((option) => ( ))}
{state.dimension === 'ip' ? ( onChange({ ipValue: event.target.value })} /> ) : (
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)]" />

地域多选

选项显示为「国家代码 国家中文名」。

已选 {state.countryValues.length}
{filteredCountries.map((option) => { const selected = selectedCountrySet.has(option.code); return ( ); })}
)}
); } 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 [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 enabledGroups = groups.filter((group) => group.enabled); const protectedSites = new Set( groups.flatMap((group) => group.applied_site_ids), ); const totalRules = groups.reduce( (sum, group) => sum + countRuleEntries(group), 0, ); 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 === '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 }); } }} /> ); }